前言
分布式锁是分布式系统的基础。synchronized在多台服务器上失效怎么办?如何防止库存超卖? Redis、Zookeeper、数据库三种方案各有优劣,理解它们的原理和坑点才能选对方案。
我并没有能力让你成为分布式专家,我只是想让你彻底理解分布式锁的实现原理、Redis主从切换丢锁的问题、以及Redisson的Watch Dog机制。每个方案都会详细推导,讲透本质。
摘要
从"库存超卖事故"出发,剖析分布式锁的三种实现方案。通过Redis的SETNX原子性、Zookeeper的临时顺序节点、以及Redisson的Watch Dog机制,揭秘分布式环境下的加锁解锁。配合详细推导,给出分布式锁的透彻理解。
一、从库存超卖说起
双十一凌晨0点,秒杀开始3分钟后。
哈吉米接到告警:
🚨 库存超卖!
100件商品,卖出了157件!
查看代码:
public boolean seckill(Long productId) {
// 查询库存
Stock stock = stockMapper.selectById(productId);
if (stock.getNum() <= 0) {
return false;
}
// 扣减库存(加了synchronized)
synchronized (this) {
stock.setNum(stock.getNum() - 1);
stockMapper.updateById(stock);
}
return true;
}
查看架构:
【负载均衡】
/ | \
服务器1 服务器2 服务器3
↓ ↓ ↓
【MySQL】
哈吉米:"有3台服务器!synchronized只锁住单台JVM,锁不住多台!"
南北绿豆和阿西噶阿西赶来。
南北绿豆:"分布式场景下,synchronized无效,必须用分布式锁。"
二、什么是分布式锁
南北绿豆:"分布式锁就是跨JVM的锁。"
2.1 单机锁 vs 分布式锁
单机锁(synchronized、ReentrantLock):
作用范围:同一个JVM进程内
3台服务器 = 3个JVM
→ 每台都有自己的锁
→ 锁不住其他服务器 ❌
分布式锁:
作用范围:所有服务器
实现:外部共享存储(Redis、Zookeeper、数据库)
→ 所有服务器共享同一把锁 ✅
2.2 分布式锁的3个核心要求
| 要求 | 说明 |
|---|---|
| 互斥性 | 同一时刻只有一个客户端能持有锁 |
| 防死锁 | 即使持有锁的客户端崩溃,锁也能被释放 |
| 高可用 | 锁服务必须高可用(不能单点故障) |
哈吉米:"分布式锁的核心是共享存储。"
三、方案1:Redis分布式锁
南北绿豆:"Redis分布式锁最常用,但有很多坑。"
3.1 版本1:SETNX(有问题)
String lockKey = "lock:stock:" + productId;
try {
// 获取锁
Boolean success = redisTemplate.opsForValue().setIfAbsent(lockKey, "1");
if (!success) {
return false;
}
// 业务逻辑
doSeckill(productId);
} finally {
// 释放锁
redisTemplate.delete(lockKey);
}
问题:没有过期时间
场景:
服务器A获取锁
服务器A宕机(finally没执行)
→ 锁永远不释放
→ 死锁 ❌
3.2 版本2:SETNX + EXPIRE(还有问题)
// 获取锁
redisTemplate.opsForValue().setIfAbsent(lockKey, "1");
// 设置过期时间
redisTemplate.expire(lockKey, 30, TimeUnit.SECONDS);
问题:不是原子操作
场景:
SETNX成功
还没执行EXPIRE,服务器宕机
→ 锁没有过期时间
→ 死锁 ❌
3.3 版本3:SET NX EX(推荐)
String lockKey = "lock:stock:" + productId;
String lockValue = UUID.randomUUID().toString();
try {
// 原子操作:SET key value NX EX 30
Boolean success = redisTemplate.opsForValue().setIfAbsent(
lockKey,
lockValue,
30,
TimeUnit.SECONDS
);
if (!success) {
return false;
}
// 业务逻辑
doSeckill(productId);
} finally {
// Lua脚本:原子删除
String script =
"if redis.call('get', KEYS[1]) == ARGV[1] then " +
" return redis.call('del', KEYS[1]) " +
"else " +
" return 0 " +
"end";
redisTemplate.execute(
new DefaultRedisScript<>(script, Long.class),
Collections.singletonList(lockKey),
lockValue
);
}
改进:
- SET NX EX:原子操作 ✅
- UUID:防止误删别人的锁 ✅
- Lua脚本:原子判断+删除 ✅
问题:业务时间超过锁过期时间
场景:
服务器A获取锁(过期30秒)
业务执行了40秒
→ 30秒时锁过期
→ 服务器B获取到锁
→ A和B同时执行 ❌
3.4 版本4:Redisson(终极方案)
@Autowired
private RedissonClient redissonClient;
public boolean seckill(Long productId) {
String lockKey = "lock:stock:" + productId;
RLock lock = redissonClient.getLock(lockKey);
try {
// 尝试获取锁(等待10秒,锁过期30秒)
boolean success = lock.tryLock(10, 30, TimeUnit.SECONDS);
if (!success) {
return false;
}
// 业务逻辑(即使执行超过30秒,锁也不会过期)
doSeckill(productId);
return true;
} catch (InterruptedException e) {
return false;
} finally {
// 释放锁
if (lock.isHeldByCurrentThread()) {
lock.unlock();
}
}
}
Watch Dog机制:
Redisson的自动续期:
1. 获取锁,设置过期时间30秒
2. 启动Watch Dog定时任务(每10秒执行)
3. 检查锁是否还被当前线程持有
4. 如果是,续期到30秒
5. 业务执行完,unlock,停止Watch Dog
结果:即使业务执行1小时,锁也不会过期 ✅
四、方案2:Zookeeper分布式锁
南北绿豆:"Zookeeper分布式锁是强一致性的选择。"
4.1 Zookeeper锁的原理
核心:利用临时顺序节点 + 监听机制
详细流程(lockPath = /lock/stock/1001):
客户端1:
1. 在/lock/stock/1001下创建临时顺序节点
→ /lock/stock/1001/0000000001
2. 获取/lock/stock/1001下的所有子节点
→ [0000000001]
3. 判断自己是否是最小序号
→ 0000000001是最小
→ 获取锁成功 ✅
客户端2:
1. 在/lock/stock/1001下创建节点
→ /lock/stock/1001/0000000002
2. 获取所有子节点
→ [0000000001, 0000000002]
3. 判断自己是否是最小序号
→ 0000000002不是最小
→ 监听前一个节点:0000000001
4. 等待...
客户端1执行完:
删除节点:/lock/stock/1001/0000000001
Zookeeper通知客户端2:
"你监听的节点删除了"
客户端2:
重新判断,0000000002现在是最小的
→ 获取锁成功 ✅
关键点:
- lockPath是基础路径
- 临时顺序节点在lockPath下面创建
- 节点格式:
lockPath/序号
4.2 代码实现
@Autowired
private CuratorFramework zkClient;
public boolean seckill(Long productId) {
// 基础路径
String lockPath = "/lock/stock/" + productId;
// Curator的InterProcessMutex封装了上述原理
InterProcessMutex lock = new InterProcessMutex(zkClient, lockPath);
try {
// 尝试获取锁(超时10秒)
if (lock.acquire(10, TimeUnit.SECONDS)) {
// 业务逻辑
doSeckill(productId);
return true;
} else {
return false;
}
} catch (Exception e) {
log.error("获取锁失败", e);
return false;
} finally {
try {
lock.release();
} catch (Exception e) {
log.error("释放锁失败", e);
}
}
}
说明:
InterProcessMutex内部自动完成:
- 在lockPath下创建临时顺序节点
- 判断是否最小节点
- 监听前一个节点
- 自动续期(通过心跳)
开发者只需要:
tryLock() → 获取锁
unlock() → 释放锁
优点:
- ✅ 强一致性(CP模型)
- ✅ 自动防死锁(临时节点,客户端宕机自动删除)
- ✅ 公平锁(严格按顺序)
缺点:
- ❌ 性能较差(需要多次网络请求)
- ❌ 依赖Zookeeper集群
五、方案3:数据库分布式锁
南北绿豆:"数据库锁实现简单,但性能最差。"
5.1 实现原理
利用唯一索引
CREATE TABLE distributed_lock (
lock_key VARCHAR(100) PRIMARY KEY,
lock_value VARCHAR(100),
expire_time DATETIME,
INDEX idx_expire(expire_time)
);
5.2 代码实现
public boolean seckill(Long productId) {
String lockKey = "stock:" + productId;
String lockValue = UUID.randomUUID().toString();
try {
// 尝试获取锁(INSERT)
DistributedLock lock = new DistributedLock();
lock.setLockKey(lockKey);
lock.setLockValue(lockValue);
lock.setExpireTime(new Date(System.currentTimeMillis() + 30000));
try {
lockMapper.insert(lock); // 插入成功 = 获取锁
} catch (DuplicateKeyException e) {
return false; // 主键冲突,锁已存在
}
// 业务逻辑
doSeckill(productId);
return true;
} finally {
// 释放锁
lockMapper.deleteByKeyAndValue(lockKey, lockValue);
}
}
优点:
- ✅ 实现简单
- ✅ 不需要额外组件
缺点:
- ❌ 性能差(每次INSERT/DELETE)
- ❌ 不支持自动续期
- ❌ 需要定时任务清理过期锁
六、三种方案完整对比
6.1 性能对比
测试环境:单机Redis、3节点Zookeeper、MySQL 5.7,1000并发
| 方案 | 平均响应时间 | QPS | 说明 |
|---|---|---|---|
| Redis(Redisson) | 12ms | ~8000 | 性能最好 |
| Zookeeper | 85ms | ~1000 | 网络请求多 |
| 数据库 | 150ms | ~600 | 数据库IO慢 |
注:实际性能取决于硬件、网络、并发数
6.2 一致性与可用性
| 方案 | 一致性 | 可用性 | 分区容错 | 说明 |
|---|---|---|---|---|
| Redis单机 | 弱 | 高 | 无 | 单机,不考虑分区 |
| Redis主从 | 弱 | 高 | 无 | 主从异步,可能丢锁 |
| Redis Cluster | 弱 | 高 | 有 | AP模型 |
| Zookeeper | 强 | 中 | 有 | CP模型 |
| 数据库单机 | 强 | 高 | 无 | 单机,不考虑分区 |
CAP说明:
分布式环境下(网络分区P必然存在):
只能选CP或AP,不能CA
Redis:牺牲一致性,保证可用性(AP)
Zookeeper:牺牲可用性,保证一致性(CP)
单机环境(无分区P):
可以同时C+A
6.3 选型建议
阿西噶阿西:
| 场景 | 推荐方案 | 原因 |
|---|---|---|
| 秒杀、抢红包 | Redis(Redisson) | 性能好,允许极少数不一致 |
| 金融交易 | Zookeeper | 强一致性,不能出错 |
| 低并发 | 数据库 | 实现简单,性能够用 |
| 高并发 | Redis Cluster | 性能+高可用 |
南北绿豆:"90%的场景,用Redisson就够了。"
七、分布式锁的常见坑
7.1 坑1:锁没有过期时间
// ❌ 错误
redisTemplate.opsForValue().setIfAbsent(lockKey, "1");
// ✅ 正确
redisTemplate.opsForValue().setIfAbsent(lockKey, "1", 30, TimeUnit.SECONDS);
7.2 坑2:删除了别人的锁
// ❌ 错误
finally {
redisTemplate.delete(lockKey); // 可能删除了别人的锁
}
// 场景:
// A获取锁,过期30秒
// 业务执行35秒
// 30秒时,锁过期,B获取锁
// A执行完,删除锁(删除了B的锁)❌
// ✅ 正确(用UUID+Lua脚本)
String uuid = UUID.randomUUID().toString();
setIfAbsent(lockKey, uuid, 30, TimeUnit.SECONDS);
// Lua脚本判断uuid再删除
7.3 坑3:业务执行时间超过锁过期时间
解决:用Redisson的Watch Dog自动续期
7.4 坑4:Redis主从切换丢锁
场景:
1. 客户端A在主节点获取锁
2. 主节点宕机(锁还没同步到从节点)
3. 从节点升级为主节点
4. 客户端B在新主节点获取锁(新主节点没有锁记录)
5. A和B同时持有锁 ❌
解决:Redlock(Redis作者提出,但有争议)
争议:
Martin Kleppmann(分布式专家)认为Redlock不安全:
- 依赖时钟同步(GC暂停可能导致问题)
- 网络分区时可能失效
Redis作者antirez反驳:
- 实际场景足够可靠
结论:
绝对不能出错 → Zookeeper
一般场景 → Redisson单实例/集群足够
八、分布式锁总结
8.1 核心要点
南北绿豆:
- 分布式锁:跨JVM的锁,基于共享存储
- Redis锁:SET NX EX,用Redisson最省心
- Zookeeper锁:临时顺序节点,强一致性
- 坑点:过期时间、误删锁、业务超时、主从切换
8.2 最佳实践
阿西噶阿西:
生产环境推荐:
1. 用Redisson(封装完善,自动续期)
2. 设置合理过期时间(根据业务)
3. 监控锁的持有时间
4. 设置锁粒度(按业务ID,不要全局锁)
哈吉米:"理解了分布式锁的原理和坑点,就不会踩坑了。"
参考资料:
- Redisson官方文档
- 《Redis设计与实现》- 黄健宏
- Martin Kleppmann - How to do distributed locking