目录
一、什么是幂等性
接口幂等性就是用户对于同一操作发起的一次请求或者多次请求的结果是一致的
,不会因为多次点击而产生了副作用;比如说支付场景,用户购买了商品支付扣款成功,但是返回结果的时候网络异常,此时钱已经扣了,用户再次点击按钮,此时会进行第二次扣款,返回结果成功,用户查询余额返发现多扣钱了,流水记录也变成了两条...,
这就没有保证接口的幂等性。
二、哪些情况需要防止
- 用户多次点击按钮
- 用户页面回退再次提交
- 微服务互相调用,由于网络问题,导致请求失败。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 机制
- 服务端提供了发送 token 的接口。我们在分析业务的时候,哪些业务是存在幂等问题的,就必须在执行业务前,先去获取 token,服务器会把 token 保存到 redis 中。
- 然后调用业务接口请求时,把 token 携带过去,一般放在请求头部。
- 服务器判断 token 是否存在 redis 中,存在表示第一次请求,然后删除 token,继续执行业务。
- 如果判断 token 不存在 redis 中,就表示是重复操作,直接返回重复标记给 client,这样就保证了业务代码,不被重复执行。
接下来考虑提交订单后是先删除token还是先执行业务呢?
分析:
后删除token
① 后删除可能导致,业务处理成功,但是服务闪断,出现超时,没有删除 token,别人继续重试,导致业务被执行两遍
② 执行业务期间,因为token还未删除,此时如果顾客按了两次提交订单,那么就导致执行两次了。
先删除token
① 代码格式大概如下
if(serverToken == redisToken) {
del(serverToken)
执行业务
}
② 如果考虑极限一点,那么仍然可能出现客户连续点击提交订单,同时过判断,执行多次业务
但是显而易见的是第二种的可能性比较小,危险性较低,所以选择先删除Token,接下来考虑优化
我们可以将判断token是否存在redis中和删除redis中token这三个步骤捆绑在一起,也就是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
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)业务层分布式锁
如果多个机器可能在同一时间同时处理相同的数据,比如多台机器定时任务都拿到了相同数据处理,我们就可以加分布式锁,锁定此数据,处理完成后释放锁。获取到锁的必须先判断这个数据是否被处理过
3、各种唯一约束
(1)数据库唯一约束
插入数据,应该按照唯一索引进行插入,比如订单号,相同的订单就不可能有两条记录插入。我们在数据库层面防止重复。这个机制是利用了数据库的主键唯一约束的特性,解决了在 insert 场景时幂等问题。但主键的要求不是自增的主键,这样就需要业务生成全局唯一的主键。如果是分库分表场景下,路由规则要保证相同请求下,落地在同一个数据库和同一表中,要不然数据库主键约束就不起效果了,因为是不同的数据库和表主键不相关。
(2)redis set 防重
很多数据需要处理,只能被处理一次,比如我们可以计算数据的
MD5
将其放入
redis
的
set
,每次处理数据,先看这个 MD5
是否已经存在,存在就不处理。
4、防重表
使用订单号
orderNo
作为去重表的唯一索引,把唯一索引插入去重表,再进行业务操作,且他们在同一个事务中。这个保证了重复请求时,因为去重表有唯一约束,导致请求失败,避免了幂等问题。这里要注意的是,去重表和业务表应该在同一库中,这样就保证了在同一个事务,即使业务操作失败了,也会把去重表的数据回滚。这个很好的保证了数据一致性。
之前说的 redis 防重也算’
5、全局请求唯一 id
调用接口时,生成一个唯一
id
,
redis
将数据保存到集合中(去重),存在即处理过。可以使用 nginx 设置每一个请求的唯一 id;
proxy_set_header X-Request-Id $request_id;
案例:订单提交
这里在进入订单确认页的时候我们就给客户端发送一个token并存放进redis,然后在提交订单的时候携带token发送到服务端,服务端从redis中取出token和客户端发送过来的进行验证,如果一样就可以创建订单。当然这里从redis中取出token、判断是否一致、和删除token这三个操作需要原子性。
① 在页面代码中携带订单应该提交的相关信息
<form action="http://order.gulimall.com/submitOrder" method="post">
<input type="hidden" name="addrId" id="addrIdInput"/>
<input type="hidden" name="payPrice" id="payPriceInput"/>
<input type="hidden" name="orderToken" th:value="${orderConfirmData.Token}"/>
<button class="tijiao" type="submit">提交订单</button>
</form>
② 设置值
function getFare(addrId) {
$("#addrIdInput").val(addrId)
$.get("http://gulimall.com/api/ware/wareinfo/fare?addrId=" + addrId, function (data) {
console.log(data);
$("#fare").text(data.data.fare)
var total = [[${orderConfirmData.total}]]
// 设置应付价格
var payPrice = data.data.fare * 1 + total * 1
$("#payPriceEle").text(payPrice)
$("#payPriceInput").val(payPrice)
$("#recieveAddressEle").text(data.data.address.provice + "" + data.data.address.detailAddress);
$("#recieverEle").text(data.data.address.name)
})
}
③ 首先在数据库层次保证订单的唯一性,为订单编号加上唯一性约束
④ 后端订单接口
@PostMapping("/submitOrder")
public String submitOrder(OrderSubmitVo vo) {
// 下单,创建订单,校验令牌,检验价格,锁库存
SubmitOrderResponseVo responseVo = orderService.submitOrder(vo);
System.out.println("订单提价的数据。。。" + vo);
if(responseVo.getCode() == 0) {
// 成功显示支付页面
return "pay";
} else {
// 失败返回订单确认页面
return "redirect:http://order.gulimall.com/toTrade";
}
}
⑤ 保证从redis获取token和比较、删除token是原子性的
@Override
public SubmitOrderResponseVo submitOrder(OrderSubmitVo vo) {
submitVoThreadLocal.set(vo);
MemberResVo memberResVo = LoginUserInterceptor.loginUser.get();
SubmitOrderResponseVo response = new SubmitOrderResponseVo();
String redisToken = redisTemplate.opsForValue().get(OrderConstant.USER_ORDER_TOKEN_PREFIX + memberResVo.getId());
String orderToken = vo.getOrderToken();
// 成功返回1 失败返回0
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<Long>(script, Long.class), Arrays.asList(OrderConstant.USER_ORDER_TOKEN_PREFIX + memberResVo.getId()), orderToken);
if(result == 0L) {
// 验证失败
response.setCode(1);
return response;
} else {
// 下单,创建订单,校验令牌,检验价格,锁库存
// 1、创建订单,订单项等信息
OrderCreateTo order = createOrder();
// 2、验价
BigDecimal payAmount = order.getOrder().getPayAmount();
if(Math.abs(payAmount.subtract(vo.getPayPrice()).doubleValue()) < 0.01) {
// 金额对比成功后保存订单
saveOrder(order);
} else {
response.setCode(2); // 金额对比失败
return response;
}
}
// 逻辑如下,但是其实我们需要保证对比和删除是原子性的,因此使用redisTemplate的脚本
// if(orderToken != null && orderToken.equals(redisToken) ) {
// // 检验成功
// redisTemplate.delete(OrderConstant.USER_ORDER_TOKEN_PREFIX + memberResVo.getId());
// } else {
// // 失败
// }
response.setCode(1);
return response;
}