掌握异步 Apex:提升 Salesforce 性能与可扩展性

背景与应用场景

作为一名 Salesforce 开发人员,我们经常面临一个核心挑战:如何在 Salesforce 平台严格的 Governor Limits(系统限制)下,处理大量数据、执行长时间运行的业务逻辑或与外部系统进行复杂的集成。同步执行的 Apex 代码(Synchronous Apex)受到严格的执行时间、DML 操作、SOQL 查询等限制,这使得它不适合处理那些可能耗时过长或资源密集型的任务。

为了解决这些问题,Salesforce 提供了强大的 Asynchronous Apex(异步 Apex)机制。异步 Apex 允许代码在独立的线程中,以后台进程的形式运行,并且拥有自己的、更为宽松的 Governor Limits,从而避免了阻塞用户界面或因超时而失败的风险。这极大地提升了应用程序的性能、可扩展性和用户体验。

以下是异步 Apex 常见的应用场景:

  • 批量数据处理:当需要处理成千上万甚至数百万条记录时(例如,数据迁移、定期数据清理、批量更新字段),同步 Apex 几乎不可能完成,而 Batch Apex 则是理想选择。
  • 外部系统集成(Callouts):与外部 Web 服务进行 HTTP 请求(Callouts)通常需要时间,如果同步执行会阻塞用户界面。使用 Future 方法或 Queueable Apex 进行异步 Callouts 可以避免此问题。
  • 长时间运行的计算或业务逻辑:例如,复杂的财务计算、数据聚合、报告生成,这些任务可能会超出同步 Apex 的 CPU 时间限制。
  • 定时任务:需要定期运行的代码(例如,每日数据同步、每周报告生成、每月数据归档),可以通过 Scheduled Apex 实现自动化。
  • 队列化操作:当需要在一个事务完成后触发另一个异步操作,或者需要将复杂的数据结构传递给异步方法时,Queueable Apex 提供了比 Future 方法更灵活的解决方案。

Salesforce 提供了四种主要的异步 Apex 类型:

  1. Future 方法 (Future Methods):最简单的异步执行方式。
  2. Batch Apex (批量 Apex):专门设计用于处理大量记录。
  3. Queueable Apex (队列化 Apex):比 Future 方法更灵活,支持链式调用和复杂数据类型。
  4. Scheduled Apex (计划 Apex):用于在特定时间运行 Apex 代码。

原理说明

异步 Apex 的核心原理在于它将任务提交到一个后台队列中,由 Salesforce 平台在资源可用时进行处理。每个异步作业都在独立的事务中运行,这意味着它们有自己独立的 Governor Limits,并且不会影响提交它们的原始事务。这种分离确保了即使异步任务失败,也不会回滚触发它的主事务。

Future 方法

Future 方法使用 @future 注解标记,它是一个静态方法,并且只能接受原始数据类型(primitive data types)、MapList 的非 SObject 类型。它不能接受 SObject 或 SObject 的集合作为参数。如果方法需要执行 Callout,则需要额外指定 (callout=true)。Future 方法是“即发即忘”(fire-and-forget)类型,不支持作业链,也无法轻松跟踪其状态。

Batch Apex

Batch Apex 用于处理大量记录,可以将查询结果集分解成多个批次(batches),每个批次在一个独立的事务中处理。它实现了 Database.Batchable 接口,并包含三个核心方法:

  • start(Database.BatchableContext bc)在批处理作业开始时调用,通常用于查询并返回要处理的记录。它返回一个 Database.QueryLocator 或一个 Iterable
  • execute(Database.BatchableContext bc, List scope)对每个批次的记录进行实际处理。scope 参数包含了当前批次的记录列表。
  • finish(Database.BatchableContext bc)在所有批次处理完成后调用,通常用于发送通知邮件或执行后续操作。

每个 execute 方法的调用都算作一个独立的事务,拥有自己的 Governor Limits。

Queueable Apex

Queueable Apex 实现了 Queueable 接口,并包含一个 execute(QueueableContext context) 方法。它提供了一些 Future 方法不具备的优势:

  • 传递 SObject 类型参数:可以直接将 SObject 类型的参数传递给 Queueable 类。
  • 作业链 (Chaining Jobs):可以在一个 Queueable 作业的 execute 方法中调用另一个 Queueable 作业,从而创建一系列的异步任务。
  • 跟踪作业状态:可以获取到提交作业的 ID,从而在 AsyncApexJob 对象中跟踪其状态。

