日志写着「完成,共 41278 条」,对账少了 17000

0 阅读3分钟

那天早上我盯着对账表,数字对不上,差了 17000 多条。

前一晚的同步任务日志干干净净,最后一行写着「完成,共 41278 条」。没有异常,没有重试记录,没有告警。它自己认为自己干完了。

我去翻代码,终止条件就一行:

if len(rows) < page_size:
    break     # 认为到最后一页了

三个月前,上游把单页上限从 100 压到了 50。我们传 100,上游回 50。代码拿到 50,跟自己心里的 100 一比,判定「这一页不满,是最后一页」,于是只拉了一页就走了。

它错得很有礼貌:没有抛错,没有空指针,没有超时。它只是把「上游没给我要的那么多」理解成了「上游没有更多了」。

四种判据,四种赌法

后来我把这类终止条件全捋了一遍,发现写法就那么四种,每一种都在赌一件上游没承诺的事:

  • 返回空数组——赌「没有数据」和「到上限了」返回同一个形状。可空页也可能是 offset 超限、权限过滤掉整页、限速重试兜底。三种都长得一样。
  • 本页条数小于每页条数——赌上游原样接受你的每页条数。我那次就栽在这。
  • has_more / next_cursor——赌字段一定存在。用真假值判断的时候,字段缺失会被当成「已结束」,字符串 "false" 会被当成「还有」。
  • 游标不再推进——赌每次返回的游标不一样。一样的那个下午,它会带你进死循环。

image.png

我把终止逻辑重写成合取判断

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。