WEBKT

Salesforce并发控制深度解析:超越乐观锁,探索FOR UPDATE与记录锁定API的抉择

14 0 0 0

一、悲观锁的利器:SOQL 中的 FOR UPDATE

FOR UPDATE 的工作机制

FOR UPDATE 的适用场景

FOR UPDATE 的代价与风险

二、显式控制:记录锁定 API 与自定义机制

1. 利用审批流程锁定 (Approval.lock() / unlock())

2. 自定义锁定机制

三、策略对比与选型指南

四、最佳实践与注意事项

结语

在 Salesforce 平台上处理数据,并发修改是绕不开的挑战。多个用户或自动化进程可能同时尝试更新同一条记录,如果处理不当,就会导致数据不一致、丢失更新等严重问题。Salesforce 默认采用乐观锁 (Optimistic Locking) 机制,它假设并发冲突不常发生。系统在更新时会检查记录自上次读取后是否被修改过(通常通过隐藏的版本字段或时间戳),如果被修改,则更新失败,需要开发者捕获异常并处理(例如重试或提示用户)。

乐观锁简单高效,适用于大多数并发冲突不激烈的场景。但当业务逻辑要求更强的控制,或者并发冲突频繁发生时,仅仅依赖乐观锁就显得捉襟见肘了。这时,我们需要探索 Salesforce 提供的其他并发控制策略,主要是悲观锁 (Pessimistic Locking) 的一种实现——FOR UPDATE,以及更显式的记录锁定 API

本文将深入探讨 FOR UPDATE 和记录锁定 API 的工作原理、适用场景、优缺点以及潜在的代价,帮助 Salesforce 架构师和开发者在面对复杂的并发场景时,做出更明智的技术选型。

一、悲观锁的利器:SOQL 中的 FOR UPDATE

与乐观锁“先更新,后检查”的思路相反,悲观锁假设冲突很可能会发生,因此在操作数据前就先将其锁定,阻止其他事务进行修改,直到当前事务完成。

Salesforce 通过在 SOQL 查询语句末尾添加 FOR UPDATE 关键字来实现行级悲观锁。

// 示例:查询并锁定单个客户记录
Account acc = [SELECT Id, Name FROM Account WHERE Id = :someAccountId FOR UPDATE];

// 示例:查询并锁定多个符合条件的联系人记录
List<Contact> contacts = [SELECT Id, Email FROM Contact WHERE AccountId = :someAccountId FOR UPDATE];

FOR UPDATE 的工作机制

  1. 加锁时机: 当执行带有 FOR UPDATE 的 SOQL 查询时,Salesforce 会尝试获取查询结果中所有记录的行级锁
  2. 锁的持有: 这些锁会一直被当前事务持有,直到事务结束(成功提交或回滚)。
  3. 阻塞行为: 如果其他事务尝试修改(更新或删除)已被 FOR UPDATE 锁定的记录,或者尝试对这些记录执行带有 FOR UPDATEFOR REFERENCE(另一种较少使用的锁定类型)的查询,它们的操作会被阻塞,进入等待状态。
  4. 等待超时: 如果等待锁的时间超过 Salesforce 的内部限制(通常是几秒到十几秒,具体时间未公开且可能变化),尝试获取锁的事务会失败,并抛出 System.QueryException: Record Currently Unavailable 异常。
  5. 锁的范围: FOR UPDATE 锁定的是数据库中的实际行,确保在事务处理期间,这些行不会被其他事务修改。

FOR UPDATE 的适用场景

FOR UPDATE 主要适用于以下场景:

  • 高并发、高冲突的关键业务: 例如,库存扣减、秒杀活动、金融交易处理等,这些场景下“最后写入者获胜”的乐观锁策略可能导致业务错误(如超卖)。FOR UPDATE 能确保读取数据和更新数据之间,记录状态不会发生变化。
  • 需要原子性操作的复杂逻辑: 当一个业务流程需要读取多条记录,进行计算,然后更新这些记录或相关记录时,使用 FOR UPDATE 锁定初始读取的记录可以保证数据的一致性,避免在计算过程中数据被外部修改。
  • 防止丢失更新: 在“读取-修改-写入”模式下,如果业务逻辑对数据一致性要求极高,不能容忍任何更新丢失,FOR UPDATE 是比乐观锁更可靠的选择。

FOR UPDATE 的代价与风险

