Skip to content

消息可靠性、幂等与顺序 ​

S4-014建议学高级约 60 分钟JDK 21企业开发进阶 · 消息队列

1. 本节定位 ​

《RabbitMQ 核心概念与工作模式》讲了路由;本节讲消息队列的三道必答题:丢不丢、重不重、乱不乱。

★ 本节的实测把三道题都跑成了数字 ✓✓:

text
丢:fire-and-forget 丢 40/1000 条 ✗✗ ;同步确认丢 0 条 ✓
重:手动 ACK 必然重投 → 幂等表把 1120 次投递收敛成 1000 次处理 ✓✓
乱:轮询投递乱序 427 处 ✗✗ ;按 key 哈希定分区 0 处 ✓✓

2. 学习目标 ​

  • 能说清"消息可能在三个环节丢失",以及每个环节的解法 ★★★;
  • 知道"不丢"与"不重"是二选一,所以必须幂等 ★★★;
  • 知道幂等要做在数据层(唯一索引)而不是应用层判断 ★★★;
  • 能说清顺序消息的实现原理与代价 ★★★;
  • 知道"先问真的需要这么可靠吗" ★★★。

3. 前置知识检查 ​

4. 为什么需要这个知识 ​

先看实测:三个环节的丢失 ✓:

text
① 生产者 → Broker:fire-and-forget 收到 960/1000 条,**丢 40 条** ✗✗
② Broker 存储:只写内存时崩溃 → 500 条**全丢** ✗✗
③ Broker → 消费者:自动 ACK 时崩溃 → **丢掉那 1 条已确认未处理的** ✗✗

★★ 三个环节都会丢,而且都不是"MQ 不靠谱" ✓✓:

是"用了不靠谱的配置" ✓

每一环都有对应的解法,但每一环都要付出代价(性能或复杂度)✓

5. 核心概念 ​

5.1 环节一:生产者到 Broker ★★★ ​

实测 ✓:

text
  ① fire-and-forget(不等确认):Broker 收到 960 / 1000 条,**丢 40 条** ✗✗
  ② 同步确认 + 重发:Broker 收到 1000 / 1000 条,**丢 0 条** ✓✓(含 41 次重发)

★ 关键认识 ✓✓:

fire-and-forget 的问题不是"会丢",而是"丢了谁都不知道" ✗✗

它既不报错、也不重试,问题会在很久以后以"数据对不上"的形式爆发 ✓

★ 三种发送可靠性 ✓:

方式语义代价
fire-and-forget ✗最多一次(可能丢)最快,无保障
同步确认(wait for ack)至少一次(可能重)每条一次 RTT ✗
异步确认(回调)★★★至少一次生产环境常用 ✓✓

★★ 注意"至少一次"的确切含义 ✓✓:

保证不丢,但可能重复 ——所以消费端必须幂等(见 5.4)✓✓

5.2 环节二:Broker 存储 ★★★ ​

实测 ✓:

text
  ① 只写内存:崩溃前 500 条 → 崩溃重启后 0 条,**全部丢失** ✗✗
  ② 落盘后再崩溃:崩溃前 500 条 → 重启后 500 条,**一条不丢** ✓✓

★ 但"持久化"不是"是/否",而是"什么时候刷盘"的三档取舍 ✓✓:

档位含义崩溃丢多少对应配置
不刷盘 ✗只写页缓存全丢 ✗✗RabbitMQ durable=false
每秒刷 ✓异步刷盘最多 1 秒 ✓Kafka(由 OS 决定)
每条刷 ✓✓等 fsync 返回几乎不丢 ✓✓RabbitMQ publisher-confirm + 持久化

★★ 这与另两个中间件是同一个取舍 ✓✓:

中间件参数
Redis ★★appendfsync:always / everysec / no(《Redis 持久化与高可用》)
MySQL ★★innodb_flush_log_at_trx_commit:1 / 2 / 0
Kafka ★★acks:all / 1 / 0

★ 一条通用方法 ✓✓:看到一个中间件,先问它"什么时候落盘" ✓

5.3 环节三:消费者处理 ★★★ ​

实测 ✓✓:

text
  ① 自动 ACK:处理了 50 条后崩溃 → 重新消费时只剩 49 条,**丢了 1 条** ✗✗
  ② 手动 ACK:处理了 50 条后崩溃 → 重新消费时还剩 50 条,**一条不丢** ✓✓

★★ 这两行是本节最重要的对比 ✓✓:

方式结果
自动 ACK(取出即确认)✗✗不重但会丢 ——那条"已确认但没处理"的消息永远消失了 ✗
手动 ACK(处理成功才确认)✓✓不丢但会重 ——前 50 条会被重新投递 ✓

