Skip to content

消息队列基础与选型 ​

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

1. 本节定位 ​

从本节起进入分布式与中间件。第一个问题是:为什么要在系统里塞一个"消息队列"?

text
它解决三件事:异步(别让用户等)、解耦(别让服务互相认识)、削峰(别被瞬时流量打死)

★ 本节的实测会把第一件事量化出来 ✓:

text
① 同步处理 3000 单(用户等待全过程):  905 ms ✗✗
② 入队(用户只等到这里):           12 ms ✓✓
   后台消费(用户不感知):         908 ms
→ 用户等待时间从 905 ms 降到 12 ms,**改善 75 倍** ✓✓

2. 学习目标 ​

  • 能说出消息队列解决的三个问题,并知道它不是"免费的" ★★★;
  • 能解释「消费并行度上限 = 分区数」,并说出分区数该怎么定 ★★★;
  • 知道批量发送的收益来自哪、代价是什么 ★★★;
  • 知道四种主流 MQ 的差异,并能按场景选 ★★★;
  • 知道「引入 MQ」意味着引入一个必须 7x24 可用的中间件 ★★。

3. 前置知识检查 ​

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

先看一个"同步链路被拖长"的真实场景 ✗:

java
@Transactional
public String createOrder(OrderRequest request) {
    Long orderId = orderRepository.save(request);      // 10 ms
    inventoryService.deduct(request);                  // 远程调用 50 ms
    couponService.use(request);                        // 远程调用 50 ms
    pointsService.add(request);                        // 远程调用 50 ms
    smsService.notify(request);                        // 第三方接口 300 ms ✗✗
    return "下单成功";
}

★ 用户为了收到那条短信,要多等 300 ms ✗ ——而这 300 ms 里他什么也做不了 ✓

★ 本节的实测就是这个场景的量化(905 ms → 12 ms)✓

5. 核心概念 ​

5.1 MQ 解决的三个问题 ★★★ ​

问题没有 MQ有 MQ
异步 ★★★用户等完整链路用户只等到"入队" ✓✓(实测 75 倍)
解耦 ★★下单接口要认识积分/短信/埋点服务业务只发一个事件,谁要谁订阅 ✓✓
削峰 ★★★秒杀流量直接打到数据库流量先堆在队列里,后端按自己的速度消费 ✓✓

★★ 但必须说清一件事 ✓✓:

MQ 不会让总工作量变少 ✓

它只是把"用户的等待"搬成了"后台的处理" ✓

本节的实测就是证据:入队 12 ms,而后台仍然要花 908 ms 才处理完 ✓

★ 所以它有一个硬前提 ✓:

用户能接受"最终一致" ——他拿到的是"受理成功",不是"处理完成" ✓

「下单成功后立刻查订单,却发现查不到」就是没想清这一点 ✗

5.2 消费并行度的上限 = 分区数 ★★★ ​

这是容量规划里最容易踩的坑 ✓✓ ——本节的实测:

text
  分区数 1   消费者 1   → 耗时  526 ms
  分区数 4   消费者 1   → 耗时  491 ms
  分区数 4   消费者 4   → 耗时  128 ms   ← 并行度 = 4,提速约 4 倍 ✓
  分区数 4   消费者 8   → 耗时  132 ms   ← **加了一倍消费者,一点没快** ✗✗
  分区数 8   消费者 8   → 耗时   69 ms   ← 加速比接近 8 ✓
  分区数 8   消费者 16  → 耗时   70 ms   ← **同样毫无收益** ✗✗

★ 规律 ✓✓:

一个分区同一时刻只能给「组内一个消费者」 ✓

所以并行度上限 = 分区数 ——加到超过分区数,多出来的消费者完全空闲 ✗✗

反过来:分区数为 1 的 topic,无论加多少消费者都是串行 ✗✗

★ 由此推出两条实践规则 ✓✓:

规则说明
建 topic 时按"未来峰值并行度 × 2"定分区数 ★★★因为 分区数只能增不能减(减分区会打乱 key 的分区归属)⚠️
监控「消费 Lag」而不是「消费者数量」 ★★消费者多不代表消费得动,Lag 才是真实水位 ✓

★ 顺带一个"坑里的坑" ✗:

增大分区数会打乱「同 key 同分区」的假设 ✗ —— 因为 hash(key) % 分区数 里的除数变了,原来落 P0 的 key 可能跑到 P3 ✓

所以「加分区」不是无痛的,要在业务低峰做,并接受一段时间的顺序错乱 ⚠️

5.3 批量发送:收益来自 RTT,代价是延迟 ★★ ​

实测(各 10 万条):

text
  逐条发送 100000 条:  55 ms(181 万条/秒)
  攒批发送 100000 条:  45 ms(222 万条/秒)

★ 注意本模型里两者差距很小 ✗ ——因为这里没有网络往返 ✓

★ 真实 MQ 的批量收益来自两处 ✓✓:

来源说明
把 N 次 RTT 合并成 1 次 ★★★呼应 《时间复杂度和空间复杂度》:「RTT 比命令数更重要」✓
小消息攒成大包再压缩压缩率随体积提升 ✓

