大家好,我是程序员柏风,AI 最全的 Vibecoding 学习地图 http://coderoadmap.cn 作者。
异步与消息
这一课你将能做到
- 区分队列(点对点)与发布/订阅语义,说清 Kafka 的 Topic、Partition、Consumer Group 如何协作
- 为本站判题链路做削峰计算:峰值积压多少条、需要多少分区和消费者
- 写出至少一次投递下的幂等消费者代码(手动 ack + 唯一键 + 重试 + 死信队列)
为什么要学
同步调用链一长,延迟和故障就互相传染:判题要 10 秒,如果提交接口同步等判题,高峰期线程池瞬间被占满,连题目列表页都打不开。消息队列把「必须立刻完成」改成「最终完成」,是系统设计里最常用的解耦、削峰、异步化三合一工具。
面试必考:「为什么用消息队列?」「消息丢了怎么办?重复了怎么办?」「怎么保证顺序?」「积压了怎么处理?」——这四问就是本课的骨架。
人话:同步像是去食堂打饭非要盯着师傅做完你的菜才走;异步像是拿号坐下等叫号。Kafka 就是大屏幕取号系统——后厨(消费者)按自己速度出餐,高峰期单子排队(削峰),且同一张单子只给一个窗口(消费者组内竞争消费)。
先建立图像
判题链路的消息化设计:
提交 API 消费者组 judge-worker
│ ① 写 MySQL (status=PENDING) ┌─────────────┐
│ ② 发 Kafka │ JudgeWorker-1│──┐
├──────────────▶ Topic: judge-task│ JudgeWorker-2│──┤ Docker 沙箱判题
│ partition 0..23 │ JudgeWorker-N│──┘
│ ③ 立即返回「排队中」 └─────────────┘
▼ │ 判完写回 MySQL
用户轮询 GET /submissions/{id} ◀─────────┘ (或 WebSocket 推送)
Kafka 三个核心概念一句话:
Topic:消息的逻辑分类(judge-task)
Partition:Topic 的物理分片,每个分区内有序、是并行度的最小单位
Consumer Group:组内每条消息只被一个消费者处理(竞争),跨组全量广播(发布/订阅)
削峰算一笔账(这是面试的量化加分项):
比赛开始瞬间:提交 500 QPS,持续 60 秒 → 3 万条消息涌入
判题能力:20 个 worker × 5 QPS = 100 QPS
积压峰值:500×60 − 100×60 = 2.4 万条
消化时间:24000 ÷ 100 = 240 秒 ≈ 4 分钟排队上限
产品对策:前端展示「当前排队第 N 位」(N = Kafka lag),体验可控
队列深度可控 + 消费能力明确 = 削峰的本质是把不可控的瞬时压力变成可观测的排队延迟。
跟着做
步骤 1:建 Topic 并理解分区数怎么定
# 24 分区、3 副本
kafka-topics.sh --create --topic judge-task \
--partitions 24 --replication-factor 3 \
--bootstrap-server kafka-1:9092
# 观察消费延迟(lag)——判题队列最重要的监控指标
kafka-consumer-groups.sh --bootstrap-server kafka-1:9092 \
--describe --group judge-worker
分区数推导:消费总能力 = 分区数 × 单分区消费速率。目标 100 QPS 判题、单 worker 5 QPS → 20 个消费者 → 至少 20 分区,留扩容余量取 24。分区数只能增不能减,起步宁多勿少。
步骤 2:生产者——提交接口发消息
spring:
kafka:
bootstrap-servers: kafka-1:9092,kafka-2:9092,kafka-3:9092
producer:
acks: all # 全部同步副本确认,防丢
properties:
enable.idempotence: true # 生产者幂等,防 broker 端重复
retries: 10
key-serializer: org.apache.kafka.common.serialization.StringSerializer
value-serializer: org.apache.kafka.common.serialization.StringSerializer
// key 用 submissionId:同一提交永远落同一分区 → 分区内天然有序
kafkaTemplate.send("judge-task", String.valueOf(submissionId), toJson(task));
步骤 3:消费者——手动 ack 与幂等消费
@KafkaListener(topics = "judge-task", groupId = "judge-worker",
concurrency = "5") // 单实例 5 线程,对应 5 个分区
public void onTask(ConsumerRecord<String, String> record, Acknowledgment ack) {
String submissionId = record.key();
try {
// 幂等闸门:状态机判断,PENDING 才判,重复消息直接跳过
int updated = submissionMapper.markJudging(Long.valueOf(submissionId));
if (updated == 0) { // 已是 JUDGING/DONE,说明处理过
ack.acknowledge();
return;
}
judgeService.execute(Long.valueOf(submissionId)); // Docker 沙箱判题
ack.acknowledge(); // 判完且落库成功才提交 offset
} catch (Exception e) {
log.error("judge task failed, offset={}", record.offset(), e);
ack.acknowledge(); // 放行进入重试/死信逻辑
deadLetterService.retryOrDlq(record, e); // 最多重试 3 次,之后进 DLQ
}
}
三个关键点:markJudging 的状态机更新(UPDATE ... SET status='JUDGING' WHERE id=? AND status='PENDING' 返回影响行数)是幂等的;手动 ack 保证「判完才确认」;失败走重试 + 死信队列(DLQ)而不是无限重试堵塞分区。
步骤 4:编排 vs 编舞的选型
判题完成后有三件衍生事:更新排行榜、发通过通知、更新题目通过率统计。
编舞(Choreography):judge-task 结果事件发到 judge-result topic,
排行榜服务、通知服务、统计服务各订各的,互不相知
编排(Orchestration):一个 Saga 编排器按步骤调三方,失败时按序补偿
本站选编舞:事件少、顺序无依赖、团队小。判断标准——流程超过 3 步且需要中间态回滚时才考虑编排,中小系统先编舞 + 严格的事件 schema。
讲清楚要点
- 消息队列的三大收益:解耦(判题慢不拖垮 API)、削峰(洪峰进队列排队)、异步化(旁路任务不占主路径)。反过来代价是:延迟从毫秒变秒级、链路变难追踪、必须处理重复和乱序。
- 投递语义默认至少一次(at-least-once),所以幂等不是可选项:生产端开 idempotence、消费端用状态机/唯一键兜底。精确一次(exactly-once)只在 Kafka 事务内成立,跨系统仍要幂等。
- 分区是并行度的最小单位:消费者数 > 分区数时多出的消费者空转;要提升吞吐先加分区再加消费者。同一 key 落同一分区是「局部有序」的全部来源。
- 顺序性只承诺分区内:全局有序要单分区(牺牲并行)。判题场景用状态机幂等化解乱序问题,比强行全局有序便宜得多。
- 监控只盯 lag 就够入门:lag = 未消费消息数。lag 持续增长说明消费能力 < 生产能力,扩消费者或查消费者卡点(通常是下游 DB 慢)。
你可能会懵的地方
-
为什么不用 Redis 的 List/Stream 做队列就行? 小流量完全可以(LPUSH/BRPOP)。但 Redis 缺持久化的重放能力、没有消费者组再平衡、消息堆积受内存限制。判题这种「不能丢、要回放、要水平扩」的场景选 Kafka;通知提醒这类丢了无所谓的用 Redis Stream 更省事。
-
消息顺序怎么保证? 两问反问自己:谁需要顺序?判题结果处理按 submissionId 分区即可(同一提交的消息有序)。全局顺序是伪需求,成本是单分区零并行。面试答「按业务 key 分区 + 分区内有序 + 消费端幂等」。
-
消费者挂了,消息会丢吗? 不会丢,会再平衡:该消费者的分区被组内其他实例接管,从上次提交的 offset 继续。风险在 offset 提交时机——先提交后处理会丢消息,先处理后提交会重复(所以我们选重复 + 幂等)。
-
消息积压几百万条怎么救火? 分四步:① 定位是生产暴增还是消费变慢(看 lag 曲线与消费速率);② 消费变慢 → 查下游(DB 慢查询/外部接口超时);③ 生产暴增 → 临时扩消费者(不能超过分区数,不够就加分区)+ 旁路消息降级;④ 历史积压可跳过时,重置 offset 到最新 + 离线补数。
-
事务消息/本地消息表什么时候需要? 「写 DB 和发消息必须同成败」的场景(提交记录必须入库且必须进判题队列)。本地消息表最实用:同事务写 business 表 + 消息表,定时任务扫描消息表补偿发送,避免分布式事务。Kafka 事务只保证 Kafka 内部原子性,跨 DB 仍要本地消息表。
错法对照
| 错法 | 问题 | 正解 |
|---|---|---|
| 提交接口同步等判题完成 | 判题 10s 占死线程池,全站拖垮 | 发 Kafka 立即返回,前端轮询/WebSocket |
| 自动提交 offset | 处理到一半宕机 = 丢消息 | 手动 ack,处理成功才提交 |
| 消费失败无限重试 | 一条毒消息卡死整个分区 | 有限重试(3 次)+ 死信队列 + 告警 |
| 消费者数远大于分区数 | 多余消费者空转浪费资源 | 分区数 ≥ 目标吞吐/单消费者速率 |
| 旁路通知和判题共用一个 topic | 通知洪峰堵塞判题主链路 | 按 SLA 分 topic,判题优先级最高 |
卡点急救
- lag 持续增长:先看消费速率是否掉了(下游 DB 慢、沙箱机器 CPU 满),再看分区数是否到了瓶颈;扩容顺序是「查卡点 → 扩消费者实例 → 加分区」,反过来做无效。
- 同一提交被判了两次:查三处——消费端有没有状态机幂等(
markJudging返回 0 应跳过)、生产端有没有因超时重发、消费者再平衡窗口内有没有重复处理。治标用幂等兜底,治本核对 offset 提交时机。 - 再平衡频繁导致消费抖动:通常是消费者处理太慢被踢出组(
max.poll.interval.ms超时)。调大该值、减小单次max.poll.records、或把耗时判题拆成「消费领任务 + 线程池异步执行」两段。
面试常问
-
为什么判题平台要用消息队列?不用会怎样? 答三层:① 削峰——比赛瞬间 500 QPS 提交,判题能力 100 QPS,队列把洪峰变成最长 4 分钟的可见排队;② 解耦——判题机独立扩容,API 永不等判题;③ 异步化——排行榜/通知等旁路与主链路隔离。再补代价(秒级延迟、幂等要求、lag 监控)体现全面性。
-
如何保证消息不丢? 全链路作答:生产端
acks=all+ 幂等 + 重试;broker 端副本 ≥3 +min.insync.replicas=2;消费端手动 ack、处理完成才提交 offset。最后补一句「跨系统最终一致用本地消息表兜底」。 -
如何保证消息不重复消费(或重复了也没事)? 正确姿势是承认 at-least-once、把功夫花在消费端幂等:状态机更新(WHERE status='PENDING')、唯一键约束、Redis 去重表(SETNX message-id)。生产端开启 idempotence 减少 broker 端重复。能画出「先标记后处理」的时序图最佳。
自测三问
- 500 QPS 持续 60 秒、判题能力 100 QPS,最大积压多少条?多久消化完?
- 分区数如何推导?为什么分区只能增加不能减少?
- 消费者的「先提交 offset 再处理」和「先处理再提交」分别有什么风险?本课选哪种、靠什么兜底?
小结一句话
异步把峰值与慢依赖挡在 API 之外——选对队列语义(至少一次),分区数按吞吐推导,消费端默认做幂等,lag 是唯一的健康真理。
延伸
- Apache Kafka 官方文档 — 设计与语义:投递语义与副本机制权威出处
- Spring for Apache Kafka 参考文档:本课 @KafkaListener 配置细节
- 《Designing Data-Intensive Applications》第 11 章:流处理的系统级论述
- 下一课:数据架构——提交表 10 亿行怎么分片、排行榜怎么预聚合、CQRS 何时登场。