引言
大多数 PHP 项目用「一张表 + CRUD」就够了。但当业务长出审计要求、时序查询、多视图读模型时,CRUD 的「只存最终状态」就开始漏水——你无法回答「这个订单在三天前是什么状态」「这个余额是怎么一步步变成 800 的」。CQRS 与事件溯源给出的答案是:把「发生了什么」作为唯一真相记录下来,当前状态只是事件的投影。本文讲清这套架构在 PHP 里的落地方式。
目录
- 1. 为什么需要 CQRS 与事件溯源
- 2. CQRS:命令与查询分离
- 3. 命令总线与处理器
- 4. 事件溯源:事件即真相
- 5. 聚合根与不变量
- 6. 事件存储设计
- 7. 投影与读模型
- 8. 事件版本、快照与重放
- 9. 最终一致性与实战落地
- 10. 速查表与一句话记忆
- 延伸阅读
1. 为什么需要 CQRS 与事件溯源
1.1 CRUD 的三处漏水
| 问题 | CRUD 的困境 |
|---|---|
| 审计 | 只存最终状态,看不到中间过程 |
| 多视图 | 报表/搜索/缓存共用写模型,读需求拖累写模型 |
1.2 两个概念的关系
- CQRS(命令查询职责分离):写走命令模型、读走查询模型,两者可不同结构、不同存储
- 事件溯源(Event Sourcing):不存当前状态,只存「状态变化的事件序列」,状态由事件重放得出
两者常一起用但不绑定:可以只做 CQRS 不做事件溯源,也可以只做事件溯源而读模型仍是同一张表。
1.3 代价先讲清楚
事件溯源不是银弹:schema 演进变难、查询需要投影、最终一致带来心智负担、调试要按事件流看。只有当「审计 / 时序 / 事件驱动」是核心需求时才值得上。
记忆:CQRS = 读写模型分离,事件溯源 = 只存事件、状态靠重放;两者可独立使用;只有「审计 + 时序 + 事件驱动」是核心需求时才值得付这份复杂度。
2. CQRS:命令与查询分离
2.1 模型分离
写侧:Command → CommandBus → Handler → 聚合根 → 事件 → 事件存储
↓(异步)
读侧:Query → ReadModel(投影表 / 搜索索引 / 缓存)
2.2 命令与查询的差异
| 维度 | 命令(Command) | 查询(Query) |
|---|---|---|
| 语义 | 改变状态 | 只读 |
| 返回 | 通常无返回(或仅 id) | 返回数据 |
| 幂等 | 需显式设计 | 天然 |
2.3 一个命令类
final readonly class PlaceOrder
{
public function __construct(
public string $orderId,
public string $customerId,
public array $items, // [['sku' => 'A', 'qty' => 2, 'price' => 1000]]
) {}
}
命令是不可变的数据包——只描述意图,不含行为。
记忆:CQRS = 写侧走 Command→Handler→聚合根,读侧走 Query→读模型;命令是「不可变意图包」、有业务校验与幂等要求,查询只读、天然幂等。
3. 命令总线与处理器
3.1 总线实现
final class SimpleCommandBus
{
private array $handlers = []; // 命令类 => 处理器
public function register(string $c, callable $h): void { $this->handlers[$c] = $h; }
public function dispatch(object $command): mixed
{
$handler = $this->handlers[$command::class]
?? throw new RuntimeException('未注册处理器: ' . $command::class);
return $handler($command);
}
}
3.2 处理器
final class PlaceOrderHandler
{
public function __construct(
private OrderRepository $orders,
private EventBus $events,
) {}
public function __invoke(PlaceOrder $command): void
{
$order = Order::place($command->orderId, $command->customerId, $command->items);
$this->orders->save($order); // 追加事件到事件存储
$this->events->publish(...$order->releaseEvents());
}
}
3.3 中间件:总线真正的价值
裸总线没多大意义,价值在于中间件管道——事务、日志、重试、校验都能插进去:TransactionMiddleware 包住事务、LoggingMiddleware 记录命令、ValidationMiddleware 做校验,按顺序组成洋葱模型。
记忆:命令总线 = 命令类 → 处理器映射 + 中间件管道;真正的价值在中间件(事务/日志/校验/重试),而不是「dispatch 转发」本身。
4. 事件溯源:事件即真相
4.1 从「状态」到「事件」
CRUD 表只存 orders(id, status, total, updated_at);事件溯源存的是发生了什么:
| version | event_type | payload |
|---|---|---|
| 1 | OrderPlaced | items 与 total |
| 2 | ItemRemoved | sku |
| 3 | OrderConfirmed | — |
当前状态 = 把事件按 version 顺序 apply 一遍的结果。
4.2 事件是「过去式事实」
final readonly class OrderPlaced
{
public function __construct(
public string $orderId,
public string $customerId,
public array $items,
public int $total,
public DateTimeImmutable $occurredAt,
) {}
}
事件命名用过去式(OrderPlaced、PaymentCaptured)——它描述的是「已经发生的事实」,不可修改、不可删除。
4.3 三条铁律
| 铁律 | 含义 |
|---|---|
| 只追加 | 事件永不 UPDATE / DELETE |
| 有序 | 同一聚合内按 version 严格递增 |
记忆:事件溯源 = 只追加不可变的事件流,当前状态靠
apply重放得出;事件命名用过去式、同一聚合内 version 严格递增、永不修改删除。
5. 聚合根与不变量
5.1 聚合根基类
abstract class AggregateRoot
{
protected int $version = 0;
private array $events = [];
protected function apply(object $event): void
{
$this->applyEvent($event); // 纯状态变换
$this->events[] = $event; // 记录待发布
$this->version++;
}
abstract protected function applyEvent(object $event): void;
public function releaseEvents(): array
{
return tap($this->events, fn () => $this->events = []);
}
}
5.2 订单聚合
final class Order extends AggregateRoot
{
private string $status = 'new';
public static function place(string $id, string $customerId, array $items): self
{
if ($items === []) { throw new DomainException('订单不能为空'); } // 不变量校验
$order = new self();
$order->apply(new OrderPlaced($id, $customerId, $items, 0, new DateTimeImmutable()));
return $order;
}
public function confirm(): void
{
if ($this->status !== 'new') {
throw new DomainException('只有新订单可以确认'); // 状态不变量
}
$this->apply(new OrderConfirmed($this->id, new DateTimeImmutable()));
}
protected function applyEvent(object $event): void
{
if ($event instanceof OrderConfirmed) { $this->status = 'confirmed'; }
}
}
5.3 关键原则
不变量校验放在「产生事件的命令方法」里,而不是 applyEvent 里——因为 applyEvent 在重放历史事件时也会被调用,此时不能抛异常。
记忆:聚合根是事务一致性边界——命令方法先校验不变量再
apply事件;applyEvent只做纯状态变换、供重放复用,绝不能在其中抛业务异常。
6. 事件存储设计
6.1 表结构与乐观并发
CREATE TABLE event_store (
aggregate_id CHAR(36) NOT NULL,
version INT NOT NULL,
event_type VARCHAR(100) NOT NULL,
payload JSON NOT NULL,
occurred_at DATETIME(3) NOT NULL,
PRIMARY KEY (aggregate_id, version) -- 复合主键 = 乐观并发控制
);
6.2 追加事件
public function append(string $aggregateId, int $expectedVersion, array $events): void
{
$this->pdo->beginTransaction();
try {
$stmt = $this->pdo->prepare(
'INSERT INTO event_store (aggregate_id, version, event_type, payload, occurred_at)
VALUES (?, ?, ?, ?, ?)'
);
foreach ($events as $i => $event) {
$stmt->execute([
$aggregateId, $expectedVersion + $i + 1, $event::class,
json_encode($event, JSON_THROW_ON_ERROR), date('Y-m-d H:i:s.v'),
]);
}
$this->pdo->commit();
} catch (PDOException $e) {
$this->pdo->rollBack();
if ($e->getCode() === '23000') { // 唯一键冲突 = 并发修改
throw new ConcurrencyException('聚合已被并发修改,请重试');
}
throw $e;
}
}
复合主键 (aggregate_id, version) 就是乐观锁:两个并发命令写同一 version,后者必然冲突,从而防止「丢失更新」。加载时按 version 升序重放即可重建聚合。
记忆:事件存储 = (aggregate_id, version) 复合主键 + 只追加;复合主键天然实现乐观并发,冲突抛 ConcurrencyException;加载时按 version 升序重放。
7. 投影与读模型
7.1 投影是什么
投影(Projection)是「把事件流折叠成查询友好的表」的消费者:OrderPlaced → 插入 order_summary;OrderConfirmed → 更新状态。
final class OrderSummaryProjector
{
public function __construct(private PDO $pdo) {}
public function handle(object $event): void
{
match (true) {
$event instanceof OrderPlaced => $this->pdo->prepare(
'INSERT INTO order_summary (id, customer_id, status, total) VALUES (?, ?, "new", ?)'
)->execute([$event->orderId, $event->customerId, $event->total]),
$event instanceof OrderConfirmed => $this->pdo->prepare(
'UPDATE order_summary SET status = "confirmed" WHERE id = ?'
)->execute([$event->orderId]),
default => null,
};
}
}
7.2 读模型可以有好几个
| 读模型 | 用途 | 存储 |
|---|---|---|
| order_summary | 订单列表页 | MySQL |
| order_search | 全文搜索 | Elasticsearch |
同一事件流喂给多个投影器,各自独立、互不影响——这正是 CQRS 读侧的价值:为每种查询定制最优结构。
记忆:投影 = 事件流 → 读模型的消费者;同一事件流可喂多个投影器(MySQL 列表 / ES 搜索 / Redis 统计),读侧各取所需、互不干扰。
8. 事件版本、快照与重放
8.1 事件版本升级
业务演进会改事件结构(如 OrderPlaced 加字段)。做法是新增事件类型而非改旧事件,读侧对老事件做「向上转换(upcasting)」补默认值。原则:已写入的事件是不可变历史,兼容靠 upcasting,绝不回头改老事件。
8.2 快照与重放
聚合事件多了之后,每次加载都要重放上千个事件。快照把「某一 version 的聚合状态」存下来,加载时从最近快照 + 之后的事件恢复:
$snap = $snapshots->latestFor($aggregateId); // {version: 500, state: {...}}
$order = Order::fromSnapshot($snap->state);
foreach ($events->since($aggregateId, $snap->version) as $e) { $order->apply($e); }
| 场景 | 做法 |
|---|---|
| 新增读模型 | 从头重放全部事件,重建投影表 |
| 修复投影 bug | 清空投影表后重放 |
重放能力是事件溯源最大的红利——你可以「回到过去重新计算」。前提是投影器必须幂等且可从零重建。
记忆:事件演进靠「新增类型 + 向上转换」,绝不改历史事件;快照 = 某 version 的聚合状态,加载时快照 + 增量事件;重放是最大红利,但投影器必须幂等且能从零重建。
9. 最终一致性与实战落地
9.1 最终一致的心智
命令写入后,读模型不是立刻可见的——投影是异步消费者。这带来两个经典现象:写后读不一致(用户刚下单,列表页还没显示)与重复投递(投影器可能收到同一事件两次)。
| 现象 | 对策 |
|---|---|
| 写后读不一致 | 关键路径读主模型 / 返回 commandId 让前端轮询 |
| 重复投递 | 投影器按 event id 幂等(唯一索引) |
| 投影落后或失败 | 监控 lag 告警 + 重试 + 死信队列 |
9.2 Laravel 落地与边界
事件发布走 Laravel 事件系统 + 队列,投影器作为 ShouldQueue 的 Listener,内部做幂等更新。务实建议:不要一上来就全量事件溯源。可以先对核心聚合(订单/支付/库存)用事件溯源,其余仍是 CRUD;读侧先读主模型,读模型作为旁路逐步接入。以下场景不该用:团队不熟悉 DDD/事件驱动、业务就是简单 CRUD、强一致是硬要求(如账户余额扣减)。
记忆:CQRS 读侧最终一致——写后读可能不一致、事件可能重复投递,对策是「关键路径读主模型 + 投影器幂等 + lag 监控 + 死信重试」;落地要渐进,别一上来就全量事件溯源。
10. 速查表与一句话记忆
| 概念 | 要点 |
|---|---|
| CQRS | 写走 Command、读走 Query,模型可分离 |
| 命令总线 | 命令→处理器映射 + 中间件管道 |
| 事件 | 过去式命名、不可变、只追加 |
| 聚合根 | 一致性边界,命令方法校验不变量 |
| applyEvent | 纯状态变换,供重放,不抛业务异常 |
| 事件存储 | (aggregate_id, version) 复合主键 |
| 乐观并发 | 版本冲突抛 ConcurrencyException |
| 投影 | 事件流 → 读模型,可多个、须幂等 |
| 快照 | 某 version 状态,加速加载 |
| 重放 | 重建读模型的最大红利 |
一句话记忆:CQRS 把写(Command→总线→聚合根)与读(Query→投影读模型)分开;事件溯源只追加不可变事件、状态靠重放得出——聚合根在命令方法里校验不变量、applyEvent 只做纯状态变换;事件存储用 (aggregate_id, version) 复合主键实现乐观并发;投影器幂等、可多路、可重放重建,读侧接受最终一致(关键路径读主模型 + lag 监控 + 死信重试);先对核心聚合试点,别全量上。
延伸阅读
继续阅读
探索更多技术文章
浏览归档,发现更多关于系统设计、工具链和工程实践的内容。