超长批次的稳定遍历:全序、边界与游标
面向亿级至十亿级数据,以稳定全序、遍历边界和持久化游标构造可恢复的超长批次,并用有界状态支撑并行、重试与跨实例接管。
本页目录
遍历一个持续变化的数据集,最容易想到的实现是查出所有目标,然后写一个循环。这个想法在几千条数据上也许够用,但面对亿级乃至十亿级对象、持续数小时甚至数天的任务,循环只是最后一步。
真正困难的是:数据仍在增加、删除和改变,进程会发布或崩溃,下游会短暂失败,多个批次还会争抢执行容量。系统必须知道自己走到了哪里,重启后从哪里继续,并证明没有因为分页方式而静默漏掉一段数据。
这套设计范式来自对数亿条消息的即时通信(IM)事件流进行持续分发。它解决的核心问题是:先为动态集合建立稳定全序,再讨论调度和恢复。
遍历失败,往往从排序开始
假设任务用 OFFSET 分页。第一批结果处理完后,有对象被删除,后续对象的偏移量便会前移;继续读取下一批时,排在边界附近的对象可能被直接跨过。反过来,若有对象插入前一批结果,原有对象又可能被重复读取。
动态筛选也会制造同类问题。任务按“当前待处理”查询,某个对象在处理游标(cursor)到达前退出条件,之后又重新进入条件,它究竟属于本轮还是下一轮并不明确。持续变化的结果集没有稳定边界,进度百分比也就没有可靠分母。
仅仅改成按时间排序仍不够。时间可能重复,也可能被回写;多个对象拥有相同排序值时,数据库可以在不同查询中返回不同顺序。若排序条件不能形成全序,游标就无法准确表达“最后处理的是哪一个”。
因此,超长批次的第一个设计对象不是执行器(worker)、队列或 Redis,而是顺序。如果不能回答任意两个对象谁在前、谁在后,就无法构造可证明的稳定遍历。
为数据建立稳定全序
建立稳定全序是一项逻辑约束,不要求把十亿个 ID 复制进一条 Redis 列表(List)。系统只需为每个待遍历对象确定一个稳定、唯一、可比较的全序键(total-order key)。
一个合格的全序键至少满足三个条件:同一对象的键在本轮遍历期间不可变;任意两个对象的键可以比较;不同对象不会落在同一位置。数据库自增序号、提交时分配的事件序号,或者 (created_at, id) 这样的复合键都可能满足条件。
但复合键成立有前提。created_at 不能被改写,新增记录也不能带着旧时间回填到游标之前。若业务无法保证这一点,就不应直接遍历可变业务表,而应把对象变化投影到一个追加写事件日志,用日志序号承担顺序契约。
稳定全序带来一个关键性质:游标不再描述“第几批”,而是描述“已经确认处理到哪个键”。每批数量可以变化,执行器可以重启,只要继续查询大于游标的键,前后关系就不会改变。
用遍历边界固定本轮范围
稳定全序还需要固定边界。任务创建时记录遍历下界 startKey,并读取当时已经提交的最大全序键作为遍历上界 endKey。本轮只处理下面的半开半闭区间(range):
startKey < key <= endKey
游标初始等于 startKey,随后只能单调向 endKey 推进。任务开始后产生的新对象必须获得更大的键,因此自然落到遍历上界之后,留给下一轮处理。本轮不会因为新数据持续写入而永远结束不了。
遍历上界固定的是候选键空间,不是对象内容的历史快照。对象可能在访问前被删除,或已经不再满足处理条件。执行器读取对象后仍需校验实时状态;不存在或不再适用时,将其记为确定跳过,然后推进游标。
这一区分很重要。批次完成只表示整个键区间都得到了处理结论,不表示所有对象都得到了预期的业务结果,更不表示执行期间业务数据没有变化。
两种全序表示,两个适用尺度
在事件流里,最自然的实现是由序列号(sequence)隐式表示全序。每个已提交事件拥有单调序列号,任务只保存遍历上界和游标,通过键集分页(keyset pagination)不断读取有界数据块(chunk)。内存中最多存在一个数据块,不随事件总量增长。
这正适合亿级或十亿级遍历。十亿只是设计规模,不是单机吞吐承诺。处理时间取决于数据块大小、并发度、单项成本和下游容量,但控制面状态始终只与活跃批次、并行区间和少量异常有关。
另一种实现是显式物化目标快照:创建任务时取得有序 ID 集合,将它们放入待处理队列,再逐个领取。这对目标较少、边界固定的批处理非常实用,因为它能直接给出总数,也方便展示逐项进度。
然而,显式队列是稳定全序遍历的一种适配,不是超大规模方案本身。若把十亿个 ID 全部复制到 Redis,不仅创建任务本身会变成长事务,内存、网络和清理成本也会与对象数量线性增长,系统还没开始遍历就先耗尽了控制面资源。
用有界状态描述十亿级任务
超大批次不应为每个正常对象保存一份状态。一个遍历任务的核心持久化信息可以收敛为:startKey、endKey、当前 cursor、少量区间租约(lease)、完成计数,以及稀疏的重试记录(retry)和死信记录(dead-letter)。
正常路径只推进游标和计数。只有暂时失败、重试耗尽或需要人工分析的对象才进入稀疏异常集合。如果异常量也可能失控,就要设置重试上限、告警阈值和独立归档,而不能让控制状态悄悄退化成另一份全量副本。
核心循环可以压缩成很短的伪代码:
for cursor < endKey {
chunk := List(cursor, endKey, chunkSize)
result := Process(chunk)
if result.hasRetryableFailure {
Backoff()
continue // 不推进,恢复后仍会读到这个数据块
}
cursor = AdvanceAtomically(cursor, chunk.lastKey, result.counters)
}
查询必须同时带上 key > cursor 和 key <= endKey,并按完整全序键升序返回。遇到全序键空洞不需要补齐;游标记录的是最后确认键,而不是已处理数量。
只有整个数据块成功,或其中失败对象已经得到“确定跳过、写入死信记录”等终态,才能原子推进游标。依赖临时失败时不推进,下一次循环或新执行器会重新读取同一数据块,因此系统提供的是至少一次处理语义(at-least-once processing semantics)。
数据块应保持有界。这样,即使进程在业务副作用完成后、游标提交前退出,最多重放一个有界数据块,而不是重新扫描整个批次。计数与游标在同一个原子状态迁移中更新,可以避免恢复时重复累计进度。
全序不等于串行
稳定全序规定的是确定的前后关系,并不要求只有一个执行器。创建任务后,可以把 (startKey, endKey] 切成有限个互不重叠区间,每个区间维护自己的游标和租约,由不同实例并行遍历。
区间必须由全序键边界定义,而不是由实时 OFFSET 定义。对于分布不均匀的键空间,可以先按索引抽样或按历史分位点划分;热点区间还可以继续拆分,但任何时刻都不能让两个有效租约覆盖同一范围。
并行会引入“后段先完成”的情况。此时全局连续完成游标不能直接跳到最远的区间游标,而只能越过从遍历下界开始连续完成的前缀。后面的区间可以标记完成并等待,直到前面的缺口被补齐。
因此,控制状态与对象规模无关,却会与并行区间数有关。区间数应该是受配置约束的有限值,而不是为每个对象创建一个分片。公平调度也在区间或批次之间轮转,不需要先物化全部目标。
Redis 状态机属于恢复层
Redis 很适合保存游标、区间所有权和调度索引,但它是稳定全序遍历之上的恢复层,不是遍历正确性的来源。真正构成权威事实来源(source of truth)的是序列号、遍历上界和已提交游标。
执行器领取区间时获得所有权令牌(claim token)和租约,执行期间通过心跳续租。游标推进脚本必须同时校验旧游标、区间边界和所有权令牌;已经失去所有权的旧执行器即使迟到,也不能覆盖新执行器的进度。
全局并发门(global concurrency gate)限制所有实例对下游的总压力;批次轮转避免一个超长任务长期占满容量。领取顺序应是先取得全局许可,再领取区间或数据块,避免任务还在等待执行槽位时就被误报为执行中。
通知只负责让执行器更快醒来,不能成为权威事实来源。通知可能合并或丢失,因此仍需低频轮询兜底。无论由通知还是定时器触发,执行器都只相信序列号与持久化游标之间尚未处理的差值。
恢复解决不了业务副作用
所有权令牌能阻止失去租约的执行器迟到推进游标,却无法撤销它已经完成的数据库更新或外部调用。进程可能在副作用成功后、提交游标前退出,新执行器随后会再次执行同一对象。
所以至少一次处理要求业务执行器具备幂等键、条件更新、去重记录或独占锁。Redis Lua 脚本可以保证调度状态原子迁移,但不能让 Redis、业务数据库和第三方系统自动形成强一致事务,更不能据此声称恰好一次处理(exactly-once processing)。
重试也应区分对象失败与依赖失败。单对象数据异常可以写入稀疏死信记录后越过;公共依赖不可用时则应停止当前数据块、退避并保留游标。否则,大面积故障会被错误地转换成“已完成但全部跳过”。
四条必须写进契约的边界
第一条是保留策略(retention policy)。数据源必须保证 (cursor, endKey] 在任务完成前仍可读取。若日志在落后消费者追上之前被截断,游标再正确也无法恢复。系统要么阻止清理越过所有活跃任务中的最小已提交游标,要么将批次明确终结为不可恢复。
第二条是全序键回写。已经被游标越过的对象若能修改全序键并移动到后方,就可能被重复处理;未到达对象若移动到游标之前,就可能永久遗漏。最稳妥的规则是全序键一经分配不可修改。
第三条是游标前插入。新对象若可能获得不大于当前遍历上界或游标的键,本轮就可能看不到它。全序键分配必须与提交顺序建立契约;做不到时,应遍历追加写变更日志,而不是直接遍历原表。
第四条是进度含义。完成计数描述已经形成处理终态的对象数,不等于获得预期业务结果的数量。分页读取和状态查询可以最终一致,但要持续校验计数不变量、区间不重叠,以及全局连续完成游标只覆盖连续前缀。
先定义顺序,再组织执行
超长批次的核心不是把队列做得更复杂,而是先回答三个问题:对象按什么稳定全序排列,本轮在哪个遍历上界结束,哪个游标代表已经确认的连续前缀。
有了稳定全序,内存只需容纳有界数据块,持久化状态只需覆盖游标、有限区间和稀疏异常。并行、全局限流、公平调度、租约、心跳和 Redis 原子脚本都有了明确位置:它们负责让遍历跑得快、跑得稳、能够接管,却不替代顺序本身。
对于较小的固定批次,可以把全序键显式物化成 ID 队列;对于亿级消息和十亿级对象,则应让序列号隐式表达它。实现形态可以不同,但可恢复遍历的根基始终相同:稳定全序、固定边界、游标单调前进。