「System.LimitException: Apex CPU time limit」回避するための実証検証

2023-08-15

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件が処理失敗という事象が発生しました。

Apex CPU time limitエラー が発生

登録更新処理に関しては、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変数を使用して、再帰的な呼び出しを制御する
 ⇒大量データ登録によりトランザクションが複数に分かれてしまった場合、制御できない
 ⇒トリガー内の処理を制御することは可能だが、再帰的なトリガーの呼び出し自体を制御することはできない