做后端的朋友应该都遇到过这种场景:秒杀库存扣减、优惠券限量发放、同一个手机号的重复下单。单机部署的时候,一个synchronized或者ReentrantLock就能把并发问题压下去;但是当服务一扩容,变成三个实例同时跑,本地锁就失效了——每个实例上各有一把锁,线程A在实例1上拿到锁,线程B在实例2上也能拿到自己的锁,两个线程同时走进临界区,库存照样超卖。这时候要做的事情,就是在多个进程之间做互斥,这就是分布式锁。而分布式锁技术选型,最经典的就是Redis和ZooKeeper。这篇文章我从实际项目出发,把两种方案的实现原理、代码细节、坑点、选型依据一次讲清楚。
1. 为什么需要分布式锁——从一次库存超卖说起
1.1 一个让我印象深刻的线上事故
我记得很清楚,当时是某个电商平台的限时抢购活动,前端的营销页面把入口打开后,用户像潮水一样涌进来。服务端是三个Tomcat实例,数据库用的是MySQL,商品库存表里有一行记录,stock字段初始为100。实现扣减的时候,最朴素的代码长这样:
java复制public boolean deductStock(Long productId) {
Product product = productMapper.selectById(productId);
if (product.getStock() > 0) {
product.setStock(product.getStock() - 1);
productMapper.updateById(product);
return true;
}
return false;
}
单机部署时,这个方法外面套一个synchronized,同一时刻只有一个线程能进这个方法,看起来没问题。一旦拆成三个实例,问题立刻暴露:三个实例上各有一把独立的锁,三台机器的三个线程可以同时读到stock=1,都判断"库存大于0",然后都执行减一。最后数据库里的stock变成了0,但订单却生成了三笔。这就是典型的超卖。
很多人第一反应是"用数据库的乐观锁不就行了",比如加一个version字段,更新时带上version条件。确实能解决问题,但如果你在真正的抢购场景里试过就知道,乐观锁在并发冲突严重的时候,会导致大量的更新失败和重试,用户体验很差。而且很多业务操作不是一句UPDATE就能搞定的,往往是一个包含多个步骤的完整事务,比如"扣库存+创建订单+锁定优惠券",这时候就需要一个能包裹整个业务流程的互斥机制。
分布式锁解决的就是这个问题:让多个进程在访问同一份共享资源时,能够像单机多线程那样互斥。它的使用方式通常是这样的:
java复制Lock lock = distributedLockFactory.getLock("product:123:seckill");
if (lock.tryLock()) {
try {
// 扣库存、创建订单、锁定优惠券等一系列操作
} finally {
lock.unlock();
}
}
1.2 分布式锁的三个刚性需求
做分布式锁之前,先要明确它在生产环境里必须满足哪些条件,否则后面实现的时候容易走偏。
第一是互斥性。这是锁的基本盘,任何时刻只能有一个客户端持锁。听起来简单,但Redis和ZooKeeper在这一点上就有本质区别,后面我会展开讲。
第二是安全性,也就是说锁必须能够被释放,绝不能出现死锁。持锁的进程如果突然崩了、被kill -9了、网络断开了,锁必须有一个兜底机制自动释放。这一条直接决定了锁的实现方式——Redis靠过期时间,ZooKeeper靠会话超时自动清理临时节点。
第三是可用性。锁组件本身不能成为系统的单点,锁服务挂了,不能拖垮所有业务。这一点会牵扯出Redis主从、ZooKeeper集群等话题。
除此之外,可重入性、公平性、阻塞还是非阻塞,这些都是"加分项"。比如可重入,同一线程多次获取同一把锁要能成功,这通常靠ThreadLocal记录持有者信息来实现。这些需求在不同业务场景下重要程度不一样,选型的时候要分开看。
1.3 为什么Redis和ZooKeeper会被同时提出来
答案很简单:因为它们是两种完全相反的分布式设计哲学的代表。
Redis的思路是"快",它把数据放在内存里,单节点就能扛住极高的QPS。实现分布式锁只需要一条SET命令,代码量极小,而且大部分公司本来就已经有Redis集群了,接入成本几乎为零。
ZooKeeper的思路是"稳",它是一个分布式协调系统,内部用ZAB协议保证数据的强一致性。实现分布式锁依赖的是它天然具备的临时顺序节点机制——节点跟会话绑定,客户端挂了节点自动消失,这种设计在"防止死锁"这件事上比Redis的过期时间优雅得多。
你需要付出的代价也很明显:ZooKeeper要额外维护一套集群,在性能上比Redis差一个数量级。
所以"选Redis还是选ZooKeeper"这个问题的本质,是你在一致性、性能、运维成本这三者之间做一个权衡。下面两章,我把两种方案的实现细节和坑位全部拆开来讲。
需要模型API调用? 免费领10W Token,多模型网关一键接入 Claude、DeepSeek 等主流模型。
2. Redis分布式锁:从SETNX到Redlock,每一层都是在补坑
2.1 最简单的加锁:一条SET命令
在Redis 2.6.12版本之前,实现分布式锁用SETNX和EXPIRE两条命令配合。但这是一个经典的坑:SETNX成功之后,EXPIRE还没执行,进程就挂了,key永远不会过期,其他客户端永远加不上锁——死锁了。
所以第一步就该学会正确的写法。Redis从2.6.12开始支持给SET命令加参数,原子地完成"加锁+设置过期时间"这两件事:
bash复制SET product:123:seckill_lock 550e8400-e29b-41d4-a716-446655440000 NX PX 30000
这条命令的含义是:当key product:123:seckill_lock 不存在时,设置这个key的值为后面的那个随机字符串,并设置30秒过期时间;如果key已经存在,什么都不做,返回nil。
参数拆开看:
NX:Not eXists,只在key不存在时才设置,这是实现互斥的关键。PX:设置过期时间,单位是毫秒,这是防死锁的关键。- value用一个全局唯一的随机值(通常用UUID),这个值在释放锁的时候要派大用场。
对应到Java代码,用Spring Data Redis写就是:
java复制String lockKey = "product:123:seckill_lock";
String requestId = UUID.randomUUID().toString();
Boolean success = redisTemplate.opsForValue()
.setIfAbsent(lockKey, requestId, 30, TimeUnit.SECONDS);
if (Boolean.TRUE.equals(success)) {
// 拿到锁,执行业务逻辑
}
有一个很多新手会忽略的细节:value必须用随机值,而不能用一个固定值。假设两个请求A和B,A拿到锁之后业务执行超时,锁自动过期了,B立刻加锁成功。这时候A终于执行完,在finally里执行DEL把锁删了——它删掉的是B的锁。B的临界区就失去了保护,另一个C请求又可以加锁进入。最终结果就是同一份资源被多个客户端同时操作。随机值的存在,就是为了在释放锁之前先验证"这把锁是不是我的"。
2.2 解锁为什么必须用Lua脚本
前面说了,加锁要原子,解锁同样要原子。解锁的完整逻辑是两步:先GET锁的value,和自己持有的requestId比对;如果相等,再执行DEL。这两步操作必须是一个原子操作,否则中间会插入其他操作。
想象一下这个竞争场景:线程A持有锁,value是UUID_A。A的锁因为业务超时过期了,线程B加锁成功,value是UUID_B。A执行完业务,先GET,发现锁的value是UUID_A(如果B还没有写入),然后执行DEL——但A就在GET和DEL之间,B已经写入并持有了锁,A的DEL就会删除B的锁。虽然我们把GET和DEL分开写,但网络请求是两个Round Trip,中间有大量时间窗口可以插入其他命令。
正确的做法是把"比对+删除"封装进一个Lua脚本,Redis服务端执行Lua脚本是原子操作,脚本执行期间不会插入任何其他命令:
lua复制if redis.call("get", KEYS[1]) == ARGV[1] then
return redis.call("del", KEYS[1])
else
return 0
end
调用方式:
bash复制EVAL "if redis.call('get', KEYS[1]) == ARGV[1] then return redis.call('del', KEYS[1]) else return 0 end" 1 product:123:seckill_lock 550e8400-e29b-41d4-a716-446655440000
Java里可以直接用RedisTemplate执行脚本:
java复制String unlockScript = "if redis.call('get', KEYS[1]) == ARGV[1] then " +
"return redis.call('del', KEYS[1]) else return 0 end";
DefaultRedisScript<Long> redisScript = new DefaultRedisScript<>(unlockScript, Long.class);
redisTemplate.execute(redisScript, Arrays.asList(lockKey), requestId);
这里顺便说一句,Redisson客户端内置的RLock,加锁解锁逻辑本身就是用Lua脚本实现的,它还会自动帮你在释放锁的时候校验持有者。所以如果不是学习目的,生产环境直接用Redisson比自己手写稳妥得多。
2.3 业务超时和看门狗续期
Redis分布式锁最麻烦的一个问题:锁的过期时间设多长?
设短了,业务还没执行完,锁先过期了。另一个客户端加锁进来,两个线程同时操作同一份数据,互斥性被打破。设长了,万一持有锁的客户端真挂了,其他客户端要等很久才能抢到锁,整个系统的可用性下降。
我在实际项目里见过一种写法:把过期时间设成10分钟甚至30分钟,就是为了避免业务超时。这种"拍脑袋设长"的方式确实简单,但副作用很明显——一旦真发生故障,恢复时间被人为拉长到半小时。更好的方案是"看门狗"续期机制:设置一个较短的初始过期时间(比如30秒),加锁成功后启动一个后台任务,每隔一段时间(通常是锁过期时间的三分之一)自动给锁续期;业务执行完释放锁时,停止续期任务。
Redisson的lock.lock()方法默认就实现了这个机制,在Spring Boot项目里引入Redisson,代码大概是:
java复制RLock lock = redissonClient.getLock("product:123:seckill_lock");
lock.lock(30, TimeUnit.SECONDS); // 不传leaseTime时使用看门狗,默认30秒续期
try {
// 业务逻辑
} finally {
lock.unlock();
}
如果自己实现看门狗,思路也不复杂:加锁成功后启动一个ScheduledExecutorService,定期执行一个Lua脚本,检查锁的value还是不是自己的,如果是就执行PEXPIRE续期;释放锁时先停止定时任务,再执行解锁Lua。关键点在于续期前必须检查value,防止锁已经易主,给别人的锁续期。
我自己踩过这个坑:有一个定时任务,业务逻辑有几次慢查询偶尔超过30秒,结果锁过期后另一个任务实例抢到锁,两边同时刷同一批数据,造成数据错乱。后来加上了看门狗续期,问题才彻底解决。记住一句话:改造锁的过期时间之前,先看看业务有没有可能超过这个时间,否则就老老实实做续期。
2.4 Redlock高级方案和现实困境
单节点Redis还有一个更深层的风险:主从切换时锁会丢失。
生产环境的Redis为了高可用,通常采用主从架构,主节点负责读写,从节点异步复制数据。想象这个场景:客户端A在主节点Master上加锁成功,Master还没来得及把数据同步给Slave就宕机了,哨兵把Slave提升为新的Master。此时Slave上根本没有这把锁的数据,客户端B立刻去新的Master上加锁成功。A和B同时认为自己持有锁,互斥性被彻底破坏。
为了解决这个问题,Redis的作者antirez提出了Redlock算法。核心思路是:不依赖单节点,而是向多个独立的Redis节点(通常是5个)同时发起加锁请求,只有拿到超过一半节点(比如5个里至少3个)的确认,并且总耗时不超过锁的有效时间,才认为加锁成功。释放锁时,向所有节点发送解锁命令。
这个算法在业界引发了非常著名的争论。分布式系统专家Martin Kleppmann专门写过一篇博客,指出Redlock在遇到GC(垃圾回收)暂停、时钟跳跃等场景时,仍然会出现两个客户端同时持有锁的情况。他的观点很尖锐:分布式锁的安全性问题不是靠多个Redis节点就能解决的,必须依赖一个真正的共识算法(比如ZAB、Raft),或者干脆接受风险,用数据库的原子操作来兜底。
我在实际项目里的态度是:如果没有极端苛刻的一致性要求,单节点Redis加锁+看门狗续期+业务幂等兜底,已经能覆盖绝大多数场景。支付、转账这种对资金安全要求极高的场景,一开始就用ZooKeeper或者etcd,不要试图把Redis拗成一个共识系统。中间摇摆不定的方案,往往是最难维护的。
3. ZooKeeper分布式锁:临时顺序节点的天然优势
3.1 先搞懂ZooKeeper的四个基础概念
ZooKeeper实现分布式锁,依赖的是它的四个基础特性。
第一个是ZNode节点。ZooKeeper的数据存储结构是一棵节点树,每个节点可以存一点数据,也可以没有数据,子节点可以继续挂子节点。锁可以看成是某个路径下的一个节点。
第二个是临时节点(Ephemeral Node)。这种节点有一个最独特的性质:它的生命周期跟创建它的客户端会话绑定。客户端崩溃、网络断开会话超时,临时节点会被ZooKeeper自动删除。不需要你手动"设置过期时间",这个机制天然地解决了分布式锁的死锁问题。
第三个是顺序节点(Sequential Node)。创建一个顺序节点时,ZooKeeper会在你指定的名字后面自动追加一个单调递增的序号,比如lock_0000000001、lock_0000000002。这个序号可以用来区分先后顺序,实现公平锁。
第四个是Watch机制。客户端可以监听节点的事件,比如节点被删除、数据变化。当事件发生时,ZooKeeper会给客户端推送通知。这个特性在锁释放时用来唤醒等待的客户端。
这四个特性组合起来,正好可以搭建出一个公平、可靠、防死锁的分布式锁模型。
3.2 加锁流程:创建节点、排队、监听前一个节点
ZooKeeper实现分布式锁的经典方案叫"临时顺序节点法",完整流程是这样的:
- 客户端在锁的根路径下(比如
/locks/product_123)创建一个临时顺序节点,名字比如是/locks/product_123/lock_0000000003。 - 客户端执行
getChildren("/locks/product_123")拿到当前所有子节点,按序号排序。 - 如果自己创建的节点是序号最小的那个,说明自己获取到了锁。
- 如果自己不是最小序号节点,说明前面还有人持有锁,那么就监听序号刚好比自己小的那个节点(也就是
lock_0000000002),然后进入等待状态。 - 当被监听的节点被删除(说明持有锁的客户端释放了锁,或者它崩了),客户端收到Watch通知,重新回到第2步,再次判断自己是不是最小序号节点。
用Java原生API写,核心逻辑大概是:
java复制String path = zkClient.create(
"/locks/product_123/lock_",
new byte[0],
ZooDefs.Ids.OPEN_ACL_UNSAFE,
CreateMode.EPHEMERAL_SEQUENTIAL);
List<String> children = zkClient.getChildren("/locks/product_123", false);
Collections.sort(children);
String selfName = path.substring(path.lastIndexOf("/") + 1);
int index = children.indexOf(selfName);
if (index == 0) {
// 我是最小序号,获取锁成功
} else {
String prevNode = "/locks/product_123/" + children.get(index - 1);
CountDownLatch latch = new CountDownLatch(1);
// 注册监听前一个节点
zkClient.exists(prevNode, event -> {
if (event.getType() == Watcher.Event.EventType.NodeDeleted) {
latch.countDown();
}
});
latch.await();
// 重新执行判断逻辑
}
这里有一个非常关键的优化点:为什么只监听前一个节点,而不是监听根路径下所有子节点的变化? 因为如果所有等待锁的客户端都监听根路径,那么每次锁释放时,ZooKeeper要同时给所有客户端推送事件,产生"惊群效应"。在锁竞争激烈时,这种写法会把ZooKeeper的服务端打爆。只监听前一个节点,相当于让所有等待者排成一个链,锁释放时只通知一个人,事件从链头传递到链尾,有效降低了通知风暴。
3.3 释放锁:主动删除与会话超时自动清理
ZooKeeper锁的释放有两种方式。
第一种是主动释放。业务逻辑执行完毕,客户端调用delete删除自己创建的那个临时节点。这对应到代码里就是finally块中的release操作。
第二种是被动释放。如果客户端进程崩溃、网络断开,ZooKeeper服务端在会话超时之后,会自动清理这个客户端创建的所有临时节点。这就是"临时节点"给分布式锁带来的最大红利:不需要像Redis那样专门设置过期时间,锁一定会在客户端故障后自动消失。
这里注意一点:会话超时时间(sessionTimeout)的设置很关键。设得太短,比如1秒,客户端正常的GC暂停、网络抖动都可能导致会话超时,锁被提前释放,引发并发问题。设得太长,比如60秒,客户端真的崩溃时,其他客户端要等60秒才能拿到锁,可用性受损。经验上一般设置在5到15秒之间,具体要看你的业务对延迟的容忍度。
3.4 会话超时的双刃剑:GC暂停会把锁弄丢?
ZooKeeper锁在"防死锁"上比Redis优雅,但"会话超时自动删节点"同样是一把双刃剑。
典型的风险场景是这样的:客户端A持有锁,正在执行业务,突然来了一次Full GC,STW(Stop The World)时间持续了几十秒。客户端A和ZooKeeper之间的心跳包因为GC暂停一直没发出去,ZooKeeper判定会话超时,把A的临时节点删了。此时客户端B加锁成功,开始执行业务。然后A的GC结束,业务继续往下跑。结果就是A和B同时进入临界区。
这和Redis方案里"锁过期、业务还没结束"的问题是同一个本质。分布式锁的安全边界从来不是某种中间件单方面能保证的,它取决于"锁的持有时间"和"业务执行时间"的对比。写分布式锁的代码时,一定要把"持有锁期间可能发生的所有停顿"这个因素考虑进去。
更麻烦的是,在ZooKeeper发生分区时,少数派节点所在的客户端无法提交写操作,锁服务在那个区域完全不可用。不过对于分布式锁这个场景,通常可以接受:锁服务短暂不可用,至少不会出现互斥性被破坏的严重问题。
3.5 Curator封装的InterProcessMutex:别重复造轮子
你可能会觉得上面那段原生API代码写起来很麻烦,而且要处理Watch重连、会话重建、事件丢失等各种边界情况。实际上生产环境里很少有人手写ZK分布式锁,基本都是用Curator框架封装的InterProcessMutex。
java复制CuratorFramework client = CuratorFrameworkFactory.newClient(
"zk1:2181,zk2:2181,zk3:2181",
new ExponentialBackoffRetry(1000, 3));
client.start();
InterProcessMutex lock = new InterProcessMutex(client, "/locks/product_123");
if (lock.acquire(5, TimeUnit.SECONDS)) {
try {
// 业务逻辑
} finally {
lock.release();
}
}
InterProcessMutex提供的acquire方法,默认就是阻塞直到获取锁,可以传超时时间;内部实现了上面说的临时顺序节点加监听前一个节点的流程,还处理了连接重连、会话恢复等细节。拿到这个锁之后,你甚至不用关心释放锁之后Watch怎么处理,Curator都帮你做了。
有一点要提醒:Curator的InterProcessMutex默认把锁的公平性做得很到位——线程之间按请求顺序排队。如果你只是想要一个简单的互斥锁,这种公平性会带来额外的排队延迟。但这通常不是问题,因为锁的获取频率一般不会高到让排队延迟成为瓶颈。
4. Redis和ZooKeeper的正面较量:一致性、性能与运维
4.1 一致性视角:AP和CP的取舍
在分布式系统的CAP理论里,Redis和ZooKeeper选择了不同的方向。
Redis的主从架构是典型的最终一致性系统(AP方向):主节点写入后异步复制给从节点,所以当主节点宕机、从节点接管时,存在数据丢失的可能。用在分布式锁上,就意味着"锁的判定结果"可能不一致——那个锁可能已经在主节点上被确认了,但新主节点根本不认识它。
ZooKeeper是典型的CP系统:写操作必须经过ZAB协议,要在集群中超过一半的节点上达成一致才算成功。用在分布式锁上,就意味着"锁的判定结果"是强一致的——要么所有客户端看到的一致,要么锁服务直接不可用,绝不会出现"两个客户端都认为锁是自己的"这种由数据不一致导致的情况。
一句话总结:用Redis做锁,你赌的是它大多数时候不出问题;用ZooKeeper做锁,你得到的是它出了任何问题都不会用错误的结果来糊弄你。
4.2 性能与吞吐:不是同一个数量级
Redis的SETNX是一个纯内存操作,通常单节点QPS能达到10万以上,加几十毫秒的延迟几乎可以忽略不计。在高频次获取锁的场景下,Redis是唯一现实的选择。
ZooKeeper的每次写操作(创建节点、删除节点)都要通过ZAB协议在集群内做日志复制,吞吐量通常比Redis低一到两个数量级。如果锁的获取频率很高,比如每次请求都要抢锁,ZK方案的表现会明显不如Redis。
但要注意,分布式锁的使用频率通常并不是系统瓶颈。大多数分布式锁场景是低频的:秒杀开始的一瞬间、定时任务触发的一瞬间、数据迁移的一瞬间。这种场景下ZK的吞吐量完全够用。只有当你在一个流量极大的关键路径上,每个请求都要抢锁,那才需要认真评估ZK能不能扛住。
4.3 一张表看完核心差异
| 对比维度 | Redis | ZooKeeper |
|---|---|---|
| 一致性模型 | 最终一致,主从切换可能丢锁 | 强一致,ZAB协议过半确认 |
| 互斥安全性 | 依赖过期时间+看门狗,极端情况可能两个客户端同时持锁 | 临时节点+会话绑定,故障时自动释放,但GC暂停也可能误释放 |
| 防死锁机制 | 过期时间,需要精心设置 | 会话超时自动删除临时节点 |
| 实现复杂度 | SET命令+Lua脚本,或直接用Redisson | 原生API复杂,推荐用Curator |
| 性能 | 单节点10万+ QPS | 写操作受ZAB限制,低一到两个数量级 |
| 运维成本 | 通常已有Redis集群,零额外成本 | 需要单独部署、监控ZK集群 |
| 公平锁 | 原生不支持,需要自行设计 | 临时顺序节点天然支持 |
| 适用场景 | 高频、低一致性要求、已有Redis | 低频、强一致性要求、已有ZK/zookeeper基础设施 |
4.4 选型决策模型:从业务、基础设施、团队三个维度看
我在做技术选型的时候,不是拿一张对比表逐条打分,而是按优先级问自己三个问题。
第一个问题:业务是否允许锁在极端情况下失效?如果业务里有幂等兜底,比如扣库存前先查一遍、写操作有唯一索引、失败后有对账补偿,那么Redis方案完全可以接受。反过来,如果业务是资金转账、兑换码生成这种绝对不能出现两个客户端同时操作的场景,那就直接选ZooKeeper或者etcd。
第二个问题:公司里已经有哪些基础设施?如果Redis已经是一个成熟的、有监控有告警的集群,很多团队的默认选择就是Redis。如果ZK集群本来就在维护,比如Hadoop集群或者dubbo注册中心已经在用ZK,那加一个锁功能几乎没有额外成本。这个因素远比理论优劣重要。
第三个问题:团队对哪个系统更熟悉?熟悉Redis的团队,遇到问题能快速定位;让一个不熟悉ZK的团队去维护ZK集群,本身就是隐患。我记得有个项目,为了"高大上"引入了ZK做锁,结果集群的Leader频繁重选没人会调,最后又退回Redis方案。
综合起来,我的一般性建议是:已有Redis且业务有兜底手段,选Redis;已有ZK且业务要求强互斥,选ZK;两者都没有,优先考虑Redis,因为它的学习成本和运维成本更低;如果业务真的很关键,先评估能不能用数据库的行锁+唯一索引解决,尽量不要为了一个锁组件引入一套重量级中间件。
4.5 顺手聊聊数据库实现的分布式锁
顺便说一下,还有一条路线是拿数据库做分布式锁。最常见的两种做法:乐观锁(版本号字段)和悲观锁(SELECT ... FOR UPDATE)。数据库锁的优势是"不需要引入新的中间件",在项目里如果数据库是唯一的基础设施,可以作为一个临时方案,但性能远不如Redis,加上数据库连接数有限,高并发下容易成为瓶颈。
数据库锁最大的坑是没有自动超时机制。如果一个事务在持有锁时异常退出且没有回滚,锁会一直持有到数据库事务超时,通常几十秒。相比之下,Redis的过期时间和ZK的会话机制都是专门为分布式锁设计的。所以数据库锁一般只在低频后台任务里用,不太适合高并发在线业务。
5. 实战中的坑位清单与我的选型建议
5.1 Redis分布式锁最容易踩的坑
第一个坑是忘记设置过期时间,或者设置了但过期时间不合理。只SETNX不EXPIRE,客户端一挂锁就永久存在;过期时间设得太短,业务没跑完锁就过期。这两个问题的解法前面已经讲过:原子命令设置过期时间,业务超时风险高就加看门狗续期。
第二个坑是释放锁不校验持有者。直接DEL别人的锁,会破坏互斥性。必须用Lua脚本先比对value再删除。
第三个坑是Redisson默认锁的leaseTime不传时的行为。lock()不传leaseTime时,Redisson会启动看门狗每10秒续期一次;但lock(30, TimeUnit.SECONDS)传了leaseTime时,看门狗不会启动,30秒后锁一定会被释放。如果你在代码里传了leaseTime,业务很可能因为超时被打断,或者更糟——锁被释放,后面进来的请求覆盖前面的操作。一定要搞清楚自己用的是哪种模式。
第四个坑是锁的粒度问题。把订单号拼到锁key里是最常见的做法,粒度太粗会导致大量请求被同一个锁串行化;粒度太细又可能让同一笔操作的多个步骤分散到不同锁里,反而起不到保护作用。一般建议锁的粒度对齐到"业务上的最小不可分割单元",比如按商品、按订单、按用户维度来设计。
5.2 ZooKeeper分布式锁的坑位清单
ZK锁的坑相对少一些,但也不是没有。第一个是会话超时时间设置不合理,前面讲过,太短的会话超时会让正常的GC暂停触发锁自动释放。建议根据JVM的GC日志和历史停顿时间,预留足够的缓冲。
第二个坑是忘记处理Curator客户端的连接状态。Curator推荐用CuratorFramework的ConnectionStateListener监听重连事件。如果你的业务在客户端断开重连期间执行了加锁操作,锁状态可能已经变化,需要做额外的补偿处理。
第三个坑是节点权限问题。ZK的节点有ACL控制,如果锁的根路径权限设置错误,有些客户端可能没权限创建子节点,导致加锁直接抛异常。这个问题平时不遇到,一旦遇到排查起来比较费劲,所以初始化ZK路径时就要确认好权限模型。
还有一个容易忽略的坑:ZK客户端本身是重量级的,一个应用里如果同时有多个流程创建了多个CuratorFramework实例,会占用大量连接资源。最佳实践是全局只创建一个CuratorFramework客户端实例,所有锁共享它。
5.3 通用建议:锁粒度、监控和压测
无论用哪种方案,锁粒度都要认真设计。锁的key最好带上业务上下文,比如product:123:seckill,而不是一个笼统的global_lock。锁范围越小,并发能力越高。
监控也非常重要。每次加锁、解锁都要打日志,记录获取锁的等待时间、持有锁的时间、是否因为超时获取失败。这些指标能帮你及时发现锁的异常。我发现很多团队在分布式锁故障之后才想起来看日志,而平时几乎没有对锁的指标做任何监控。
上线前一定要做压测。模拟多个实例同时抢同一把锁,看看吞吐量和锁获取成功率。ZK方案在锁竞争激烈时,节点创建删除的损耗会被放大,压测能提前测出瓶颈。
5.4 我的个人选型经验
做了几年分布式系统,我自己的选型标准越来越简单:先看业务有没有"不能错"的底线,再看公司已有的基础设施。
如果业务可以容忍极端情况下的短暂并发问题(比如多领一张优惠券,事后可以人工处理),那我直接用Redis,配合Redisson或者自己写的那套Lua逻辑,简单、快、好维护。
如果业务是资金相关、库存核心数据,或者有强监管要求,那我不会为了省事去赌Redis的主从切换概率,直接用ZooKeeper/etcd。虽然多维护一个集群,但换来的是故障时不会出现"两个锁同时生效"这种灾难性结果。
有一种情况我会特别谨慎:当有人提议"用Redlock解决Redis锁不可靠的问题"时,我会反问一句"你有没有考虑过引入这5个Redis节点的运维成本和故障概率"。Redlock在理论上还存在争议,实现复杂度也远高于单节点方案,绝大多数业务的真实需求并不需要这么重的方案。
最后再分享一个小技巧:不管选Redis还是ZooKeeper,都记得给锁的执行加上超时控制。锁是协调工具,不是业务保障,它只负责在你设定的边界内保证互斥。真正的数据一致性,最终还是要靠业务本身的幂等设计和对账体系来兜底。这比纠结选型重要得多。
