基于AOP和自定义注解实现接口幂等性、消息队列消费幂等性

182 阅读20分钟

🎯导读:该文档主要介绍了在软件开发中如何实现接口的幂等性,以确保在不稳定网络环境或系统故障下,重复请求同一接口不会引起系统状态的异常变化。文档首先解释了幂等性的概念及其重要性,随后详细探讨了几种实现幂等性的策略,包括使用token机制、乐观锁与版本号、分布式锁、唯一约束以及防重表等方法。文档中还提供了具体的代码示例,展示了如何利用SpEL表达式来生成请求的唯一标识符,从而实现请求的幂等性控制。此外,文档描述了一个基于简单工厂模式的handler工厂类,它可以根据不同的场景和类型选择合适的幂等性处理策略。特别地,文档深入讨论了如何在消息队列(MQ)场景下实现幂等性,通过使用Redis缓存来存储幂等性标记,确保消息不会被重复消费。最后,文档提供了一些配置类的例子,说明了如何将幂等性相关的组件注入到Spring容器中,以便于在整个应用中统一管理幂等性逻辑。

🏠️ 项目仓库:智能排班系统

📙 项目介绍:【智能排班系统】开源说明

接口幂等性介绍

接口幂等性,源自数学中的幂运算性质,意指一个操作无论执行一次还是多次,对系统状态的影响均等同于执行一次的效果。在编程和API设计语境中,幂等性意味着对同一接口发起的多次相同请求,不论请求次数如何,其最终结果都是确定的且对系统资源状态的改变是一致的

为何重视接口幂等性

接口幂等性的价值主要体现在以下几个方面:

  • 容错与重试机制在网络环境不稳定或系统短暂故障时,客户端的请求可能丢失、超时或重复发送。幂等接口允许客户端在未收到明确响应或响应不可信时,安全地重新发起请求,而不必担心导致数据不一致或资源的重复消耗。例如,支付系统中的扣款操作,即使因网络问题重复发送扣款请求,幂等设计确保只扣除一次费用,避免用户资金被重复扣减。

  • 系统一致性维护: 在分布式环境中,系统间的通信延迟、节点故障等情况可能导致操作的重复执行。幂等接口确保即使在这些异常情况下,系统状态也能保持一致,不会因为重复请求而出现逻辑混乱或数据冲突。

  • 安全性提升防止恶意攻击或用户误操作导致的资源滥用。例如,用户无意中连续点击“提交订单”按钮,幂等设计能确保仅创建一份有效的订单,而不是生成多个重复订单。

幂等性实现策略

实现接口幂等性通常采用以下几种策略:

token机制

服务端提供一个获取token的接口。在执行操作之前,先去访问token接口来获取一个token,该token会被存储到Redis中。在发起操作请求的时候带上token,在执行操作逻辑之前,先判断token存在,如果存在,先删除token则执行操作逻辑;否则说明同样的请求已经被处理过,不再执行处理逻辑

存在问题: 先删除token还是执行完业务再删除
  • 先删除:可能导致业务由于服务器宕机没有执行,重试还带上之前token,由于防重设计,请求不能再执行
  • 后删除:业务处理成功,但是服务闪断,没有成功删除token。发起同样的请求,导致业务被执行两遍

个人看法:不好保证删除token和执行业务的原子性,本人还没有找到可行的方案

乐观锁与版本号

在更新资源时,使用乐观锁机制,通过比较在更新资源的时候比较版本号来判断操作是否为重复执行。若版本号不匹配(即资源已被其他操作更新),则拒绝此次更新请求。

个人看法:需要对数据库进行改造,比较麻烦,除非一开始在设计数据库的时候就考虑

分布式锁

通过获取方法参数或使用SpEL表达式来生成分布式锁的键,在执行业务之前获取分布式锁,获取锁成功才可以执行业务,业务执行完毕删除锁,保证同一时间同一个请求只能处理一次

唯一约束

  • 数据库唯一约束:给MySQL表的字段添加唯一约束,例如给username添加唯一约束之后,就算用户同时发起了多次注册请求,也只有一个请求可以注册成功
  • Redis Set 防重:将方法参数的MD5码添加到set中,每次执行请求时,判断md5是否在set中,如果在,说明已经执行过,直接返回失败(不建议,set会越来越多)

防重表

类似上面的唯一约束,只是单独用一个表来存储响应标识,并对该表示设置唯一约束。能插入标识说明是第一次执行

实现

枚举

【应用场景枚举】

package com.dam.enums;

/**
 * 幂等验证场景枚举
 *
 */
public enum IdempotentSceneEnum {
    
    /**
     * 基于 RestAPI 场景验证
     */
    RESTAPI,
    
    /**
     * 基于 MQ 场景验证
     */
    MQ
}

【验证方法类型枚举】

package com.dam.enums;

/**
 * 幂等验证类型枚举
 *
 */
public enum IdempotentTypeEnum {
    
    /**
     * 基于 Token 方式验证
     */
    TOKEN,
    
    /**
     * 基于方法参数方式验证
     */
    PARAM,
    
    /**
     * 基于 SpEL 表达式方式验证
     */
    SPEL
}

【消费状态枚举】

import lombok.Getter;
import lombok.RequiredArgsConstructor;

import java.util.Objects;

/**
 * 幂等 MQ 消费状态枚举
 */
