☰
分布式锁选型指南:Redis、ZooKeeper与数据库方案全解析
2026/10/3 3:01:30 网站建设 项目流程

1. 从一次订单超卖事故说起

先聊一个我踩过的真实事故:线上商城做秒杀活动,活动刚开始两分钟,后台告警短信就炸了——库存扣成了负数。当时我们的订单服务有两台机器在跑,扣减库存的逻辑是先查库存、再update数据库。两台机器同时读到库存剩余1件,同时通过校验,同时执行扣减,结果库存变成了-1。加个synchronized就能解决单机并发问题,但多台机器之间,JVM锁是隔离开的,谁也管不着谁。

这时候就需要引入分布式锁:让多台机器上的多个进程,通过一个公共的第三方组件去排队抢一个“占用标记”,抢到的人才允许执行后续业务。分布式锁的本质上就是在问一个问题:同一时刻,这个临界资源到底归谁用?顺着这个问题往下选型,你会发现市面上的方案看似五花八门,真正普适的其实就三条路——基于Redis的缓存锁、基于ZooKeeper的临时节点锁、基于数据库的悲观锁或唯一索引锁。

这篇文章我打算把你真正关心的东西一次讲透:三种方案从底层原理到实现细节,从踩坑现场到最终选型,包含我实际生产环境里积累下来的判断依据。如果你是后端开发、架构师,或者正在准备分布式相关的面试,这篇内容应该能帮你省下不少弯路。

2. Redis方案:性能天花板,但有个老毛病叫“丢锁”

Redis做分布式锁应该是当前互联网公司里普及率最高的方案,没有之一。原因很简单——它快,而且接入成本极低,走的是缓存那一套网络模型,单实例QPS能到十万级别。但别急着欢呼,Redis方案的坑也最多,很多人在没理解透原理的情况下就上生产,结果出问题后一脸懵。

2.1 加锁的核心用法:SETNX加过期时间,一个命令解决

Redis实现分布式锁最核心的命令是SETNX,全称是SET if Not eXists,语义是“只有当key不存在时才能设置成功”。加锁时执行命令来绑定一个线程标识和过期时间:

SET lock_key my_unique_id NX PX 30000

这个命令拆解一下:

  • NX:保证只有key不存在时才设置,起到“互斥”作用;
  • PX:设置key的过期时间为30秒,防止持有锁的进程宕机后死锁;
  • my_unique_id:这里不要存固定的字符串,建议存当前进程的UUID或线程ID,后面释放锁时要靠它做身份校验。

很多人一开始写的是两条命令组合:先SETNX,再单独EXPIRE。这两条命令不是原子的——如果SETNX成功后还没来得及设置过期时间,JVM就宕机了,锁key会一直被占着,后续所有请求全部阻塞。务必使用带过期参数的单一命令,这是第一个最基础也最关键的注意点。

释放锁时也不能直接DEL。假设线程A持有锁后处理业务超时,锁已自动过期;此时线程B拿到了锁开始干活。A处理完之后回来执行DEL,把B的锁误删掉,临界区就直接被穿透了。正确的释放姿势是这样的:

-- Lua脚本:先校验持有者身份,再删除 if redis.call("get", KEYS[1]) == ARGV[1] then return redis.call("del", KEYS[1]) else return 0 end

这段Lua脚本通过原子操作保证“校验身份+删除”两步不被打断。你想想看,这就好比有人进了会议室,发现你根本不是会议室当前登记人,就直接把门锁拆了,后面的安排全乱套。误删锁的问题在生产环境是真实出现过的,我见过不止一次因为释放锁不校验身份导致的库存超卖事故。

2.2 锁续期到底为什么是必要的

Redis锁设置过期时间的矛盾之处在于:过期时间设短了,业务还没执行完锁就自动释放了,其他线程趁虚而入;设长了,一旦持有锁的节点宕机,锁长时间不释放,等于把整个分布式系统的可用性降级成了单机失败模式。那么真正周到的方案,是引入自动续期机制。

简单类比一下就是:你去银行柜台办业务,取号排队后,系统担心超时把你的号作废,于是每隔一段时间自动帮你续一次等待时间;只有当你真正办完离开,号才会释放;如果窗口突然挂掉,号在最大时限后也会自动作废,不会永久占着。

