一个消费服务重启后,业务方发现同一批订单被处理了两次。查下来消费位点是每处理一批提交一次,而提交发生在业务写入之后、偏移提交之前——中间进程被重启,位点没提交,于是这批消息又被消费了一遍。
位点管理看起来只是"存一个数字",实际上它是消息系统与业务状态之间唯一的一致性依据。存错位置、存错时机、存错粒度,任何一种都会让"只处理一次"这个承诺失效。
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. 自测
- 位点与水位分别表达什么?两者之差为什么就是风险窗口?
- 处理前提交与处理后提交各自承诺什么?为什么前者在生产里几乎不可用?
- 提交粒度应按什么定?为什么不该按吞吐定?
- 位点存外部存储的价值是什么?它需要什么前提才能成立?
- 重平衡时"提交前被踢出组"会造成什么?根因通常是哪个参数?
- 位点正常推进而业务数据缺失,最典型的代码形态是什么?