天下没有免费的午餐,FOR UPDATE 带来的强一致性保障是有代价的:

  1. 性能开销: 获取和管理锁本身会消耗系统资源。更重要的是,锁会阻塞其他事务,降低系统的整体并发处理能力。如果锁定的记录非常热门,或者事务持有锁的时间过长,可能会导致大量事务排队等待,显著影响系统性能和用户体验。
  2. 死锁风险 (Deadlock): 当两个或多个事务互相等待对方持有的锁时,就会发生死锁。例如,事务 A 锁定了记录 1,然后尝试锁定记录 2;同时事务 B 锁定了记录 2,然后尝试锁定记录 1。双方都无法继续执行,最终通常会被 Salesforce 的死锁检测机制发现并强制终止其中一个事务(抛出异常)。避免死锁的关键是让所有事务总是以相同的顺序请求锁。
  3. QueryException 异常: 如前所述,如果等待锁超时,会抛出 QueryException。这意味着开发者必须在代码中显式捕获并处理这种异常,可能需要实现重试逻辑或向用户反馈失败信息。
  4. 用户体验影响: 如果 FOR UPDATE 被用在触发器 (Trigger) 或与用户界面交互紧密的 Apex 代码(如 Visualforce 控制器或 Aura/LWC 的 Apex 方法)中,并且持有锁的时间较长,可能会导致用户界面卡顿或操作失败,用户体验会很差。
  5. Governor Limit 限制: 虽然 Salesforce 没有明确公布 FOR UPDATE 的直接限制,但锁定的记录数量、锁等待时间等都可能间接影响事务的 CPU 时间、执行时间等 Governor Limits。

思考: 使用 FOR UPDATE 就像在繁忙的路口设置了一个临时红绿灯,能保证你的车队安全通过,但可能会让其他方向的车排起长队,甚至引发新的拥堵(死锁)。必须谨慎评估其必要性。

二、显式控制:记录锁定 API 与自定义机制

除了依赖数据库层面的 FOR UPDATE,Salesforce 还提供了一些更上层、更显式的记录锁定方式,允许开发者在应用程序逻辑中更灵活地控制记录的锁定状态。这主要包括利用标准功能(如审批流程)和构建自定义锁定机制。

1. 利用审批流程锁定 (Approval.lock() / unlock())

Salesforce 的标准审批流程在记录提交审批后,默认会将其锁定,防止除指定用户(如管理员或当前审批人)外的其他人编辑。开发者可以通过 Apex 中的 Approval 类提供的静态方法 lock(recordIdOrList, skipEntryCriteria)unlock(recordIdOrList, skipEntryCriteria) 来编程式地锁定或解锁记录,即使这些记录并未实际进入审批流程

工作机制: 调用 Approval.lock() 时,Salesforce 会检查记录是否符合某个活动审批流程的入口条件。如果 skipEntryCriteria 设置为 true,则跳过此检查,直接尝试锁定记录。锁定成功后,记录在用户界面上通常会显示为已锁定,并且标准的编辑操作会被阻止(除非用户具有“Modify All Data”权限或特殊配置)。解锁则通过 Approval.unlock() 完成。

适用场景:

  • 模拟审批锁定: 在自定义逻辑中需要临时阻止用户编辑记录,模拟审批过程中的锁定状态。
  • 长事务或异步处理: 在一个长时间运行的操作(例如复杂的 Batch Apex 或 Queueable Job)开始时锁定相关记录,处理完成后再解锁,以防止在此期间用户或其他自动化修改数据。这对于维护跨多个事务或阶段的状态一致性特别有用。

优点:

  • 标准功能: 利用了 Salesforce 的内置锁定机制,用户界面有直观反馈(记录被锁定)。
  • 相对简单: 调用 API 本身不复杂。

缺点与风险:

  • 语义混淆: 该 API 的主要设计意图是服务于审批流程,滥用它来实现通用锁定可能导致代码意图不清,增加维护难度。
  • 权限依赖: 锁定和解锁操作受用户权限和审批流程配置的影响,可能并非在所有情况下都能按预期工作。
  • 范围限制: 它锁定的是整个记录,粒度较粗。如果只需要保护特定字段或逻辑,这种锁定可能过于严格。
  • 潜在冲突: 如果记录确实处于某个实际的审批流程中,随意调用 unlock() 可能破坏审批状态。
  • 性能考虑: 频繁调用这些 API 也可能带来性能开销。

