读模型重建不能在命令 handler 里同步做,因为会导致事务未提交就读不到数据、并发写入死锁、违反 cqrs 职责边界;正确做法是命令 handler 只校验并写事件,由独立 projection 进程异步、幂等、原子地重建读模型。

读模型重建为什么不能在命令 handler 里同步做
常见错误是:用户注册成功后,立刻在 CommandHandler 里调用 UpdateUserListView() 去更新读表。这会导致三类问题:事务未提交就读不到新数据、并发写入时产生死锁、违反 CQRS 的职责边界——查询侧本该异步、幂等、可重试。
真正该做的,是让写操作只负责发事件,重建交给独立的 projection 进程。事件一旦落库(比如写入 PostgreSQL 的 events 表),就触发下游重建,不卡主流程。
- 命令 handler 只做两件事:校验 + 写事件存储(event store),然后立即返回
nil - 读模型重建必须由单独的 goroutine 或服务监听事件流,例如轮询
events表或消费 Kafka 主题 - 重建逻辑必须能处理重复事件:同一事件可能因网络抖动、服务重启被多次投递
Projection 实现要满足幂等和原子性
用 INSERT ... ON CONFLICT DO UPDATE 是最轻量且可靠的做法。别手写 SELECT + INSERT/UPDATE 逻辑——它在并发下大概率出错,且无法保证单条事件的原子应用。
以用户信息宽表为例,假设读模型是 users_view,字段包含 id, name, email, order_count:
INSERT INTO users_view (id, name, email, order_count)
VALUES ($1, $2, $3, $4)
ON CONFLICT (id) DO UPDATE
SET name = EXCLUDED.name,
email = EXCLUDED.email,
order_count = users_view.order_count + EXCLUDED.order_count;
-
EXCLUDED是 PostgreSQL 关键字,代表本次 INSERT 中冲突被拒的那行数据 - 如果事件是
UserRegistered,order_count来自事件 payload;如果是OrderPlaced,则只更新计数字段,其他字段保持原值 - 避免在 projection 里做 JOIN 或复杂计算——读模型应预计算好,不是“实时拼装”
如何安全地从零重建读模型
上线新读模型、修复脏数据、或切换存储(比如从 SQLite 换成 PostgreSQL)时,必须支持全量重放。但直接重放全部事件可能耗时数小时,期间新事件还在持续写入。
正确做法是分阶段重建:
- 先启动一个临时 projection,从 event store 当前位置开始消费新事件,把变更写入新表(如
users_view_new) - 再用离线 job 批量重放历史事件到
users_view_new,期间允许新事件继续写入 - 最后用
ALTER TABLE ... RENAME TO原子切换表名,并删掉旧表 - 切换后,新 projection 从上次断点继续消费,不会漏事件
关键点:重建过程不能阻塞线上写入,也不能要求停服。所有操作都得在数据库层面可中断、可验证。
别把读模型写成 PostgreSQL 的镜像
很多人一上来就建个 users_read 表,字段和写库 users 完全一致——这等于没做 CQRS。读模型的价值在于「为查而生」。
- 把高频查询字段冗余进来:比如用户列表页需要「最近登录时间」「角色名称」「部门路径」,这些在写模型里可能分散在多张表,读模型就该合并成宽表
- 用 JSON 字段存动态属性:
metadata JSONB存标签、偏好、设备信息,避免频繁加字段 - 对聚合统计建物化视图:
CREATE MATERIALIZED VIEW user_stats AS SELECT user_id, COUNT(*) FROM orders GROUP BY user_id,定期刷新 - 本地缓存场景可用 SQLite:适合离线终端、CLI 工具或边缘服务,用 WAL 模式保证并发安全
重建不是把事件日志翻译成 SQL 插入语句,而是根据查询需求反向设计数据形态。同一个事件流,可以支撑多个读模型:一个给 API,一个给 BI 报表,一个给搜索索引——这才是 CQRS 的真实弹性。











