跳转到内容
123xiao | 无名键客

《Spring Boot 中基于拦截器与 AOP 的接口幂等性设计与实战》

字数: 0 阅读时长: 1 分钟

背景与问题

接口幂等性这个词,很多同学第一次听会觉得“有点抽象”,但业务里它其实非常接地气:

  • 用户连点两次“提交订单”
  • 支付平台回调重复通知
  • 前端超时重试导致后端收到多次请求
  • MQ 消息重复消费
  • 网关重放请求

如果接口没有幂等保护,轻则重复插入数据,重则出现重复下单、重复扣款、库存错乱

在 Spring Boot 项目里,幂等性经常有两种落地方式:

  1. 基于拦截器(Interceptor):适合在 Web 请求入口统一做校验,偏“请求级治理”
  2. 基于 AOP(Aspect):适合围绕业务方法增强,偏“方法级治理”

这两种方式不是对立关系,反而非常适合组合使用。我自己的经验是:

  • 拦截器:负责“先拦住不合法请求”
  • AOP:负责“围绕目标方法做幂等令牌获取、锁定、释放、结果处理”

如果只选一种,往往会在灵活性或统一性上吃亏。


背景中的典型场景

先把场景分清楚,不然幂等设计很容易“用错武器”。

场景是否需要幂等推荐方案
新增订单必须token + Redis + 业务唯一索引
更新用户资料通常建议请求去重或版本号控制
扣减库存必须分布式锁 / 去重表 / 状态机
支付回调必须回调单号唯一约束 + 状态判断
查询接口一般不强调天然幂等

一个容易混淆的点:
HTTP 的 GET/PUT/DELETE 语义上的幂等,和业务上的幂等保障不是一回事。
比如一个 POST 创建订单,HTTP 语义上不是幂等,但业务上必须做到“只创建一次”。


核心原理

接口幂等性的本质,是让“同一请求的重复提交,只产生一次有效副作用”。

常见实现思路主要有三类:

  1. 数据库唯一约束
  2. 防重 token / requestId
  3. 分布式锁或状态机控制

本文重点讲第二类,并配合第一类兜底。

1. 幂等键是什么

要实现幂等,第一步是识别“什么叫同一请求”。

常见幂等键来源:

  • 前端生成 requestId
  • 后端下发一次性 token
  • 业务字段拼接,如 userId + 商品ID + 时间窗口
  • 第三方平台回调的唯一流水号

在 Web 系统中,最稳妥的做法通常是:

  • 提交前先获取一个 idempotent token
  • 客户端提交时把 token 放在请求头或参数中
  • 服务端校验并“消费”这个 token
  • 消费成功才允许业务继续执行

2. 为什么要用 Redis

因为幂等判断本质上需要一个跨实例共享状态的地方。

单机内存有两个问题:

  • 多实例部署后失效
  • 服务重启后状态丢失

Redis 很适合做这个事情,因为它支持:

  • 高性能读写
  • TTL 过期
  • 原子命令
  • 分布式部署

3. 拦截器和 AOP 分别解决什么问题

拦截器适合做:

  • 提前拦截 HTTP 请求
  • 读取 Header / Parameter
  • 做通用参数校验
  • 对指定接口统一启用幂等检查

AOP 适合做:

  • 用注解声明哪些接口要幂等
  • 围绕 Controller / Service 方法增强
  • 统一处理“加锁、执行业务、异常回滚、结果返回”

我更推荐的架构是:

  • 注解:声明式开启幂等
  • 拦截器:前置校验 token 存在性
  • AOP:原子消费 token 并执行业务
  • 数据库唯一约束:兜底防线

方案对比与取舍分析

常见方案横向对比

方案优点缺点适用场景
数据库唯一索引简单可靠只能兜底,用户体验一般订单号、流水号唯一
Redis token性能高、体验好需要客户端配合表单提交、防重复点击
分布式锁控制力强设计复杂,容易误用库存扣减、资源竞争
去重表审计性好表膨胀,需要清理MQ 消费幂等
状态机最稳妥实现成本高支付、履约、退款

一个务实的选择

对于大多数 Spring Boot Web 应用,我建议优先采用:

“Redis token + 注解 + AOP + 数据库唯一索引兜底”

原因很简单:

  • 业务侵入相对小
  • 易于在多个接口复用
  • 能兼顾体验和可靠性
  • 出现边界问题时还有数据库兜底

整体架构设计

下面先看一个整体流程图。

