文章导航
本页目录

UPDATE互相等待的死锁分析(gpt-5.6-sol生成)

报错中的 ShareLock on transaction 14165 不是说会话 2 正在对 t1 的某一行加 ShareLock,而是说它正在对“事务 14165”这个锁对象请求 ShareLock,借此等待事务 14165 结束。

行冲突首先记录在 tuple header 的 xmax 中,真正睡眠等待时转换成 LOCKTAG_TRANSACTION + ShareLock,所以死锁报告显示的是 transaction lock。

涉及的锁和状态

层次 锁对象或状态 锁模式 物理位置 是否构成本次死锁
表级 LOCKTAG_RELATION(t1) RowExclusiveLock fast-path 或主锁表 否,两个 RowExclusiveLock 相互兼容
索引级 LOCKTAG_RELATION(t1_pkey) 通常也是 RowExclusiveLock fast-path 或主锁表
行级持有信息 tuple header 的 xmax/infomask 本例通常是 LockTupleNoKeyExclusive 语义 数据页中的 tuple header 标识哪一个 XID 更新了该行
tuple 等待仲裁 LOCKTAG_TUPLE 本例通常映射为 heavyweight ExclusiveLock 主锁表 LOCK/PROCLOCK 用于多个等待者排队,不是报错中的 ShareLock
事务 ID 锁 LOCKTAG_TRANSACTION(14165) 拥有者持有 ExclusiveLock 主锁表 LOCK/PROCLOCK
事务 ID 锁 LOCKTAG_TRANSACTION(14166) 拥有者持有 ExclusiveLock 主锁表 LOCK/PROCLOCK
事务等待请求 对方的 LOCKTAG_TRANSACTION 等待者请求 ShareLock 主锁表等待队列 是,形成两条等待边

UPDATE 首先取得表级 RowExclusiveLock

执行器打开 UPDATE 的目标 relation 时使用:

resultRelation = heap_open(
    resultRelationOid,
    RowExclusiveLock
);

源码位于 src/gausskernel/runtime/executor/execMain.cpp。

两个会话都持有:

LOCKTAG_RELATION(t1) + RowExclusiveLock

但 RowExclusiveLock 与另一个 RowExclusiveLock 兼容,因此表锁不会造成这个死锁。

因为 RowExclusiveLock 满足 relation fast-path 资格,所以这两个表锁还可能保存在各自的:

PGPROC.fpRelId[]
PGPROC.fpLockBits[]

中,而不一定进入主锁表。

每个 UPDATE 都为自己的 XID 持有 ExclusiveLock

真正更新 tuple 时进入 src/gausskernel/storage/access/heap/heapam.cpp:

TransactionId xid = GetCurrentTransactionId();

如果当前事务还没有分配 XID,GetCurrentTransactionId() 会调用:

AssignTransactionId()
  → GetNewTransactionId()
  → XactLockTableInsert(xid)

XactLockTableInsert() 位于 src/gausskernel/storage/lmgr/lmgr.cpp:

void XactLockTableInsert(TransactionId xid)
{
    LOCKTAG tag;

    SET_LOCKTAG_TRANSACTION(tag, xid);

    LockAcquire(
        &tag,
        ExclusiveLock,
        false,
        false
    );
}

因此假设:

Session 1 的 XID = 14165
Session 2 的 XID = 14166

主锁表状态是:

Session 1:
    LOCKTAG_TRANSACTION(14165)
    ExclusiveLock
    granted = true

Session 2:
    LOCKTAG_TRANSACTION(14166)
    ExclusiveLock
    granted = true

LOCKTAG_TRANSACTION 不符合 relation fast-path 条件,因此这两个事务锁会直接进入:

LOCK hash
PROCLOCK hash

行锁主要记录在 tuple header,不为每一行长期保留 LOCK

第一次更新成功后,tuple header 大致变成:

id = 1 的旧版本:
    xmax = 14165

id = 2 的旧版本:
    xmax = 14166

所以:

Session 1 更新 id=1
  → id=1 的 tuple header 记录 XID 14165

Session 2 更新 id=2
  → id=2 的 tuple header 记录 XID 14166

openGauss 不会为每一个被更新的 tuple 长期在共享锁表中保留一个 LOCKTAG_TUPLE,否则大事务更新数百万行时会消耗大量共享锁表空间。

