消息中间件能保证的事情比大多数人以为的少。生产者调 send() 拿到成功,只说明 Broker 收了;消费者 onMessage 返回,只说明这一轮处理完了。中间任何一次重试、重投、重平衡,都会让同一条消息被处理两遍以上。所以"消息语义"说到底不是 Broker 的属性,而是你的业务代码愿不愿意为重复和乱序付出代价。
1. 三种投递语义,和"恰好一次"的真相
| 语义 | 承诺 | 实现方式 | 真实代价 |
|---|---|---|---|
| 最多一次 at-most-once | 不重复,可能丢 | 发完不等确认、消费先提交位点 | 丢消息 |
| 至少一次 at-least-once | 不丢,可能重复 | 发送重试、消费成功再提交 | 重复处理 |
| 恰好一次 exactly-once | 不丢不重 | 事务性生产 + 幂等消费 + 位点原子提交 | 端到端构造,不是中间件开关 |
Kafka 的 enable.idempotence=true 只解决"生产者在会话内重试导致 Broker 写入重复"。RocketMQ 的事务消息只解决"本地事务与发送的原子性"。两者都不管你的消费端,所以只要消费端写库不是幂等的,"恰好一次"就不成立。工程上正确的表述是:用至少一次投递 + 幂等消费,构造出端到端的恰好一次效果。
2. 重复的四个来源
| 来源 | 触发场景 | 表现 |
|---|---|---|
| 生产端重试 | send 超时、acks 未返回、网络抖动 |
同一业务事件写入两条 |
| Broker 重投 | 消费位点未提交、消费组重平衡 | 同一 offset 被不同实例再消费 |
| 消费端未及时提交 | 处理完但提交前后崩溃 | 已处理的又处理一遍 |
| 消费端重平衡 | 分区被回收给别的实例 | 未提交部分整段重放 |
四个来源里只有第一个能靠生产者幂等解决。剩下的必须在消费端兜住——这就是为什么"幂等"是消费端的必修课,不是可选优化。
3. 幂等的四种实现与成本
| 实现 | 原理 | 成本 | 适用 |
|---|---|---|---|
| 唯一索引 | 让重复写入在数据库层直接冲突 | 一次额外索引写,最便宜 | 插入型业务(订单、流水) |
| 状态机条件更新 | WHERE status = 旧值 才更新 |
一次条件索引扫描 | 有明确状态流转的业务 |
| 幂等键 + 结果缓存 | 处理过就返回上次的结果 | 需要存储结果,占内存/磁盘 | 查询型、需要返回值的接口 |
| 去重表 | 单独记录已处理的 key | 多一张表,要清理 | 无法在业务表上加唯一键时 |
唯一索引要建在非空列上。MySQL 的唯一索引允许多个 NULL,把幂等键建在可空列上等于没建——三条 NULL 全部通过校验。
ALTER TABLE t_order ADD COLUMN idem_key VARCHAR(96) NOT NULL DEFAULT '';
-- 存量行必须先回填唯一值,否则多行空串互撞,建索引直接失败
ALTER TABLE t_order ADD UNIQUE KEY uk_idem (idem_key);
状态机条件更新适合"只能往前走"的业务:
int n = jdbc.update("""
UPDATE t_order SET status = 'PAID', pay_no = ?
WHERE order_no = ? AND status = 'UNPAID'
""", payNo, orderNo);
if (n == 0) log.info("重复回调,订单已处理 orderNo={}", orderNo);
n == 0 有两种含义:已处理过(幂等命中)或订单不存在。要区分就得再查一次,别把两者混成"成功返回"。
4. 幂等键怎么组,有效期多久
幂等键 = 业务主键 + 操作类型 + 版本。三个成分各有理由:
| 成分 | 作用 | 漏了会怎样 |
|---|---|---|
| 业务主键(orderNo) | 定位对象 | 不同订单互相顶掉 |
| 操作类型(PAY/REFUND) | 区分同一对象的多次动作 | 支付与退款被当成同一件事 |
| 版本(第几次) | 区分同一动作的多次意图 | 用户真的想退两次,被当成重复 |
绝对不能把时间戳放进幂等键。时间戳每次都不同,等于每个请求都是新键,幂等形同虚设。同理 UUID 也不行——UUID 做键的结果是"永不重复",也就"永不幂等"。
String idemKey = orderNo + ":" + OpType.PAY + ":" + attemptNo;
有效期要跟业务窗口对齐:支付回调窗口通常按小时级,退款可能按天级。短于业务重试窗口 → 重复被执行;长于业务窗口 → 去重表无限膨胀。去重表必须有清理任务,按 created_at 分区删除,不要指望单表删。
5. 顺序的三个层面
| 层面 | 谁保证 | 破坏因素 |
|---|---|---|
| 生产顺序 | 单线程发送 + 同步确认 | 多线程并发 send、异步回调乱序 |
| 分区内顺序 | Broker 追加写日志 | 换 key 导致换分区 |
| 消费顺序 | 单分区单线程消费 | 多线程、异步提交、重平衡 |
"全局顺序"要求所有消息进同一分区或同一队列,吞吐直接被单分区上限锁死。绝大多数业务要的其实是按业务键有序(同一订单的支付、发货、退款有序),不同订单之间乱序无所谓。把需求从全局降到按 key,是保住吞吐的关键一步。
6. Kafka 分区内有序的前提
# 1) 最多 1 个未确认请求在途,否则重试会插队
max.in.flight.requests.per.connection=1
# 2) 生产者幂等,避免重试写重复
enable.idempotence=true
# 3) 全部副本确认,避免提交后丢
acks=all
第 1 条常被忽略:max.in.flight > 1 时,第一批失败重试、第二批已成功,Broker 上的顺序就反转了。Kafka 3.x 起配合 enable.idempotence=true 可以把该值放宽到 5 仍保序(幂等生产者带序号,Broker 会拒绝乱序),但具体行为按使用的客户端版本核对,保守起见仍建议显式写 1。
另一条前提容易被漏掉:生产端 key 必须是 orderNo 这类业务键,消费端也不能自己重算哈希另路由。
7. 消费端保序的三种做法
| 做法 | 实现 | 代价 |
|---|---|---|
| 单线程单分区 | 每分区一个消费线程,处理完才拉下一批 | 分区数即并行上限 |
| 按 key 哈希到固定 worker | 内存队列 + hash(key) % N |
需要自己管队列与背压 |
| 乱序容忍 + 业务拒绝旧消息 | 版本号/状态机兜底 | 业务要能表达"这条过期了" |
第一种最稳,第二种吞吐高但要自己写"单 key 串行"的调度器——同一个 key 必须永远进同一个 worker,worker 内串行。第三种是很多团队最终的选择:不追求消费顺序,追求业务上的幂等与单调。
// 按 key 哈希到固定 worker,同 key 串行、不同 key 并行
int idx = Math.floorMod(Objects.hash(msg.getOrderNo()), workers.length);
workers[idx].submit(() -> handle(msg));
8. 乱序到达用版本号兜底
// 旧消息直接拒绝:只有版本更大才允许覆盖
int n = jdbc.update("""
UPDATE t_order SET status = ?, version = ?
WHERE order_no = ? AND version < ?
""", newStatus, newVersion, orderNo, newVersion);
if (n == 0) {
log.warn("过期消息丢弃 orderNo={} version={}", orderNo, newVersion);
}
版本号可以是业务序号(支付单的 seq),也可以是消息里的 eventTime 转成的单调计数。不要用消息到达时间——它反映的是队列延迟,不是业务先后。
9. 重平衡为什么制造重复
重平衡时分区被收回,消费者必须在 onPartitionsRevoked 里提交已完成位点,否则新实例会从头重放。提交太早会丢(还没处理完),提交太晚来不及(已被收回)。正确做法是处理完立刻提交,并在 revoke 回调里再提交一次兜底:
consumer.subscribe(List.of("order-events"), new ConsumerRebalanceListener() {
@Override public void onPartitionsRevoked(Collection<TopicPartition> parts) {
consumer.commitSync(currentOffsets()); // 兜底,别在这里做耗时逻辑
}
@Override public void onPartitionsAssigned(Collection<TopicPartition> parts) {
// 可从外部存储恢复位点,或直接 seekToBeginning 但必须幂等
}
});
注意 commitSync 在 revoke 里执行有超时上限(max.poll.interval.ms 之内),别在这里查库、发 HTTP。位点用"处理完再提交"而不是"提交了再处理",是至少一次语义的必然结果——重复无法消除,只能幂等。
10. 常见坑
- 用 UUID 做幂等键。每个请求都是新键,永不重复也永不幂等。
- 唯一索引建在可空列。多行
NULL不冲突,去重彻底失效。 - 多线程消费同一分区还以为有序。分区内有序的前提是单线程消费,多线程必须自己做 key 路由。
- 消费成功但提交失败。崩溃后整段重放,只能靠幂等兜住,别指望位点。
- 去重表没有 TTL。半年后表比业务表还大,写入开始变慢。
- 幂等键里带时间戳或随机数,与第一条同因。
11. 排错:五种"重复/乱序"现场
| 现象 | 常见原因 | 处理 |
|---|---|---|
| 同一订单出现两条支付记录 | 生产端重试 + 消费端无唯一键 | 补 idem_key 唯一索引 |
| 状态回退(已发货变待发货) | 旧消息后到,无条件更新 | 加 version 条件更新 |
| 重平衡期间大量重复 | revoke 未提交位点 | 在 onPartitionsRevoked 补 commitSync |
| 同 key 消息处理顺序错乱 | max.in.flight > 1 且未开幂等 |
设 1 或开 enable.idempotence |
| 去重表越来越大、写入变慢 | 无清理策略 | 按时间分区 + 定时删除 |
12. 边界:什么情况本就不该追顺序
| 场景 | 更合适的做法 | 理由 |
|---|---|---|
| 不同用户之间的通知 | 完全不保序 | 业务无因果关系 |
| 日志、埋点、审计 | 按 key 保序即可 | 全局顺序代价过高 |
| 跨系统的长流程 | 状态机 + 版本号 | 消息顺序不可控,业务兜底更可靠 |
| 需要严格全序的账务 | 单分区 + 批处理 | 用吞吐换确定性,接受上限 |
| 幂等成本高于重复成本 | 允许重复,事后对账 | 例如纯查询类副作用 |
一句话收束:投递语义只能到至少一次,恰好一次要靠"幂等键 + 条件更新 + 位点提交"三件套自己造;顺序只在业务键维度上值得追。
13. 自测
- 为什么说"恰好一次"是端到端构造出来的,而不是中间件的一个开关?
- 重复的四个来源里,哪几个必须由消费端兜住?
- 幂等键为什么不能包含时间戳或 UUID?
- 唯一索引建在可空列上为什么会失效?
max.in.flight.requests.per.connection > 1在什么条件下仍能保序?- 重平衡时位点应该在哪个回调里提交,为什么不能在那里做耗时逻辑?