☰
分布式锁三种实现对比:数据库、Redis与ZooKeeper
2026/10/7 10:50:36 网站建设 项目流程

第一次在大促压测现场遇到库存超卖,说实话人是很懵的。单机时代,一个synchronized加在扣库存的方法上,几百个线程照样排得明明白白;可当服务拆成三个实例之后,同一秒进来的三个请求会各自拿到一把“JVM 里边的锁”,然后同时扣掉同一件商品的三件库存。那一刻你才会真正意识到,synchronized和ReentrantLock的作用范围永远只是单个 Java 进程内的线程互斥,进程与进程之间根本锁不住。

这个问题的标准解法,就是分布式锁:让不同节点上的线程,在访问共享资源前先竞争一个跨节点的互斥信号。目前业界主流的实现方式主要有三种——基于数据库、基于 Redis、基于 ZooKeeper。网上讲这三种方案的文章很多,但大多数要么只贴代码,要么只讲原理,真正把“为什么要这么写”“哪些地方容易踩坑”“线上到底怎么选型”讲透的不多。

这篇内容我会从一次真实的并发事故说起,把三种方案各自的原理、核心代码、隐蔽问题和选型依据完整梳理一遍。无论你是刚接触分布式锁的初中级开发,还是已经在生产环境维护过锁的老手,应该都能找到想要的东西。

1. 先从业务困境说起:单机锁为什么在分布式环境彻底失灵

1.1 一个典型的并发事故现场

我接手过一个商城库存系统,业务逻辑不复杂:用户下单时,对sku_id对应的库存做扣减。最初代码是这样写的:

public synchronized boolean deductStock(Long skuId, int count) { Stock stock = stockMapper.selectBySkuId(skuId); if (stock.getStock() < count) { return false; } stock.setStock(stock.getStock() - count); stockMapper.updateById(stock); return true; }

单实例部署时一切正常,synchronized保证了同一时刻只有一个线程能执行扣减。后来为了扛住流量,服务水平扩展成三个实例,流量通过负载均衡分发到不同节点。问题就来了:三个实例里的三把锁互不相干,线程 A 在实例 1 上读到了库存 10,线程 B 在实例 2 上也读到了库存 10,线程 C 在实例 3 上还是读到库存 10,三个线程同时执行updateById,最终库存被改成了 7,而不是 7 这个“正确值”。更危险的场景是并发量再大一点,库存直接被扣成负数。

这个案例几乎是分布式锁入门必讲的经典场景。它说明的只有一件事:当“互斥边界”从单个 JVM 扩展到多个进程时,原来那把只能锁住一个进程的同步锁就彻底失效了,锁的作用域必须跟着扩展。

1.2 分布式锁必须具备的硬性条件

很多初学者以为分布式锁就是把synchronized换成一个远程的什么标记位,这个理解方向对,但远远不够。一个真正能上生产环境的分布式锁,至少要满足下面几个条件,任何一个缺失都会埋雷。

第一,互斥性。任意时刻,只能有一个客户端持有锁,这是分布式锁存在的根本前提。如果两个客户端同时拿到锁,那这个锁就不是锁,而是个笑话。

第二,防死锁,也就是锁必须能自动释放。拿到锁的客户端如果中途崩了、网络断了、进程被 kill -9 了,锁不能一直存在,否则其他所有客户端都会永远等下去。释放的方式通常是“过期时间”或者“临时节点失效”,这也是我在后文反复强调的一个核心设计点。

第三,释放锁必须安全。不能出现“把别人的锁释放掉”的情况。最简单的做法是加锁时给锁绑定一个唯一的持有者标识(比如 UUID),释放时校验只有持有者才能释放。如果没有这个校验,就会发生经典的误删场景:A 持有锁,锁过期后 B 拿到锁,A 执行完业务后把 B 的锁删了。

第四,锁的持有时间是可控的。业务执行时间是不可预测的,可能快也可能慢。如果锁的过期时间太短,业务没执行完锁就释放了,互斥性就被破坏;如果锁的过期时间太长,一旦客户端真的崩了,其他客户端要等很久。理想的设计是通过“续期”机制动态延长锁的持有时间,而不是赌一个固定的过期秒数。

