# ERP 费用核销并发控制方案 ## 1. 文档目的 本文整理 ERP 费用核销场景下的并发控制方案,重点解决以下问题: - 一笔费用允许被多次核销; - 多个用户可能同时核销同一笔费用; - 任何情况下都不能超额核销; - 必须保留完整核销流水; - 必须支持核销撤销和财务审计; - 批量核销多笔费用时,允许部分成功,一笔失败不能影响其他费用; - 接口重试、重复点击等情况不能导致重复核销。 本文对版本锁、数据库悲观锁和数据库原子更新三种方案进行分析,并给出推荐实现。 ## 2. 基本业务场景 假设费用原始金额为 100,当前状态如下: ```text 原始金额:100 已核销金额:0 剩余金额:100 ``` 用户 A 和用户 B 同时打开该费用: - 用户 A 核销 50; - 用户 B 核销 30; - 正确结果应为剩余金额 20; - 系统不能因为两个用户读取到相同旧余额而产生覆盖或超额核销。 并发控制需要解决的核心问题不是让所有操作真正并行修改同一余额,而是确保对同一余额的修改在数据库中正确串行化,同时尽量减少不必要的用户重试。 ## 3. 方案一:版本锁 ### 3.1 实现方式 费用表增加版本字段: ```text remain_amount = 100 version = 1 ``` 用户 A 提交核销 50: ```sql UPDATE dbo.fee SET remain_amount = remain_amount - 50, version = version + 1 WHERE id = @fee_id AND version = 1 AND remain_amount >= 50; ``` 更新成功后: ```text remain_amount = 50 version = 2 ``` 用户 B 仍然携带页面读取到的 `version = 1`,因此更新影响行数为 0,需要提示用户刷新后重新操作。 ### 3.2 优点 - 可以防止旧数据覆盖新数据; - 不需要在用户查看页面期间持有数据库锁; - 实现方式简单,适合普通资料编辑; - 可以明确告诉用户数据已经发生变化。 ### 3.3 局限 - 会拒绝原本可以成功的合法并发核销; - 批量核销时容易出现多笔版本冲突; - 财务人员需要刷新、重新定位并重新操作,使用体验较差; - 如果版本冲突后自动读取最新余额重试,实际行为已经接近原子更新方案; - 版本锁只防止旧数据覆盖,不等同于完整的财务一致性设计。 版本锁能够保证金额正确的前提是:所有余额修改入口都严格执行版本校验,并且余额更新与流水写入处于同一事务。任何绕过版本条件的接口、脚本或后台任务都可能破坏该保证。 ### 3.4 适用范围 版本字段适合用于: - 普通资料编辑冲突检测; - 页面数据变化提示; - 缓存失效; - 判断记录在用户查看后是否发生过变化。 如果业务允许多用户对同一费用连续核销,不建议将页面版本作为核销成功的必要条件。 ## 4. 方案二:数据库悲观锁 ### 4.1 实现方式 在事务中读取并锁定费用记录,然后校验和更新: ```sql BEGIN TRANSACTION; SELECT remain_amount, status FROM dbo.fee WITH (UPDLOCK, ROWLOCK) WHERE id = @fee_id; -- 校验余额和状态 -- 更新余额 -- 写入核销流水 COMMIT; ``` 用户 A 先锁定记录并核销 50,用户 B 等待。A 提交后,B 读取到最新余额 50,再核销 30,最终剩余金额为 20。 ### 4.2 优点 - 读取、判断、更新之间的数据状态稳定; - 可以处理多表、多条件和复杂额度判断; - 后来的用户通常只需等待,不必因为页面版本过期而立即失败; - 逻辑直观,便于表达复杂业务规则。 ### 4.3 局限 - 显式锁从查询开始持有,临界区通常比原子 UPDATE 更长; - 热点费用会产生锁等待; - 同时锁定多条费用且顺序不一致时容易产生死锁; - `ROWLOCK` 只是 SQL Server 锁提示,不保证数据库一定采用行锁; - 大批量处理时,如果使用一个总事务,前面记录的锁可能一直持有到整个批次结束。 ### 4.4 使用要求 - 锁定查询必须在数据库事务内执行; - 获取锁后不能等待用户确认,也不能执行耗时的远程调用; - 事务应尽可能短; - 同时操作多条费用时,应按费用主键的固定顺序加锁; - 对死锁错误和锁等待超时设置有限次数的重试机制。 ### 4.5 适用范围 悲观锁适合以下场景: - 一次核销需要同时读取和修改多条费用; - 需要跨表判断预算、额度、期间、审批或占用状态; - 核销金额需要根据数据库当前状态动态分配; - 业务规则无法表达为单条带条件的 UPDATE。 对于单条费用的简单余额扣减,没有必要先执行显式锁定查询。 ## 5. 方案三:数据库原子更新 ### 5.1 实现方式 直接通过带余额条件的 UPDATE 完成扣减: ```sql UPDATE dbo.fee SET remain_amount = remain_amount - @amount, version = version + 1 WHERE id = @fee_id AND status = 'OPEN' AND remain_amount >= @amount; ``` 根据影响行数判断结果: ```sql IF @@ROWCOUNT = 0 BEGIN THROW 50001, N'费用不存在、状态不可核销或剩余金额不足', 1; END; ``` ### 5.2 并发结果 用户 A 和用户 B 同时更新同一条费用时: 1. 一个 UPDATE 先获得记录的更新锁和排他锁; 2. 另一个 UPDATE 等待; 3. 第一个事务提交后,第二个 UPDATE 基于数据库当前余额继续判断; 4. 当前余额足够则更新成功,不足则影响行数为 0; 5. 余额不会被旧页面数据覆盖,也不会出现负数。 原子更新并不是数据库完全不加锁,而是不需要应用程序预先显式加锁。SQL Server 会在执行 UPDATE 时取得必要的锁。 ### 5.3 SQL Server 锁行为 在常见的 `READ COMMITTED` 或启用 RCSI 的环境中: - UPDATE 通常先获得更新锁,修改时转换为排他锁; - 排他锁一般持有到事务结束; - 同一记录上的其他写操作会等待; - RCSI 可以减少读写阻塞,但写操作之间仍然需要锁; - 使用完整 `SNAPSHOT` 事务隔离时可能出现更新冲突,需要捕获异常并重试。 对于同一个费用余额,任何能够保证强一致性的方案最终都需要串行化写操作。原子 UPDATE 的优势在于锁持有时间通常最短。 ### 5.4 适用范围 原子 UPDATE 非常适合: - 单条费用余额扣减; - 库存扣减; - 额度占用; - 次数扣减; - 其他可以使用条件 UPDATE 表达的数值变更。 ## 6. 三种方案对比 | 方案 | 防止超额核销 | 合法并发体验 | 锁持有时间 | 实现复杂度 | 推荐用途 | | --- | --- | --- | --- | --- | --- | | 版本锁 | 可以,但所有入口必须使用版本条件 | 较差,版本变化即失败 | 短 | 低 | 普通资料编辑、变化提示 | | 显式悲观锁 | 可以 | 会等待,通常不要求刷新 | 相对较长 | 中高 | 多行、多表、复杂规则 | | 原子 UPDATE | 可以 | 合法操作依次成功 | 较短 | 低 | 单费用余额扣减,首选 | 如果在原子 UPDATE 中同时增加页面版本条件: ```sql WHERE id = @fee_id AND version = @page_version AND remain_amount >= @amount ``` 虽然仍然安全,但会因为版本变化拒绝本来可以成功的核销,因此不建议在正常核销时使用页面版本作为必要条件。 ## 7. 核销流水与事务一致性 ### 7.1 建议数据结构 费用主表: ```text fee - id - original_amount - remain_amount - status - version ``` 核销流水表: ```text fee_writeoff - id - request_id - batch_id - batch_item_id - fee_id - amount - operation_type - reversal_of_id - operator_id - operation_time - reason - business_document_no ``` ### 7.2 单笔核销事务 余额扣减与核销流水必须在同一个数据库事务内: ```sql SET XACT_ABORT ON; BEGIN TRY BEGIN TRANSACTION; UPDATE dbo.fee SET remain_amount = remain_amount - @amount, version = version + 1 WHERE id = @fee_id AND status = 'OPEN' AND remain_amount >= @amount; IF @@ROWCOUNT = 0 THROW 50001, N'剩余金额不足或费用当前不可核销', 1; INSERT INTO dbo.fee_writeoff ( request_id, fee_id, amount, operation_type, operator_id, operation_time, business_document_no ) VALUES ( @request_id, @fee_id, @amount, 'WRITEOFF', @operator_id, SYSDATETIME(), @business_document_no ); COMMIT; END TRY BEGIN CATCH IF @@TRANCOUNT > 0 ROLLBACK; THROW; END CATCH; ``` 这样可以保证: - 不会出现余额已扣减但没有流水; - 不会出现流水已生成但余额没有扣减; - 任意一步失败,当前费用的余额和流水一起回滚。 ## 8. 批量核销与部分成功 ### 8.1 事务边界 如果业务要求一笔费用失败不能影响其他费用,推荐采用: > 批次负责组织和汇总,每个批次明细使用独立事务处理。 处理流程: ```text 创建批次和批次明细 | 逐笔处理,每笔一个独立事务 | 记录每笔成功或失败结果 | 汇总批次最终状态 ``` 不能简单地在整个批量方法外层开启一个总事务,否则中间一笔抛出异常时,之前成功的费用也可能被回滚。 ### 8.2 业务失败与技术失败 需要区分: - 业务失败:余额不足、版本冲突、费用已关闭、期间不允许核销; - 技术失败:数据库死锁、锁等待超时、程序异常、唯一键冲突。 如果多笔费用放在一个数据库事务中,可以通过检查 `@@ROWCOUNT` 跳过余额不足等业务失败,但一旦发生导致事务不可提交的技术异常,整个事务仍可能回滚。 如果要求任何一笔发生任何类型的失败都不影响已经处理成功的费用,则每笔费用必须使用独立事务。 ### 8.3 版本锁下的批量处理 每笔费用使用自己的页面版本和独立事务: ```text 费用1:成功 费用2:版本冲突 费用3:成功 ``` 费用2的失败不会回滚费用1和费用3。但批量选择的费用越多,页面数据过期和版本冲突的概率越高,因此用户体验通常较差。 ### 8.4 悲观锁下的批量处理 每笔费用分别执行以下过程: 1. 开启当前费用的事务; 2. 使用 `UPDLOCK` 读取当前余额; 3. 校验、更新并写入流水; 4. 提交当前事务; 5. 继续处理下一笔。 不要在一个总事务中先后锁定全部费用,否则锁会长期持有,并增加等待和死锁概率。 ### 8.5 原子 UPDATE 下的批量处理 每笔费用分别执行原子 UPDATE: ```text 费用A:剩余100,核销50,成功 费用B:剩余20,核销30,失败 费用C:剩余80,核销40,成功 ``` 最终批次结果: ```text 成功:2笔,金额90 失败:1笔,金额30 批次状态:PARTIAL_SUCCESS ``` 费用 A 和费用 C 的余额及流水正常提交,不受费用 B 影响。 ### 8.6 推荐批次状态 批次主表状态: ```text PROCESSING SUCCESS PARTIAL_SUCCESS FAILED ``` 批次明细状态: ```text PENDING PROCESSING SUCCESS FAILED ``` 失败原因建议使用明确的业务代码: ```text INSUFFICIENT_BALANCE VERSION_CONFLICT FEE_CLOSED PERIOD_CLOSED DUPLICATE_REQUEST LOCK_TIMEOUT SYSTEM_ERROR ``` 财务流水表只记录实际成功的财务变动;失败尝试记录在批次明细或操作日志中。 ### 8.7 批量处理伪代码 ```text 创建批次及所有批次明细 for each batchItem: 开启独立事务 尝试原子扣减当前费用余额 如果余额扣减成功: 写入核销流水 将批次明细标记为 SUCCESS 提交当前事务 如果业务校验失败: 将批次明细标记为 FAILED 保存失败原因 提交当前事务 如果发生技术异常: 回滚当前事务 使用新的短事务记录技术失败 汇总批次状态和金额 ``` 如果应用框架使用外层事务注解,需要确保逐笔处理真正开启独立事务。例如 Java/Spring 中应避免因为同类内部调用导致 `REQUIRES_NEW` 代理失效,也不应让外层批量方法持有一个覆盖全部明细的事务。 ## 9. 幂等控制 前端重复点击、HTTP 超时重试、消息重复消费或批次恢复都可能导致重复核销,因此必须为每个批次明细生成唯一请求号。 建议增加唯一约束: ```sql CREATE UNIQUE INDEX UX_fee_writeoff_request ON dbo.fee_writeoff(request_id); ``` 批次明细还可以增加: ```sql CREATE UNIQUE INDEX UX_writeoff_batch_item ON dbo.writeoff_batch_item(batch_id, line_no); ``` 重复请求的处理规则: - 已成功明细直接返回第一次成功结果,不再次扣款; - 业务失败明细可按业务规则重新校验; - 技术失败明细可以有限次数重试; - 重试整个批次时,只处理未成功的明细。 如果一个批次中允许同一费用出现多条明细,应提前明确规则: - 可以先按费用汇总核销金额后执行一次原子更新;或者 - 按固定明细顺序逐条处理并接受后续明细可能余额不足。 否则,同一费用多条明细的成功结果可能依赖执行顺序。 ## 10. 核销撤销 财务审计场景不建议删除或覆盖原核销流水。撤销应通过新增反向流水完成: ```text 原流水:operation_type = WRITEOFF,amount = 50 撤销流水:operation_type = REVERSAL,amount = 50 reversal_of_id = 原流水ID ``` 撤销时原子增加余额: ```sql UPDATE dbo.fee SET remain_amount = remain_amount + @amount, version = version + 1 WHERE id = @fee_id AND remain_amount + @amount <= original_amount; ``` 同时要求: - 撤销余额更新和反向流水写入处于同一事务; - `reversal_of_id` 建立唯一约束,防止同一笔核销被撤销两次; - 保存撤销人、撤销时间、原因和审批信息; - 已结账期间通过冲销记录处理,不物理修改或删除历史流水; - 批量撤销同样按每笔独立事务处理,以支持部分成功。 ## 11. 数据库防御性约束 建议增加余额非负约束: ```sql ALTER TABLE dbo.fee ADD CONSTRAINT CK_fee_remain_nonnegative CHECK (remain_amount >= 0); ``` 如果业务始终要求剩余金额不能超过原始金额,可以增加: ```sql ALTER TABLE dbo.fee ADD CONSTRAINT CK_fee_remain_range CHECK ( remain_amount >= 0 AND remain_amount <= original_amount ); ``` 其他建议: - 金额字段使用 `decimal`,不要使用 `float`; - 精度根据币种和业务确定,例如 `decimal(19, 4)`; - 原子更新必须通过费用主键或高选择性索引定位记录; - 限制业务账号直接修改余额字段的权限; - 所有余额变化统一通过服务或存储过程执行; - 定期使用流水汇总结果与费用余额进行一致性对账。 ## 12. 最终推荐架构 推荐采用: > 数据库原子 UPDATE + 每笔独立事务 + 核销流水 + 批次明细 + 幂等控制 + 数据库约束 各机制职责如下: - 原子 UPDATE:保证余额不会被超扣; - 每笔独立事务:保证批量核销可以部分成功; - 核销流水:满足审计、追溯、对账和撤销要求; - 唯一请求号:防止重复提交和故障重试造成重复扣款; - CHECK 约束:作为余额合法性的数据库防线; - version/rowversion:用于页面变化提示、缓存失效和普通资料编辑,不作为正常核销的必要条件; - 显式悲观锁:仅在多行、多表或复杂规则无法使用条件 UPDATE 表达时采用; - 批次主表和明细表:记录部分成功结果、失败原因和重试状态。 ## 13. 财务业务边界 采用部分成功前,需要确认批量核销的业务含义。 如果多笔费用只是一次操作选择形成的处理批次,可以允许部分成功,成功项分别形成核销流水。 如果多笔费用共同对应一张不可拆分的付款单、结算单或会计凭证,并且必须满足固定总金额,则部分成功可能造成单据金额不平。这种场景应采用全成全败,或者先完成逐笔预校验和额度预占,再统一确认入账。 因此,建议将允许部分成功的批次定义为“操作批次”,而不是一笔不可拆分的会计交易。最终会计单据应根据成功明细生成,并确保借贷和业务金额平衡。