1
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

DBや外部サービスをまたぐ処理で整合性を保つために気をつけていること

Posted at

こんにちは、ツーナです!

今日は、DB(データベース)や外部サービスをまたぐ処理に関して、データベースへの登録周りの実装をする際に、整合性をどのように担保しているかについて、実体験ベースで気をつけていることや、よく出会う罠、そしてその対処法についてまとめてみたいと思います。


なぜ整合性が重要か

整合性とは、「データが壊れていないこと」「矛盾していないこと」を意味します。
特に複数人や複数システムが同時にデータを扱うような場面では、
整合性が崩れると例えば以下のような問題が発生します。

  • 在庫が1個しかないのに2人が同時に購入してしまった
  • ポイント加算処理で同じ値が2回反映された
  • 外部決済システムとDBの状態が不一致になった
  • フォームで編集して保存したのに、他の人の編集で上書きされていた

こうした問題を防ぐために、僕が気をつけていることを具体的に紹介します。


トランザクションで一貫性を保つ

トランザクションは、一連の処理をまとめて「1つの処理」として扱い、どれか1つでも失敗したら全部なかったことにする考え方です。
途中で失敗したら ROLLBACKを実施します。
これによって、途中で失敗した際に不整合な値が入ることを防ぐことができます。

use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Log;

try {
    DB::beginTransaction();

    // 処理1: ポイント加算
    DB::table('users')->where('id', 1)->increment('point', 100);

    // 処理2: ログ保存(ここで失敗するかも)
    DB::table('logs')->insert([
        'user_id' => 1,
        'message' => 'ポイント加算',
    ]);

    DB::commit();
} catch (\Throwable $e) {
    DB::rollBack();
    Log::error('トランザクション失敗: ' . $e->getMessage());
    // 必要に応じて throw $e; で再スロー
}

トランザクションがないと、例えば次のようなことが起こりえます。
・ 外部APIでクレジット決済を実施し、クレジットカードから引き落としがされた
・ その直後、サービス側の決済履歴更新処理で不具合が発生した
・ その結果、サービスの決済履歴と実際のクレジットの引き落とし履歴に不整合が発生した

こういった問題を防ぐためには、関連する処理をまとめて1つの単位として管理する「トランザクション制御」が不可欠です。


排他ロック(悲観的ロック)で「同時更新」を防ぐ

例えば、在庫が10個ある商品を複数人が同時に購入しようとした場合、
トランザクションだけでは、「どちらの処理も在庫を10と見て、それぞれ9に更新した」といった競合が発生します。

本来であれば、2人が購入すれば在庫は8個になるはずです。
しかしこのような競合が起きると、在庫が1つしか減らない
(9個にしかならない)といった不整合が生じてしまうのです。

こうした問題を防ぐために、SQLの FOR UPDATE を使って、
特定の行(たとえば products テーブルの id = 1 の行)に対して
排他ロックをかけます。

これにより、トランザクションが完了するまで他の処理はその行に
触れることができず、同時更新による不整合を確実に防ぐことができます。

use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Log;

try {
    DB::beginTransaction();

    // 対象の商品を排他ロック(FOR UPDATE)
    $product = DB::table('products')
        ->where('id', 1)
        ->lockForUpdate()
        ->first();

    if ($product->stock > 0) {
        // 在庫がある場合のみ減らす
        DB::table('products')
            ->where('id', 1)
            ->update(['stock' => $product->stock - 1]);
    } else {
        throw new \Exception('在庫がありません');
    }

    DB::commit();
} catch (\Throwable $e) {
    DB::rollBack();
    Log::error('在庫更新失敗: ' . $e->getMessage());
    // 必要に応じて 
    throw $e;
}

楽観ロックで「競合が稀なデータ」の整合性を保つ

例えば、社内の管理画面で従業員のプロフィール情報を編集するようなケースを考えてみてください。
このような画面では同時に複数人が同じレコードを編集する可能性は低いですが、ゼロではありません。

そういった場合に、以下のようなことが起こり得ます。

・ 担当者Aがプロフィールを編集し始める

