针对近期处理的两例并发场景引发的「互斥性」「幂等性」问题进行过程分析、输出系统方案。

阅读本文前,我想重申一个观点: 在相对复杂的工业代码中(绕来绕去,不是简单的一个函数调用),排查「简单问题」也不是一件简单的事。

背景

近期处理租赁合同工单、报警,发现有些接口在并发、调用重试、用户连击等情况下的存在幂等性、数据一致性问题。

本文借由一些问题实例,来系统性分析解决幂等性、数据一致性问题,同时针对这类问题做一下系统性的梳理。

问题案例

案例 影响
问题1:已签约合同状态瞬时回退到起草状态 下游服务收到消息乱序。
问题2:重复写入 biz_record 合同详情报错,获取合同完整数据处操作完全阻塞。

具体的排查、分析过程放在内网工单处理过程wiki。

本文我们核心目标是从上往下审视系统设计,以此来完成「理论指导实践」的落地,具体的代码细节不做过多展示(脱敏)。

首先对问题逐个进行问题核心原因的分析。

问题1

剖析

以问题2为例,租赁合同场景,系统主要用户:

  • 经纪人
  • 业客(业主+租客)

签约过程的核心状态机:

其中盖章由经纪人在我们PC后台、App端完成。业客完成签字后,合同状态从盖章流转到签约状态。

此时由于业客已经完成了签字动作,合同状态正常只能往后流转,即过户(物业交割)。

真实场景下,如果合同确实存在变更(业客变更、条款变更),则应走变更流程。变更单提交并且通过审核后,合同状态才会回到起草,从头开始作业。

而问题1案例中,根据下游服务的RD反馈,他们连续收到了两条消息:

  • 2024-03-04 19:31:27.296 合同已签约
  • 2024-03-04 19:31:27.467 合同起草

经过细致的对日志+源码进行排查分析,我最终成功还原了当时并发的场景,如图:

简单描述下当时的情景:

  1. 首先业客首先完成了签署;
    • 状态更新的时刻:Mar 4, 2024 @ 19:31:27.290 随后事务提交;
    • 随后分布式锁释放;
  2. 然后在稍后的同一时刻,经纪人修改了合同;
    • 接口进入时刻:Mar 4, 2024 @ 19:31:27.277 此时业客完成签署的状态事务还未提交;
    • 此时首先开启事务并查询了合同状态,查到的状态为旧的【已盖章】;
    • 接着进行了一段较耗时的dubbo调用,调用收到响应的时刻 2024-03-04T19:31:27.361 ,对比上面的时间,可以发现此时业客签署已经处理完毕;
    • 接着获取分布式锁(由于业客签署的接口已经执行完释放了锁,因此此处可正常获取到锁);
    • 在锁范围内进行一系列业务校验、业务更新;

前人此处考虑到了多个业务流间保证操作的互斥性,但是部分代码迭代后没处理好锁的范围。

前人想做到的效果图例:

一句话总结问题:使用了分布式锁,意图想控制业客签署、修改合同两个业务流的互斥访问,但是在锁外先做了状态判断,导致锁内更新数据错误。

此处使用分布式锁不当,导致两个应该互斥访问的操作隔离性不足。

问题1核心是未保证多个互斥接口间的并发访问,从而导致数据一致性出错。

解决

问题核心原因是在获取锁之前就判断了业务状态,两个接口间未保证同步性(访问互斥性)。

仅需保证先获取锁,在锁内进行数据状态的获取与判断,问题即可解决。

解决口诀:一锁、二判、三更新。

问题2

剖析

问题2是一个典型的幂等性问题。

由于代码脱敏原因,针对并发场景,我们使用图例还原下并发错误时的过程:

一句话总结下问题:有分布式锁,但是没锁全。导致两个先后到达的请求可并发进入内部方法 sendMessageToUser,并发时,此方法使用事务无法保证幂等性。

因此最后写入了多条 biz_record 数据,正常情况一个合同应该只有一条 biz_record

解决

问题核心是分布式锁有,但是没锁全整个过程。

因此只要保证锁住整个方法,问题即可解决。

解决口诀:一锁、二判、三更新。

总结

处理完上述两个问题,我们总结下共性。

问题1:多个应当保证访问互斥的接口间,使用了分布式锁,但是在锁外先判断了业务状态,导致锁内拿到的其实是过期数据。

问题2:接口应当保证幂等性,使用了分布式锁,但是未锁住需保证幂等的完整范围。

可以看到,在这两类问题中,我们都只要做到:在需要保证访问互斥、幂等的接口上使用悲观锁对完整的代码范围加锁,同时需遵循【先加锁后做判断与更新】原则,问题都能解决。

使用分布式锁确保业务互斥性的正反例对比:

使用分布式锁确保接口幂等性的正反例对比: )