从 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)
相关推荐
杨运交8 小时前
[041][公共模块]分布式唯一ID生成器设计与实现:一款灵活可扩展的雪花算法框架
spring boot
用户3074596982071 天前
Redis 延时队列详解
redis
烤代码的吐司君1 天前
Redis 数据结构 ZSet, BIT, HyperLogLog,Geo 空间数据
redis·后端
Flittly1 天前
【AgentScope Java新手村系列】(14)人机交互
java·spring boot·spring
Flynt2 天前
从Spring Boot 4.0升到4.1,我在Maven和gRPC上栽了跟头
java·spring boot·后端
掉鱼的猫3 天前
Spring Boot → Solon 注解迁移实战指南:一张对照表说清楚
java·spring boot
leeyi3 天前
Checkpoint 机制:Agent 怎么在断电后接着跑
redis·aigc·agent
人活一口气4 天前
Spring Boot与AIGC的完美结合:从零搭建智能内容生成平台
java·spring boot·aigc
云技纵横4 天前
一个 @Async 让循环依赖暴雷:Spring 代理的暗坑
redis
犯困蛋挞yy5 天前
用Claude快速解决Redis代码报错反复无解的问题
redis