接口幂等性是在设计接口的时候需要考虑的一个点,本文通过专题方式系统学习一下😁


什么是幂等性?

数学中的概念,即进行一次变化或多次变化产生的结果应该是相同的。

什么是接口幂等性?

以相同的请求调用这个接口一次或者多次,对系统产生的应该是相同的。

就一般来说,查询操作的接口一般都是具有接口幂等性的,因为一次或者多次查询并不会对系统数据造成改变或者破坏。

一般要注意接口幂等性的都是对系统的修改操作。

接口幂等性解决了什么问题?

由于接口幂等性的特性,可以用来解决以下的问题,比如:

  • 防止表单重复提交
  • 防止用户恶意刷单
  • 防止接口超时重复提交
  • 防止消息重复消费

最核心的一点就是,阻止多次相同的操作对系统产生影响,个人认为接口幂等性设计还可以防止重放攻击(?)。

引入后对系统有什么影响?

由于接口幂等性的设计是在服务端进行的操作,一定程度上会增加服务端的逻辑复杂度以及成本;

所以在系统设计的时候需要考虑,并不是每个接口都要设计满足幂等性,而需要根据实际的业务具体分析,除了特殊业务是不太需要引入接口幂等性的。

RESTful 方法幂等性情况

方法类型是否幂等描述
GetGet 方法用于获取资源。其一般不会也不应当对系统资源进行改变,所以是幂等的
Post×Post 方法一般用于创建新的资源。其每次执行都会新增数据,所以不是幂等的。
Put-Put 方法一般用于修改资源。该操作则分情况来判断是不是满足幂等,更新操作中直接根据某个值进行更新,也能保持幂等。
不过执行累加操作的更新是非幂等
Delete-Delete 方法一般用于删除资源。该操作则分情况来判断是不是满足幂等,当根据唯一值进行删除时,删除同一个数据多次执行效果一样。不过需要注意,带查询条件的删除则就不一定满足幂等了。
例如在根据条件删除一批数据后,这时候新增加了一条数据也满足条件,然后又执行了一次删除,那么将会导致新增加的这条满足条件数据也被删除。

接口幂等性的几种实现方案

数据库唯一主键

这种方案利用了数据库主键唯一约束的特性,在插入数据的时候,如果相同主键的数据将被拒绝,所以这种方案适合数据插入操作的接口。

这里就有一个问题,如果使用的数据库的自增主键,那么主键唯一约束就用不上了,方案就没了意义,所以这种方案的主键一般都是需要外部生成控制的。

比如说引入【分布式 ID 服务】,由这个服务来控制接口,保证分布式环境下 ID 的全局唯一性。

流程及说明:

数据库唯一主键方式图解

主要流程:

  1. 客户端执行创建请求,调用服务端接口。

  2. 服务端执行业务逻辑,生成一个分布式 ID,将该 ID 充当待插入数据的主键,然后执数据插入操作,运行对应的 SQL 语句。

  3. 服务端将该条数据插入数据库中,如果插入成功则表示没有重复调用接口。如果抛出主键重复异常,则表示数据库中已经存在该条记录,返回错误信息到客户端。

数据库乐观锁

乐观锁的一个很明显的特征是使用版本控制,落到实现上就是可以在数据表中添加一列数据的版本标识,在更新数据的时候,把版本标识作为条件,更新完之后再将版本值增加。这里案例使用到的字段是 version,实际使用中其实也可以使用 timestamp 毫秒值作为版本号。

乐观锁实现示例图:

乐观锁方式图解

UPDATE my_table SET price=price+50,version=version+1 WHERE id=1 AND version=5

Token 令牌

Token 令牌机制能够有效的防止客户端的重复提交以及超时重试的情况,实现原理即调用方再调用接口的时候先向后端请求一个全局 ID(即 Token),请求的时候携带 Token ,后端需要根据 Token 作为的 key 到 Redis 中进行判断,此时可能出现两种情况:

  • 匹配到:删除该 Token 并正常执行后面的逻辑
  • 没有匹配到:返回重复执行的错误消息

由于这种方案引入了其他的存储组件,比如 Redis ,在并发操作的时候,在 Redis 中查找数据与删除数据需要做原子性保证才行。

流程图及解释:

Token 令牌方式流程图

  1. 服务端提供获取 Token 的接口,该 Token 可以是一个序列号,也可以是一个分布式 ID 或者 UUID 串。

  2. 客户端调用接口获取 Token,这时候服务端会生成一个 Token 串。

  3. 然后将该串存入 Redis 数据库中,以该 Token 作为 Redis 的键(注意设置过期时间)。

  4. 将 Token 返回到客户端,客户端拿到后应存到表单隐藏域中。

  5. 客户端在执行提交表单时,把 Token 存入到 Headers 中,执行业务请求带上该 Headers。

  6. 服务端接收到请求后从 Headers 中拿到 Token,然后根据 Token 到 Redis 中查找该 key 是否存在。

  7. 服务端根据 Redis 中是否存该 key 进行判断,如果存在就将该 key 删除,然后正常执行业务逻辑。如果不存在就抛异常,返回重复提交的错误信息。

下游传递唯一序列号

原理上与 Token 令牌方式相似,只不过令牌由调用方(下游)生成,传递给被调用的服务,后端将该唯一序列号与 Redis 中的序列号进行比对,匹配之后也可能出现两种情况:

  • 匹配到:返回重复执行的错误消息
  • 没有匹配到:存取该序列号,并正常执行后面的逻辑

这种方式也有并发方面的问题,并且一般还得给存储的序列号一定的过期时间,不然一段时间后 Redis 会由于存储太多爆掉。

