Salesforce レコードロッキング戦略:同時実行操作におけるデータ整合性の確保

背景と応用シーン

今日のデジタルビジネス環境において、複数のユーザーや自動化されたプロセスが同時に同じデータにアクセスし、変更しようとすることは日常茶飯事です。特に、複数の操作が同一のSalesforceレコードに対して行われる場合、データの一貫性(data consistency)と整合性(data integrity)を確保することが極めて重要となります。この同時実行性の問題(concurrency issues)を解決しないと、データ破損(data corruption)、ビジネスロジックの不整合な状態(inconsistent states)、またはユーザー体験の低下(poor user experience)といった重大な問題を引き起こす可能性があります。

Salesforceプラットフォームは、標準のUI操作やDML(Data Manipulation Language)操作において、ある程度のロック機構を自動的に提供していますが、複雑なビジネスロジックや高頻度で更新されるシナリオでは、明示的なレコードロッキング(record locking)が必要となります。これは、特にカスタムApexコードを使用して、重要なビジネスロジックを実装する際に顕著です。

レコードロッキングは、特定のレコードが現在変更中であることを他のトランザクションに通知し、同時変更を一時的にブロックすることで、データの安全性を保証するメカニズムです。Salesforceにおけるレコードロッキングの主な応用シーンは以下の通りです。

  • 高頻度の在庫管理(High-frequency Inventory Management): 複数の販売チャネルからの注文が同時に在庫レコードを更新しようとする場合、負の在庫(negative inventory)やオーバーセル(overselling)を防ぐために、在庫数レコードを正確にロックして更新する必要があります。
  • 金融取引や会計処理(Financial Transactions and Accounting Processes): 支払い、送金、または貸借対照表の更新など、金銭的なトランザクションは、絶対的なデータ整合性が求められます。同一アカウントに対する複数回の処理が同時に発生することを防ぎ、残高の正確性を保証します。
  • 複雑な承認プロセス(Complex Approval Processes): 複数のユーザーが同時に承認ステータスを変更しようとしたり、承認アクションが競合したりするのを防ぎます。これにより、承認パスの一貫性が保たれます。
  • カスタムApexバッチ処理(Custom Apex Batch Processing): 大量のレコードを処理するバッチジョブが、他のバッチジョブやリアルタイム操作と競合して同じレコードを更新しようとする場合、データの一貫性を維持するためにロッキングが不可欠です。
  • ユニークなリソースの予約(Unique Resource Reservation): イベントの座席予約、会議室の予約など、限られたリソースを複数のユーザーが同時に予約しようとする際に、二重予約(double-booking)を防ぎます。

これらのシナリオでは、Salesforceが提供する明示的なロッキングメカニズム、特にApexにおけるSOQLのFOR UPDATE句が不可欠なツールとなります。


原理説明

Salesforceにおけるレコードロッキングは、特定のレコードに対する同時変更を制御し、データ整合性を保護するための重要な機能です。

明示的なロッキング: SOQLのFOR UPDATE

Salesforce Apex開発において最も強力で頻繁に使用される明示的なロッキングメカニズムは、SOQL(Salesforce Object Query Language)クエリにFOR UPDATE句を追加することです。この句を使用すると、クエリによって取得されたレコードが、現在のトランザクションの期間中、排他的にロックされます(exclusive lock)。

  • ロックの仕組み: FOR UPDATEを使用すると、Salesforceデータベースは、クエリ結果に含まれる各レコードに対して、現在のトランザクションが終了するまで書き込みロックを設定します。これにより、他のトランザクションがこれらのレコードをFOR UPDATEでクエリしたり、DML操作(insert, update, delete, upsert)を実行したりしようとすると、競合が発生します。
  • ロックの範囲: ロックはレコードレベルで機能します。つまり、レコードの特定のフィールドではなく、レコード全体がロックされます。また、マスター・詳細関係(master-detail relationship)にある子レコードが更新されると、親レコードもロールアップサマリーフィールド(roll-up summary fields)の再計算のために一時的にロックされる可能性があります。
  • ロックの解放: ロックは、トランザクションが正常にコミットされるか、ロールバックされると自動的に解放されます。Salesforceのトランザクションは、通常、単一のApexメソッドの実行、トリガーの実行、またはAPI呼び出しの実行などの範囲で定義されます。
  • ロックの競合とタイムアウト:
    • 他のトランザクションが既にレコードをロックしている場合、FOR UPDATEクエリは、デフォルトで最大10秒間ロックの解放を待機します。この時間内にロックが解放されない場合、クエリはSystem.QueryException(具体的にはUNABLE_TO_LOCK_ROWエラー)をスローして失敗します。
    • ロックされたレコードに対して、別のトランザクションがFOR UPDATEなしでDML操作を実行しようとすると、即座にDmlExceptionUNABLE_TO_LOCK_ROWエラー)が発生し、操作は失敗します。

