谷粒商城-高级-69 -商城业务-订单服务-接口幂等性

任意多次执行所产生的影响均与一次执行的影响相同,这是幂等性的核心特点。其实在我们编程中主要操作就是CURD,其中读取(Retrieve)操作和删除(Delete)操作是天然幂等的,受影响的就是创建(Create)、更新(Update)。

一、接口幂等性概念

1. 接口调用存在的问题

现如今我们的系统大多拆分为分布式SOA,或者微服务,一套系统中包含了多个子系统服务,而一个子系统服务往往会去调用另一个服务,而服务调用服务无非就是使用RPC通信或者restful,既然是通信,那么就有可能在服务器处理完毕后返回结果的时候挂掉,这个时候用户端发现很久没有反应,那么就会多次点击按钮,这样请求有多次,那么处理数据的结果是否要统一呢?那是肯定的!尤其在支付场景。

2. 什么是接口幂等性

接口幂等性就是用户对于同一操作发起的一次请求或者多次请求的结果是一致的,不会因为多次点击而产生了副作用。举个最简单的例子,那就是支付,用户购买商品后支付,支付扣款成功,但是返回结果的时候网络异常,此时钱已经扣了,用户再次点击按钮,此时会进行第二次扣款,返回结果成功,用户查询余额返发现多扣钱了,流水记录也变成了两条,这就没有保证接口的幂等性。

3. 什么情况下需要保证接口的幂等性

在增删改查4个操作中,尤为注意就是增加或者修改,

A: 查询操作

 查询对于结果是不会有改变的,查询一次和查询多次,在数据不变的情况下,查询结果是一样的。select是天然的幂等操作

B: 删除操作

删除一次和多次删除都是把数据删除。(注意可能返回结果不一样,删除的数据不存在,返回0,删除的数据多条,返回结果多个,在不考虑返回结果的情况下,删除操作也是具有幂等性的)

C: 更新操作
修改在大多场景下结果一样,但是如果是增量修改是需要保证幂等性的,如下例子:
把表中id为XXX的记录的A字段值设置为1,这种操作不管执行多少次都是幂等的
把表中id为XXX的记录的A字段值增加1,这种操作就不是幂等的

D: 新增操作
增加在重复提交的场景下会出现幂等性问题,如以上的支付问题

二、那些情况需要防止

对于业务中需要考虑幂等性的地方一般都是接口的重复请求,重复请求是指同一个请求因为某些原因被多次提交。导致这个情况会有几种场景:

  • 前端重复提交:提交订单,用户快速重复点击多次,造成后端生成多个内容重复的订单。
  • 接口超时重试:对于给第三方调用的接口,为了防止网络抖动或其他原因造成请求丢失,这样的接口一般都会设计成超时重试多次。
  • 消息重复消费:MQ消息中间件,消息重复消费。
  • 用户页面回退再次提交
  • 微服务互相调用:由于网络问题,导致请求失败。feign触发重试机制
  • 其他业务情况

对于一些业务场景影响比较大的,接口的幂等性是个必须要考虑的问题,例如金钱的交易方面的接口。否则一个错误的、考虑不周的接口可能会给公司带来巨额的金钱损失,那么背锅的肯定是程序员自己了。

三、幂等解决方案

1、Token机制

1、服务端提供了发送Token的接口。我们在分析业务的时候,哪些业务是存在幂等问题的,就必须在执行业务前,先去获取Token,服务器会把Token保存到redis中。
2、然后调用业务接口请求时,把token携带过去,一般放在请求头部。
3、服务器判断token是否存在redis中,存在表示第一次请求,然后删除token,继续执行业务。

2、各种锁机制

1、数据库悲观锁

select * from xxx 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版本。
乐观锁主要使用于处理读多写少的问题。

3、业务层分布式锁

如果多个机器可能在同一时间处理相同的数据,比如多台机器定时任务都拿到了相同的数据处理,我们就可以加分布式锁,锁定此数据,处理完成后释放锁。获取到锁的必须先判断这个数据是否被处理过。

四、项目实战

为了防止订单重复提交,所以,我们使用Token令牌来做幂等性处理。
file

1、订单确认页生成token

在给订单确认页返回数据时,生成一个令牌Token。

核心代码:

// TODO 5、防重令牌(防止表单重复提交)
//为用户设置一个token,三十分钟过期时间(存在redis)
String token = UUID.randomUUID().toString().replace("-", "");
redisTemplate.opsForValue().set(OrderConstant.USER_ORDER_TOKEN_PREFIX+memberResponseVo.getId(), token,30, TimeUnit.MINUTES);
confirmVo.setOrderToken(token);

