一、概念

一个幂等操作的特点是其任意多次执行所产生的影响均与一次执行的影响相同。幂等函数,或幂等方法,是指可以使用相同参数重复执行,并能获得相同结果的函数。这些函数不会影响系统状态,也不用担心重复执行会对系统造成改变。

二、场景

1、前端页面在填写一些表单点击提交保存按钮的时候,因网络波动没有及时对用户做出提交成功响应,致使用户认为没有成功提交,然后一直点提交按钮,这时就会发生重复提交表单请求,后端收到了好几次提交,这时就会在数据库中重复创建了多条记录,这就是接口没有幂等性带来的 bug。

2、接口恶意调用刷单,比如投票功能,针对某一个用户重复提交,会导致接口接收到用户重复提交的投票信息,这样会使投票结果与事实严重不符。

3、 一个订单创建接口,第一次调用超时了,然后调用方重试了一次,虽然第一次超时了,但是实际也许创建成功了,再次调用接口重试,这个时候就会调用2次创建接口,会创建2张订单,实际我们只想创建一张订单。

4、电商系统订单消耗库存场景:在订单创建时,我们需要去扣减库存,由于种种原因接口发生了超时,调用方重试了一次,如果接口不是幂等的,就有可能减2次库存。我们重试的目的,其实只是想一次成功的请求,如果真的减去2次库存,那就不满足需求。

5、电商系统订单退款的场景:当用户发起退款,退款接口超时,长时间未返回是否退款成功的结果,退款接口调用方重试一次,结果2次的退款请求都成功了,则会给用户退2次钱。

6、使用消息中间件来处理消息队列,且手动 ack 确认消息被正常消费时。如果消费者突然断开连接,那么已经执行了一半的消息会重新放回队列。当消息被其他消费者重新消费时,如果没有幂等性,就会导致消息重复消费时结果异常,如数据库重复数据,数据库数据冲突,资源重复等。

三、解决方案

1、数据库唯一标识

在数据库唯一主键或者在相关的字段上添加唯一索引,客户端执行创建请求,调用服务端接口,后端生成布式 ID(雪花id、redis生成全局id等等方法) 充当主键或者建立唯一索引的字段值,这样才能能保证在分布式环境下 ID 的全局唯一性,后端将该条数据插入数据库中,如果插入成功则表示没有重复调用接口。如果抛出主键重复异常,则表示数据库中已经存在该条记录,返回错误信息到客户端;

2、乐观锁

建表test01,在test01表中添加一个标识字段version,初始值设为1;

现在需要将张继科的age + 10;
更新前先查询张继科当前的version:1;

select version from test01 where name = '张继科';update test01 set age = age + 100,version = version + 1 where name = '张继科' and version = 1;

更新数据的同时version+1,条件加上version = 1 (当前线程查到的版本号),然后判断本次update操作的影响行数,如果大于0,则说明本次更新成功,如果等于0,则说明本次更新没有让数据变更。

由于第一次请求version等于1是可以成功的,操作成功后version变成2了。这时如果并发的请求过来,再执行相同的sql:

update test01 set age = age + 100,version = version + 1 where name = '张继科' and version = 1;

该update操作不会真正更新数据,最终sql的执行结果影响行数是0,因为version已经变成2了,where中的version = 1肯定无法满足条件。但为了保证接口幂等性,接口可以直接返回成功,因为version值已经修改了,那么前面必定已经成功过一次,后面都是重复的请求。

注意:乐观锁的更新操作,最好用主键或者唯一索引来更新,这样是行锁,否则更新时会锁表;索引与表锁行锁的问题,我其他文章有详细说明;

3、悲观锁

要使用悲观锁,我们必须关闭mysql数据库的自动提交属性,因为MySQL默认使用autocommit模式,当你执行一个更新操作后,MySQL会立刻将结果进行提交。

查询是否开启自动提交事务

select @@autocommit;

设置为手动提交事务

set autocommit = 0;

假如实际业务中:需要将张继科的age修改为16;
开启事务:

begin;

查询并锁当前行;注意:name字段上有索引;