暗黙的なロッキングとSalesforceの振る舞い

ApexのFOR UPDATE句は明示的なロッキングを提供しますが、Salesforceプラットフォームは多くの標準的な操作において、裏で暗黙的なロッキング(implicit locking)を処理しています。

  • 標準UI操作: ユーザーがSalesforce UIでレコードを編集・保存する際、プラットフォームは内部的にレコードのロックを試みます。これにより、通常、2人のユーザーが同時に同じレコードを保存しようとした場合に、競合を検出し、一方のユーザーに警告を発したり、変更を拒否したりします。
  • 標準DML操作: Apexでupdate record;のようなDML操作を実行する際、Salesforceは自動的に対象レコードのロックを試みます。もしレコードが既に別のトランザクションによってロックされている場合、前述の通りUNABLE_TO_LOCK_ROWエラーが発生します。これは、FOR UPDATEを使用しないDMLでも起こり得るため、同時実行性の問題を考慮する上で重要です。
  • デッドロック(Deadlock): 複数のトランザクションがお互いのロック解除を永遠に待ち続ける状況をデッドロックと呼びます。例えば、トランザクションAがレコードXをロックし、レコードYのロックを待っている間に、トランザクションBがレコードYをロックし、レコードXのロックを待っている場合などです。SalesforceのFOR UPDATEは、デッドロックを完全に防ぐことはできませんが、これを軽減するためのベストプラクティスは後述します。

FOR UPDATEは、特にミッションクリティカルなデータ操作において、開発者がデータ整合性をプログラム的に保証するための強力なツールです。しかし、その使用には注意が必要であり、パフォーマンスへの影響やデッドロックのリスクを理解しておく必要があります。


サンプルコード

ここでは、SalesforceのレコードロッキングをApexで実現するためのFOR UPDATE句の使用例を示します。在庫管理のシナリオを想定し、商品の在庫数を安全に減らす処理を実装します。

シナリオ:商品の在庫数更新

ある商品(Product2レコード)の在庫数(カスタムフィールドStock_Quantity__cを想定)を、複数の注文処理が同時に更新しようとする場合、FOR UPDATEを使用して競合を防ぎます。

/**
 * @description 指定されたProduct2レコードの在庫数を安全に更新するApexクラス。
 *              FOR UPDATE句を使用し、レコードの同時更新によるデータ不整合を防ぎます。
 */
public class ProductStockUpdater {