flowchart TD
    A[客户端请求获取 token] --> B[服务端生成 token 存入 Redis]
    B --> C[客户端提交业务请求并携带 token]
    C --> D[Spring MVC 拦截器预校验]
    D --> E[AOP 切面原子消费 token]
    E -->|成功| F[执行业务逻辑]
    E -->|失败| G[返回重复提交]
    F --> H[数据库唯一索引兜底]
    H --> I[返回业务结果]

再看一次请求在系统里的调用时序。

sequenceDiagram
    participant Client as 客户端
    participant Controller as Controller
    participant Interceptor as IdempotentInterceptor
    participant Aspect as IdempotentAspect
    participant Redis as Redis
    participant Service as OrderService
    participant DB as MySQL

    Client->>Controller: POST /orders + token
    Controller->>Interceptor: 进入请求
    Interceptor->>Interceptor: 检查 token 是否存在
    Interceptor-->>Controller: 放行
    Controller->>Aspect: 调用目标方法
    Aspect->>Redis: 原子删除/消费 token
    alt token 有效
        Redis-->>Aspect: 成功
        Aspect->>Service: 执行业务
        Service->>DB: 插入订单
        DB-->>Service: 成功
        Service-->>Aspect: 返回结果
        Aspect-->>Client: 200 OK
    else token 无效或已消费
        Redis-->>Aspect: 失败
        Aspect-->>Client: 409 重复提交
    end

设计要点

1. token 必须“一次性消费”

如果 token 只是“校验存在”,而不是“校验后立刻原子删除”,就会有并发漏洞:

  • 请求 A 校验通过
  • 请求 B 也校验通过
  • 两个请求同时进入业务逻辑

所以关键不是 get,而是原子消费

2. 幂等失败要明确返回

不要简单返回 500。更合适的是:

  • 409 Conflict
  • 业务码提示“请勿重复提交”

3. 幂等不是锁一切

有些同学会把幂等做成“同一个用户一分钟只能调用一次接口”,这不叫幂等,更像限流。

幂等判断的是:

是否同一业务请求被重复执行

而不是:

是否短时间内重复访问

这两个目标不同,别混在一起。


实战代码(可运行)

下面给出一个可运行的 Spring Boot 示例,核心包含:

  • 幂等注解
  • 生成 token 接口
  • 拦截器
  • AOP 切面
  • Redis 配置
  • 示例下单接口

目录结构

src/main/java/com/example/idempotent
├── IdempotentApplication.java
├── config
│   └── WebMvcConfig.java
├── controller
│   ├── OrderController.java
│   └── TokenController.java
├── aspect
│   └── IdempotentAspect.java
├── interceptor
│   └── IdempotentInterceptor.java
├── annotation
│   └── Idempotent.java
├── service
│   └── OrderService.java
├── exception
│   └── BizException.java
└── util
    └── RedisTokenService.java

1)Maven 依赖

<dependencies>
    <dependency>
        <groupId>org.springframework.boot</groupId>
        <artifactId>spring-boot-starter-web</artifactId>
    </dependency>

    <dependency>
        <groupId>org.springframework.boot</groupId>
        <artifactId>spring-boot-starter-aop</artifactId>
    </dependency>

    <dependency>
        <groupId>org.springframework.boot</groupId>
        <artifactId>spring-boot-starter-data-redis</artifactId>
    </dependency>

    <dependency>
        <groupId>org.projectlombok</groupId>
        <artifactId>lombok</artifactId>
        <optional>true</optional>
    </dependency>
</dependencies>

2)启动类

package com.example.idempotent;

import org.springframework.boot.SpringApplication;
import org.springframework.boot.autoconfigure.SpringBootApplication;

@SpringBootApplication
public class IdempotentApplication {
    public static void main(String[] args) {
        SpringApplication.run(IdempotentApplication.class, args);
    }
}

3)幂等注解

package com.example.idempotent.annotation;

import java.lang.annotation.*;

@Target(ElementType.METHOD)
@Retention(RetentionPolicy.RUNTIME)
@Documented
public @interface Idempotent {
    /**
     * token 在请求头中的名称
     */
    String header() default "Idempotent-Token";
}

4)业务异常

package com.example.idempotent.exception;

public class BizException extends RuntimeException {
    public BizException(String message) {
        super(message);
    }
}

5)Redis token 服务

这里为了保证“消费”动作原子性,直接用 Lua 脚本做删除判断。

package com.example.idempotent.util;

