202511|技术日志

1104

计算机网络分层模型

参考

OSI七层

应用层(HTTP FTP POP3 SMTP等)

表示层(加密、protobuf等序列化协议)

会话层(Socket API)

传输层(TCP UDP是重点)

网络层(IP,ARP)

数据链路层(MAC)

物理层(网卡,光纤)

TCP/IP四层

应用层(OSI的应用层+表示层+会话层)

传输层

网络层

网络接口层(OSI的物理层和数据链路层)

封装和解包

从Source主机出发,一个网络包,从上往下,每一层都会在传输的过程中加上协议对应的Header,这一过程叫做封装。

之后经过层层设备,这中间可能会进行中间层的封装和解包操作。

到达目标主机后,从下往上,一层一层提取Header解析内容,这一过程叫做解包。

自底向上

网线直连

物理层面的端到端连接

+实现简单粗暴

-设备数量上升时布线混乱,物理线路数量几何式增长

集线器

中央设备无脑转发,通过接收方的MAC地址进行区分

类似同一个房间说话所有人都能听得到,但是每个人都会去”检查”喊的是不是自己,不是的话就不予理会

+物理层面广播,实现较为简单

-只支持单工通信。同一个时间只能有一个人在房间内说话

-带宽浪费,由于是广播因此会浪费不必要的通信成本

-性能问题,所有设备共享一个相同的集线器带宽,设备越多,网络越拥挤

交换机

“智能学习”、精确通知的集线器

相比于集线器的广播,交换机内部维护了一个MAC地址映射表,Map<MAC, portNumber>

一开始表没数据,还是通过广播的方式,收到接入设备的应答后更新表数据用于下次精准转发;

随着接入物理设备的变动,这张表会动态变更。最终实现通信数据的精准转发(指定端口)

交换机位于计算机网络模型中的数据链路层

1105

路由

随着接入设备的上升,仅靠交换机也不够了。毕竟如果1000多个人,就需要几百个交换机。数据传输开销上而言、以及网络环境复杂度而言都是极为复杂不能接受的。

要是能将1000多个人分组,组内通过MAC进行学习并精准转发指定端口;组与组之间通过某个”管理员”进行消息传递就好了。

关键是如何设计一个地址系统,让设备既知道自己属于哪个小区,管理员又能据此找到目标小组?

由于MAC地址本质上是扁平的(因为每一个设备都有唯一的MAC地址),就像是身份证号,全国所有人只靠身份证号去检索依然十分困难。而通过精确到省——市——区的层级搜索方式,能够更快且精准定位:这就是ip地址系统的设计思想。而那个根据 IP 地址在小组之间转发消息的管理员就是路由器

IP基础计算

1)IP地址构成:

  • IP地址(如192.168.1.11)是32位二进制数,分 4 段显示(四个 8 bit)
  • 每段0-255,称为”点分十进制”

在同一个小组(小区),我们一般叫做一个子网:

  • 同子网内的设备可以直接通信
  • 不同子网要通过路由器转发

2)那么怎么判断是不是在一个子网呢?这就需要提到子网掩码了:

子网掩码也是由 32 位二进制数组成,连续的 1 表示网络部分,连续的 0 表示主机部分。

如 255.255.255.0 转为二进制就是 24个1和 8 个 0,简写为 /24,表示前面 24 位都是网络部分。

判断同网段:

  • 将IP地址和子网掩码做”与”运算,结果相同的IP就在同一网段
  • 如:192.168.1.11和192.168.1.22都与255.255.255.0运算后得到192.168.1.0

3)网关:

  • 通常是路由器的IP地址,作为子网对外的”门户”
  • 不同子网之间进行通信时,数据先发给网关
  • 比如192.168.1.1常作为默认网关

需要注意的是,路由器的引入只是随着计算机网络系统复杂度上升所引入的新系统。在之前的例子中,一开始我们通过直连、然后是集线器广播,再到交换机,这些都是通过MAC寻址。而路由器最终发送数据也需要借助MAC地址通过网线。随着层次上升,对于下层的系统技术只是进行了增强,在最终依赖的路径上并未舍弃。就像寄快递:

  • IP 地址相当于城市、街道:决定包裹该送到哪个区域
  • MAC 地址相当于收件人身份信息:最后一步实际交付

由于最终上层的协议/系统技术还是需要下层来进行落地交付,因此位于分层模型中下层的数据包,数据部分总是会包含完整的上一层的header和数据。

1106

Kafka架构概念整理

Broker,Topic,Partition,Consumer Group

如果用现实例子来举例:

Broker: 相当于是快递的网点,某快递在某地有多个网点,网点之间的快件是不同的,从设计上来说增加网点数量是为了增加这一片区域的快件吞吐量

Topic: 快递分类,大件/小件,只是一个逻辑概念,并不会实际存储数据

Partition: 货架,实际存储数据的物理形式