★★★ 结论(必须记住) ✓✓:

「不丢」与「不重」二选一,MQ 只能给你前者 ✓✓

所以「幂等」不是可选项,是"选择手动 ACK"的必然配套 ✓✓

★ 对应配置 ✓:

MQ配置
RabbitMQautoAck=false + 手动 basicAck ✓
Kafkaenable.auto.commit=false + 手动提交 offset ✓
RocketMQ返回 CONSUME_SUCCESS 才算确认 ✓

5.4 幂等:把"重复投递"收敛成"一次处理" ★★★ ​

实测 ✓✓:

text
  投递总数 1120 条(其中 120 条是重复投递)
  ① 无幂等:处理了 1120 次 ✗
  ② 幂等表去重:**只处理 1000 次**,跳过 120 条重复 ✓✓

★ 但注意一个前提 ✓✓:

如果业务本身是幂等的,重复消费无害 ✓✓

「set status=PAID」是幂等的;「balance = balance + 100」不是 ✗✗

所以第一选择是"把业务设计成幂等",而不是"加去重表" ✓

★ 幂等的三种实现(按推荐度) ✓✓:

手段原理适用
业务天然幂等 ★★★操作本身就是终态赋值最优 ✓✓
唯一索引兜底 ★★★重复插入会冲突最可靠 ✓✓
去重表(本节做法)★★处理前查/写 msgId通用,但要多一次读写 ⚠️

★★ 一条硬建议 ✓✓:

幂等要做在「数据层」而不是「应用层判断」 ✓✓

因为应用层的"先查再写"不是原子的(《操作系统:调度、同步与死锁》 的 check-then-act)✗

正确做法:唯一索引 + 插入冲突即忽略 ——让数据库替你做原子判断 ✓✓

★ 一个具体例子 ✓:

sql
-- 消费"支付成功"消息时:
INSERT INTO payment_record (order_no, trade_no, amount)
VALUES (?, ?, ?);
-- 如果 order_no 有唯一索引,重复消息会抛冲突 → 捕获并忽略(视为已处理)✓✓

5.5 顺序消息:同 key 必须进同一分区 ★★★ ​

实测(修正实验设计后) ✓✓:

text
  ① 轮询投递到 3 个分区:消费顺序与发送顺序不一致 **427 处** ✗✗
  ② 按 key 哈希定分区:消费顺序与发送顺序不一致 **0 处** ✓✓

★ 一个诚实的说明 ✓✓:

本实验的第一版"乱序数恒为 0" ✗ ——

因为我的读取顺序恰好也是轮询,读出来的顺序恰好等于发送顺序 ✓

改成"每个分区一个线程并行拉取"后,才复现出真实的 427 处乱序 ✓✓

教训:并发类的实验,必须让"并发时序"真的发生 ✓✓

★ 核心认识 ✓✓:

MQ 保证的是「分区内有序」,不是「全局有序」 ✓✓

所以"要顺序"必须翻译成:同一个 key 的消息进同一个分区 ✓

实现方式:发送时指定 key(如 orderId),由客户端按 key 哈希定分区 ✓

★ 判断某个业务要不要顺序 ✓:

业务乱序会怎样要不要顺序
订单状态流转(创建→支付→发货)★★★乱序会出错(先发货后支付)✗必须按 orderId 定分区 ✓
用户行为日志 ★乱序无所谓 ✓随意分区,吞吐优先 ✓✓
账单流水 ★★乱序影响对账按账户 ID 定分区 ✓

★★ 顺序消息的代价(必须知道) ✓✓:

代价说明
热点 key 压垮单分区 ✗✗大客户的订单全挤一个分区 → 该分区成为瓶颈 ✓
并行度受限 ⚠️要顺序就得串行,与"高并行"天然矛盾 ✓
加分区会打乱顺序 ✗hash % 分区数 的除数变了(《消息队列基础与选型》)✓

★ 热点 key 的解法 ✓:

key 加盐打散(如 orderId + 分片号)——代价是牺牲该 key 的顺序 ✓

或者按业务分层:核心状态变更走顺序通道,其他走普通通道 ✓✓

5.6 死信队列:重试够多次就别再试了 ★★ ​

★ 为什么需要死信队列 ✓✓:

一条"永远处理不成功"的消息(如数据格式错误)会被无限重试 ✗✗

它会一直占着分区/队列,拖住后面的消息 ✗

★ 标准做法 ✓✓:

text
消费失败 → 重试 N 次(带退避)→ 仍失败 → 投递到死信队列(DLQ)→ 人工/单独程序处理 ✓

★ 对应配置 ✓:

