概念

接口幂等性的本质是接口可重复调用,多次调用后,接口最终的结果是一致的。有些接口天然具有幂等性,例如查询接口,查一次和查两次对于系统来说没有任何影响。有些接口不具有幂等性,例如新增,更新,删除。需要通过设计实现幂等性。

因为并发的存在,所以在代码层面做简单校验是做不到幂等的,只能在最后一步数据的原子操作,或在代码上添加锁(代码或 redis 锁)的方式实现幂等。

前景

我们直接假设一个场景:现在有一个订单相关的流程,包含创建订单、支付、收获三个过程。

幂等性方案

产生幂等性问题主要有以下几个原因:

  • 表单的重复提交
  • 多线程并发

token 机制

假设用户准备购买鼠标

  • 可能用户在点击提交订单的这一步卡了一下或前端 loading 没有生效,于是重复点击了一次提交,后台实际收到了两个创建订单的请求
  • 用户购买一个鼠标后,手速够快,0.01 秒后又再次下单,购买了同样的一个鼠标,后台也收到两个创建订单的请求

情况一我们只希望创建一个订单,情况二希望创建两个订单,但是如何区分以上两种情况呢?

解决方案:

前端进入提交页面以后,向后台请求一个 token,后台生成一个唯一的 token,将它存入 redis 中,并设置过期时间,然后将改 token 返回给前端。当用户点击提交时,携带着 token 一起发送给后端,后端从 redis 中取出并删除 token 再执行后续操作,如果是重复提交,则第二次从 redis 中取不到 token,直接报错 return。

乐观锁

在系统设计的过程中,合理的使用乐观锁,通过 version 或者 updateTime(timestamp)等其他条件,来做乐观锁的判断条件,这样保证更新操作即使在并发的情况下,也不会有太大的问题。例如

select * from tablename where condition=#condition# //取出要跟新的对象,带有版本 versoin

update tableName set name=#name#,version=version+1 where version=#version#

在更新的过程中利用 version 来防止,其他操作对对象的并发更新,导致更新丢失。为了避免失败,通常需要一定的重试机制。

缺点:乐观锁的实现有些麻烦,更新之前必须先进行查询,有时候可能会有遗漏。

悲观锁

select for update,整个执行过程中锁定该订单对应的记录。

缺点:这种在 DB 读大于写的情况下尽量少用,并且可能会产生死锁。

状态机幂等

假设一个订单有三种状态:待付款、付款成功、收货成功。于是状态的流转只能是从待付款=>付款成功=>收货成功。

场景:假设对于收货的操作,没有做任何幂等方案,此时用户快速的点击了两次收货成功,数据库更新成功,第一次成功的更新了数据库的状态,第二次应该报错,因为此时已经是收货成功的状态,不符合状态的流转。

和乐观锁类似,将订单的状态设置为数字,并且按照流程设置数字的大小,比如订单的创建为 0,付款成功为 100。付款失败为 99,在做状态机更新时,我们就这可以这样控制 update order set status=#{status} where id=#{id} and status<#{status}

缺点:因为只适合简单的线性流转,对于复杂的状态流转处理不了。并且状态的值隐式的带上了逻辑,不利于他人阅读或维护代码。

注意:并发的情况下,单纯在代码中做状态流程或有限状态机的判断是无用的,必须在数据库的原子性操作上进行处理。

去重表

在插入数据的时候,插入去重表,利用数据库的唯一索引特性,保证唯一的逻辑。

这种方法适用于在业务中有唯一标的插入场景中,比如在以上的支付场景中,如果一个订单只会支付一次,所以订单 ID 可以作为唯一标识。这时,我们就可以建一张去重表,并且把唯一标识作为唯一索引,在我们实现时,把创建支付单据和写入去去重表,放在一个事务中,如果重复创建,数据库会抛出唯一约束异常,操作就会回滚。

redis 分布式锁

请求到达 java 代码后,首先通过 set key, value, NX, PX, 2000) 命令在 redis 中设置一个分布式锁(具体实现请搜索本博客相关文章),key 为订单号,value 为$收货成功状态。处理完成后删除该锁。

总结

redis 分布式锁是万金油,代码维护简单,并且也适用于分布式系统。其他方案可以根据业务的不同,选用适合的方案。

在高并发的分布式系统里,通常采用补偿的机制实现幂等,也就是最终一致性