@RequiredArgsConstructor
public enum IdempotentMQConsumeStatusEnum {

    /**
     * 消费中
     */
    CONSUMING("0"),

    /**
     * 已消费
     */
    CONSUMED("1"),
    
    /**
     * 消费失败
     */
    CONSUME_FAILURE("2");

    @Getter
    private final String code;

    /**
     * 如果消费状态等于消费失败,返回失败
     *
     * @param consumeStatus 消费状态
     * @return 是否消费失败
     */
    public static boolean isError(String consumeStatus) {
        return Objects.equals(CONSUME_FAILURE.code, consumeStatus);
    }
}

定义注解

import com.dam.enums.IdempotentSceneEnum;
import com.dam.enums.IdempotentTypeEnum;

import java.lang.annotation.*;

/**
 * 幂等注解
 */
@Target({ElementType.TYPE, ElementType.METHOD})
@Retention(RetentionPolicy.RUNTIME)
@Documented
public @interface Idempotent {

    /**
     * 幂等Key,只有在 {@link Idempotent#type()} 为 {@link IdempotentTypeEnum#SPEL} 时生效
     */
    String key() default "";

    /**
     * 触发幂等失败逻辑时,返回的错误提示信息
     */
    String message() default "您操作太快,请稍后再试";

    /**
     * 验证幂等类型,支持多种幂等方式
     * RestAPI 建议使用 {@link IdempotentTypeEnum#TOKEN} 或 {@link IdempotentTypeEnum#PARAM}
     * 其它类型幂等验证,使用 {@link IdempotentTypeEnum#SPEL}
     */
    IdempotentTypeEnum type() default IdempotentTypeEnum.PARAM;

    /**
     * 验证幂等场景,支持多种 {@link IdempotentSceneEnum}
     */
    IdempotentSceneEnum scene() default IdempotentSceneEnum.RESTAPI;

    /**
     * 设置防重令牌 Key 前缀,MQ 幂等去重可选设置
     * {@link IdempotentSceneEnum#MQ} and {@link IdempotentTypeEnum#SPEL} 时生效
     */
    String uniqueKeyPrefix() default "";

    /**
     * 设置防重令牌 Key 过期时间,单位秒,默认 1 小时,MQ 幂等去重可选设置
     * {@link IdempotentSceneEnum#MQ} and {@link IdempotentTypeEnum#SPEL} 时生效
     */
    long keyTimeout() default 3600L;
}

handler处理器

handler接口定义

注意,接口定义了两个空方法,因为不需要所有处理器都需要处理那两个方法。该接口默认实现了execute方法,即每个处理器的共同之处都是先调用buildParam构建包装参数,再调用handler方法来进行幂等逻辑判断,

import com.dam.annotation.Idempotent;
import com.dam.core.aop.IdempotentParam;
import org.aspectj.lang.ProceedingJoinPoint;

/**
 * 幂等执行处理器
 *
 */
public interface IdempotentExecuteHandler {

    IdempotentParam buildParam(ProceedingJoinPoint joinPoint);

    /**
     * 幂等处理逻辑
     *
     * @param wrapper 幂等参数包装器
     */
    void handler(IdempotentParam wrapper);

    /**
     * 执行幂等处理逻辑
     *
     * @param joinPoint  AOP 方法处理
     * @param idempotent 幂等注解
     */
    default void execute(ProceedingJoinPoint joinPoint, Idempotent idempotent){
        // 模板方法模式:构建幂等参数包装器
        IdempotentParam idempotentParam = buildParam(joinPoint).setIdempotent(idempotent);
        // 如果不满足幂等,handler方法会通过抛出异常来使下面的程序被中断
        handler(idempotentParam);
    };

    /**
     * 幂等异常流程处理
     */
    default void exceptionProcessing() {

    }

    /**
     * 执行目标方法成功的后置处理
     */
    default void postProcessing() {

    }
}
接口实现:基于token机制
Service
import com.dam.core.handler.IdempotentExecuteHandler;

/**
 * Token 实现幂等接口
 *
 */
public interface IdempotentTokenService extends IdempotentExecuteHandler {

    /**
     * 创建幂等验证Token
     */
    String createToken();
}

Controller

使用token机制,在发送具体请求之前,需要先发送一个前置请求来向服务端获取token,因此需要开发一个Controller供前端使用

import com.dam.constant.KeyConstants;
import com.dam.model.result.R;
import lombok.RequiredArgsConstructor;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.RestController;

/**
 * 基于 Token 验证请求幂等性控制器
 */
@RestController
@RequiredArgsConstructor
public class IdempotentTokenController {

    private final IdempotentTokenService idempotentTokenService;

    /**
     * 请求申请Token
     */
    @GetMapping("/token")
    public R createToken() {
        return R.ok().addData(KeyConstants.TOKEN_KEY, idempotentTokenService.createToken());
    }
}
实现
import cn.hutool.core.util.StrUtil;
import com.dam.constant.KeyConstants;
import com.dam.core.aop.IdempotentParam;
import com.dam.exception.SSSException;
import com.dam.model.enums.ResultCodeEnum;
import com.dam.properties.IdempotentTokenProperties;
import com.google.common.base.Strings;
import lombok.RequiredArgsConstructor;
import org.aspectj.lang.ProceedingJoinPoint;
import org.springframework.data.redis.core.StringRedisTemplate;
import org.springframework.web.context.request.RequestContextHolder;
import org.springframework.web.context.request.ServletRequestAttributes;

