十年匠心定制 · 商业建站与技术教学双线并行 咨询热线:400-886-1026 service@lmnt.cn
ARTICLE DETAIL

资讯详情

深耕网站建设与运营推广的一线实战洞察。

PHP项目实战:CQRS架构模式如何解决读写性能瓶颈与数据一致性难题

PHP项目实战:CQRS架构模式如何解决读写性能瓶颈与数据一致性难题 前阵子团队接手了一个已经跑了两年的PHP图书管理系统刚进来的时候代码看着还行MVC分层规规矩矩的。结果一查线上慢查询日志问题全出来了用户查一本书的借阅历史要join六张表而读者还书的写操作被这些复杂查询拖到几百毫秒甚至秒级。更要命的是产品那边不断加新需求图书推荐、借阅统计、逾期分析全都往同一个“万能查询”里塞越塞越乱。后来我们决定在系统里逐步引入CQRS模式。这篇文章就是把这次重构的完整思路和落地过程整理出来包括Command与Query的契约怎么定、命令总线和查询总线怎么写、读写模型怎么拆分、队列异步化怎么做、事务边界和幂等性怎么处理以及我们踩过的几个真坑。如果你是PHP开发者手头项目也到了“查询拖垮写入、业务逻辑和查询逻辑缠成一团”的阶段这篇应该能帮上忙。1. 先给CQRS“卸妆”它到底解决什么问题CQRS全称是Command Query Responsibility Segregation中文一般叫“命令查询职责分离”。它不是某个具体框架而是一种架构思想核心就一句话改变系统状态的操作Command和读取系统状态的操作Query要分开建模而不是共用同一套领域模型和同一个数据访问通道。这话听起来简单但很多人把它理解成“把Service层拆成两部分”那种做法根本没吃到CQRS的甜头。我们真正要拆的是模型、数据通道、甚至数据库实例。我下面详细拆解。1.1 传统MVC架构下代码是怎么一步步变乱的传统MVC里Controller调用ServiceService操作ModelModel对应一张表每个Model既要做增删改又要承担各种查询。开发初期没啥问题但业务复杂起来矛盾就暴露了。我拿图书管理系统举例。当时有一个Book模型既要负责借书、还书、预约这种会改变库存和状态的写操作又要给前端提供“图书列表”“热门排行”“用户借阅历史”这些查询数据。结果一个模型上挂了十几个方法有的方法还特别重。最典型的代码长这样class BookService { public function getBookListWithBorrowInfo(array $filters) { // 先查图书表 // 再关联作者表 // 再关联借阅记录表 // 再关联读者表 // 如果有条件再查预约表 // 最后还要统计逾期天数 } }这种“万能查询方法”越来越多最终结果就是数据库压力全压在主库上一个页面请求会触发十几次关联查询而关键的借书操作反而要跟这些复杂查询抢资源。读操作拖垮了写操作这就是传统MVC在读写混合场景下的通病。1.2 CQRS的核心思路写一条路读一条路CQRS的思路看起来很“不经济”——同一份业务数据偏要维护两套模型。但正是这种“浪费”带来了巨大的灵活性。在CQRS架构下写操作走Command路线客户端发一个BorrowBookCommand命令总线把它路由到对应的HandlerHandler里执行真正的业务校验、修改写模型、落库、发布领域事件。读操作走Query路线客户端发一个GetBookBorrowHistoryQuery查询总线把它路由到查询HandlerHandler从读模型可能是独立表、独立库甚至是全文索引里取数据直接返回给前端。两条路互不干扰写模型可以追求业务规则的正确性和强一致性读模型可以怎么快怎么来。1.3 什么时候不要上CQRS这一点必须先说清楚因为CQRS被滥用的情况太多了。我们当时就约定如果系统是纯粹的CRUD读写逻辑天然对称、查询就是按主键取出一条数据进行展示那就不要上CQRS。上了只会把代码量翻倍维护成本成倍增加。CQRS适合的场景有几个特征读多写少、读写负载差异大、查询参数变化频繁、同一个数据实体在不同页面展示的形态差异极大以及写入侧有复杂业务规则需要保证强一致。如果你的项目符合两三条再认真考虑。如果只是觉得“CQRS听起来很高级想试试”那我劝你冷静。2. 方案选型与技术栈确定要用CQRS之后第一个问题是用现成库还是自己写现在PHP社区有一些现成的CQRS库比如Prooph、Ecotone之类。但我们当时的判断是项目已经跑了两三年框架用的是老版本现成库对新老版本的兼容性和项目结构定制支持都有限。我们最终决定自己写一个轻量级的CQRS骨架就一百来行核心代码但在我们自己的业务场景里跑得很顺。2.1 项目现状与技术背景这套图书管理系统的基本情况如下运行环境PHP 7.4 MySQL 5.7部署在普通云服务器上核心框架原生PHP没有使用Laravel或Symfony业务量注册读者15万藏书30万册日均请求量8万左右在业务高峰时段借书还书操作响应时间平均在600ms以上其中大量时间消耗在数据库查询上。系统没有使用Redis等缓存组件也没有队列系统。这就是我们引入CQRS时的起点——基础设施比较简陋能靠架构优化就不要先堆组件。2.2 为什么没有选择现成的CQRS框架当时我们做了个简单的技术调研对比了Prooph和Ecotone。Prooph功能完整但它的Event Store、Snapshot等模块对我们这种轻量场景来说偏重Ecotone很现代但要求PHP 8.0以上我们还在PHP 7.4。为了一个架构模式去升级PHP版本风险太大。更重要的是CQRS本身的核心逻辑并不复杂真正复杂的是围绕它构建的事务、持久化、消息队列和幂等策略。这些在不同项目中差异极大现成框架很难完全契合。最后我们决定自己实现一个最小可用版本把核心总线逻辑控制在100行左右其余部分完全按我们的业务需求来扩展。2.3 目录结构与模块划分项目目录结构如下这是我们在实践后逐渐沉淀下来的形态src/ ├── Application/ │ ├── Command/ │ │ ├── Book/ │ │ │ ├── BorrowBookCommand.php │ │ │ └── BorrowBookHandler.php │ │ └── Member/ │ ├── Query/ │ │ ├── Book/ │ │ │ ├── GetBookBorrowHistoryQuery.php │ │ │ └── GetBookBorrowHistoryHandler.php │ ├── Bus/ │ │ ├── CommandBus.php │ │ ├── QueryBus.php │ │ └── Middleware/ │ └── Service/ ├── Domain/ │ ├── Model/ │ ├── Repository/ │ └── Event/ ├── Infrastructure/ │ ├── Persistence/ │ ├── MessageQueue/ │ └── ReadModel/ └── UI/ ├── Http/ └── Console/注意看Application层里把Command和Query分成了两棵独立的目录树各自负责自己的Handler。Domain层主要放领域模型和领域事件。Infrastructure层负责读模型、消息队列的落地实现。这个结构不是设计出来的是根据实际需要一步步长出来的。3. 从头手写一个轻量CQRS骨架这一部分是实践的核心。我们用了最原始的方式实现Command Bus和Query Bus没有引入任何外部依赖。好处是对整个执行链路完全透明出现问题可以直接定位。3.1 Command与Query的契约在写总线之前先定义Command和Query的结构。Command代表一个用户意图它天然是名词加动词的组合比如“借书”“还书”“预约”。我们需要一个接口来统一约束?php declare(strict_types1); namespace App\Application\Command; interface CommandInterface { /** * 返回命令的唯一标识 * 在日志追踪和幂等判断时使用 */ public function getCommandId(): string; }所有Command类都实现这个接口并且必须生成一个唯一的commandId。当时为了这个ID我们想了很久最后用的是UUID函数简单直接。这个ID在后面做幂等处理时帮了大忙。Query接口更简单不需要commandId因为查询不会改变系统状态也不需要做幂等interface QueryInterface { }单纯看代码会觉得这两个接口太简单了但它们提供的约束恰恰是CQRS的起点命令是有身份、有意图的查询只是个问题。3.2 Command Bus的实现Command Bus的职责是把一个Command对象交给对应的Handler去处理。最简单的实现就是一个方法?php declare(strict_types1); namespace App\Application\Bus; use App\Application\Command\CommandInterface; use Psr\Container\ContainerInterface; class CommandBus { private ContainerInterface $container; private array $middlewares []; public function __construct(ContainerInterface $container) { $this-container $container; } public function addMiddleware(callable $middleware): void { $this-middlewares[] $middleware; } public function dispatch(CommandInterface $command): void { $handler $this-resolveHandler($command); $dispatch function (CommandInterface $command) use ($handler) { // 在实际项目中这里应该做事务包裹后面专门讲 $handler-handle($command); }; // 从后往前逐个包裹中间件 $middlewares array_reverse($this-middlewares); foreach ($middlewares as $middleware) { $dispatch fn($command) $middleware($command, fn($cmd) $dispatch($cmd)); } $dispatch($command); } private function resolveHandler(CommandInterface $command): object { $class get_class($command); // 约定优于配置BorrowBookCommand BorrowBookHandler $handlerClass str_replace(Command, Handler, $class); if (!$this-container-has($handlerClass)) { throw new \RuntimeException(没有找到 {$handlerClass} 对应的Handler); } return $this-container-get($handlerClass); } }在这个实现里有两个设计点值得说明。第一Handler的解析用的是“约定优于配置”。BorrowBookCommand对应的Handler就是BorrowBookHandler不需要在配置文件里逐个注册。一开始我们觉得这个太“魔法”但用了一段时间之后发现这个约定大大提高了开发效率。因为Command和Handler本来就该是一一对应的关系不需要让开发者去配置这种映射。第二中间件机制是整个总线的灵魂。日志、事务、幂等校验都可以做成中间件挂载在总线上而不是写进Handler内部。这使得Handler可以保持纯粹只关注业务逻辑。3.3 Query Bus的实现Query Bus的实现方式和Command Bus几乎一样只是返回类型不同。Command执行后没有返回值Query执行后必须返回数据?php declare(strict_types1); namespace App\Application\Bus; use App\Application\Query\QueryInterface; use Psr\Container\ContainerInterface; class QueryBus { private ContainerInterface $container; public function __construct(ContainerInterface $container) { $this-container $container; } public function dispatch(QueryInterface $query): mixed { $handler $this-resolveHandler($query); return $handler-handle($query); } private function resolveHandler(QueryInterface $query): object { $class get_class($query); $handlerClass str_replace(Query, Handler, $class); if (!$this-container-has($handlerClass)) { throw new \RuntimeException(没有找到 {$handlerClass} 对应的Handler); } return $this-container-get($handlerClass); } }有人可能会问查询为什么要走总线直接实例化Handler不行吗这里的关键在于查询处理器经常需要做缓存处理、权限校验、数据脱敏这些横切逻辑如果写在Handler里就会导致Handler变得臃肿。走总线之后这些都可以做成中间件统一处理。我们后来在Query Bus上挂了一个缓存中间件根据Query参数生成缓存键命中就直接返回效果非常明显。3.4 Handler的注册与依赖注入我们的项目没有用Composer的PSR-4容器而是自己写了一个极简的依赖注入容器能力非常有限但支持按类名实例化和构造器参数递归解析。这是容器注册的关键代码?php declare(strict_types1); namespace App\Infrastructure\Container; use ReflectionClass; class SimpleContainer { private array $instances []; public function set(string $key, object $value): void { $this-instances[$key] $value; } public function get(string $class): object { if (isset($this-instances[$class])) { return $this-instances[$class]; } $reflection new ReflectionClass($class); $constructor $reflection-getConstructor(); if ($constructor null) { $this-instances[$class] $reflection-newInstance(); return $this-instances[$class]; } $params $constructor-getParameters(); $dependencies array_map(function ($param) { $type $param-getType(); if ($type null || $type-isBuiltin()) { throw new \RuntimeException(无法解析参数: {$param-getName()}); } return $this-get($type-getName()); }, $params); $this-instances[$class] $reflection-newInstanceArgs($dependencies); return $this-instances[$class]; } }这个容器到后面会越来越不像容器更像一个服务定位器。但对当时的项目来说够用了而且把依赖关系暴露得很直接团队成员看代码就能明白Handler依赖了哪些服务。4. 实战改造图书管理系统的读写模型拆分理论说得再漂亮也要落到具体代码里。这一节我们完整过一遍图书管理系统中借书这个场景的改造过程从Controller一直到读模型整个链路都拆开来看看。4.1 借书命令的完整链路改造前借书操作在Controller里直接调用一个巨大的BorrowService方法里面既要做业务逻辑还要拼装各种查询数据。改造后Controller变得非常干净?php declare(strict_types1); namespace App\UI\Http\Controller; use App\Application\Command\Member\BorrowBookCommand; use App\Application\Bus\CommandBus; class BorrowController { public function __construct( private CommandBus $commandBus ) { } public function borrow(Request $request): Response { $command new BorrowBookCommand( commandId: Uuid::uuid4()-toString(), bookId: (int)$request-get(book_id), memberId: (int)$request-get(member_id), dueDate: new \DateTimeImmutable($request-get(due_date)) ); $this-commandBus-dispatch($command); return new Response(202, [message 操作已受理]); } }Command对象本身是个简单的DTO包含了所有业务操作所需的数据。注意它只携带数据没有任何业务逻辑。这个Command对象创建后被CommandBus分发到对应的Handler处理。对应Handler的代码?php declare(strict_types1); namespace App\Application\Command\Member; use App\Domain\Model\Book; use App\Domain\Model\Member; use App\Domain\Repository\BookRepositoryInterface; use App\Domain\Repository\MemberRepositoryInterface; use App\Domain\Event\BookBorrowedEvent; use App\Infrastructure\Event\EventDispatcher; class BorrowBookHandler { public function __construct( private BookRepositoryInterface $bookRepository, private MemberRepositoryInterface $memberRepository, private EventDispatcher $eventDispatcher ) { } public function handle(BorrowBookCommand $command): void { $book $this-bookRepository-find($command-bookId); $member $this-memberRepository-find($command-memberId); if ($book null) { throw new \DomainException(图书不存在); } if ($member null) { throw new \DomainException(读者不存在); } $book-borrow($member, $command-dueDate); $this-bookRepository-save($book); $this-eventDispatcher-dispatch(new BookBorrowedEvent( $book-getId(), $member-getId(), $command-dueDate, $command-getCommandId() )); } }Handler的方式很直白先取领域模型做业务校验执行领域方法修改状态然后通过Repository持久化最后发布领域事件。这个Handler不关心前端展示什么数据不关心借阅历史里有什么内容它只做一件事改变系统状态。4.2 读模型专门为查询设计的投影表如果按传统思路查询“某个读者的借阅历史”要去实时join图书表、借阅表、读者表。CQRS改造后我们为这个查询专门建了一张投影表book_borrow_history_projection它的结构几乎就是前端页面需要的数据形态CREATE TABLE book_borrow_history_projection ( id bigint(20) unsigned NOT NULL AUTO_INCREMENT, borrow_id bigint(20) unsigned NOT NULL, member_id bigint(20) unsigned NOT NULL, member_name varchar(50) NOT NULL, book_id bigint(20) unsigned NOT NULL, book_title varchar(200) NOT NULL, book_author varchar(100) NOT NULL, borrowed_at datetime NOT NULL, due_date datetime NOT NULL, returned_at datetime DEFAULT NULL, status varchar(20) NOT NULL DEFAULT borrowed, created_at datetime NOT NULL DEFAULT CURRENT_TIMESTAMP, PRIMARY KEY (id), KEY idx_member_status (member_id, status), KEY idx_book_id (book_id) ) ENGINEInnoDB DEFAULT CHARSETutf8mb4;这张表的所有字段都是展示所需查询Hander不需要做任何join?php declare(strict_types1); namespace App\Application\Query\Member; use App\Infrastructure\Persistence\ReadModel\BookBorrowHistoryProjectionRepository; class GetMemberBorrowHistoryHandler { public function __construct( private BookBorrowHistoryProjectionRepository $projectionRepository ) { } public function handle(GetMemberBorrowHistoryQuery $query): array { return $this-projectionRepository-findByMember( $query-memberId, $query-status, $query-page, $query-limit ); } }4.3 数据同步事件驱动还是队列驱动读模型和写模型分离之后核心问题来了投影表的数据从哪来怎么保证它和写模型一致我们根据数据实时性要求把数据同步分成两条路线。对于实时性要求高的数据比如借阅成功后立即展示的“当前借阅状态”我们采用基于事件同步。在BookBorrowedEvent发布时事件监听器立即更新投影表?php declare(strict_types1); namespace App\Infrastructure\ReadModel\Projector; use App\Domain\Event\BookBorrowedEvent; use App\Infrastructure\Persistence\ReadModel\BookBorrowHistoryProjectionRepository; class BookBorrowedProjector { public function __construct( private BookBorrowHistoryProjectionRepository $projectionRepository ) { } public function __invoke(BookBorrowedEvent $event): void { $this-projectionRepository-add( $event-getBorrowId(), $event-getMemberId(), $event-getMemberName(), $event-getBookId(), $event-getBookTitle(), $event-getBookAuthor(), $event-getDueDate() ); } }对于实时性要求不高的数据比如图书推荐、阅读排行榜这种统计类查询我们用异步队列更新。因为统计聚合需要计算大量数据没有必要求一次算一次。队列消费端定时从消息里取出事件重新投影。这一块我们在后面单独出一节详细讲。5. 事务边界、最终一致性与幂等处理CQRS模式落地过程中最容易出问题的就是数据一致性。如果一个操作用了CQRS但没处理好事务边界和幂等系统的一致性会非常脆弱。这里把我们的经验和教训都整理出来。5.1 事务的边界到底画在哪里我们在刚开始实践CQRS的时候一个最直观的问题是事务应该包在哪里是包在整个Handler外面还是包在Repository操作里面我们的结论是事务边界就是写模型持久化的边界它是Repository的具体实现细节不应该出现在Command Handler的业务逻辑中。在SimpleContainer里我们给Repository的实现加了一个基于数据库连接的封装?php declare(strict_types1); namespace App\Infrastructure\Persistence; use PDO; class TransactionManager { private PDO $pdo; public function __construct(PDO $pdo) { $this-pdo $pdo; } public function begin(): void { $this-pdo-beginTransaction(); } public function commit(): void { $this-pdo-commit(); } public function rollback(): void { $this-pdo-rollBack(); } public function run(callable $callback): mixed { $this-begin(); try { $result $callback(); $this-commit(); return $result; } catch (\Throwable $e) { $this-rollback(); throw $e; } } }在Command Bus的中间件里使用它?php declare(strict_types1); namespace App\Application\Bus\Middleware; use App\Infrastructure\Persistence\TransactionManager; use App\Application\Command\CommandInterface; class TransactionMiddleware { public function __construct( private TransactionManager $transactionManager ) { } public function __invoke(CommandInterface $command, callable $next): void { $this-transactionManager-run(function () use ($command, $next) { $next($command); }); } }为什么事务要放在中间件因为在Handler里开启事务会造成大量重复代码同时把事务和业务逻辑耦合在一起以后想换成分布式事务或者接入消息队列事务都得动业务代码。放在中间件里业务代码完全无感知。5.2 读模型更新失败最终一致性的核心问题读模型是独立于写模型之外的一张表更新它和更新写模型不在同一个事务里。如果投影表更新失败就会出现写模型已经改变了但读模型还是旧数据。这种问题不能简单靠“重试”解决。因为重试可能是不可靠的。我们的方案是记录事件投递状态配合补偿机制保证最终一致。实现思路是给事件表加上投递状态字段CREATE TABLE domain_events ( event_id char(36) NOT NULL, event_type varchar(100) NOT NULL, payload text NOT NULL, occurred_at datetime NOT NULL, published tinyint(1) NOT NULL DEFAULT 0, PRIMARY KEY (event_id) ) ENGINEInnoDB DEFAULT CHARSETutf8mb4;事件发布时先记录事件再异步投递给投影器。投影器成功处理后更新published字段。如果失败后台定时任务会扫描未发布的事件重新投递。这是一个最小可用的“outbox”模式变体虽然没有把事件发送和数据库操作放在同一个事务里但在我们的场景下已经能保证基本一致。注意真正的Outbox模式要求写入业务数据和事件数据在同一个事务里这样事件不会因为进程崩溃而丢失。我们当时的实现是个简化版如果你做金融类系统建议用严格的事务型Outbox。5.3 幂等性同一个命令不能执行两次命令的幂等处理非常关键。试想一下客户端点击了两次“借书”按钮如果每次点击都生成一个全新的Command那就有可能出现两本同样的书被同一个读者借走的异常情况。我们的做法是在应用层加一个幂等中间件利用Command自带的commandId做唯一性判断?php declare(strict_types1); namespace App\Application\Bus\Middleware; use App\Infrastructure\Persistence\IdempotencyStore; use App\Application\Command\CommandInterface; class IdempotencyMiddleware { public function __construct( private IdempotencyStore $store ) { } public function __invoke(CommandInterface $command, callable $next): void { $commandId $command-getCommandId(); if ($this-store-exists($commandId)) { // 已经处理过的命令直接返回不重复执行 return; } $next($command); $this-store-markProcessed($commandId); } }这个中间件需要和事务中间件组合使用顺序很重要。我们当时的中间件顺序是日志 - 幂等 - 事务 - Handler。为什么幂等在事务之前因为如果事务失败了不应该把commandId标记为已处理否则补偿逻辑就没法重新执行了。所以幂等中间件里的markProcessed必须等到事务提交成功之后再执行。我们后来干脆把这一步放在事务中间件里class TransactionMiddleware { public function __invoke(CommandInterface $command, callable $next): void { $this-transactionManager-run(function () use ($command, $next) { $next($command); }); // 事务提交成功后标记幂等 $this-idempotencyStore-markProcessed($command-getCommandId()); } }这个改动很小但避免了“事务失败但幂等已标记”的问题。实际线上跑下来借书还书的重复提交问题基本绝迹。6. 实战中的五类典型坑CQRS不是银弹实践过程中我们踩了不少坑。有些是CQRS本身容易踩的有些是PHP后端特有的问题。我把它们列出来希望对大家有帮助。6.1 把CQRS当成Service层的简单拆分这是最常见的误用。团队开始实践时习惯性地把原来的BookService拆成BookCommandService和BookQueryService但代码逻辑几乎没有变动——读操作还是去查写模型的表写操作还是直接操作数据库。这种“伪CQRS”不仅没有解决读写互相干扰的问题还白白增加了类的数量。CQRS的核心是模型和存储的分离不是类名的拆分。真正的CQRS至少要保证QueryHandler不再使用领域Entity而是使用专门的查询对象或投影表。如果没有这层分离就不要声称用了CQRS。6.2 读模型和写模型共用同一个数据库连接我们有一段时间Query Handler为了省事直接复用了写模型的数据连接。结果系统在高峰期还是会出现慢查询拖垮主库的情况。后来我们给读写分离建立了数据库账号级隔离写操作走主库账号只允许访问业务主库的写表查询走只读账号只允许访问投影表所在的库。如果查询Hander查询了不属于自己范围的表直接在数据库层就报错。这个“物理隔离”非常有效谁再不小心把查询写到主库表上系统立刻就能发现。6.3 事件处理失败导致投影数据不一致最惨的一次事故是这样的图书推荐位上线后推荐模块的投影器逻辑比较复杂在处理某类异常数据时频繁抛错。因为事件投递是异步的且没有失败告警导致推荐位上的数据一直不更新。等到我们发现时已经有一周没有人发现这个数据不对因为页面还能正常显示只是内容是旧的。这之后我们做了两件事。第一加了事件处理告警连续失败超过三次就通知值班人第二对投影器做了分批重跑机制每天晚上定时全量重算一遍推荐位数据把脏数据慢慢修正回来。现在想一下当时的Luck还是不错的因为图书推荐位不是关键业务。6.4 读模型与写模型字段不一致导致的坑投影表由事件驱动更新而有些事件没有携带足够的字段导致投影表里的数据不完整。比如BookBorrowedEvent最初只包括借阅ID、图书ID、读者ID、借阅时间但投影表还需要图书标题、作者、读者姓名。事件里没有这些字段投影器就只能去数据库里join查询又退回到老路子上。我们的教训是事件本身应该携带足够多的上下文信息而不是只携带ID。事件是历史上已经发生过的事实事实发生时的上下文必须完整记录下来。否则后来的投影器想用的时候发现缺数据非常被动。6.5 过度设计读模型和写模型分库在项目中期我们对CQRS越来越有自信一度考虑把读模型放在一个独立的MySQL实例上实现物理分库。但评估后发现投影表和主库表之间的数据同步网络开销反而比单库高而且运维复杂度上升不少。最终只在账号权限层面做了读写隔离物理分库的方案被搁置。CQRS的价值在于逻辑分离物理分库是量变引起质变之后的选项不是标配。过度设计很容易把一个本来清晰的问题搞复杂。7. 性能表现与优化方向改造完成上线后我们做了一轮压测数据比较能说明问题。以“查询某个读者的借阅历史”为例。改造前这个查询要实时关联5张表在数据量约15万读者、50万条借阅记录时平均响应时间在850ms左右。改造后查询直接走投影表单表查询命中索引平均响应时间降到了35ms提升了20多倍。借书操作在高峰期的耗时也从600ms降到了约180ms主要节省的是不再被复杂查询拖累主库的资源占用。因为借书事务本身涉及的操作不多延迟主要来自数据库锁竞争读操作分离之后锁竞争明显下降。实际项目的压测结果因数据规模、机器配置不同会有差异但CQRS对读多写少场景的优化方向是一致的。后续的优化方向有几个第一查询结果加Redis缓存。投影表数据更新频率不高加缓存很划算。但要注意缓存失效策略不能出现读者已经还书了查询结果还是“在借”的尴尬情况。第二引入消息队列。当前的事件投递是同步的靠定时任务补偿。如果将来数据量再上一个量级可以考虑引入RabbitMQ或者Redis Stream来做异步消息。这里不展开但在我们的规划列表里。第三按业务域拆分投影表。目前所有投影表都在一个schema下未来不同业务的查询负载差别变大时可以按业务域把投影表拆到不同的库进一步隔离压力。8. 回顾与个人的一点体会我在这个PHP项目里实践CQRS的最大体会是CQRS不是技术难题而是一个“取舍”决策。它放弃了“一份数据模型走天下”的便利换来了读写两侧的独立演进能力。在复杂的业务系统里这种取舍非常划算。如果让我给还在观望的团队一个建议我不会直接劝你用CQRS。我会先让团队写一个并发的、读多写少的业务模块把读写逻辑硬拆开然后观察一段时间。如果确实感觉到传统MVC的束缚——复杂查询拖慢写入、模型上挂满各种方法、测试难以覆盖——再引入CQRS也不迟。对PHP项目而言CQRS的启动成本并不高核心代码几十行就能搭起来关键是团队成员能不能接受“模型分离”这件事。项目当前跑得比较稳定下一步我们准备把借阅统计、图书推荐这类重查询彻底异步化同时把领域事件从同步分发改成真正的消息队列分发。过程会踩多少坑不好说但至少架构上已经留好了余量。
返回列表