mobile wallpaper 1
mobile wallpaper 2
mobile wallpaper 3
mobile wallpaper 4
4376 字
11 分钟
分布式锁详解:数据库与 Redis 两条路线的实现、坑点与选型
2026-09-01

秒杀开抢那一刻,库存 100 件的商品成交了 102 单。代码里明明加了 synchronized,为什么没挡住?

因为 synchronized 锁的是单个 JVM 的内存。服务部署成集群之后,每台机器各锁各的:实例 1 和实例 2 同时读到「库存还剩 1」,同时判断通过,同时扣减——超卖就这么发生了。进程内的并发控制是另一个话题(Python 的多线程甚至无法真正并行执行字节码,见 GIL 详解:Python 多线程为什么快不起来),这里要面对的是跨实例的问题。

分布式锁要解决的,就是把「同一时刻只有一个执行者」从单机内存搬到所有实例都认的共享存储上。但先给结论:电商里大部分看似需要分布式锁的场景,最优解其实不是锁,而是原子操作和唯一约束。这篇从数据库和 Redis 两条主流路线把实现讲透,再快速过一遍 ZooKeeper、etcd,最后落到选型决策和实践清单。

先立标准:一把合格的分布式锁要满足四条#

在比较方案之前,先定验收标准,后面每个方案都拿这四条对照:

  1. 互斥:同一时刻,全集群只有一个客户端持有锁。
  2. 超时释放:持锁者宕机,锁不能永远占着,否则就是死锁。
  3. 不误删:只能释放自己持有的锁,不能删掉别人的。
  4. 高可用:锁服务自己不能是单点,它挂了互斥语义也就没了。

听起来简单,但每条都有方案栽在上面。

数据库方案:零新组件的三种玩法#

电商系统本来就有 MySQL,用数据库实现锁不需要引入任何新组件,排查问题时链路也最短。按用法分三种。

InnoDB 是什么

InnoDB 是 MySQL 5.5 以来的默认存储引擎,本节三种玩法都建立在它的两个特性上:事务(一组操作要么全部生效,要么全部回滚)和行级锁(锁加在索引记录上,只挡住访问同一行的并发)。对照组是老引擎 MyISAM:只有表锁、不支持事务,如今基本只存在于历史系统里。「锁加在索引记录上」这一点后面还会用到——FOR UPDATE 走不到索引时锁范围扩大,根源就在这里。

唯一索引:insert 成功才算拿到锁#

建一张锁表,抢锁就是插入一行:

CREATE TABLE distribute_lock (
lock_key VARCHAR(128) NOT NULL PRIMARY KEY,
holder VARCHAR(64) NOT NULL,
expire_at DATETIME NOT NULL
);
-- 抢锁:插入成功 = 拿到锁;报 Duplicate entry 就是没抢到
INSERT INTO distribute_lock (lock_key, holder, expire_at)
VALUES ('seckill:1001', 'instance-3', NOW() + INTERVAL 30 SECOND);
-- 释放:带上 holder 条件,防止删掉别人重新抢到的锁
DELETE FROM distribute_lock
WHERE lock_key = 'seckill:1001' AND holder = 'instance-3';

PRIMARY KEY 的唯一性由 InnoDB 保证,并发 insert 同一个 lock_key 只有一个能成功,互斥性是天生的。

但它对照四条标准,缺口明显:

  • 超时释放没有。持锁实例宕机,行就一直留在表里,得靠定时任务扫 expire_at 清理,清理间隔就是死锁的最长持续时间。
  • 非阻塞。没抢到只是收到一个 duplicate key 报错,想等待就得应用层自己写重试循环。
  • 不可重入。同一个实例再次 insert 只会再报一次错,得自己在应用层记录持有状态。

所以这张锁表实际很少用。真正常见的是它的变种——业务唯一约束

  • 防重复下单:订单表给 user_id + idempotency_key 建唯一索引,用户双击、前端重试、网络重传,最多落一单。
  • 防重复领券:user_coupon 表对 (user_id, coupon_id) 建唯一索引,一个用户一张券就是一行。

这已经不太像「锁」,更像幂等设计。但它才是唯一索引在电商里的主力形态:与其抢到锁再保证只做一次,不如让「重复」在数据库层根本插不进去。

悲观锁:SELECT … FOR UPDATE 把行锁住#

BEGIN;
-- 锁住这一行,其他事务的 FOR UPDATE 会阻塞在这里
SELECT stock FROM sku_stock WHERE sku_id = 1001 FOR UPDATE;
-- 事务内完成判断和扣减
UPDATE sku_stock SET stock = stock - 1 WHERE sku_id = 1001;
COMMIT;

这是最「像锁」的用法:读出来、判断、扣减,三步都在行锁保护下,其他事务进来就排队。账户余额、订单状态推进这种不能错的场景,它至今是稳妥的选择。

