背景:死鎖的成因與解決方式
死鎖 指兩個實體在運行過程中因競爭資源而形成的一種僵局,如無外力作用,兩個實體都無法向前繼續推進。從操作系統的層面來看,實體可以是進程或線程,資源可以是設備/信號/消息等;從數據庫的層面來看,實體可以是事務,資源可以是鎖。從理論上來說,發生死鎖需要同時滿足以下四個條件:
- 互斥條件:實體對資源有排他控制權
- 請求和保持條件:實體在因獲取資源而阻塞時,不釋放已獲取的資源
- 不搶占條件:實體不可剝奪其他實體已獲得的資源,只能等待其他實體自行釋放
- 環路等待條件:實體之間的等待關系形成環路
在 DBMS 中,四個條件有可能同時滿足:
- 互斥條件:主流 DBMS 各自有着鎖及鎖兼容性的完備定義
- 請求和保持條件:事務遵循兩階段鎖定 (2PL),在擴展階段不會釋放鎖
- 不搶占條件:事務在 2PL 收縮階段自行釋放所有已獲得的鎖
- 環路等待條件:DBMS 內允許事務以非確定性的順序獲得表鎖,因此事務之間可能形成等待環路
從理論上來說,解決死鎖有如下幾種策略:
- 死鎖預防
- 死鎖避免
- 死鎖檢測與解除
這三種策略的強度由緊至松,開銷由高到低。如果采用偏緊迫的策略,或許可以解決死鎖,但是緊迫策略的開銷可能導致吞吐率的嚴重下降 (假設事務全都串行化)。如果死鎖並不是一件經常發生的事情,那么采用緊迫策略而付出較大的開銷是不划算的。因此,需要在具體場景下,根據對策略開銷的預期、對死鎖發生頻率的預期、對資源吞吐量的預期,進行綜合考慮,再選擇合適的策略。在 PostgreSQL 中,對死鎖的處理采用第三種策略:以較為寬松的策略允許事務向前推進,如果死鎖發生,再通過特定的機制 檢測並解除 死鎖。
死鎖檢測的觸發時機
由於 PostgreSQL 不對死鎖的預防和避免做任何工作,事務如何察覺到自己可能陷入死鎖?PostgreSQL 對發生死鎖的預期比較樂觀。執行事務的進程在獲取鎖時,發現鎖因為正被其他事務持有,且請求鎖的模式 (lock mode) 與持有鎖的事務存在沖突而需要等待后:
- 設置一個死鎖定時器,然后立刻進入睡眠,不檢測死鎖
- 如果定時器超時前,進程已經成功獲得鎖,那么定時器被提前取消,沒有發生死鎖
- 如果定時器超時,則進程被喚醒並執行死鎖檢測算法
只有定時器超時后,才執行死鎖檢測算法。這種設計避免對超時時間以內的每一個睡眠等待進程都執行一次死鎖檢測,這也是 樂觀等待 策略的體現。定時器的超時時間通過 GUC 參數 deadlock_timeout 設置。
死鎖檢測的觸發實現在 ProcSleep() 函數中。這是進程被阻塞而進入睡眠時的函數:
/* 標志,死鎖等待是否超時 */
static volatile sig_atomic_t got_deadlock_timeout;
/* 死鎖等待超時后,被信號處理函數調用 */
void
CheckDeadLockAlert(void)
{
int save_errno = errno;
got_deadlock_timeout = true; /* 設置死鎖檢測超時標志 */
SetLatch(MyLatch); /* 喚醒睡眠進程 */
errno = save_errno;
}
int
ProcSleep(LOCALLOCK *locallock, LockMethod lockMethodTable)
{
/* ... */
/* 超時標志初始化 */
got_deadlock_timeout = false;
/* ... */
/* 啟動定時器 */
enable_timeout_after(DEADLOCK_TIMEOUT, DeadlockTimeout);
/* ... */
do
{
/* ... */
else
{
WaitLatch(MyLatch, WL_LATCH_SET, 0,
PG_WAIT_LOCK | locallock->tag.lock.locktag_type);
/* 進程被喚醒 */
ResetLatch(MyLatch);
/* 如果進程是因為死鎖超時被喚醒,那么檢測死鎖 */
if (got_deadlock_timeout)
{
CheckDeadLock();
got_deadlock_timeout = false;
}
CHECK_FOR_INTERRUPTS();
}
/* ... */
} while (myWaitStatus == STATUS_WAITING);
/* ... */
/* 注銷定時器 */
disable_timeout(DEADLOCK_TIMEOUT, false);
}
原子變量 got_deadlock_timeout 指示死鎖計時器是否超時,初始化為 false。設置超時計時器后,在超時處理函數 CheckDeadLockAlert() 中,這個值會被更新為 true。進程因超時而被喚醒后,就會進入 CheckDeadLock() 函數中檢測死鎖。
鎖等待隊列
如果進程需要睡眠等待一個鎖,需要把自己放入鎖的等待隊列中。一般來說,進程會到隊列的最后排隊,PG 盡可能以先來后到的順序授予鎖。但是有一些例外:如果進程已經持有了與隊列中某些進程沖突的鎖,那么進程應當排到隊列中第一個沖突進程之前。舉個例子,假設目前:
- Lock A 正被 P1 進程持有,等待隊列中已有 P2 進程
- Lock B 正被 P3 進程持有,等待隊列中已有 P2 進程
LOCK A: [P1] --> P2
LOCK B: [P3] --> P2
此時,P3 想要獲得 Lock A。如果它被添加到了 Lock A 等待隊列的尾部:
LOCK A: [P1] --> P2 --> P3
LOCK B: [P3] --> P2
當 P1 進程釋放 Lock A 后,將會喚醒 P2。P2 將獲得 Lock A 后形成如下局面:
LOCK A: [P2] --> P3
LOCK B: [P3] --> P2
此時,P2 等待 P3 釋放 Lock B,P3 等待 P2 釋放 Lock A,形成了一個典型的死鎖。為了避免發生這種情況,在 P3 加入 Lock A 隊列時,需要找到會與自己已持有鎖發生沖突的第一個進程 (P2),並插隊到該進程之前:
LOCK A: [P1] --> P3 --> P2
LOCK B: [P3] --> P2
這個優化並不是必須的,因為后面將介紹的死鎖檢測算法也會干這件事 (重排等待隊列的順序)。但是這里提前這么做,可以避免一次死鎖檢測的超時。
Wait-For 圖
由於 PostgreSQL 定義了各種鎖的兼容性,同時服從 2PL,因此死鎖產生的前三個條件 (互斥/請求和保持/不搶占) 已經滿足。死鎖檢測函數只需要檢測出發生了第四個條件 (環路等待),就能夠確定發生了死鎖。環路等待可被建模為一張 有向圖:圖的頂點代表進程,邊代表進程間的等待關系。邊總是從一個等待鎖的進程指向一個正在持有鎖且鎖請求沖突的進程:
typedef struct
{
PGPROC *waiter; /* the leader of the waiting lock group */
PGPROC *blocker; /* the leader of the group it is waiting for */
LOCK *lock; /* the lock being waited for */
int pred; /* workspace for TopoSort */
int link; /* workspace for TopoSort */
} EDGE;
如果有向圖內出現了環路,那么意味着出現了死鎖。為了解決死鎖,應當犧牲圖中的一個或多個頂點 (事務回滾) 以及連接它們的邊,從而打破圖中的環路。每一個進程進入死鎖檢測函數時,都會從 當前進程 出發,向自己依賴的等待關系進行深度優先搜索,同時構造等待圖。三種可能的結果:
- 沒有環路
- 有環路,但環路沒有回到起點
- 有環路,且環路回到了起點 (當前進程)

