跳至主要內容

什么是接口的幂等性

LeoSpringbootSpringboot约 3301 字大约 11 分钟

什么是接口的幂等性,如何保证接口的幂等性?

1.前言☕

大家好,我是Leo哥🫣🫣🫣,有半个月没更新了,最近都忙着工作跟其他事情,博客水都没时间水了。这不年前几天,没啥任务了,昨天看了下关于幂等性的一些问题,今天早上抽空写了篇博客,分享给大家。好了,话不多说让我们开始吧😎😎😎。

2.什么是幂等性

幂等是一个数学与计算机学概念,在数学中某一元运算为幂等时,其作用在任一元素两次后会和其作用一次的结果相同。在计算机中编程中,一个幂等操作的特点是其任意多次执行所产生的影响均与一次执行的影响相同。幂等函数或幂等方法是指可以使用相同参数重复执行,并能获得相同结果的函数。这些函数不会影响系统状态,也不用担心重复执行会对系统造成改变。

3.什么是接口幂等性

所谓接口幂等性,就是一次和多次请求某一个资源对于资源本身应该具有同样的结果。

接口的 幂等性(Idempotence) 是指一个操作在执行一次和多次执行时,其结果是一样的。换句话说,无论这个操作被执行多少次,它对系统状态的影响都是相同的。幂等性是分布式系统中的一个重要概念,它有助于确保系统的健壮性和一致性。

在网络通信和 API 设计中,幂等性尤为重要,因为它可以防止由于网络重传、客户端或服务器端的重复请求等问题导致的数据不一致。例如,一个幂等的 HTTP 请求(如 GET 或 PUT 请求)在多次发送时,服务器应该能够正确处理,确保不会对资源造成意外的影响。

4.应用场景

不知道以下场景,朋友们是否遇到过:

  • 前端重复提交表单: 在填写一些表格时候,用户填写完成提交,很多时候会因网络波动没有及时对用户做出提交成功响应,致使用户认为没有成功提交,然后一直点提交按钮,这时就会发生重复提交表单请求。
  • 用户恶意进行刷单: 例如在实现用户投票这种功能时,如果用户针对一个用户进行重复提交投票,这样会导致接口接收到用户重复提交的投票信息,这样会使投票结果与事实严重不符。
  • 接口超时重复提交: 很多时候 HTTP 客户端工具都默认开启超时重试的机制,尤其是第三方调用接口时候,为了防止网络波动超时等造成的请求失败,都会添加重试机制,导致一个请求提交多次。
  • 消息进行重复消费: 当使用 MQ 消息中间件时候,如果发生消息中间件出现错误未及时提交消费信息,导致发生重复消费。

没错,这些都是幂等性问题。

接口幂等性是指用户对于同一操作发起的一次请求或者多次请求的结果是一致的,不会因为多次点击而产生了副作用。

这类问题多发于接口的:

  • insert操作,这种情况下多次请求,可能会产生重复数据。
  • update操作,如果只是单纯的更新数据,比如:update user set status=1 where id=1,是没有问题的。如果还有计算,比如:update user set status=status+1 where id=1,这种情况下多次请求,可能会导致数据错误。

5.解决方案

5.1 inset之前先select

通常情况下,在保存数据的接口中,我们为了防止产生重复数据,一般会在insert前,先根据name字段select一下数据。如果该数据已存在,则执行update操作,如果不存在,才执行 insert操作。

image-20240202095611459
image-20240202095611459

5.2 数据库唯一索引

大多数情况下,我们为了防止数据重复提交,我们都会在表中添加唯一索引,这个一个非常简单而且有奇效的方案。

alter table `order` add UNIQUE KEY `t_code` (`code`);

加了唯一索引之后,第一次请求数据可以插入成功。但后面的相同请求,插入数据时会报Duplicate entry '002' for key 'order.t_code异常,表示唯一索引有冲突。

具体流程图如下:

image-20240202101415975
image-20240202101415975

5.3 Token机制

针对客户端连续点击或者调用方的超时重试等情况,例如提交订单,此种操作就可以用 Token 的机制实现防止重复提交。简单的说就是调用方在调用接口的时候先向后端请求一个全局 ID(Token),请求的时候携带这个全局 ID 一起请求**(Token 最好将其放到 Headers 中)**,后端需要对这个 Token 作为 Key,用户信息作为 Value 到 Redis 中进行键值内容校验,如果 Key 存在且 Value 匹配就执行删除命令,然后正常执行后面的业务逻辑。如果不存在对应的 Key 或 Value 不匹配就返回重复执行的错误信息,这样来保证幂等操作。

