2961 字
约 9 分钟
19
S17-X12 · 幂等与顺序:消息语义的落地

消息中间件能保证的事情比大多数人以为的少。生产者调 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. 自测

  1. 为什么说"恰好一次"是端到端构造出来的,而不是中间件的一个开关?
  2. 重复的四个来源里,哪几个必须由消费端兜住?
  3. 幂等键为什么不能包含时间戳或 UUID?
  4. 唯一索引建在可空列上为什么会失效?
  5. max.in.flight.requests.per.connection > 1 在什么条件下仍能保序?
  6. 重平衡时位点应该在哪个回调里提交,为什么不能在那里做耗时逻辑?
S17-X12 · 幂等与顺序:消息语义的落地
https://www.clxhxhhr.top/posts/5886/
作者
clxstart
发布于
2024-12-31
许可协议
CC BY-NC-SA 4.0
评论
0 条
还没有评论,先写一条吧。