从 0 到 1:我如何用 Spring Boot 3 + Redis 打造一个生产级通用幂等与防重中间件(含图解 + 代码 + 案例)

GitHubhttps://github.com/songrongzhen/OnceKit
技术栈 :Spring Boot 3.0 + JDK 17 + Spring AOP + Redis + Lua +SpEL
目标:开箱即用、生产就绪、注解驱动、支持高并发防重场景

一、为什么要做这个中间件?

1.1 痛点场景

  • 用户点击"提交订单"按钮多次 → 生成多笔订单
  • 网络超时重试 → 后端重复处理支付回调
  • MQ 消息重复投递 → 账户余额被多次扣减
  • 考生重复提交报名信息 → 数据库出现多条相同身份证记录

这些都违反了 幂等性 (Idempotency)原则:同一操作无论执行多少次,结果应一致

1.2 现有方案的问题

方案 缺点
数据库唯一索引 仅适用于写入场景,无法防"并发穿透"
前端按钮禁用 不可靠(可绕过)
Token 机制 需前后端配合,增加复杂度
手动写 Redis 重复代码多,维护成本高

于是,我决定:用 AOP + 注解 + Redis,打造一个通用、轻量、高性能的幂等中间件

二、整体架构设计

2.1 系统架构图

整个过程在 毫秒级完成 ,且 无数据库压力

2.2 核心组件

组件 职责
@Idempotent 自定义注解,声明幂等规则
IdempotentAspect AOP 切面,拦截带注解的方法
SpelKeyGenerator 使用 Spring SpEL 动态生成唯一 Key
RedisIdempotentStore 基于 Redis 实现原子校验
IdempotentFailureHandler 自定义重复请求处理策略

三、核心代码实现

3.1 注解定义

java 复制代码
@Target(ElementType.METHOD)
@Retention(RetentionPolicy.RUNTIME)
@Documented
public @interface Idempotent {
   
    String key();
   
    int expire() default 300;
 
    String value() default "1";
}

3.2 AOP 切面逻辑

java 复制代码
@Aspect
public class IdempotentAspect {

    private final IdempotentService idempotentService;
    private final ExpressionParser parser = new SpelExpressionParser();
    private final StandardReflectionParameterNameDiscoverer discoverer =
            new StandardReflectionParameterNameDiscoverer();

    public IdempotentAspect(IdempotentService idempotentService) {
        this.idempotentService = idempotentService;
    }

    @Around("@annotation(idempotent)")
    public Object around(ProceedingJoinPoint joinPoint, Idempotent idempotent) throws Throwable {
        MethodSignature signature = (MethodSignature) joinPoint.getSignature();
        String[] paramNames = discoverer.getParameterNames(signature.getMethod());
        Object[] args = joinPoint.getArgs();

        // 解析 SpEL key
        StandardEvaluationContext context = new StandardEvaluationContext();
        for (int i = 0; i < args.length; i++) {
            context.setVariable(paramNames[i], args[i]);
        }
        String key = parser.parseExpression(idempotent.key()).getValue(context, String.class);

        if (!idempotentService.tryLock(key, idempotent.expire())) {
            if (idempotent.mode() == Idempotent.Mode.REJECT) {
                throw new IllegalStateException("重复请求,请勿重复提交");
            }
            // TODO: RETURN_CACHE 模式(需结果缓存)
        }

        return joinPoint.proceed();
    }
}

3.4 自定义失败处理器(可扩展)

java 复制代码
public interface IdempotentFailureHandler {
    void handle(String key, Method method);
}

@Component
public class DefaultIdempotentFailureHandler implements IdempotentFailureHandler {
    @Override
    public void handle(String key, Method method) {
        // 默认什么都不做,由 AOP 抛出异常
    }
}

四、使用案例

案例 1:下单(防重复下单)

java 复制代码
@PostMapping("/order")
@Idempotent(key = "'order:' + #userId + ':' + #goodsId", expire = 300)
public Result<String> createOrder(
    @RequestParam String userId,
    @RequestParam String goodsId) {
    // 模拟下单逻辑
    orderService.create(userId, goodsId);
    return Result.success("下单成功");
}

若同一用户对同一商品在 5 分钟内重复下单,后续请求将被拒绝。

案例 2:考生报名(防身份证重复)

java 复制代码
@PostMapping("/enroll")
@Idempotent(key = "'enroll:' + #candidate.idCard", expire = 300)
public Result<Void> enroll(@RequestBody Candidate candidate) {
    // 防止同一身份证重复报名
    enrollmentService.save(candidate);
    return Result.OK();
}

// 简写一个dto类吧
public class Candidate {
    private String name;
    private String idCard;
    private String phone;
}

key 为 enroll:11010119900307XXXX,5分钟内无法重复提交。

案例 3:秒杀场景(用户 + 商品维度)

java 复制代码
@PostMapping("/seckill")
@Idempotent(key = "'seckill:' + #userId + ':' + #goodsId", expire = 60)
public Result<String> seckill(@RequestParam String userId, @RequestParam Long goodsId) {
    return seckillService.execute(userId, goodsId);
}

即使用户疯狂点击,1 分钟内只允许一次有效请求。

五、性能与可靠性

  • 性能 :Redis SET NX EX 是原子操作,单节点 QPS > 5w+
  • 一致性:基于 Redis 分布式锁语义,天然支持集群
  • 安全性:Key 由业务生成,无注入风险(SpEL 在受控上下文中执行)
  • 资源:Key 自动过期,无内存泄漏风险

✅✅✅我已经将项目完整的放到GitHub上,欢迎大家学习使用,如对你的项目有帮助,帮忙给个点点star🌟

🔧使用超级简单,将项目OnceKit git clone到你本地,然后mvn install,然后在你的项目中引用

然后在你的需要幂等和防止重复提交的接口上加上一行注解就OK

java 复制代码
@Idempotent(key = "'order:' + #userId + ':' + #goodsId", expire = 300)
相关推荐
彭于晏Yan1 天前
MQTT消息服务
spring boot·后端·中间件
indexsunny1 天前
互联网大厂Java面试实战:从Spring Boot到微服务架构的深度解析
java·spring boot·spring cloud·kafka·prometheus·security·microservices
java1234_小锋1 天前
分享一套优质的SpringBoot+Vue咖啡商城系统
vue.js·spring boot·咖啡商城
haixingtianxinghai1 天前
Redis真的是单线程吗?
数据库·redis·缓存
悟空码字1 天前
滑块拼图验证:SpringBoot完整实现+轨迹验证+Redis分布式方案
java·spring boot·后端
小江的记录本1 天前
【MyBatis-Plus】Spring Boot + MyBatis-Plus 进行各种数据库操作(附完整 CRUD 项目代码示例)
java·前端·数据库·spring boot·后端·sql·mybatis
码界奇点1 天前
基于Spring Boot的医院药品管理系统设计与实现
java·spring boot·后端·车载系统·毕业设计·源代码管理
海南java第二人2 天前
Cursor 高级实战:从 Spring Boot 到微服务,AI 驱动的全流程开发指南
人工智能·spring boot·微服务
爱笑的源码基地2 天前
门诊his系统源码,中西医结合的数字化门诊解决方案
java·spring boot·源码·二次开发·门诊系统·云诊所系统·诊所软件源码
庞轩px2 天前
缓存Key设计的“七要七不要”
java·jvm·redis·缓存