Salesforceのレコードロックをマスターする:開発者のためのFOR UPDATE徹底解説

背景と適用シナリオ

私は Salesforce 開発者として、日々、データの一貫性と整合性を保つためのコードを書いています。Salesforce はマルチテナント環境であり、複数のユーザーや自動化プロセスが同時に同じデータにアクセスし、更新を試みる可能性があります。このような同時実行性の高い環境では、race condition (競合状態) や「last-write-wins」の問題が発生し、データの不整合を引き起こすリスクが常に存在します。

例えば、次のようなシナリオを考えてみましょう。ある商社のカスタムシステムでは、Account (取引先) オブジェクトに `Total_Order_Amount__c` というカスタム項目があり、関連するすべての Opportunity (商談) の金額を合計した値を保持しています。この合計金額は、Opportunity が作成または更新されるたびに Apex トリガーによって再計算されます。

もし、2つの異なる Opportunity (同じ Account に関連) がほぼ同時に更新されたらどうなるでしょうか?

  1. トランザクションAが開始され、Account の現在の `Total_Order_Amount__c` を読み取ります。
  2. ほぼ同時に、トランザクションBも開始され、同じ Account の現在の `Total_Order_Amount__c` を読み取ります。(この時点ではトランザクションAの計算はまだコミットされていません)
  3. トランザクションAは自身の Opportunity の金額を加えて新しい合計を計算し、Account レコードを更新します。
  4. トランザクションBも自身の Opportunity の金額を加えて新しい合計を計算し、Account レコードを更新します。

この結果、トランザクションBの更新がトランザクションAの更新を上書きしてしまい、最終的な `Total_Order_Amount__c` は不正確な値になってしまいます。これは「lost update」問題として知られています。

このようなデータ整合性の問題を解決するために、Salesforce は record locking (レコードロック) というメカニズムを提供しています。開発者として、私たちは SOQL の `FOR UPDATE` 句を利用してこの機能を活用し、重要なデータ操作を安全に実行することができます。この記事では、Salesforce 開発者の視点から `FOR UPDATE` の原理、使い方、注意点、そしてベストプラクティスについて詳しく解説します。


原理の説明

Salesforce のレコードロックは、pessimistic locking (悲観的ロック) と呼ばれるアプローチに基づいています。これは、「競合は発生するもの」と想定し、データ処理を開始する前に、対象となるレコードを明示的にロックして他のトランザクションからの変更を防ぐ手法です。

Apex においてこのロックを獲得するための主要な手段が、SOQL (Salesforce Object Query Language) クエリの末尾に追加する `FOR UPDATE` 句です。

`SELECT Id, Name FROM Account WHERE Id = :someId FOR UPDATE`

このクエリが実行されると、Salesforce プラットフォームは `WHERE` 句に一致したレコード(この場合は `someId` の Account レコード)に行ロックをかけます。このロックには以下の特徴があります。

  • ロックのスコープ: ロックは、`FOR UPDATE` を実行した transaction (トランザクション) が完了するまで維持されます。Salesforce におけるトランザクションとは、トリガーの実行、`@AuraEnabled` メソッドの呼び出し、バッチ Apex の `execute` メソッドの1回の実行など、一連の処理の単位を指します。処理が正常に完了するか、ロールバックされると、ロックは自動的に解放されます。
  • ロック待機: あるトランザクションがレコードをロックしている間に、別のトランザクションが同じレコードを `FOR UPDATE` で照会しようとしたり、DML 操作で更新しようとしたりすると、後者のトランザクションは待機状態に入ります。
  • タイムアウト: ロックが解放されるのを待機する時間には上限があります(約10秒)。この時間内にロックが取得できない場合、待機していたトランザクションは `System.QueryException: UNABLE_TO_LOCK_ROW` という致命的なエラーで失敗します。これにより、システム全体がフリーズするのを防いでいます。

この仕組みにより、開発者は特定のレコード群をトランザクション内で排他的に操作する権利を確保し、他のプロセスからの干渉を防ぐことで、データの一貫性を保証することができます。


サンプルコード

ここでは、前述のシナリオ(親である Account の集計項目を更新する)を解決するための具体的なコード例を見ていきましょう。このコードは、Opportunity が作成または更新されたときに、関連する Account レコードを安全にロックし、集計項目を更新するトリガーです。

