那天早上我盯着对账表,数字对不上,差了 17000 多条。
前一晚的同步任务日志干干净净,最后一行写着「完成,共 41278 条」。没有异常,没有重试记录,没有告警。它自己认为自己干完了。
我去翻代码,终止条件就一行:
if len(rows) < page_size:
break # 认为到最后一页了
三个月前,上游把单页上限从 100 压到了 50。我们传 100,上游回 50。代码拿到 50,跟自己心里的 100 一比,判定「这一页不满,是最后一页」,于是只拉了一页就走了。
它错得很有礼貌:没有抛错,没有空指针,没有超时。它只是把「上游没给我要的那么多」理解成了「上游没有更多了」。
四种判据,四种赌法
后来我把这类终止条件全捋了一遍,发现写法就那么四种,每一种都在赌一件上游没承诺的事:
- 返回空数组——赌「没有数据」和「到上限了」返回同一个形状。可空页也可能是 offset 超限、权限过滤掉整页、限速重试兜底。三种都长得一样。
- 本页条数小于每页条数——赌上游原样接受你的每页条数。我那次就栽在这。
has_more/next_cursor——赌字段一定存在。用真假值判断的时候,字段缺失会被当成「已结束」,字符串"false"会被当成「还有」。- 游标不再推进——赌每次返回的游标不一样。一样的那个下午,它会带你进死循环。
我把终止逻辑重写成合取判断
MAX_PAGES = 2000 # 页数护栏
MAX_STALL = 2 # 同一游标最多容忍两次
EMPTY_TOLERANCE = 1 # 中间空页容忍一次
def pull_all(fetch, *, page_size=50):
cursor, pages, count = None, 0, 0
prev_cursor, stall, empty = None, 0, 0
while pages < MAX_PAGES:
resp = fetch(cursor=cursor, limit=page_size)
pages += 1
rows = resp.get("list") or []
effective = resp.get("limit_effective") or page_size # 关键:用生效值比
if len(rows) > effective:
raise PullError("分页参数被上游改写")
if not rows:
empty += 1
if resp.get("has_more") is True or empty <= EMPTY_TOLERANCE:
cursor = resp.get("next_cursor") or cursor
continue
return count, "empty"
count += len(rows)
flag = resp["has_more"] if "has_more" in resp else None
if flag is False:
return count, "no_more_flag"
if flag is None and len(rows) < effective:
return count, "short_page"
nxt = resp.get("next_cursor")
if nxt is None or nxt == "":
return count, "cursor_exhausted"
stall = stall + 1 if nxt == prev_cursor else 0
if stall >= MAX_STALL:
raise PullError("游标连续未推进")
prev_cursor, cursor = nxt, nxt
raise PullError("达到页数上限,判定为被截断")
三处改动最关键:短页比较的对象换成上游实际生效的每页条数;空页不立刻退出,给一次容忍;撞到页数上限时抛错,而不是返回成功。
现在每次同步都会留下一行结局
记录表加了三个字段:结局原因、页数、条数。
结局原因只有五种取值:显式结束、游标耗尽、短页、连续空页、撞上限。前三种才能标「完成」,第四种进待确认队列,第五种自动转成按时间窗分段重拉。
上线两周,这个字段帮我抓出过两次类似的静默少拉——都发生在上游调整单页上限之后。如果没这一列,它们会一直在日志里写着「完成」。
三句话带走
第一,「到达上限」不等于「拉完了」,这是假终止的唯一源头。
第二,短页要跟上上游生效的每页条数比,不是你请求的那个数字。
第三,让终止函数说出「为什么停」,比让它说出「停了」有用得多。
分母对不上的时候,问题往往不在数据,在那个 if。