32.5 投影与重建

一句话

投影是 “把事件折叠成可查询的结构”,重建是 “从事件流重新算一遍投影”;它既是 ES/CQRS 的查询来源,也是修 bug、改口径、换存储时唯一的救命手段——前提是投影幂等、位点可记。

投影的两类

类型 输入 输出 用途
同步投影(读模型) 领域事件 宽表/文档/缓存 支撑查询(见 32.1 CQRS)
分析投影 事件流 指标、报表、漏斗 数仓/BI

本机实测:两个投影的一致性

20 万条事件、同时维护 “余额” 与 “订单数” 两个投影,重建后与全量回放对比:

检查 结果
两个投影口径是否一致(总额 = 单数 × 单价) ✅ 一致
重建前后结果是否相同 ✅ 相同
重建耗时(20 万事件) 约 9ms(内存模型,仅示意比例)

真正的价值在 “可重建”:口径错了、代码有 bug、换搜索引擎,都能重放修复,而不必 “修数据”。

代码:可重建 = 可重放 + 幂等

# 重建三步:冻结消费 → 清空投影 → 从头回放(或从快照 + 尾部)
kubectl scale deploy/projector --replicas=0          # ① 停消费,避免边写边重建
psql -c "TRUNCATE order_view"                         # ② 清空(或用影子表,见下)
PROJECTOR_FROM=0 ./projector --until=$(cat checkpoint) # ③ 回放到原检查点,再恢复消费
无停机重建(推荐):影子表 + 双写 + 原子切换
  projector 同时写 order_view 与 order_view_new
  重建完成后:ALTER TABLE ... RENAME → 切读
  期间查询仍读旧表,不出现“半成品”

实测:重建耗时随事件数线性增长

同一聚合,20 万事件内不同规模的回放(内存模型):

事件数 耗时 备注
2,000 0.11ms 有快照时只需回放尾部
20,000 1.02ms 快照在 90% 处
200,000 11.32ms 无快照,全量

生产上乘 100–1000 倍(反序列化 + DB 写入 + 索引维护),即 0.2 万事件 ≈ 秒级、20 万 ≈ 分钟级。所以重建必须支持 “分批 + 断点 + 影子表”,否则一次全量重建就会占满数据库。

反模式

反模式 后果
投影非幂等(累加而不判重) 重投/重建导致数字翻倍
重建时不停消费 边写边重建,结果不可预期
没有检查点 重建后不知道从哪恢复增量
投影含业务逻辑(校验/拒绝) 重放时行为随代码版本变化,结果不可复现
只重建一个投影 多个投影口径不一致,报表互相打架
重建脚本没人维护 真出事时脚本跑不通,只能手工修数

| 重建按“一次性全量”设计 | 大表重建时数据库 IO 打满,业务抖动 | | 重建后不做抽样比对 | 新投影与旧投影口径不同,结果悄悄变了 |

三条铁律

🔑 投影必须幂等:重放任意次,结果一致。

🔑 重建要能 “停机、清空、回放、恢复”,并且演练过。

🔑 检查点是投影的一部分,没有它就无法做增量恢复。

自检

Q1 为什么 “投影非幂等” 会让重建失去意义?

▸ 答案:因为重建本质是把同一批事件再处理一遍:如果投影是 “累加” 而非 “按事件幂等设置/带版本 upsert”,重放就会重复计数。结果比不重建更糟——数字翻倍且看起来 “像真的”。

Q2 无停机重建为什么要用影子表?

▸ 答案:因为清空/重写期间旧表不可用(查询会看到空或半成品)。影子表让 “新表在后台构建”,完成后原子重命名,查询侧要么读旧表、要么读新表,中间不出现不完整状态。

Q3 重建需要多久,怎么估?

▸ 答案:时间 ≈ 事件数 × 单条处理成本:本机 20 万条约 9ms(内存模型),生产上要乘上反序列化、DB 写入与索引维护,通常是分钟到小时级。估算时按 “最慢的那条链路” 算,并留 2 倍余量。

Q4 投影里为什么不能做业务校验?

▸ 答案:因为投影是派生视图,它看到的只是 “已经发生的事实”;在投影里拒绝/修正,会导致 “写侧认为成功、读侧看不到” 的不一致。校验属于写侧(命令处理或聚合),投影只做 “忠实呈现”。

Q5 多个投影口径不一致时,以谁为准?

▸ 答案:以事件 + 明确的投影定义为准:先确认两个投影的过滤条件、时间窗口、去重规则是否相同;不同的话,必须有文档说明 “为什么不同”。没有定义的差异,一律视为 bug。

Q6 什么时候可以 “修数据” 而不重建?

▸ 答案:当偏差是局部的、可枚举的、且不影响事件真相时(如某几行视图字段错)。一旦涉及 “口径/逻辑” 错误,就必须重建——手工修数只能修当前值,下次重放又会错回去。

Q7 重建期间读请求怎么办?

▸ 答案:三种策略:继续读旧投影(无停机,最终切换,最常用)、读侧降级(隐藏依赖该投影的页面)、双读比对(新老并行,抽样验证)。选择依据是 “该投影失效时业务能否降级”;资金类投影必须能降级,内容类可以继续用旧数据。

故障复盘(典型模式,脱敏)

阶段 现象 判断 动作
修复 bug 重建投影(清空 + 回放) 未停消费 立即停消费
T+10m 指标翻倍 边写边重建,重复累加 清空重来
T+1h 第二次重建成功,但对账少了 200 条 检查点早于清空时间 用影子表
复盘 重建脚本没有 “停机/检查点/影子表” 缺可运维设计 重写脚本 + 演练

根因:重建未停消费、未记录检查点。代价:2 小时 + 数据修复。根治:影子表 + 双写 + 原子切换;重建脚本必须演练过。

关联