在生产环境中,我们经常会遇到两个需求:
-
限制用户在N秒内不能重复操作(如连续点击导出按钮)
-
确保同一时间只有一个线程能操作共享资源(如扣减库存)
很多开发者习惯用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:尝试获取,失败时等待 -
手动释放 :必须在
finally中unlock(),否则死锁 -
看门狗:未指定
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%开发者都踩过的坑。