Salesforce 乐观锁新思路:为何以及如何使用字段校验和替代版本号?
传统乐观锁的痛点
校验和乐观锁:核心思想
Apex 实现细节
校验和锁的优势
校验和锁的劣势与挑战
何时考虑使用校验和锁?
总结与思考
在 Salesforce 开发中,处理并发数据修改是一个绕不开的话题。当多个用户或系统同时尝试更新同一条记录时,如何确保数据的一致性,避免“丢失更新”问题?乐观锁(Optimistic Locking)是最常用的策略之一。传统的实现方式通常依赖 Salesforce 自动维护的 SystemModstamp
字段,或者开发者自定义的版本号字段(Version Number)。
但这些方法并非万能。SystemModstamp
只要记录有任何字段被修改,它就会更新,这可能过于敏感,导致不必要的冲突。自定义版本号字段虽然可以更精细地控制,但通常也只是在每次保存时简单递增,无法区分是哪个字段被修改了。
那么,有没有更精细化的乐观锁实现方式呢?今天我们来探讨一种不那么常见,但在特定场景下可能非常有用的方法:基于特定字段计算校验和(Checksum/Hash)来实现乐观锁。
传统乐观锁的痛点
想象一个场景:你有一个复杂的 Order__c
对象,上面有几十个字段。一个集成程序负责每小时更新订单的 Price__c
和 Discount__c
字段。同时,客服人员可能在用户界面编辑订单的 Shipping_Address__c
和 Description__c
字段。
使用
SystemModstamp
:- 集成程序读取订单记录(包含当时的
SystemModstamp
)。 - 客服几乎同时读取了同一条订单记录。
- 客服修改了
Shipping_Address__c
并保存。SystemModstamp
更新。 - 集成程序计算完价格和折扣,尝试用它读取到的旧
SystemModstamp
作为条件去更新Price__c
和Discount__c
。 - 冲突发生! Salesforce 发现
SystemModstamp
已经改变,拒绝了集成程序的更新,因为它认为记录已经被修改。但实际上,客服修改的地址和集成程序要修改的价格折扣,业务上可能并不冲突。
- 集成程序读取订单记录(包含当时的
使用自定义版本号字段 (
Version__c
):- 假设我们有一个
Version__c
字段,每次记录保存时通过触发器Version__c++
。 - 流程和上面类似:集成程序读取记录(含
Version__c
),客服读取记录(含Version__c
)。 - 客服修改地址并保存,触发器执行
Version__c
递增。 - 集成程序尝试用旧的
Version__c
作为条件更新价格。 - 同样冲突! 尽管修改的字段不同,但版本号的增加阻止了集成程序的更新。
- 假设我们有一个
这种“误报”的冲突在集成场景或高并发环境下会非常恼人,导致不必要的重试逻辑、处理延迟甚至数据同步失败。
校验和乐观锁:核心思想
校验和锁的核心思想是:只关心那些对当前操作真正重要的字段。如果这些关键字段没有被其他人修改,即使记录的其他部分(甚至 SystemModstamp
或版本号)发生了变化,也允许本次更新成功。
具体机制如下:
- 选择关键字段:确定一组对特定更新操作至关重要的字段。例如,对于更新价格的集成程序,关键字段就是
Price__c
和Discount__c
(可能还有ProductId__c
等影响价格计算的字段)。 - 计算校验和:将这些关键字段的当前值以某种一致的方式(例如,按固定顺序拼接成字符串)组合起来。
- 生成哈希值:使用一个标准的哈希算法(如 SHA-256)对组合后的字符串计算哈希值。
- 存储哈希值:将计算出的哈希值存储在记录的一个自定义文本字段中(例如
Critical_Fields_Checksum__c
)。 - 更新前检查:
- 当需要更新记录时,首先从数据库中查询出记录当前的
Critical_Fields_Checksum__c
值。 - 重新计算:基于数据库中记录的当前关键字段值,再次执行步骤 2 和 3,得到一个新的哈希值。
- 比较:将新计算出的哈希值与从数据库查询到的
Critical_Fields_Checksum__c
值进行比较。 - 决策:
- 如果两者相同,说明关键字段自上次校验和更新以来没有被修改。允许执行更新操作。更新完成后,需要用更新后的关键字段值重新计算哈希值,并更新到
Critical_Fields_Checksum__c
字段。 - 如果两者不同,说明关键字段在读取和尝试更新之间已被其他人修改。拒绝本次更新,抛出冲突错误。
- 如果两者相同,说明关键字段自上次校验和更新以来没有被修改。允许执行更新操作。更新完成后,需要用更新后的关键字段值重新计算哈希值,并更新到
- 当需要更新记录时,首先从数据库中查询出记录当前的
这种方式的精妙之处在于,它将锁的粒度从整个记录缩小到了你真正关心的一组字段。
Apex 实现细节
我们通常在 before update
触发器中实现这个逻辑。
1. 定义自定义字段
首先,在你的对象上创建一个文本字段,用于存储校验和。例如,Checksum__c
(Text, Length 建议 64 或更大,以存储 Base64 编码的 SHA-256 哈希值)。
2. Apex 触发器 (before update
)
trigger MyObjectTrigger on MyObject__c (before update) {
// 定义需要监控的关键字段 API Name
Set<String> criticalFields = new Set<String>{'Field1__c', 'Field2__c', 'Important_Status__c'};
// 收集需要检查的记录 ID
Set<Id> recordIds = Trigger.newMap.keySet();
// 查询数据库中当前的校验和以及关键字段的值
// 这一步至关重要!不能依赖 Trigger.old 或 Trigger.oldMap 中的 Checksum__c
// 因为它们可能不是最新的数据库状态
Map<Id, MyObject__c> currentRecordsFromDb = new Map<Id, MyObject__c>(
[SELECT Id, Checksum__c, Field1__c, Field2__c, Important_Status__c
FROM MyObject__c
WHERE Id IN :recordIds]
);
for (MyObject__c newRecord : Trigger.new) {
MyObject__c oldRecord = Trigger.oldMap.get(newRecord.Id);
MyObject__c currentDbRecord = currentRecordsFromDb.get(newRecord.Id);
// 检查是否有任何关键字段被修改了,只有修改了才需要重新计算和比较
// 这是一种优化,如果关键字段都没变,理论上校验和也不应该变(除非上次更新失败或逻辑有误)
Boolean criticalFieldChanged = false;
for (String fieldName : criticalFields) {
if (newRecord.get(fieldName) != oldRecord.get(fieldName)) {
criticalFieldChanged = true;
break;
}
}
// 如果关键字段发生了变化,或者你想更严格地每次都检查
if (criticalFieldChanged) {
// 基于数据库当前记录的关键字段值,计算校验和
String currentDbChecksum = calculateChecksum(currentDbRecord, criticalFields);
// 获取存储在数据库中的校验和
String storedChecksum = currentDbRecord.Checksum__c;
System.debug('Record ID: ' + newRecord.Id + ', Stored Checksum: ' + storedChecksum + ', Calculated DB Checksum: ' + currentDbChecksum);
// 比较校验和
// 注意处理首次计算校验和的情况 (storedChecksum 可能为 null)
if (storedChecksum != null && storedChecksum != currentDbChecksum) {
// 冲突!关键字段在读取和尝试更新之间被修改了
newRecord.addError('数据已被修改,请刷新后重试。关键业务字段已被并发更新。');
} else {
// 校验通过或首次计算
// 基于 *即将保存* 的新记录值,计算 *新* 的校验和
String newChecksum = calculateChecksum(newRecord, criticalFields);
// 将新的校验和设置到新记录上,随本次 DML 一起保存
newRecord.Checksum__c = newChecksum;
System.debug('Record ID: ' + newRecord.Id + ', Checksum updated to: ' + newChecksum);
}
}
}
}
// 辅助方法:计算校验和
public static String calculateChecksum(SObject record, Set<String> fieldsToHash) {
List<String> fieldValues = new List<String>();
// 确保字段按固定顺序处理,避免顺序变化导致 hash 不同
List<String> sortedFields = new List<String>(fieldsToHash);
sortedFields.sort();
for (String fieldName : sortedFields) {
Object value = record.get(fieldName);
// 对不同类型和 null 值进行规范化处理,非常重要!
String stringValue = '';
if (value != null) {
// 对日期/时间特殊处理,确保格式一致
if (value instanceof Datetime) {
stringValue = ((Datetime)value).formatGmt('yyyy-MM-dd HH:mm:ss.SSS');
} else if (value instanceof Date) {
stringValue = ((Date)value).formatGmt('yyyy-MM-dd');
} else {
stringValue = String.valueOf(value);
}
}
// 使用特殊分隔符明确区分字段和 null
fieldValues.add(fieldName + '::=' + stringValue);
}
// 将所有字段值拼接成一个长字符串
String concatenatedValues = String.join('|', fieldValues);
System.debug('Concatenated string for hashing: ' + concatenatedValues);
// 使用 SHA-256 计算哈希值
Blob hashBlob = Crypto.generateDigest('SHA-256', Blob.valueOf(concatenatedValues));
// 将 Blob 转换为 Base64 字符串以便存储在文本字段中
return EncodingUtil.base64Encode(hashBlob);
}
关键点说明:
- 查询当前数据库值:最重要的一步是
SELECT ... FROM MyObject__c WHERE Id IN :recordIds
。你必须获取数据库中此刻的关键字段值和校验和来进行比较。不能依赖Trigger.oldMap
中的校验和,因为它代表的是本次事务开始时的状态,可能已经被其他并发事务修改了。 - 字段值规范化:
calculateChecksum
方法中,如何将不同类型的字段值(文本、数字、日期、布尔、查找关系 ID 等)以及null
值转换成字符串,并且保证顺序一致,是成败的关键。上面示例代码提供了一种思路:- 按字段名排序,确保顺序固定。
- 对
null
使用空字符串表示。 - 对日期/时间使用
formatGmt
保证时区和格式一致性。 - 使用明确的分隔符(如
::=
和|
)来区分字段名、字段值以及字段之间,避免歧义(例如,字段 A 的值是B
,字段 C 的值是D
,拼接成BD
;与字段 A 的值是BD
,字段 C 的值是null
,拼接成BD
,无法区分)。更健壮的方法可能是将字段名和值构造成一个 Map,然后用JSON.serialize
序列化这个 Map,再对 JSON 字符串计算哈希。
- 哈希算法选择:
Crypto.generateDigest
支持多种算法。SHA-256
是目前推荐的选择,提供了足够的安全性(抗碰撞性)和可接受的性能。MD5 和 SHA-1 已不推荐用于安全性场景。虽然哈希碰撞理论上存在,但对于 SHA-256 来说,在实际业务数据中发生的概率极小,可以忽略不计。 - 冲突处理:
newRecord.addError()
会阻止记录的保存,并将错误信息显示给用户或返回给 API 调用方。 - 更新校验和:如果校验通过,必须用即将保存的新记录值(
newRecord
)来计算新的校验和,并赋给newRecord.Checksum__c
,这样下次更新时才能基于最新的状态进行比较。 - 首次填充:对于已有记录,需要一个一次性的脚本来计算并填充初始的
Checksum__c
值。
校验和锁的优势
- 高度的粒度控制:这是最大的优势。你可以精确定义哪些字段的变更需要触发冲突检测。对于那些只更新非关键字段的操作,完全不会产生冲突,即使
SystemModstamp
或版本号变化了。 - 显著减少“误报”冲突:直接解决了传统方法中因修改不相关字段而导致的伪冲突问题。特别适合集成场景,不同系统可能负责更新同一记录的不同字段子集。
- 更清晰的业务意图:校验和字段本身(如果命名得当,如
Pricing_Checksum__c
,Inventory_Checksum__c
)就能反映出它保护的是哪部分业务数据的一致性。
校验和锁的劣势与挑战
没有银弹,这种方法也有其成本和复杂性:
- 实现复杂度高:相比简单递增版本号,你需要编写和维护更复杂的 Apex 触发器逻辑,特别是
calculateChecksum
方法中的字段值规范化部分,需要非常仔细地处理各种数据类型和边界情况。 - 性能开销:
- CPU 消耗:计算哈希值(尤其是 SHA-256)比简单的整数加法消耗更多的 CPU 时间。虽然对于单条记录通常很快,但在
before update
触发器中对大量记录进行计算(例如批量数据加载或更新)时,需要关注 CPU 时间限制。 - 数据库查询:每次更新前都需要额外查询一次数据库以获取当前的校验和与关键字段值。虽然查询是基于 ID 的,效率较高,但仍增加了事务的数据库交互次数。
- CPU 消耗:计算哈希值(尤其是 SHA-256)比简单的整数加法消耗更多的 CPU 时间。虽然对于单条记录通常很快,但在
- 字段选择与维护:
- 初始定义:准确定义哪些字段是“关键字段”至关重要。遗漏了关键字段会导致漏报冲突;包含了不必要的字段则会降低该方法的优势。
- 变更管理:如果业务需求变化,需要增删关键字段,必须记得同步更新触发器中的
criticalFields
集合和calculateChecksum
逻辑。这增加了维护成本和出错的可能性。
- 字符串拼接/序列化策略:
calculateChecksum
中如何稳定、无歧义地组合字段值是难点。任何微小的改变(如字段顺序、null 处理方式、日期格式)都可能导致哈希值不同,从而引发错误的冲突或漏报。使用JSON.serialize
可能更稳妥,但也要注意 JSON 序列化本身的一些特性(如字段顺序不保证,但对于 Map 序列化通常稳定)。 - 调试困难:当出现非预期的冲突或校验和不匹配时,调试可能比版本号问题更复杂,需要仔细检查字段值的规范化过程和哈希计算逻辑。
- 存储成本:增加了一个自定义字段的存储空间。
何时考虑使用校验和锁?
鉴于其复杂性,校验和锁并非普适方案。它更像是一把“手术刀”,适用于以下特定场景:
- 高并发且冲突主要由非相关字段更新引起:当标准乐观锁(
SystemModstamp
或版本号)导致大量“误报”冲突,严重影响用户体验或集成效率时。 - 多系统集成更新同一记录的不同部分:例如,CRM 用户更新客户联系信息,而 ERP 系统更新该客户的信用额度。使用针对不同字段集的校验和可以有效隔离冲突。
- 需要保护特定业务状态的完整性:当记录的某个“状态”是由一组特定字段的值共同决定时,对这组字段计算校验和可以确保状态转换的原子性(相对于这组字段而言)。
- 团队有足够的 Apex 开发和测试能力:能够驾驭其复杂性,并进行充分的测试。
什么时候不建议使用?
- 大多数常规场景,简单的
SystemModstamp
或版本号字段已经足够。 - 并发冲突本身很少发生。
- 开发资源有限,或团队对 Apex 不够熟悉。
- 性能是极端瓶颈,无法承受额外的查询和哈希计算开销(虽然通常这点影响不大)。
总结与思考
基于字段校验和的乐观锁机制,为 Salesforce 并发控制提供了一种更精细化的武器。它通过聚焦于真正关键的字段,有效减少了因更新不相关数据而产生的伪冲突,尤其在复杂的集成和高并发场景下显示出潜力。
然而,这种精细化是有代价的:更高的实现复杂性、潜在的性能开销以及持续的维护挑战。选择是否采用这种方法,需要仔细权衡其带来的好处(减少冲突)与成本(开发、维护、性能)。
它不是要取代传统的乐观锁,而是在传统方法捉襟见肘时,提供的一个值得考虑的备选方案。下次当你遇到棘手的并发冲突问题,并且发现冲突大多源于“无关”字段的修改时,或许可以尝试评估一下,这把“校验和”手术刀是否能精准地解决你的痛点。
你觉得这种方法怎么样?在你的项目中遇到过类似的并发难题吗?欢迎分享你的看法和经验!