PHP 的 CQRS 与事件溯源:命令总线、事件存储与投影

PHP 的 CQRS 与事件溯源实战:读写分离的动机、命令总线与处理器、聚合根与不变量保护、事件存储的追加与乐观并发、投影与读模型重建、事件版本升级与快照、重放与最终一致性、Laravel 落地与常见陷阱。

引言

大多数 PHP 项目用「一张表 + CRUD」就够了。但当业务长出审计要求、时序查询、多视图读模型时,CRUD 的「只存最终状态」就开始漏水——你无法回答「这个订单在三天前是什么状态」「这个余额是怎么一步步变成 800 的」。CQRS 与事件溯源给出的答案是:把「发生了什么」作为唯一真相记录下来,当前状态只是事件的投影。本文讲清这套架构在 PHP 里的落地方式。

前置:DDD 与分层架构、微服务架构、数据库与事务。


目录


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);事件溯源存的是发生了什么:

versionevent_typepayload
1OrderPlaceditems 与 total
2ItemRemovedsku
3OrderConfirmed—

当前状态 = 把事件按 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 监控 + 死信重试);先对核心聚合试点,别全量上。


延伸阅读

继续阅读

探索更多技术文章

浏览归档,发现更多关于系统设计、工具链和工程实践的内容。

全部文章 返回首页

「php」更多文章

  1. PHP 多租户 SaaS 架构:隔离策略、数据作用域与按租户计费
  2. PHP 支付集成实战:Stripe、支付宝与微信支付的状态机与回调
  3. Serverless PHP 与 Bref:Lambda 运行时、事件驱动与 Laravel Octane