高并发下保证接口幂等性方案

生命无罪,健康万岁,我是laity。

我曾七次鄙视自己的灵魂:

第一次,当它本可进取时,却故作谦卑;

第二次,当它在空虚时,用爱欲来填充;

第三次,在困难和容易之间,它选择了容易;

第四次,它犯了错,却借由别人也会犯错来宽慰自己;

第五次,它自由软弱,却把它认为是生命的坚韧;

第六次,当它鄙夷一张丑恶的嘴脸时,却不知那正是自己面具中的一副;

第七次,它侧身于生活的污泥中,虽不甘心,却又畏首畏尾。

接口幂等性问题对于我们开发人员来说,是一个跟语言无关的公共问题,但是又不得不解决该问题。
绝大部分方案我在项目中是实践过的,给有需要的小伙伴一个参考!

一、什么是接口幂等性?

我们在填写form表单时,因为网络问题或者不小心点击多次保存提交按钮倒是表单数据重复提交(数据库表中同时也产生了多条重复的数据,只是id不同),而接口幂等性就是用户对于同一操作发起的一次请求或者多次请求的结果是一致的,不会因为多次点击而产生了副作用;不会因为多次点击而产生了副作用;比如说支付场景,用户购买了商品支付扣款成功,但是返回结果的时候网络异常,此时钱已经扣了,用户再次点击按钮,此时会进行第二次扣款,返回结果成功,用户查询余额返发现多扣钱了,流水记录也变成了两条...,这就没有保证接口的幂等性。

扫描二维码关注公众号,回复: 14699490 查看本文章

二、哪些情况需要防止

  • 用户多次点击按钮
  • 用户页面回退再次提交
  • 微服务互相调用,由于网络问题,导致请求失败。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,如果业务调用失败,就重新获取 token 再次请求。
      • 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、各种锁机制

    • 数据库悲观锁

      • select * from xxxx where id = 1 for update;

        悲观锁使用时一般伴随事务一起使用,数据锁定时间可能会很长,需要根据实际情况选用。另外要注意的是,id 字段一定是主键或者唯一索引,不然可能造成锁表的结果,处理起来会非常麻烦。

    • 数据库乐观锁

      • 这种方法适合在更新的场景中,

        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、各种唯一约束

    • 数据库唯一约束

      • 插入数据,应该按照唯一索引进行插入,比如订单号,相同的订单就不可能有两条记录插入。

        我们在数据库层面防止重复。

        这个机制是利用了数据库的主键唯一约束的特性,解决了在 insert 场景时幂等问题。但主键的要求不是自增的主键,这样就需要业务生成全局唯一的主键。

        如果是分库分表场景下,路由规则要保证相同请求下,落地在同一个数据库和同一表中,要不然数据库主键约束就不起效果了,因为是不同的数据库和表主键不相关。

    • redis set 防重

      • 很多数据需要处理,只能被处理一次,比如我们可以计算数据的 MD5 将其放入 redis 的 set,每次处理数据,先看这个 MD5 是否已经存在,存在就不处理。

  • 4、防重表

    • 使用订单号 orderNo 做为去重表的唯一索引,把唯一索引插入去重表,再进行业务操作,且他们在同一个事务中。这个保证了重复请求时,因为去重表有唯一约束,导致请求失败,避免了幂等问题。这里要注意的是,去重表和业务表应该在同一库中,这样就保证了在同一个事务,即使业务操作失败了,也会把去重表的数据回滚。这个很好的保证了数据一致性。

    • 之前说的 redis 防重也算

  • 5、全局请求唯一 id

    • 调用接口时,生成一个唯一 id,redis 将数据保存到集合中(去重),存在即处理过。

      可以使用 nginx 设置每一个请求的唯一 id;

      proxy_set_header X-Request-Id $request_id;

五、模拟案例

接口幂等性解决方案:token机制

提交你购买的商品信息到结算页面,生成订单等信息,当你生成订单接口没有预防幂等性时,那么重复提交就会生成多个订单信息,就会出问题。
提交表单到确认页面

        //TODO: 防重令牌(防止表单重复提交)
        //为用户设置一个token,三十分钟过期时间(存在redis)
        String token = UUID.randomUUID().toString().replace("-", "");
        // USER_TOKEN_PREFIX  该常量为存储redis中key的前缀
        // key = prefix + userId; 比较具有唯一性
        redisTemplate.opsForValue().set(USER_TOKEN_PREFIX + userResponseVo.getId(), token, 30, TimeUnit.MINUTES);

下单功能,提交订单

        UserResponseVo userResponseVo = 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脚本原子操作redis验证令牌和删除令牌
        Long result = redisTemplate.execute(new DefaultRedisScript<>(script, Long.class),
                Arrays.asList(USER_TOKEN_PREFIX + userResponseVo.getId()),
                orderToken);
        //根据lua脚本的设置,如果令牌验证失败则返回0
        if (result == 0L) {
    
    
            // 令牌验证失败
            // 执行令牌校验失败的逻辑
            ...
        } else {
    
    
        	// 表示令牌验证成功
        	// 执行后续的操作: 生成订单、验价、保存订单等
        	...
		}

青春好比吸烟。烟在飞扬。烟灰在坠落。我是Laity,正在前行的Laity。

猜你喜欢

转载自blog.csdn.net/duyun0/article/details/127920335