只有第三種情況,死鎖檢測算法才會報告出現死鎖。在后續處理中,通過取消當前進程的事務來破壞環路,解決死鎖。對於第二種情況,取消當前進程不會對解除死鎖有任何幫助,應當等 P2 進程發生死鎖超時觸發死鎖檢測時,從 P2 進程出發檢測到一個回到 P2 的環路,P2 進程才會嘗試回滾自己的事務,破壞這個環路。PostgreSQL 通過以下函數判斷從當前進程出發是否存在等待關系環路:
static bool
FindLockCycle(PGPROC *checkProc,
EDGE *softEdges, /* output argument */
int *nSoftEdges) /* output argument */
{
nVisitedProcs = 0;
nDeadlockDetails = 0;
*nSoftEdges = 0;
return FindLockCycleRecurse(checkProc, 0, softEdges, nSoftEdges);
}
static bool
FindLockCycleRecurse(PGPROC *checkProc,
int depth,
EDGE *softEdges, /* output argument */
int *nSoftEdges) /* output argument */
{
/* DFS */
}
等待隊列重排序
如果從當前進程出發檢測出了環路,在回滾自身事務之前,PostgreSQL 提供了一次 最后的掙扎機會:重新安排鎖等待隊列中進程的順序。如果新的鎖等待次序能夠消除圖中的環路,那么自身事務就無需回滾。這是 PG 死鎖檢測算法中最復雜的部分。
PostgreSQL 為區別於上述等待關系模型中的邊 (hard edge),提出了所謂 soft edge 的概念。它們的區別:
- Hard edge:位於鎖等待隊列中的進程 A 指向持有該鎖且 lock mode 沖突的進程 B (進程 B 已持有鎖)
- Soft edge:位於鎖等待隊列中的進程 A 指向位於同一等待隊列中的進程 B (進程 A、B 都未持有鎖)
舉個例子,假設:
- P1 正持有 4 級的 Lock A
- P2 請求持有 8 級的 Lock A,因 lock mode 沖突而進入等待隊列
- P3 請求持有 5 級的 Lock A,因 lock mode 沖突而進入等待隊列
LOCK A: [P1] --> P2 --> P3
L4 L8 L5
根據上述定義,從 P2 到 P1,從 P3 到 P1,各有一條 hard edge。而 P3 到 P2 雖然暫未形成 hard edge 關系,但是隨着后續 P1 釋放鎖,P2 持有鎖后,由於 P3 請求的 lock mode 與 P2 沖突,也將會形成一條 P3 到 P2 的 hard edge。因此,根據上述定義,P3 到 P2 之間形成了一條 soft edge:

