synchronized 和 ReentrantLock 只能管住单个 JVM 进程内的线程,服务一旦多实例部署,跨进程互斥就必须把锁状态外置到共享存储。本文从单机锁失效的根源讲起,依次拆解数据库的三种实现、Redis 原子加锁与 Lua 安全释放,以及锁续期难题和 Redisson 看门狗的工作机制。

它解决什么问题

先看单机锁为什么不够。synchronized 依赖对象头里的 Mark Word,ReentrantLock 依赖 AQS 的内存状态,两者的锁信息都只存在于当前进程的堆内。订单服务部署三个实例后,每个实例的本地锁各管各的:实例 A 里的线程拿到了锁,实例 B 里的线程对此毫无感知,照样大摇大摆进入临界区。

后果用秒杀举例最直观。库存扣减是典型的 check-then-act:先读库存判断是否大于零,再扣减并写订单。两个请求分属不同实例,几乎同时读到”还剩 1 件”,都通过校验,都执行扣减,于是只有 1 件库存却成交了两单,超卖发生。这里有个细节经常被忽略:要保护的不是”查询库存”这一步,而是”校验 → 扣减 → 写订单”这一整段临界区。只锁查询、不锁后续写入,并发错误照样出现。

除了共享数据竞争,还有一类典型需求是任务互斥:定时任务在多个实例上各有一份调度,不加锁就会重复执行同批数据的结算、推送。两类需求的共同点——同一时刻、同一资源,只能有一个执行者,而”同一时刻”的判定必须跨进程成立。

核心原理

本地锁把互斥状态放在进程内存里,分布式锁的思路就是把这个状态挪到所有进程都能访问的外部系统中,并借助该系统提供的原子操作来表达”先到先得”。候选存储有数据库、Redis、ZooKeeper、etcd,本文重点讲前两类。

一把合格的分布式锁至少要满足三个基本条件:

  • 互斥:同一个 lock key 在任何时刻只有一个有效持有者。key 要按资源粒度设计,如 stock:{skuId},而不是一把全局大锁罩住所有商品。
  • 防死锁:持锁进程崩溃后,锁必须能被回收。手段是过期时间(TTL)或会话租约,否则一次宕机就永久阻塞所有竞争者。
  • 安全释放:只能释放自己持有的锁。通用做法是加锁时写入一个随机 token 作为身份,释放时先校验身份再删除。

在此之上还有几项进阶语义:可重入(同一线程重复进入不自我死锁)、明确的获取语义(阻塞等待、限时等待或立即失败,生产中绝不允许无限等待)、续约机制(业务没执行完时延长 TTL),以及金融级场景需要的 Fencing Token——每次加锁附带一个单调递增的令牌,下游资源只接受更大令牌的写入,用来拦截锁过期后旧持有者的迟到写。

深入机制

数据库实现一:唯一索引

建一张锁表,对锁名建唯一索引。加锁就是插入一行,插入成功即持锁;释放就是删除该行;插入撞唯一约束说明锁被别人持有:

CREATE TABLE `distributed_lock` (
`id` BIGINT UNSIGNED NOT NULL AUTO_INCREMENT,
`lock_name` VARCHAR(64) NOT NULL,
`owner` VARCHAR(64) NOT NULL,
`expire_at` BIGINT NOT NULL,
PRIMARY KEY (`id`),
UNIQUE KEY `uk_lock_name` (`lock_name`)
) ENGINE=InnoDB;
INSERT INTO distributed_lock (lock_name, owner, expire_at)
VALUES ('stock:1001', 'instance-a-uuid', 1719734400000);

这个方案零额外组件,但短板明显:持锁者崩溃后行记录残留,要靠 expire_at 字段加定时任务清理,而清理逻辑本身又要处理时钟一致性和并发抢占;没有阻塞等待语义,抢锁失败只能自旋重试;也不天然支持可重入。它适合并发低、已有强数据库依赖、不想引入新组件的系统。

数据库实现二:悲观锁 for update

在事务里用 SELECT ... FOR UPDATE 锁住代表资源的行,业务操作全部做完后提交事务,行锁随之释放:

