「System.LimitException: Apex CPU time limit」回避するための実証検証
Salesforce の開発をしている人は、「System.LimitException: Apex CPU time limit」エラーに
遭遇した経験があるのではないでしょうか?
本記事では、私が実際に担当したお客様で、「System.LimitException: Apex CPU time limit」エラーを回避するために多角的に検証を行い、解決に向けたプロセスをまとめた記事になります。
処理速度の計測も行っていますので、是非開発時の参考にしてください。
Apex CPU time limitエラーとは…
「Apex CPU time limitエラー(CPU時間の制限)」とはSalesforceに設けられているガバナ制限の1つで、1つのトランザクションで発生する処理時間の制限を指します。
トランザクションが消費するCPU時間が長すぎると、長時間実行トランザクションとしてシャットダウンされます。
システム構成と事象
大量データを扱う環境(取引先データが約450万)であり、取引先オブジェクトから取引先オブジェクト(自身)に、参照関係が結ばれています。
更に、取引先が起因で発火するワークフロー、プロセスビルダー、Apexトリガー処理が煩雑に実装されています。処理も煩雑ですが、それぞれのプロセス本数も多くあります。
作成本数 | |
プロセスビルダー | 6 |
項目自動更新 | 4 |
Apexトリガー | 2 |
とある日の処理を例にすると、
3,674件の取引先データを一括登録更新する際に、Apex CPU time limitエラーが発生し、
3,674件のうち、3,569件が処理成功、105件が処理失敗という事象が発生しました。
登録更新処理に関しては、Bulk APIを使用したupsert処理を使用しています。
バッチサイズは2,000、並行処理で行っています。
再帰的にApexトリガーが呼ばれていないか確認したところ、
取引先データ1件を新規登録しただけで、Beforeトリガーが10回も呼び出されていました。
Apex CPU time limitエラーとなる原因
ワークフロー、プロセスビルダー等の処理内で、取引先データの更新を行っています。
そのため、ワークフロー、プロセスビルダー等の処理が終了するタイミング(取引先データの更新)で、
トリガーが再帰的に呼び出されていることが分かりました。
また、積み上げ集計項目の値が再計算された場合でもトリガーが再帰的に呼び出されてしまいます。
Apex Beforeトリガー → ApexAfterトリガー → ワークフロー → Apex Beforeトリガー → ApexAfterトリガー → プロセスビルダー → Apex Beforeトリガー → ApexAfterトリガー → …と続きます
1レコードを新規登録するだけで、約9秒もかかります。
試したこと
ワークフロー、プロセスビルダー等が煩雑に実装されていることが原因だった為、下記①、②を実施しました。
①ワークフロー、プロセスビルダー等の処理を無効化して、Apexトリガーのみを有効化する
Apexトリガーのみを有効化し、その他のワークフロー関連処理は全て無効化にしました。
先ほど登録に9秒掛かっていた同じデータを使用して、まずは1件新規登録をすると、1秒も掛からず、CPU time も約1/12まで抑えることが出来ました。
また、Apexトリガーの呼び出し回数は1回のみになりました。
(単純に、1トランザクション処理を軽減したので、驚く結果ではありませんが…)
ワークフロー、プロセスビルダー等の処理を無効化した環境(Apexトリガーのみ有効化)で、1万件のレコードを一括登録を実施しました。
1万件のレコードを3回繰り返し登録して、その平均を取ったところ、約5分46秒(3回平均:約5分58秒,5分48秒,5分32秒)でした。
ちなみに、改修前の状態では、1万件のレコードを一括登録することはできず、
Apex CPU time limitエラーが発生し、約6,000件のデータ登録に失敗していた為、全件登録することができただけで、少し安堵しました。
②ワークフロー、プロセスビルダー等の処理をApexトリガーに全て寄せる
ワークフロー関連処理をApexトリガーに実装し、Apexトリガーのみ有効化しました。
①同様、同じデータを使用して、まずは1件新規登録をすると、約2秒ほど掛かりましたが、CPU time は当初から約1/3に抑えることが出来ました。
そして、なんと、約63万件のデータを23分で処理することが出来ました。
Apex CPU time limitエラーが出力されなかったこと、23分という最速スピードで処理できたことに本当に驚きました。
結論
1オブジェクトに対し、他フローが干渉してしまった結果、Apexトリガーの再帰的な呼び出しがされ、
大量データを一括更新する際に、Apex CPU time limitエラーが発生するケース起因に繋がっていました。
他フローの干渉を抑える為、Apexトリガーに全処理を寄せることで、回避できました。
ワークフロー、プロセスビルダーの処理をApexトリガーに寄せる際の注意事項
処理内容を、beforeトリガーとafterトリガーのどちらに実装するか考慮する必要があります。
beforeトリガーに記載すべき内容
・insert/updateされたレコードを、DBに保存される前に操作する場合
・入力規則のチェックより前に処理を実行する場合
・トリガ対象のオブジェクト自身をinsert/updateする場合
afterトリガーに記載すべき内容
・DBに保存された後のレコードにアクセスする場合
・トリガー対象のオブジェクトのId項目を参照する場合
・トリガー対象以外のオブジェクトをinsert/updateする場合
注意事項
・SOQLをfor文の中で回さない
・DML処理をする際はリストなどでまとめて行う
・afterトリガーでトリガー対象のオブジェクトをinsert/updateする場合は再帰的トリガー発火制御が必要
Appendix
おまけ1 ー 煩雑な環境になってしまった背景
初期構築を行った際は、Apexトリガーに処理を実装していました。
初期構築完了後、開発ベンダーが顧客から離れてしまいました。
しかし、その後も新規の業務要望があり、徐々に顧客自身でフロー等を実装するようになりました。
顧客はエンジニアではないため、Apexの知識はなく、ノーコードで実装できるワークフローやプロセスビルダーに頼り、追加機能を実装していきました。
また、既存プロセスに手を加えることに抵抗があり、新規要望が出てきたときには、
次から次へ、新規プロセスを作成し、結果プロセス本数が多くなってしまいました。
おまけ2 ー Apexトリガーへ1本化すること以外に検討した内容
他に、良い方法がないか検討しましたが、結果は没でした。
アイデアとしては下記2つがありました。
①バッチ化(非同期処理)にすることで、ガバナ制約の緩和させる
⇒Apexトリガーの値変更前のデータ「Trigger.old、Trigger.oldMap」を用意するのが困難
⇒バッチ実行タイミングを考慮する必要がある(運用でデータ更新されない時間を確保)
②static変数を使用して、再帰的な呼び出しを制御する
⇒大量データ登録によりトランザクションが複数に分かれてしまった場合、制御できない
⇒トリガー内の処理を制御することは可能だが、再帰的なトリガーの呼び出し自体を制御することはできない