它在一定程度上可以看作是 Future 方法和 Batch Apex 的结合体,适用于需要更复杂参数传递和作业链的场景。

Scheduled Apex

Scheduled Apex 实现了 Schedulable 接口,并包含一个 execute(SchedulableContext sc) 方法。它允许我们使用 System.schedule 方法,通过 CRON 表达式 来指定代码的运行时间。Scheduled Apex 可以用来运行 Batch Apex 或 Queueable Apex,使其定期执行。


示例代码

以下是一些异步 Apex 类型的代码示例,均基于 Salesforce 官方文档提供的方法和结构。

Future 方法示例

此示例展示了一个简单的 Future 方法,用于在后台更新一个账户的描述,并模拟了一个外部 Callout。

public class FutureMethods {

    // 示例1: 更新账户描述 - 不涉及Callout
    @future
    public static void updateAccountDescription(Id accountId, String newDescription) {
        try {
            Account acc = [SELECT Id, Description FROM Account WHERE Id = :accountId LIMIT 1];
            if (acc != null) {
                acc.Description = newDescription + ' (Updated by Future Method)';
                update acc;
                System.debug('Account ' + accountId + ' description updated successfully by future method.');
            }
        } catch (Exception e) {
            System.debug('Error updating account description: ' + e.getMessage());
        }
    }

    // 示例2: 模拟Callout到外部服务
    // 注意: 真正的Callout需要配置Remote Site Settings
    @future(callout=true)
    public static void makeHttpCallout(Id accountId) {
        try {
            // 模拟一个HTTP请求
            HttpRequest req = new HttpRequest();
            req.setEndpoint('https://www.someexternalapi.com/data/' + accountId); // 替换为实际的外部API端点
            req.setMethod('GET');
            Http http = new Http();
            HttpResponse res = http.send(req);

            if (res.getStatusCode() == 200) {
                System.debug('Callout successful for account ' + accountId + '. Response: ' + res.getBody());
                // 在此处可以解析响应并更新Salesforce记录
            } else {
                System.debug('Callout failed for account ' + accountId + '. Status: ' + res.getStatusCode() + ', Body: ' + res.getBody());
            }
        } catch (Exception e) {
            System.debug('Error making HTTP callout: ' + e.getMessage());
        }
    }

    // 调用Future方法
    public static void invokeFutureMethods() {
        // 创建一个测试账户
        Account testAcc = new Account(Name = 'Test Account for Future', Description = 'Original description');
        insert testAcc;

        // 调用更新账户描述的Future方法
        FutureMethods.updateAccountDescription(testAcc.Id, 'New description from async process');

        // 调用模拟HTTP Callout的Future方法
        // 注意: 真实环境中,需要在Setup -> Remote Site Settings中添加'https://www.someexternalapi.com'
        FutureMethods.makeHttpCallout(testAcc.Id);
    }
}

Batch Apex 示例

此示例展示了一个 Batch Apex 类,用于批量更新所有账户的描述,为其添加一个后缀。

public class AccountBatchUpdate implements Database.Batchable, Database.Stateful {
    
    // 用于存储处理的账户数量,Database.Stateful 接口允许在 execute 方法之间保持实例状态
    public Integer recordsProcessed = 0;

    // start 方法:查询要处理的记录
    public Database.QueryLocator start(Database.BatchableContext bc) {
        System.debug('Batch Start method executed.');
        // 返回所有账户记录
        return Database.getQueryLocator('SELECT Id, Name, Description FROM Account');
    }

    // execute 方法:处理每个批次的记录
    public void execute(Database.BatchableContext bc, List scope) {
        System.debug('Batch Execute method started for ' + scope.size() + ' records.');
        List accountsToUpdate = new List();
        for (Account acc : scope) {
            // 避免重复更新,确保幂等性
            if (acc.Description == null || !acc.Description.contains(' (Batch Updated)')) {
                acc.Description = (acc.Description == null ? '' : acc.Description) + ' (Batch Updated)';
                accountsToUpdate.add(acc);
                recordsProcessed++;
            }
        }
        if (!accountsToUpdate.isEmpty()) {
            update accountsToUpdate;
            System.debug('Updated ' + accountsToUpdate.size() + ' accounts in this batch.');
        }
    }

