幂等解决方案集合(一)

3,158

什么是幂等(idempotent)

百度百科:

幂等(idempotent、idempotence)是一个数学与计算机学概念,常见于抽象代数中。

在编程中一个幂等操作的特点是其任意多次执行所产生的影响均与一次执行的影响相同。幂等函数,或幂等方法,是指可以使用相同参数重复执行,并能获得相同结果的函数。

这些函数不会影响系统状态,也不用担心重复执行会对系统造成改变。例如,“setTrue()”函数就是一个幂等函数,无论多次执行,其结果都是一样的。更复杂的操作幂等保证是利用唯一交易号(流水号)实现。

幂等的数学概念

幂等是源于一种数学概念。其主要有两个定义

如果在一元运算中,x 为某集合中的任意数,如果满足 f(f(x))=f(x)f(f(x))=f(x) ,那么该 f 运算具有幂等性,比如绝对值运算 abs(a)=abs(abs(a))abs(a) = abs(abs(a)) 就是幂等性函数。

如果在二元运算中,x 为某集合中的任意数,如果满足 f(x,x)=xf(x,x) = x,前提是 f 运算的两个参数均为 x,那么我们称 f 运算也有幂等性,比如求大值函数 max(x,x)=xmax(x,x) =x 就是幂等性函数。

幂等的业务概念

幂等性不仅仅只是一次或多次操作对资源没有产生影响,还包括第一次操作产生影响后,以后多次操作不会再产生影响。并且幂等关注的是是否对资源产生影响,而不关注结果。

举例:服务端会进行重试等操作或客户端有可能会进行多次点击提交。如果这样请求多次的话,那最终处理的数据结果就一定要保证统一,如支付场景。此时就需要通过保证业务幂等性方案来完成。

幂等的维度

  • 时间
  • 空间

时域唯一性

定义幂等的有效期。有些业务需要永久性保证幂等,如下单、支付等。而部分业务只要保证一段时间幂等即可。 你希望在多长时间内保证某次操作的幂等?

空域唯一性

定义了幂等的范围,如生成订单的话,不允许出现重复下单。 一次操作=服务方法+传入的业务数据

同时对于幂等的使用一般都会伴随着出现锁的概念,用于解决并发安全问题。

HTTP 协议语义幂等性

引用自:

安全方法 对于 GETHEAD 方法而言,除了进行获取资源信息外,这些请求不应当再有其他意义。也就是说,这些方法应当被认为是“安全的”。 客户端可能会使用其他“非安全”方法,例如 POSTPUTDELETE,应该以特殊的方式(通常是按钮而不是超链接)告知客户可能的后果(例如一个按钮控制的资金交易),或请求的操作可能是不安全的(例如某个文件将被上传或删除)。但是,不能想当然地认为服务器在处理某个 GET 请求时不会产生任何副作用。事实上,很多动态资源会把这作为其特性。这里重要的区别在于用户并没有请求这一副作用,因此不应由用户为这些副作用承担责任。 副作用 假如在不考虑诸如错误或者过期等问题的情况下,若干次请求的副作用与单次请求相同或者根本没有副作用,那么这些请求方法就能够被视作“幂等 (idempotence)”的。GETHEADPUTDELETE 方法都有这样的幂等属性,同样由于根据协议,OPTIONSTRACE 都不应有副作用,因此也理所当然也是幂等的。 假如一个由若干请求组成的请求序列产生的结果,在重复执行这个请求序列或者其中任何一个或多个请求后仍没有发生变化,则这个请求序列便是“幂等”的。但是,可能出现一个由若干请求组成的请求序列是“非幂等”的,即使这个请求序列中所有执行的请求方法都是幂等的。例如,这个请求序列的结果依赖于某个会在下次执行这个序列的过程中被修改的变量。

总结下:

HTTP MethodIdempotentSafe
OPTIONSyesyes
GETyesyes
HEADyesyes
PUTyesno
POSTnono
DELETEyesno
PATCHnono

常见幂等问题

业务上

  • 当用户购物进行下单操作,用户 操作多次,但订单系统对于本次操作只能产生一个订单(不控制会导致恶意刷单)。
  • 当用户对订单进行付款,支付系统不管出现什么问题,应该只对用户扣一次款。
  • 当支付成功对库存扣减时,库存系统对订单中商品的库存数量也只能扣减一次。
  • 当对商品进行发货时,也需保证物流系统有且只能发一次货。

技术上

  • 前端重复提交表单,导致同一条数据重复提交。
  • 当添加重试机制,一个请求重试多次,导致数据不一致。
  • 当使用 MQ 消息中间件时候,如果发生消息中间件出现错误未及时提交消费信息,导致发生重复消费。

前端解决方案

前端防重

通过前端防重保证幂等是最简单的实现方式,前端相关属性和 JS 代码即可完成设置。可靠性并不好,有经验的人员可以通过工具跳过页面仍能重复提交。主要适用于表单重复提交或按钮重复点击。

PRG 模式

