「做完了」的两种说法
Mainline 的分析流水线用 franz-go 消费 Kafka。这个库给消费者两种汇报进度的方式,而它们之间的差别,就是「至少一次投递」和「静默扔掉几行」之间的差别。
便宜的那种是提交 poll 水位:也就是消费者最后取到的那个 offset。它只要几行代码、不需要任何簿记,也是你第一次写的时候自然会写的东西。但它是错的,而且错得没有任何报错,因为水位每次 poll 都会前进,从不回退。
设想一批一起 poll 下来的三条记录——A、B、C。B 的 handler 遇到一个瞬时失败。消费者重试 B,这次成功了,于是提交。它提交的是水位,而水位此刻已经越过了 C——于是 C 被标记成已处理,尽管它根本没有被处理过。如果 C 属于某个没人注意到的分区,这一行就单纯没了:没有异常、没有指标、没有 lag。我们三个 worker——一个 enricher、一个 person merger、一个 crash ingest——各自都带着这个模式,也各自一直在安静地错。
把游标交给一个 owner
修法不是在每个调用点写得更小心,而是删掉那些调用点。Foundation/Analytics/Go/mq.Resolver 现在拥有一条不变式,就写在它自己的文档注释里:
消费游标绝不越过一条未解决的 record。
每个消费者持有一个 Resolver,Resolve 接收一次 poll 的记录,返回其中可以安全提交的子集。所有消费者把各自的 handler 接进去,水位 API 从代码库里彻底消失。
真正决定这条不变式成不成立的,是寿命。Resolver 必须活过发现问题的那次 poll。 一个每轮重建阻塞集的实现看上去是对的,而且不是:第 N 轮拒绝提交越过记录 X,第 N+1 轮从一个空阻塞集开始,把 X 以及它之后的一切照常处理,然后提交水位——越过 X。丢失不在任何一轮内部发生,那正是守卫看得见的地方;它发生在两轮之间,那里没有任何东西在看。
三种结局,而关机不是其中之一
解决必须区分的不只是成功和失败,因为把「我们放弃了」并进「这条失败了」,会丢掉那个决定管道该停还是该继续的区分:
const (
settled outcome = iota // 已妥善处理,或永久失败且已停放到别处
unsettled // 退避预算耗尽
canceled // context 取消
)
canceled 单独立一个结局,理由值得直说:关机不是失败。 一条被滚动发布遗留下来的 record 不是一条失败的 record;为它阻塞分区直到有人看见,等于把每次发布都变成一次事故。取消只截断这一批、什么也不阻塞;下次启动时这条 record 会从 committed offset 重新读出来,正如它本该如此。
截断是按分区的。毒丸 record 是一个分区级的事实——一行解析不了的数据,只该停住它所在的那个分区,一并停住别的才是问题。扁平截断会把交错在它后面的健康分区也一起停掉,而那些分区的 committed offset 随之停止前进,卡死监视就会把它们全报成卡死。一条坏数据,一个点名了四个受害者的告警。
「无限重试」原来根本实现不了
重试策略看着就是个普通结构体,直到你问一句:如果一条 record 永远不可能成功,会怎么样。
type RetryPolicy struct {
BaseBackoff time.Duration // 首次退避,其后翻倍到 MaxBackoff
MaxBackoff time.Duration
MaxElapsed time.Duration // 单条 record 的退避总预算;必须 > 0
}
MaxElapsed 的零值过去表示「一直重试」。现在它会在归一化时直接 panic,而解释原因的注释比字段本身还长:
Resolve 是在消费 goroutine 里同步调用的。无限重试意味着它永不返回,于是消费循环的其余部分——能发现新数据的 poll、能发现这个分区不再前进的监视器——全部执行不到。盲区正好合拢在那个本该盯着它的东西上:进程存活、健康检查全绿、没有任何告警、一条永远 resolve 不了的 record。
具体损失花了好几个月才被发现。我们的安装归因 topic 从 2026-06-19 起停止推进,并且一直停着:一条已经老到广告服务不再记得的安装反复失败,每次重试循环又把分区焊死一次。系统里没有任何东西能报出这件事,因为能报的那部分就住在那个已经停掉的循环后面。
同一个结构体里还带着两个小决定。预算按未抖动的退避阶梯累计,于是你能重试几次只由策略决定,而不由某个下游当下有多慢决定——抖动的用途是让一组副本在同一个下游故障上不要同步成惊群,让它去影响重试次数,等于把「什么时候放弃」变成一个随机数。而且阻塞并不等于丢:分区会停到进程重启,然后从 committed offset 重读那条未解决的 record。
「重试到没戏了」不是一个意思
最后这一块,是把卡死的管道变回健康管道的那块。预算耗尽时,「再试也没意义」在不同消费者那里是不同的意思:
// ExhaustedSettles 决定预算耗尽意味着什么。true = 调用方已把这条 record 停放到
// 丢不掉的地方,于是视同已处理,让游标继续前进。false 或 nil = 阻塞该分区(默认)。
ExhaustedSettles func(rec *kgo.Record, err error) bool
阻塞是正确的默认值,留在那里不是偷懒。丢掉一份 crash 上报就是丢掉一份证据——crash 正是你搭这条管道要留住的东西。但一次返回 404 的广告归因查询根本不是失败:它是对「这次安装有没有来自广告」这个问题给出的正确且终局的答案——没有。为一条内容其实是「什么都没发生」的行把整条管道扣做人质,比丢掉这一行严格更糟。
默认仍然是阻塞,只有能说清这条 record 去了哪里的调用方才允许退出。回答「直接丢掉吧」的函数不在 API 里,因为「直接丢掉」正是这个包要防的那种静默丢失。
然后把修复写法本身禁掉
一条需要每个工程师都记得住的不变式,终归会烂掉。resolver 存在之后,水位 API 已经没有正当调用者了——所以有一道守卫按名字禁掉它们:
id = "analytics-commit-granularity"
risk = "critical"
paths = ["Analytics/**/*.go", "Foundation/Analytics/**/*.go"]
forbid = [
"*uncommittedoffsets*",
"*commitmarkedoffsets*",
"*markcommitrecords*",
"*.commitoffsets(*",
"*.commitoffsetssync(*",
]
checks = ["guard:go"]
glob 写成带点号的形式而不是裸的,是刻意的。裸的 *commitoffsets* 也会命中进度监视器的 ObserveCommit… 辅助函数,把那些根本没碰过水位的文件一并标记;钉住调用形状,把影响面收回到那五个真正就是 bug 的 API 条目上。(因为匹配是针对整行新增内容做的、注释和字符串也算在内,所以谈论这些方法的散文必须绕着说——这是「一条 agent 没法用话术绕过去的规则」所付的小价钱。)
小结
如果你的消费库提供了「提交我取到的位置」这种便利,先弄清它的代价再决定要不要用:这项便利恰恰是让丢失变得不可见的原因,因为不管活干了没有,offset 都会动。把游标交给一个 owner,并让这个 owner 的寿命长于单次 poll——真正在漏的那道口子位于两轮之间,不在一轮之内。
要让「我们放弃了」拥有不止一种含义,并强迫调用方指明适用哪一种。重试预算应当按策略度量而不是按墙钟,否则下游延迟会悄悄变成你正确性论证的一部分。而当你发现某个选项从来就不可实现时——同步重试循环里的「无限」就是常驻例子——删掉它,并让编译器或构造函数自己说出来,因为一个能表达出不可能状态的配置值,就是等着有人把它敲进去的一次事故。
最后:一条不需要谁记得住的不变式才能活下来。把被禁的 API 写上名字,就是防止下一个找到捷径的人把修复又拆掉的唯一办法。