来源2024年5月第4题
问题一
分值(9分)
基于数据库实现分布式锁的缺点。
基于数据库实现分布式锁常见的实现方式主要有以下两种:
- 利用唯一索引插入
基本原理是 唯一性约束充当"互斥机制",确保同一时间只有一个客户端能获得锁。
- 创建一张锁表,如 distributed_locks (lock_key VARCHAR PRIMARY KEY, owner_id VARCHAR, expire_time DATETIME)
- 某客户端尝试通过插入一条唯一的 lock_key 记录来加锁:
bash
INSERT INTO distributed_locks (lock_key, owner_id, expire_time)
VALUES ('my_lock', 'instance_1', NOW() + INTERVAL 10 SECOND);
-
如果插入成功,则加锁成功;如果因主键冲突插入失败,说明锁已被持有。
-
由 owner_id 判断是否是当前客户端持有锁,只有持有者才可删除:
bash
DELETE FROM distributed_locks WHERE lock_key = 'my_lock' AND owner_id = 'instance_1';
- 利用数据库的 SELECT ... FOR UPDATE 加悲观锁
-
基本原理是通过数据库行锁实现互斥。
-
在锁表中先插入一条标志性记录。
使用事务和 SELECT ... FOR UPDATE 对该记录加锁。
bash
BEGIN;
SELECT * FROM distributed_locks WHERE lock_key = 'my_lock' FOR UPDATE;
-- 执行临界区代码
COMMIT;
这种方法的主要的问题是
竞争激烈时插入失败频繁。在高并发场景中,大量节点同时尝试 INSERT 锁记录会频繁触发主键冲突,导致性能下降,数据库压力剧增。
锁重入难实现。默认情况下无法支持可重入(同一线程/服务可多次加锁),需要额外存储 owner_id 和 lock_count 等字段,并设计相应逻辑,增加实现难度。
数据库成为瓶颈。分布式锁操作本质是对数据库的高频写入和查询操作,容易将数据库打满,拖慢主业务性能。
这两种方式虽然直观,但在高并发、高可用的生产环境中存在显著缺陷。其核心问题在于数据库是为数据持久化设计的,而非高频率的锁状态协调。
两种实现方式简述
- 基于唯一索引插入
- 原理 :创建一张锁表,通过
资源名(如method_name)的唯一索引来实现。加锁时,尝试插入一条代表该资源的记录,成功则获锁;释放锁时则删除该记录。
- 原理 :创建一张锁表,通过
- 基于
SELECT ... FOR UPDATE悲观锁- 原理 :同样有一张锁表。加锁时,查询目标资源记录并使用
FOR UPDATE对其加上行锁,成功则获锁;释放锁时提交事务即可。
- 原理 :同样有一张锁表。加锁时,查询目标资源记录并使用
基于数据库实现分布式锁的共性缺点
无论采用上述哪种方式,都会面临以下一个或多个核心问题:
1. 性能与可伸缩性差
- 数据库压力大:每次加锁、释放锁都是一次或多次数据库事务操作(INSERT/DELETE 或 SELECT FOR UPDATE)。在高并发场景下,大量锁竞争会给数据库带来巨大的IO和CPU压力,使其成为系统瓶颈。
- 响应延迟高:与基于内存的Redis等方案相比,数据库操作的网络和磁盘IO延迟要高出一个数量级,严重影响业务的响应速度。
2. 可靠性风险与单点故障
- 单点问题:如果使用单机数据库,一旦数据库宕机,整个分布式锁服务将完全不可用,导致系统崩溃。
- 主从切换数据丢失:为解决单点问题而采用数据库主从复制时,在主从切换的瞬间,可能因数据同步延迟导致锁状态丢失。例如,客户端在主库上加锁成功,但锁记录还未同步到从库,此时主库宕机,从库升级为新主库,但新主库上没有这条锁记录,导致另一个客户端也能成功加锁,破坏了互斥性。
3. 死锁与锁清理难题
- 客户端崩溃导致死锁 :这是最致命的问题之一。如果客户端在持有锁期间崩溃,无法正常执行
DELETE或提交事务来释放锁,那么这个锁将被永久占用,其他客户端永远无法获得该锁。 - 需要复杂的清理机制 :为解决上述问题,必须引入超时机制 。这需要在锁记录中增加一个
过期时间字段,并配套一个独立的定时任务来扫描和清理过期的锁。这大大增加了系统的复杂度和维护成本。
4. 锁的公平性与"惊群效应"
- 非公平锁 :当锁被释放时,所有等待的客户端会同时发起下一轮抢锁请求(例如,不断重试INSERT),这会导致数据库在瞬间承受巨大的压力,即"惊群效应"。并且无法保证等待时间最长的客户端能优先获得锁。
5. 实现复杂性高(针对特定场景)
- 可重入性实现困难:可重入锁是指同一个线程可以多次获取同一把锁。在数据库层面实现这一点非常复杂,需要在表中记录持有者信息和重入计数,并在获取和释放时进行判断,完全失去了其简单的初衷。
- 阻塞等待实现低效:实现阻塞等待通常需要应用层通过"循环重试 + 睡眠"来模拟,这既低效又消耗资源。
两种方式的特有缺点
- 对唯一索引插入方式 :无法简单实现锁的自动超时释放,除非额外增加时间字段和定时任务。在并发插入时,大量冲突会导致大量的数据库异常,需要应用层捕获并处理。
- 对
SELECT ... FOR UPDATE方式 :严重依赖数据库的行级锁 。如果表设计或查询不当,可能升级为表锁,造成灾难性后果。同时,它必须在一个数据库事务中完成,长时间持有事务连接会耗尽数据库连接池。
总结
数据库实现分布式锁的核心缺陷在于:它将一个需要高性能、低延迟、高可用的协调服务,强加在了一个为持久化存储设计的关系型数据库上。
因此,它通常只适用于:
- 并发量极低的简单场景。
- 已经重度依赖数据库且不允许引入新组件的遗留系统。
- 作为学习原型或演示。
对于现代分布式系统,Redis (通过SETNX + Lua脚本)或 ZooKeeper/etcd(通过临时有序节点)是实现生产级分布式锁的更优选择,它们在设计之初就充分考虑了对这些问题的解决方案。
问题二
分值(10分)
举一个产生Redis分布式锁死锁的场景。
Redis 分布式锁本身通过「超时自动释放」机制(设置 expire 时间)降低了死锁风险,但在 锁超时、锁释放逻辑异常、主从切换 等场景下,仍可能出现「伪死锁」或「真死锁」(虽 Redis 无传统数据库的死锁检测,但会导致资源长期无法释放)。以下是一个 最典型、生产环境高频出现的死锁场景:
死锁场景:真死锁,业务阻塞
死锁的本质是
锁未及时释放 + 锁还未自动过期
⇒ 所有其他节点都被"锁"住了,无法进入临界区 ⇒ 死锁
系统使用 Redis 实现分布式锁,每个节点在执行关键业务逻辑前通过 SET key value NX PX 加锁。
加锁成功后执行临界区操作,最后显式调用 DEL key 解锁。
服务 A 加锁成功,执行如下命令:
- 服务 A 加锁成功,执行如下命令:
bash
SET lock:order:123 "nodeA-uuid" NX PX 30000
表示锁住订单 123,锁有效期为 30 秒。
-
服务 A 开始执行下单逻辑。
-
执行过程中服务 A 崩溃(进程退出、机器重启、OOM 等)。
-
因为服务 A 没来得及执行 DEL 解锁命令,Redis 上的锁 lock:order:123 仍然存在。
-
此时锁还没过期(30 秒未到),其他服务节点(如服务 B)尝试加锁失败,始终得不到锁,业务阻塞。
死锁场景:锁超时 + 业务执行超时 + 错误释放他人锁
场景前提
- 业务:秒杀系统的库存扣减,用 Redis 分布式锁保证同一商品不会超卖(锁 key 为
lock:goods:1001,对应商品 ID=1001); - 锁配置:设置锁超时时间
30 秒(认为业务最多 30 秒完成),采用「非原子操作」释放锁(先判断是否自己的锁,再删除); - 并发情况:节点 A、节点 B 同时竞争该锁。
场景流程(一步步触发死锁)
-
节点 A 成功加锁 :
节点 A 执行
SET lock:goods:1001 "nodeA:thread123" EX 30 NX,成功获取锁,开始执行库存扣减业务(如查询库存、调用支付接口、写入订单表)。 -
节点 A 业务执行超时 :
因支付接口响应缓慢(或网络波动、服务器 CPU 飙升),节点 A 的业务执行了
35 秒,超过了锁的超时时间30 秒。此时 Redis 会自动释放该锁(删除lock:goods:1001键)。 -
节点 B 抢占释放的锁 :
节点 B 一直等待锁,Redis 释放节点 A 的锁后,节点 B 立即执行
SET命令成功获取锁,开始执行自己的库存扣减业务。 -
节点 A 错误释放节点 B 的锁 :
节点 A 的业务终于执行完成,开始执行「释放锁」逻辑。但此时节点 A 持有的锁已被 Redis 超时释放,且锁已被节点 B 抢占。
若释放锁时未做「原子校验」(仅通过
GET命令判断锁值是否为自己的标识,再执行DEL),会出现以下问题:java// 节点 A 的错误释放锁逻辑(非原子操作) String lockValue = redis.get("lock:goods:1001"); if ("nodeA:thread123".equals(lockValue)) { // 此时锁值已变成 "nodeB:thread456",判断失败?不! // 关键问题:GET 和 DEL 之间存在时间差,可能出现「幻读」 // 假设节点 A 执行 GET 时,节点 B 的锁刚好超时释放(极端情况),或判断逻辑被绕过 redis.del("lock:goods:1001"); // 错误删除了节点 B 持有的锁! }(注:即使判断逻辑看似没问题,高并发下
GET和DEL之间的时间窗口仍可能被利用,导致误删他人锁) -
节点 C 抢占节点 B 被释放的锁 :
节点 A 错误删除节点 B 的锁后,节点 C (另一台服务器)立即抢占锁,开始执行库存扣减业务。
-
死锁/资源混乱形成:
- 节点 B 仍在执行业务,但锁已被节点 A 误删,节点 C 同时持有锁执行相同业务,导致「锁失效」------ 两个节点同时操作库存,出现超卖;
- 若节点 B 后续执行释放锁逻辑,又可能删除节点 C 的锁,形成「连锁误删」,最终导致锁完全失控,所有节点都能同时操作资源,相当于「分布式锁失效」,本质是一种「伪死锁」(锁无法有效保护资源,等同于资源被死锁占用)。
更极端的「真死锁」变种
若节点 A 加锁时 未设置超时时间 (EX 参数遗漏),且节点 A 执行业务时突然宕机(如服务器断电、JVM 崩溃),则节点 A 持有的锁会永久保存在 Redis 中,没有任何机制能自动释放。后续所有节点(B、C、D)都无法获取该锁,导致商品 1001 的库存扣减业务完全阻塞,形成「真死锁」。
场景核心问题分析
- 锁超时与业务超时不匹配:锁超时时间设置过短,业务未执行完锁就被释放,是后续所有问题的根源;
- 非原子释放锁 :
GET + DEL是两步操作,存在时间窗口,导致「误删他人锁」; - 未设置锁超时时间 (变种场景):遗漏
EX/PX参数,节点宕机后锁无法自动释放,直接形成永久死锁; - 无锁持有者校验(若释放时不判断锁值):会直接删除任意节点的锁,加速死锁触发。
如何避免该场景的死锁?
-
锁超时时间合理设置 :基于业务最大执行时间上浮 50%-100%(如业务最多 30 秒,设置锁超时 60 秒),并结合「锁续期」(如用 Redisson 的
watch dog机制,业务未完成时自动延长锁超时); -
原子释放锁 :用 Lua 脚本执行「校验 + 删除」原子操作,避免时间窗口:
luaif redis.call('get', KEYS[1]) == ARGV[1] then return redis.call('del', KEYS[1]) else return 0 end -
强制设置锁超时 :禁止不设置
EX/PX的加锁操作,防止节点宕机后锁永久残留; -
使用成熟客户端:优先用 Redisson、Jedis 等封装好的分布式锁工具,内置锁续期、原子释放、死锁防护机制,避免手写逻辑出错。
总结
该场景的核心是「锁超时与业务超时不匹配」引发的连锁反应:锁被自动释放 → 他人抢占锁 → 原持有者误删他人锁 → 锁失控/资源混乱。这是 Redis 分布式锁最典型的死锁场景,也是生产环境中最容易踩坑的情况,关键解决思路是「合理设置超时 + 原子释放 + 锁续期」。