三个使用前提:

  1. 必须在事务里,事务提交锁才释放。
  2. WHERE 必须命中索引。InnoDB 锁的是索引记录,走不到索引时会锁住扫描过的所有行(RR 隔离级别下还附带间隙锁),表现上接近锁全表,并发直接塌掉。
  3. 事务要短。持锁期间做一次远程调用,就是把这条数据的并发上限交给了对端的响应时间。

硬伤也很直白:锁的时长 = 事务时长 = 数据库连接被占用的时长。秒杀这种热点行场景,所有请求在同一行上串行排队,连接池很快被打满。它保护得了正确性,扛不住热点。

乐观锁与原子 UPDATE:很多时候根本不需要锁#

乐观锁的标准做法是 version 字段:

-- 读的时候记下 version
SELECT stock, version FROM sku_stock WHERE sku_id = 1001;
-- 更新时比对 version,相等才更新并 +1
UPDATE sku_stock SET stock = stock - 1, version = version + 1
WHERE sku_id = 1001 AND version = 3;

affected rows 是 1 就成功,是 0 说明期间被别人改过了,应用层重试。

但对扣库存这个具体场景,连 version 都可以省掉:

UPDATE sku_stock SET stock = stock - 1
WHERE sku_id = 1001 AND stock > 0;
-- affected rows = 1 扣减成功,= 0 库存不足

单行 UPDATE 的原子性由 InnoDB 行锁在内部保证,stock > 0 的判断和扣减之间不会被并发插入。一行 SQL,没有锁表、没有事务管理、没有重试逻辑,就把超卖问题解决了。

这就是为什么开头说「最优解往往不是锁」:锁是协调手段,原子性才是正确性手段。能用一条原子 SQL 表达的不变量,就不该上升到分布式锁。

当然,单机 MySQL 扛不住秒杀级别的热点行更新压力,高并发场景会把库存前置到 Redis——这就引出了第二条路线。

Redis 方案:从 SETNX 到 Redisson 的五次进化#

Redis 做锁的逻辑很直白:单线程执行命令、内存操作、所有实例连同一个 Redis,天然是共享存储。但「能跑」和「能上线」之间隔着好几层坑,按进化的顺序过一遍。

加锁必须原子:SET key value NX EX#

最早的写法是两条命令:

SETNX lock:seckill:1001 1 # 不存在才设置成功
EXPIRE lock:seckill:1001 30 # 再补一个过期时间

问题:两条命令之间客户端宕机,锁永远没有过期时间——死锁,直接违反「超时释放」。

Redis 2.6.12 起 SET 支持扩展参数,一条命令原子完成:

SET lock:seckill:1001 7f3a9c2d NX EX 30

NX 不存在才设置(互斥),EX 30 带上过期时间(防死锁)。这是加锁的最低正确形态。

释放必须原子:唯一 value + Lua#

注意上面 value 不是随便填的 1,而是一个唯一标识(UUID 或实例 ID)。它要解决的是「误删」:

  1. 实例 A 拿到锁,业务执行超过 30 秒,锁自动过期。
  2. 实例 B 抢到同一把锁,开始执行。
  3. A 终于执行完,调用 DEL——删掉的是 B 的锁。
  4. 实例 C 趁机也抢到锁,互斥失效。

修法是释放前先确认「锁还是我的」,且比对和删除必须原子:否则比对完、删除前锁又过期被别人抢走,老问题换个时间点重演。DEL 没有条件删除的参数,所以用 Lua 把两步焊在一起(Redis 保证脚本整体原子执行):

-- KEYS[1] = 锁的 key, ARGV[1] = 加锁时写入的唯一标识
if redis.call('get', KEYS[1]) == ARGV[1] then
return redis.call('del', KEYS[1])
else
return 0
end

到这里,一个「最小正确」的 Redis 锁成型了:SET k uuid NX EX 30 加锁,Lua 脚本解锁。自己手写客户端,封装到这个程度才算及格。

业务没跑完,锁先过期了:Redisson 看门狗#

最小版本还剩一个根本矛盾:过期时间设多长?设短了,业务没执行完锁就放走;设长了,持锁者宕机后其他实例干等。

Redisson(Java 生态最常用的 Redis 客户端)的答案是看门狗(watchdog):加锁默认给 30 秒,后台线程每 10 秒(过期时间的 1/3)检查一次——只要实例还活着、锁还被持有,就续回 30 秒。实例宕机,续期线程跟着死,锁最多残留 30 秒自动释放。

什么是租约时间

