分布式锁深度实战——Redis Redlock、ZooKeeper、MySQL 三种方案的对比与坑
分布式锁深度实战——Redis Redlock、ZooKeeper、MySQL 三种方案的对比与坑
适读人群:遇到分布式锁问题的 Java 后端开发者 | 阅读时长:约19分钟 | 核心价值:彻底理解三种分布式锁的原理与适用场景,不再被面试题难倒,更不在生产环境踩坑
一次超卖事故的复盘
去年双十一前,我的读者小黄发来消息,语气很急促:"老张,我们秒杀接口出问题了,明明库存只有 100 件,卖出去 170 件,超卖了 70 件,这下惨了。"
我问他:"你们怎么加锁的?"
他说:"用的 Redis SETNX,我觉得应该没问题啊,伪代码就是:
SET lock_key value NX EX 30
try:
check_stock()
deduct_stock()
finally:
del lock_key这不是标准做法吗?"
"你在多少台服务器上跑的?"
"8 台,我们是 Redis 主从,一个 Master 两个 Slave。"
问题找到了。Redis 主从架构下,SETNX 成功写入 Master,但在同步到 Slave 之前,Master 宕机了。Slave 晋升为新 Master,这时候锁信息没了,另一个进程又能拿到锁,导致并发操作,超卖了。
这是分布式锁最经典的陷阱之一,今天把三种主流方案的原理、坑和适用场景全部讲透。
方案一:Redis 分布式锁
基础实现
@Component
public class RedisDistributedLock {
@Autowired
private StringRedisTemplate redisTemplate;
private static final String LOCK_PREFIX = "distributed_lock:";
private static final Long LOCK_EXPIRE_TIME = 30L; // 30秒
/**
* 加锁
* @param lockKey 锁的 key
* @param lockValue 锁的 value(必须唯一,用于解锁时校验是不是自己的锁)
* @param expireTime 过期时间(秒)
* @return 是否加锁成功
*/
public boolean tryLock(String lockKey, String lockValue, long expireTime) {
Boolean result = redisTemplate.opsForValue()
.setIfAbsent(LOCK_PREFIX + lockKey, lockValue,
Duration.ofSeconds(expireTime));
return Boolean.TRUE.equals(result);
}
/**
* 解锁(Lua 脚本保证原子性)
* 关键:只能解自己的锁,通过 value 校验
*/
public boolean unlock(String lockKey, String lockValue) {
String luaScript =
"if redis.call('get', KEYS[1]) == ARGV[1] then " +
" return redis.call('del', KEYS[1]) " +
"else " +
" return 0 " +
"end";
RedisScript<Long> script = RedisScript.of(luaScript, Long.class);
Long result = redisTemplate.execute(script,
Collections.singletonList(LOCK_PREFIX + lockKey),
lockValue);
return Long.valueOf(1L).equals(result);
}
}为什么解锁要用 Lua 脚本?
如果先 GET 校验 value,再 DEL,这两步不是原子的:
- 线程 A GET 校验通过
- 锁恰好过期
- 线程 B 重新获取了锁
- 线程 A DEL,把线程 B 的锁删了
Lua 脚本在 Redis 里原子执行,避免这个问题。
看门狗(自动续期)
30 秒的锁过期时间如果不够,业务还没执行完锁就超期了,另一个线程进来,会出现并发问题。
解法:锁续期(看门狗机制)。Redisson 的 RLock 实现了这个功能:
@Component
public class RedissonLockService {
@Autowired
private RedissonClient redissonClient;
public void executeWithLock(String lockKey, Runnable task) throws InterruptedException {
RLock lock = redissonClient.getLock("distributed_lock:" + lockKey);
// tryLock(等待时间, 锁超时时间, 时间单位)
// 锁超时时间设为 -1:开启看门狗,自动续期(每隔 10 秒检查并续期)
boolean locked = lock.tryLock(3, -1, TimeUnit.SECONDS);
if (!locked) {
throw new BusinessException("获取锁失败,请稍后重试");
}
try {
task.run();
} finally {
// 必须在 finally 中释放锁
if (lock.isHeldByCurrentThread()) {
lock.unlock();
}
}
}
/**
* 秒杀扣减库存示例
*/
public void deductStock(String skuId, int quantity) throws InterruptedException {
executeWithLock("stock:" + skuId, () -> {
int stock = inventoryMapper.getStock(skuId);
if (stock < quantity) {
throw new BusinessException("库存不足");
}
inventoryMapper.deductStock(skuId, quantity);
});
}
}Redlock 算法:应对主从切换问题
小黄遇到的问题(主从切换导致锁丢失),Redis 官方提出了 Redlock 算法:
原理: 部署 5 个独立的 Redis 节点(不是集群,是完全独立的),加锁时向所有节点发 SETNX,超过半数(3个)成功才算加锁成功。解锁时向所有节点发 DEL。
// 使用 Redisson 的 RedLock
RLock lock1 = redissonInstance1.getLock("stockLock");
RLock lock2 = redissonInstance2.getLock("stockLock");
RLock lock3 = redissonInstance3.getLock("stockLock");
RLock lock4 = redissonInstance4.getLock("stockLock");
RLock lock5 = redissonInstance5.getLock("stockLock");
RedissonRedLock redLock = new RedissonRedLock(lock1, lock2, lock3, lock4, lock5);
redLock.lock();
try {
// 临界区代码
} finally {
redLock.unlock();
}Redlock 的争议: Martin Kleppmann(《DDIA》作者)指出 Redlock 在极端情况下仍然不安全(GC Pause 期间锁过期后继续执行)。这个争议至今未有定论,我的建议是:如果对安全性要求极高,用 ZooKeeper;如果能接受极低概率的并发问题,Redisson + 看门狗已经够用。
方案二:ZooKeeper 分布式锁
核心原理
ZooKeeper 的分布式锁基于临时顺序节点:
- 在
/locks/resource-name/路径下创建临时顺序节点:lock-000001、lock-000002... - 每个客户端创建节点后,查看所有子节点,判断自己的节点序号是否最小
- 如果是最小节点,持有锁
- 如果不是最小节点,监听比自己小一个序号的节点(不监听最小节点,避免羊群效应)
- 持有锁的节点执行完业务后删除节点
- 被监听的节点删除,触发 Watch,下一个节点发现自己变成了最小节点,持有锁
@Component
public class ZookeeperDistributedLock {
private final CuratorFramework client;
public ZookeeperDistributedLock(@Value("${zookeeper.connect-string}") String connectString) {
client = CuratorFrameworkFactory.builder()
.connectString(connectString)
.sessionTimeoutMs(5000)
.connectionTimeoutMs(3000)
.retryPolicy(new ExponentialBackoffRetry(1000, 3))
.build();
client.start();
}
/**
* 使用 Curator 的 InterProcessMutex 实现分布式锁
* Curator 已经封装了复杂的临时顺序节点逻辑
*/
public void executeWithLock(String resourceName, Runnable task)
throws Exception {
InterProcessMutex lock = new InterProcessMutex(
client, "/distributed-locks/" + resourceName);
// 尝试获取锁,等待最多 10 秒
boolean acquired = lock.acquire(10, TimeUnit.SECONDS);
if (!acquired) {
throw new BusinessException("获取锁超时");
}
try {
task.run();
} finally {
lock.release();
}
}
/**
* 可重入锁示例
*/
public void reentrantExample(String resourceName) throws Exception {
InterProcessMutex lock = new InterProcessMutex(
client, "/distributed-locks/" + resourceName);
lock.acquire();
try {
// 同一线程可以再次获取锁(可重入)
lock.acquire();
try {
// 内层操作
} finally {
lock.release();
}
} finally {
lock.release();
}
}
}ZooKeeper 锁的特点:
- 强一致性: ZooKeeper 使用 Zab 协议保证强一致性,不存在 Redis 主从延迟的问题
- 自动释放: 临时节点在客户端断开后自动删除,不会出现锁泄漏
- 公平锁: 按照节点序号顺序持有锁,天然公平
ZooKeeper 锁的缺点:
- 性能低于 Redis(每次加解锁涉及多次网络交互)
- ZooKeeper 集群维护成本高
- Watch 机制在高并发下可能产生大量网络包
方案三:MySQL 分布式锁
基于唯一索引
@Component
public class MySQLDistributedLock {
@Autowired
private DistributedLockMapper lockMapper;
/**
* 基于唯一索引:INSERT 成功=加锁成功,唯一冲突=加锁失败
*/
public boolean tryLock(String lockKey, String owner, int expireSeconds) {
try {
lockMapper.insertLock(lockKey, owner,
LocalDateTime.now().plusSeconds(expireSeconds));
return true;
} catch (DuplicateKeyException e) {
// 唯一索引冲突,锁已被占用
return false;
}
}
public void unlock(String lockKey, String owner) {
lockMapper.deleteLock(lockKey, owner);
}
/**
* 清理过期锁(定时任务调用)
*/
@Scheduled(fixedRate = 60000)
public void cleanExpiredLocks() {
lockMapper.deleteExpiredLocks(LocalDateTime.now());
}
}基于悲观锁(SELECT FOR UPDATE)
@Service
@Transactional
public class MySQLPessimisticLock {
@Autowired
private StockMapper stockMapper;
/**
* SELECT FOR UPDATE:对查询到的行加排他锁
* 其他事务的 SELECT FOR UPDATE 会阻塞,等待锁释放
* 事务提交/回滚后自动释放锁
*/
public void deductStock(String skuId, int quantity) {
// 加行级锁
Stock stock = stockMapper.selectForUpdate(skuId);
if (stock.getQuantity() < quantity) {
throw new BusinessException("库存不足");
}
stockMapper.deductStock(skuId, quantity);
// 事务提交时自动释放锁
}
}<!-- Mapper.xml -->
<select id="selectForUpdate" resultType="Stock">
SELECT * FROM stock
WHERE sku_id = #{skuId}
FOR UPDATE
</select>三大踩坑实录
坑一:Redis 锁过期时间设太短,业务没执行完锁就没了
现象: 设置锁过期 5 秒,但当 DB 压力大时,一个锁内的数据库操作超过 5 秒,锁过期后另一个线程进来,导致并发执行,超卖。
原因: 固定过期时间无法适应业务执行时间的波动。
解法: 使用 Redisson 的 lock.tryLock(等待时间, -1, 时间单位),-1 表示不设置过期时间,开启看门狗自动续期(默认 30 秒到期,每 10 秒检查并续期)。
坑二:ZooKeeper 会话超时导致临时节点删除,锁意外释放
现象: 某台服务器 GC Pause 了 8 秒,ZooKeeper 客户端心跳超时(默认 5 秒),临时节点被删除,锁被别的客户端抢走,GC 结束后原来的线程仍在执行,导致并发写。
原因: ZooKeeper 临时节点的生命周期绑定到会话,会话超时(因为心跳断开)就会删除节点,即使业务还在执行中。
解法:
- 把 ZooKeeper 的
sessionTimeout设大一点(比如 30 秒) - 业务代码中用 JVM 锁(synchronized)保护临界区,减少 GC Pause 时已获得锁的代码被并发执行的窗口
- 在业务逻辑层加版本号检查(乐观锁兜底)
坑三:MySQL 分布式锁在高并发下锁表
现象: 用 SELECT FOR UPDATE 做分布式锁,并发 200 请求打进来,数据库连接池打满,服务不可用。
原因: SELECT FOR UPDATE 持锁期间占用数据库连接,高并发下连接很快耗尽。
解法: MySQL 分布式锁只适合低并发场景(< 100 并发)。高并发下用 Redis 或 ZooKeeper。如果必须用 MySQL,要用非阻塞 lock(NOWAIT 关键字,锁占用时立即返回失败,而不是阻塞等待):
SELECT * FROM stock WHERE sku_id = #{skuId} FOR UPDATE NOWAIT三种方案综合对比
| 维度 | Redis(Redisson) | ZooKeeper(Curator) | MySQL |
|---|---|---|---|
| 性能 | 高(10万+/s) | 中(1-5万/s) | 低(1000/s以下) |
| 可靠性 | 中(主从切换有风险) | 高(强一致性) | 中 |
| 实现复杂度 | 低 | 中 | 低 |
| 锁自动释放 | 过期释放 | 会话断开释放 | 需定时清理 |
| 适用场景 | 高并发,对一致性容忍 | 对一致性要求极高 | 低并发,已有 MySQL |
我的推荐策略:
- 90% 的场景:Redisson + 看门狗,够用且简单
- 金融核心业务(不允许任何并发):ZooKeeper
- 小项目/运维成本极低:MySQL 行锁
