2496 字
约 8 分钟
20
S17-A21 · 消费位点与水位:重启之后从哪里继续

一个消费服务重启后,业务方发现同一批订单被处理了两次。查下来消费位点是每处理一批提交一次,而提交发生在业务写入之后、偏移提交之前——中间进程被重启,位点没提交,于是这批消息又被消费了一遍。

位点管理看起来只是"存一个数字",实际上它是消息系统与业务状态之间唯一的一致性依据。存错位置、存错时机、存错粒度,任何一种都会让"只处理一次"这个承诺失效。


1. 位点、水位与提交点

三个词经常被混用,含义不同。

概念 含义 谁维护
消费位点(offset) 下一个要读的位置 消费者提交
水位(watermark) 已确认完成的最大位置 业务或框架
提交点(commit) 已持久化的位点 存储(Broker 或外部)

差别在语义:位点表达"我读到哪",水位表达"我处理完到哪"。只提交位点不维护水位,就无法区分"读到了"和"处理好了"——而这两者的差正是重复处理的来源。

// 三个位置的关系,差值就是风险窗口
已提交位点 ---- 水位(已处理完) ---- 已读取位置
      ↑                ↑                  ↑
   重启从这里开始   业务真正完成处      可能已在内存里
// 位点 < 水位 → 重复消费;位点 > 水位 → 丢消息

2. 提交时机的三种选择

时机 语义 后果
处理前提交 at-most-once 崩溃则丢消息
处理后提交 at-least-once 崩溃则重复
与业务写同事务 effectively-once 需要支持事务或幂等

这三种选择有个常被忽略的前提:"处理"的边界在哪里。如果一条消息触发的是三步操作(校验、写库、发通知),那么"处理完成"指三步都完成,还是第一步完成就算?边界定义不清,位点提交得再正确也没有意义。

第二种是绝大多数系统的实际选择,因为它性能好、实现简单。它的代价是必须接受重复,并把去重责任交给业务——这意味着幂等不是可选项,而是这套方案的一部分。

可以给提交时机画一条明确的判定线:

// 提交位置的判定:问"这一刻崩溃,业务能接受什么"
在处理前提交 → 崩溃后这条消息再也读不到     (丢)
在处理后提交 → 崩溃后这条消息会被再读一次   (重)
与业务同事务 → 崩溃后要么都在、要么都不在   (一致,成本最高)

第三种在消息系统支持事务时可行(如 Kafka 的事务性 producer 与 consumer),但它要求业务写入也在同一个事务边界内,跨库时不成立。

3. 提交粒度:批量与逐条

粒度 吞吐 重复窗口 适用
每条提交 最低 最小 极低频重要消息
每 N 条 中 中(N 条) 常规
每批时间窗口 高 窗口内全部 高吞吐
手动提交(业务决定) 视实现 可控 需要精确语义

第三种的重复窗口是"整个窗口",一次崩溃可能重复处理上千条。所以窗口大小要按"重复处理的代价"定,而不是按吞吐定——如果重复处理一条消息的代价很高,窗口就必须小。

// 批量提交:把重复窗口显式声明在代码里,而不是隐含在配置里
props.put("enable.auto.commit", "false");        // 关掉自动提交,自己控制
props.put("max.poll.records", "200");            // 单批上限 = 最大重复窗口

while (running) {
    var records = consumer.poll(Duration.ofMillis(500));
    handle(records);                              // 业务处理(必须幂等)
    consumer.commitSync();                        // 处理完才提交
}
// 崩溃时最多重复 200 条;业务侧的去重键必须覆盖这 200 条

4. 位点存哪里

位置 一致性 迁移成本
Broker 内部(Kafka 默认) 与分区绑定 换集群要迁移位点
外部存储(DB/Redis) 可与业务写同事务 需要自己维护
消费者本地 无 重启即丢

第一种最省事,第二种能实现"位点与业务数据同事务",第三种不可用于生产。

第二种的价值在需要精确一次时体现:把位点和业务写入放在同一个数据库事务里,要么都成功要么都失败,重复窗口归零。

-- 位点与业务写同事务:重复窗口为零
BEGIN;
INSERT INTO order_ledger (msg_id, order_id, amount) VALUES (?, ?, ?)
  ON DUPLICATE KEY UPDATE msg_id = msg_id;      -- 幂等兜底
