Redis防重复点击与分布式锁

在生产环境中,我们经常会遇到两个需求:

  1. 限制用户在N秒内不能重复操作(如连续点击导出按钮)

  2. 确保同一时间只有一个线程能操作共享资源(如扣减库存)

很多开发者习惯用Redisson的RLock来解决这两个问题,但这其实是一种语义错位。今天我们来聊聊为什么"防重复点击"不应该用分布式锁。


一、防重复点击:设置一个"冷却标记"

1.1 业务本质

防重复点击的核心需求是:在用户操作后,设置一个N秒后自动消失的"冷却标记"。这个时间与业务执行时长无关,纯粹是业务规则限制。

1.2 正确实现(RedisTemplate)

复制代码
@Service
public class DuplicateCheckService {
    
    @Autowired
    private RedisTemplate<String, String> redisTemplate;
    
    /**
     * 防重复点击检查
     * @param bizType 业务类型(如export、submit)
     * @param userId 用户ID
     * @param cooldown 冷却时间(秒)
     */
    public void checkDuplicate(String bizType, Long userId, long cooldown) {
        String key = String.format("duplicate:%s:%d", bizType, userId);
        
        // 核心:SET NX EX - 不存在才设置,并自动过期
        Boolean success = redisTemplate.opsForValue()
                .setIfAbsent(key, "1", cooldown, TimeUnit.SECONDS);
        
        if (Boolean.FALSE.equals(success)) {
            Long ttl = redisTemplate.getExpire(key, TimeUnit.SECONDS);
            throw new BusinessException(ttl + "秒内不可重复操作");
        }
    }
}

关键点

  • setIfAbsent = Redis的SET NX命令,原子性判断+设置

  • 自动过期:Redis会在cooldown秒后删除Key,无需手动清理

  • 无锁竞争:失败时直接返回,不等待

1.3 使用示例

复制代码
@Transactional
public Result exportData(ExportRequest request, User user) {
    // 检查60秒内是否重复点击
    duplicateCheckService.checkDuplicate("export", user.getId(), 60);
    
    // 执行导出逻辑(在事务内)
    return doExport(request);
}

二、分布式锁:确保"排他性访问"

2.1 业务本质

分布式锁的核心需求是:保护共享资源,确保同一时间只有一个线程能修改它。必须手动释放锁,否则会造成死锁。

2.2 正确实现(Redisson)

复制代码
@Service
public class DistributedLockService {
    
    @Autowired
    private RedissonClient redissonClient;
    
    /**
     * 带锁执行业务逻辑
     * @param lockKey 锁标识
     * @param waitTime 获取锁最大等待时间(秒)
     * @param leaseTime 锁自动释放时间(秒,防死锁)
     */
    public void executeWithLock(String lockKey, long waitTime, long leaseTime, 
                                Runnable businessLogic) {
        RLock lock = redissonClient.getLock(lockKey);
        try {
            // 尝试获取锁(可重入)
            if (!lock.tryLock(waitTime, leaseTime, TimeUnit.SECONDS)) {
                throw new BusinessException("获取锁失败,请稍后重试");
            }
            
            // 执行业务逻辑
            businessLogic.run();
            
        } catch (InterruptedException e) {
            Thread.currentThread().interrupt();
            throw new BusinessException("操作被中断");
        } finally {
            // ⚠️ 必须手动释放!
            if (lock.isHeldByCurrentThread()) {
                lock.unlock();
            }
        }
    }
}

关键点

  • tryLock:尝试获取,失败时等待

  • 手动释放 :必须在finallyunlock(),否则死锁

  • 看门狗:未指定leaseTime时会自动续期

2.3 使用示例

复制代码
public void deductStock(Long productId, int quantity) {
    String lockKey = "stock:" + productId;
    
    // 保护库存扣减操作
    lockService.executeWithLock(lockKey, 3, 10, () -> {
        int stock = getStockFromDB(productId);
        if (stock < quantity) {
            throw new BusinessException("库存不足");
        }
        updateStock(productId, stock - quantity);
    });
}

三、核心区别对比