    /**
     * @description 指定された商品IDの在庫数を減少させます。
     *              このメソッドは、UNABLE_TO_LOCK_ROWエラーが発生した場合の処理も示します。
     * @param productId 更新対象の商品ID
     * @param quantityToDecrease 減少させる在庫数
     * @return 更新後の在庫数を返します。更新に失敗した場合は-1を返します。
     */
    public static Integer decreaseStockQuantity(Id productId, Integer quantityToDecrease) {
        if (productId == null || quantityToDecrease <= 0) {
            System.debug('ERROR: Invalid productId or quantityToDecrease.');
            return -1;
        }

        Product2 product;
        Integer updatedQuantity = -1;

        try {
            // STEP 1: ロックを取得してProduct2レコードを検索します。
            // FOR UPDATE句を使用することで、このレコードは現在のトランザクション中にロックされます。
            // 他のトランザクションがこのレコードを更新しようとすると、UNABLE_TO_LOCK_ROWエラーが発生するか、
            // ロックが解放されるまで待機します。
            List<Product2> products = [SELECT Id, Name, Stock_Quantity__c 
                                        FROM Product2 
                                        WHERE Id = :productId 
                                        FOR UPDATE];

            if (products.isEmpty()) {
                System.debug('ERROR: Product with ID ' + productId + ' not found.');
                return -1;
            }

            product = products[0];

            // STEP 2: 在庫数を検証し、更新します。
            if (product.Stock_Quantity__c == null || product.Stock_Quantity__c < quantityToDecrease) {
                System.debug('ERROR: Insufficient stock for product ' + product.Name + 
                             '. Current stock: ' + product.Stock_Quantity__c + 
                             ', Decrease requested: ' + quantityToDecrease);
                // 在庫不足の場合でも、UNABLE_TO_LOCK_ROWエラーではないため、トランザクションはコミットされますが、
                // 在庫の更新は行われません。
                return product.Stock_Quantity__c != null ? (Integer)product.Stock_Quantity__c : 0;
            }

            product.Stock_Quantity__c -= quantityToDecrease;

            // STEP 3: ロックを保持した状態でレコードを更新します。
            // DML操作が成功すると、トランザクションがコミットされ、ロックが解放されます。
            update product;
            updatedQuantity = (Integer)product.Stock_Quantity__c;
            System.debug('SUCCESS: Stock updated for product ' + product.Name + 
                         '. New quantity: ' + updatedQuantity);

        } catch (DmlException e) {
            // DmlExceptionをキャッチし、特にUNABLE_TO_LOCK_ROWエラーを処理します。
            // このエラーは、他のトランザクションが既にレコードをロックしている場合に発生します。
            if (e.getStatusCode() == StatusCode.UNABLE_TO_LOCK_ROW) {
                System.debug('ERROR: Failed to acquire lock for product ' + productId + 
                             '. Another transaction is currently holding the lock. ' + e.getMessage());
                // この場合、呼び出し元はリトライロジックを実装するか、ユーザーに通知する必要があります。
                return -1; 
            } else {
                // その他のDMLエラー
                System.debug('ERROR: DML Exception during stock update for product ' + productId + 
                             ': ' + e.getMessage());
                return -1;
            }
        } catch (Exception e) {
            // その他の予期せぬエラー
            System.debug('ERROR: Unexpected Exception during stock update for product ' + productId + 
                         ': ' + e.getMessage());
            return -1;
        }

        return updatedQuantity;
    }

    /**
     * @description テスト用のProduct2レコードを作成し、在庫数を設定するヘルパーメソッド。
     *              存在しない場合は新規作成し、存在する場合は更新します。
     * @param productName 商品名
     * @param initialQuantity 初期在庫数
     * @return 作成または更新されたProduct2レコード
     */
    public static Product2 setupTestProduct(String productName, Integer initialQuantity) {
        Product2 p = [SELECT Id, Name, Stock_Quantity__c FROM Product2 WHERE Name = :productName LIMIT 1 FOR VIEW];
        if (p == null) {
            p = new Product2(
                Name = productName,
                Stock_Quantity__c = initialQuantity,
                IsActive = true // Product2レコードは通常IsActiveが必要です
            );
            insert p;
            System.debug('Created new product: ' + p.Name + ' with ID: ' + p.Id);
        } else {
            p.Stock_Quantity__c = initialQuantity;
            update p;
            System.debug('Updated existing product: ' + p.Name + ' with ID: ' + p.Id);
        }
        return p;
    }
}