UPDATE consumer_offset SET next_offset = ? WHERE partition = ? AND group_id = ?;
COMMIT;
-- 事务失败则位点不前进,下次重新消费;幂等键保证不会重复入账

5. 重平衡与位点的关系

消费者组发生重平衡时,分区会换主人,位点交接是这一过程最容易出问题的地方。

情形 风险 缓解
提交前被踢出组 新消费者从旧位点开始,重复 缩短处理时间或调大 max.poll.interval
提交后立即重平衡 新消费者从新位点开始,正常 无
两个消费者同时认为拥有分区 并发处理同一分区 用 ConsumerRebalanceListener 处理撤销
位点提交到错误分区 数据错位 提交时显式指定分区

重平衡还有一个不常被提到的成本:分区交接期间这个分区停止消费。分区越多、成员越多,一次重平衡期间的停顿越明显。频繁重平衡的系统看起来"没有故障",但吞吐上不去——因为大量时间花在交接上。

第一种最常见,根因是单批处理时间超过了 max.poll.interval.ms。表现是"每隔一段时间就被踢一次,然后重复消费最后一批"。

// 重平衡回调:在撤销分区时把已处理的位点提交掉,减少重复
consumer.subscribe(topics, new ConsumerRebalanceListener() {
    public void onPartitionsRevoked(Collection<TopicPartition> parts) {
        consumer.commitSync();           // 撤销前提交,缩小重复窗口
    }
    public void onPartitionsAssigned(Collection<TopicPartition> parts) { }
});

6. 排错:五种"位点看着对但行为不对"

现象 成因 处理方向
重启后重复处理一批 处理完未提交就崩溃 幂等 + 缩小提交粒度
消息丢失 处理前提交了位点 改为处理后提交
周期性重复消费 被踢出组后从旧位点重来 调大 max.poll.interval
位点正常推进但业务数据缺 业务写入失败被吞,位点仍前进 位点前进必须以业务成功为条件
多实例处理同一分区 重平衡回调未处理撤销 加 onPartitionsRevoked 提交

把它写成代码对比会更直观:

// 反例:异常被吞,位点照常前进 → 消息永久丢失
try {
    handle(records);
} catch (Exception e) {
    log.error("处理失败", e);          // 只记日志
}
consumer.commitSync();                 // 无条件提交

// 正例:失败则不提交,让这批消息重新被消费(配合幂等与重试上限)
try {
    handle(records);
    consumer.commitSync();
} catch (Exception e) {
    metrics.counter("consume.failed").increment();
    consumer.seek(partition, lastCommitted);   // 回到已确认位置重新处理
}

第四项最难发现:代码里 try { handle(); } catch (Exception e) { log.error(...) } 之后照样提交,位点一路前进,而失败的那批消息再也不会被读到。这类问题的症状是"数据少了一点",而不是"报错"。

7. 边界:什么情况可以简化位点管理

场景 为什么 做法
消息可完全重放且无副作用 重复与丢失都无害 自动提交即可
消费端天然幂等(按主键覆盖写) 重复无影响 放宽提交粒度
只做实时统计,允许误差 语义要求低 定时提交
单消费者、无重平衡 无交接问题 简化回调

还有一层运维侧的边界:位点重置是高风险操作。把位点回退到很早的位置,等于要求下游能吃下一整批重放;把位点前移,等于主动丢弃消息。这两个动作都应当在变更流程里,而不是在故障现场临时决定。

判断口径一句话:这位点错了,代价是重复还是丢失。两者都能接受,位点管理可以交给框架;任一不能接受,就必须自己控制提交时机与幂等。


8. 自测

  1. 位点与水位分别表达什么?两者之差为什么就是风险窗口?
  2. 处理前提交与处理后提交各自承诺什么?为什么前者在生产里几乎不可用?
  3. 提交粒度应按什么定?为什么不该按吞吐定?
  4. 位点存外部存储的价值是什么?它需要什么前提才能成立?
  5. 重平衡时"提交前被踢出组"会造成什么?根因通常是哪个参数?
  6. 位点正常推进而业务数据缺失,最典型的代码形态是什么?
S17-A21 · 消费位点与水位:重启之后从哪里继续
https://www.clxhxhhr.top/posts/6401/
作者
clxstart
发布于
2024-12-31
许可协议
CC BY-NC-SA 4.0
评论
0 条
还没有评论,先写一条吧。