InnoDB 用回滚其中一个事务来解决锁交叉的方案

InnoDB 死锁会回滚整个受害事务

当多个事务形成不兼容锁请求的等待环时,就发生 InnoDB 死锁。开启死锁检测后,引擎选择一个受害者,回滚它的整个事务,使其他参与者能够继续。应用收到错误 1213(SQLSTATE 40001)。若只重试报错的那条语句,就会漏掉同一事务中更早执行、但已经一起被回滚的工作。 坏掉的样子 不能给所有锁错误套用同一种回滚规则。默认关闭超时整笔回滚时,锁等待超时只撤销正在等待的那条语句,事务内此前执行的语句仍然待决。开启对应服务端选项后,超时才会改为回滚整个事务。死锁本来就会回滚整个受害事务,不能把默认超时的语句级行为误套在死锁上。 受害者选择也不保证某个固定会话永远输。应用必须检查真实错误,再以有界次数重试完整逻辑事务。不可逆的外部副作用如果没有单独去重契约,不应跟着数据库事务自动重放。提交回复途中连接断开,也不等于已经得到死锁回滚的确定结论;错误类型与已知事务结果需要分开判断。 快速验证 在可丢弃的 MySQL 8.4 实例上打开甲、乙和观察者三个全新管理客户端,先确认三个会话均处于自动提交模式,关闭客户端自动重新连接,保持会话身份稳定。本次实际执行版本是 MySQL 8.4.11。观察者先创建尚未使用的实验模式,并核对死锁检测开启、超时采用默认语句级回滚: CREATE DATABASE xbcatm_deadlock_lab; USE xbcatm_deadlock_lab; CREATE TABLE accounts (id INT PRIMARY KEY, balance INT NOT NULL) ENGINE=InnoDB; INSERT INTO accounts VALUES (1,100),(2,100); SELECT @@innodb_deadlock_detect,@@innodb_rollback_on_timeout; 两个设置应分别为一和零。甲开始一项给两个账户各增加十的操作,先锁住第一个账户: USE xbcatm_deadlock_lab; SET SESSION innodb_lock_wait_timeout=30; SET @increment=10; START TRANSACTION; UPDATE accounts SET balance=balance+@increment WHERE id=1; 乙开始一项各增加一百的操作,先锁住第二个账户: ...

四月 20, 2028 · 1 分钟 · XB Field Notes
让一次锁等待有时间上限的可配参数

事务拿锁时别的请求会礼貌等待

等待锁可能是正常协调,也可能让连接和请求持续排队。PostgreSQL 的 lock_timeout 可以让调用方停止等待,但它不会解释持锁者为什么慢,更不会自动决定业务是否可以重试。应从操作能够接受的延迟出发设定预算,同时找到实际阻塞者;单纯延长超时或增加尝试次数,可能只是扩大同一条等待队列。 坏掉的样子 锁超时分别作用于每一次锁获取尝试,包括语句隐式申请的锁。它测量的是等待当前锁的时间,而不是整条语句的总运行时间。查询已经执行了一段时间,随后又申请一把锁,仍然可能触发这个限制。反过来,多次较短的等待也可能累计超过一个配置间隔。把锁超时理解成“只有语句刚开始时检查一次”,或把它当成整次查询的总截止时间,都会漏掉真实的等待范围。 statement_timeout 限制整条语句执行,其中也包括等锁。如果它更短,可能先于锁超时取消语句。空闲事务会话超时处理的则是另一种状态:事务还开着,但服务器正在等待客户端发下一条命令。卡在更新内部等待锁的后端通常仍是活动状态,等待事件属于锁;不能看到连接排队,就直接把所有等待者叫作空闲事务。真正闲着不提交的持锁者和正在等待的受阻者,需要分别定位。 快速验证 准备同一个 PostgreSQL 18 可丢弃数据库,开启甲、乙两个全新的 psql -X -v ON_ERROR_STOP=1 会话。实验使用默认的零值锁超时,且没有其他会提前干预的超时设置。下面的毫秒参数只用于快速复现,不是生产建议值。甲先建立独立模式和计数表: CREATE SCHEMA timeout_demo; CREATE TABLE timeout_demo.counter (id integer PRIMARY KEY,n integer); INSERT INTO timeout_demo.counter VALUES (1,0); 甲在显式事务中把数字改成十,暂时不提交,也不回滚,以便继续持有目标行的锁: BEGIN; UPDATE timeout_demo.counter SET n=10 WHERE id=1; 乙开启事务,对同一行加一,并给取锁等待一百毫秒预算。为展示预期错误及后续事务状态,只在这一小段临时关闭遇错停止: ...

二月 16, 2028 · 1 分钟 · XB Field Notes
一种单调编号,让过期主节点的写入全部失效。

fencing token 让过期的旧主节点失效