コードの説明と注意点:

  • FOR UPDATE句: [SELECT ... FROM ... WHERE ... FOR UPDATE]の形式でSOQLクエリを実行すると、対象のレコードが現在のApexトランザクション終了までロックされます。
  • UNABLE_TO_LOCK_ROW: 他のトランザクションが既にレコードをロックしている、またはロックを待機している間にDMLを実行しようとすると、DmlExceptionとしてStatusCode.UNABLE_TO_LOCK_ROWが発生します。このエラーを適切にキャッチし、再試行ロジック(retry logic)を実装するか、ユーザーに情報を提供するなどの対応が必要です。
  • トランザクションの範囲: ロックは、SOQLクエリが実行された時点から、Apexトランザクションがコミットまたはロールバックされるまで有効です。この例では、decreaseStockQuantityメソッドの実行が1つのトランザクションと見なされます。
  • 最小限のロック時間: ロックはパフォーマンスに影響を与える可能性があるため、クリティカルセクション(critical section)と呼ばれるロックが必要な処理はできるだけ短くし、速やかにトランザクションを完了させることが推奨されます。

注意事項

レコードロッキングは強力なツールですが、その使用には慎重な計画と実装が必要です。誤った使用は、アプリケーションのパフォーマンス低下やデッドロックを引き起こす可能性があります。

パフォーマンスへの影響

レコードロッキングは、本質的に同時実行性を制限します。レコードがロックされている間、他のトランザクションはそのレコードにアクセスできなくなるか、タイムアウトまで待機させられます。高トラフィックなシステムで頻繁にロックを使用したり、ロックを長時間保持したりすると、ロック競合(lock contention)が増加し、アプリケーション全体の応答時間(response time)が低下する可能性があります。

タイムアウトとエラー処理

  • タイムアウト(Timeout): Salesforceのデータベースは、FOR UPDATEクエリがロックを取得するために待機する時間に制限を設けています(通常10秒)。この時間内にロックが取得できない場合、UNABLE_TO_LOCK_ROWエラーが発生します。
  • エラーハンドリング(Error Handling): UNABLE_TO_LOCK_ROWエラーは、DmlExceptionまたはSystem.QueryExceptionとして捕捉できます。開発者は、このエラーを適切に処理するためのロジックを実装する必要があります。これには、ユーザーへの通知、操作の再試行(retry)、または代替ロジックの実行などが含まれます。無計画な再試行は、ロック競合をさらに悪化させる可能性があるため、注意が必要です。

ロックの範囲とデッドロックの防止

  • レコードレベルのロック: SalesforceのFOR UPDATEはレコードレベルのロックを提供します。つまり、レコード全体がロックされ、特定のフィールドのみではありません。
  • 関連レコードへの影響: マスター・詳細関係の親レコードは、子レコードの更新によってロールアップサマリーフィールドが再計算される際に、一時的にロックされることがあります。また、Apex管理共有(Apex Managed Sharing)の計算など、プラットフォームの内部プロセスによってもレコードがロックされることがあります。
  • デッドロックの防止: 複数のレコードをロックする必要がある場合(例:アカウントとそれに関連する複数の連絡先)、ロックを取得する順序を常に一貫させることで、デッドロックのリスクを大幅に減らすことができます。たとえば、常にIdの昇順でレコードをロックするなどです。

Governor LimitとAPI制限

レコードロッキング自体が直接Governor Limit(ガバナ制限)の対象となるわけではありませんが、ロッキングに起因する問題が間接的にGovernor Limitに影響を与える可能性があります。

  • CPU時間制限: ロック競合のために複数のトランザクションが繰り返しタイムアウトし、再試行ロジックが頻繁に実行されると、総CPU時間が不必要に消費され、CPU時間制限(CPU time limit)に達するリスクが高まります。
  • DMLステートメント数: ロック競合によるDML操作の失敗と再試行は、DMLステートメントの総数を増加させる可能性があります。
  • トランザクションの長さ: ロックを必要以上に長く保持するトランザクションは、システムのリソースを長く占有し、他のトランザクションに影響を与え、全体的なスループット(throughput)を低下させます。