Soft edge 指的是目前暫時還沒有形成,但是后續將會形成的等待關系。在檢測死鎖時,soft edge 比 hard edge 優先級低 (兩個進程間同時存在 hard edge 和 soft edge 時,認為它們之間是 hard edge 關系),但同樣是一條參與環路檢測的邊。由於 soft edge 等待關系中的兩個進程 (如 P2、P3) 暫時都還沒有獲得鎖,因此調換它們在等待隊列中的相對順序不會對圖中已有的任何 hard edge 有影響,但可能導致其它 soft edge 的增加或減少。如果能找到一種相對順序,使得圖中包括 hard edge 和 soft edge 在內的環路被打破,那么鎖等待隊列按照這個順序重排,就不再會產生死鎖,當前事務也不需要回滾了。這個算法有一定的開銷,但是在回滾一個事物的代價面前,還是值得試一試的。
舉個例子:有三把鎖 A、B、C,分別由四個事務 (T1、T2、T3、T4) 並發訪問,S/X 分別表示共享鎖/排他鎖。其訪問時序如下:
-------------------------------> time
Transaction 1 S(A)
Transaction 2 S(B) X(C)
Transaction 3 S(C) X(A)
Transaction 4 X(A) X(B)
假設事務已按上述時序獲取鎖后,以執行 T3 事務的進程作為起點,將會構造出如下所示的 wait-for graph:

其中 T3 對 Lock A 加排他鎖不僅導致 T3 到 T1 形成一條 hard edge,還導致 T3 到 T4 形成一條 soft edge:因為它們都位於 Lock A 的等待隊列中,並且請求的 lock mode 沖突 (排他鎖 vs 排他鎖)。如圖,T3、T4、T2 形成了環路。后續隨着 T1 commit,T4 獲得 Lock A,從 T3 到 T4 的 soft edge 將坐實為 hard edge:

此時,將不得不對 T3 進行 rollback,才能解除死鎖。那么如果對 Lock A 的等待隊列進行重排序呢?試着反轉從 T3 到 T4 的 soft edge 兩端進程的順序:

