cqrs在go中需手动分离读写:命令只改状态且不返回数据,查询只读数据且不修改;应定义独立的commandhandler和queryhandler接口,使用不同结构体(如userwritemodel/userview)和数据库连接(主库/只读副本),避免混用。

Go 里怎么用 CQRS 分离读写逻辑
CQRS 不是 Go 标准库功能,也没有现成的 cqrs 包,它是一种架构风格,靠你主动拆分接口和结构体来落地。核心就两条:命令(Command)只改状态、不返回数据;查询(Query)只读数据、绝不修改。
常见错误是把 UpdateUser 命令函数设计成返回更新后的完整用户结构体——这已经悄悄混入了查询职责,后续加缓存、审计或事件溯源时会卡住。
- 命令处理函数应返回
error或空结构体(如struct{}),例如:func (h *UserCommandHandler) HandleChangeEmail(cmd *ChangeEmailCmd) error - 查询函数应独立命名、独立路径,比如
GetUserByID和SearchUsers放在另一个 handler 包里,连数据库连接都建议用只读副本 - 避免在命令 Handler 里调用查询函数来“验证当前状态”——该验证应由领域模型自己完成,或通过事件快照(Event Sourcing)查表,而不是实时 SELECT
为什么 Go 的 struct 和 interface 天然适合 CQRS
Go 没有继承树,但靠组合 + 接口能干净地隔离契约。CQRS 要求读写模型不同,而 Go 的 struct 可以按场景定义字段,interface 可以按职责定义方法集,不强耦合。
容易踩的坑是共用一个 User struct 做读写:字段越来越多,读接口被迫加载冗余字段(比如密码哈希、更新时间戳),写逻辑又得校验不该由它管的展示规则(比如昵称长度限制是否影响前端渲染)。
立即学习“go语言免费学习笔记(深入)”;
- 写模型用
UserWriteModel,只含 ID、Email、PasswordHash、Version 字段,带Apply()方法处理领域事件 - 读模型用
UserView,字段为 ID、DisplayName、AvatarURL、JoinedAt,甚至可嵌套ProfileSummary结构体 - 定义两个接口:
type CommandHandler interface { Handle(interface{}) error }和type QueryHandler interface { Execute(interface{}) (interface{}, error) },实现类互不依赖
PostgreSQL 事务中执行 CQRS 命令时的隔离问题
Go 的 database/sql 默认使用 ReadCommitted 隔离级别,对 CQRS 来说不够安全:命令执行中若并发读取同一行,可能看到部分更新状态(比如邮箱已改但版本号未升),导致下游事件重复或丢失。
这不是 Go 语言问题,是数据库行为。你必须显式控制事务粒度和锁策略。
- 关键命令(如转账、库存扣减)应在事务内用
SELECT ... FOR UPDATE锁住聚合根主键,再做更新,避免幻读 - 不要在事务里调用外部 HTTP 查询服务——超时或失败会导致长事务阻塞,破坏 CQRS 的解耦初衷
- 读模型同步不能依赖事务提交后立刻查库,要用异步方式(如监听 PostgreSQL 的
pg_notify或发 Kafka 事件),否则读写延迟不可控
用 Gin + GORM 实现 CQRS 时的路由和中间件陷阱
Gin 路由本身不区分读写,但 CQRS 要求明确语义。很多人把 POST /api/users 当作命令入口,却在同一个 handler 里查 DB 返回新建用户详情,这就违背了 CQRS 原则。
更隐蔽的问题是中间件污染:比如全局日志中间件记录了所有请求 body,而命令体可能含敏感字段(如原始密码),但查询请求 body 是空的——日志策略本该不同。
- 命令路由统一用
POST /commands/xxx或PUT /commands/xxx,查询路由用GET /queries/xxx,从 URL 就能区分职责 - 命令中间件只记录 command type、ID、error;查询中间件记录 query type、filter 参数、耗时,两者日志结构分离
- GORM 的
Session要按读写分离配置:写用主库db.Session(&gorm.Session{NewDB: true}),读用只读副本roDB.Session(...),别共用一个*gorm.DB
CQRS 在 Go 里不是靠框架自动实现的,而是靠你每定义一个函数、每个 SQL 查询、每次 HTTP 路由选择,都在回答一个问题:它到底是在改变世界,还是在观察世界。一旦开始混用,后面加事件溯源或者读写库拆分时,就得重写整条链路。