该方案跟之前的所有方案都有点不一样,需要两次请求才能完成一次业务操作。

  1. 第一次请求获取token
  2. 第二次请求带着这个token,完成业务操作。

具体流程图如下:

image-20240202095841563
image-20240202095841563

5.4 悲观锁机制

比如某银行有个转账场景,用户A手里有200块钱,想转出100元,正常情况下,用户A转账之后只剩下了100元,SQL如下:

update user amount = amount-100 where id=888;

但是实际情况下,并非如此,如果有个相同的请求进来,用户A的账户就会一直扣减,直到变成负数。这种情况,用户A直接哭死,在业务场景中也是不允许出现的。

通常情况下通过如下sql锁住单行数据:

select * from user id=888 for update;

具体步骤:

  1. 多个请求同时根据id查询用户信息。
  2. 判断余额是否不足100,如果余额不足,则直接返回余额不足。
  3. 如果余额充足,则通过for update再次查询用户信息,并且尝试获取锁。
  4. 只有第一个请求能获取到行锁,其余没有获取锁的请求,则等待下一次获取锁的机会。
  5. 第一个请求获取到锁之后,判断余额是否不足100,如果余额足够,则进行update操作。
  6. 如果余额不足,说明是重复请求,则直接返回成功。

悲观锁需要在同一个事务操作过程中锁住一行数据,如果事务耗时比较长,会造成大量的请求等待,影响接口性能。

此外,每次请求接口很难保证都有相同的返回值,所以不适合幂等性设计场景,但是在防重场景中是可以的使用的。

5.5 乐观锁机制

因为悲观锁是比较消耗的性能的操作,那么我们为了提高接口性能,完全可以使用乐观锁。需要再表中添加一个version字段。

比如:

alter table user add version int2);

每当我们更新数据的时候,需要对我们的版本号+1

update user set amount = amount+100,version=version+1 where id = 888 and version =1  

更新数据的同时version+1,然后判断本次update操作的影响行数,如果大于0,则说明本次更新成功,如果等于0,则说明本次更新没有让数据变更。

等到下一个请求过来的时候,依然回去执行这行SQL,此时发现,根本不可能满足,version= 1 这个条件,因为version值已经修改了,那么前面必定已经成功过一次,后面都是重复的请求。

流程图如下:

image-20240202103825041
image-20240202103825041

具体步骤:

  1. 先根据id查询用户信息,包含version字段
  2. 根据id和version字段值作为where条件的参数,更新用户信息,同时version+1
  3. 判断操作影响行数,如果影响1行,则说明是一次请求,可以做其他数据操作。
  4. 如果影响0行,说明是重复请求,则直接返回成功。

5.6 建防重表

有时候我们的表中需要一些重复数据,只有一些特殊场景才不需要重复数据,此时我们上面的唯一索引方案可能就不太行了。

这时候,直接在表中加唯一索引,显然是不太合适的。

针对这种情况,我们可以通过建防重表来解决问题。

简单来说就是我们再单独建立一张表,只需要含有id和唯一索引,当然唯一索引可以是多个字段比如:name、code等组合起来的唯一标识

流程图如下:
image-20240202104525417

具体步骤:

  1. 用户通过浏览器发起请求,服务端收集数据。
  2. 将该数据插入mysql防重表。
  3. 判断是否执行成功,如果成功,则做mysql其他的数据操作(可能还有其他的业务逻辑)。
  4. 如果执行失败,捕获唯一索引冲突异常,直接返回成功。

6.代码实践

以上我们都是给出了一些大概的解决方案跟思路,接下来Leo哥大家以Token机制为例,用代码实现如果解决接口的幂等性。

首先我们来回顾一下Token机制的整个流程。

image-20240202095841563
image-20240202095841563

下面开始直接上代码。

首先准备一个springboot工程项目,只需要添加两个依赖即可。

image-20240202104822062
image-20240202104822062

然后开始编写Redis工具类跟一个简单的Token工具类。

RedisService

package org.javatop.idempotent.token;

import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.data.redis.core.RedisTemplate;
import org.springframework.data.redis.core.ValueOperations;
import org.springframework.stereotype.Component;

import java.util.concurrent.TimeUnit;

/**
 * @author : Leo
 * @version 1.0
 * @date 2024-02-01 21:03
 * @description :
 */
@Component
public class RedisService {

    @Autowired
    private RedisTemplate redisTemplate;

