漏读分页游标,让客户巡检空转23天
客户巡检任务只请求前100条记录,未处理接口返回的 next_cursor,导致其余4312名客户连续23天未被检查。日报仍将局部扫描结果报告为正常,掩盖了覆盖范围缺失。
AI 深度解读
一个夜间任务连续 23 天报告「检查了 100 条客户记录,零问题」,实际却一直只读第一页,把另外 4,312 名客户留在检查范围之外。作者的教训很具体:请求成功、没有报错、结果为空,都不能证明任务做完了。
这篇案例值得读,不在于分页有多难,而在于它暴露了定时任务和 Agent 的共同盲点:执行程序自己报喜,监控照单全收。要发现这种静默失败,得先定义「完成」,再拿独立证据核对。
为什么每天检查 100 条,反而应该报警
任务原本要遍历客户表,找出订阅已失效、状态仍为 active 的客户,再把更新银行卡的邮件放进队列。作者当时的实现如下:
def audit_customers():
resp = requests.get(
"https://api.internal.example/v1/customers",
params={"limit": 100},
headers={"Authorization": f"Bearer {TOKEN}"},
timeout=30,
)
resp.raise_for_status()
rows = resp.json()["data"]
stale = [r for r in rows if r["status"] == "active" and r["renewed_at"] is None]
for r in stale:
queue_email(r["email"], template="card_update")
return {"checked": len(rows), "queued": len(stale)}
要看的是 params={"limit": 100}:它限制了单次读取量,却没有配套的翻页逻辑。API 返回了 next_cursor,代码根本没读。
表里共有 4,412 条记录,任务每天重复处理同一批 100 条,其余 4,312 条没有进入审计。这里要区分:原文并没有证明这 4,312 人都应该收到邮件,只能确认他们没有被检查。
日报直接根据返回值生成「检查 100 条,零问题,一切正常」。数字是真的,结论却越过了证据。
「读了多少条」是测量值,「有没有读完」才是完成条件。
作者用了 11 分钟修复,却花了 23 天才发现。缺的不是错误日志,而是一条能让任务失败的判断:实际覆盖范围是否符合预期。
怎样让任务证明自己已经读完
作者补了三层检查。
第一层是游标分页。新的 iter_customers(page_size=100, max_pages=200) 每页读取 data,再取 next_cursor;有游标就继续,没有才正常结束。请求保留 timeout=30,后续请求带上 cursor 参数。
max_pages=200 是硬上限。达到上限仍没走到末尾,就抛出 RuntimeError。上限能防止任务无限跑,但不能成为静默截断数据的理由。
第二层是独立计数,用数据库里的数量检查 API 读取结果:
rows = list(iter_customers())
expected = db.scalar("SELECT count(*) FROM customers WHERE status='active'")
assert len(rows) >= expected, f"read {len(rows)} rows, table says {expected}"
要看最后一行:比较的是读取数量与独立查询结果,而不是继续相信任务自己返回的 checked。
作者承认这是近似检查。任务运行时数据会变,API 的过滤条件也可能与 SQL 不同。它针对的是「应有四千多条,却只读了一百条」这种巨大缺口。
第三层是修改日报。新的示例包含 4,412/4,412 条 active 记录、游标已结束、运行时间 02:14–02:19 UTC,以及入队邮件数 61。覆盖量和业务结果终于分开了:零封邮件可能正常,只读一页却未必正常。
只有分页器真正走到末尾,日报才能写完成;否则标记为 PARTIAL,任务以非零状态退出。作者还把摘要检查从执行函数中拆出,由另一个检查查询水位表(watermark table)并比较结果。不过,原文没有给出这张表的结构及具体校验逻辑。
为什么客户反馈应该先推翻绿色日报
第 23 天,一名客户反馈,自己已经连续三周收到支付失败通知。作者先怀疑服务提供商,依次检查邮件队列、模板和发送日志,花了 40 分钟才回头审视扫描任务。
这些下游环节可以全部正常。客户没有进入待处理名单,发送日志自然也不会记录相应的发送失败。
作者此前在 9 月的一次监控故障后,就写过「客户报告优先于自动报告」的规则,但绿色面板仍然影响了排查顺序。
这次他把规则改成操作步骤:客户反馈与日报冲突时,先怀疑日报,并把获取原始记录数的命令放进排障手册第一步。原文没有提供这条命令,不能照抄复现,但思路清楚:让排查入口直接触达底层数据。
重复计数也是一种廉价线索。不过,每天相同不自动等于故障。作者自己也承认,日报连续一周逐字相同,既可能业务稳定,也可能任务卡住,单看日志分不出来。
我的判断
这篇最有价值的地方,是把「完成」从一句日志变成可验证的条件。对定时任务、后台批处理和 Agent,这比调整日报措辞更有用。
但作者提出的目标是「每条符合条件的记录恰好处理一次」,展示的检查还达不到这个保证。len(rows) >= expected 只能发现部分数量缺口;重复读取可能掩盖遗漏,API 返回全部客户、SQL 只统计 active 客户时,也可能漏读后仍通过检查。原文前后对总记录数和 active 记录数的口径,也没有交代清楚。
游标正常结束同样不能单独证明业务处理完整。遍历期间数据变动、邮件重复入队、部分处理失败如何恢复,文章没有展开。示例中的 list(iter_customers()) 还会把结果全部放进内存;实际规模更大时,需要另行考虑。
因此,独立计数适合做粗粒度的异常探测,不能替代唯一 ID 去重、处理状态和必要的对账。Python 的 assert 在优化模式下还可能被移除,生产环境中的关键失败条件更适合显式判断并抛错。
这些改动是否降低了多少漏检率,作者没有提供对照数据。文末还带有付费手册推广,LAUNCH90、$1.90 和首周节省 5 小时的承诺属于营销信息,不能当作方案有效性的证据。
能拿来做什么
- 检查现有 Node 定时任务或 Agent 中带
limit的 API 请求,确认它会沿next_cursor、page或offset走到终点,并在达到分页上限时明确失败。 - 给批处理分别记录读取数量、预期数量、分页结束状态和起止时间,再从独立查询获取预期值;先统一过滤条件,避免拿不同范围的数据比较。
- 把日报中的「零问题」拆成覆盖情况与处理结果。如果任务只完成部分扫描,就明确显示
PARTIAL,让调度系统也收到失败状态。 - 在排障手册第一步写入核对原始记录和处理状态的方法。客户反馈与日报冲突时,先验证客户是否进入过扫描范围,再查邮件模板和发送链路。