聊聊幂等性
幂等性
1.幂等的数学概念
如果在一元运算中,x 为某集合中的任意数,如果满足 f(x) = f(f(x)) ,那么该 f 运算具有幂等性。
绝对值运算 abs(a) = abs(abs(a)) 就是幂等性函数
如果在二元运算中,x 为某集合中的任意数,如果满足 f(x,x) = x,前提是 f 运算的两个参数均为 x,那么我们称 f 运算也有幂等性。
求大值函数 max(x,x) = x 就是幂等性函数
2.幂等概述
2.1幂等业务场景分析
生产环境经常出现过重复的数据?在排查问题的时候,数据又是正常的。这个是何解呢?怎么会出现这种情况,而且还很难排查问题。
原因 :产生重复数据或数据不一致(假定程序业务代码没问题),绝大部分就是发生了重复的请求,重复请求是指同一个请求因为某些原因被多次提交。导致这个情况会有几种场景:(本质上:多次请求)
1)微服务场景,在我们传统应用架构中调用接口,要么成功,要么失败。但是在微服务架构下,会有第三个情况【未知】,也就是超时。如果超时了,微服务框架会进行重试。
2)用户交互的时候多次点击。如:快速点击按钮多次。
3)MQ消息中间件,消息重复消费。
4)第三方平台的接口(如:支付成功回调接口),因为异常也会导致多次异步回调。
5)其他中间件/应用服务根据自身的特性,也有可能进行重试。
2.2接口幂等
接口的幂等性实际上就是 接口可重复调用,在调用方多次调用的情况下,接口最终得到的结果是一致的。更准确的讲:多次调用对系统的产生的影响是一样的,即对资源的作用是一样的,但是返回值允许不同。
2.3幂等业务场景举例
场景1:支付场景
1.一个订单创建接口,第一次调用超时了,然后调用方重试了一次
2.在订单创建时,我们需要去扣减库存,这时接口发生了超时,调用方重试了一次
3.当这笔订单开始支付,在支付请求发出之后,在服务端发生了扣钱操作,接口响应超时了,调用方重试了一次
4.一个订单状态更新接口,调用方连续发送了两个消息,一个是已创建,一个是已付款。但是你先接收到已付款,然后又接收到了已创建
5.在支付完成订单之后,需要发送一条短信,当一台机器接收到短信发送的消息之后,处理较慢。消息中间件又把消息投递给另外一台机器处理
场景2:一键三连
小破站有一个一键三连的功能,长按可以对up主进行激励,每个人对每个视频只有一个一键三连的机会。就算再喜欢某个视频,多次操作,也只能有一键三连一次。
场景3:统计DAU/MAU
DAU/MAU,又叫日活/月活,是用于反映网站、互联网应用或网络游戏的运营情况的统计指标。所以一个用户当天或者当月登录多次(或者达到某种活跃用户判断机制多次),也只能看作一个活跃用户,不能重复计算。
2.4CRUD与幂等
有些接口可以天然的实现幂等性,比如查询接口,对于查询来说,查询一次和多次,对于系统来说,没有任何影响,查出的结果也是一样,而其他功能,例如:增加、更新、删除都要保证幂等性。
以user表举例
1、查询,select * from user where xxx,不会对数据产生任何变化,具备幂等性
2、新增,insert into user(userid, name) values(1, ‘a’)
如 userid 为唯一主键,即重复操作上面的业务,只会插入一条用户数据,具备幂等性
如 userid 不是主键,可以重复,那上面业务多次操作,数据都会新增多条,不具备幂等性
3、修改,区分直接赋值和计算赋值
直接赋值,update user set point = 20 where userid = 1,不管执行多少次,point都一样,具备幂等性
计算赋值,update user set point = point + 20 where userid = 1,每次操作 point 数据都不一样,不具备幂等性
4、删除,delete from user where userid = 1,多次操作,结果一样,具备幂等性
因此,我们可以得出,没有唯一主键约束的数据,和修改计算赋值数据的操作都不具备幂等性 。
同理扩展到请求类型get、put、post和delete
1.get:只是查询,安全和幂等。就像数据库select操作一样,没有副作用。进行多次的结果都一样。
2.put:发送数据改变内容,幂等。就像update一样,但是不会增加。
3.post:发送数据,改变种类,就像insert一样,也可请求资源(非幂等)
4.delete:删除某个资源 就像数据库的delete,幂等。
3.解决方案
3.1token + redis机制
token + redis 的幂等方案,适用于绝大部分场景。主要思想:
token作为请求的唯一性标示
redis作为存储token的数据库
每次请求先去redis查看token是否存在
不存在,将返回结果缓存到redis
存在,直接返回缓存结果
设置缓存有效期
服务端提供发送token的接口,业务调用接口前先获取token,然后调用业务接口请求时,把token携带过去,服务器判断token是否存在redis中,存在表示第一次请求,可以继续执行业务,执行业务完成后,最后需要把redis中的token删除。
3.2乐观锁机制
乐观锁这里解决了计算赋值型的修改场景
update user set point = #{point}+ 20, version = #{version}+ 1 where userid=#{userid} and version=#{version}
加上了版本号后,就让此计算赋值型业务,具备了幂等性
缺点:就是在操作业务前,需要先查询出当前的version版本,version实例如下:
- 多版本控制
这种方法适合在更新的场景中,比如我们要更新商品的名字,这时我们就可以在更新的接口中增加一个版本号,来做幂等
boolean updateGoodsName(int id,String newName,int version);
在实现时可以如下
update goods set name=#{newName},version=#{version} where id=#{id} and version<${version}
- 状态机控制
这种方法适合在有状态机流转的情况下,比如就会订单的创建和付款,订单的付款肯定是在之前,这时我们可以通过在设计状态字段时,使用int类型,并且通过值类型的大小来做幂等,比如订单的创建为0,付款成功为10,付款失败为-1。
在做状态机更新时,我们就这可以这样控制
update `order` set status=#{status} where id=#{id} and status<#{status}
3.3唯一主键机制
唯一主键机制就是根据业务的操作和内容生成一个全局唯一的主键ID,在执行操作前先根据这个全局唯一的主键ID是否存在,来判断这个操作是否已经执行。如果不存在则把全局ID,存储到存储系统中,比如数据库、redis等。如果存在则表示该方法已经执行。
从工程的角度来说,使用全局ID做幂等可以作为一个业务的基础的微服务存在,在很多的微服务中都会用到这样的服务,在每个微服务中都完成这样的功能,会存在工作量重复。另外打造一个高可靠的幂等服务还需要考虑很多问题,比如一台机器虽然把全局唯一的主键ID先写入了存储,但是在写入之后挂了,这就需要引入全局唯一的主键ID的超时机制。使用全局唯一ID是一个通用方案,可以支持插入、更新、删除业务操作。但是这个方案看起来很美但是实现起来比较麻烦。
总而言之,这个机制是利用了数据库的主键唯一约束的特性,解决了在insert场景时幂等问题。但主键的要求不是自增的主键,这样就需要业务生成全局唯一的主键 ID来解决。
如果是分库分表场景下,路由规则要保证相同请求下,落点在同一个数据库和同一表中,要不然数据库主键约束就不起效果了,因为是不同的数据库和表主键不相关。
因为对主键有一定的要求,这个方案就跟业务有点耦合了,无法用自增主键了。
3.4去重表机制
这种方法适用于在业务中有唯一标的插入场景中,比如在以上的支付场景中,如果一个订单只会支付一次,所以订单ID可以作为唯一标识。这时,我们就可以建一张去重表,并且把唯一标识作为唯一索引,在我们实现时,把创建支付单据和写入去去重表,放在一个事务中,如果重复创建,数据库会抛出唯一约束异常,操作就会回滚。
简单来说,就是 把唯一主键插入去重表,再进行业务操作,且他们在同一个事务中。这个保证了重复请求时,因为去重表有唯一约束,导致请求失败,避免了幂等问题。
这里要注意的是,去重表和业务表应该在同一库中,这样就保证了在同一个事务,即使业务操作失败了,也会把去重表的数据回滚。这个很好的保证了数据一致性。
这个方案也是比较常用的,去重表是跟业务无关的,很多业务可以共用同一个去重表,只要规划好唯一主键就行了。
3.5门票机制
支付场景:单次支付请求,也就是直接支付了,不需要额外的数据库操作了,这个时候发起异步请求创建一个唯一的ticketId,就是门票,这张门票只能使用一次就作废。
具体步骤:
1.异步请求获取门票
2.调用支付,传入门票
3.根据门票ID查询此次操作是否存在,如果存在则表示该操作已经执行过,直接返回结果;如果不存在,支付扣款,保存结果
4.返回结果到客户端
如果步骤4通信失败,用户再次发起请求,那么最终结果还是一样的.