[Spring] 接口幂等性组件开发-分布式锁实现方式

接口幂等性组件开发

包名约定: org.penistrong.wheel.idempotence

接口幂等性及其解决方案

HTTP/1.1 中对幂等性的定义如下:

幂等性描述了一次和多次请求某一个资源对于资源本身应该具有相同的效果。任意多次执行某一个请求,对于资源本身应该具有同样的副作用。

副作用是指不会对结果产生破坏性或者不可预料的结果,任意多次执行对资源本身产生的影响均与一次执行的影响相同。

接口幂等性是实际业务中经常需要处理的问题,比如:

  • 前端表单重复提交: 前端未做防抖,用户短时间内多次点击提交按钮,导致后端接口多次执行,产生多条重复数据

  • 脚本恶意刷单: 恶意用户通过脚本高频调取接口,产生大量恶意重复数据

  • 接口超时重复提交: 第三方调用接口时因为网络波动而重试,而前一个数据报姗姗来迟,导致一个请求可能被提交多次

  • 消息重复消费: 消息队列的消息在各个环节未被正确处理时,可能会导致消费者重复消费消息

维护接口幂等性显然会增加后端的逻辑复杂性,但是在一些对数据一致性要求较高的场景下接口幂等性是必须要保证的(比如支付接口、订单接口等)

解决方案概览

前置需求: 不管是什么方案都需要一个全局唯一ID标记某条请求的唯一性,根据不同的解决方案使用不同形式的全局唯一ID即可

对于分布式环境而言,为了保证不同实例能够生成分布式全局唯一ID,可以使用类似雪花算法的方式生成唯一性ID(百度的UidGenerator或者美团的Leaf),或者利用中间件实现分布式锁

数据库唯一主键

利用关系型数据库的主键唯一约束,注意不是表自己的自增主键,而是分布式ID作为主键充当全局唯一ID

出现重复提交时就会插入重复数据,insert就会抛出异常,业务逻辑捕获该异常后认为出现了重复提交再进行后续处理,但这种方案仅适用于插入场景且是将压力转移到数据库上,并发较高的情况下不适用

数据库悲观锁

利用数据库的事务,在请求到达时开启事务,由数据库控制在快照读/当前读的操作时加锁(比如临键锁、插入意向锁等),事务中的查询、更新、删除流程都会加锁,保证了事务的原子性

当又有一条重复请求到达时,如果上一条请求开启的事务还未结束,则相关资源被锁定,新的请求无法获取到锁,从而保证了幂等性

但是悲观锁会导致数据行、数据表被锁,其他接口如果想要操作相关数据也只能等到,如果当前事务耗时较长就会影响接口性能。同时,由于每个事务都是一个与数据库的连接绑定的,当并发量较高时显然会耗尽数据库连接池,所以悲观锁方案不太适用于接口幂等性校验

数据库乐观锁

利用版本号机制的乐观锁,在数据表中添加一列字段version,充当数据的版本标识

不开启事务的情况下,先利用查询语句获取数据及其版本号:

SELECT id, order_no, version FROM order WHERE order_no = ${order_no};

在更新时,将上一步查询出的版本号version连同order_no一起作为条件执行更新:

UPDATE order SET version = version + 1, status = 'purchased' WHERE order_no = ${order_no} AND version = ${version};

如果在查询和更新步骤的中间又到达一条重复请求,该重复在更新时由于version不匹配(已被上一条请求更改),更新语句不会生效,从而保证了幂等性

乐观锁的解决方案仅适用于更新场景,如果接口不需要操作数据库,那么乐观锁便失效了

去重表

大致步骤为:

  1. 增加一张去重表,其中的某个字段建立唯一索引作为全局唯一ID

  2. 调用者发起请求,后端将这次请求的部分信息和生成的全局唯一ID插入到这张去重表中,并设置过期时间

    • 插入成功则说明不存在其他重复请求,继续执行业务逻辑
    • 插入失败说明本次请求时重复提交,直接返回
  3. 设置过期时间的原因是,如果实例宕机导致去重表中已经完成的请求对应的数据没有被删除,就会导致后续相同请求提交失败,增加过期时间条件可以额外判断

