1. 分布式锁的核心价值与典型场景
在分布式系统中,多个服务实例对共享资源进行并发访问时,传统的单机锁机制会完全失效。我曾经历过一个典型的线上事故:促销活动期间,由于没有正确实现分布式锁,导致同一个优惠券被重复核销了37次。这个惨痛教训让我深刻认识到分布式锁的重要性。
分布式锁需要满足三个基本特性:
- 互斥性:任意时刻只有一个客户端能持有锁
- 避免死锁:即使客户端崩溃,锁也能自动释放
- 容错性:只要大部分Redis节点存活,客户端就能获取和释放锁
在实际业务中,黑马点评这类高并发系统会频繁遇到需要分布式锁的场景:
- 秒杀活动中防止超卖
- 优惠券发放的幂等控制
- 用户余额修改的并发安全
- 分布式定时任务的防重复执行
关键提示:不要为了用锁而用锁。我曾见过有团队给所有写操作都加分布式锁,导致系统吞吐量下降80%。正确的做法是先评估并发冲突的概率和影响,再决定是否引入锁机制。
需要模型API调用? 免费领10W Token,多模型网关一键接入 Claude、DeepSeek 等主流模型。
2. Redis实现分布式锁的演进之路
2.1 最基础的SETNX实现
初学Redis分布式锁时,很多人会写出这样的代码:
java复制Boolean result = redisTemplate.opsForValue().setIfAbsent("lock_key", "1");
if (result) {
try {
// 业务逻辑
} finally {
redisTemplate.delete("lock_key");
}
}
这种实现存在致命缺陷:如果获取锁的客户端崩溃,锁将永远无法释放。我在测试环境用kill -9模拟进程崩溃时,整个系统因此完全死锁。
2.2 引入过期时间的改进版
于是我们加入过期时间:
java复制Boolean result = redisTemplate.opsForValue()
.setIfAbsent("lock_key", "1", 30, TimeUnit.SECONDS);
但这又带来了新问题:
- 业务操作可能超过30秒,导致锁提前释放
- 非原子性操作可能导致设置值和过期时间不同步
2.3 Lua脚本保证原子性
Redis 2.6之后支持Lua脚本,我们可以用脚本保证操作的原子性:
lua复制if redis.call('setnx', KEYS[1], ARGV[1]) == 1 then
return redis.call('pexpire', KEYS[1], ARGV[2])
else
return 0
end
2.4 可重入锁的实现
对于递归调用或嵌套方法,需要实现锁的可重入性。Redisson通过客户端ID和计数器实现了这一点:
java复制// 同一个线程可重复获取锁
lock.lock();
try {
lock.lock(); // 计数器+1
// 业务逻辑
} finally {
lock.unlock(); // 计数器-1
lock.unlock(); // 真正释放
}
3. Redisson分布式锁源码深度解析
3.1 核心类结构
Redisson的锁实现主要涉及这几个关键类:
RedissonLock: 基础锁实现RedissonFairLock: 公平锁实现LockPubSub: 处理锁的订阅发布消息RedissonLockEntry: 维护锁的等待队列
3.2 加锁流程剖析
以tryLockInnerAsync方法为例,核心Lua脚本如下:
lua复制local key = KEYS[1];
local threadId = ARGV[1];
local leaseTime = ARGV[2];
-- 1. 锁不存在时直接获取
if (redis.call('exists', key) == 0) then
redis.call('hset', key, threadId, '1');
redis.call('pexpire', key, leaseTime);
return nil;
end;
-- 2. 可重入处理
if (redis.call('hexists', key, threadId) == 1) then
redis.call('hincrby', key, threadId, '1');
redis.call('pexpire', key, leaseTime);
return nil;
end;
-- 3. 返回剩余生存时间
return redis.call('pttl', key);
3.3 看门狗机制
Redisson最精妙的设计是看门狗(Watchdog)机制。当不指定leaseTime时,Redisson会启动一个定时任务,默认每10秒检查一次,如果业务还在执行就延长锁的生存时间:
java复制private void scheduleExpirationRenewal(long threadId) {
// 每隔internalLockLeaseTime/3时间续期
Timeout task = commandExecutor.getConnectionManager()
.newTimeout(new TimerTask() {
public void run(Timeout timeout) {
// 续期Lua脚本
renewExpirationAsync(threadId);
}
}, internalLockLeaseTime / 3, TimeUnit.MILLISECONDS);
}
4. 生产环境中的最佳实践
4.1 锁的命名规范
我建议采用这样的命名规则:
code复制业务线:系统名:模块:操作类型:资源ID
例如:
code复制trade:order:create:coupon:12345
这样既方便排查问题,又能利用Redis的键空间通知功能做监控。
4.2 超时时间设置原则
根据我的经验,超时时间设置应遵循:
- 设置合理的等待时间(tryLock的waitTime)
- 业务超时时间 = 平均耗时 × 3 + 缓冲时间
- 监控P99耗时,动态调整超时时间
4.3 常见问题排查指南
问题现象:获取锁耗时突然增加
- 检查Redis节点负载
- 检查网络延迟
- 确认没有长事务阻塞Redis
问题现象:出现锁失效
- 检查时钟同步(NTP)
- 检查GC日志,避免长时间STW
- 验证看门狗线程是否存活
4.4 性能优化方案
- 采用分段锁降低争抢:
java复制// 将库存分成16段
int segment = itemId.hashCode() & 15;
RLock lock = redisson.getLock("stock:" + segment);
- 使用读写锁提升并发:
java复制RReadWriteLock rwLock = redisson.getReadWriteLock("resource_lock");
rwLock.readLock().lock(); // 多个读可以并发
rwLock.writeLock().lock(); // 写独占
- 避免锁的"惊群效应":
- 使用Redisson的公平锁(RedissonFairLock)
- 设置随机退避时间
5. 分布式锁的替代方案对比
5.1 基于ZooKeeper的实现
ZooKeeper通过临时顺序节点实现分布式锁:
- 优点:强一致性,天然的可重入性
- 缺点:性能较差(通常比Redis慢10倍)
- 适用场景:对可靠性要求极高的金融交易
5.2 基于数据库的实现
通过唯一索引或乐观锁实现:
sql复制-- 乐观锁示例
UPDATE inventory SET count=count-1
WHERE item_id=1001 AND count>0 AND version=123;
- 优点:无需额外组件
- 缺点:性能差,容易产生死锁
5.3 Redis vs ZK vs DB选型矩阵
| 特性 | Redis | ZooKeeper | 数据库 |
|---|---|---|---|
| 性能 | 10万+/秒 | 1万+/秒 | 1千+/秒 |
| 一致性 | 最终一致 | 强一致 | 强一致 |
| 实现复杂度 | 中等 | 高 | 低 |
| 可重入 | 需额外实现 | 原生支持 | 需额外实现 |
| 推荐场景 | 高并发场景 | 金融交易 | 简单业务 |
6. 黑马点评中的实战应用
在黑马点评的优惠券核销场景中,我们最终采用的方案是:
java复制RLock lock = redisson.getLock("coupon:" + userId + ":" + couponId);
try {
if (lock.tryLock(1, 10, TimeUnit.SECONDS)) {
// 1. 查询优惠券状态
Coupon coupon = couponService.getById(couponId);
// 2. 校验是否可用
if (coupon.getStatus() == 0) {
// 3. 更新状态
coupon.setStatus(1);
couponService.updateById(coupon);
}
}
} finally {
lock.unlock();
}
这个方案解决了我们遇到的几个关键问题:
- 防止用户重复使用同一张优惠券
- 避免不同用户同时抢到最后的几张优惠券
- 系统崩溃时能自动释放锁
在压力测试中,这个方案支持了5000+ QPS的并发核销请求,错误率为0。