PRG 模式即 POST-REDIRECT-GET。当用户进行表单提交时,会重定向到另外一个提交成功页面,而不是停留在原先的表单页面。这样就避免了用户刷新导致重复提交。同时防止了通过浏览器按钮前进/后退导致表单重复提交。是一种比较常见的前端防重策略。

Token 模式

token 模式主要是为了防重的。

需要前后端进行一定程度的交互来完成。需要利用到 Redis。

具体流程步骤:

  1. 客户端会先发送一个请求去获取 token,服务端会生成一个全局唯一的 ID 作为 token 保存在 redis 中,同时把这个 ID 返回给客户端
  2. 客户端第二次调用业务请求的时候必须携带这个 token
  3. 服务端会校验这个 token,如果校验成功,则执行业务,并删除 redis 中的 token
  4. 如果校验失败,说明 redis 中已经没有对应的 token,则表示重复操作,直接返回指定的结果给客户端

注意:

  • 在并发情况下,执行 Redis 查找数据与删除需要保证原子性,否则很可能在并发下无法保证幂等性。其实现方法可以使用分布式锁或者使用 Lua 表达式来注销查询与删除操作。

  • 全局唯一 ID 可以用百度的 uid-generator、美团的 Leaf 去生成

Redis 分布式锁 可以利用:典型的实现 setnx + getset 或 Redisson

这里给出两种实现的核心方法,首先是用 Redisson 的:

/**
     * 创建 Token 存入 Redis,并返回该 Token
     * @param value 用于辅助验证的 value 值
     * @return 生成的 Token 串
     */
    public String generateToken(String value) {
    
        String token = UUID.randomUUID().toString();
        String key = IDEMPOTENT_TOKEN_PREFIX + token;
        /**
         * 在真实业务中 采用唯一标志 例如 流水号啊
         */
        redisTemplate.opsForValue().set(key, value, 5, TimeUnit.MINUTES);
        return token;
    }

    /**
     * 分布式锁实现幂等性
     */
    @PostMapping("/distributeLock")
    @ApiOperation(value = "分布式锁实现幂等性")
    public String distributeLock(HttpServletRequest request) {
    
        String token = request.getHeader("token");
        // 获取用户信息(这里使用模拟数据)
        String userInfo = "mydlq";
        RLock lock = redissonClient.getLock(token);
        lock.lock(10, TimeUnit.SECONDS);
        try {
    
           Boolean flag = tokenUtilService.validToken2(token, userInfo);
            // 根据验证结果响应不同信息
            if (flag) {
    
                /**
                 * 执行正常的逻辑
                 */
                log.info("执行正常的逻辑………………");
            }
            return flag ? "正常调用" : "重复调用";
        } catch (Exception e) {
    
            e.printStackTrace();
            return  "重复调用";
        } finally {
    
            lock.unlock();
        }
    }

然后是带 Lua 脚本的

  /**
     * 验证 Token 正确性
     *
     * @param token token 字符串
     * @param value value 存储在 Redis 中的辅助验证信息
     * @return 验证结果
     */
    public Boolean validToken(String token, String value) {
    
        // 设置 Lua 脚本,其中 KEYS[1] 是 key,KEYS[2] 是 value, 这段 lua 脚本的意思是获取 redis 的 KEYS[1] 的值,与 KEYS[2] 的值作比较,如果相等则返回 KEYS[1] 的值并删除 redis 中的 KEYS[1], 否则返回 0
        String script = "if redis.call('get',KEYS[1]) == KEYS[2] then return redis.call('del', KEYS[1]) else return 0 end";
        RedisScript<Long> redisScript = new DefaultRedisScript<>(script, Long.class);
        // 根据 Key 前缀拼接 Key
        String key = IDEMPOTENT_TOKEN_PREFIX + token;
        // 执行 Lua 脚本
        Long result = redisTemplate.execute(redisScript, Arrays.asList(key, value));
        // 根据返回结果判断是否成功匹配并删除 Redis 键值对,若果结果不为空和 0,则验证通过
        if (result != null && result != 0L) {
    
            log.info("验证 token={},key={},value={} 成功", token, key, value);
            return true;
        }
        log.info("验证 token={},key={},value={} 失败", token, key, value);

        return false;
    }

Lua 脚本的比较好理解,这里说两句 Redisson 的,Redisson 的锁 在调用redissonClient.getLock(“myLockKey”) 时,redis 中不能存在同名的 key, 不然会报错。redisson 锁其内部是基于 lua 脚本语言完成锁获取的。因为获取锁的过程涉及到了多步,为了保证执行过程的原子性,使用了 Lua 脚本。

具体就是这段:

<T> RFuture<T> tryLockInnerAsync(long waitTime, long leaseTime, TimeUnit unit, long threadId, RedisStrictCommand<T> command) {
        return this.evalWriteAsync(this.getRawName(), LongCodec.INSTANCE, command, "if (redis.call('exists', KEYS[1]) == 0) then redis.call('hincrby', KEYS[1], ARGV[2], 1); redis.call('pexpire', KEYS[1], ARGV[1]); return nil; end; if (redis.call('hexists', KEYS[1], ARGV[2]) == 1) then redis.call('hincrby', KEYS[1], ARGV[2], 1); redis.call('pexpire', KEYS[1], ARGV[1]); return nil; end; return redis.call('pttl', KEYS[1]);", Collections.singletonList(this.getRawName()), new Object[]{unit.toMillis(leaseTime), this.getLockName(threadId)});
    }