这四个条件看起来基础,但三种主流实现方式恰恰是在这些点的处理上分化出了各自的优势和坑位。搞清这一点,后文的内容就不会看着像个“三选一”的选择题,而是一场针对自己系统约束条件的权衡。

2. 基于数据库实现:最朴素,但深坑最多的方案

数据库实现分布式锁是历史最悠久、也最容易被新手“顺手抄走”的方案。原因很简单:业务系统里本来就有数据库,不需要额外引入任何中间件,加锁就是一行 SQL 的事。但它的坑也几乎是三种方案里最多的。

2.1 方案一:利用唯一索引做互斥

思路非常直白:建一张锁表,lock_key字段加上唯一索引。需要加锁时,往表里插入一条记录;能插入成功,说明抢到锁;插入报唯一键冲突,说明有人已经持锁。

CREATE TABLE `distributed_lock` ( `id` bigint NOT NULL AUTO_INCREMENT, `lock_key` varchar(64) NOT NULL COMMENT '锁的唯一标识', `holder` varchar(64) NOT NULL COMMENT '持有者标识,如ip:port:uuid', `status` tinyint NOT NULL DEFAULT '0' COMMENT '0-占用 1-已释放', `expire_time` datetime DEFAULT NULL COMMENT '过期时间,兜底用', `create_time` datetime DEFAULT NULL, `update_time` datetime DEFAULT NULL, PRIMARY KEY (`id`), UNIQUE KEY `uk_lock_key` (`lock_key`) ) ENGINE=InnoDB;

加锁时执行:

INSERT INTO distributed_lock (lock_key, holder, expire_time) VALUES ('order:12345:pay', '192.168.1.10:8080:uuid-xxx', DATE_ADD(NOW(), INTERVAL 30 SECOND));

如果INSERT正常返回,锁就拿到了;如果抛DuplicateEntryException,说明锁已被别人持有,加锁失败。释放锁时执行:

DELETE FROM distributed_lock WHERE lock_key = 'order:12345:pay' AND holder = '192.168.1.10:8080:uuid-xxx';

holder条件保证了“只释放自己的锁”。这个方案实现成本几乎为零,三五分钟就能跑通。但它的问题非常尖锐:如果持有锁的服务在释放锁之前宕机了,DELETE永远不会执行,这条锁记录就会永久卡在表里。所以你必须在业务代码里做“一把锁记录最多存在 N 秒”的兜底,比如让定时任务扫描expire_time超过当前时间的记录,强制删除。

可这样又引入了另一个问题:如果业务执行时间刚好超过expire_time,定时任务会把还在使用中的锁删掉,另一个节点就能插入同样的lock_key,互斥性被直接破坏。你只是在用“一个定时任务猜过期时间”的方式,替换掉了“进程自己管理锁生命周期”的方式,并没有真正解决问题。

2.2 方案二:行锁配合事务实现排他

另一种数据库实现是借助 InnoDB 的行级锁。利用SELECT ... FOR UPDATE,在事务内锁定某一行,其他事务执行相同的SELECT ... FOR UPDATE时会被阻塞,直到当前事务提交或回滚。

BEGIN; SELECT * FROM distributed_lock WHERE lock_key = 'order:12345:pay' FOR UPDATE; -- 执行业务逻辑 UPDATE stock SET stock = stock - 1 WHERE sku_id = 12345; COMMIT;

事务提交时,行锁自动释放,不需要你手动DELETE。相比唯一索引方案,这个用法的好处是“业务执行完就自动释放”,不会出现忘了删除导致锁卡死的问题。

但它有几个前提条件,每一环都可能踩坑。第一,SELECT ... FOR UPDATE必须在事务里执行,如果 autocommit 开着,语句执行完锁就释放了,形同虚设。第二,WHERE条件的字段必须有合适的索引,否则 InnoDB 在锁行时会退化到锁整张表,所有加锁请求全部串行化,吞吐量直接归零。第三,事务没有处理异常回滚时,如果业务代码中途抛异常但没有ROLLBACK,锁可能一直不释放,直到数据库层面打断事务连接。