    // finish 方法:所有批次处理完成后执行
    public void finish(Database.BatchableContext bc) {
        System.debug('Batch Finish method executed. Total records processed: ' + recordsProcessed);
        // 可以发送邮件通知或执行后续清理工作
        AsyncApexJob job = [SELECT Id, Status, NumberOfErrors, JobItemsProcessed, TotalJobItems, CreatedBy.Email
                            FROM AsyncApexJob WHERE Id = :bc.getJobId()];
        
        System.debug('Batch job ' + job.Id + ' finished with status ' + job.Status + '. Errors: ' + job.NumberOfErrors);

        // 发送通知邮件
        Messaging.SingleEmailMessage mail = new Messaging.SingleEmailMessage();
        String[] toAddresses = new String[] {job.CreatedBy.Email}; // 发送给启动批处理的用户
        mail.setToAddresses(toAddresses);
        mail.setSubject('Account Batch Update Finished: ' + job.Status);
        mail.setPlainTextBody(
            'The batch job to update account descriptions has finished.\n' +
            'Total records processed: ' + recordsProcessed + '\n' +
            'Job ID: ' + job.Id + '\n' +
            'Status: ' + job.Status + '\n' +
            'Errors: ' + job.NumberOfErrors
        );
        Messaging.sendEmail(new Messaging.SingleEmailMessage[] { mail });
    }

    // 如何调用Batch Apex
    public static Id runBatch() {
        AccountBatchUpdate batchJob = new AccountBatchUpdate();
        return Database.executeBatch(batchJob, 200); // 每次处理200条记录
    }
}

Queueable Apex 示例

此示例展示了一个 Queueable Apex 类,用于在后台处理一个联系人列表,并演示了如何进行作业链。

public class ProcessContactsQueueable implements Queueable {
    
    private List contactIds;
    private Boolean chainNextJob;

    public ProcessContactsQueueable(List ids, Boolean chainNext) {
        this.contactIds = ids;
        this.chainNextJob = chainNext;
    }

    public void execute(QueueableContext context) {
        System.debug('Queueable job started for ' + contactIds.size() + ' contacts.');
        List contactsToUpdate = [SELECT Id, Description FROM Contact WHERE Id IN :contactIds];
        List updatedContacts = new List();

        for (Contact con : contactsToUpdate) {
            con.Description = (con.Description == null ? '' : con.Description) + ' (Processed by Queueable)';
            updatedContacts.add(con);
        }

        if (!updatedContacts.isEmpty()) {
            update updatedContacts;
            System.debug('Updated ' + updatedContacts.size() + ' contacts.');
        }

        // 示例: 链式调用另一个Queueable作业
        if (chainNextJob && !updatedContacts.isEmpty()) {
            List nextBatchIds = new List();
            // 假设我们只处理第一个被更新联系人的父账户
            if(updatedContacts[0].AccountId != null) {
                 nextBatchIds.add(updatedContacts[0].AccountId);
            }
            if (!nextBatchIds.isEmpty()) {
                System.debug('Chaining to next Queueable job to process account: ' + nextBatchIds[0]);
                System.enqueueJob(new ProcessAccountsQueueable(nextBatchIds));
            }
        }
    }

    // 另一个 Queueable 类,用于演示链式调用
    public class ProcessAccountsQueueable implements Queueable {
        private List accountIds;

        public ProcessAccountsQueueable(List ids) {
            this.accountIds = ids;
        }

        public void execute(QueueableContext context) {
            System.debug('Chained Queueable job (ProcessAccountsQueueable) started for ' + accountIds.size() + ' accounts.');
            List accountsToUpdate = [SELECT Id, BillingCity FROM Account WHERE Id IN :accountIds];
            for (Account acc : accountsToUpdate) {
                if (acc.BillingCity == null) {
                    acc.BillingCity = 'Default City';
                }
            }
            if (!accountsToUpdate.isEmpty()) {
                update accountsToUpdate;
                System.debug('Updated ' + accountsToUpdate.size() + ' accounts from chained job.');
            }
        }
    }

