23 · 重建与核对:让多个系统持续一致
目标:从完整事件历史重建报名视图,并用独立核对区分延迟与永久错误。
可以等好的错误,与等不好的错误
搜索或统计晚几秒,是时效性问题。消费者永久漏了一条事件,或者把重试算了两次,是完整性问题。后者不会仅凭等待自动变好。系统设计要分别观测延迟、完整性以及业务约束,而不是用一个“最终一致”标签涵盖所有问题。
把多个专用系统组成数据流时,指定事实源与确定的派生规则,可以让搜索、日报和用户列表分别演进。事件溯源把事实存成业务事件日志,CQRS 用不同读模型服务查询;普通数据库加 CDC 也能维护视图,但若历史已经压缩成最新行状态,就不能假设还能重放所有业务过程。
重建需要固定起点和追赶边界。可以从一致快照加后续日志开始,或者从完整事件历史开始;新视图在后台构建,追到约定位置并核对,再切读流量。无界流不会有永恒的“最新”,发布时要明确切换位置与后续增量归属。
从加一计数转向状态派生
报名可能取消。仅对到达事件计数无法表达最终有效报名。下面按报名 ID 与版本维护状态,重复和乱序旧版本不改变最新状态。保存 dataflow.py,先预测取消后有效数量以及缺少取消事件的坏视图。
events = [
("r1", 1, "active"),
("r2", 1, "active"),
("r1", 2, "cancelled"),
]
def project(deliveries):
states = {}
for registration_id, version, status in deliveries:
prior = states.get(registration_id)
if prior is not None and version == prior[0]:
if status != prior[1]:
raise ValueError("same version has conflicting contents")
if prior is None or version > prior[0]:
states[registration_id] = (version, status)
return states
def active_count(states):
return sum(status == "active" for version, status in states.values())
canonical = project(events)
replayed = project(list(reversed(events)) + events)
broken = project(events[:2])
assert replayed == canonical
assert active_count(canonical) == 1
assert active_count(broken) == 2
print("correct:", active_count(canonical))
print("missing_cancel:", active_count(broken))
print("replay_equal:", replayed == canonical)
uv run --python 3.12 --no-project dataflow.py
预期输出:
correct: 1
missing_cancel: 2
replay_equal: True
版本来自同一报名事实的受控更新序列。事件携带完整目标状态,因此较高版本到达后可以忽略较低版本。如果事件是“增量加一”或“扣一”,直接丢弃较旧版本可能遗漏效果,需要按序执行或使用另一种可合并规则。
引导练习:① 在末尾再加一次 ('r1', 1, 'active');② 有效数仍是 1;③ 增加 ('r1', 2, 'active') 则应被拒绝,因为同一身份的版本内容矛盾,不能按到达顺序悄悄决定。
独立核对才可能发现共同错误
两个消费者使用同一段错误函数,结果一致也可能一起错。可以用主数据库中已确认且未取消的报名数量核对视图,用唯一报名 ID 集合查缺失与多余,再用不同路径计算汇总。只比较总数不够:漏一条又多一条,总数仍相同。
核对必须对齐截止位置。主库已推进到 100、视图只到 98 时,直接比较会把正常延迟报成损坏。先让两侧都表示同一个边界,再比较键集合、状态和汇总;遇到差异保留证据并重建,不直接把某个数字改到相等。
独立练习:写出坏视图与正确视图中状态不同的报名 ID,验收结果只有 r1。再给出切换新视图时避免漏事件的步骤。
参考实现与切换步骤
执行 different = sorted(k for k in broken.keys() | canonical.keys() if broken.get(k) != canonical.get(k)),应得 ['r1']。先固定输入快照与位置 L,构建新视图并消费 L 之后的日志,追到切换位置 H,在相同边界核对。切换读入口后让新消费者继续从 H 后推进;旧视图暂留以便回滚。若事件保留期不足以覆盖构建时间,必须先解决输入留存问题。
重放后计数错误时,检查是否把完整状态事件与增量事件混用;核对始终有差异时,先看比较位置和删除状态。在 design.md 增加新旧视图并行、切换、回滚与按键核对策略。