而且这个方案有个隐性问题——它会把“持有锁”和“占用数据库连接和事务”绑定在一起。一个长事务长时间占用连接,在数据库连接池比较小的系统里,很容易把连接池打满。我在线下系统里见过最大的事故,就是有人用FOR UPDATE做了一个需要几十秒的报表导出任务,结果连接池被锁占满,全站接口全部超时。

2.3 方案三:乐观锁 CAS 更新

严格来说,乐观锁不是我眼中的“分布式锁”,它更像是一种“不用锁的并发控制”,但很多文章和面试答案都会把它列进来,这里也说清楚。

乐观锁的核心是给数据加版本号:

UPDATE stock SET stock = stock - 1, version = version + 1 WHERE sku_id = 12345 AND version = 5;

如果受影响行数为 1,说明更新成功;为 0,说明版本号已经变了,需要重试或放弃。这个方案同样实现了“多个进程更新同一行数据时只有一个能成功”的效果,而且实现极其简单。

但它有个本质区别:乐观锁不阻塞、不排队,它是“失败后重试”的模型。这在更新单个数据行、且冲突概率不高的时候非常好用;一旦并发冲突率高,大量请求会陷入无休止的重试,数据库压力反而更大。另外,乐观锁只能针对单行数据做控制,如果同一把锁要保护多条记录或者一个跨表的业务流程,乐观锁就无能为力了。所以我的建议是:在系统设计中不要把乐观锁当成分布式锁的正面答案,它可以作为性能优化手段,代码评审的时候说“这是乐观锁不是分布式锁”,面试反而会加分。

2.4 数据库方案的共性短板,别被“零依赖”骗了

三种数据库方案摆在一起看,共性短板其实很清晰。

第一个是性能天花板。加锁操作要么是 INSERT 要么是 SELECT FOR UPDATE,每一次都要跟数据库交互,还要享受“事务提交”的完整开销。在低并发、低频场景下这都不是事,一旦压到每秒几百上千次加锁请求,数据库很容易成为瓶颈。

第二个是单点与可用性问题。如果数据库是单实例,数据库一挂,锁全部失效;如果用了主从,主库故障切换的窗口期,两个节点可能同时执行加锁,无法保证绝对互斥。

第三个是运维维护成本高。锁表要不要清理、过期时间怎么设、定时任务删锁会不会误删、事务异常导致锁悬挂……这些问题没有一个统一框架给你兜底,全得自己写、自己测、自己扛。

数据库方案的真正价值在于“零新依赖”——如果你所在的公司连 Redis 和 ZooKeeper 都没有,且加锁频率非常低,那用数据库临时顶一下是可以接受的。但我个人认为,它在生产系统里更适合作为“最终兜底”,而不是“主锁”。

3. 基于 Redis 实现:性能王者,细节决定成败

Redis 方案是当前互联网业务里用得最多的分布式锁实现。为什么?因为大多数团队本来就有 Redis,加锁就是一条命令,性能是微秒级,配合成熟客户端能省掉大批自研成本。但 Redis 锁的坑非常隐蔽,网上流传的错误写法也最多。

3.1 第一版:SETNX + EXPIRE 是两步走,不是原子操作

很多老文章会教你这么写:

SETNX lock_key uuid_value EXPIRE lock_key 30

先SETNX,如果返回 1 说明加锁成功,接着EXPIRE设置过期时间。这个写法在单命令层面没有任何问题,但两个命令合在一起不是原子操作,中间会有一个时间窗口。

如果客户端执行完SETNX之后,在EXPIRE之前崩了,锁就没有过期时间,会永久存在,所有后续请求全部卡死。这就是“死锁”的一种形态。这个坑在当年很常见,很多老项目里至今还能翻到这种写法。

正确做法是用 Redis 2.6.12 之后提供的SET命令一步完成:

SET lock_key uuid_value NX EX 30

NX表示只有当 key 不存在时才设置成功,EX 30表示同时设置 30 秒过期时间,整个操作是原子的。一条命令把“加锁”和“设超时”都做完了,这才算拿到了一个最基础的有效锁。

3.2 带身份标识的原子加锁:SET NX EX 一次搞定

加锁命令本身不难,真正的难点在于“谁能解锁”。很多人一开始是这样写的:

