一次支付回调引发的「血案」:我是如何用 Redis + AOP 实现接口幂等的

大家好,我是前端小张同学

前几天线上出了个事儿:用户反馈自己只买了一件商品,却收到了两件。排查下来发现,是支付平台的回调被我们处理了两次------网络抖动导致微信多推了一次,而我们的接口没有做幂等,结果库存扣了两次,订单也发了两次。

这事儿之后,我决定把幂等好好做一做。调研了一圈,最后用 Redis + AOP + 自定义注解 搞了一套方案,用下来挺顺手,今天跟大家分享一下。


先说说,幂等到底要解决啥问题?

你可能也遇到过类似情况:

  • 用户点「提交订单」没反应,又点了几下,结果生成了好几笔订单;
  • 支付成功了,回调却来了好几遍,积分加了一次又一次;
  • 消息队列同一条消息被消费多次,短信发到手软......

这些问题的本质都一样:同一个操作,被重复执行了

幂等要做的,就是保证:同一个请求,无论来多少次,效果都和只执行一次一样


我的思路:用 Redis 当「一次性通行证」

当时想了几个方案:数据库唯一索引、Token 机制、状态机......最后选了 Redis,原因很简单:快、简单、天然支持过期。

思路是这样的:把每次请求当成一次「领通行证」的过程。

  • 第一次请求:去 Redis 领通行证,领到了 → 执行业务;
  • 重复请求:再去领,发现已经有人领过了 → 直接拒绝,不执行业务。

关键就在于 Redis 的 SET key value NX EX timeout,也就是 Spring 里的 setIfAbsent

  • NX:只有 key 不存在的时候才写入;
  • EX:给 key 设个过期时间,防止一直占着不释放。

这样一来,第一次请求能写入成功,后面的重复请求都会写入失败,我们根据这个结果决定是放行还是拦截。


怎么实现的?注解 + 切面

我不想在每个方法里都写一堆 if-else 判断 Redis,所以用了 自定义注解 + AOP ,在需要幂等的方法上加个 @Idempotent 就行了。

1. 先定义个注解

java 复制代码
@Target({ElementType.METHOD})
@Retention(RetentionPolicy.RUNTIME)
public @interface Idempotent {

    int timeout() default 1;                    // 默认 1 秒内防重复
    TimeUnit timeUnit() default TimeUnit.SECONDS;
    String message() default "重复请求,请稍后重试";

    Class<? extends IdempotentKeyResolver> keyResolver() default DefaultIdempotentKeyResolver.class;
    String keyArg() default "";

    boolean deleteKeyWhenException() default true;  // 异常时删 Key,允许重试
}

timeout 可以按业务调,比如支付回调可能处理慢一点,可以设 5 秒;deleteKeyWhenException 很重要,后面会说到。

2. 切面里干两件事:占位 + 执行

java 复制代码
@Around(value = "@annotation(idempotent)")
public Object aroundPointCut(ProceedingJoinPoint joinPoint, Idempotent idempotent) throws Throwable {
    // 根据方法、参数等生成一个唯一的 key
    String key = keyResolver.resolver(joinPoint, idempotent);

    // 尝试在 Redis 里占位
    boolean success = idempotentRedisDAO.setIfAbsent(key, idempotent.timeout(), idempotent.timeUnit());
    if (!success) {
        // 占位失败 = 重复请求,直接拦掉
        throw new ServiceException(REPEATED_REQUESTS.getCode(), idempotent.message());
    }

    try {
        return joinPoint.proceed();  // 占位成功,执行业务
    } catch (Throwable throwable) {
        // 业务抛异常了,把 Key 删掉,不然用户没法重试
        if (idempotent.deleteKeyWhenException()) {
            idempotentRedisDAO.delete(key);
        }
        throw throwable;
    }
}

逻辑很直白:能占位就执行,占不了就报错;执行失败了就删 Key,让用户能再试一次。

3. Redis 这边就两行

java 复制代码
public Boolean setIfAbsent(String key, long timeout, TimeUnit timeUnit) {
    String redisKey = "idempotent:" + key;
    return redisTemplate.opsForValue().setIfAbsent(redisKey, "", timeout, timeUnit);
}

