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 小时 + 数据修复。根治:影子表 + 双写 + 原子切换;重建脚本必须演练过。