流程图及说明:

下游传递唯一序列号方式流程图

主要步骤:

  1. 下游服务生成分布式 ID 作为序列号,然后执行请求调用上游接口,并附带 “唯一序列号” 与请求的 “认证凭据 ID”。

  2. 上游服务进行安全效验,检测下游传递的参数中是否存在 “序列号” 和 “凭据 ID”。

  3. 上游服务到 Redis 中检测是否存在对应的 “序列号” 与 “认证 ID” 组成的 Key,如果存在就抛出重复执行的异常信息,然后响应下游对应的错误信息。如果不存在就以该 “序列号” 和 “认证 ID” 组合作为 Key,以下游关键信息作为 Value,进而存储到 Redis 中,然后正常执行接来来的业务逻辑。

四种方案的比较

方案名称适用方法实现复杂度方案缺点
数据库唯一主键插入操作 删除操作简单- 只能用于插入操作;
- 只能用于存在唯一主键场景;
数据库乐观锁更新操作简单- 只能用于更新操作;
- 表中需要额外添加字段;
请求序列号插入操作 更新操作 删除操作简单- 需要保证下游生成唯一序列号;
- 需要 Redis 第三方存储已经请求的序列号;
防重 Token 令牌插入操作 更新操作 删除操作适中- 需要 Redis 第三方存储生成的 Token 串;
  • 对于下单等存在唯一主键的,可以使用 “唯一主键方案” 的方式实现。
  • 对于更新订单状态等相关的更新场景操作,使用 “乐观锁方案” 实现更为简单。
  • 对于上下游这种,下游请求上游,上游服务可以使用 “下游传递唯一序列号方案” 更为合理。
  • 类似于前端重复提交、重复下单、没有唯一 ID 号的场景,可以通过 Token 与 Redis 配合的 “防重 Token 方案” 实现更为快捷。

实现防重 Token 方案

Github 项目地址: Study-idempotent

这个方案的核心点是后端需要开放接口,当下游/前端获取到 token,后端生成的 token 要存储到 Redis 中,下游/前端提交的请求中要携带 token 用于校验。整理以下基本要实现以下几个核心功能:

  • 前端获取 token 接口
  • 后端认证 token

由于不是所有的方法都需要做幂等性处理,所以可以自定义一个注解,来标注需要幂等性处理的接口。

参考项目:huchao1009/idempotent: 接口幂等性校验 的实现方式是通过拦截器实现,我尝试使用切面方式去实现了一下

核心代码:

获取 token 接口核心方法

public R createToken() {
    // 使用 UUID 生成 Token
    String token = UUID.randomUUID().toString();
    String zone = String.format(TOKEN_PREFIX, token);

    // 存入 redis
    redisTemplate.opsForValue().set(zone, "0", TOKEN_EXPIRATION_TIME, TimeUnit.SECONDS);
    return R.success(RCode.SUCCESS.getMsg(), token);
}

自定义幂等接口注解

/**
 * 幂等性接口注解
 *
 * @author whale
 */
@Target({ElementType.METHOD})
@Retention(RetentionPolicy.RUNTIME)
public @interface ApiIdempotent {
}

拦截器方式拦截注解方法(拦截器方式)

@Component
public class TokenInterceptor implements HandlerInterceptor {

    @Autowired
    private TokenService tokenService;

    @Override
    public boolean preHandle(HttpServletRequest request, HttpServletResponse response, Object handler) throws Exception {

        // 只拦截方法
        if (!(handler instanceof HandlerMethod)) {
            return true;
        }

        HandlerMethod handlerMethod = (HandlerMethod) handler;

        // 判断方法上是否有 ApiIdempotent 注解
        ApiIdempotent annotation = handlerMethod.getMethod().getAnnotation(ApiIdempotent.class);
        if (annotation != null) {
            tokenService.checkToken(request);
        }

        return true;
    }
}

切面方式处理注解方法(切面方式)

@Aspect
@Component
@Slf4j
public class TokenAspect {

    @Autowired
    private TokenService tokenService;

    @Pointcut("execution(public * me.zxxj.studyidempotent.controller.TestController.*(..)) && @annotation(me.zxxj.studyidempotent.annotations.ApiIdem)")
    public void tokenAdvice() {
    }

    @Before("tokenAdvice()")
    public void tokenCheck(JoinPoint joinPoint) {
        log.info("进入切面");
        // 获取 HttpServletRequest,貌似直接用 @Autowired 注入就可以了
        HttpServletRequest request = ((ServletRequestAttributes) RequestContextHolder.currentRequestAttributes()).getRequest();
        tokenService.checkToken(request);
    }

}

后端处理需要幂等性处理的方法

@Override
public void checkToken(HttpServletRequest request) {
    // 获取判断 header 中的 token
    String token = request.getHeader(TOKEN_FLAG);

    // header 中不存在尝试在 参数中获取
    if (!StringUtils.hasLength(token)) {
        token = request.getParameter(TOKEN_FLAG);
        if (!StringUtils.hasLength(token)) {
            throw new ServiceException(RCode.ILLEGAL_ARGUMENT.getMsg());
        }
    }

    String zone = String.format(TOKEN_PREFIX, token);
    // 判断 token 是否在 redis 中
    if (!redisTemplate.hasKey(zone)) {
        throw new ServiceException(RCode.REPETITIVE_OPERATION.getMsg());
    }


    // 删除 token
    Boolean delete = redisTemplate.delete(zone);

    // 如果删除失败,说明已经被其他线程删除了
    if (!delete) {
        throw new ServiceException(RCode.REPETITIVE_OPERATION.getMsg());
    }
}

参考 & 学习