import javax.servlet.http.HttpServletRequest;
import java.util.Optional;
import java.util.UUID;

/**
 * 基于 Token 验证请求幂等性, 通常应用于 RestAPI 方法
 */
@RequiredArgsConstructor
public final class IdempotentTokenExecuteHandler implements IdempotentTokenService {
    /**
     * Redis 操作模板,用于存储和验证幂等Token
     */
    private final StringRedisTemplate redisTemplate;
    /**
     * 幂等相关配置属性
     */
    private final IdempotentTokenProperties idempotentTokenProperties;

    /**
     * 如果配置文件里面没有写明,则使用该默认值
     */
    private static final String TOKEN_PREFIX_KEY = KeyConstants.IDEMPOTENT_PREFIX + "token:";

    /**
     * 幂等Token在Redis中的默认过期时间(毫秒),若配置文件未指定,则使用此值
     */
    private static final long TOKEN_EXPIRED_TIME = 6000;

    @Override
    public IdempotentParam buildParam(ProceedingJoinPoint joinPoint) {
        return new IdempotentParam();
    }

    /**
     * 创建一个新的幂等Token。生成一个全局唯一的UUID,并添加前缀(从配置或默认值获取),
     * 将此Token作为键存入Redis,并设置一个过期时间(从配置或默认值获取)。
     * 值为空字符串,因为仅用于标识Token的存在,不存储额外数据。
     *
     * @return 新创建的幂等Token字符串
     */
    @Override
    public String createToken() {
        String token = Optional.ofNullable(Strings.emptyToNull(idempotentTokenProperties.getPrefix())).orElse(TOKEN_PREFIX_KEY) + UUID.randomUUID();
        redisTemplate.opsForValue().set(token, "", Optional.ofNullable(idempotentTokenProperties.getTimeout()).orElse(TOKEN_EXPIRED_TIME));
        return token;
    }

    /**
     * 处理幂等Token验证逻辑。在接收到业务请求时,从请求头或请求参数中提取幂等Token,
     * 然后验证该Token在Redis中的存在性。如果存在,则删除该Token并允许业务逻辑执行;
     * 否则,抛出异常表示重复请求或无效Token。
     *
     * @param wrapper 幂等参数包装器
     */
    @Override
    public void handler(IdempotentParam wrapper) {
        // 获取当前请求上下文中的HttpServletRequest对象
        HttpServletRequest request = ((ServletRequestAttributes) (RequestContextHolder.currentRequestAttributes())).getRequest();
        // 从请求头或者请求参数中获取token对应的值
        String token = request.getHeader(KeyConstants.TOKEN_KEY);
        if (StrUtil.isBlank(token)) {
            token = request.getParameter(KeyConstants.TOKEN_KEY);
            if (StrUtil.isBlank(token)) {
                // 如果请求中未找到Token,抛出异常
                throw new SSSException(ResultCodeEnum.IDEMPOTENT_TOKEN_NULL_ERROR);
            }
        }
        // 验证并删除Token
        Boolean tokenDelFlag = redisTemplate.delete(token);
        if (!tokenDelFlag) {
            // 删除失败,说明token对应的请求已经被执行过了,不能再执行了
            String errMsg = StrUtil.isNotBlank(wrapper.getIdempotent().message())
                    ? wrapper.getIdempotent().message()
                    : ResultCodeEnum.IDEMPOTENT_TOKEN_DELETE_ERROR.getMessage();
            throw new SSSException(ResultCodeEnum.IDEMPOTENT_TOKEN_DELETE_ERROR.getCode(), errMsg);
        }
    }
}
接口实现:参数param作为分布式锁的键
Service

注意,该Service继承接口IdempotentExecuteHandler,多写一层接口是为了定义一些属于自己的方法

import com.dam.core.handler.IdempotentExecuteHandler;

/**
 * 参数方式幂等实现接口
 *
 */
public interface IdempotentParamService extends IdempotentExecuteHandler {
}
实现

幂等性判断逻辑:将包含请求路径当前用户ID参数MD5值组合为key来做分布式锁,如果请求能加锁成功,说明是第一次执行,执行结束之后解锁。使用该方式可以保证同一时刻,同样的请求只有一个在执行。

import cn.hutool.core.util.StrUtil;
import cn.hutool.crypto.digest.DigestUtil;
import com.alibaba.fastjson2.JSON;
import com.dam.constant.KeyConstants;
import com.dam.context.UserContext;
import com.dam.core.IdempotentContext;
import com.dam.core.aop.IdempotentParam;
import com.dam.exception.SSSException;
import com.dam.model.enums.ResultCodeEnum;
import lombok.RequiredArgsConstructor;
import org.aspectj.lang.ProceedingJoinPoint;
import org.redisson.api.RLock;
import org.redisson.api.RedissonClient;
import org.springframework.web.context.request.RequestContextHolder;
import org.springframework.web.context.request.ServletRequestAttributes;

/**
 * 基于方法参数验证请求幂等性
 * 基于分布式锁实现
 */
@RequiredArgsConstructor//使用构造器注入
public final class IdempotentParamExecuteHandler implements IdempotentParamService {

    /**
     * 注入Redisson客户端,用于操作分布式锁
     */
    private final RedissonClient redissonClient;