该方案是借助于数据冗余检查重复提交,但是数据一致性的维护难度较高:

  • 如果去重表和业务表不在一个库里,当业务逻辑失败需要回滚时,需要主动删除去重表中的数据
  • 如果去重表和业务表在一个库中,如果事务回滚可以使两个表中的数据一起回滚,但是涉及到分库分表时,维护去重表的数据一致性就会变得更复杂
携带防重Token发起请求

抛开数据库的限制,将接口幂等性扩展到通用情况(比如不需要操作数据库时),可以从调用者的角度上考虑: 调用者在发起请求时携带一个防重Token,后端接口在处理请求时根据Token判断是否重复请求

具体步骤大致为:

  1. 客户端先发送请求获取token,后端生成一个全局唯一ID作为token保存到中间件比如Redis中,同时将该token返回给客户端

  2. 客户端发起实际的业务请求时必须携带该token,后端通过与缓存中的token比对进行校验,执行业务并删除token

  3. 如果校验失败,说明缓存中已不存在token,说明当前请求是重复操作,直接返回相关结果

该方案的优点是可以扩展到不需要操作数据库的场景,但是需要和调用者协商调用过程,无法做到对调用者透明

分布式锁

防重Token方案里已经使用了Redis等中间件,那么生成全局唯一ID后将其作为分布式锁使用

请求到达接口后,根据请求来源参数其他扩展指标等生成一个唯一key作为全局唯一ID,将其存入Redis中并设置过期时间,接口在完成请求的业务逻辑后会删除该key以释放分布式锁,如果在这期间有重复请求到达,那么该请求生成的唯一key因为已经存在故而无法获取分布式锁,从而保证了幂等性

该方案的缺点是,如果重复请求因为网络时延姗姗来迟,而第一条请求已经被处理完毕分布式锁也已释放,那么该重复请求还是能够正常执行,可能需要引入存储操作唯一性记录的数据表做进一步的校验

分布式锁实现方式

调研了常见的接口幂等性解决方案后,防重Token和分布式锁都是较为简单且有效的方式,但是前者还需要和接口调用者协商调用过程,无法即开即用,所以决定采用分布式锁的方式基于注解+AOP开发一个简单的接口幂等性小组件

注解定义

接口幂等性的核心注解需要在想要保证幂等性的接口上使用,该方案中使用的全局唯一性ID由AOP切面根据接口参数、请求来源等生成,注解定义如下:

  • 幂等性检查间隔intervel
  • 时间单位timeUnit
  • 幂等性检查失败时的提示信息message
@Documented
@Inherited
@Target(ElementType.METHOD)
@Retention(RetentionPolicy.RUNTIME)
public @interface NoRepeatSubmit {

    int interval() default 1000;

    TimeUnit timeUnit() default TimeUnit.MILLISECONDS;

    String message() default "请勿重复提交";
}

AOP切面定义

将被@NoRepeatSubmit注解的接口方法设置为切面,在接口执行前和执行后进行处理,而分布式锁是在接口执行前获取,接口执行后释放

分布式锁的键即接口幂等性解决方案所需的全局唯一ID,在实际开发中可以根据场景自行选择生成方式,我这里选择的属性为:

  • 请求头中携带的JWT,JWT不存在时则使用请求的X-Real-IP

  • 接口接收的参数列表,利用过滤器滤掉MultipartFile等业务无关的参数不进行拼接

拼接以上属性后计算其MD5值,和常量前缀请求URL再次拼接后存入Redis中,当其他实例在该分布式锁存在期间接收到相同请求时则判断为重复请求,抛出异常予以拦截

@Slf4j
@RequiredArgsConstructor
@Aspect
@EnableAspectJAutoProxy(proxyTargetClass = true)
public class NoRepeatSubmitAspect {

    private static final ThreadLocal<String> KEY_CACHE = new ThreadLocal<>();

    private final RedisService redisService;