select * from test01 where name = '张继科' for UPDATE;

执行业务,张继科的年龄改为16

UPDATE test01 set age = 16 where name ='张继科';

注意:暂时先不执行: commint

模拟另一个线程(新建一个查询窗口):
#执行业务,张继科的年龄改为32
UPDATE test01 set age = 32 where name = ‘张继科’;

它会一直处于阻塞状态;

直到刚才修改age为16的线程提交(commint),才会释放;其他线程才能更新操作;不影响查询操作;我们现在把刚才修改age为16的线程commint:更新成功;其他线程可以正常更新;

需要特别注意的是:如果使用的是mysql数据库,存储引擎必须用innodb,因为它才支持事务。此外,这里name字段一定要建立索引,不然会锁住整张表。悲观锁需要在同一个事务操作过程中锁住一行数据,如果事务耗时比较长,会造成大量的请求等待,影响接口性能。此外,每次请求接口很难保证都有相同的返回值,所以不适合幂等性设计场景,但是在防重场景中是可以的使用的。在这里顺便说一下,防重设计 和 幂等设计,其实是有区别的。防重设计主要为了避免产生重复数据,对接口返回没有太多要求。而幂等设计除了避免产生重复数据之外,还要求每次请求都返回一样的结果。

4.Token机制

客户端在调用接口的时候向后台请求一个全局id(token),请求的时候就携带这个全局id传到后台,后端对这个token作为key,用户信息(sessionId)作为value,以键值对的方式在redis中进行校验,如果key相同且value匹配则删除,然后执行删除操作(存的是需要设置失效时间,删除时候注意原子性操作),否则属于重复提交;

a、服务端提供生成token的接口,注意全局唯一;
b、客户端调用接口获取token,同时后端将token放到redis中,token作为key,用户信息为value;
c、客户端将获取到的token放到当前表单隐藏域中;
d、客户端在执行提交表单时,把 token 存入到 Headers 中,执行业务请求带上该 Headers;
e、服务端收到请求后,从header中拿到token,根据key在redis中查找是否存在;
f、服务端根据 Redis 中是否存该 key 进行判断,如果存在就将该 key 删除,然后正常执行业务逻辑。如果不存在就抛异常,返回重复提交的错误信息。

注意:在并发情况下,执行 Redis 查找数据与删除需要保证原子性,否则很可能在并发下无法保证幂等性。其实现方法可以使用分布式锁或者使用 Lua 表达式来注销查询与删除操作

5.分布式锁

在业务系统插入数据或者更新数据,先获取锁,获取到锁,就继续后面的业务逻辑。如果没有获取到锁,就等待锁的释放直到获取锁,当执行完业务逻辑时,释放锁,当然,锁要设置超时时间,防止意外没有释放到锁,它可以用来解决分布式系统的幂等性,布式锁类似于防重表,将防重并发放到了缓存中,较为高效,同一时间只能完成一次操作,常用的分布式锁实现方案是redis和zookeeper等;目前redision是最常用的,它不需要我们过于考虑原子操作,它包含了常用锁的类型,基本的可重入锁,读写锁,以及CountDownLatch的设置及使用,redisson的作者就是在加锁和解锁的执行层面采用Lua脚本,有原子性保证;总之它很轻大,我会在后面总结关于分布式锁的详细内容。

四、总结

幂等性应该是合格程序员的一个基因,在设计系统时,是首要考虑的问题,尤其是在像支付宝,银行,互联网金融公司等涉及的都是钱的系统,既要高效,数据也要准确,所以不能出现多扣款,多打款等问题,这样会很难处理,用户体验也不好。另外,幂等性是为了简化客户端逻辑处理,能放置重复提交等操作,但却增加了服务端的逻辑复杂性和成本,其主要是:并行执行的功能改为串行执行,降低了执行效率。增加了额外控制幂等的业务逻辑,复杂化了业务功能;所以在使用时候需要考虑是否引入幂等性的必要性,根据实际业务场景具体分析,除了业务上的特殊要求外,一般情况下不需要引入的接口幂等性,