一止长渊

接口幂等性

N 人看过
字数:2.1k字 | 预计阅读时长:7分钟

1.定义:

用户对于统一操作发起的一次请求或者多次请求的结果是一致的;例如支付场景用户购买商品扣款,网络延迟用户再次点击也不会重复扣款。
就好比 1 的幂,无论是 1 的 100 次幂,还是 1 的 1000 次幂都是 1

2.哪些情况需要防止:

用户多次点击按钮
用户页面回退后再次提交
微服务相互调用,由于网络问题,导致请求失败,feign 触发重试机制。 例如订单下达到库存模块减库存的操作不可重复进行。
其他业务情况

3.什么情况需要幂等:

以 SQL 为例,有些操作是天然幂等的。
例如查询 SELECT * FROM table WHER id=?,
将某已记录更新 UPDATE tab1 SET col1=1 WHERE col2=2
根据主键删除某一条记录 delete from user where userid=1
携带主键插入一条记录,userid 为指定的主键 insert into user(userid,name) values(1,’a’)
而某些 SQL 不是幂等的 UPDATE tab1 SET col1=col1+1 WHERE col2=2,每次执行的结果都会发生变化,不是幂等的。
insert into user(userid,name) values(1,’a’) 如 userid 不是主键,可以重复,那上面业务多次操作,数据都会新增多条,不具备幂等性。

例如订单模块:
除了自增主键 id 外,订单号 order_sn 字段需要做成唯一约束,每次插入记录都指定订单号,在数据库级别字段的唯一约束保证同一订单号不会频繁插入一条记录。
截屏2021-06-16 15.35.58.png

4.幂等解决方案

1、token 机制(令牌机制)

例如 12306 锁定作为通过验证码验证,发送给后端通过验证码验证正确后就通过,验证码通过服务器就会把该验证码就会删除。
流程:
1)服务端提供了发送 token 的接口,在分析业务的时候,哪些业务是存在幂等性的,就必须在业务执行之前,先去获取 token,服务器会把 token 保存在 redis 中
2)然后调用业务接口请求时,会将 token 放在请求头部一同携带过去
3)服务器端判断 token 是否存在 redis 中,存在表示第一次请求,然后删除 token,继续执行业务
4)如果判断 token 不存在 redis 中,就表示为重复操作,直接返回重复标记给 client,这样就保证了业务代码不会被重复执行。
**
危险性:令牌从 redis 删除的时机(是验证通过后就删令牌还是在业务执行完成后再删除令牌)

  • 业务执行完成后再删除令牌

以订单模块,如果用户点击了创建订单,携带 token 的订单发送给后台,如果用户网络延迟连续快速点击了两次提交订单动作,两次提交订单的请求都携带了同一个 token 给了后台,由于是业务执行完成后再删除令牌,由于业务的耗时,可能第一个请求还未来得及完成以删除令牌,第二个请求就已经通过令牌在执行了,这样也会造成数据库插入了两条记录。
就好比高速两条方向路,一头是服务器,一头是 redis,redis 到服务器的单向高速通畅(用来获取令牌),服务器到 redis 的单向高速堵塞(用户删除令牌),就会发生第一个请求删除令牌操作堵在半路,第二个请求就已经获得了还未删除的令牌并校验通过执行业务逻辑了。

  • 业务执行前验证通过后立即删除令牌

token = redis.get()
if(token == validToken){
del token
执行业务逻辑
}
令牌在分布式系统下存在 redis 中,如果用户两次重复请求很快,第一次订单请求删除 redis token 有延迟令牌还没来得及删除,第二个订单请求就进入可以通过 get 获取到还未删除的令牌,也会进入业务逻辑破坏了幂等性。
原因在于:获取令牌、校对令牌、删除令牌应该是一个原子性操作,不应该被分隔,避免中间分布操作网络延时破坏了幂等性。可以使用 redisson 分布式锁中的 lua 脚本,保证原子性操作

if redis.call('get', KEYS[1]) == ARGV[1] then return redis.call('del', KEYS[1]) else return 0 end

2、各种锁机制

1)数据库悲观锁
select * from xxx where id = 1 for update; (先看再更新 select 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)业务层分布式锁
例如用户订单支付后未付款,该订单就会锁住库存,超过指定的时间后,该失败订单就会将占的库存释放掉。例如多台机器在同一时间都去处理相同的失败订单,多台机器的定时任务都拿到了相同的订单号进行释放库存操作,这样我们可以在订单号加分布式锁,只有拿到了该订单号的分布式锁的机器才可以释放该订单的库存,获取锁必须先判断这个数据是否被处理过,避免多条机器都拿到相同的订单号重复释放内存

3、各种唯一约束

1)数据库唯一约束
插入数据,应该按照唯一索引进行插入,比如订单号,相同的订单就不可能有两条记录插入。 我们在数据库层面防止重复。这个机制是利用了数据库的主键唯一约束的特性,解决了在 insert 场景时幂等问题。但主键 的要求不是自增的主键,这样就需要业务生成全局唯一的主键。

2)redis set 防重
很多数据需要处理,只能被处理一次,比如我们可以计算数据的 MD5 将其放入 redis 的 set(MD5 同一个数据是相同的), 每次处理数据,先看这个 MD5 是否已经存在,存在就不处理。

4、防重表

使用订单号 orderNo 作为去重表的唯一索引,把所以索引插入去重表中,再进行业务操作。这个保证了重复请求时,因为去重表有唯一约束,导致请求失败,避 免了幂等问题。这里要注意的是,去重表和业务表应该在同一库中,这样就保证了在同一个 事务,即使业务操作失败了,也会把去重表的数据回滚。这个很好的保证了数据一致性。

5、全局请求唯一 id

调用接口时,生成一个唯一 id,redis 将数据保存到集合中(去重),存在即处理过。 可以使用 nginx 设置每一个请求的唯一 id;
proxy_set_header X-Request-Id $request_id;
例如 Fegin 之间调用失败,是通过重试机制将之前的老请求再次发送,这里就可以利用 nginx 为每个请求添加一个唯一 id,就可以防止这个请求被重复处理,此外也可以做链路追踪

本作品采用 知识共享署名-非商业性使用-禁止演绎 4.0 国际许可协议 (CC BY-NC-ND 4.0) 进行许可。