    /**
     * 获取到的分布式锁对应的key,用来给上下文使用
     */
    private final static String CONTEXT_LOCK_KEY = KeyConstants.IDEMPOTENT_PREFIX + "lock:param:restAPI";

    @Override
    public IdempotentParam buildParam(ProceedingJoinPoint joinPoint) {
        // 构建分布式锁的唯一key,包含请求路径、当前用户ID和参数MD5值
        String lockKey = String.format(KeyConstants.IDEMPOTENT_PREFIX + "lock:param:path:%s:currentUserId:%s:md5:%s", getServletPath(), getCurrentUserId(), calcArgsMD5(joinPoint));
        return IdempotentParam.builder().lockKey(lockKey).joinPoint(joinPoint).build();
    }

    /**
     * 幂等逻辑判断,如果获取分布式锁失败,说明同样的请求和参数已经在执行,返回异常
     *
     * @param wrapper 幂等参数包装器
     */
    @Override
    public void handler(IdempotentParam wrapper) {
        // 从包装器中获取分布式锁对应的key
        String lockKey = wrapper.getLockKey();
        // 通过RedissonClient获取分布式锁实例
        RLock lock = redissonClient.getLock(lockKey);
        // 尝试获取锁,如果无法立即获取(即锁已被其他请求持有),则抛出异常,表示请求重复
        if (!lock.tryLock()) {
            throw new SSSException(ResultCodeEnum.FAIL.getCode(), wrapper.getIdempotent().message());
        }
        // 将获取到的锁存储在IdempotentContext中,以便后续解锁操作
        IdempotentContext.put(CONTEXT_LOCK_KEY, lock);
    }

    /**
     * 将分布式锁进行解锁
     */
    @Override
    public void postProcessing() {
        RLock lock = null;
        try {
            // 从IdempotentContext中获取之前存储的锁实例
            lock = (RLock) IdempotentContext.getKey(CONTEXT_LOCK_KEY);
        } finally {
            if (lock != null) {
                lock.unlock();
            }
        }
    }


    /**
     * 获取当前线程上下文中的ServletPath(请求路径)
     *
     * @return 当前线程上下文 ServletPath
     */
    private String getServletPath() {
        ServletRequestAttributes sra = (ServletRequestAttributes) RequestContextHolder.getRequestAttributes();
        // 从ServletAttributes中获取请求的ServletPath
        return sra.getRequest().getServletPath();
    }

    /**
     * 获取当前操作用户的ID
     *
     * @return 当前操作用户 ID
     */
    private String getCurrentUserId() {
        // 从UserContext中获取当前用户的ID
        String userId = UserContext.getUserId();
        if (StrUtil.isBlank(userId)) {
            throw new SSSException(ResultCodeEnum.FAIL.getCode(), "用户ID获取失败,请登录");
        }
        return userId;
    }

    /**
     * 计算参数的MD5码
     *
     * @param joinPoint 包含方法参数的ProceedingJoinPoint对象
     * @return 参数的MD5值,用于标识请求参数的唯一性
     */
    private String calcArgsMD5(ProceedingJoinPoint joinPoint) {
        String md5 = DigestUtil.md5Hex(JSON.toJSONBytes(joinPoint.getArgs()));
        return md5;
    }
}

接口实现:SPEL表达式的值作为分布式锁的键
Service
import com.dam.core.handler.IdempotentExecuteHandler;

/**
 * SpEL 方式幂等实现接口
 *
 */
public interface IdempotentSpELService extends IdempotentExecuteHandler {
}
实现
import com.dam.annotation.Idempotent;
import com.dam.constant.KeyConstants;
import com.dam.core.IdempotentContext;
import com.dam.core.aop.IdempotentAspect;
import com.dam.core.aop.IdempotentParam;
import com.dam.core.handler.spel.IdempotentSpELService;
import com.dam.exception.SSSException;
import com.dam.model.enums.ResultCodeEnum;
import com.dam.toolkit.SpELUtil;
import lombok.RequiredArgsConstructor;
import lombok.SneakyThrows;
import org.aspectj.lang.ProceedingJoinPoint;
import org.aspectj.lang.reflect.MethodSignature;
import org.redisson.api.RLock;
import org.redisson.api.RedissonClient;

/**
 * 基于 SpEL 方法验证请求幂等性,适用于 RestAPI(Restful) 场景
 * 基于分布式锁实现
 */
@RequiredArgsConstructor
public final class IdempotentSpELByRestAPIExecuteHandler implements IdempotentSpELService {

    /**
     * Redisson 客户端,用于操作分布式锁
     */
    private final RedissonClient redissonClient;

    /**
     * 分布式锁的基础键名,用于存储全局唯一标识的锁
     */
    private final static String LOCK = KeyConstants.IDEMPOTENT_PREFIX + "lock:spEL:restAPI";

    /**
     * 构建幂等参数包装器,通过解析 SpEL 表达式生成请求的唯一标识(锁键)
     *
     * @param joinPoint 切点对象,包含目标方法信息及参数
     * @return 构建好的幂等参数包装器
     */
    @SneakyThrows
    @Override
    public IdempotentParam buildParam(ProceedingJoinPoint joinPoint) {
        // 从切点对象中获取方法上的 @Idempotent 注解
        Idempotent idempotent = IdempotentAspect.getIdempotent(joinPoint);
        // 使用 SpEL 工具解析注解中的 key 属性表达式,生成请求的唯一标识(锁键)
        String lockKey = (String) SpELUtil.parseKey(idempotent.key(), ((MethodSignature) joinPoint.getSignature()).getMethod(), joinPoint.getArgs());
        return IdempotentParam.builder().lockKey(lockKey).joinPoint(joinPoint).build();
    }