この例は、Salesforce の公式ドキュメントで紹介されている標準的な `FOR UPDATE` の使用法に基づいています。

AccountTriggerHandler.cls

Account レコードのロックと更新ロジックを格納するハンドラークラスです。

public class AccountTriggerHandler {
    public static void updateTotalAmount(Set<Id> accountIds) {
        if (accountIds == null || accountIds.isEmpty()) {
            return;
        }

        // 1. まず、更新対象の親である Account レコードを FOR UPDATE を使ってロックする。
        // これにより、このトランザクションが完了するまで、他のプロセスはこれらの Account レコードを変更できなくなる。
        // これがデータの一貫性を保つための最も重要なステップ。
        List<Account> accountsToLock = [SELECT Id, Total_Order_Amount__c FROM Account WHERE Id IN :accountIds FOR UPDATE];

        // 2. 関連するすべての Opportunity を集計する。
        // AggregateResult を使用して、各 Account に紐づく商談の合計金額を効率的に計算する。
        Map<Id, Decimal> amountByAccountId = new Map<Id, Decimal>();
        for (AggregateResult ar : [
            SELECT AccountId, SUM(Amount) totalAmount
            FROM Opportunity
            WHERE AccountId IN :accountIds AND Amount != null
            GROUP BY AccountId
        ]) {
            amountByAccountId.put((Id)ar.get('AccountId'), (Decimal)ar.get('totalAmount'));
        }

        // 3. ロックした Account レコードの集計項目を更新する。
        List<Account> accountsToUpdate = new List<Account>();
        for (Account acc : accountsToLock) {
            Decimal totalAmount = amountByAccountId.get(acc.Id);
            // 新しい合計金額が現在の値と異なる場合のみ更新リストに追加する。
            if (acc.Total_Order_Amount__c != totalAmount) {
                acc.Total_Order_Amount__c = totalAmount != null ? totalAmount : 0;
                accountsToUpdate.add(acc);
            }
        }
        
        // 4. DML 操作を実行する。
        // try-catch ブロックで DmlException を捕捉し、エラーハンドリングを行う。
        try {
            if (!accountsToUpdate.isEmpty()) {
                update accountsToUpdate;
            }
        } catch (DmlException e) {
            // エラーログの記録や、ユーザーへの通知などの処理をここに追加する。
            System.debug('Account の更新に失敗しました: ' + e.getMessage());
            // 必要に応じて例外を再スローする。
            throw e;
        }
    }
}

OpportunityTrigger.trigger

Opportunity の変更を検知し、ハンドラークラスを呼び出すトリガーです。

trigger OpportunityTrigger on Opportunity (after insert, after update, after delete, after undelete) {
    Set<Id> accountIds = new Set<Id>();

    // トリガーコンテキストに応じて、影響を受ける Account の ID を収集する。
    if (Trigger.isInsert || Trigger.isUpdate || Trigger.isUndelete) {
        for (Opportunity opp : Trigger.new) {
            if (opp.AccountId != null) {
                accountIds.add(opp.AccountId);
            }
        }
    }
    
    // update の場合は、変更前の AccountId も考慮に入れる必要がある。
    if (Trigger.isUpdate || Trigger.isDelete) {
        for (Opportunity opp : Trigger.old) {
            if (opp.AccountId != null) {
                accountIds.add(opp.AccountId);
            }
        }
    }

    // 収集した Account ID を使ってハンドラーメソッドを呼び出す。
    if (!accountIds.isEmpty()) {
        AccountTriggerHandler.updateTotalAmount(accountIds);
    }
}

このコードパターンにより、複数の Opportunity 更新が同時に発生した場合でも、Account の `Total_Order_Amount__c` は常に正しい値に保たれます。各トランザクションは、順番に Account レコードをロックし、安全に計算と更新を行うため、競合状態が排除されます。


注意事項

`FOR UPDATE` は強力なツールですが、誤った使い方をするとパフォーマンスの低下やエラーの原因となります。開発者として、以下の点に細心の注意を払う必要があります。

Lock Contention (ロック競合)

最も注意すべき問題です。頻繁に更新されるレコード(例えば、単一の親レコードに多数の子レコードがぶら下がっている「hotspot」レコード)をロックすると、多くのトランザクションが待機状態となり、システムの応答性が著しく低下します。これをロック競合と呼びます。ロックするレコードは必要最小限に絞り、ロックを保持する時間はできるだけ短くする必要があります。

