本文目录导读:

我来详细讲解接口幂等性防重复提交的令牌机制(Token Pattern),这是一种最常用的解决方案。
什么是令牌机制
核心思想:客户端在提交请求前,先获取一个唯一令牌(Token),提交时必须携带此令牌,服务端处理请求时,检查这个令牌是否存在且有效。
实现原理
sequenceDiagram
participant C as 客户端
participant S as 服务端
participant DB as Redis
C->>S: 1. 请求获取令牌
S->>DB: 2. 生成唯一令牌并存储
DB-->>S: 3. 返回令牌
S-->>C: 4. 返回令牌
C->>S: 5. 请求处理(携带令牌)
S->>DB: 6. 检查并删除令牌
alt 令牌存在
DB-->>S: 7. 删除成功
S->>S: 8. 执行业务逻辑
S-->>C: 9. 返回成功
else 令牌不存在
DB-->>S: 7. 删除失败(已用/过期)
S-->>C: 8. 返回重复请求
end
完整代码实现
令牌生成与存储
@Service
public class TokenService {
@Autowired
private RedisTemplate<String, String> redisTemplate;
// 生成令牌
public String createToken(String key) {
// 1. 生成唯一令牌
String token = UUID.randomUUID().toString();
// 2. 存储到Redis,设置有效期(比如30分钟)
String redisKey = buildRedisKey(key, token);
redisTemplate.opsForValue()
.set(redisKey, "1", 30, TimeUnit.MINUTES);
return token;
}
// 校验并删除令牌
public boolean checkAndDeleteToken(String key, String token) {
String redisKey = buildRedisKey(key, token);
// 使用Lua脚本保证原子性
String script = "if redis.call('get', KEYS[1]) == '1' then " +
"return redis.call('del', KEYS[1]) " +
"else return 0 end";
Long result = redisTemplate.execute(
new DefaultRedisScript<>(script, Long.class),
Collections.singletonList(redisKey)
);
return result != null && result > 0;
}
private String buildRedisKey(String key, String token) {
return "token:prevent:" + key + ":" + token;
}
}
自定义注解
@Target(ElementType.METHOD)
@Retention(RetentionPolicy.RUNTIME)
public @interface PreventDuplicateSubmit {
// 令牌获取的key,支持SpEL表达式
String key();
// 令牌来源,默认从请求头获取
String tokenSource() default "header";
// 自定义验证失败的消息
String message() default "请勿重复提交";
}
AOP拦截器
@Aspect
@Component
public class PreventDuplicateAspect {
@Autowired
private TokenService tokenService;
@Around("@annotation(preventDuplicate)")
public Object around(ProceedingJoinPoint point,
PreventDuplicateSubmit preventDuplicate) throws Throwable {
// 1. 解析SpEL表达式获取key
String key = parseKey(preventDuplicate.key(), point);
// 2. 从请求中获取令牌
String token = getTokenFromRequest(preventDuplicate.tokenSource());
// 3. 校验令牌
if (!tokenService.checkAndDeleteToken(key, token)) {
throw new RuntimeException(preventDuplicate.message());
}
// 4. 执行业务逻辑
return point.proceed();
}
private String getTokenFromRequest(String source) {
HttpServletRequest request = ((ServletRequestAttributes)
RequestContextHolder.getRequestAttributes()).getRequest();
if ("header".equals(source)) {
return request.getHeader("idempotent-token");
} else {
return request.getParameter("idempotent-token");
}
}
}
控制器实现
@RestController
public class OrderController {
@Autowired
private TokenService tokenService;
// 获取令牌接口
@GetMapping("/token")
public Response<String> getToken() {
String token = tokenService.createToken("order");
return Response.success(token);
}
// 提交订单(防止重复提交)
@PostMapping("/order/create")
@PreventDuplicateSubmit(
key = "'order:' + #order.userId",
tokenSource = "header",
message = "订单正在处理中,请勿重复提交"
)
public Response<String> createOrder(@RequestBody OrderDTO order) {
// 业务逻辑
return Response.success("订单创建成功");
}
}
前端使用示例
// Vue.js 示例
export default {
data() {
return {
idempotentToken: '',
submitting: false
}
},
methods: {
async submitOrder(orderData) {
this.submitting = true;
try {
// 第一步:获取令牌
const tokenResponse = await axios.get('/api/token');
this.idempotentToken = tokenResponse.data.data;
// 第二步:携带令牌提交请求
const result = await axios.post('/api/order/create', orderData, {
headers: {
'idempotent-token': this.idempotentToken
},
timeout: 30000
});
console.log('提交成功', result);
} catch (error) {
if (error.response?.status === 400) {
alert(error.response.data.message); // "请勿重复提交"
} else {
alert('提交失败,请重试');
}
} finally {
this.submitting = false;
}
}
}
}
Redis Lua脚本(保证原子性)
-- check_and_delete.lua
-- KEYS[1]: token key
-- ARGV[1]: expected value
local token = redis.call('GET', KEYS[1])
if token == false or token ~= ARGV[1] then
return 0 -- 令牌不存在或不匹配
end
redis.call('DEL', KEYS[1])
return 1 -- 校验成功并删除
高级优化方案
批量请求优化
@Component
public class BatchTokenService {
// 批量生成令牌
public Map<String, String> createBatchTokens(int count) {
Map<String, String> tokens = new HashMap<>();
// 使用pipeline批量写入
redisTemplate.executePipelined((RedisCallback<Object>) connection -> {
for (int i = 0; i < count; i++) {
String token = UUID.randomUUID().toString();
connection.setEx(
buildRedisKey(token).getBytes(),
1800, // 30分钟
"1".getBytes()
);
tokens.put("token_" + i, token);
}
return null;
});
return tokens;
}
}
分布式环境支持
public interface DistributedTokenService {
// 使用分布式锁保证token获取的互斥性
default boolean acquireTokenWithLock(String key, String token) {
// 1. 先尝试获取分布式锁
String lockKey = "lock:" + key;
String lockValue = UUID.randomUUID().toString();
Boolean locked = redisTemplate.opsForValue()
.setIfAbsent(lockKey, lockValue, 10, TimeUnit.SECONDS);
if (Boolean.TRUE.equals(locked)) {
try {
// 2. 执行业务逻辑
return processToken(key, token);
} finally {
// 3. 释放锁(使用Lua脚本保证原子性)
releaseLock(lockKey, lockValue);
}
}
return false;
}
}
注意事项
令牌有效期
# application.yml
token:
prevent:
# 令牌有效期(分钟)
expire-time: 30
# Redis key前缀
key-prefix: "idempotent:token:"
# 清理过期令牌的间隔(分钟)
clean-interval: 60
异常处理
@RestControllerAdvice
public class GlobalExceptionHandler {
@ExceptionHandler(DuplicateSubmitException.class)
public Response<String> handleDuplicateSubmit(DuplicateSubmitException e) {
return Response.error(400, e.getMessage());
}
@ExceptionHandler(TokenExpiredException.class)
public Response<String> handleTokenExpired(TokenExpiredException e) {
return Response.error(401, "令牌已过期,请重新获取");
}
}
性能优化
- 使用Redis pipeline批量操作
- 令牌预生成(提前生成一批令牌)
- 使用本地缓存结合Redis(Caffeine + Redis)
这个令牌机制是实现接口幂等性最可靠的方式之一,适用于金融转账、订单创建、支付等关键业务场景。