甲持有行一,乙持有行二;相反直角请求形成等待环,下方注明受害者整笔事务回滚。

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

当多个事务形成不兼容锁请求的等待环时,就发生 InnoDB 死锁。开启死锁检测后,引擎选择一个受害者,回滚它的整个事务,使其他参与者能够继续。应用收到错误 1213(SQLSTATE 40001)。若只重试报错的那条语句,就会漏掉同一事务中更早执行、但已经一起被回滚的工作。 坏掉的样子 不能给所有锁错误套用同一种回滚规则。默认关闭超时整笔回滚时,锁等待超时只撤销正在等待的那条语句,事务内此前执行的语句仍然待决。开启对应服务端选项后,超时才会改为回滚整个事务。死锁本来就会回滚整个受害事务,不能把默认超时的语句级行为误套在死锁上。 受害者选择也不保证某个固定会话永远输。应用必须检查真实错误,再以有界次数重试完整逻辑事务。不可逆的外部副作用如果没有单独去重契约,不应跟着数据库事务自动重放。提交回复途中连接断开,也不等于已经得到死锁回滚的确定结论;错误类型与已知事务结果需要分开判断。 死锁受害者可能是任一参与者。超时对照要求关闭超时整笔回滚,并在全部会话使用可重复读;所示结果须人工检查。 快速验证 历史三会话测试保持原样,本轮没有重跑。保留的审阅记录报告当时无法使用 MySQL,且未在保留的验证文件中找到原始 MySQL 日志;下述版本与受害者选择仍归属于原文报告。 在可丢弃的 MySQL 8.4 实例上打开甲、乙和观察者三个全新管理客户端,先确认三个会话均处于自动提交模式,且隔离级别为 REPEATABLE READ;修改过的服务器默认值可能改变可见性,或让后面的读取也进入锁等待。关闭客户端自动重新连接,保持会话身份稳定。既有运行记录中的版本是 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; 两个设置应分别为一和零。甲开始一项给两个账户各增加十的操作,先锁住第一个账户: ...

四月 20, 2028 · 2 分钟 · XB Field Notes
外层语句预算矩形包含执行、等锁、执行、等待四块;两个等待块下分别用红括号表示逐次计时。

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

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

二月 16, 2028 · 1 分钟 · XB Field Notes
资源观察到世代二后接受令牌二,而资源侧检查阻止旧令牌一。

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

