概要とビジネスシーン
データスキュー(Data Skew)は、Salesforce 環境における大規模データ処理のパフォーマンス低下やロック競合を引き起こす、特定のデータレコードへの参照が極端に偏る現象であり、その理解と対策はシステム安定性とスケーラビリティの鍵となります。特にSalesforceのマルチテナントアーキテクチャでは、データストレージとリレーションシップがパフォーマンスに大きく影響するため、コンサルタントとしてこの問題に早期に対処することが、顧客のビジネス継続性を確保する上で不可欠です。
実際のビジネスシーン
データスキューは様々な業界でパフォーマンス問題を引き起こします。
- シーンA:製造業 - 親子リレーションシップにおけるスキュー
- ビジネス課題:ある大規模製造業者が、すべての部品オーダーを単一の親アカウント(例: 「本社」または「共通資材部門」)に紐付けていました。月末の大量レポート作成や一括更新処理時、この「本社」アカウントへの参照が集中し、排他的なレコードロック(Record Lock)競合やタイムアウトが頻繁に発生。基幹システムとの連携バッチ処理もエラーが多発し、生産計画に遅延が生じていました。
- ソリューション:データモデルの見直しを提案し、ダミーの親アカウントを複数作成して関連するオーダーを分散させる設計変更を実施しました。これにより、ロック競合のリスクを低減し、並行処理を可能にしました。
- 定量的効果:レポート生成時間が約50%短縮され、夜間バッチ処理のエラー率が90%減少。生産計画の遅延が解消されました。
- シーンB:金融サービス - レコード所有者スキュー
- ビジネス課題:ある金融機関が顧客情報を管理しており、特定の「顧客タイプ」(例: 「一般顧客」)に属するレコードが全体の80%を占めていました。さらに、この大量の「一般顧客」レコードの所有者(Owner)がシステムインテグレーションユーザーや特定の管理者一人に集中しており、このユーザーが関与するレポートやバッチ処理が非常に遅延し、システム負荷が高まりました。
- ソリューション:レコードタイプだけでなく、さらに粒度の細かいカテゴリ(例: 地域、担当者、契約日など)でデータを分散させるためのカスタム項目を導入し、インデックスを最適化しました。また、所有者スキュー(Owner Skew)対策として、自動割り当てルールやバッチ処理を用いて、レコードの所有者を複数人のダミーユーザーや共有キューに分散させました。
- 定量的効果:バッチ処理の完了時間が30%短縮され、ユーザーインターフェースの応答性が向上。月次レポートの生成も安定しました。
技術原理とアーキテクチャ
データスキューは、Salesforceのデータベース管理システム(DBMS)において、大量の子レコードが特定の親レコードに集中したり、特定のユーザーが大量のレコードを所有したりすることで発生します。これにより、対象のレコードに対する操作(読み取り、更新、削除)がボトルネックとなり、レコードロック競合(Record Locking Contention)やガバナ制限(Governor Limits)への抵触、パフォーマンス低下を引き起こします。
主要なデータスキューの種類は以下の通りです。
- オーナーベーススキュー(Owner-Based Skew):一人のユーザーが大量のレコードを所有し、そのユーザーがロール階層のトップに位置する場合、共有ルールやロール階層計算に大きな負荷がかかります。
- ルックアップスキュー(Lookup Skew):多数の子レコードが単一の親レコードを参照するLookupリレーションシップで発生します。
- 親スキュー(Parent Skew):特にMaster-Detailリレーションシップ(Master-Detail Relationship)において、多数の子レコードが単一の親レコードに紐づく場合に発生します。この場合、親レコードの更新時に子レコード全体のロックが必要となるため、競合が起こりやすくなります。
これらのスキューは、Salesforceのマルチテナントアーキテクチャにおいて、共有リソースの効率的な利用を妨げます。特に、DML(Data Manipulation Language)操作時のロックメカニズムや、共有ルールの再計算時に顕著な影響が出ます。
データフロー:データスキュー発生時の影響
| ステップ | 通常時のデータフロー | データスキュー発生時の影響 |
|---|---|---|
| 1. ユーザーまたはバッチがDML操作を開始 | 対象レコードと関連レコード(親など)をロック | 特定の親/所有者にDMLが集中し、ロックの待ち行列が発生 |
| 2. ロックが取得され、処理実行 | DML操作が効率的に完了 | ロック取得に時間がかかり、タイムアウトやデッドロックが発生 |
| 3. 関連する共有ルール/ロール階層の再計算 | 効率的な計算 | 大量のレコードを持つ親/所有者の変更により、再計算に莫大なリソースと時間が必要 |
| 4. レポート、リストビューの表示 | フィルターされたデータが迅速に表示 | 大量の関連データを持つオブジェクトのクエリが遅延、応答不能に |
ソリューション比較と選定
データスキュー対策には複数のアプローチが存在します。以下に主要なソリューションを比較します。
| ソリューション | 適用シーン | パフォーマンス | Governor Limits | 複雑度 |
|---|---|---|---|---|
| データ分散(Data Distribution) (Owner/Parentの分散) |
特定のParentやOwnerにレコードが集中している場合。Master-DetailやLookup Skew、Owner Skewの根本的解決。 | 大幅な改善。ロック競合の解消。 | バッチ処理などによる分散時にDML制限に注意。 | データモデル変更、データ移行が必要な場合あり。中〜高。 |
| カスタムインデックスの活用(Custom Indexing) | 大規模なデータセットに対して頻繁にフィルターされる項目がある場合。特定のクエリのパフォーマンス改善。 | クエリ性能の改善。 | インデックスはGovernor Limitsに直接影響しないが、過度な使用はストレージを消費。 | 比較的低。インデックス追加のみ。 |
| 非同期処理(Asynchronous Processing)の活用 (Batch Apex, Queueable Apex) |
大規模なデータ更新、共有ルール再計算など、リアルタイム性が不要な重い処理。 | ユーザー体験の向上(UIブロッキング回避)、大量データ処理が可能。 | 非同期Apexの実行制限に注意(例: 1日最大 250,000 回の非同期Apexメソッド実行)。 | Apexコード開発が必要。中。 |
データスキュー対策を優先すべき場合:
- ✅ 特定のMaster-Detailリレーションシップにおいて、親レコードが10,000以上の子レコードを持つ場合(Parent Skew)。
- ✅ 特定のユーザーが10,000以上のレコードを所有している場合、特にそのユーザーがロール階層のトップに近い場合(Owner Skew)。
- ✅ 大量のレポートがタイムアウトしたり、DML操作時に頻繁にレコードロック競合が発生する場合。
- ❌ 不適用シーン:単純に組織全体のデータ量が多いだけで、特定のレコードへの参照の偏りがない場合。この場合は、インデックスの最適化や標準のバッチ処理で十分に対応できることが多いです。
実装例
Salesforceのデータスキュー対策は、データモデルの設計段階から考慮することが重要ですが、既存のスキューを緩和するためには、Apexバッチ処理によるデータ分散が有効です。ここでは、Owner Skewを緩和するために、特定のOwnerに集中したAccountレコードを、事前に用意したダミーのOwnerに分散させるBatch Apexの例を示します。
この例では、`Database.Batchable`インターフェースを実装し、ターゲットのOwnerが持つAccountレコードを取得し、指定された新しいOwner(またはOwnerキュー)に順次割り当てるロジックを記述します。これにより、単一のOwnerに集中していたDML操作や共有ルールの計算負荷を分散させます。
public class AccountOwnerRedistributionBatch implements Database.Batchable<SObject>, Database.AllowsCallouts {
// ターゲットとなる古いOwnerのID (スキューの元となるOwner)
private Id oldOwnerId;
// 分散先の新しいOwnerのIDリスト (ダミーOwnerやキュー)
private List<Id> newOwnerIds;
// 処理中の新しいOwnerのインデックス
private Integer ownerIndex = 0;
/**
* コンストラクタ
* @param oldOwnerId スキューを持つ現在のOwnerのID
* @param newOwnerIds 分散先の新しいOwnerのIDリスト
*/
public AccountOwnerRedistributionBatch(Id oldOwnerId, List<Id> newOwnerIds) {
if (oldOwnerId == null || newOwnerIds == null || newOwnerIds.isEmpty()) {
throw new IllegalArgumentException('Old Owner ID and New Owner IDs list cannot be null or empty.');
}
this.oldOwnerId = oldOwnerId;
this.newOwnerIds = newOwnerIds;
}
/**
* バッチ処理の開始時に実行され、処理対象のレコードセットを定義します。
* Database.QueryLocator を使用することで、SOQLクエリの制限を超えてレコードを取得できます。
*/
public Database.QueryLocator start(Database.BatchableContext bc) {
// 特定の古いOwnerに属するAccountレコードを選択
// WITH SECURITY_ENFORCED を使用して、現在のユーザーの権限でアクセス可能なレコードのみを処理
String query = 'SELECT Id, OwnerId FROM Account WHERE OwnerId = :oldOwnerId';
return Database.getQueryLocator(query + ' WITH SECURITY_ENFORCED');
}
/**
* startメソッドで取得されたレコードがバッチ単位で処理されます。
* ここでレコードのOwnerを変更するDML操作を実行します。
* @param scope 現在のバッチで処理されるレコードのリスト
*/
public void execute(Database.BatchableContext bc, List<Account> scope) {
List<Account> accountsToUpdate = new List<Account>();
for (Account acc : scope) {
// 現在のOwnerインデックスに基づいて、新しいOwnerを循環的に割り当てる
acc.OwnerId = newOwnerIds[ownerIndex];
accountsToUpdate.add(acc);
// 次のOwnerにインデックスを進める(リストの最後に達したら最初に戻る)
ownerIndex = (ownerIndex + 1) % newOwnerIds.size();
}
if (!accountsToUpdate.isEmpty()) {
// DML操作を実行し、部分的な成功と失敗を処理するためにDatabase.updateを使用
Database.SaveResult[] results = Database.update(accountsToUpdate, false); // falseで部分的な成功を許可
for (Integer i = 0; i < results.size(); i++) {
if (!results[i].isSuccess()) {
// エラーログの記録(Debug LogやCustom Objectに記録するなど)
System.debug('Error updating account ' + accountsToUpdate[i].Id + ': ' + results[i].getErrors()[0].getMessage());
}
}
}
}
/**
* バッチ処理の終了時に実行されます。後処理や通知などを行います。
*/
public void finish(Database.BatchableContext bc) {
// バッチ処理の完了を通知(例: メール通知、Chatter投稿など)
System.debug('Account Owner Redistribution Batch finished. Job Id: ' + bc.getJobId());
// 例えば、メールを送信する場合
// Messaging.SingleEmailMessage mail = new Messaging.SingleEmailMessage();
// mail.setToAddresses(new String[] {'admin@example.com'});
// mail.setSubject('Account Owner Redistribution Batch Completed');
// mail.setPlainTextBody('The batch for redistributing account owners has completed successfully.');
// Messaging.sendEmail(new Messaging.SingleEmailMessage[] { mail });
}
}
実装ロジック解析:
- コンストラクタ: スキューの原因となっている現在のOwnerのIDと、レコードを分散させる先の新しいOwner(またはキュー)のIDリストを受け取ります。
startメソッド: `Database.getQueryLocator` を使用して、指定された古いOwnerに属するすべてのAccountレコードを取得します。`WITH SECURITY_ENFORCED` を使用することで、現在のユーザーの権限でアクセス可能なレコードのみが処理され、セキュリティが強化されます。executeメソッド: `start` メソッドで取得されたレコードがバッチ単位で渡されます。各バッチ内で、レコードリストを繰り返し処理し、`newOwnerIds` リストから新しいOwnerを循環的に割り当てます。これにより、レコードが複数のOwnerに分散されます。`Database.update(records, false)` を使用することで、一部のレコードの更新が失敗しても、残りのレコードの処理を続行できます。finishメソッド: バッチ処理が完了した際に実行され、処理完了の通知や追加の後処理を行います。
このバッチは、特にOwner Skewが深刻で、手動でのOwner変更が困難な大規模な組織で非常に有効です。実行する際は、Developer Consoleの匿名実行ウィンドウやスケジュールされたApexを通じて開始します。
// バッチの実行例
// 1. スキューを持つOwnerのIDを取得
Id oldOwnerId = '005XXXXXXXXXXXXXXX'; // 実際のOwner IDに置き換える
// 2. 分散先の新しいOwnerのIDリストを取得 (ダミーユーザーやキューなど)
List<Id> newOwnerIds = new List<Id>{
'005YYYYYYYYYYYYYYY', // 新しいOwner 1
'005ZZZZZZZZZZZZZZZ' // 新しいOwner 2
// 必要に応じてさらに追加
};
// 3. バッチインスタンスを作成し、実行
Database.executeBatch(new AccountOwnerRedistributionBatch(oldOwnerId, newOwnerIds), 200); // 200はバッチスコープサイズ
注意事項とベストプラクティス
権限要件
- Apex実行権限:バッチクラスを実行するユーザーは「Apexの実行」権限が必要です。
- オブジェクト権限:Accountオブジェクトに対する「編集」権限が必要です。
- レコード所有権変更権限:レコードのOwnerIdを変更するためには、「すべてのデータを変更」権限または対象オブジェクトの「所有権を移譲」権限が必要です。
Governor Limits(2025年版)
データスキュー対策としてバッチ処理を用いる場合、以下のGovernor Limitsに特に注意が必要です。
- DML操作の最大レコード数:1回のApexトランザクションあたり10,000レコードまで。バッチの`execute`メソッド内でDML操作を行う場合、`scope`のレコード数(デフォルト200)がこの制限に収まるように設計してください。
- SOQLクエリの最大行数:`start`メソッドで`Database.QueryLocator`を使用する場合、5000万レコードまで処理できます。それ以外の場合、50,000レコードまでです。
- CPU時間の制限:1回のApexトランザクションあたり10,000ms(非同期処理は60,000ms)。複雑なロジックを`execute`メソッドに入れると、この制限に抵触する可能性があります。
- ヒープサイズの制限:非同期Apexトランザクションあたり12MB。大量のデータをメモリに読み込むと、ヒープサイズエラーが発生する可能性があります。
- 1日あたりの非同期Apex呼び出し回数:各組織は1日あたり最大 250,000 回の非同期 Apex メソッドを実行できます。
エラー処理
- `Database.SaveResult`の利用:DML操作を行う際は、`Database.update(records, false)`のように`allOrNone`パラメータを`false`に設定し、部分的な成功を許可することで、バッチ処理全体が停止することを防ぎます。結果は`Database.SaveResult`オブジェクトで確認し、失敗したレコードのエラーメッセージをログに出力します。
- カスタムエラーログオブジェクト:重要なバッチ処理では、エラーの詳細を記録するためのカスタムオブジェクトを作成し、エラー発生時にレコードを作成するように設計すると、デバッグや監視が容易になります。
パフォーマンス最適化
- データモデルの慎重な設計:Master-Detailリレーションシップの使用は慎重に行い、特に大量の子レコードを持つ親を持つ可能性のある場合は、Lookupリレーションシップを検討するか、ダミーの親レコードでデータを分散させます。
- 適切なインデックスの作成:クエリで頻繁にフィルターされるカスタム項目には、カスタムインデックスを作成します。Salesforceサポートを通じてカスタムインデックスをリクエストできます。
- Owner Based Skewの回避:システムインテグレーションユーザーや特定の管理者に大量のレコードが集中しないよう、所有権を積極的に分散させます(上記のバッチ例のように)。必要に応じて、ダミーユーザーやキューを作成してOwnerとして利用します。
- バッチ処理の最適化:
- `start`メソッドで`Database.QueryLocator`を使用し、ガバナ制限内で大規模データセットを処理します。
- `execute`メソッド内のロジックは可能な限りシンプルにし、DML操作をバッチ化します。
- `scope`サイズ(`Database.executeBatch`の2番目の引数)を調整し、Governor Limitsに抵触しない範囲で最適な並列性を確保します。
- レポートとリストビューの最適化:複雑なレポートやリストビューは、`WHERE`句を最適化し、必要な列のみを選択するようにします。可能であれば、非同期レポート生成ツールやダッシュボードキャッシュを活用します。
よくある質問 FAQ
Q1:データスキューはなぜSalesforce環境で特に問題になるのですか?
A1:Salesforceはマルチテナントアーキテクチャを採用しており、データベースリソースを複数の顧客(テナント)で共有しています。特定のレコードにアクセスが集中すると、共有リソースに過度な負荷がかかり、他のテナントや自身の組織のパフォーマンスにも悪影響を及ぼすレコードロック競合やタイムアウトが発生しやすくなるためです。
Q2:組織内のデータスキューをどのように特定すればよいですか?
A2:Developer ConsoleのQuery Editorやレポート機能を使用して、特定の親レコードやオーナーが持つ子レコードの数を集計するSOQLクエリを実行します。 例えば、Parent Skewを検出するには:
SELECT ParentId, COUNT(Id) FROM ChildObject__c GROUP BY ParentId ORDER BY COUNT(Id) DESC LIMIT 10Owner Skewを検出するには:
SELECT OwnerId, COUNT(Id) FROM Account GROUP BY OwnerId ORDER BY COUNT(Id) DESC LIMIT 10これにより、偏りのある親レコードやOwnerを特定できます。また、デバッグログで「UNABLE_TO_LOCK_ROW」エラーやDML操作の遅延を確認することも手掛かりになります。
Q3:データスキュー対策を導入した後の効果はどのように監視・評価すれば良いですか?
A3:以下の指標を監視します:
- システムパフォーマンス指標:Salesforceの標準ツール(例: Health Check、API Usage)やカスタムダッシュボードで、レポート実行時間、バッチ処理完了時間、Apex実行時間、API応答時間などを追跡します。
- エラーログ:`UNABLE_TO_LOCK_ROW`エラーやDMLタイムアウトエラーの発生頻度を監視し、改善が見られるかを確認します。
- ユーザーエクスペリエンス:ユーザーからのシステム遅延に関するフィードバックが減少したかを確認します。
まとめと参考資料
データスキューはSalesforce環境におけるパフォーマンスのボトルネックとなりうる重要な課題です。Salesforceコンサルタントとして、その技術的原理とビジネスへの影響を深く理解し、適切なデータモデル設計、インデックス最適化、そして効果的な非同期処理を組み合わせることで、顧客のSalesforce環境の安定性とスケーラビリティを確保できます。早期の対策と継続的な監視が、健全なSalesforce運用には不可欠です。
公式リソース:
コメント
コメントを投稿