学 Netty 的人都听过一句话:「handler 里不用加锁,因为同一连接的事件都在一条线程上串行执行」。但只要再问三层,这个结论就会崩塌式地展开:把耗时操作丢进线程池会怎样?处理线程不绑连接怎么保序?客户端呢?本文基于 Netty 4.1.115、Kafka 3.7、Redis 6.2.0 的源码,横向对比五个角色(Netty 服务端、Kafka broker、Kafka producer、Redis 服务端、Redis 客户端)对同一个问题的不同解法,并归纳出 NIO 框架处理「串行与并行」的五种主流思路。
S --- Situation:多路复用解决了连接数,却制造了一个新问题
BIO 时代线程和连接绑死,一万连接就要一万个线程,这个瓶颈大家都熟。NIO 用「少量线程 + 多路复用」解决之后,一个新的、更隐蔽的问题浮上水面:
一个线程管一批连接,那一条连接上的多个请求,怎么保证按到达顺序处理?
很多同学的直觉解法是:在 channelRead 里把耗时的处理丢进线程池,不阻塞 IO 线程就行了------
java
// 看起来人畜无害的写法
public void channelRead(ChannelHandlerContext ctx, Object msg) {
Request req = (Request) msg;
businessExecutor.execute(() -> { // 业务线程池
Response resp = doSlowQuery(req);
ctx.writeAndFlush(resp);
});
}
反常的地方在这里:收包是按序的,处理却乱了。消息 M1 耗时 800ms、M2 只要 10ms,客户端先发 M1 再发 M2,收到的响应却是 R2 在前 R1 在后。如果协议里有「先登录、后操作」这种状态依赖,这直接就是线上事故。更隐蔽的是:那句著名的「handler 不用加锁」也在这一刻失效了------业务线程和 IO 线程同时在碰你的 handler 字段。
问题的机理如下:
这个问题不只是 Netty 有------任何基于 NIO 的系统(Kafka、Redis、所有 RPC 框架)都必须回答它,只是答案各不相同。
T --- Task:本文的目标
- 讲透机制:逐一剖析五家(Netty、Kafka broker、Kafka producer、Redis 服务端、Redis 客户端)如何保证同一连接的顺序,全部落到源码行号;
- 提炼套路:从五个解法中归纳 NIO 框架的五种主流思路;
- 给出决策:什么协议、什么负载该选哪种方案。
约束:只讨论「同连接保序 + 尽量并行」这一个问题域,不展开各系统的其他设计。
A --- Action:五家源码,五种答案
〇、先看地基:网络层(TCP 长连接)给了什么、没给什么
前文的五个方案全部建在同一块地基上------TCP 长连接。Kafka 客户端与 broker 之间、Redis 客户端与服务端之间,都是长连接。先把这块地基的承诺边界划清楚:
TCP 已经保证的(同一条连接内):字节流有序、可靠、不重复------靠序列号、确认与重传实现,重传对应用透明且不打乱顺序(接收端按序列号重组完才交给应用)。所以「先发的字节先到」是内核保证的,Kafka、Redis 的全部上层手段都建立在这之上。
TCP 没有保证的,每一条都有配套手段:
- 字节有序 ≠ 消息有序 :TCP 只认字节不认消息,切边界靠应用层协议。Kafka 用长度前缀 + correlationId------同一连接上 metadata、produce、fetch 混跑也能按编号配对响应;RESP 没有编号,所以 Redis 客户端只能串行或排队(见第五节)。协议有没有请求编号,决定了同一连接能不能有多个在途请求;
- 连接内有序 ≠ 跨连接有序 :断线重连后,新 TCP 连接对「旧连接处理到哪了」一无所知,重发怕重复、不发怕丢失。Kafka 用 PID + epoch + 序列号跨连接兜底------重连后 epoch 提升,broker 丢弃旧会话的迟到请求(
TransactionManager.java:720-759);断连瞬间所有在途请求直接以异常失败(NetworkClient.java:803-845),是否重发由 producer 带着原序列号决定。Redis 更保守:客户端重连后未完成的命令一律失败、不自动重发------因为 INCR、LPUSH 这类命令不幂等,重复执行比失败更危险,重试的决定权交给应用; - 长连接会「假死」,需要维护 :NAT、防火墙会悄悄杀掉空闲连接,两头都不知道。配套:Kafka broker 默认 10 分钟主动关闭空闲连接(
connections.max.idle.ms,KafkaConfig.scala:115,客户端侧在Selector.java:849标记过期),consumer group 另有应用层心跳;Redis 服务端默认永不关闭空闲客户端(timeout 0,config.c:2492),靠客户端 PING 或 TCP keepalive 保活。
Netty 在这一层的定位 :它是一个「不破坏」TCP 承诺的框架------同一连接的字节按序到达,EventLoop 的单线程模型保证按序解码、按序进 pipeline;但消息怎么编号、断线怎么幂等,Netty 不替你做,留给你的协议设计。网络层的保序手段不在 Netty 里,而在 TCP 和你自己的协议设计里------这也是它和 Kafka、Redis 最大的身份差异:一个是「造协议的原料」,两个是「造好的协议实现」。
一、Netty:把连接焊死在一条线程上
Netty 的答案最「重」:让乱序在物理上不可能发生。先看整体结构(每个虚线框是一条线程或一个线程组,绿色是保序关键点):
怎么读:boss 只负责分配(绑定点标绿);worker 的闭环 = 一个事件循环顺流走 pipeline;重活通过任务队列进入固定的业务线程,再以任务方式回到 IO 线程------全程没有两条线程同时碰同一连接。 三重保险:
- 绑定只发生一次 :连接 accept 后,
childGroup.register(child)挑一个 EventLoop,结果写进 Channel 的字段(AbstractChannel.java:476); - 内核只可能唤醒这条线程 :连接底下的 socket 只注册到这条线程私有的 Selector 上(
AbstractNioChannel.java:385),别的线程的 Selector 根本不知道这条连接存在; - 别的线程的操作也被送回来 :业务线程调
writeAndFlush,不是本线程就打包成任务丢回这条线程的队列(AbstractChannelHandlerContext.java:978-990)。
所以同一连接的一切事件都串行在这条线程上,「不用加锁」是结构保证的,不是纪律保证的。重活怎么办?把 handler 挂到业务线程组:
java
EventExecutorGroup bizGroup = new DefaultEventExecutorGroup(16);
p.addLast(bizGroup, "dbHandler", new BlockingDbHandler());
⚠️ 踩坑一:addLast(bizGroup, ...) 的保序是有开关的。 这个重载的保序机制在于「每条连接对同一个线程组只挑一次线程并缓存」(DefaultChannelPipeline.java:122-148,childExecutors 以组对象为 key)。但 ChannelOption.SINGLE_EVENTEXECUTOR_PER_GROUP 显式设为 false 时(:126-128),会退化为每次轮询挑人,保序立即失效。教训:框架的顺序保证往往依赖一个默认开启的开关,迁移配置时要确认它没被关掉。
⚠️ 踩坑二:挂组只「接管」挂上去的 handler,不是整条 pipeline。 归属按 handler 节点记录:没绑组的 Decoder、Encoder 仍在 IO 线程执行(AbstractChannelHandlerContext.java:130-136,executor == null 时返回 channel 的 eventLoop)。典型布局是「解码编码留 IO 线程、重业务挂组」,事件像接力一样在线程之间传递。
二、Kafka broker:全局线程池 + 停读
先看结构(注意处理线程与连接完全解耦,保序压在 mute 这一个动作上):
怎么读:同一连接的请求在同一时刻只有一个能进入全局队列(mute 标绿),所以处理线程池再怎么抢任务,也绝不可能让同连接的两个请求并行或乱序。 Kafka 的网络层和 Netty 几乎同构:Acceptor 轮询把新连接分给某个 Processor、注册到它私有的 selector 上终身绑定(SocketServer.scala:766-782)。但处理层故意反着来 :所有 Processor 把请求放进同一个全局 requestQueue(RequestChannel.scala:364),num.io.threads(默认 8)个处理线程抢任务,与连接毫无绑定。
那同一连接怎么不乱序?Kafka 的答案令人拍案:mute/unmute(停读)------
scala
// SocketServer.scala:1194-1198
requestChannel.sendRequest(req)
selector.mute(connectionId) // 去掉这条连接的读事件
把请求 M1 放进队列的同时,直接移除这条连接的 OP_READ:客户端就算已经发出 M2,服务端也不读;等 M1 的响应完整写回才 unmute 恢复(:1224-1231)。同一连接一次只允许一个在途请求 ,处理线程随便并行,物理上乱不了序。这本质上是把 Netty 的 autoRead(false) 用在了每条连接的每个请求上。代价是单连接变成停等式串行,吞吐受往返延迟限制------Kafka 用客户端多开连接来补。
认知刷新一:保序不一定要绑线程------在时间上把请求隔开,同样有效。
三、Kafka producer:一条线程 + 序列号兜底
结构(注意全部连接被压到唯一一条线程上,乱序风险被推到协议层):
怎么读:线程层只有一条 Sender(天然有序),提速靠同连接 5 个在途(蓝色),乱序的兜底交给协议层的序列号(蓝色)------顺序保证从线程层挪到了协议层。 客户端没有「服务一大堆连接」的压力,Kafka producer 走了另一个极端:整个客户端只有一条 Sender 线程 轮询所有 broker 连接的读写(KafkaProducer.java:464-468),相当于把全部连接绑到同一个 EventLoop 上------天然无并发、天然按序。
但单连接停等太慢,producer 允许同一连接最多 5 个在途请求提速,重试就可能乱序。Kafka 的兜底在协议层:每个 batch 带 per-(PID, partition) 单调序列号(RecordAccumulator.java:978-987),broker 端按序列号去重、拒绝乱序写入;幂等模式下在途上限锁死 5(ProducerConfig.java:565-573,因为 broker 每producerId 只缓存 5 个 batch 的元数据)。
认知刷新二:顺序保证可以从线程层挪到协议层。
四、Kafka consumer:分区独占 + 单线程拉取
producer 保的是「同一连接的请求顺序」,consumer 面对的是另一回事------它要保的是「同一分区内消息的处理顺序」,解法也换了维度:
怎么读:绿色是消费侧保序的核心------分区独占(分地盘)替代了连接绑定;一个分区自始至终只被一个消费者拉取,fetch 按 offset 递增,单线程处理即有序、零锁。
- 实例非线程安全是故意的设计 :javadoc 明言 "The consumer is not thread-safe" (
KafkaConsumer.java:59)、"We have intentionally avoided implementing a particular threading model for processing" (:485)。整个 consumer 的网络 IO 就跑在调用 poll() 的用户线程 里------producer 好歹还有条后台 Sender 线程,consumer 连这条都省了:你的调用线程就是它的 IO 线程。唯一的后台线程是心跳(AbstractCoordinator.java:146),它和用户线程通过ConsumerNetworkClient共享网络层,靠对象监视器锁串行化(ConsumerNetworkClient.java:58-59注释原话:"the mutable state of this class is protected by the object's monitor"); - 独占即保序:一个分区同一时刻只分配给组内一个消费者,拉取按 offset 递增、broker 按日志顺序返回------这和 Jedis 的「连接独占」同构,只是把地盘从连接换成了分区;
- ⚠️ 踩坑三:处理太慢会被踢出组。 poll 循环里干重活,超过
max.poll.interval.ms心跳线程发现你「假死」,触发 rebalance、分区易主------这是消费版的「别阻塞 EventLoop」; - ⚠️ 踩坑四:commitAsync 的完成顺序不保证。 后提交的旧 offset 可能覆盖先提交的新 offset(提交是按位覆盖),重启后重复消费;要精确就用
commitSync或带回调的commitAsync。rebalance 本身也会因「未提交的 offset 被新主人从上次提交点重拉」造成重复消费------这是 at-least-once 语义的来源,不是 bug。
认知刷新三:多线程消费的标准姿势还是「绑定」------官方 javadoc 给的模式就是把 ConsumerRecords 按分区分发到每分区一个的 worker 队列,按分区绑 worker、保分区内有序;跨分区顺序本来就不承诺,要全局有序只能单分区。
五、Redis 服务端:I/O 可以多线程,执行永远单线程
结构(主线程是唯一的指挥官和执行者,IO 线程只被「借手」):
怎么读:黄色是关键拦截------IO 线程解析出命令也只打标记(networking.c:2079-2085),执行永远回到主线程(绿色);一个客户端一轮只进一个桶,不存在两个线程同时碰它。 Redis 的答案最彻底:执行单线程,全局天然有序,零锁 。6.2 开启 io-threads 后,读和写按轮询分桶给各 IO 线程(item_id % io_threads_num,networking.c:3634-3645),主线程自旋等所有 IO 线程完成(:3655-3661)再依次执行。
六、Redis 客户端:独占 or 排队,协议说了算
RESP 协议里请求和响应没有请求编号(对比 Kafka 的 correlationId),响应只能按请求先后顺序配对------这决定了 Redis 客户端只有两条路可走:
怎么读:Jedis 用「不共享」消灭问题;Lettuce 用「共享但排队」消化问题------它的顺序配对(绿色)之所以成立,前提正是下一层 Redis 服务端按序执行、按序返回。 两家的具体取舍:
- Jedis:连接独占 。官方 wiki 原话 "A single Jedis instance is not threadsafe!",两个线程共用会把命令写串、读走别人的响应。方案是 JedisPool 借还:每线程一条独占连接,用物理隔离消灭共享------BIO 的思路在客户端复活;
- Lettuce:共享一条连接 + 队列串行 + 顺序配对。多线程的命令先进先出排队、顺序写进同一条 TCP 连接,Redis 单线程按序返回,客户端按先进先出把响应配回各自的 promise。它的底层就是 Netty,连接读写由 EventLoop 驱动。
最深的一层:服务端和客户端是配套设计的------Lettuce 的按序配对成立的前提,正是 Redis 服务端「执行单线程、响应按序返回」;协议有没有请求编号,决定了服务端能不能并行处理。
R --- Result:五种主流思路与选型决策
五家方案放进一张表:
| 网络层 | 处理层 | 同连接保序手段 | 跨重连保序 | 代价 | |
|---|---|---|---|---|---|
| Netty | 连接绑定 EventLoop | 可挂业务线程组(每连接固定一条) | 绑线程 + 先进先出队列 | 留给应用的协议设计 | 业务重时需要额外线程组 |
| Kafka broker | 连接绑定 Processor | 全局队列 + 线程池(不绑连接) | 停读:一次一个在途请求 | 连接由客户端维护 | 单连接吞吐受 RTT 限制 |
| Kafka producer | 全部连接一条 Sender 线程 | 无独立处理层 | 单线程天然有序 + 序列号兜底 | PID + epoch + 序列号 | 客户端吞吐受单线程限制 |
| Kafka consumer | 网络层跑在用户调用线程(仅心跳在后台) | 用户线程处理记录 | 分区独占,fetch 按 offset 有序 | 从已提交 offset 重拉(重复消费) | 处理太慢触发 rebalance 被踢出组 |
| Redis 服务端 | 主线程事件循环 + IO 线程分桶 | 执行永远在主线程 | 执行单线程 | --- | 多核只能加速 I/O,不能加速执行 |
| Redis 客户端 | 独占连接(Jedis)/ 共享连接(Lettuce) | --- | 物理隔离 / 队列串行 + 顺序配对 | 未完成命令直接失败,重试交给应用 | 依赖服务端按序返回 |
抽象一下,其实只有五种主流思路:
- 绑定(affinity):把连接/客户端焊死在一条线程上,物理串行------Netty EventLoop、childExecutor pin、Kafka Processor、Redis 分桶,本质都是它;
- 停读(backpressure) :处理不过来就不读------Kafka 的 mute、Netty 的
autoRead(false)、出站缓冲水位线; - 排队(serialization queue):多生产者一个队列先进先出,单通道消费------Netty 任务队列、Lettuce 的命令队列;
- 编号(sequence number):允许并行和乱序,在协议层重排或去重------Kafka 幂等 producer;
- 单线程执行(share nothing):干脆全局串行,零锁------Redis。
五家方案与五种思路的对应关系:
怎么选?顺着这张决策树走:
复盘沉淀的三条原则
- 先看协议,再选线程模型:有请求编号,服务端才有「并行处理 + 客户端重排」的资格;没有编号(如 RESP),服务端就必须在时间或线程上保证串行;
- 并行度和顺序是一对可交换的筹码:绑定、停读、单线程执行都是在「用并行换顺序」,丢失的并行要用别处补回来------Kafka 补多连接,Netty 补业务线程组,Redis 补 IO 线程;
- 跨出串行边界的那一刻,锁和编号总得选一个:要么用绑定/队列保证仍然串行,要么接受乱序并付出配对/去重的成本,不存在免费的并行。
遗留事项
- gRPC / Dubbo 的 HTTP/2 多路复用在「单连接多流」下如何保序(stream id 是思路 4 的又一实例),值得另开一篇;
- Redis 7 的 RESP3 与 client-side caching(服务端主动 push)对「响应必须按序」的松动;
- Kafka 新版将 requestQueue 从单队列改为按请求类型分队列(消除热点),是「全局队列」方案自身的演进。
本文全部素材来自开源项目公开源码(Netty 4.1.115.Final、Kafka 3.7、Redis 6.2.0)与官方文档,所有行号均经逐一核对,不涉及任何内部系统信息。