如何选择一个合适的幂等实现方案!

2,868 阅读11分钟

持续创作,加速成长!这是我参与「掘金日新计划 · 6 月更文挑战」的第1天,点击查看活动详情

背景

如何选择一个合适的库存扣减方案? 中我们遗留了一个问题,当订单侧调用库存扣减方法时发生了接口超时,返回了调用失败,但订单侧却不知道虽然上次调用返回了失败,但实际上库存已经扣减成功了,默认发起了一次重试(比如说是dubbo调用上配置的retries="1"),如果库存扣减没做幂等控制,那同一笔订单号可能会扣两次库存,影响营收,需要对库存扣减做幂等控制。

什么是幂等?

幂等是一个数学与计算机科学概念。

  • 在数学中,幂等用函数表达式就是:f(x) = f(f(x))。比如求绝对值的函数,就是幂等的,abs(x) = abs(abs(x))
  • 计算机科学中,幂等表示一次和多次请求某一个资源应该具有同样的副作用,或者说,多次请求所产生的影响与一次请求执行的影响效果相同。

在维基百科上的定义,幂等(idempotent、idempotence)是一个数学与计算机学概念。

在数学中,幂等用函数表达式就是:f(x) = f(f(x))。比如求绝对值的函数,就是幂等的,abs(x) = abs(abs(x))

**在计算机科学中一个幂等操作的特点是其任意多次执行所产生的影响均与一次执行的影响相同。**幂等函数,或幂等方法,是指可以使用相同参数重复执行,并能获得相同结果的函数。在我们日常编程中,读操作是天然幂等的,需要做幂等控制的只有写操作。

需要幂等控制的场景

刚刚库存扣减的场景只是其中一种情况,来看看我在工作中遇到过的所有需要幂等控制的场景。

1.接口调用重试

可能是刚刚提到超时重试场景,调用超时但实际已经成功产生影响的话,二次请求后直接返回成功。也可能不是超时,而是别人的代码bug。所以对于比较重要的写操作,最好都加上幂等控制。

对于调用超时但确实是业务失败了,重复执行应该还是会失败,一般不需要对失败做幂等。比如重试之前是库存不足,重试的时候库存已经补充了,就有可能会成功,这种场景不建议直接使用保存的幂等结果,最好再执行一次业务逻辑。当然如果业务方特别要求的话例外。

2.MQ消费组重复消费

对于一段消费消息的逻辑,由于消息是可能会读到重复消息的,所以消息消费需要做幂等,做幂等的方式可以根据业务场景来选择,稍后会介绍。

3.前端重复提交

比如这里的下单,如果快速点击提交按钮,可能会一瞬间创建两笔订单。当然这里也不一定需要控制,看产品诉求,毕竟订单可以取消。但是大部分的Form表单重复提交还是需要控制。

幂等如何设计?

幂等的核心是控制同一个请求的影响,无论什么方案,首先都需要一个唯一的ID来标识这个请求是独一无二的。

4.1 全局的唯一性ID

全局唯一性ID我们怎么获取呢?

我们可以使用UUID,但是UUID的缺点比较明显,它字符串占用的空间比较大,生成的ID过于随机,可读性差,而且没有递增。

我们还可以使用雪花算法(Snowflake) 生成唯一性ID。

雪花算法是一种生成分布式全局唯一ID的算法,生成的ID称为Snowflake IDs。这种算法由Twitter创建,并用于推文的ID。

一个Snowflake ID有64位。

  • 第1位:Java中long的最高位是符号位代表正负,正数是0,负数是1,一般生成ID都为正数,所以默认为0。
  • 接下来前41位是时间戳,表示了自选定的时期以来的毫秒数。
  • 接下来的10位代表计算机ID,防止冲突。
  • 其余12位代表每台机器上生成ID的序列号,允许在同一毫秒内创建多个Snowflake ID。

雪花算法

当然,全局唯一性的ID,还可以使用百度的Uidgenerator,或者美团的Leaf,都是凯源的方案,后面会专门写一篇博客来介绍使用细节。