MQ配置
RabbitMQ消费端 basicNack(requeue=false) + 队列配 x-dead-letter-exchange ✓
Kafka无原生 DLQ —— 需要自己实现(处理失败就发到 xxx.DLQ topic)✓
RocketMQ失败重试 16 次后自动进 %DLQ%消费组名 ✓

★★ 死信队列的三条运维要求 ✓✓:

要求原因
必须监控 DLQ 的消息数 ★★★有消息进 DLQ 就说明有业务在持续失败 ✗
必须能重放 ★★修好 bug 后要把 DLQ 的消息重新投回去 ✓
必须能区分"可重试"与"不可重试" ★★格式错误的消息重试 1 万次也没用 ✗

5.7 三道题的答案对照 ★★★ ​

问题在哪一环出错解法对应配置
丢不丢生产者没等确认异步确认 + 失败回调重发publisher-confirm
丢不丢Broker 崩溃持久化 + 副本durable / acks=all
丢不丢消费者处理失败手动 ACK,成功才提交autoAck=false
重不重手动 ACK 必然重投幂等消费(唯一索引)业务实现
乱不乱同 key 落到多个分区按 key 定分区partition key

★★ 一句话记住三者关系 ✓✓:

要「不丢」就得「手动 ACK」,手动 ACK 必然「会重」,所以必须「幂等」 ✓✓

要「有序」就得「同 key 同分区」,代价是「热点与并行度受限」 ✓✓

★★★ 最后的忠告 ✓✓:

先问「真的需要这么可靠吗」 ✓

把"不丢 + 不重 + 有序"全都要上,代价是吞吐下降与复杂度飙升 ⚠️

而很多消息(通知、埋点、推荐信号)丢几条完全无所谓 ✓✓

按重要性分级,只给关键链路(支付、库存)配全套可靠性 ✓✓

6. 递进示例 ​

6.1 最小示例:手动 ACK ​

java
channel.basicQos(10);                       // 最多 10 条未确认
channel.basicConsume(queue, false, (tag, delivery) -> {
    try {
        handle(delivery);                    // 先处理
        channel.basicAck(delivery.getEnvelope().getDeliveryTag(), false);   // ✓ 成功才确认
    } catch (Exception e) {
        channel.basicNack(delivery.getEnvelope().getDeliveryTag(), false, false);  // 进 DLQ
    }
}, consumerTag);

6.2 业务示例:唯一索引做幂等 ​

见 5.4 ✓

6.3 反例:自动 ACK + 业务异常 ​

java
// ✗ 取出即确认,处理失败消息就永久丢了
channel.basicConsume(queue, true, (tag, delivery) -> {
    handle(delivery);          // 抛异常也没人管,消息已经没了
});

7. 完整可运行代码与实测 ​

代码:labs/distributed-lab/src/main/java/cn/kb/learn/dist/mq/ReliabilityLesson.java

运行:

powershell
cd labs
.\mvnw.cmd -pl distributed-lab compile
java "-Dstdout.encoding=UTF-8" -cp distributed-lab/target/classes cn.kb.learn.dist.mq.ReliabilityLesson

实测输出(关键部分):

text
===== 1. 生产者 → Broker =====
  ① fire-and-forget:Broker 收到 960 / 1000 条,**丢 40 条** ✗✗(0 ms)
  ② 同步确认 + 重发:Broker 收到 1000 / 1000 条,**丢 0 条** ✓✓(含 41 次重发)

===== 2. Broker 存储 =====
  ① 只写内存:崩溃前 500 条 → 崩溃重启后 0 条,**全部丢失** ✗✗
  ② 落盘后再崩溃:崩溃前 500 条 → 重启后 500 条,**一条不丢** ✓✓

===== 3. 消费者处理 =====
  ① 自动 ACK:处理了 50 条后崩溃 → 重新消费时只剩 49 条,**丢了 1 条** ✗✗
  ② 手动 ACK:处理了 50 条后崩溃 → 重新消费时还剩 50 条,**一条不丢** ✓✓

===== 4. 幂等 =====
  投递总数 1120 条(其中 120 条是重复投递)
  ① 无幂等:处理了 1120 次 ✗
  ② 幂等表去重:**只处理 1000 次**,跳过 120 条重复 ✓✓

===== 5. 顺序 =====
  ① 轮询投递到 3 个分区:消费顺序与发送顺序不一致 **427 处** ✗✗
  ② 按 key 哈希定分区:消费顺序与发送顺序不一致 **0 处** ✓✓

★ 三条可落地的结论 ✓:

#结论依据
①三个环节都会丢,且各有解法40 条 / 500 条 / 1 条 ✗
②手动 ACK 必然带来重复,所以要幂等1120 → 1000 ✓
③顺序靠"同 key 同分区",不靠 MQ 的承诺427 vs 0 ✓✓