分布式锁看似简单,可一旦客户端停顿后晚醒,它的持锁声明就会失真;fencing token 把这种过期声明变成了可校验的编号。 光有锁还不够 分布式锁能挡住两个并发的写者,却挡不住一个「曾经拥有锁」的写者。一个节点写到一半卡住,租约过期,新的主节点接管,而旧节点醒来时仍带着它自认的那个许可帧继续写。这正是锁的盲区:它已经不再拥有令牌,却还觉得自己的主位一直没丢。站在资源一侧看,两次写入都像是合法所有者做的,因为在两个相互交叉的时间片里它们确实都持有效份。只靠租约乐观地自动过期,会在事故到账之前就留下一段够大够脏的窗口。 行动前先取编号 护栏令牌用一个递增编号堵住这个洞。当前主节点在动共享资源之前,先向协调服务取一枚单调递增的序列号,并且每次写入都把这个号带上。任何携带更旧编号的写入都会被拒,因为资源能看出已经有更新的所有者在写了。令牌必须来自可信来源,而不是写者自己那口时钟:墙上时钟是伪造的,而且伪造它的恰恰就是这套护栏要约束的那个进程。资源能安全比较的,只有它信任的签发者给出的数字。 更旧的全体拒绝 拒绝规则才是核心,而且必须无条件。当资源按「来者编号 > 上一次编号」比较时,拒绝旧写者不是因为对方是坏家伙,而是因为它的号更旧——这是资源唯一能依赖的新鲜度证据。当编号在所有人之间共享同一条递增序列时,设计会简单得多:一次比较就把所有过期的主者同时挡出去。这个思路并不只是文件的专利:分布式系统里的租约围栏、epoch 编号、护栏令牌,本质上都是在把「相信时钟」这把靠不住的梯子,换成一次数字比较。 坏掉的样子 围栏失效多半源于编号往回走。签发端计数器若只放内存,崩溃重启就会归零:老主节点攥着四十八号,新主节点只拿到二号,资源反而把过期写入当最新落盘。计数器必须落盘,或从日志位点派生;脏重启后在归属对账清楚前拒绝发号。租约只能选出新主,拦不住旧主的迟到覆写,围栏补的正是这一刀。 第二种是拿墙上时钟当编号。一次授时回拨、容器跨宿主机迁移的偏差、同毫秒两位主节点撞号,都可能造出相等或递减的时间戳,资源无从排序。等值必须按重放拒绝;时钟只配做超时猜测,不能替代签发序列。若两端拿到同号,资源只能二选一瞎猜,等于把仲裁权交还给运气。 第三种是脑裂双主,拆掉的是比较点本身。分区两侧各自选主、各自签发七号、各自落盘,愈合后两份历史都自称合法,光看编号已无法裁决谁是后来者。没有单一签发序列和单一校验资源,新旧只是标签,比较再严格也挡不住各说各话的覆写。 最安静的是令牌半路丢失截断。升级丢字段、代理去掉陌生请求头、兼容逻辑把缺失当零跳过检查,旧写就混了进去;镜像是资源重启丢了上一次编号,服过三十四号又接住三十三号。为老客户端留的兼容后门,往往正好给过期写入让路。每次变更必带令牌,缺失默认拒绝,高水位与数据同边界落盘。 快速验证 把下面脚本保存为 fencing.py,直接运行 python3 fencing.py 即可。它只用标准库,同时模拟单调递增的签发者和带校验的资源服务,不需要起任何外部组件,也不依赖系统时钟,几秒内就能看到旧写被拒、新写放行的完整过程: class StaleFenceError(RuntimeError): pass class FencingResource: """只接受严格递增围栏编号的资源服务。""" def __init__(self): self.last_seen = 0 self.data = None def write(self, token, value): if token is None: raise StaleFenceError("missing fence token") if token <= self.last_seen: raise StaleFenceError(f"stale token {token} <= {self.last_seen}") self.last_seen = token self.data = value return True class Issuer: """单调递增的签发者,生产环境里这个计数器必须落盘。""" def __init__(self): self.counter = 0 def acquire(self): self.counter += 1 return self.counter issuer = Issuer() resource = FencingResource() old_leader = issuer.acquire() # 1:暂停前的老主节点 new_leader = issuer.acquire() # 2:租约过期后接管的新主节点 assert (old_leader, new_leader) == (1, 2) assert resource.write(new_leader, "from-new-leader") is True assert resource.data == "from-new-leader" try: resource.write(old_leader, "from-stale-leader") except StaleFenceError as error: print(f"rejected stale write: {error}") else: raise AssertionError("stale token was accepted") try: resource.write(new_leader, "replay") except StaleFenceError as error: print(f"rejected replay: {error}") else: raise AssertionError("equal token was accepted") try: resource.write(None, "no-token") except StaleFenceError as error: print(f"rejected missing token: {error}") else: raise AssertionError("missing token was accepted") next_token = issuer.acquire() # 3 assert resource.write(next_token, "from-new-leader-again") is True assert (resource.data, resource.last_seen) == ("from-new-leader-again", 3) print("PASS: stale, replayed, and missing tokens rejected; newer token accepted") 脚本复刻停顿苏醒:老主节点持一号冻结,新主节点以二号先写并记下高水位。随后旧号迟到、同号重放、无号写入均被拒绝,只有三号成功。每步都有断言,误放行即抛错,不会悄悄打印通过。 ...