import org.springframework.data.redis.core.StringRedisTemplate;
import org.springframework.data.redis.core.script.DefaultRedisScript;
import org.springframework.stereotype.Service;

import java.util.Collections;
import java.util.UUID;
import java.util.concurrent.TimeUnit;

@Service
public class RedisTokenService {

    private static final String TOKEN_PREFIX = "idem:token:";

    private final StringRedisTemplate stringRedisTemplate;

    public RedisTokenService(StringRedisTemplate stringRedisTemplate) {
        this.stringRedisTemplate = stringRedisTemplate;
    }

    public String createToken() {
        String token = UUID.randomUUID().toString().replace("-", "");
        String key = TOKEN_PREFIX + token;
        stringRedisTemplate.opsForValue().set(key, "1", 10, TimeUnit.MINUTES);
        return token;
    }

    public boolean exists(String token) {
        return Boolean.TRUE.equals(stringRedisTemplate.hasKey(TOKEN_PREFIX + token));
    }

    public boolean consumeToken(String token) {
        String key = TOKEN_PREFIX + token;

        String scriptText =
                "if redis.call('exists', KEYS[1]) == 1 then " +
                "   return redis.call('del', KEYS[1]) " +
                "else " +
                "   return 0 " +
                "end";

        DefaultRedisScript<Long> script = new DefaultRedisScript<>();
        script.setScriptText(scriptText);
        script.setResultType(Long.class);

        Long result = stringRedisTemplate.execute(script, Collections.singletonList(key));
        return result != null && result > 0;
    }
}

6)拦截器:做前置校验

这里有个设计点:拦截器不直接消费 token,只检查“有没有带”,避免业务还没执行就把 token 删掉。

package com.example.idempotent.interceptor;

import com.example.idempotent.annotation.Idempotent;
import com.example.idempotent.exception.BizException;
import jakarta.servlet.http.HttpServletRequest;
import jakarta.servlet.http.HttpServletResponse;
import org.springframework.core.annotation.AnnotationUtils;
import org.springframework.stereotype.Component;
import org.springframework.web.method.HandlerMethod;
import org.springframework.web.servlet.HandlerInterceptor;

@Component
public class IdempotentInterceptor implements HandlerInterceptor {

    @Override
    public boolean preHandle(HttpServletRequest request, HttpServletResponse response, Object handler) {
        if (!(handler instanceof HandlerMethod handlerMethod)) {
            return true;
        }

        Idempotent idempotent = AnnotationUtils.findAnnotation(handlerMethod.getMethod(), Idempotent.class);
        if (idempotent == null) {
            return true;
        }

        String token = request.getHeader(idempotent.header());
        if (token == null || token.trim().isEmpty()) {
            throw new BizException("缺少幂等 token");
        }
        return true;
    }
}

7)注册拦截器

package com.example.idempotent.config;

import com.example.idempotent.interceptor.IdempotentInterceptor;
import org.springframework.context.annotation.Configuration;
import org.springframework.web.servlet.config.annotation.InterceptorRegistry;
import org.springframework.web.servlet.config.annotation.WebMvcConfigurer;

@Configuration
public class WebMvcConfig implements WebMvcConfigurer {

    private final IdempotentInterceptor idempotentInterceptor;

    public WebMvcConfig(IdempotentInterceptor idempotentInterceptor) {
        this.idempotentInterceptor = idempotentInterceptor;
    }

    @Override
    public void addInterceptors(InterceptorRegistry registry) {
        registry.addInterceptor(idempotentInterceptor).addPathPatterns("/**");
    }
}

8)AOP:原子消费 token

真正关键的动作放在这里。

package com.example.idempotent.aspect;

import com.example.idempotent.annotation.Idempotent;
import com.example.idempotent.exception.BizException;
import com.example.idempotent.util.RedisTokenService;
import jakarta.servlet.http.HttpServletRequest;
import org.aspectj.lang.ProceedingJoinPoint;
import org.aspectj.lang.annotation.Around;
import org.aspectj.lang.annotation.Aspect;
import org.springframework.core.annotation.AnnotationUtils;
import org.springframework.stereotype.Component;
import org.springframework.web.context.request.RequestContextHolder;
import org.springframework.web.context.request.ServletRequestAttributes;

import java.lang.reflect.Method;

@Aspect
@Component
public class IdempotentAspect {

    private final RedisTokenService redisTokenService;

    public IdempotentAspect(RedisTokenService redisTokenService) {
        this.redisTokenService = redisTokenService;
    }

