如何用数据库实现分布式锁?有哪几种方式?
简化版
用数据库实现分布式锁主要有三种方式:① 唯一索引(插入即加锁)——往锁表插一条记录,唯一约束保证只有一个能插成功;② 悲观锁(SELECT ... FOR UPDATE)——用行锁锁住某行,事务提交才释放;③ 乐观锁(版本号)——update ... where version=?,靠 CAS 更新成功与否判断。数据库锁实现简单、无需额外中间件,但性能差、有单点风险、防死锁麻烦,一般只在并发不高或作为兜底方案时用。
详细版
① 唯一索引方式:建一张锁表,method_name 加唯一索引。加锁 = INSERT 一条记录,插入成功即获得锁,插入失败(唯一冲突)即锁被占。解锁 = DELETE 这条记录。
- 优点:实现简单。
- 缺点:非阻塞(失败要自己轮询重试)、不可重入(要额外记录持有者+计数)、没有过期机制(持锁进程宕机不删记录会死锁,要靠定时任务清理超时记录)。
② 悲观锁 SELECT ... FOR UPDATE:在事务里 SELECT * FROM lock_table WHERE id=? FOR UPDATE,数据库给这行加排他行锁,其他事务的 FOR UPDATE 会阻塞等待,直到本事务提交/回滚释放锁。
- 优点:阻塞等待天然、由数据库管理锁、事务结束自动释放(防死锁较好)。
- 缺点:占用数据库连接和行锁,高并发下连接池易耗尽;要确保走索引,否则可能升级为表锁。
③ 乐观锁(版本号 / CAS):UPDATE t SET data=?, version=version+1 WHERE id=? AND version=?,影响行数 = 1 表示抢到、= 0 表示被别人改过(没抢到)。
- 优点:不加锁、并发高。
- 缺点:冲突多时大量重试;严格说更像「无锁并发控制」而非互斥锁。
完整版教学
一、唯一索引方式:把「加锁」变成「插入」
最朴素的思路:建一张 distributed_lock 表,对「锁的名字」字段加唯一索引。谁能成功 INSERT 一行,谁就持有锁;因为唯一约束,同名的第二次插入会失败,就是「锁被占」。解锁就是 DELETE 掉那行。这利用了数据库唯一索引的原子性来实现互斥,简单直接。但缺陷明显:
- 没有过期时间:如果持锁进程
INSERT后就宕机,那行记录永远在,锁永远不释放 → 死锁。补救:记录加锁时间,用定时任务清理「超时未释放」的锁记录(但清理阈值不好定,和 Redis 过期时间是同样的两难)。 - 非阻塞:插入失败只能返回失败,客户端要自己轮询重试,不像
FOR UPDATE能阻塞排队。 - 不可重入:默认不支持,要额外存持有者标识和计数。
二、悲观锁:借用数据库的行锁
SELECT ... FOR UPDATE 直接复用数据库自带的排他行锁。在一个事务里对某行加 FOR UPDATE,别的事务想对同一行 FOR UPDATE 就得排队等,直到本事务 COMMIT/ROLLBACK。好处是「阻塞排队」和「事务结束自动释放锁」都由数据库天然提供,比唯一索引方式更完善(宕机时事务回滚会释放锁,防死锁更好)。注意:
- 查询条件必须走索引,否则 MySQL(InnoDB) 可能把行锁升级成表锁,锁住整张表,并发雪崩。
- 整个持锁期间占用一个数据库连接和事务,持锁时间长会耗尽连接池。
三、乐观锁:其实是「无锁并发控制」
版本号方式严格说不是「互斥锁」,而是「冲突检测」:读数据时带出 version,更新时 WHERE version=旧值,如果这期间没人改过(version 还是旧值)就更新成功、version+1;如果被别人改过(version 变了),影响行数为 0,说明冲突,本次失败、需重试或放弃。它没有「阻塞等待」,靠「更新成功与否」来仲裁并发。适合冲突不激烈的更新场景(如库存扣减),冲突激烈时大量重试反而更慢。
记忆点:唯一索引 = 插入即加锁(要自己搞过期);
FOR UPDATE= 借数据库行锁阻塞排队(事务结束自动释放);版本号 = 无锁 CAS 冲突检测。前两种是真互斥,第三种是乐观并发。
四、数据库锁的整体短板
- 性能差:数据库本身是稀缺资源,用它做锁会占用连接、行锁、事务,QPS 远不如 Redis(内存)。
- 单点问题:单库是单点,挂了锁服务就没了(要靠数据库高可用兜底)。
- 防死锁麻烦:唯一索引方式没有自动过期,要额外定时清理。
- 可重入、阻塞、公平等特性都要自己费劲实现。
所以数据库锁一般只在:并发量低、不想引入 Redis/ZK 中间件、或作为其他锁方案的兜底时使用。高并发场景优先 Redis/ZooKeeper。
五、数据库锁落地自检
数据库锁适合低频、短事务、已有数据库强约束的场景,不适合高频热点抢锁。落地时至少要检查 3 件事:锁表的唯一键是否命中、事务是否足够短、持锁方宕机后是否有过期字段或清理任务。否则一个残留锁记录就可能让后台任务停摆几十分钟。
六、常见误区与追问
这道题不能只背概念,要把「数据库分布式锁」放回真实分布式系统里解释:谁发起、谁协调、状态如何变化、失败后怎么兜底,以及它和性能、可用性、一致性的取舍。
| 回答层次 | 要讲清的内容 | 容易漏掉的边界 |
|---|---|---|
| 核心结论 | 数据库锁可通过唯一约束、悲观锁或乐观锁实现互斥,简单可靠但性能和死锁风险要控制 | 不要停在名词解释 |
| 流程机制 | 尝试插入唯一锁记录或 select for update -> 成功进入临界区 -> 提交或删除记录释放 -> 失败重试或返回 -> 通过超时字段清理过期锁 | 说明触发方、参与方、状态变化和兜底 |
| 工程取舍 | 插入 lock_name=jobA 的唯一记录成功表示拿锁,重复插入失败表示锁被占用 | 分布式锁只能控制临界区进入,不能替代业务幂等和状态校验 |
数据库分布式锁 面试拆解:
1. 尝试插入唯一锁记录或 select for update
2. 成功进入临界区
3. 提交或删除记录释放
4. 失败重试或返回
5. 通过超时字段清理过期锁
记忆钩子:先说为什么单机锁失效,再说明加锁、过期、续期、解锁、防误删和故障恢复;回答时要紧扣「数据库分布式锁」这道题,不要把相邻概念混成一段泛泛的分布式套话。
- 误区:数据库锁一定比 Redis 锁安全。 数据库事务强但也有死锁、连接耗尽和性能瓶颈。
- 误区:唯一约束锁不需要过期。 持锁进程宕机后记录可能残留,需要 owner、过期时间和清理。
- 误区:乐观锁就是分布式锁。 乐观锁适合并发更新冲突检测,不一定提供进入临界区前的互斥。
- 追问:数据库锁有哪些实现? 唯一索引插入、select for update、version 乐观锁。
- 追问:适合什么场景? 低频后台任务、管理操作、已有数据库依赖且并发不高的场景。
- 追问:如何防死锁? 固定加锁顺序、短事务、超时、索引命中和异常释放。
七、加强记忆
数据库分布式锁三种:唯一索引(INSERT 成功即持锁、DELETE 解锁,缺过期机制要定时清理、非阻塞不可重入)、悲观锁 SELECT...FOR UPDATE(借数据库排他行锁阻塞排队、事务结束自动释放、须走索引防表锁)、乐观锁版本号(update...where version=? 的 CAS 冲突检测,不阻塞、适合低冲突)。整体优点是简单无需中间件,缺点是性能差、单点、防死锁麻烦,只适合低并发或兜底,高并发用 Redis/ZK。