什么是幂等性
接口幂等性就是用户对于同一操作发起的一次请求或者多次请求的结果是一致的,不会因为多次点击而产生了副作用。举个最简单的例子,那就是支付,用户购买商品后支付,支付扣款成功,但是返回结果的时候网络异常,此时钱已经扣了,用户再次点击按钮,此时会进行第二次扣款,返回结果成功,用户查询余额返发现多扣钱了,流水记录也变成了两条,这就没有保证接口的幂等性。
那些情况需要防止
对于业务中需要考虑幂等性的地方一般都是接口的重复请求,重复请求是指同一个请求因为某些原因被多次提交。导致这个情况会有几种场景:
- 前端重复提交:提交订单,用户快速重复点击多次,造成后端生成多个内容重复的订单。
- 接口超时重试:对于给第三方调用的接口,为了防止网络抖动或其他原因造成请求丢失,这样的接口一般都会设计成超时重试多次。
- 消息重复消费:MQ消息中间件,消息重复消费。
- 用户页面回退再次提交
- 微服务互相调用:由于网络问题,导致请求失败。feign触发重试机制
- 其他业务情况
对于一些业务场景影响比较大的,接口的幂等性是个必须要考虑的问题,例如金钱的交易方面的接口。否则一个错误的、考虑不周的接口可能会给公司带来巨额的金钱损失,那么背锅的肯定是程序员自己了。
什么情况下需要幂等
以SQL为例,有些操作时天然幂等的
基本SELECT、UPDATE、DELETE、INSERT。CRUD操作具备幂等性。
但是UPDATE、INSERT某些情况下不是幂等的
例1:UPDATE tab1 SET col1 = col1+1 WHERE col2 = 2,每次执行的结果都会发生变化,不是幂等的
例2:INSERT into user(userid,name) values(1,‘a’) 如果userid不是主键,可以重复,那么多次进行业务操作,数据会新增多条,不具备幂等性。
幂等性解决方案
token机制
- 服务端提供了发送Token的接口。我们在分析业务的时候,哪些业务是存在幂等问题的,就必须在执行业务前,先去获取Token,服务器会把Token保存到redis中。
- 然后调用业务接口请求时,把token携带过去,一般放在请求头部。
- 服务器判断token是否存在redis中,存在表示第一次请求,然后删除token,继续执行业务。
但是这种方式存在危险性 - 先删除token还是后删除token
- 先删可能导致业务没有执行,重试还带上之前的token,请求不能执行
- 后删可能导致业务处理成功,但是服务闪断,出现超时,没有删除token,重试后执行两边
- token的获取、比较和删除三个操作必须时原子性的,要实现原子性可以在redis情况下使用lua脚本
各种锁机制
数据库悲观锁
悲观锁一般伴随事务一起使用,id字段一定是主键或者唯一索引,不然可能会造成锁表结果。
数据库乐观锁
乐观锁主要处理读多写少的问题这种情况适合在更新场景中,根据数据的version版本,去对比操作时数据带过来的version,如果version一样可以进行更新操作,否则更新失败。
业务层分布式锁
如果多个及其可能在同一时间同时处理相同的数据,比如多台机器定时任务都拿到了相同数据处理,我们就可以加分布式锁,处理完成后释放锁。获取到锁的必须先判断这个数据是否被处理过。
各种唯一约束
数据库唯一约束
插入数据应该按照唯一索引进行插入
这种机制是立通了数据库的主键唯一约束的特性,解决了insert场景时幂等问题
不过分布式情况下,数据必须落在同一个数据库和同同一个表中
redis set防重
数据处理过了就不处理,比如百度网盘的秒传功能,把数据计算为一个MD5值,若该值已存在,就不操作
防重表
一个数据处理过了就在防重表中放一条数据表明这个数据已经操作过了,这个数据再过来就去防重表查询,有数据就不操作
全局的唯一请求id
可以在Feign中用
调用接口时,生成一个唯一id,redis讲数据保存到集合中(去重),存在即处理过。
可以使用nginx设置每一个请求的唯一id
项目实战
//为用户设置一个token,三十分钟过期时间(存在redis)
String token = UUID.randomUUID().toString().replace("-", "");
redisTemplate.opsForValue().set(USER_ORDER_TOKEN_PREFIX+memberResponseVo.getId(),token,30, TimeUnit.MINUTES);
confirmVo.setOrderToken(token);
CompletableFuture.allOf(addressFuture,cartInfoFuture).get();
return confirmVo;
/**
* 提交订单
* @param vo
* @return
*/
// @Transactional(isolation = Isolation.READ_COMMITTED) 设置事务的隔离级别
// @Transactional(propagation = Propagation.REQUIRED) 设置事务的传播级别
// @GlobalTransactional(rollbackFor = Exception.class)
@Transactional
@Override
public SubmitOrderResponseVo submitOrder(OrderSubmitVo vo) {
confirmVoThreadLocal.set(vo);
SubmitOrderResponseVo responseVo = new SubmitOrderResponseVo();
//去创建、下订单、验令牌、验价格、锁定库存...
//获取当前用户登录的信息
MemberResponseVo memberResponseVo = LoginUserInterceptor.loginUser.get();
responseVo.setCode(0);
//1、验证令牌是否合法【令牌的对比和删除必须保证原子性】
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();
//通过lure脚本原子验证令牌和删除令牌
Long result = redisTemplate.execute(new DefaultRedisScript<Long>(script, Long.class),
Arrays.asList(USER_ORDER_TOKEN_PREFIX + memberResponseVo.getId()),
orderToken);
if (result == 0L) {
//令牌验证失败
responseVo.setCode(1);
return responseVo;
} else {
//令牌验证成功
//1、创建订单、订单项等信息
OrderCreateTo order = createOrder();
//2、验证价格
BigDecimal payAmount = order.getOrder().getPayAmount();
BigDecimal payPrice = vo.getPayPrice();
if (Math.abs(payAmount.subtract(payPrice).doubleValue()) < 0.01) {
//金额对比
//TODO 3、保存订单
saveOrder(order);
//4、库存锁定,只要有异常,回滚订单数据
//订单号、所有订单项信息(skuId,skuNum,skuName)
WareSkuLockVo lockVo = new WareSkuLockVo();
lockVo.setOrderSn(order.getOrder().getOrderSn());
//获取出要锁定的商品数据信息【order里面存储的是Entity】
List<OrderItemVo> orderItemVos = order.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());
lockVo.setLocks(orderItemVos);
//TODO 调用远程锁定库存的方法
//出现的问题:扣减库存成功了,但是由于网络原因超时,出现异常,导致订单事务回滚,库存事务不回滚(解决方案:seata)
//为了保证高并发,不推荐使用seata,因为是加锁,并行化,提升不了效率,可以发消息给库存服务
R r = wmsFeignService.orderLockStock(lockVo);
if (r.getCode() == 0) {
//锁定成功
responseVo.setOrder(order.getOrder());
//int i = 10/0;
//TODO 订单创建成功,发送消息给MQ
rabbitTemplate.convertAndSend("order-event-exchange","order.create.order",order.getOrder());
//删除购物车里的数据
redisTemplate.delete(CART_PREFIX+memberResponseVo.getId());
return responseVo;
} else {
//锁定失败
String msg = (String) r.get("msg");
throw new NoStockException(msg);
// responseVo.setCode(3);
// return responseVo;
}
} else {
responseVo.setCode(2);
return responseVo;
}
}
}