    @Around("@annotation(com.example.idempotent.annotation.Idempotent)")
    public Object around(ProceedingJoinPoint joinPoint) throws Throwable {
        ServletRequestAttributes attributes =
                (ServletRequestAttributes) RequestContextHolder.getRequestAttributes();

        if (attributes == null) {
            throw new BizException("无法获取当前请求");
        }

        HttpServletRequest request = attributes.getRequest();
        Method method = ((org.aspectj.lang.reflect.MethodSignature) joinPoint.getSignature()).getMethod();
        Idempotent idempotent = AnnotationUtils.findAnnotation(method, Idempotent.class);

        if (idempotent == null) {
            return joinPoint.proceed();
        }

        String token = request.getHeader(idempotent.header());
        if (token == null || token.trim().isEmpty()) {
            throw new BizException("缺少幂等 token");
        }

        boolean consumed = redisTokenService.consumeToken(token);
        if (!consumed) {
            throw new BizException("请求重复提交,请勿重复操作");
        }

        return joinPoint.proceed();
    }
}

9)业务 Service

为了便于演示,这里简单返回结果。实际项目里请在数据库中加唯一索引做兜底。

package com.example.idempotent.service;

import org.springframework.stereotype.Service;

import java.time.LocalDateTime;
import java.util.HashMap;
import java.util.Map;
import java.util.UUID;

@Service
public class OrderService {

    public Map<String, Object> createOrder(String userId, String productId) {
        Map<String, Object> result = new HashMap<>();
        result.put("orderNo", UUID.randomUUID().toString());
        result.put("userId", userId);
        result.put("productId", productId);
        result.put("createTime", LocalDateTime.now().toString());
        result.put("status", "SUCCESS");
        return result;
    }
}

10)获取 token 接口

package com.example.idempotent.controller;

import com.example.idempotent.util.RedisTokenService;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.RestController;

import java.util.Map;

@RestController
public class TokenController {

    private final RedisTokenService redisTokenService;

    public TokenController(RedisTokenService redisTokenService) {
        this.redisTokenService = redisTokenService;
    }

    @GetMapping("/token")
    public Map<String, String> token() {
        String token = redisTokenService.createToken();
        return Map.of("token", token);
    }
}

11)下单接口

package com.example.idempotent.controller;

import com.example.idempotent.annotation.Idempotent;
import com.example.idempotent.service.OrderService;
import org.springframework.web.bind.annotation.*;

import java.util.Map;

@RestController
@RequestMapping("/orders")
public class OrderController {

    private final OrderService orderService;

    public OrderController(OrderService orderService) {
        this.orderService = orderService;
    }

    @PostMapping
    @Idempotent
    public Map<String, Object> createOrder(@RequestParam String userId,
                                           @RequestParam String productId) {
        return orderService.createOrder(userId, productId);
    }
}

12)统一异常返回

package com.example.idempotent.controller;

import com.example.idempotent.exception.BizException;
import org.springframework.http.HttpStatus;
import org.springframework.web.bind.annotation.*;

import java.util.Map;

@RestControllerAdvice
public class GlobalExceptionHandler {

    @ExceptionHandler(BizException.class)
    @ResponseStatus(HttpStatus.CONFLICT)
    public Map<String, Object> handleBizException(BizException e) {
        return Map.of(
                "code", 409,
                "message", e.getMessage()
        );
    }
}

13)配置文件

server:
  port: 8080

spring:
  data:
    redis:
      host: localhost
      port: 6379

如何验证

第一步:获取 token

curl http://localhost:8080/token

返回:

{"token":"7f1e1d1f2d7346ce8d0d7c2f6e8ab123"}

第二步:携带 token 提交订单

curl -X POST "http://localhost:8080/orders?userId=1001&productId=sku-01" \
  -H "Idempotent-Token: 7f1e1d1f2d7346ce8d0d7c2f6e8ab123"

第一次返回成功。

第三步:重复提交同一请求

再次使用相同 token 提交:

curl -X POST "http://localhost:8080/orders?userId=1001&productId=sku-01" \
  -H "Idempotent-Token: 7f1e1d1f2d7346ce8d0d7c2f6e8ab123"

会返回:

{
  "code": 409,
  "message": "请求重复提交,请勿重复操作"
}

进一步增强:结合数据库唯一索引兜底

仅靠 Redis token 还不够吗?

老实说,在核心交易场景不够