思考: Approval.lock() 像是一把“万能钥匙”,能锁上很多门,但并非为所有锁设计。用它来解决通用并发问题,有点像用锤子拧螺丝,虽然有时可行,但总觉得不太对劲,且有潜在风险。

2. 自定义锁定机制

当标准锁定机制不满足需求时,开发者可以构建自己的锁定逻辑。常见的实现方式包括:

  • 使用自定义字段: 在需要锁定的对象上添加一个自定义字段,例如复选框 Is_Locked__c 或时间戳字段 Locked_Until__c。在执行关键操作前,检查并设置该字段。操作完成后清除该字段。
    • 优点: 实现简单直观,完全由开发者控制逻辑。
    • 缺点:
      • 非原子性: 检查字段和设置字段是两个独立的操作,存在竞态条件 (Race Condition)。两个事务可能同时检查到记录未锁定,然后都尝试去锁定,只有一个会成功(或都认为自己成功了,取决于后续逻辑)。需要配合 FOR UPDATE 来保证检查和设置操作的原子性,但这又回到了悲观锁的范畴。
      • 可靠性: 如果解锁逻辑失败(例如代码出错或事务回滚),记录可能被永久锁定,需要手动干预。
      • 性能: 如果大量事务频繁检查和更新这个锁字段,可能会产生争用。
  • 使用专用锁对象 (Lock Object): 创建一个独立的自定义对象(例如 Record_Lock__c),包含被锁定记录的 ID (Locked_Record_Id__c)、锁定持有者标识 (Lock_Holder_Id__c)、锁定时间 (Lock_Timestamp__c) 等字段。需要锁定时,尝试插入一条对应记录 ID 的锁记录。如果插入成功(利用 Locked_Record_Id__c 上的唯一约束),则获得锁;如果失败(唯一约束冲突),则表示已被锁定。操作完成后删除该锁记录。
    • 优点:
      • 原子性: 利用数据库的唯一约束来保证锁获取的原子性,避免了自定义字段方案的竞态条件。
      • 灵活性: 可以存储更丰富的锁信息(如锁类型、持有者信息、超时时间等)。
      • 解耦: 将锁定逻辑与业务对象分离。
    • 缺点:
      • 复杂性: 实现和维护成本相对较高。
      • 性能: 每次加锁/解锁都需要 DML 操作,对锁对象的争用可能成为瓶颈。
      • 清理机制: 需要健壮的机制来处理锁未被正常释放的情况(例如,通过定时任务清理过期的锁记录)。
      • 事务边界: 锁记录的插入和删除必须与主业务操作在同一事务中,或者需要仔细设计跨事务的锁定逻辑。

思考: 自定义锁像是自己打造一套门禁系统。你可以设计得非常精巧、灵活,满足各种特殊需求,但也需要投入更多精力去设计、建造和维护,还要时刻警惕系统可能出现的漏洞。

三、策略对比与选型指南

了解了各种锁定机制后,如何在具体场景中做出选择?我们需要权衡它们的核心特性和影响。

特性/策略 乐观锁 (默认) 悲观锁 (FOR UPDATE) 记录锁定 API (Approval.lock) 自定义锁定机制
核心机制 更新时检查版本/时间戳 查询时获取数据库行级锁 应用层API调用,模拟审批锁定 应用层逻辑(字段/锁对象)
锁定时机 更新提交时 (检查) SOQL 查询执行时 Approval.lock() 调用时 自定义逻辑执行时
锁的粒度 记录级 (隐式) 行级 (查询结果) 记录级 可自定义 (通常记录级)
阻塞行为 不阻塞读取,更新冲突时失败 阻塞其他事务的写操作及加锁读 阻止标准UI编辑,不直接阻塞 DML 取决于实现 (通常不直接阻塞)
性能影响 低 (无冲突时) 高 (阻塞导致并发下降) 中 (API调用开销) 中/高 (DML/查询开销, 逻辑复杂度)
实现复杂度 低 (平台内置) 低 (添加关键字) 低/中 (API调用, 权限配置) 高 (需要自行设计、实现、维护)
死锁风险 高 (需注意加锁顺序) 中 (如果实现不当)
用户体验影响 低 (冲突时需重试) 高 (可能导致UI卡顿/操作失败) 中/高 (UI显示锁定, 操作受限) 取决于实现
主要适用场景 低并发冲突,非关键数据 高并发冲突,关键数据原子性操作 长事务/异步锁定,模拟审批状态 特定复杂锁定逻辑,标准机制不足