实现幂等的几种方案

1.select + insert + 唯一索引冲突

以扣减库存的场景为例,我们有一张记录成功扣减库存记录的表,主要有三个字段,订单号,库存id,扣减个数。

在这个场景中,订单号orderSn就是我们的唯一ID,当请求过来时,先select是否有关于该笔订单的扣减记录,接着有三种情况:

  • 如果扣减记录已存在,拦截请求,直接返回成功。
  • 如果扣减记录不存在,执行insert,如果insert成功,正常返回成功。
  • 如果扣减记录不存在,执行insert,如果insert失败,catch看是否是DuplicateKeyException,是的话表示在重试的多个请求间隔时间太短,同时绕过了select的数据判断。

伪代码如下:

public boolean deduct(String orderSn, Long inventoryId, Integer deductCount) {
    Record record = selectByOrderSn(orderSn);

    if (record != null) {
        //重复请求,返回成功
        return true;
    }

    try {
        insert(orderSn, inventoryId, deductCount);
    } catch (DuplicateKeyException e) {
        //唯一冲突,重复请求,返回成功
        return true;
    }

    //正常处理逻辑...如果报错可能会返回false,比如库存不足

    return true;
}

一般来说,需要insert与后续的正常处理逻辑包含在同一个事务中。这是因为如果第一个请求insert扣减记录成功后,往后走可能会报错,如果没有回滚之前的insert的话,第二次以后的请求都会给直接拦截从而返回true。

除此之外,这里可能会发生库存不足扣减失败的场景,对于这样的业务失败,假设调用方要求也要做幂等,也就是下次传同样的参数也返回失败的话,那也需要记录到扣减记录表里,这时记录表需要增加一个status字段以及一个extInfo来记录具体报错情况,之后可以直接将报错明细返回。

2.insert + 唯一索引冲突

该方案与之前的区别就是不需要一开始的select查询了,其余逻辑一致。用于在重复请求的概率比较低的情况下。

3.状态校验 + update行锁

很多业务场景都是有状态的,成功进行了一系列业务逻辑后会流转至下一个状态。比如下单的时候如果带上优惠券,会把该优惠券的状态标记为“占用状态“,一般有如下几个状态

下单占用优惠券的Sql可以这么写(一般还会记录下是哪笔订单,哪个sku占用的)

update coupon_instance set status = 2 where coupon_id = '12315' and status = 1;

伪代码实现如下:

void holdCoupon(Request request) {
    String couponId = request.getCouponId();
    int rows = "update coupon_instance set status = 2 where couponId = #{couponId} and status = 1;";
    if (rows == 0) {
        //不处理,直接返回
        return;
    }
    if (rows > 1) {
        //异常情况,告警出来
        throw new IllegalStateException();
    }
    // rows == 1 为正常情况,处理其他业务逻辑,比如扣减核销库存,占用券后禁用某些券等...
}

couponId12135的请求第一次请求到来时,该券的状态是未使用,要更新为占用状态,该update语句执行完后返回的影响行数为1,正常执行之后的流程。

当同样的券来第二次请求时,该券的状态已是占用状态,要更新为占用状态,该update语句执行完后返回的影响行数为0,直接返回。

另外,这里的校验方式有一个缺点。如果不止该方法会修改状态字段,比如券到期后有任务将status修改成了3-已过期,这时因为更新后得到row == 0,所以返回成功了,一般占用券是订单业务在调用的,如果券过期了还给其返回成功,那下单会继续进行下去,势必会造成一些问题。

所在在这个场景下,row == 0之后报错更为妥当,但如果改为报错,实际上该方法就不是幂等的了,只是做了防重控制,因为第一次调用返回成功,第二次调用抛出异常。防重主要为了避免产生重复数据,把重复请求拦截下来即可。而幂等设计除了拦截已经处理的请求,还要求相同的请求都返回一样的结果。对于消息重复消费的情况我认为倒是可以接受报错的实现,一来因为重复消息也不会太多,其次是报错之后会导致消息进入死信队列,丢弃即可。