    // HttpServletRequest被注入时,IOC容器在依赖查找时寻找到的是RequestObjectFactory
    // 其内部使用了RequestContextHolder利用ThreadLocal获取当前线程的HttpServletRequest
    // 而这个RequestObjectFactory会被AutowireUtils创建一个代理对象
    // 最终由ObjectFactoryDelegatingInvocationHandler调用invoke方法触发当前线程对应的HttpServletRequest中的方法
    private final HttpServletRequest request;

    @Pointcut("@annotation(noRepeatSubmit)")
    private void checkRepeatSubmit(NoRepeatSubmit noRepeatSubmit) {}

    /**
     * 前置通知,用于拦截验证是否重复提交,使用请求url + 请求参数 + JWT token生成唯一Key
     * @param joinPoint 切入点
     * @param noRepeatSubmit 防止重复提交注解
     */
    @Before(value = "checkRepeatSubmit(noRepeatSubmit)", argNames = "joinPoint,noRepeatSubmit")
    public void doBefore(JoinPoint joinPoint, NoRepeatSubmit noRepeatSubmit) {
        long interval = noRepeatSubmit.interval() > 0 ? noRepeatSubmit.timeUnit().toMillis(noRepeatSubmit.interval()) : 0;

        String url = request.getRequestURI();

        String params = paramsArrayToString(joinPoint.getArgs());
        // 利用JWT(不存在JWT则使用IP地址替代)拼接参数后计算MD5值
        String mixedMD5 = Optional.ofNullable(request.getHeader(HEADER_AUTHORIZATION_KEY))
                .map(StringUtils::trimToEmpty)
                .orElseGet(() -> request.getHeader(HEADER_IP_KEY));
        mixedMD5 = SecureUtil.md5(mixedMD5 + ":" + params);

        String submitKey = IDEMPOTENCE_NO_REPEAT_SUBMIT_KEY_CACHE_PREFIX + ":" + url + ":" + mixedMD5;
        if (redisService.getLock(submitKey, "", TimeUnit.MILLISECONDS.toSeconds(interval))) {
            KEY_CACHE.set(submitKey);
        } else {
            log.warn("[Repeat Submit Detected] Url: '{}' with params: {}", url, params);
            throw new IdempotenceException(noRepeatSubmit.message());
        }
    }

    /**
     * 后置通知,用于清除缓存
     * @param joinPoint 切入点
     * @param noRepeatSubmit 防止重复提交注解
     * @param result 返回值, 统一响应结果类型为CommonResult
     */
    @AfterReturning(pointcut = "checkRepeatSubmit(noRepeatSubmit)", returning = "result", argNames = "joinPoint,noRepeatSubmit,result")
    public void doAfterReturning(JoinPoint joinPoint, NoRepeatSubmit noRepeatSubmit, Object result) {
        if (result instanceof CommonResult) {
            try {
                CommonResult<?> r = (CommonResult<?>) result;
                if (r.success())
                    return;
                redisService.removeLock(KEY_CACHE.get());
            } finally {
                KEY_CACHE.remove();
            }
        }
    }

    /**
     * 拦截异常,用于清除缓存
     * @param joinPoint 切入点
     * @param noRepeatSubmit 防止重复提交注解
     * @param e 异常信息
     */
    @AfterThrowing(pointcut = "checkRepeatSubmit(noRepeatSubmit)", throwing = "e", argNames = "joinPoint,noRepeatSubmit,e")
    public void doAfterThrowing(JoinPoint joinPoint, NoRepeatSubmit noRepeatSubmit, Exception e) {
        redisService.removeLock(KEY_CACHE.get());
        KEY_CACHE.remove();
    }

    private String paramsArrayToString(Object[] paramsArray) {
        StringBuilder params = new StringBuilder();
        Optional.ofNullable(paramsArray)
                .stream()
                .filter(param -> !paramFilter(param))
                .map(JSONUtil::toJsonStr)
                .forEach(param -> params.append(param).append(" "));
        return params.toString().trim();
    }

