大家好,我是前端小张同学
前几天线上出了个事儿:用户反馈自己只买了一件商品,却收到了两件。排查下来发现,是支付平台的回调被我们处理了两次——网络抖动导致微信多推了一次,而我们的接口没有做幂等,结果库存扣了两次,订单也发了两次。
这事儿之后,我决定把幂等好好做一做。调研了一圈,最后用 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. 先定义个注解
@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. 切面里干两件事:占位 + 执行
@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 这边就两行
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,谁调都一样,只认「同一份请求」。
return SecureUtil.md5(methodName + argsStr);
用户级幂等:再加上 userId、userType,同一用户、同一操作才防重复,不同用户互不影响。
return SecureUtil.md5(methodName + argsStr + userId + userType);
自定义幂等:用 SpEL 表达式,比如 #orderId,按订单 ID 来,适合退款、取消订单这种场景。
用起来长这样
// 支付回调,全局幂等
@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 策略按场景选。如果你也在为幂等发愁,可以试试这个思路,有更好的方案也欢迎交流~