    /**
     * 幂等性逻辑处理。尝试获取分布式锁,如果获取失败(即锁已被其他请求持有),说明有相同方法和参数的请求正在执行,
     * 此时抛出异常,拒绝当前请求。
     *
     * @param wrapper 幂等参数包装器
     */
    @Override
    public void handler(IdempotentParam wrapper) {
//        System.out.println("wrapper.getLockKey():" + wrapper.getLockKey());
        String uniqueKey = KeyConstants.IDEMPOTENT_PREFIX + wrapper.getIdempotent().uniqueKeyPrefix() + wrapper.getLockKey();
        RLock lock = redissonClient.getLock(uniqueKey);
        // 尝试获取锁,如果无法立即获取(即锁已被其他请求持有),抛出异常,表示请求重复
        if (!lock.tryLock()) {
//            System.out.println("wrapper.getIdempotent().message():" + wrapper.getIdempotent().message());
            throw new SSSException(ResultCodeEnum.FAIL.getCode(), wrapper.getIdempotent().message());
        }
        // 上下文用来传递分布式锁,便于请求处理完成之后进行解锁
        IdempotentContext.put(LOCK, lock);
    }

    /**
     * 后处理,对分布式锁进行解锁
     */
    @Override
    public void postProcessing() {
        // 从 IdempotentContext 中获取之前存储的锁实例
        RLock lock = null;
        try {
            lock = (RLock) IdempotentContext.getKey(LOCK);
        } finally {
            // 如果锁实例不为空,进行解锁操作
            if (lock != null) {
                lock.unlock();
            }
        }
    }

    /**
     * 请求的方法执行过程中发生了异常,也对分布式锁进行解锁
     */
    @Override
    public void exceptionProcessing() {
        // 从 IdempotentContext 中获取之前存储的锁实例
        RLock lock = null;
        try {
            lock = (RLock) IdempotentContext.getKey(LOCK);
        } finally {
            // 如果锁实例不为空,进行解锁操作
            if (lock != null) {
                lock.unlock();
            }
        }
    }
}

接口实现:消息队列幂等实现

幂等判断逻辑: 1、生成幂等唯一Key(buildParam方法) 2、使用setnx添加到缓存中,消费状态设置为消费中,过期时间为10分钟。如果setnx返回true,进入第3步;否则判断消费是否为消费中,如果消息处于消费中,不确定消费是否失败,先抛出异常,进入第4步处理异常(handle方法) 3、执行消费,消费结束之后,修改缓存中的消费状态为消费完成(postProcessing方法),并设置过期时间,确保即使在短时间内有相同的重复消息到来,它们也不会被重复处理 4、删除幂等防重标识,消息后面会被重新投递(exceptionProcessing方法)

package com.dam.core.handler.spel.mq;

import com.dam.annotation.Idempotent;
import com.dam.core.IdempotentContext;
import com.dam.core.aop.IdempotentAspect;
import com.dam.core.aop.IdempotentParam;
import com.dam.core.exception.RepeatConsumptionException;
import com.dam.core.handler.spel.IdempotentSpELService;
import com.dam.enums.IdempotentMQConsumeStatusEnum;
import com.dam.toolkit.LogUtil;
import com.dam.toolkit.SpELUtil;
import lombok.RequiredArgsConstructor;
import lombok.SneakyThrows;
import org.aspectj.lang.ProceedingJoinPoint;
import org.aspectj.lang.reflect.MethodSignature;
import org.springframework.data.redis.core.StringRedisTemplate;

import java.util.concurrent.TimeUnit;

/**
 * 基于 SpEL 方法验证请求幂等性,适用于 MQ 场景
 * 这个类主要用于处理消息队列(MQ)中的重复消息问题,确保每条消息只被处理一次。
 */
@RequiredArgsConstructor
public final class IdempotentSpELByMQExecuteHandler implements IdempotentSpELService {

    private final StringRedisTemplate redisTemplate;
    /**
     * 设置一个常量表示 Redis 中存储的状态的超时时间,这里设置为10分钟
     */
    private final static int TIMEOUT = 600;
    /**
     * 用于在上下文中保存包装对象的键
     */
    private final static String WRAPPER = "wrapper:spEL:MQ";

    /**
     * 构建 IdempotentParam 对象,该对象包含了用于幂等性检查所需的信息。
     *
     * @param joinPoint AOP 中的连接点,代表了被通知的方法调用。
     * @return 返回构建好的 IdempotentParam 对象。
     */
    @SneakyThrows
    @Override
    public IdempotentParam buildParam(ProceedingJoinPoint joinPoint) {
        // 获取方法上的 Idempotent 注解
        Idempotent idempotent = IdempotentAspect.getIdempotent(joinPoint);
        // 解析出幂等性的 key,基于方法参数和注解中的表达式
        String key = (String) SpELUtil.parseKey(idempotent.key(), ((MethodSignature) joinPoint.getSignature()).getMethod(), joinPoint.getArgs());
        // 构建并返回 IdempotentParam 对象
        return IdempotentParam.builder().lockKey(key).joinPoint(joinPoint).build();
    }

