面试官问:你们的分布式锁为什么选 Redis 不选 ZK
七月底跳槽面试,被问到分布式锁。我说我们用 Redis + Redisson。面试官追了一句:"Redis 锁在主从切换时会丢锁,你们业务能接受?为什么不用 Zookeeper?"
我当时答得挺虚的。回来把三种实现都搭了一遍,用 JMeter 压了压,才真正搞清楚它们各自的边界在哪。
先把三种实现都跑起来
数据库:最简单也最脆
CREATE TABLE t_distributed_lock (
lock_name VARCHAR(64) NOT NULL PRIMARY KEY,
owner VARCHAR(64) NOT NULL COMMENT '持有者标识',
expire_at DATETIME NOT NULL,
INDEX idx_expire (expire_at)
) ENGINE = InnoDB;
加锁就是一条 insert,靠主键唯一约束互斥;释放是 delete:
// 加锁
try {
lockMapper.insert(new LockRow(lockName, owner, LocalDateTime.now().plusSeconds(30)));
return true; // 拿到锁
} catch (DuplicateKeyException e) {
// 锁被别人持有,检查是否过期
int n = lockMapper.trySteal(lockName, owner, LocalDateTime.now());
return n > 0;
}
这方案我一直不推荐在生产用,理由很直接:加锁失败时没有阻塞等待机制,只能自己 while 循环 sleep 重试,重试间隔设多少都别扭;锁过期要靠业务自己清理;最要命的是数据库就是你的瓶颈,锁 QPS 高一点就把连接池吃光了。我们压测 50 并发抢一把锁,HikariCP 20 个连接全被占满,其他业务接口直接 503。
它的唯一优点是实现成本为零,且强一致——主从延迟导致的锁丢失问题不存在,因为写只走主库。所以只在一种场景我还会用:定时任务的多实例互斥,一天抢一次,性能完全无所谓。
Redis:SET NX PX 一把梭
正确写法只有一条命令(Redis 2.6.12 之后 SET 支持 NX/PX 组合,别再用 SETNX + EXPIRE 两步走了,中间宕机就死锁):
SET lock:order:12345 "a1b2c3-uuid" NX PX 30000
释放必须校验 value 再删,用 Lua 保证原子性:
if redis.call('get', KEYS[1]) == ARGV[1] then
return redis.call('del', KEYS[1])
else
return 0
end
生产上我直接用 Redisson 3.13,它的 RLock 自带 watchdog,默认 30s 过期、每 10s 续一次,业务没跑完不会中途丢锁:
RLock lock = redissonClient.getLock("lock:order:" + orderNo);
boolean locked = lock.tryLock(5, 30, TimeUnit.SECONDS);
if (!locked) {
throw new BizException("系统繁忙,请稍后重试");
}
try {
doBusiness();
} finally {
lock.unlock(); // 注意判断 isHeldByCurrentThread
}
性能实测(单机 Redis 6.0,本地网络 0.2ms):抢锁+释放一次来回平均 0.6ms,单锁 QPS 能到 1600 左右。这个数字比 ZK 高一个数量级。
Zookeeper:临时顺序节点
Curator 4.3 的 InterProcessMutex 一行代码的事:
InterProcessMutex mutex = new InterProcessMutex(curatorFramework, "/locks/order/12345");
if (mutex.acquire(5, TimeUnit.SECONDS)) {
try { doBusiness(); } finally { mutex.release(); }
}
原理是:所有客户端在锁目录下创建临时顺序节点,序号最小的拿到锁,其他人 watch 自己的前一个节点。前一个节点被删除(客户端释放或会话断开)时,ZK 通知下一个。
实测数据(3 节点 ZK 3.6 集群):抢锁+释放一次平均 6.8ms,单锁 QPS 大概 150。为什么慢?因为一次加锁要 创建节点(写请求,需集群过半确认)+ 判断是否最小 + 注册 watcher,网络往返至少三次,而且写请求要 leader 同步给 follower。
一致性的差别才是关键
| 维度 | 数据库 | Redis | Zookeeper |
|---|---|---|---|
| 加锁延迟 | 2~5ms | 0.5~1ms | 5~8ms |
| 单锁 QPS | 200 左右 | 1500~2000 | 100~200 |
| 一致性 | 强 | 弱(异步复制) | 顺序一致(ZAB) |
| 锁自动释放 | 靠 expire_at 清理 | PX 过期时间 | 会话断开即删 |
| 阻塞等待 | 无,自己轮询 | 订阅 channel 通知 | watcher 通知 |
面试官说的"主从切换丢锁"是真的,而且不用等主从切换。Redis 的复制是异步的:客户端 A 在 master 上 SET 成功拿到锁,命令还没同步给 slave,master 就挂了;哨兵把 slave 提为新 master,客户端 B 再来抢同一把锁,也能 SET 成功。此时 A 和 B 同时持有锁。
我本地复现过这个场景,步骤很简单:
# 1. 起一主一从
# 2. 客户端 A 加锁成功后,立刻 kill -9 掉 master
redis-cli -p 6379 SET lock:test "A" NX PX 30000
kill -9 $(cat /var/run/redis_6379.pid)
# 3. 等哨兵完成切换(我们配的 down-after-milliseconds 5000)
redis-cli -p 26379 SENTINEL get-master-addr-by-name mymaster
# 4. 在新的 master 上加锁,同样成功
redis-cli -p 6380 SET lock:test "B" NX PX 30000 # OK
对 ZK 来说这个问题不存在。ZK 的写请求走 ZAB 协议,leader 收到后要过半 follower 的 ack才返回成功,所以一旦客户端收到"创建成功",这个节点就已经被多数派持久化了,leader 挂了新 leader 也一定有这条数据。代价就是那三次网络往返。
CP 还是 AP,得看丢锁的后果
选型的判断标准不是"哪个更好",而是同一把锁被两个线程同时持有,你们的业务会怎样。
我们组的实际情况:
- 订单状态流转、库存扣减:用 Redis 锁。真出现极端情况下的双持,最终还有数据库层的兜底——库存扣减走的是
UPDATE t_sku SET stock = stock - #{n} WHERE sku_id = ? AND stock >= #{n},靠行锁和条件保证不会超卖。锁只是减少冲突,不是唯一防线。 - 定时任务调度:用数据库锁。一天执行几次,跑重了的后果是重复发券,不可接受,宁可慢也要稳。
- 我们没用 ZK 做锁,因为项目里 ZK 只是给 Dubbo 当注册中心,专门为锁去维护一套 ZK 集群,运维成本不划算。Dubbo 那个 ZK 是 CP 的,但它是注册中心,跟锁没关系。
Redisson 有个 RedLock(多 master 独立部署,过半成功才算拿到锁),我们评估后没上。Martin Kleppmann 那篇著名的文章指出它依赖"各节点时钟基本同步"这个假设,而 GC 停顿、时钟跳跃都可能打破它。为了一个理论上更安全的锁,多维护 3 个独立 Redis 实例,投入产出比不合适。
就写到这。如果哪天你也被《分布式锁的三种实现对比:Redis、Zookeeper、数据库》里同一个坑绊住,回来翻这篇,能省半小时。