・ その間に別の担当者Bも同じプロフィールを編集して保存

・ 担当者Aが保存すると、Bの変更が上書きされて消えてしまう

こうした稀な競合を防ぐために、楽観ロックを使います。
具体的には、以下のようにプロフィール情報にversionカラムを持たせ、更新時にバージョン番号をチェックします。

use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Log;

try {
    // 更新前のバージョン番号(例:version = 3)
    $currentVersion = 3;

    $updatedRows = DB::table('employees')
        ->where('id', 1)
        ->where('version', $currentVersion)
        ->update([
            'name' => 'ツーナ',
            'version' => DB::raw('version + 1'),
        ]);

    if ($updatedRows === 0) {
        throw new \Exception('競合が発生しました(他の誰かがすでに更新済み)');
    }

    // 成功時の後処理
} catch (\Throwable $e) {
    Log::error('楽観ロック更新失敗: ' . $e->getMessage());
    // 必要に応じて throw $e;
}

他の誰かが先に version を 4 に更新していた場合、この UPDATE は 0 件になり、競合が検出されます。
アプリ側ではこの結果を見て:

「他のユーザーが更新しています。最新の内容を確認してからもう一度お試しください」と表示 → 再取得 → 再編集を促す

という形でユーザーに明示的な選択を委ねることができます


複数サービスへの共通処理をインターフェース+DIまとめる

たとえば、以下のような「在庫確保 → 決済 → 注文保存」の処理を、Aサービス・Bサービス・Cサービスごとに実装しなければならないとします。


🙅‍♂️ 悪い例:同じような処理を何回も書く

// A社
$aClient = new AClient();
$aClient->reserveStock($productId);
$aClient->pay($userId, $amount);
$aClient->saveOrder($order);

// B社
$bClient = new BClient();
$bClient->reserveStock($productId);
$bClient->pay($userId, $amount);
bClient->saveOrder($order);

// C社も同様に……

このように書いてしまうと:

  • 実装ごとに修正が必要になり、保守が大変になる
  • 共通化されていないため、テストもしづらい
  • バグや漏れが発生しやすい

✅ 良い例:共通インターフェース + DI

各サービス共通のインターフェースを定義し、サービス固有の実装を注入できるようにすれば、処理を一本化できます。

interface VendorOrderServiceInterface {
    public function reserveStock(int $productId): void;
    public function pay(int $userId, int $amount): void;
    public function saveOrder(OrderInput $input): void;
}

class OrderService {
    public function __construct(
        private VendorOrderServiceInterface $vendorService,
        private TransactionManagerInterface $tx
    ) {}

    public function createOrder(OrderInput $input): bool
    {
        return $this->tx->execute(function () use ($input) {
            $this->vendorService->reserveStock($input->getProductId());
            $this->vendorService->pay($input->getUserId(), $input->getAmount());
            $this->vendorService->saveOrder($input);
            return true;
        });
    }
}

🧩 各社の実装(例)

class AOrderService implements VendorOrderServiceInterface {
    public function reserve(int $productId): void {
        // A社APIで在庫確保
    }

    public function pay(int $userId, int $amount): void {
        // A社APIで決済
    }

    public function saveOrder(OrderInput $input): void {
        // A社APIで注文保存
    }
}

このようにすれば、B社・C社向けのクラスも VendorOrderServiceInterface を実装するだけでOKです。
複数サービスを横断して、データの保存・更新を行う際はこういった考え方も是非取り入れたいですね。


まとめ

  • トランザクションは整合性を守る基本中の基本!
  • 排他ロック(悲観ロック)は「同時に触られたら困る」データに使う
  • 楽観ロックは「ほとんど競合しないけど、念のため守りたい」データに使う
  • 外部サービスをまたぐ処理は、依存性注入で分離して、トランザクション制御を1箇所に集中させるのがコツ

最終的には、「この処理、他の誰かと同時に実行される可能性ある?」と想像することが、設計の出発点になるなぁと日々感じています。

この記事が少しでも誰かの設計判断のヒントになれば嬉しいです!🔥

1
0
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
1
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?