    // 如何调用Queueable Apex
    public static Id enqueueJob() {
        // 创建一些测试联系人
        List testContacts = new List();
        for (Integer i = 0; i < 5; i++) {
            testContacts.add(new Contact(FirstName = 'Test', LastName = 'Contact ' + i, Description = 'Initial Description'));
        }
        insert testContacts;

        List contactIdsToProcess = new List();
        for (Contact c : testContacts) {
            contactIdsToProcess.add(c.Id);
        }

        // 提交Queueable作业,并指示是否链式调用
        return System.enqueueJob(new ProcessContactsQueueable(contactIdsToProcess, true));
    }
}

Scheduled Apex 示例

此示例展示了一个 Scheduled Apex 类,用于每天凌晨运行一个清理任务。

public class DailyCleanupScheduler implements Schedulable {

    public void execute(SchedulableContext sc) {
        System.debug('DailyCleanupScheduler job started. Job Name: ' + sc.getJobId());

        // 可以在这里调用其他异步作业,例如 Batch Apex 或 Queueable Apex
        // 例如,启动一个批量删除旧任务的 Batch Apex
        // Database.executeBatch(new DeleteOldTasksBatch(), 200);
        
        // 也可以直接执行DML操作或SOQL查询
        List oldTasks = [SELECT Id FROM Task WHERE CreatedDate < LAST_N_DAYS:90];
        if (!oldTasks.isEmpty()) {
            delete oldTasks;
            System.debug('Deleted ' + oldTasks.size() + ' tasks older than 90 days.');
        } else {
            System.debug('No old tasks found to delete.');
        }
    }

    // 如何调度Scheduled Apex
    public static String scheduleDailyJob() {
        // CRON 表达式: 秒 分 时 日 月 星期几 (年 - 可选)
        // '0 0 0 * * ?' 表示每天凌晨 00:00:00 运行
        // '0 30 1 * * ?' 表示每天凌晨 01:30:00 运行
        String cronExp = '0 0 0 * * ?'; // 每天午夜运行

        DailyCleanupScheduler dailyJob = new DailyCleanupScheduler();
        String jobId = System.schedule('Daily Cleanup Job', cronExp, dailyJob);
        System.debug('Scheduled Daily Cleanup Job with ID: ' + jobId);
        return jobId;
    }
}

注意事项

在使用异步 Apex 时,作为开发人员,需要特别关注以下几个方面:

Governor Limits

虽然异步 Apex 拥有更宽松的限制,但它们并非无限。每种异步类型都有其特定的限制:

  • Future 方法:每个事务最多可以调用 50 个 Future 方法。
  • Batch Apex:
    • 单个 `execute` 方法中查询的记录数:Database.QueryLocator 最多可以处理 5000 万条记录,Iterable 则最多 5 万条。
    • 每个 `execute` 方法都有独立的 Governor Limits(例如,10000 条 DML 行、100 个 SOQL 查询等)。
    • 同时运行的批处理作业数量:最多 5 个。
  • Queueable Apex:
    • 每个事务最多可以入队 50 个 Queueable 作业。
    • 可以通过 System.enqueueJob 链式调用多达 5 层(一个作业可以再调用一个,依此类推,最多 5 个)。
  • Scheduled Apex:
    • 最多可以有 100 个 Scheduled Apex 作业处于“已计划”状态。
    • Scheduled Apex 调度后,它会触发一次 `execute` 方法的运行,该运行也受异步 Governor Limits 约束。

务必查阅 Apex Governor Limits 官方文档以获取最准确和最新的信息。

错误处理与监控

  • Try-Catch 块:在异步方法中,始终使用 try-catch 块来捕获潜在的异常。这可以防止作业完全失败,并允许你记录错误信息。
  • 日志记录:使用 System.debug() 进行详细的日志记录,以便在沙箱环境中调试。在生产环境中,可以考虑将错误信息记录到自定义对象中,以便于监控和报告。
  • 邮件通知:在 Batch Apex 的 finish 方法或 Queueable Apex 的 execute 方法(如果发生严重错误)中发送邮件通知给管理员,以便及时了解作业状态和错误。
  • 监控:通过 Salesforce UI 中的 Setup (设置) -> Apex Jobs (Apex 作业) 或通过查询 AsyncApexJob 对象来监控异步作业的状态。

幂等性 (Idempotency)

设计异步作业时,应尽可能使其具有幂等性。这意味着即使同一个作业被多次运行,其结果也应保持一致,不会产生不必要的副作用(例如,重复创建记录或重复更新数据)。在更新记录时,可以先检查记录的当前状态,避免不必要的 DML 操作。