BEGIN;
SELECT * FROM stock WHERE sku_id = 1001 FOR UPDATE;
-- 校验库存、扣减、写订单
UPDATE stock SET count = count - 1 WHERE sku_id = 1001;
COMMIT;

互斥由 InnoDB 行锁保证,语义正确。问题在于成本:锁的整个生命周期都占用一条数据库连接,临界区稍长就会拖垮连接池;锁等待受 innodb_lock_wait_timeout(默认 50 秒)控制,超时语义和业务期望往往对不上;同样不支持可重入。并发稍高的场景不宜采用。

数据库实现三:乐观锁 version

严格说这不是锁,而是冲突检测:不给资源加任何锁,更新时校验版本号没被别人动过:

UPDATE stock
SET count = count - 1, version = version + 1
WHERE sku_id = 1001 AND version = 37;

受影响行数为 1 说明成功,为 0 说明期间有人改过,业务侧重试。乐观锁吞吐高、无死锁,适合冲突频率低的场景;冲突激烈时大量请求反复失败重试,反而不如互斥。把它列在这里是为了强调一个观点:很多以为需要分布式锁的场景,用条件更新、唯一约束或乐观锁就能解决,而且更快、更简单。

Redis:SET NX PX 的原子性

Redis 方案的加锁只有一条命令:

SET lock:stock:1001 7f3a9c2e-uuid PX 30000 NX

NX 保证 key 不存在才能写入(互斥),PX 设置毫秒级过期时间(防死锁),value 写入随机 UUID 作为持有者身份。必须强调演进史:早期写法是先 SETNX 再 EXPIRE,两步之间客户端一旦崩溃,key 会永久存在造成死锁。Redis 2.6.12 起 SET 支持 NX 与过期时间组合成单条原子命令,这是唯一正确的姿势。

释放锁为什么要 Lua

朴素释放是 GET 比对 value 再 DEL,但这两步之间锁可能恰好过期并被别的客户端获取,随后你的 DEL 删掉了别人的锁。比对和删除必须合成一个原子操作,Lua 脚本就是干这个的:

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

Redis 对单个 Lua 脚本的执行是原子的,脚本运行期间不会插入其他命令,校验与删除之间不存在窗口。

锁续期与看门狗

TTL 是防死锁的手段,却引入了新矛盾:业务执行时间超过 TTL,锁提前过期,另一个客户端趁虚而入,出现两个持有者。TTL 拍脑袋设很长,则持锁者宕机后大家要干等到过期,可用性变差。两难的标准解法是续约——只要业务还在跑,就周期性把 TTL 往后推。

Redisson 把这个机制做成了看门狗(Watch Dog):不显式指定锁时长时,锁默认 TTL 30 秒,后台线程每 10 秒(即租期的三分之一)检查一次,只要持锁线程还活着就把过期时间重置回 30 秒。续期同样走 Lua 脚本保证原子性:

// Redisson 续期脚本的核心逻辑(简化)
// 确认自己仍是持有者,才允许延长过期时间
"if (redis.call('hexists', KEYS[1], ARGV[2]) == 1) then " +
" redis.call('pexpire', KEYS[1], ARGV[1]); " +
" return 1; " +
"end; " +
"return 0;"

设计上有一个自洽之处:进程真死,看门狗作为进程内线程一同消亡,续约停止,锁在 30 秒内自然过期——不会出现”续约让死锁变永久”的问题。看门狗的软肋是进程暂停而非死亡:长时间 Full GC 让续约线程和业务线程一起冻结,锁过期后被他人获取,GC 结束后旧持有者”复活”继续写。这类窗口靠续约堵不住,需要 Fencing Token 在资源端兜底。

Redisson 封装的价值

手写 Redis 锁要处理原子加锁、Lua 释放、续约、重入计数、阻塞等待、异常兜底,每一块都有坑。Redisson 的价值在于把这些细节全部收进经过大量生产验证的实现里:锁用 hash 结构存储,field 是 UUID:threadId、value 是重入次数,天然支持可重入;抢锁失败不是自旋空转,而是订阅 channel 等待释放通知,配合信号量限时阻塞,CPU 友好;解锁后发布消息唤醒等待者。业务侧只剩两行:

