CodeRoadMap
首页路线题库资源库文章社区
CodeRoadMap

程序员的学习成长路线图。内容免费开放,登录后可同步路线图与课时进度。

由 程序员柏风 独立维护 · 关于作者

学习

  • 路线图
  • 课程
  • 面试题
  • 文章
  • 我

更多

  • 资源
  • 社区
  • 关于作者
  • 内容说明

© 2026 CodeRoadMap

津ICP备2026012044号-1·津公网安备12011002026155号·coderoadmap@126.com
系统设计入门/课时/异步与消息
RawBlame
课时目录12 节

系统设计入门

  1. 01系统设计是什么
  2. 02性能与扩展基础
  3. 03一致性与 CAP
  4. 04负载均衡
  5. 05缓存模式
  6. 06异步与消息
  7. 07数据架构
  8. 08微服务与网关
  9. 09云设计模式
  10. 10可靠性模式
  11. 11性能反模式
  12. 12综合练习

异步与消息

pub/sub、队列、competing consumers、choreography。

35 分钟

异步与消息

这一课你将能做到

  1. 区分队列(点对点)与发布/订阅语义,说清 Kafka 的 Topic、Partition、Consumer Group 如何协作
  2. 为本站判题链路做削峰计算:峰值积压多少条、需要多少分区和消费者
  3. 写出至少一次投递下的幂等消费者代码(手动 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。

讲清楚要点

  1. 消息队列的三大收益:解耦(判题慢不拖垮 API)、削峰(洪峰进队列排队)、异步化(旁路任务不占主路径)。反过来代价是:延迟从毫秒变秒级、链路变难追踪、必须处理重复和乱序。
  2. 投递语义默认至少一次(at-least-once),所以幂等不是可选项:生产端开 idempotence、消费端用状态机/唯一键兜底。精确一次(exactly-once)只在 Kafka 事务内成立,跨系统仍要幂等。
  3. 分区是并行度的最小单位:消费者数 > 分区数时多出的消费者空转;要提升吞吐先加分区再加消费者。同一 key 落同一分区是「局部有序」的全部来源。
  4. 顺序性只承诺分区内:全局有序要单分区(牺牲并行)。判题场景用状态机幂等化解乱序问题,比强行全局有序便宜得多。
  5. 监控只盯 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,判题优先级最高

卡点急救

  1. lag 持续增长:先看消费速率是否掉了(下游 DB 慢、沙箱机器 CPU 满),再看分区数是否到了瓶颈;扩容顺序是「查卡点 → 扩消费者实例 → 加分区」,反过来做无效。
  2. 同一提交被判了两次:查三处——消费端有没有状态机幂等(markJudging 返回 0 应跳过)、生产端有没有因超时重发、消费者再平衡窗口内有没有重复处理。治标用幂等兜底,治本核对 offset 提交时机。
  3. 再平衡频繁导致消费抖动:通常是消费者处理太慢被踢出组(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 端重复。能画出「先标记后处理」的时序图最佳。

自测三问

  1. 500 QPS 持续 60 秒、判题能力 100 QPS,最大积压多少条?多久消化完?
  2. 分区数如何推导?为什么分区只能增加不能减少?
  3. 消费者的「先提交 offset 再处理」和「先处理再提交」分别有什么风险?本课选哪种、靠什么兜底?

小结一句话

异步把峰值与慢依赖挡在 API 之外——选对队列语义(至少一次),分区数按吞吐推导,消费端默认做幂等,lag 是唯一的健康真理。

延伸

  • Apache Kafka 官方文档 — 设计与语义:投递语义与副本机制权威出处
  • Spring for Apache Kafka 参考文档:本课 @KafkaListener 配置细节
  • 《Designing Data-Intensive Applications》第 11 章:流处理的系统级论述
  • 下一课:数据架构——提交表 10 亿行怎么分片、排行榜怎么预聚合、CQRS 何时登场。
加载本节练习…
← 上一课缓存模式下一课 →数据架构