关于幂等的各种设计
欢迎来到我的博客:TWind的博客
我的CSDN::Thanwind-CSDN博客
我的掘金:Thanwinde 的个人主页
一.什么是幂等
幂等,指的是一种属性,带有这种属性的操作做了n次仍然和做了1次相同,就像1^1和1^100000一样
对于一个成熟的系统,无论是分布式还是单体项目,幂等都是必须要考虑到的一点,从大了说,关键模块(比如支付,交易回调)的幂等出错会直接导致资金损失降低平台信誉。往小了说,对于一些例如提交表单,查询数据,用户习惯性的多次点击会导致后端资源浪费。总而言之,幂等是一个系统要成熟的必备项,下面我将介绍各个部分的幂等以及其用途和实现方式。
二.接口幂等
接口幂等故名思意,是在接口层面进行幂等校验,从而阻断到业务的重复流量,也就是后端的“防抖”
但注意,接口幂等是面向用户的,并不负责对恶意攻击的防护
(一) 前端幂等
这里是最简单的实现,前端可以自己写一个防抖,比如只允许提交一遍,这是最简单的幂等,但是也是最不稳定的,有前端知识的人可以很轻松的绕过这一层限制,“防君子不防小人”。
(二) 前端生成Token+后端校验
这里是上一个方案的加强版,前端会在进入页面/开始流程的时候生成一个Token(比如UUID),随着请求一起发送到后端。
后端会对这个Token进行校验:有没有其他请求携带了相同的Token进来?有的话就认为是重复请求,没有的话就放行。具体实现可以JVM本地缓存(单体项目),或者是存在Redis中(分布式),然后执行完业务再删除Token。
但这里其实有个问题,如果两个请求很近,在第一个Token还没存入时第二个已经通过校验也准备存入了,就会导致重复请求,想要解决就只能加锁,引入Redis等等,但这其实是一个可忍受的一个小case,毕竟这只是接口幂等,少量的重复请求是可接受的,后面还有业务幂等来过滤重复请求。
这个和前端幂等的区别是什么?后端可以对这个Token加入更多校验(用户信息,订单id等),实现更多的逻辑,便于拓展。如果愿意,可以把这个Token扩展到整个业务作为唯一键。
(三) 后端生成Token
这个本质的逻辑和(二)相同,区别在于后端生成的Token可以有效的防止CSRF攻击,而且速度显然明显慢于前者,同样也会存在一些小问题:是先执行逻辑还是先删除Token?前者会导致有重复请求的出现,后者会导致如果业务失败Token也会被删除,但同样的,这只是接口幂等,两者其实都不严重,毕竟后面还有业务幂等来过滤重复请求。
三.业务幂等
业务幂等顾名思义,是位于业务层面的幂等,专门用来处理接口幂等无法处理的恶意攻击,偶发事件等
比如交易去重,处理回调超时等
(一) 唯一键约束
这是最简单的幂等方式,一个订单/流程/阶段有着一个唯一的id,同时在数据库里将这个字段设为UNIQUE,这样就算出现了重复的插入操作也会被这个唯一键挡住,防重表 的逻辑也是如此,只是独立出来了
但问题在于,唯一键只能挡 插入 挡不了 更新 ,所以ABA问题会发生
对于吞吐量低的业务,也可以采用先查再插的办法
先SELECT,没有数据就插入/更改,问题在于可能有多个线程一起进入了 插入/更改 业务中,需要加锁保证线程安全
但其实在一些安全性要求不高的地方,完全可以采用这种办法,简单快捷不涉及表修改,出错概率也低,完全可以让人工客服兜住
(二) 版本号与条件机制
版本号是业内最常用的一套用于处理幂等的方法,其安全性更强,能确保仅有一个请求能真正被执行
具体来说,对于每一个数据,会维护一个version,比如现在有一个新创建的订单,version为0
现在用户付了款,执行UPDATE order SET pay_type = 1 WHERE order_id = #{id} 执行成功了,但是网络发生了抖动,用户以为没付成,又付了一遍,又执行UPDATE order SET pay_type = 1 WHERE order_id = #{id} ,也成功了,好了,用户付了两遍钱,两次都成功了
要是加上版本号,执行UPDATE order SET pay_type = 1 , version = 1 WHERE order_id = #{id} ,version = 0第一次成功,第二次执行UPDATE order SET pay_type = 1 , version = 1 WHERE order_id = #{id} ,version = 0就会失败,因为版本号已经被改成了1,成功做到了幂等
这只是一个简单的例子,一般来说版本号更多用于一个处理链,每一条链都有其对应的版本号(或是上游传递),这样就能实现整个业务逻辑的幂等
条件机制的本质其实和版本号机制差不多
以超卖问题演示,如果我们先从数据库查得amount,判断库存是否充足再执行UPDATE store SET amount=amount-#{num} WHERE goods_id=#{goodsId},再高并发的情况下完全会出现扣减到负数的情况
那么,我们只需要改成UPDATE store SET amount=amount-#{num} WHERE goods_id=#{goodsId} AND amout = #{amount}就可以避免这种情况,归根到底,也就是用了amount作为版本号
(但实际上超卖问题有很多解决办法,这里只是举例,改成UPDATE store SET amount=amount-#{num} WHERE goods_id=#{goodsId} AND amout > 0会好很多)
版本号机制和条件机制都能很好的解决幂等问题,其本质是乐观锁,在流量不高的情况下运转良好,但在冲突严重的时候会导致大量请求失败导致业务成功率低
同时,其过于依赖数据库,会增大数据库的压力
(三) Redis分布式幂等
Redis具有速度快,单线程等优点,非常适合用来作缓存来降低数据库压力,同时也很适合用来做幂等
举个例子,我们可以在修改订单的时候,先看Redis中有没有idempotent:order:123456这个KV,有的话直接返回,说明已经在处理这个订单了,没有的话就存入这个KV,然后执行逻辑,实际上就是把这个KV作为锁,来控制对同一对象的重复访问
也就是SET idempotent:order:10086 1 NX EX 30
注意,这里必须设过期时间,因为不能出现死锁的情况
但是,如果业务耗时过长超过了过期时间呢?
可以考虑使用Redission的看门狗功能,会在执行命令的时候创建一个守护线程,不断为其延长过期时间
同时,如果采用了Redis集群,这里的锁其实是有风险的
Redis分片集群默认是异步更新主从的
假如在A(主节点)上加了锁,还没来得及同步到从节点就挂掉了,从节点被选举成为了新主,自然没了这个锁,重复请求就能顺利的拿到这个锁,接着执行业务
目前唯一比较成熟的避免办法是Redis官方提供的RedLock,但是性能很差,算是Redis的美中不足了
这里Redis的主要职责是防止对同一对象重复处理,在数据库前面提前拦住重复流量,就算漏了几个也能靠数据库兜住
Redis的用法还有很多,和这里的思路其实都差不多,都是利用其单线程的特点进行加锁,校验,再执行业务
四.消息幂等
消息幂等,指的是对消息队列中消息的幂等,有些时候,会往消息队列中发送重复的消息,比如:
MQ 投递消息 给消费者
消费者 执行完业务逻辑(插入数据库成功)
消费者 刚要发 ACK 给 MQ 时,网断了 或者 消费者进程挂了
MQ 等不到 ACK,以为消息没处理成功
MQ 重发 这条消息
这时候就需要对消息的消费进行幂等处理
对消息的消费也属于业务,完全可以套用业务幂等的那一套:查重表,版本号等等,非常容易解决
五.总结
目前主流的幂等策略就是上述几种,值得注意的是,幂等只是为了解决重复访问带来的重复请求,并不能一定解决高并发带来的线程安全问题,它们都属于高并发系统必备的一个防御措施