    /**
     * 过滤不需要处理的参数,主要是MultipartFile对象
     * @param o 参数对象
     * @return true: 不需要处理 false: 需要处理
     */
    public boolean paramFilter(final Object o) {
        Class<?> clazz = o.getClass();
        if (clazz.isArray()) {
            return clazz.getComponentType().isAssignableFrom(MultipartFile.class);
        } else if (Collection.class.isAssignableFrom(clazz)) {
            Collection<?> collection = (Collection<?>) o;
            if (!collection.isEmpty()) {
                return collection.iterator().next() instanceof MultipartFile;
            }
        } else if (Map.class.isAssignableFrom(clazz)) {
            Map<?, ?> map = (Map<?, ?>) o;
            if (!map.isEmpty()) {
                return map.values().iterator().next() instanceof MultipartFile;
            }
        }
        return o instanceof MultipartFile
                || o instanceof HttpServletRequest
                || o instanceof HttpServletResponse
                || o instanceof BindingResult;
    }
}

// Constants defined in IdempotenceConfigConstant.java
public class IdempotenceConfigConstant {

    public static final String IDEMPOTENCE_NO_REPEAT_SUBMIT_KEY_CACHE_PREFIX = "idempotence:cache:submitKey";

    public static final String HEADER_AUTHORIZATION_KEY = "Authorization";

    public static final String HEADER_IP_KEY = "X-Real-IP";
}

上述AOP切面中,使用了前置通知(@Before)、返回通知(@AfterReturning)和异常通知(@AfterThrowing)进行处理

由于要保证请求在处理完成返回返回值或者抛出异常时移除分布式锁,所以使用ThreadLocal存储当前请求的分布式锁Key,这样在返回通知和异常通知中都可以获取到该Key,从而移除分布式锁

注意,在返回通知中,只有当该请求的返回状态码不为200时才会移除分布式锁,即该请求出错后移除分布式锁就可以再次重复提交,否则认为本次请求成功,在防止重复提交的间隔时间interval里再次接收到相同请求则不予处理

如果业务场景不需要这样的功能,可以将返回通知中的判断逻辑去掉

如果使用环绕通知@Around可以不使用ThreadLocal存储分布式锁的key,在try…catch…finally…块中处理分布式锁即可

创建自定义Spring-Boot-Starter

以下配置方式基于Spring Boot 3.0,使用的部分注解是Spring Boot提供的复合注解,而不是Spring框架本身提供的

使用Spring框架提供的基础注解也可以控制相关Bean的创建和注入过程

编写自动配置类IdempotenceAutoConfiguration,在该类中将NoRepeatSubmitAspect切面注入到IOC容器中

  • 是否开启切面由配置文件中的idempotence.enabled属性是否为true决定
  • 由于切面中需要使用RedisService,所以在@AutoConfiguration中指定在RedisAutoConfiguration配置完毕后再加载
@AutoConfiguration(after = {RedisAutoConfiguration.class})
@ConditionalOnProperty(prefix = "idempotence", name = "enabled", havingValue = "true", matchIfMissing = false)
public class IdempotenceAutoConfiguration {

    @Bean
    public NoRepeatSubmitAspect noRepeatSubmitAspect(RedisService redisService, HttpServletRequest request){
        return new NoRepeatSubmitAspect(redisService, request);
    }
}

在资源目录resource/META-INF/spring/下添加自动配置文件org.springframework.boot.autoconfigure.AutoConfiguration.imports,供Spring Boot扫描并加载自动配置类

# 自动配置类的全限定名
org.penistrong.wheel.idempotence.config.IdempotenceAutoConfiguration

为了在编写application.yaml时可以做到自动提示配置字段(需要spring-boot-configuration-processor依赖),在没有添加IdempotenceProperties类的情况下(无法被@EnableConfigurationProperties注解加载),在资源目录resource/META-INF/下添加additional-spring-configuration-metadata.json,编写简单的元数据文件:

{
  "properties": [
    {
      "name": "idempotence.enabled",
      "type": "java.lang.Boolean",
      "description": "是否开启接口幂等性校验."
    }
  ]
}

只需在配置文件比如application.yaml中添加上述字段即可

idempotence:
  enabled: true

包结构一览

接口幂等性组件开发.png

源码

接口幂等性组件作为轮子项目的子模块存在,仓库地址https://github.com/Penistrong/Java-Wheels/tree/master/idempotence

评论 1
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值