Scaling Laravel: Mastering CQRS Architecture 🏗️のアイキャッチ画像

Prajapati Paresh

従来のCRUDのボトルネック

標準的なLaravelアプリケーションを構築する際、通常はCRUD(Create, Read, Update, Delete)のパラダイムに従います。単一のUserモデルと単一のUserControllerが存在します。このコントローラーはデータの書き込み(ユーザー作成)とデータの読み取り(ユーザーリストの取得)の両方を処理します。内部的には、読み取りと書き込みの両方の操作が同じMySQLまたはPostgreSQLデータベーステーブルとやり取りします。

ほとんどのアプリケーションではこれで問題ありません。しかし、高トラフィックのエンタープライズシステムでは、ワークロードはほとんど対称的ではありません。レポートダッシュボードやeコマースプラットフォームでは、アプリケーションが1回の「書き込み」クエリに対して1,000回の「読み取り」クエリを実行する可能性があります。読み取りと書き込みの両方に同じデータベースとEloquentモデルを使用する場合、行をロックしトランザクションの整合性を必要とする重い書き込み操作が、高速な読み取り操作をブロックし始め、アプリケーションが遅くなってしまいます。

Smart Tech Devsでは、大量のスケーラビリティを必要とするプラットフォームを設計する際、CQRS(Command Query Responsibility Segregation)を実装しています。CQRSは、データを変更する操作(Command)とデータを読み取る操作(Query)を厳密に分離するアーキテクチャパターンです。

CQRSの理念

CommandとQueryを分離することで、それぞれを独立して最適化できます。

  • Command(書き込み):複雑なビジネスバリデーション、ドメインロジック、トランザクションの整合性を処理します。データ(成功の確認以外)を返しません。
  • Query(読み取り):複雑なドメインロジックを完全にバイパスします。Redisなどの高度に最適化された非正規化リードレプリカやキャッシュレイヤーから、可能な限り高速にデータを取得します。

フェーズ1:Commandスタックの構築

注文処理システムを設計してみましょう。ユーザーが注文する際、在庫確認、支払い処理、ステータス変更を含む複雑な書き込み操作が発生します。これをCommandオブジェクトとCommand Handler内に完全にカプセル化します。


namespace App\CQRS\Commands;

// 1. The Command: A simple Data Transfer Object (DTO)
class PlaceOrderCommand
{
    public function __construct(
        public readonly string $userId,
        public readonly array $items,
        public readonly string $paymentToken
    ) {}
}

次にHandlerを構築します。このクラスはビジネスロジックを実行します。作成されたOrderオブジェクトを返さず、単に変更を実行することに注意してください。


namespace App\CQRS\Handlers;

use App\CQRS\Commands\PlaceOrderCommand;
use App\Models\Order;
use Illuminate\Support\Facades\DB;

class PlaceOrderHandler
{
    public function handle(PlaceOrderCommand $command): void
    {
        DB::transaction(function () use ($command) {
            // 1. Validate inventory (Domain Logic)
            // 2. Process Payment via Stripe
            
            // 3. Persist to the "Write" Database
            $order = Order::create([
                'user_id' => $command->userId,
                'status' => 'processing',
                'total' => calculateTotal($command->items)
            ]);

            // 4. Fire an event to sync the Read Database later
            OrderPlaced::dispatch($order);
        });
    }
}

フェーズ2:Queryスタックの構築

次に読み取り側を見てみましょう。ユーザーが注文履歴を表示する場合、複雑なドメインロジックは必要なく、Eloquentのオブジェクトハイドレーションのオーバーヘッドも必ずしも必要ありません。必要なのは生の速度だけです。リクエストを定義するためのQueryオブジェクトと、データを取得するためのQuery Handlerを作成します。


namespace App\CQRS\Queries;

class GetUserOrdersQuery
{
    public function __construct(public readonly string $userId) {}
}

namespace App\CQRS\Handlers;

use App\CQRS\Queries\GetUserOrdersQuery;
use Illuminate\Support\Facades\DB;

class GetUserOrdersHandler
{
    public function handle(GetUserOrdersQuery $query): array
    {
        // 1. We bypass Eloquent entirely for maximum read speed.
        // 2. We query from the optimized "Read" database connection.
        return DB::connection('mysql_read')
            ->table('user_order_views')
            ->where('user_id', $query->userId)
            ->orderBy('created_at', 'desc')
            ->get()
            ->toArray();
    }
}

フェーズ3:物理的なデータベース分離

CQRSの真の力は、データベースを物理的に分離したときに発揮されます。Laravelでは、config/database.phpファイルでプライマリ/レプリカ接続を設定できます。データをプライマリデータベースに書き込み、レプリカからデータを読み取ります。


'mysql' => [
    'read' => [
        'host' => ['192.168.1.2', '192.168.1.3'], // Read Replicas
    ],
    'write' => [
        'host' => ['192.168.1.1'], // Primary Master
    ],
    'driver' => 'mysql',
    'database' => env('DB_DATABASE'),
    'username' => env('DB_USERNAME'),
    'password' => env('DB_PASSWORD'),
    // ...
],

注文が作成されると(Command)、書き込みデータベースに保存されます。以前にディスパッチされたOrderPlacedイベントが非同期バックグラウンドワーカーをトリガーします。このワーカーは、リードレプリカ上の高度に非正規化されたuser_order_viewsテーブルを更新します。これはEventual Consistencyと呼ばれます。読み取りデータベースは書き込みデータベースよりミリ秒単位で遅れる可能性がありますが、その代わりに、プライマリテーブルをロックすることなく、数万の同時読み取りを処理できます。

エンジニアリングROI

LaravelでCQRSを採用するのは、シンプルなブログや内部ツールには適していません。紛れもないアーキテクチャの複雑さをもたらします。しかし、高スループットのレポート処理、大量のユーザー同時実行、または複雑なドメインルールを扱うエンタープライズシステムでは、ゲームチェンジャーとなります。読み取りと書き込みを分離することで、データベースインフラストラクチャを非対称にスケールできます。1台の強力なプライマリ書き込みサーバーを維持しながら、10台の安価なリードレプリカサーバーを立ち上げることができます。さらに、コードベースは高度に整理されます。複雑なレポートクエリを最適化する開発者が、意図せずコアの支払い処理ロジックを破壊することはありません。2つの懸念事項は物理的・論理的に分離されているからです。