Value 我直接用的空字符串,反正我们只关心 key 存不存在,不关心里面存啥。


Key 怎么生成?三种策略

不同业务场景,幂等的「粒度」不一样。有的要全局唯一(比如支付回调),有的按用户来就行(比如下单),有的按业务 ID(比如订单号)。所以我做了三种 Key 解析器。

全局幂等:方法名 + 参数做 MD5,谁调都一样,只认「同一份请求」。

java 复制代码
return SecureUtil.md5(methodName + argsStr);

用户级幂等:再加上 userId、userType,同一用户、同一操作才防重复,不同用户互不影响。

java 复制代码
return SecureUtil.md5(methodName + argsStr + userId + userType);

自定义幂等 :用 SpEL 表达式,比如 #orderId,按订单 ID 来,适合退款、取消订单这种场景。


用起来长这样

java 复制代码
// 支付回调,全局幂等
@Idempotent
public void payCallback(PayNotifyDTO dto) {
    // ...
}

// 用户下单,按用户幂等
@Idempotent(keyResolver = UserIdempotentKeyResolver.class)
public void createOrder(OrderParam param) {
    // ...
}

// 退款,按订单 ID 幂等
@Idempotent(keyResolver = ExpressionIdempotentKeyResolver.class, keyArg = "#orderId")
public void refund(Long orderId) {
    // ...
}

需要幂等的地方加个注解,选好 Key 策略,就完事了。


几个设计上的小细节

为啥用 setIfAbsent,不先 exists 再 set?

因为「先查再写」不是原子的。高并发下,两个请求可能同时查到「不存在」,然后都去 set,就都成功了,幂等就失效了。setIfAbsent 是原子操作,判断和写入一步完成,不会有这个问题。

为啥业务成功了不删 Key?

幂等的意思就是「同一请求只执行一次」。成功了,在 timeout 内再来一次,就应该被拦掉。等 Key 过期了,用户想再试,那是新的请求,可以再执行。

为啥异常了要删 Key?

业务报错了,说明没执行成功,用户肯定要重试。要是 Key 不删,用户会一直看到「重复请求」,想重试都重试不了,体验很差。

和分布式锁有啥区别?

幂等是「防重复执行」,锁是「防并发执行」。幂等成功后不删 Key,等它自然过期;锁是执行完就释放。场景不一样,别混用。


踩过的坑

参数是复杂对象的时候 :Default 和 User 两种 KeyResolver 会用 toString() 拼参数。如果没重写 toString(),可能每次都是 ClassName@hashCode,不同实例 Key 不一样,幂等会失效。建议要么重写 toString(),要么用 ExpressionIdempotentKeyResolver 指定具体字段。

UserIdempotentKeyResolver 拿不到 userId:要保证请求进来时,Filter 或 Interceptor 里已经把用户信息塞进上下文了,不然会空指针。


小结

这套方案跑了一段时间,支付回调、下单、领券这些场景都上了,没再出重复执行的问题。核心就三点:Redis setIfAbsent 做原子占位、AOP 统一拦截、三种 Key 策略按场景选。如果你也在为幂等发愁,可以试试这个思路,有更好的方案也欢迎交流~

相关推荐
孟沐2 小时前
Java IO 流 - FileOutputStream & ObjectOutputStream 大白话解析
后端
lichenyang4532 小时前
Node.js文件上传原理
后端
Java水解2 小时前
微服务架构下Spring Session与Redis分布式会话实战全解析
后端·spring
Moe4882 小时前
如何使用 Spring Cache 结合 Redis 和 Caffeine 构建二级缓存机制
后端
Json_Lee3 小时前
2026 年了,多 Agent 编码该怎么选?agent-team vs Claude Agent Teams vs Claude Squad vs Met
前端·后端·vibecoding
陈随易3 小时前
刚上市就断货?如此火爆的编程显示器到底有什么魔力
前端·后端·程序员
ray_liang4 小时前
一小时手搓轻量级可代替 Qdrant 的向量数据库
后端·架构
昵称为空C4 小时前
spring-ai mcp-server(ssh工具)
后端·ai编程
前端付豪6 小时前
AI 数学辅导老师项目构想和初始化
前端·后端·python