cqrs在go中本质是读写路径的契约约定:命令只改状态不返回数据,查询只读不触发副作用;必须分离连接池、事务、缓存及错误处理,否则仅为伪cqrs。

Go 里落地 CQRS 不是引入一个框架就能跑起来的事,它本质是一套读写路径的契约约定——命令只改状态、不返回数据;查询只读数据、不触发副作用。没拆开这两条路,就只是把 CRUD 换了个名字。
Command Handler 必须只返回 error,别偷偷塞回业务数据
常见错误是 CreateOrder 函数返回 *Order 或 OrderID,看起来方便,实则埋雷:前端拿到 ID 后立刻调 GetOrderByID,结果查不到——因为事务还没提交,或查询走的是异步更新的 Redis 缓存。
- 命令函数签名应为
func (h *OrderCommandHandler) Create(ctx context.Context, cmd *CreateOrderCmd) error - 若需透出 ID,用
cmd.OrderID字段预先生成(如 UUID),写入时直接用,避免 DB 自动生成再 SELECT - 禁止在
Create内部调repo.FindByID做“写后验证”——校验逻辑应前置到领域模型,或靠事件快照表(snapshot)支撑 - 事务必须显式控制:
tx, err := h.db.BeginTx(ctx, &sql.TxOptions{Isolation: sql.LevelReadCommitted}),别依赖默认隔离级别
Query Handler 要彻底脱离写库连接,连 *sql.DB 都不该共用
很多项目声明一个全局 db *sql.DB,命令和查询 handler 都用它,只是读写 SQL 不同——这不算 CQRS,只是 SQL 分工。
- 查询侧应使用独立连接池,比如
readDB *sql.DB或pgxpool.Pool,指向只读副本或物化视图 -
UserReader接口只能有FindByID、Search等方法,绝不能有Update或Delete - 缓存层(如 Redis)应由 Query Handler 直接对接,而非通过中间 service 包转发;否则缓存失效逻辑会绕过命令路径
- 若用 PostgreSQL,读操作别进
BeginTx——即使是只读事务,也会占用 WAL slot,长期运行可能触发snapshot too old
事件发布必须卡在事务提交之后,不能写完就发
Go 没有类似 Spring 的 @TransactionalEventListener,硬在 handler 里 eventbus.Publish() 极易导致下游消费到未提交的数据,尤其当 Kafka 重试或消费者重启时,状态错乱不可逆。
- 命令 handler 应返回
[]interface{}事件切片,例如return []interface{}{&OrderCreatedV1{ID: cmd.ID, Version: 1}} - 外层统一处理:事务
Commit()成功后,再循环 dispatch 这些事件 - 事件结构体必须带
Version int字段,重放时靠switch event.Version分支兼容老数据 - Kafka 生产者必须开启幂等性:
config.Net.Producer.Idempotent = true,否则重复事件会破坏读模型一致性
最常被忽略的点是:CQRS 的“分离”不是代码目录分开了就算,而是连接池、事务生命周期、缓存策略、甚至错误处理方式都得各自独立。一旦某次 debug 时在 query handler 里临时加个 log.Printf("debug: %+v", repo.Update(...)),整套读写隔离就塌了一角。
golang免费学习笔记(深入):立即使用
在学习笔记中,你将探索golang的核心概念和高级技巧!











