future methodsで痛い目を見たあの頃:開発者が語る「安易な非同期化」の落とし穴

future methods で実際にやらかした判断の話から始めよう。あれは私がSalesforceの開発を始めて間もない頃、パフォーマンス問題やガバナリミットに直面したときに、安易に「非同期にすれば解決する」と思い込んでいた時代のことだ。

あるオブジェクトAの更新トリガーで、関連するオブジェクトBの複数レコードを更新する必要があった。同期的に処理すると、更新対象のBのレコード数が多くなった場合にガバナリミット(特にSOQLクエリ数やDMLステートメント数)に引っかかる可能性があったので、私は何の躊躇もなくトリガーから future メソッドを呼んだ。

未来に丸投げしたかった過去の自分

当時の私は、とにかくトリガー内の処理を軽くして、ユーザートランザクションの応答速度を確保することしか考えていなかった。future メソッドは非同期で実行されるため、トリガーのガバナリミットから解放されるという認識だけが先行していたのだ。

具体的なコードはこんな感じだった。

public class ObjectATriggerHandler {
    public static void afterUpdate(List<ObjectA__c> newRecords, Map<Id, ObjectA__c> oldMap) {
        Set<Id> relevantObjectAIds = new Set<Id>();
        for (ObjectA__c newA : newRecords) {
            // 特定の条件を満たすObjectAの更新があった場合のみ処理
            if (newA.Status__c == 'Completed' && oldMap.get(newA.Id).Status__c != 'Completed') {
                relevantObjectAIds.add(newA.Id);
            }
        }

        if (!relevantObjectAIds.isEmpty()) {
            ObjectAProcessor.processRelatedObjectsFuture(relevantObjectAIds);
        }
    }
}

public class ObjectAProcessor {
    @future
    public static void processRelatedObjectsFuture(Set<Id> objectAIds) {
        // ここでやってしまった...
        List<ObjectA__c> parentObjects = [SELECT Id, Name, RelatedField__c FROM ObjectA__c WHERE Id IN :objectAIds];
        
        Set<Id> relatedObjectBIdsToUpdate = new Set<Id>();
        for (ObjectA__c a : parentObjects) {
            // ObjectAと関連するObjectBのIDを収集するロジック
            // 例: ObjectA.RelatedField__c をキーにObjectBを探すなど
            // ...大量のSOQLやDMLがここに続く...
            // 今回は例として直接ObjectAから関連レコードを更新する体で記述
            for(ObjectB__c b : [SELECT Id, Status__c FROM ObjectB__c WHERE LookupToA__c = :a.Id]) {
                 relatedObjectBIdsToUpdate.add(b.Id);
            }
        }

        List<ObjectB__c> objectsBToUpdate = new List<ObjectB__c>();
        for (Id bId : relatedObjectBIdsToUpdate) {
            objectsBToUpdate.add(new ObjectB__c(Id = bId, Status__c = 'Processed'));
        }
        
        if (!objectsBToUpdate.isEmpty()) {
            update objectsBToUpdate;
        }
    }
}

当時はこれで問題ないと思っていた。むしろ「良い設計だ」とすら考えていたかもしれない。

引数にオブジェクトを渡せない制約と、そこから生まれた無駄

まず最初の後悔は、future メソッドの引数の制約だ。プリミティブ型やプリミティブ型の配列しか渡せないため、トリガーで取得した `ObjectA__c` のレコードをそのまま `future` メソッドに渡すことができなかった。仕方なく、私は `ObjectA__c` のIDのSetを渡し、`future` メソッドの中で再度SOQLを発行して、`ObjectA__c` のレコードを取得し直していたのだ。

これがどれだけ無駄だったか、今となってはぞっとする。トリガー内で既にメモリ上に存在し、必要なら簡単に渡せるはずのデータを、非同期の壁を越えるために一度失い、そして再びデータベースから取得し直す。これは明らかに非効率で、パフォーマンス改善どころか、余計なSOQLクエリを発行し、データベースへの負荷を増やしていた。当時の私は、この「再SOQL」のコストを全く意識していなかった。

今ならこんな無駄なSOQLは絶対に書かない。どうしてもオブジェクトのデータが必要なら、必要なフィールドだけを抽出してプリミティブ型のリストとして渡すか、Custom Setting/Custom Metadataを使って設定情報として持たせるなど、いくらでもやりようがあったはずだ。

トリガーとfutureのトランザクション分離がもたらす地獄

そして最大の問題は、トランザクションの分離がもたらすレコードロックとデータ不整合だった。

ObjectAのレコードが更新され、それがコミットされた後でfutureメソッドが実行される。この間に何が起こるか?

  1. ObjectAの更新がコミットされる。
  2. futureメソッドがキューに乗り、後で実行される。
  3. その間に、別のユーザーやプロセスが、futureメソッドが更新しようとしている関連ObjectBのレコード、あるいはObjectAのレコードを更新してしまう。
  4. futureメソッドが実行されたときに、既にロックされているObjectBのレコードにアクセスしようとして、**"UNABLE_TO_LOCK_ROW"** エラーが発生する。
  5. あるいは、futureメソッドが参照するObjectAやObjectBのデータが、別のプロセスによって変更されてしまい、futureメソッドが意図しない古いデータに基づいて処理を進めてしまうことで、データ不整合が発生する

特に痛かったのは、ObjectAの更新が成功しても、その後に実行されたfutureメソッドがエラーで失敗した場合だ。ObjectAは更新されているのに、関連するObjectBは更新されず、データが中途半端な状態になってしまった。futureメソッドには同期トランザクションのようなロールバック機構がないため、この中途半端な状態を検知・修正する仕組みを別途構築する必要があったが、当時の私にはそこまで気が回らなかった。

当時は「非同期処理は独立しているから安全だ」と漠然と思っていたが、実際には独立しているが故に、主となるトランザクションとの間に時間差と状態の不一致が生まれるという、非常に危険な側面があることを思い知らされた。

今なら、同期的な処理でガバナリミットを回避できないか、トリガーではなく別の自動化(フローや標準機能)で解決できないか、そもそもそのデータ構造が適切か、といった根本的な部分から見直すだろう。


これは当時の自分向けのメモだ。

コメント