它是什么
“消息队列”这个词背后有两种设计。工作队列(SQS、经典 RabbitMQ)跟踪每条消息的状态,并在消费者确认后删除消息。分区日志(Kafka)只追加;消费者自己记住每个分区上的位置(offset),消息不会因被消费而删除,只会因保留策略而删除。这一差别决定了重放、向多个 consumer group 的扇出,以及极高吞吐,是设计本身的属性,还是后来硬加上去的功能。
Important
核心要点: 队列会删除它投递过的内容;日志会记住它追加过的内容。当消息是需要逐条重试和死信处理、且量不大的任务时,选工作队列;当吞吐、重放或多个独立消费者占主导时,选日志。
何时需要它
“为什么用 Kafka 而不是 SQS?“(或者反过来)是任何异步设计的标准追问,而”如果一个消费者需要重新处理上周的数据呢?“这个问题,一句话就能把两种模型区分开。
Note
面试信号:
把第三种选项,即把数据库表当队列用(SELECT ... FOR UPDATE SKIP LOCKED),在每秒大约一千条消息以内当作正确答案提出来,体现了判断力。这是加分项而不是错误,因为它不增加任何新的基础设施。
三种选项
| 维度 | 工作队列 | 分区日志 | 数据库表 |
|---|---|---|---|
| 吞吐上限 | 逐条状态:约每秒 1 万到 10 万 | 顺序追加:跨分区每秒数百万 | 行锁:约每秒 1K |
| 重放 | 不可能(已投递就是已删除) | 回拨 offset;免费 | 可以,但表会膨胀 |
| 多个独立的 consumer group | 每个 group 一个队列;生产者要把每条消息写 G 次 | 免费:每个 group 读同样的字节,各自有 offset | 每个 group 一个状态列 |
| 顺序 | 竞争消费者下没有 | 在一个分区内有保证 | 事务顺序,只有一个消费者 |
| 逐条重试和死信 | 原生(visibility timeout) | 在客户端逻辑里实现 | 原生(状态列) |
| Broker 的复杂度 | 高:每条消息一个状态机 | 低:追加、复制、服务 | 无,它就是你的数据库 |
日志是怎么工作的
一个 topic 被拆成多个分区,每个分区是一个有序的、只追加的消息序列,offset 递增。
发布。
- 生产者计算分区:
hash(key) mod partition_count。同一个 key 进入同一个分区,这是按 key 保序的唯一来源。 - 生产者按分区批量发送消息(等待几毫秒并压缩)。批处理是每秒一百万条小消息变得可行的原因:broker 看到的是几千次大的顺序写。
- 分区 leader 追加这一批并分配 offset;follower 拉取并追加。
- 使用
acks=all时,leader 在每一个 in-sync replica 都有这一批之后才应答。in-sync 集合就是持久性的契约。 - 生产者在超时后的重试带有序列号,所以 leader 会丢弃重复(发布侧幂等)。
消费。
- 消费者加入一个 consumer group;协调者分配分区。一个分区在一个 group 里恰好属于一个消费者,所以不会有两个 worker 在同一个 key 的消息上竞争。
- 每个消费者从自己的 offset 开始拉取(long poll 能以推送般的延迟,又没有推送的问题)。
- 处理之后,消费者提交自己的 offset。处理后提交意味着 at-least-once:在两者之间崩溃,会重新拉取这一批。
- 重放是客户端的行为:重置已提交的 offset(或按时间戳查找)并向前读。这些字节一直都在。
- 当消费者加入或死亡时,协调者会重新平衡分区。
一个 group 的全部状态就是一个 partition -> offset 的映射,这就是为什么扇出是免费的、重放也很简单。
算例:并行度上限。 一个有 4 个分区的 topic,在一个 group 里最多让 4 个消费者保持忙碌。一个 10 个消费者的 group 会有 6 个闲置。分区数是并行度的上限,在创建 topic 时就固定了,所以要按峰值消费者并行度来规划。
Caution
常见错误:
消费者落后时”加分区就行了”。改变分区数会改变 hash(key) mod N,key 会重新映射,按 key 的顺序也就在边界处被打破。要为将来的并行度提前规划好分区大小,消费者只扩到那个上限为止。
背压与 lag
日志让背压变成结构性的:消费者是拉取的,所以慢的消费者拉取得少,broker 内存里什么也不会堆积;未读的消息只是留在日志里。需要盯着的是 lag,也就是日志头部与 group 已提交 offset 之间的差距。
Warning
生产实践: Lag 有一个硬性的截止时间。如果 lag 超过了保留期,最老的消息会在被消费者读到之前就被删除,所以慢的消费者会在每个 broker 都健康的情况下悄悄地丢数据。要对 lag 占保留期的比例报警,而不是对绝对的 lag 报警。
热点分区。 hash(key) 分散的是 key,不是负载:一个很吵的 key 会集中在一个分区,把该 key 限制在单个 broker 的追加速度上。选项:把 key 加宽(user_id 变成 user_id:session_id),前提是只需要在更细的粒度上保序;把已知的热点 key 路由到专用的分区集合;或者为它预留足够的资源,因为有时一个 key 的顺序确实不能拆分。
保留与 compaction
| 模型 | 保留什么 | 服务于 |
|---|---|---|
| 按时间或大小保留 | 一个窗口内的消息(例如 7 天) | 事件流;重放受限于窗口 |
| 日志 compaction | 每个 key 的最新一条消息,永久保留 | 一个持久的 changelog:从 offset 0 重放,就能重建每个 key 的当前状态 |
被 compaction 的 topic 就是消费者 offset 本身的存储方式,也是通向”把日志当数据库”的桥梁。
如何选择
| 情形 | 选择 | 原因 |
|---|---|---|
| 需要逐条重试、visibility timeout、死信队列的作业(缩放这张图片) | 工作队列 | 逐条状态正是你想要的 |
| 事件流、多个消费者、重放、极高吞吐 | 分区日志 | 顺序 I/O,每个 group 一份 offset |
| 每秒不到约 1K 条消息,已经有数据库 | 带 SKIP LOCKED 的表 | 不增加新的基础设施 |
| 按 key 保序 | 日志,用该 key 作为分区键 | 分区内有序 |
| 从历史重建下游存储 | 带长保留期或 compaction 的日志 | 重放 |
相关
- 演练:设计分布式队列 — 这份参考所比较的 broker 的完整设计。
- 演练:设计广告点击聚合系统 — 分区日志作为大流量聚合管道的事实来源。
- 参考:队列投递语义 — 在任一模型之上的至多一次、至少一次与效果上的恰好一次。