    /**
     * 幂等逻辑判断,
     *
     * @param wrapper 包含了幂等性检查所需的参数的包装对象。
     */
    @Override
    public void handler(IdempotentParam wrapper) {
        // 生成唯一的幂等性键
        String uniqueKey = wrapper.getIdempotent().uniqueKeyPrefix() + wrapper.getLockKey();
        // 尝试将唯一键设置为正在消费的状态,并设置过期时间
        Boolean setIfAbsent = redisTemplate
                .opsForValue()
                // 将key设置为正在消费状态
                .setIfAbsent(uniqueKey, IdempotentMQConsumeStatusEnum.CONSUMING.getCode(), TIMEOUT, TimeUnit.SECONDS);
        if (setIfAbsent != null && !setIfAbsent) {
            // --if--如果键已存在,则获取其状态并判断是否需要抛出异常
            String consumeStatus = redisTemplate.opsForValue().get(uniqueKey);
            // 判断是否消费失败
            boolean error = IdempotentMQConsumeStatusEnum.isError(consumeStatus);
            if (error) {
                // 记录警告日志
                LogUtil.getLog(wrapper.getJoinPoint()).warn("[{}] MQ 重复消费, {}.", uniqueKey, "等待客户端延迟消费");
                // 抛出异常,删除幂等性键,待消息队列重新投递来消费
                throw new RepeatConsumptionException(error);
            }
            // 记录警告日志
            LogUtil.getLog(wrapper.getJoinPoint()).warn("[{}] MQ 重复消费, {}.", uniqueKey, "消费已完成");

        }
        // 将包装对象存入上下文
        IdempotentContext.put(WRAPPER, wrapper);
    }

    /**
     * 在客户端消费过程中发生异常时,需要清理幂等标识以便下次消息重试时能正常处理
     */
    @Override
    public void exceptionProcessing() {
        // 从上下文中获取包装对象
        IdempotentParam wrapper = (IdempotentParam) IdempotentContext.getKey(WRAPPER);
        if (wrapper != null) {
            // 获取幂等性注解
            Idempotent idempotent = wrapper.getIdempotent();
            // 生成唯一的幂等性键
            String uniqueKey = idempotent.uniqueKeyPrefix() + wrapper.getLockKey();
            try {
                // 删除 Redis 中的幂等性键
                redisTemplate.delete(uniqueKey);
            } catch (Throwable ex) {
                // 记录错误日志
                LogUtil.getLog(wrapper.getJoinPoint()).error("[{}] 删除MQ防重令牌失败。", uniqueKey);
            }
        }
    }

    /**
     * 消息消费完成的后处理
     */
    @Override
    public void postProcessing() {
        // 从上下文中获取包装对象
        IdempotentParam wrapper = (IdempotentParam) IdempotentContext.getKey(WRAPPER);
        if (wrapper != null) {
            // 获取幂等性注解
            Idempotent idempotent = wrapper.getIdempotent();
            // 生成唯一的幂等性键
            String uniqueKey = idempotent.uniqueKeyPrefix() + wrapper.getLockKey();
            try {
                // 更新幂等性键的状态为消费完成,并设置过期时间
                redisTemplate.opsForValue().set(uniqueKey, IdempotentMQConsumeStatusEnum.CONSUMED.getCode(), idempotent.keyTimeout(), TimeUnit.SECONDS);
            } catch (Throwable ex) {
                // 记录错误日志
                LogUtil.getLog(wrapper.getJoinPoint()).error("[{}]  删除MQ防重令牌失败。", uniqueKey);
            }
        }
    }
}

工具类
import cn.hutool.core.util.ArrayUtil;
import com.google.common.collect.Lists;
import org.springframework.core.DefaultParameterNameDiscoverer;
import org.springframework.expression.Expression;
import org.springframework.expression.ExpressionParser;
import org.springframework.expression.spel.standard.SpelExpressionParser;
import org.springframework.expression.spel.support.StandardEvaluationContext;

import java.lang.reflect.Method;
import java.util.ArrayList;
import java.util.Optional;

/**
 * SpEL 表达式解析工具
 */
public class SpELUtil {

    /**
     * 校验并返回实际使用的 SpEL 表达式
     *
     * @param spEl       SpEL 表达式字符串
     * @param method     目标方法对象
     * @param contextObj 目标方法的参数数组
     * @return 如果传入的 SpEL 表达式包含特定符号(如 "#" 或 "T("),则解析并返回其实际值;否则直接返回传入的 SpEL 表达式字符串
     */
    public static Object parseKey(String spEl, Method method, Object[] contextObj) {
        // 定义一个列表,存储 SpEL 表达式可能包含的特殊标志符
        ArrayList<String> spELFlag = Lists.newArrayList("#", "T(");
        // 查找传入 SpEL 表达式是否包含这些特殊标志符中的任意一个
        Optional<String> optional = spELFlag.stream().filter(spEl::contains).findFirst();
        // 如果找到,则需要解析 SpEL 表达式
        if (optional.isPresent()) {
            // 调用 parse 方法解析 SpEL 表达式,并返回解析后的值
            Object parse = parse(spEl, method, contextObj);
            return parse;
        }
        // 如果未找到特殊标志符,直接返回传入的 SpEL 表达式字符串
        return spEl;
    }