十一月 30, 2027 · 2 分钟 · XB Field Notes
由应用加锁、与表级锁隔离的一个命名标识。

PostgreSQL 咨询锁是应用起的名字

建议锁让应用使用一个标识来协调工作,不要求这个标识对应数据库里某一行。比如,多名工作者可以约定,同一时间只能有一个连接刷新某份报表。PostgreSQL 负责管理锁的持有与竞争,应用则负责定义标识的业务含义,以及哪些操作必须先拿锁才能执行。这个分工决定了它能提供的保障,也决定了绕过协议时会发生什么。 坏掉的样子 建议锁的键只能是一个大整数,或者两个普通整数,没有接受文本名字的函数重载。这两种键空间彼此独立。如果应用通过哈希把名称映射为数字,必须明确一致的编码规则,也要考虑碰撞;同一资源被不同算法映射到不同数字,同样会破坏协调。直接维护有文档的整数命名空间,通常比隐藏在各个调用方里的临时哈希规则更容易核对。 建议锁是参与者共同遵守的协议。忽略它的连接,仍然可以更新所谓受保护的表行;它不能代替唯一约束、事务或权限检查。会话锁在回滚后仍然保留,直到对应次数的解锁完成,或服务器结束该会话。事务锁则随事务结束释放。如果处于自动提交模式,一条语句取得的事务锁,在后续语句开始之前就已经释放,无法保护接下来才执行的工作。 快速验证 准备同一个 PostgreSQL 18 可丢弃数据库,开启甲、乙两个全新的 psql -X -v ON_ERROR_STOP=1 会话。为本实验预留下面的整数键,不要与已有业务约定混用。按照块的顺序切换终端。甲先创建计数器: CREATE SCHEMA advisory_demo; CREATE TABLE advisory_demo.counter (id integer PRIMARY KEY,n integer); INSERT INTO advisory_demo.counter VALUES (1,0); 甲传入显式文本类型的键,预期返回 42883,即不存在匹配的函数。故意报错这一段结束后恢复遇错停止设置: \set ON_ERROR_STOP off SELECT pg_advisory_lock('job-name'::text); \echo text_key_state :SQLSTATE \set ON_ERROR_STOP on 甲在事务里把同一把会话锁取两次,然后回滚。注意函数名使用的是会话级形式: ...

十一月 24, 2027 · 1 分钟 · XB Field Notes
两个事务各持一锁并等待对方,直到其中一个被中止。

死锁:两个写者在互相等待

死锁需要不兼容的等待关系形成一个环。一个事务持有行锁、另一个事务等待时,持锁者仍可能正常结束并放锁;双方各自握着对方必须获得的资源时,就无法沿当前路径完成。PostgreSQL 会检测这种循环,中止其中一个事务,让另一个继续。应用需要区分服务器明确拒绝事务与普通的慢等待,而不是把所有停顿都叫作死锁。 坏掉的样子 即使没有显式锁语句,更新行也会获取锁。因此,两段看起来简单的更新,如果以相反顺序访问两行,就可能互相等待。参与者更多、包含外键检查、触发器、建议锁或锁模式升级时,依赖环会更难看见。给某一段行编号排序,只能解决这部分访问顺序,不能据此证明整个业务路径再也没有死锁。 数据库检测到死锁时返回 40P01。应用不能依赖“后开始者必输”或“工作量少的一方会被中止”之类规则。锁等待超时、语句超时也可能先于死锁检测触发,返回另一种错误。因此必须查看实际错误码和服务器诊断,再判断发生了什么。没有看到死锁码,并不等于没有形成过循环;重试了一次,也不代表原来的取锁顺序已经修好。 快速验证 准备同一个 PostgreSQL 18 可丢弃数据库,开启甲、乙两个全新的 psql -X -v ON_ERROR_STOP=1 会话。甲创建两行数据,并显示死锁检测等待参数: CREATE SCHEMA deadlock_demo; CREATE TABLE deadlock_demo.items (id integer PRIMARY KEY,n integer); INSERT INTO deadlock_demo.items VALUES (1,0),(2,0); SHOW deadlock_timeout; 本次环境显示一秒。下面给语句十秒预算,让检测有时间发生,并确保没有更短的锁超时抢先干预。如果你的检测间隔更长,应相应调整实验预算。甲记录自己要增加的十,并先更新第一行,暂不提交: \set delta 10 SET statement_timeout='10s'; BEGIN; UPDATE deadlock_demo.items SET n=n+:delta WHERE id=1; 乙记录自己要增加的一百,先更新第二行,也保持事务打开: ...

八月 11, 2026 · 1 分钟 · XB Field Notes