背景と応用シナリオ
Salesforce開発者として、私たちは日々、堅牢でスケーラブルなアプリケーションの構築に取り組んでいます。その中で最も重要な課題の一つが、Data Integrity (データ整合性) の維持です。特に、複数のユーザーや自動化プロセスが同時に同じレコードを更新しようとする、いわゆるConcurrency (同時実行性) の高いシナリオでは、データ破損のリスクが常に伴います。
例えば、以下のようなシナリオを考えてみましょう。
シナリオ1:営業担当者による商談の同時更新
二人の営業担当者が、同じ大規模な商談レコードに対して、それぞれ異なる関連情報を更新しようとしています。一人がフェーズを変更し、もう一人が関連商品を更新した場合、適切なロック機構がなければ、一方の更新がもう一方の更新を上書きしてしまう「Lost Update (更新の喪失)」問題が発生する可能性があります。
シナリオ2:バッチ処理とユーザー操作の競合
夜間のバッチ処理が、全取引先レコードの年間売上を集計・更新している最中に、あるユーザーが特定の取引先レコードを手動で編集しようとしています。バッチ処理が古いデータを読み込んで計算し、その結果を書き込む前にユーザーがデータを変更してしまうと、最終的な集計結果は不正確なものになります。
これらの問題を解決するためにSalesforceが提供している強力なメカニズムが、Record Locking (レコードロック) です。レコードロックは、特定のトランザクションが完了するまで、他のトランザクションが同じレコードを変更できないようにする排他制御の仕組みです。これにより、データの整合性を保証し、予期せぬデータ破損を防ぐことができます。本記事では、特にApex開発者向けに、SOQLの FOR UPDATE 句を用いた悲観的ロックの実装方法とそのベストプラクティスについて詳しく解説します。
原理説明
Salesforceにおけるレコードロックは、データベースレベルで実装されており、Apex開発者が利用できる最も直接的な方法は、SOQLクエリに FOR UPDATE 句を追加することです。これは一般的にPessimistic Locking (悲観的ロック) と呼ばれるアプローチです。
悲観的ロックの基本的な考え方は、「競合は頻繁に発生するだろう」と悲観的に予測し、データにアクセスする最初の段階で、そのデータをロックしてしまうというものです。具体的には、SELECT ... FOR UPDATE を実行すると、そのクエリで取得されたレコードは、現在のApex Transaction (Apexトランザクション) が完了するまでロックされます。
トランザクションのスコープ
ここで重要なのが「トランザクション」のスコープです。Apexにおけるトランザクションとは、一連の処理が一つの単位として扱われる実行コンテキストを指します。例えば、あるApexメソッドが実行を開始してから終了するまでが、一つのトランザクションです。このトランザクション内でレコードがロックされると、トランザクションがcommit (コミット) されるか、rollback (ロールバック) されるまで、ロックは維持されます。
ロックの動作
- トランザクションAが
SELECT Id FROM Account WHERE Name = 'ACME' FOR UPDATEを実行します。 - データベースは'ACME'という名前の取引先レコードをロックします。
- トランザクションBが、トランザクションAが完了する前に、同じ'ACME'レコードに対して
UPDATE操作やFOR UPDATE付きのSELECTを実行しようとします。 - このとき、トランザクションBは待機状態になります。トランザクションAがロックを解放するまで、処理を進めることができません。
- トランザクションAがDML操作を完了し、トランザクションが正常に終了(コミット)すると、ロックが解放されます。
- 待機していたトランザクションBがロックを取得し、処理を再開します。
もし、トランザクションBの待機時間が長すぎると、タイムアウトが発生し、System.QueryException: Record currently unavailable というエラーがスローされます。この仕組みにより、複数のプロセスが同時に同じレコードを操作することを防ぎ、データの一貫性を保つことができるのです。
示例コード
ここでは、親レコード(取引先)に関連する子レコード(カスタムオブジェクトの「プロジェクト」)の予算合計を計算し、親レコードの集計フィールドを更新するという典型的なシナリオを考えます。この処理中に他のユーザーやプロセスが新しいプロジェクトを追加・更新すると、計算結果が不正確になるため、親である取引先レコードをロックする必要があります。
以下のコードは、指定された取引先IDに対して、関連する全ての `Project__c` レコードの `Budget__c` フィールドを合計し、取引先の `Total_Project_Budget__c` フィールドを更新するApexメソッドです。
public class AccountBudgetCalculator {
public static void calculateAndSaveTotalBudget(Id accountId) {
// トランザクションが失敗した場合に備えて、セーブポイントを設定します。
Savepoint sp = Database.setSavepoint();
try {
// =========================================================================
// 1. 親レコードのロック
// =========================================================================
// FOR UPDATE句を使用して、これから更新する親の取引先レコードをロックします。
// これにより、このトランザクションが完了するまで、他のプロセスはこのレコードを
// 更新できなくなります。これにより、計算中にデータが変更されることを防ぎます。
Account acc = [SELECT Id, Name, Total_Project_Budget__c
FROM Account
WHERE Id = :accountId
FOR UPDATE];
// =========================================================================
// 2. 子レコードの集計
// =========================================================================
// 関連するすべての子レコード(Project__c)を取得し、予算を合計します。
// ここでロックされた取引先に紐づく子レコードを取得します。
Decimal totalBudget = 0;
for (Project__c proj : [SELECT Id, Budget__c
FROM Project__c
WHERE Account__c = :accountId]) {
if (proj.Budget__c != null) {
totalBudget += proj.Budget__c;
}
}
// =========================================================================
// 3. 親レコードの更新とDML操作
// =========================================================================
// 計算した合計予算を親レコードのフィールドに設定します。
acc.Total_Project_Budget__c = totalBudget;
// DML操作を実行してデータベースに更新を反映させます。
// このupdateが完了し、トランザクションがコミットされると、
// FOR UPDATEで取得したロックが解放されます。
update acc;
} catch (DmlException e) {
// DMLエラー(ロック競合によるタイムアウトなど)が発生した場合、
// トランザクションをセーブポイントまでロールバックします。
Database.rollback(sp);
System.debug('DML Error occurred during budget calculation: ' + e.getMessage());
// エラーを再スローするか、カスタム例外をスローして呼び出し元に通知します。
throw new AuraHandledException('An error occurred while updating the account budget. Please try again.');
} catch (QueryException e) {
// クエリエラー(ロック待機中のタイムアウトなど)をキャッチします。
Database.rollback(sp);
System.debug('Query Error occurred, possibly due to a record lock timeout: ' + e.getMessage());
throw new AuraHandledException('The account record is currently locked by another process. Please try again in a few moments.');
}
}
}
注意事項
FOR UPDATE は非常に強力なツールですが、その使用には慎重な検討が必要です。誤った使い方をすると、システムのパフォーマンス低下や、予期せぬエラーの原因となります。
Lock Contention (ロック競合) とパフォーマンス
多くのプロセスが頻繁に同じレコードをロックしようとすると、Lock Contention (ロック競合) が発生します。これにより、多くのトランザクションが待機状態となり、システム全体のスループットが低下します。特に、全レコードを更新するようなバッチ処理で安易に FOR UPDATE を使用すると、ユーザーのオンライン操作に深刻な影響を与える可能性があります。ロックは必要最小限の範囲と時間にとどめるべきです。
Deadlocks (デッドロック)
デッドロックは、2つ以上のトランザクションが互いに相手が保持しているロックの解放を待ち、永久に処理が進まなくなる状態です。
例:
- トランザクションAがレコードXをロックし、次にレコードYをロックしようとする。
- トランザクションBがレコードYをロックし、次にレコードXをロックしようとする。
エラー処理の重要性
前述の通り、レコードが既にロックされている場合、後続のトランザクションはタイムアウトする可能性があります。これは QueryException または DmlException として現れます。したがって、FOR UPDATE を使用するコードは、必ず try-catch ブロックで囲み、これらの例外を適切に処理する必要があります。ユーザーに「現在レコードはロックされています。しばらくしてから再試行してください」といった分かりやすいメッセージを返すか、必要に応じてリトライロジックを実装することが推奨されます。
ガバナ制限
ロックを保持しているトランザクションの実行時間が長引くと、待機している他のトランザクションがCPU時間制限などのガバナ制限に達するリスクが高まります。ロックを取得したら、できるだけ速やかに処理を完了し、トランザクションを終了させることが重要です。ロックを取得する前に、可能な限りの計算や準備を済ませておきましょう。
まとめとベストプラクティス
Salesforceにおけるレコードロック、特に SOQL FOR UPDATE は、同時実行環境下でのデータ整合性を確保するための不可欠なツールです。その原理と潜在的なリスクを正しく理解し、責任を持って使用することが、信頼性の高いアプリケーションを構築する鍵となります。
以下に、レコードロックを実装する際のベストプラクティスをまとめます。
- ロックは短く、局所的に
トランザクションの開始直前、必要になった時点で初めてレコードをロックし、DML操作が完了したら速やかにトランザクションを終了させてください。ロックを保持する時間は可能な限り短くします。 - 必要なレコードのみをロックする
WHERE句を使い、ロック対象のレコードをできるだけ具体的に特定してください。不必要に多くのレコードをロックすることは、ロック競合のリスクを増大させます。 - 一貫した順序でロックする
複数のレコードを更新するロジックでは、必ずレコードIDの昇順などでソートし、一貫した順序でレコードをロックしてください。これはデッドロックを回避するための最も効果的な戦略です。 - 堅牢なエラーハンドリングを実装する
ロックの取得が失敗する可能性を常に念頭に置き、try-catchブロックでロック関連の例外を捕捉し、ユーザーフレンドリーなフィードバックやリトライ機構を提供してください。 - 代替案を検討する
全てのケースで悲観的ロックが必要なわけではありません。更新の競合が稀にしか発生しないシナリオでは、カスタムのバージョン番号フィールドなどを用いたOptimistic Locking (楽観的ロック) の方が、パフォーマンス面で有利な場合があります。要件に応じて適切なロック戦略を選択することが重要です。
私たち開発者は、これらの原則を守ることで、Salesforceプラットフォームの能力を最大限に引き出し、ユーザーに安定した価値を提供し続けることができます。
コメント
コメントを投稿