概念
接口幂等性的本质是接口可重复调用,多次调用后,接口最终的结果是一致的。有些接口天然具有幂等性,例如查询接口,查一次和查两次对于系统来说没有任何影响。有些接口不具有幂等性,例如新增,更新,删除。需要通过设计实现幂等性。
因为并发的存在,所以在代码层面做简单校验是做不到幂等的,只能在最后一步数据的原子操作,或在代码上添加锁(代码或 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 分布式锁是万金油,代码维护简单,并且也适用于分布式系统。其他方案可以根据业务的不同,选用适合的方案。
在高并发的分布式系统里,通常采用补偿的机制实现幂等,也就是最终一致性