文章目录
一、什么是幂等性
接口幂等性就是用户对于同一操作发起的一次请求或者多次请求的结果是一致的,不会因 为多次点击而产生了副作用;比如说支付场景,用户购买了商品支付扣款成功,但是返回结 果的时候网络异常,此时钱已经扣了,用户再次点击按钮,此时会进行第二次扣款,返回结 果成功,用户查询余额返发现多扣钱了,流水记录也变成了两条...,这就没有保证接口 的幂等性。
二、哪些情况需要防止
用户多次点击按钮
用户页面回退再次提交
mq重复消费、重试
微服务互相调用,由于网络问题,导致请求失败。feign 触发重试机制
其他业务情况
三、什么情况下需要幂等
以 SQL 为例,有些操作是天然幂等的。
SELECT * FROM table WHER id=?,无论执行多少次都不会改变状态,是天然的幂等。
UPDATE tab1 SET col1=1 WHERE col2=2,无论执行成功多少次状态都是一致的,也是幂等操作。
delete from user where userid=1,多次操作,结果一样,具备幂等性
insert into user(userid,name) values(1,‘a’) 如 userid 为唯一主键,即重复操作上面的业务,只 会插入一条用户数据,具备幂等性。
UPDATE tab1 SET col1=col1+1 WHERE col2=2,每次执行的结果都会发生变化,不是幂等的。
insert into user(userid,name) values(1,‘a’) 如 userid 不是主键,可以重复,那上面业务多次操 作,数据都会新增多条,不具备幂等性。
读和写请求都需要做幂等吗?
读请求不需要做幂等(因为读请求不会对数据发生改变)。
写请求需要做幂等(对数据发生改变了就根据需要做幂等)。
四、幂等解决方案
1、token 机制
1、服务端提供了发送 token 的接口。我们在分析业务的时候,哪些业务是存在幂等问题的, 就必须在执行业务前,先去获取 token,服务器会把 token 保存到 redis 中。
2、然后调用业务接口请求时,把 token 携带过去,一般放在请求头部。
3、服务器判断 token 是否存在 redis 中,存在表示第一次请求,然后删除 token,继续执行业 务。
4、如果判断 token 不存在 redis 中,就表示是重复操作,直接返回重复标记给 client,这样 就保证了业务代码,不被重复执行。
危险性:
1、先删除 token 还是后删除 token;
(1) 先删除可能导致,业务确实没有执行,重试还带上之前 token,由于防重设计导致, 请求还是不能执行。
(2) 后删除可能导致,业务处理成功,但是服务闪断,出现超时,没有删除 token,别 人继续重试,导致业务被执行两边
(3) 我们最好设计为先删除 token,如果业务调用失败,就重新获取 token 再次请求。
2、Token 获取、比较和删除必须是原子性
(1) redis.get(token) 、token.equals、redis.del(token)如果这两个操作不是原子,可能导 致,高并发下,都 get 到同样的数据,判断都成功,继续业务并发执行
(2) 可以在 redis 使用 lua 脚本完成这个操作
if redis.call('get', KEYS[1]) == ARGV[1] then return redis.call('del', KEYS[1]) else return 0 end
第一步,先获取token:
第二步,做具体业务:
防重逻辑,token用完即删:
2、各种锁机制
(1)数据库悲观锁
select * from xxxx where id = 1 for update; 悲观锁使用时一般伴随事务一起使用,数据锁定时间可能会很长,需要根据实际情况选用。 另外要注意的是,id 字段一定是主键或者唯一索引,而且字段值一定要存在
,不然可能造成锁表的结果,处理起来会 非常麻烦。
(2)数据库乐观锁
这种方法适合在更新的场景中,
update t_goods set count = count -1 , version = version + 1 where good_id=2 and version = 1 根据 version 版本,也就是在操作库存前先获取当前商品的 version 版本号,然后操作的时候 带上此 version 号。我们梳理下,我们第一次操作库存时,得到 version 为 1,调用库存服务 version 变成了 2;但返回给订单服务出现了问题,订单服务又一次发起调用库存服务,当订 单服务传如的 version 还是 1,再执行上面的 sql 语句时,就不会执行;因为 version 已经变 为 2 了,where 条件就不成立。这样就保证了不管调用几次,只会真正的处理一次。 乐观锁主要使用于处理读多写少的问题。
(3)唯一索引(兜底)
绝大数情况下,为了防止重复数据的产生,我们都会在表中加唯一索引
,这是一个非常简单,并且有效的方案。
选择语言加了唯一索引之后,第一次清求数据可以插入成功。但后面的相同请求,插入数据时会报 Duplicate entry ’002' for key’ order.un_code
异常,表示唯一索引有冲突。
虽说抛异常对数据来说没有影响,不会造成错误数据。但是为了保证接口幕等性,我们需要对该异常进行捕获,然后返回成功。
如果是 java 程序需要捕获: Duplicatekeyxception
异常,如果使用了 spring框架还需要捕获:MysqlIntegrityConstraintViolationException
异常。
但是如果加了唯一索引,就
很难使用逻辑删除
。
解决方式一:提升is_delete列的取值范围,当is_delete=0时表示记录有效,当is_delete>0时表示记录被删除,在删除记录时将is_delete值设置为不同数值,只要确保相同order_id的记录使用不同数值即可(很多表都使用自增主键,可以取自增主键的值来作为is_delete值)。
解决方式二:新增列dex_xid来保持方式一中is_delete的原有取值范围,当is_delete=0时设置order_rid=0,当is_delete=1时设需order_rid为任意非0值,只要确保相同order_id的记录使用不同值即可(同样建议参照自增主键值来设置,或者uuid),然后对(order_id,yn, order_rid)建唯一素引。
(4)业务层分布式锁
如果多个机器可能在同一时间同时处理相同的数据,比如多台机器定时任务都拿到了相同数 据处理,我们就可以加分布式锁,锁定此数据,处理完成后释放锁。获取到锁的必须先判断 这个数据是否被处理过。
使用redis或者zookeeper。
3、各种唯一约束
1、数据库唯一约束
插入数据,应该按照唯一索引进行插入,比如订单号,相同的订单就不可能有两条记录插入。 我们在数据库层面防止重复。
这个机制是利用了数据库的主键唯一约束的特性,解决了在 insert 场景时幂等问题。但主键 的要求不是自增的主键,这样就需要业务生成全局唯一的主键。 如果是分库分表场景下,路由规则要保证相同请求下,落地在同一个数据库和同一表中,要 不然数据库主键约束就不起效果了,因为是不同的数据库和表主键不相关。
2、数据库使用原子操作:没有该数据就新增,有该数据就修改
数据库没有就新增,有就修改
3、redis set 防重
很多数据需要处理,只能被处理一次,比如我们可以计算数据的 MD5 将其放入 redis 的 set, 每次处理数据,先看这个 MD5 是否已经存在,存在就不处理。
4、防重表
有时候表中并非所有的场景都不允许产生重复的数据,只有某些特定场景才不允许。这时候,直接在表中加唯一索引,显然是不太合适的。
针对这种情况,我们可以通过 建防重表 来解决问题。
该表可以只包含两个字段:id 和 唯一索引
,唯一索引可以是多个字段比如:name、code等组合起来的唯一标识,例如:zhangsan 0001。
使用订单号 orderNo 做为去重表的唯一索引,把唯一索引插入去重表,再进行业务操作,且 他们在同一个事务中。这个保证了重复请求时,因为去重表有唯一约束,导致请求失败,避 免了幂等问题。这里要注意的是,去重表和业务表应该在同一库中,这样就保证了在同一个 事务,即使业务操作失败了,也会把去重表的数据回滚。这个很好的保证了数据一致性。
之前说的 redis 防重也算
5、全局请求唯一 id
调用接口时,生成一个唯一 id,redis 将数据保存到集合中(去重),存在即处理过。
可以使用 nginx 设置每一个请求的唯一 id;
proxy_set_header X-Request-Id $request_id;
6、前端加loading(不保险)
前端按钮提交点击之后,置灰loading不允许重复点击第二次,可以在前端层面做幂等。
但是这种方式防君子不防小人,而且卡顿操作可能会造成置灰没生效,还是得后端实现幂等操作。
7、先select再insert/update(不保险)
并发场景下,因为有竞态条件
,有可能select操作多线程同时进行,导致查询结果滞后。
通常配合分布式锁使用,或者用户级别上低并发情况下选择性使用。
8、根据状态机(类似乐观锁)
很多时候业务表是有状态的,比如订单表中有:1-下单、2-已支付、3-完成、4-撤销
等状态。如果这些状态的值是有规律的,按照业务节点正好是从小到大,我们就能通过它来保证接口的幂等性。
假如id=123的订单状态是 已支付,现在要变成完成 状态。
update order set status=3 where id=123 and status=2
第一次请求时,该订单的状态是已支付,值是2,所以该update 语句可以正常更新数据,sql执行结果的影响行数是1,订单状态变成了3。
后面有相同的请求过来,再执行相同的sql时,由于订单状态变成了3,再用 status=2 作为条件,无法查询出需要更新的数据,所以最终sql执行结果的影响行数是0,即不会真正的更新数据。但为了保证接口幕等性,影响行数是0时,接口也可以直接返回成功。