Salesforceレコードロック:ApexのFOR UPDATEと同時実行性管理の徹底解説

背景と応用シナリオ

Salesforceは、世界中の何百万ものユーザーが同時にアクセスするマルチテナント型のクラウドプラットフォームです。このような環境では、複数のユーザーや自動化プロセスが同じレコードを同時に更新しようとすることが頻繁に発生します。この現象はrace condition(競合状態)として知られており、適切な制御がない場合、データの不整合や上書きによるデータ損失を引き起こす可能性があります。

例えば、以下のようなシナリオを考えてみましょう。

シナリオ1:商談の最終承認プロセス
ある商談が最終承認フェーズにあり、営業マネージャーAと営業マネージャーBの双方が承認権限を持っているとします。両者がほぼ同時に商談レコードを開き、「承認」ボタンをクリックした場合、ロック機構がなければ、最後に保存したマネージャーの操作のみが有効となり、最初のマネージャーの操作(例えば、承認コメントの追加など)は失われてしまう可能性があります。さらに悪いケースでは、二重に承認後のプロセスが実行されてしまうことも考えられます。

シナリオ2:在庫管理システム
カスタムオブジェクトで在庫を管理しているECサイトを考えます。ある人気商品の在庫が残り1点になったとき、顧客Aと顧客Bがほぼ同時に購入を試みました。両方の購入プロセスが同時に在庫数を読み取り(在庫=1)、それぞれが在庫を引き当てようとします。ロックがなければ、両方のプロセスが「在庫あり」と判断し、在庫を-1する処理を実行してしまう可能性があります。結果として、在庫は-1となり、1つの商品に対して2つの注文が成立してしまうという重大なデータ不整合が発生します。

これらの問題を解決するために、Salesforceはrecord locking(レコードロック)という仕組みを提供しています。これは、あるtransaction(トランザクション)がレコードを更新している間、他のトランザクションがそのレコードを更新できないように排他制御を行う機能です。本記事では、特にApex開発において重要となるSOQLのFOR UPDATE句を用いたレコードロックのメカニズムと、そのベストプラクティスについて詳しく解説します。


原理説明

Salesforceにおけるレコードロックは、主にpessimistic locking(悲観的ロック)モデルに基づいています。これは、「競合は発生するものである」という前提に立ち、データを読み取る時点で更新のためにロックを獲得し、他のトランザクションによる変更を未然に防ぐアプローチです。Apexでは、この悲観的ロックをSOQLクエリにFOR UPDATE句を追加することで実現します。

SOQLクエリでFOR UPDATEを使用すると、そのクエリによって取得されたすべてのレコードがロックされます。このロックは、現在のApexトランザクションが完了する(正常にコミットされるか、エラーによりロールバックされる)まで維持されます。

ロックが獲得されると、他のトランザクション(他のApexコード、Flow、API経由の更新など)が同じレコードを更新しようとした場合、その操作は待機させられます。しかし、最初のトランザクションがロックを解放するまで長時間待機することはできないため、システムは一定時間待機した後、UNABLE_TO_LOCK_ROWというエラーをスローして後続のトランザクションを失敗させます。これにより、システムのデッドロックを防ぎ、パフォーマンスの低下を最小限に抑えています。

ロックの範囲:
FOR UPDATEによってロックされるのは、クエリで取得されたレコードそのものだけではありません。親子関係にあるレコードもロックの対象となる場合があります。例えば、親レコード(例:取引先)をロックすると、その子レコード(例:取引先責任者)に対する一部の操作(親を変更するような操作)もブロックされることがあります。この挙動は、データの一貫性を保つために重要です。

対照的な概念としてoptimistic locking(楽観的ロック)があります。これは、「競合は稀にしか発生しない」という前提に立ち、ロックをかけずにデータを読み込み、更新時にデータが変更されていないかを確認する手法です。Salesforceプラットフォーム上でこれを実現するには、カスタムの「バージョン番号」フィールドや「最終更新日」フィールドを使い、更新前に読み取った時点の値と現在のデータベース上の値を比較するロジックを自前で実装する必要があります。FOR UPDATEは、プラットフォームが提供するより直接的で強力なロックメカニズムと言えます。


示例代码

ここでは、口座残高を更新するシナリオを想定したApexコードの例を示します。複数のプロセスが同時に同じ口座の残高を更新しようとする可能性があるため、FOR UPDATEを使用してレコードをロックし、データの整合性を確保します。