完整代码:
gulimall-order/xxx/order/service/impl/OrderServiceImpl.java

 @Override
    public OrderConfirmVo confirmOrder() throws ExecutionException, InterruptedException {

        // 构建OrderConfirmVo
        OrderConfirmVo confirmVo = new OrderConfirmVo();

        // 获取当前用户登录的信息
        MemberResponseVo memberResponseVo = LoginUserInterceptor.loginUser.get();

        //TODO :获取当前线程请求头信息(解决Feign异步调用丢失请求头问题)
        RequestAttributes requestAttributes = RequestContextHolder.getRequestAttributes();

        // 开启第一个异步任务
        CompletableFuture<Void> addressFuture = CompletableFuture.runAsync(()->{

            //每一个线程都来共享之前的请求数据
            RequestContextHolder.setRequestAttributes(requestAttributes);

            // 1、远程查询所有的收货地址列表
            List<MemberAddressVo> address = memberFeignService.getAddress(memberResponseVo.getId());
            confirmVo.setAddress(address);

        }, threadPoolExecutor);

        //开启第二个异步任务
        CompletableFuture<Void> cartInfoFuture = CompletableFuture.runAsync(() -> {

            //每一个线程都来共享之前的请求数据
            RequestContextHolder.setRequestAttributes(requestAttributes);

            //2、远程查询购物车所有选中的购物项
            List<OrderItemVo> currentCartItems = cartFeignService.getCurrentCartItems();
            confirmVo.setItems(currentCartItems);
            //feign在远程调用之前要构造请求,调用很多的拦截器
        }, threadPoolExecutor).thenRunAsync(() -> {
            List<OrderItemVo> items = confirmVo.getItems();
            //获取全部商品的id
            List<Long> skuIds = items.stream()
                .map((itemVo -> itemVo.getSkuId()))
                .collect(Collectors.toList());

            //远程查询商品库存信息
            R skuHasStock = wmsFeignService.getSkuHasStock(skuIds);
            List<SkuStockVo> skuStockVos = skuHasStock.getData("data", new TypeReference<List<SkuStockVo>>() {});

            if (skuStockVos != null && skuStockVos.size() > 0) {
                //将skuStockVos集合转换为map
                Map<Long, Boolean> skuHasStockMap = skuStockVos.stream().collect(Collectors.toMap(SkuStockVo::getSkuId, SkuStockVo::getHasStock));
                confirmVo.setStocks(skuHasStockMap);
            }
        },threadPoolExecutor);

        // 3、查询用户积分
        Integer integration = memberResponseVo.getIntegration();
        confirmVo.setIntegration(integration);

        // 4、价格数据自动计算

        //TODO 5、防重令牌(防止表单重复提交)
        //为用户设置一个token,三十分钟过期时间(存在redis)
        String token = UUID.randomUUID().toString().replace("-", "");
        redisTemplate.opsForValue().set(OrderConstant.USER_ORDER_TOKEN_PREFIX+memberResponseVo.getId(), token,30, TimeUnit.MINUTES);
        confirmVo.setOrderToken(token);

        // 异步编排
        CompletableFuture.allOf(addressFuture,cartInfoFuture).get();

        return confirmVo;

    }

前端展示页面:
前端页面将该Token设置为隐藏域,订单确认提交的时候,该Token会携带上。
gulimall-order/src/main/resources/templates/confirm.html

 <input name="orderToken" th:value="${confirmOrderData.orderToken}" type="hidden"/>

提交订单:
gulimall-order/xxx/order/service/impl/OrderServiceImpl.java

@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();

        //通过lua脚本原子验证令牌和删除令牌
        Long result = redisTemplate.execute(new DefaultRedisScript<Long>(script, Long.class),
            Arrays.asList(OrderConstant.USER_ORDER_TOKEN_PREFIX + memberResponseVo.getId()),
            orderToken);

        if (result == 0L) {
            //令牌验证失败
            responseVo.setCode(1);
            return responseVo;
        } else {
            //令牌验证成功
            //1、创建订单、订单项等信息
            OrderCreateTo order = createOrder();

                        //  后边的代码省略...
         }
}

说明:在处理提交上来的订单时,一定要通过lua脚本原子验证令牌和删除令牌机制,否则还会有漏洞。如果快速点击了两下提交按钮,这时两个请求几乎同时进来,第一个先去查询redis,然后再准备删除token的时候,第二个也查了redis,存在Token,这样还是会造成重复提交。如果使用原子验证令牌,就可以完美解决该问题。

原子验证核心代码:

//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();

//通过lua脚本原子验证令牌和删除令牌
Long result = redisTemplate.execute(new DefaultRedisScript<Long>(script, Long.class),
     Arrays.asList(OrderConstant.USER_ORDER_TOKEN_PREFIX + memberResponseVo.getId()),
     orderToken);

相关文章:
接口的幂等性
分布式系统中接口的幂等性

为者常成,行者常至