因为仍然可能有这些边界情况:

  • token 机制被绕过
  • 内部服务调用没带 token
  • 运维手动重试
  • 极端情况下 Redis 数据异常
  • 同一业务从多个入口进入

所以建议数据库层加一层唯一约束。比如订单表里引入 request_no

CREATE TABLE t_order (
    id BIGINT PRIMARY KEY AUTO_INCREMENT,
    request_no VARCHAR(64) NOT NULL,
    user_id VARCHAR(64) NOT NULL,
    product_id VARCHAR(64) NOT NULL,
    create_time DATETIME NOT NULL,
    UNIQUE KEY uk_request_no (request_no)
);

业务插入时:

  • 有唯一索引冲突:说明已处理过
  • 返回已有结果或提示重复提交

这是非常实用的“双保险”。


容量估算与工程考虑

做架构设计时,不只是“能跑”,还要看能不能在流量上扛得住。

Redis 容量估算

假设:

  • 峰值每分钟生成 10 万个 token
  • token 保存 10 分钟
  • 同时在 Redis 中大约存在 100 万个 token

如果每个 token key 平均按 100~150 字节估算(包括 key/value/元数据开销),那么内存占用大致在:

  • 100万 * 100B = 100MB
  • 100万 * 150B = 150MB

这个量通常是可接受的,但如果你的 token TTL 拉到 1 小时,内存会明显上升。

建议

  • Web 表单防重:TTL 5~15 分钟足够
  • 支付回调去重:可适当更长,但更建议落库
  • 避免把大对象塞进 token value

常见坑与排查

这一部分我想多说一点,因为真正上线后,问题往往不在“不会写”,而在“以为自己写对了”。

坑 1:用 GET 判断 token,再 DELETE 删除

错误示意:

if (redisTemplate.hasKey(key)) {
    redisTemplate.delete(key);
    return true;
}
return false;

这个逻辑在并发下不是原子的,两个线程可能同时判断成功。

排查方式

  • 压测同一个 token 并发请求
  • 看是否出现两个请求都成功

正确做法

  • 使用 Lua 脚本
  • 或使用支持原子操作的命令组合

坑 2:在拦截器里提前删除 token

有些人会把 token 校验和删除都写到拦截器中,结果业务方法还没执行,token 就被消耗了。

如果后续业务因为参数校验失败、事务回滚、数据库异常而失败,用户再重试就会提示“重复提交”,体验很差。

建议

  • 拦截器只做轻校验
  • AOP 中做真正消费
  • 业务失败是否允许重试,要根据场景决定

这里有边界条件:
如果你的业务是“只要请求到达就不能再重放”,那也可以在入口立即消费。
但大多数表单提交场景不建议这么做。


坑 3:AOP 切不到方法

典型原因:

  • 方法不是 public
  • 同类内部调用导致代理失效
  • 注解加在接口上,但切点没正确识别
  • Spring AOP 代理模式理解不清

排查思路

  1. 确认切面是否被 Spring 扫描
  2. 确认目标方法是否通过代理调用
  3. 打日志输出切面是否进入
  4. 检查 @EnableAspectJAutoProxy 是否需要显式开启

坑 4:只做了 token 幂等,没有业务唯一约束

结果就是:

  • Web 请求挡住了重复提交
  • 但内部补偿任务又插入了一条相同订单
  • 最后还是重复数据

建议

永远记住一句话:

幂等校验在入口,唯一约束在落库。

入口层负责体验,存储层负责最终一致的底线。


坑 5:token 设计成和用户无关

如果 token 完全裸奔,不和用户、接口、业务上下文绑定,理论上可能被误用或重放到其他接口。

更稳妥的做法

可以在 Redis value 中记录:

  • userId
  • path
  • method
  • createTime

消费时做一致性校验。


安全/性能最佳实践

这一节很关键,很多实现“功能上能用”,但一上生产就开始露问题。

安全最佳实践

1. token 要有过期时间

不要生成永久 token。否则:

  • Redis 键无限增长
  • 重放窗口过大
  • 安全风险提升

2. token 最好与用户绑定

例如 token 创建时,value 存储当前登录用户 ID。消费时校验:

  • 该 token 是否属于当前用户
  • 是否用于当前接口

这样可以防止 token 被其他人复用。

3. 重要接口叠加签名机制

对支付、转账、优惠券核销等高风险接口,仅有 token 不够,建议叠加:

  • 用户身份校验
  • 时间戳
  • 请求签名
  • 服务端业务状态校验