租约到期时,持有者可能只是暂停,并没有真正停止。旧进程恢复后,仍会发送迟到写入。存储服务需要一种方法拒绝已经被取代的所有权;围栏令牌提供有序的所有权世代编号,并随每次变更传递。保护来自资源侧实际检查,而不是锁服务仅仅发出了一个数字。理解这个执行位置,才能判断旧主是否真的被挡住。 光有锁还不够 客户端可以先检查租约,然后因垃圾回收暂停,等另一个客户端取得资源后才恢复。即使在发送前立即检查,也无法消除检查与使用之间的空隙。网络延迟同样可能让已经发送的请求很晚才到达。资源必须在操作真正到达时决定是否接受,不能只相信客户端记忆中的持有状态仍然有效。 租约超时是故障检测机制,不证明先前进程已经停止。旧进程的凭据可能仍能通过身份认证,但它的所有权已经过期。因此应区分身份认证、权限与所有权世代。围栏不替代访问控制,也不能允许恶意客户端随意声称一个极大的编号,把合法所有者永久挡在外面。合法编号的来源同样属于协议需要保证的边界。 把递增编号绑定到所有权 协调者在取得所有权时发出递增令牌。暂停的持有者仍只有旧编号,不能在没有重新取得所有权时,随便再申请一个新数字继续写。发号序列必须在故障后避免回退和复用;墙上时钟、分区节点各自维护的计数器,都不会自动具备这个契约。时间戳看起来更大,不等于它证明了有效的所有权交接。 被保护资源保存已接受的最大世代,拒绝更小编号,接受更大编号时推进高水位。比较和对应的数据变更必须在同一个原子、持久化边界内完成。如果先单独检查,再无条件写入,就重新制造了竞争窗口。复制资源还需要一致的执行边界,不能让每个副本独立比较、各自接受不同所有者,之后再声称全部操作已经被围栏保护。 相等编号需要单独定义策略 一个租约持有者通常会在同一世代里执行多次写入。如果拒绝所有等于高水位的编号,该持有者就只能成功写一次。常见的所有权世代规则拒绝较小值,相等值仍可按应用正常权限处理。每次操作都使用严格递增编号,是另一种协议,不能与每次取得所有权才发一枚令牌的机制混为一谈。 相等编号也不能说明某次操作是否重复。需要防止重试重复产生效果时,应使用操作标识、幂等更新或其他明确的顺序规则。围栏区分不同所有权世代,不会自动排列同一世代的全部操作,也不会自动去重,更不能单独保证业务效果恰好发生一次。把这些责任写清楚,可以避免用一个编号替代所有并发控制。 坏掉的样子 发出新令牌,不会立即通知所有被保护资源。在资源尚未见过新世代之前到达的旧写入,仍可能通过高水位检查。如果契约要求取得新所有权的一刻就立即撤销旧权限,需要在允许后续工作前,通过合适的协调步骤把新世代建立到资源侧。普通围栏不能描述成对全部在途请求的即时取消机制,测试应保留这个先后顺序边界。 计数器回退可能让新所有者拿到比旧持有者更小的编号。资源丢失持久化高水位,也可能重新接受旧世代。恢复流程必须同时保全发号序列与资源状态;只把一端恢复到旧备份,却不协调所有权,可能破坏原来的假设,即使两个组件都能正常启动。组件存活与协议仍安全,是不同判断。 缺失或格式错误的令牌不能悄悄绕过检查。检查每一条变更入口,包括维护任务和兼容客户端。不能执行令牌比较的文件服务或外部 API,需要另一种受支持的并发控制机制;仅在请求头中放一个数字,并不会保护实际副作用。比较必须位于真正拥有写入权的资源边界,而不是只存在于调用方日志中。 在资源侧执行围栏检查。依据原文绘制机制;模型值与原文报告的观察不代表本次运行测试。 快速验证 把下面标准库 Python 示例保存为 fencing.py 后运行。它是顺序执行的内存模型,不是持久锁服务,也不是并发存储实现;没有外部服务器、系统时钟或真实凭据参与,所有状态都只存在于当前程序中。 class StaleFenceError(RuntimeError): pass class FencingResource: """Sequential model: equal tokens permit more writes in the same epoch.""" def __init__(self): self.last_seen = 0 self.data = None def write(self, token, value): if type(token) is not int or token <= 0: raise StaleFenceError("missing or invalid 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: """Test counter; a real issuer must bind durable epochs to ownership.""" def __init__(self): self.counter = 0 def acquire(self): self.counter += 1 return self.counter def refused(resource, token): before = (resource.last_seen, resource.data) try: resource.write(token, "must-not-land") except StaleFenceError as error: assert (resource.last_seen, resource.data) == before print("rejected:", error) else: raise AssertionError("invalid or stale write was accepted") issuer = Issuer() old_leader, new_leader = issuer.acquire(), issuer.acquire() resource = FencingResource() # Issuance alone has not informed this resource about epoch 2. assert resource.write(old_leader, "arrived-before-new-epoch") assert resource.write(new_leader, "from-new-leader") refused(resource, old_leader) assert resource.write(new_leader, "second-write-in-same-epoch") assert resource.last_seen == 2 refused(resource, None) refused(resource, "2") refused(resource, True) next_token = issuer.acquire() assert resource.write(next_token, "newest") refused(resource, new_leader) assert (resource.last_seen, resource.data) == (3, "newest") print("PASS: observed newer epochs fence older writes; equal epoch remains usable") 首次旧令牌写入成功,因为资源还没有观察到第二世代。第二世代写入后,第一世代被拒绝,已存数据和高水位都不改变。同一个第二世代随后还能再写,说明一位所有者可以合法进行多次操作。缺失值、字符串和布尔值被拒绝;第三世代到来后,第二世代也成为旧值,不能继续修改资源。 ...

十一月 30, 2027 · 2 分钟 · XB Field Notes
会话持有通过两次解锁从二降至一再到零;回滚仍保留两次持有。

建议锁寿命属于连接或事务

建议锁让应用使用一个标识来协调工作,不要求这个标识对应数据库里某一行。比如,多名工作者可以约定,同一时间只能有一个连接刷新某份报表。PostgreSQL 负责管理锁的持有与竞争,应用则负责定义标识的业务含义,以及哪些操作必须先拿锁才能执行。这个分工决定了它能提供的保障,也决定了绕过协议时会发生什么。 坏掉的样子 建议锁的键只能是一个大整数,或者两个普通整数,没有接受文本名字的函数重载。这两种键空间彼此独立。如果应用通过哈希把名称映射为数字,必须明确一致的编码规则,也要考虑碰撞;同一资源被不同算法映射到不同数字,同样会破坏协调。直接维护有文档的整数命名空间,通常比隐藏在各个调用方里的临时哈希规则更容易核对。 建议锁是参与者共同遵守的协议。忽略它的连接,仍然可以更新所谓受保护的表行;它不能代替唯一约束、事务或权限检查。会话锁在回滚后仍然保留,直到对应次数的解锁完成,或服务器结束该会话。事务锁则随事务结束释放。如果处于自动提交模式,一条语句取得的事务锁,在后续语句开始之前就已经释放,无法保护接下来才执行的工作。 会话持有次数在回滚后保留;事务持有随事务结束。数值为人工预期,不是本轮竞争观察。 快速验证 本轮没有重跑原样保留的双会话示例。保留的审阅记录给出源文核对后的预期,没有原始多会话输出;单用户后端不能证明会话间竞争。 准备同一个 PostgreSQL 18 可丢弃数据库,开启甲、乙两个全新的 psql -X -v ON_ERROR_STOP=1 会话。使用同一角色并具备创建模式权限,确认 advisory_demo 尚不存在;显式事务外保持自动提交,且没有会中断调度的超时。为本实验预留下面的整数键,不要与已有业务约定混用。按照块的顺序切换终端。甲先创建计数器: 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 会检测这种循环,中止其中一个事务,让另一个继续。应用需要区分服务器明确拒绝事务与普通的慢等待,而不是把所有停顿都叫作死锁。 死锁需要循环等待,任意一方都可能失败,全部更新随之回滚。只重试失败的完整操作;所有相关资源都应遵守一致顺序,并为重试设定有限预算。 坏掉的样子 即使没有显式锁语句,更新行也会获取锁。因此,两段看起来简单的更新,如果以相反顺序访问两行,就可能互相等待。参与者更多、包含外键检查、触发器、建议锁或锁模式升级时,依赖环会更难看见。给某一段行编号排序,只能解决这部分访问顺序,不能据此证明整个业务路径再也没有死锁。 数据库检测到死锁时返回 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