Skip to main content

参考:队列与日志

一份对比工作队列、分区日志与数据库表的参考——吞吐、重放、扇出、顺序、consumer lag 与保留策略——以及各自在什么时候是对的选择。

它是什么

“消息队列”这个词背后有两种设计。工作队列(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 递增。

发布。

  1. 生产者计算分区:hash(key) mod partition_count。同一个 key 进入同一个分区,这是按 key 保序的唯一来源。
  2. 生产者按分区批量发送消息(等待几毫秒并压缩)。批处理是每秒一百万条小消息变得可行的原因:broker 看到的是几千次大的顺序写。
  3. 分区 leader 追加这一批并分配 offset;follower 拉取并追加。
  4. 使用 acks=all 时,leader 在每一个 in-sync replica 都有这一批之后才应答。in-sync 集合就是持久性的契约。
  5. 生产者在超时后的重试带有序列号,所以 leader 会丢弃重复(发布侧幂等)。

消费。

  1. 消费者加入一个 consumer group;协调者分配分区。一个分区在一个 group 里恰好属于一个消费者,所以不会有两个 worker 在同一个 key 的消息上竞争。
  2. 每个消费者从自己的 offset 开始拉取(long poll 能以推送般的延迟,又没有推送的问题)。
  3. 处理之后,消费者提交自己的 offset。处理后提交意味着 at-least-once:在两者之间崩溃,会重新拉取这一批。
  4. 重放是客户端的行为:重置已提交的 offset(或按时间戳查找)并向前读。这些字节一直都在。
  5. 当消费者加入或死亡时,协调者会重新平衡分区。

一个 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 的日志重放

相关