接口防抖实战:后端高并发场景下的请求去重策略

4 阅读6分钟

一、背景:为什么接口防抖如此重要?

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脚本
高可靠方案三 分布式锁理解锁的边界,会处理死锁
架构师方案四 + 一体化理解防抖与幂等的关系