关于事件溯源与 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 = ?

一开始挺好的,但业务复杂起来之后问题就暴露了:

问题一:状态丢失

订单从"待支付"变到"已支付",中间发生了什么?日志里说有更新,但到底是谁在什么时候改的,因为什么原因改的?不知道。

运营来问:“为什么这个订单状态突然变成了已取消?“查了一圈,只能告诉你"确实变了”,原因不清楚。

问题二:审计困难

系统上线半年,要生成一份季度报表:平均支付时间、取消订单的主要时段、支付方式的使用趋势。

在传统架构里,这些数据要么一开始就埋点记录,要么就没办法统计。订单表里只有当前状态,历史数据早就没了。

问题三:读写不匹配

查询订单列表的时候,业务方要求按创建时间、金额、状态、用户等多维度组合查询。为了优化查询,建了一堆索引,数据库压力越来越大。

但写的时候其实很简单:用户下单,插入一条记录;用户支付,更新状态。读写的需求完全不一样,硬放在一张表里,两边都别扭。

事件溯源的基本思路

事件溯源的核心思想是:不要保存状态,保存导致状态变化的事件。

传统方式:订单当前状态是"已支付” 事件溯源:订单经历了"订单创建" → “支付成功” → “物流发货"这些事件

状态是可以计算出来的,但事件一旦发生就不能改。

graph LR A[事件流] --> B[事件存储] B --> C[事件重构器] C --> D[当前状态] C --> E[投影/视图] style A fill:#FFD700 style B fill:#90EE90 style C fill:#87CEEB style D fill:#FFB6C1 style E fill:#FFB6C1

实践中的事件溯源

事件定义

先定义事件结构:

{
  "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)的核心是把命令和查询分离。

graph TB subgraph 命令侧 A[用户操作] --> B[命令处理器] B --> C[事件存储] C --> D[发布事件] end subgraph 查询侧 D --> E[订阅事件] E --> F[投影构建器] F --> G[读模型] H[查询请求] --> G end style B fill:#FFD700 style F fill:#87CEEB style G fill:#90EE90

命令侧:处理写操作,发布事件 查询侧:监听事件,构建专门优化的读模型

读模型优化

订单列表查询复杂,我们单独建了一个读模型:

-- 读模型:专门为查询优化
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/) 转载或引用必须申明原指尖魔法屋来源及源地址!