关于 Redisson 锁还有几个进阶的概念比如:“红锁”,感兴趣的朋友可以看一下:redis.cn/topics/dist…

后端解决方案

利用数据库实现的方案

去重表

去重表的实现思路也非常简单,首先创建一张表作为去重表,同时在该表中建立一个或多个字段的唯一索引作为防重字段,用于保证并发情况下,数据只有一条。在向业务表中插入数据之前先向去重表插入,如果插入失败则表示是重复数据。

比如:同一个用户同一件商品不能在同一分钟下两次单,那么就需要 user_id, product_id, created_at 这三个字段做为去重字段。

唯一主键 insert、delete 场景

可以通过设置数据库的唯一主键约束或唯一索引约束来实现,这样重复的 key 就不会插入成功了。比如你用 userId 为 1 的数据插入,第一次成功了再重试一次就不会成功了。

这个方案可以防止新增脏数据,具有防重效果。

防重设计幂等设计,是有区别的。防重设计主要为了避免产生重复数据,对接口返回没有太多要求。而幂等设计除了避免产生重复数据之外,还要求每次请求都返回一样的结果

乐观锁 update 场景

数据库乐观锁方案一般只能适用于执行更新操作的过程,我们可以提前在对应的数据表中多添加一个字段,充当当前数据的版本标识。

基本思路是:版本号+条件 ,实现思想是基于 MySQL 的行锁思想来实现的。 以订单扣减库存举例:

如果我们只以版本号作为条件更新

update tb_stock set amount=amount-#{num},version=version+1 where goods_id=#{goodsId} and version=#{version}

那么同时下单的用户就只有一个能扣减库存成功,其他的都失败。这种情况我们可以再加入一个条件来判断比如:

update tb_stock set amount=amount-#{num} where goods_id=#{goodsId} and amount-#{num}>=0

只要不发生超卖就可以了。

举一反三,也可以用在订单只支付一次的场景,只不过条件不同罢了(也有称这种方法为状态标识状态机幂等),比如:

update table item set item.status=:newstatus where item.id = :id and item.status = oldstatus

当然这也仅仅是保证了接口的幂等性,放在真实的分布式环境里,服务间的调用很可能会涉及分布式事务,那么还需要在幂等的基础上加分布式事务的解决方案,比如 seata, 有关分布式事务由于不属于本文的重点就不多讨论了。但幂等是一个很重要基础,它能够保证业务数据的一致性。

为什么不用悲观锁?

首先悲观锁有可能会锁表,有性能问题。

比如 select for update

由于 InnoDB 预设是 Row-Level Lock,所以只有「明确」的指定主键,MySQL 才会执行 Row lock (行锁) ,否则 MySQL 将会执行 Table Locck(锁表)Lock

其次使用悲观锁有可能产生死锁

比如:一个用户 A 访问表 A(锁住了表 A),然后试图访问表 B;另一个用户 B 访问表 B(锁住了表 B),然后试图访问表 A。 这时对于用户 A 来说,由于表 B 已经被用户 B 锁住了,所以用户 A 必须等到用户 B 释放表 B 才能访问。同时对于用户 B 来说,由于表 A 已经被用户 A 锁住了,所以用户 B 必须等到用户 A 释放表 A 才能访问。此时死锁就已经产生了。

redis 分布式锁实现

具体流程步骤:

  1. 客户端先请求服务端,会拿到一个能代表这次请求业务的唯一字段
  2. 将该字段以 SETNX 的方式存入 redis 中,并根据业务设置相应的超时时间
  3. 如果设置成功,证明这是第一次请求,则执行后续的业务逻辑
  4. 如果设置失败,则代表已经执行过当前请求,直接返回

整体来看,与上文的 token 方案中使用 redis 类似,基本还是 分布式 ID+分布式锁。

比较适合于服务间调用时的接口幂等,比如订单调库存,订单调用支付等。

比如订单先生成了一个 id 标识(比如订单号),id 标识可由分布式 ID 生成器生成,然后带着这个标识一起请求库存,如果之前没有在 redis 存过则正常执行,如果发生接口重试,再次用相同 id 标识请求,redis 返回失败表示重复请求。

这里比较重要的是要记得设置 redis 的过期时间,具体时间要根据

  • 业务执行时间
  • 上游系统或整个系统整体的重试次数以及时间设置

假设我们设置为 2 秒,那么套一下业务就是:比如同一个订单(id 标识是订单号),在 2 秒内执行了 2 次以上扣减库存,很明显是一个重复操作,需要幂等处理(返回失败表示重复请求)。

如果不考虑重试次数和时间,一旦 redis key 超时失效,调用方服务再次重试还是无法保证幂等。

消息幂等

在接收消息的时候,消息推送重复。如果处理消息的接口无法保证幂等,那么重复消费消息产生的影响可能会非常大。

具体解决方案请看下回分解。

参考