8. Debug 观察点 ​

  1. 把 basicAck 的位置从"处理后"挪到"处理前":观察丢消息 ✗✗;
  2. 去掉唯一索引,只靠应用层"先查再写":并发下观察重复插入 ✗;
  3. 把发送的 key 改成 null:观察同一业务的消息被分散到不同分区 ✗;
  4. 给消费端加大 basicQos:观察批量确认带来的重复窗口变大 ⚠️;
  5. 统计 DLQ 的消息数:任何非零都值得查 ✓。

9. 常见错误 ​

#错误说明
1用 fire-and-forget 却以为不会丢 ✗✗丢 4%(实测)✓
2以为"持久化"是布尔值 ✗✗它是"什么时候刷盘"的三档 ✓
3自动 ACK 处理业务异常 ✗✗消息永久丢失 ✓
4手动 ACK 但不做幂等 ✗✗重复下单/重复加积分 ✓✓
5用"先查再写"实现幂等 ✗✗不是原子的 ✓
6以为 MQ 保证全局有序 ✗✗只保证分区内有序 ✓✓
7不管死信队列 ✗✗有人持续失败却没人知道 ✓

10. 动手实验 ​

目标:把三道题都亲手验证一遍。

步骤:

  1. 跑 ReliabilityLesson,记录五个实验的数字 ✓;
  2. 把"同步确认"改成"fire-and-forget",观察丢失数量 ✓;
  3. 给幂等表加上"消息 ID 落库"(而不是内存 Set),让它能跨重启生效 ★★;
  4. 把同一个 key 的消息发到两个不同分区,验证顺序被打乱 ★★★;
  5. 设计一个 DLQ 处理流程:重试 3 次 → 进 DLQ → 告警 → 人工处理后重放 ✓。

验收方式:能说出"你的系统里每条消息是"最多一次/至少一次"、幂等靠什么"。

扩展任务:给"支付回调"设计完整的可靠性方案:

  • 生产者:第三方回调,如何保证不丢(提示:回调方会重试,你要做的是幂等)✓;
  • 消费者:如何保证不重复扣款(提示:唯一索引 + 事务)✓✓;
  • 顺序:同一笔订单的多条回调如何保证顺序 ✓;
  • 对账:每天跑一次对账兜底(这是最后一道防线)★★

11. 练习题 ​

  1. 判断:手动 ACK 可以同时做到"不丢"和"不重"。(对 / 错)
  2. 填空:MQ 保证的是 ______ 有序,而不是 ______ 有序。
  3. 简答:为什么幂等要做在数据层而不是应用层?
  4. (场景判断)
text
某消息处理逻辑:
    int points = userRepo.getPoints(userId);
    userRepo.updatePoints(userId, points + 100);

这条消息能重复消费吗?怎么改? 5. (设计)给"订单状态流转"设计消息方案:要求同一订单的消息严格有序。

参考答案(要点)
  1. 错。手动 ACK 是"至少一次"(不丢但会重) ✗ —— 要"不重"必须靠幂等,而不是靠 ACK 方式 ✓★
  2. 分区内;全局 ✓
  3. 因为应用层的"先查再写"不是原子的 ✗ ——并发下两个线程可能都查到"未处理" → 都执行 ✗;唯一索引让数据库做原子判断 ✓✓
  4. 不能 ✗ ——它是"读-改-写",重复执行会多加积分 ✗。 改法:① 改成幂等的 SQL(update ... set points = points + 100 仍不幂等 ✗); ② 加幂等表(记录 msgId,处理前插入)✓✓; ③ 用业务唯一键(如 user_id + 业务类型 + 业务ID 唯一索引)✓✓
  5. 要点:按 orderId 定分区(同订单进同一分区)✓; 发送时带 key,不要用轮询 ✓; 消费端手动 ACK + 幂等(状态流转用"只允许从 A 到 B"的条件更新)✓✓

12. 掌握标准 ​

  • [ ] 能说出三个丢失环节与各自解法 ★★★;
  • [ ] 知道"不丢"与"不重"是二选一 ★★★;
  • [ ] 知道幂等要做在数据层(唯一索引) ★★★;
  • [ ] 能说清顺序消息的原理与代价 ★★★;
  • [ ] 知道死信队列的三条运维要求 ★★;
  • [ ] 能背出三道题的答案对照表 ★★★;
  • [ ] 知道"按重要性分级配可靠性" ★★★。

13. 扩展资源 ​

14. 下一步学习 ​

消息队列三节到此完成。《Nginx 安装与反向代理》:所有流量的必经之路。

后续知识

暂无

进度只保存在本机浏览器,可随时清除

个人学习项目:AI 生成内容均经人工复核,第三方资源仅作索引