★ 它的代价 ✓:

吞吐上升,但单条延迟上升 ——因为要等"攒批时间" ✗

这就是 Kafka 的 linger.ms(等多久攒批)与 batch.size 的意义 ✓✓

★ 一条调参经验 ✓:

场景linger.ms
吞吐优先(日志、埋点)✓大一些(如 20~100 ms)
延迟优先(交易、通知)✓小一些(如 0~5 ms) ✓

5.4 四种主流 MQ 的差异 ★★ ​

实测输出里的对照表 ✓:

产品最强的地方最弱的地方典型场景
Kafka ★★★吞吐(百万级/秒)延迟不低、无优先级日志、埋点、流处理
RabbitMQ ★★路由灵活、延迟低吞吐一般业务解耦、延迟任务
RocketMQ ★★事务消息、顺序消息生态不如 Kafka电商交易链路
Pulsar ★存储计算分离、多租户运维复杂大规模多租户

★ 三条比"背参数"更有用的判断 ✓✓:

#先问什么为什么
①要不要顺序?要顺序就得用分区,还要考虑"热点 key 压单分区"(《消息可靠性、幂等与顺序》)✗
②能不能丢?不能丢就要确认 + 持久化 + 幂等消费(《消息可靠性、幂等与顺序》)✓
③团队会运维哪个?MQ 的运维成本远高于接入成本 ⚠️

5.5 引入 MQ 的代价 ★★ ​

★ 一条容易被忽略的事实 ✓✓:

引入 MQ = 引入一个必须 7x24 可用的中间件 ✓

它挂了,下单接口可能就发不出消息了 ✗

★ 所以要提前回答三个问题 ✓:

问题典型答案
MQ 挂了,业务能降级吗? ★★★本地消息表 + 定时补偿(《Seata 分布式事务》)✓
消息发重复了会怎样? ★★★消费端幂等(《消息可靠性、幂等与顺序》)✓
消息积压了怎么办? ★★扩消费者(受分区数限制)+ 临时降级

5.6 什么时候不需要 MQ ★★★ ​

★ 这一节比"怎么用"更重要 ✓✓:

需求更好的选择
只是"异步执行" ★★线程池就够了(《操作系统:进程与线程》)✓
只是"解耦"且同进程 ★★Spring 事件就够了(《设计模式在 Spring 中的应用》)✓
只是"定时任务"@Scheduled / 调度平台 ✓
只是想"削峰"但量不大限流 + 排队(《Sentinel 限流与熔断》)✓

★ 真正需要 MQ 的信号 ✓✓:

text
① 跨进程 / 跨团队 —— 生产者和消费者不在一个应用里 ✓
② 要削峰 —— 瞬时流量远超下游处理能力 ✓
③ 要重放 —— 消息要能被"重新消费"(如补数据、修 bug 后重跑)✓✓
④ 要多个消费方各自消费 —— 且互不影响 ✓

★ 四条都满足才值得上 MQ,只满足一两条要慎重 ⚠️

6. 递进示例 ​

6.1 最小示例:一个 topic 三行代码 ​

java
// 生产者:按 key 发送(同 key 会进同一分区,从而保证有序)
producer.send(new ProducerRecord<>("order.created", orderId.toString(), payload));

// 消费者:订阅并处理
consumer.subscribe(List.of("order.created"));
while (true) {
    for (ConsumerRecord<String, String> record : consumer.poll(Duration.ofMillis(500))) {
        handle(record);
    }
}

6.2 业务示例:把长链路改成"只入队" ​

java
@Transactional
public String createOrder(OrderRequest request) {
    Long orderId = orderRepository.save(request);        // 核心:落库
    // ★ 其余全部异步:发一条"订单已创建"消息,谁关心谁订阅
    mqProducer.send("order.created", orderId.toString(), buildEvent(orderId, request));
    return "下单成功";                                     // 立刻返回 ✓
}

6.3 反例:用 MQ 解决"进程内的异步" ​

java
// ✗ 同一个应用里"异步写日志"用 MQ:多了一个中间件、一次网络往返、一套运维
mqProducer.send("app.log", json);

// ✓ 用 Spring 事件 + @Async 就够了
applicationEventPublisher.publishEvent(new LogEvent(json));

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

代码:labs/distributed-lab/src/main/java/cn/kb/learn/dist/mq/ —— MiniBroker(最小消息队列:Topic / Partition / Offset / 消费组)+ MqBasicsLesson

运行:

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

实测输出(关键部分):

text
===== 1. 削峰填谷:同步处理 vs 入队后异步处理 =====
  ① 同步处理 3000 单(用户等待全过程):  905 ms ✗✗
  ② 入队(用户只等到这里):           12 ms ✓✓
     后台消费(用户不感知):         908 ms
  → 用户等待时间从 905 ms 降到 12 ms,**改善 75 倍** ✓✓