Consumer Group: 针对快件的业务处理小队,可能有派送小队,有统计小队。小队与小队之间(消费组之间)都有某个partition全量的消息。但是同一个小队内(同一个消费者组内部)不会重复消费消息。

从原理上来说,之所以同一个消费组内部不会重复消费消息,是因为Kafka topic的消费是依靠offset来标识的。

offset指示一个partition中的消费进度。

每一个消费者组都维护一个独立的offset。也就是消费者组id-partition才能确定一个唯一的offset。不同消费组的offset互不干扰

而关联关系上来说:

  • 同一个消费组内
    • 一个消费者可对应多个partition
    • 但一个topic下的某个partition只能被一个消费组内的一个消费者消费(本质上是避免同组内重复消费)
  • 不同消费组之间
    • 一个topic下的某个partition可被n个消费组内的n个消费者消费
    • (本质是 “每个消费组独立消费该分区的全量消息”,互不干扰)

1107

覆盖索引,回表

参考 MySQL 索引

1110

禁止join以及最佳实践

这里的禁止一般来说是禁止在三张表以上的关联查询需求时禁止使用join语句。

原因在于三张表以上join存在严重的性能问题以及会带来数据库侧的查询压力,在高并发场景下应当禁止。

这一规约的核心思想在于,在高并发环境下,将数据库侧的密集计算压力,转移到应用业务服务中进行处理

场景举例

用户购买商品下订单,这里关联三张单独的表