Transaction Boundaries (トランザクションの境界)

ロックはトランザクションの終了時に解放されることを理解することが重要です。`@future` や Queueable Apex の呼び出しのように、非同期処理を呼び出すと、現在のトランザクションは終了します。つまり、`callout=true` を指定したメソッドを呼び出す前にロックをかけても、コールアウトの実行前にトランザクションがコミットされ、ロックが解放されてしまうため意味がありません。ロックは、DML 操作の直前に行い、同一トランザクション内で完結させる必要があります。

Deadlocks (デッドロック)

デッドロックは、2つ以上のトランザクションが互いに相手の持つロックの解放を待ち、永遠に処理が進まなくなる状態です。

  • トランザクションAがレコードXをロックし、次にレコードYをロックしようと待機する。
  • トランザクションBがレコードYをロックし、次にレコードXをロックしようと待機する。
Salesforce プラットフォームはデッドロックを検知し、いずれかのトランザクションを強制的に終了させることで解消しますが、これは避けるべき事態です。デッドロックを防ぐ最も一般的な方法は、すべてのトランザクションで、レコードを常に同じ順序でロックすることです。例えば、常にレコード ID の昇順でクエリ (`ORDER BY Id`) してからロックをかけることで、この問題を回避できます。

エラー処理 (Error Handling)

前述の通り、ロックの取得に失敗すると `UNABLE_TO_LOCK_ROW` エラーが発生します。このエラーはキャッチ可能な `QueryException` です。本番環境で動作するコードでは、`FOR UPDATE` を含む SOQL や関連する DML 操作を `try-catch` ブロックで囲み、この例外を適切に処理することが不可欠です。処理戦略としては、数回リトライする(ただし、リトライのやりすぎはガバナ制限に抵触する可能性があるため注意)、ユーザーにエラーメッセージを表示して再試行を促す、などが考えられます。


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

`FOR UPDATE` は、Salesforce の同時実行環境下でデータ整合性を確保するための強力な機能です。しかし、その力を最大限に引き出し、副作用を最小限に抑えるためには、以下のベストプラクティスを遵守することが極めて重要です。

  1. ロックは遅く、トランザクションは短く (Lock Late, Keep Transactions Short):

    ロックの取得は、トランザクションのできるだけ後のタイミングで行いましょう。特に、ロックした状態で複雑な計算や外部システムへのコールアウト(そもそも不可)など、時間のかかる処理を行うべきではありません。SOQL でロックを取得し、必要な計算を行い、DML で更新したら、すぐにトランザクションを完了させるのが理想です。

  2. 必要なレコードのみをロックする (Lock Only What You Need):

    `WHERE` 句をできるだけ具体的にし、ロック対象のレコード数を最小限に抑えてください。不必要に多くのレコードをロックすると、ロック競合のリスクが飛躍的に高まります。

  3. 一貫した順序でロックする (Lock in a Consistent Order):

    複数のレコードをロックする場合は、常に `ORDER BY Id` などを利用して、一貫した順序でレコードを取得・ロックしてください。これはデッドロックを回避するための最も効果的な戦略です。

  4. 堅牢なエラーハンドリングを実装する (Implement Robust Error Handling):

    `UNABLE_TO_LOCK_ROW` 例外を捕捉し、適切に対応する `try-catch` ブロックを必ず実装してください。ユーザー体験とシステムの安定性を損なわないために不可欠です。

  5. 代替案を検討する (Consider Alternatives):

    すべての集計処理に `FOR UPDATE` が必要とは限りません。リアルタイム性が必須でない場合は、Batch Apex や Queueable Apex を使った非同期処理で夜間に集計する方が、パフォーマンスへの影響が少ない場合があります。また、単純な親子関係の集計であれば、標準の積み上げ集計項目 (Roll-Up Summary Fields) の利用が第一の選択肢です。

Salesforce 開発者として、私たちは単に機能するコードを書くだけでなく、スケーラブルで堅牢、そして安全なアプリケーションを構築する責任があります。レコードロックの仕組みを深く理解し、これらのベストプラクティスを実践することで、複雑な同時実行性の問題からデータを保護し、信頼性の高いシステムを顧客に提供することができるのです。

コメント