Administrator
发布于 2020-06-25 / 6789 阅读
94

分布式锁的三种实现对比:Redis、Zookeeper、数据库

面试官问:你们的分布式锁为什么选 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。

一致性的差别才是关键

维度数据库RedisZookeeper
加锁延迟2~5ms0.5~1ms5~8ms
单锁 QPS200 左右1500~2000100~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、数据库》里同一个坑绊住,回来翻这篇,能省半小时。

参考