具体操作是:加锁成功后,启动一个守护线程,每隔过期时间的三分之一就去执行一次PEXPIRE刷新过期时间;业务执行完毕后主动释放锁,并把这线程停下来。Java里Redisson框架已经把这个能力内置了,叫watchdog,默认给锁续期到30秒,每隔10秒续一次。这套机制在Redisson里拿现成代码就能用,但如果你是自己手写的锁,千万别忽略续期逻辑。

我再补一个极端场景:持有锁的线程执行太慢,到达续期节点时自身JVM发生了一次Full GC,或者网络抖动导致续期请求晚到了几百毫秒,锁还是提前过期了。另一个线程顺利拿锁。这时前一个线程醒来继续写数据,互相覆盖。这属于Redis方案在架构层面没法根除的一个权衡,你要有心理预期——它解决的是99%场景的高性能锁需求,但它不是强一致性锁。

2.3 主从切换导致的锁丢失:最隐蔽的坑

Redis集群模式下还有一桩公案:客户端A向主节点写入锁key,数据还没同步到从节点时,主节点宕机了;哨兵触发故障转移,从节点升主。此时从节点上根本没有这个锁key,客户端B来加锁竟然直接成功,两个节点同时持有“锁”。像秒杀这种场景,如果主从切换恰好发生在持锁期间,那该超卖还是会超卖,锁的意义直接归零。

有人说用RedLock红锁方案解决——向多个独立的Redis节点同时申请锁,只有超过半数节点加锁成功才算真正拿锁。这个方案在Redis作者和很多分布式系统研究者之间有争议,核心反对观点是它在网络分区、进程暂停的场景下依然不能保证绝对安全。我的个人建议是:如果业务允许少量重复流量,就踏实用普通Redis锁,性能好、代码成熟;如果业务不能接受任何重复执行,那干脆别用Redis,直接上ZooKeeper。

3. ZooKeeper方案:用临时顺序节点换强一致性

ZooKeeper做分布式锁的思路和Redis完全不一样,它不依赖一条命令的互斥性,而是依赖ZooKeeper自身的分布式一致性协议(ZAB协议)和节点模型。ZooKeeper本身是个分布式协调组件,设计目标就是多节点之间维持一份强一致的数据视图,所以由它来做锁,天然比缓存系统靠谱。

3.1 临时顺序节点:一套清晰的排队机制

ZooKeeper的节点类型里有两种比较特殊:

  • 临时节点(Ephemeral):创建该节点的客户端会话结束(主动断开、超时等)后,节点自动被ZooKeeper删除;
  • 顺序节点(Sequential):创建节点时,ZooKeeper会在节点名末尾追加一个单调递增的序号。

把两者组合起来就得到了临时顺序节点,这就是实现分布式锁的关键材料。假设锁的根路径是/locks/lockA,所有客户端都在这个路径下创建临时顺序节点:

/locks/lockA/lock_0000000001 /locks/lockA/lock_0000000002 /locks/lockA/lock_0000000003

规则是:序号最小的节点代表当前持锁者;其他客户端监听自己前一个节点的删除事件,前一个节点被删掉时,自己就去检查自己是不是最小序号,如果是,说明自己拿到了锁。

这种“排队叫号”逻辑和Redis那种“一把锁拍下去”有本质区别。Redis方案里拿不到锁的客户端基本就放弃了,或者自旋重试;ZooKeeper方案里拿不到锁的客户端安心等待前面的号叫到自己即可,逻辑上更公平,不存在锁饥饿,也不会因为频繁重试打爆Redis。

3.2 Curator封装后,代码到底有多简练

实际操作中很少有人直接操作ZooKeeper原生API去监听事件,因为原生API的连接管理、监听注册、异常重试都要自己处理,工作量大且容易出错。Apache Curator框架把分布式锁封装成了一个叫InterProcessMutex的类,用起来几乎是无脑的:

