主题
界面
主题
《RabbitMQ 核心概念与工作模式》讲了路由;本节讲消息队列的三道必答题:丢不丢、重不重、乱不乱。
★ 本节的实测把三道题都跑成了数字 ✓✓:
丢:fire-and-forget 丢 40/1000 条 ✗✗ ;同步确认丢 0 条 ✓
重:手动 ACK 必然重投 → 幂等表把 1120 次投递收敛成 1000 次处理 ✓✓
乱:轮询投递乱序 427 处 ✗✗ ;按 key 哈希定分区 0 处 ✓✓先看实测:三个环节的丢失 ✓:
① 生产者 → Broker:fire-and-forget 收到 960/1000 条,**丢 40 条** ✗✗
② Broker 存储:只写内存时崩溃 → 500 条**全丢** ✗✗
③ Broker → 消费者:自动 ACK 时崩溃 → **丢掉那 1 条已确认未处理的** ✗✗★★ 三个环节都会丢,而且都不是"MQ 不靠谱" ✓✓:
是"用了不靠谱的配置" ✓
每一环都有对应的解法,但每一环都要付出代价(性能或复杂度)✓
实测 ✓:
① fire-and-forget(不等确认):Broker 收到 960 / 1000 条,**丢 40 条** ✗✗
② 同步确认 + 重发:Broker 收到 1000 / 1000 条,**丢 0 条** ✓✓(含 41 次重发)★ 关键认识 ✓✓:
fire-and-forget 的问题不是"会丢",而是"丢了谁都不知道" ✗✗
它既不报错、也不重试,问题会在很久以后以"数据对不上"的形式爆发 ✓
★ 三种发送可靠性 ✓:
| 方式 | 语义 | 代价 |
|---|---|---|
| fire-and-forget ✗ | 最多一次(可能丢) | 最快,无保障 |
| 同步确认(wait for ack) | 至少一次(可能重) | 每条一次 RTT ✗ |
| 异步确认(回调)★★★ | 至少一次 | 生产环境常用 ✓✓ |
★★ 注意"至少一次"的确切含义 ✓✓:
保证不丢,但可能重复 ——所以消费端必须幂等(见 5.4)✓✓
实测 ✓:
① 只写内存:崩溃前 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 |
★ 一条通用方法 ✓✓:看到一个中间件,先问它"什么时候落盘" ✓
实测 ✓✓:
① 自动 ACK:处理了 50 条后崩溃 → 重新消费时只剩 49 条,**丢了 1 条** ✗✗
② 手动 ACK:处理了 50 条后崩溃 → 重新消费时还剩 50 条,**一条不丢** ✓✓★★ 这两行是本节最重要的对比 ✓✓:
| 方式 | 结果 |
|---|---|
| 自动 ACK(取出即确认)✗✗ | 不重但会丢 ——那条"已确认但没处理"的消息永远消失了 ✗ |
| 手动 ACK(处理成功才确认)✓✓ | 不丢但会重 ——前 50 条会被重新投递 ✓ |
★★★ 结论(必须记住) ✓✓:
「不丢」与「不重」二选一,MQ 只能给你前者 ✓✓
所以「幂等」不是可选项,是"选择手动 ACK"的必然配套 ✓✓
★ 对应配置 ✓:
| MQ | 配置 |
|---|---|
| RabbitMQ | autoAck=false + 手动 basicAck ✓ |
| Kafka | enable.auto.commit=false + 手动提交 offset ✓ |
| RocketMQ | 返回 CONSUME_SUCCESS 才算确认 ✓ |
实测 ✓✓:
投递总数 1120 条(其中 120 条是重复投递)
① 无幂等:处理了 1120 次 ✗
② 幂等表去重:**只处理 1000 次**,跳过 120 条重复 ✓✓★ 但注意一个前提 ✓✓:
如果业务本身是幂等的,重复消费无害 ✓✓
「
set status=PAID」是幂等的;「balance = balance + 100」不是 ✗✗所以第一选择是"把业务设计成幂等",而不是"加去重表" ✓
★ 幂等的三种实现(按推荐度) ✓✓:
| 手段 | 原理 | 适用 |
|---|---|---|
| 业务天然幂等 ★★★ | 操作本身就是终态赋值 | 最优 ✓✓ |
| 唯一索引兜底 ★★★ | 重复插入会冲突 | 最可靠 ✓✓ |
| 去重表(本节做法)★★ | 处理前查/写 msgId | 通用,但要多一次读写 ⚠️ |
★★ 一条硬建议 ✓✓:
幂等要做在「数据层」而不是「应用层判断」 ✓✓
因为应用层的"先查再写"不是原子的(《操作系统:调度、同步与死锁》 的 check-then-act)✗
正确做法:唯一索引 + 插入冲突即忽略 ——让数据库替你做原子判断 ✓✓
★ 一个具体例子 ✓:
-- 消费"支付成功"消息时:
INSERT INTO payment_record (order_no, trade_no, amount)
VALUES (?, ?, ?);
-- 如果 order_no 有唯一索引,重复消息会抛冲突 → 捕获并忽略(视为已处理)✓✓实测(修正实验设计后) ✓✓:
① 轮询投递到 3 个分区:消费顺序与发送顺序不一致 **427 处** ✗✗
② 按 key 哈希定分区:消费顺序与发送顺序不一致 **0 处** ✓✓★ 一个诚实的说明 ✓✓:
本实验的第一版"乱序数恒为 0" ✗ ——
因为我的读取顺序恰好也是轮询,读出来的顺序恰好等于发送顺序 ✓
改成"每个分区一个线程并行拉取"后,才复现出真实的 427 处乱序 ✓✓
教训:并发类的实验,必须让"并发时序"真的发生 ✓✓
★ 核心认识 ✓✓:
MQ 保证的是「分区内有序」,不是「全局有序」 ✓✓
所以"要顺序"必须翻译成:同一个 key 的消息进同一个分区 ✓
实现方式:发送时指定 key(如
orderId),由客户端按 key 哈希定分区 ✓
★ 判断某个业务要不要顺序 ✓:
| 业务 | 乱序会怎样 | 要不要顺序 |
|---|---|---|
| 订单状态流转(创建→支付→发货)★★★ | 乱序会出错(先发货后支付)✗ | 必须按 orderId 定分区 ✓ |
| 用户行为日志 ★ | 乱序无所谓 ✓ | 随意分区,吞吐优先 ✓✓ |
| 账单流水 ★★ | 乱序影响对账 | 按账户 ID 定分区 ✓ |
★★ 顺序消息的代价(必须知道) ✓✓:
| 代价 | 说明 |
|---|---|
| 热点 key 压垮单分区 ✗✗ | 大客户的订单全挤一个分区 → 该分区成为瓶颈 ✓ |
| 并行度受限 ⚠️ | 要顺序就得串行,与"高并行"天然矛盾 ✓ |
| 加分区会打乱顺序 ✗ | hash % 分区数 的除数变了(《消息队列基础与选型》)✓ |
★ 热点 key 的解法 ✓:
key 加盐打散(如
orderId + 分片号)——代价是牺牲该 key 的顺序 ✓或者按业务分层:核心状态变更走顺序通道,其他走普通通道 ✓✓
★ 为什么需要死信队列 ✓✓:
一条"永远处理不成功"的消息(如数据格式错误)会被无限重试 ✗✗
它会一直占着分区/队列,拖住后面的消息 ✗
★ 标准做法 ✓✓:
消费失败 → 重试 N 次(带退避)→ 仍失败 → 投递到死信队列(DLQ)→ 人工/单独程序处理 ✓★ 对应配置 ✓:
| MQ | 配置 |
|---|---|
| RabbitMQ | 消费端 basicNack(requeue=false) + 队列配 x-dead-letter-exchange ✓ |
| Kafka | 无原生 DLQ —— 需要自己实现(处理失败就发到 xxx.DLQ topic)✓ |
| RocketMQ | 失败重试 16 次后自动进 %DLQ%消费组名 ✓ |
★★ 死信队列的三条运维要求 ✓✓:
| 要求 | 原因 |
|---|---|
| 必须监控 DLQ 的消息数 ★★★ | 有消息进 DLQ 就说明有业务在持续失败 ✗ |
| 必须能重放 ★★ | 修好 bug 后要把 DLQ 的消息重新投回去 ✓ |
| 必须能区分"可重试"与"不可重试" ★★ | 格式错误的消息重试 1 万次也没用 ✗ |
| 问题 | 在哪一环出错 | 解法 | 对应配置 |
|---|---|---|---|
| 丢不丢 | 生产者没等确认 | 异步确认 + 失败回调重发 | publisher-confirm |
| 丢不丢 | Broker 崩溃 | 持久化 + 副本 | durable / acks=all |
| 丢不丢 | 消费者处理失败 | 手动 ACK,成功才提交 | autoAck=false |
| 重不重 | 手动 ACK 必然重投 | 幂等消费(唯一索引) | 业务实现 |
| 乱不乱 | 同 key 落到多个分区 | 按 key 定分区 | partition key |
★★ 一句话记住三者关系 ✓✓:
要「不丢」就得「手动 ACK」,手动 ACK 必然「会重」,所以必须「幂等」 ✓✓
要「有序」就得「同 key 同分区」,代价是「热点与并行度受限」 ✓✓
★★★ 最后的忠告 ✓✓:
先问「真的需要这么可靠吗」 ✓
把"不丢 + 不重 + 有序"全都要上,代价是吞吐下降与复杂度飙升 ⚠️
而很多消息(通知、埋点、推荐信号)丢几条完全无所谓 ✓✓
按重要性分级,只给关键链路(支付、库存)配全套可靠性 ✓✓
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);见 5.4 ✓
// ✗ 取出即确认,处理失败消息就永久丢了
channel.basicConsume(queue, true, (tag, delivery) -> {
handle(delivery); // 抛异常也没人管,消息已经没了
});代码:labs/distributed-lab/src/main/java/cn/kb/learn/dist/mq/ReliabilityLesson.java
运行:
cd labs
.\mvnw.cmd -pl distributed-lab compile
java "-Dstdout.encoding=UTF-8" -cp distributed-lab/target/classes cn.kb.learn.dist.mq.ReliabilityLesson实测输出(关键部分):
===== 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 ✓✓ |
basicAck 的位置从"处理后"挪到"处理前":观察丢消息 ✗✗;basicQos:观察批量确认带来的重复窗口变大 ⚠️;| # | 错误 | 说明 |
|---|---|---|
| 1 | 用 fire-and-forget 却以为不会丢 ✗✗ | 丢 4%(实测)✓ |
| 2 | 以为"持久化"是布尔值 ✗✗ | 它是"什么时候刷盘"的三档 ✓ |
| 3 | 自动 ACK 处理业务异常 ✗✗ | 消息永久丢失 ✓ |
| 4 | 手动 ACK 但不做幂等 ✗✗ | 重复下单/重复加积分 ✓✓ |
| 5 | 用"先查再写"实现幂等 ✗✗ | 不是原子的 ✓ |
| 6 | 以为 MQ 保证全局有序 ✗✗ | 只保证分区内有序 ✓✓ |
| 7 | 不管死信队列 ✗✗ | 有人持续失败却没人知道 ✓ |
目标:把三道题都亲手验证一遍。
步骤:
ReliabilityLesson,记录五个实验的数字 ✓;验收方式:能说出"你的系统里每条消息是"最多一次/至少一次"、幂等靠什么"。
扩展任务:给"支付回调"设计完整的可靠性方案:
某消息处理逻辑:
int points = userRepo.getPoints(userId);
userRepo.updatePoints(userId, points + 100);这条消息能重复消费吗?怎么改? 5. (设计)给"订单状态流转"设计消息方案:要求同一订单的消息严格有序。
update ... set points = points + 100 仍不幂等 ✗); ② 加幂等表(记录 msgId,处理前插入)✓✓; ③ 用业务唯一键(如 user_id + 业务类型 + 业务ID 唯一索引)✓✓消息队列三节到此完成。《Nginx 安装与反向代理》:所有流量的必经之路。
暂无