    public boolean setEx(String key, Object value, Long expireTime) {
        boolean result = false;
        try {
            ValueOperations ops = redisTemplate.opsForValue();
            ops.set(key, value);
            redisTemplate.expire(key, expireTime, TimeUnit.SECONDS);
            result = true;
        } catch (Exception e) {
            e.printStackTrace();
        }
        return result;
    }

    /**
     * 判断key是否存在
     * @param key key
     * @return
     */
    public boolean exists(String key) {
        return Boolean.TRUE.equals(redisTemplate.hasKey(key));
    }

    /**
     * 删除key
     * @param key key
     * @return
     */
    public boolean remove(String key) {
        if (exists(key)) {
            return Boolean.TRUE.equals(redisTemplate.delete(key));
        }
        return false;
    }
}

TokenService

主要是生成一个全局唯一不重复的Token,以及前端请求过来被拦截后需要检验token的方法。

package org.javatop.idempotent.token;

import io.micrometer.common.util.StringUtils;
import jakarta.servlet.http.HttpServletRequest;
import org.javatop.idempotent.exception.IdempotentException;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.stereotype.Component;

import java.util.UUID;

/**
 * @author : Leo
 * @version 1.0
 * @date 2024-02-01 21:01
 * @description :
 */
@Component
public class TokenService {

    @Autowired
    RedisService redisService;

    public String createToken() {
        String uuid = UUID.randomUUID().toString();
        redisService.setEx(uuid, uuid, 10000L);
        return uuid;
    }

    public boolean checkToken(HttpServletRequest request) throws IdempotentException {
        String token = request.getHeader("token");
        if (StringUtils.isEmpty(token)) {
            token = request.getParameter("token");
            if (StringUtils.isEmpty(token)) {
                throw new IdempotentException("token 不存在");
            }
        }
        if (!redisService.exists(token)) {
            throw new IdempotentException("重复的操作");
        }
        boolean remove = redisService.remove(token);
        if (!remove) {
            throw new IdempotentException("重复的操作");
        }
        return true;
    }
}

自定义幂等注解

我们自定义一个幂等注解,来对我们想要幂等性一致的接口进行标识。

/**
 * @author : Leo
 * @version 1.0
 * @date 2024-02-01 21:17
 * @description : 幂等注解
 */
@Target(ElementType.METHOD)
@Retention(RetentionPolicy.RUNTIME)
public @interface AutoIdempotent {
}

添加拦截器

在拦截器中,我们解析出所有的请求,标注有幂等注解的请求,我们去检验他的token,然后来决定下一步操作。

package org.javatop.idempotent.interceptor;

import jakarta.servlet.http.HttpServletRequest;
import jakarta.servlet.http.HttpServletResponse;
import org.javatop.idempotent.annotation.AutoIdempotent;
import org.javatop.idempotent.exception.IdempotentException;
import org.javatop.idempotent.token.TokenService;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.context.annotation.Configuration;
import org.springframework.stereotype.Component;
import org.springframework.web.method.HandlerMethod;
import org.springframework.web.servlet.HandlerInterceptor;
import org.springframework.web.servlet.ModelAndView;

/**
 * @author : Leo
 * @version 1.0
 * @date 2024-02-01 21:14
 * @description :
 */
@Component
public class IdempotentInterceptor implements HandlerInterceptor {
    @Autowired
    TokenService tokenService;

    @Override
    public boolean preHandle(HttpServletRequest request, HttpServletResponse response, Object handler) throws Exception {
        if (!(handler instanceof HandlerMethod)) {
            return true;
        }
        HandlerMethod handlerMethod = (HandlerMethod) handler;
        AutoIdempotent idempotent = handlerMethod.getMethod().getAnnotation(AutoIdempotent.class);
        if (idempotent != null) {
            try {
                return tokenService.checkToken(request);
            } catch (IdempotentException e) {
                throw e;
            }
        }
        return true;
    }
}

测试

最后编写接口进行测试。

首先生成一个Token,然后把这个token放到hello接口的请求头上面。

image-20240202110510677
image-20240202110510677

可以看到,第一次可以正常访问接口

image-20240202110612723
image-20240202110612723

但当你第二次访问该接口的时候,已经提示你操作重复了。因为在我们第一次访问接口之后,就把Redis中的token删除了。

image-20240202110656581
image-20240202110656581

7.总结

以上便是本文的全部内容,本人才疏学浅,文章有什么错误的地方,欢迎大佬们批评指正!我是Leo,一个在互联网行业的小白,立志成为更好的自己。

如果你想了解更多关于Leo,可以关注公众号-程序员Leo,后面文章会首先同步至公众号。

公众号封面
公众号封面