RLock lock = redisson.getLock("lock:stock:1001");
lock.lock(); // 不传时长,启用看门狗续期
try {
// 临界区
} finally {
lock.unlock();
}

注意 lock.lock(10, TimeUnit.SECONDS) 这种显式传时长的写法会关闭看门狗,选哪种要心里有数。

工程实践

  • 锁粒度贴着资源走:stock:{skuId}、order:{orderId},避免一把锁罩住整个活动让无关商品也排队。
  • TTL 按临界区 P99 耗时乘以安全系数设定,但更优先的动作是压缩临界区:锁内不放 RPC、不跑长 SQL。
  • 获取失败要有明确策略:快速失败返回,或有限次数重试加随机退避,别让抢锁线程无限堆积拖垮线程池。
  • 先问能不能不用锁:库存扣减用 UPDATE ... WHERE count > 0 条件更新,用户限购买唯一索引,重复提交用幂等键,订单流水用 MQ 单分区串行消费——这些方案往往比锁更快更稳。
  • 时刻记住锁不等于事务:锁只保证互斥进入临界区,不保证数据库提交成功,跨库一致性仍要靠本地事务、幂等和补偿。

对比与权衡

方案 互斥可靠性 吞吐 阻塞等待 可重入 防死锁手段 运维成本
DB 唯一索引 高 低 需自旋 需自实现 expire 字段+清理任务 零新增组件
DB for update 高 低(占连接) 数据库自带 不支持 事务结束/超时 零新增组件
DB 乐观 version 冲突检测非互斥 高 不适用 不适用 无需释放 最低
Redis SET NX PX 较高(主从切换有窗口) 高 需自实现或用 Redisson Redisson 支持 TTL+看门狗 需 Redis
ZK/etcd 高(会话语义) 中低 原生支持 Curator 支持 临时节点/租约 需协调组件

面试视角

为什么 SETNX 加 EXPIRE 不行? 两条命令不是原子操作,客户端在 SETNX 成功、EXPIRE 执行前崩溃,会留下一个没有过期时间的永久锁,其他竞争者永远进不来。正确姿势是 Redis 2.6.12 起的 SET key value NX PX ttl 单命令原子完成。

释放锁为什么要用 Lua 脚本? 释放前要校验 value 确认是自己的锁,但 GET 和 DEL 之间存在窗口:校验通过后锁恰好过期被他人获取,随后的 DEL 就误删了新持有者的锁。Lua 脚本在 Redis 中原子执行,把校验与删除合成不可分割的一步。

锁过期了业务还没执行完怎么办? 三个层次:TTL 按 P99 耗时设置并留余量;用 Redisson 看门狗在业务存活期间自动续约;续约堵不住进程暂停类窗口,正确性要求极高时引入 Fencing Token,让下游资源拒绝旧令牌的写入。

可重入分布式锁怎么实现? Redisson 用 hash 存锁:field 为客户端 UUID 拼接线程 ID,value 为重入计数,同一线程再次加锁只需把计数加一,释放时减到零才真正删 key。注意这只覆盖同 JVM 同线程,跨进程的业务级”重入”要靠应用层幂等设计。

Redis 主从架构下锁会丢吗? 会。主从复制是异步的,主节点写入锁后尚未同步到从节点就宕机,故障转移提升的新主没有这条锁记录,其他客户端可重复加锁。这是单节点 Redis 锁的固有窗口,敏感场景要配合幂等兜底或换协调服务。

小结

  • 单机锁的锁状态在进程堆内,跨进程天然失效;分布式锁的本质是把互斥状态外置到共享存储并借助其原子操作。
  • 数据库三选一:低并发用唯一索引或 for update 图省事,冲突少用乐观 version 换吞吐,但它们都只是特定场景的最小可行解。
  • Redis 方案的两个原子性红线:加锁用 SET NX PX 单命令,释放用 Lua 先校验身份再删除。
  • TTL 与业务耗时的矛盾靠看门狗续期缓解,进程暂停类窗口则需 Fencing Token 在资源端兜底。
  • 上手优先级:能用条件更新、唯一索引、幂等解决的别用锁;必须用锁时直接上 Redisson 这类成熟封装,不要手写。