    /**
     * 转换参数为字符串
     *
     * @param spEl       spEl 表达式
     * @param contextObj 上下文对象
     * @return 解析的字符串值
     */
    public static Object parse(String spEl, Method method, Object[] contextObj) {
        DefaultParameterNameDiscoverer discoverer = new DefaultParameterNameDiscoverer();
        ExpressionParser parser = new SpelExpressionParser();
        Expression exp = parser.parseExpression(spEl);
        String[] params = discoverer.getParameterNames(method);
//        System.out.println("contextObj:" + JSON.toJSONString(contextObj));
        StandardEvaluationContext context = new StandardEvaluationContext();
        if (ArrayUtil.isNotEmpty(params)) {
            for (int len = 0; len < params.length; len++) {
                context.setVariable(params[len], contextObj[len]);
            }
        }
        Object value = exp.getValue(context);
        return value;
//        String md5Hex = DigestUtil.md5Hex(JSON.toJSONString(contextObj));
//        System.out.println("md5Hex:" + md5Hex);
//        return md5Hex;
    }
}

handler工厂

简单工厂模式提供专门的工厂类用于创建对象,实现了对象创建和使用的职责分离,客户端不需知道所创建的具体产品类的类名以及创建过程(在这里不需要创建,只需要从容器里面获取相应的Bean即可),只需知道具体产品类所对应的参数即可,通过引入配置文件,可以在不修改任何客户端代码的情况下更换和增加新的具体产品类,在一定程度上提高了系统的灵活性。

import com.dam.ApplicationContextHolder;
import com.dam.core.handler.param.IdempotentParamService;
import com.dam.core.handler.spel.mq.IdempotentSpELByMQExecuteHandler;
import com.dam.core.handler.spel.restful.IdempotentSpELByRestAPIExecuteHandler;
import com.dam.core.handler.token.IdempotentTokenService;
import com.dam.enums.IdempotentSceneEnum;
import com.dam.enums.IdempotentTypeEnum;

/**
 * 幂等执行处理器工厂
 * 简单工厂模式
 */
public final class IdempotentExecuteHandlerFactory {

    /**
     * 根据枚举参数获取对应的幂等执行处理器handler
     *
     * @param scene 指定幂等验证场景类型
     * @param type  指定幂等处理类型
     * @return 幂等执行处理器
     */
    public static IdempotentExecuteHandler getInstance(IdempotentSceneEnum scene, IdempotentTypeEnum type) {
        IdempotentExecuteHandler result = null;
        switch (scene) {
            case RESTAPI: {
                switch (type) {
                    case PARAM:
                        result = ApplicationContextHolder.getBean(IdempotentParamService.class);
                        break;
                    case TOKEN:
                        result = ApplicationContextHolder.getBean(IdempotentTokenService.class);
                        break;
                    case SPEL:
                        result = ApplicationContextHolder.getBean(IdempotentSpELByRestAPIExecuteHandler.class);
                        break;
                    default: {
                    }
                }
                break;
            }
            case MQ:
                result = ApplicationContextHolder.getBean(IdempotentSpELByMQExecuteHandler.class);
                break;
            default: {
            }
        }
        return result;
    }
}

AOP

幂等相关参数包装

import com.dam.annotation.Idempotent;
import com.dam.enums.IdempotentTypeEnum;
import lombok.AllArgsConstructor;
import lombok.Builder;
import lombok.Data;
import lombok.NoArgsConstructor;
import lombok.experimental.Accessors;
import org.aspectj.lang.ProceedingJoinPoint;

/**
 * 幂等参数包装
 *
 */
@Data
@Builder
@NoArgsConstructor
@AllArgsConstructor
@Accessors(chain = true)
public final class IdempotentParam {

    /**
     * 幂等注解
     */
    private Idempotent idempotent;

    /**
     * AOP 处理连接点
     */
    private ProceedingJoinPoint joinPoint;

    /**
     * 锁标识,{@link IdempotentTypeEnum#PARAM}
     */
    private String lockKey;
}

切面

import com.dam.annotation.Idempotent;
import com.dam.core.IdempotentContext;
import com.dam.core.exception.RepeatConsumptionException;
import org.aspectj.lang.ProceedingJoinPoint;
import org.aspectj.lang.annotation.Around;
import org.aspectj.lang.annotation.Aspect;
import org.aspectj.lang.reflect.MethodSignature;

import java.lang.reflect.Method;

/**
 * 幂等注解 AOP 拦截器
 */
@Aspect
public final class IdempotentAspect {

    /**
     * 使用Around来对Idempotent注解标记的方法进行环绕增强
     * @param joinPoint 使用 @Around ,自定义的切入点
     * @return
     * @throws Throwable
     */
    @Around("@annotation(com.dam.annotation.Idempotent)")
    public Object idempotentHandler(ProceedingJoinPoint joinPoint) throws Throwable {
        // 获取注解信息
        Idempotent idempotent = getIdempotent(joinPoint);
        // 根据注解来获取相应的处理器
        IdempotentExecuteHandler instance = IdempotentExecuteHandlerFactory.getInstance(idempotent.scene(), idempotent.type());
        // 存储实际方法的执行结果
        Object resultObj;
        try {
            // 当不满足幂等时,execute会报错,后面的代码不会再执行
            instance.execute(joinPoint, idempotent);
            // 请求的具体方法执行
            resultObj = joinPoint.proceed();
            // 处理器进行后处理,如解除分布式锁
            instance.postProcessing();
        }  finally {
            IdempotentContext.clean();
        }
        return resultObj;
    }

