做后端开发的,迟早要碰一次分布式锁。不管你是用Spring一把梭写单体应用,还是已经在微服务架构里摸爬滚打,只要服务一拆、多实例一部署,synchronized和Lock就管不住局面了——线程锁锁的是单个JVM进程内的资源,跨进程、跨节点的那批共享资源,必须换一套思路来锁。
本文想聊的就是这件事:分布式锁的4种主流实现方式。我会把这四种方案的核心原理、实现细节、常见坑位和选型依据全部摊开来讲,包含可直接参考的配置、命令和代码片段,覆盖大家在面试和实际项目中最高频遇到的那些问题。如果你是刚接触分布式锁的后端工程师,或者正在为团队选型做技术调研,这篇应该能帮你省掉不少翻文档的时间。
1. 分布式锁到底锁的是什么?先搞清楚需求再谈实现
很多人一上来就盯着Redis还是ZooKeeper选型,其实顺序反了。分布式锁本质是多个进程对同一临界资源的互斥访问控制,但具体到业务里,“临界资源”的形态千差万别。不先把需求想清楚,后面选型一定纠结。
1.1 哪些场景真的需要分布式锁
最常见的一类是共享资源扣减。典型例子是秒杀库存:商品库存存在数据库里,十个订单服务实例同时收到下单请求,每个实例都先查库存、再扣库存,如果没有跨进程互斥,两个请求可能同时读到剩余1件,各自扣减成功,超卖就这么来的。单体应用可以用数据库行锁解决,但服务拆成多实例后,行锁依然能兜底,可缓存层、业务层的并发控制就需要分布式锁了。
第二类是定时任务的重复执行问题。很多系统会用@Scheduled在每个实例上跑同一个定时任务,比如凌晨同步数据、生成报表。多个实例同时执行同一个任务,轻则浪费资源,重则产生重复数据。分布式锁保证同一时刻只有一个实例拿到执行权,其他实例直接跳过,这是非常典型的应用场景。
第三类是接口幂等性控制。支付回调、消息消费这类场景,同一个事件可能被投递多次,如果用“先查状态再写记录”的方式判断是否处理过,在并发情况下依然有漏洞。分布式锁可以确保同一个业务标识在同一时刻只有一个请求能进入处理逻辑,后续重复请求直接拒绝或等待。
第四类是缓存失效瞬间的保护。缓存击穿时,大量请求同时回源数据库,可以在回源逻辑上加锁,只让一个线程去查库、重建缓存,其他线程等待或直接拿旧值。这个场景里锁的粒度可以做得比较大,因为回源本身耗时可控,锁的可靠性要求也不算极致。
1.2 一把合格的分布式锁要满足哪些条件
加锁解锁看起来简单,但细抠起来一条条列会很多。我这里按重要性排序说:
- 互斥性:这是底线,任何时刻只能有一个客户端持有锁。做不到互斥,整个方案就是摆设。
- 自动释放:持有锁的节点宕机、网络分区、进程被kill,锁必须在有限时间内自动释放,否则整个系统会被一把死锁卡死。Redis靠过期时间兜底,ZooKeeper靠临时节点特性兜底,数据库方案要靠事务和超时机制兜底。
- 可重入性:同一个线程如果已经持有锁,再次获取同一把锁应该直接成功,而不是把自己挡住。分布式锁的可重入实现比单机锁麻烦,需要记录持有线程和重入次数。
- 高性能:加锁解锁的开销要足够低,不能在业务主链路上成为瓶颈。Redis锁的单次操作是微秒级,数据库唯一索引插入在低并发下也还好,ZooKeeper涉及到节点创建和watcher通知,毫秒级,高并发下吞吐差距明显。
- 高可用:锁服务本身不能成为单点。Redis主从切换会带来锁丢失问题,ZooKeeper集群和Etcd集群在多数派存活时可用。
- 公平性:是否需要按照请求顺序获取锁。ZooKeeper和Etcd的方案天然公平(按顺序节点/revision),Redis方案默认不公平(随机抢占),大多数业务不需要公平锁,但少数场景(如分布式任务调度)会关注。
注意:不要指望一把分布式锁同时满足全部条件,不同方案本质上是在一致性、可用性、性能之间做取舍。比如Redis锁性能最高但极端情况下会失效,ZooKeeper锁可靠性强但吞吐有限。选型前先把“业务能容忍什么损失”定义清楚,比挑技术方案更重要。
2. 方案一:基于数据库实现的分布式锁
数据库方案是历史最悠久、实现最直白的一种,也是我见过很多老系统里还在跑的方式。它不需要额外引入中间件,直接复用现有MySQL实例,适合对性能要求不高、并发量有限的内部系统。
2.1 唯一索引实现:insert和delete就是加锁和释放
核心思路很简单:建一张锁表,在某几个关键字段上建唯一索引,获取锁就是向表里插入一条记录,释放锁就是删除这条记录。谁插入成功,谁就拿到了锁。
建表语句大致长这样:
CREATE TABLE `distributed_lock` ( `id` bigint(20) NOT NULL AUTO_INCREMENT, `lock_key` varchar(128) NOT NULL COMMENT '锁的唯一标识', `owner_id` varchar(64) NOT NULL COMMENT '持有者标识,用于防止误删', `expire_time` datetime DEFAULT NULL COMMENT '过期时间,兜底用', PRIMARY KEY (`id`), UNIQUE KEY `uk_lock_key` (`lock_key`) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;加锁逻辑就是往这张表插入一行。比如业务标识是order_pay_10086,则执行:
INSERT INTO distributed_lock (lock_key, owner_id) VALUES ('order_pay_10086', 'instance-123');如果插入成功,说明拿到了锁,可以继续执行业务逻辑。如果插入报Duplicate entry错误,说明锁已被其他节点持有,就轮询重试或直接失败。释放锁则是删除对应记录:
DELETE FROM distributed_lock WHERE lock_key = 'order_pay_10086' AND owner_id = 'instance-123';删除时带上owner_id是为了避免误删别人的锁——比如线程A持有锁后处理时间过长,锁表里没有自动过期机制,A宕机后锁记录还在;后来线程B插入失败,一直等不到锁释放,这时如果用DELETE WHERE lock_key = ?就会把别人的锁删掉。而带owner_id能保证只有持有者本人能删自己的锁。
但这个方案有个致命问题:没有自动过期。持有者宕机,锁记录永远留在表里,其他节点永远拿不到锁。所以实际项目中必须加一个定时清理任务,定期扫描expire_time过期的记录并删除。这里expire_time的设置比较讲究,设太短会导致业务还没执行完锁就被清理,设太长会导致宕机后锁长时间不可用,我的经验是设置为业务预估最大耗时的2到3倍,再让清理任务每30秒跑一次。
2.2 悲观锁方案:select ... for update
除了唯一索引,数据库方案还可以用SELECT ... FOR UPDATE实现。它的思路是借助数据库自身的事务锁机制:在一个事务里对某一行加行级排他锁,事务结束才释放。
BEGIN; SELECT * FROM distributed_lock WHERE lock_key = 'order_pay_10086' FOR UPDATE; -- 执行业务逻辑... COMMIT;这个方案的优点是实现简单,完全借助InnoDB的行锁能力,不存在“忘记释放”的问题——事务提交或回滚都会释放锁。但它要求lock_key字段有索引,否则FOR UPDATE会升级为表锁,并发能力直接崩掉。
缺点是性能和连接占用问题很明显。持有锁期间,数据库连接一直被占用,如果锁的持有时间稍长(比如几百毫秒),连接池很容易被打满。另外,如果业务逻辑里出现了慢查询或长时间事务,会拖垮整个数据库。这个方案我只建议在锁持有时间非常短(几十毫秒以内)、并发量极低的场景下使用。
2.3 数据库方案的适用场景与明显短板
数据库方案的最大优势是“零成本引入”,不需要额外维护Redis、ZooKeeper这类中间件,团队如果对数据库运维非常熟练,可以快速实现一套。同时它天然有事务保证,可以和业务操作绑定在同一个事务里,一致性更好理解。
短板也很突出:
- 性能瓶颈:数据库写入和行锁的开销远大于Redis,高并发下会产生大量锁等待和重试,吞吐量上不去。
- 单点风险:单库部署本身是单点,锁服务跟着数据库一起挂;即使做了主从,主从切换期间锁记录的一致性也难保证。
- 没有自动续期机制:业务执行时间超过预估后,锁可能被定时清理或超时释放,导致多个节点同时进入临界区,互斥性被打破。
- 数据库故障影响面大:锁表和业务表混在同一个实例上,一旦锁表出现死锁或锁等待,可能拖累核心业务。
我自己的结论是:数据库锁适合那种“并发不大、不想引入新组件、对极端一致性要求不高”的内部管理系统,比如后台管理工具的互斥操作、定时任务的防重。真正的核心交易链路,我基本不用它,宁可多花点成本上Redis。
3. 方案二:基于Redis的分布式锁
Redis锁是当前互联网公司里用得最广的分布式锁方案,没有之一。原因很简单:性能高、实现简单、Redis几乎每个团队都已经在运维。但它也有很多容易踩的细节坑,我需要从最小实现开始完整讲一遍。
3.1 最小可用实现:SET NX EX 两步合一
最早很多人用SETNX加锁、EXPIRE设置过期时间两步操作,但这样有个经典bug:SETNX成功后进程崩溃,EXPIRE没执行,锁就永远不释放。现在推荐的做法是用一条命令同时完成加锁和过期时间设置:
SET lock:order_pay_10086 instance-123 NX EX 30参数含义:
NX:只有当key不存在时才设置,保证互斥性;EX 30:设置过期时间为30秒,防止持有者宕机后锁不释放;value = instance-123:这个value非常重要,它标识锁的持有者,释放锁时必须校验,防止误删。
释放锁时需要先检查value是否是自己,再删除。这一步推荐用Lua脚本保证原子性:
if redis.call("get", KEYS[1]) == ARGV[1] then return redis.call("del", KEYS[1]) else return 0 endJava侧的伪代码如下:
String lockKey = "lock:order_pay_10086"; String ownerId = UUID.randomUUID().toString(); // 加锁,10秒过期 Boolean locked = redisTemplate.opsForValue() .setIfAbsent(lockKey, ownerId, Duration.ofSeconds(10)); if (Boolean.TRUE.equals(locked)) { try { // 执行业务逻辑 } finally { // 释放锁,校验ownerId 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), List.of(lockKey), ownerId); } }很多新手会问:释放锁时为什么不能直接DEL?举一个场景:线程A拿到锁,因为GC停顿或网络延迟,业务执行超过了30秒,锁自动过期;线程B拿到同key的锁开始执行;A终于苏醒,执行结束后DEL——它删掉的是B持有的锁。B刚执行到一半,锁没了,线程C又拿到锁进来,互斥性瞬间被打破。带ownerId校验能避免这种误删,但不能避免A持锁超时这种根因问题。
3.2 Redisson的看门狗机制解决续期问题
上面那个GC停顿导致锁提前过期的问题,业界通行的解法是自动续期。Redisson这个客户端库实现了类似看门狗的机制:默认情况下,加锁后如果业务没执行完,后台会有一个定时任务每隔lockLeaseTime / 3(默认约10秒)检查一次锁是否还持有,如果持有就把过期时间重置为30秒。这个过程不断重复,直到业务执行完手动释放。
这就是所谓“看门狗”带来的体验:业务代码里不用手动设置过期时间,锁会自动跟着业务执行时长续期。它的本质是起一个定时任务在后台续期,业务结束时关闭定时任务并释放锁。
使用Redisson时有一个细节要特别注意:lock()和tryLock()默认会启动看门狗,但如果你手动传入了leaseTime参数,看门狗就不会启动,锁会在你指定的时间后强制过期。我见过不少人传了leaseTime又以为锁会自动续期,结果长任务在锁过期后变成多人同时执行。所以要么不传leaseTime用默认看门狗,要么自己保证业务能在leaseTime内完成。
可重入方面,Redisson也做了支持:同一个线程重复lock()同一把锁时,内部会维护一个ThreadLocal计数器,每进入一次加1,每释放一次减1,减到0才真正删除key。这个设计跟JDK的ReentrantLock一致,保证同一个线程的重入不会被自己的锁挡住。
3.3 Redlock算法与主从切换的争议
Redis锁一个经常被面试官追问的点是:锁在Redis主从架构下会丢吗?答案是会。默认的复制是异步的,A在主节点SET NX EX成功,此时从节点还没来得及复制这条数据,主节点挂了,哨兵把从节点提升为新主节点,新主节点上没有这把锁,另一个客户端就能加同一把锁成功,互斥性被破坏。
为了解决这个问题,Redis官方提出了Redlock算法:向多个独立的Redis节点(一般5个)依次尝试加锁,每个节点设置一个比锁总生命周期短的超时时间,只有在超过一半节点加锁成功且总耗时小于锁的生存时间时才认为加锁成功。释放时向所有节点发送释放命令。
这个算法理论上更可靠,但业界对它的争议一直很大。以Martin Kleppmann为代表的一派认为Redlock在复杂的网络分区场景下依然无法保证绝对安全,而且它会引入额外的延迟和运维成本。我的实际建议是:绝大多数业务的并发问题不需要用到Redlock这种强一致方案。Redis锁的优点是快,如果需要绝对强一致,应该直接换ZooKeeper或Etcd,而不是在Redis上堆算法。Redlock更适合Redis多节点部署、业务能容忍极小概率锁丢失、但如果加锁失败会造成严重后果的场景,这种场景其实很少。
提示:Redis锁适用的底色是“容忍极小概率的锁失效”。选它,等于承认在极端情况(主从切换、长时间GC)下可能出现两个客户端同时持有锁。如果你的业务是扣钱、转账这类绝对不能并发的,要么给Redis方案做好补偿机制(如数据库的唯一约束兜底),要么直接上ZooKeeper。
4. 方案三:基于ZooKeeper的分布式锁
ZooKeeper做的分布式锁在可靠性和公平性上比Redis强很多,代价是性能不如Redis、运维也更重。很多做互联网金融、交易系统的团队会把关键链路的锁放到ZK上,靠它强一致的特性兜底。
4.1 临时顺序节点与Watcher机制
ZK的锁模型建立在两个核心特性上:临时节点和顺序节点。
- 临时节点:创建节点的客户端会话结束时,ZK会自动删除该节点。这天然解决了“持有者宕机后锁不释放”的问题,不需要像Redis那样依赖过期时间。
- 顺序节点:同一父节点下创建节点时,ZK会自动在名字后面追加一个单调递增的序号。这个序号天然给请求排了队,公平锁的“先来后到”就靠它实现。
加锁的流程大致这样:所有客户端在同一个父节点下创建临时顺序节点,比如/locks/order_pay_10086/lock_0000000001。然后每个客户端获取父节点下的全部子节点列表,判断自己的序号是不是最小的。如果最小,说明拿到锁;如果不是,就监听排在自己前面那个节点的删除事件,并进入等待状态。当前一个节点被删除(说明前一个持有者释放了锁),自己再去判断一次是否轮到自己。
所谓“监听前一个节点”而不是“监听全部节点”,是为了避免羊群效应。如果每个客户端都监听整个父节点的变化,一旦锁释放,所有等待者全部被唤醒,大家同时去判断自己是否最小,瞬间产生大量并发请求。而只监听前一个节点时,锁释放只唤醒一个节点,整体的通知量和ZK集群压力都会小很多。
4.2 基于Curator封装的使用示例
直接操作ZK原生API写分布式锁非常繁琐,要处理重连、会话过期、节点监听异常等等。生产环境建议直接用Curator官方封装,它把分布式锁的细节全部屏蔽掉了,API简单到看一遍就能上手。
CuratorFramework client = CuratorFrameworkFactory.newClient( "zk1:2181,zk2:2181,zk3:2181", new RetryNTimes(3, 1000)); client.start(); InterProcessMutex lock = new InterProcessMutex( client, "/locks/order_pay_10086"); lock.acquire(10, TimeUnit.SECONDS); try { // 执行业务逻辑 } finally { lock.release(); }这个InterProcessMutex默认就是公平锁,且支持可重入。Curator内部还提供了InterProcessReadWriteLock,支持读写锁区分,读读不互斥、读写互斥、写写互斥。这在一些“读多写少”的分布式资源管理里非常有用,比如配置中心的配置发布和客户端读取。
Curator有一个需要注意的参数是sessionTimeout,它决定客户端与ZK之间的会话超时时间。这个值设置过短,网络抖动会导致会话过期,锁被自动释放,业务还在执行,引发并发问题;设置过长,客户端真正故障后锁要很久才能释放,阻塞其他节点。一般建议设置为10到30秒,并且要配合JVM的GC停顿情况来权衡。另外ZK客户端重连后,底层持有的锁对象会自动重建还是需要重新获取,Curator的封装已经处理了大部分,但实际运行中还是要靠单测和故障注入来验证。
4.3 ZK锁的优缺点与适用场景
ZK锁最大的优点是可靠性高:
- 临时节点保证持有者会话结束自动释放锁,没有Redis那种“锁过期时间没设好导致永久死锁”的问题;
- 顺序节点天然提供公平性,不存在两个客户端同时抢到锁的确定性错误;
- ZK集群通过ZAB协议保证多数派数据一致,主从切换时锁的数据不容易丢。
缺点是性能相对较低。每次加锁解锁都涉及ZK节点创建和删除,以及一次或多次网络RTT,吞吐量在每秒几千次级别,跟Redis的每秒几万到十几万次不在一个数量级。另外ZK集群本身要维护3到5个节点,监控、扩容、故障处理都需要专门的运维知识,相比Redis的普及度,ZK的学习和运维成本更高。
适用场景我总结为三类:对一致性和可靠性要求极高的核心链路、读写锁等复杂锁语义需要原生支持、业务并发量不算极高但绝不允许出错的锁场景。如果你们团队已经把ZK当做注册中心或配置中心在用,那直接复用ZK做分布式锁几乎零额外成本,那么用它比额外引入Etcd更顺手。
5. 方案四:基于Etcd的分布式锁
Etcd是云原生生态里的后起之秀,和Kubernetes深度绑定,很多新项目会直接选择它作为配置中心和注册中心。Etcd内部用Raft协议保证强一致性,而且API设计比ZK更现代,做分布式锁也是它的常见用法之一。
5.1 租约与Revision:Etcd锁的两个核心概念
Etcd实现分布式锁的核心依赖两个机制:Lease(租约)和Revision(版本号)。
- 租约:Etcd支持创建一个带TTL的租约,把key关联到这个租约上。租约会定期自动续期,不会因为客户端不操作就过期;但如果客户端崩溃、网络断开或主动撤销租约,关联的key会被自动删除。这一点非常像ZK的临时节点,但实现方式更优雅。
- Revision:Etcd的每一次写操作都会分配一个全局递增的
revision。同一个前缀下创建的key,revision越大说明创建越晚。判断是否持有锁,本质上就是看自己的revision是不是当前前缀下最小的。
加锁的过程大致是:客户端创建一个特定前缀下的key,例如/locks/order_pay_10086/,key值带上自己的标识并关联一个租约;然后通过Txn事务查询该前缀下的所有key,拿到自己的revision;如果自己的revision是最小的,就拿到锁;否则监听revision比自己小的、最大的那个key的删除事件。
5.2 Etcd锁的完整逻辑与代码示意
用Go的clientv3库写Etcd锁,思路如下:
cli, _ := clientv3.New(clientv3.Config{ Endpoints: []string{"http://etcd1:2379", "http://etcd2:2379", "http://etcd3:2379"}, DialTimeout: 5 * time.Second, }) defer cli.Close() // 1. 创建租约,TTL 10秒 leaseResp, _ := cli.Grant(context.TODO(), 10) leaseID := leaseResp.ID // 2. 在租约上创建key lockKey := "/locks/order_pay_10086/" + fmt.Sprintf("%x", time.Now().UnixNano()) cli.Put(context.TODO(), lockKey, "holder-instance-1", clientv3.WithLease(leaseID)) // 3. 获取前缀下所有key,按revision排序 resp, _ := cli.Get(context.TODO(), "/locks/order_pay_10086/", clientv3.WithPrefix(), clientv3.WithSort(clientv3.SortByKey, clientv3.SortAscend)) // 4. 判断自己的revision是否最小 for _, kv := range resp.Kvs { if string(kv.Key) == lockKey { // 我是最小的,拿到锁 break } // 否则,监听前一个key的删除事件 }不需要每条都自己实现。Etcd官方在contrib/recipes目录下提供了sync.RWMutex风格的分布式锁封装,直接用就行,这个库和ZK的Curator一样帮我们屏蔽了租约续期、节点监听这些细节。它同样支持RWMutex的读写锁语义。
Etcd锁的续期机制很有意思:客户端持有租约后,需要周期性地通过KeepAlive请求续租,否则租约到期后key被自动删除。这个设计和Redis的看门狗不同,Redis的续期是客户端本地的定时任务,Etcd的续约是客户端和Etcd服务端之间有真实的心跳请求。两者比下来,Etcd的续约更可控,因为服务端清楚租约是否仍然活跃,而Redis只能靠key的过期时间推断。
5.3 Etcd与ZK的对比,以及适用场景
Etcd锁和ZK锁在分布式锁的实现思路上几乎一致:都是“先创建带自动清理的节点/key,再按序号排队”。区别主要在底层一致性协议和客户端生态上。
| 对比项 | Etcd | ZooKeeper |
|---|---|---|
| 一致性协议 | Raft | ZAB |
| Key过期机制 | 租约 + 定时续约 | 临时节点随会话结束删除 |
| 实现锁的方式 | 前缀目录 + revision排序 | 父节点下临时顺序节点 |
| 客户端生态 | gRPC风格,Cloud Native生态好 | Curator功能丰富,老牌稳定 |
| 与K8s集成 | 天然集成 | 无 |
| 性能 | 较高,Raft多副本写入 | 中等,ZAB写入性能略低 |
如果你的团队已经在用Kubernetes和Etcd,再引入一个ZK就明显不划算,Etcd一个组件就够用了。如果团队已经有成熟的ZK集群在支撑Dubbo或其他系统,那么沿用ZK也完全合理。两者选谁,更多取决于你们的基础设施现状,而不是技术本身的高低。
6. 四种方案横向对比与选型建议
前面讲完四种方案,接下来的内容会很直接:把它们的各项能力放在同一张表里做对比,并给出我实际选型时的一套判断逻辑。
6.1 一张表看懂四种方案的差异
| 维度 | 数据库 | Redis | ZooKeeper | Etcd |
|---|---|---|---|---|
| 实现复杂度 | 简单(建表/事务) | 简单(SET NX EX) | 中等(Curator封装) | 中等(官方recipe) |
| 性能 | 低 | 高 | 中 | 中高 |
| 可靠性 | 低(单点+无自动过期) | 中(主从切换会丢锁) | 高 | 高 |
| 公平性 | 不支持 | 默认不支持 | 天然公平 | 天然公平 |
| 可重入 | 需自行设计 | 需用Redisson等库 | Curator支持 | recipe支持 |
| 读写锁 | 不支持 | 需自行设计 | Curator支持 | recipe支持 |
| 自动释放机制 | 无、需定时清理 | 过期时间 | 临时节点 | 租约 |
| 额外运维成本 | 无 | 低(Redis常用) | 高 | 中 |
| 典型场景 | 内部管理低频互斥 | 高并发缓存/幂等控制 | 强一致核心链路 | 云原生基础设施 |
表格里“可靠性”一栏要解释一下。Redis那行写“中”不是说Redis锁不好,而是它在主从切换、长时间GC等极端场景下存在锁丢失的可能。数据库那行写“低”是因为它既没有自动过期,又受制于单库的稳定性。ZK和Etcd都靠多数派协议保证强一致,只要集群多数节点存活,锁数据就不会丢。
6.2 选型决策路径与我的建议
选型不要上来就看技术社区风向,先把业务的硬约束列出来:
第一步,问自己能不能容忍锁极端情况下失效。如果能容忍极小概率的并发问题(比如生成报表、缓存回源、非支付类幂等控制),直接选Redis,理由是性能好、落地快、团队基本都会。如果不能容忍(比如支付、转账、资金流水),优先ZK或Etcd。
第二步,看团队已有的基础设施。如果Redis已经在线上了,没有特别理由就不要为了一把锁引一个新组件;如果ZK已经用于Dubbo注册中心,那ZK锁顺手就行;如果项目跑在Kubernetes上,Etcd本来就在,部署一套Etcd锁也不会有额外运维包袱。数据库锁只在“并发极低、绝对不能新增依赖、锁时长很短”时考虑。
第三步,评估锁的使用频率和持有时间。高频短时锁(比如缓存重建)Redis优势明显;低频但持有时间不确定的长任务(比如大数据批处理任务)ZK或Etcd的自动释放机制更让人放心,因为你不用焦虑“过期时间定多长才合适”。
我自己在项目里用得最多的是Redis + Redisson,覆盖了绝大部分业务场景。只有在资金相关、必须强一致的极少量接口上,我才会单独用ZK或Etcd给它们加一道锁,并且还会在数据库层面做唯一索引兜底。这种“多层兜底”的思想,比把全部安全性寄托在某一种锁上要实在得多。
7. 高频面试问答与实测排坑记录
最后这部分全是干货,前半部分整理面试中最高频的分布式锁问题及回答方向,后半部分是我在实际项目中踩过的坑和修复经验。
7.1 面试官常问的5个问题
第一问:Redis分布式锁如何保证释放锁时的原子性?
回答要点:释放锁不能简单DEL,要先用GET比较value是否为当前持有者标识,再DEL。这两个操作必须原子执行,否则存在“判断通过后、删除前锁被其他线程抢走”的时间窗口。标准做法是用Lua脚本把比较和删除合成一个原子操作,Redisson也是这么做的。
第二问:Redis锁过期了业务还没执行完怎么办?
回答要点:方案有两个方向。一是引入看门狗自动续期机制,Redisson默认用后台定时任务每隔leaseTime/3续期一次;二是业务代码里尽量缩短锁持有时间,把不必要的外部RPC调用移出临界区。无论哪种,都要想清楚极端情况下的兜底手段。
第三问:Redis主从切换时锁丢了怎么办?
回答要点:先承认问题的存在:默认异步复制下,主节点故障瞬间从节点没同步到锁数据,锁会丢。然后说解决方案的取舍:Redlock算法在网络分区和时钟偏差下也有争议;更务实的做法是对强一致场景不上Redis锁,改用ZK或Etcd;或者用数据库唯一约束等机制做最终兜底。
第四问:ZooKeeper锁为什么不会出现锁丢失?
回答要点:因为临时节点和会话强绑定。客户端会话未结束,节点一定存在;会话结束(宕机、网络断开、心跳超时),ZK自动删除节点。同时ZK的ZAB协议保证多数派同步,主从切换不会丢数据。所以只要客户端节点还活着,锁就在;节点死了,锁立刻释放,不会出现死锁。
第五问:分布式锁的可重入是怎么实现的?
回答要点:单机锁靠ThreadLocal计数,分布式锁本质上一样:在key的value里记录持有者标识,同时在客户端本地维护一个ThreadLocal计数器。同一个线程再次加锁时,如果持有者标识匹配且计数大于0,则计数加1,不触发真正的远端加锁;释放时每释放一次计数减1,减到0才真正删除远端key。
7.2 实际项目中踩过的坑
第一个坑:Redis锁的value用了固定字符串,导致误删锁。早期我在一个项目中直接用"lock"作为value,某次一个节点GC停顿了十几秒,锁过期后另一个节点拿锁,第一个节点醒来后直接删掉了第二个节点的锁,线上出现了一次重复扣款。修复方案很简单:value改成UUID或实例ID+线程ID,删除前强制校验,这个校验逻辑不能漏。
第二个坑:Redisson的tryLock(long waitTime, long leaseTime, TimeUnit unit)传了leaseTime但不知道看门狗会关闭。我当时把一个定时任务的锁设成30秒,结果某次任务跑了40秒,锁在第30秒就没了,两个节点同时跑任务,重复处理了一批数据。排查了很久才意识到传了leaseTime就失去自动续期。解决方案是改成只传waitTime,让看门狗接管续期。
第三个坑:ZK锁的sessionTimeout设太小,网络抖动导致锁自动释放。一次线上发布,某节点频繁GC,ZK认为会话过期删掉了临时节点,重构后的客户端又重新获取锁,出现了本不该出现的双执行。后来把sessionTimeout从5秒调到20秒,配合JVM的GC暂停时间一起评估,再也没出现过这个问题。
第四个坑:数据库唯一索引锁在压力测试下出现大量死锁和超时。原因是多个线程以不同顺序获取多把锁,造成循环等待。后来我对所有锁操作统一了获取顺序,并把锁粒度进一步细化,死锁问题才消失。这个问题提醒我:数据库锁不只是建表插数据那么简单,并发控制的基本功该有的还得有。
分布式锁没有银弹。这四种方案里没有“最好”,只有“最合适”。我见过Redis锁解决日订单千万级的并发问题,也见过ZK锁保障资金链路的稳定。把每种方案的原理搞清楚,把它们的薄弱点记在心里,选型时自然会给出合理的判断。如果你也在做相关技术选型,可以参考这个思路梳理一遍自己的业务约束,比在网上搜“哪个方案最强”要靠谱得多。