返回
创建于
状态公开

MySQL 命名锁(User-Level Locks)的两个极端:在生命周期管理上极其优雅,但在高可用架构下存在原生缺陷。

我们分深浅两个维度来拆解。


1. 致命坑:高可用切换导致锁“凭空消失”

在分布式系统中,锁的“互斥性”依赖于全局唯一的状态记录。而 MySQL 的命名锁有一个特性:它们只存在于当前 MySQL 实例的内存(Memory)中。

故障演进过程

假设你有一套 主从架构(Primary-Replica),并使用了 MHA 或 Orchestrator 进行自动切主。

  1. 持有锁:实例 A 连接到 主库1,执行 GET_LOCK('job_x', -1) 成功。
  2. 主库故障主库1 突然宕机(硬件故障或断网)。
  3. 自动切主:监控工具发现主库挂了,立刻将 从库1 提升为新的 主库2,并更新域名或 VIP 指向它。
  4. 锁丢失:由于 GET_LOCK 的锁信息不记录在磁盘,也不参与 Binlog 同步,新提升的 主库2 的内存里是一片空白,根本不知道 'job_x' 曾被锁过。
  5. 并发冲突:实例 B 连接到新 主库2,请求 GET_LOCK('job_x', 0)主库2 发现内存没这个锁,直接返回 1(成功)。
  6. 灾难发生:实例 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_LOCKRedis SET NX
高可用一致性(不跨实例同步,切主即丢)(Redlock 算法或多副本同步)
过期处理完美(连接断开即释放,无需续期)麻烦(需要不断续期或设置 TTL)
死锁风险极低(随连接关闭自动释放)较高(若无 TTL 且进程挂掉则死锁)
性能一般(受限于数据库 QPS)极高(内存操作)

什么时候该用它?

  • 推荐使用:后台定时任务(Cron Job)排他执行、低频的数据导入导出、单机房内的多实例互斥。在这些场景中,数据库切主的概率极低,且逻辑简单。
  • 禁止使用:高频秒杀、金融转账、跨地域多集群部署。

针对高可用坑的补救方案

如果你必须在主从切换的环境下使用: 在执行业务逻辑内部,一定要再次检查数据状态(幂等性)。锁只是第一层防御,即便锁失效了,数据库的 UNIQUE KEY 或事务版本号也要能挡住第二次冲击。

事务版本号(Transaction Versioning),通常被称为 乐观锁(Optimistic Locking),是解决你刚才提到的“分布式锁在高可用切换下失效”问题的最后一道防线

即便 GET_LOCK 因为数据库切主而失效了,如果你的数据表里有版本号校验,依然可以保证数据的绝对安全。


1. 核心原理

它的核心思想是:假设冲突不会发生,但在真正写入数据时,检查数据是否被别人动过。

通常在表中增加一个字段:version(整数,初始为 0)。

执行逻辑:

  1. 读取 (Read):取出数据的同时,拿到当前的 version(假设是 5)。
  2. 修改 (Local Update):在内存中修改业务数据。
  3. 校验并写入 (Write with Check):执行更新时,在 WHERE 条件中带上刚才拿到的版本号。

2. SQL 表达方式

相比于普通的 UPDATE,带版本号的更新如下:

sql
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 拿到了锁(锁失效了)。
  • 并发发生
  1. 实例 A 和 B 都读到了 version = 5
  2. 实例 A 执行 UPDATE ... SET version = 6 WHERE version = 5。由于它先到达数据库,执行成功
  3. 实例 B 随后执行 UPDATE ... SET version = 6 WHERE version = 5。但此时数据库里的 version 已经是 6 了,WHERE 条件不匹配,执行失败

结论:即使分布式锁“由于天灾”失效了,版本号机制也能保证最终只有一个实例能写成功。这就是为什么金融级系统通常会结合使用“分布式锁(第一层防线)”和“版本号/乐观锁(第二层防线)”。


4. 在 Prisma / Node.js 中的实现示例

在使用 Prisma 时,你可以这样实现:

typescript
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 的一个条件)
适用场景业务逻辑复杂、耗时长、不希望频繁重试业务逻辑简单、并发冲突概率低
数据安全性存在高可用失效风险极高(数据库行级保证)

最佳实践建议:

  1. 高频、短小的操作(如扣减库存):直接用版本号Atomic UpdateSET stock = stock - 1 WHERE stock > 0),甚至不需要 GET_LOCK
  2. 长耗时、复杂的业务(如生成报表、调用第三方支付回调):先用 GET_LOCK 挡住大部分流量,防止浪费计算资源,最后在写数据库时用版本号兜底。