CuratorFramework client = CuratorFrameworkFactory.newClient( "zk1:2181,zk2:2181,zk3:2181", new ExponentialBackoffRetry(1000, 3)); client.start(); InterProcessMutex lock = new InterProcessMutex(client, "/locks/order_123456"); lock.acquire(); try { // 这里放业务逻辑 } finally { lock.release(); }

这里要提一句:acquire()默认是个阻塞方法,拿不到锁会一直等。你也可以使用acquire(long time, TimeUnit unit)传入等待超时,避免业务无限期挂起。生产环境我强烈建议用带超时参数的版本,因为ZooKeeper临时节点持锁和网络分区叠加时,可能会出现锁一直不可得的极端情况,宁可让请求快速失败回滚,也不要让线程池全部阻塞。

Curator的InterProcessMutex是可重入的,同一个客户端线程可以多次acquire同一把锁,内部用计数器维护重入次数。这一点内部实现非常精妙:它解决了某些业务要在嵌套流程中重复对一个资源加锁的痛点。Redis的Redisson同样支持重入,但数据库方案想支持重入就得自己设计额外字段,复杂度会明显上升。

3.3 羊群效应与超时时间:你知道它为什么慢吗

ZooKeeper可靠,但网上很多声音说它慢。这里特别说一下“羊群效应”:如果一把锁下创建的等待节点很多,每个节点都去监听根节点或者前一个节点的状态变化,一旦锁被释放,ZooKeeper需要同时向所有等待节点推送通知。等待者越多,通知风暴越严重,这就是最早的“羊群效应”问题。

当前主流的实现方式是只监听前一个节点,把通知范围限制在相邻节点之间,羊群效应被极大缓解。但是,如果频繁加锁解锁,ZooKeeper的写事务处理上还是有吞吐上限的。实测下来,跨IDC网络环境下,一次ZooKeeper加锁的全链路RTT在几十毫秒量级,这和Redis的亚毫秒到一两毫秒差距明显。做高频短临界资源的锁,ZooKeeper并不是好的选择;做低频、但对一致性要求极高的资源,它的可靠性优势就出来了。

另一个常见误区是ZooKeeper会话超时参数的设置。锁的持有时间上限基本上与sessionTimeout绑定——会话超时了,临时节点就没了,锁自然被释放。如果你把sessionTimeout设成60秒,业务却需要跑2分钟,那锁在中途就丢了,效果和Redis锁过期一模一样。但设太长呢?持锁客户端挂掉后,锁要等完整超时时间才被清理,系统恢复速度慢。这块只能根据业务最大执行时间来权衡,没有银弹。

4. 数据库方案:最朴素、最重,也常被低估

聊完Redis和ZooKeeper,再来看数据库方案。在很多技术文章里,数据库分布式锁往往被一笔带过,理由是“性能太差,不推荐”。这个判断不算错,但过于武断。做技术选型,本质是在约束条件下找最优解。如果你的系统本身已经重度依赖MySQL,数据量不大,并发写入量不高,数据库锁会是三种方案里架构最简单、排障最直观的一个——毕竟锁的数据就躺在表里,随时能查。

4.1 用悲观锁的for update解决互斥

数据库悲观锁依赖的是MySQL InnoDB引擎的行锁机制。实现逻辑很简单:在一个事务内,对目标记录的某一行执行SELECT ... FOR UPDATE,这条语句会锁住该行,事务提交或回滚时锁才释放。其他事务再执行同样语句时会被阻塞,直到锁释放。

为了用这个机制,我们需要在一个独立的表里初始化出一条“锁记录”,比如:

-- 分布式锁表 CREATE TABLE `distributed_lock` ( `lock_key` VARCHAR(64) NOT NULL COMMENT '锁标识', `owner_id` VARCHAR(64) NOT NULL COMMENT '持有者标识', `expire_time` DATETIME NOT NULL COMMENT '过期时间', PRIMARY KEY (`lock_key`) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;

提前向这张表插入若干条锁记录,比如lock_key = 'order_stock_lock',后续加锁事务就这么写:

BEGIN; SELECT * FROM distributed_lock WHERE lock_key = 'order_stock_lock' FOR UPDATE; -- 执行业务逻辑 COMMIT;

这个方案的核心优势是:数据一致性强依赖事务提交,不存在Redis那种“主从切换丢锁”的问题。InnoDB行锁是存储引擎层面保证的,和业务代码、跨节点时钟都无关,只要数据库本身没有发生脑裂,锁就是可靠的。

但for update方案有一个必须记住的坑:一定要在事务内执行。如果你用ORM框架,又不小心把事务配置成自动提交,那么SELECT FOR UPDATE执行完,锁立刻就被释放了,等于白锁。另外一个坑是它锁的是索引记录,条件必须命中唯一索引或主键,否则InnoDB会退化成锁整个表,这个区别在小表上不直观,在业务表上会引起严重的并发阻塞。

4.2 唯一索引:无锁化地用冲突替代等待

如果你不想用事务包裹长业务逻辑,还有一条基于唯一索引的旁路——利用数据库的“插入冲突”来做互斥。创建一个包含lock_key、owner_id等字段的表,给lock_key建唯一索引,加锁就是执行一条INSERT语句:

INSERT INTO distributed_lock(lock_key, owner_id, expire_time) VALUES ('order_stock_lock', 'instance_1', '2099-12-31 23:59:59');

只有插入成功的那一个客户端算拿到了锁;其他客户端插入时触发DuplicateKeyException,拿锁失败。释放锁就是DELETE掉这条记录。这个方法实现极其简单,没有任何事务和锁等待,在高并发下吞吐也很不错——因为实际上是靠数据库唯一索引的冲突检测,而不是锁。不过它的痛点是:

  • 没有自动过期机制。持有者宕机后,记录会永久留在表里,必须由业务后台或定时任务来清理僵尸记录;
  • 加锁成功率直接依赖插入冲突的反馈,业务上需要针对异常类型做区分判定;
  • 等待间隔、重试策略都得自己写,不像Curator那样有现成的监听机制。

还有一种乐观锁思路是给业务表加version字段,每次更新时带上期望版本号,如果更新影响行数为0就说明version已被其他线程改过,需要重试。但严格说,乐观锁解决的是“多人并发更新同一行”的冲突检测问题,它并不会阻塞其他线程去读相同的数据,从互斥语义上和分布式锁并不完全等价。用不用,要小心区分场景。

4.3 数据库方案的性能分析与适用边界

网上很多文章一说数据库分布式锁就只说“慢”,这个结论是有前提的。拿MySQL来说,单条SELECT FOR UPDATE的耗时正常情况下在几百微秒到几毫秒之间,和Redis的网络RTT相差并不悬殊;真正拖垮数据库的是锁等待——当同一行锁记录上堆积了大量并发事务时,后面的请求全都阻塞在InnoDB的锁等待队列里,数据库的活跃会话数会暴涨,甚至拖垮整个实例。

有一个通用的经验法则:如果临界资源的并发争抢程度低,数据库方案能扛住;如果高并发、短时间几万甚至几十万的请求同时抢一把锁,数据库方案会先顶不住。数据库方案更适合的场景是低频任务调度、定时任务互斥、对强一致性要求高于性能的运营后台操作。这就像一个高速路口,平时车不多的时候用个简单的手动栏杆就够了,非得花大价钱装一套ETC系统,反而性能过剩和复杂度超标。

5. 三种方案横向对比与选型地图

方案之间的技术剖析已经说完了,接下来把三者放在一张表里直观对照。我自己做选型时的判断标准是五个维度:性能上限、可靠性、实现复杂度、运维成本、自愈能力。下面这个表格是我在业务里经常拿出来对着看的参考表:

对比维度RedisZooKeeper数据库
性能极高,毫秒以内中等,几十毫秒量级低,锁等待场景恶化明显
可靠性存在主从切换丢锁风险高,无丢锁问题高
实现复杂度低,Redisson直接引用中偏低,Curator封装完善低,SQL写好即可
运维成本依赖Redis可用性需要单独维护ZK集群复用现有DB,零额外组件
自愈能力靠过期时间兜底临时节点随会话消失需手动清理或定时任务
可重入支持支持支持需要自行设计

无论做哪个选型,我觉得真正决定成败的不是方案本身,而是对业务场景的审视。围绕这个,我再展开三类场景下的选型建议。

5.1 低峰期项目:优先考虑数据库

对于后台管理系统、运营配置中心、定时报表生成这类业务,请求并发量往往很小,可能一个时间段内就只有几条线程在抢一把锁。最务实的选择就是数据库方案——不需要额外引入Redis和ZooKeeper集群,不需要处理配置文件和密码,SQL语句任何人都能看懂,出问题时可以直接查表分析。我见过有的小团队为了给定时任务加锁,专门搭了一套Redis集群,最后因为监控缺失,Redis挂了导致定时任务全部重复执行,这其实是用一个复杂系统去解决一个简单问题。

数据库方案里我特别推荐用唯一索引加插入记录来实现锁,因为它的实现路径最短、可排查性最好。配合一个定时清理僵尸锁记录的Job,就能稳定跑了。

5.2 核心链路与二手细节并存的业务:优先Redis

涉及订单、库存、优惠券等核心链路的互斥场景,Redis锁是最主流的选择。只要业务能容忍极低概率的重复执行(比如在订单号生成、幂等写库时做二次校验),Redis方案通吃。Redisson的RLock封装完善,用起来很顺手,内部已经解决了续期、重入、超时释放这些关键细节。

但Redis锁这里有几个使用细则:锁的粒度要尽量细化。比如锁订单资源时不要全局锁,而是锁“订单ID对应的那个资源”,并发能力差别巨大。锁的过期时间要按“业务最差执行时长”的1.5到2倍去设置,而不是按平均耗时设置,否则长尾请求必炸。业务执行完后记得在finally块中释放锁,避免异常路径上锁永久占用。

5.3 对强一致和防重复有硬性要求:选ZooKeeper

处理金融级别的扣款、分布式任务调度里的主节点选举、多机部署下的幂等审核等场景时,我建议上ZooKeeper。虽然它的吞吐上限不如Redis,但它在节点的会话管理机制上做得极其干净——临时节点随着会话一起销毁,任何进程异常退出、断网超时都逃不过ZooKeeper的会话超时机制,锁会被自动释放,不存在“锁忘了删”这种低级问题。

另外如果你们团队本来就有ZooKeeper集群(大数据、Kafka等生态里的组件经常会用到它),那复用的边际成本就更低了。反过来看,如果为了做一把锁单独引入一个ZooKeeper集群,我大概率会劝你冷静一下——毕竟它本身是AP架构系统,运维门槛比Redis高不少。

6. 生产环境实战:加锁解锁都要讲究细节

从选型落到代码,再到稳定上线,中间藏着的细节远比教科书多。下面几个是我在这些年实际维护分布式锁的过程中踩过坑、也最终沉淀下来的心得。

6.1 Redis锁的过期时间怎么定

一个容易被忽略的点:很多业务执行耗时是波动的,同一个接口平时十几毫秒,遇到慢SQL或Full GC就能冲到好几秒。如果锁只设置了2秒过期,慢请求在临界区还没走完,锁就没了,其他线程涌进来重复执行。建议把过期时间设置成指标数据中P99执行耗时的两倍,并配合续期线程,才能有安全余量。

比如订单状态流转接口做完压测后,P99耗时在800毫秒左右,那我把锁过期时间定在2000毫秒;同时开启Redisson自带的watchdog续期,以防极端情况。如果业务里有些长事务周期能达到分钟级,那就要慎重考虑——这种时长其实已经不适合用Redis锁扛了,优先拆业务或改异步化。

6.2 ZooKeeper会话超时和重试策略配置

用Curator操作ZooKeeper时,有两个连接层参数很重要。一个是sessionTimeoutMs,它决定了临时节点的生存期;另一个是connectionTimeoutMs,是客户端和ZooKeeper服务端建连的超时上限。很多人把这两个混淆,结果会话超时设了60秒,连接超时设了3秒,系统一抖动,锁先丢了。

重试策略我通常选ExponentialBackoffRetry,设置基数为1秒,重试次数为3次,指数退避。不要配置成无限重试,因为锁获取是一个瞬时操作,如果ZooKeeper集群已经持续异常,无限重试只会让线程池堆积成灾难现场。带上超时失败回退,是更负责任的做法。

6.3 释放锁的顺序与业务安全退出

三种方案释放锁时的原则高度一致:必须在finally代码块中释放,绝不让异常路径绕过释放逻辑。更严谨的写法是在try之前加锁,catch住所有代码,不管业务成功还是失败,只要能返回就给后续线程拿到锁的机会。

还有一点容易被忽略:释放锁之前的业务操作要确保事务已经结束。如果事务还没提交就去释放锁,下一个线程进来读到的数据可能还是旧值,等于锁是上了,但保护的语义没保住。正确顺序是“先提交事务,再释放锁”。这一点在Redis和数据库方案中我都吃过亏。

6.4 加锁字段统一与幂等设计

分布式锁标识(即LockKey)的生成要格外小心。比如用用户ID做锁,就统一用同一个格式函数生成,不能在A服务里生成的是字符串拼接结果,到B服务里变成JSON序列化结果,那样两个服务其实在抢两把完全不相干的锁。我建议在公共代码里提供统一的锁Key生成工具,并加上业务前缀,比如order_pay_123456和stock_deduct_10001,便于排查和灰度。

就算用了分布式锁,核心业务也一定要保留幂等性作为兜底。锁是一道防线而不是唯一的防线,接口层面再做个数据库唯一约束或者幂等表校验,双重保障,这样才能真正把事故窗口压缩到最小。我见过太多项目,以为加了锁就万事大吉,最后还是被极端并发和缓存穿透一起打了脸。

7. 常见问题与排查技巧实录

以下这些问题都是我实际生产和交流中见过的典型问题,按出现频率排个序,再附上排查思路,你可以直接拿来当速查手册用。

7.1 Redis锁偶尔失效,业务重复执行

先检查释放锁时是不是没做身份校验,看看Lua脚本是不是直接拿DEL删的。再检查过期时间是不是太短,业务执行时间是不是有长尾。最后看Redis集群是不是发生了主从切换。排查方法是:在业务里打印加锁结果和锁标识,对照日志时间线,看看重复执行的两个请求之间锁到底是怎么交接的;同时开启Redisson自带的日志输出锁的续期行为,快速定位问题在哪一环。

7.2 ZooKeeper锁长时间获取不到,服务卡死

排查时先看ZooKeeper集群的会话状态,是不是session超时参数设得太短导致连接频繁重连。再用四字命令查看当前/locks节点下堆积的子节点数量。如果子节点数量很大,说明前一个持有者释放异常,或者锁Key设计得太粗,一把锁被大量请求争抢。解决手段:一是改用小粒度锁Key,二是合理配置会话超时时间和等待超时参数,三是检查持有者是不是在finally里正确释放了锁。

7.3 数据库for update导致连接池打满

这个问题在数据库锁方案里很常见。原因是等待锁的会话阻塞在事务里,同时长事务还占着连接不释放,连接池被耗尽。排查时通过数据库的信息模式表查当前活跃事务和锁等待关系,定位到具体行锁记录。根治方法有两条:一是把锁记录拆细,比如按用户维度拆锁记录,同一把锁的争抢量就少了;二是增加获取锁的超时机制,不要无限期等待,等待超时即抛错返回,让请求快速失败降级。

8. 我的最终建议:没有最好的方案,只有最适合的方案

分布式锁的选型脱离了业务场景,谈优劣都是耍流氓。我的个人经验是这样:多数互联网业务链路里,Redis锁是综合性价比最高的解,因为它足够快、足够成熟、生态足够完整;核心资金操作或强一致场景,宁可牺牲一点性能也要换ZooKeeper的确定性;而系统体量小、并发量低、不想多维护任何组件时,数据库方案反而是最稳妥的选择。

还有一个小技巧分享给后面接手的同学:无论选了哪种方案,都要在落地之前画一份“锁的分布地图”——每个业务模块用了哪把锁、锁的保护范围是什么、锁的生命周期由谁管理、异常兜底是什么。这份图不一定要画得多么专业,哪怕是写在文档里的一条条列表也好。等线上真的出了故障,这份地图能让你少熬好几个通宵。分布式锁本身不复杂,复杂的是用它保护的业务逻辑,所以,请永远保持对临界区代码的敬畏。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询