Salesforce 乐观锁实战:防止并发更新冲突的几种方法对比与选择
问题的根源:并发更新与数据丢失
什么是乐观锁?为什么在 Salesforce 中常用?
方法一:利用标准字段 LastModifiedDate
方法二:使用自定义版本字段 (数字或日期时间)
对比与选择:LastModifiedDate vs 自定义版本字段
优雅地处理冲突:用户体验是关键
辅助工具:Flow 和 Validation Rule 的角色?
最佳实践与注意事项
结论
问题的根源:并发更新与数据丢失
在任何多用户系统中,Salesforce 也不例外,并发操作是常态。想象一下这个场景:两个销售人员(或者一个用户和一个自动化流程)同时打开了同一个“业务机会”记录。销售A 更新了“金额”,销售B 更新了“结束日期”。如果系统没有并发控制机制,后保存的操作可能会覆盖先保存的操作中修改的数据,导致“金额”或“结束日期”的更新丢失。这就是典型的“丢失更新”(Lost Update)问题。对于依赖数据准确性的业务系统来说,这种情况是不可接受的。
什么是乐观锁?为什么在 Salesforce 中常用?
为了解决并发冲突,数据库系统通常有两种主要的锁定策略:
- 悲观锁 (Pessimistic Locking): 假定冲突很可能会发生。在用户读取数据时就将其锁定,阻止其他用户修改,直到当前用户释放锁。这种方式可以绝对保证数据一致性,但在 Web 环境和 Salesforce 这种基于请求/响应模型的平台上,用户持有锁的时间可能很长(比如用户打开编辑页面但长时间未操作),会严重影响系统的并发性能和可用性。
- 乐观锁 (Optimistic Locking): 假定冲突发生的概率较低。系统允许多个用户同时读取数据。在用户尝试更新数据时,系统会检查自该用户读取数据后,数据是否已被其他用户修改过。如果数据未被修改,则允许更新;如果数据已被修改,则拒绝本次更新,并通知用户发生了冲突。
Salesforce 平台本身在底层处理事务和记录锁定,但其内置的行级锁定主要是为了保证 DML 操作的原子性和事务隔离性,通常是短暂的悲观锁。它并不能直接解决用户层面长时间编辑导致的“丢失更新”问题。因此,我们需要在应用程序层面实现乐观锁机制。
乐观锁的核心思想是:“先读取,后检查再更新”。它不在读取时加锁,而是在提交更新时检查版本,因此对并发性能的影响较小,更适合 Web 应用和 Salesforce。
方法一:利用标准字段 LastModifiedDate
最直接的方法是利用 Salesforce 每个对象都自带的标准字段 LastModifiedDate
。这个字段记录了记录最后一次被修改的时间戳。
核心逻辑:
- 用户打开编辑页面或开始处理某条记录时,获取该记录当前的
LastModifiedDate
值,并将其保存在客户端(如 Visualforce 的 ViewState 或 LWC 的属性)或传递给后端处理逻辑。 - 当用户提交保存请求时,将之前保存的
LastModifiedDate
值传回服务器端。 - 在服务器端的 Apex 代码中,执行更新操作前,先查询数据库中该记录当前的
LastModifiedDate
。 - 比较用户开始编辑时获取的
LastModifiedDate
和数据库中当前的LastModifiedDate
。- 如果两者一致,说明在用户编辑期间,记录没有被其他人修改,可以执行更新操作。
- 如果两者不一致,说明记录已被修改,发生了并发冲突。此时应阻止本次更新,并向用户返回错误信息。
实现示例 (LWC + Apex):
LWC JavaScript (myRecordEditor.js):
import { LightningElement, wire, api, track } from 'lwc'; import { getRecord, getFieldValue } from 'lightning/uiRecordApi'; import LAST_MODIFIED_DATE_FIELD from '@salesforce/schema/Account.LastModifiedDate'; import NAME_FIELD from '@salesforce/schema/Account.Name'; import updateAccountOptimistic from '@salesforce/apex/AccountController.updateAccountOptimistic'; import { ShowToastEvent } from 'lightning/platformShowToastEvent'; export default class MyRecordEditor extends LightningElement { @api recordId; @track account; originalLastModifiedDate; draftName; @wire(getRecord, { recordId: '$recordId', fields: [NAME_FIELD, LAST_MODIFIED_DATE_FIELD] }) wiredAccount({ error, data }) { if (data) { this.account = data; // 保存初始的 LastModifiedDate this.originalLastModifiedDate = getFieldValue(this.account, LAST_MODIFIED_DATE_FIELD); this.draftName = getFieldValue(this.account, NAME_FIELD); this.error = undefined; } else if (error) { this.error = error; this.account = undefined; } } handleNameChange(event) { this.draftName = event.target.value; } async handleSave() { try { // 将 recordId, 要更新的字段, 以及原始的 LastModifiedDate 传递给 Apex await updateAccountOptimistic({ recordId: this.recordId, name: this.draftName, expectedLastModifiedDate: this.originalLastModifiedDate }); this.dispatchEvent( new ShowToastEvent({ title: 'Success', message: 'Account updated successfully', variant: 'success' }) ); // 成功后需要重新获取数据以更新 LWC 状态和 originalLastModifiedDate // import { refreshApex } from '@salesforce/apex'; // refreshApex(this.wiredAccountResult); // 假设你保存了 wire 的结果 // 或者导航离开/关闭组件 } catch (error) { // 检查是否是我们的并发冲突错误 if (error.body && error.body.message.includes('CONCURRENCY_CONFLICT')) { this.dispatchEvent( new ShowToastEvent({ title: 'Update Conflict', message: 'This record was modified by another user after you started editing. Please refresh and try again.', variant: 'error', mode: 'sticky' // 让用户更容易看到 }) ); } else { // 其他错误 this.dispatchEvent( new ShowToastEvent({ title: 'Error updating record', message: error.body ? error.body.message : error.message, variant: 'error' }) ); } } } }
Apex Controller (AccountController.cls):
public with sharing class AccountController {
@AuraEnabled
public static void updateAccountOptimistic(Id recordId, String name, Datetime expectedLastModifiedDate) {
// 1. 查询数据库中当前的记录状态
// 注意:这里需要使用 FOR UPDATE 来锁定记录,防止在我们检查和更新之间发生新的修改
// 虽然乐观锁本身不依赖数据库锁,但检查和更新操作的原子性很重要。
// 或者,不使用 FOR UPDATE,但在 DML 失败时捕获异常。
// 更简洁的方式是直接尝试更新,依赖 DML 结果或后续查询判断。
// 我们采用先检查的方式,更明确。
Account currentAccount = [SELECT Id, Name, LastModifiedDate FROM Account WHERE Id = :recordId FOR UPDATE];
// 2. 比较 LastModifiedDate
// 注意比较 DateTime 时的精度问题,虽然通常够用。
if (currentAccount.LastModifiedDate != expectedLastModifiedDate) {
// 抛出自定义异常或返回特定错误信息
throw new AuraHandledException('CONCURRENCY_CONFLICT: Record has been modified since it was loaded.');
}
// 3. 如果版本匹配,执行更新
try {
currentAccount.Name = name;
// 其他字段更新...
update currentAccount;
} catch (DmlException e) {
// 处理可能的 DML 异常 (虽然 FOR UPDATE 应该能防止大部分并发问题,但仍需处理其他 DML 错误)
throw new AuraHandledException('Error saving record: ' + e.getMessage());
}
}
// Visualforce 对应的 RemoteAction 方法类似逻辑
/*
@RemoteAction
global static String updateAccountVFOptimistic(Id recordId, String name, String expectedLastModifiedDateStr) {
Datetime expectedLastModifiedDate = (Datetime)JSON.deserialize(expectedLastModifiedDateStr, Datetime.class);
try {
Account currentAccount = [SELECT Id, Name, LastModifiedDate FROM Account WHERE Id = :recordId FOR UPDATE];
if (currentAccount.LastModifiedDate != expectedLastModifiedDate) {
return 'CONCURRENCY_CONFLICT';
}
currentAccount.Name = name;
update currentAccount;
return 'SUCCESS';
} catch (Exception e) {
return 'ERROR: ' + e.getMessage();
}
}
*/
}
优点:
- 简单: 无需添加额外字段,利用现有标准字段。
- 通用:
LastModifiedDate
存在于所有可更新的标准和自定义对象上。
缺点:
- 粒度过粗:
LastModifiedDate
会因为任何字段的修改(包括后台自动化、公式字段更新触发的保存、甚至某些系统进程)而改变。这意味着,即使用户 A 和用户 B 修改的是完全不相关的字段,只要在用户 A 编辑期间有任何其他更新发生,系统都会报告冲突。这可能导致过多的“假阳性”冲突,影响用户体验。 - 精度问题: 虽然
DateTime
类型精度很高,但在极端的、亚秒级并发场景下,理论上存在两个事务恰好在同一毫秒更新的可能性(虽然概率极低)。 - 系统干扰: 某些 Salesforce 内部操作或配置(如启用审计字段)可能会影响
LastModifiedDate
的行为,虽然通常是记录最后用户修改时间。
方法二:使用自定义版本字段 (数字或日期时间)
为了克服 LastModifiedDate
粒度过粗的问题,我们可以引入一个专门用于乐观锁的自定义字段。
选项:
- 数字版本字段 (Number Field): 例如,创建一个名为
Version__c
的数字字段(整数,不允许小数)。 - 日期时间版本字段 (DateTime Field): 例如,创建一个名为
Record_Version_Timestamp__c
的日期时间字段。
核心逻辑:
- 字段设置: 创建自定义字段。通常建议将其设置为页面布局上只读,甚至不显示在布局上,仅通过代码维护。
- 版本维护: 需要通过自动化(通常是 Apex Trigger)来更新这个版本字段。
- 数字字段: 每次记录被“有效”更新时,将
Version__c
的值加 1。 - 日期时间字段: 每次记录被“有效”更新时,将
Record_Version_Timestamp__c
更新为当前时间 (System.now()
)。 - “有效”更新: 这是关键!你可以在 Trigger 中定义哪些字段的变更才算是需要更新版本的修改。例如,只有当
Amount
或StageName
改变时才更新版本字段,而Description
的修改则不更新。这提供了精细的粒度控制。
- 数字字段: 每次记录被“有效”更新时,将
- 冲突检测: 逻辑与使用
LastModifiedDate
类似。- 用户开始编辑时,获取并保存记录当前的
Version__c
或Record_Version_Timestamp__c
。 - 用户提交保存时,将此“期望版本”传回 Apex。
- Apex 中,查询记录当前的实际版本。
- 比较期望版本和实际版本。
- 如果一致,执行更新,并且同时更新版本字段(数字+1 或设置为
System.now()
)。 - 如果不一致,则报告冲突。
- 用户开始编辑时,获取并保存记录当前的
实现示例 (Apex Trigger + Controller Logic):
Apex Trigger (e.g., AccountOptimisticLockTrigger.trigger):
trigger AccountOptimisticLockTrigger on Account (before update) {
// 检查是否需要增加版本号
// 这个逻辑可以根据业务需求定制,哪些字段的变更需要更新版本
for (Account newAcc : Trigger.new) {
Account oldAcc = Trigger.oldMap.get(newAcc.Id);
// 示例:只有当 Name 或 AnnualRevenue 发生变化时才更新版本
// 注意:这里只是更新版本字段的值,真正的冲突检查在 Controller 中进行
// 确保 Version__c 字段存在
if (Schema.sObjectType.Account.fields.Map.containsKey('Version__c')) {
// 首次创建或 Version__c 为空时初始化
if (oldAcc.Version__c == null) {
newAcc.Version__c = 1;
} else if (newAcc.Name != oldAcc.Name || newAcc.AnnualRevenue != oldAcc.AnnualRevenue) {
// 只有在特定字段更改时才递增版本号
newAcc.Version__c = oldAcc.Version__c + 1;
} else {
// 如果关注的字段没有变化,则不改变版本号
// 这样可以避免无关字段更新导致的冲突
newAcc.Version__c = oldAcc.Version__c;
}
}
// 如果使用 DateTime 字段 (Record_Version_Timestamp__c)
/*
if (Schema.sObjectType.Account.fields.Map.containsKey('Record_Version_Timestamp__c')) {
if (newAcc.Name != oldAcc.Name || newAcc.AnnualRevenue != oldAcc.AnnualRevenue) {
newAcc.Record_Version_Timestamp__c = System.now();
} else if (oldAcc.Record_Version_Timestamp__c != null) {
// 保持旧的时间戳,如果关注字段不变
newAcc.Record_Version_Timestamp__c = oldAcc.Record_Version_Timestamp__c;
} else {
// 初始化
newAcc.Record_Version_Timestamp__c = System.now();
}
}
*/
}
}
Apex Controller (AccountController.cls - using Version__c):
public with sharing class AccountController {
@AuraEnabled
public static void updateAccountWithCustomVersion(Id recordId, String name, Decimal annualRevenue, Integer expectedVersion) {
// 1. 查询当前版本和需要更新的字段 (使用 FOR UPDATE 增加原子性保障)
Account currentAccount = [SELECT Id, Name, AnnualRevenue, Version__c
FROM Account
WHERE Id = :recordId FOR UPDATE];
// 2. 检查版本
if (currentAccount.Version__c == null || currentAccount.Version__c != expectedVersion) {
// 处理版本为空或不匹配的情况
Integer dbVersion = (currentAccount.Version__c == null) ? 0 : (Integer)currentAccount.Version__c;
String conflictMessage = String.format('CONCURRENCY_CONFLICT: Record version mismatch. Expected: {0}, Found: {1}. Record may have been modified.',
new List<Object>{expectedVersion, dbVersion});
throw new AuraHandledException(conflictMessage);
}
// 3. 版本匹配,执行更新 (注意:版本号的递增现在由 Trigger 处理)
try {
currentAccount.Name = name;
currentAccount.AnnualRevenue = annualRevenue;
// Trigger (before update) 会自动处理 Version__c 的更新
update currentAccount;
} catch (DmlException e) {
throw new AuraHandledException('Error saving record: ' + e.getMessage());
}
}
// LWC/Visualforce 端需要获取并传递 Version__c 字段的值
}
优点:
- 粒度可控: 你可以精确定义哪些字段的修改会触发版本更新,显著减少“假阳性”冲突。
- 意图清晰: 有一个专门的字段用于版本控制,代码逻辑更清晰。
- 数字版本更可靠: 使用自增数字通常比比较时间戳更不容易出现精度问题,逻辑也更简单直观(+1)。
缺点:
- 需要额外字段: 每个需要乐观锁的对象都需要添加一个自定义字段。
- 需要自定义逻辑: 必须编写和维护 Apex Trigger 来正确更新版本字段。如果 Trigger 逻辑有误(比如忘记处理 null 值,或者没有覆盖所有需要触发版本更新的场景),乐观锁就会失效。
- 稍微复杂: 比起直接用
LastModifiedDate
,设置和维护成本稍高。
对比与选择:LastModifiedDate
vs 自定义版本字段
特性 | LastModifiedDate |
自定义版本字段 (Number/DateTime) |
---|---|---|
实现复杂度 | 低 | 中 (需要加字段和 Trigger) |
冲突粒度 | 粗 (任何字段更新都可能触发) | 可控 (由 Trigger 逻辑决定) |
假阳性冲突 | 较高 | 低 (如果 Trigger 逻辑合理) |
额外字段 | 否 | 是 |
维护成本 | 低 | 中 (需要维护 Trigger 逻辑) |
适用场景 | 简单应用,对假阳性冲突容忍度较高,不希望增加字段 | 复杂应用,需要精确控制冲突检测,对用户体验要求高 |
选择建议:
- 优先考虑自定义版本字段 (特别是数字类型): 对于大多数需要健壮并发控制的企业级应用,自定义版本字段提供了更好的控制力和用户体验。虽然初始设置稍复杂,但长远来看,减少不必要的冲突提示是值得的。数字版本字段通常比日期时间版本字段更易于管理和比较。
- 何时使用
LastModifiedDate
: 如果你的应用场景非常简单,并发更新的可能性本身就很低,或者用户对于偶尔出现的(可能是假阳性的)冲突提示不敏感,那么使用LastModifiedDate
是一个快速、便捷的选择。
优雅地处理冲突:用户体验是关键
无论使用哪种方法,当检测到冲突时,如何告知用户并引导他们解决问题至关重要。
- 清晰的错误提示: 避免显示技术性的错误信息(如 DML 异常的完整堆栈)。使用简洁、用户能懂的语言解释发生了什么。例如:
- 中文:“抱歉,您提交的更改无法保存,因为在您编辑期间,该记录已被其他用户修改。请刷新页面查看最新数据,然后重新应用您的更改。”
- 英文:“Sorry, your changes could not be saved because this record was modified by someone else while you were editing. Please refresh the page to see the latest data and then re-apply your changes.”
- 明确的下一步: 告诉用户应该怎么做。最常见的建议是“刷新页面”或“重新加载数据”。
- 避免数据丢失: 确保用户的输入不会因为冲突提示而丢失。在 LWC/Visualforce 中,冲突发生后,用户界面上的输入值应该仍然保留,直到用户明确选择刷新或取消。
- 冲突解决策略 (高级):
- 显示差异 (Diff): 在更复杂的场景下,可以考虑向用户展示他们尝试保存的数据与当前数据库中数据的差异,让他们决定如何合并。这需要存储用户开始编辑时的原始数据、用户提交的数据以及检测到冲突时数据库的当前数据,实现起来相当复杂。
- 自动合并: 尝试自动合并非冲突字段的更改。风险很高,容易出错,一般不推荐。
- 对于大多数应用,要求用户刷新并重新应用更改是最安全、最简单的策略。
在 LWC/Visualforce/Apex 中实现:
- Apex: 在检测到冲突时,抛出
AuraHandledException
(LWC) 或返回特定的错误代码/消息 (Visualforce RemoteAction)。不要直接抛出未处理的DmlException
或通用Exception
。 - LWC/Visualforce: 在
catch
块或回调函数中检查 Apex 返回的错误。如果是预定义的冲突错误,则显示友好的提示信息 (如使用ShowToastEvent
in LWC)。
辅助工具:Flow 和 Validation Rule 的角色?
- Validation Rule (验证规则): 不适合用于实现乐观锁的核心冲突检测逻辑。验证规则在事务的不同阶段运行,并且它们通常无法访问“用户开始编辑时的版本”这类状态信息。它们主要用于在保存前强制执行数据完整性规则。
- Flow (流程):
- Record-Triggered Flow (Before Save): 可以用来维护自定义版本字段。例如,创建一个 Before Save Flow,当特定字段改变时,自动递增
Version__c
或更新Record_Version_Timestamp__c
。这可以替代 Apex Trigger 的一部分功能,尤其对于简单的版本更新逻辑。 - Screen Flow / Autolaunched Flow: 无法直接用于 UI 交互中的冲突检测,因为它们同样缺乏用户编辑开始时的状态信息。核心的“比较版本”逻辑仍然最适合放在处理用户保存请求的 Apex Controller 中。
- Record-Triggered Flow (Before Save): 可以用来维护自定义版本字段。例如,创建一个 Before Save Flow,当特定字段改变时,自动递增
Flow 可以作为维护版本字段的辅助手段,但不能完全替代 Apex 在处理用户交互式编辑场景下的冲突检测职责。
最佳实践与注意事项
- 检查与更新的原子性: 理想情况下,检查版本和执行更新操作应该在一个原子事务中完成。在 Apex 中使用
SELECT ... FOR UPDATE
可以在查询时锁定记录,防止在检查版本和执行update
DML 之间记录被再次修改,从而提高检查的有效性。但这会引入短暂的悲观锁,在高并发下可能增加锁等待,需要权衡。 - 将检查逻辑放在 Controller: 处理用户界面交互时,版本检查的核心逻辑(比较期望版本和当前版本)应该放在 Apex Controller 方法中,该方法由 LWC 或 Visualforce 调用。Trigger 主要负责维护版本字段本身。
- 性能考量: 每次更新前增加一次查询(获取当前版本)会带来微小的性能开销,但这通常远小于并发冲突导致数据错误或用户挫败感的成本。
- 测试: 测试乐观锁比较困难。可以尝试手动模拟:打开两个浏览器窗口,用不同用户(或同一用户)编辑同一记录,先保存一个,再尝试保存另一个。或者编写 Apex 测试类,模拟并发更新场景(虽然 Apex 测试通常是单线程的,模拟并发需要技巧,比如使用
Test.startTest()
/Test.stopTest()
结合不同的 DML 操作尝试触发冲突)。 - 批量处理: 如果你的代码需要处理批量记录更新(例如,在 LWC 中一次保存多个子记录),确保乐观锁逻辑能够正确处理批量场景。需要为每条记录单独获取和比较版本。
结论
乐观锁是解决 Salesforce 中并发更新导致“丢失更新”问题的有效且常用的策略。虽然使用标准 LastModifiedDate
字段实现起来最简单,但其粒度过粗可能导致不必要的冲突提示。引入自定义版本字段(尤其是数字类型)并结合 Apex Trigger 进行维护,提供了更精细的控制和更好的用户体验,是大多数复杂应用场景下的推荐方案。
无论选择哪种方法,关键在于正确实现版本比较逻辑,并在检测到冲突时提供清晰、友好的用户反馈和操作指引。通过仔细设计和实现乐观锁机制,你可以显著提高 Salesforce 应用的数据一致性和用户满意度。