Boolean locked = redisTemplate.opsForValue().setIfAbsent(lockKey, "1", 30, TimeUnit.SECONDS); try { // 业务逻辑 } finally { redisTemplate.delete(lockKey); }

这个版本有个典型 bug:假设 A 的锁 30 秒后过期,B 成功用同一个 key 加锁,此时 A 的业务执行完毕,执行delete(lockKey),把 B 的锁删掉了。之后 C 又加锁成功……结果就是本来应该互斥的三个节点,在锁被误删的瞬间全部进入了临界区。

要解决这个问题,加锁时的 value 必须带上一个全局唯一的持有者标识,通常是 UUID。释放锁时先判断 value 是否匹配,匹配才删除。

String requestId = UUID.randomUUID().toString(); String lockKey = "inventory:order:" + orderId; Boolean locked = redisTemplate.opsForValue() .setIfAbsent(lockKey, requestId, 30, TimeUnit.SECONDS); if (Boolean.TRUE.equals(locked)) { try { // 业务逻辑 } finally { // 释放锁,见下一小节 } }

value 用 UUID 是它的核心安全防线,千万不要偷懒存一个固定字符串。

3.3 安全的释放锁:为什么必须用 Lua 脚本

有了 value 标识之后,释放锁看起来是这样两步:

String value = redisTemplate.opsForValue().get(lockKey); if (requestId.equals(value)) { redisTemplate.delete(lockKey); }

但这里又踩了原子性的坑。GET和DEL是两个独立操作,中间同样存在时间窗口。极端情况下:A 执行GET,发现自己还是持有者;此时锁过期了,B 加锁成功;A 接着执行DEL,把 B 的锁删了。要彻底堵住这个窗口,必须让“检查身份”和“删除 key”在一个原子操作里完成。最标准的做法就是 Lua 脚本:

if redis.call('get', KEYS[1]) == ARGV[1] then return redis.call('del', KEYS[1]) else return 0 end

Java 侧通过RedisTemplate执行这段脚本:

DefaultRedisScript<Long> script = new DefaultRedisScript<>(); String lua = "if redis.call('get', KEYS[1]) == ARGV[1] then return redis.call('del', KEYS[1]) else return 0 end"; script.setScriptText(lua); script.setResultType(Long.class); Long result = redisTemplate.execute(script, Collections.singletonList(lockKey), requestId);

Lua 脚本在 Redis 里是原子的,要么整个执行,要么整个不执行,不会再出现读和删之间被插队的窗口。这个细节就是“手写 Redis 分布式锁”和“拿轮子直接跑”的分水岭。

3.4 一个不能回避的问题:锁过期了业务还没做完怎么办

假设你给锁设置了 30 秒过期,但业务内部有一次慢 SQL、两次 RPC 调用、一次文件导出,加起来恰好超过了 30 秒。这时候锁自动释放,另一个线程拿着同一个 key 进来了,两个节点同时执行同一份库存扣减——又超卖了。

解决方案有两种思路。

第一种,设置一个非常宽裕的过期时间。比如预估业务最长执行 5 秒,你给锁设置 30 秒。这种方案简单,但它是靠“猜”来保证安全的,一旦遇到极端慢的情况,锁照样提前释放。

第二种,使用看门狗自动续期。这也是 Redisson 这类成熟客户端的核心能力。看门狗的逻辑简单说就是:给锁一个默认租期(一般是 30 秒),同时启动一个后台定时任务,每隔 10 秒检查一次,如果锁还在、业务还没结束,就自动把过期时间重置为 30 秒。业务结束执行unlock,定时任务也会随之取消。这样既不会因为业务执行慢导致锁提前释放,也不会因为进程崩溃导致锁永远不释放——进程都崩了,定时任务自然停了,锁会在 30 秒后自动消失。

Redisson 的使用方式非常直接:

RLock lock = redissonClient.getLock("inventory:sku:" + skuId); boolean locked = false; try { // 尝试加锁,最多等待3秒,锁租期30秒 locked = lock.tryLock(3, 30, TimeUnit.SECONDS); if (locked) { // 业务逻辑 } } finally { if (locked) { lock.unlock(); } }

我在生产里强烈建议,如果走 Redis 路线,除非有特殊限制,否则直接用 Redisson,不要自己去手写一遍加锁、解锁、续期——手写的代码只覆盖了单元测试里的正常路径,永远覆盖不了生产环境里的超时、重试、GC 停顿、主从切换这些非正常路径。

3.5 Redlock 的争议,以及我个人的取舍判断

说到 Redis 分布式锁,绕不开 Redlock。它解决的问题是:单节点 Redis 如果发生故障,锁可能会丢。比如客户端 A 在主节点上加锁成功,主节点还没把数据同步到从节点就宕机了,从节点被提升为主节点,此时它没有这把锁的数据,客户端 B 就能对同一个 key 加锁成功,互斥性被破坏。

Redlock 的思路是:不依赖单节点,而是准备 N 个独立部署的 Redis 节点,客户端依次对所有节点执行加锁命令,只要超过半数节点加锁成功,并且加锁总耗时小于锁的过期时间,就认为加锁成功。

Martin Kleppmann 写过一篇文章,批评 Redlock 对“GC 停顿”场景无能为力:持有锁的客户端发生长时间 GC,锁在停顿期间过期,另一个客户端拿到锁;停顿结束后第一个客户端恢复执行,两个客户端同时进入了临界区。这个批评在理论层面是对的,任何基于“时间租约”的锁都无法完全杜绝这类问题,ZooKeeper 的会话超时机制同样存在类似窗口。

但回到生产实践,我的观点是:对绝大多数业务系统来说,单节点 Redis + 哨兵/集群已经够用,Redlock 很少是必须的。它代价高(需要多个独立节点,运维成本翻倍)、收益有限(只能解决主从切换那一类故障,解决不了业务自身的长停顿)。如果你的业务真的对“绝对互斥”要求苛刻到这种程度,那更靠谱的方案是切换一致性模型,直接上 ZooKeeper 或者 etcd,而不是在 Redis 上做文章。判断标准很简单:你的锁失效时,系统最坏会造成多大损失?如果只是超卖少赚一笔钱,Redis 足够;如果是资金多扣、重复发货这种不可逆后果,重心放在“锁外的状态校验和幂等”,而不是把希望寄托在锁本身。

4. 基于 ZooKeeper 实现:一致性优先,分布式锁的“学院派”

如果说 Redis 锁是性能优先的实用主义者,那 ZooKeeper 的实现就是一致性优先的“学院派”。它在分布式锁这个场景里,方案设计得最优雅,也最不容易被业务方误用,代价是性能和运维成本更高。

4.1 临时顺序节点为什么天然适合做锁

ZooKeeper 提供了几种节点类型,分布式锁用到的是其中两种特性的叠加。

临时节点(EPHEMERAL):创建节点的客户端和 ZooKeeper 之间的会话结束时,这个节点会被自动删除。注意,这里说的“会话结束”是指连接断开且超过 sessionTimeout,或者客户端主动关闭连接。这个特性意味着客户端宕机后,锁会被 ZooKeeper 自动清理,不需要你手动删除、也不需要设置过期时间,天然防死锁。

顺序节点(SEQUENTIAL):创建节点时指定SEQUENTIAL后缀,ZooKeeper 会在路径末尾自动追加一个单调递增的序号。多个客户端创建的节点,顺序谁先谁后一目了然。

把两者结合,就得到了一个既公平、又自动释放的锁模型:

  1. 客户端在锁目录下创建临时顺序节点,比如/locks/inventory_0000000001。
  2. 客户端获取锁目录下所有子节点,按序号排序。
  3. 如果自己创建的节点序号最小,说明拿到了锁,直接执行业务。
  4. 如果自己不是最小节点,则对序号比自己小一级的节点注册一个删除事件的 watch,然后进入等待。
  5. 当前一个节点被删除时(说明持有者释放了锁或会话结束),客户端被唤醒,重新检查自己是否成为序号最小的节点。

这个方案的精妙之处在于两点:一是公平性天然成立,加锁请求按顺序排列,先到先得;二是不会有轮询风暴,每个客户端只关注自己前一个节点,避免了“所有人都 watch 锁目录,每次锁一释放所有客户端都被唤醒”的羊群效应。

4.2 用 Curator 半小时跑通 InterProcessMutex

Curator 是 ZooKeeper 官方生态里的高级客户端,封装了分布式锁的实现,直接用就行,不需要自己写节点监听逻辑。

CuratorFramework client = CuratorFrameworkFactory.builder() .connectString("127.0.0.1:2181") .sessionTimeoutMs(30000) .connectionTimeoutMs(5000) .retryPolicy(new ExponentialBackoffRetry(1000, 3)) .build(); client.start(); InterProcessMutex lock = new InterProcessMutex(client, "/locks/inventory_" + skuId); if (lock.acquire(5, TimeUnit.SECONDS)) { try { // 业务逻辑 } finally { lock.release(); } } else { // 5秒内没拿到锁,按获取失败处理 } client.close();

跑通这个 Demo 只需要 20 分钟,但上线前要留意几个点。第一个是连接池和会话管理:CuratorFramework是重量级客户端,一个应用应该全局共享,不要每次加锁都 new 一个。第二个是锁路径的规划:/locks下的子节点数量会随时间增长,记得做定期清理策略,或者用 ZK 的自动清理工具。第三个是 sessionTimeout 的设置,这个参数直接关系到“锁提前释放”的临界值,见下一小节。

4.3 会话超时、性能上限与公平性的真实代价

ZooKeeper 方案最大的坑,是“锁提前释放”。

假设 sessionTimeout 设置成了 30 秒,客户端和 ZooKeeper 之间维持着心跳。如果持有锁的客户端发生了一次长达 40 秒的 Full GC,期间无法发送心跳,ZooKeeper 判断会话超时,就会把所有临时节点全部删除——包括这把锁。此时另一个客户端加锁成功,而第一个客户端 GC 结束后还在继续执行业务,两个节点就同时进入了临界区。这和 Redis 锁过期问题本质上是同一类,都是“时间假设被突发停顿破坏”的问题,只是触发条件从锁过期时间变成了会话超时时间。

所以在设置 sessionTimeout 时,我的建议是宁可大不要小。业务 GC 停顿一旦发生,你希望 ZK 能多容忍一会儿;大话说的直白点,ZooKeeper 的方案帮你解决了“进程崩溃后锁不释放”的问题,但它不可能帮你解决“进程假死但业务还在跑”的问题,那是任何分布式锁都解决不了的。

另一个问题是性能。ZooKeeper 的每次写操作都需要过一遍 ZAB 协议,多数节点确认后才算成功,而且加锁过程涉及创建节点、获取子节点、注册 watch、监听删除事件至少四步交互。单次加锁耗时在几毫秒到几十毫秒之间,比 Redis 慢了一个数量级以上。在每秒几千次加锁的系统里,ZooKeeper 集群压力会非常大,性能天花板明显。

但它的优势也非常鲜明:一致性强、公平、没有锁过期时间需要你去猜。如果系统里已经有 ZooKeeper 基础设施,且锁请求频率不高、对互斥性要求严格,选它非常稳。我在团队里见过用了 ZK 锁之后最典型的效果:监控上几乎没有“加锁失败”的告警,大家也基本不用讨论锁过期时间怎么设,省心。

5. 三张表看清三种方案的差异,选型照着抄就行

三种方案讲完,我猜你已经意识到它们不是“越高级越好”,而是各有各的边界。这一节把差异全部摊开,方便你做选型决策。

5.1 关键能力维度横评

对比维度数据库RedisZooKeeper
互斥实现唯一索引 / 行锁SET NX 原子命令临时顺序节点
自动释放机制需自行管理过期时间过期时间自动过期临时节点随会话删除
释放锁的安全性靠 holder 字段,需 SQL 保障Lua 脚本实现原子校验+删除只能本客户端删除自己的节点
公平性不保证不保证,靠客户端自旋/等待顺序节点天然公平
加锁性能秒级,事务开销大微秒级毫秒级,要过 ZAB 协议
一致性模型强一致(单机),主从切换有窗口主从 AP,存在锁丢失窗口强一致(ZAB 多数派确认)
实现成本低,SQL 能跑就行中,要考虑原子性、身份、续期中高,需要维护 ZK 集群
宕机兜底能力弱,需定时清理兜底强,过期时间兜底强,临时节点自动清理
典型适用场景低频 B 端管理操作高并发、低延迟、允许短暂不互斥强一致、公平性要求高、低频

从上到下扫一眼,你会发现一个很有意思的规律:三种方案其实是在“一致性、性能、运维成本”这三件事上做取舍。数据库把成本和一致性做得很表面,但性能和自动释放最弱;Redis 把性能拉满,代价是极端场景下的一致性有窗口;ZooKeeper 把一致性做到位,代价是性能和维护成本。

5.2 不同业务场景的选型建议

结合我在不同团队和项目里的经验,这里给几个可以直接照抄的选型判断标准。

场景一:电商秒杀、库存扣减、活动领券,这类高并发业务。首选 Redis 方案,并且尽量用 Redisson 这类成熟客户端。理由很简单:请求量大,必须要微秒级的加锁能力和自动续期机制;就算极端情况下锁丢了一个窗口、多放进来一个请求,库存系统本身还有库存不足就拒绝的兜底逻辑,损失可控。

场景二:资金对账、订单状态流流转、分布式任务调度,这类低频但对互斥性高度敏感的业务。可以考虑 ZooKeeper 或 etcd。锁一天只被触发几十次,性能完全不是问题,关键是“绝对不能出现两个节点同时跑同一个任务”。比如我一个外部系统就有个凌晨的批量退款任务,之前用 Re dis 锁,网络抖动的那几天偶尔会出现重复退款工单,换到 ZooKeeper 之后一次都没有过。

场景三:新项目、小团队、中间件资源有限,业务对并发要求也不高。用数据库临时顶一顶没有不行,但必须把“过期时间 + 定时清理 + holder 校验”三件套做完整,并且要想清楚后续可以平滑迁移到 Redis 的方案。我见过太多小项目用数据库锁硬扛到用户量上来,最后在锁表上吊死的案例。

场景四:如果系统里已经同时有 Redis 和 ZooKeeper。我的偏好是高频短临界区业务走 Redis,低频高价值任务走 ZooKeeper,不要把 DNA 混在一个锁上。双锁方案“Redis 快速锁 + DB 兜底锁”在理论上漂亮,但在实现上没有两把锁状态同步的机制,反而容易把互斥搞乱,不建议轻易尝试。

选型时还有一个容易忽略的点:评估锁失效后的业务后果,而不是选型时最简单的那个。我习惯在下单前问三个问题:锁丢了一次,系统最坏发生什么?锁的最长持有时间有没有兜底?换个实现要付出多大迁移成本?答案清晰之后,选型就变成一个非常快的决定。

6. 生产实践中的高频坑位,以及我的排查思路

方案选完、代码上线,真正的挑战才刚刚开始。以下这几个坑,每一个都是我亲眼在线上事故里看到过的,排雷顺序按频率从高到低。

6.1 锁粒度失控:把一个细粒度锁做成全局热点

这是我见过最容易犯的错,而且越资深越容易犯。最初写锁的时候,key 还知道加业务维度,比如inventory:sku:12345;后来为了图省事,或者为了“万无一失”,直接改成inventory:lock,美其名曰“全局锁”。结果所有商品的扣减全部串行化,流量稍微上去,Redis 或数据库的连接立刻被打满,吞吐量和单机锁没什么区别。

正确的做法是按资源维度拆锁:一个 sku 一把锁,不同的 sku 互不影响。如果业务还涉及多个资源,比如一次下单要扣多个 sku 的库存,那就需要对多个 key 加锁,同时要小心加锁顺序,避免多个节点持有不同 key 时相互等待形成分布式死锁。我的习惯是先把所有 key 排序,再按顺序加锁,释放时逆序释放。

还有个很隐蔽的粒度问题:同一个业务资源,在不同服务里用了不同 key。比如订单服务加锁用的 key 是order:12345,支付服务加锁用的 key 却是pay:12345,两边都在改同一笔订单的状态,可它们根本不是同一把锁,互斥彻底失效。这个问题的排查方式很简单,看监控里加锁 key 的分布,凡是相近业务多个 key 并存,就要追问为什么。

6.2 业务超时 VS 锁过期:续期与降级要双管齐下

锁过期时间设短了,业务没跑完锁没了;设长了,进程崩了之后其他节点等太久。这个矛盾没有一个“最优解”,只有“工程解法”。

先说你一定能用的优化手段:让锁只保护“状态判断与状态变更”那一段。比如库存扣减,不要在锁内执行读库存、扣减、写日志、发 MQ 一堆操作,可以把“预占库存”这一步用锁保护,库存扣减成功后就立刻释放锁,剩下的发消息、写快照放到锁外。锁的持有时间从几百毫秒降到几十毫秒,过期时间就不需要设得很大,风险自然降低。

再说兜底:如果确实有一段耗时不可控的操作必须在锁内执行,那就用 Redisson 的看门狗自动续期。但如果你们团队用的是自研锁、没有续期机制,那我的建议是在业务层做一个“锁内操作超时断言”——记录加锁时间,如果锁内操作超过设计上限,主动中止业务,同时把告警打出来,而不是傻等锁自然过期。

我线上最惨烈的一次事故,就是导出任务持有锁超过设计时长,另一个节点在锁过期后的瞬间也进来了,两台机器同时导出同一份报表。排查时才发现,导出逻辑里有对一个第三方服务的不设超时调用,第三方服务刚好在降级,直接把调用时间拖到了 25 秒,而锁过期时间是 10 秒。后来我把导出任务改成“任务表+队列”架构,锁只保护“领取任务”这一步,领取后立即释放锁,导出交给后台线程池慢慢跑,问题根除。

6.3 异步链路里锁的传递,比想象中容易丢

分布式锁的持有者信息经常存在ThreadLocal或线程上下文中,一旦代码走到异步链路,@Async线程池、CompletableFuture、MQ 消费者线程,上下文默认不会自动传递,锁对象可能拿不到,或者拿到一个“已经被别的线程释放”的错误状态。

我踩过的一个具体坑是这样的:加锁后业务抛给线程池执行,主线程在finally里直接执行了unlock(),然后异步线程还在处理业务,锁却已经没了,另一个请求直接进来,并发安全崩溃。修复思路很简单:如果你要在异步任务里继续持有锁,释放逻辑必须和异步任务绑定,在主线程里决不能提前释放;如果你只是在异步开始时需要锁保护一段初始化操作,那就把加锁和释放都挪到异步线程内部,让锁的生命周期完全包含在这段业务里。

排查这类问题的时候,最有效的工具不是看代码,而是看日志。加锁和解锁的日志必须打上同一个 requestId 和锁 key,一翻日志就能看到释放时间比业务结束时间早,问题就暴露了。

6.4 监控、告警与压测:给锁加上“仪表盘”

最后说一个很多团队都忽略的点:锁是要被监控的。

我建议至少采集这些指标:加锁成功次数、加锁失败次数、加锁等待耗时、锁持有时间分布、续期次数、释放锁失败次数。有了这些数据后,设定几个实用告警:加锁失败率突然升高,说明锁可能被某个节点长时间持有了;锁持有时间超过 P99 的几倍,说明锁内业务出现了异常慢路径;续期次数异常升高,说明业务执行时间持续逼近锁过期时间。

压测也不能省。我每次给新业务引入分布式锁,都会做一次“杀进程”演练:两个节点同时持锁演练、突然 kill 掉持有锁的节点、模拟 Redis 主从切换,确认锁在故障后的释放和重新获取行为符合预期。没做过这组演练的锁,永远只能活在单元测试里,不配称为“生产级锁”。

这些坑排查下来,你会发现一个共性:分布式锁本身不难,难的是你愿不愿意把它当成一个基础设施去设计、监控和维护。那些直接SETNX一把锁上线、从来不关注持有时间和故障窗口的系统,迟早会在一次高并发或一次依赖故障里给你还回来。

我个人现在的倾向很简单:能短则短,能细则细,能自动释放就不要手工释放。锁保护的范围越小,锁的过期时间越宽裕,锁的粒度越精致,整个系统的并发安全就越扎实。每次接入新的加锁场景,我都会先问自己一个问题:这把锁最长会被持有多久?如果回答超过 1 秒,我会先想怎么把这段业务拆出去,而不是急着改锁的过期时间。

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

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

立即咨询