关于事件溯源与 CQRS的几点记录
这次做订单系统重构,从传统 CRUD 转到了事件溯源 + CQRS 的架构。
传统架构的问题
原来的订单系统很简单:
CREATE TABLE orders (
id BIGINT PRIMARY KEY,
user_id BIGINT,
amount DECIMAL,
status VARCHAR(20),
created_at TIMESTAMP,
updated_at TIMESTAMP
);
查订单就 SELECT * FROM orders WHERE id = ?,改订单就 UPDATE orders SET status = ? WHERE id = ?。
一开始挺好的,但业务复杂起来之后问题就暴露了:
问题一:状态丢失
订单从"待支付"变到"已支付",中间发生了什么?日志里说有更新,但到底是谁在什么时候改的,因为什么原因改的?不知道。
运营来问:“为什么这个订单状态突然变成了已取消?“查了一圈,只能告诉你"确实变了”,原因不清楚。
问题二:审计困难
系统上线半年,要生成一份季度报表:平均支付时间、取消订单的主要时段、支付方式的使用趋势。
在传统架构里,这些数据要么一开始就埋点记录,要么就没办法统计。订单表里只有当前状态,历史数据早就没了。
问题三:读写不匹配
查询订单列表的时候,业务方要求按创建时间、金额、状态、用户等多维度组合查询。为了优化查询,建了一堆索引,数据库压力越来越大。
但写的时候其实很简单:用户下单,插入一条记录;用户支付,更新状态。读写的需求完全不一样,硬放在一张表里,两边都别扭。
事件溯源的基本思路
事件溯源的核心思想是:不要保存状态,保存导致状态变化的事件。
传统方式:订单当前状态是"已支付” 事件溯源:订单经历了"订单创建" → “支付成功” → “物流发货"这些事件
状态是可以计算出来的,但事件一旦发生就不能改。
实践中的事件溯源
事件定义
先定义事件结构:
{
"event_id": "evt_123456",
"aggregate_id": "order_789",
"event_type": "OrderPaid",
"data": {
"order_id": "order_789",
"payment_method": "alipay",
"amount": 99.00,
"paid_at": "2026-07-16T14:30:00Z"
},
"timestamp": "2026-07-16T14:30:00Z",
"version": 5
}
关键点:
aggregate_id:聚合根 ID,事件属于哪个业务实体event_type:事件类型,OrderPaid、OrderCancelled 等version:版本号,用于检测并发冲突timestamp:事件发生时间
事件存储
事件表设计:
CREATE TABLE events (
id BIGINT PRIMARY KEY AUTO_INCREMENT,
aggregate_id VARCHAR(64) NOT NULL,
event_type VARCHAR(64) NOT NULL,
event_data JSON NOT NULL,
version INT NOT NULL,
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
INDEX idx_aggregate (aggregate_id),
UNIQUE KEY uk_aggregate_version (aggregate_id, version)
);
通过 aggregate_id + version 的唯一约束,确保事件的顺序和完整性。
状态重构
需要当前状态的时候,从头开始重放事件:
def get_order_state(aggregate_id):
events = load_events(aggregate_id)
state = {}
for event in events:
state = apply_event(state, event)
return state
def apply_event(state, event):
if event['event_type'] == 'OrderCreated':
state['id'] = event['data']['order_id']
state['status'] = 'created'
state['amount'] = event['data']['amount']
elif event['event_type'] == 'OrderPaid':
state['status'] = 'paid'
state['payment_method'] = event['data']['payment_method']
# ... 其他事件处理
return state
CQRS 的读写分离
CQRS(Command Query Responsibility Segregation)的核心是把命令和查询分离。
命令侧:处理写操作,发布事件 查询侧:监听事件,构建专门优化的读模型
读模型优化
订单列表查询复杂,我们单独建了一个读模型:
-- 读模型:专门为查询优化
CREATE TABLE order_read_model (
order_id VARCHAR(64) PRIMARY KEY,
user_id BIGINT,
amount DECIMAL,
status VARCHAR(20),
payment_method VARCHAR(32),
created_at TIMESTAMP,
updated_at TIMESTAMP,
INDEX idx_user_status (user_id, status),
INDEX idx_created_status (created_at, status),
INDEX idx_amount_status (amount, status)
);
这个表不是业务逻辑的核心,只是为了查询方便。数据丢失了没关系,重放事件就能重建。
踩过的坑
坑一:事件定义不稳定
一开始事件结构变动很频繁,今天加个字段,明天改个名字。结果历史事件读不了,重构出来的状态不对。
解决:
- 事件结构要谨慎设计,改之前考虑好兼容性
- 新增字段可以,但改字段名和删除字段要慎重
- 考虑事件版本化,不同版本的事件用不同的处理器
坑二:重放性能问题
事件多了之后,从头重放一次要几秒,这个性能接受不了。
解决:
# 1. 快照机制
定期保存当前状态的快照,重放时从最近快照开始
def get_order_state(aggregate_id):
snapshot = load_latest_snapshot(aggregate_id)
events = load_events_since(aggregate_id, snapshot.version)
state = snapshot.state
for event in events:
state = apply_event(state, event)
return state
# 2. 状态缓存
把频繁访问的状态缓存到 Redis,减少重放次数
坑三:投影延迟
命令侧写入了事件,但投影还在处理,用户查到的是旧数据。
解决:
- 接受最终一致性,在业务上容忍短暂延迟
- 对于必须一致的场景,使用同步投影
- 监控投影延迟,及时告警
坑四:调试困难
传统架构里直接查表就能看状态,事件溯源里要重放一堆事件才知道发生了什么。
解决:
# 建一个事件查询接口
def get_events(aggregate_id):
return load_events(aggregate_id)
# 前端展示事件流,方便调试
什么时候用,什么时候不用
事件溯源 + CQRS 不是银弹,带来好处的同时也增加了复杂度。
适合用的场景:
- 业务逻辑复杂,需要审计和追溯
- 读多写少,查询模式复杂多样
- 需要支持时间旅行(查看某个时间点的状态)
- 团队有一定架构能力,愿意投入学习成本
不适合用的场景:
- 简单 CRUD 系统,增加复杂度不划算
- 团队规模小,没有专门的人维护这套架构
- 对强一致性要求极高,不能接受最终一致
写在最后
事件溯源和 CQRS 这套架构,真正改变的是对"状态"的理解。
传统方式下,我们认为状态是永久的,数据存储就是保存状态。事件溯源告诉我们,状态是临时的、可计算的,真正有价值的是事件本身——这些不可修改的业务事实。
想清楚这个,很多设计决策就自然了。
但架构不是越复杂越好。如果你的系统简单,团队资源有限,传统 CRUD 也没问题。等复杂度真的上来再考虑这些模式,不要为了技术而技术。
这次重构花了三个月,中间还有过反复。但回过头看,这种对"不可变事实"的思考方式,对理解系统设计确实有很大帮助。
版权声明: 本文首发于 指尖魔法屋-关于事件溯源与 CQRS的几点记录(https://blog.thinkmoon.cn/post/12-event-sourcing-cqrs-pattern/) 转载或引用必须申明原指尖魔法屋来源及源地址!
评论
使用 GitHub 账号登录后即可留言,支持 Markdown。