现在有一个频繁查询的接口,查询所有已经支付的订单(Order)并展示它们对应的用户信息(User)和产品信息(Product

如果是简单暴力的SQL,是这样的:

1
2
3
4
5
6
7
8
9
10
11
12
13
SELECT 
o.*,
u.name,
p.product_name
FROM
t_order o
JOIN
t_user u ON o.user_id = u.id
JOIN
t_product p ON o.product_id = p.id
WHERE
o.status = 'paid' -- 假设这里条件很复杂
LIMIT 10;

存在的问题

① 锁定风暴:高并发的头号杀手 (Lock Contention)

这是最致命的原因。

  • 事务变长: 一个复杂的 JOIN 查询会比单表查询慢几个数量级。
  • 锁粒度变大: 这条 JOIN 查询会同时锁定多张表中相关的行(InnoDB 的行锁)。
  • 阻塞与雪崩: 在高并发时,一个缓慢的 JOIN 事务(A)持有了 3 张表的锁,会导致其他所有试图读写这些表的事务(B, C, D…)全部**阻塞 (Blocking)**。在高并发场景下会迅速耗尽数据库连接池,导致整个服务雪崩。

② 优化器黑盒:不可控的性能炸弹 (Optimizer Instability)

  • JOIN 顺序爆炸: 2 张表 JOIN 只有 2 种执行顺序(A->B, B->A)。3 张表有 6 种。4 张表有 24 种… 随着表增多,MySQL 查询优化器(Optimizer)需要做的“排列组合”呈指数级增长。
  • 选错执行计划: 当表越多,优化器越容易“估算失误”(例如选错了驱动表,或者用错了索引),导致本应很快的查询突然退化为全表扫描,造成不可控的性能抖动

③ 数据库资源消耗:CPU 与内存的噩梦

  • JOIN 操作(如 Hash Join, Nested Loop Join)需要大量的 CPU 计算。
  • 如果 JOIN 无法全部在索引中完成,MySQL 需要在内存中创建临时表 (Temporary Table)join_buffer 来排序和匹配数据,这会极大消耗数据库服务器宝贵的内存和 I/O 资源。

④ 架构伸缩性差:拖垮整个系统 (Poor Scalability)

这是架构层面的核心原因。

  • 应用层(Java)无状态的,可以水平扩展(加机器)来分摊压力。
  • 数据库(MySQL) 通常是有状态的,尤其是主库(写库),是整个系统的单一瓶颈
  • 结论: 我们必须不惜一切代价(包括增加 Java 层的复杂度),来保证数据库这个“瓶颈”只做最简单、最快的事情(如:SELECT ... WHERE id = ?)。而 JOIN 这种“重计算”任务,应该交给可以无限扩展的应用层服务器去做

⑤ 业务解耦与微服务:天然的冲突

在微服务架构中,user 表可能在“用户服务”的库里,order 表在“订单服务”的库里。物理上的隔离,使得你根本无法使用 JOIN

这条规约也是在强制我们,在单体应用中就要养成“服务拆分”的思维,避免跨业务领域的强耦合查询

应用层组装处理

具体实现

核心思想:将臃肿的JOIN等价替换为多个粒度更小,能覆盖到索引,查询效率极快的单表查询,将多个单表查询结果在内存组装。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
@Data
class OrderVO {
private Long id;
private Long userId;
private Long productId;

// 组装的数据
private String userName;
private String productName;
}

public class OrderServiceImpl implements OrderService{

@Resource
private OrderMapper orderMapper;
@Resource
private UserMapper userMapper;
@Resource
private ProductMapper productMapper;

@Override
public List<OrderVO> getPaidOrders() {

// --- 步骤 1: 查询主表 (Order) ---
// 这是一个简单的、基于索引的查询
// SELECT * FROM t_order WHERE status = 'paid' LIMIT 10
List<Order> orders = orderMapper.findPaidOrders(10);

if (orders.isEmpty()) {
return Collections.emptyList();
}

// --- 步骤 2: 收集所有需要查询的 ID ---
// (使用 Java 8 Stream API)
List<Long> userIds = orders.stream()
.map(Order::getUserId)
.distinct() // 去重
.collect(Collectors.toList());

List<Long> productIds = orders.stream()
.map(Order::getProductId)
.distinct()
.collect(Collectors.toList());

// --- 步骤 3: 批量查询辅表 (User & Product) ---
// 关键:使用 WHERE id IN (...)
// 这是两次最高效的主键查询,速度极快
// SELECT * FROM t_user WHERE id IN (id1, id2, ...)
Map<Long, User> userMap = userMapper.findByIds(userIds)
.stream()
.collect(Collectors.toMap(User::getId, user -> user));

// SELECT * FROM t_product WHERE id IN (p1, p2, ...)
Map<Long, Product> productMap = productMapper.findByIds(productIds)
.stream()
.collect(Collectors.toMap(Product::getId, product -> product));

// --- 步骤 4: 在应用层内存中组装数据 ---
List<OrderVO> vos = orders.stream().map(order -> {
OrderVO vo = new OrderVO();
BeanUtils.copyProperties(order, vo); // 复制基础属性

// 从 Map 中高效获取数据并设置
User user = userMap.get(order.getUserId());
if (user != null) {
vo.setUserName(user.getName());
}

Product product = productMap.get(order.getProductId());
if (product != null) {
vo.setProductName(product.getProductName());
}
return vo;
}).collect(Collectors.toList());

return vos;
}
}

实现细节

  1. 潜在OOM:如果orders列表非常长,那么查询出的辅助表记录也会很大,不仅查询慢,在生产环境还可能会导致OOM 的问题。考虑在客户端接口场景下通过分页的方式返回接口数据;在内部服务Service部分通过分批处理的方式批量执行n次。限制主表order表查询出来的所有已经支付的 orders 数据量在可控的范围内。
  2. 微服务远程调用:如果辅助表记录查询的服务模块已经拆分,可以考虑采用RPC框架。

大宽表字段冗余存储

除了在应用层进行数据组装,还有一种可选的方案是在设计表的时候就进行字段的冗余存储

原始表结构(简化):

  • t_order (id, user_id, product_id, …)
  • t_user (id, name, …)
  • t_product (id, product_name, …)

大宽表设计

1
2
3
4
5
6
7
CREATE TABLE t_order_full (
order_id INT,
user_id INT,
user_name VARCHAR(100),
product_id INT,
product_name VARCHAR(100)
);

这种方式本质上是在用空间换时间

+简化SQL逻辑

-数据冗余存储增加成本

-维护逻辑复杂度提升

数据一致性问题

关于维护逻辑复杂度这一块,我们详细分析,其实是架构设计中常见的数据一致性问题。

在使用这种方式的时候,用户修改名称/产品修改名称时,需要更新大宽表中所有和这个用户/产品相关的订单记录。

在更新的时候不能采用同步事务更新,可能会导致 order 表大量更新记录时锁表,导致服务延时。

一种最佳实践是采用 MQ 异步通知订阅的方式来实现最终一致性

最佳实践场景

因此我们也得出了这种方式适合的场景:

  1. 读多写少
  2. 对数据一致性要求不高,可容忍数据不及时更新,能接受“数据在短时间内可能不一致”这个事实。
  3. 历史快照(Snapshot): 某些业务场景可能要求保留“下单那一刻”的快照。例如,订单中的 product_nameprice,即使后来商品改名或降价了,历史订单也不应该改变。在这种情况下,“冗余”不再是缺陷,反而成了必需的功能

总结

在高并发场景下,禁止在多表数量3张以上时使用JOIN,尽可能考虑业务层组装数据 / 大宽表设计冗余存储

  • 表结构不好扩展,有强一致性数据要求:用业务层组装数据替换JOIN
  • 表结构好扩展,可以容忍最终一致性,读多写少,有历史快照数据需求:用大宽表冗余字段设计

1117

整理复习,编写线程的生命周期

1126

线程 interrupt 方法作用,从系统设计的角度出发,以及编写实际业务时的最佳实践


202511|技术日志
http://example.com/2025/11/04/202511-技术日志/
作者
Noctis64
发布于
2025年11月4日
许可协议