幂等性设计

近期有一个项目使用了Spring Cloud微服务的框架,利用Eureka+Fegin+Ribbon的方式进行RPC调用,并进行了幂等性设计,在使用的过程中还是出现了问题,账户系统重复处理了一些订单

幂等性

什么是幂等性:系统针对同样的请求(包括参数,地址),无论发生多少次请求,得到的结果都是一致的,接口的幂等性保证了分布式系统在复杂的环境中不会因为重复请求而发生重复处理,简化了系统处理故障的步骤

常用解决方案
  • MVCC多版本并发控制,使用乐观锁来保证数据不会被重复处理,一个Version只有一次成功机会
  • 去重表,使用数据库对数据行的唯一约束来保证数据不会被重复处理,一个行对于一次成功
  • TOKEN,长流程业务在处理中常用的机制,根据某个标识在系统间进行传递,每个业务在同一标识上只有一次成功机会
  • 悲观锁,将数据行锁住,防止其他线程的事务进行提交,通过串行化保证只有一次成功
  • 分布式锁,使用Redis或者ZK节点等方式,防止其他线程的事务进行提交,通过串行化保证只有一次成功
  • 异步处理,对Insert操作不保证幂等性,使用定时任务或者其他方式对数据进行筛选处理
  • 状态机幂,在处理长流程的时候,如果状态字段已经变更,不应处理之前所有字段的业务请求
    Rest请求的幂等性
  • GET不用额外处理,进行数据查询的接口本身就是幂等性的,不管请求多少次,数据都是一样的
  • POSTPOST请求并不能保证幂等性,在HTTP的规范中都没有保证POST的幂等,但是实际场景中经常碰到需要处理的例子
  • PUT需要保证幂等性,需要保证数据不会重复提交以及重复更新
  • DELETE删除一个资源的请求需要保证幂等性,对一个资源多次请求删除,结果都是删除成功

问题复盘

进行排查
  1. 进行日志排查,针对某一订单号查询Client和Server的日志,发现Client只发生过一次调用,而Server在三台集群下共发生了7次消费,相互间隔10S
  2. Server端7个线程在接收请求之后全部发生了阻塞,直到某一时刻,7次请求全部成功并打印出成功日志
  3. Client得到一次响应结果并继续处理

问题的原因很容易思考出来,在框架选型的时候,远程RPC架构使用了Eureka+Fegin+Ribbon,在实现方式上Spring的RestTemplate进行的HTTP协议的调用,而一次HTTP调用是不可能有7个服务端链接的,出现这个问题的原因是因为我所看到的1次请求只不过是1次业务端日志打印,而实际上Fegin的默认实现里,超过10秒没有接收到响应的请求就会被抛弃(可以配置),Fegin会根据事先设定好的访问规则选取另一台服务器再次发起请求,我们没有主动设置规则,默认的实现是轮询的方式,多次请求全都因为数据库而堵塞,在轮询的时候其实已经抛弃了上一个Http链接的结果,但是HTTP链接结束并没有把Server端线程给结束,造成了这次的问题出现

幂等性设计1.0,出错版本
  1. 根据交易订单号作为幂等性的标识
  2. 每次请求第一步操作是根据订单号进行查询,发现有数据则将原结果返回
  3. 进行业务处理,获取所有交易方的账户信息,增加流水,进行余额变更
    原因分析
    数据库堵塞导致了上述3、4步没有继续进行下去,但是Server端线程实际上都是在运行状态的,本次出现该问题的原因是数据库操作超过10S,而内存、CPU、磁盘IO、网络等许多情况都会影响到响应时间,这时需要将幂等性重新进行设计
    入手方向
    这个操作是一个INSERT+UPDATE操作,本身属于一个POST请求,但是需要保证幂等性来满足上游系统,原本的幂等性设计是根据数据库中有没有记录来确定是否有过处理记录,而数据库的数据改变是事务提交之后的,这样的设计是完全无法保证高并发下的幂等的,而此次的入手点就是改变如何判断请求有没有处理过
    幂等性设计2.0,存在问题,未暴露版本
    在业务处理之前,需要保证Server端只有一个线程进行业务处理和数据提交,我们采用Redis做一个分布式锁,将执行操作和分布式锁结合,修改后的Server端步骤如下
  4. 根据订单号作为幂等性的标识
  5. 使用Redis的SetNX把订单号放在Redis里,设置一个中等的过期时间1分钟,如果没有拿到分布式锁则进行自旋
  6. 每次请求第一步操作是根据订单号进行查询,发现有数据则将原结果返回
  7. 进行业务处理,处理此交易的所有流水和余额
  8. 提交事务
  9. 释放Redis锁
    ######再次发现问题
    最近在看《逆流而上,阿里巴巴技术成长之路的时候》,其中有一个章节提到阿里因为幂等性原因不完善导致的重复入账,和我们遇到的情况基本一致,而我们修改后的场景竟然完全符合阿里的错误场景,对原方案进行再次分析,存在的问题有:
  10. Redis的SetNx方法并不能同时设置超时时间,所以原方案其实是两步操作,没有保证原子性(自己发现)
  11. 分布式锁的时间和数据库事务的等待时间不一致,分布式锁的1分钟等待时间远远低于设置的数据库事务等待时间,所以在超过1分钟的时候仍有可能绕过幂等性校验

解决方案

  1. 如果想要解决第一个问题,有三种可行方案:
    1. 开启Redis事务
    2. 使用Lua脚本
    3. 在Redis锁中添加过期时间
  2. 将事务等待时间和分布式锁生效时间全部设置N,在这个时间内只有一个线程会争夺到锁并处理,如果失败就会发起重试,线程处理+M次重试的总时间和也为N

    考虑到我司的开发人员水平参差不齐,在解决上述问题1于是我选择使用第三种方式对Redis进行了封装,避免前两种解决方案下因为环境问题造成的潜在风险。
    解决第二个

######幂等性设计3.0

  1. 根据订单号作为幂等性的标识
  2. 使用Redis的SetNX把订单号放在Redis里,设置对应的值为预估过期时间,如果获得锁即进行处理,如果发现超时锁就用CAS操作更新锁,更新成功进行处理,更新失败退出更新并再次争夺
  3. 每次请求第一步操作是根据订单号进行查询,发现有数据则将原结果返回
  4. 进行业务处理,处理此交易的所有流水和余额
  5. 提交事务
  6. 释放Redis锁