権限とセキュリティ

レコードロッキングは、レコードにアクセスし、更新する権限を持つユーザーまたはプロセスによってのみ実行できます。権限セット(Permission Set)やプロファイル(Profile)を通じて、適切なオブジェクト権限(Object Permissions)とフィールドレベルセキュリティ(Field-Level Security)が設定されていることを確認してください。


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

Salesforceにおけるレコードロッキングは、同時実行環境下でのデータ整合性を確保するための重要なメカニズムです。特にApexのFOR UPDATE句は、ミッションクリティカルなビジネスロジックにおいて、予期せぬデータ破損を防ぐために不可欠なツールです。

主要なポイントの要約:

  • 目的: 複数のユーザーやプロセスによる同一レコードへの同時更新を防ぎ、データの一貫性と整合性を保証します。
  • 主なツール: ApexのSOQLクエリにおけるFOR UPDATE句が、明示的な排他ロック(exclusive lock)を提供します。
  • 挙動: FOR UPDATEで取得されたレコードは、現在のトランザクションが終了するまでロックされます。他のトランザクションがそのレコードを更新しようとすると、UNABLE_TO_LOCK_ROWエラーが発生するか、タイムアウトまで待機します。

ベストプラクティス:

  1. FOR UPDATEの慎重な使用: レコードロッキングは、絶対的なデータ整合性が必要であり、かつ同時変更のリスクが高い場合にのみ使用すべきです。不必要にロックを使用すると、システム全体のパフォーマンスに悪影響を与えます。
  2. ロック期間の最小化: ロックを取得してから解放するまでの時間を可能な限り短縮します。FOR UPDATEクエリとそれに続くDML操作を、できるだけ簡潔なコードブロックにまとめ、トランザクションのクリティカルセクションを最小限に抑えます。
  3. 堅牢なエラーハンドリングの実装: UNABLE_TO_LOCK_ROWエラーを常に捕捉し、適切に処理するロジックを実装します。再試行ロジックは有効な場合もありますが、無限ループやデッドロックを避けるために、試行回数や待機時間を制限する必要があります。
  4. 一貫したロック順序の採用: 複数のレコードをロックする必要がある場合は、常に特定の順序(例:Idの昇順)でレコードをクエリしてロックすることで、デッドロックのリスクを軽減します。
  5. 最小限のレコードのロック: 必要最小限のレコードのみをロックするようにSOQLクエリを最適化します。関連する多数のレコードを不必要にロックすると、競合の可能性が高まります。
  6. 非同期処理の検討: 高頻度で競合が発生する可能性のあるシナリオでは、Queueable Apex、Batch Apex、またはPlatform Eventsのような非同期処理メカニズムを活用することを検討します。これにより、リアルタイムのロック競合を減らし、処理をオフロードすることができます。
  7. 徹底的なテスト: ロッキングが関わるロジックは、必ず同時実行シナリオ(concurrent scenarios)をシミュレートしたテスト(stress testing, concurrency testing)を実施し、予期せぬ挙動やデッドロックが発生しないことを確認します。
  8. 楽観的ロック(Optimistic Locking)との使い分け: Salesforceの標準DML操作は、通常、更新前にレコードのSystemModstampを確認する楽観的ロックの原則に基づいて動作します。しかし、これは競合発生時の更新失敗を意味します。FOR UPDATEは、更新前に排他ロックを確保することで、競合を能動的に防ぎ、更新の成功を確実にするという点で異なります。どちらのアプローチが適切かは、具体的なビジネス要件によって異なります。

レコードロッキングは、Salesforceアプリケーションの信頼性とスケーラビリティ(scalability)を向上させるために不可欠な要素です。これらのベストプラクティスを適用することで、開発者は安全で効率的なデータ操作を実装し、ユーザーに安定した体験を提供できるでしょう。

コメント