    /**
     * 从给定的ProceedingJoinPoint中获取目标方法上的Idempotent注解实例
     *
     * @param joinPoint 切点对象,包含方法签名等信息
     * @return 目标方法上的Idempotent注解实例
     * @throws NoSuchMethodException 如果找不到对应方法时抛出此异常
     */
    public static Idempotent getIdempotent(ProceedingJoinPoint joinPoint) throws NoSuchMethodException {
        // 获取方法签名对象,包含方法名、参数类型等信息
        MethodSignature methodSignature = (MethodSignature) joinPoint.getSignature();
        // 通过反射获取目标类(切面所拦截的对象)上与当前方法签名匹配的Method对象
        Method targetMethod = joinPoint.getTarget().getClass().getDeclaredMethod(methodSignature.getName(), methodSignature.getMethod().getParameterTypes());
        // 从Method对象上获取Idempotent注解实例
        return targetMethod.getAnnotation(Idempotent.class);
    }
}

配置类

将需要的Bean注入到容器中

import com.dam.core.aop.IdempotentAspect;
import com.dam.core.handler.param.IdempotentParamExecuteHandler;
import com.dam.core.handler.param.IdempotentParamService;
import com.dam.core.handler.spel.IdempotentSpELService;
import com.dam.core.handler.spel.mq.IdempotentSpELByMQExecuteHandler;
import com.dam.core.handler.spel.restful.IdempotentSpELByRestAPIExecuteHandler;
import com.dam.core.handler.token.IdempotentTokenExecuteHandler;
import com.dam.core.handler.token.IdempotentTokenService;
import com.dam.properties.IdempotentTokenProperties;
import org.redisson.api.RedissonClient;
import org.springframework.boot.autoconfigure.condition.ConditionalOnMissingBean;
import org.springframework.boot.context.properties.EnableConfigurationProperties;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.data.redis.core.StringRedisTemplate;

/**
 * 幂等自动装配
 */
@EnableConfigurationProperties(IdempotentTokenProperties.class)
@Configuration
public class IdempotentAutoConfiguration {

    /**
     * 幂等切面
     */
    @Bean
    public IdempotentAspect idempotentAspect() {
        return new IdempotentAspect();
    }

    /**
     * 参数方式幂等实现,基于 RestAPI 场景
     */
    @Bean
    @ConditionalOnMissingBean
    public IdempotentParamService idempotentParamExecuteHandler(RedissonClient redissonClient) {
        return new IdempotentParamExecuteHandler(redissonClient);
    }

    /**
     * Token 方式幂等实现,基于 RestAPI 场景
     */
    @Bean
    @ConditionalOnMissingBean
    public IdempotentTokenService idempotentTokenExecuteHandler(StringRedisTemplate distributedCache,
                                                                IdempotentTokenProperties idempotentTokenProperties) {
        return new IdempotentTokenExecuteHandler(distributedCache, idempotentTokenProperties);
    }

    /**
     * 申请幂等 Token 控制器,基于 RestAPI 场景
     */
//    @Bean
//    public IdempotentTokenController idempotentTokenController(IdempotentTokenService idempotentTokenService) {
//        return new IdempotentTokenController(idempotentTokenService);
//    }

    /**
     * SpEL 方式幂等实现,基于 RestAPI 场景
     */
    @Bean
    @ConditionalOnMissingBean
    public IdempotentSpELService idempotentSpELByRestAPIExecuteHandler(RedissonClient redissonClient) {
        return new IdempotentSpELByRestAPIExecuteHandler(redissonClient);
    }

    /**
     * SpEL 方式幂等实现,基于 MQ 场景
     */
    @Bean
    @ConditionalOnMissingBean
    public IdempotentSpELByMQExecuteHandler idempotentSpELByMQExecuteHandler(StringRedisTemplate distributedCache) {
        return new IdempotentSpELByMQExecuteHandler(distributedCache);
    }
}

使用

SpEL方式

在方法上面添加@Idempotent注解,案例如下: 想要自定义分布式锁的键,可以给key设置不同的SpEL表达式

/**
 * 修改
 */
@PostMapping("/update")
@PreAuthorize("hasAnyAuthority('bnt.user.update','bnt.storeUser.update')")
@Idempotent(
        uniqueKeyPrefix = "sss-system-server:lock_userInfo_update:",
        key = "T(com.dam.context.UserContext).getUsername()",
        message = "正在执行用户信息修改流程,请稍后...",
        scene = IdempotentSceneEnum.RESTAPI,
        type = IdempotentTypeEnum.SPEL
)
public R update(@RequestBody UserEntity user) {
    boolean b = userService.updateById(user);
    return R.ok();
}

param方式

@Idempotent(
        message = "正在执行用户信息修改流程,请稍后...",
        scene = IdempotentSceneEnum.RESTAPI,
        type = IdempotentTypeEnum.PARAM
)

说明

本文代码来源于马哥 12306 的代码,本人只是根据自己的理解进行少量修改并应用到智能排班系统中。代码仓库为12306,该项目含金量较高,有兴趣的朋友们建议去学习一下。