源码设计文档 src/gausskernel/storage/access/heap/README.tuplock 将其称为两级机制:

第一层:

tuple header 的 xmax + infomask
保存行锁拥有者和模式

第二层:

必须等待时,临时使用 LOCKTAG_TUPLE
负责多个等待者的排队和公平性

为什么还有临时 LOCKTAG_TUPLE

当 Session 1 尝试更新 id=2 时,heap_update() 看到:

id=2.xmax = 14166
事务 14166 仍在运行

在真正等待事务 14166 前,它先调用:

LOCK_TUPLE_TUP_LOCK(
    relation,
    &oldtup.t_self,
    mode
);

源码位于 src/gausskernel/storage/access/heap/heapam.cpp。

本例只修改 value,不修改主键 id,在支持增强 tuple lock 的单机场景下通常选择:

mode = LockTupleNoKeyExclusive;

这个 tuple 语义模式映射到 heavyweight lock manager 的:

ExclusiveLock

映射表位于 src/include/access/heapam.h:

LockTupleKeyShare       → AccessShareLock
LockTupleShared         → RowShareLock
LockTupleNoKeyExclusive → ExclusiveLock
LockTupleExclusive      → AccessExclusiveLock

LockTuple() 再构造:

LOCKTAG_TUPLE {
    dbid,
    relid,
    bucketid,
    block number,
    offset number
}

并调用普通 LockAcquire(),源码位于 src/gausskernel/storage/lmgr/lmgr.cpp。

这个 tuple heavyweight lock 的作用是排队仲裁:

LockTuple()
  → XactLockTableWait()
  → 成为下一位后重新检查 tuple header
  → 完成更新
  → UnlockTuple()

它不是长期行锁的主要载体,也不是错误消息里的 ShareLock。

为什么等待事务时请求 ShareLock

拿到 tuple 排队位置后,Session 1 执行:

XactLockTableWait(xwait, true);

这里:

xwait = tuple header 中的 xmax

也就是 Session 2 的 XID 14166。

源码位于 src/gausskernel/storage/lmgr/lmgr.cpp:

SET_LOCKTAG_TRANSACTION(tag, xid);

LockAcquire(
    &tag,
    ShareLock,
    false,
    false
);

LockRelease(
    &tag,
    ShareLock,
    false
);

之所以使用 ShareLock,是因为事务拥有者已经持有:

LOCKTAG_TRANSACTION(xid) + ExclusiveLock

而冲突矩阵中:

ShareLock 与 ExclusiveLock 冲突

因此:

事务仍运行:
    ExclusiveLock 未释放
    ShareLock 请求被阻塞

事务提交或回滚:
    ExclusiveLock 被释放
    ShareLock 请求被唤醒并获得
    XactLockTableWait() 随即释放 ShareLock 并返回

使用 ShareLock 还有一个重要好处:多个等待同一个事务结束的后端之间可以兼容。

T1 持有 XID 100 的 ExclusiveLock

T2 请求 XID 100 的 ShareLock
T3 请求 XID 100 的 ShareLock
T4 请求 XID 100 的 ShareLock

T2、T3、T4 不应该互相阻塞,它们只需要共同等待 T1。因此使用:

一个 ExclusiveLock 拥有者
多个相互兼容的 ShareLock 等待者

正好表达“大家一起等待同一个事务结束”的语义。

这里的 ShareLock 与“共享读取这一行”没有关系。

本例的具体等待图

第三步中,Session 1 更新 id=2:

id=2.xmax = 14166
  → XactLockTableWait(14166)
  → 请求 ShareLock on transaction 14166
  → 被 Session 2 的 ExclusiveLock 阻塞

第四步中,Session 2 更新 id=1:

id=1.xmax = 14165
  → XactLockTableWait(14165)
  → 请求 ShareLock on transaction 14165
  → 被 Session 1 的 ExclusiveLock 阻塞

形成:

Session 1 / XID 14165
    持有 ExclusiveLock on transaction 14165
    等待 ShareLock on transaction 14166


Session 2 / XID 14166
    持有 ExclusiveLock on transaction 14166
    等待 ShareLock on transaction 14165

                    └────────回到 Session 1

即:

Session 1 → Session 2 → Session 1

这是只有 hard edge 的硬死锁,不能通过调整等待队列顺序解决。

ShareLock 请求如何进入主锁表等待队列