この例では、指定された2つの取引先レコード間で資金を移動します。資金移動元(fromAccount)の残高を減らし、資金移動先(toAccount)の残高を増やします。この一連の操作はアトミック(不可分)である必要があるため、両方のレコードをロックして処理します。

Apex Class: AccountBalanceUpdater

public class AccountBalanceUpdater {
    public static void transferFunds(Id fromAccountId, Id toAccountId, Decimal amount) {
        // 資金移動を行う前に、関連する両方の取引先レコードをロックするために
        // FOR UPDATE句を使用してクエリを実行します。
        // これにより、このトランザクションが完了するまで、他のプロセスは
        // これらのレコードを更新できなくなり、競合状態を防ぎます。
        // 処理の順序を一定に保つ(IDでソートするなど)ことで、デッドロックのリスクを低減できます。
        List<Account> accounts = [SELECT Id, Name, Balance__c 
                                  FROM Account 
                                  WHERE Id IN (:fromAccountId, :toAccountId) 
                                  ORDER BY Name ASC
                                  FOR UPDATE];

        Account fromAccount = null;
        Account toAccount = null;
        
        // 取得したレコードを特定
        for(Account acc : accounts) {
            if(acc.Id == fromAccountId) {
                fromAccount = acc;
            } else if (acc.Id == toAccountId) {
                toAccount = acc;
            }
        }
        
        // レコードが存在し、かつ残高が十分であることを確認
        if (fromAccount != null && toAccount != null && fromAccount.Balance__c >= amount) {
            try {
                // 口座残高を更新
                fromAccount.Balance__c -= amount;
                toAccount.Balance__c += amount;
                
                // DML操作を実行。ここでデータベースの更新が行われます。
                // try-catchブロックで囲むことで、DMLエラー(ロック競合を含む)を捕捉します。
                update accounts;
                
                System.debug('資金移動が成功しました。');
                
            } catch (DmlException e) {
                // DmlExceptionを捕捉します。特にロックの競合によって発生するエラーを処理します。
                // e.getDmlStatusCode() をチェックすることで、具体的なエラー原因を特定できます。
                if (e.getMessage().contains('UNABLE_TO_LOCK_ROW')) {
                    System.debug('レコードのロックに失敗しました。他のプロセスがレコードを編集中です。後ほど再試行してください。エラー: ' + e.getMessage());
                    // ここで再試行ロジックやユーザーへの通知などを実装できます。
                } else {
                    // その他のDMLエラーの処理
                    System.debug('予期せぬDMLエラーが発生しました: ' + e.getMessage());
                    // エラーを再スローするか、適切に処理します。
                    throw e;
                }
            }
        } else {
            // 残高不足、または口座が見つからない場合の処理
            System.debug('資金移動に失敗しました。残高が不足しているか、口座が見つかりません。');
            // 必要に応じてカスタム例外をスローします。
            throw new AccountBalanceException('残高不足または口座不明です。');
        }
    }

    public class AccountBalanceException extends Exception {}
}

このコードは、まず資金移動に関わる2つの取引先レコードをFOR UPDATE句付きのSOQLで取得します。これにより、これらのレコードは即座にロックされます。その後、残高を計算し直し、update DML操作を実行します。もし、このコードが実行されている間に別のトランザクションが同じレコードを更新しようとすると、後者のトランザクションはUNABLE_TO_LOCK_ROWエラーで失敗します。このエラーをtry-catchブロックで適切に捕捉し、処理することが重要です。


注意事項

FOR UPDATEを使用する際には、いくつかの重要な点に注意する必要があります。これらを怠ると、パフォーマンスの低下や予期せぬエラーにつながる可能性があります。

1. ロック競合 (Lock Contention)

多数のトランザクションが頻繁に同じレコードセットをロックしようとすると、lock contention(ロック競合)が発生します。これにより、多くのトランザクションがUNABLE_TO_LOCK_ROWエラーで失敗し、システムの全体的なスループットが低下します。競合を避けるためには、以下の点を考慮してください。

  • トランザクションを短く保つ:ロックを獲得してから解放するまでの時間をできるだけ短くします。SOQLでロックを獲得した後は、複雑な計算や外部システムへのコールアウト(Callout)を行わず、速やかにDML操作を完了させてトランザクションを終了させるべきです。
  • ロックするレコードを最小限に絞る:WHERE句を使い、本当に必要なレコードのみをクエリしてロックします。不必要に広範なクエリで大量のレコードをロックすることは避けてください。