在等待隊列中將 T3 調整到 T4 之前,此時從 T3 到 T4 的 soft edge 沒有了。由於 T4 和 T3 的鎖請求 lock mode 依舊沖突 (排他鎖 vs 排他鎖),從 T4 到 T3 將會形成一條 soft edge。此時圖中不存在任何環路,因此無需回滾 T3。死鎖解除了,也沒有任何事務發生回滾。
死鎖檢測對 Group Locking 的支持
目前,PostgreSQL 支持並行執行,這意味着死鎖可能發生在幾個進程組之間,而不是幾個獨立的進程之間。這種設計並沒有給 PG 的死鎖檢測的算法帶來很多改動,因為 PG 目前規定同一個 parallel group 內的所有進程之間獲取鎖時不會發生沖突,同一個組內的進程可以同時對同一個表獲取自斥的鎖。如果不這樣設計,在組內進程之間很容易發生 自死鎖。
PG 在共享內存的進程數據結構 PGPROC 中維護了三個 field 支持 group locking:
lockGroupLeader- 當進程不參與並行執行時,該指針為空
- 當進程參與並行執行,並成為 parallel leader 時,該指針指向自己
- 當進程參與並行執行,並成為 parallel worker 時,該指針指向 parallel leader
lockGroupMembers當進程成為 parallel leader 時啟用,是維護 parallel group 內的 leader 和所有 parallel worker 的鏈表lockGroupLink指向進程自身在上述鏈表中的節點
/*
* Support for lock groups. Use LockHashPartitionLockByProc on the group
* leader to get the LWLock protecting these fields.
*/
PGPROC *lockGroupLeader; /* lock group leader, if I'm a member */
dlist_head lockGroupMembers; /* list of members, if I'm a leader */
dlist_node lockGroupLink; /* my member link, if I'm a member */
在死鎖檢測代碼中,判斷兩個進程是否存在等待關系時,會首先判斷兩個進程的 lockGroupLeader 是否相同 (兩個進程是否屬於同一個 parallel group)。如果兩個進程同屬一個 parallel group,那么這兩個進程之間的鎖請求不會發生沖突,因而不存在等待關系,跳過。尋找 hard edge 時的處理:
/* 獲取已經持有鎖的進程隊列,遍歷 (找 hard edge) */
procLocks = &(lock->procLocks);
proclock = (PROCLOCK *) SHMQueueNext(procLocks, procLocks,
offsetof(PROCLOCK, lockLink));
while (proclock)
{
PGPROC *leader;
/* 隊列中的等待進程,以及該進程所在組的 leader */
proc = proclock->tag.myProc;
pgxact = &ProcGlobal->allPgXact[proc->pgprocno];
leader = proc->lockGroupLeader == NULL ? proc : proc->lockGroupLeader;
/* A proc never blocks itself or any other lock group member */
/* 如果兩個進程屬於不同的 lock group (leader 不同),才可能存在等待關系 */
if (leader != checkProcLeader)
{
/* 檢測兩個進程之間是否存在 hard edge */
}
/* 下一個等待進程 */
proclock = (PROCLOCK *) SHMQueueNext(procLocks, &proclock->lockLink,
offsetof(PROCLOCK, lockLink));
}
尋找 soft edge 時的處理:
/* 獲取已經持有鎖的進程隊列,遍歷 (找 soft edge) */
for (i = 0; i < queue_size; i++)
{
PGPROC *leader;
/* 等待隊列上的進程,以及該進程所在組的 leader */
proc = procs[i];
leader = proc->lockGroupLeader == NULL ? proc :
proc->lockGroupLeader;
/* 如果兩個進程同屬一個 lock group (leader 相同),那么不可能發生鎖沖突,跳出 */
if (leader == checkProcLeader)
break;
/* Is there a conflict with this guy's request? */
/* 兩個進程屬於不同 lock group,則繼續檢測鎖沖突 */
if ((LOCKBIT_ON(proc->waitLockMode) & conflictMask) != 0)
{
/* 檢測兩個進程之間是否存在 soft edge */
}
}
