1. 一次库存并发事故让我重新拾起MySQL主键ID锁
去年我维护的一个模拟订单系统是从本地库存的,多个消费节点同时处理消息队列,针对同一个商品编码做扣减。上线不久就收到对账报警:销售记录和库存流水对不上,库存莫名少了一截。
先怀疑是业务事务写错了,代码翻来覆去看了几遍,最后把问题锁定在分布式锁上。当时用的是Redis的 SETNX 加过期时间,踩了两类经典坑:一是锁过期时间设短了,业务执行超过过期时间,锁自动没了,第二个节点趁虚而入;二是Redis主从切换过程中,已经写入的锁没有同步到新主节点,切换后锁记录直接丢失,又放进一个并发请求。两个节点同时走到扣库存的代码块,超卖就出现了。
事后我评估了当时的情况:Redis集群并不算稳定,运维人力也有限,而且业务本身并没有超高并发。我决定换一种特别“土”的方案——使用MySQL的主键ID实现分布式锁。核心做法是:新建一张锁表,把要保护的资源标识作为主键ID;谁能成功向这张表插入一行,谁就拿到锁;处理完毕删掉这一行,就是释放锁。
这个方案让我用了近一年没出过事,后来还被团队里其他项目抄走了,下面我会完整展开。如果你也有类似痛点——没有现成的Redis基础设施,或者对Redis锁的极端故障不放心,同时团队已经在用MySQL,想拿到一种低成本、强一致、可维护的分布式锁,这篇文章会比较适合你。
2. 锁表设计:把resource_id塞进主键,锁就有了唯一凭证
2.1 建表语句与每一列的设计意图
CREATE TABLE biz_lock ( lock_key varchar(64) NOT NULL COMMENT '锁的资源ID,也就是业务主键', owner_id varchar(64) NOT NULL COMMENT '锁持有者标识,推荐UUID', expire_at bigint NOT NULL DEFAULT 0 COMMENT '过期时间戳,毫秒', created_at datetime NOT NULL DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间,排查用', PRIMARY KEY (lock_key) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='基于主键ID的分布式锁表';这张表的精髓就是主键约束。不要把锁状态设计成“一个普通表,里面有一列lock_status”,然后用UPDATE去改状态;那样一来,并发环境下两个节点同时读到锁空闲,都可能去UPDATE,必须引入版本号或行锁才能保证互斥。而我们直接把lock_key设为主键,从存储层保证了“同一个资源ID在表里最多只能有一行”,这种保证是原子的、由InnoDB强制执行的。
owner_id列的作用是标记“当前锁归谁”。我见过不带owner_id的简易实现,结果就是前面说的误删:A持锁超时,B抢到锁后,A才释放,DELETE无条件删行,把B的锁删了。带上owner_id,释放和续租都要求“只看自己的”。
expire_at列是防死锁的兜底。正常业务不需要它,但程序崩溃、网络分区、机器宕机时不把锁删掉,其他节点会永远进不来。加一个毫秒时间戳,超过这个时刻的锁就被判定为可抢占。created_at列定位问题用,排查“这个锁谁创建、什么时候创建”时很有用。
2.2 主键唯一约束怎么就成了并发“闸门”
InnoDB对主键的约束体现在插入路径上:当多个事务尝试插入相同的主键ID时,存储引擎会先判断唯一性,再决定冲突。对于已经存在的键值,后来的INSERT会收到duplicate key entry错误;对于正在插入但还没提交的事务,后来的插入会等待。这种机制本质上就是一个由数据库引擎实现的互斥判断,不需要应用程序处理并发竞态。
我常说,它相当于一个免费的CAS原语放在数据库里。很多分布式锁实现绕一大圈,无非是想保证“同一时刻只有一个节点能持有资源”。而MySQL主键的唯一性把这个判断下沉到存储引擎,实现简单、逻辑清晰。尤其在高并发下,同时有几十个请求抢同一把锁,最终必然只有一个INSERT成功,其余全部收到主键冲突错误——不管请求来自同一台机器的多线程,还是来自不同机器的多个进程。
只要记住一点:锁表必须用InnoDB,MyISAM这类引擎没有主键冲突保证,也缺乏事务支持,不适合作为分布式锁的载体。
3. 获取和释放锁的SQL:1062主键冲突与带owner的DELETE
3.1 获取锁:INSERT成功即持有,冲突要区分“占用中”与“已过期”
获取锁的代码我用Python示例,Java下逻辑一样:
import time import pymysql def acquire_lock(conn, lock_key, owner_id, expire_ms=30000): expire_at = int(time.time() * 1000) + expire_ms cursor = conn.cursor() try: cursor.execute( "INSERT INTO biz_lock (lock_key, owner_id, expire_at) " "VALUES (%s, %s, %s)", (lock_key, owner_id, expire_at) ) conn.commit() return True except pymysql.err.IntegrityError as exc: if exc.args[0] == 1062: # 主键冲突,说明锁已被占用 now = int(time.time() * 1000) cursor.execute( "UPDATE biz_lock SET owner_id=%s, expire_at=%s " "WHERE lock_key=%s AND expire_at <= %s", (owner_id, expire_at, lock_key, now) ) conn.commit() return cursor.rowcount == 1 raise为什么先INSERT而不是上来就UPDATE?因为INSERT成功意味着“锁在那一瞬间还没有主人”,这是最干脆的语义。一上来UPDATE无法判断之前锁是否存在、属于谁,条件写起来麻烦。INSERT失败后补一次带过期条件的UPDATE,也正好把“占用中被拒绝”和“锁已过期可抢占”区分开:如果expire_at <= now不成立,说明锁还没过期,直接返回失败;如果成立,说明这行已经是僵尸数据,我可以安全地把owner换成自己。
这段逻辑里有个细节值得强调:不能使用INSERT IGNORE。IGNORE会把主键冲突吞掉,冲突时你都不知道插入成功没有,丢失了“锁是否属于自己”的信息。必须捕获1062错误再处理。
还有一个容易踩的坑:加锁后要立即显式commit。如果加锁发生在某个事务中间,事务没提交,行锁会被持有到事务结束;这时候另一个节点来抢锁会阻塞在INSERT上,而不是得到明确的主键冲突,表现就像锁“卡死”一样。后面会专门讲事务边界的问题。
3.2 释放锁:DELETE必须同时校验owner
def release_lock(conn, lock_key, owner_id): cursor = conn.cursor() cursor.execute( "DELETE FROM biz_lock WHERE lock_key=%s AND owner_id=%s", (lock_key, owner_id) ) conn.commit() return cursor.rowcount > 0有人会问:释放锁为什么不用UPDATE把行改成“空闲”?这里直接用DELETE更简单,因为“空闲状态”在表中就是不存在的行。INSERT是加锁,DELETE是释放,表里同时存在的状态只有“有锁”和“没锁”两种,不会出现“有一行,但状态字段写错了”的畸形数据。
释放锁必须带owner_id。我再讲一遍那个场景:A持锁,锁过期,B抢到,A业务还没结束,此时A调用release_lock。如果没有owner校验,B的锁会被直接删掉,而B此刻可能正在执行关键操作。带owner校验后,A收到rowcount=0,它就知道锁已经不是自己的了,应该立刻中止业务或触发告警。
3.3 释放失败、重试和防抖
锁释放接口很容易出现网络超时的二义性。比如DELETE发出后客户端迟迟没有收到响应,实际上服务端已经删成功了。此时重试同样的DELETE,结果还是rowcount=0,但这不能说明释放失败。所以释放锁的正确判断标准应该是:该行最终不存在,而不是“必须由我把它删掉”。如果业务侧收到超时,可以先查询一下当前owner;若锁确实不在,就当释放成功来处理补偿。
获取锁的重试要加防抖。在高并发场景,如果一次抢锁失败就立刻重试,并且业务层是循环调用,数据库会瞬间收到大量重复INSERT请求,锁等待放大好几倍。建议采用指数退避:第一次失败后等200ms,第二次400ms,第三次800ms,最多重试N次再返回失败给上游。
4. 过期时间与续租:把死锁挡在门外
4.1 过期时间怎么定才安全
锁过期时间不能拍脑袋。设太短,业务还没执行完锁就过期,另一个节点会抢到锁,互斥被打破;设太长,持有者崩溃时锁自动释放的兜底就形同虚设。
我的经验是先做业务耗时压测。比如某个步骤平均2秒,p99是8秒,那么初始过期时间给10秒;同时必须配续租,否则p99之外碰到慢查询怎么办?续租周期可以设为过期时间的三分之一左右,比如每3秒续租一次,续租后继续保持10秒。这样即使业务卡顿,只要续租线程还在跑,锁就不会过期;如果连租都停了,说明进程异常,锁最多10秒后自动让位,其他节点恢复执行。
从理解上看,可以把expire_at当成一个租约(lease),业务持有的是某段有效期内的使用权。在有效期内的资源优势不会转移,一旦超过有效期,资源就可能被分配给下一个节点。
4.2 续租看门狗的实现要点
续租不一定非要单独线程,但持锁时间超过一个过期周期时就明显需要它。用Python伪代码表示:
import threading import uuid def run_with_lock(lock_key, business_func): owner_id = uuid.uuid4().hex conn = get_lock_conn() # 显式获取一个专用连接 if not acquire_lock(conn, lock_key, owner_id, expire_ms=10000): return False stop_evt = threading.Event() def watchdog(): while not stop_evt.wait(3): if not renew_lock(conn, lock_key, owner_id, expire_ms=10000): stop_evt.set() break t = threading.Thread(target=watchdog, daemon=True) t.start() try: business_func(owner_id) finally: stop_evt.set() release_lock(conn, lock_key, owner_id) t.join(timeout=3)看门狗发现续租返回False时该怎么处理?我的建议是立刻置停并抛异常或强制回滚,而不是只打个日志“锁丢了但我继续跑”。锁丢了意味着你不再拥有互斥权,继续操作可能造成数据损坏。即便不抛异常,也要至少设置一个全局标志,让业务代码在每个关键步骤前检查一次“我还持锁吗”。
4.3 过期抢占的时间窗与时钟问题
过期抢占必然存在一个短暂的时间窗:A的锁刚刚过期,B在同一毫秒尝试抢占成功,而A的业务仍在运行,它可能还会继续执行几百毫秒。这个时间窗是分布式锁的固有代价,锁过期只是一个兜底,不能替代你的业务设计和对租约的认知。因此我通常建议:核心业务逻辑尽量在过期前完成,续租机制只是降低概率,而不是消除问题。
时钟漂移也要重视。如果各应用节点系统时间不一致,A节点认为还差1秒过期,B节点认为已经过期1秒,B就会抢先删掉A的锁。稳妥的做法是把过期判断放到数据库端,用UNIX_TIMESTAMP()*1000计算当前时间,让所有节点参照同一时钟源。不过这样做会增加一条SQL的逻辑复杂度,我的建议是,简单业务直接用应用时间也可以,但一定要加时钟监控,尤其要避免某个节点时间被回拨。
5. 连接池与事务边界:最隐蔽的两个“锁假死”点
5.1 加锁事务必须短小精悍
我见过最典型的错误是把加锁INSERT和业务UPDATE全部塞到同一个事务里:
BEGIN; INSERT INTO biz_lock (...) VALUES (...); -- 加锁 UPDATE t_stock SET stock=stock-1 WHERE id=?; -- 业务 DELETE FROM biz_lock WHERE lock_key=?; -- 释放 COMMIT;这个写法从锁语义上确实没问题,但工程上问题很大。它把锁的持有时间等同于事务持续时间,而业务里如果出现慢SQL、跨接口调用、等待其他资源,事务就一直不提交,MySQL会话就会一直持有主键锁。后面的节点全被阻塞在INSERT上等待,数据库连接池可能被打满,业务接口的响应时间直接劣化。
我更推荐把锁的获取、业务更新、锁的释放拆成三段独立的短事务,锁本身依靠表里那行记录标记。加锁成功后立即commit,业务事务按自己的边界提交,最后DELETE再commit。锁的生命周期由业务代码控制,而不是由事务隐式控制,出问题时排查也方便得多。
5.2 连接池里“拿错连接”的风险
用连接池时,最隐蔽的问题是加锁和释放锁用到了不同的物理连接。比如Service层先调用lockDao.acquire(lockKey),它从连接池拿连接A执行INSERT;等到业务做完了,lockDao.release(lockKey)又从连接池拿到了连接B去执行DELETE。正常情况下这也能跑通,因为表行是持久化的,跟连接的物理归属无关。
但你不能依赖这种“碰巧”。真正严重的是业务事务边界和锁操作在同一个连接上打架:如果连接A的事务还没提交,你就从连接池拿到另一个连接B去释放锁,那么锁极可能放不到数据库里,业务事务随后回滚,甚至事务长时间不提交。建议在分布式锁服务内部维护显式连接,或者至少保证加锁、续租、释放三个操作从同一个连接对象发起,并配合清晰的提交顺序。
5.3 嵌套调用与不可重入问题
MySQL主键锁是不支持可重入的。同一线程拿到锁后再调一次acquire_lock同一个lock_key,必然撞主键冲突。有人会在加锁时判断“owner是不是我自己,如果是就跳过”,这会导致一个问题:外层释放DELETE时把内层“重入”的保护也删掉了,两个嵌套逻辑共享一把锁却只有一次释放,后续其他节点可能提前获得锁。
如果确实需要重入,我建议在进程内加引用计数:第一次acquire才真正插入数据库,后续同owner的acquire只对进程内的计数器加1;最外层release时计数归零才真正DELETE。不用计数器的业务上,最好直接禁止重入,代码结构上不允许锁内再取同锁。
6. 主键ID锁的性能边界:单行热点与分桶优化
6.1 单行锁争用是它的天花板
所有并发请求抢同一个lock_key,最终都会排队到InnoDB的同一条记录上。如果是低并发业务——比如定时任务、异步通知、订单状态流转,这个方案毫无压力;但如果是秒杀、抽奖这种超高热点,同一行上的锁等待会非常明显。可能出现几百个请求同时卡在INSERT/UPDATE上,数据库锁等待事件激增,接口延迟上升到几百毫秒甚至秒级。
想验证一把锁是不是遇到瓶颈,可以看MySQL的status变量,比如innodb_row_lock_waits、innodb_row_lock_current_waits,以及SHOW ENGINE INNODB STATUS里的LOCK WAIT信息。一旦锁等待数量持续增长,就应该考虑降低单锁压力,而不是在应用层加更多重试。
6.2 分桶锁:一把锁拆成N把
如果业务允许,分桶是绕开单行热点的最好办法。拿库存秒杀举例:原本商品SKU只有一把锁,可以把库存拆成10个桶,每个桶有自己独立的库存计数和一把独立的锁。下单时根据用户ID hash选桶,只锁对应的那把分桶锁。这样同一个SKU的锁竞争被摊到了10行上,吞吐能提升接近一个数量级。
分桶的代价是代码复杂度。库存分桶后,某桶可能先卖完,需要判断“如果该桶为0,是否允许路由到其他还有余量的桶”;如果允许,又会把锁的粒度变回全局,所以要做好边界设计。分桶只适合可以随机分配资源、不要求绑定顺序的业务,不能为了性能强行套到所有场景。
6.3 什么信号告诉你该换方案了
该换方案有两个很强烈的信号:一是同一把锁的每秒抢锁次数到几千,数据库连接和锁等待已经明显拖累整体QPS;二是需要跨MySQL实例锁,比如读写分离、多个数据库之间的一致性协调。这时可以认真评估Redis或etcd。
但我想强调,换Redis不等于问题自动消失。锁过期时间、owner校验、续租、重试防抖这些设计在Redis上同样要做,甚至因为Redis本身的一致性弱于MySQL,后续要面对的故障场景更多。不要为了性能把正确性丢掉。
7. 和Redis、ZooKeeper方案比,我为什么保留MySQL
我把几个主流方案放在一起比较,按自己的经验打分:
| 方案 | 一致性 | 运维成本 | 性能 | 典型适用场景 |
|---|---|---|---|---|
| MySQL 主键ID锁 | 高,单库强一致 | 低,一张表就能跑 | 中 | 任务互斥、订单处理、低频热点 |
| Redis SETNX+过期 | 弱,主从切换可能丢锁 | 中 | 高 | 高并发抢购、短事务 |
| ZooKeeper/etcd | 很高,分布式协调能力强 | 高 | 中低 | 强一致协调、选主、配置分发 |
MySQL主键ID锁最大的优势不是性能,而是“正确性便宜且直观”。一套表结构、两个SQL,放在已有的MySQL里,不需要额外维护一套中间件;业务逻辑在哪儿往哪儿加,问题的边界清晰。对于大多数业务系统,这已经足够了。
Redis的优势是性能高,适合短、快、频繁的锁操作。它的问题是当锁丢失时往往没有可靠机制兜底,分布式锁在某些故障场景下会退化成“没有锁”。如果团队没有成熟的Redis运维经验,用它会冒很大风险。
ZooKeeper和etcd适合真正需要强一致协调的平台级场景,比如服务选主、分布式队列。但运维成本高,部署和监控都更复杂,业务团队如果只是需要一个“互斥执行”的锁,用它确实有点杀鸡用牛刀。
8. 我的实际体会
最后分享两个实操体会。
第一,把锁逻辑封装成统一的锁客户端,底层用MySQL,上层提供acquire/release/renew三个接口,业务代码只传lock_key和超时时间。这样做的好处是后续想换实现,业务代码不用动,只要换底层实现类。我自己用下来接近一年,没有出现过一次真正的事故,核心原因还是InnoDB主键约束的强一致给了我很高信心。
第二,团队落地时最难解释的是“锁不属于某个事务”。很多人看到INSERT就下意识把它归到事务里,结果加锁、业务更新、释放全在同一个长事务里执行,锁的竞争和数据库连接都被拖垮。把这层讲清楚,这个方案基本就不会出大问题。
如果你也在纠结“没有Redis怎么实现分布式锁”,或者Redis锁在极端故障下已经坑过你一次,我建议直接试试这个基于MySQL主键ID的分布式锁。它最坏也只是多了一张表和两个SQL,但换来的是一眼能看穿的并发保护。