1. 为什么我们需要乐观并发控制?
在分布式系统开发中,数据并发访问是个永恒的话题。想象这样一个场景:采购部门的Lisa和财务部门的Tom同时打开同一个采购订单准备修改。Lisa先提交了修改后的供应商信息,而Tom稍后提交了调整后的付款条款。如果没有并发控制机制,后提交的操作会直接覆盖前一次修改,导致数据不一致。
传统解决方案是采用悲观锁——第一个打开记录的用户获得独占锁,其他用户必须等待。这种方式在SAP这类企业级系统中会带来严重的性能问题和糟糕的用户体验。我在2018年参与的一个SAP MM模块升级项目中,就曾因为过度使用悲观锁导致月末关账时出现大量用户排队等待的情况。
乐观并发控制(Optimistic Concurrency Control)采取了完全不同的哲学:假设冲突很少发生,允许多个用户同时读取和修改数据,只在提交时检查是否发生冲突。这种模式特别适合SAP这类读多写少的业务系统,也是SAP RAP(RESTful Application Programming)和Gateway服务的默认策略。
2. ETag机制深度解析
2.1 ETag的本质与生成逻辑
ETag(实体标签)是HTTP协议中用于资源版本标识的机制,本质上是一个不透明的字符串。在SAP Gateway中,ETag的典型格式类似W/"datetime'20230501T120000'",其中:
W/表示弱验证器(Weak Validator),允许语义等价的内容有不同ETag- 引号内是服务器决定的版本标识,通常包含时间戳或哈希值
我通过调试SAP Gateway服务的实现代码发现,OData服务的ETag生成逻辑主要在/IWBEP/IF_MGW_ODATA_ETAG接口中实现。默认情况下,SAP会为每个实体记录生成基于最后修改时间戳的ETag:
METHOD get_entity_etag. DATA(lv_timestamp) = get_last_changed_time( iv_entity_name, iv_entity_key ). rv_etag = |W/"datetime'{ lv_timestamp ISO }'"|. ENDMETHOD.2.2 ETag在请求-响应周期中的流转
一个完整的ETag交互流程如下:
初次请求:客户端GET获取资源,响应头包含:
ETag: W/"datetime'20230501T120000'"修改请求:客户端PUT/PATCH更新资源时,必须携带:
If-Match: W/"datetime'20230501T120000'"服务端验证:Gateway比较当前ETag与If-Match值:
- 匹配:执行更新,返回200 OK和新ETag
- 不匹配:返回412 Precondition Failed
我在测试环境曾用Postman模拟过并发场景:两个并行请求使用相同ETag,第一个成功更新后,第二个必然失败。这种设计确保了数据的原子性更新。
3. SAP RAP中的并发控制实现
3.1 行为定义中的版本标识
在SAP RAP的BDEF行为定义中,通过use etag关键字启用乐观锁:
define behavior for Z_PURCHASE_ORDER use etag lock master { // 行为实现 }实际项目中我发现几个关键点:
- RAP框架会自动在OData元数据中添加
@odata.etag注解 - 对于header-item结构,默认只有root实体有ETag
- 可以通过
use etag on child entity为子实体单独启用
3.2 冲突检测的底层逻辑
当客户端提交更新时,RAP框架会执行以下检查序列:
- 从请求头解析If-Match值
- 从数据库加载当前记录
- 调用
get_etag方法获取当前版本 - 比较两个ETag(考虑弱验证规则)
- 触发
check_before_modify进行业务逻辑校验
我曾遇到一个典型问题:自定义ETag生成逻辑时忽略了弱验证规则,导致本应成功的请求被拒绝。正确的实现应该类似:
METHOD get_etag. DATA(lv_hash) = calculate_entity_hash( iv_entity ). rv_etag = |W/"hash:{ lv_hash }"|. ENDMETHOD.4. Gateway服务的并发处理机制
4.1 OData协议中的并发控制
SAP Gateway严格遵循OData 4.0协议规范:
- 对于
PUT请求:必须提供If-Match头(或值为*) - 对于
PATCH请求:建议提供If-Match头 - 对于
DELETE请求:必须提供If-Match头
在网关系统参数中,可以通过以下配置调整ETag行为:
/iwbep/etag_version = 2 " 控制ETag生成算法版本 /iwbep/etag_default = STRONG " 默认验证强度4.2 自定义ETag策略的实现
标准ETag基于时间戳可能不满足某些业务场景。例如需要检查特定字段变更时,可以创建自定义ETag提供器:
- 实现
/IWBEP/IF_MGW_ETAG_PROVIDER接口 - 在DPC类中重载
get_etag方法 - 注册自定义提供器:
METHOD /iwbep/if_mgw_appl_srv_runtime~get_etag_provider. ro_etag_provider = NEW zcl_custom_etag_provider( ). ENDMETHOD.在最近一个物料主数据项目中,我们实现了基于关键字段MD5哈希的ETag,确保只有核心字段变更才会触发冲突:
METHOD get_entity_etag. DATA: lv_matnr TYPE matnr, lv_werks TYPE werks_d, lv_hash TYPE string. lv_matnr = it_key_tab[ name = 'Matnr' ]-value. lv_werks = it_key_tab[ name = 'Werks' ]-value. SELECT SINGLE matnr, werks, mtart, matkl FROM makt INTO @DATA(ls_makt) WHERE matnr = @lv_matnr AND werks = @lv_werks. lv_hash = md5( |{ ls_makt-matnr }{ ls_makt-werks }{ ls_makt-mtart }{ ls_makt-matkl }| ). rv_etag = |W/"hash:{ lv_hash }"|. ENDMETHOD.5. 实战中的冲突处理策略
5.1 客户端的最佳实践
基于多个Fiori项目经验,我总结出客户端处理ETag冲突的推荐模式:
async function updateOrder(order) { try { const response = await fetch(`/Orders('${order.ID}')`, { method: 'PATCH', headers: { 'If-Match': order['@odata.etag'], 'Content-Type': 'application/json' }, body: JSON.stringify(order) }); if (response.status === 412) { // 重新加载数据并提示用户 const freshData = await loadCurrentVersion(order.ID); showConflictDialog(order, freshData); } } catch (error) { handleError(error); } }关键注意事项:
- 始终缓存最新的ETag值
- 412错误必须引导用户解决冲突
- 对于长时间编辑场景,建议实现自动刷新
5.2 服务端的增强处理
标准412响应可能无法满足复杂业务需求。可以通过BAdI增强:
METHOD /iwbep/if_mgw_appl_srv_runtime~check_etag. IF cv_etag_failed = abap_true AND io_request->get_method( ) = 'PATCH'. " 获取变更前后的数据差异 DATA(lt_before) = get_before_image( iv_entity_name, it_key_tab ). DATA(lt_after) = map_request_to_internal( io_request ). " 业务自定义冲突解决逻辑 IF is_non_conflict_change( lt_before, lt_after ). cv_etag_failed = abap_false. " 覆盖框架决定 ENDIF. ENDIF. ENDMETHOD.在SD模块的一个案例中,我们实现了字段级冲突检测:只有相同字段被不同用户修改时才报错,否则自动合并更改。
6. 性能优化与疑难排查
6.1 ETag对性能的影响
在压力测试中我们发现:
- 启用ETag会使GET请求响应时间增加5-8%
- 批量请求($batch)中的ETag检查可能成为瓶颈
优化方案包括:
- 对只读实体禁用ETag
- 调整ETag计算粒度(如按关键字段而非全字段)
- 使用缓存ETag值:
METHOD get_entity_etag. DATA(lv_cache_key) = build_cache_key( iv_entity_name, it_key_tab ). IF is_cache_valid( lv_cache_key ). rv_etag = get_cached_etag( lv_cache_key ). ELSE rv_etag = calculate_fresh_etag( iv_entity_name, it_key_tab ). update_cache( lv_cache_key, rv_etag ). ENDIF. ENDMETHOD.6.2 常见问题排查指南
根据支持经验,ETag相关问题主要分为几类:
问题现象:所有更新请求都返回412
- 检查客户端是否正确携带If-Match头
- 验证服务器时间是否同步(ETag使用时间戳时)
- 排查自定义ETag逻辑中的BUG
问题现象:ETag不更新
- 确认数据库更新后调用了COMMIT WORK
- 检查GET_ENTITY_ETAG方法是否被正确覆盖
- 排查是否有缓存未清除
问题现象:批量操作性能差
- 考虑禁用非关键实体的ETag
- 评估使用STRONG而非WEAK ETag
- 优化ETag计算SQL查询
在HR模块的一个案例中,我们曾发现自定义ETag实现没有考虑CLIENT字段,导致测试环境误报冲突。这类问题可以通过以下检查脚本定位:
SELECT entity_name, count(*) as conflict_count FROM gateway_error_log WHERE error_code = '412' GROUP BY entity_name ORDER BY conflict_count DESC.7. 进阶应用场景
7.1 分布式系统中的ETag扩展
在SAP云平台项目中,我们扩展了标准ETag机制以实现跨服务一致性:
全局版本号:中心服务维护全局版本,各微服务同步
rv_etag = |W/"global:{ lv_global_version }|.组合ETag:聚合多个服务的版本信息
rv_etag = |W/"composite:{ lv_main_ver }-{ lv_ref_ver }"|.业务时间戳:使用业务事件时间而非系统时间
rv_etag = |W/"biztime:{ lv_last_biz_event }"|.
7.2 与变更日志的集成
将ETag与变更日志结合可以实现强大的审计功能:
METHOD update_entity. DATA(lv_old_etag) = get_current_etag( iv_entity_name, it_key_tab ). " 标准更新逻辑 super->update_entity( iv_entity_name, iv_entity_set_name, ... ). DATA(lv_new_etag) = get_current_etag( iv_entity_name, it_key_tab ). log_change( iv_entity_name = iv_entity_name it_key_tab = it_key_tab iv_old_etag = lv_old_etag iv_new_etag = lv_new_etag iv_requested_data = er_entity ). ENDMETHOD.在财务系统中,这种模式帮助我们快速定位特定时间段内的数据变更,相比传统审计日志节省了70%的存储空间。
8. 设计模式与替代方案
8.1 何时不适合使用ETag
虽然ETag是通用解决方案,但在以下场景可能需要替代方案:
高频更新场景:如计数器更新,考虑使用:
UPDATE zcounter SET value = value + 1 WHERE id = 'XYZ'.离线编辑场景:采用客户端时间戳+冲突解决算法
大数据量批处理:使用批处理锁而非行级锁
8.2 与悲观锁的混合模式
在特定业务场景下,可以组合使用两种策略:
METHOD lock_and_update. " 1. 尝试获取业务锁 TRY lock_entity( iv_lock_key ). " 2. 检查ETag check_etag( iv_etag ). " 3. 执行业务更新 update_entity( ). " 4. 释放锁 unlock_entity( iv_lock_key ). CATCH cx_entity_locked. " 处理锁定冲突 CATCH cx_etag_mismatch. " 处理版本冲突 ENDTRY. ENDMETHOD.在库存管理系统中,我们对关键物料采用这种混合模式,既保证了数据一致性,又避免了长期锁定的性能问题。