MySQL 命名锁(User-Level Locks)的两个极端:在生命周期管理上极其优雅,但在高可用架构下存在原生缺陷。
我们分深浅两个维度来拆解。
1. 致命坑:高可用切换导致锁“凭空消失”
在分布式系统中,锁的“互斥性”依赖于全局唯一的状态记录。而 MySQL 的命名锁有一个特性:它们只存在于当前 MySQL 实例的内存(Memory)中。
故障演进过程
假设你有一套 主从架构(Primary-Replica),并使用了 MHA 或 Orchestrator 进行自动切主。
- 持有锁:实例 A 连接到 主库1,执行
GET_LOCK('job_x', -1)成功。 - 主库故障:主库1 突然宕机(硬件故障或断网)。
- 自动切主:监控工具发现主库挂了,立刻将 从库1 提升为新的 主库2,并更新域名或 VIP 指向它。
- 锁丢失:由于
GET_LOCK的锁信息不记录在磁盘,也不参与 Binlog 同步,新提升的 主库2 的内存里是一片空白,根本不知道'job_x'曾被锁过。 - 并发冲突:实例 B 连接到新 主库2,请求
GET_LOCK('job_x', 0)。主库2 发现内存没这个锁,直接返回1(成功)。 - 灾难发生:实例 A 还在跑旧逻辑(它可能还没意识到数据库挂了,或者正在重连),实例 B 开始跑新逻辑,分布式锁宣告失效。
结论:如果你的业务场景绝对不允许两个实例同时运行(如涉及金钱对账、库存扣减),且数据库集群存在自动 Failover 机制,那么
GET_LOCK是不安全的。
2. 核心优势:为什么它比 Redis 锁“更省心”?
尽管有上述高可用风险,但在普通场景下,GET_LOCK 有一个 Redis 无法比拟的天然优势:锁的生命周期与 TCP 连接(Session)强绑定。
Redis 锁的“续期”难题 (The Watchdog Problem)
在 Redis 中实现分布式锁,为了防止程序崩溃导致死锁,必须设置过期时间(TTL):
- 设短了:业务还没跑完,锁过期了,别人进来抢锁,导致并发(需要 Redisson 这样的库搞个“看门狗”来续期)。
- 设长了:程序挂了,锁得等很久才能自动释放,导致业务阻塞。
MySQL 锁的“共生”机制
MySQL 的 GET_LOCK 逻辑非常单纯:
- 连接在,锁就在:只要你的数据库连接没断,这个锁可以持有一个小时、一天,甚至直到永远。你不需要写任何代码去“续期”。
- 连接断,锁必开:如果你的应用进程被
kill -9了,或者服务器断电了,操作系统会关闭 TCP 连接。MySQL 服务端检测到连接断开,会立刻清理该连接持有的所有命名锁。
优势总结:它解决了“业务执行时间不可控”的问题。你不需要担心设置 30 秒还是 60 秒的超时,MySQL 会帮你盯着那个连接。
3. 综合对比与选择建议
| 维度 | MySQL GET_LOCK | Redis SET NX |
|---|---|---|
| 高可用一致性 | 差(不跨实例同步,切主即丢) | 强(Redlock 算法或多副本同步) |
| 过期处理 | 完美(连接断开即释放,无需续期) | 麻烦(需要不断续期或设置 TTL) |
| 死锁风险 | 极低(随连接关闭自动释放) | 较高(若无 TTL 且进程挂掉则死锁) |
| 性能 | 一般(受限于数据库 QPS) | 极高(内存操作) |
什么时候该用它?
- 推荐使用:后台定时任务(Cron Job)排他执行、低频的数据导入导出、单机房内的多实例互斥。在这些场景中,数据库切主的概率极低,且逻辑简单。
- 禁止使用:高频秒杀、金融转账、跨地域多集群部署。
针对高可用坑的补救方案
如果你必须在主从切换的环境下使用:
在执行业务逻辑内部,一定要再次检查数据状态(幂等性)。锁只是第一层防御,即便锁失效了,数据库的 UNIQUE KEY 或事务版本号也要能挡住第二次冲击。
事务版本号(Transaction Versioning),通常被称为 乐观锁(Optimistic Locking),是解决你刚才提到的“分布式锁在高可用切换下失效”问题的最后一道防线。
即便 GET_LOCK 因为数据库切主而失效了,如果你的数据表里有版本号校验,依然可以保证数据的绝对安全。
1. 核心原理
它的核心思想是:假设冲突不会发生,但在真正写入数据时,检查数据是否被别人动过。
通常在表中增加一个字段:version(整数,初始为 0)。
执行逻辑:
- 读取 (Read):取出数据的同时,拿到当前的
version(假设是5)。 - 修改 (Local Update):在内存中修改业务数据。
- 校验并写入 (Write with Check):执行更新时,在
WHERE条件中带上刚才拿到的版本号。
2. SQL 表达方式
相比于普通的 UPDATE,带版本号的更新如下:
1-- 这里的 version = 5 是你刚才查出来的旧版本号
2UPDATE products
3SET
4 stock = stock - 1,
5 version = version + 1
6WHERE id = 101 AND version = 5;结果处理:
- 如果受影响行数 (Rows Affected) = 1:说明从你读到写的这段时间,没有其他人修改过,更新成功。
- 如果受影响行数 = 0:说明版本号已经变了(被别人抢先一步),更新失败。此时你需要根据业务决定:是报错,还是重试(重新读取新版本再次尝试)。
3. 为什么它能补掉 GET_LOCK 的坑?
回到你之前的分布式场景:
- 场景:主库切换,实例 A 和 实例 B 此时都通过
GET_LOCK拿到了锁(锁失效了)。 - 并发发生:
- 实例 A 和 B 都读到了
version = 5。 - 实例 A 执行
UPDATE ... SET version = 6 WHERE version = 5。由于它先到达数据库,执行成功。 - 实例 B 随后执行
UPDATE ... SET version = 6 WHERE version = 5。但此时数据库里的version已经是6了,WHERE条件不匹配,执行失败。
结论:即使分布式锁“由于天灾”失效了,版本号机制也能保证最终只有一个实例能写成功。这就是为什么金融级系统通常会结合使用“分布式锁(第一层防线)”和“版本号/乐观锁(第二层防线)”。
4. 在 Prisma / Node.js 中的实现示例
在使用 Prisma 时,你可以这样实现:
1async function updateStock(productId: number) {
2 // 1. 查询当前数据和版本号
3 const product = await db.product.findUnique({ where: { id: productId } });
4
5 if (!product) throw new Error("Not found");
6
7 // 2. 尝试更新,WHERE 条件包含版本号
8 const result = await db.product.updateMany({
9 where: {
10 id: productId,
11 version: product.version, // 关键:校验读到的版本
12 },
13 data: {
14 stock: { decrement: 1 },
15 version: { increment: 1 }, // 版本号自增
16 },
17 });
18
19 // 3. 检查是否更新成功
20 if (result.count === 0) {
21 throw new Error("并发冲突:数据已被修改,请重试");
22 }
23}5. 两种锁的最终对比
| 维度 | GET_LOCK (或 Redis 锁) | 事务版本号 (乐观锁) |
|---|---|---|
| 性质 | 悲观/预防性:不让别人开始 | 乐观/检测性:提交时才发现冲突 |
| 开销 | 较大(需要额外的网络请求加锁) | 极小(只是 SQL 的一个条件) |
| 适用场景 | 业务逻辑复杂、耗时长、不希望频繁重试 | 业务逻辑简单、并发冲突概率低 |
| 数据安全性 | 存在高可用失效风险 | 极高(数据库行级保证) |
最佳实践建议:
- 高频、短小的操作(如扣减库存):直接用版本号或 Atomic Update(
SET stock = stock - 1 WHERE stock > 0),甚至不需要GET_LOCK。 - 长耗时、复杂的业务(如生成报表、调用第三方支付回调):先用
GET_LOCK挡住大部分流量,防止浪费计算资源,最后在写数据库时用版本号兜底。