一、背景:为什么接口防抖如此重要?
1.1 分布式系统的隐形杀手
在分布式系统中,接口防抖(Debounce)绝非"前端小事"。当系统演进到分布式架构后,以下场景会直接威胁系统稳定性:
典型事故场景:
| 场景 | 事故现象 | 根因分析 |
|---|---|---|
| 热点商品秒杀 | 数据库CPU 100%,服务雪崩 | 缓存击穿,大量请求穿透到DB |
| 营销活动开启 | 同一用户创建多笔订单 | 前端防抖失效,后端无拦截 |
| 消息消费者重试 | 重复执行业务逻辑 | 消费ack失败导致消息重复投递 |
| 定时任务调度 | 同一任务被多个Worker执行 | 分布式锁失效,任务竞争 |
1.2 防抖 vs 幂等:容易被混淆的两个概念
防抖(Debounce):对同一请求在时间窗口内只处理一次,解决的是"重复请求"问题 幂等(Idempotent):对同一业务标识只处理一次,解决的是"重复执行"问题 两者是互补关系,不是替代关系
二、防抖核心原理与关键设计点
2.1 请求key的生成策略
/**
* 请求Key生成器
* 核心要点:
* 1. 唯一性:userId + api + 参数hash
* 2. 确定性:相同输入必须生成相同key
* 3. 排序:参数JSON必须排序后hash,确保 {a:1,b:2} == {b:2,a:1}
*/
@Component
public class RequestKeyGenerator {
private final ObjectMapper objectMapper = new ObjectMapper();
public String generate(String userId, String endpoint, Object params) {
try {
// 1. 参数序列化为JSON并排序
String jsonParams = objectMapper.writeValueAsString(params);
String sortedParams = sortJson(jsonParams);
// 2. 构造原始字符串
String raw = userId + ":" + endpoint + ":" + sortedParams;
// 3. MD5哈希(生产环境建议用SHA-256)
return "debounce:" + DigestUtils.md5Hex(raw);
} catch (JsonProcessingException e) {
throw new RuntimeException("参数序列化失败", e);
}
}
private String sortJson(String json) throws JsonProcessingException {
JsonNode node = objectMapper.readTree(json);
ObjectNode sorted = objectMapper.createObjectNode();
List<String> fieldNames = new ArrayList<>();
node.fieldNames().forEachRemaining(fieldNames::add);
Collections.sort(fieldNames);
for (String fieldName : fieldNames) {
sorted.set(fieldName, node.get(fieldName));
}
return objectMapper.writeValueAsString(sorted);
}
}
2.2 时间窗口的设计考量
/**
* 窗口时间配置策略
*
* 窗口设置原则:
* - 太长:正常请求被误拒,用户体验差
* - 太短:失去防抖意义,高并发时仍会穿透
*
* 推荐配置:
* - 表单提交:3-5秒
* - 支付/退款:10-30秒
* - 缓存预热:1-2秒
* - 搜索联想:300-500ms(建议前端防抖)
*/
@Configuration
public class DebounceConfig {
@Value("${debounce.window.seconds:5}")
private int defaultWindowSeconds;
@Bean
public DebounceProperties debounceProperties() {
DebounceProperties props = new DebounceProperties();
props.setDefaultWindowSeconds(defaultWindowSeconds);
// 按场景配置不同窗口
Map<String, Integer> sceneWindows = new HashMap<>();
sceneWindows.put("submit", 5); // 表单提交
sceneWindows.put("pay", 30); // 支付
sceneWindows.put("refund", 30); // 退款
sceneWindows.put("cache_warm", 2); // 缓存预热
props.setSceneWindows(sceneWindows);
return props;
}
}
三、4种企业级Java实现方案
3.1 方案一:Redis SETNX(最常用)
3.1.1 基础实现
@Service
public class RedisDebounceService {
private final StringRedisTemplate redisTemplate;
private final RequestKeyGenerator keyGenerator;
private final DebounceProperties properties;
/**
* 判断是否为重复请求
*
* 原理:
* SETNX key value - 如果key不存在则设置并返回true,如果key已存在则返回false
* EXPIRE key seconds - 设置key的过期时间
*
* 问题:SETNX和EXPIRE两步操作不是原子的,存在竞态窗口
*/
public boolean isDuplicate(String userId, String endpoint, Object params) {
return isDuplicate(userId, endpoint, params, properties.getDefaultWindowSeconds());
}
public boolean isDuplicate(String userId, String endpoint, Object params, int windowSeconds) {
String key = keyGenerator.generate(userId, endpoint, params);
// SETNX + EXPIRE(非原子,存在竞态窗口)
Boolean result = redisTemplate.opsForValue().setIfAbsent(key,
String.valueOf(System.currentTimeMillis()),
windowSeconds,
TimeUnit.SECONDS);
// result为true表示是新请求(设置成功)
// result为false表示是重复请求(key已存在)
return !Boolean.TRUE.equals(result);
}
}
3.1.2 完整集成(Spring Boot + AOP)
/**
* 防抖注解
*/
@Target(ElementType.METHOD)
@Retention(RetentionPolicy.RUNTIME)
public @interface Debounce {
/**
* 场景标识,用于区分不同接口
*/
String scene() default "default";
/**
* 时间窗口(秒)
*/
int window() default 5;
/**
* 提示消息
*/
String message() default "请求过于频繁,请稍后重试";
/**
* 是否启用
*/
boolean enabled() default true;
}
/**
* 防抖切面
*/
@Aspect
@Component
@Order(Ordered.HIGHEST_PRECEDENCE)
public class DebounceAspect {
private final RedisDebounceService debounceService;
private final HttpServletRequest request;
public DebounceAspect(RedisDebounceService debounceService,
HttpServletRequest request) {
this.debounceService = debounceService;
this.request = request;
}
@Around("@annotation(debounce)")
public Object around(ProceedingJoinPoint joinPoint, Debounce debounce) throws Throwable {
if (!debounce.enabled()) {
return joinPoint.proceed();
}
// 获取用户ID(从SecurityContext或RequestHeader)
String userId = getCurrentUserId();
// 获取接口路径
String endpoint = request.getRequestURI();
// 获取请求参数
Object[] args = joinPoint.getArgs();
Map<String, Object> params = argsToMap(joinPoint.getSignature(), args);
// 检查是否重复
if (debounceService.isDuplicate(userId, endpoint, params, debounce.window())) {
throw new DebounceException(debounce.message());
}
return joinPoint.proceed();
}
private String getCurrentUserId() {
// 从JWT或Session获取用户ID
Authentication auth = SecurityContextHolder.getContext().getAuthentication();
return auth != null ? auth.getName() : "anonymous";
}
private Map<String, Object> argsToMap(MethodSignature signature, Object[] args) {
Map<String, Object> map = new HashMap<>();
String[] parameterNames = signature.getParameterNames();
for (int i = 0; i < parameterNames.length; i++) {
// 排除HttpServletRequest等非业务参数
if (args[i] != null && !isExcludeType(args[i].getClass())) {
map.put(parameterNames[i], args[i]);
}
}
return map;
}
private boolean isExcludeType(Class<?> clazz) {
return clazz == HttpServletRequest.class
|| clazz == HttpServletResponse.class
|| clazz == MultipartFile.class;
}
}
// 使用示例
@RestController
@RequestMapping("/api/order")
public class OrderController {
@PostMapping("/create")
@Debounce(scene = "submit", window = 5, message = "订单创建中,请勿重复提交")
public Result<OrderVO> createOrder(@RequestBody @Valid OrderCreateRequest request) {
// 业务逻辑
return Result.success(orderService.create(request));
}
@PostMapping("/pay")
@Debounce(scene = "pay", window = 30, message = "支付处理中,请勿重复操作")
public Result<PayResult> pay(@RequestBody @Valid PayRequest request) {
// 业务逻辑
return Result.success(paymentService.pay(request));
}
}
3.2 方案二:Lua脚本(原子性保障)
3.2.1 为什么需要Lua?
Redis的SETNX + EXPIRE两步操作不是原子的。在高并发下可能出现:
1. 线程A执行SETNX(key, value),返回true
2. 线程A还没来得及执行EXPIRE
3. 线程B执行SETNX(key, value),由于key已存在,返回false
4. 线程A的EXPIRE设置成功,但此时防抖逻辑已经出错
Lua脚本在Redis中是原子执行的,可以避免这种竞态窗口
3.2.2 Lua脚本实现
@Component
public class LuaDebounceService {
private final StringRedisTemplate redisTemplate;
private final DefaultRedisScript<List> debounceScript;
public LuaDebounceService(StringRedisTemplate redisTemplate) {
this.redisTemplate = redisTemplate;
// 定义Lua脚本
String script = """
local key = KEYS[1]
local window = tonumber(ARGV[1])
local current_time = tonumber(ARGV[2])
-- 检查key是否存在
if redis.call('EXISTS', key) == 1 then
-- key存在,返回{0, 剩余过期时间}
local ttl = redis.call('TTL', key)
return {0, ttl}
else
-- key不存在,设置key并返回{1, 0}
redis.call('SETEX', key, window, current_time)
return {1, 0}
end
""";
debounceScript = new DefaultRedisScript<>();
debounceScript.setScriptText(script);
debounceScript.setResultType(List.class);
}
/**
* 原子性防抖检查
*
* @return DebounceResult(isAllowed, remainingSeconds)
*/
public DebounceResult tryAcquire(String key, int windowSeconds) {
long currentTime = System.currentTimeMillis();
List result = redisTemplate.execute(
debounceScript,
Collections.singletonList(key),
String.valueOf(windowSeconds),
String.valueOf(currentTime)
);
if (result == null || result.isEmpty()) {
return new DebounceResult(true, 0);
}
long isAllowed = ((Number) result.get(0)).longValue();
long remaining = ((Number) result.get(1)).longValue();
return new DebounceResult(isAllowed == 1, (int) remaining);
}
@Data
public static class DebounceResult {
private final boolean allowed;
private final int remainingSeconds;
}
}
3.2.3 增强版:支持自定义返回值
/**
* 增强版Lua脚本:支持返回更丰富的状态信息
*/
@Component
public class EnhancedLuaDebounceService {
private final StringRedisTemplate redisTemplate;
private final DefaultRedisScript<Map> script;
private static final String SCRIPT = """
local key = KEYS[1]
local window = tonumber(ARGV[1])
local current_time = tonumber(ARGV[2])
local max_retry = tonumber(ARGV[3])
-- 检查是否存在
local exists = redis.call('EXISTS', key)
if exists == 1 then
-- key存在,获取更多信息
local ttl = redis.call('TTL', key)
local value = redis.call('GET', key)
local retry_count = tonumber(redis.call('GET', key .. ':retry') or '0')
return {
allowed = 0,
ttl = ttl,
first_request_time = value,
retry_count = retry_count
}
else
-- key不存在,设置key和重试计数
redis.call('SETEX', key, window, current_time)
redis.call('SETEX', key .. ':retry', window, 1)
return {
allowed = 1,
ttl = window,
first_request_time = current_time,
retry_count = 0
}
end
""";
@PostConstruct
public void init() {
script = new DefaultRedisScript<>();
script.setScriptText(SCRIPT);
script.setResultType(Map.class);
}
public EnhancedResult tryAcquire(String key, int windowSeconds) {
Map result = redisTemplate.execute(script,
Collections.singletonList(key),
String.valueOf(windowSeconds),
String.valueOf(System.currentTimeMillis()),
String.valueOf(3) // 允许最多3次重试
);
if (result == null) {
return new EnhancedResult(true, windowSeconds, 0, 0);
}
return new EnhancedResult(
((Number) result.get("allowed")).intValue() == 1,
((Number) result.get("ttl")).intValue(),
((Number) result.get("first_request_time")).longValue(),
((Number) result.get("retry_count")).intValue()
);
}
public record EnhancedResult(
boolean allowed,
int ttlSeconds,
long firstRequestTime,
int retryCount
) {}
}
3.3 方案三:分布式锁(高精度场景)
3.3.1 适用场景分析
分布式锁适用场景: - 支付扣款:同一笔订单只能支付一次 - 库存扣减:库存不能超卖 - 优惠券领取:优惠券数量有限
与普通防抖的区别:
- 防抖:时间窗口内拒绝请求
- 锁:整个业务处理期间持有锁
3.3.2 Redisson实现
@Service
public class DistributedLockService {
private final RedissonClient redissonClient;
public DistributedLockService(RedissonClient redissonClient) {
this.redissonClient = redissonClient;
}
/**
* 尝试获取分布式锁
*
* @param lockKey 锁的key
* @param waitTime 获取锁等待时间
* @param leaseTime 锁持有时间
* @param unit 时间单位
* @return 锁上下文,用于try-with-resources
*/
public LockContext tryLock(String lockKey, long waitTime, long leaseTime, TimeUnit unit) {
RLock lock = redissonClient.getLock(lockKey);
try {
boolean acquired = lock.tryLock(waitTime, leaseTime, unit);
if (!acquired) {
throw new LockAcquireException("获取锁失败,请稍后重试");
}
return new LockContext(lock);
} catch (InterruptedException e) {
Thread.currentThread().interrupt();
throw new LockAcquireException("获取锁被中断", e);
}
}
/**
* 简化版本:获取锁,失败抛异常
*/
public void lock(String key, long leaseTimeSeconds) {
RLock lock = redissonClient.getLock(key);
lock.lock(leaseTimeSeconds, TimeUnit.SECONDS);
}
/**
* 释放锁
*/
public void unlock(RLock lock) {
if (lock.isHeldByCurrentThread()) {
lock.unlock();
}
}
/**
* 锁上下文(用于try-with-resources)
*/
public static class LockContext implements AutoCloseable {
private final RLock lock;
public LockContext(RLock lock) {
this.lock = lock;
}
public RLock getLock() {
return lock;
}
@Override
public void close() {
unlock(lock);
}
}
/**
* 业务处理示例:支付
*/
@Service
public class PaymentService {
private final DistributedLockService lockService;
private final PaymentMapper paymentMapper;
public void pay(PayRequest request) {
String lockKey = "lock:pay:" + request.getOrderId();
// 使用try-with-resources自动释放锁
try (LockContext ctx = lockService.tryLock(lockKey, 10, 30, TimeUnit.SECONDS)) {
// 1. 再次检查订单状态(防止并发)
Order order = orderMapper.selectById(request.getOrderId());
if (order.getStatus() != OrderStatus.PENDING) {
throw new BusinessException("订单状态异常");
}
// 2. 检查是否已支付(幂等检查)
Payment existingPayment = paymentMapper.selectByOrderId(request.getOrderId());
if (existingPayment != null) {
// 已支付,直接返回
return;
}
// 3. 执行支付
Payment payment = doPay(request);
// 4. 更新订单状态
orderMapper.updateStatus(request.getOrderId(), OrderStatus.PAID);
} catch (LockAcquireException e) {
throw new BusinessException("系统繁忙,请稍后重试");
}
}
}
}
3.3.3 数据库实现(无Redis场景)
/**
* 基于数据库的分布式锁实现
* 适用于没有Redis的小型系统
*/
@Component
public class DatabaseLockService {
@Autowired
private DistributedLockMapper lockMapper;
/**
* 尝试获取锁
*
* 实现原理:
* 1. 插入锁记录(唯一索引保证原子性)
* 2. 插入成功表示获取锁成功
* 3. 插入失败(唯一索引冲突)表示锁已被占用
*/
@Transactional
public boolean tryAcquire(String lockKey, String owner, int expireSeconds) {
// 1. 检查是否已存在且未过期
DistributedLock existing = lockMapper.selectByLockKey(lockKey);
if (existing != null) {
// 检查是否过期
if (existing.getExpireTime().isBefore(LocalDateTime.now())) {
// 锁已过期,删除旧记录
lockMapper.deleteByLockKey(lockKey);
} else {
// 锁未过期,检查是否是自己持有的
if (existing.getOwner().equals(owner)) {
// 自己持有的锁,续期
lockMapper.updateExpireTime(lockKey,
LocalDateTime.now().plusSeconds(expireSeconds));
return true;
}
return false;
}
}
// 2. 尝试插入新锁记录
DistributedLock lock = new DistributedLock();
lock.setLockKey(lockKey);
lock.setOwner(owner);
lock.setExpireTime(LocalDateTime.now().plusSeconds(expireSeconds));
lock.setCreateTime(LocalDateTime.now());
try {
lockMapper.insert(lock);
return true;
} catch (DuplicateKeyException e) {
// 唯一索引冲突,获取锁失败
return false;
}
}
/**
* 释放锁(只能释放自己持有的锁)
*/
@Transactional
public boolean release(String lockKey, String owner) {
return lockMapper.releaseLock(lockKey, owner) > 0;
}
/**
* 定时清理过期锁(防死锁)
*/
@Scheduled(fixedRate = 60000)
@Transactional
public void cleanupExpiredLocks() {
lockMapper.deleteExpiredLocks(LocalDateTime.now());
}
}
// Mapper层
@Mapper
public interface DistributedLockMapper {
@Select("SELECT * FROM distributed_lock WHERE lock_key = #{lockKey}")
DistributedLock selectByLockKey(String lockKey);
@Insert("INSERT INTO distributed_lock (lock_key, owner, expire_time, create_time) " +
"VALUES (#{lockKey}, #{owner}, #{expireTime}, #{createTime})")
void insert(DistributedLock lock) throws DuplicateKeyException;
@Update("UPDATE distributed_lock SET expire_time = #{expireTime} WHERE lock_key = #{lockKey}")
void updateExpireTime(String lockKey, LocalDateTime expireTime);
@Delete("DELETE FROM distributed_lock WHERE lock_key = #{lockKey} AND owner = #{owner}")
int releaseLock(String lockKey, String owner);
@Delete("DELETE FROM distributed_lock WHERE expire_time < #{now}")
void deleteExpiredLocks(LocalDateTime now);
}
3.4 方案四:无状态Token方案(去中心化)
3.4.1 原理与适用场景
方案原理: 1. 服务端生成带有时间戳的Token 2. 客户端请求时携带Token 3. 服务端验证Token是否在有效期内
优点:
- 无需Redis,不依赖外部存储
- 可水平扩展,天然分布式
缺点:
- 依赖客户端时间(客户端时间不准会导致问题)
- 无法动态调整窗口(Token发出后无法修改)
- 无法强制失效(只能等自然过期)
3.4.2 Java实现
@Component
public class TokenDebounceService {
@Value("${debounce.token.secret:your-256-bit-secret-key-here}")
private String secret;
@Value("${debounce.token.window-seconds:10}")
private int windowSeconds;
private final SecretKeySpec secretKey;
@PostConstruct
public void init() {
// 使用HMAC-SHA256
this.secretKey = new SecretKeySpec(secret.getBytes(StandardCharsets.UTF_8), "HmacSHA256");
}
/**
* 生成防抖Token
*
* Token结构:base64(HMAC-SHA256(userId:action:timestamp))
*/
public String generateToken(String userId, String action) {
long timestamp = System.currentTimeMillis();
String payload = userId + ":" + action + ":" + timestamp;
try {
Mac mac = Mac.getInstance("HmacSHA256");
mac.init(secretKey);
byte[] hmacBytes = mac.doFinal(payload.getBytes(StandardCharsets.UTF_8));
// 组装Token:payload.signature
String signature = Base64.getEncoder().encodeToString(hmacBytes);
String encodedPayload = Base64.getEncoder().encodeToString(payload.getBytes());
return encodedPayload + "." + signature;
} catch (NoSuchAlgorithmException | InvalidKeyException e) {
throw new RuntimeException("Token生成失败", e);
}
}
/**
* 验证Token
*/
public ValidationResult validate(String token) {
try {
String[] parts = token.split("\.");
if (parts.length != 2) {
return ValidationResult.invalid("Token格式错误");
}
String encodedPayload = parts[0];
String signature = parts[1];
// 1. 验证签名
String payload = new String(Base64.getDecoder().decode(encodedPayload));
Mac mac = Mac.getInstance("HmacSHA256");
mac.init(secretKey);
byte[] expectedHmac = mac.doFinal(payload.getBytes(StandardCharsets.UTF_8));
String expectedSignature = Base64.getEncoder().encodeToString(expectedHmac);
if (!signature.equals(expectedSignature)) {
return ValidationResult.invalid("Token签名验证失败");
}
// 2. 验证时间窗口
String[] payloadParts = payload.split(":");
long timestamp = Long.parseLong(payloadParts[2]);
long now = System.currentTimeMillis();
if (now - timestamp > windowSeconds * 1000) {
return ValidationResult.invalid("Token已过期");
}
return ValidationResult.valid(payloadParts[0], payloadParts[1]);
} catch (Exception e) {
return ValidationResult.invalid("Token验证异常: " + e.getMessage());
}
}
public record ValidationResult(
boolean valid,
String userId,
String action,
String errorMessage
) {
public static ValidationResult valid(String userId, String action) {
return new ValidationResult(true, userId, action, null);
}
public static ValidationResult invalid(String errorMessage) {
return new ValidationResult(false, null, null, errorMessage);
}
}
}
/**
* 前端使用示例
*/
@RestController
@RequestMapping("/api/payment")
public class PaymentController {
private final TokenDebounceService tokenService;
private final PaymentService paymentService;
/**
* 前端获取防抖Token
*/
@GetMapping("/token")
public Result<String> getToken(@AuthenticationPrincipal User user) {
String token = tokenService.generateToken(user.getId(), "pay");
return Result.success(token);
}
/**
* 支付请求(需携带Token)
*/
@PostMapping("/pay")
public Result<Void> pay(@RequestBody PayRequest request,
@RequestHeader("X-Debounce-Token") String token) {
// 验证Token
var validation = tokenService.validate(token);
if (!validation.valid()) {
return Result.error(validation.errorMessage());
}
// 验证Token中的用户ID与当前用户一致
if (!validation.userId().equals(request.getUserId())) {
return Result.error("Token与用户不匹配");
}
paymentService.pay(request);
return Result.success();
}
}
四、进阶:防抖 + 幂等一体化设计
4.1 为什么要一体化?
防抖解决的是"重复请求"问题 幂等解决的是"重复执行"问题
两者结合的必要性:
1. 防抖可能被绕过(直接调用API、不走前端)
2. 消息重试场景下,防抖无法覆盖
3. 分布式环境下,防抖和幂等缺一不可
4.2 一体化架构实现
@Service
public class IdempotentDebounceService {
private final StringRedisTemplate redisTemplate;
private final RequestKeyGenerator keyGenerator;
public IdempotentDebounceService(StringRedisTemplate redisTemplate,
RequestKeyGenerator keyGenerator) {
this.redisTemplate = redisTemplate;
this.keyGenerator = keyGenerator;
}
/**
* 业务处理模板方法
*
* 流程:
* 1. 抢占防抖锁
* 2. 检查业务幂等
* 3. 执行业务逻辑
* 4. 缓存结果(成功/失败)
* 5. 释放防抖锁
*
* @param bizId 业务唯一标识(如订单号)
* @param userId 用户ID
* @param handler 业务处理函数
* @param <T> 返回值类型
* @return 业务处理结果
*/
public <T> IdempotentResult<T> execute(
String bizId,
String userId,
String endpoint,
BusinessHandler<T> handler) {
String lockKey = keyGenerator.generate(userId, endpoint, Map.of("bizId", bizId));
String resultKey = "result:" + bizId;
// 1. 尝试获取防抖锁
Boolean acquired = redisTemplate.opsForValue()
.setIfAbsent(lockKey, "processing", 30, TimeUnit.SECONDS);
if (!Boolean.TRUE.equals(acquired)) {
// 2. 锁获取失败,检查是否有缓存结果
String cached = redisTemplate.opsForValue().get(resultKey);
if (cached != null) {
return IdempotentResult.fromCache(cached);
}
return IdempotentResult.processing();
}
try {
// 3. 检查业务幂等(查询是否已处理)
String existingResult = redisTemplate.opsForValue().get(resultKey);
if (existingResult != null) {
return IdempotentResult.fromCache(existingResult);
}
// 4. 执行业务逻辑
T result = handler.handle();
// 5. 缓存成功结果(1小时)
String resultJson = toJson(result);
redisTemplate.opsForValue().set(resultKey, "SUCCESS:" + resultJson, 1, TimeUnit.HOURS);
return IdempotentResult.success(result);
} catch (Exception e) {
// 6. 缓存失败结果(5分钟),避免立即重试
redisTemplate.opsForValue().set(
resultKey,
"FAILED:" + e.getMessage(),
5,
TimeUnit.MINUTES
);
throw e;
} finally {
// 7. 释放防抖锁
redisTemplate.delete(lockKey);
}
}
private String toJson(Object obj) {
try {
return new ObjectMapper().writeValueAsString(obj);
} catch (JsonProcessingException e) {
throw new RuntimeException("结果序列化失败", e);
}
}
@FunctionalInterface
public interface BusinessHandler<T> {
T handle() throws Exception;
}
public record IdempotentResult<T>(
Status status,
T data,
String errorMessage,
boolean fromCache
) {
public enum Status {
SUCCESS, FAILED, PROCESSING
}
public static <T> IdempotentResult<T> success(T data) {
return new IdempotentResult<>(Status.SUCCESS, data, null, false);
}
public static <T> IdempotentResult<T> processing() {
return new IdempotentResult<>(Status.PROCESSING, null, "业务处理中", false);
}
public static <T> IdempotentResult<T> fromCache(String cached) {
if (cached.startsWith("SUCCESS:")) {
try {
T data = new ObjectMapper().readValue(
cached.substring(8),
(Class<T>) Object.class
);
return new IdempotentResult<>(Status.SUCCESS, data, null, true);
} catch (JsonProcessingException e) {
return new IdempotentResult<>(Status.FAILED, null, "缓存解析失败", true);
}
} else if (cached.startsWith("FAILED:")) {
return new IdempotentResult<>(
Status.FAILED,
null,
cached.substring(7),
true
);
}
return new IdempotentResult<>(Status.PROCESSING, null, "未知状态", true);
}
}
}
五、方案选型决策树
5.1 场景推荐
| 场景 | 窗口时间 | 推荐方案 | 原因 |
|---|---|---|---|
| 普通表单提交 | 3-5秒 | 方案一 SETNX | 简单高效,满足需求 |
| 支付/退款 | 10-30秒 | 方案三 分布式锁 | 强保证,不容有失 |
| 库存扣减 | 实时 | 方案三 分布式锁 | 需强一致性 |
| 搜索建议 | 300ms | 前端防抖 | 后端方案没必要 |
| 缓存预热 | 1-2秒 | 方案二 Lua | 原子性,高并发友好 |
| 无Redis环境 | - | 方案四 Token | 依赖少,可部署 |
| 消息消费 | - | 方案三 + 幂等 | 必须保证只消费一次 |
5.2 方案对比
| 方案 | 代码复杂度 | 性能 | 可靠性 | 运维成本 | 适用规模 |
|---|---|---|---|---|---|
| SETNX | ⭐ | ⭐⭐⭐⭐⭐ | ⭐⭐⭐ | 低 | 中小 |
| Lua | ⭐⭐ | ⭐⭐⭐⭐⭐ | ⭐⭐⭐⭐⭐ | 中 | 中大 |
| 分布式锁 | ⭐⭐⭐ | ⭐⭐⭐ | ⭐⭐⭐⭐⭐ | 中高 | 大 |
| Token | ⭐⭐ | ⭐⭐⭐⭐⭐ | ⭐⭐⭐ | 无 | 任意 |
六、生产环境最佳实践
6.1 监控与告警
/**
* 防抖指标采集
*/
@Component
public class DebounceMetrics {
private final MeterRegistry meterRegistry;
private final Counter duplicateRequestCounter;
private final Counter totalRequestCounter;
private final DistributionSummary windowDuration;
public DebounceMetrics(MeterRegistry meterRegistry) {
this.meterRegistry = meterRegistry;
this.duplicateRequestCounter = Counter.builder("debounce.duplicate")
.description("重复请求数量")
.tag("scene", "all")
.register(meterRegistry);
this.totalRequestCounter = Counter.builder("debounce.total")
.description("总请求数量")
.register(meterRegistry);
this.windowDuration = DistributionSummary.builder("debounce.window.duration")
.description("防抖窗口时长")
.publishPercentiles(0.5, 0.95, 0.99)
.register(meterRegistry);
}
public void recordDuplicate(String scene) {
duplicateRequestCounter.increment();
// 按场景记录
Counter.builder("debounce.duplicate")
.tag("scene", scene)
.register(meterRegistry)
.increment();
}
public void recordRequest(String scene, int windowSeconds) {
totalRequestCounter.increment();
windowDuration.record(windowSeconds);
}
/**
* 告警规则:
* - 重复请求率 > 20%:可能存在攻击或前端bug
* - 特定场景重复率异常:如支付场景重复率突然升高
*/
}
6.2 配置中心集成
# application.yml
debounce:
enabled: true
default-window-seconds: 5
# 按场景配置
scenes:
submit:
window-seconds: 5
enabled: true
pay:
window-seconds: 30
enabled: true
refund:
window-seconds: 30
enabled: true
cache-warm:
window-seconds: 2
enabled: true
# Redis配置
redis:
key-prefix: "debounce:"
timeout-ms: 3000
# 监控配置
monitor:
alert-threshold: 0.2 # 重复率20%告警
enable-detailed-log: false
七、总结
7.1 技术选型建议
接口防抖是后端工程师的必备技能,它不仅是技术实现,更是一种系统设计思维。
学习路径:
| 阶段 | 推荐方案 | 重点掌握 |
|---|---|---|
| 入门 | 方案一 SETNX | 理解防抖原理,会用AOP切面 |
| 进阶 | 方案二 Lua | 理解原子性重要性,会写Lua脚本 |
| 高可靠 | 方案三 分布式锁 | 理解锁的边界,会处理死锁 |
| 架构师 | 方案四 + 一体化 | 理解防抖与幂等的关系 |