幂等性问题
什么是幂等性
接口幂等性就是用户对同一操作发起的一次请求和多次请求结果是一致的 ,不会因为多次点击而产生了副作用,比如支付场景,用户购买了商品,支付扣款成功,但是返回结果的时候出现了网络异常,此时钱已经扣了,用户再次点击按钮,此时就会进行第二次扣款,返回结果成功,用户查询余额发现多扣钱了,流水记录也变成了两条。这就没有保证接口幂等性
哪些情况需要防止幂等性问题:
用户多次点击按钮
用户页面回退再次提交
微服务互相调用,由于网络问题,导致请求失败,feign 触发重试机制导致发送重复请求
以 SQL 为例,有些操作是天然幂等 的:
SELECT * FROM table WHERE id =?
无论执行多少次都不会改变状态
UPDATE tab1 SET col1=1 WHERE col2=2
无论执行多少次状态都是一致的
DELETE FROM user WHERE userid=1
无论执行多少次状态都是一致的
INSERT INTO user(userid, name) VALUES(1, 'a' )
若 userid
为唯一索引 UNIQUE
,则无论执行多少次,都只会插入一条用户记录
不是幂等的操作:
UPDATE tab1 SET col1=col1+1 WHERE col2=2
每次执行的结果都会发生变化,不是幂等的
INSERT INTO user(userid, name) VALUES(1, 'a' )
若 userid
不是唯一索引,则执行多次会重复插入相同的用户记录,不具备幂等性。
幂等性问题解决方案
1. token(令牌)机制
分析一个问题:当用户在前端连续多次点击【提交订单】,会发出多个创建订单的请求 /submitOrder
。这会导致同一个订单被创建多次,从而不满足幂等性 。因此我们必须要解决幂等性问题,使得一个用户只能同时处理一个订单,其他订单请求都失效。体现在 Redis 里就是同一时刻只能保存唯一的一个订单的 uuid(order:token:userId - uuid
)。
解决方案:使用 token(令牌)机制 。在购物车页面点击【去结算】时,就在 /toTrade
业务代码中为当前用户生成一个防重令牌 (防止重复提交),并保存到 Redis 中(格式为 order:token:userId - uuid
)。同时将该令牌保存到 OrderConfirmVo
对象中返回给前端保存。
代码:
1 2 3 4 5 6 String token = UUID.randomUUID().toString().replace("-" , "" ); redisTemplate.opsForValue().set(OrderConstant.USER_ORDER_TOKEN_PREFIX + memberResponseVo.getId(), token, 30 , TimeUnit.MINUTES); confirmVo.setOrderToken(token);
这样前端页面在点击【提交订单】按钮后跳转到 /submitOrder
业务时,就会将该防重令牌携带上。此时我们只需要再从 Redis 中根据该用户 id 获取到其保存在 Redis 中的令牌,并与客户端携带上来的令牌进行比较即可。流程:
根据用户 order:token:userId
从 Redis 中获取令牌
判断客户端携带的令牌 和 Redis 中查询的令牌 是否相等
如果相等,说明验证成功,从 Redis 中删除令牌
如果不相等,说明验证失败,当前请求是重复请求(或者用户同时开两个网页提交两个订单),拒绝访问 ,直接返回
这三个步骤的代码:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 SubmitOrderResponseVo responseVo = new SubmitOrderResponseVo(); MemberResponseVo memberResponseVo = LoginInterceptor.loginUserThreadLocal.get(); String orderToken = submitVo.getOrderToken(); String redisToken = redisTemplate.opsForValue().get(OrderConstant.USER_ORDER_TOKEN_PREFIX + memberResponseVo.getId()); if (orderToken != null && orderToken.equals(redisToken)) { redisTemplate.delete(OrderConstant.USER_ORDER_TOKEN_PREFIX + memberResponseVo.getId()); } else { }
注意:这三个操作必须是原子性的 。否则高并发场景下会出现问题。因此可以使用 LUA 脚本保证这三个过程的原子性:
1 2 3 4 5 String script = "if redis.call('get', KEYS[1]) == ARGV[1] then return redis.call('del', KEYS[1]) else return 0 end" ; Long result = redisTemplate.execute(new DefaultRedisScript<>(script, Long.class), Arrays.asList(OrderConstant.USER_ORDER_TOKEN_PREFIX + memberResponseVo.getId()), orderToken);
令牌机制的两个潜在问题:
1、先删除 token 还是后删除 token:
先删除可能导致,业务确实没有执行,重试还得带上之前的 token, 由于防重设计导致,请求还是不能执行
后删除可能导致,业务处理成功,但是服务闪断,出现超时,没有删除掉token,别人继续重试,导致业务被执行两次
我们最后设计为先删除 token,如果业务调用失败,就重新获取 token 再次请求(重新跳转回去结算页面或者重新给其生成一个token,保存到redis并还给他
2、Token 获取,比较和删除必须是原子性 。可以在 redis 使用 lua 脚本完成这个操作
1 "if redis.call('get',KEYS[1]) == ARGV[1] then return redis.call('del',KEYS[1]) else return 0 end"
2. 各种锁机制
数据库悲观锁
查询时添加悲观锁:SELECT * FROM xxx WHERE id = 1 FOR UPDATE
。这样在查询时就会给该行加悲观锁,其他请求都需要阻塞等待。
悲观锁通常伴随事务 一起使用,数据锁定时间可能会很长,需要根据实际情况选择是否使用。另外需要注意的是:id 字段一定是主键或唯一索引 ,不然可能造成锁表 的结果,处理起来会非常麻烦。
数据库乐观锁
这种方法适合在更新的场景中:
1 UPDATE t_goods SET count = count - 1 , version = version + 1 WHERE good_id = 2 and version = 1
根据 version
版本,也就是在操作数据库存前先获取当前商品的 version
版本号,然后操作的时候带上 version
版本号,这样就保证了不管执行几次该语句,只会真正处理一次。乐观锁主要使用于处理读多写少的问题
业务层分布式锁
如果多个机器可能在同一时间处理相同的数据,比如多台机器定时任务拿到了相同的数据,我们就可以加分布式锁锁定此数据,处理完成后后释放锁,获取锁必须先判断这个数据是否被处理过。
3. 各种唯一约束
数据库唯一约束
插入数据时应该按照唯一索引 进行插入。比如为订单号字段 order_sn
添加唯一索引 UNIQUE
,这样相同的订单就不可能插入两次,从而实现在数据库层面上防止重复插入。同时需要业务生成全局唯一的字段值 ,以保证不会同一个订单不会重复插入到数据库中。
示例:为订单号字段 order_sn
添加唯一性索引 UNIQUE
,从而做到数据库层面的幂等性:
这个机制利用了数据库的唯一索引约束的特性,解决了插入场景的幂等性问题。如果将主键设置为唯一索引,则需要注意主键不能设置为自增的。
如果是分库分表场景下,设置的路由规则要保证:相同的请求必须路由到同一个数据库和同一表中 ,不然数据库的唯一索引约束就不起效果了。
Redis Set 防重
一些不能重复的数据可以存放到 Redis 的 Set 结构中,例如百度网盘在防止重复上传相同文件数据时的做法是:第一次保存数据时先计算文件数据的 MD5 值将其放入 Redis 的 Set 中。之后再想重复上传该文件时,先去 Redis 中查看这个 MD5 值是否已经存在,存在就代表该文件数据之前上传过了,不需要再上传了。
4. 防重表
防重不仅可以在 Redis 里做,也可以使用数据库实现。
例如:在取消订单后执行解锁库存时,一旦解锁了库存,就需要在防重表 (保存订单号和库存信息,该表设置了订单号 order_sn
为唯一索引 UNIQUE
)里插入一条数据,表示该订单已经解锁了库存。之后重复解锁时再想插入到该防重表就会报错(因为设置了订单号的唯一索引 UNIQUE
),此时就知道不能再解锁库存了。只有插入到该防重表成功,才可以执行解锁库存的业务。
防止重复解锁库存另一个思路:在工作单详情表里为每一条工作单添加一个字段,代表该工作单是否已被解锁。这样每次解锁前先查看该状态就可以得知是否仍需要解锁。
注意:去重表和业务表应该在同一个库中 ,这样就保证了在同一个事务,即使业务操作失败,也会把去重表的数据回滚 ,这个很好的保证了数据的一致性。
5. 全局请求唯一 id
前提:Feign 一旦远程调用失败后,就会不断重试发出相同请求 。可能实际上远程服务已经收到了调用请求并且执行了业务,但是因为网络延迟等因素,Feign 认为没有调用成功,从而发出了同样的请求。
为了避免这种情况的发生,我们可以在 Feign 远程调用 接口前,先生成一个唯一的 uuid
,并存入到 Redis 的 Set 结构中 (例如一个用户保存一个 Set,其内保存了该用户所有远程调用请求的 uuid
,能够保证不重复)。远程服务在执行业务前先检查 Redis 中该用户的 Set 里是否存在该 uuid
,如果存在即代表当前远程请求之前就被处理过了(该请求被重复调用了),那么就不再执行了。
注意:这种方式只适用于 Feign 的远程调用防重 。如果是前端页面发来的重复请求 ,是无法防住的 (需要使用上面的令牌机制 ),因为即使使用 Nginx 给每个前端发来的请求添加唯一 id,点击两次前端页面也会发送两个 id 不同的请求,从而无法做到防重。但是 Nginx 添加 id 的思路可以用于实现链路追踪 功能。
总结:Feign 防止重复调用可以使用这个方法。但是页面的重复提交防止不了。
在 Nginx 中为每一个请求设置一个唯一 id:
1 proxy_set_header X-Request-Id $Request_id