当然,不止status可以这样处理,其他字段也行,只是status比较有代表性。

4.幂等控制表(推荐)

1与2的方案中一般需要单独实现记录表来做幂等,实际上项目中一般不止一两个点需要控制幂等,所以往往希望幂等控制的操作能与业务分隔开来。下面来看看具体设计。

首先,先建一张幂等控制表

CREATE TABLE `idempotent` (
  `id` bigint(20) NOT NULL AUTO_INCREMENT COMMENT '自增主键',
  `biz_id` varchar(128) NOT NULL COMMENT '外部请求id,唯一标识号,可以是order_sn,uuid等',
  `biz_type` varchar(32) NOT NULL COMMENT '业务类型',
  `request` varchar(1024) NOT NULL COMMENT '请求内容备份',
  `response` varchar(1024) DEFAULT NULL COMMENT '响应内容备份,status为成功表示正常返回结果备份,失败表示异常内容备份',
  `status` int(10) NOT NULL COMMENT '状态,0-初始化,1-成功,2-失败',
  PRIMARY KEY (`id`),
  UNIQUE KEY `biz_id_type` (`biz_id`,`biz_type`)
) ENGINE=InnoDB COMMENT='幂等表'

为了多种业务能保存在一张表内,增加一个biz_type,这样假如biz_id是订单号的话,通过联合唯一索引可以保存多个场景的幂等。

以扣减库存为例,核心实现代码如下:

public Response deduct(Request request) {
        //先判断之前是否处理过,如果处理成功过直接返回结果
        //request.getOrderSn() 为 bizId, "deduct"为bizType
        Idempotent idempotent = idempotentDAO.getIdempotent(request.getOrderSn(), "deduct");
        if (idempotent != null) {
            return JSON.parseObject(idempotent.getResponse(), new TypeReference<Response>() {});
        }
        idempotentDAO.insert(request.getOrderSn(), "deduct", request);

        //执行业务代码...
        Response response = new Response();

        //执行业务代码成功后更新记录
        idempotentDAO.setStatus(WorkOrderStatusEnum.SUCCESS.getCode());
        idempotentDAO.setResponse(JSON.toJSONString(response));
        idempotentDAO.update(idempotentDAO);
    }

观察上面的代码,不难发现,这段逻辑实际上比较固定,类似于模版代码,如果每块需要幂等控制的业务代码都需要增加这样的前后逻辑的话,会导致项目重复代码过多,影响代码可读性。有没有什么法子优化一下呢?

我们很容易想到利用切面配合注解的方式来实现,其原理是利用动态代理的方式,动态生成一个子类来控制对真实对象的访问。

与之前巧用 分布式锁 🔥 - 掘金同理,我们增加自定义一个注解@Idempotent,在需要幂等性保证的接口上加上该注解,核心逻辑抽取到切面去,这样便不会造成代码侵入和污染。

5.幂等控制表 + 其他方式实现

使用Mysql实现幂等控制表的方式有个缺点,当数据量到达一定程度之后会影响到实际接口的性能。

目前考虑到可以用redis,HBase等实现,但没有实际操作过,探索后回来补充下...

6.Token机制

针对前端重复连续多次点击的情况,例如用户提交订单的接口就可以通过 Token 的机制实现防止重复创建多个相同的订单。

主要流程就是:

  1. 服务端提供了发送token的接口。我们在分析业务的时候,哪些业务是存在幂等问题的,就必须在执行业务前,先去获取token,服务器会把token保存到redis中。(微服务肯定是分布式了,如果单机就适用jvm缓存)。
  2. 然后调用业务接口请求时,把token携带过去,一般放在请求头部。
  3. 服务器判断token是否存在redis中,存在表示第一次请求,这时把redis中的token删除,继续执行业务。
  4. 如果判断token不存在redis中,就表示是重复操作,直接返回重复标记给client,这样就保证了业务代码,不被重复执行。

参考

分布式系统中接口的幂等性 - JaJian - 博客园

实战!聊聊幂等设计 - 掘金