2. デッドロック (Deadlock)

Deadlock(デッドロック)は、2つ以上のトランザクションが互いに相手が保持しているロックの解放を待ち、永遠に処理が進まなくなる状態です。 例えば、トランザクションAがレコードXをロックし、次にレコードYをロックしようとしているとします。同時に、トランザクションBがレコードYをロックし、次にレコードXをロックしようとしています。この場合、両者はお互いを待ち続け、デッドロックが発生します。

Salesforceプラットフォームはデッドロックを検知し、一方のトランザクションを強制的にエラー終了させることで解消しますが、デッドロックの発生自体を避ける設計が重要です。最も効果的な対策は、システム全体でリソース(レコード)をロックする順序を統一することです。例えば、「常に関連するレコードを名前やIDの昇順でソートしてからロックする」というルールを設けることで、デッドロックのリスクを大幅に削減できます。前述のサンプルコードでもORDER BY Name ASC句を使用しているのはこのためです。

3. エラーハンドリング

前述の通り、UNABLE_TO_LOCK_ROWエラーは避けられない場合があります。このエラーが発生した際に、単に処理を失敗させるだけでなく、回復可能な戦略を実装することが堅牢なアプリケーションの鍵となります。try-catchブロックでDmlExceptionを捕捉し、エラーメッセージの内容を解析して、ロックエラーの場合には再試行ロジックを組み込むことを検討してください。ただし、無限に再試行すると他のプロセスに影響を与える可能性があるため、再試行回数に上限を設けるべきです。

4. ガバナ制限と非同期処理

FOR UPDATE自体に直接的なガバナ制限(「ロックできるレコードは100件まで」など)はありません。しかし、ロックするレコードの数が増えれば、SOQLクエリの制限(取得50,000件)やCPU時間、ヒープサイズといった他のガバナ制限に達する可能性が高まります。

また、FOR UPDATEは同期的にも非同期的(Batch Apex, Queueable Apexなど)にも使用できますが、非同期処理、特に並列実行されるバッチ処理で広範囲のレコードをロックすると、バッチジョブ間でのロック競合が多発する可能性があります。バッチサイズを調整したり、処理対象のレコードを重複しないように分割したりする工夫が必要です。


まとめとベストプラクティス

Salesforceにおけるレコードロック、特にApexのFOR UPDATE句は、同時実行環境下でのデータ整合性を保証するための強力なツールです。しかし、その強力さゆえに、誤った使い方をするとパフォーマンスの問題やデッドロックを引き起こす原因ともなります。

以下に、レコードロックを効果的に活用するためのベストプラクティスをまとめます。

  1. 必要な場合にのみロックする: 読み取り操作のみで、データの整合性に影響がない場合はFOR UPDATEを使用しないでください。同時更新のリスクが実際に存在する、重要なデータ更新処理に限定して使用します。
  2. トランザクションは短く、速やかに: ロックを獲得したら、可能な限り迅速にDML操作を実行し、トランザクションを完了させてください。ロック保持中のCalloutや複雑なループ処理は避けるべきです。
  3. ロック範囲を最小化する: 具体的で的を射たWHERE句を使用して、更新に必要な最小限のレコードセットのみをロックの対象とします。
  4. 一貫した順序でロックする: 複数のレコードをロックする必要がある場合は、アプリケーション全体で一貫した順序(例:IDや名前でのソート)でロックを取得するルールを徹底し、デッドロックを防止します。
  5. 堅牢なエラーハンドリングを実装する: UNABLE_TO_LOCK_ROWエラーを想定し、必ずtry-catchブロックでDML操作を囲んでください。必要に応じて、上限付きの再試行メカニズムを導入することを検討します。
  6. 代替案を検討する: すべての同時実行性の問題が悲観的ロックを必要とするわけではありません。競合が稀な場合は、カスタムフィールドを用いた楽観的ロックの実装や、Platform Eventsを用いた非同期処理モデルがより適切なアーキテクチャとなることもあります。

これらの原則を理解し、適切に適用することで、Salesforceプラットフォーム上で安全かつスケーラブルなアプリケーションを構築することが可能になります。

コメント