租约时间(lease time)是锁的持有期限:到期没续上,锁就自动释放,「超时释放」这条标准就是靠它实现的。Redis 锁的 EX 30、Redisson 的 leaseTime 参数、后面会提到的 ZooKeeper 会话和 etcd lease,本质都是租约。难点在于设多长:太短误伤正常业务,太长宕机后恢复慢。看门狗的思路是不设固定租约,改成活着就自动续。

RLock lock = redisson.getLock("lock:seckill:" + skuId);
try {
// 最多等待 3 秒抢锁;不传租约时间,持有时长交给看门狗
if (!lock.tryLock(3, TimeUnit.SECONDS)) {
return Result.fail("当前抢购人数过多,请稍后再试");
}
// 拿到锁后的业务:Lua 预减库存、写订单消息……
} catch (InterruptedException e) {
Thread.currentThread().interrupt();
} finally {
if (lock.isHeldByCurrentThread()) {
lock.unlock();
}
}

生产环境建议直接上 Redisson 而不是手撸 Lua:看门狗、可重入计数、阻塞等待、锁重试,这些边界它都处理了。

传了 leaseTime,看门狗就没了

tryLock(3, 10, TimeUnit.SECONDS) 这种带租约时间的重载会关闭看门狗:10 秒一到锁准时过期,业务没跑完也不管。只有在业务耗时有硬上限、并且这个上限远小于租约时才这么用;估不准就别传。

看门狗不是免死金牌

看门狗续的是「实例活着」,续不了「实例被卡住」。一次几十秒的 Full GC、一次网络分区,都能让业务线程在锁早已过期后继续执行。锁过期和业务完成之间没有硬保证,这是 Redis 路线要认的账。

主从切换丢锁:Redis 路线的先天缺陷#

生产 Redis 必然主从 + 哨兵,这带来最后一个坑:主从复制是异步的。

sequenceDiagram participant A as 实例 A participant B as 实例 B participant M as Master participant S as Slave A->>M: SET lock:sku NX EX 30(拿到锁) Note over M: 锁 key 还没异步复制到 Slave Note over M: Master 宕机 Note over S: Slave 升主,数据里没有这把锁 B->>S: SET lock:sku NX EX 30(也拿到锁) Note over A,B: 两个实例同时持有同一把锁,互斥失效

这不是配置问题。Redis 的 WAIT 命令能等到至少 N 个副本确认、收窄窗口,但消除不了——只要复制是异步的,「锁写入」和「锁可见」之间就永远有缝。

Redlock:多节点多数派,以及那场著名争论#

antirez(Redis 作者)给出的官方补丁是 Redlock:部署奇数个(通常 5 个)互相独立、不做主从的 Redis 节点,客户端依次向所有节点加锁,拿到过半(≥3)才算成功;锁的有效时间 = TTL 减去加锁总耗时,超时就放弃并到所有节点解锁。思路是躲开异步复制窗口:单个节点宕机不影响多数派。

2016 年 Martin Kleppmann(《Designing Data-Intensive Applications》作者)发文《How to do distributed locking》公开质疑:Redlock 的安全性依赖三个假设——时钟不跳变、网络延迟有界、进程不被 GC 长时间停顿,而这三个假设在生产环境都会破。antirez 随后撰文《Is Redlock safe?》反驳。这场争论没有公认赢家,但留下了两个有价值的共识:

  1. 业务只是追求效率(比如定时任务防重,锁失效最多多跑一次),Redlock 乃至单节点 Redis 都够用。
  2. 锁失效意味着资金错误,任何基于超时假设的锁都不够,需要 fencing token:拿锁时同时发一个单调递增的序号(Redis 里用 INCR 就能生成),下游资源拒绝执行序号更旧的请求。这样即使锁过期、两个客户端同时持锁,持旧序号的写也会被拒。

Kleppmann 的观点其实更激进:很多需要 fencing 的场景,用数据库的乐观锁和事务实现更便宜。这和前面「原子性优于锁」是同一个道理。

其他方案速览:ZooKeeper 与 etcd#

这两条路线实现思路相近,知道什么时候该想到它们就够了。

ZooKeeper:抢锁 = 在锁目录下创建临时顺序节点,序号最小者持锁;没抢到的 watch 自己的前驱节点,前驱被删时才唤醒,避免所有等待者同时惊醒的羊群效应。关键优势是会话即锁:客户端宕机或网络分区导致会话过期,临时节点被服务端自动删除——「持锁者死亡」由服务端心跳判定,不依赖客户端自觉续期。Java 里直接用 Curator 的 InterProcessMutex,可重入、阻塞等待都是现成的。代价是重:写入要走 ZAB 共识协议,延迟和吞吐都比不上单机内存的 Redis,为了一把锁单独部署一套 ZK 集群不划算;系统里本来就有(老 Kafka、Dubbo 架构)才顺手用。

