在我们的订单业务中,如何保证接口的幂等性
1、token 机制
1、服务端提供了发送 token 的接口,我们在分析业务的时候,哪些业务是存在幂等性问题的,就必须在执行业务前,先获取 token,服务器会把 token 保存到 redis 中
2、然后调用业务接口请求时, 把 token 携带过去,一般放在请求头部
3、服务器判断 token 是否存在 redis,存在表示第一次请求,然后删除 token,继续执行业务
4、如果判断 token 不存在 redis 中,就表示重复操作,直接返回重复标记给 client,这样就保证了业务代码,不被重复执行
危险性:
1、先删除 token 还是后删除 token:
先删除可能导致,业务确实没有执行,重试还得带上之前的 token, 由于防重设计导致,请求还是不能执行
后删除可能导致,业务处理成功,但是服务闪断,出现超时,没有删除掉token,别人继续重试,导致业务被执行两次
我们最后设计为先删除 token,如果业务调用失败,就重新获取 token 再次请求
2、Token 获取,比较 和删除 必须是原子性
redis.get(token),token.equals、redis.del(token),如果说这两个操作都不是原子,可能导致,在高并发下,都 get 同样的数据,判断都成功,继续业务并发执行
可以在 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 xxx where id = 1 for update;
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防重也算token机制
5、全局请求唯一id
调用接口时,生成一个唯一的id,redis 将数据保存到集合中(去重),存在即处理过,可以使用 nginx 设置每一个请求一个唯一id
proxy_set_header X-Request-Id $Request_id。
我们如下这里演示的是token机制
在点击了去结算,跳转到订单结算页面时,要在后端生成一个验证幂等性的防重令牌token回传到前端,之后提交订单的时候将其传递到后端进行校验。
/**
* 去结算
* 给订单确认ye返回数据
*/
@Override
public OrderConfirmVo confirmOrder() throws ExecutionException, InterruptedException {
//要返回的大对象
OrderConfirmVo confirmVo = new OrderConfirmVo();
//在TreadLocal中获取用户
MemberResVo memberResVo = LoginUserInterceptor.loginUser.get();
//Feign + 异步任务 需要共享RequestAttributes 每个任务都要setRequestAttributes()
RequestAttributes requestAttributes = RequestContextHolder.getRequestAttributes();
CompletableFuture<Void> getAddressFuture = CompletableFuture.runAsync(() -> {
//每一个线程都要共享之前的请求数据
RequestContextHolder.setRequestAttributes(requestAttributes);
//1 远程查询大对象的第一个属性 收货地址列表
List<MemberAddressVo> address = memberFeignService.getAddress(memberResVo.getId());
confirmVo.setAddress(address);
//Feign在远程调用之前要构造请求,调用很多的拦截器
}, executor);
CompletableFuture<Void> cartFuture = CompletableFuture.runAsync(() -> {
//每一个线程都要共享之前的请求数据
RequestContextHolder.setRequestAttributes(requestAttributes);
//2 远程查询大对象的第二个属性 所有购物项
List<OrderItemVo> items = cartFeignService.currentUserItems();
confirmVo.setItems(items);
}, executor).thenRunAsync(() -> {
List<OrderItemVo> items = confirmVo.getItems();
List<Long> ids = items.stream().map((item) -> {
return item.getSkuId();
}).collect(Collectors.toList());
//远程查看库存
R r = wareFeignService.getSkuHasStock(ids);
List<SkuStockVo> skuStockVos = r.getData(new TypeReference<List<SkuStockVo>>() {
});
if (skuStockVos != null) {
Map<Long, Boolean> booleanMap = skuStockVos.stream().collect(Collectors.toMap(SkuStockVo::getSkuId, SkuStockVo::getHasStock));
confirmVo.setStocks(booleanMap);
}
}, executor);
//3 远程查询用户积分
Integer integration = memberResVo.getIntegration();
confirmVo.setIntegration(integration);
//4 其他的数据自动计算
//TODO 5 防重令牌
String token = UUID.randomUUID().toString().replace("-", "");
//给服务器一个 并指定过期时间
redisTemplate.opsForValue().set(OrderConstant.USER_ORDER_TOKEN_PREFIX + memberResVo.getId(), token, 30, TimeUnit.MINUTES);
//给页面一个
confirmVo.setOrderToken(token);
CompletableFuture.allOf(getAddressFuture, cartFuture).get();
return confirmVo;
}
当我们点击提交订单的时候就会生成一笔订单,之后再跳转到支付页面。
在生成订单的时候,未来保证接口的幂等性,避免订单重复提交,我们需要将防重令牌token传递过来进行校验,这里验证原子令牌的时候使用了一段脚本保证对比与删除是原子性的。类似与一把分布式锁,不过这里
//1、首先验证令牌
//0失败 - 1成功 | 不存在0 存在 删除?1:0
String script = “if redis.call(‘get’,KEYS[1]) == ARGV[1] then return redis.call(‘del’,KEYS[1]) else return 0 end”;
/**
* 提交订单 去支付
*
* @Transactional 是一种本地事物,在分布式系统中,只能控制住自己的回滚,控制不了其他服务的回滚
* 分布式事物 最大的原因是 网络问题+分布式机器。
* (isolation = Isolation.REPEATABLE_READ) MySql默认隔离级别 - 可重复读
*/
//分布式事务 全局事务
//@GlobalTransactional 不用
@Transactional
@Override
public SubmitOrderResponseVo submitOrder(OrderSubmitVo vo) {
//共享前端页面传过来的vo
confirmVoThreadLocal.set(vo);
//要返回到大对象
SubmitOrderResponseVo responseVo = new SubmitOrderResponseVo();
//登录的用户
MemberResVo memberResVo = LoginUserInterceptor.loginUser.get();
responseVo.setCode(0);
//1、首先验证令牌
//0失败 - 1成功 | 不存在0 存在 删除?1:0
String script = "if redis.call('get',KEYS[1]) == ARGV[1] then return redis.call('del',KEYS[1]) else return 0 end";
String orderToken = vo.getOrderToken();
//原子验证令牌 和 删除令牌
Long result = redisTemplate.execute(new DefaultRedisScript<Long>(script, Long.class),
Arrays.asList(OrderConstant.USER_ORDER_TOKEN_PREFIX + memberResVo.getId()),
orderToken);
if (result == 0) {
//令牌验证失败 非0失败码
responseVo.setCode(1);
return responseVo;
} else {
//令牌验证成功 -> 执行业务代码
//下单 去创建订单 验证令牌 验证价格 锁库存
//TODO 3 保存订单
OrderCreatTo orderCreatTo = creatOrder();
BigDecimal payAmount = orderCreatTo.getOrder().getPayAmount();
BigDecimal payPrice = vo.getPayPrice();
//金额对比
if (Math.abs(payAmount.subtract(payPrice).doubleValue()) < 0.01) {
//保存到数据库
saveOrder(orderCreatTo);
//库存锁定 只要有异常就回本订单数据
WareSkuLockVo wareSkuLockVo = new WareSkuLockVo();
wareSkuLockVo.setOrderSn(orderCreatTo.getOrder().getOrderSn());
List<OrderItemVo> collect = orderCreatTo.getOrderItems().stream().map((item) -> {
OrderItemVo orderItemVo = new OrderItemVo();
orderItemVo.setSkuId(item.getSkuId());
orderItemVo.setCount(item.getSkuQuantity());
orderItemVo.setTitle(item.getSkuName());
return orderItemVo;
}).collect(Collectors.toList());
wareSkuLockVo.setLocks(collect);
/**
* TODO 4 远程锁库存 非常严重
* 库存成功了,但是网络原因超时了,订单可以回滚,库存不能回滚
* 为了保证高并发,库存需要自己回滚。 这样可以采用发消息给库存服务
* 库存服务本身也可以使用自动解锁模式 使用消息队列完成 使用延时队列
*/
R r = wareFeignService.orderLockStock(wareSkuLockVo);
if (r.getCode() == 0) {
//锁成功
responseVo.setOrder(orderCreatTo.getOrder());
//TODO 5 远程扣减积分
//库存成功了,但是网络原因超时了,订单回滚,库存不回滚
// int i = 1 / 0;//模拟积分系统异常
//TODO 订单创建成功,发消息给MQ
rabbitTemplate.convertAndSend("order-event-exchange", "order.create.order", orderCreatTo.getOrder());
return responseVo;
} else {
//锁定失败
String msg1 = (String) r.get("msg");
throw new NoStockException(msg1);
}
} else {
responseVo.setCode(2);
return responseVo;
}
}
}