目录
注意:本文参考 java中接口幂等性解决方案总结_青朽_的博客-CSDN博客_java 接口幂等性
知识积累:幂等性问题的思考和总结,防重、幂等,常用解决方案,解决方式_一张船票的博客-CSDN博客_幂等性和防重区别
有状态服务 & 无状态服务_后端老A的博客-CSDN博客_无状态服务
无状态服务(stateless service)_晴空排云的博客-CSDN博客_无状态服务
幂等性
幂等性概念
一个幂等操作的特点是其任意多次执行所产生的影响均与一次执行的影响相同。幂等函数,或幂等方法,是指可以使用相同参数重复执行,并能获得相同结果的函数。这些函数不会影响系统状态,也不用担心重复执行会对系统造成改变。
防重和幂等的区别:
防重设计主要为了避免产生重复数据,对接口返回没有太多要求。
而幂等设计除了避免产生重复数据之外,还要求每次请求都返回一样的结果。
幂等性场景
1、前端页面在填写一些表单点击提交保存按钮的时候,因网络波动没有及时对用户做出提交成功响应,致使用户认为没有成功提交,然后一直点提交按钮,这时就会发生重复提交表单请求,后端收到了好几次提交,这时就会在数据库中重复创建了多条记录,这就是接口没有幂等性带来的 bug。
2、接口恶意调用刷单,比如投票功能,针对某一个用户重复提交,会导致接口接收到用户重复提交的投票信息,这样会使投票结果与事实严重不符。
3、 一个订单创建接口,第一次调用超时了,然后调用方重试了一次,虽然第一次超时了,但是实际也许创建成功了,再次调用接口重试,这个时候就会调用2次创建接口,会创建2张订单,实际我们只想创建一张订单。
4、电商系统订单消耗库存场景:在订单创建时,我们需要去扣减库存,由于种种原因接口发生了超时,调用方重试了一次,如果接口不是幂等的,就有可能减2次库存。我们重试的目的,其实只是想一次成功的请求,如果真的减去2次库存,那就不满足需求。
5、电商系统订单退款的场景:当用户发起退款,退款接口超时,长时间未返回是否退款成功的结果,退款接口调用方重试一次,结果2次的退款请求都成功了,则会给用户退2次钱。
6、使用消息中间件来处理消息队列,且手动 ack 确认消息被正常消费时。如果消费者突然断开连接,那么已经执行了一半的消息会重新放回队列。当消息被其他消费者重新消费时,如果没有幂等性,就会导致消息重复消费时结果异常,如数据库重复数据,数据库数据冲突,资源重复等。
上述可以归类为三种:
1 前端重复提交
用户注册,用户创建商品等操作,前端都会提交一些数据给后台服务,后台需要根据用户提交的数据在数据库中创建记录。如果用户不小心多点了几次,后端收到了好几次提交,这时就会在数据库中重复创建了多条记录。这就是接口没有幂等性带来的 bug。
2 接口超时重试
对于给第三方调用的接口,有可能会因为网络原因而调用失败,这时,一般在设计的时候会对接口调用加上失败重试的机制。如果第一次调用已经执行了一半时,发生了网络异常。这时再次调用时就会因为脏数据的存在而出现调用异常。
3 消息重复消费
在使用消息中间件来处理消息队列,且手动 ack 确认消息被正常消费时。如果消费者突然断开连接,那么已经执行了一半的消息会重新放回队列。
当消息被其他消费者重新消费时,如果没有幂等性,就会导致消息重复消费时结果异常,如数据库重复数据,数据库数据冲突,资源重复等。
解决方案
数据库唯一标识
在数据库唯一主键或者在相关的字段上添加唯一索引,客户端执行创建请求,调用服务端接口,后端生成布式 ID(雪花id、redis生成全局id等等方法) 充当主键或者建立唯一索引的字段值,这样才能能保证在分布式环境下 ID 的全局唯一性,后端将该条数据插入数据库中,如果插入成功则表示没有重复调用接口。如果抛出主键重复异常,则表示数据库中已经存在该条记录,返回错误信息到客户端;
乐观锁
建表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值已经修改了,那么前面必定已经成功过一次,后面都是重复的请求。
注意:乐观锁的更新操作,最好用主键或者唯一索引来更新,这样是行锁,否则更新时会锁表;索引与表锁行锁的问题,我其他文章有详细说明;
悲观锁
要使用悲观锁,我们必须关闭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 = '张继科';
注意:暂时先不执行: commit
模拟另一个线程(新建一个查询窗口):
#执行业务,张继科的年龄改为32
UPDATE test01 set age = 32 where name = ‘张继科’;
它会一直处于阻塞状态;
直到刚才修改age为16的线程提交(commint),才会释放;其他线程才能更新操作;不影响查询操作;我们现在把刚才修改age为16的线程commint:更新成功;其他线程可以正常更新;
需要特别注意的是:如果使用的是mysql数据库,存储引擎必须用innodb,因为它才支持事务。此外,这里name字段一定要建立索引,不然会锁住整张表。悲观锁需要在同一个事务操作过程中锁住一行数据,如果事务耗时比较长,会造成大量的请求等待,影响接口性能。此外,每次请求接口很难保证都有相同的返回值,所以不适合幂等性设计场景,但是在防重场景中是可以的使用的。在这里顺便说一下,防重设计 和 幂等设计,其实是有区别的。防重设计主要为了避免产生重复数据,对接口返回没有太多要求。而幂等设计除了避免产生重复数据之外,还要求每次请求都返回一样的结果。
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 表达式来注销查询与删除操作
分布式锁
在业务系统插入数据或者更新数据,先获取锁,获取到锁,就继续后面的业务逻辑。如果没有获取到锁,就等待锁的释放直到获取锁,当执行完业务逻辑时,释放锁,当然,锁要设置超时时间,防止意外没有释放到锁,它可以用来解决分布式系统的幂等性,布式锁类似于防重表,将防重并发放到了缓存中,较为高效,同一时间只能完成一次操作,常用的分布式锁实现方案是redis和zookeeper等;目前redision是最常用的,它不需要我们过于考虑原子操作,它包含了常用锁的类型,基本的可重入锁,读写锁,以及CountDownLatch的设置及使用,redisson的作者就是在加锁和解锁的执行层面采用Lua脚本,有原子性保证;总之它很轻大,我会在后面总结关于分布式锁的详细内容。
有状态服务和无状态服务
定义
无状态服务:就是没有特殊状态的服务,各个请求对于服务器来说统一无差别处理,请求自身携带了所有服务端所需要的所有参数(服务端自身不存储跟请求相关的任何数据,不包括数据库存储信息)
有状态服务:与之相反,有状态服务在服务端保留之前请求的信息,用以处理当前请求,比如session等
两者如何选择
有状态服务常常用于实现事务(并不是唯一办法,下文有另外的方案)。举一个常见的例子,在商城里购买一件商品。需要经过放入购物车、确认订单、付款等多个步骤。由于HTTP协议本身是无状态的,所以为了实现有状态服务,就需要通过一些额外的方案。比如最常见的session,将用户挑选的商品(购物车),保存到session中,当付款的时候,再从购物车里取出商品信息 。
有状态服务可以很容易地实现事务,所以也是有价值的。但是经常听到一种说法,即server要设计为无状态的,这主要是从可伸缩性来考虑的。如果server是无状态的,那么对于客户端来说,就可以将请求发送到任意一台server上,然后就可以通过负载均衡等手段,实现水平扩展。如果server是有状态的,那么就无法很容易地实现了,因为客户端需要始终把请求发到同一台server才行,所谓“session迁移”等方案,也就是为了解决这个问题。
有状态服务和无状态服务各有优劣,它们在一些情况下是可以转换的,或者有时候可以共用,并非一定要全部否定。
无状态实现事务的方法
并不是一定要用有状态服务才能实现事务,本文提供另外的几种方案作为参考
举一个多次提交的场景作为例子:用户需要提交很多数据,分为2个页面提交
这里就涉及到2次http请求,第一次提交字段1、2、3,第二次提交字段4、5、6
用session很容易实现这个需求,server只需要将第一次提交的数据,保存在session里,然后返回第2个表单作为相应;然后取出第一次提交的数据,和第二次提交的数据汇聚以后,一起存入数据库即可
不用session同样也可以实现,server接收到第一次请求以后,将数据作为隐藏元素,放在第2个表单里返回;这样用户第2次提交的时候,就隐含地再次提交了第一次的数据;server将所有数据存入数据库
用HTML5,则还可以进一步优化,client可以将第一次提交的数据,保存在sessionStorage里
用cookie也是类似的道理,同样可以实现,但是不太好
总的来说,3种替代方案(隐藏表单元素、sessionStorage、cookie)都避免了在server端暂存数据,从而实现了stateless service。本质上,这3种方案的请求里,都包含了所有必须的数据,符合本文一开始的定义
将有状态服务转换成无状态服务
在一定需要处理请求上下文的情况下又想使用无状态服务,可以将相关的请求信息存储到共享内存中或者数据库中,参考分布式session的实现方式:
1.基于数据库的Session共享
2.基于NFS共享文件系统
3.基于memcached 的session
4. 基于resin/tomcat web容器本身的session复制机制
5. 基于TT/Redis 或 jbosscache 进行 session 共享
6. 基于cookie 进行session共享
或者在业务实现上,将上下文需要的信息在请求中返回,在客户端中进行存储,只不过,这个方案存在技术风险,需要用一定的手段规避。
或者使用负载均衡,将同一个用户的请求打到同一个服务器上,保证session一直在一个服务器