转自https://mp.weixin.qq.com/s/odRypb6YqF3xuRn-4YAwlg
https://blog.csdn.net/wanglei303707/article/details/88298211
幂等就是一个操作,不论执行多少次,产生的效果和返回的结果都是一样的。
1、防止页面重复提交——token
1.1 场景
页面的数据只能被点击提交一次
1.2 发生原因
由于重复点击或者网络重发,或者nginx重发等情况会导致数据被重复提交
1.3场景:
场景一:在网络延迟的情况下让用户有时间点击多次submit按钮导致表单重复提交
场景二:表单提交后用户点击【刷新】按钮导致表单重复提交
场景三:用户提交表单后,点击浏览器的【后退】按钮回退到表单页面后进行再次提交
1.4 解决办法
集群环境:采用token加redis(redis单线程的,处理需要排队)
单JVM环境:采用token加redis或token加jvm内存
1.5 处理流程:
1.5.1 数据提交前要向服务的申请token,token放到redis或jvm内存,token有效时间;
1.5.2 前端提交后,后台校验token,同时删除token,执行业务生成新的token返回
token特点:要申请,一次有效性,可以限流
因为redis单线程的原因,当多次提交时,redis需要排队处理,所以只有第一次提交可以删除token成功,当删除成功代表token校验通过,删除失败,说明是重复提交。
2、防止新增脏数据(重复数据)——唯一索引
查询操作和删除操作天然就是幂等的:
查询操作:查询一次和查询多次,在数据不变的情况下,查询结果是一样的。select是天然的幂等操作;
删除操作:删除操作也是幂等的,删除一次和多次删除都是把数据删除。(注意可能返回结果不一样,删除的数据不存在,返回0,删除的数据多条,返回结果多个)
新增操作:有些服务是有重试机制的(ribbon),在这种情况下,就可能会出现新增两条一样的数据,这时候服务要支持幂等操作,否则会出问题,这时候可以通过数据库唯一索引来防止新增脏数据。
比如:支付宝的资金账户,支付宝也有用户账户,每个用户只能有一个资金账户,怎么防止给用户创建资金账户多个,那么给资金账户表中的用户ID加唯一索引,所以一个用户新增成功一个资金账户记录
2.1 唯一索引或唯一组合索引来防止新增数据存在脏数据
当表存在唯一索引,并发时新增报错时,再查询一次就可以了,数据应该已经存在了,返回结果即可
2.2 防重表
使用订单号orderNo做为去重表的唯一索引,每次请求都根据订单号向去重表中插入一条数据。第一次请求查询订单支付状态,当然订单没有支付,进行支付操作,无论成功与否,执行完后更新订单状态为成功或失败,删除去重表中的数据。后续的订单因为表中唯一索引而插入失败,则返回操作失败,直到第一次的请求完成(成功或失败)。可以看出防重表作用是加锁的功能。
防重表可以在一些主业务表不方便加唯一索引或者唯一组合索引时其作用,通过单独新建一张防重表来解决问题
3、对外提供接口的api如何保证幂等
如银联提供的付款接口:需要接入商户提交付款请求时附带:source来源,seq序列号,source+seq在数据库里面做唯一索引,防止多次付款,(并发时,只能处理一个请求)
重点: 对外提供接口为了支持幂等调用,接口有两个字段必须传,一个是来源source,一个是来源方序列号seq,这个两个字段在提供方系统里面做联合唯一索引
这样当第三方调用时,先在本方系统里面查询一下,是否已经处理过,返回相应处理结果;没有处理过,进行相应处理,返回结果。
注意,为了幂等友好,一定要先查询一下,是否处理过该笔业务,不查询直接插入业务系统,会报错,但实际已经处理了。
这里使用的还是数据库唯一索引(联合唯一索引)。
对于一些有时效性的业务处理接口来说,事实上还有一种方法,就是使用redis。
- 当第三方调用时,先判断是否过期,如果过期了,就不处理,如果没有过期,则下一步;
- 判断redis中是否存在source_seq key,如果存在,说明已经处理过了,是重复调用,直接返回;如果不存在,则set source_seq key ,注意这里的存在则返回,不存在则set key,是需要保持原子操作的,可以通过lua脚本来提交;
- key有效期和业务有关系, 比如说我支付消息有效期只有1h,那么key 有效期也是1h;
4、同一时间只能完成一次请求——乐观锁、分布式锁
在分布式环境下,因为网络、重试等原因导致一个长流程请求经过不同的实例时,会发生重复操作,为了防止这种情况,可以采用乐观锁,或者分布式锁来解决。
使用乐观锁,或者分布式锁,其实就是在mysql中,同一时间只有一次请求。
在有些场景下,使用version乐观锁还是麻烦,其实可以使用主键id,或者唯一索引来锁定mysql数据,然后再操作,比如状态变更,如下
update tableA set status = 5 where id=4 and status=4;
就算同一时间有重复操作,但是因为mysql行锁的特性,同一时间只有一个操作能锁定id为4的这行数据,状态变更成功以后(返回值为1),后面的操作已经不满足status=4的条件了,利用这个特性,不止能防重复,对于一些并发开奖场景,可以直接替代乐观锁的使用。
5、总结
查询操作:select,查询一次和查询多次,在数据不变的情况下,查询结果是一样的。select是天然的幂等操作;
删除操作:delete,删除操作也是幂等的,删除一次和多次删除都是把数据删除。(注意可能返回结果不一样,删除的数据不存在,返回0,删除的数据多条,返回结果多个);
新增操作:insert,业务逻辑上,先select再insert;数据库上尽量通过唯一索引或唯一组合索引来防止新增脏数据(重复数据);如果没有唯一索引,则再考虑分布式锁。
更新操作:update,对于状态操作,where条件中,一定要加上原本的状态值,即明确是由旧状态变更为新状态,这个过程是不可逆的。
如果上面的一般方法还是不能防重,则可以使用redis分布式锁。
使用token+redis的好处是,一个token只能使用一次,token使用或者过期后,就只能再次申请token,是严格防止重复的。但是会比较麻烦,一个请求,需要两次操作,一次获取token,一次执行业务。
其实可以简单的只用redis分布式锁来防止重复,把获取锁和释放锁的逻辑写在过滤器,或者拦截器中,只有获取到锁,才能执行业务逻辑。—— 对那些需要加防重的接口url,可以放在配置文件中,而不要放在redis中,因为绝对部分接口url是不需要防重的,如果放在redis中,每个请求都会去redis中获取url配置,太浪费redis了,如果赶上公司搞活动,很容易就报警了。
key的设置:可以先把请求参数中为空的去掉,然后拼接为String,并对其MD5加密,再加上userId,最后拼接成key。
至于redis分布式的实现,可以见redis系列文章。https://www.jianshu.com/nb/39445385