选型决策树 (简化版):

  1. 并发冲突是否频繁且不可接受数据不一致?
    • 否 -> 乐观锁 (默认通常足够)
    • 是 -> 继续
  2. 是否需要在单个事务内保证“读取-修改-写入”的原子性,防止期间数据被篡改?
    • 是 -> FOR UPDATE (优先考虑,但需评估性能和死锁风险)
    • 否 -> 继续
  3. 是否需要在跨越多个事务、长时间运行的操作(如 Batch, Queueable)或复杂用户界面交互中保持记录锁定状态?
    • 是 -> 考虑 记录锁定 API (Approval.lock)自定义锁定机制
      • 如果模拟审批锁定状态且能接受其局限性 -> Approval.lock
      • 如果需要更灵活、精确的控制或 Approval.lock 不适用 -> 自定义锁定机制 (锁对象方案通常更可靠)
    • 否 -> 回头审视需求,是否真的需要比乐观锁更强的机制?或者是否可以通过优化业务流程、异步化处理来避免并发?

重要考量因素:

  • 事务边界: FOR UPDATE 的锁与 Apex 事务绑定。显式锁定机制的锁生命周期可以跨越事务,但也带来了管理复杂性。
  • 用户交互: 避免在直接响应用户操作的同步 Apex 代码中使用长时间持有锁的策略(尤其是 FOR UPDATE),优先考虑异步处理或优化交互设计。
  • 可维护性: 选择最简单、最符合 Salesforce 平台特性的方案。自定义锁虽然灵活,但长期维护成本最高。

四、最佳实践与注意事项

无论选择哪种策略,遵循以下原则有助于构建健壮、高效的并发控制方案:

  1. 最小化锁范围和持续时间:
    • 仅锁定确实需要保护的记录。
    • 尽可能晚地获取锁,尽可能早地释放锁。对于 FOR UPDATE,这意味着让加锁的 SOQL 靠近需要保护的操作,并尽快结束事务。
    • 对于显式锁,确保有可靠的解锁逻辑,包括异常处理路径。
  2. 避免死锁:
    • 如果需要锁定多条记录,所有代码路径都应始终以相同且固定的顺序(例如按 ID 排序)来获取锁。
  3. 优雅处理锁异常:
    • FOR UPDATE 准备好处理 QueryException (Record Currently Unavailable)。
    • 为自定义锁设计好处理锁获取失败(已被他人锁定)的逻辑。
    • 考虑引入重试机制(带退避策略),但要小心避免无限重试。
  4. 关注用户体验:
    • 尽量避免因锁导致用户界面长时间无响应或频繁操作失败。
    • 提供清晰的反馈信息,告知用户为何操作受阻(例如,“记录正在被其他流程处理,请稍后重试”)。
  5. 充分测试:
    • 并发场景很难通过单元测试完全模拟。需要进行集成测试,甚至在沙箱中模拟高并发负载,以发现潜在的死锁、性能瓶颈和逻辑错误。
  6. 监控与调优:
    • 上线后持续监控系统性能、事务执行时间、错误日志(特别是锁相关的异常)。
    • 根据实际运行情况调整锁定策略或优化代码。

结语

Salesforce 中的并发控制远不止默认的乐观锁。FOR UPDATE 提供了数据库层面的强一致性保障,适用于高冲突的关键事务,但伴随着性能和死锁的风险。记录锁定 API (Approval.lock) 和自定义锁定机制则提供了应用层面的显式控制能力,更适合长事务或特定业务逻辑,但也引入了复杂性和维护成本。

没有哪种策略是万能的。作为 Salesforce 架构师和开发者,我们需要深入理解每种机制的原理、优劣和适用场景,结合具体的业务需求、预期的并发级别、对数据一致性的要求以及对性能和用户体验的影响,进行细致的权衡和审慎的选择。选择合适的并发控制策略,是构建健壮、可扩展 Salesforce 应用的关键一环。记住,最有效的策略往往是那个在满足业务需求的前提下,对系统影响最小、最易于理解和维护的方案。

Apex架构师老王 Salesforce并发控制记录锁定

评论点评

打赏赞助
sponsor

感谢您的支持让我们更好的前行

分享

QRcode

https://www.webkt.com/article/8946