对比维度 防重复点击 分布式锁
业务语义 冷却计时器(N秒后自动解除) 互斥信号(必须手动释放)
Redis命令 SET key value NX EX seconds SET key value + 续期+手动DEL
生命周期 自动过期(与业务无关) 手动释放(与业务强相关)
失败策略 直接拒绝(不等待) 可选等待或失败
性能 极高(单次O(1)操作) 较高(有竞争开销)
代码复杂度 极低(3行代码) 较高(try-finally+异常处理)
事务兼容性 ✅ 完美兼容(无状态) ⚠️ 需分离锁与事务
适用场景 防重、限流、短信冷却 库存扣减、并发写文件

四、致命误区:用锁实现防重复点击

❌ 错误代码(最常见)

复制代码
// 误区1:finally立即释放(锁无效)
@Transactional
public Result export() {
    lock.tryLock(0, 60, TimeUnit.SECONDS);
    try {
        return doExport();
    } finally {
        lock.unlock(); // ⚠️ 业务还没结束,锁就没了
    }
}

// 误区2:不释放等过期(用户体验差)
public Result export() {
    lock.tryLock(0, 60, TimeUnit.SECONDS);
    return doExport(); // ⚠️ 导出5秒完成,用户必须等55秒
}

// 误区3:与事务冲突
@Transactional
public Result export() {
    lock.lock();
    // 事务提交前释放锁 → 脏读
    // 事务回滚后释放锁 → 锁已失效
    return doExport();
}

问题根源 :分布式锁的生命周期必须人为控制 ,而防重复点击需要的是 "设置后不管" 的临时标记。


五、决策指南:何时用哪个?

5.1 选择流程图

复制代码
需求:限制操作频率
    ↓
是"限制同一用户N秒内不能重复操作"?
    ↓ 是
使用 RedisTemplate.setIfAbsent()(防重复点击)
    ↓
否 → 是"保护共享资源,防止并发修改"?
    ↓ 是
使用 Redisson RLock(分布式锁)
    ↓
否 → 其他方案(如限流器RateLimiter)

5.2 一句话总结

当你想"限制用户在N秒内不能操作"时,用带过期时间的标记;当你想"确保只有一个线程能操作"时,才用分布式锁。


六、生产环境最佳实践

6.1 Key设计规范

复制代码
// 防重复点击:用户级粒度
String key = "duplicate:export:" + userId;

// 分布式锁:资源级粒度
String key = "lock:stock:" + productId;

6.2 冷却时间设置建议

  • 导出类:60-300秒(防止频繁生成大文件)

  • 提交类:5-10秒(防止表单重复提交)

  • 短信类:60秒(运营商普遍限制)

6.3 锁时长设置建议

  • leaseTime:必须大于业务最大执行时间

  • waitTime:根据业务容忍度设置,避免长时间阻塞


七、总结

防重复点击和分布式锁是两种完全不同的语义,但开发者常因"都用到Redis"而混用。记住:

  • 防重复点击 = 冷却计时器 :用SET NX EX,自动过期,无需释放

  • 分布式锁 = 互斥信号 :用Redisson RLock,手动释放,保护资源

选错工具不仅代码复杂,还会引入死锁、性能下降、用户体验差等隐患。希望这篇文章能帮你避开这个90%开发者都踩过的坑。

相关推荐
itwangyang5202 小时前
macOS(Sequoia 15.x)+ MacTeX 2025 + TeXShop + 期刊模板
java·开发语言·macos
spencer_tseng2 小时前
Eclipse HeapDump
java·ide·eclipse
Vic101012 小时前
Redis防重复点击与分布式锁实现方案对比笔记
java·redis·笔记·分布式
计算机毕设指导62 小时前
基于微信小程序的派出所业务管理系统【源码文末联系】
java·spring boot·mysql·微信小程序·小程序·tomcat·uniapp
程可爱2 小时前
详解Redis 中 RDB 与 AOF 的区别
redis
罗政2 小时前
mybatis-plus插件解决sql报错:this is incompatible with sql_mode=only_full_group_by ”
数据库·sql·mybatis
ohoy2 小时前
Xxl-Job实现订单30分钟未支付自动取消
java
leo_qiu_s2 小时前
MERGE INTO语句
数据库
明洞日记2 小时前
【设计模式手册022】抽象工厂模式 - 创建产品家族
java·设计模式·抽象工厂模式