XactLockTableWait() 调用普通:

LockAcquire()
  → LockAcquireExtended()
  → LockAcquireExtendedXC()

由于 LOCKTAG_TRANSACTION 不符合 relation fast-path,它进入主锁表:

LockHashPartitionLock(hashcode)
  → SetupLockInTable()
       → LOCK hash
       → PROCLOCK hash
  → LockCheckConflicts()
  → 发现对方 ExclusiveLock
  → WaitOnLock()
  → ProcSleep()
  → 插入 LOCK.waitProcs

此时 PGPROC 中会记录:

waitLock      → transaction 14165 或 14166 的 LOCK
waitProcLock  → 当前等待者对应的 PROCLOCK
waitLockMode  = ShareLock
waitStatus    = STATUS_WAITING

所以死锁检测器看到的图是事务 ID 锁图,而不是直接扫描 tuple header。

死锁检测源码位置

等待超过 deadlock_timeout 后,ProcSleep() 的定时器触发 CheckDeadLock()。

src/gausskernel/storage/lmgr/proc.cpp 先按顺序获取全部锁表分区 LWLock,然后调用:

t_thrd.storage_cxt.deadlock_state =
    DeadLockCheck(t_thrd.proc);

调用点位于 src/gausskernel/storage/lmgr/proc.cpp。

真正的等待图搜索入口是:

src/gausskernel/storage/lmgr/deadlock.cpp:193

核心过程是:

DeadLockCheck()
  → DeadLockCheckRecurse()
  → TestConfiguration()
  → FindLockCycle()
  → FindLockCycleRecurse()
  → 沿 PROCLOCK.holdMask 和 LOCK.waitProcs 构造等待边
  → 再次回到起点,识别死锁环

本例两条边都来自已经授予的事务 ExclusiveLock,属于 hard edge,因此返回:

DS_HARD_DEADLOCK

随后 CheckDeadLock():

RemoveFromWaitQueue()
→ 当前检测进程 waitStatus = STATUS_ERROR
→ 唤醒该进程的 semaphore

WaitOnLock() 发现 ProcSleep() 返回失败后,释放锁表分区 LWLock并调用:

DeadLockReport();

调用位置:

src/gausskernel/storage/lmgr/lock.cpp:1757

错误文本在哪里生成

最终错误在 src/gausskernel/storage/lmgr/deadlock.cpp 中生成。

DETAIL 行来自:

appendStringInfo(
    &clientbuf,
    "Process %lu waits for %s on %s; "
    "blocked by process %lu.",
    ...
);

源码位于 src/gausskernel/storage/lmgr/deadlock.cpp。

最终报错是:

ereport(
    ERROR,
    (
        errcode(ERRCODE_T_R_DEADLOCK_DETECTED),
        errmsg("deadlock detected"),
        errdetail_internal("%s", clientbuf.data),
        errhint("See server log for query details.")
    )
);

源码位于 src/gausskernel/storage/lmgr/deadlock.cpp。

transaction 14165 这样的对象描述则由:

DescribeLockTag()

处理 LOCKTAG_TRANSACTION 时生成。

关于“会话 2 回滚”的准确说法

本次运行中,会话 2 的检测器识别出硬死锁,所以会话 2 的当前事务被选为受害者。具体哪个会话成为受害者与检测时序有关,并不保证永远是最后执行 UPDATE 的会话。

在显式:

BEGIN;

事务块中,死锁 ERROR 会使当前事务进入 aborted 状态并释放事务资源,使另一个会话能够继续。客户端仍通常需要执行:

ROLLBACK;

才能退出失败的事务块。

最终可以把整个例子压缩为:

UPDATE t1
  → 对 t1 持有 RowExclusiveLock
  → 对自己的 XID 持有 ExclusiveLock
  → 把自己的 XID 写入目标 tuple 的 xmax

遇到别人正在更新的 tuple
  → 从 tuple.xmax 得到对方 XID
  → LockTuple() 做等待者仲裁
  → 对对方 XID 请求 ShareLock
  → ShareLock 与对方的 ExclusiveLock 冲突
  → 进入事务锁等待队列

两个会话互相等待对方 XID
  → XID 14165 → XID 14166
  → XID 14166 → XID 14165
  → DeadLockCheck() 发现环
  → DeadLockReport() 报 deadlock detected