分区有序不等于业务有序
3.2 的投影消费者假定事件「从 Kafka 出来就能直接用」,但这一条仅在单分区、无重试、无网络抖动时才成立。真实系统里,同一个 key 的消息可能因重试而乱序,不同的事件源之间没有全局时钟,分区本身的顺序也只保证「同一生产者写入同一分区的先后顺序」,不保因果。
先拆三个概念:
| 概念 | 含义 | Kafka 是否保证 |
|---|---|---|
| 分区有序 | 同一生产者发到同一分区的消息,按发送顺序消费 | 是(无重试时) |
| 业务有序 | 同一业务对象的状态变化,按因果顺序消费 | 否 |
| 因果有序 | 事件 A 的逻辑需要在事件 B 之前发生 | 否 |
分区有序是最弱的一条。生产者重试(retries > 0)时,send() 先到的不一定先到 broker;网络抖动导致 broker ACK 延迟,生产者可能重发先发出的消息,导致后发的反而先到。多分区场景下,一个业务对象的事件还会因再平衡而迁移分区,顺序完全不可预期。
所以依赖 Kafka 的投递顺序来推断业务状态是错误的——必须另外维护因果序。
因果序的识别
因果序不是全序,它只是部分序:有些事件之间没关系,有些事件之间必须先后。以一个借书流程为例:
BookCreated(初始上架)LoanInitiated(借阅被创建)LoanPaired(与具体图书绑定)LoanCompleted(归还完成)
事件 3 必须在事件 2 之后,事件 4 必须在事件 3 之后。但它们之间不需要全局时钟同步,只需要一个单调递增的版本号。
版本号怎么来?有两个来源:
- 业务侧生成:聚合根每次状态变化时自增版本,事件体携带该版本号。这是首选,因为版本号直接绑定业务状态,不受网络或时钟影响。
- 消费者侧生成:消费者按到达顺序赋一个顺序号。这是次选,它只能保证「我看到了什么顺序」,不保证这个顺序是业务上正确的。如果消费者重启或发生再平衡,顺序号会断裂。
本节采用业务侧版本号。
重分区导致乱序的隐蔽场景
除了网络重试,还有一个容易被忽略的乱序源:重分区(repartition)。当消费者组发生再平衡时,一个分区的所有权从消费者 A 转移到消费者 B。如果 A 在处理一批消息的途中崩溃,这批消息会由 B 重新拉取——但 A 可能已经提交了部分位移。
具体来说,消费者 A 拉取了 offset=0,1,2,3,处理完 0 和 1 后提交位移到 2,然后在处理 2 的时候崩溃。消费者 B 接管后从位移 2 开始消费,消息 2 会被再次处理,而消息 3 的处理顺序也变了。这不是 broker 的问题,是消费者处理模型的自然结果。
更隐蔽的是跨分区乱序。假设一个 bookId 原先在分区 p0,因为 key 分发策略调整(或分区扩容),新的消息落到了 p1。此时 p0 的历史事件和 p1 的新事件之间,消费者看到的顺序完全取决于两个分区的消费速度差异。分区有序在这时候变成了「每个分区各自有序」,整体没有序。
这两种情况共同点:有序性的保证方从 broker 转移到了应用层。broker 只做机械投递,业务因果必须由代码维护。
乱序到达实验:时间戳排序 vs 版本号排序
本机实验用一个单分区主题模拟三种事件按业务因果顺序为 INIT(v=1) → PAIR(v=2) → COMPLETE(v=3),但在网络上被重新排列为 COMPLETE → INIT → PAIR。
本机实测(Temurin 25.0.5+8 / Kafka 4.3.0 单节点 KRaft / kafka-clients 4.3.0 / 2026-10-29):以下输出为单次运行原样截取,端口 19592 独占。
生产者依次发送三条消息,但故意让事件 3(COMPLETE)先于事件 1(INIT)发出:
record LoanEvent(String eventId, String bookId, String status,
long eventTime, int version) {}
// Send event 3 first (COMPLETE)
long t3 = System.currentTimeMillis();
String v3 = "ev3|B-101|COMPLETE|" + t3 + "|3";
producer.send(new ProducerRecord<>(TOPIC, "B-101", v3)).get();
Thread.sleep(50);
// Send event 1 second (INIT)
long t1 = System.currentTimeMillis();
String v1 = "ev1|B-101|INIT|" + t1 + "|1";
producer.send(new ProducerRecord<>(TOPIC, "B-101", v1)).get();
Thread.sleep(50);
// Send event 2 third (PAIR)
long t2 = System.currentTimeMillis();
String v2 = "ev2|B-101|PAIR|" + t2 + "|2";
producer.send(new ProducerRecord<>(TOPIC, "B-101", v2)).get();
消费者读取到的顺序是投递顺序,不是业务顺序:
--- Receive order ---
ev3 version=3 status=COMPLETE
ev1 version=1 status=INIT
ev2 version=2 status=PAIR
尝试 1:按时间戳排序
直觉上可以用 eventTime 排序,因为事件 1 的业务发生时间早于事件 3。但时间戳是生产者本地的 System.currentTimeMillis(),跟事件 1 在事件 3 之后才发出这一事实相矛盾:
byTimestamp.sort(Comparator.comparingLong(e -> e.eventTime));
--- Sorted by timestamp (WRONG) ---
version=3 status=COMPLETE
version=1 status=INIT
version=2 status=PAIR
Event 1-3 time diff: 269 ms
Timestamp order correct? false
时间戳排序失败的直接原因是:事件 3 的时间戳比事件 1 早 269 毫秒——因为事件 3 在事件 1 之前被 send(),而生产者侧的时间戳就是 send() 的那一刻。在真实系统里,时钟漂移、NTP 同步、批量压缩等因素还会进一步放大这个问题。
结论:时间戳不能代表业务因果序。 时间戳回答的是「什么时候 send 的」,不是「业务上应该先发生哪一件」。
尝试 2:按版本号排序
每个事件体里自带一个单调递增的 version,在业务侧由聚合根在每轮状态变化时自增:
byVersion.sort(Comparator.comparingInt(e -> e.version));
--- Sorted by version (CORRECT) ---
version=1 status=INIT
version=2 status=PAIR
version=3 status=COMPLETE
Version order correct? true
版本号排序完全不受网络乱序或时钟漂移的影响,因为它不是「观测到的顺序」,而是「业务定义的因果链」。版本号由聚合根维护,写入事件时已经确定,消费者只需要按这个号重组即可。
这一组实验结论是:规则必须内生于事件体。外部排序(时间戳、位移、到达顺序)都是观测属性,不是因果属性。
版本号的策略选择
版本号不是只有一种用法。根据幂等和乱序容忍度的不同,有三种常见策略:
| 策略 | 语义 | 乱序容忍 | 重复容忍 | 适用场景 |
|---|---|---|---|---|
| 严格递增 | version = n,必须收到 n-1 才能处理 n | 有限(允许短暂乱序,拒绝永久缺失) | 精确 | 强因果链路,如账务 |
| 稀疏递增 | version 可跳号,缺失事件触发回填 | 是 | 精确 | 允许最终一致的场景 |
| 时间戳+版本 | version 用于去重,时间戳仅作辅助观测 | 是 | 是 | 日志追踪、投影重建 |
严格递增最简单:消费者看到一个版本 5,就知道 1–4 都已经处理过。如果 3 缺失,消费者等或报错。稀疏递增更灵活:消费者看到 5,2 还没收到,它先处理 5,同时把 2 标记为「待回填」。
实际选择哪种,取决于业务对「不可缺失」的容忍度。借阅系统的归还事件如果丢失,会导致图书永远显示「在借中」,这在核心业务里是不可接受的。所以借阅场景选严格递增。
还有一个常被忽略的点:版本号不是事件 ID。一个 version=3 的事件,即使重发三回,版本号仍然是 3。消费者应该记录「已处理版本 3」,而不是记录每个 eventId。这样重复事件就自然被过滤掉——同一版本号只处理一次。
在哪里维护版本号
版本号可以放在三个位置,各有代价:
| 位置 | 优点 | 缺点 |
|---|---|---|
| 事件体字段 | 最直观,消费者无额外查询 | 需要生产者保证单调递增 |
| Kafka message key 的元数据 | 不污染事件体 | 消费者需要额外解析 |
| 消费者侧的状态存储 | 业务无需改动 | 状态存储本身需要幂等写入 |
推荐放在事件体中,因为版本号是事件语义的一部分,和 status、bookId 放在同一层级最自然。消费者按版本号排序时,不需要再做额外查询。
版本号必须和聚合根绑定。一个 bookId 的版本号不能混用到另一个 bookId 上——跨聚合之间没有因果序,也不需要全局版本。
跨聚合的因果序
单聚合内的因果序用版本号可以解决,但跨聚合怎么办?例如 library.loan.created(借阅聚合)发生后,library.inventory.adjusted(库存聚合)必须在这之后执行,否则先扣库存再发现借阅失败了,库存就回不来。
跨聚合没有共享版本号,解决方案有两种:
- 事件驱动编排:借阅聚合发送
loan.created事件,库存聚合监听该事件后执行扣减。库存聚合的版本号只针对库存自身,但扣减事件的触发时机由loan.created事件保证。「借书」这个事务在生产端已经原子完成,inventory.adjusted只是它的下游投影。 - Saga 编排:如果借阅和库存需要在无事件依赖时也能协同执行,用 Saga(卷二讲述的模式)显式编排每一步的补偿操作。这不是因果序,是补偿序。
无论哪种,跨聚合的因果边界都停留在「事件触发」这层,不追求版本号贯通。试图用一个全局版本号串起所有聚合,会引入单点瓶颈,违背事件驱动的解耦初衷。
缺失版本怎么办
乱序到达的极端情况是「某个版本永远也到不了」。这不是排序算法能解决的,而需要一个补偿策略:
- 等待窗口:消费者收到版本 n,但缺少 n-1,等一个配置的超时(如 30 秒)。超时后仍未到,触发告警。
- 回填请求:消费者主动向事件源(可能是一个事件溯源存储)查询缺失版本的事件。
- 人工介入:对账系统发现版本号不连续,生成差异报告,按 4.2 的兜底流程处置。
等待窗口不是一个可以随便选的数字。它必须大于「事件在正常链路下的最大乱序延迟」。如果窗口设得太短,正常乱序会被误判为丢失;设得太长,故障发现就会被拖延。一个实际做法是用窗口度量数据(4.3 的 p99 值)乘一个系数(如 3–5 倍),作为初始窗口。例如 4.3 实测 paced 负载下 p99 为 78 毫秒,则等待窗口初始至少 300 毫秒,再根据业务容忍度向上调整。
消费者端的版本号缓存设计
消费者处理按版本号排序的事件时,有一个性能细节:每次都从数据库读取当前版本号,然后排序,再写入,会形成「读-排-写」的三步循环。如果事件量很大,这种设计会成为瓶颈。
一个简单的优化是在消费者内存中维护一个「每个聚合根的最新已处理版本」缓存:
Map<String, Integer> lastVersion = new ConcurrentHashMap<>();
int current = lastVersion.getOrDefault(bookId, 0);
if (event.version <= current) {
// 重复或旧版本,忽略
return;
}
if (event.version > current + 1) {
// 跳号,进入等待窗口
pending.put(bookId, event);
return;
}
// 顺序正确,处理并更新缓存
process(event);
lastVersion.put(bookId, event.version);
这个缓存不需要持久化——消费者重启后从数据库加载每个聚合的 last_event_version 即可重建。缓存的存在把「版本号检查」从数据库查询降到了内存读取,当消费者处理每秒数千条事件时,这一步优化能把延迟从几十毫秒降到微秒级。
但要注意缓存过期:如果一个聚合根长时间没有事件,它的缓存条目应该被清理,否则内存会无限增长。一个实用的做法是 LRU 缓存加固定容量(例如 10000 个条目),超出的条目按最近最少使用淘汰。
版本号在事件溯源中的特殊角色
在事件溯源(Event Sourcing)架构中,版本号的角色比普通 CQRS 更重。事件溯源的写模型本身就是不可变的事件流,聚合根的状态由重放事件流得到。此时版本号不仅是排序依据,还是一致性检查的唯一标准——你会发现两个版本号相同的事件,那一定是同一个事件被重复写入了。
事件溯源下的版本号策略通常是严格递增,且不允许跳号,因为每一个版本号都对应一个确定的状态快照。缺失任何版本号,重放就会中断,聚合根的状态就无法正确还原。这个约束使得事件溯源的消费者对缺失版本的容忍度最低:等待窗口必须足够短(因为消费者不能停滞太久),同时回填能力必须足够强(因为缺失的事件必须从存储中快速找回)。
libraryd 目前不是事件溯源架构,而是 CQRS + 投影读模型,所以版本号的约束可以稍微放松——允许稀疏递增和短暂跳号。但如果未来要迁移到事件溯源,版本号的严格递增会成为前置条件。
时间戳唯一可能正确的地方
把「时间戳排序失败」的否定掉之后,还要说明时间戳在顺序问题里的唯一合法角色:它是可观测指标,不是排序依据。
在度量消费者端到端延迟时,时间戳是有用的。你可以统计「版本 3 被处理的时间」减去「版本 1 被处理的时间」,来度量整条因果链的消费延迟。但这个计算的前提是:你已经用版本号把事件按正确顺序排好了,时间戳只是附加的标签。
另一个合法用途是 TTL:消费者可以把「版本号缺失超过 5 分钟」的事件直接丢弃或告警,这里时间戳作为「什么时候第一次看到这个版本号」的记录,和因果序无关。
还有一类场景是「在版本号缺失时提供退化的排序」。如果某个老系统的事件没有版本号,你无法按版本号排序,此时可以按时间戳排序——但要明确这是退而求其次,不是正确方案,输出结果应注明「排序基于时间戳,可能因时钟偏移而不准确」。
小结
- Kafka 的分区有序只保证同一分区内同一生产者的发送顺序,不保业务因果顺序。
- 网络重试、重分区再平衡、跨分区迁移都会导致事件乱序到达消费者,分区有序性在真实链路中不可靠。
- 重分区再平衡时消费者 A 崩溃,后续消费者从已提交位移继续,可能造成部分消息重复和部分消息乱序。
- 时间戳排序在实验中失败(事件 1 的时间戳比事件 3 晚 269 毫秒),因为时间戳反映的是发送时刻,不是业务因果。单次运行,随调度波动。
- 版本号排序在实验中成功,版本号由聚合根自增并写入事件体,消费者按版本号重组即可。
- 版本号策略分严格递增与稀疏递增,前者适用于「不可缺失」的强因果链路。
- 版本号不是 eventId,重复事件因同版本而被过滤,无需额外去重键。
- 消费者内存缓存聚合根最新版本号,可将版本检查从数据库查询降至内存读取。
- 事件溯源架构中版本号是一致性检查的唯一标准,不允许跳号,对缺失版本的容忍度最低。
- 跨聚合因果序不依赖共享版本号,而依赖事件驱动编排或 Saga 补偿序。
- 缺失版本的处理:等待窗口 → 回填请求 → 人工介入,窗口大小应参考 4.3 的延迟度量。
- 时间戳的唯一合法角色是辅助度量,不是排序依据;缺失版本号时可退化为时间戳排序,须注明局限性。
阅读导航:上一节 4.3 补偿与最终一致性 | 下一节 5.2 幂等边界的设计
继续阅读
探索更多技术文章
浏览归档,发现更多关于系统设计、工具链和工程实践的内容。