测试异步代码

异步 Apex 代码必须通过单元测试,并且需要使用 Test.startTest()Test.stopTest() 方法。所有在 Test.startTest()Test.stopTest() 之间提交的异步作业,都会在 Test.stopTest() 执行后立即同步运行,确保测试能够覆盖异步逻辑。

@isTest
private class FutureMethodsTest {
    @isTest static void testUpdateAccountDescription() {
        Account testAcc = new Account(Name = 'Test Acc For Future');
        insert testAcc;

        Test.startTest();
        FutureMethods.updateAccountDescription(testAcc.Id, 'New Future Desc');
        Test.stopTest();

        // 验证异步操作是否成功
        Account updatedAcc = [SELECT Id, Description FROM Account WHERE Id = :testAcc.Id];
        System.assertEquals('New Future Desc (Updated by Future Method)', updatedAcc.Description);
    }
    // ... 其他异步方法的测试
}

共享与安全

异步 Apex (Future, Batch, Queueable, Scheduled) 默认在“系统模式”(System Mode)下运行,这意味着它们会忽略当前用户的共享设置(sharing settings)和字段级安全性(field-level security)。如果需要考虑用户权限,开发者必须在代码中显式实现权限检查,或者对 Batch Apex 使用 `with sharing` 关键字。通常情况下,对于后台数据处理,系统模式是期望的行为,但对于涉及敏感数据或用户特定视图的逻辑,需要特别注意。

请注意:虽然 `Batch Apex` 可以声明 `with sharing`,但 `QueryLocator` 在 `start` 方法中执行的查询仍会在系统模式下运行。`execute` 方法中的 DML 操作会遵循 `with sharing` 的权限设置。

链式调用限制

Queueable Apex 允许链式调用,但最多只能链式调用 5 层。过度链式调用可能会导致性能问题或达到其他 Governor Limits。


总结与最佳实践

异步 Apex 是 Salesforce 平台上处理复杂、长时间运行或大批量任务的基石。作为一名 Salesforce 开发人员,熟练掌握异步编程对于构建高性能、可扩展的应用程序至关重要。以下是一些总结和最佳实践:

  • 选择合适的异步类型:
    • Future 方法:适用于简单的“即发即忘”任务,如单个 Callout 或后台更新少数记录,不需跟踪状态。
    • Batch Apex:处理大量记录(数万到数百万),需要对数据进行分批处理,并且需要跟踪作业进度。
    • Queueable Apex:比 Future 方法更灵活,可传递 SObject 参数,支持作业链,并且可以跟踪作业 ID。适用于需要更精细控制的异步任务。
    • Scheduled Apex:用于定期(例如每日、每周)执行代码,通常与其他异步类型结合使用(例如,调度一个 Batch Apex)。
  • 最小化异步块中的工作量:虽然异步代码有更宽松的限制,但仍应尽量减少在这些方法中执行的工作量。只处理必要的逻辑,避免不必要的查询和计算,以提高效率。
  • 健壮的错误处理:始终在异步代码中实现 try-catch 块,并确保有适当的日志记录和通知机制(如邮件),以便在发生故障时及时响应。
  • 设计幂等性操作:考虑到异步作业可能因系统重试或人为干预而多次运行,设计操作时应确保多次执行与单次执行产生相同的结果。
  • 全面测试:使用 Test.startTest()Test.stopTest() 确保所有异步逻辑都被单元测试覆盖。
  • 监控与优化:定期监控 Apex Jobs,关注失败的作业和性能瓶颈。根据监控结果,对异步逻辑进行优化。
  • 避免硬编码 ID:在异步方法中避免硬编码记录 ID,而是通过参数传递或动态查询获取。
  • 考虑替代方案:对于某些事件驱动的场景,可以考虑使用 Platform EventsChange Data Capture (CDC) 结合 Triggers 或 Flow 来实现更松散耦合的异步处理。

通过遵循这些最佳实践,您将能够有效地利用异步 Apex 来解决复杂的业务挑战,并为您的 Salesforce 应用程序提供卓越的性能和稳定性。

评论

此博客中的热门博文

Salesforce 登录取证:深入解析用户访问监控与安全

Salesforce Experience Cloud 技术深度解析:构建社区站点 (Community Sites)

Salesforce Einstein AI 编程实践:开发者视角下的智能预测