4. 错误信息不要过度暴露

不要告诉调用方“Redis key 不存在”这种实现细节,统一返回:

  • 请勿重复提交
  • 请求已失效,请刷新后重试

性能最佳实践

1. token key 要短小稳定

避免超长 key,Redis 内存开销会增大。建议:

idem:token:{token}

足够清晰,也方便排查。

2. 使用 Lua 脚本减少网络往返

相比先查再删,Lua 脚本一次请求就能完成判断和删除,性能和原子性都更好。

3. 不要对所有接口都启用幂等

幂等也有成本:

  • 增加 Redis 访问
  • 增加请求链路复杂度
  • 可能影响故障排查

只在以下接口启用更合适:

  • 创建类接口
  • 扣减类接口
  • 回调类接口
  • 有明显副作用的接口

4. 降级策略要提前想好

如果 Redis 不可用怎么办?

我见过线上最尴尬的情况是:为了幂等保护,结果整个下单链路不可用了。

你至少要想清楚策略:

  • 严格模式:Redis 挂了直接拒绝请求,保证安全
  • 降级模式:依赖数据库唯一索引继续执行,保证可用性

具体选哪个,要看业务风险等级。


一个更完整的状态视角

从状态机角度看,token 实际上有自己的生命周期。

stateDiagram-v2
    [*] --> Created
    Created --> Submitted: 客户端携带 token 提交
    Submitted --> Consumed: 服务端原子消费成功
    Submitted --> Expired: token 过期
    Created --> Expired: 长时间未使用
    Consumed --> [*]
    Expired --> [*]

如果你把这个状态想清楚,很多实现细节就不容易写偏:

  • Created:可用
  • Consumed:不可重试
  • Expired:需重新获取

拦截器与 AOP 的职责边界建议

我在项目里比较推荐下面这种职责分层:

拦截器负责

  • 判断是否需要幂等校验
  • 提前校验 token 是否缺失
  • 做请求日志记录、traceId 透传
  • 提前拦截明显非法请求

AOP 负责

  • 根据注解读取 token 规则
  • 调 Redis 做原子消费
  • 包裹业务执行
  • 统一幂等异常输出

Service/DAO 负责

  • 业务唯一性校验
  • 数据库唯一约束
  • 事务控制
  • 幂等后的“已处理结果”查询

这样分层的好处是:

  • 代码职责清晰
  • 容易测试
  • 后续扩展到 MQ 消费幂等时,AOP 思路也能复用

什么时候不建议只靠这种方案

说实话,token 幂等不是银弹。下面几类场景,我不建议只靠拦截器 + AOP:

1. 支付、退款、履约状态流转

这类场景更适合:

  • 业务单号唯一约束
  • 状态机
  • 事件表 / 流水表
  • 补偿机制

2. MQ 消费幂等

MQ 没有 HTTP 请求,也没有拦截器这一说,更常见的是:

  • 消息唯一 ID 去重
  • 消费记录表
  • 乐观锁 / 状态位

3. 高并发库存扣减

库存问题更核心的是“并发一致性”,幂等只是其中一环。通常要配合:

  • Redis 预扣
  • 数据库乐观锁
  • 队列削峰
  • 最终一致性补偿

总结

如果你要在 Spring Boot 里做一个实用、可维护的接口幂等方案,我建议按下面这个顺序落地:

  1. 用注解标识需要幂等的接口
  2. 用拦截器校验 token 是否携带
  3. 用 AOP 在业务执行前原子消费 token
  4. 用 Redis 存储 token,并设置合理 TTL
  5. 对核心表加数据库唯一索引做兜底
  6. 根据业务决定失败后是否允许重新申请 token 重试

最后给一个非常务实的结论:

对“防重复点击”这类场景,
拦截器 + AOP + Redis token 已经很好用。

对“资金、支付、库存”这类强一致场景,
它只能算第一层保护,数据库唯一约束和业务状态机才是底盘

如果你现在就准备开做,我建议先挑一个“创建类接口”试点,比如“创建订单”或“提交审批单”,先把注解、切面、Redis token 跑通,再逐步推广。这样最稳,也最容易在团队里形成统一规范。


分享到:

上一篇
《分布式架构中服务拆分后的事务一致性实战:基于 Saga、Outbox 与幂等设计的落地方案》
下一篇
《从抓包到还原签名链路:一次典型 Web 逆向中 JS 混淆、加密参数与接口复现的实战拆解》