etcd:思路类似,租约(lease)+ keepalive 实现会话即锁,写 key 时绑定租约,续租失败 key 自动消失。额外送一个福利:每次写入产生全局递增的 revision,天然就是 fencing token,这是 etcd 相对 Redis 的硬优势。已经在 K8s 生态里的团队几乎零成本接入。

电商场景怎么选#

回到业务。把常见场景摊开,答案基本是一一对应的:

场景推荐做法为什么
秒杀扣库存Redis Lua 预减 + 消息队列削峰落库热点行扛不住 DB 行锁,Lua 原子扣减不需要锁
普通下单扣库存原子 UPDATE(stock > 0一行 SQL 的事,不上锁
防重复下单 / 防重复领券业务表唯一索引约束是最强保证,比锁可靠
账户余额、资金变动数据库悲观锁,或乐观锁 + 重试宁可慢、宁可失败重试,不能错
分布式定时任务防重(订单超时关闭、对账)Redis SET NX EX,或 Redisson锁失效最多多跑一次,业务幂等兜住就行
跨实例互斥执行批量任务Redisson 看门狗需要真正的阻塞互斥和自动续期
已有 ZK / etcd 基础设施Curator / etcd lease不引入新组件,会话即锁更省心
锁失效 = 资金事故不只用锁:fencing token + 数据库约束双保险超时假设挡不住 GC 和时钟跳变

秒杀场景的标准姿势值得单独写一下,它不用任何「锁」:

-- 预减库存:KEYS[1] = seckill:stock:1001, ARGV[1] = 购买数量
local stock = tonumber(redis.call('get', KEYS[1]) or '-1')
if stock < tonumber(ARGV[1]) then
return 0 -- 库存不足,直接拒
end
return redis.call('decrby', KEYS[1], ARGV[1])

活动开始前把库存从 DB 加载进 Redis,抢的时候 Lua 原子扣减,扣成功的丢进消息队列异步落库。整个链路没有显式的锁,靠的是 Redis 单线程执行脚本的原子性。

还没有把握时,走这张决策图:

graph TD A[需要跨实例互斥] --> B{"能用原子操作或唯一约束表达吗?<br/>(原子 UPDATE / 唯一索引 / Lua)"} B -->|能| C[不要上锁<br/>原子性才是正确性] B -->|不能| D{"锁意外失效一次<br/>代价是什么?"} D -->|最多重复执行一次<br/>业务幂等能兜住| E[Redis:SET NX EX<br/>生产直接用 Redisson 看门狗] D -->|资金级错误| F[数据库悲观锁 / 乐观锁<br/>或锁 + fencing token 双保险] E --> G{"系统里已有 ZK / etcd?"} G -->|有| H[改用 Curator / etcd lease<br/>少维护一个假设]

实践清单#

不管选了哪条路线,这些坑是共通的:

  1. 锁的 key 粒度尽量细。锁 lock:seckill:{skuId} 而不是 lock:seckill,锁单个账户而不是账户表。粒度每细一级,并发上限高一截,不同商品、不同用户之间互不阻塞。
  2. 超时时间永远要设,且按 P99 业务耗时估。估不准就用看门狗续期,不要拍一个巨大的值——那是拿宕机后的恢复时间换安全感。
  3. 锁是优化,不是正确性。锁挡住的冲突,最终都要靠数据库约束(唯一索引、原子 UPDATE、CHECK stock >= 0)兜底。锁的作用是减少冲突和重试的成本,不是保证「绝对不发生」。按「锁可能失效」设计业务,锁失效就只是性能事件,而不是资损事故。
  4. 可重入想清楚。同一个线程在链路内重复进入持锁区,要自己计数或用 Redisson 的 RLock(默认可重入),否则自己把自己锁死。
  5. 持锁期间别调外部接口。锁时长 = 业务耗时 + 所有下游的耗时,把第三方接口的抖动引进锁里,等于把并发上限交给别人。
  6. 释放放在 finally,先 isHeldByCurrentThread() 再 unlock,别让 unlock 的异常盖住业务异常。
  7. 给锁配监控:加锁失败率、平均持锁时长、看门狗续期失败。持锁时长缓慢上涨,往往比报错更早暴露问题。

收个尾:选型的顺序是先问「能不能不用锁」,再问「锁丢了会怎样」。大部分电商场景停在前两格——原子 SQL 和唯一索引;真正需要分布式锁的地方,Redis + 看门狗是性价比最高的答案;而资金场景值得为数据库锁和 fencing token 多付一点成本。

分享

如果这篇文章对你有帮助,欢迎分享给更多人!

分布式锁详解:数据库与 Redis 两条路线的实现、坑点与选型
https://l1ngg.info/posts/tech/distributed-lock/
作者
L1ngg
发布于
2026-09-01
许可协议
CC BY-NC-SA 4.0

部分信息可能已经过时

目录