不可变事件结构体需字段全导出、用time.Time而非*type、显式实现接口、不含逻辑函数;UserRegistered必须含Version和Timestamp。

怎么定义不可变事件结构体才不翻车
事件不是日志,是“已发生的事实”,一旦写入就不能改。Golang里最容易犯的错,是把事件设计成可变指针或嵌套未导出字段,导致 JSON 序列化失败、版本升级时 panic 或 event replay 时字段为空。
- 所有事件字段必须首字母大写(导出),否则
json.Marshal会忽略它们 - 避免使用
*time.Time或map[string]interface{}—— 它们在反序列化时行为不确定;统一用time.Time和具体 struct - 必须显式实现
EventType()、AggregateID()等接口方法,不能依赖匿名嵌入自动继承(Go 不支持虚函数) - 别在事件里存业务逻辑函数或闭包,事件只承载数据,不是执行单元
示例中 UserRegistered 结构体带 Version int 和 Timestamp time.Time 是刚需,不是可选:前者用于乐观并发控制,后者是事件因果排序依据。
聚合根 Apply() 方法怎么写才安全
聚合根不是“状态容器”,而是“事件应用引擎”。常见错误是直接修改字段而不调用 Apply(),或者在 Apply() 里做外部 I/O(比如查 DB、发 HTTP),导致事务边界失控、重放失败。
-
Apply()必须是纯内存操作,只改变聚合内部状态,并追加到UncommittedEvents切片 - 版本号
Version要在事件生成时就设好(不是入库后由 DB 自动生成),否则重放时顺序错乱 - 聚合 ID 必须与事件的
AggregateID()严格匹配,建议在Apply()开头加校验:if event.AggregateID() != a.ID { return ErrInvalidAggregateID } - 不要在
Apply()里调用AppendEvent()后立刻清空UncommittedEvents—— 提交前要先持久化事件,再清空
一个典型翻车点:在处理 AddItem 命令时,直接 a.Items = append(a.Items, newItem),却忘了生成 ItemAdded 事件 —— 这样系统就丢了事实,后续任何审计或重建都失效。
立即学习“go语言免费学习笔记(深入)”;
用 Kafka 做事件总线时,kafka-go 怎么配才不丢事件
Kafka 天然适合事件溯源,但 Golang 客户端默认配置极易丢数据:网络抖动、broker 重启、producer 缓冲区满都会静默失败。
- 必须设置
RequiredAcks: kafka.RequireAll(而非默认的RequireNone),否则消息发出去就不管 broker 是否写入成功 -
BatchSize和BatchTimeout要平衡吞吐与延迟:高并发场景下BatchSize: 100+BatchTimeout: 100 * time.Millisecond较稳 - 务必启用
RetryBackoff(如500 * time.Millisecond),否则临时连接失败直接 panic - 别复用
kafka.Writer实例跨 goroutine 写不同 topic —— 它不是线程安全的,应为每个 topic 单独 new 一个
如果你看到 failed to write message: EOF 或日志里反复出现 retrying after error,大概率是没开 RequiredAcks 或重试参数为 0。
为什么不能跳过快照(snapshot),哪怕只有几千条事件
事件回放不是“越全越好”。从零开始重放 5 万条事件可能耗时数秒,服务启动慢、查询毛刺高、CPU 爆表 —— 这不是理论风险,是上线后第一周就会遇到的线上问题。
- 快照不是可选项,是性能兜底项。建议每 500~2000 条事件存一次快照(取决于事件平均大小和重建耗时)
- 快照内容必须包含完整聚合状态 + 当前最大事件版本号(
Snapshot.Version),否则恢复时无法判断该从哪条事件继续重放 - 快照存储可以和事件存储分离(比如事件存 EventStore,快照存 Redis 或本地 BoltDB),但读取路径必须原子:先读快照,再读该版本之后的事件
- 别在快照里存指针、channel、goroutine 相关状态 —— 快照是冷数据,只序列化值类型和可 marshal 的 struct
最常被忽略的一点:快照本身也是事件流的一部分,它不该有业务含义,也不该触发任何副作用。它的唯一职责,就是让 rehydrate 变快 —— 快到用户感知不到。