===== 3. 消费并行度:消费者数超过分区数会怎样 =====
  分区数 4   消费者 4   → 耗时  128 ms
  分区数 4   消费者 8   → 耗时  132 ms   ← 有消费者分不到分区,纯属浪费 ✗
  分区数 8   消费者 8   → 耗时   69 ms
  分区数 8   消费者 16  → 耗时   70 ms   ← 有消费者分不到分区,纯属浪费 ✗

★ 三条可落地的结论 ✓:

#结论依据
①MQ 的收益是"改善用户等待",不是"减少总工作量"入队 12 ms,后台仍要 908 ms ✓
②分区数决定并行度上限4 分区加 8 消费者毫无收益 ✗
③分区数只能增不能减,要一次规划好减分区会打乱 key 归属 ⚠️

8. Debug 观察点 ​

  1. 把"后台消费"改成同步执行:观察用户等待时间涨回去 ✓;
  2. 把分区数设为 1:观察加多少消费者都没用 ✗✗;
  3. 量一下自己系统里"一次用户请求跨了几次远程调用":超过 3~5 次就该用 MQ 异步化 ✓;
  4. 看消费者 Lag 曲线:它才是"消费得动吗"的真实指标 ✓。

9. 常见错误 ​

#错误说明
1以为 MQ 能"加快"处理 ✗✗它只是搬家(5.1)✓
2分区数定太小 ✗✗并行度封顶,扩消费者无效 ✓✓
3以为"加消费者就能提升消费速度" ✗✗上限是分区数 ✓
4上线后再改分区数 ⚠️会打乱 key 的顺序保证 ✓
5用 MQ 替代线程池/Spring 事件 ✗✗过度设计(5.6)✓
6没考虑"MQ 挂了会怎样" ✗✗缺降级方案 ✓
7把"入队成功"当"处理成功" ✗✗用户查不到订单 ✓

10. 动手实验 ​

目标:把"跨服务调用"改成"发消息",量出用户等待时间的改善。

步骤:

  1. 跑 MqBasicsLesson,确认 905 ms → 12 ms 的改善 ✓;
  2. 自己数一个真实接口的"同步远程调用次数",逐个判断"能否异步" ★★;
  3. 改分区数(1 / 4 / 8)并各跑一次消费,画出加速比曲线 ✓;
  4. 给某个同步链路加上"入队 + 后台消费",对比改造前后的接口 P99 ★★★;
  5. 回答三个问题:MQ 挂了怎么办、重复消费怎么办、积压怎么办 ✓。

验收方式:能说出"你这个系统里,哪条链路该用 MQ、为什么、分区数定几"。

扩展任务:设计一个"秒杀下单"的消息队列方案:

  • 分区数定多少?依据是什么(提示:峰值 QPS ÷ 单消费者能力,再 ×2)✓;
  • 用什么做分区 key(提示:要顺序吗?不要的话可以随机,换取更好的分布)✓;
  • 消息积压了怎么快速消化(提示:临时消费者 + 只做落库不做业务)★★

11. 练习题 ​

  1. 判断:使用消息队列可以让系统处理得更快。(对 / 错)
  2. 填空:一个 topic 有 4 个分区,消费组最多能有多少个消费者有效工作?______
  3. 简答:为什么"分区数只能增不能减"?
  4. (场景判断)
text
需求:用户注册后要发欢迎邮件、加新人积分、通知运营。
     三个动作互不依赖,且失败不影响注册。

该用 MQ 还是 Spring 事件?为什么? 5. (设计)给"订单导出"设计异步方案:导出 10 万条要 30 秒,前端要立刻返回。

参考答案(要点)
  1. 错。它把用户的等待搬到后台,总工作量不变 ✗ —— 本节的实测就是证据:入队 12 ms,后台仍要 908 ms ✓★
  2. 4 个 ✓ ——多出来的消费者分不到分区,纯属浪费 ✓
  3. 因为 key 的分区归属是 hash(key) % 分区数 ✗ —— 除数变了,原来落 P0 的 key 可能跑到 P3 → 顺序保证被打破 ✓★
  4. Spring 事件 + @Async 就够了 ✓ —— 理由:同一个应用内、不需要重放、不需要跨团队(5.6 的四条信号都不满足)✓✓
  5. 要点:前端立刻返回"任务已提交 + 任务 ID",后台异步导出 ✓; 把结果存到文件/对象存储,前端轮询或推送通知 ✓; 要点是"异步任务的状态"要能查询(否则用户不知道好没好)✓★

12. 掌握标准 ​

  • [ ] 能说出 MQ 解决的三个问题,并知道它不减少总工作量 ★★★;
  • [ ] 能解释"并行度上限 = 分区数",并知道分区数怎么定 ★★★;
  • [ ] 知道批量发送的收益(RTT)与代价(延迟) ★★★;
  • [ ] 能按三条判断选 MQ 产品 ★★;
  • [ ] 知道引入 MQ 的代价与三个必须回答的问题 ★★;
  • [ ] 知道"线程池 / Spring 事件 / MQ"的分界线 ★★★。

13. 扩展资源 ​

14. 下一步学习 ​

《RabbitMQ 核心概念与工作模式》:Exchange、Binding 与四种路由行为。

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

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