Deadlock ở PostgreSQL và MySQL — các trường hợp, cách nhận biết, phòng và chữa

Deadlock (khoá chết) là khi hai hay nhiều transaction mỗi bên đang giữ một khoá mà bên kia cần, và mỗi bên lại đứng chờ khoá của bên kia — một chu trình chờ không ai tự thoát được. Cả PostgreSQL và MySQL InnoDB đều không để chu trình đó kéo dài mãi: chúng phát hiện ra và huỷ một transaction (victim — nạn nhân) để transaction còn lại chạy tiếp. Vì vậy deadlock không làm treo hệ thống, nhưng làm một thao tác nghiệp vụ thất bại giữa chừng — và nếu app không chạy lại đúng cách, kết quả là mất giao dịch, ghi một nửa, hoặc gửi email hai lần.

Bài này đi theo thứ tự: cơ chế (mục 1) → từng trường hợp deadlock đã tái hiện ở PostgreSQL (mục 2) và MySQL (mục 3) → hai DB khác nhau ở đâu với cùng một thao tác (mục 4) → cách nhận biết và runbook lúc trực (mục 5) → thiết kế để không bị (mục 6) → xử lý khi đã xảy ra, gồm lab Laravel 8 worker chuyển tiền và retry (mục 7) → áp vào LMS Uni (mục 8) → anti-patterns, câu hỏi phỏng vấn, phần chưa verify và phụ lục lab.

Môi trường lab: PostgreSQL 18.4 (container my-test-pgdb, DB riêng tmp_dl_pg / tmp_dl_app, chỉ dùng SET cấp session), MySQL 8.4.13 (container tạm, mặc định REPEATABLE READ, innodb_lock_wait_timeout 50), PHP 8.4.27 + Laravel 13.40.0. Mỗi trường hợp chạy 2–5 lần, số lần ghi dạng X/N; mọi khối output là dòng nguyên văn từ labs/database/deadlock/*/out/*.log. Lab gap lock gốc (một transaction khoá khoảng trống, người khác chèn vào phải chờ) đã có ở bài PostgreSQL vs MySQL, mục 4.4 — bài này không lặp lại, chỉ đi tiếp sang chuyện gap lock gây deadlock.

Video 50 giây, số liệu lab thật (PostgreSQL 18.4 · MySQL 8.4.13): (1) chu trình chờ — S1 giữ id=1 chờ id=2, S2 giữ id=2 chờ id=1; (2) PostgreSQL chờ deadlock_timeout rồi session chờ lâu nhất tự kiểm tra và tự huỷ (40P01); (3) InnoDB phát hiện ngay lúc phải chờ, huỷ transaction nhẹ hơn chứ không phải bên khép chu trình (1213), so với 1205 chỉ rollback câu lệnh; (4) cách chữa — khoá cùng thứ tự id: 0 deadlock, kèm 5 nguyên tắc.

1. Deadlock là gì: chu trình chờ khoá

Deadlock (khoá chết) là lúc hai hay nhiều transaction chờ nhau thành một vòng kín. Transaction A giữ khoá X và đang chờ khoá Y. Transaction B giữ khoá Y và đang chờ khoá X. Không bên nào tự nhả khoá được: muốn nhả thì phải chạy xong, mà muốn chạy xong thì phải có được cái khoá đang nằm trong tay bên kia. Database không can thiệp thì cả hai chờ mãi. Vì vậy cả PostgreSQL lẫn InnoDB đều có deadlock detector (bộ phát hiện deadlock). Nó đi tìm vòng kín đó, chọn một transaction trong vòng làm victim (nạn nhân, transaction bị huỷ) rồi huỷ nó để các transaction còn lại chạy tiếp.

Tài liệu PostgreSQL 18 có hai câu mà cả phần này dựa vào. Câu thứ nhất: "deadlocks can also occur as the result of row-level locks (and thus, they can occur even if explicit locking is not used)". Nghĩa là chỉ cần hai câu UPDATE bình thường cũng đủ gây deadlock, không cần ai gõ LOCK TABLE. Câu thứ hai: PostgreSQL "automatically detects deadlock situations and resolves them by aborting one of the transactions involved", nhưng transaction nào bị huỷ thì "is difficult to predict and should not be relied upon". Mục 1.3 sẽ cho thấy lab vẫn điều khiển được victim, và vì sao không nên đem điều đó vào thiết kế.

1.1. Lock wait, hàng đợi khoá và deadlock khác nhau thế nào

Khi có sự cố, người ta hay gọi chung ba hiện tượng khác nhau là "bị deadlock". Ba hiện tượng đó có nguyên nhân khác nhau, cách database xử lý khác nhau và cách sửa cũng khác nhau, nên phải tách ra từ đầu.

1. Lock wait (chờ khoá) là chuyện bình thường. Lab 08-detection-ops (phần 2a): S1 chạy UPDATE tmp_dl_ops … WHERE id = 1 rồi đứng yên, không commit. S2 sửa đúng dòng đó nên phải chờ. Session MON chụp lại trạng thái lúc S2 đang chờ. Dưới đây là phần trích: đã bỏ header các bảng, các dòng khoá relation và vài dòng log nối tiếp.

[t=+  0.20s][S1] > UPDATE tmp_dl_ops SET balance = balance - 1 WHERE id = 1;
[t=+  0.21s][S1]   UPDATE 1
[t=+  0.40s][S2] > UPDATE tmp_dl_ops SET balance = balance - 2 WHERE id = 1;
[t=+  1.63s][MON]    S1  | 2047871 | idle in transaction | Client          | ClientRead    | {}         |       1.4 | UPDATE tmp_dl_ops SET balance = balance - 1 W
[t=+  1.63s][MON]    S2  | 2047879 | active              | Lock            | transactionid | {2047871}  |       1.2 | UPDATE tmp_dl_ops SET balance = balance - 2 W
[t=+  1.64s][MON]    S1  | transactionid | ExclusiveLock    | t       | 723577 |            |      |
[t=+  1.64s][MON]    S2  | transactionid | ShareLock        | f       | 723577 |            |      |
[t=+  1.64s][MON]    S2  | tuple         | ExclusiveLock    | t       |        | tmp_dl_ops |    0 |     3
[t=+  2.50s][S1] > COMMIT;
[t=+  2.50s][S2]   UPDATE 1
[t=+  2.50s][S2]   (returned after 2.10s)
  | 2026-11-05 10:09:50.303 UTC [2047879] LOG:  process 2047879 still waiting for ShareLock on transaction 723577 after 1000.766 ms
  | 2026-11-05 10:09:50.303 UTC [2047879] DETAIL:  Process holding the lock: 2047871. Wait queue: 2047879.
Cách nói khi phỏng vấn: "Đây là một lần chờ khoá bình thường, không có gì sai. Session thứ hai muốn sửa dòng mà session thứ nhất đã sửa nhưng chưa commit, nên nó đứng chờ. Điểm cần để ý là session đang giữ khoá không chạy câu nào cả, nó đang ngồi không trong một transaction còn mở. Bên thứ nhất commit thì bên thứ hai chạy tiếp ngay, tổng cộng chờ khoảng hai giây."

Có bốn điều đọc được từ output trên. (a) S2 có wait_event_type = Lock, wait_event = transactionid, còn pg_blocking_pids trả về pid của S1. (b) Ở PostgreSQL, chờ một dòng thực chất là chờ transaction đang giữ dòng đó kết thúc: S2 xin ShareLock trên transactionid 723577, tức xid (transaction id) của S1, và chưa được cấp (f). Cùng lúc S2 đang giữ khoá tuple, nghĩa là S2 đứng đầu hàng cho dòng (0,3). (c) Bên giữ khoá ở trạng thái idle in transaction: nó không chạy câu nào, nó chỉ chưa đóng transaction. (d) Sau 1 giây (deadlock_timeout), S2 chạy deadlock check, không thấy vòng nào, nên chỉ ghi một dòng log still waiting rồi chờ tiếp. S1 commit lúc 2.50s thì S2 xong, tổng thời gian chờ 2.10s. Không đặt lock_timeout thì S2 sẽ chờ vô hạn. Tài liệu PostgreSQL viết rõ: "So long as no deadlock situation is detected, a transaction seeking either a table-level or row-level lock will wait indefinitely for conflicting locks to be released."

2. Hàng đợi khoá (lock queue) là xếp hàng sau một người cũng đang chờ. Lab P6-B: S1 mở transaction, chạy SELECT count(*) (giữ AccessShareLock trên bảng) rồi đứng yên. S2 chạy migration ALTER TABLE … ADD COLUMN, cần AccessExclusiveLock. S3 đến sau và chỉ chạy một câu SELECT count(*) thường. Phần trích dưới đây đã bỏ header bảng và các dòng gửi lệnh:

[t=+  1.52s][MON]    S1  | idle in transaction | Client          | ClientRead | {}         |    1.4 | SELECT count(*) FROM tmp_dl_t;
[t=+  1.52s][MON]    S2  | active              | Lock            | relation   | {2047411}  |    1.1 | ALTER TABLE tmp_dl_t ADD COLUMN c_new int;
[t=+  1.52s][MON]    S3  | active              | Lock            | relation   | {2047419}  |    0.7 | SELECT count(*) FROM tmp_dl_t;
[t=+  1.53s][MON]    S1  | relation | AccessShareLock     | t       | tmp_dl_t | {}
[t=+  1.53s][MON]    S2  | relation | AccessExclusiveLock | f       | tmp_dl_t | {2047411}
[t=+  1.53s][MON]    S3  | relation | AccessShareLock     | f       | tmp_dl_t | {2047419}
[t=+  4.01s][S3]   (returned after 3.21s)
S3 (a plain SELECT) waited 3.21s
  | 2026-11-05 10:07:40.896 UTC [2047419] DETAIL:  Process holding the lock: 2047411. Wait queue: 2047419, 2047426.
Cách nói khi phỏng vấn: "Câu SELECT thứ ba chỉ cần khoá đọc, mà khoá đọc thì không xung đột với transaction đầu tiên. Nó vẫn phải chờ, vì đứng trước nó trong hàng là câu ALTER đang xin khoá độc quyền, và PostgreSQL không cho ai chen hàng. Vậy là một câu SELECT trên trăm dòng mất hơn ba giây. Đây chưa phải deadlock, vì chuỗi chờ dừng ở một transaction không chờ ai. Nhưng ở production, nhìn từ ngoài nó giống hệt sự cố sập bảng."

S3 cần AccessShareLock, loại khoá tương thích với khoá S1 đang giữ. Nhưng blocked_by của S3 là 2047419, tức pid của S2 chứ không phải S1. Tài liệu pg_blocking_pids gọi đây là soft block (chặn mềm): một process "is waiting for a lock that would conflict with the blocked process's lock request and is ahead of it in the wait queue". Dòng log Wait queue: 2047419, 2047426 cho thấy đúng thứ tự trong hàng: S2 rồi S3. Đây không phải deadlock. Đồ thị chờ là một chuỗi S3 → S2 → S1, còn S1 không chờ ai, nên chuỗi tự tan khi S1 commit (S3 xong lúc 4.01s). Ở production chuỗi này lại đáng sợ hơn deadlock: deadlock detector không có gì để huỷ, vì không có vòng nào. Một transaction quên commit cộng với một migration là đủ chặn mọi câu đọc vào bảng đó. Cách chữa (lock_timeout cho DDL) nằm ở mục 2.6.

3. Deadlock là vòng kín. Mỗi transaction trong vòng vừa giữ một thứ người khác cần, vừa chờ một thứ người khác giữ. Vòng này không bao giờ tự tan. Chờ thêm bao lâu cũng vô ích, nên database buộc phải huỷ một bên.

Lock waitHàng đợi khoáDeadlock
Hình dạng đồ thị chờmột mũi tên A → Bchuỗi C → B → A (có thể có cạnh "mềm" do thứ tự hàng)vòng kín A → B → A
Tự hết khi nàokhi B commit / rollbackkhi transaction ở đầu chuỗi kết thúckhông bao giờ
Database có tự can thiệp khôngkhông (trừ khi app đặt lock_timeout)khôngcó: PG sau deadlock_timeout, InnoDB ngay lúc bắt đầu chờ
App nhận lỗi gìkhông có lỗi, chỉ chậm (hoặc 55P03 nếu có lock_timeout)không có lỗi, chỉ chậmPG 40P01; MySQL 1213
Nhìn thấy ở đâupg_stat_activity, pg_blocking_pids, log still waitingnhư cột bên trái, cộng Wait queue: trong loglog deadlock detected, pg_stat_database.deadlocks
flowchart LR
  subgraph W["Lock wait"]
    W2["S2 đang chờ"] -->|"chờ xid của S1"| W1["S1 idle in transaction"]
  end
  subgraph Q["Hàng đợi khoá"]
    Q3["S3 SELECT"] -->|"soft: đứng sau S2"| Q2["S2 ALTER"]
    Q2 -->|"hard: S1 giữ AccessShare"| Q1["S1 idle in transaction"]
  end
  subgraph D["Deadlock"]
    D1["S1 giữ id=1"] -->|"chờ id=2"| D2["S2 giữ id=2"]
    D2 -->|"chờ id=1"| D1
  end

1.2. Bốn điều kiện của deadlock và vì sao database chọn "phát hiện rồi huỷ"

Lý thuyết hệ điều hành đưa ra bốn điều kiện Coffman: deadlock chỉ xảy ra khi cả bốn cùng đúng một lúc. Mỗi cách chữa ở phần sau thực chất là phá một trong bốn điều kiện này. Nhớ được cách chữa nào phá điều kiện nào thì khi phỏng vấn sẽ trả lời được câu "vì sao cách này hết deadlock", thay vì chỉ thuộc lòng "hãy sort id".

  1. Mutual exclusion (loại trừ lẫn nhau). Tài nguyên bị giữ ở chế độ mà người khác không dùng chung được, ví dụ khoá dòng khi UPDATE hay AccessExclusiveLock khi ALTER. Hai khoá chia sẻ thì không chặn nhau: hai FOR SHARE cùng tồn tại được trên một dòng (P3), hai FOR KEY SHARE cũng vậy (P4). Muốn có deadlock thì phải có ít nhất một yêu cầu khoá độc quyền.

  2. Hold and wait (giữ và chờ). Transaction đang giữ khoá lấy từ câu trước, rồi xin thêm khoá mới. Transaction nhiều câu lệnh nào cũng ở trạng thái này. Một câu lệnh đơn lẻ cũng có thể như vậy, vì nó khoá từng dòng một: P2 và P5-D deadlock chỉ với hai câu lệnh.

  3. No preemption (không tước được). Database không lấy lại khoá từ một transaction đang sống. Tài liệu PG: "Once acquired, a lock is normally held until the end of the transaction." (ngoại lệ: rollback về savepoint thì khoá lấy sau savepoint được nhả).

  4. Circular wait (chờ vòng tròn). Các mũi tên "ai chờ ai" khép thành vòng kín. Ba điều kiện trên có ở hầu hết mọi hệ thống OLTP. Điều kiện này là thứ thiết kế của mình kiểm soát được.

Điều kiện bị pháCách chữa tương ứng (chi tiết ở mục 6)Ví dụ trong bàiCái giá
Circular waitMọi đường code khoá theo cùng một thứ tự: sort id, khoá cha trước con, lấy advisory key tăng dầnfix của P1, P2, P4, P5, P7Phải là quy ước của cả team. Chỉ cần một đường code lệch thứ tự là deadlock quay lại.
Hold and wait (phần "nâng cấp khoá")Xin ngay chế độ mạnh nhất sẽ cần, thay vì xin yếu trước rồi nâng saufix của P3 (FOR UPDATE), P6 (LOCK TABLE … ACCESS EXCLUSIVE đầu tiên)Giảm song song: người đến sau phải chờ ngay từ câu đầu
No preemption (app tự rút lui)lock_timeout / NOWAIT: không chờ quá N ms, tự bỏ cuộcfix lock queue P6-B; lab 08 phần 4Deadlock biến thành 55P03 và không được đếm vào pg_stat_database.deadlocks. Metric báo thiếu.
Mutual exclusionDùng chế độ khoá yếu hơn (không xung đột), hoặc không khoá: một câu UPDATE nguyên tử, đọc MVCC không FOR UPDATEP4-A: FOR NO KEY UPDATE không đụng FOR KEY SHARE, 0/3 deadlockPhải hiểu rõ bảng xung đột của từng chế độ. Bỏ khoá thì phải chứng minh logic vẫn đúng.
Gom nhiều khoá thành mộtKhoá thô: advisory lock theo khoá nghiệp vụ, bảng "semaphore" một dòng, đưa qua queue một worker(mục 6)Thông lượng bị giới hạn bởi đoạn chạy tuần tự. Người giữ khoá chậm sẽ kéo cả đoàn phía sau chậm theo (convoy).

Vì sao database chọn "phát hiện rồi huỷ" thay vì "ngăn từ đầu". Có ba lý do, cả ba đều thấy trong lab:

  1. Database không biết trước một câu SQL sẽ khoá những gì, theo thứ tự nào. Muốn ngăn deadlock từ đầu (khoá mọi thứ theo một thứ tự toàn cục) hoặc né deadlock (kiểu thuật toán banker, bắt mỗi transaction khai báo trước nhu cầu tối đa) thì phải biết trước tập khoá. Với SQL, tập khoá phụ thuộc vào plan: ở P2 cùng 8 dòng nhưng Index Scan đi 1→8, Seq Scan đi 8→1. Nó phụ thuộc vào dữ liệu: kiểm tra FK chạm dòng cha (P4), kiểm tra unique chạm transaction khác (P5). Nó còn phụ thuộc vào trigger. Ở tầng database những thứ này không biết trước được.

  2. Deadlock hiếm hơn lock wait rất nhiều, nên kiểm tra mọi lần chờ là phí. PostgreSQL chọn "optimistic waiting" (chờ lạc quan): "if a process cannot acquire the lock it wants immediately, it goes to sleep without any deadlock check. But it also sets a delay timer…" (README của lmgr, REL_18_STABLE).

  3. Cái giá khi xảy ra thì có giới hạn: chỉ huỷ một transaction, các transaction khác chạy tiếp, app chạy lại (retry) bên bị huỷ. Hai nhà cung cấp đều nói vậy. MySQL: "Always be prepared to re-issue a transaction if it fails due to deadlock. Deadlocks are not dangerous. Just try again." PG: "deadlocks can be handled on-the-fly by retrying transactions that abort due to deadlocks". Người vận hành thì nhìn khác: incident.io viết deadlock "are usually not seen as transient or load-inflicted errors, rather they are indications of poorly structured logic", còn Cybertec viết "There is NO MAGIC CONFIGURATION PARAMETER to fix this problem". Cả hai cách nhìn đều đúng: retry dùng cho deadlock hiếm và không tránh được, còn deadlock lặp lại đều đặn là lỗi thiết kế phải sửa bằng thứ tự khoá.

1.3. PostgreSQL phát hiện deadlock thế nào

1. Mô hình: waits-for graph (đồ thị ai-chờ-ai). README của lock manager viết: "There is a graph edge leading from process A to process B if A waits for B, ie, A is waiting for some lock and B holds a conflicting lock. There is a deadlock condition if and only if the WFG contains a cycle." Mỗi node là một backend (process phục vụ một kết nối). Đây là lý do thông báo lỗi luôn nói bằng Process <pid>, không nói bằng tên session.

2. Lúc nào kiểm tra: chỉ sau deadlock_timeout. Lúc bắt đầu chờ, PG không kiểm tra gì, chỉ hẹn giờ. Sau deadlock_timeout (mặc định 1s, trong lab là deadlock_timeout | 1000 | ms) mà vẫn chưa có khoá thì backend đó mới chạy deadlock check. Việc kiểm tra tốn kém: CheckDeadLock() trong proc.c phải lấy mọi partition lock của bảng khoá ở chế độ exclusive. Vì vậy tài liệu khuyên tăng chứ không giảm giá trị này trên server tải nặng: "Ideally the setting should exceed your typical transaction time". Hệ quả nhìn thấy trong mọi lab dùng khoá dòng: thời điểm phát hiện = lúc victim bắt đầu chờ + 1s. Ở P1, S1 bắt đầu chờ lúc 0.60s và nhận lỗi lúc 1.60s.

3. Ai kiểm tra và ai bị huỷ: chính backend đang chờ, và nó tự huỷ mình. Backend có bộ hẹn giờ vừa hết chạy DeadLockCheck(), lấy chính mình làm điểm xuất phát (start point), và chỉ quan tâm vòng đi qua mình. Thấy vòng cứng thì nó huỷ yêu cầu khoá của chính nó: "We resolve such a deadlock by canceling the start point's lock request and reporting an error in that transaction". Vậy victim là backend có bộ hẹn giờ hết trước trong lúc vòng đang tồn tại. README nói thêm hai ý: "If earlier waiters in the cycle have not yet run CheckDeadLock, then the first one to do so will be the victim", và backend cuối cùng khép vòng thì "is certain to detect and resolve the cycle when it later runs CheckDeadLock". Mỗi lần chờ, bộ hẹn giờ chỉ chạy một lần. Nếu lúc nó hết giờ chưa có vòng, backend chỉ ghi log still waiting (khi log_lock_waits = on) và không kiểm tra lại nữa.

4. Cạnh cứng, cạnh mềm và sắp lại hàng đợi. Cạnh cứng (hard edge) là khi B đang giữ khoá xung đột với yêu cầu của A. Cạnh mềm (soft edge) là khi A đứng sau B trong hàng chờ của cùng một khoá và hai yêu cầu xung đột nhau (giống S3 sau S2 ở mục 1.1). README: "we may be able to resolve a soft block without aborting the transactions involved: we can instead rearrange the order of the wait queue". Chỉ khi không sắp lại được (DS_HARD_DEADLOCK) thì mới huỷ transaction. Lab thấy được cạnh mềm qua pg_blocking_pids, còn việc sắp lại hàng đợi để tránh huỷ thì chưa verify (nguồn: README và deadlock.c, REL_18_STABLE).

5. Lab điều khiển victim (V1–V4). Vì victim là "người có bộ hẹn giờ hết trước trong lúc vòng tồn tại", lab dựng bốn kịch bản AB/BA, đổi khoảng cách giữa hai lần chờ và đổi deadlock_timeout riêng từng session. Mỗi kịch bản chạy 3 lần. Khối output bên dưới gồm bốn dòng tổng kết của lab và hai dòng server log của V2, các dòng khác đã bỏ.

Kịch bảnDiễn biếnVictimX/N
V1: cả hai 1s, S1 chờ trước, S2 khép vòng sau 0.4sS1 chờ từ 0.6s, bộ hẹn giờ hết lúc 1.6s. Lúc đó vòng đã có (từ 1.0s).S13/3
V2: cả hai 1s, S2 khép vòng sau 1.5sS1 hết giờ lúc 1.6s, chưa có vòng, chỉ log still waiting. S2 khép vòng lúc 2.1s, hết giờ lúc 3.1s.S23/3
V3: S1 SET deadlock_timeout = '5s', S1 chờ trướcS2 chờ từ 1.0s, hết giờ lúc 2.0s, trước S1 (5.6s)S23/3
V4: S2 đặt 5s, S2 chờ trướcS1 khép vòng lúc 1.0s, hết giờ lúc 2.0sS13/3
V1 1s/1s, S1 first, gap 0.4s: victim == S1 in 3/3 runs
V2 1s/1s, S1 first, gap 1.5s: victim == S2 in 3/3 runs
V3 S1=5s, S1 first, gap 0.4s: victim == S2 in 3/3 runs
V4 S2=5s, S2 first, gap 0.4s: victim == S1 in 3/3 runs
  | 2026-11-05 10:10:32.679 UTC [2048226] LOG:  process 2048226 still waiting for ShareLock on transaction 723605 after 1000.269 ms
  | 2026-11-05 10:10:34.178 UTC [2048232] LOG:  process 2048232 detected deadlock while waiting for ShareLock on transaction 723604 after 1000.251 ms
Cách nói khi phỏng vấn: "Trong PostgreSQL, deadlock không được phát hiện ngay. Session nào chờ quá một giây thì tự đi tìm vòng, và nếu thấy vòng thì tự huỷ chính mình. Nên nạn nhân là session có đồng hồ hết giờ đầu tiên trong lúc vòng đã hình thành. Lab đổi khoảng cách thời gian và đổi tham số riêng từng session thì điều khiển được nạn nhân ba trên ba lần. Nhưng tài liệu nói rõ không được dựa vào chuyện ai bị huỷ, nên code phải coi bên nào cũng có thể là nạn nhân."

Hai dòng log cuối là bằng chứng cho V2. Backend 2048226 (S1) hết giờ lúc vòng chưa có nên chỉ ghi still waiting. 1.5 giây sau, backend 2048232 (S2) hết giờ và mới là bên ghi detected deadlock. Có ba lưu ý quan trọng. (a) Tài liệu PG 18 vẫn viết "Exactly which transaction will be aborted is difficult to predict and should not be relied upon". Kết quả lab đúng trong điều kiện thời gian được dựng có chủ đích, chỉ với vòng hai session và khoá dòng. Vòng 3+ session chưa test. (b) deadlock_timeout là tham số superuser (PGC_SUSET, trong mã nguồn có chú thích "to prevent hiding from log_lock_waits"), nên user ứng dụng thường không tự đặt được. (c) Muốn một job nền "nhường" cho request người dùng, cách user-level đã lab là cho job đó SET lock_timeout ngắn hơn deadlock_timeout. Lab 08 phần 4: S1 đặt lock_timeout = '500ms' và nhận 55P03 lúc 1.10s, S2 chạy tiếp, 2/2. Cái giá là pg_stat_database.deadlocks không tăng, nên metric deadlock báo thiếu (xem mục 5).

sequenceDiagram
  participant S1
  participant S2
  Note over S1,S2: V1 - cả hai deadlock_timeout 1s
  S1->>S2: t=0.6 S1 chờ dòng S2 giữ, hẹn giờ tới 1.6
  S2->>S1: t=1.0 S2 chờ dòng S1 giữ, vòng khép, hẹn giờ tới 2.0
  Note over S1: t=1.6 S1 kiểm tra, thấy vòng, tự huỷ, nhận 40P01
  Note over S2: S2 được khoá ngay, không cần tới mốc 2.0
  Note over S1,S2: V2 - S2 khép vòng muộn 1.5s
  S1->>S2: t=0.6 S1 chờ, hẹn giờ tới 1.6
  Note over S1: t=1.6 chưa có vòng, chỉ log still waiting, không kiểm tra lại
  S2->>S1: t=2.1 S2 khép vòng, hẹn giờ tới 3.1
  Note over S2: t=3.1 S2 kiểm tra, thấy vòng, tự huỷ

6. Điều bất ngờ ở P6: deadlock khoá bảng bị phát hiện ngay, không chờ 1 giây. Ở P6-A (cả hai session SELECT rồi cả hai ALTER TABLE), lỗi đến đúng lúc S2 gửi lệnh (t=1.00s). Victim luôn là S2, bên khép vòng (3/3), và không có dòng log detected deadlock while waiting vì S2 chưa hề ngủ chờ. Output nằm ở mục 2.6. Lời giải thích sau đây lấy từ đọc mã nguồn ProcSleep(), không có trong tài liệu, là suy luận: trước khi ngủ, PG kiểm tra sớm một trường hợp đặc biệt. S2 đã giữ một khoá (AccessShare) xung đột với yêu cầu đang xếp hàng của S1 trên cùng một đối tượng khoá, và yêu cầu của S2 lại xung đột với khoá S1 đang giữ, nên S2 bị từ chối ngay. Các deadlock khoá dòng/xid (P1–P5, P7) nằm trên những đối tượng khoá khác nhau, và lần nào cũng chờ đủ deadlock_timeout.

7. Sau khi phát hiện. Transaction của victim bị abort (huỷ) và khoá của nó được nhả ngay lúc lỗi được raise, không đợi đến khi app gửi ROLLBACK. Ở P1, câu UPDATE đang chờ của S2 trả về cùng thời điểm 1.60s. Session victim vẫn ở trong một transaction hỏng: câu kế tiếp nhận 25P02, còn COMMIT thì trả về ROLLBACK (mục 2.1). Bộ đếm pg_stat_database.deadlocks tăng 1 cho mỗi lần 40P01, nhưng được flush bất đồng bộ: trong lab, session khác thấy giá trị mới sau 0.12–0.24s.

1.4. InnoDB phát hiện deadlock thế nào

1. Phát hiện ngay lúc bắt đầu chờ. Khác PG, InnoDB không có bước chờ deadlock_timeout. Manual 8.4: "InnoDB detects deadlocks immediately and rolls back one of the deadlocked transactions", và innodb_lock_wait_timeout không áp dụng cho deadlock khi detection đang bật. Từ 8.0.18, việc phát hiện chạy trên một background thread riêng, làm việc trên bản sao của đồ thị chờ (blog kỹ sư InnoDB: "As the algorithm works on a copy you don’t need to “stop the world” when it runs"). Trên thực tế độ trễ gần như bằng 0. Lab MySQL 8.4.13 (12-victim-choice, kịch bản c2): S2 khép vòng lúc 1.60s, S1 nhận lỗi lúc 1.61s. Đây là phần trích lần chạy 1/2: đã bỏ các dòng setup, header của bảng INNODB_TRX và dòng COMMIT. Dòng cuối lấy từ SHOW ENGINE INNODB STATUS của cùng lần chạy.

[t=+ 0.10s][S2] UPDATE heavy SET x = x + 1
[t=+ 0.10s][S2]   -> OK, 200 row(s) affected (0.00s)
[t=+ 0.80s][S1] UPDATE acc SET balance = balance + 10 WHERE id = 2   -- S1 waits (first waiter)
[t=+ 1.30s][MON]    | pid | trx_id | trx_state | trx_weight | trx_rows_modified | trx_rows_locked | trx_lock_structs |
[t=+ 1.30s][MON]    | 44  | 2101   | RUNNING   | 205        | 201               | 202             | 4                |
[t=+ 1.30s][MON]    | 43  | 2102   | LOCK WAIT | 4          | 1                 | 2               | 3                |
[t=+ 1.60s][S2] UPDATE acc SET balance = balance + 20 WHERE id = 1   -- S2 closes the cycle
[t=+ 1.61s][S2]   -> OK, 1 row(s) affected (0.01s)
[t=+ 1.61s][S1]   -> ERROR 1213 (40001): Deadlock found when trying to get lock; try restarting transaction  (0.81s)
[t=+ 1.61s][S1]      server view: SERVER_STATUS_IN_TRANS=False; no INNODB_TRX row for this connection
*** WE ROLL BACK TRANSACTION (1)
Cách nói khi phỏng vấn: "Bên MySQL, vòng vừa khép là bị phát hiện ngay, trong vòng chục mili giây, không có một giây chờ như PostgreSQL. Nạn nhân cũng không phải bên khép vòng. InnoDB chọn transaction nhẹ hơn, ở đây là bên mới sửa một dòng, còn bên đã sửa hai trăm dòng được giữ lại. Bên bị chọn bị rollback cả transaction, server xác nhận nó không còn ở trong transaction nữa."

2. Victim = transaction "nhẹ" hơn theo trx_weight. Manual: "InnoDB tries to pick small transactions to roll back, where the size of a transaction is determined by the number of rows inserted, updated, or deleted." Blog gọi tên con số cụ thể: INFORMATION_SCHEMA.INNODB_TRX.TRX_WEIGHT. Lab giữ cố định "S1 chờ trước, S2 khép vòng" và chỉ đổi độ nặng, mỗi kịch bản 2 lần:

Kịch bảntrx_weight S1 / S2 ngay trước khi khép vòngVictimX/N
c0 cả hai nhẹ4 (LOCK WAIT) / 3S2 (WE ROLL BACK TRANSACTION (2))2/2
c1 S1 (bên chờ) đã sửa 200 dòng206 / 3S22/2
c2 S2 (bên khép vòng) đã sửa 200 dòng4 / 205S1, bên không khép vòng2/2
c3 S2 khoá 200 dòng FOR UPDATE, chỉ sửa 14 / 5 (trx_rows_locked 202)S12/2

Trong mọi dòng lab in ra, trx_weight khớp với rows_modified + lock_structs (206 = 201 + 5, 4 = 1 + 3, 5 = 1 + 4). Đây là quan sát, chưa đối chiếu với mã nguồn. Hệ quả thực tế rút ra từ c3: 200 khoá dòng trên cùng một page chỉ tính là một lock struct. Vì vậy một transaction khoá rất nhiều nhưng sửa ít vẫn bị coi là nhẹ. Thứ giữ một transaction sống sót là số dòng nó đã ghi. Với c0, khi hai bên nặng ngang nhau, bên xin khoá (S2) bị rollback. Đây là suy luận, chưa verify trong mã nguồn. Giống PG, đây vẫn là hành vi quan sát được chứ không phải hợp đồng để thiết kế. Quy tắc riêng cho transaction ưu tiên cao của Group Replication chưa test.

3. Giới hạn độ sâu tìm kiếm. Manual 8.4 vẫn ghi: khi danh sách wait-for vượt 200 transaction, hoặc phải xét hơn 1.000.000 khoá, InnoDB coi đó là deadlock và rollback transaction đang kiểm tra, kèm thông báo TOO DEEP OR LONG SEARCH IN THE LOCK TABLE WAITS-FOR GRAPH, WE WILL ROLL BACK FOLLOWING TRANSACTION. Nhưng blog 8.0.18 nói các giới hạn bước "cứng" thuộc về thuật toán DFS cũ. Hai nguồn này không khớp nhau. Lab chưa verify bản 8.4 còn áp dụng giới hạn này hay không, nên chỉ coi đây là "có trong tài liệu, có thể đã lỗi thời".

4. Tắt được phát hiện. innodb_deadlock_detect = OFF tắt hẳn việc phát hiện, khi đó deadlock chỉ được gỡ bởi innodb_lock_wait_timeout (mặc định 50 giây). Lab, tranh luận giữa manual và kỹ sư InnoDB, và cái bẫy mất dữ liệu đi kèm nằm ở mục 7.

PostgreSQL 18.4InnoDB (MySQL 8.4.13)
Khi nào kiểm trasau deadlock_timeout (1s) kể từ lúc bắt đầu chờ, một lần mỗi lần chờngay khi một yêu cầu khoá phải chờ
Ai kiểm trachính backend đang chờbackground thread, chạy trên bản sao đồ thị (từ 8.0.18)
Victimbackend có bộ hẹn giờ hết trước trong lúc vòng tồn tại (nó tự huỷ mình)transaction có trx_weight nhỏ hơn
Lỗi trả về40P01, transaction vẫn mở ở trạng thái hỏng cho tới khi app ROLLBACK1213 / SQLSTATE 40001, cả transaction đã bị rollback sẵn
Ngoại lệ đã thấyvòng nâng cấp khoá bảng (P6) bị từ chối ngay, victim là bên khép vòngvòng quá sâu (200 / 1.000.000) theo manual, chưa verify

2. Các trường hợp deadlock ở PostgreSQL

Mọi output trong phần này chạy thật trên PostgreSQL 18.4 (container my-test-pgdb, database riêng tmp_dl_pg, isolation mặc định READ COMMITTED, deadlock_timeout = 1s). log_lock_waits = on do một lần ALTER SYSTEM có từ trước lab. Mặc định của PG 18 là off, nên các dòng log still waiting / detected deadlock while waiting sẽ không có trên server mặc định. Mỗi session là một kết nối psql sống suốt kịch bản, chạy \set VERBOSITY verbose để lỗi in kèm SQLSTATE và LOCATION. Cách đọc timeline: dòng [t=+X.XXs][S1] > câu lệnh là lúc gửi lệnh, các dòng khác là output theo thời điểm nhận được, (returned after N s) là thời gian câu lệnh chạy. Muốn chạy lại: cd labs/database/deadlock/pg && bash 00-setup.sh && bash 01-row-opposite-order.sh (… tới 08-detection-ops.sh, cuối cùng bash 99-cleanup.sh). Mỗi kịch bản race chạy ít nhất 2–3 lần, kết quả ghi dạng X/N.

2.1. P1 — Hai transaction khoá hai dòng theo thứ tự ngược nhau

Bối cảnh nghiệp vụ (ví dụ minh hoạ). Ví điện tử nội bộ có hai lệnh chuyển tiền chạy cùng lúc: tài khoản 1 chuyển 10 cho tài khoản 2, tài khoản 2 chuyển 20 cho tài khoản 1. Code chuyển tiền nào cũng "trừ người gửi trước, cộng người nhận sau", rất tự nhiên, nhưng chính cách viết đó làm hai transaction khoá hai dòng theo thứ tự ngược nhau.

CREATE TABLE tmp_dl_acc (id int PRIMARY KEY, balance int NOT NULL);
INSERT INTO tmp_dl_acc VALUES (1, 1000), (2, 1000);

-- S1 chuyển 10 từ 1 sang 2, S2 chuyển 20 từ 2 sang 1; cả hai đã BEGIN
-- t=0.2 S1:
UPDATE tmp_dl_acc SET balance = balance - 10 WHERE id = 1;
-- t=0.4 S2:
UPDATE tmp_dl_acc SET balance = balance - 20 WHERE id = 2;
-- t=0.6 S1 (phải chờ S2):
UPDATE tmp_dl_acc SET balance = balance + 10 WHERE id = 2;
-- t=1.0 S2 (khép vòng):
UPDATE tmp_dl_acc SET balance = balance + 20 WHERE id = 1;
-- sau đó mỗi bên COMMIT
Cách nói khi phỏng vấn: "Hai lệnh chuyển tiền đi ngược chiều nhau. Mỗi bên trừ tài khoản của mình trước, nên mỗi bên giữ khoá một dòng. Sau đó mỗi bên đi cộng tiền vào tài khoản của bên kia, đúng dòng bên kia đang giữ. Bên thứ nhất chờ bên thứ hai, bên thứ hai chờ lại bên thứ nhất, thành một vòng kín."

Output thật, lần chạy 1/3 (out/01-row-opposite-order.log):

[t=+  0.20s][S1] > UPDATE tmp_dl_acc SET balance = balance - 10 WHERE id = 1;
[t=+  0.21s][S1]   UPDATE 1
[t=+  0.40s][S2] > UPDATE tmp_dl_acc SET balance = balance - 20 WHERE id = 2;
[t=+  0.40s][S2]   UPDATE 1
[t=+  0.60s][S1] > UPDATE tmp_dl_acc SET balance = balance + 10 WHERE id = 2;
[t=+  1.00s][S2] > UPDATE tmp_dl_acc SET balance = balance + 20 WHERE id = 1;
[t=+  1.61s][S1]   ERROR:  40P01: deadlock detected
[t=+  1.61s][S1]   DETAIL:  Process 2048737 waits for ShareLock on transaction 724450; blocked by process 2048744.
[t=+  1.61s][S1]   Process 2048744 waits for ShareLock on transaction 724436; blocked by process 2048737.
[t=+  1.61s][S1]   HINT:  See server log for query details.
[t=+  1.61s][S1]   CONTEXT:  while updating tuple (0,4) in relation "tmp_dl_acc"
[t=+  1.61s][S1]   LOCATION:  DeadLockReport, deadlock.c:1133
[t=+  1.61s][S1]   (returned after 1.00s)
[t=+  1.61s][S2]   UPDATE 1
[t=+  1.61s][S2]   (returned after 0.61s)
[t=+  1.61s][S1] > SELECT balance FROM tmp_dl_acc WHERE id = 1;
[t=+  1.61s][S1]   ERROR:  25P02: current transaction is aborted, commands ignored until end of transaction block
[t=+  1.61s][S1]   LOCATION:  exec_simple_query, postgres.c:1136
[t=+  1.61s][S1] > COMMIT;
[t=+  1.61s][S1]   ROLLBACK
[t=+  1.62s][S2] > COMMIT;
[t=+  1.62s][S2]   COMMIT
--- application retry: S1 re-runs the WHOLE transaction
[t=+  1.62s][S1] > UPDATE tmp_dl_acc SET balance = balance - 10 WHERE id = 1;
[t=+  1.62s][S1]   UPDATE 1
[t=+  1.62s][S1] > UPDATE tmp_dl_acc SET balance = balance + 10 WHERE id = 2;
[t=+  1.62s][S1]   UPDATE 1
[t=+  1.63s][S1]   COMMIT
final balances: 1=1010 2=990
pg_stat_database.deadlocks after = 61 (delta 1; counter became visible after 0.13s of polling)
Cách nói khi phỏng vấn: "Session thứ nhất bắt đầu chờ ở giây 0,6 và đúng một giây sau thì nhận lỗi deadlock, vì PostgreSQL chỉ đi tìm vòng sau một giây chờ. Đúng khoảnh khắc đó, session thứ hai được khoá và chạy tiếp, tức khoá của nạn nhân được nhả ngay lúc báo lỗi. Sau lỗi, transaction của nạn nhân thành vô dụng: câu kế tiếp bị từ chối, gõ commit thì server trả lời là rollback. App phải chạy lại cả transaction từ đầu, và lúc đó cả hai lệnh chuyển tiền đều được ghi đúng."

Ở bản in trên đã bỏ các dòng BEGIN, dòng CHECK của orchestrator và BEGIN/COMMIT gửi đi của lần retry. Phần còn lại giữ nguyên.

Đọc lỗi 40P01 từng dòng (đây là thứ app và log ứng dụng nhận được):

  1. ERROR: 40P01: deadlock detected. 40P01 là SQLSTATE deadlock_detected. Code retry nên phân loại theo mã này, không theo chuỗi chữ, vì chuỗi chữ có thể bị dịch nếu server đặt lc_messages khác tiếng Anh. Số 40 là lớp "transaction rollback", cùng lớp với 40001 serialization failure. Cả hai đều có nghĩa "chạy lại cả transaction".

  2. DETAIL: Process 2048737 waits for ShareLock on transaction 724450; blocked by process 2048744. Đây là cạnh thứ nhất của vòng. Backend 2048737 (S1) chờ backend 2048744 (S2). Nó không chờ "dòng id=2" mà chờ ShareLock trên transaction 724450, tức xid của S2. Người đến sau một dòng đang bị khoá thì chờ transaction đang giữ dòng đó kết thúc. Vì vậy deadlock khoá dòng luôn hiện ra dưới dạng ShareLock on transaction <xid>, dù câu lệnh chỉ là UPDATE.

  3. Process 2048744 waits for ShareLock on transaction 724436; blocked by process 2048737. Đây là cạnh thứ hai, khép vòng. Ghép hai dòng lại thì suy ra được xid 724436 là của S1. Với vòng 3 bên thì sẽ có 3 dòng như vậy, đọc nối đuôi nhau.

  4. HINT: See server log for query details. Client không được thấy câu SQL của session kia (có thể chứa dữ liệu của người dùng khác), nên câu SQL chỉ được ghi vào server log.

  5. CONTEXT: while updating tuple (0,4) in relation "tmp_dl_acc". Victim đang làm gì lúc bị huỷ: cập nhật tuple có ctid (0,4) (vị trí vật lý: page 0, item 4) của bảng tmp_dl_acc. Ctid thay đổi sau mỗi UPDATE: lần chạy 2 là (0,11), lần 3 là (0,18). Vì vậy chỉ dùng ctid để đối chiếu trong cùng một sự cố, không dùng nó làm id. Với SELECT … FOR UPDATE chữ sẽ là while locking tuple, với INSERT vào unique index là while inserting index tuple (P5). Khoá bảng và advisory lock không có dòng này (P6, P7).

  6. LOCATION: DeadLockReport, deadlock.c:1133. Hàm C đã raise lỗi. Dòng này chỉ có khi client bật verbosity verbose, tiện khi đọc mã nguồn.

  7. Sau lỗi: 25P02 … current transaction is aborted cho mọi câu tiếp theo, và COMMIT trả về ROLLBACK. Ở đây ORM rất dễ "nuốt" lỗi: nếu code bắt exception rồi vẫn chạy tiếp các câu khác trên cùng kết nối, nó sẽ nhận một tràng 25P02 khó hiểu. Code đúng là ROLLBACK (hoặc ROLLBACK TO SAVEPOINT) rồi chạy lại cả transaction. Dòng retry trong log làm đúng như vậy và số dư cuối 1=1010 2=990 có đủ cả hai lệnh chuyển.

Server log của cùng lần chạy (docker logs my-test-pgdb; tiền tố | do orchestrator thêm vào):

  | 2026-11-05 10:11:32.356 UTC [2048737] LOG:  process 2048737 detected deadlock while waiting for ShareLock on transaction 724450 after 1000.164 ms
  | 2026-11-05 10:11:32.356 UTC [2048737] DETAIL:  Process holding the lock: 2048744. Wait queue: .
  | 2026-11-05 10:11:32.356 UTC [2048737] CONTEXT:  while updating tuple (0,4) in relation "tmp_dl_acc"
  | 2026-11-05 10:11:32.356 UTC [2048737] STATEMENT:  UPDATE tmp_dl_acc SET balance = balance + 10 WHERE id = 2;
  | 2026-11-05 10:11:32.357 UTC [2048737] ERROR:  deadlock detected
  | 2026-11-05 10:11:32.357 UTC [2048737] DETAIL:  Process 2048737 waits for ShareLock on transaction 724450; blocked by process 2048744.
  | 	Process 2048744 waits for ShareLock on transaction 724436; blocked by process 2048737.
  | 	Process 2048737: UPDATE tmp_dl_acc SET balance = balance + 10 WHERE id = 2;
  | 	Process 2048744: UPDATE tmp_dl_acc SET balance = balance + 20 WHERE id = 1;
  | 2026-11-05 10:11:32.357 UTC [2048737] HINT:  See server log for query details.
  | 2026-11-05 10:11:32.357 UTC [2048737] CONTEXT:  while updating tuple (0,4) in relation "tmp_dl_acc"
  | 2026-11-05 10:11:32.357 UTC [2048737] STATEMENT:  UPDATE tmp_dl_acc SET balance = balance + 10 WHERE id = 2;
  | 2026-11-05 10:11:32.363 UTC [2048737] ERROR:  current transaction is aborted, commands ignored until end of transaction block
  | 2026-11-05 10:11:32.363 UTC [2048737] STATEMENT:  SELECT balance FROM tmp_dl_acc WHERE id = 1;
Cách nói khi phỏng vấn: "Server log có thứ mà client không có, đó là câu SQL của cả hai bên. Khối đầu tiên chỉ xuất hiện khi bật ghi log chờ khoá: nó cho biết session chờ đúng một giây rồi phát hiện vòng. Khối thứ hai là lỗi deadlock thật, có danh sách ai chờ ai, rồi từng process đang chạy câu gì. Khi điều tra sự cố, tôi lấy hai câu SQL đó, tìm trong code xem chúng nằm ở đâu, rồi so thứ tự khoá của hai đường code."

Đọc từng dòng:

  1. [2048737] trong mọi dòng là pid của backend ghi log. Ở đây đó là victim, vì victim tự kiểm tra và tự báo lỗi (mục 1.3). Muốn lọc log theo sự cố thì lọc theo pid này. Dòng bắt đầu bằng tab là dòng nối tiếp, không có tiền tố, nên thuộc về pid của dòng phía trên.

  2. LOG: process 2048737 detected deadlock while waiting for ShareLock on transaction 724450 after 1000.164 ms. Dòng này chỉ có khi log_lock_waits = on (PG 18 mặc định off, PG 19 sẽ đổi thành on). Con số 1000.164 ms xác nhận việc kiểm tra chạy đúng lúc hết deadlock_timeout.

  3. DETAIL: Process holding the lock: 2048744. Wait queue: . Ai đang giữ khoá, và hàng chờ của khoá đó. Hàng chờ rỗng vì S1 là người chờ duy nhất trên xid 724450.

  4. CONTEXT và STATEMENT của dòng LOG là câu lệnh của victim.

  5. Khối ERROR: deadlock detected lặp lại phần client đã thấy. Khác biệt là DETAIL có thêm hai dòng Process 2048737: UPDATE … WHERE id = 2; và Process 2048744: UPDATE … WHERE id = 1;, tức câu lệnh đang chờ của mỗi bên. Lưu ý đây là câu đang chờ chứ không phải câu đã lấy khoá: câu WHERE id = 1 đầu tiên của S1 (câu giữ khoá mà S2 đang chờ) không có trong log. Muốn biết vì sao S1 giữ id=1 thì phải đọc code hoặc bật thêm log câu lệnh.

  6. Hai dòng current transaction is aborted cuối cùng là 25P02 của câu SELECT app gửi sau lỗi, cũng được ghi lại.

Vì sao deadlock. (1) Lúc 0.21s, S1 giữ khoá dòng id=1 (xid 724436). (2) Lúc 0.40s, S2 giữ khoá dòng id=2 (xid 724450). (3) Lúc 0.60s, S1 cần id=2 nên chờ xid 724450. Đây mới là lock wait thường. (4) Lúc 1.00s, S2 cần id=1 nên chờ xid 724436. Vòng khép lại. (5) Lúc 1.60s, bộ hẹn giờ của S1 (bắt đầu từ 0.60s) hết, S1 thấy vòng và tự huỷ. S2 không phải chờ đến lượt kiểm tra của mình (2.00s). 3/3 lần chạy đều đúng một session nhận 40P01, session kia chạy tiếp. Cả 3 lần victim là S1, bên chờ trước, đúng theo cơ chế ở mục 1.3. Mỗi lần pg_stat_database.deadlocks tăng đúng 1.

flowchart LR
  S1["S1 pid 2048737<br/>giữ id=1 (xid 724436)"] -->|"0.60s chờ ShareLock xid 724450"| S2["S2 pid 2048744<br/>giữ id=2 (xid 724450)"]
  S2 -->|"1.00s chờ ShareLock xid 724436"| S1

Cách chữa: khoá cả hai dòng theo cùng một thứ tự ngay từ đầu (phá điều kiện circular wait). Cả hai transaction mở đầu bằng SELECT id FROM tmp_dl_acc WHERE id IN (1, 2) ORDER BY id FOR UPDATE; rồi mới UPDATE, bất kể chuyển theo chiều nào. Lần chạy fix 1/2:

[t=+  0.20s][S1] > SELECT id FROM tmp_dl_acc WHERE id IN (1, 2) ORDER BY id FOR UPDATE;
[t=+  0.21s][S1]   (2 rows)
[t=+  0.40s][S2] > SELECT id FROM tmp_dl_acc WHERE id IN (1, 2) ORDER BY id FOR UPDATE;
[t=+  0.61s][S1]   UPDATE 1
[t=+  0.80s][S1]   UPDATE 1
[t=+  2.01s][S1]   COMMIT
[t=+  2.01s][S2]   (returned after 1.61s)
[t=+  2.01s][S2] > UPDATE tmp_dl_acc SET balance = balance - 20 WHERE id = 2;
[t=+  2.01s][S2]   COMMIT
final balances: 1=1010 2=990
  | 2026-11-05 10:11:41.367 UTC [2048897] LOG:  process 2048897 still waiting for ShareLock on transaction 724584 after 1000.099 ms
  | 2026-11-05 10:11:41.367 UTC [2048897] CONTEXT:  while locking tuple (0,24) in relation "tmp_dl_acc"
CHECK ok  : fixed order: no error, both transfers applied, deadlocks delta=0: {'S1': 'ok', 'S2': 'ok'}
Cách nói khi phỏng vấn: "Cách chữa là cả hai bên khoá hai tài khoản theo thứ tự id tăng dần ngay từ câu đầu tiên, không quan tâm tiền đi chiều nào. Bên đến sau chờ ngay từ câu khoá đầu tiên, lúc nó chưa giữ gì trong tay, nên không thể có vòng. Lab cho thấy bên sau chờ khoảng một giây sáu rồi chạy, không có lỗi, cả hai lệnh chuyển tiền đều ghi đúng. Cái giá là hai lệnh chuyển đụng cùng tài khoản bị xếp hàng tuần tự."

Bản in trên đã cắt bớt các dòng kết quả id và một số dòng UPDATE/COMMIT gửi đi. Fix chạy 2/2 lần không có deadlock, deadlocks delta=0. Dòng log cho thấy S2 chuyển từ "khép vòng" sang "chờ thường" trên câu locking tuple đầu tiên. Trade-off: (a) cách này chỉ làm được khi biết trước tập dòng sẽ khoá. Nếu transaction phát hiện dần dòng cần sửa (đọc dòng A mới biết phải sửa B) thì không sắp thứ tự trước được, phải chuyển sang khoá thô (mục 6) hoặc chấp nhận retry. (b) Thứ tự là quy ước toàn hệ thống: mọi đường code đụng hai bảng/dòng này phải theo cùng thứ tự, kể cả job nền và script tay. (c) Chi phí là tuần tự hoá: S2 mất 1.61s chờ. Với một tài khoản "nóng" (ví quỹ chung), hàng chờ dài ra. Khi đó phải tính đến tách dòng nóng, không chỉ dừng ở sắp thứ tự. Trên MySQL cùng kịch bản này cũng deadlock (lab MySQL M1, 3/3), nhưng lỗi đến gần như ngay lập tức, victim được chọn theo trx_weight, và 1213 đã rollback sẵn cả transaction. Xem mục 4.

2.2. P2 — UPDATE nhiều dòng không có ORDER BY

Bối cảnh nghiệp vụ (ví dụ minh hoạ). Hai job nền cùng chạy lúc nửa đêm. Job đối soát tồn kho cộng 1 cho các mã hàng id 1..8 (lọc theo khoảng id). Job khuyến mãi trừ 1 cho nhóm hàng grp = 'hot', đúng 8 mã đó. Mỗi job chỉ có một câu lệnh, không có chỗ nào để "sai thứ tự" trong code, vậy mà vẫn deadlock.

CREATE TABLE tmp_dl_items (id int PRIMARY KEY, grp text NOT NULL, qty int NOT NULL DEFAULT 0, pad text);
-- 8 dòng 'hot' chèn theo id GIẢM dần -> thứ tự vật lý trong heap là 8,7,...,1
INSERT INTO tmp_dl_items SELECT g, 'hot', 0, repeat('x', 50) FROM generate_series(8, 1, -1) g;
INSERT INTO tmp_dl_items SELECT g, 'cold', 0, repeat('x', 50) FROM generate_series(9, 20000) g;
-- trigger mỗi dòng: RAISE NOTICE 'pid % locked id=%' rồi pg_sleep(0.3) (giả lập xử lý chậm từng dòng)
CREATE TRIGGER tmp_dl_slow BEFORE UPDATE ON tmp_dl_items FOR EACH ROW EXECUTE FUNCTION tmp_dl_slow_row();

-- t=0.2 S1 (BEGIN trước đó):
UPDATE tmp_dl_items SET qty = qty + 1 WHERE id BETWEEN 1 AND 8;
-- t=0.3 S2:
UPDATE tmp_dl_items SET qty = qty - 1 WHERE grp = 'hot';
Cách nói khi phỏng vấn: "Hai job, mỗi job đúng một câu update tám dòng giống hệt nhau. Câu thứ nhất lọc theo khoảng khoá chính, câu thứ hai lọc theo nhóm. Tôi cố ý chèn tám dòng đó theo thứ tự ngược, để thứ tự lưu trên đĩa ngược với thứ tự khoá chính. Trigger làm mỗi dòng chậm đi ba trăm mili giây để hai câu kịp gặp nhau ở giữa."

Plan của hai câu (EXPLAIN) và timeline lần chạy 1/3:

Update on tmp_dl_items  (cost=0.29..8.47 rows=0 width=0)
  ->  Index Scan using tmp_dl_items_pkey on tmp_dl_items  (cost=0.29..8.47 rows=8 width=10)
        Index Cond: ((id >= 1) AND (id <= 8))
Update on tmp_dl_items  (cost=0.00..497.02 rows=0 width=0)
  ->  Seq Scan on tmp_dl_items  (cost=0.00..497.02 rows=8 width=10)
        Filter: (grp = 'hot'::text)
[t=+  0.20s][S1] > UPDATE tmp_dl_items SET qty = qty + 1 WHERE id BETWEEN 1 AND 8;
[t=+  0.21s][S1]   NOTICE:  00000: pid 2049634 locked id=1
[t=+  0.30s][S2] > UPDATE tmp_dl_items SET qty = qty - 1 WHERE grp = 'hot';
[t=+  0.31s][S2]   NOTICE:  00000: pid 2049640 locked id=8
[t=+  0.51s][S1]   NOTICE:  00000: pid 2049634 locked id=2
[t=+  0.61s][S2]   NOTICE:  00000: pid 2049640 locked id=7
[t=+  0.81s][S1]   NOTICE:  00000: pid 2049634 locked id=3
[t=+  0.91s][S2]   NOTICE:  00000: pid 2049640 locked id=6
[t=+  1.11s][S1]   NOTICE:  00000: pid 2049634 locked id=4
[t=+  1.21s][S2]   NOTICE:  00000: pid 2049640 locked id=5
[t=+  2.41s][S2]   NOTICE:  00000: pid 2049640 locked id=4
[t=+  2.41s][S1]   ERROR:  40P01: deadlock detected
[t=+  2.41s][S1]   DETAIL:  Process 2049634 waits for ShareLock on transaction 726327; blocked by process 2049640.
[t=+  2.41s][S1]   Process 2049640 waits for ShareLock on transaction 726326; blocked by process 2049634.
[t=+  2.41s][S1]   CONTEXT:  while locking tuple (0,4) in relation "tmp_dl_items"
[t=+  3.31s][S2]   NOTICE:  00000: pid 2049640 locked id=1
[t=+  3.62s][S2]   UPDATE 8
  | 	Process 2049634: UPDATE tmp_dl_items SET qty = qty + 1 WHERE id BETWEEN 1 AND 8;
  | 	Process 2049640: UPDATE tmp_dl_items SET qty = qty - 1 WHERE grp = 'hot';
Cách nói khi phỏng vấn: "Plan quyết định thứ tự khoá. Câu lọc theo khoá chính đi theo index, khoá từ một lên tám. Câu lọc theo nhóm quét bảng theo thứ tự lưu trên đĩa, khoá từ tám xuống một. Hai bên gặp nhau ở giữa: bên này giữ một đến bốn và chờ dòng năm, bên kia giữ tám đến năm và chờ dòng bốn. Server log cho thấy hai câu SQL deadlock với nhau đều là câu lệnh đơn."

Bản in đã cắt các dòng HINT/LOCATION, các NOTICE id=3, id=2 của S2 sau khi S1 bị huỷ, và phần đầu của khối server log. Vì sao deadlock:

  1. Một câu UPDATE nhiều dòng không khoá cả tập dòng cùng một lúc. Nó khoá từng dòng một, đúng theo thứ tự node scan trả dòng lên. Index Scan trả dòng theo thứ tự khoá (1→8). Seq Scan trả dòng theo thứ tự vật lý trong heap (8→1, vì lab chèn ngược).

  2. Lúc 1.11s, S1 giữ 1–4. Lúc 1.21s, S2 giữ 8–5. S1 cần dòng 5 nên chờ S2 (từ khoảng 1.41s, sau 0.3s trigger của dòng 4). S2 cần dòng 4 nên chờ S1 (từ khoảng 1.51s). Vòng khép lại.

  3. S1 bắt đầu chờ trước nên bộ hẹn giờ hết trước (2.41s), S1 bị huỷ. S2 lấy ngay dòng 4 rồi đi tiếp 3, 2, 1. Ở đây hai lần bắt đầu chờ chỉ cách nhau khoảng 0.1s, nên victim nên được coi là kết quả race. Bất biến chắc chắn là đúng một session nhận 40P01, 3/3.

  4. Không có trigger chậm thì vẫn xảy ra. Bảng tmp_dl_items_big có 2000 dòng hot chèn ngược và 48 000 dòng khác, hai câu UPDATE bắn cùng lúc: deadlock 5/5, victim lẫn lộn (S1 3 lần, S2 2 lần), ví dụ CONTEXT: while updating tuple (22,64) in relation "tmp_dl_items_big". Ở production, thứ tự heap thay đổi theo thời gian (mỗi UPDATE ghi phiên bản dòng mới ở chỗ khác), nên lỗi kiểu này có lúc có lúc không, rất khó tái hiện.

flowchart LR
  S1["S1 Index Scan 1→8<br/>đã khoá 1,2,3,4"] -->|"chờ dòng 5"| S2["S2 Seq Scan 8→1<br/>đã khoá 8,7,6,5"]
  S2 -->|"chờ dòng 4"| S1

Cách chữa: khoá trước theo ORDER BY, rồi mới update. Có hai dạng, cả hai đều đã lab. Dạng 2 câu: SELECT id FROM tmp_dl_items WHERE grp = 'hot' ORDER BY id FOR UPDATE; rồi UPDATE. Dạng 1 câu: UPDATE tmp_dl_items SET qty = qty - 1 WHERE id IN (SELECT id FROM tmp_dl_items WHERE grp = 'hot' ORDER BY id FOR UPDATE);. Plan cho thấy Sort nằm dưới LockRows, tức các dòng được sắp xong rồi mới bị khoá:

Update on tmp_dl_items  (cost=499.62..565.88 rows=0 width=0)
  ->  Nested Loop  (cost=499.62..565.88 rows=8 width=38)
        ->  HashAggregate  (cost=499.33..499.41 rows=8 width=32)
              ->  Subquery Scan on "ANY_subquery"  (cost=499.13..499.31 rows=8 width=32)
                    ->  LockRows  (cost=499.13..499.23 rows=8 width=10)
                          ->  Sort  (cost=499.13..499.15 rows=8 width=10)
                                Sort Key: tmp_dl_items_1.id
                                ->  Seq Scan on tmp_dl_items tmp_dl_items_1  (cost=0.00..499.01 rows=8 width=10)
[t=+  0.20s][S1] > UPDATE tmp_dl_items SET qty = qty + 1 WHERE id IN (SELECT id FROM tmp_dl_items WHERE id BETWEEN 1 AND 8 ORDER BY id FOR UPDATE);
[t=+  0.30s][S2] > UPDATE tmp_dl_items SET qty = qty - 1 WHERE id IN (SELECT id FROM tmp_dl_items WHERE grp = 'hot' ORDER BY id FOR UPDATE);
[t=+  2.61s][S1]   UPDATE 8
[t=+  5.03s][S2]   UPDATE 8
[t=+  5.03s][S2]   (returned after 4.73s)
CHECK ok  : no error: {'S1': 'ok', 'S2': 'ok'}; hot rows qty now = 0
bursts that deadlocked: 5/5
FIX bursts that deadlocked: 0/5
Cách nói khi phỏng vấn: "Cách chữa là trong cùng câu lệnh, chọn và khoá các dòng theo thứ tự id trước, rồi mới cập nhật. Plan xác nhận việc sắp thứ tự diễn ra trước bước khoá, nên dù câu nào quét kiểu gì thì cũng khoá theo cùng một thứ tự. Bên đến sau chờ ngay từ dòng đầu tiên rồi chạy hết, không có lỗi. Với hai nghìn dòng bắn cùng lúc, bản cũ deadlock năm trên năm lần, bản sửa không lần nào."

Bản in trên đã cắt một số node của plan và các dòng NOTICE. Kết quả: dạng 2 câu 2/2 không lỗi, dạng 1 câu 2/2, bản 2000 dòng không sleep 0/5 deadlock (so với 5/5 trước khi sửa). Ở dạng 1 câu, NOTICE của trigger in thứ tự 8,7,1,5,4,2,6,3. Đó là thứ tự hash của phép join bên ngoài khi UPDATE đi qua các dòng đã được subquery khoá theo id, nên vô hại. Trade-off: (a) thêm một bước sort và một subquery. Với 8 dòng không đáng kể, nhưng với hàng triệu dòng, khoá hết trong một transaction là một vấn đề riêng: hãy chia batch nhỏ theo khoá tăng dần (mục 6). (b) ORDER BY phải theo một khoá duy nhất. Sắp theo cột không unique mà không có cột phụ phân xử thì thứ tự giữa các dòng trùng giá trị lại do scan quyết định. (c) Quy tắc phải áp dụng cho mọi câu ghi nhiều dòng vào cùng bảng, kể cả câu viết tay trong migration. Trên MySQL, khoá đi theo index range được quét chứ không theo tập dòng thoả WHERE, nên thiếu index còn tệ hơn. Xem mục 4.

2.3. P3 — Cùng FOR SHARE rồi cùng nâng lên UPDATE

Bối cảnh nghiệp vụ (ví dụ minh hoạ). Hai request đặt mua cùng một sản phẩm. Lập trình viên muốn "đảm bảo tồn kho không đổi trong lúc kiểm tra" nên đọc bằng FOR SHARE, thấy còn hàng thì trừ 1. Ý định là thận trọng, nhưng kết quả là deadlock mỗi lần hai request trùng nhau.

CREATE TABLE tmp_dl_stock (id int PRIMARY KEY, qty int NOT NULL);
INSERT INTO tmp_dl_stock VALUES (1, 10);

-- S1 và S2 chạy cùng một đoạn code, lệch nhau 0.2-0.4s; cả hai đã BEGIN
SELECT qty FROM tmp_dl_stock WHERE id = 1 FOR SHARE;   -- S1 t=0.2, S2 t=0.4 (cả hai đều được cấp)
UPDATE tmp_dl_stock SET qty = qty - 1 WHERE id = 1;    -- S1 t=0.6, S2 t=1.0
COMMIT;
Cách nói khi phỏng vấn: "Cả hai request đọc tồn kho bằng khoá chia sẻ. Khoá chia sẻ không chặn nhau nên cả hai đều đọc được. Sau đó cả hai cùng muốn trừ tồn kho, tức cùng muốn nâng khoá lên mức ghi trên đúng một dòng."
[t=+  0.20s][S1] > SELECT qty FROM tmp_dl_stock WHERE id = 1 FOR SHARE;
[t=+  0.40s][S2] > SELECT qty FROM tmp_dl_stock WHERE id = 1 FOR SHARE;
[t=+  0.50s][MON] > SELECT locked_row, locker, multi, xids, modes, pids FROM pgrowlocks('tmp_dl_stock');
[t=+  0.50s][MON]    (0,2)      |  22031 | t     | {727400,727401} | {"For Share","For Share"} | {2050294,2050301}
[t=+  0.60s][S1] > UPDATE tmp_dl_stock SET qty = qty - 1 WHERE id = 1;
[t=+  1.00s][S2] > UPDATE tmp_dl_stock SET qty = qty - 1 WHERE id = 1;
[t=+  1.60s][S1]   ERROR:  40P01: deadlock detected
[t=+  1.60s][S1]   DETAIL:  Process 2050294 waits for ShareLock on transaction 727401; blocked by process 2050301.
[t=+  1.60s][S1]   Process 2050301 waits for ShareLock on transaction 727400; blocked by process 2050294.
[t=+  1.60s][S1]   CONTEXT:  while updating tuple (0,2) in relation "tmp_dl_stock"
[t=+  1.60s][S2]   UPDATE 1
  | 	Process 2050294: UPDATE tmp_dl_stock SET qty = qty - 1 WHERE id = 1;
  | 	Process 2050301: UPDATE tmp_dl_stock SET qty = qty - 1 WHERE id = 1;
FOR SHARE runs with exactly one 40P01: 3/3
Cách nói khi phỏng vấn: "Công cụ xem khoá dòng cho thấy dòng đó đang có hai người cùng giữ khoá chia sẻ. Mỗi câu update phải chờ người kia nhả khoá chia sẻ, mà người kia lại đang chờ mình, nên hai câu chờ nhau. Thông báo lỗi trông y hệt deadlock hai dòng ngược thứ tự. Chỉ trong server log mới thấy cả hai bên chạy cùng một câu trên cùng một dòng, và đó là dấu hiệu của deadlock nâng cấp khoá."

Bản in đã bỏ các dòng kết quả qty, HINT, LOCATION và phần đầu server log. Vì sao deadlock:

  1. Hai khoá FOR SHARE trên cùng một dòng tương thích nhau. PG lưu nhiều người cùng khoá một dòng thành một MultiXact (nhóm xid dùng chung một dấu khoá trên dòng): multi = t, xids {727400,727401}.

  2. UPDATE không đổi cột khoá cần FOR NO KEY UPDATE. Theo bảng xung đột khoá dòng, chế độ này xung đột với FOR SHARE. Vậy S1 phải chờ xid 727401 của S2 nhả FOR SHARE, và S2 phải chờ xid 727400 của S1. Đây là vòng "nâng cấp khoá" (lock upgrade) trên một dòng.

  3. DETAIL không phân biệt được trường hợp này với P1 (vẫn là ShareLock on transaction). Dấu hiệu nhận ra nằm ở server log: hai dòng Process …: chạy cùng một câu UPDATE trên cùng một id. Đúng một session nhận 40P01 trong 3/3 lần, victim là bên chờ trước (S1) cả 3 lần, và qty cuối = 9 vì lab không retry victim.

flowchart LR
  R[("dòng id=1<br/>MultiXact: FOR SHARE của S1 và S2")]
  S1["S1 xin FOR NO KEY UPDATE"] -->|"chờ S2 nhả FOR SHARE"| S2["S2 xin FOR NO KEY UPDATE"]
  S2 -->|"chờ S1 nhả FOR SHARE"| S1
  S1 -.->|"đang giữ"| R
  S2 -.->|"đang giữ"| R

Cách chữa: xin ngay chế độ mạnh nhất sẽ cần. Tài liệu PG: "ensure that the first lock acquired on an object in a transaction is the most restrictive mode that will be needed for that object". Đổi FOR SHARE thành FOR UPDATE (hoặc FOR NO KEY UPDATE):

[t=+  0.20s][S1] > SELECT qty FROM tmp_dl_stock WHERE id = 1 FOR UPDATE;
[t=+  0.40s][S2] > SELECT qty FROM tmp_dl_stock WHERE id = 1 FOR UPDATE;
[t=+  0.50s][MON]    (0,8)      | 727584 | f     | {727584} | {"For Update"} | {2050404}
[t=+  0.60s][S1] > UPDATE tmp_dl_stock SET qty = qty - 1 WHERE id = 1;
[t=+  1.00s][S1]   COMMIT
[t=+  1.00s][S2]      9
[t=+  1.00s][S2]   (returned after 0.60s)
final qty = 8 (10 minus one per committed transaction)
FOR UPDATE run 2: {'S1': 'ok', 'S2': 'ok'} final qty=8
FOR NO KEY UPDATE run 1: {'S1': 'ok', 'S2': 'ok'} final qty=8
FOR NO KEY UPDATE run 2: {'S1': 'ok', 'S2': 'ok'} final qty=8
Cách nói khi phỏng vấn: "Biết trước là sẽ ghi thì xin khoá ghi ngay lúc đọc. Bên thứ hai bị chặn ngay ở câu đọc, lúc nó chưa giữ gì, nên không có vòng. Khi được chạy, nó đọc thấy chín chứ không phải mười, tức nó thấy giá trị mới nhất, nên tồn kho cuối là tám, đúng cho hai lần mua. Trong PostgreSQL tôi chọn FOR NO KEY UPDATE, trừ khi định xoá dòng hoặc đổi khoá."

Bản in đã cắt phần header của pgrowlocks và các dòng UPDATE/COMMIT của S2. FOR UPDATE 2/2, FOR NO KEY UPDATE 2/2, không deadlock, qty 10 → 8. Trade-off: (a) khoá ghi ngay từ lúc đọc thì mọi request đọc dòng đó đều bị tuần tự hoá, kể cả request đọc xong mà không mua. Nên chỉ khoá khi gần như chắc chắn sẽ ghi. (b) Nên ưu tiên FOR NO KEY UPDATE: nó không chặn INSERT của bảng con có FK trỏ vào dòng này (P4), còn FOR UPDATE thì chặn. Cybertec: "Unless you plan to delete a row or modify a key column, you should use SELECT FOR NO KEY UPDATE." (c) Một hướng khác là bỏ hẳn bước đọc và viết một câu nguyên tử UPDATE … SET qty = qty - 1 WHERE id = 1 AND qty > 0. Bài này không lab hướng đó. Hành vi của nó dưới READ COMMITTED (đánh giá lại WHERE sau khi chờ) có trong bài optimistic locking.

2.4. P4 — Khoá ngoại: khi nào deadlock, khi nào không

Bối cảnh nghiệp vụ (ví dụ minh hoạ). LMS có bảng course (cha) và enrolment (con, FK trỏ về course). Khi một sinh viên ghi danh, code chèn một dòng con rồi tăng bộ đếm n_children trên dòng cha. Đây là kiểu "counter cache" rất phổ biến. Câu hỏi là hai sinh viên ghi danh cùng lúc vào cùng một khoá học thì có deadlock không. Với PostgreSQL, câu trả lời là tuỳ việc bạn sửa cột nào của dòng cha.

CREATE TABLE tmp_dl_parent (id int PRIMARY KEY, code text NOT NULL UNIQUE, name text, n_children int NOT NULL DEFAULT 0);
CREATE TABLE tmp_dl_child (id int GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
                           parent_id int NOT NULL REFERENCES tmp_dl_parent(id), note text);
CREATE INDEX ON tmp_dl_child (parent_id);
INSERT INTO tmp_dl_parent VALUES (1, 'P-1', 'course 1', 0);

-- cả S1 và S2: BEGIN; INSERT INTO tmp_dl_child (parent_id, note) VALUES (1, '...');   -- t=0.4 / 0.5
-- rồi một trong ba biến thể (t=0.8 / 1.2):
--   A: UPDATE tmp_dl_parent SET n_children = n_children + 1 WHERE id = 1;   -- cột thường
--   B: UPDATE tmp_dl_parent SET code = 'P-1-s1' WHERE id = 1;               -- code là UNIQUE -> cột "khoá"
--   C: DELETE FROM tmp_dl_parent WHERE id = 1;
Cách nói khi phỏng vấn: "Hai transaction cùng chèn một bản ghi con trỏ vào cùng một dòng cha, rồi cùng sửa dòng cha đó. Tôi thử ba kiểu sửa: tăng bộ đếm thường, đổi một cột có ràng buộc duy nhất, và xoá dòng cha. Ba kiểu cho ba kết quả khác nhau, vì kiểm tra khoá ngoại chỉ lấy một loại khoá rất nhẹ trên dòng cha."
[t=+  0.78s][MON]    (0,2)      | t     | {727945,727946} | {"For Key Share","For Key Share"} | {2050623,2050629}
[t=+  0.81s][S1] > UPDATE tmp_dl_parent SET n_children = n_children + 1 WHERE id = 1;
[t=+  0.85s][S1]   UPDATE 1
[t=+  1.01s][MON]    (0,2)      | t     | {727945,727945,727946} | {"For Key Share","No Key Update","For Key Share"} | {2050623,2050623,2050629}
[t=+  1.20s][S2] > UPDATE tmp_dl_parent SET n_children = n_children + 1 WHERE id = 1;
[t=+  2.60s][S2]   UPDATE 1
[t=+  2.60s][S2]   (returned after 1.40s)
final: parent=1/P-1/n_children=2  children=2
A non-key UPDATE: runs with a 40P01: 0/3
[t=+  0.80s][S1] > UPDATE tmp_dl_parent SET code = 'P-1-s1' WHERE id = 1;
[t=+  1.20s][S2] > UPDATE tmp_dl_parent SET code = 'P-1-s2' WHERE id = 1;
[t=+  1.80s][S1]   ERROR:  40P01: deadlock detected
[t=+  1.80s][S1]   DETAIL:  Process 2050741 waits for ShareLock on transaction 728133; blocked by process 2050748.
[t=+  1.80s][S1]   Process 2050748 waits for ShareLock on transaction 728132; blocked by process 2050741.
[t=+  1.80s][S1]   CONTEXT:  while updating tuple (0,11) in relation "tmp_dl_parent"
B key UPDATE: runs with a 40P01: 3/3
[t=+  1.80s][S1]   CONTEXT:  while deleting tuple (0,17) in relation "tmp_dl_parent"
[t=+  1.80s][S2]   ERROR:  23503: update or delete on table "tmp_dl_parent" violates foreign key constraint "tmp_dl_child_parent_id_fkey" on table "tmp_dl_child"
[t=+  1.80s][S2]   DETAIL:  Key (id)=(1) is still referenced from table "tmp_dl_child".
C DELETE parent: runs with a 40P01: 2/2
Cách nói khi phỏng vấn: "Kiểu tăng bộ đếm thường không deadlock lần nào: bên thứ hai chỉ chờ bên thứ nhất commit rồi chạy, bộ đếm và số bản ghi con khớp nhau. Kiểu đổi cột duy nhất thì deadlock ba trên ba. Kiểu xoá dòng cha thì deadlock hai trên hai, và bên thắng xoá tiếp lại bị lỗi khoá ngoại, vì bản ghi con của nó vẫn còn trỏ vào. Cùng một mẫu code, khác nhau chỉ ở cột bị sửa."

Bản in ghép ba lần chạy (A lần 1, B lần 1, C lần 1) và đã cắt các dòng INSERT, header của pgrowlocks, HINT/LOCATION. Vì sao:

  1. Kiểm tra FK khi INSERT dòng con lấy FOR KEY SHARE trên dòng cha. Đây là khoá dòng yếu nhất. Hai khoá này cùng tồn tại được ({"For Key Share","For Key Share"}, multi = t).

  2. Bảng xung đột khoá dòng (tài liệu PG 18): FOR KEY SHARE chỉ xung đột với FOR UPDATE. Một UPDATE không đổi cột "khoá" lấy FOR NO KEY UPDATE, không xung đột với FOR KEY SHARE. Vì vậy ở A, câu UPDATE của S1 không chờ (0.85s): pgrowlocks cho thấy "No Key Update" của S1 nằm cạnh hai "For Key Share". S2 chỉ chờ No Key Update của S1, một lock wait thường kéo dài 1.40s. Kết quả 0/3 deadlock, n_children=2, children=2.

  3. Ở B, cột code có unique index dùng được cho FK. Tài liệu viết rằng UPDATE sửa "columns … that have a unique index on them that can be used in a foreign key" thì lấy FOR UPDATE, giống DELETE ở C. FOR UPDATE của S1 xung đột với FOR KEY SHARE của S2 và ngược lại, nên vòng khép. Lưu ý code không phải cột FK trỏ tới (FK trỏ tới id), chỉ cần nó có unique index là đủ.

  4. Ở C, sau khi victim S1 bị huỷ, S2 xoá tiếp nhưng bản ghi con của chính S2 vẫn trỏ vào nên nhận 23503. Cả hai transaction đều thất bại.

flowchart TD
  P[("dòng cha id=1<br/>FOR KEY SHARE của S1 và S2")]
  A["A: UPDATE n_children<br/>FOR NO KEY UPDATE"] -->|"không xung đột với KEY SHARE"| OK["chờ thường, 0/3 deadlock"]
  B["B: UPDATE code (UNIQUE) hoặc C: DELETE<br/>FOR UPDATE"] -->|"xung đột KEY SHARE của bên kia"| DL["S1 chờ S2, S2 chờ S1<br/>B 3/3, C 2/2 deadlock"]
  P --- A
  P --- B

Cách chữa cho B/C: khoá dòng cha trước, rồi mới chèn con, tức theo thứ tự cha → con.

[t=+  0.20s][S1] > SELECT id FROM tmp_dl_parent WHERE id = 1 FOR UPDATE;
[t=+  0.30s][S2] > SELECT id FROM tmp_dl_parent WHERE id = 1 FOR UPDATE;
[t=+  0.40s][S1] > INSERT INTO tmp_dl_child (parent_id, note) VALUES (1, 'from S1');
[t=+  0.80s][S1] > UPDATE tmp_dl_parent SET code = 'P-1-s1' WHERE id = 1;
[t=+  1.01s][S1]   COMMIT
[t=+  1.01s][S2]   (returned after 0.71s)
[t=+  1.03s][S2] > INSERT INTO tmp_dl_child (parent_id, note) VALUES (1, 'from S2');
[t=+  1.05s][S2]   COMMIT
final: parent=1/P-1-s2/n_children=0  children=2
B FIX parent FOR UPDATE first: runs with a 40P01: 0/2
Cách nói khi phỏng vấn: "Khoá dòng cha bằng khoá ghi ngay từ câu đầu tiên, sau đó mới chèn bản ghi con. Bên thứ hai bị chặn ngay ở câu khoá đầu tiên nên không có vòng. Hai lần chạy đều không lỗi và có đủ hai bản ghi con. Cái giá là mọi lượt ghi danh vào cùng một khoá học bị xếp hàng, kể cả những lượt không đổi gì trên dòng cha."

Bản in đã cắt dòng kết quả id, pgrowlocks và một số dòng INSERT 0 1/UPDATE 1. Fix chạy 2/2 không deadlock. Trade-off: (a) FOR UPDATE trên dòng cha chặn mọi INSERT con của dòng cha đó, vì FK check cần FOR KEY SHARE. Một khoá học nóng lúc mở đăng ký sẽ trở thành cổ chai. Chỉ dùng khi thật sự đổi cột khoá hoặc xoá. (b) Chữa tận gốc là không sửa cột có unique index trên dòng cha đang được tham chiếu. Dùng surrogate key bất biến làm đích FK, mã nghiệp vụ đổi được thì để ở cột không cần unique, hoặc đổi trong một quy trình riêng. (c) Kiểu A (counter cache cột thường) an toàn ở PG, nhưng vẫn là một dòng nóng mà mọi lượt ghi danh cùng phải chờ. Trên MySQL cùng mẫu A lại deadlock, vì INSERT dòng con lấy khoá S trên dòng cha (lab MySQL M5, 3/3). Xem mục 4.

2.5. P5 — Unique index và INSERT / ON CONFLICT theo thứ tự ngược

Bối cảnh nghiệp vụ (ví dụ minh hoạ). Hệ thống đặt chỗ ghi các mã ghế vào một bảng có khoá chính. Request 1 giữ ghế A rồi B, request 2 giữ ghế B rồi A. Hoặc hai worker cùng upsert bảng đếm lượt xem theo một lô key mà thứ tự trong lô do map/dict quyết định. Không ai UPDATE dòng nào của ai, vậy mà vẫn deadlock.

CREATE TABLE tmp_dl_u (k text PRIMARY KEY, who text);
CREATE TABLE tmp_dl_cnt (k text PRIMARY KEY, n int NOT NULL);

-- P5-A  S1: INSERT 'A' (t=0.2) rồi 'B' (t=0.6)      S2: INSERT 'B' (t=0.4) rồi 'A' (t=1.0)
INSERT INTO tmp_dl_u VALUES ('A', 'S1');
-- P5-C  cùng thứ tự ngược nhưng dùng upsert
INSERT INTO tmp_dl_cnt VALUES ('A', 1) ON CONFLICT (k) DO UPDATE SET n = tmp_dl_cnt.n + 1;
-- P5-D  một câu, 3000 key có sẵn: S1 ORDER BY g, S2 ORDER BY g DESC, bắn cùng lúc
INSERT INTO tmp_dl_cnt (k, n) SELECT g::text, 1 FROM generate_series(1, 3000) g ORDER BY g
ON CONFLICT (k) DO UPDATE SET n = tmp_dl_cnt.n + 1;
Cách nói khi phỏng vấn: "Tôi thử ba dạng. Dạng một là chèn hai khoá theo thứ tự ngược nhau. Dạng hai là upsert hai khoá theo thứ tự ngược. Dạng ba là một câu upsert ba nghìn khoá, một bên chạy theo thứ tự tăng, bên kia theo thứ tự giảm. Không bên nào sửa dòng của bên kia, chỉ có chèn và upsert."
[t=+  0.60s][S1] > INSERT INTO tmp_dl_u VALUES ('B', 'S1');
[t=+  1.00s][S2] > INSERT INTO tmp_dl_u VALUES ('A', 'S2');
[t=+  1.60s][S1]   ERROR:  40P01: deadlock detected
[t=+  1.60s][S1]   DETAIL:  Process 2051069 waits for ShareLock on transaction 728543; blocked by process 2051076.
[t=+  1.60s][S1]   Process 2051076 waits for ShareLock on transaction 728542; blocked by process 2051069.
[t=+  1.60s][S1]   CONTEXT:  while inserting index tuple (0,3) in relation "tmp_dl_u_pkey"
A INSERT opposite order: runs with a 40P01: 3/3
[t=+  1.60s][S1]   CONTEXT:  while inserting index tuple (0,2) in relation "tmp_dl_cnt"
C1 upsert existing keys opposite order: runs with a 40P01: 3/3
C2 upsert absent keys opposite order: runs with a 40P01: 3/3
[t=+  1.35s][S1]   CONTEXT:  while inserting index tuple (11,146) in relation "tmp_dl_cnt"
D batch upsert asc vs desc: bursts with a 40P01: 3/3
Cách nói khi phỏng vấn: "Cả ba dạng đều deadlock trong mọi lần chạy. Ngữ cảnh lỗi nói rõ nạn nhân đang chèn vào unique index lúc bị huỷ. Kể cả upsert, khi khoá đã có sẵn, lỗi vẫn báo ở bước chèn index, vì upsert kiểm tra xung đột trước rồi mới chuyển sang cập nhật. Riêng dạng ba chỉ có hai câu lệnh mà vẫn deadlock ba trên ba lần."

Bản in ghép dòng lỗi của A (lần 1), C2 (lần 1), D (burst 1) và các dòng tổng kết. Vì sao deadlock:

  1. Khi S1 INSERT 'B' mà S2 đã chèn 'B' nhưng chưa commit, S1 không biết sẽ trùng hay không (S2 có thể rollback). Vì vậy S1 chờ transaction của S2 kết thúc: ShareLock on transaction 728543, CONTEXT … inserting index tuple … "tmp_dl_u_pkey". S2 làm điều tương tự với 'A' của S1, và vòng khép. Vẫn là mẫu "chờ xid", chỉ khác chỗ chờ là bước kiểm tra unique.

  2. Upsert ON CONFLICT DO UPDATE chờ ở cùng chỗ đó, dù key đã có (C1) hay chưa có (C2). Trong cả hai trường hợp, CONTEXT đều là while inserting index tuple. Upsert không miễn nhiễm deadlock, nó chỉ nguyên tử với từng key.

  3. Một câu INSERT … SELECT … ON CONFLICT nhiều dòng xử lý key tuần tự theo thứ tự dòng đi vào. Thứ tự tăng gặp thứ tự giảm ở giữa, giống P2. incident.io gặp đúng chuyện này ở production: "Postgres acquires locks individually and sequentially during a bulk insert". Nguyên nhân là các dòng đi qua một map nên mỗi lần chạy có thứ tự ngẫu nhiên. Victim ở D lẫn lộn (S1 1, S2 2), là race.

flowchart LR
  S1["S1 đã chèn A (chưa commit)<br/>đang chèn B"] -->|"B đã có bản chèn dở của S2<br/>chờ xid S2"| S2["S2 đã chèn B (chưa commit)<br/>đang chèn A"]
  S2 -->|"A đã có bản chèn dở của S1<br/>chờ xid S1"| S1

Cách chữa: chèn / upsert theo cùng thứ tự key. Sort trong app trước khi gửi, hoặc ORDER BY chính cột key trong INSERT … SELECT. Ở A, thêm ON CONFLICT DO NOTHING để bên đến sau không bị lỗi trùng:

[t=+  0.20s][S1] > INSERT INTO tmp_dl_u VALUES ('A', 'S1') ON CONFLICT DO NOTHING;
[t=+  0.40s][S2] > INSERT INTO tmp_dl_u VALUES ('A', 'S2') ON CONFLICT DO NOTHING;
[t=+  2.72s][S2]   INSERT 0 0
[t=+  2.72s][S2]   (returned after 2.32s)
A FIX sorted + ON CONFLICT DO NOTHING: runs with a 40P01: 0/2
C FIX upsert sorted run 1: {'S1': 'ok', 'S2': 'ok'}  u=k:S1  cnt=A:2,B:2
[t=+  0.30s][S1] > INSERT INTO tmp_dl_cnt (k, n) SELECT g::text, 1 FROM generate_series(1, 3000) g ORDER BY g::text ON CONFLICT (k) DO UPDATE SET n = tmp_dl_cnt.n + 1;
D FIX both ascending: bursts with a 40P01: 0/3
Cách nói khi phỏng vấn: "Cả hai bên chèn theo cùng một thứ tự khoá. Bên sau chờ bên trước kết thúc ở đúng khoá đầu tiên. Bên trước commit thì bên sau thấy khoá đã có và bỏ qua, không lỗi. Với upsert hàng loạt, chỉ cần hai bên sắp theo cùng một thứ tự là hết deadlock. Lab không còn lần nào lỗi, và bộ đếm cộng đủ cho cả hai bên."

Fix: A 0/2, C 0/2 (cnt=A:2,B:2, đủ cả hai lượt cộng), D 0/3. Chi tiết dễ sót ở D: fix sắp theo g::text, đúng kiểu của cột key k text. Điều quan trọng là hai bên cùng một thứ tự, và an toàn nhất là sắp theo đúng kiểu/collation của chính cột key. Đối chiếu P5-B (không deadlock ở PG): ba session chèn cùng key 'k', S1 giữ bản chèn rồi ROLLBACK:

[t=+  1.01s][MON]    S2  | 2051276 | Lock            | transactionid | {2051269}  | INSERT INTO tmp_dl_u VALUES ('k', 'S2');
[t=+  1.01s][MON]    S3  | 2051283 | Lock            | transactionid | {2051269}  | INSERT INTO tmp_dl_u VALUES ('k', 'S3');
[t=+  2.00s][S1] > ROLLBACK;
[t=+  2.00s][S2]   INSERT 0 1
after S1 ROLLBACK: still waiting = ['S3']
[t=+  2.51s][MON]    S3  | Lock            | transactionid | {2051276}
[t=+  3.50s][S3]   ERROR:  23505: duplicate key value violates unique constraint "tmp_dl_u_pkey"
B 3-session, S1 ROLLBACK: runs with a 40P01: 0/3
Cách nói khi phỏng vấn: "Ba người cùng chèn một khoá, người đầu tiên rollback. Ở PostgreSQL không có deadlock: một trong hai người còn lại chèn được, người kia quay sang chờ đúng người vừa thắng, rồi nhận lỗi trùng khoá khi người đó commit. Ai thắng là may rủi, nhưng luôn đúng một người thắng. Đây là chỗ PostgreSQL và MySQL khác nhau rõ nhất trong nhóm lỗi unique."

0/3 deadlock. Bất biến: đúng một trong S2/S3 chèn được (S2 hai lần, S3 một lần, là race), bên còn lại chuyển sang chờ xid của bên thắng và nhận 23505. Biến thể S1 COMMIT thì cả hai nhận 23505. Trên MySQL, đúng kịch bản này lại deadlock, vì lỗi trùng để lại khoá S trên bản ghi trùng (lab MySQL M3a, 3/3). Xem mục 4. Trade-off của fix: sort trong app tốn ít nhưng phải nhớ làm ở mọi nơi ghi lô, và ORM không làm hộ. ON CONFLICT DO NOTHING che lỗi trùng, nên app phải tự kiểm tra INSERT 0 0 nếu cần biết "ai giữ được ghế". Lô càng lớn thì càng giữ nhiều khoá lâu, nên chia lô nhỏ theo thứ tự key.

2.6. P6 — Khoá cấp bảng: SELECT rồi ALTER/LOCK

Bối cảnh nghiệp vụ (ví dụ minh hoạ). Hai script migration (hoặc một migration và một job bảo trì) cùng chạy trong transaction. Cả hai đọc bảng trước để kiểm tra điều kiện (SELECT count(*)), rồi mới ALTER TABLE. Trên thực tế, GitLab từng có một post-deployment migration thất bại vì "a PostgreSQL deadlock while attempting to add a foreign key to a partitioned CI table", va chạm với query của ứng dụng lúc tải cao. Deploy bị chặn khoảng 3 giờ (theo nhãn incident).

-- tmp_dl_t: 100 dòng
-- cả S1 và S2 đã BEGIN
-- t=0.2 S1, t=0.4 S2 (mỗi bên lấy AccessShareLock):
SELECT count(*) FROM tmp_dl_t;
-- t=0.6 S1 (phải chờ AccessShare của S2):
ALTER TABLE tmp_dl_t ADD COLUMN c1 int;
-- t=1.0 S2 (khép vòng):
ALTER TABLE tmp_dl_t ADD COLUMN c2 int;
-- biến thể A2: LOCK TABLE tmp_dl_t IN ACCESS EXCLUSIVE MODE; thay cho ALTER
Cách nói khi phỏng vấn: "Hai transaction cùng đọc một bảng, nên cùng giữ khoá đọc cấp bảng. Sau đó cả hai cùng muốn đổi cấu trúc bảng, việc cần khoá độc quyền. Khoá độc quyền phải chờ mọi khoá đọc của người khác nhả ra, nên mỗi bên chờ khoá đọc của bên kia. Đây là deadlock nâng cấp khoá, giống P3 nhưng ở cấp bảng."
[t=+  0.60s][S1] > ALTER TABLE tmp_dl_t ADD COLUMN c1 int;
[t=+  0.81s][MON]    S1  | relation | AccessShareLock     | t       | tmp_dl_t | {2047228}
[t=+  0.81s][MON]    S1  | relation | AccessExclusiveLock | f       | tmp_dl_t | {2047228}
[t=+  0.81s][MON]    S2  | relation | AccessShareLock     | t       | tmp_dl_t | {}
[t=+  1.00s][S2] > ALTER TABLE tmp_dl_t ADD COLUMN c2 int;
[t=+  1.00s][S2]   ERROR:  40P01: deadlock detected
[t=+  1.00s][S2]   DETAIL:  Process 2047228 waits for AccessExclusiveLock on relation 773700 of database 772093; blocked by process 2047221.
[t=+  1.00s][S2]   Process 2047221 waits for AccessExclusiveLock on relation 773700 of database 772093; blocked by process 2047228.
[t=+  1.00s][S2]   HINT:  See server log for query details.
[t=+  1.00s][S2]   LOCATION:  DeadLockReport, deadlock.c:1133
[t=+  1.00s][S1]   ALTER TABLE
[t=+  1.00s][S1]   (returned after 0.40s)
A ALTER after SELECT: runs with a 40P01: 2/2
A2 LOCK TABLE after SELECT: runs with a 40P01: 1/1
Cách nói khi phỏng vấn: "Điểm lạ là lỗi đến ngay lúc câu ALTER thứ hai được gửi, không có một giây chờ như deadlock khoá dòng. Nạn nhân là bên khép vòng chứ không phải bên chờ trước. Thông báo lỗi ghi khoá trên một quan hệ theo số định danh, không có tên bảng và không có dòng ngữ cảnh. Lần nào cũng chỉ câu ALTER của bên đầu tiên chạy được."

Bản in bắt đầu từ câu ALTER đầu tiên (đã bỏ phần SELECT và header của truy vấn pg_locks). Vì sao deadlock và ba điểm khác với khoá dòng:

  1. ALTER TABLE … ADD COLUMN cần AccessExclusiveLock, chế độ duy nhất xung đột với cả AccessShareLock mà SELECT thường lấy. S1 giữ AccessShare và xin AccessExclusive. Bản thân S1 không xung đột với chính nó ("a transaction never conflicts with itself"), nhưng phải chờ AccessShare của S2. pg_locks cho thấy đúng như vậy: dòng AccessExclusiveLock | f của S1 bị chặn bởi pid của S2. S2 làm điều tương tự, và vòng khép trên cùng một đối tượng là bảng 773700.

  2. Phát hiện ngay lúc t=1.00s, không phải sau 1s, và victim là S2, bên khép vòng (3/3 gồm 2 ALTER + 1 LOCK TABLE). Server log không có dòng detected deadlock while waiting. Giải thích bằng kiểm tra sớm trong ProcSleep() là suy luận từ mã nguồn (mục 1.3, điểm 6).

  3. DETAIL ghi relation 773700 of database 772093 là OID, không có tên bảng và không có CONTEXT. Muốn biết tên thì phải tra SELECT 773700::regclass trong đúng database đó. Câu SQL của hai bên chỉ có trong server log.

flowchart LR
  T[("bảng tmp_dl_t<br/>relation 773700")]
  S1["S1 giữ AccessShare<br/>xin AccessExclusive"] -->|"chờ AccessShare của S2"| S2["S2 giữ AccessShare<br/>xin AccessExclusive"]
  S2 -->|"chờ AccessShare của S1, bị từ chối ngay"| S1
  S1 -.- T
  S2 -.- T

Cách chữa: khoá mạnh nhất ngay câu đầu tiên. Mở đầu transaction migration bằng LOCK TABLE tmp_dl_t IN ACCESS EXCLUSIVE MODE;:

[t=+  0.20s][S1] > LOCK TABLE tmp_dl_t IN ACCESS EXCLUSIVE MODE;
[t=+  0.20s][S1]   LOCK TABLE
[t=+  0.40s][S2] > LOCK TABLE tmp_dl_t IN ACCESS EXCLUSIVE MODE;
[t=+  0.80s][S1]   ALTER TABLE
[t=+  2.03s][S1]   COMMIT
[t=+  2.03s][S2]   LOCK TABLE
[t=+  2.03s][S2]   (returned after 1.63s)
final columns of tmp_dl_t: id,v,c1,c2
A FIX lock first: runs with a 40P01: 0/2
Cách nói khi phỏng vấn: "Migration xin khoá độc quyền ngay câu đầu tiên. Bên thứ hai chờ ở câu khoá, lúc nó chưa giữ gì, rồi chạy sau. Bảng có đủ cả hai cột mới. Nhưng trong lúc chờ, bảng bị khoá với mọi người, nên ở production bắt buộc phải đi kèm giới hạn thời gian chờ khoá."

Bản in đã cắt các dòng SELECT/ALTER gửi đi và pg_locks ở giữa. Fix chạy 2/2 không deadlock. Trade-off — và đây là chỗ nối với hàng đợi khoá ở mục 1.1: một LOCK TABLE … ACCESS EXCLUSIVE (hay một ALTER bất kỳ) đang chờ cũng đã chặn mọi câu SELECT đến sau nó (P6-B: một câu SELECT phải chờ 3.21s). Vì vậy fix "khoá mạnh trước" chỉ an toàn khi đi kèm lock_timeout ngắn và retry ở tầng script deploy:

[t=+  0.30s][S2] > SET lock_timeout = '2s';
[t=+  0.40s][S2] > ALTER TABLE tmp_dl_t ADD COLUMN c_new int;
[t=+  0.80s][S3] > SELECT count(*) FROM tmp_dl_t;
[t=+  2.40s][S2]   ERROR:  55P03: canceling statement due to lock timeout
[t=+  2.40s][S3]   (returned after 1.60s)
S3 (a plain SELECT) waited 1.60s
B FIX lock_timeout=2s          : {'S1': 'ok', 'S2': 'error 55P03', 'S3': 'ok'}  S3 SELECT waited 1.6s
Cách nói khi phỏng vấn: "Câu migration tự đặt giới hạn hai giây cho việc chờ khoá. Hết hai giây mà chưa lấy được khoá thì nó tự bỏ cuộc, và câu SELECT đang xếp hàng sau nó được chạy ngay. Người dùng chỉ chậm khoảng một giây rưỡi thay vì bị chặn vô hạn. Đổi lại, script deploy phải biết chạy lại migration vào lúc khác."

Với mục 1.2, đây là phá điều kiện "no preemption" ở phía app: DDL tự rút lui thay vì chặn cả hàng. Tài liệu khuyên không đặt lock_timeout trong postgresql.conf ("would affect all sessions"), nên chỉ đặt ở session của migration. Con số 2s là giá trị minh hoạ của lab. Giá trị thật phải nhỏ hơn mức chậm mà người dùng chịu được.

2.7. P7 — Advisory lock lấy ngược thứ tự

Bối cảnh nghiệp vụ (ví dụ minh hoạ). App dùng advisory lock (khoá tư vấn: khoá do app tự đặt tên bằng một số, database không gắn nó với dòng hay bảng nào) để tuần tự hoá theo khoá nghiệp vụ. Job tính điểm khoá "khoá học 1" rồi "khoá học 2", job đồng bộ lại khoá theo thứ tự ngược. Moodle trên PostgreSQL cũng dùng advisory lock cho lock factory mặc định (postgres_lock_factory), nên mẫu này không chỉ có trên lý thuyết.

-- P7-A: khoá theo transaction, cả hai đã BEGIN
SELECT pg_advisory_xact_lock(1);   -- t=0.2 S1
SELECT pg_advisory_xact_lock(2);   -- t=0.4 S2
SELECT pg_advisory_xact_lock(2);   -- t=0.6 S1 (chờ S2)
SELECT pg_advisory_xact_lock(1);   -- t=1.0 S2 (khép vòng)
-- P7-B: cùng thứ tự đó nhưng dùng pg_advisory_lock (khoá theo SESSION), không BEGIN
Cách nói khi phỏng vấn: "Mỗi bên lấy hai khoá do ứng dụng tự đặt tên, theo thứ tự ngược nhau. Database coi khoá tư vấn như mọi khoá khác, nên chúng cũng nằm trong đồ thị chờ và cũng deadlock được. Tôi thử cả hai loại: khoá tự nhả khi hết transaction, và khoá gắn với cả phiên kết nối."
[t=+  0.84s][MON]    S1  |       0 |     1 |        1 | ExclusiveLock | t
[t=+  0.84s][MON]    S1  |       0 |     2 |        1 | ExclusiveLock | f
[t=+  0.84s][MON]    S2  |       0 |     2 |        1 | ExclusiveLock | t
[t=+  1.00s][S2] > SELECT pg_advisory_xact_lock(1);
[t=+  1.62s][S1]   ERROR:  40P01: deadlock detected
[t=+  1.62s][S1]   DETAIL:  Process 2047565 waits for ExclusiveLock on advisory lock [772093,0,2,1]; blocked by process 2047572.
[t=+  1.62s][S1]   Process 2047572 waits for ExclusiveLock on advisory lock [772093,0,1,1]; blocked by process 2047565.
[t=+  1.62s][S1]   HINT:  See server log for query details.
xact-level opposite order: runs with a 40P01: 3/3
xact-level FIX (sorted keys) run 1: {'S1': 'ok', 'S2': 'ok'}
xact-level FIX (sorted keys) run 2: {'S1': 'ok', 'S2': 'ok'}
Cách nói khi phỏng vấn: "Bảng khoá cho thấy bên thứ nhất giữ khoá số một và đang chờ khoá số hai, bên thứ hai giữ khoá số hai. Bên thứ hai xin khoá số một thì vòng khép, và sau đúng một giây PostgreSQL huỷ một bên, giống hệt deadlock khoá dòng. Lấy khoá theo thứ tự số tăng dần thì hai lần chạy đều không lỗi."

Đọc DETAIL: advisory lock [772093,0,2,1] gồm OID database 772093 rồi ba số khớp với các cột classid | objid | objsubid mà MON in ra từ pg_locks (0 | 2 | 1), trong đó objid chính là key 2 app truyền vào. 3/3 deadlock, mỗi lần đúng một 40P01. Fix lấy key tăng dần, 2/2. Cơ chế y hệt P1. Advisory lock đi qua cùng lock manager nên nằm trong cùng waits-for graph. Tài liệu viết: "a complete list of advisory locks currently held by any session can be found in the pg_locks system view".

flowchart LR
  S1["S1 giữ advisory key 1"] -->|"chờ key 2"| S2["S2 giữ advisory key 2"]
  S2 -->|"chờ key 1"| S1

Điều bất ngờ: với pg_advisory_lock (khoá theo session), victim vẫn giữ khoá sau lỗi 40P01.

[t=+  0.20s][S1] > SELECT pg_advisory_lock(1);
[t=+  0.40s][S2] > SELECT pg_advisory_lock(2);
[t=+  0.60s][S1] > SELECT pg_advisory_lock(2);
[t=+  1.00s][S2] > SELECT pg_advisory_lock(1);
[t=+  1.60s][S1]   ERROR:  40P01: deadlock detected
victim = S1; the other session busy (still waiting)? True
[t=+  2.52s][MON]    S1  |       0 |     1 |        1 | ExclusiveLock | t
[t=+  2.52s][MON]    S2  |       0 |     1 |        1 | ExclusiveLock | f
[t=+  2.52s][MON]    S2  |       0 |     2 |        1 | ExclusiveLock | t
[t=+  2.53s][MON]    S1  | idle   | Client          | ClientRead | {}
[t=+  2.53s][MON]    S2  | active | Lock            | advisory   | {2047697}
[t=+  4.00s][S1] > SELECT pg_advisory_unlock_all();
[t=+  4.00s][S2]   (returned after 3.00s)
Cách nói khi phỏng vấn: "Với khoá theo phiên, deadlock vẫn được phát hiện, nhưng nạn nhân chỉ mất yêu cầu khoá đang chờ, còn khoá số một nó đã giữ thì vẫn giữ. Vì vậy bên được coi là thắng vẫn tiếp tục bị chặn. Phiên nạn nhân đang rảnh, không chạy gì, nhưng bên kia phải chờ thêm ba giây cho tới khi nạn nhân tự nhả toàn bộ khoá. Nếu app bắt lỗi mà quên nhả khoá, bên kia sẽ chờ mãi."

Bản in đã cắt các dòng kết quả rỗng của hàm và header của truy vấn MON. Đây là kịch bản 1/1. Cơ chế: lỗi 40P01 huỷ yêu cầu khoá và transaction hiện tại, nhưng khoá advisory theo session không theo ngữ nghĩa transaction. Tài liệu viết: "session-level advisory lock requests do not honor transaction semantics: a lock acquired during a transaction that is later rolled back will still be held following the rollback". S1 vẫn giữ key 1 (t), S1 đang idle, còn S2 vẫn chờ key 1 với blocked_by = pid của S1. Chỉ khi S1 gọi pg_advisory_unlock_all() lúc 4.00s thì S2 mới chạy tiếp, sau 3.00s chờ. Ở mục 1.1 đây là "lock wait", nhưng về nghiệp vụ nó tệ hơn deadlock: không có lỗi nào báo cho bên đang chờ, và nếu code xử lý lỗi của S1 không nhả khoá thì S2 chờ cho tới khi kết nối của S1 đóng. Với connection pool, chuyện đó có thể không bao giờ xảy ra.

Trade-off và lựa chọn: (a) mặc định dùng pg_advisory_xact_lock: khoá tự nhả khi transaction kết thúc, kể cả khi bị huỷ vì deadlock, và tài liệu nói nó "behave more like regular lock requests". (b) Chỉ dùng khoá theo session khi buộc phải giữ khoá qua nhiều transaction (job dài commit từng phần). Khi đó mọi nhánh lỗi phải gọi pg_advisory_unlock/pg_advisory_unlock_all() trong finally, hoặc huỷ kết nối. (c) Advisory lock dùng để tuần tự hoá theo khoá nghiệp vụ (mục 6) là cách chữa deadlock hiệu quả, nhưng lấy hai advisory key trở lên thì lại phải sắp thứ tự như mọi khoá khác. Trên MySQL, khoá tên tương đương là GET_LOCK(). Theo tài liệu, deadlock của nó trả ER_USER_LOCK_DEADLOCK (3058) và "does not cause transactions to roll back". Lab chưa verify điều này. Xem mục 4.

3. Các trường hợp deadlock ở MySQL InnoDB

Mục này chạy lại các tình huống của mục 2 trên MySQL 8.4.13 (InnoDB, isolation mặc định REPEATABLE READ, innodb_deadlock_detect=ON, innodb_lock_wait_timeout=50), cộng thêm những trường hợp chỉ InnoDB mới có vì nó khoá theo bản ghi index chứ không theo dòng: gap lock, thiếu index, secondary index đi ngược primary key. Mỗi session là một kết nối PyMySQL riêng (mysql/files/dl.py), mỗi câu lệnh bắt đầu ở một mốc thời gian tuyệt đối t=+x; dòng kết quả -> … ghi kèm thời gian câu lệnh đã chạy, nên câu nào phải chờ sẽ lộ ra ở con số trong ngoặc. Sau mỗi lỗi 1205/1213/1062, orchestrator gửi thêm DO 0 rồi đọc INNODB_TRX và in dòng server view: … — đó là trạng thái transaction phía server, không phải phỏng đoán của client. Một session MON đọc performance_schema.data_locks / data_lock_waits trong lúc các session khác đang chờ. Mọi case kiểu "đua" đều chạy ít nhất 2–3 lần trên schema tạo lại từ đầu; số đếm ghi dạng X/N. Chạy lại: bash 00-up.sh, rồi bash 01-m1-opposite-order.sh … bash 07-m7-upsert-replace.sh trong labs/database/deadlock/mysql/, dọn bằng bash 99-cleanup.sh.

3.0. Đọc khối LATEST DETECTED DEADLOCK trong 2 phút

Khi ứng dụng nhận ERROR 1213, bằng chứng duy nhất InnoDB giữ sẵn (với cấu hình mặc định) là đoạn LATEST DETECTED DEADLOCK trong SHOW ENGINE INNODB STATUS. Đúng như tên gọi, nó chỉ giữ deadlock gần nhất: tài liệu của innodb_print_all_deadlocks ghi rõ khi biến này tắt (mặc định) thì "you see information about only the last deadlock". Deadlock (khoá chết — hai hay nhiều transaction chờ nhau thành vòng tròn, không bên nào tự đi tiếp được) xảy ra lần sau sẽ ghi đè lần trước, nên phải đọc ngay hoặc bật ghi ra error log (mục 5). Câu lệnh đọc và câu truy vấn xem ai đang chờ ai lúc deadlock chưa xảy ra:

SHOW ENGINE INNODB STATUS\G   -- tìm đoạn LATEST DETECTED DEADLOCK

-- ai đang chờ ai (lock wait — chờ khoá — đang diễn ra, chưa thành vòng tròn)
SELECT w.REQUESTING_ENGINE_TRANSACTION_ID AS waiting_trx, w.BLOCKING_ENGINE_TRANSACTION_ID AS blocking_trx,
       rl.LOCK_MODE AS waiting_mode, rl.LOCK_DATA AS waiting_data, bl.LOCK_MODE AS blocking_mode
FROM performance_schema.data_lock_waits w
JOIN performance_schema.data_locks rl ON rl.ENGINE_LOCK_ID = w.REQUESTING_ENGINE_LOCK_ID
JOIN performance_schema.data_locks bl ON bl.ENGINE_LOCK_ID = w.BLOCKING_ENGINE_LOCK_ID;
Cách nói khi phỏng vấn: "Khi MySQL báo deadlock, việc đầu tiên tôi làm là lấy trạng thái InnoDB và đọc đoạn deadlock gần nhất, vì server chỉ giữ đúng một cái và cái sau sẽ đè cái trước. Còn khi hệ thống mới chỉ đang chậm vì chờ khoá, chưa thành vòng tròn, tôi nối hai bảng của performance schema để thấy transaction nào đang chờ transaction nào, chờ khoá kiểu gì, trên giá trị khoá nào. Một cái là ảnh chụp sau tai nạn, một cái là camera lúc đang kẹt xe."

Khối dưới đây lấy nguyên văn từ lab M1 (mục 3.1, out/01-m1-opposite-order.log, run 1): S1 chuyển tiền từ tài khoản 1 sang 2, S2 chuyển từ 2 sang 1. Phần hex dump của bản ghi thứ hai trong mỗi khối được lược bớt, chỉ giữ dòng 0: len 4; hex … đầu tiên của bản ghi để đọc ra giá trị khoá.

LATEST DETECTED DEADLOCK
------------------------
2026-11-05 10:18:34 140017292777024
*** (1) TRANSACTION:
TRANSACTION 1815, ACTIVE 2 sec starting index read
mysql tables in use 1, locked 1
LOCK WAIT 3 lock struct(s), heap size 1128, 2 row lock(s), undo log entries 1
MySQL thread id 14, OS thread handle 140017748870720, query id 44 172.23.0.3 root updating
UPDATE acc SET balance = balance + 10 WHERE id = 2   -- waits for S2
*** (1) HOLDS THE LOCK(S):
RECORD LOCKS space id 2 page no 4 n bits 72 index PRIMARY of table `lab`.`acc` trx id 1815 lock_mode X locks rec but not gap
Record lock, heap no 2 PHYSICAL RECORD: n_fields 4; compact format; info bits 0
 0: len 4; hex 80000001; asc     ;;
*** (1) WAITING FOR THIS LOCK TO BE GRANTED:
RECORD LOCKS space id 2 page no 4 n bits 72 index PRIMARY of table `lab`.`acc` trx id 1815 lock_mode X locks rec but not gap waiting
Record lock, heap no 3 PHYSICAL RECORD: n_fields 4; compact format; info bits 0
 0: len 4; hex 80000002; asc     ;;
*** (2) TRANSACTION:
TRANSACTION 1816, ACTIVE 2 sec starting index read
mysql tables in use 1, locked 1
LOCK WAIT 3 lock struct(s), heap size 1128, 2 row lock(s), undo log entries 1
MySQL thread id 15, OS thread handle 140017747813952, query id 47 172.23.0.3 root updating
UPDATE acc SET balance = balance + 20 WHERE id = 1   -- closes the cycle
*** (2) HOLDS THE LOCK(S):
RECORD LOCKS space id 2 page no 4 n bits 72 index PRIMARY of table `lab`.`acc` trx id 1816 lock_mode X locks rec but not gap
*** (2) WAITING FOR THIS LOCK TO BE GRANTED:
RECORD LOCKS space id 2 page no 4 n bits 72 index PRIMARY of table `lab`.`acc` trx id 1816 lock_mode X locks rec but not gap waiting
*** WE ROLL BACK TRANSACTION (2)

Đọc theo thứ tự này là đủ trong hầu hết trường hợp:

  1. Hai khối *** (1) TRANSACTION và *** (2) TRANSACTION là hai thành viên của vòng tròn. TRANSACTION 1815 là trx id của InnoDB — cùng con số bạn thấy ở data_locks.ENGINE_TRANSACTION_ID và INNODB_TRX.trx_id. ACTIVE 2 sec là tuổi transaction. MySQL thread id 14 là CONNECTION_ID() / id trong processlist, tức là cái nối được về connection của ứng dụng (và là thứ KILL nhận).

  2. Dòng LOCK WAIT 3 lock struct(s), … 2 row lock(s), undo log entries 1: transaction đang chờ khoá; 2 row lock(s) = khoá dòng đang giữ cộng khoá đang xin; undo log entries 1 = số dòng nó đã sửa tới lúc đó. Con số undo này là đầu vào để InnoDB chọn victim (nạn nhân bị rollback) — tài liệu nói "InnoDB tries to pick small transactions to roll back, where the size of a transaction is determined by the number of rows inserted, updated, or deleted". Ở M1 hai bên đều là 1 nên dòng này không phân định được ai nhỏ hơn.

  3. Dòng câu lệnh ngay dưới (UPDATE acc SET balance = balance + 10 WHERE id = 2) là câu lệnh đang chờ, không phải câu lệnh đã lấy khoá đang giữ. Khoá trên id=1 của transaction (1) được lấy bởi câu UPDATE … WHERE id = 1 chạy trước đó, và câu đó không hề xuất hiện ở đây. Muốn biết đầy đủ transaction đã làm gì, ứng dụng phải tự log — đây là lý do log ứng dụng cần trx id hoặc connection id.

  4. HOLDS THE LOCK(S) và WAITING FOR THIS LOCK TO BE GRANTED: cái đang giữ và cái đang xin. Mỗi khoá được định danh bằng index PRIMARY of table `lab`.`acc` (khoá nằm trên index nào của bảng nào) và bản ghi cụ thể qua heap no + hex dump. Trường 0 của bản ghi clustered index là primary key: hex 80000001 là số nguyên 1 (bit dấu bị lật), hex 80000002 là 2. Ghép lại: (1) giữ id=1, chờ id=2; (2) giữ id=2, chờ id=1 → vòng tròn.

  5. Chữ mô tả kiểu khoá ở cuối dòng RECORD LOCKS: lock_mode X locks rec but not gap là khoá X chỉ trên bản ghi (không kèm khoảng trống); thêm chữ waiting nghĩa là đang xin chưa được. Bảng tra bên dưới liệt kê đủ các biến thể sẽ gặp ở M2–M7.

  6. *** WE ROLL BACK TRANSACTION (2): victim là transaction (2), tức S2. InnoDB rollback toàn bộ transaction đó, không chỉ câu lệnh cuối — lab xác nhận ở dòng server view: SERVER_STATUS_IN_TRANS=False; no INNODB_TRX row for this connection (mục 3.1, mục 4.2).

Bảng tra các kiểu khoá InnoDB (định nghĩa trích từ trang InnoDB Locking của MySQL 8.4; cột "gặp ở" là case trong lab này):

Kiểu khoáÝ nghĩaTrong SHOW ENGINE INNODB STATUSdata_locks.LOCK_MODEGặp ở
Shared (S) / exclusive (X)S cho phép nhiều transaction cùng giữ; X loại trừ mọi khoá khác trên cùng bản ghi ("a request … for a lock of either type on r cannot be granted immediately")lock mode S … / lock_mode X …tiền tố S, / X,mọi case
Record lock (khoá bản ghi)Khoá đúng một bản ghi index; "Record locks always lock index records, even if a table is defined with no indexes"locks rec but not gapX,REC_NOT_GAP / S,REC_NOT_GAPM1, M5, M7
Gap lock (khoá khoảng trống)Khoá khoảng giữa hai bản ghi index, chỉ để chặn INSERT vào khoảng đó; "Gap locks can co-exist" — hai transaction giữ cùng một gap không chặn nhaulocks gap before recX,GAP / S,GAPM2, M3
Next-key lockRecord lock + gap lock phía trước bản ghi; kiểu mặc định khi quét/tìm ở REPEATABLE READ (chống phantom)lock_mode X (không có hậu tố)X / SM4, M6
Insert intention (khoá "ý định chèn")Một loại gap lock do INSERT xin trước khi chèn; các INSERT khác vị trí trong cùng gap không chờ nhau, nhưng nó phải chờ gap lock / next-key lock của transaction khác trên gap đólocks gap before rec insert intentionX,GAP,INSERT_INTENTIONM2, M3
Intention lock IS / IX (cấp bảng)Đánh dấu "sắp khoá dòng ở bảng này"; "Intention locks do not block anything except full table requests"(không in trong khối deadlock)IS / IX, LOCK_TYPE = TABLEmọi case

Hai chi tiết đọc data_locks hay bị hiểu sai. Một: LOCK_DATA của secondary index in cả giá trị cột index lẫn primary key (ví dụ 10, 1 ở M6 = k=10 của dòng id=1). Hai: dòng vừa INSERT chưa commit giữ một implicit lock (khoá ngầm) không hiện trong data_locks cho tới khi một transaction khác chạm vào nó; lúc đó InnoDB mới đổi thành khoá tường minh (thấy ở M2c và M7b). Vì vậy "không thấy khoá trong data_locks" không có nghĩa là "không có khoá".

3.1. M1 — Hai transaction khoá hai dòng theo thứ tự ngược nhau

Bối cảnh (ví dụ minh hoạ): ví điện tử nội bộ của một hệ thống học trực tuyến — học viên A chuyển 10 điểm thưởng cho B đúng lúc B chuyển 20 điểm cho A. Mỗi lệnh chuyển là một transaction trừ bên gửi trước, cộng bên nhận sau. Thứ tự khoá vì thế phụ thuộc ai là người gửi, và hai lệnh ngược chiều khoá hai dòng theo thứ tự ngược nhau.

CREATE TABLE acc (id INT PRIMARY KEY, balance INT NOT NULL) ENGINE=InnoDB;
INSERT INTO acc VALUES (1, 100), (2, 100);

-- t=+0.00s  S1: START TRANSACTION;          S2: START TRANSACTION;
-- t=+0.20s  S1: UPDATE acc SET balance = balance - 10 WHERE id = 1;   -- khoá id=1
-- t=+0.40s  S2: UPDATE acc SET balance = balance - 20 WHERE id = 2;   -- khoá id=2
-- t=+0.80s  S1: UPDATE acc SET balance = balance + 10 WHERE id = 2;   -- chờ S2
-- t=+1.80s  S2: UPDATE acc SET balance = balance + 20 WHERE id = 1;   -- đóng vòng tròn
-- t=+2.60s  cả hai: COMMIT;
Cách nói khi phỏng vấn: "Hai lệnh chuyển tiền ngược chiều nhau. Lệnh thứ nhất khoá tài khoản một rồi xin tài khoản hai, lệnh thứ hai khoá tài khoản hai rồi xin tài khoản một. Mỗi bên đang giữ đúng thứ bên kia cần, nên không ai nhả trước được. Đây là dạng deadlock kinh điển nhất và nó không phụ thuộc database nào."

Kết quả run 1 (đã lược các dòng câu truy vấn của MON, giữ bảng data_locks):

[t=+ 0.20s][S1] UPDATE acc SET balance = balance - 10 WHERE id = 1   -- S1 transfers 1 -> 2
[t=+ 0.20s][S1]   -> OK, 1 row(s) affected (0.00s)
[t=+ 0.40s][S2] UPDATE acc SET balance = balance - 20 WHERE id = 2   -- S2 transfers 2 -> 1
[t=+ 0.40s][S2]   -> OK, 1 row(s) affected (0.00s)
[t=+ 0.80s][S1] UPDATE acc SET balance = balance + 10 WHERE id = 2   -- waits for S2
[t=+ 1.31s][MON]    | session | trx  | tbl | idx     | type   | mode          | status  | data |
[t=+ 1.31s][MON]    | S1      | 1815 | acc | NULL    | TABLE  | IX            | GRANTED | NULL |
[t=+ 1.31s][MON]    | S1      | 1815 | acc | PRIMARY | RECORD | X,REC_NOT_GAP | GRANTED | 1    |
[t=+ 1.31s][MON]    | S1      | 1815 | acc | PRIMARY | RECORD | X,REC_NOT_GAP | WAITING | 2    |
[t=+ 1.31s][MON]    | S2      | 1816 | acc | NULL    | TABLE  | IX            | GRANTED | NULL |
[t=+ 1.31s][MON]    | S2      | 1816 | acc | PRIMARY | RECORD | X,REC_NOT_GAP | GRANTED | 2    |
[t=+ 1.83s][S2] UPDATE acc SET balance = balance + 20 WHERE id = 1   -- closes the cycle
[t=+ 1.85s][S1]   -> OK, 1 row(s) affected (1.05s)
[t=+ 1.85s][S2]   -> ERROR 1213 (40001): Deadlock found when trying to get lock; try restarting transaction  (0.01s)
[t=+ 1.85s][S2]      server view: SERVER_STATUS_IN_TRANS=False; no INNODB_TRX row for this connection
RUN 1: outcome {'S1': 'no error', 'S2': '1213'} | INNODB_METRICS delta: lock_deadlocks +1, lock_timeouts +0
[t=+ 2.63s][MON]    | id | balance |
[t=+ 2.63s][MON]    | 1  | 90      |
[t=+ 2.63s][MON]    | 2  | 110     |
SUMMARY [M1 opposite-order row locks: S1 locks id=1 then id=2, S2 locks id=2 then id=1]: ERROR 1213 seen in 3/3 runs; outcomes per run: [{'S1': 'no error', 'S2': '1213'}, {'S1': 'no error', 'S2': '1213'}, {'S1': 'no error', 'S2': '1213'}]

Vì sao deadlock. Ở t=+1.31s bảng data_locks cho thấy nửa đầu vòng tròn: S1 giữ X,REC_NOT_GAP trên id=1 và đang WAITING cùng kiểu khoá trên id=2, do S2 giữ. Lúc này mới chỉ là một lock wait bình thường. Ở t=+1.83s S2 xin X trên id=1 — cạnh thứ hai của waits-for graph (đồ thị "ai chờ ai") xuất hiện, đồ thị có vòng. Khoá ở đây là khoá bản ghi trên index PRIMARY vì cả bốn câu UPDATE tìm theo primary key bằng điều kiện bằng; tài liệu nói rõ tìm một dòng duy nhất qua unique index thì không cần gap lock, nên không có chữ gap nào trong khối deadlock ở mục 3.0.

Phát hiện ngay, không đợi. S2 nhận lỗi sau (0.01s) và cùng lúc câu đang chờ của S1 được cấp khoá ((1.05s) = chờ từ 0.80 tới 1.85). Trang tham số của MySQL 8.4 viết "InnoDB detects deadlocks immediately"; blog kỹ thuật của chính tác giả lock system InnoDB mô tả rằng từ 8.0.18 việc tìm vòng chạy trên một background thread riêng, trên bản sao đồ thị chờ. Hai mô tả khác nhau về cơ chế nhưng thống nhất về kết quả, và lab đo được đúng kết quả đó: lỗi đến sau 0,01–0,03 s (M1, M4b), không bị trì hoãn như PostgreSQL phải đợi deadlock_timeout (1 giây) rồi mới kiểm tra (P1 ở mục 2; bảng so sánh ở mục 4.1). Dòng server view xác nhận S2 không còn transaction nào: toàn bộ transaction của S2 — kể cả câu UPDATE … - 20 WHERE id = 2 chạy thành công lúc 0.40 — đã bị rollback. Số dư cuối 90/110 đúng bằng chỉ riêng lệnh chuyển của S1. Lệnh COMMIT mà S2 gửi sau đó trả OK, 0 row(s) và không làm gì: không có transaction nào để commit. Ứng dụng nào coi "COMMIT không lỗi" là "chuyển tiền thành công" sẽ báo sai cho người dùng.

Ai là victim. 3/3 run victim đều là S2, bên đóng vòng tròn. Đừng đọc thành quy tắc "bên đóng vòng luôn chết": hai transaction ở đây có cùng kích thước (mỗi bên 1 undo entry). Lab chọn victim riêng (12-victim-choice.sh) cho thấy khi bên đóng vòng sửa 200 dòng thì InnoDB rollback bên kia — InnoDB chọn transaction nhẹ hơn theo INNODB_TRX.trx_weight, còn khi hai bên ngang nhau thì kết quả lab không đủ để kết luận một quy tắc. Bất biến đúng để nói là: đúng một transaction nhận 1213, transaction kia chạy tiếp ngay.

flowchart LR
  T1["S1 (trx 1815)<br/>giữ X trên acc id=1"] -- "chờ X trên id=2" --> T2["S2 (trx 1816)<br/>giữ X trên acc id=2"]
  T2 -- "chờ X trên id=1 (cạnh đóng vòng)" --> T1

Cách chữa: mọi đường code khoá theo cùng một thứ tự. Trước khi sửa, khoá cả hai dòng trong một câu, sắp theo id tăng dần, bất kể chiều chuyển tiền:

START TRANSACTION;
SELECT id, balance FROM acc WHERE id IN (2, 1) ORDER BY id FOR UPDATE;  -- luôn khoá id nhỏ trước
UPDATE acc SET balance = balance - 20 WHERE id = 2;
UPDATE acc SET balance = balance + 20 WHERE id = 1;
COMMIT;
Cách nói khi phỏng vấn: "Tôi bắt mọi transaction khoá các dòng nó cần ngay từ đầu, theo một thứ tự cố định, ví dụ id tăng dần, dù nghiệp vụ là chuyển từ A sang B hay từ B sang A. Khi đó hai transaction tranh cùng dòng đầu tiên, bên đến sau chỉ xếp hàng chờ chứ không giữ được thứ gì để chặn ngược bên trước. Chờ thì có, nhưng vòng tròn không thể hình thành."
[t=+ 0.20s][S1] SELECT id, balance FROM acc WHERE id IN (1, 2) ORDER BY id FOR UPDATE   -- lock both, ascending id
[t=+ 0.41s][S2] SELECT id, balance FROM acc WHERE id IN (2, 1) ORDER BY id FOR UPDATE   -- same order -> waits on id=1
[t=+ 0.91s][MON]    | S2      | 1891 | acc | PRIMARY | RECORD | X,REC_NOT_GAP | WAITING | 1    |
[t=+ 1.60s][S1] COMMIT
[t=+ 1.61s][S2]   -> 2 row(s) (1.20s)
[t=+ 2.01s][MON]    | 1  | 110     |
[t=+ 2.01s][MON]    | 2  | 90      |
SUMMARY [M1-FIX same lock order: both lock (1,2) in ascending id with SELECT ... ORDER BY id FOR UPDATE]: ERROR 1213 seen in 0/3 runs; outcomes per run: [{'S1': 'no error', 'S2': 'no error'}, {'S1': 'no error', 'S2': 'no error'}, {'S1': 'no error', 'S2': 'no error'}]

S2 chờ 1,20 s ở chính câu SELECT … FOR UPDATE (trên id=1, trước khi kịp giữ id=2), rồi chạy hết; số dư cuối 110/90 = cả hai lệnh chuyển đều được áp dụng. 0/3 run có deadlock, so với 3/3 khi không sắp thứ tự.

Trade-off. (1) Dòng thứ hai bị khoá sớm hơn và lâu hơn cần thiết — với hai dòng thì không đáng kể, với transaction khoá hàng trăm dòng thì giảm concurrency rõ. (2) Phải biết trước toàn bộ tập id cần khoá; luồng nghiệp vụ "đọc xong mới biết cần sửa dòng nào" không áp dụng thẳng được. (3) Quy ước thứ tự phải đúng ở mọi đường code chạm các bảng đó — chỉ một job batch hay một endpoint cũ khoá ngược là vòng tròn quay lại. Đây vẫn là cách chữa đầu tiên mà cả tài liệu MySQL ("do those operations in a consistent order each time") lẫn PostgreSQL đều khuyên.

3.2. M2 — Gap lock: cùng SELECT … FOR UPDATE một khoá chưa tồn tại rồi cùng INSERT

Bài PostgreSQL vs MySQL, mục 4.4, đã có lab cơ bản: một transaction SELECT … WHERE id BETWEEN 11 AND 19 FOR UPDATE trên khoảng không có dòng nào, và InnoDB vẫn chặn INSERT 15 của người khác cho tới khi commit vì khoá X,GAP trên bản ghi 20. Phần đó không lặp lại ở đây. Mục này đi tiếp một bước: khi cả hai transaction cùng làm "kiểm tra rồi chèn", gap lock biến thành deadlock — kể cả khi hai khoá chèn khác nhau.

Bối cảnh (ví dụ minh hoạ): đặt lịch thi theo ca. Bảng slots có các ca 10, 20, 30 đã được đặt. Hai sinh viên cùng muốn đặt một ca trống trong khoảng (10, 20). Code "an toàn" kiểu SELECT … FOR UPDATE → không thấy → INSERT được viết với ý định khoá ca đó lại trước khi chèn.

-- REPEATABLE READ (mặc định)
CREATE TABLE slots (id INT PRIMARY KEY, who VARCHAR(10), cnt INT NOT NULL DEFAULT 1) ENGINE=InnoDB;
INSERT INTO slots (id, who) VALUES (10, 'seed'), (20, 'seed'), (30, 'seed');

-- t=+0.20s  S1: START TRANSACTION; SELECT * FROM slots WHERE id = 15 FOR UPDATE;   -- 0 dòng
-- t=+0.40s  S2: START TRANSACTION; SELECT * FROM slots WHERE id = 15 FOR UPDATE;   -- 0 dòng (M2b: id = 16)
-- t=+1.00s  S1: INSERT INTO slots (id, who) VALUES (15, 'S1');                    -- chờ
-- t=+1.80s  S2: INSERT INTO slots (id, who) VALUES (15, 'S2');                    -- M2b: (16, 'S2')
Cách nói khi phỏng vấn: "Hai người cùng kiểm tra xem ca số mười lăm còn trống không bằng một câu đọc có khoá. Ca đó chưa tồn tại nên InnoDB không có dòng nào để khoá, nó khoá khoảng trống giữa mười và hai mươi. Khoá khoảng trống của hai người không chặn nhau, nên cả hai đều tưởng mình đã giữ chỗ. Đến lúc chèn thì mỗi người lại bị khoảng trống của người kia chặn, và đó là vòng tròn."

Kết quả M2a run 1 (đã lược các dòng câu truy vấn của MON):

[t=+ 0.20s][S1] SELECT * FROM slots WHERE id = 15 FOR UPDATE   -- not found -> 'safe to insert'
[t=+ 0.40s][S2] SELECT * FROM slots WHERE id = 15 FOR UPDATE   -- not found either
[t=+ 0.71s][MON]    | session | trx  | tbl   | idx     | type   | mode  | status  | data |
[t=+ 0.71s][MON]    | S1      | 1991 | slots | PRIMARY | RECORD | X,GAP | GRANTED | 20   |
[t=+ 0.71s][MON]    | S2      | 1992 | slots | PRIMARY | RECORD | X,GAP | GRANTED | 20   |
[t=+ 1.00s][S1] INSERT INTO slots (id, who) VALUES (15, 'S1')
[t=+ 1.40s][MON]    | S1      | 1991 | slots | PRIMARY | RECORD | X,GAP,INSERT_INTENTION | WAITING | 20   |
[t=+ 1.40s][MON]    | 1991        | 1992         | X,GAP,INSERT_INTENTION | 20           | X,GAP         |
[t=+ 1.80s][S2] INSERT INTO slots (id, who) VALUES (15, 'S2')
[t=+ 1.80s][S1]   -> OK, 1 row(s) affected (0.80s)
[t=+ 1.81s][S2]   -> ERROR 1213 (40001): Deadlock found when trying to get lock; try restarting transaction  (0.01s)
SUMMARY [M2a gap lock, same missing key: both SELECT id=15 FOR UPDATE, both INSERT 15]: ERROR 1213 seen in 3/3 runs; outcomes per run: [{'S1': 'no error', 'S2': '1213'}, {'S1': 'no error', 'S2': '1213'}, {'S1': 'no error', 'S2': '1213'}]

Khối deadlock tương ứng (lược hex dump, giữ trường 0 của bản ghi):

TRANSACTION 1991, ACTIVE 2 sec inserting
LOCK WAIT 3 lock struct(s), heap size 1128, 2 row lock(s)
INSERT INTO slots (id, who) VALUES (15, 'S1')
*** (1) HOLDS THE LOCK(S):
RECORD LOCKS space id 9 page no 4 n bits 72 index PRIMARY of table `lab`.`slots` trx id 1991 lock_mode X locks gap before rec
 0: len 4; hex 80000014; asc     ;;
*** (1) WAITING FOR THIS LOCK TO BE GRANTED:
RECORD LOCKS space id 9 page no 4 n bits 72 index PRIMARY of table `lab`.`slots` trx id 1991 lock_mode X locks gap before rec insert intention waiting
TRANSACTION 1992, ACTIVE 1 sec inserting
INSERT INTO slots (id, who) VALUES (15, 'S2')
RECORD LOCKS space id 9 page no 4 n bits 72 index PRIMARY of table `lab`.`slots` trx id 1992 lock_mode X locks gap before rec
RECORD LOCKS space id 9 page no 4 n bits 72 index PRIMARY of table `lab`.`slots` trx id 1992 lock_mode X locks gap before rec insert intention waiting
*** WE ROLL BACK TRANSACTION (2)

Vì sao deadlock. hex 80000014 là 20: cả hai khoá đều treo trên bản ghi 20, kiểu locks gap before rec — khoảng trống (10, 20). Bước 1: hai câu SELECT … FOR UPDATE đều được cấp X,GAP cùng lúc, vì "Gap locks can co-exist". Chữ X ở đây không mang nghĩa loại trừ như ở record lock: gap lock chỉ có một tác dụng là chặn INSERT. Bước 2: S1 chèn 15, xin X,GAP,INSERT_INTENTION, bị gap lock của S2 chặn (data_lock_waits: 1991 chờ 1992). Bước 3: S2 chèn 15, xin insert intention, bị gap lock của S1 chặn → vòng tròn, S2 nhận 1213 sau 0,01 s, INSERT của S1 thành công sau 0,80 s chờ. Dòng cuối của bảng là 15 | S1.

Điểm mới so với bài trước: khoá khác nhau vẫn deadlock. M2b cho S2 kiểm tra và chèn 16 thay vì 15. Hai khoá không bao giờ trùng nhau, nhưng cùng nằm trong khoảng (10, 20):

[t=+ 0.40s][S2] SELECT * FROM slots WHERE id = 16 FOR UPDATE   -- not found either
[t=+ 0.70s][MON]    | S1      | 2060 | slots | PRIMARY | RECORD | X,GAP | GRANTED | 20   |
[t=+ 0.70s][MON]    | S2      | 2061 | slots | PRIMARY | RECORD | X,GAP | GRANTED | 20   |
[t=+ 1.80s][S2] INSERT INTO slots (id, who) VALUES (16, 'S2')
[t=+ 1.80s][S2]   -> ERROR 1213 (40001): Deadlock found when trying to get lock; try restarting transaction  (0.00s)
[t=+ 1.80s][S1]   -> OK, 1 row(s) affected (0.80s)
SUMMARY [M2b gap lock, different missing keys in the same gap (15 vs 16)]: ERROR 1213 seen in 3/3 runs; outcomes per run: [{'S1': 'no error', 'S2': '1213'}, {'S1': 'no error', 'S2': '1213'}, {'S1': 'no error', 'S2': '1213'}]

Đây là chỗ deadlock gap lock khó debug ngoài production: hai request chèn hai bản ghi hoàn toàn khác nhau (ca 15 và ca 16, hay hai đơn hàng khác id) vẫn giết nhau, chỉ vì chúng rơi vào cùng một khoảng trống giữa hai bản ghi đang có. Khoảng càng rộng (bảng thưa, khoá ngẫu nhiên) thì càng nhiều request không liên quan rơi chung một gap — điểm này là suy luận từ cơ chế, lab chỉ đo khoảng (10, 20).

sequenceDiagram
  participant S1
  participant DB as slots 10, 20, 30
  participant S2
  S1->>DB: SELECT id 15 FOR UPDATE
  DB-->>S1: 0 dòng, cấp X GAP trên khoảng 10 tới 20
  S2->>DB: SELECT id 15 hoặc 16 FOR UPDATE
  DB-->>S2: 0 dòng, cũng cấp X GAP, gap lock không chặn nhau
  S1->>DB: INSERT 15, xin insert intention
  Note over DB: bị gap lock của S2 chặn, S1 chờ
  S2->>DB: INSERT 15 hoặc 16, xin insert intention
  Note over DB: bị gap lock của S1 chặn, vòng tròn
  DB-->>S2: ERROR 1213
  DB-->>S1: INSERT xong

Chữa bằng READ COMMITTED? M2c/M2d chạy y hệt dưới READ COMMITTED. Tài liệu MySQL: ở RC "gap locking is disabled for searches and index scans and is used only for foreign-key constraint checking and duplicate-key checking".

SET SESSION TRANSACTION ISOLATION LEVEL READ COMMITTED;   -- cho cả S1 và S2, rồi chạy lại đúng timeline trên
Cách nói khi phỏng vấn: "Ở mức read committed, InnoDB không khoá khoảng trống khi tìm kiếm nữa, nên câu kiểm tra không khoá được gì cả. Hai người chèn hai ca khác nhau thì cùng thành công. Hai người chèn cùng một ca thì người sau chờ người trước, rồi nhận lỗi trùng khoá chứ không phải deadlock. Đổi lại, câu kiểm tra mất luôn tác dụng giữ chỗ."
[t=+ 0.70s][MON]    | session | trx  | tbl   | idx  | type  | mode | status  | data |
[t=+ 0.70s][MON]    | S1      | 2129 | slots | NULL | TABLE | IX   | GRANTED | NULL |
[t=+ 0.70s][MON]    | S2      | 2130 | slots | NULL | TABLE | IX   | GRANTED | NULL |
[t=+ 1.00s][S1] INSERT INTO slots (id, who) VALUES (15, 'S1')
[t=+ 1.00s][S1]   -> OK, 1 row(s) affected (0.00s)
[t=+ 1.80s][S2] INSERT INTO slots (id, who) VALUES (15, 'S2')
[t=+ 2.60s][S1] COMMIT
[t=+ 2.60s][S2]   -> ERROR 1062 (23000): Duplicate entry '15' for key 'slots.PRIMARY'  (0.80s)
[t=+ 2.61s][S2]      server view: SERVER_STATUS_IN_TRANS=True; INNODB_TRX: trx_id=2130 state=RUNNING rows_modified=0 rows_locked=1
SUMMARY [M2c same as M2a under READ COMMITTED (no gap locks for searches)]: ERROR 1062 seen in 3/3 runs; outcomes per run: [{'S1': 'no error', 'S2': '1062'}, {'S1': 'no error', 'S2': '1062'}, {'S1': 'no error', 'S2': '1062'}]
[t=+ 1.80s][S2] INSERT INTO slots (id, who) VALUES (16, 'S2')
[t=+ 1.81s][S2]   -> OK, 1 row(s) affected (0.01s)
SUMMARY [M2d same as M2b under READ COMMITTED (different keys 15 vs 16)]: ERROR 1213 seen in 0/2 runs; outcomes per run: [{'S1': 'no error', 'S2': 'no error'}, {'S1': 'no error', 'S2': 'no error'}]

Sau hai câu SELECT … FOR UPDATE, data_locks chỉ còn khoá IX cấp bảng — không có gap lock nào. Cùng khoá (M2c): S2 chờ S1 commit rồi nhận 1062 trong 3/3 run, 0 deadlock; dòng server view cho thấy 1062 không kết thúc transaction của S2 (SERVER_STATUS_IN_TRANS=True … rows_locked=1). Khác khoá (M2d): cả hai commit, 0/2 lỗi.

Trade-off của RC, và vì sao không nên coi nó là "cách chữa": (1) phantom có thể xảy ra — tài liệu: "phantom row problems may occur, as other sessions can insert new rows into the gaps"; mọi logic dựa vào "đọc có khoá một khoảng để giữ nó" (đếm sĩ số rồi chèn, kiểm tra trùng lịch theo khoảng thời gian) mất bảo vệ, chuyển từ deadlock sang sai dữ liệu im lặng nếu không có ràng buộc UNIQUE đỡ phía dưới. (2) RC bắt buộc binlog dạng row ("Only row-based binary logging is supported with the READ COMMITTED isolation level"); 8.4 mặc định là ROW nên thường không vướng, nhưng hệ thống cũ còn STATEMENT thì phải đổi. (3) RC vẫn giữ gap lock cho kiểm tra khoá ngoại và kiểm tra trùng khoá — M3 ngay sau đây cho thấy RC không cứu được deadlock trùng khoá. Về chính sách: trang InnoDB Deadlocks viết "The possibility of deadlocks is not affected by the isolation level", trong khi trang về isolation level nói RC "greatly reduces the probability of deadlocks, but they can still happen" và danh sách cách giảm deadlock khuyên thử RC cho locking read. Hai câu không mâu thuẫn nếu đọc là: deadlock vẫn có thể xảy ra ở mọi mức, RC làm nó ít đi — "giảm xác suất, không loại bỏ".

Chữa ở đúng chỗ: bỏ bước "kiểm tra", để UNIQUE (ở đây là primary key) làm trọng tài. Hai biến thể, đều ở REPEATABLE READ:

-- FIX1: chèn luôn, bắt lỗi 1062 rồi xử lý (đọc dòng của người thắng)
START TRANSACTION;
INSERT INTO slots (id, who) VALUES (15, 'S2');          -- 1062 nếu đã có người chèn
SELECT * FROM slots WHERE id = 15 FOR UPDATE;           -- chỉ chạy khi bắt được 1062
COMMIT;

-- FIX2: upsert (chèn, hoặc cập nhật nếu đã có)
START TRANSACTION;
INSERT INTO slots (id, who) VALUES (15, 'S2') ON DUPLICATE KEY UPDATE cnt = cnt + 1;
COMMIT;
Cách nói khi phỏng vấn: "Thay vì hỏi trước rồi mới chèn, tôi chèn thẳng và để ràng buộc duy nhất quyết định ai thắng. Người đến sau chờ trên đúng bản ghi mà người trước vừa chèn, không dính khoảng trống nào, nên chỉ có chờ chứ không có vòng tròn. Sau đó người thua hoặc nhận lỗi trùng khoá để báo 'ca đã có người đặt', hoặc nếu nghiệp vụ là cộng dồn thì dùng upsert để câu lệnh tự chuyển sang cập nhật."
[t=+ 0.20s][S1] INSERT INTO slots (id, who) VALUES (15, 'S1')   -- just try the insert
[t=+ 0.50s][S2] INSERT INTO slots (id, who) VALUES (15, 'S2')   -- same key: waits on S1's uncommitted row
[t=+ 0.90s][MON]    | S2      | 2248 | slots | PRIMARY | RECORD | S,REC_NOT_GAP | WAITING | 15   |
[t=+ 1.20s][S1] COMMIT
[t=+ 1.22s][S2]   -> ERROR 1062 (23000): Duplicate entry '15' for key 'slots.PRIMARY'  (0.71s)
[t=+ 1.60s][S2] SELECT * FROM slots WHERE id = 15 FOR UPDATE   -- app handles 1062: read/lock the winner's row
[t=+ 1.60s][S2]    | 15 | S1  | 1   |
SUMMARY [M2-FIX1 insert first, handle duplicate (REPEATABLE READ)]: ERROR 1062 seen in 3/3 runs; outcomes per run: [{'S1': 'no error', 'S2': '1062'}, {'S1': 'no error', 'S2': '1062'}, {'S1': 'no error', 'S2': '1062'}]
[t=+ 0.20s][S1] INSERT INTO slots (id, who) VALUES (15, 'S1') ON DUPLICATE KEY UPDATE cnt = cnt + 1
[t=+ 0.50s][S2] INSERT INTO slots (id, who) VALUES (15, 'S2') ON DUPLICATE KEY UPDATE cnt = cnt + 1
[t=+ 0.90s][MON]    | S2      | 2319 | slots | PRIMARY | RECORD | X,REC_NOT_GAP | WAITING | 15   |
[t=+ 1.20s][S1] COMMIT
[t=+ 1.20s][S2]   -> OK, 2 row(s) affected (0.70s)
[t=+ 1.61s][MON]    | 15 | S1   | 2   |
SUMMARY [M2-FIX2 INSERT ... ON DUPLICATE KEY UPDATE (REPEATABLE READ)]: ERROR 1213 seen in 0/3 runs; outcomes per run: [{'S1': 'no error', 'S2': 'no error'}, {'S1': 'no error', 'S2': 'no error'}, {'S1': 'no error', 'S2': 'no error'}]

Insert-first: S2 chờ với S,REC_NOT_GAP trên bản ghi 15 mà S1 vừa chèn (khoá ngầm của S1 đã được đổi thành tường minh), nhận 1062 khi S1 commit, rồi đọc dòng của người thắng; 0 deadlock trong 3/3 run. Upsert (IODKU — INSERT … ON DUPLICATE KEY UPDATE): S2 chờ với X,REC_NOT_GAP (không phải S — đúng như tài liệu "an exclusive lock rather than a shared lock is placed on the row to be updated when a duplicate-key error occurs"), rồi trả 2 row(s) affected = nhánh cập nhật; dòng cuối cnt = 2; 0/3 deadlock.

Trade-off: insert-first đòi ứng dụng phân biệt 1062 "đã có người đặt" với lỗi khác, và nhớ rằng sau 1062 transaction vẫn mở và vẫn giữ khoá (rows_locked=1) — phải commit hoặc rollback chủ động. IODKU đổi ngữ nghĩa: người thứ hai không thất bại mà cộng dồn; đúng cho bộ đếm, tiến độ học, thống kê, nhưng sai cho đặt chỗ, nơi người thứ hai phải bị từ chối. Với khoá trùng ở unique secondary index (không phải primary key), tài liệu nói IODKU lấy "an exclusive next-key lock" — tức có dính gap; lab này chỉ thử trên primary key nên trường hợp đó chưa verify. Bẫy IODKU khi bảng có hai UNIQUE đã có lab riêng ở mục 8.3 bài PostgreSQL vs MySQL.

3.3. M3 — Ba session INSERT trùng khoá

Bối cảnh (ví dụ minh hoạ): ba request cùng tạo bản ghi "lần đầu" cho cùng một khoá tự nhiên — ví dụ bản ghi tiến độ của một học viên cho một bài học, khi học viên mở bài trên ba tab, hoặc ba worker cùng xử lý một idempotency key. Request đầu chèn trong một transaction dài hơn và sau đó rollback (lỗi nghiệp vụ, timeout gọi dịch vụ ngoài). Không ai khoá ngược thứ tự, không có câu kiểm tra nào, vậy mà vẫn deadlock. Tài liệu MySQL mô tả đúng kịch bản này trong trang Locks Set by Different SQL Statements; lab tái hiện nó và thử hai "cách chữa" hay được truyền miệng.

CREATE TABLE t (id INT PRIMARY KEY, who VARCHAR(10), cnt INT NOT NULL DEFAULT 1) ENGINE=InnoDB;
INSERT INTO t (id, who) VALUES (1, 'seed'), (10, 'seed');

-- t=+0.20s  S1: START TRANSACTION; INSERT INTO t (id, who) VALUES (5, 'S1');   -- chưa commit
-- t=+0.50s  S2: START TRANSACTION; INSERT INTO t (id, who) VALUES (5, 'S2');   -- trùng khoá: chờ
-- t=+0.80s  S3: START TRANSACTION; INSERT INTO t (id, who) VALUES (5, 'S3');   -- trùng khoá: chờ
-- t=+1.80s  S1: ROLLBACK;
-- M3b: dòng 5 có sẵn, S1 DELETE 5 rồi COMMIT.   M3c: S1 COMMIT thay vì ROLLBACK.
Cách nói khi phỏng vấn: "Session đầu chèn khoá số năm nhưng chưa commit. Hai session sau chèn cùng khoá đó, thấy trùng, nên mỗi bên xin một khoá chia sẻ trên bản ghi năm và xếp hàng chờ. Session đầu rollback, bản ghi năm biến mất, hai khoá chia sẻ đang chờ biến thành khoá khoảng trống mà cả hai cùng giữ. Giờ ai muốn chèn cũng bị khoảng trống của người kia chặn, nên một trong hai bị chọn làm nạn nhân."
[t=+ 0.20s][S1] INSERT INTO t (id, who) VALUES (5, 'S1')
[t=+ 0.20s][S1]   -> OK, 1 row(s) affected (0.00s)
[t=+ 0.50s][S2] INSERT INTO t (id, who) VALUES (5, 'S2')
[t=+ 0.80s][S3] INSERT INTO t (id, who) VALUES (5, 'S3')
[t=+ 1.31s][MON]    | S1      | 2392 | t   | PRIMARY | RECORD | X,REC_NOT_GAP | GRANTED | 5    |
[t=+ 1.31s][MON]    | S2      | 2393 | t   | PRIMARY | RECORD | S,REC_NOT_GAP | WAITING | 5    |
[t=+ 1.31s][MON]    | S3      | 2394 | t   | PRIMARY | RECORD | S,REC_NOT_GAP | WAITING | 5    |
[t=+ 1.80s][S1] ROLLBACK
[t=+ 1.80s][S3]   -> ERROR 1213 (40001): Deadlock found when trying to get lock; try restarting transaction  (1.00s)
[t=+ 1.80s][S2]   -> OK, 1 row(s) affected (1.30s)
[t=+ 2.40s][MON]    | session | trx  | tbl | idx     | type   | mode                   | status  | data |
[t=+ 2.40s][MON]    | S2      | 2393 | t   | PRIMARY | RECORD | X,GAP,INSERT_INTENTION | GRANTED | 10   |
[t=+ 2.40s][MON]    | S2      | 2393 | t   | PRIMARY | RECORD | S,GAP                  | GRANTED | 10   |
[t=+ 2.40s][MON]    | S2      | 2393 | t   | PRIMARY | RECORD | S,GAP                  | GRANTED | 5    |
SUMMARY [M3a S1 INSERT 5 (uncommitted), S2+S3 INSERT 5 wait, S1 ROLLBACK]: ERROR 1213 seen in 3/3 runs; outcomes per run: [{'S1': 'no error', 'S2': 'no error', 'S3': '1213'}, {'S1': 'no error', 'S2': 'no error', 'S3': '1213'}, {'S1': 'no error', 'S2': 'no error', 'S3': '1213'}]

Khối deadlock (lược hex dump; hex 8000000a = 10):

TRANSACTION 2393, ACTIVE 2 sec inserting
LOCK WAIT 4 lock struct(s), heap size 1128, 2 row lock(s)
INSERT INTO t (id, who) VALUES (5, 'S2')
RECORD LOCKS space id 26 page no 4 n bits 72 index PRIMARY of table `lab`.`t` trx id 2393 lock mode S locks gap before rec
 0: len 4; hex 8000000a; asc     ;;
RECORD LOCKS space id 26 page no 4 n bits 72 index PRIMARY of table `lab`.`t` trx id 2393 lock_mode X locks gap before rec insert intention waiting
TRANSACTION 2394, ACTIVE 1 sec inserting
INSERT INTO t (id, who) VALUES (5, 'S3')
RECORD LOCKS space id 26 page no 4 n bits 72 index PRIMARY of table `lab`.`t` trx id 2394 lock mode S locks gap before rec
RECORD LOCKS space id 26 page no 4 n bits 72 index PRIMARY of table `lab`.`t` trx id 2394 lock_mode X locks gap before rec insert intention waiting
*** WE ROLL BACK TRANSACTION (2)

Vì sao deadlock. Bốn bước, khớp từng dòng output: (1) S1 chèn 5, giữ X trên bản ghi mới (hiện thành X,REC_NOT_GAP … 5 khi có người chạm vào). (2) S2 và S3 gặp trùng khoá; tài liệu: "If a duplicate-key error occurs, a shared lock on the duplicate index record is set" — cả hai xin S,REC_NOT_GAP trên 5 và chờ X của S1. (3) S1 rollback, bản ghi 5 biến mất; các khoá S đang xếp hàng không mất mà trở thành gap lock trên bản ghi kế tiếp còn tồn tại là 10 — khối deadlock ghi lock mode S locks gap before rec trên hex 8000000a, tức khoảng (1, 10). (4) Cả hai giờ thực sự chèn 5, cần insert intention vào khoảng (1, 10), và mỗi bên bị S gap lock của bên kia chặn → vòng tròn. Đây chính là câu trong tài liệu: "At this point, sessions 2 and 3 deadlock: Neither can acquire an exclusive lock for the row because of the shared lock held by the other." Sau đó người sống sót (S2 ở run này) giữ X,GAP,INSERT_INTENTION trên 10 cùng các S,GAP, đúng hình bên dưới.

flowchart TD
  A["S1 INSERT 5, chưa commit<br/>giữ X trên bản ghi 5"] --> B["S2, S3 INSERT 5: trùng khoá<br/>mỗi bên xin S trên bản ghi 5, xếp hàng"]
  B --> C["S1 ROLLBACK: bản ghi 5 biến mất"]
  C --> D["S của S2 và S3 thành S gap lock<br/>trên khoảng (1, 10)"]
  D --> E["S2 xin insert intention vào (1, 10)<br/>bị S gap của S3 chặn"]
  D --> F["S3 xin insert intention vào (1, 10)<br/>bị S gap của S2 chặn"]
  E --> G["Vòng tròn: đúng một bên nhận 1213,<br/>bên kia chèn được"]
  F --> G

Thứ kích hoạt là ROLLBACK (hoặc DELETE + COMMIT), không phải INSERT trùng. M3b (dòng 5 có sẵn, S1 DELETE rồi COMMIT) cho cùng kết quả 3/3. M3c cho S1 COMMIT bản ghi vừa chèn thay vì rollback: không deadlock, cả S2 lẫn S3 nhận 1062:

[t=+ 1.81s][S2]   -> ERROR 1062 (23000): Duplicate entry '5' for key 't.PRIMARY'  (1.31s)
[t=+ 1.82s][S3]   -> ERROR 1062 (23000): Duplicate entry '5' for key 't.PRIMARY'  (1.01s)
SUMMARY [M3c S1 INSERT 5 then COMMIT (not rollback): S2+S3 just get 1062?]: ERROR 1062 seen in 2/2 runs; outcomes per run: [{'S1': 'no error', 'S2': '1062', 'S3': '1062'}, {'S1': 'no error', 'S2': '1062', 'S3': '1062'}]

Điều bất ngờ: IODKU và READ COMMITTED đều không cứu được. Hai lời khuyên hay gặp là "dùng ON DUPLICATE KEY UPDATE vì nó lấy khoá X thay vì S" và "chuyển sang READ COMMITTED để bỏ gap lock". Lab chạy lại M3a với cả ba session dùng IODKU, rồi với cả ba ở READ COMMITTED:

-- M3-FIX?: cả ba session
INSERT INTO t (id, who) VALUES (5, 'S1') ON DUPLICATE KEY UPDATE cnt = cnt + 1;
-- M3d: giữ INSERT thường, nhưng cả ba session
SET SESSION TRANSACTION ISOLATION LEVEL READ COMMITTED;
Cách nói khi phỏng vấn: "Tôi từng nghĩ chỉ cần đổi sang upsert hoặc hạ isolation xuống read committed là hết. Lab cho thấy không: với upsert, hai người chờ xin khoá loại trừ thay vì chia sẻ, nhưng sau khi người đầu rollback họ vẫn cùng giữ khoá khoảng trống và vẫn chặn nhau khi chèn. Còn read committed vẫn giữ khoá khoảng trống cho việc kiểm tra trùng khoá, và đây đúng là việc kiểm tra trùng khoá."
[t=+ 1.30s][MON]    | S2      | 2594 | t   | PRIMARY | RECORD | X,REC_NOT_GAP | WAITING | 5    |
[t=+ 1.30s][MON]    | S3      | 2595 | t   | PRIMARY | RECORD | X,REC_NOT_GAP | WAITING | 5    |
[t=+ 1.81s][S3]   -> ERROR 1213 (40001): Deadlock found when trying to get lock; try restarting transaction  (1.01s)
[t=+ 2.41s][MON]    | S2      | 2594 | t   | PRIMARY | RECORD | X,GAP                  | GRANTED | 10   |
[t=+ 2.41s][MON]    | S2      | 2594 | t   | PRIMARY | RECORD | X,GAP                  | GRANTED | 5    |
SUMMARY [M3-FIX? same as M3a but all three use INSERT ... ON DUPLICATE KEY UPDATE]: ERROR 1213 seen in 3/3 runs; outcomes per run: [{'S1': 'no error', 'S2': 'no error', 'S3': '1213'}, {'S1': 'no error', 'S2': '1213', 'S3': 'no error'}, {'S1': 'no error', 'S2': '1213', 'S3': 'no error'}]
SUMMARY [M3d same as M3a under READ COMMITTED]: ERROR 1213 seen in 3/3 runs; outcomes per run: [{'S1': 'no error', 'S2': 'no error', 'S3': '1213'}, {'S1': 'no error', 'S2': '1213', 'S3': 'no error'}, {'S1': 'no error', 'S2': '1213', 'S3': 'no error'}]

IODKU: hai người chờ đúng là xin X,REC_NOT_GAP thay vì S, nhưng sau rollback họ kết thúc với X,GAP thừa kế — và vì gap lock (kể cả X) "can co-exist" rồi cùng chặn insert intention của nhau, vòng tròn vẫn y nguyên: 3/3. READ COMMITTED: 3/3, đúng như câu tài liệu đã trích ở M2 rằng RC vẫn dùng gap lock "for … duplicate-key checking". Lưu ý thêm về ai chết: M3a và M3b chọn S3 trong 6/6 run, nhưng cùng hình dạng đó ở IODKU, RC và bản retry bên dưới chọn S2 trong 6/9 run. Hai transaction có cùng kích thước nên đây là một cuộc đua; bất biến duy nhất là "đúng một trong hai nhận 1213, người kia chèn được".

Cách duy nhất lab xác nhận là retry cả transaction. Deadlock này cố hữu trong cuộc đua ba bên, nên thay vì cố ngăn, ứng dụng bắt 1213 và chạy lại từ START TRANSACTION:

-- victim, sau khi bắt được 1213 (transaction đã bị InnoDB rollback sẵn)
START TRANSACTION;
INSERT INTO t (id, who) VALUES (5, 'S2-retry') ON DUPLICATE KEY UPDATE cnt = cnt + 1;
COMMIT;
Cách nói khi phỏng vấn: "Với deadlock ba bên trên cùng một khoá mới, tôi không cố thiết kế cho nó biến mất mà coi nó là lỗi tạm thời: bắt mã lỗi deadlock, chạy lại cả transaction từ đầu, và viết transaction sao cho chạy lại không gây hậu quả kép. Ở đây lần chạy lại dùng upsert, nên nó gặp dòng của người thắng và cộng dồn, kết quả cuối đếm đủ cả hai lượt."
[t=+ 1.80s][S1] ROLLBACK
[t=+ 1.80s][S3]   -> OK, 1 row(s) affected (0.98s)
[t=+ 1.80s][S2]   -> ERROR 1213 (40001): Deadlock found when trying to get lock; try restarting transaction  (1.30s)
[t=+ 2.80s][S2] (app caught ERROR 1213 -> retries the WHOLE transaction from the start)
[t=+ 2.80s][S2] INSERT INTO t (id, who) VALUES (5, 'S2-retry') ON DUPLICATE KEY UPDATE cnt = cnt + 1
[t=+ 2.80s][S2]   -> OK, 2 row(s) affected (0.00s)
[t=+ 2.81s][MON]    | 5  | S3   | 2   |
SUMMARY [M3-FIX retry: the deadlock is inherent to the 3-way race, so the victim retries its whole transaction]: ERROR 1213 seen in 3/3 runs; outcomes per run: [{'S1': 'no error', 'S2': '1213', 'S3': 'no error'}, {'S1': 'no error', 'S2': '1213', 'S3': 'no error'}, {'S1': 'no error', 'S2': 'no error', 'S3': '1213'}]

3/3 run vẫn có 1213 (retry không ngăn deadlock), nhưng 3/3 run kết thúc đúng: dòng 5 có cnt = 2, cả hai lượt chèn của S2 và S3 đều được tính. Trade-off: retry chỉ an toàn khi chạy lại toàn bộ transaction và mọi tác dụng phụ (gửi email, gọi API, đẩy job) nằm ngoài hoặc idempotent; retry không backoff có thể đụng lại đúng đối thủ cũ — phần này để mục 7. Cách giảm tần suất ở tầng thiết kế (theo RESULTS, không đo riêng): đừng giữ một INSERT chưa commit trên khoá "nóng" rồi rollback nó — ví dụ chèn khoá đó trong một transaction ngắn riêng, xong phần việc dài mới làm sau. PostgreSQL ở cùng kịch bản (P5-B) không deadlock: 0/3, người thua nhận 23505 — giải thích ở mục 4.1.

3.4. M4 — Thiếu index: UPDATE khoá cả bảng

Bối cảnh (ví dụ minh hoạ): job đối soát thanh toán chạy UPDATE orders SET status = 'paid' WHERE customer_id = ? cho từng khách, trong khi một request khác cập nhật đơn của khách khác. Hai việc không đụng chung dòng nào, nhưng cột customer_id chưa có index.

CREATE TABLE orders (id INT PRIMARY KEY, customer_id INT NOT NULL, status VARCHAR(10) NOT NULL DEFAULT 'new') ENGINE=InnoDB;
-- 200 dòng, customer_id = id % 20  -> mỗi khách 10 đơn
INSERT INTO orders (id, customer_id)
  WITH RECURSIVE n(i) AS (SELECT 1 UNION ALL SELECT i + 1 FROM n WHERE i < 200) SELECT i, i % 20 FROM n;

-- M4a (chỉ chặn):   S1: UPDATE orders SET status = 'paid' WHERE customer_id = 1;   (t=+0.20s)
--                   S2: UPDATE orders SET status = 'paid' WHERE customer_id = 2;   (t=+0.70s, khách KHÁC)
-- M4b (deadlock):   S2: UPDATE orders SET status = 'ship' WHERE id = 182;          (t=+0.20s, đơn của khách 2)
--                   S1: UPDATE orders SET status = 'paid' WHERE customer_id = 1;   (t=+0.50s)
--                   S2: UPDATE orders SET status = 'paid' WHERE customer_id = 2;   (t=+1.40s)
Cách nói khi phỏng vấn: "Câu cập nhật lọc theo mã khách hàng nhưng cột đó không có index, nên InnoDB phải quét cả bảng theo khoá chính. InnoDB khoá mọi bản ghi nó quét qua chứ không chỉ những dòng khớp điều kiện. Thế là cập nhật mười đơn của khách một lại khoá cả hai trăm dòng, và người cập nhật khách hai phải chờ dù không đụng đơn nào của khách một."
[t=+ 0.00s][MON]    | 1  | SIMPLE      | orders | NULL       | ALL  | NULL          | NULL | NULL    | NULL | 200  | 10.0     | Using where |
[t=+ 0.20s][S1] UPDATE orders SET status = 'paid' WHERE customer_id = 1   -- 10 rows match
[t=+ 0.20s][S1]   -> OK, 10 row(s) affected (0.00s)
[t=+ 0.50s][MON]    | session | type   | mode | status  | n   |
[t=+ 0.50s][MON]    | S1      | RECORD | X    | GRANTED | 201 |
[t=+ 0.70s][S2] UPDATE orders SET status = 'paid' WHERE customer_id = 2   -- a DIFFERENT customer
[t=+ 1.21s][MON]    | S2      | RECORD | X    | WAITING | 1   |
[t=+ 1.61s][S2]   -> OK, 10 row(s) affected (0.91s)
SUMMARY [M4a no index on customer_id: S1 updates customer 1, S2 updates customer 2 (blocking, not deadlock)]: ERROR 1213 seen in 0/1 runs; outcomes per run: [{'S1': 'no error', 'S2': 'no error'}]

EXPLAIN báo type ALL … rows 200: quét toàn bảng. 10 dòng khớp nhưng S1 giữ 201 khoá X (next-key) — 200 bản ghi cộng bản ghi giả cuối index (supremum), theo phân tích trong RESULTS. Tài liệu nói thẳng: "If you have no indexes suitable for your statement and MySQL must scan the entire table to process the statement, every row of the table becomes locked". S2 cập nhật khách khác vẫn chờ ở ngay bản ghi đầu tiên nó quét, và chỉ chạy xong (sau 0,91 s) khi S1 commit. Đó mới là chặn. Thêm một thứ tự khoá "xui" là thành deadlock:

[t=+ 0.20s][S2] UPDATE orders SET status = 'ship' WHERE id = 182   -- customer 2's row, by primary key
[t=+ 0.50s][S1] UPDATE orders SET status = 'paid' WHERE customer_id = 1   -- full scan, locks rows 1..181, waits at 182
[t=+ 1.00s][MON]    | S1      | RECORD | X             | GRANTED | 181 |
[t=+ 1.00s][MON]    | S1      | RECORD | X             | WAITING | 1   |
[t=+ 1.00s][MON]    | S2      | RECORD | X,REC_NOT_GAP | GRANTED | 1   |
[t=+ 1.42s][S2] UPDATE orders SET status = 'paid' WHERE customer_id = 2   -- full scan from id=1 -> waits on S1
[t=+ 1.43s][S1]   -> OK, 10 row(s) affected (0.93s)
[t=+ 1.45s][S2]   -> ERROR 1213 (40001): Deadlock found when trying to get lock; try restarting transaction  (0.03s)
TRANSACTION 2842, ACTIVE 1 sec fetching rows
LOCK WAIT 3 lock struct(s), heap size 1128, 182 row lock(s), undo log entries 10
RECORD LOCKS space id 44 page no 4 n bits 272 index PRIMARY of table `lab`.`orders` trx id 2842 lock_mode X
RECORD LOCKS space id 44 page no 4 n bits 280 index PRIMARY of table `lab`.`orders` trx id 2842 lock_mode X waiting
TRANSACTION 2841, ACTIVE 1 sec starting index read
LOCK WAIT 3 lock struct(s), heap size 1128, 2 row lock(s), undo log entries 1
RECORD LOCKS space id 44 page no 4 n bits 272 index PRIMARY of table `lab`.`orders` trx id 2841 lock_mode X locks rec but not gap
RECORD LOCKS space id 44 page no 4 n bits 280 index PRIMARY of table `lab`.`orders` trx id 2841 lock_mode X waiting
*** WE ROLL BACK TRANSACTION (2)
SUMMARY [M4b no index: S2 locks one customer-2 row by PK, S1 scans customer 1, S2 scans customer 2]: ERROR 1213 seen in 3/3 runs; outcomes per run: [{'S1': 'no error', 'S2': '1213'}, {'S1': 'no error', 'S2': '1213'}, {'S1': 'no error', 'S2': '1213'}]

Vì sao deadlock. S2 đã khoá dòng 182 (một đơn của khách 2) qua primary key. S1 quét từ id=1, khoá dần 181 bản ghi và dừng chờ ở 182. S2 bắt đầu quét khách 2 cũng từ id=1 và đụng ngay khoá của S1 → S1 chờ S2 (ở 182), S2 chờ S1 (ở 1). Khối deadlock in ra bằng chứng về kích thước: transaction (1) là S1 với 182 row lock(s), undo log entries 10, transaction (2) là S2 với 2 row lock(s), undo log entries 1; InnoDB rollback (2) — bên nhẹ hơn, khớp quy tắc chọn transaction nhỏ. Hai câu lệnh của hai khách hàng khác nhau, viết đúng logic, vẫn deadlock 3/3, chỉ vì thiếu một index.

flowchart TD
  Q["UPDATE … WHERE customer_id = 1<br/>không có index: EXPLAIN type ALL"] --> S["Quét PRIMARY từ id 1 tới 200"]
  S --> L["Khoá next-key MỌI bản ghi đã quét:<br/>201 khoá cho 10 dòng khớp"]
  L --> B["UPDATE khách 2 cũng quét từ id 1<br/>đụng khoá ở bản ghi 1: chờ"]
  P["S2 đã giữ id 182 trước"] --> D["S1 dừng ở 182, S2 chờ ở 1:<br/>vòng tròn, S2 nhẹ hơn nhận 1213"]
  B --> D
  F["ADD INDEX idx_customer"] --> F2["Chỉ khoá 10 entry idx_customer<br/>+ 1 gap + 10 dòng PRIMARY"]

Cách chữa: thêm index cho cột trong WHERE của UPDATE / DELETE / SELECT … FOR UPDATE.

ALTER TABLE orders ADD INDEX idx_customer (customer_id);
-- chạy lại đúng hai timeline M4a và M4b ở trên
Cách nói khi phỏng vấn: "Với InnoDB, index không chỉ để đọc nhanh mà còn quyết định khoá bao nhiêu. Có index trên mã khách hàng, câu cập nhật chỉ đi qua mười entry của đúng khách đó, khoá mười entry index, một khoảng trống ở biên và mười dòng chính. Hai khách khác nhau không còn chạm nhau, nên cả chờ lẫn deadlock đều biến mất."
[t=+ 0.00s][MON]    | 1  | SIMPLE      | orders | NULL       | ref  | idx_customer  | idx_customer | 4       | const | 10   | 100.0    | NULL  |
[t=+ 0.50s][MON]    | session | type   | mode          | status  | n  |
[t=+ 0.50s][MON]    | S1      | RECORD | X             | GRANTED | 10 |
[t=+ 0.50s][MON]    | S1      | RECORD | X,GAP         | GRANTED | 1  |
[t=+ 0.50s][MON]    | S1      | RECORD | X,REC_NOT_GAP | GRANTED | 10 |
[t=+ 0.70s][S2] UPDATE orders SET status = 'paid' WHERE customer_id = 2   -- a DIFFERENT customer
[t=+ 0.70s][S2]   -> OK, 10 row(s) affected (0.00s)
[t=+ 0.31s][MON]    | S1      | 3047 | orders | idx_customer | RECORD | X             | GRANTED | 1, 1   |
[t=+ 0.31s][MON]    | S1      | 3047 | orders | idx_customer | RECORD | X,GAP         | GRANTED | 2, 2   |
[t=+ 0.31s][MON]    | S1      | 3047 | orders | PRIMARY      | RECORD | X,REC_NOT_GAP | GRANTED | 1      |
SUMMARY [M4-FIX same deadlock plan with idx_customer]: ERROR 1213 seen in 0/3 runs; outcomes per run: [{'S1': 'no error', 'S2': 'no error'}, {'S1': 'no error', 'S2': 'no error'}, {'S1': 'no error', 'S2': 'no error'}]

Có index: EXPLAIN type ref key idx_customer rows 10; S1 giữ 10 next-key lock trên idx_customer, 1 gap lock trên entry 2, 2 (biên phải của khoảng khách 1) và 10 record lock trên PRIMARY — 21 thay vì 201. S2 chạy trong (0.00s); kịch bản deadlock 0/3. Ba dòng cuối của khối trên lấy từ lần chạy "liệt kê từng khoá", chỉ giữ một dòng đại diện cho mỗi loại. (Chú thích -- full scan … trong timeline M4-FIX là comment cũ của script, câu lệnh lúc đó đã đi qua index.)

Đừng chữa bằng READ COMMITTED. M4c giữ nguyên bảng không index nhưng chạy ở RC: S1 chỉ còn giữ X,REC_NOT_GAP 10 khoá (khoá của dòng không khớp được thả sau khi xét WHERE) và S2 không phải chờ nhờ "semi-consistent read"; kịch bản deadlock 0/2.

[t=+ 0.02s][MON]    | 1  | SIMPLE      | orders | NULL       | ALL  | NULL          | NULL | NULL    | NULL | 200  | 10.0     | Using where |
[t=+ 0.50s][MON]    | S1      | RECORD | X,REC_NOT_GAP | GRANTED | 10 |
[t=+ 0.75s][S2]   -> OK, 10 row(s) affected (0.01s)
SUMMARY [M4c contrast: NO index, READ COMMITTED, deadlock plan]: ERROR 1213 seen in 0/2 runs; outcomes per run: [{'S1': 'no error', 'S2': 'no error'}, {'S1': 'no error', 'S2': 'no error'}]

Nhìn thì như đã khỏi, nhưng EXPLAIN vẫn là type ALL … rows 200: câu lệnh vẫn quét cả bảng, chỉ là khoá được thả sớm. RC che lỗi thiếu index chứ không sửa nó; bảng lớn lên thì chi phí quét (CPU, I/O, thời gian giữ transaction) vẫn còn đó, và tài liệu vẫn nói deadlock ở RC "can still happen". Trade-off của index: mỗi index thêm chi phí ghi cho mọi INSERT/UPDATE cột đó và tốn dung lượng; và khoá X,GAP trên 2, 2 nghĩa là trong lúc S1 chưa commit, chèn một đơn mới cho khách 1 vào cuối khoảng đó sẽ phải chờ — suy luận từ định nghĩa gap lock, lab không chạy thao tác này. Tài liệu MySQL đưa index vào chính danh sách giảm deadlock: "Add well-chosen indexes to your tables so that your queries scan fewer index records and set fewer locks."

3.5. M5 — Khoá ngoại: INSERT con giữ S lock trên dòng cha

Bối cảnh (ví dụ minh hoạ): ghi danh vào khoá học. Mỗi lượt ghi danh chèn một dòng enrolment có khoá ngoại tới course, rồi tăng bộ đếm course.enrolled (counter cache — lưu sẵn sĩ số để khỏi COUNT(*)). Đầu học kỳ, hàng trăm sinh viên ghi danh cùng một khoá trong vài giây.

CREATE TABLE course (id INT PRIMARY KEY, name VARCHAR(20), enrolled INT NOT NULL DEFAULT 0) ENGINE=InnoDB;
CREATE TABLE enrolment (id INT AUTO_INCREMENT PRIMARY KEY, course_id INT NOT NULL, student VARCHAR(10),
  CONSTRAINT fk_course FOREIGN KEY (course_id) REFERENCES course (id)) ENGINE=InnoDB;
INSERT INTO course VALUES (1, 'Moodle 101', 0);

-- t=+0.20s  S1: START TRANSACTION; INSERT INTO enrolment (course_id, student) VALUES (1, 'an');
-- t=+0.40s  S2: START TRANSACTION; INSERT INTO enrolment (course_id, student) VALUES (1, 'binh');
-- t=+1.00s  S1: UPDATE course SET enrolled = enrolled + 1 WHERE id = 1;   -- chờ
-- t=+1.80s  S2: UPDATE course SET enrolled = enrolled + 1 WHERE id = 1;   -- vòng tròn
Cách nói khi phỏng vấn: "Khi chèn một dòng con, InnoDB phải kiểm tra dòng cha có tồn tại không, và nó đặt một khoá chia sẻ lên dòng cha để dòng cha không bị xoá giữa chừng. Hai người cùng ghi danh thì cùng giữ khoá chia sẻ trên cùng một khoá học. Sau đó cả hai muốn tăng sĩ số, cần khoá loại trừ trên chính dòng đó, và mỗi người bị khoá chia sẻ của người kia chặn. Đây là deadlock nâng cấp khoá, chỉ khác là khoá chia sẻ do khoá ngoại tự lấy, code không hề viết ra."
[t=+ 0.70s][MON]    | S1      | 3153 | course    | PRIMARY | RECORD | S,REC_NOT_GAP | GRANTED | 1    |
[t=+ 0.70s][MON]    | S2      | 3154 | course    | PRIMARY | RECORD | S,REC_NOT_GAP | GRANTED | 1    |
[t=+ 1.00s][S1] UPDATE course SET enrolled = enrolled + 1 WHERE id = 1
[t=+ 1.40s][MON]    | S1      | 3153 | course | PRIMARY | RECORD | X,REC_NOT_GAP | WAITING | 1    |
[t=+ 1.40s][MON]    | 3153        | 3154         | X,REC_NOT_GAP | 1            | S,REC_NOT_GAP |
[t=+ 1.80s][S2] UPDATE course SET enrolled = enrolled + 1 WHERE id = 1
[t=+ 1.80s][S1]   -> OK, 1 row(s) affected (0.80s)
[t=+ 1.80s][S2]   -> ERROR 1213 (40001): Deadlock found when trying to get lock; try restarting transaction  (0.00s)
RECORD LOCKS space id 55 page no 4 n bits 72 index PRIMARY of table `lab`.`course` trx id 3153 lock mode S locks rec but not gap
RECORD LOCKS space id 55 page no 4 n bits 72 index PRIMARY of table `lab`.`course` trx id 3153 lock_mode X locks rec but not gap waiting
RECORD LOCKS space id 55 page no 4 n bits 72 index PRIMARY of table `lab`.`course` trx id 3154 lock mode S locks rec but not gap
RECORD LOCKS space id 55 page no 4 n bits 72 index PRIMARY of table `lab`.`course` trx id 3154 lock_mode X locks rec but not gap waiting
*** WE ROLL BACK TRANSACTION (2)
[t=+ 2.61s][MON]    | id | enrolled | real_children |
[t=+ 2.61s][MON]    | 1  | 1        | 1             |
SUMMARY [M5 FK: both INSERT child of course 1 (S lock on parent), then both UPDATE the parent counter]: ERROR 1213 seen in 3/3 runs; outcomes per run: [{'S1': 'no error', 'S2': '1213'}, {'S1': 'no error', 'S2': '1213'}, {'S1': 'no error', 'S2': '1213'}]

Vì sao deadlock. Sau hai INSERT con, data_locks cho thấy cả S1 và S2 cùng giữ S,REC_NOT_GAP trên course id=1 — tài liệu: "any insert, update, or delete that requires the constraint condition to be checked sets shared record-level locks on the records that it looks at". S + S tương thích nên cả hai INSERT đều xong ngay. S1 UPDATE course xin X, bị S của S2 chặn (data_lock_waits: 3153 chờ 3154 với blocking_mode S,REC_NOT_GAP). S2 xin X, bị S của S1 chặn → vòng tròn. Kết quả cuối enrolled = 1, real_children = 1: dòng ghi danh của victim cũng bị rollback cùng transaction, nên dữ liệu vẫn nhất quán — chỉ là một sinh viên phải bấm lại. Đối chứng: chỉ chèn con, không cập nhật cha → không chờ, 1/1.

sequenceDiagram
  participant S1
  participant C as course id 1
  participant S2
  S1->>C: INSERT enrolment, kiểm tra FK, lấy S
  S2->>C: INSERT enrolment, kiểm tra FK, lấy S
  Note over S1,S2: S và S tương thích, cả hai cùng giữ
  S1->>C: UPDATE enrolled, xin X
  Note over C: X của S1 bị S của S2 chặn
  S2->>C: UPDATE enrolled, xin X
  Note over C: X của S2 bị S của S1 chặn, vòng tròn
  C-->>S2: ERROR 1213, rollback cả transaction
  C-->>S1: được X, UPDATE xong

Cách chữa: lấy khoá mạnh nhất cần dùng trên dòng cha ngay từ đầu. Hai cách cùng nguyên lý:

-- FIX1: khoá dòng cha trước, rồi mới chèn con và tăng bộ đếm
START TRANSACTION;
SELECT id, enrolled FROM course WHERE id = 1 FOR UPDATE;
INSERT INTO enrolment (course_id, student) VALUES (1, 'binh');
UPDATE course SET enrolled = enrolled + 1 WHERE id = 1;
COMMIT;

-- FIX2: cập nhật dòng cha trước khi chèn con
START TRANSACTION;
UPDATE course SET enrolled = enrolled + 1 WHERE id = 1;
INSERT INTO enrolment (course_id, student) VALUES (1, 'binh');   -- kiểm tra FK: đã giữ X rồi
COMMIT;
Cách nói khi phỏng vấn: "Nguyên tắc là lần đầu chạm vào một dòng thì lấy luôn loại khoá mạnh nhất sẽ cần. Tôi khoá hoặc cập nhật dòng khoá học trước, rồi mới chèn dòng ghi danh. Người thứ hai xếp hàng ngay từ câu đầu tiên, lúc đó họ chưa giữ khoá chia sẻ nào để chặn ngược người thứ nhất. Đổi lại, mọi lượt ghi danh vào cùng một khoá học giờ đi tuần tự qua đúng một dòng."
[t=+ 0.40s][S2] SELECT id, enrolled FROM course WHERE id = 1 FOR UPDATE   -- waits (no S lock held yet)
[t=+ 0.70s][MON]    | 3318        | 3317         | X,REC_NOT_GAP | 1            | X,REC_NOT_GAP |
[t=+ 1.21s][S2]   -> 1 row(s) (0.81s)
[t=+ 1.81s][MON]    | 1  | 2        | 2             |
SUMMARY [M5-FIX1 lock the parent FOR UPDATE first, then insert child + update counter]: ERROR 1213 seen in 0/3 runs; outcomes per run: [{'S1': 'no error', 'S2': 'no error'}, {'S1': 'no error', 'S2': 'no error'}, {'S1': 'no error', 'S2': 'no error'}]
[t=+ 0.40s][S2] UPDATE course SET enrolled = enrolled + 1 WHERE id = 1   -- waits
[t=+ 1.21s][S2]   -> OK, 1 row(s) affected (0.81s)
SUMMARY [M5-FIX2 update the parent before inserting the child]: ERROR 1213 seen in 0/3 runs; outcomes per run: [{'S1': 'no error', 'S2': 'no error'}, {'S1': 'no error', 'S2': 'no error'}, {'S1': 'no error', 'S2': 'no error'}]

Cả hai cách: S2 chờ X ở câu đầu tiên (0,81 s), không deadlock 0/3, kết quả enrolled = 2 = real_children. Trade-off: dòng course trở thành hot row (một dòng mà mọi transaction đều tranh) — throughput ghi danh vào một khoá học bị giới hạn bởi thời gian giữ khoá trên đúng một dòng, và transaction càng dài thì hàng đợi càng dài. Nếu bộ đếm không cần chính xác tức thời, cách bền hơn là bỏ hẳn hot row khỏi transaction (đếm lúc đọc, hoặc cộng dồn bất đồng bộ) — hướng này là đề xuất thiết kế trong research, lab không đo throughput. PostgreSQL chạy cùng mẫu (P4-A) không deadlock vì kiểm tra FK chỉ lấy FOR KEY SHARE, tương thích với khoá của một UPDATE không đổi cột khoá — xem mục 4.1.

3.6. M6 — Secondary index và primary key khoá ngược thứ tự

Bối cảnh (ví dụ minh hoạ): một job quét theo cột trạng thái có index (WHERE k = 10, hình dung k là trạng thái bài nộp) để cập nhật cột khác, trong khi request của giảng viên sửa đúng dòng đó theo id và đổi chính cột trạng thái. Hai câu lệnh chạm cùng một dòng, nhưng đi vào từ hai cửa khác nhau.

InnoDB lưu dòng trong clustered index (cây primary key); secondary index chỉ chứa giá trị cột + primary key. Tài liệu: "If a secondary index is used in a search and the index record locks to be set are exclusive, InnoDB also retrieves the corresponding clustered index records and sets locks on them." Một UPDATE đi qua secondary index vì vậy khoá theo thứ tự idx_k → PRIMARY; một UPDATE theo id mà đổi giá trị cột có index thì khoá PRIMARY → idx_k (phải đánh dấu xoá entry cũ trong idx_k). Hai thứ tự ngược nhau trên cùng một dòng.

CREATE TABLE t (id INT PRIMARY KEY, k INT NOT NULL, v INT NOT NULL DEFAULT 0, KEY idx_k (k)) ENGINE=InnoDB;
-- 1000 dòng, k = id * 10  -> dòng id=1 có k=10

-- M6a (2 câu, có transaction):
-- t=+0.20s  B (S2): START TRANSACTION; SELECT id, k FROM t WHERE id = 1 FOR UPDATE;   -- PRIMARY trước
-- t=+0.40s  A (S1): START TRANSACTION; UPDATE t SET v = v + 1 WHERE k = 10;         -- idx_k rồi PRIMARY (chờ)
-- t=+1.20s  B (S2): UPDATE t SET k = 11 WHERE id = 1;                               -- cần entry idx_k (10,1) A đang giữ

-- M6b (mỗi câu là 1 transaction autocommit, 2 luồng chạy liên tục 4 giây):
-- A: UPDATE t SET v = v + 1 WHERE k IN (10, 11);     -- range trên idx_k
-- B: UPDATE t SET k = 21 - k WHERE id = 1;           -- theo PK, lật k giữa 10 và 11
Cách nói khi phỏng vấn: "Một câu tìm dòng qua index phụ nên khoá entry trong index phụ trước, rồi mới khoá dòng chính. Câu kia tìm theo khoá chính nên khoá dòng chính trước, rồi vì đổi giá trị cột có index nên phải khoá entry trong index phụ. Cùng một dòng, hai lối vào, hai thứ tự khoá ngược nhau. Điều đáng sợ là chuyện này xảy ra được giữa hai câu lệnh đơn, mỗi câu tự commit, không có transaction dài nào cả."
[t=+ 0.20s][S2] SELECT id, k FROM t WHERE id = 1 FOR UPDATE   -- B: PRIMARY record first
[t=+ 0.40s][S1] UPDATE t SET v = v + 1 WHERE k = 10   -- A: via idx_k -> idx_k entry, then PRIMARY (waits)
[t=+ 0.80s][MON]    | S1      | 3579 | t   | idx_k   | RECORD | X             | GRANTED | 10, 1 |
[t=+ 0.80s][MON]    | S1      | 3579 | t   | PRIMARY | RECORD | X,REC_NOT_GAP | WAITING | 1     |
[t=+ 0.80s][MON]    | S2      | 3578 | t   | PRIMARY | RECORD | X,REC_NOT_GAP | GRANTED | 1     |
[t=+ 1.20s][S2] UPDATE t SET k = 11 WHERE id = 1   -- B: must delete-mark idx_k entry (10,1) held by A
[t=+ 1.20s][S1]   -> ERROR 1213 (40001): Deadlock found when trying to get lock; try restarting transaction  (0.80s)
[t=+ 1.20s][S2]   -> OK, 1 row(s) affected (0.00s)
TRANSACTION 3579, ACTIVE 1 sec starting index read
LOCK WAIT 3 lock struct(s), heap size 1128, 2 row lock(s)
RECORD LOCKS space id 75 page no 5 n bits 1072 index idx_k of table `lab`.`t` trx id 3579 lock_mode X
RECORD LOCKS space id 75 page no 6 n bits 568 index PRIMARY of table `lab`.`t` trx id 3579 lock_mode X locks rec but not gap waiting
TRANSACTION 3578, ACTIVE 1 sec updating or deleting
LOCK WAIT 3 lock struct(s), heap size 1128, 2 row lock(s), undo log entries 1
RECORD LOCKS space id 75 page no 6 n bits 568 index PRIMARY of table `lab`.`t` trx id 3578 lock_mode X locks rec but not gap
RECORD LOCKS space id 75 page no 5 n bits 1072 index idx_k of table `lab`.`t` trx id 3578 lock_mode X locks rec but not gap waiting
*** WE ROLL BACK TRANSACTION (1)
SUMMARY [M6a A updates via secondary index, B holds PK row then changes the indexed column]: ERROR 1213 seen in 3/3 runs; outcomes per run: [{'S1': '1213', 'S2': 'no error'}, {'S1': '1213', 'S2': 'no error'}, {'S1': '1213', 'S2': 'no error'}]

Vì sao deadlock. Khối deadlock là ví dụ đẹp nhất trong lab về việc đọc index …: transaction (1) = A giữ khoá trên index idx_k và chờ index PRIMARY; transaction (2) = B giữ index PRIMARY và chờ index idx_k. Cùng một dòng id=1 nhưng là hai bản ghi index khác nhau. Lần này victim là (1) — A, bên không đóng vòng tròn: A chưa sửa dòng nào (không có dòng undo log entries), B đã có undo log entries 1, nên A nhẹ hơn. Đây là bằng chứng thêm rằng victim không phải "bên đến sau".

Ngay cả hai câu lệnh đơn autocommit cũng deadlock. M6b bỏ hẳn transaction nhiều câu: hai luồng, mỗi luồng lặp một câu UPDATE tự commit trong 4 giây.

[t=+ 2.15s][MON]    | 1  | UPDATE      | t     | NULL       | range | idx_k         | idx_k | 4       | const | 2    | 100.0    | Using where |
M6b run 1 result: A ok=552 1213=8 other_err=0 | B ok=555 1213=3 other_err=0 | INNODB_METRICS lock_deadlocks +11, lock_row_lock_waits +1126
UPDATE t SET k = 21 - k WHERE id = 1
RECORD LOCKS space id 81 page no 6 n bits 568 index PRIMARY of table `lab`.`t` trx id 5964 lock_mode X locks rec but not gap
RECORD LOCKS space id 81 page no 5 n bits 1072 index idx_k of table `lab`.`t` trx id 5964 lock_mode X locks rec but not gap waiting
UPDATE t SET v = v + 1 WHERE k IN (10, 11)
RECORD LOCKS space id 81 page no 5 n bits 1072 index idx_k of table `lab`.`t` trx id 5962 lock_mode X
RECORD LOCKS space id 81 page no 6 n bits 568 index PRIMARY of table `lab`.`t` trx id 5962 lock_mode X locks rec but not gap waiting
M6b run 2 result: A ok=110 1213=9 other_err=0 | B ok=113 1213=4 other_err=0 | INNODB_METRICS lock_deadlocks +13, lock_row_lock_waits +240
SUMMARY [M6b single-statement race]: at least one 1213 in 2/2 runs

11 deadlock trong 4 giây ở run 1, 13 ở run 2; cả hai phía đều từng làm victim (A 8 + 9 lần, B 3 + 4 lần). Khối deadlock của một lần như vậy chỉ chứa hai câu lệnh đơn, đúng hình M6a. Hệ quả cho người vận hành: "câu lệnh của tôi chỉ một dòng, chạy autocommit, nên không thể deadlock" là sai với InnoDB — tài liệu cũng nói "You can get deadlocks even in the case of transactions that just insert or delete a single row", vì một thao tác trên một dòng chạm nhiều bản ghi index.

flowchart LR
  subgraph PA["A: UPDATE … WHERE k = 10"]
    A1["1. khoá idx_k (10, 1)"] --> A2["2. xin PRIMARY id=1"]
  end
  subgraph PB["B: UPDATE … SET k = 11 WHERE id = 1"]
    B1["1. khoá PRIMARY id=1"] --> B2["2. xin idx_k (10, 1) để đổi k"]
  end
  A2 -- "chờ B" --> B1
  B2 -- "chờ A" --> A1

Cách chữa (bản hai câu): cho A đi cùng cửa với B. A tìm primary key bằng một câu đọc không khoá, rồi UPDATE theo primary key kèm điều kiện kiểm tra lại k:

START TRANSACTION;
SELECT id FROM t WHERE k = 10;                          -- đọc thường (consistent read), không khoá
UPDATE t SET v = v + 1 WHERE id = 1 AND k = 10;         -- khoá PRIMARY trước, như B; k = 10 là điều kiện kiểm tra lại
COMMIT;
Cách nói khi phỏng vấn: "Tôi tách thành hai bước: đọc không khoá để lấy khoá chính, rồi cập nhật theo khoá chính và kiểm tra lại điều kiện cũ trong cùng câu cập nhật. Như vậy cả hai bên đều vào bằng cửa khoá chính, cùng một thứ tự, nên chỉ còn chờ chứ không còn vòng tròn. Cái giá là phải xử lý trường hợp cập nhật không trúng dòng nào vì ai đó đã đổi giá trị trong lúc mình chờ."
[t=+ 0.40s][S1] SELECT id FROM t WHERE k = 10   -- A: plain (non-locking) read to find the PK
[t=+ 0.52s][S1] UPDATE t SET v = v + 1 WHERE id = 1 AND k = 10   -- A: lock by PRIMARY, same path as B (waits)
[t=+ 1.20s][S2] UPDATE t SET k = 11 WHERE id = 1
[t=+ 1.20s][S2]   -> OK, 1 row(s) affected (0.00s)
[t=+ 1.60s][S2] COMMIT
[t=+ 1.60s][S1]   -> OK, 0 row(s) affected (1.08s)
SUMMARY [M6-FIX A finds the PK with a plain read, then updates by PK (+ recheck k): same lock path as B]: ERROR 1213 seen in 0/3 runs; outcomes per run: [{'S1': 'no error', 'S2': 'no error'}, {'S1': 'no error', 'S2': 'no error'}, {'S1': 'no error', 'S2': 'no error'}]

0/3 deadlock: A chờ trên PRIMARY 1,08 s rồi trả 0 row(s) affected — vì B đã đổi k thành 11, điều kiện kiểm tra lại không còn đúng. Đó chính là trade-off: hai round-trip thay vì một, và ứng dụng phải hiểu "0 dòng" là "dòng đã đổi trạng thái, bỏ qua hoặc đọc lại", không phải lỗi. Ngoài ra, trong khoảng giữa câu đọc và câu cập nhật, dòng khác có thể vừa đổi sang k = 10 và bị bỏ sót ở lượt này — suy luận, lab không đo. Với bản câu đơn M6b, lab không thử cách chữa ở tầng SQL nào; biện pháp là retry câu lệnh bị 1213 (mục 7), và cân nhắc lại thiết kế để job không cập nhật theo đúng cột mà request khác đang đổi.

3.7. M7 — INSERT … ON DUPLICATE KEY UPDATE / REPLACE theo thứ tự ngược

Bối cảnh (ví dụ minh hoạ): hai worker cùng ghi bảng tổng hợp điểm theo khoá (học viên, bài) bằng upsert. Worker 1 xử lý lô theo thứ tự đọc từ file, worker 2 theo thứ tự đọc từ queue — hai thứ tự khác nhau cho cùng các khoá. Nhiều người nghĩ upsert là "một câu nguyên tử" nên miễn nhiễm deadlock; lab cho thấy upsert chỉ là một cách khác để khoá dòng.

CREATE TABLE kv (k INT PRIMARY KEY, v INT NOT NULL) ENGINE=InnoDB;
INSERT INTO kv VALUES (0, 0), (100, 0);          -- M7b/M7d: khoá 1, 2 chưa tồn tại

-- M7a–d (2 câu / transaction): S1 khoá 1 rồi 2, S2 khoá 2 rồi 1
-- t=+0.20s  S1: INSERT INTO kv (k, v) VALUES (1, 1) ON DUPLICATE KEY UPDATE v = v + 1;
-- t=+0.40s  S2: INSERT INTO kv (k, v) VALUES (2, 1) ON DUPLICATE KEY UPDATE v = v + 1;
-- t=+0.80s  S1: INSERT INTO kv (k, v) VALUES (2, 1) ON DUPLICATE KEY UPDATE v = v + 1;   -- chờ
-- t=+1.60s  S2: INSERT INTO kv (k, v) VALUES (1, 1) ON DUPLICATE KEY UPDATE v = v + 1;   -- vòng tròn
-- (M7c/M7d: cùng timeline với REPLACE INTO kv VALUES (...))

-- M7e/M7f (1 câu nhiều dòng, autocommit, 2 luồng x 4 giây):
-- A: INSERT INTO kv (k, v) VALUES (1, 1), (2, 1) ON DUPLICATE KEY UPDATE v = v + 1;
-- B: INSERT INTO kv (k, v) VALUES (2, 1), (1, 1) ON DUPLICATE KEY UPDATE v = v + 1;
-- (M7f: REPLACE INTO kv VALUES (1, 10), (2, 20)  vs  REPLACE INTO kv VALUES (2, 20), (1, 10))
Cách nói khi phỏng vấn: "Upsert hay replace vẫn phải khoá từng dòng nó chèn hoặc sửa, lần lượt theo thứ tự các dòng trong câu lệnh. Hai câu upsert cùng hai khoá nhưng liệt kê ngược thứ tự thì y hệt hai lệnh chuyển tiền ngược chiều. Cả khi khoá chưa tồn tại, dòng vừa chèn cũng bị khoá ngay, nên vẫn deadlock."
[t=+ 0.20s][S1] INSERT INTO kv (k, v) VALUES (1, 1) ON DUPLICATE KEY UPDATE v = v + 1
[t=+ 0.40s][S2] INSERT INTO kv (k, v) VALUES (2, 1) ON DUPLICATE KEY UPDATE v = v + 1
[t=+ 0.80s][S1] INSERT INTO kv (k, v) VALUES (2, 1) ON DUPLICATE KEY UPDATE v = v + 1
[t=+ 1.21s][MON]    | S1      | 6567 | kv  | NULL    | TABLE  | IX            | GRANTED | NULL |
[t=+ 1.21s][MON]    | S1      | 6567 | kv  | PRIMARY | RECORD | X,REC_NOT_GAP | WAITING | 2    |
[t=+ 1.21s][MON]    | S2      | 6568 | kv  | PRIMARY | RECORD | X,REC_NOT_GAP | GRANTED | 2    |
[t=+ 1.60s][S2] INSERT INTO kv (k, v) VALUES (1, 1) ON DUPLICATE KEY UPDATE v = v + 1
[t=+ 1.61s][S2]   -> ERROR 1213 (40001): Deadlock found when trying to get lock; try restarting transaction  (0.01s)
SUMMARY [M7b IODKU, keys do NOT exist yet (insert path, same gap 0..100), opposite order]: ERROR 1213 seen in 3/3 runs; outcomes per run: [{'S1': 'no error', 'S2': '1213'}, {'S1': 'no error', 'S2': '1213'}, {'S1': 'no error', 'S2': '1213'}]
SUMMARY [M7a IODKU, keys already exist, S1: 1 then 2, S2: 2 then 1]: ERROR 1213 seen in 3/3 runs; outcomes per run: [{'S1': 'no error', 'S2': '1213'}, {'S1': 'no error', 'S2': '1213'}, {'S1': 'no error', 'S2': '1213'}]
SUMMARY [M7c REPLACE, keys already exist, opposite order]: ERROR 1213 seen in 3/3 runs; outcomes per run: [{'S1': 'no error', 'S2': '1213'}, {'S1': 'no error', 'S2': '1213'}, {'S1': 'no error', 'S2': '1213'}]
SUMMARY [M7d REPLACE, keys do NOT exist yet, opposite order]: ERROR 1213 seen in 3/3 runs; outcomes per run: [{'S1': 'no error', 'S2': '1213'}, {'S1': 'no error', 'S2': '1213'}, {'S1': 'no error', 'S2': '1213'}]

Vì sao deadlock. Cả bốn biến thể (IODKU / REPLACE × khoá đã có / chưa có) đều 3/3, và vòng tròn luôn nằm trên record lock X,REC_NOT_GAP — không có gap lock nào tham gia, kể cả khi chèn khoá mới vào khoảng (0, 100). Chi tiết đáng chú ý ở M7b: lúc t=+1.21s, data_locks không có dòng nào cho khoá của S1 trên k=1 mà S1 vừa chèn — đó là implicit lock (mục 3.0). Khoá k=2 của S2 hiện ra là GRANTED chỉ vì S1 đã chạm vào nó, buộc InnoDB đổi khoá ngầm thành tường minh. Khi S2 chạm vào k=1, chuyện tương tự xảy ra theo chiều ngược lại và vòng tròn khép.

Một câu lệnh nhiều dòng cũng đủ. M7e/M7f cho mỗi luồng gửi đúng một câu upsert hai dòng, autocommit; khác nhau duy nhất ở thứ tự các dòng trong VALUES:

run 1: A ok=672 1213=6 other=0 | B ok=669 1213=7 other=0 | lock_deadlocks +13, lock_row_lock_waits +1366
INSERT INTO kv (k, v) VALUES (1, 1), (2, 1) ON DUPLICATE KEY UPDATE v = v + 1
INSERT INTO kv (k, v) VALUES (2, 1), (1, 1) ON DUPLICATE KEY UPDATE v = v + 1
*** WE ROLL BACK TRANSACTION (2)
run 2: A ok=584 1213=7 other=0 | B ok=582 1213=5 other=0 | lock_deadlocks +12, lock_row_lock_waits +1186
SUMMARY [M7e multi-row IODKU in one statement, opposite row order]: at least one 1213 in 2/2 runs
run 1: A ok=1607 1213=53 other=0 | B ok=1584 1213=60 other=0 | lock_deadlocks +113, lock_row_lock_waits +293
REPLACE INTO kv VALUES (1, 10), (2, 20)
REPLACE INTO kv VALUES (2, 20), (1, 10)
run 2: A ok=672 1213=21 other=0 | B ok=670 1213=23 other=0 | lock_deadlocks +44, lock_row_lock_waits +106
SUMMARY [M7f multi-row REPLACE in one statement, opposite row order]: at least one 1213 in 2/2 runs

IODKU: 13 và 12 deadlock mỗi lượt 4 giây; REPLACE: 113 và 44. Khối deadlock chỉ chứa đúng hai câu lệnh đơn đó. Với batch upsert thực tế (hàng trăm dòng mỗi câu, dữ liệu đến từ nhiều nguồn có thứ tự khác nhau), đây là nguồn deadlock "không có transaction nào cả" phổ biến nhất — suy luận mở rộng từ lab hai dòng.

flowchart LR
  A["A: VALUES (1), (2)<br/>khoá k=1 rồi k=2"] -- "giữ k=1, chờ k=2" --> B["B: VALUES (2), (1)<br/>khoá k=2 rồi k=1"]
  B -- "giữ k=2, chờ k=1" --> A
  F["Sắp các dòng theo khoá<br/>ở mọi câu lệnh"] --> OK["Cả hai cùng khoá k=1 trước:<br/>chỉ chờ, không vòng tròn"]

Cách chữa: sắp khoá — cả giữa các câu lẫn giữa các dòng trong một câu.

-- 2 câu / transaction: mọi session upsert theo khoá tăng dần
INSERT INTO kv (k, v) VALUES (1, 1) ON DUPLICATE KEY UPDATE v = v + 1;
INSERT INTO kv (k, v) VALUES (2, 1) ON DUPLICATE KEY UPDATE v = v + 1;
-- 1 câu nhiều dòng: ứng dụng sắp mảng theo khoá trước khi build VALUES
INSERT INTO kv (k, v) VALUES (1, 1), (2, 1) ON DUPLICATE KEY UPDATE v = v + 1;
Cách nói khi phỏng vấn: "Trước khi ghi một lô, tôi sắp các bản ghi theo khoá chính, ở mọi worker, kể cả khi gộp nhiều dòng vào một câu. Khi mọi người đều khoá theo cùng một thứ tự thì người đến sau chỉ chờ người đến trước. Chờ vẫn nhiều, nhưng số deadlock về không."
[t=+ 0.40s][S2] INSERT INTO kv (k, v) VALUES (1, 1) ON DUPLICATE KEY UPDATE v = v + 1
[t=+ 1.20s][MON]    | S2      | 6784 | kv  | PRIMARY | RECORD | X,REC_NOT_GAP | WAITING | 1    |
[t=+ 2.41s][S2]   -> OK, 2 row(s) affected (2.01s)
SUMMARY [M7-FIX IODKU on new keys, both sessions in ascending key order]: ERROR 1213 seen in 0/3 runs; outcomes per run: [{'S1': 'no error', 'S2': 'no error'}, {'S1': 'no error', 'S2': 'no error'}, {'S1': 'no error', 'S2': 'no error'}]
run 1: A ok=529 1213=0 other=0 | B ok=528 1213=0 other=0 | lock_deadlocks +0, lock_row_lock_waits +1054
run 2: A ok=400 1213=0 other=0 | B ok=401 1213=0 other=0 | lock_deadlocks +0, lock_row_lock_waits +798
SUMMARY [M7-FIX multi-row IODKU, both statements with rows sorted by key]: at least one 1213 in 0/2 runs

Bản hai câu: S2 chờ 2,01 s trên k=1 rồi chạy hết, 0/3 deadlock. Bản một câu: 0 deadlock trong cả hai lượt, nhưng lock_row_lock_waits vẫn 1054 và 798 — sắp thứ tự không bỏ tranh chấp, nó chỉ biến tranh chấp thành hàng đợi. Trade-off: ứng dụng phải sort trước khi ghi (rẻ với vài nghìn dòng, nhưng là một quy ước phải giữ ở mọi worker và mọi thư viện bulk upsert), và batch lớn vẫn giữ nhiều khoá lâu — chia nhỏ batch theo khoảng khoá là bước tiếp theo (mục 6). PostgreSQL có cùng hiện tượng với ON CONFLICT DO UPDATE (P5-C, P5-D) và cùng cách chữa.

4. Cùng một thao tác, PostgreSQL và MySQL khác nhau ở đâu

Hai lab dùng cùng kịch bản ở mức "thao tác nghiệp vụ" nhưng khác chi tiết (tên bảng, số dòng, mốc thời gian), nên so sánh theo hình dạng và kết quả, không so từng mili giây. PostgreSQL 18.4 chạy ở READ COMMITTED (mặc định của nó), MySQL 8.4.13 ở REPEATABLE READ (mặc định của nó) trừ các biến thể ghi rõ RC. Ô nào một bên không chạy lab được ghi "không lab" thay vì đoán.

4.1. Bảng đối chiếu từng trường hợp

Trường hợpPostgreSQL 18.4 (X/N có deadlock)MySQL 8.4.13 (X/N có deadlock)Vì sao giống / khác
Hai dòng, thứ tự ngược nhau (chuyển tiền A↔B)P1: 3/3, đúng một bên 40P01; sắp thứ tự FOR UPDATE: 0/2M1: 3/3, đúng một bên 1213; sắp thứ tự: 0/3Giống: vòng tròn thuần trên khoá dòng, không phụ thuộc engine. Khác ở thời điểm phát hiện (PG sau deadlock_timeout 1 s, InnoDB gần như tức thì) và cách chọn victim — xem đoạn dưới bảng.
UPDATE nhiều dòng, thứ tự do plan quyết địnhP2: 3/3 (có sleep), 5/5 burst không sleep; khoá trước bằng ORDER BY … FOR UPDATE: 0/5Gần nhất là M4b (full scan vs khoá theo PK): 3/3; M6b (index phụ vs PK, câu đơn): 11 và 13 deadlock / 4 sCùng gốc: thứ tự khoá đi theo đường truy cập, không theo code. InnoDB còn thêm một tầng: khoá đi theo bản ghi index, nên cùng một dòng có thể bị khoá qua hai index theo hai thứ tự (M6). Kịch bản M6 không chạy trên PG.
Cùng FOR SHARE rồi cùng UPDATE (nâng cấp khoá)P3: 3/3; lấy FOR UPDATE / FOR NO KEY UPDATE từ đầu: 0/2Không lab dạng FOR SHARE tường minh; M5 là cùng hình dạng với S lock do FK tự lấy: 3/3Nâng cấp S → X là deadlock ở cả hai; nguyên tắc chữa giống nhau: lần đầu chạm vào dòng thì lấy luôn khoá mạnh nhất sẽ cần.
INSERT con (FK) + UPDATE cột không phải khoá của dòng chaP4-A: 0/3 — chỉ chờ bình thườngM5: 3/3Khác hẳn. PG: kiểm tra FK lấy FOR KEY SHARE; UPDATE không đổi cột khoá lấy FOR NO KEY UPDATE; hai mode này tương thích (bảng xung đột của PG: FOR KEY SHARE chỉ xung đột với FOR UPDATE). InnoDB: kiểm tra FK lấy S record lock thường trên dòng cha, mà S xung đột với X của mọi UPDATE.
INSERT con + UPDATE cột khoá / DELETE dòng chaP4-B: 3/3; P4-C: 2/2 (bên thắng còn nhận 23503); khoá cha trước: 0/2Không lab (M5 đã deadlock ở mức nhẹ hơn)Ở PG, đổi cột có unique index dùng được cho FK hoặc DELETE lấy FOR UPDATE, xung đột với FOR KEY SHARE → quay về đúng hình M5.
INSERT hai khoá theo thứ tự ngượcP5-A: 3/3; sắp khoá + ON CONFLICT DO NOTHING: 0/2Không lab với INSERT thường; nhánh chèn của upsert (M7b, M7d): 3/3Giống: dòng mới chèn bị khoá ngay (PG: người chèn sau chờ trên xid của người chèn trước; InnoDB: implicit lock thành record lock khi bị chạm).
Ba session INSERT cùng khoá, session đầu ROLLBACKP5-B: 0/3 — một bên chèn được, bên kia nhận 23505 khi bên thắng commitM3a: 3/3; cả IODKU (3/3) và RC (3/3) đều không cứuKhác hẳn. PG: kiểm tra unique của người đến sau chờ trên transaction id của người đang chèn; khi người đó rollback, nó kiểm tra lại và chờ tiếp trên xid của người thắng mới — không có khoá dòng nào bị "thừa kế". InnoDB: người đến sau xin S lock trên bản ghi trùng; khi bản ghi biến mất, các S lock thành gap lock cùng giữ, rồi chặn insert intention của nhau.
Upsert hai khoá theo thứ tự ngượcP5-C1 (khoá có sẵn) 3/3, P5-C2 (khoá chưa có) 3/3; sắp thứ tự: 0/2M7a–d (IODKU / REPLACE × có sẵn / chưa có): 3/3 mỗi loại; sắp thứ tự: 0/3Giống: upsert ở cả hai vẫn khoá từng dòng theo thứ tự xuất hiện.
Batch upsert một câu, thứ tự ngượcP5-D (3000 khoá, tăng vs giảm): 3/3; cả hai ORDER BY: 0/3M7e (IODKU 2 dòng): 13 và 12 deadlock / 4 s; M7f (REPLACE): 113 và 44; sắp thứ tự: 0 trong 2/2 lượtGiống: một câu lệnh đơn cũng là một transaction, thứ tự dòng trong câu quyết định thứ tự khoá.
"Kiểm tra rồi chèn" một khoá chưa có (SELECT … FOR UPDATE → INSERT)Không lab trong bộ deadlock; lab mục 4.4 bài PostgreSQL vs MySQL cho thấy PG không chặn INSERT vào khoảng trốngM2a (cùng khoá) 3/3, M2b (khác khoá, cùng gap) 3/3; RC: 0 deadlock (1062 hoặc cả hai commit)PG không có gap lock: FOR UPDATE trên 0 dòng không khoá gì, nên không có deadlock kiểu này — nhưng cũng không giữ được chỗ (hai bên cùng tưởng còn trống, UNIQUE mới chặn). InnoDB ở RR khoá khoảng trống, gap lock cùng tồn tại rồi chặn insert intention của nhau.
UPDATE theo cột không có indexKhông labM4a: chặn khách khác (201 khoá cho 10 dòng); M4b: 3/3; thêm index: 0/3InnoDB khoá mọi bản ghi index đã quét ("does not remember the exact WHERE condition"). PG: chưa verify trong bộ lab này.
Nâng cấp khoá bảng: cùng SELECT rồi cùng ALTER / LOCK TABLEP6-A: 3/3, phát hiện ngay, victim là bên đóng vòng; LOCK TABLE … ACCESS EXCLUSIVE trước: 0/2Không labPhía MySQL, DDL đi qua cơ chế khoá khác (không phải khoá dòng InnoDB) — chưa verify.
Advisory / named lock theo thứ tự ngượcP7 (pg_advisory_xact_lock): 3/3; sắp khoá: 0/2; bản session-level: victim vẫn giữ khoá sau 40P01Không labTheo tài liệu MySQL, deadlock giữa GET_LOCK() trả ER_USER_LOCK_DEADLOCK và "does not cause transactions to roll back" — chưa verify.

Bằng chứng cho hai dòng "khác hẳn" của bảng, đặt cạnh nhau (dòng tóm tắt nguyên văn từ pg/out/04-foreign-key.log, pg/out/05-unique-insert.log, mysql/out/05-m5-foreign-key.log, mysql/out/03-m3-duplicate-key.log):

A non-key UPDATE: runs with a 40P01: 0/3
SUMMARY [M5 FK: both INSERT child of course 1 (S lock on parent), then both UPDATE the parent counter]: ERROR 1213 seen in 3/3 runs; outcomes per run: [{'S1': 'no error', 'S2': '1213'}, {'S1': 'no error', 'S2': '1213'}, {'S1': 'no error', 'S2': '1213'}]
B 3-session, S1 ROLLBACK run 1: {'S1': 'ok', 'S2': 'ok', 'S3': 'error 23505'}  final=k:S2  still-waiting-after-S1-ended=['S3']
B 3-session, S1 ROLLBACK: runs with a 40P01: 0/3
SUMMARY [M3a S1 INSERT 5 (uncommitted), S2+S3 INSERT 5 wait, S1 ROLLBACK]: ERROR 1213 seen in 3/3 runs; outcomes per run: [{'S1': 'no error', 'S2': 'no error', 'S3': '1213'}, {'S1': 'no error', 'S2': 'no error', 'S3': '1213'}, {'S1': 'no error', 'S2': 'no error', 'S3': '1213'}]

Ba khác biệt nền tảng giải thích gần hết bảng trên:

  1. Đơn vị khoá. PostgreSQL khoá tuple (phiên bản dòng trong heap) và người chờ chờ trên transaction id của người giữ — mọi DETAIL của PG đều là waits for ShareLock on transaction <xid>. InnoDB khoá bản ghi index và khoảng trống giữa chúng; một dòng có bao nhiêu index là bấy nhiêu nơi có thể bị khoá, và tập khoá phụ thuộc đường quét (M4, M6). Hệ quả: InnoDB có thêm cả một họ deadlock mà PG không có (gap lock M2, thừa kế khoá M3, thiếu index M4, index phụ vs PK M6).

  2. Độ mịn của mode khoá. PG có bốn mức khoá dòng; FOR KEY SHARE (FK) và FOR NO KEY UPDATE (UPDATE thường) được thiết kế để không chặn nhau — chính là lý do P4-A 0/3. InnoDB chỉ có S/X trên bản ghi, nên kiểm tra FK (S) đụng mọi UPDATE dòng cha (X). Ngược lại PG có khoá bảng dạng nâng cấp (P6) mà lab MySQL không đụng tới.

  3. Phát hiện và chọn victim. PG ngủ chờ, sau deadlock_timeout (mặc định 1 s) chính backend đang chờ mới chạy kiểm tra và nếu thấy vòng tròn thì tự huỷ mình — nên victim là người chờ có đồng hồ hết trước khi vòng tồn tại (lab P1: victim = người chờ đầu, 3/3; lab điều khiển V1–V4 đổi được victim bằng mốc thời gian và deadlock_timeout riêng từng session), và tài liệu PG vẫn dặn "should not be relied upon". InnoDB phát hiện gần như tức thì ("InnoDB detects deadlocks immediately" theo trang tham số; từ 8.0.18 là một background thread riêng theo blog kỹ thuật MySQL) và chọn transaction nhẹ hơn theo trx_weight (M4b: bên 1 undo bị rollback thay vì bên 10 undo; M6a: bên chưa có undo bị rollback dù không đóng vòng). Hệ quả thực tế: mỗi deadlock ở PG tốn thêm khoảng 1 s chờ trước khi được giải, ở MySQL gần như không — so hai dòng dưới.

[t=+  1.60s][S1]   ERROR:  40P01: deadlock detected
[t=+  1.60s][S1]   (returned after 1.00s)
[t=+ 1.85s][S2]   -> ERROR 1213 (40001): Deadlock found when trying to get lock; try restarting transaction  (0.01s)

PG (P1 run 3): victim bắt đầu chờ ở t=0,60, bên kia đóng vòng ở 1,00, nhưng lỗi chỉ đến ở 1,60 = 0,60 + 1 s. MySQL (M1 run 1): lỗi đến 0,01 s sau khi câu đóng vòng được gửi.

flowchart TD
  X["Cùng một thao tác nghiệp vụ"] --> PG["PostgreSQL: khoá tuple,<br/>chờ trên transaction id"]
  X --> MY["InnoDB: khoá bản ghi index<br/>+ khoảng trống (RR)"]
  PG --> PG1["Mode mịn: FOR KEY SHARE<br/>không chặn UPDATE thường"]
  PG --> PG2["Phát hiện sau deadlock_timeout,<br/>người chờ tự huỷ mình"]
  MY --> MY1["Thêm deadlock từ gap lock,<br/>thiếu index, index phụ vs PK"]
  MY --> MY2["Phát hiện ngay,<br/>rollback transaction nhẹ hơn"]

4.2. Mã lỗi và trạng thái transaction sau lỗi

Biết "có deadlock" chưa đủ; ứng dụng phải biết sau lỗi, transaction đang ở trạng thái nào, vì cùng một đoạn code xử lý lỗi có thể đúng ở database này và làm hỏng dữ liệu ở database kia. Lab đo bốn trường hợp bằng cách gửi tiếp câu lệnh sau lỗi và xem câu đó đi đâu.

PostgreSQL, 40P01 (deadlock_detected). Transaction bị đánh dấu hỏng; mọi câu lệnh sau đó nhận 25P02 cho tới khi gửi ROLLBACK; gửi COMMIT thì server trả lời ROLLBACK. Trích P1 run 3 (pg/out/01-row-opposite-order.log):

[t=+  1.60s][S1]   ERROR:  40P01: deadlock detected
[t=+  1.60s][S1]   DETAIL:  Process 2048842 waits for ShareLock on transaction 724558; blocked by process 2048850.
[t=+  1.60s][S1] > SELECT balance FROM tmp_dl_acc WHERE id = 1;
[t=+  1.61s][S1]   ERROR:  25P02: current transaction is aborted, commands ignored until end of transaction block
[t=+  1.61s][S1] > COMMIT;
[t=+  1.61s][S1]   ROLLBACK

PostgreSQL, 55P03 (lock_not_available) do lock_timeout. Cùng hành vi: lỗi chờ khoá trong một transaction block cũng làm hỏng cả transaction, câu sau nhận 25P02 (pg/out/08-detection-ops.log, phần 3a):

[t=+  0.21s][S2] > SET LOCAL lock_timeout = '1500ms';
[t=+  1.72s][S2]   ERROR:  55P03: canceling statement due to lock timeout
[t=+  1.72s][S2] > SELECT 1;
[t=+  1.73s][S2]   ERROR:  25P02: current transaction is aborted, commands ignored until end of transaction block
[t=+  1.73s][S2] > ROLLBACK;
[t=+  1.73s][S2]   ROLLBACK

MySQL, 1213 (ER_LOCK_DEADLOCK, SQLSTATE 40001). InnoDB đã rollback toàn bộ transaction và session quay về autocommit. Không có trạng thái "hỏng" nào chặn câu tiếp theo: câu tiếp theo chạy như một transaction mới, tự commit. Lab A1 (mysql/out/10-timeout-vs-deadlock-scope.log): S2 đã INSERT 99 trước khi thành victim.

[t=+ 0.40s][S2] INSERT INTO acc VALUES (99, 99)   -- S2's first write
[t=+ 1.21s][S2]   -> ERROR 1213 (40001): Deadlock found when trying to get lock; try restarting transaction  (0.01s)
[t=+ 1.22s][S2]      server view: SERVER_STATUS_IN_TRANS=False; no INNODB_TRX row for this connection
[t=+ 1.60s][S2] SELECT id, balance FROM acc WHERE id = 99   -- is 99 still there?
[t=+ 1.60s][S2]   -> 0 row(s) (0.00s)
[t=+ 1.80s][S2] INSERT INTO acc VALUES (100, 100)   -- app continues without START TRANSACTION
[t=+ 2.00s][S2] ROLLBACK   -- does this undo 100?
[t=+ 2.43s][MON]    | 100 | 100     |

Dòng 99 đã biến mất (rollback cả transaction, đúng tài liệu "A transaction deadlock causes InnoDB to roll back the entire transaction"), nhưng dòng 100 — gửi sau lỗi, không có START TRANSACTION mới — đã tự commit, và ROLLBACK sau đó không gỡ được nó. Code kiểu "bắt lỗi, làm nốt mấy câu còn lại rồi ROLLBACK cho chắc" ở MySQL sẽ ghi một nửa nghiệp vụ vĩnh viễn; ở PG cùng code đó chỉ nhận một loạt 25P02 vô hại.

MySQL, 1205 (ER_LOCK_WAIT_TIMEOUT, SQLSTATE HY000), cấu hình mặc định. Ngược hẳn 1213: chỉ câu lệnh đang chờ bị rollback; transaction vẫn mở, vẫn giữ các ghi trước đó và khoá của chúng. Lab A0, với innodb_lock_wait_timeout mặc định 50 s:

[t=+ 0.40s][S2] INSERT INTO acc VALUES (99, 99)   -- S2's first write in this transaction
[t=+ 0.60s][S2] UPDATE acc SET balance = balance + 10 WHERE id = 1   -- blocked by S1 -> lock wait timeout
[t=+50.64s][S2]   -> ERROR 1205 (HY000): Lock wait timeout exceeded; try restarting transaction  (50.04s)
[t=+50.64s][S2]      server view: SERVER_STATUS_IN_TRANS=True; INNODB_TRX: trx_id=25254 state=RUNNING rows_modified=1 rows_locked=1
[t=+52.30s][S2] INSERT INTO acc VALUES (100, 100)   -- app continues without START TRANSACTION
[t=+52.50s][S2] COMMIT   -- what does this keep / undo: 99 and 100?
[t=+52.81s][MON]    | 99  | 99      |
[t=+52.81s][MON]    | 100 | 100     |

Sau 50,04 s chờ, chỉ câu UPDATE bị huỷ; COMMIT sau đó lưu cả 99 (ghi trước lỗi) lẫn 100 (ghi sau lỗi) — một transaction dở dang được commit như thể thành công. Tài liệu nói đúng như vậy: "the current statement is rolled back (not the entire transaction)". Muốn 1205 rollback cả transaction phải khởi động server với --innodb-rollback-on-timeout; biến này không đổi được lúc chạy:

ERROR 1238 (HY000) at line 2: Variable 'innodb_rollback_on_timeout' is a read only variable
[t=+ 3.61s][S2]   -> ERROR 1205 (HY000): Lock wait timeout exceeded; try restarting transaction  (3.01s)
[t=+ 3.62s][S2]      server view: SERVER_STATUS_IN_TRANS=False; no INNODB_TRX row for this connection

Dòng thứ hai và ba là lab B0, server tạo lại với innodb_rollback_on_timeout=ON: 1205 giờ cư xử như 1213 (không còn transaction), kéo theo đúng cái bẫy của 1213 — câu gửi sau lỗi tự commit. Tổng hợp:

LỗiSQLSTATECái gì bị rollbackSession ngay sau lỗiỨng dụng phải làm
PG deadlock40P01Cả transaction (khoá được thả ngay lúc báo lỗi — bên kia chạy tiếp cùng mốc 1,60 s)Transaction "aborted": mọi câu → 25P02, COMMIT → ROLLBACKROLLBACK, rồi chạy lại cả transaction
PG lock_timeout / NOWAIT55P03Cả transaction (nếu đang trong transaction block)Như trên: 25P02 tới khi ROLLBACKNhư trên; lưu ý đây không phải deadlock và không được đếm vào pg_stat_database.deadlocks
MySQL deadlock40001 (errno 1213)Cả transactionKhông còn transaction, về autocommit; câu sau tự commit; ROLLBACK là no-opKhông gửi thêm câu nào của transaction cũ; chạy lại từ START TRANSACTION
MySQL lock wait timeout (mặc định)HY000 (errno 1205)Chỉ câu lệnh đang chờTransaction vẫn mở, giữ ghi + khoá trước đóChủ động ROLLBACK rồi mới retry; COMMIT lúc này = commit dở dang
MySQL 1205 với innodb_rollback_on_timeout=ONHY000Cả transactionNhư 1213Như 1213

Thêm một cái bẫy ở tầng mã lỗi (suy luận từ bảng mã lỗi của hai bên, không phải hành vi lab): SQLSTATE 40001 ở MySQL nghĩa là deadlock, còn ở PostgreSQL 40001 là serialization failure và deadlock có mã riêng 40P01. Một bộ xử lý "retry khi SQLSTATE = 40001" viết cho MySQL sẽ bỏ sót deadlock khi chạy trên PG; một bộ xử lý chỉ nhìn 1213 sẽ bỏ sót 1205 — loại lỗi mà ở MySQL lại để transaction ở trạng thái nguy hiểm nhất.

flowchart TD
  ERR["Nhận lỗi khoá giữa transaction"] --> P["PostgreSQL 40P01 hoặc 55P03:<br/>transaction bị abort"]
  P --> P2["Mọi câu sau: 25P02<br/>tới khi ROLLBACK"]
  ERR --> M13["MySQL 1213:<br/>server đã rollback cả transaction"]
  M13 --> M13b["Session về autocommit:<br/>câu gửi tiếp sẽ tự commit"]
  ERR --> M05["MySQL 1205 mặc định:<br/>chỉ huỷ câu đang chờ"]
  M05 --> M05b["Transaction vẫn mở, còn giữ ghi và khoá:<br/>COMMIT lúc này = ghi dở dang"]
  P2 --> R["ROLLBACK (vô hại ở mọi nhánh)<br/>rồi chạy lại CẢ transaction từ đầu"]
  M13b --> R
  M05b --> R
Cách nói khi phỏng vấn: "Sau lỗi deadlock, PostgreSQL giữ transaction ở trạng thái hỏng, mọi câu sau đều bị từ chối cho tới khi tôi rollback, nên lỡ code chạy tiếp cũng không ghi được gì. MySQL thì đã tự rollback giúp tôi và đưa session về chế độ tự commit, nên câu nào lỡ chạy tiếp sẽ được ghi vĩnh viễn. Còn lỗi hết thời gian chờ khoá của MySQL chỉ huỷ đúng câu đang chờ, transaction vẫn mở, và nếu tôi commit thì thành ghi dở dang. Vì vậy quy tắc chung của tôi cho cả hai là: gặp lỗi khoá thì dừng ngay, rollback, rồi chạy lại cả transaction từ đầu."

Quy tắc "rollback rồi chạy lại cả transaction" nghe đơn giản, nhưng cài đúng trong framework (Laravel retry những mã nào, transaction lồng nhau, backoff, side effect không idempotent) là cả một chủ đề — mục 7 đi vào phần đó với lab Laravel.

5. Nhận biết deadlock

Ba mục trước giải thích deadlock (khoá chết vòng tròn: hai transaction mỗi bên giữ một khoá mà bên kia đang chờ) sinh ra thế nào. Mục này trả lời câu hỏi vận hành: làm sao biết hệ thống đang bị deadlock, và phân biệt nó với những thứ trông giống deadlock. Có ba thứ hay bị gọi chung là "deadlock" nhưng xử lý khác hẳn nhau:

  1. Deadlock thật: có vòng trong waits-for graph (đồ thị ai-chờ-ai). Database tự phá vòng bằng cách huỷ một transaction (victim, nạn nhân). Ứng dụng nhận lỗi khá nhanh: PostgreSQL sau deadlock_timeout (mặc định 1 s) tính từ lúc victim bắt đầu chờ, MySQL gần như ngay lập tức.

  2. Lock wait (chờ khoá): không có vòng, chỉ là A giữ khoá lâu và B chờ. Không ai bị huỷ cho tới khi A xong hoặc B chạm timeout. Triệu chứng là chậm, không phải lỗi.

  3. Lock queue (xếp hàng sau một request khoá mạnh): B chờ không phải vì A đang giữ khoá xung đột với B, mà vì B đứng sau C (thường là một câu DDL, tức câu đổi cấu trúc bảng như ALTER TABLE) trong hàng đợi. Case P6-B ở dưới là ví dụ.

Mỗi loại có dấu hiệu riêng ở tầng ứng dụng, ở log, ở counter và ở view sống. Các mục 5.1–5.3 đi lần lượt từng tầng, 5.4 gom lại thành metric và cảnh báo, 5.5 là runbook (quy trình xử lý sự cố từng bước) khi bị gọi dậy lúc 3 giờ sáng.

5.1. Triệu chứng ở tầng ứng dụng

1. Ứng dụng thấy một exception, không phải một request treo. Đây là dấu hiệu quan trọng nhất để tách deadlock khỏi lock wait. Bốn dòng dưới đây là exception thật mà Laravel 13.40 (PHP 8.4.27) nhận được trong lab app/, mỗi dòng một loại lỗi:

[t=+1.54s][S1]   !! 40P01/7 SQLSTATE[40P01]: Deadlock detected: 7 ERROR:  deadlock detected | DETAIL:  Process 2045606 waits for ShareLock on transaction 723165; blocked by process 2045607. | Process 2045607 waits for ShareLock on transaction 723164; blocked by process 2045606. | HINT...
[t=+0.62s][S2]   !! 40001/1213 SQLSTATE[40001]: Serialization failure: 1213 Deadlock found when trying to get lock; try restarting transaction (Connection: mysql_lab, Host: tmp-dl-app-mysql, Port: 3306, Database: tmp_dl_app, SQL: select * from `accounts` where `id` = 1 limit 1 for update)
[t=+1.21s][S2]   !! HY000/1205 SQLSTATE[HY000]: General error: 1205 Lock wait timeout exceeded; try restarting transaction (Connection: mysql_lab, Host: tmp-dl-app-mysql, Port: 3306, Database: tmp_dl_app, SQL: select * from `accounts` where `id` = 1 limit 1 for update)
[t=+1.21s][S2]   !! 55P03/7 SQLSTATE[55P03]: Lock not available: 7 ERROR:  canceling statement due to lock timeout | CONTEXT:  while locking tuple (0,1) in relation "accounts" (Connection: pgsql_lab, Host: my-test-pgdb, Port: 5432, Database: tmp_dl_app, SQL: select...

Bảng dưới gom lại bốn mã lỗi cần thuộc. Cột cuối là thứ hay bị bỏ qua nhất: mỗi lỗi để lại transaction ở trạng thái khác nhau, và code xử lý sai trạng thái đó còn nguy hiểm hơn chính cái lỗi.

MãDBNghĩaĐến sau bao lâu (lab)Transaction còn lại gì
40P01 deadlock_detectedPostgreSQLDeadlock thật, mình là victim Lúc bắt đầu chờ + deadlock_timeout (P1: chờ từ 0.60 s, lỗi lúc 1.60 s) Transaction bị abort. Câu tiếp theo nhận 25P02, COMMIT trả về ROLLBACK. Phải ROLLBACK rồi chạy lại cả transaction.
1213 ER_LOCK_DEADLOCK, SQLSTATE 40001MySQLDeadlock thật, mình là victim Gần như ngay (M1: (0.01s) sau câu khép vòng) Server đã rollback cả transaction (SERVER_STATUS_IN_TRANS=False). Câu tiếp theo chạy ở autocommit nếu app không mở transaction mới.
1205 ER_LOCK_WAIT_TIMEOUT, SQLSTATE HY000MySQLChờ khoá quá innodb_lock_wait_timeout Mặc định 50 s (lab: (50.04s)) Mặc định chỉ rollback câu lệnh; transaction vẫn mở, vẫn giữ khoá và các thay đổi trước đó.
55P03 lock_not_availablePostgreSQLChờ khoá quá lock_timeout, hoặc NOWAIT không lấy được khoá Theo lock_timeout (mặc định 0 = chờ mãi) Trong transaction block: transaction bị abort như 40P01 (lab 08: câu sau nhận 25P02).

Hai dòng lab MySQL dưới đây cho thấy khác biệt ở cột cuối. Dòng đầu là 1205 với timeout mặc định: S2 chờ đúng 50 s, và sau lỗi transaction vẫn mở với 1 dòng đã sửa. Dòng sau là 1213: transaction đã biến mất phía server.

[t=+ 0.40s][S2] INSERT INTO acc VALUES (99, 99)   -- S2's first write in this transaction
[t=+50.64s][S2]   -> ERROR 1205 (HY000): Lock wait timeout exceeded; try restarting transaction  (50.04s)
[t=+50.64s][S2]      server view: SERVER_STATUS_IN_TRANS=True; INNODB_TRX: trx_id=25254 state=RUNNING rows_modified=1 rows_locked=1
[t=+ 0.40s][S2] INSERT INTO acc VALUES (99, 99)   -- S2's first write
[t=+ 1.21s][S2]   -> ERROR 1213 (40001): Deadlock found when trying to get lock; try restarting transaction  (0.01s)
[t=+ 1.22s][S2]      server view: SERVER_STATUS_IN_TRANS=False; no INNODB_TRX row for this connection

Hệ quả thực tế: nếu app bắt 1205 rồi vẫn COMMIT, nó commit một nửa transaction (lab A0: INSERT 99 trước đó và INSERT 100 sau đó đều được lưu). Cách bắt lỗi và chạy lại đúng cho từng mã nằm ở mục 7; ở đây chỉ cần nhớ: đọc mã lỗi trước khi đọc message, và đừng gộp 1205 với 1213 vào cùng một nhánh xử lý mà không nghĩ.

Một bẫy nữa ở cột "Mã": PostgreSQL dùng 40001 cho serialization failure (xung đột ở REPEATABLE READ / SERIALIZABLE), còn MySQL dùng chính 40001 làm SQLSTATE của deadlock 1213. Dashboard đếm "lỗi 40001" trên hai DB là đang đếm hai hiện tượng khác nhau.

2. Lỗi lác đác, chỉ khi có tải. Deadlock cần hai transaction chạm cùng các dòng theo thứ tự ngược nhau trong cùng một khoảng thời gian ngắn. Chạy một mình thì không bao giờ thấy, test tích hợp tuần tự cũng không. Lab app chạy 8 worker × 200 lần chuyển tiền giữa 10 tài khoản nóng, mỗi transaction khoá tài khoản nguồn, ngủ 1–5 ms rồi khoá tài khoản đích. Bản ngây thơ (v1, khoá theo thứ tự nguồn → đích, không retry (chạy lại cả transaction khi lỗi)) so với bản khoá theo id tăng dần (v3):

workload: 8 workers x 200 transfers, random (from,to) among 10 accounts, sleep 1-5 ms between the two row locks
[pgsql v1 run1] committed=1433/1600 failed=167 {"40P01\/7":167} entries=1600 retries=0 errs_in_closure={"40P01\/7":167} hist={"1":1600} wall=103.82s server={"deadlocks":167} sum_ok=yes log=1433 ledger_ok=yes side=[]
[pgsql v3 run1] committed=1600/1600 failed=0 [] entries=1600 retries=0 errs_in_closure=[] hist={"1":1600} wall=4.88s server={"deadlocks":0} sum_ok=yes log=1600 ledger_ok=yes side=[]
[mysql v1 run1] committed=1545/1600 failed=55 {"40001\/1213":55} entries=1600 retries=0 errs_in_closure={"40001\/1213":55} hist={"1":1600} wall=9.49s server={"deadlocks":55,"timeouts":0} sum_ok=yes log=1545 ledger_ok=yes side=[]
[mysql v3 run1] committed=1600/1600 failed=0 [] entries=1600 retries=0 errs_in_closure=[] hist={"1":1600} wall=9.85s server={"deadlocks":0,"timeouts":0} sum_ok=yes log=1600 ledger_ok=yes side=[]

Cùng một chuỗi cặp tài khoản (seed cố định theo run và worker), bản v1 mất 167/1600 giao dịch trên PostgreSQL và 55/1600 trên MySQL. Run 2 cho 133 và 50. Bản v3 mất 0. Con số tuyệt đối phụ thuộc vào mức tranh chấp: 10 dòng nóng là cố tình dồn ép, nên đừng dùng 10% hay 3% làm "tỉ lệ bình thường". Điều rút ra được là hình dạng: lỗi rải rác trên cả nghìn request thành công, tăng theo concurrency, và biến mất hoàn toàn khi đổi thứ tự khoá. Bản production của triệu chứng này là "thỉnh thoảng có vài request 500 lúc cao điểm, retry tay thì chạy được".

3. Deadlock khác lock wait ở chỗ nào, nhìn từ app. Deadlock cho lỗi nhanh và tự giải phóng: ngay khi victim bị huỷ, bên kia chạy tiếp (P1: câu UPDATE đang chờ của S2 trả về cùng thời điểm 1.60 s với lỗi của S1). Lock wait thì ngược lại: request chậm dần, connection pool bị chiếm, p99 latency tăng, và lỗi (nếu có) chỉ đến khi chạm timeout. Nếu dashboard cho thấy latency tăng vọt mà số lỗi 40P01/1213 phẳng, thì gần như chắc chắn là lock wait hoặc lock queue, đi thẳng sang phần view sống ở 5.2/5.3.

flowchart TD
  A["Request lỗi hoặc chậm bất thường"] --> B{"App nhận lỗi gì?"}
  B -->|"40P01 hoặc 1213"| C["Deadlock thật<br/>DB đã huỷ một transaction"]
  B -->|"1205, 55P03, 57014"| D["Lock wait quá timeout<br/>không có vòng"]
  B -->|"không lỗi, chỉ chậm"| E["Đang chờ khoá<br/>hoặc xếp hàng sau DDL"]
  C --> F["Đọc server log hoặc error log<br/>lấy cặp câu lệnh"]
  D --> G["Tìm blocker bằng view sống<br/>pg_blocking_pids, sys.innodb_lock_waits"]
  E --> G
  G --> H{"Blocker là gì?"}
  H -->|"idle in transaction"| I["Code mở transaction<br/>rồi đi làm việc khác"]
  H -->|"một câu DDL đang chờ"| J["Lock queue<br/>huỷ DDL, đặt lock_timeout"]
  H -->|"query đang chạy lâu"| K["Query chậm giữ khoá<br/>xem plan, index"]

5.2. PostgreSQL: log, log_lock_waits, pg_stat_database, pg_locks

1. Câu lệnh gây deadlock chỉ nằm trong server log. Client nhận DETAIL với pid và transaction id, kèm HINT: See server log for query details., nhưng không có text câu lệnh. Text câu lệnh chỉ được ghi vào server log. Đây là entry thật của P1 (run 3), lấy từ docker logs my-test-pgdb:

  | 2026-11-05 10:11:38.424 UTC [2048842] LOG:  process 2048842 detected deadlock while waiting for ShareLock on transaction 724558 after 1000.135 ms
  | 2026-11-05 10:11:38.424 UTC [2048842] DETAIL:  Process holding the lock: 2048850. Wait queue: .
  | 2026-11-05 10:11:38.424 UTC [2048842] CONTEXT:  while updating tuple (0,18) in relation "tmp_dl_acc"
  | 2026-11-05 10:11:38.424 UTC [2048842] STATEMENT:  UPDATE tmp_dl_acc SET balance = balance + 10 WHERE id = 2;
  | 2026-11-05 10:11:38.424 UTC [2048842] ERROR:  deadlock detected
  | 2026-11-05 10:11:38.424 UTC [2048842] DETAIL:  Process 2048842 waits for ShareLock on transaction 724558; blocked by process 2048850.
  | 	Process 2048850 waits for ShareLock on transaction 724557; blocked by process 2048842.
  | 	Process 2048842: UPDATE tmp_dl_acc SET balance = balance + 10 WHERE id = 2;
  | 	Process 2048850: UPDATE tmp_dl_acc SET balance = balance + 20 WHERE id = 1;
  | 2026-11-05 10:11:38.424 UTC [2048842] HINT:  See server log for query details.
  | 2026-11-05 10:11:38.424 UTC [2048842] CONTEXT:  while updating tuple (0,18) in relation "tmp_dl_acc"
  | 2026-11-05 10:11:38.424 UTC [2048842] STATEMENT:  UPDATE tmp_dl_acc SET balance = balance + 10 WHERE id = 2;

Đọc từ trên xuống: dòng LOG: ... detected deadlock while waiting for ShareLock on transaction 724558 after 1000.135 ms cho biết victim (pid 2048842) đã chờ đủ deadlock_timeout rồi mới kiểm tra. Khối ERROR: deadlock detected liệt kê cạnh của vòng (Process A waits for ... blocked by process B) và hai dòng Process 2048842: UPDATE ... / Process 2048850: UPDATE ..., tức là câu đang chờ của từng bên. Câu đã lấy khoá trước đó (UPDATE id = 1 của S1, UPDATE id = 2 của S2) không có trong log. Muốn biết "bên kia đã khoá gì trước", phải ghép với code path. CONTEXT: while updating tuple (0,18) in relation "tmp_dl_acc" cho biết bảng và vị trí vật lý (ctid) của dòng. Khoá dòng hiện ra dưới dạng ShareLock on transaction <xid> vì người chờ một dòng thực chất chờ transaction đang giữ dòng đó kết thúc.

2. log_lock_waits mặc định TẮT ở PostgreSQL 18.4. Đây là bất ngờ của lab. Container test có log_lock_waits = on, nhưng pg_settings cho thấy giá trị đó đến từ file cấu hình, còn boot_val là off:

[t=+  0.16s][MON]                   name                 | setting | unit | boot_val |       source       |  context
[t=+  0.16s][MON]   -------------------------------------+---------+------+----------+--------------------+-----------
[t=+  0.16s][MON]    deadlock_timeout                    | 1000    | ms   | 1000     | default            | superuser
[t=+  0.16s][MON]    idle_in_transaction_session_timeout | 0       | ms   | 0        | default            | user
[t=+  0.16s][MON]    lock_timeout                        | 0       | ms   | 0        | default            | user
[t=+  0.16s][MON]    log_lock_failures                   | off     |      | off      | default            | superuser
[t=+  0.16s][MON]    log_lock_waits                      | on      |      | off      | configuration file | superuser
[t=+  0.16s][MON]    transaction_timeout                 | 0       | ms   | 0        | default            | user

(Đã cắt dòng statement_timeout: giá trị 20000 ở dòng đó là lưới an toàn do orchestrator của lab tự SET trong session.) Giá trị on nằm trong postgresql.auto.conf từ trước lab, do ai đó đã chạy ALTER SYSTEM. Docs PG 18 ghi "The default is off". Theo ghi chú research, bản devel docs và release notes PostgreSQL 19 đổi mặc định thành on ("Enable server variable log_lock_waits by default"). Lab chạy 18.4 nên phần PG 19 chưa verify. Với PG 18 thì phải tự bật, nếu không entry deadlock ở trên mất dòng LOG: ... detected deadlock while waiting, và mọi lock wait dài đều im lặng.

ALTER SYSTEM SET log_lock_waits = on;   -- cần superuser
SELECT pg_reload_conf();
Cách nói khi phỏng vấn: "Ở PostgreSQL 18, cờ ghi log khi một session phải chờ khoá lâu vẫn tắt theo mặc định, nên việc đầu tiên khi nhận một hệ thống là bật nó lên và reload cấu hình. Sau đó, mỗi lần có session chờ khoá lâu hơn deadlock_timeout, mặc định một giây, log sẽ có một dòng 'still waiting', và khi lấy được khoá có thêm một dòng 'acquired'. Lab của tôi không phải chạy lại lệnh này vì container đã bật sẵn từ trước, tôi chỉ xác nhận giá trị mặc định gốc là off qua pg_settings."

Khi đã bật, một lock wait bình thường (không deadlock) để lại hai dòng: still waiting ... after 1000.766 ms lúc nó vượt deadlock_timeout, và acquired ... after 2096.442 ms lúc lấy được khoá. Hiệu hai con số là thời gian chờ thêm sau mốc 1 s:

  | 2026-11-05 10:09:50.303 UTC [2047879] LOG:  process 2047879 still waiting for ShareLock on transaction 723577 after 1000.766 ms
  | 2026-11-05 10:09:50.303 UTC [2047879] DETAIL:  Process holding the lock: 2047871. Wait queue: 2047879.
  | 2026-11-05 10:09:50.303 UTC [2047879] CONTEXT:  while updating tuple (0,3) in relation "tmp_dl_ops"
  | 2026-11-05 10:09:50.303 UTC [2047879] STATEMENT:  UPDATE tmp_dl_ops SET balance = balance - 2 WHERE id = 1;
  | 2026-11-05 10:09:51.398 UTC [2047879] LOG:  process 2047879 acquired ShareLock on transaction 723577 after 2096.442 ms
CHECK ok  : no 'still waiting' log line when the WAITING session has log_lock_waits=off

Dòng CHECK cuối là thí nghiệm 2b: khi chính session đang chờ SET log_lock_waits = off, dòng still waiting không xuất hiện. Cờ này được đọc ở phía người chờ, không phải phía người giữ khoá. Cả log_lock_waits và deadlock_timeout đều có context superuser, nên user ứng dụng thường không tự tắt được. Theo comment trong guc_tables.c mà research trích, việc để deadlock_timeout ở mức superuser là cố ý, "to prevent hiding from log_lock_waits".

3. pg_stat_database.deadlocks: counter để vẽ rate. Mỗi 40P01 làm counter của database đó tăng 1. Lab đọc counter trước và sau mỗi run:

SELECT deadlocks FROM pg_stat_database WHERE datname = current_database();
Cách nói khi phỏng vấn: "PostgreSQL giữ một bộ đếm số deadlock đã phát hiện cho mỗi database. Bộ đếm này chỉ tăng, nên thứ cần theo dõi là tốc độ tăng theo thời gian chứ không phải giá trị tuyệt đối. Trong lab, mỗi lần một session nhận lỗi deadlock thì bộ đếm tăng đúng một, và session khác nhìn thấy giá trị mới sau chưa tới một phần tư giây."
pg_stat_database.deadlocks before = 60
pg_stat_database.deadlocks after = 61 (delta 1; counter became visible after 0.13s of polling)
pg_stat_database.deadlocks delta over the whole script: 12

Delta luôn bằng số 40P01 trong script (08: 12 = 3 run × 4 kịch bản victim), và hiện ra với session khác sau 0.12–0.24 s polling. Counter không cho biết câu nào gây deadlock, chỉ cho biết có bao nhiêu. Nó là nguồn cho alert (5.4), còn log là nguồn để điều tra.

4. View sống: chỉ hữu ích cho lock wait, gần như vô dụng cho deadlock. PostgreSQL phá một deadlock sau khoảng 1 s, nên lúc bạn kịp mở psql thì vòng đã biến mất. Thứ bạn bắt được bằng view sống là lock wait kéo dài. Đây là query lab dùng để dựng "cây ai chặn ai" từ pg_stat_activity và pg_blocking_pids():

SELECT w.application_name AS waiter,
       w.wait_event_type || ':' || w.wait_event AS waits_on,
       b.application_name AS blocker,
       b.state AS blocker_state,
       left(b.query, 40) AS blocker_last_query,
       round(extract(epoch FROM now() - b.xact_start)::numeric, 1) AS blocker_xact_s
FROM pg_stat_activity w
CROSS JOIN LATERAL unnest(pg_blocking_pids(w.pid)) AS bp(pid)
JOIN pg_stat_activity b ON b.pid = bp.pid
WHERE w.datname = current_database();
Cách nói khi phỏng vấn: "Tôi lấy từng session đang chờ, hỏi PostgreSQL danh sách các tiến trình đang chặn nó, rồi nối ngược lại bảng hoạt động để xem kẻ chặn đang ở trạng thái gì, câu lệnh cuối cùng của nó là gì và transaction của nó đã mở bao lâu. Thông tin quan trọng nhất thường là trạng thái của kẻ chặn: nếu nó đang 'idle in transaction' thì vấn đề nằm ở code ứng dụng giữ transaction mở, không phải ở database chậm."

Output thật trong thí nghiệm 2a (S1 UPDATE id = 1 rồi đứng yên trong transaction, S2 UPDATE cùng dòng), lần lượt từ pg_stat_activity, pg_locks và query trên:

[t=+  1.63s][MON]    app |   pid   |        state        | wait_event_type |  wait_event   | blocked_by | waiting_s |                     query
[t=+  1.63s][MON]   -----+---------+---------------------+-----------------+---------------+------------+-----------+-----------------------------------------------
[t=+  1.63s][MON]    S1  | 2047871 | idle in transaction | Client          | ClientRead    | {}         |       1.4 | UPDATE tmp_dl_ops SET balance = balance - 1 W
[t=+  1.63s][MON]    S2  | 2047879 | active              | Lock            | transactionid | {2047871}  |       1.2 | UPDATE tmp_dl_ops SET balance = balance - 2 W
[t=+  1.64s][MON]    app |   locktype    |       mode       | granted |  xid   |    rel     | page | tuple
[t=+  1.64s][MON]   -----+---------------+------------------+---------+--------+------------+------+-------
[t=+  1.64s][MON]    S1  | relation      | RowExclusiveLock | t       |        | tmp_dl_ops |      |
[t=+  1.64s][MON]    S1  | transactionid | ExclusiveLock    | t       | 723577 |            |      |
[t=+  1.64s][MON]    S2  | relation      | RowExclusiveLock | t       |        | tmp_dl_ops |      |
[t=+  1.64s][MON]    S2  | transactionid | ExclusiveLock    | t       | 723578 |            |      |
[t=+  1.64s][MON]    S2  | transactionid | ShareLock        | f       | 723577 |            |      |
[t=+  1.64s][MON]    S2  | tuple         | ExclusiveLock    | t       |        | tmp_dl_ops |    0 |     3
[t=+  1.65s][MON]    waiter |      waits_on      | blocker |    blocker_state    |            blocker_last_query            | blocker_xact_s
[t=+  1.65s][MON]   --------+--------------------+---------+---------------------+------------------------------------------+----------------
[t=+  1.65s][MON]    S2     | Lock:transactionid | S1      | idle in transaction | UPDATE tmp_dl_ops SET balance = balance  |            1.5

Ba điều đọc được. (a) S2 có wait_event_type = Lock, wait_event = transactionid, blocked_by = {2047871}, tức pid của S1. (b) Trong pg_locks, S2 đã giữ khoá tuple (0,3), nghĩa là nó đứng đầu hàng cho dòng đó, và đang chờ ShareLock với granted = f trên transactionid 723577 của S1. (c) Kẻ chặn ở trạng thái idle in transaction (đã mở transaction nhưng không chạy câu nào, đang chờ client) từ 1.5 s. Trong production, đây là dấu hiệu kinh điển của code mở transaction rồi đi gọi HTTP, đẩy queue hoặc chờ người dùng. Docs PG 18 cảnh báo pg_blocking_pids() cần truy cập độc quyền ngắn vào lock manager ("Frequent calls to this function could have some impact on database performance"), nên đừng cho exporter gọi nó mỗi giây trên mọi session.

5. lock_timeout ngắn hơn deadlock_timeout che mất deadlock. Đây là bất ngờ thứ hai. Lab lặp lại đúng vòng của P1 (S1 giữ id = 1, S2 giữ id = 2, S1 chờ id = 2 từ 0.60 s, S2 khép vòng lúc 1.00 s), chỉ thêm SET lock_timeout = '500ms' cho S1:

[t=+  0.31s][S1] > SET lock_timeout = '500ms';
[t=+  0.31s][S1]   SET
[t=+  0.36s][S1] > UPDATE tmp_dl_ops SET balance = balance - 1 WHERE id = 1;
[t=+  0.39s][S1]   UPDATE 1
[t=+  0.39s][S2] > UPDATE tmp_dl_ops SET balance = balance - 1 WHERE id = 2;
[t=+  0.43s][S2]   UPDATE 1
[t=+  0.60s][S1] > UPDATE tmp_dl_ops SET balance = balance + 1 WHERE id = 2;
[t=+  1.00s][S2] > UPDATE tmp_dl_ops SET balance = balance + 1 WHERE id = 1;
[t=+  1.10s][S2]   UPDATE 1
[t=+  1.10s][S1]   ERROR:  55P03: canceling statement due to lock timeout
[t=+  1.10s][S1]   CONTEXT:  while updating tuple (0,14) in relation "tmp_dl_ops"
[t=+  1.10s][S1]   LOCATION:  ProcessInterrupts, postgres.c:3437
[t=+  1.10s][S1]   (returned after 0.50s)
[t=+  1.11s][S1] > ROLLBACK;
[t=+  1.11s][S1]   ROLLBACK
[t=+  1.11s][S2] > COMMIT;
[t=+  1.11s][S2]   COMMIT
----- server log (docker logs my-test-pgdb --since 2026-11-05T10:10:04Z, pids [2047986, 2047999]) -----
  | 2026-11-05 10:10:07.761 UTC [2047986] ERROR:  canceling statement due to lock timeout
----- end server log -----
result: {'S1': 'error 55P03', 'S2': 'ok'}  victim(40P01)=None  lock_timeout(55P03)=S1  pg_stat_database.deadlocks delta=0

S1 bắt đầu chờ lúc 0.60 s. Bộ hẹn giờ lock_timeout 500 ms của nó nổ lúc 1.10 s, sớm hơn mốc 1.60 s mà deadlock_timeout sẽ chạy deadlock check. Statement bị huỷ với 55P03, transaction của S1 bị abort, khoá được nhả, S2 chạy tiếp. Vòng bị phá, nhưng không ai "phát hiện deadlock": server log chỉ có canceling statement due to lock timeout, pg_stat_database.deadlocks delta=0, 2/2 run. Vẽ theo thời gian:

sequenceDiagram
  participant S1 as S1 lock_timeout 500ms
  participant DB as PostgreSQL
  participant S2 as S2
  S1->>DB: t=0.36 UPDATE id=1 (giu id=1)
  S2->>DB: t=0.39 UPDATE id=2 (giu id=2)
  S1->>DB: t=0.60 UPDATE id=2, bat dau cho
  S2->>DB: t=1.00 UPDATE id=1, vong da khep
  Note over S1,DB: t=1.10 lock_timeout cua S1 no truoc moc 1.60 cua deadlock_timeout
  DB-->>S1: 55P03 canceling statement due to lock timeout
  DB-->>S2: UPDATE 1, chay tiep
  Note over DB: pg_stat_database.deadlocks khong tang

Hệ quả cho vận hành: nếu ứng dụng đặt lock_timeout ngắn (một thói quen tốt để request không treo), counter deadlocks sẽ đếm thiếu và log không còn chữ "deadlock". Deadlock vẫn xảy ra, chỉ đổi tên thành 55P03. Ứng dụng cũng nhận một mã lỗi khác. Theo phân tích source trong research (mục 7 kiểm chứng), Laravel không coi 55P03 là lỗi concurrency nên không tự retry. Vì vậy, nếu dùng lock_timeout ở tầng app, phải đếm 55P03 cùng với 40P01, và đọc tỉ lệ 55P03 như một proxy cho deadlock. Docs cũng khuyên không đặt lock_timeout trong postgresql.conf ("would affect all sessions"); chỗ hợp lý nhất cho nó là session chạy migration (6.8).

6. log_lock_failures (mới ở PG 18) chỉ cho NOWAIT. Release notes 18 thêm biến này để log các lần lấy khoá thất bại. Docs ghi rõ "Currently, only lock failures due to SELECT NOWAIT is supported". Lab bật nó trong session và chạy FOR UPDATE NOWAIT lên dòng đang bị khoá:

[t=+  7.29s][S2] > SELECT id FROM tmp_dl_ops WHERE id = 1 FOR UPDATE NOWAIT;
[t=+  7.29s][S2]   ERROR:  55P03: could not obtain lock on row in relation "tmp_dl_ops"
  | 2026-11-05 10:10:03.607 UTC [2047951] LOG:  process 2047951 could not obtain ShareLock on transaction 723583
  | 2026-11-05 10:10:03.607 UTC [2047951] DETAIL:  Process holding the lock: 2047937, Wait queue: .

Dòng log cho biết ai đang giữ khoá (Process holding the lock: 2047937), thứ mà client với 55P03 không biết. Nó có ích nếu hệ thống dùng NOWAIT làm cơ chế "thử khoá, không được thì bỏ qua". Nó không log deadlock và cũng không log lock_timeout.

7. Có nên chỉnh deadlock_timeout? Docs PG 18 khuyên tăng trên server tải nặng và giữ nó lớn hơn thời gian transaction điển hình, vì mỗi lần check phải khoá toàn bộ lock table. Hạ nó xuống chỉ hợp lý ở mức session, để chẩn đoán ("If you are trying to investigate locking delays you might want to set a shorter than normal deadlock_timeout"), và chỉ superuser làm được. Lab cho thấy timer này còn quyết định ai làm victim (session có timer nổ trước khi vòng đã tồn tại sẽ tự huỷ chính nó; chi tiết ở mục 2). Docs vẫn nói không được dựa vào việc đoán victim, nên đừng chỉnh nó với mục đích "bảo vệ" một job.

5.3. MySQL: innodb_print_all_deadlocks, data_locks, INNODB_METRICS

1. SHOW ENGINE INNODB STATUS chỉ giữ deadlock gần nhất. Section LATEST DETECTED DEADLOCK (cách đọc ở mục 3, case M1) bị ghi đè mỗi lần có deadlock mới. Docs của innodb_print_all_deadlocks: "Otherwise, you see information about only the last deadlock, using the SHOW ENGINE INNODB STATUS statement." Lúc 3 giờ sáng có 200 deadlock/phút thì section đó chỉ là một mẫu ngẫu nhiên, không phải bức tranh. Muốn có lịch sử, phải bật ghi toàn bộ vào error log.

2. Bật innodb_print_all_deadlocks thôi là chưa đủ. Đây là bất ngờ của lab. Hai biến đều dynamic:

SET GLOBAL innodb_print_all_deadlocks = ON;
SET GLOBAL log_error_verbosity = 3;   -- không có dòng này thì mất header
SELECT @@global.innodb_print_all_deadlocks, @@global.log_error_verbosity;
Cách nói khi phỏng vấn: "Trên MySQL tôi bật cờ ghi mọi deadlock vào error log, nhưng phải nâng luôn mức chi tiết của error log lên ba. Ở mức mặc định là hai, log chỉ có phần dump thô của hai transaction, không có dòng tiêu đề, không có thời điểm và không biết bên nào bị rollback. Cả hai biến đổi được lúc server đang chạy. Lab đã đặt lại về mặc định sau khi thử."

Với log_error_verbosity = 2 (mặc định), error log nhận được đúng phần dump này, không timestamp, không dòng *** (1) TRANSACTION, không WE ROLL BACK:

----- docker logs --since 1793874201 tmp-dl-mysql (verbatim) -----
TRANSACTION 25167, ACTIVE 1 sec starting index read
mysql tables in use 1, locked 1
LOCK WAIT 3 lock struct(s), heap size 1128, 2 row lock(s), undo log entries 1
MySQL thread id 355, OS thread handle 140017749927488, query id 13500 172.23.0.3 root updating
UPDATE acc SET balance = balance + 10 WHERE id = 2
RECORD LOCKS space id 105 page no 4 n bits 72 index PRIMARY of table `lab`.`acc` trx id 25167 lock_mode X locks rec but not gap

Nâng lên 3 thì có đủ khung, mỗi tiêu đề là một dòng [Note] có timestamp và mã lỗi riêng (đã cắt phần hex dump của từng record):

2026-11-05T10:23:33.124303Z 0 [Note] [MY-012468] [InnoDB] Transactions deadlock detected, dumping detailed information.
2026-11-05T10:23:33.124366Z 0 [Note] [MY-012469] [InnoDB]  *** (1) TRANSACTION:
TRANSACTION 25192, ACTIVE 2 sec starting index read
mysql tables in use 1, locked 1
LOCK WAIT 3 lock struct(s), heap size 1128, 2 row lock(s), undo log entries 1
MySQL thread id 361, OS thread handle 140017748870720, query id 13538 172.23.0.3 root updating
UPDATE acc SET balance = balance + 10 WHERE id = 2
2026-11-05T10:23:33.124401Z 0 [Note] [MY-012469] [InnoDB]  *** (1) HOLDS THE LOCK(S):
2026-11-05T10:23:33.124505Z 0 [Note] [MY-012469] [InnoDB]  *** (1) WAITING FOR THIS LOCK TO BE GRANTED:
RECORD LOCKS space id 106 page no 4 n bits 72 index PRIMARY of table `lab`.`acc` trx id 25192 lock_mode X locks rec but not gap waiting
2026-11-05T10:23:33.124804Z 0 [Note] [MY-012469] [InnoDB] *** WE ROLL BACK TRANSACTION (2)
2026-11-05T10:23:33.760420Z 359 [Note] [MY-010914] [Server] Aborted connection 359 to db: 'unconnected' user: 'root' host: '172.23.0.3' (Got an error reading communication packets).

Để alert từ log, grep MY-012468, nhưng mã này chỉ xuất hiện ở verbosity 3. Cái giá là verbosity 3 ghi mọi Note: dòng cuối Aborted connection ... (Got an error reading communication packets) ở trên là của chính connection monitor của lab thoát mà không gửi COM_QUIT, không liên quan tới deadlock. Trên server bận, cần tính trước dung lượng log. SET GLOBAL mất khi restart; muốn giữ phải đưa vào file cấu hình (chưa verify trong lab này, lab chỉ test SET GLOBAL).

3. Innodb_deadlocks không tồn tại ở MySQL 8.4. Rất nhiều bài viết và dashboard cũ dùng status variable này. Trên 8.4.13 nó trả về 0 dòng (đó là tên của MariaDB/Percona). Counter đúng nằm ở hai chỗ khác:

SELECT NAME, `COUNT`, STATUS FROM information_schema.INNODB_METRICS
 WHERE NAME IN ('lock_deadlocks', 'lock_timeouts', 'lock_row_lock_waits', 'lock_deadlock_false_positives')
 ORDER BY NAME;

SELECT ERROR_NUMBER, ERROR_NAME, SQL_STATE, SUM_ERROR_RAISED, SUM_ERROR_HANDLED, LAST_SEEN
  FROM performance_schema.events_errors_summary_global_by_error
 WHERE ERROR_NAME IN ('ER_LOCK_DEADLOCK', 'ER_LOCK_WAIT_TIMEOUT');
Cách nói khi phỏng vấn: "Trên MySQL 8.4 tôi không dùng biến trạng thái Innodb_deadlocks vì nó không còn tồn tại, đó là tên của MariaDB. Tôi lấy số deadlock từ bảng metric nội bộ của InnoDB, chỉ số lock_deadlocks, vốn bật sẵn theo mặc định. Tôi đối chiếu thêm với bảng thống kê lỗi của performance schema, nơi đếm số lần lỗi 1213 và 1205 thực sự trả về cho client. Một bên đếm từ góc nhìn engine, một bên đếm từ góc nhìn ứng dụng."

Trước và sau đúng một deadlock kiểu M1:

[t=+ 0.07s][MON] SHOW GLOBAL STATUS LIKE 'Innodb_deadlocks'
[t=+ 0.07s][MON]    | Variable_name | Value |
[t=+ 0.07s][MON]   -> 0 row(s) (0.00s)
[t=+ 0.07s][MON]    | NAME                          | COUNT | STATUS  |
[t=+ 0.07s][MON]    | lock_deadlocks                | 252   | enabled |
[t=+ 0.07s][MON]    | lock_deadlock_false_positives | 0     | enabled |
[t=+ 0.07s][MON]    | lock_row_lock_waits           | 6320  | enabled |
[t=+ 0.07s][MON]    | lock_timeouts                 | 0     | enabled |
[t=+ 2.03s][MON]    | NAME                          | COUNT | STATUS  |
[t=+ 2.03s][MON]    | lock_deadlocks                | 253   | enabled |
[t=+ 2.03s][MON]    | lock_deadlock_false_positives | 0     | enabled |
[t=+ 2.03s][MON]    | lock_row_lock_waits           | 6322  | enabled |
[t=+ 2.03s][MON]    | lock_timeouts                 | 0     | enabled |
[t=+ 0.09s][MON]    | ERROR_NUMBER | ERROR_NAME           | SQL_STATE | SUM_ERROR_RAISED | SUM_ERROR_HANDLED | LAST_SEEN           |
[t=+ 0.09s][MON]    | 1205         | ER_LOCK_WAIT_TIMEOUT | HY000     | 0                | 0                 | NULL                |
[t=+ 0.09s][MON]    | 1213         | ER_LOCK_DEADLOCK     | 40001     | 252              | 0                 | 2026-11-05 10:23:04 |
[t=+ 2.05s][MON]    | ERROR_NUMBER | ERROR_NAME           | SQL_STATE | SUM_ERROR_RAISED | SUM_ERROR_HANDLED | LAST_SEEN           |
[t=+ 2.05s][MON]    | 1205         | ER_LOCK_WAIT_TIMEOUT | HY000     | 0                | 0                 | NULL                |
[t=+ 2.05s][MON]    | 1213         | ER_LOCK_DEADLOCK     | 40001     | 253              | 0                 | 2026-11-05 10:23:18 |

lock_deadlocks 252 → 253, SUM_ERROR_RAISED của 1213 252 → 253. INNODB_METRICS lock_deadlocks bật sẵn, không cần innodb_monitor_enable. Innodb_row_lock_waits / lock_row_lock_waits (6320 → 6322) đếm số lần chờ, không phải số deadlock, đừng nhầm.

Counter cũng có điểm mù, giống lock_timeout ở PG. Khi innodb_deadlock_detect = OFF (lab 11, innodb_lock_wait_timeout = 5), vòng vẫn tồn tại nhưng không ai phá, cả hai bên nhận 1205 và lock_deadlocks đứng yên:

[t=+ 6.01s][S1]   -> ERROR 1205 (HY000): Lock wait timeout exceeded; try restarting transaction  (5.21s)
[t=+ 7.01s][S2]   -> ERROR 1205 (HY000): Lock wait timeout exceeded; try restarting transaction  (5.01s)
RUN 1: outcome {'S1': '1205', 'S2': '1205'} | INNODB_METRICS delta: lock_deadlocks +0, lock_timeouts +2
[t=+ 8.17s][MON]    | id | balance | total_should_be_200 |
[t=+ 8.17s][MON]    | 1  | 90      | 170                 |
[t=+ 8.17s][MON]    | 2  | 80      | 170                 |

Tệ hơn, app trong run này bỏ qua 1205 rồi COMMIT. Mỗi bên đã lỡ trừ tiền ở câu đầu, nên tổng tiền còn 170 thay vì 200. Đây là lý do 5.5 ghi "đừng tắt deadlock detection lúc 3 giờ sáng". Tranh luận về việc có nên tắt detection (manual cho phép, kỹ sư InnoDB khuyên giữ ON từ 8.0.18) nằm ở mục 7.3.

4. View sống cho lock wait: sys.innodb_lock_waits. Cũng như PG, deadlock InnoDB được phá gần như ngay, nên view sống chỉ dùng cho lock wait. Lab 09: S1 UPDATE ... WHERE id = 1 rồi đứng yên trong transaction, S2 autocommit UPDATE cùng dòng:

SELECT wait_started, wait_age, wait_age_secs, locked_table, locked_index, locked_type,
       waiting_trx_id, waiting_pid, waiting_query, waiting_lock_mode,
       blocking_trx_id, blocking_pid, blocking_query, blocking_lock_mode,
       blocking_trx_started, blocking_trx_age, blocking_trx_rows_modified,
       sql_kill_blocking_query, sql_kill_blocking_connection
  FROM sys.innodb_lock_waits\G
Cách nói khi phỏng vấn: "Schema sys của MySQL có sẵn một view gom các lock wait đang diễn ra: ai đang chờ, chờ khoá gì trên bảng nào, ai đang chặn, transaction của kẻ chặn đã mở bao lâu và sửa bao nhiêu dòng. View này còn sinh sẵn câu lệnh để kill kẻ chặn. Tôi dùng nó khi request chậm mà không thấy lỗi deadlock, để tìm ra transaction đang ngồi giữ khoá."
[t=+ 1.71s][MON]    *************************** 1. row ***************************
[t=+ 1.71s][MON]                    wait_started: 2026-11-05 10:23:38
[t=+ 1.72s][MON]                        wait_age: 0:00:01
[t=+ 1.72s][MON]                   wait_age_secs: 1
[t=+ 1.72s][MON]                    locked_table: `lab`.`acc`
[t=+ 1.72s][MON]                    locked_index: PRIMARY
[t=+ 1.72s][MON]                     locked_type: RECORD
[t=+ 1.72s][MON]                  waiting_trx_id: 25218
[t=+ 1.72s][MON]                     waiting_pid: 368
[t=+ 1.72s][MON]                   waiting_query: UPDATE acc SET balance = balan ... ommit statement, blocked by S1
[t=+ 1.72s][MON]               waiting_lock_mode: X,REC_NOT_GAP
[t=+ 1.72s][MON]                 blocking_trx_id: 25217
[t=+ 1.72s][MON]                    blocking_pid: 367
[t=+ 1.72s][MON]                  blocking_query: None
[t=+ 1.72s][MON]              blocking_lock_mode: X,REC_NOT_GAP
[t=+ 1.72s][MON]            blocking_trx_started: 2026-11-05 10:23:37
[t=+ 1.72s][MON]                blocking_trx_age: 0:00:02
[t=+ 1.72s][MON]      blocking_trx_rows_modified: 1
[t=+ 1.72s][MON]         sql_kill_blocking_query: KILL QUERY 367
[t=+ 1.72s][MON]    sql_kill_blocking_connection: KILL 367
[t=+ 1.72s][MON]   -> 1 row(s)

Chi tiết đáng giá nhất là blocking_query: None (giá trị NULL, orchestrator Python của lab in thành None). Kẻ chặn không chạy câu nào, đúng như hai view khác xác nhận: trong INNODB_TRX transaction 25217 RUNNING với trx_query NULL, còn trong processlist connection 367 có Sleep.

[t=+ 1.80s][MON]    | trx_id | trx_state | trx_started         | trx_wait_started    | pid | trx_rows_locked | trx_rows_modified | trx_weight | trx_query                                                    |
[t=+ 1.80s][MON]    | 25217  | RUNNING   | 2026-11-05 10:23:37 | NULL                | 367 | 1               | 1                 | 3          | NULL                                                         |
[t=+ 1.80s][MON]    | 25218  | LOCK WAIT | 2026-11-05 10:23:38 | 2026-11-05 10:23:38 | 368 | 1               | 0                 | 2          | UPDATE acc SET balance = balance + 5 WHERE id = 1   -- autoc |
[t=+ 1.90s][MON]    | ID  | COMMAND | TIME | STATE     | INFO                                                         |
[t=+ 1.90s][MON]    | 366 | Sleep   | 2    |           | NULL                                                         |
[t=+ 1.90s][MON]    | 367 | Sleep   | 2    |           | NULL                                                         |
[t=+ 1.90s][MON]    | 368 | Query   | 1    | updating  | UPDATE acc SET balance = balance + 5 WHERE id = 1   -- autoc |

Đây là bản MySQL của "idle in transaction". View gợi ý KILL QUERY 367 và KILL 367, nhưng với kẻ chặn đang Sleep thì KILL QUERY không có gì để kill. Chỉ KILL 367 (đóng connection, rollback transaction) mới giải phóng khoá. Lab chỉ in hai câu này ra, không chạy. Một lưu ý khi tự join performance_schema.data_locks: cột THREAD_ID là thread đã tạo lock struct, không phải lúc nào cũng là chủ của transaction (mục 3, bất ngờ S7). Hãy map qua ENGINE_TRANSACTION_ID ↔ INNODB_TRX.trx_id như lab đã làm.

5.4. Metric và cảnh báo nên đặt

Nguyên tắc: page (gọi người trực dậy) theo tác động lên người dùng, tạo ticket theo xu hướng. Cả hai vendor đều coi deadlock lác đác là bình thường nếu app có retry (manual MySQL: "they are not dangerous unless they are so frequent that you cannot run certain transactions at all"). Ngược lại, practitioner (incident.io, Cybertec, trong research) coi deadlock lặp lại là bug thiết kế. Hai cách nhìn không mâu thuẫn: rate thấp và ổn định thì ghi nhận, rate tăng hoặc gắn với một code path mới thì đó là bug cần sửa.

Đo cái gìPostgreSQLMySQL 8.4Ngưỡng (ví dụ minh hoạ)Lý do chọn ngưỡng
Rate deadlock Tốc độ tăng của pg_stat_database.deadlocks theo từng DB Tốc độ tăng của INNODB_METRICS.lock_deadlocks, đối chiếu SUM_ERROR_RAISED của 1213 Ticket khi rate trung bình 1 giờ gấp 3 lần baseline (mức nền) 7 ngày. Page chỉ khi đi kèm tỉ lệ lỗi request tăng. Giá trị tuyệt đối phụ thuộc tải (lab: 0 tới 167 trên 1600 chỉ vì đổi thứ tự khoá). So với chính hệ thống hôm qua thì có nghĩa hơn so với một con số cố định.
Lỗi lock theo mã, ở tầng app 40P01, 55P03, 25P02 1213, 1205 Gắn nhãn theo route/job. Alert khi một route vượt 1% request lỗi lock trong 10 phút. Counter DB đếm thiếu (55P03 che deadlock ở 5.2, detection OFF ở 5.3) và không cho biết code path nào. App biết cả hai.
Lock wait Số session có wait_event_type = 'Lock'; số dòng still waiting trong log Innodb_row_lock_current_waits, lock_threads_waiting, tốc độ tăng của Innodb_row_lock_time (ms) Alert khi có hơn 5 session chờ khoá liên tục 2 phút. Một dòng still waiting nghĩa là đã chờ quá deadlock_timeout (1 s). Vài dòng mỗi phút là tranh chấp, hàng chục dòng là có kẻ giữ khoá lâu.
Transaction dài / idle in transaction now() - xact_start, state = 'idle in transaction' trong pg_stat_activity INNODB_TRX.trx_started + processlist Command = Sleep Warn khi có transaction mở quá 30 s, hoặc idle in transaction quá 10 s. Transaction OLTP điển hình chỉ vài chục ms. Thứ giữ khoá 30 s sẽ sớm làm người khác chạm 1205 (50 s) hoặc treo vô hạn ở PG (lock_timeout = 0).
Retry của appSố lần retry và số lần hết lượt retry, theo route/job (mục 7) Alert khi số lần hết lượt retry khác 0. Retry che deadlock khỏi người dùng. Hết lượt retry nghĩa là người dùng đã thấy lỗi.

Mọi ngưỡng trong bảng là ví dụ minh hoạ, dựng từ lý do ở cột cuối, không đo từ hệ thống thật. Hãy lấy baseline của chính bạn hai tuần trước khi chốt số.

Query liệt kê transaction dài cho PostgreSQL. Câu này đã chạy thử trên PG 18.4 để kiểm tra cú pháp; output không dán vào đây vì môi trường test dùng chung:

SELECT pid, application_name, state, wait_event_type,
       round(extract(epoch FROM now() - xact_start)::numeric, 1) AS xact_s,
       left(query, 40) AS last_query
  FROM pg_stat_activity
 WHERE xact_start IS NOT NULL
   AND pid <> pg_backend_pid()
   AND (state = 'idle in transaction' OR now() - xact_start > interval '30 seconds')
 ORDER BY xact_start;
Cách nói khi phỏng vấn: "Tôi liệt kê mọi session đang có transaction mở, bỏ qua chính mình, và chỉ giữ những session đang 'idle in transaction' hoặc có transaction mở quá ba mươi giây. Danh sách sắp theo transaction cũ nhất trước, vì kẻ giữ khoá lâu nhất thường là gốc của chuỗi chờ. Tên ứng dụng trong kết quả giúp tôi biết ngay service hay job nào đang giữ transaction."

Bản MySQL là query INNODB_TRX mà lab 09 đã chạy (output ở 5.3):

SELECT trx_id, trx_state, trx_started, trx_wait_started, trx_mysql_thread_id AS pid,
       trx_rows_locked, trx_rows_modified, trx_weight, LEFT(trx_query, 60) AS trx_query
  FROM information_schema.INNODB_TRX
 ORDER BY trx_started;
Cách nói khi phỏng vấn: "Trên MySQL tôi đọc danh sách transaction InnoDB đang mở, sắp theo thời điểm bắt đầu. Ngoài tuổi của transaction, tôi xem nó đang chạy hay đang chờ khoá, đã khoá và sửa bao nhiêu dòng, và câu lệnh hiện tại. Nếu câu lệnh hiện tại trống mà transaction đã mở lâu thì đó là connection đang ngồi giữ khoá."

Về exporter: research chưa tra tên metric cụ thể mà postgres_exporter và mysqld_exporter xuất ra cho các counter trên, cũng chưa tra collector nào phải bật. Phần này chưa verify. Cách làm an toàn là curl endpoint /metrics của exporter đang chạy, grep deadlock và lock, rồi đối chiếu với giá trị SQL ở trên trước khi viết alert.

5.5. Runbook: bị gọi lúc 3h sáng vì deadlock

Mục tiêu của runbook không phải sửa tận gốc lúc 3 giờ sáng. Mục tiêu là (1) xác nhận đúng loại sự cố, (2) cầm máu, (3) giữ lại đủ bằng chứng để ban ngày sửa tận gốc. Bước 0 chung cho cả hai DB, sau đó tách nhánh.

flowchart TD
  A["Alert: lỗi hoặc latency tăng"] --> B["Bước 0: lỗi app theo mã<br/>và delta counter deadlock"]
  B --> C{"Counter deadlock tăng?"}
  C -->|"có"| D["Deadlock thật<br/>lấy cặp câu lệnh từ log"]
  C -->|"không, nhưng có 55P03 hoặc 1205"| E["Lock wait hoặc deadlock bị che<br/>xem lock_timeout, detection"]
  C -->|"không, chỉ chậm"| F["View sống: ai chặn ai"]
  D --> G["Map cặp câu lệnh về code path<br/>job, route, deploy gần nhất"]
  E --> F
  F --> H{"Kẻ chặn"}
  H -->|"idle in transaction"| I["Kill connection đó<br/>ghi lại pid, user, app"]
  H -->|"DDL trong hàng đợi"| J["Huỷ DDL, chạy lại sau<br/>với lock_timeout"]
  G --> K["Cầm máu: giảm concurrency<br/>của job va chạm, bật retry"]
  K --> L["Ban ngày: sửa tận gốc theo mục 6"]
  I --> L
  J --> L

Bước 0 (chung): đây có thật là deadlock không?

  1. Mở dashboard lỗi của app, tách theo mã: 40P01/1213 (deadlock), 1205/55P03 (timeout chờ khoá), 57014 (statement timeout, có thể không liên quan đến khoá), và lỗi pool/connection. Nếu chỉ có latency tăng mà không có mã nào trong số này, đó không phải deadlock.

  2. Xem delta counter trong 15 phút gần nhất: pg_stat_database.deadlocks hoặc INNODB_METRICS.lock_deadlocks. Counter tăng đồng thời với lỗi app là deadlock thật. Counter đứng yên mà app có nhiều 55P03 thì nghĩ ngay tới case "lock_timeout che deadlock" (5.2). Ở MySQL, kiểm tra SELECT @@innodb_deadlock_detect; nếu là 0 thì deadlock hiện ra dưới dạng 1205 (5.3).

  3. Nếu là lock wait hoặc lock queue, chạy query "ai chặn ai" (5.2 cho PG, sys.innodb_lock_waits cho MySQL) và đi theo nhánh phải của sơ đồ. Kẻ chặn idle in transaction thì ghi lại pid, user, application_name/host, tuổi transaction, sau đó mới kill. PG có pg_terminate_backend(pid), MySQL có KILL <id> (lab không chạy lệnh kill, chỉ quan sát view). Kẻ chặn là một DDL đang chờ (P6-B) thì huỷ DDL đó trước, vì nó đang chặn cả các câu đọc phía sau.

Nhánh PostgreSQL

  1. Xác nhận và đo. Grep server log tìm deadlock detected và đếm theo phút. Nếu log_lock_waits đang tắt (mặc định của 18), bật lên bằng ALTER SYSTEM + pg_reload_conf() để có dòng still waiting cho những lần sau. Không cần restart.

  2. Lấy cặp câu lệnh. Trong mỗi khối ERROR: deadlock detected, hai dòng Process N: <câu lệnh> là câu đang chờ của hai bên. CONTEXT cho bảng; waits for ShareLock on transaction nghĩa là khoá dòng, on relation nghĩa là khoá bảng (DDL, P6), on advisory lock nghĩa là advisory lock (P7). Gom các khối theo cặp câu lệnh đã bỏ tham số: thường chỉ một hai cặp chiếm gần hết.

  3. Map về code path. Log không có câu đã lấy khoá trước đó, nên phải tìm trong code xem transaction nào chạy hai câu này, và chạy theo thứ tự nào. application_name giúp rất nhiều nếu mỗi service/job đặt tên riêng cho connection (lab đặt S1, S2... và thấy ngay trong pg_stat_activity). Đối chiếu với deploy, cron hoặc migration vừa chạy.

  4. Cầm máu. Nếu code path đã có retry đúng (mục 7), deadlock lác đác tự hết, chỉ cần theo dõi. Nếu không, giảm concurrency của job va chạm (tạm dừng một nửa worker, tạm hoãn cron), hoặc tạm serialize (cho chạy tuần tự) đoạn nóng bằng advisory lock (khoá do ứng dụng tự đặt tên, 6.7). Đừng hạ lock_timeout xuống dưới deadlock_timeout như một cách "sửa": nó chỉ đổi tên lỗi thành 55P03 và làm counter mù (5.2).

  5. Sửa tận gốc. Nhận dạng pattern theo bảng 6.9: hai UPDATE ngược thứ tự → 6.1; FOR SHARE rồi UPDATE → 6.2; DDL → 6.8; INSERT trùng key → 6.6. Viết lại thành test tái hiện có hai session trước khi sửa.

Nhánh MySQL

  1. Xác nhận và đo. Delta lock_deadlocks và SUM_ERROR_RAISED của 1213. Chụp ngay SHOW ENGINE INNODB STATUS vì nó chỉ giữ deadlock gần nhất. Nếu tần suất cao, bật tạm innodb_print_all_deadlocks = ON và log_error_verbosity = 3 (cả hai dynamic) để gom mẫu, nhớ tắt lại khi xong vì log sẽ phình.

  2. Lấy cặp câu lệnh và kiểu khoá. Mỗi khối có *** (1) TRANSACTION / *** (2) TRANSACTION với câu đang chờ, HOLDS THE LOCK(S) và WAITING FOR THIS LOCK. Kiểu khoá gợi ý pattern: locks gap before rec insert intention → gap lock (khoá khoảng trống giữa hai bản ghi index; M2/M3, 6.5/6.6); index idx_x ở một bên và index PRIMARY ở bên kia → secondary vs PK (M6, 6.3); số row lock(s) lớn bất thường như 182 row lock(s) → thiếu index (M4, 6.3); lock mode S locks rec but not gap trên bảng cha → foreign key (M5, 6.2). *** WE ROLL BACK TRANSACTION (n) cho biết victim (transaction "nhẹ" hơn theo trx_weight).

  3. Map về code path. MySQL thread id trong khối = processlist id; user/host trên cùng dòng cho biết service nào. Nếu có ProxySQL đứng giữa thì host là ProxySQL, phải map tiếp qua query digest.

  4. Cầm máu. Giống PG: dựa vào retry nếu có, giảm concurrency của job va chạm. Đừng tắt innodb_deadlock_detect: lab 11 cho thấy cả hai bên chờ tới timeout rồi nhận 1205, và nếu app commit sau 1205 thì dữ liệu sai (170 thay vì 200). Tăng innodb_lock_wait_timeout cũng không giúp: khi detection bật, docs ghi timeout này "does not apply to deadlocks"; khi detection tắt, chờ lâu hơn chỉ kéo dài thời gian treo vì không bên nào nhả khoá.

  5. Sửa tận gốc. Theo bảng 6.9. Với MySQL, kiểm tra thêm EXPLAIN của cả hai câu: rất nhiều deadlock InnoDB thực chất là thiếu index hoặc quét sai index (M4, M6).

6. Phòng ngừa: thiết kế để không bị deadlock

Cả hai vendor đều mở đầu lời khuyên bằng cùng một câu. PostgreSQL 18: "The best defense against deadlocks is generally to avoid them by being certain that all applications using a database acquire locks on multiple objects in a consistent order", và "the first lock acquired on an object in a transaction is the most restrictive mode that will be needed". MySQL 8.4: "do those operations in a consistent order each time. Then transactions form well-defined queues and do not deadlock." Mục này đi qua từng kỹ thuật, mỗi kỹ thuật gắn với case đã tái hiện ở mục 2–3, kèm output của bản sửa, cái giá phải trả, và lúc không nên dùng.

Có hai trường phái về vai trò của retry (chạy lại cả transaction khi bị chọn làm victim). Manual MySQL viết "Deadlocks are not dangerous. Just try again." Practitioner thì nói ngược lại: incident.io coi deadlock là "indications of poorly structured logic", Cybertec viết "There is NO MAGIC CONFIGURATION PARAMETER to fix this problem". Bài này đứng ở giữa: retry là lưới an toàn bắt buộc cho phần không tránh được (M3, M6b), còn mục 6 là để phần tránh được thì tránh hẳn. Cách viết retry đúng nằm ở mục 7.

6.1. Khoá theo một thứ tự cố định

1. Hai dòng, hai transaction: khoá cả hai theo id tăng dần ngay câu đầu (P1, M1). Case gốc: chuyển tiền 1 → 2 và 2 → 1 cùng lúc, mỗi bên khoá dòng nguồn trước. Bản sửa thêm một câu khoá cả hai dòng theo cùng một thứ tự trước khi làm gì khác:

BEGIN;
SELECT id FROM tmp_dl_acc WHERE id IN (1, 2) ORDER BY id FOR UPDATE;   -- cả hai session đều chạy câu này
UPDATE tmp_dl_acc SET balance = balance - 10 WHERE id = 1;
UPDATE tmp_dl_acc SET balance = balance + 10 WHERE id = 2;
COMMIT;
Cách nói khi phỏng vấn: "Trước khi sửa số dư, tôi khoá cả hai tài khoản trong một câu, sắp theo id tăng dần, bất kể chiều chuyển tiền là gì. Như vậy hai giao dịch ngược chiều luôn tranh nhau tài khoản có id nhỏ hơn trước. Bên đến sau phải chờ ngay ở bước đầu khi chưa giữ khoá nào, nên không thể có vòng chờ. Cái giá là bên đến sau phải chờ bên kia commit xong."

Output P1-FIX run 1/2 trên PostgreSQL 18.4. S2 đứng chờ ngay ở câu SELECT ... FOR UPDATE và không giữ gì trong lúc chờ:

[t=+  0.20s][S1] > SELECT id FROM tmp_dl_acc WHERE id IN (1, 2) ORDER BY id FOR UPDATE;
[t=+  0.40s][S2] > SELECT id FROM tmp_dl_acc WHERE id IN (1, 2) ORDER BY id FOR UPDATE;
[t=+  0.60s][S1] > UPDATE tmp_dl_acc SET balance = balance - 10 WHERE id = 1;
[t=+  0.61s][S1]   UPDATE 1
[t=+  0.80s][S1] > UPDATE tmp_dl_acc SET balance = balance + 10 WHERE id = 2;
[t=+  0.80s][S1]   UPDATE 1
[t=+  2.00s][S1] > COMMIT;
[t=+  2.01s][S1]   COMMIT
[t=+  2.01s][S2]   (returned after 1.61s)
final balances: 1=1010 2=990
CHECK ok  : fixed order: no error, both transfers applied, deadlocks delta=0: {'S1': 'ok', 'S2': 'ok'}

S2 chờ 1.61 s rồi chạy tiếp, không lỗi, deadlocks delta=0, số dư cuối 1010/990 (cả hai giao dịch đều được áp dụng), 2/2 run. Bản deadlock là 3/3. Trên MySQL 8.4 (M1-FIX), data_locks cho thấy đúng hình dạng đó: S1 giữ X,REC_NOT_GAP trên cả id 1 và 2, còn S2 WAITING trên id 1, dòng đầu tiên theo thứ tự:

[t=+ 0.41s][S2] SELECT id, balance FROM acc WHERE id IN (2, 1) ORDER BY id FOR UPDATE   -- same order -> waits on id=1
[t=+ 0.91s][MON]    | session | trx  | tbl | idx     | type   | mode          | status  | data |
[t=+ 0.91s][MON]    | S1      | 1890 | acc | NULL    | TABLE  | IX            | GRANTED | NULL |
[t=+ 0.91s][MON]    | S1      | 1890 | acc | PRIMARY | RECORD | X,REC_NOT_GAP | GRANTED | 1    |
[t=+ 0.91s][MON]    | S1      | 1890 | acc | PRIMARY | RECORD | X,REC_NOT_GAP | GRANTED | 2    |
[t=+ 0.91s][MON]    | S2      | 1891 | acc | NULL    | TABLE  | IX            | GRANTED | NULL |
[t=+ 0.91s][MON]    | S2      | 1891 | acc | PRIMARY | RECORD | X,REC_NOT_GAP | WAITING | 1    |

M1-FIX: ERROR 1213 seen in 0/3 runs, so với 3/3 ở bản gốc. Lý do bản sửa đúng là một tính chất chứ không phải may mắn về timing: khi mọi transaction lấy khoá theo cùng một thứ tự toàn phần, không thể có vòng, vì cạnh chờ luôn đi từ "đang giữ khoá nhỏ hơn" sang "đang giữ khoá lớn hơn".

sequenceDiagram
  participant S1 as S1 chuyen 1 sang 2
  participant DB as Database
  participant S2 as S2 chuyen 2 sang 1
  S1->>DB: SELECT id IN (1,2) ORDER BY id FOR UPDATE
  DB-->>S1: cap khoa id=1 roi id=2
  S2->>DB: SELECT id IN (1,2) ORDER BY id FOR UPDATE
  Note over DB,S2: S2 cho tai id=1, chua giu khoa nao nen khong co vong
  S1->>DB: UPDATE 1, UPDATE 2, COMMIT
  DB-->>S2: cap id=1 va id=2 (sau 1.61s)
  S2->>DB: UPDATE 2, UPDATE 1, COMMIT

2. Ở tầng ứng dụng, dưới tải. Lab Laravel ở 5.1 so sánh đúng hai cách viết này với 8 worker × 200 giao dịch. Bản v3 khoá min(id) rồi max(id), bản v3in dùng một câu whereIn ... orderBy('id') ... lockForUpdate(). Đoạn code thật trong DlRun.php:

case 'v3':
    $acc()->where('id', min($from, $to))->lockForUpdate()->first();
    usleep($sleepUs);
    $acc()->where('id', max($from, $to))->lockForUpdate()->first();
    break;
case 'v3in':
    $acc()->whereIn('id', [$from, $to])->orderBy('id')->lockForUpdate()->get();
    usleep($sleepUs);
    break;
Cách nói khi phỏng vấn: "Trong Laravel tôi có hai cách. Cách thứ nhất là khoá tài khoản có id nhỏ hơn trước rồi mới khoá tài khoản id lớn hơn, dù tiền chuyển theo chiều nào. Cách thứ hai là khoá cả hai trong một truy vấn có sắp xếp theo id. Cả hai đều cho cùng kết quả: dưới tải tám worker, số deadlock từ hơn một trăm xuống không. Điều quan trọng không phải cú pháp mà là mọi chỗ trong code khoá hai tài khoản đều phải đi qua cùng một hàm này."
[pgsql v1 run1] committed=1433/1600 failed=167 {"40P01\/7":167} entries=1600 retries=0 errs_in_closure={"40P01\/7":167} hist={"1":1600} wall=103.82s server={"deadlocks":167} sum_ok=yes log=1433 ledger_ok=yes side=[]
[pgsql v3 run1] committed=1600/1600 failed=0 [] entries=1600 retries=0 errs_in_closure=[] hist={"1":1600} wall=4.88s server={"deadlocks":0} sum_ok=yes log=1600 ledger_ok=yes side=[]
[pgsql v3in run1] committed=1600/1600 failed=0 [] entries=1600 retries=0 errs_in_closure=[] hist={"1":1600} wall=6.06s server={"deadlocks":0} sum_ok=yes log=1600 ledger_ok=yes side=[]
[mysql v1 run1] committed=1545/1600 failed=55 {"40001\/1213":55} entries=1600 retries=0 errs_in_closure={"40001\/1213":55} hist={"1":1600} wall=9.49s server={"deadlocks":55,"timeouts":0} sum_ok=yes log=1545 ledger_ok=yes side=[]
[mysql v3 run1] committed=1600/1600 failed=0 [] entries=1600 retries=0 errs_in_closure=[] hist={"1":1600} wall=9.85s server={"deadlocks":0,"timeouts":0} sum_ok=yes log=1600 ledger_ok=yes side=[]
[mysql v3in run1] committed=1600/1600 failed=0 [] entries=1600 retries=0 errs_in_closure=[] hist={"1":1600} wall=14.01s server={"deadlocks":0,"timeouts":0} sum_ok=yes log=1600 ledger_ok=yes side=[]

3. Một câu lệnh cũng có thứ tự khoá, và plan (kế hoạch thực thi) quyết định thứ tự đó (P2). Hai câu UPDATE nhiều dòng, không có transaction nhiều bước nào: UPDATE ... WHERE id BETWEEN 1 AND 8 đi theo index (id tăng dần), còn UPDATE ... WHERE grp = 'hot' đi Seq Scan theo thứ tự heap (id 8 → 1, do lab cố tình insert ngược). Chúng gặp nhau ở giữa và deadlock 3/3 run, 5/5 burst khi không có sleep. Code ứng dụng không sai thứ tự ở đâu cả; plan sai. Cách sửa đã test là ép khoá theo thứ tự trong subquery:

UPDATE tmp_dl_items SET qty = qty - 1
 WHERE id IN (SELECT id FROM tmp_dl_items WHERE grp = 'hot' ORDER BY id FOR UPDATE);
Cách nói khi phỏng vấn: "Một câu UPDATE nhiều dòng không khoá tất cả cùng lúc. Nó khoá từng dòng theo thứ tự mà kế hoạch thực thi đi qua, và hai câu với điều kiện khác nhau có thể đi theo hai thứ tự ngược nhau. Tôi tách phần chọn dòng ra một truy vấn con có sắp xếp theo id và khoá luôn ở đó. Câu UPDATE bên ngoài chỉ sửa những dòng đã được khoá theo thứ tự cố định."
LockRows  (cost=499.13..499.23 rows=8 width=10)
  ->  Sort  (cost=499.13..499.15 rows=8 width=10)
        Sort Key: id
        ->  Seq Scan on tmp_dl_items  (cost=0.00..499.01 rows=8 width=10)
              Filter: (grp = 'hot'::text)
[t=+  0.20s][S1] > UPDATE tmp_dl_items SET qty = qty + 1 WHERE id IN (SELECT id FROM tmp_dl_items WHERE id BETWEEN 1 AND 8 ORDER BY id FOR UPDATE);
[t=+  0.30s][S2] > UPDATE tmp_dl_items SET qty = qty - 1 WHERE id IN (SELECT id FROM tmp_dl_items WHERE grp = 'hot' ORDER BY id FOR UPDATE);
[t=+  2.61s][S1]   UPDATE 8
[t=+  2.61s][S1]   (returned after 2.41s)
[t=+  5.03s][S2]   UPDATE 8
[t=+  5.03s][S2]   (returned after 4.73s)
CHECK ok  : no error: {'S1': 'ok', 'S2': 'ok'}; hot rows qty now = 0
runs with exactly one 40P01: 3/3
bursts that deadlocked: 5/5
FIX bursts that deadlocked: 0/5

EXPLAIN cho thấy Sort nằm dưới LockRows: dòng được sắp xếp trước khi khoá. S2 chờ toàn bộ S1 (4.73 s) thay vì deadlock. Burst 2000 dòng không sleep: 5/5 deadlock trước khi sửa, 0/5 sau khi sửa. Thứ tự NOTICE của câu UPDATE bên ngoài (8,7,1,5,...) không quan trọng: đó là lúc nó ghé lại những dòng subquery đã khoá theo id.

4. Batch upsert (chèn, hoặc cập nhật nếu key đã có): sắp xếp key trước khi gửi (P5-D, M7, incident.io). Một câu INSERT ... ON CONFLICT DO UPDATE hay INSERT ... ON DUPLICATE KEY UPDATE nhiều dòng khoá từng key theo thứ tự các dòng trong câu. Lab P5-D upsert 3000 key, một bên tăng dần, một bên giảm dần, không sleep: deadlock 3/3. Cả hai cùng ORDER BY key: 0/3.

INSERT INTO tmp_dl_cnt (k, n)
SELECT g::text, 1 FROM generate_series(1, 3000) g
 ORDER BY g::text
ON CONFLICT (k) DO UPDATE SET n = tmp_dl_cnt.n + 1;
Cách nói khi phỏng vấn: "Một lệnh upsert hàng loạt không khoá cả lô như một khối. Nó khoá từng khoá chính theo thứ tự các dòng trong lô. Tôi sắp lô theo đúng khoá trước khi gửi. Nếu khoá là chuỗi thì tôi sắp theo chuỗi, để mọi tiến trình đi cùng một thứ tự. Một bên sắp theo số còn một bên sắp theo chuỗi là đã có hai thứ tự khác nhau."
[t=+  0.30s][S1] > INSERT INTO tmp_dl_cnt (k, n) SELECT g::text, 1 FROM generate_series(1, 3000) g ORDER BY g::text ON CONFLICT (k) DO UPDATE SET n = tmp_dl_cnt.n + 1;
[t=+  0.30s][S2] > INSERT INTO tmp_dl_cnt (k, n) SELECT g::text, 1 FROM generate_series(1, 3000) g ORDER BY g::text ON CONFLICT (k) DO UPDATE SET n = tmp_dl_cnt.n + 1;
[t=+  0.49s][S2]   INSERT 0 3000
[t=+  0.79s][S1]   INSERT 0 3000
[t=+  0.79s][S1]   (returned after 0.49s)
D batch upsert asc vs desc: bursts with a 40P01: 3/3
D FIX both ascending: bursts with a 40P01: 0/3

MySQL cho cùng kết quả với một câu autocommit duy nhất. M7e/f chạy hai câu IODKU/REPLACE nhiều dòng ngược thứ tự liên tục trong 4 s. Bản sửa chỉ đổi thứ tự hai tuple trong VALUES:

== M7e multi-row IODKU in one statement, opposite row order  (2 runs x 4s, autocommit)
   A: INSERT INTO kv (k, v) VALUES (1, 1), (2, 1) ON DUPLICATE KEY UPDATE v = v + 1
   B: INSERT INTO kv (k, v) VALUES (2, 1), (1, 1) ON DUPLICATE KEY UPDATE v = v + 1
run 1: A ok=672 1213=6 other=0 | B ok=669 1213=7 other=0 | lock_deadlocks +13, lock_row_lock_waits +1366
run 2: A ok=584 1213=7 other=0 | B ok=582 1213=5 other=0 | lock_deadlocks +12, lock_row_lock_waits +1186
== M7f multi-row REPLACE in one statement, opposite row order  (2 runs x 4s, autocommit)
   A: REPLACE INTO kv VALUES (1, 10), (2, 20)
   B: REPLACE INTO kv VALUES (2, 20), (1, 10)
run 1: A ok=1607 1213=53 other=0 | B ok=1584 1213=60 other=0 | lock_deadlocks +113, lock_row_lock_waits +293
run 2: A ok=672 1213=21 other=0 | B ok=670 1213=23 other=0 | lock_deadlocks +44, lock_row_lock_waits +106
== M7-FIX multi-row IODKU, both statements with rows sorted by key  (2 runs x 4s, autocommit)
   A: INSERT INTO kv (k, v) VALUES (1, 1), (2, 1) ON DUPLICATE KEY UPDATE v = v + 1
   B: INSERT INTO kv (k, v) VALUES (1, 1), (2, 1) ON DUPLICATE KEY UPDATE v = v + 1
run 1: A ok=529 1213=0 other=0 | B ok=528 1213=0 other=0 | lock_deadlocks +0, lock_row_lock_waits +1054
run 2: A ok=400 1213=0 other=0 | B ok=401 1213=0 other=0 | lock_deadlocks +0, lock_row_lock_waits +798

13 và 12 deadlock (IODKU), 113 và 44 (REPLACE) mỗi run 4 s. Bản IODKU sắp xếp cho 0 và 0; bản REPLACE sắp xếp không được test riêng. Đây đúng là incident của incident.io (research §5): hai lần chạy của cùng một code deadlock với nhau trên một bulk upsert, vì các dòng "were ordered randomly, having been placed in a map before being converted back into a slice". Bài học họ rút ra: "Postgres acquires locks individually and sequentially during a bulk insert". Fix của họ là sắp xếp trước khi upsert. Bài học cho code PHP/Laravel: nếu batch được gom qua một map hay associative array ở tầng app, hãy sort tường minh theo key ngay trước khi dựng câu lệnh, đừng giả định thứ tự.

5. Advisory lock cũng phải theo thứ tự (P7). pg_advisory_xact_lock(1) rồi (2) ở một bên, ngược lại ở bên kia: 40P01 3/3, vì advisory lock đi qua cùng lock manager và cùng deadlock detector. Sắp key: 2/2 không lỗi.

xact-level opposite order: runs with a 40P01: 3/3
xact-level FIX (sorted keys) run 1: {'S1': 'ok', 'S2': 'ok'}
xact-level FIX (sorted keys) run 2: {'S1': 'ok', 'S2': 'ok'}

Trade-off và lúc không dùng. (a) Thứ tự cố định chỉ có tác dụng khi mọi code path tuân theo. Chỉ cần một job cũ khoá ngược là vòng quay lại, nên quy ước phải sống ở một chỗ (một hàm, một repository method), không phải trong đầu từng người. Với nhiều bảng, cần quy ước giữa các bảng (ví dụ: luôn khoá bảng cha trước bảng con, bảng orders trước payments). (b) Deadlock biến thành chờ: S2 ở P1 chờ 1.61 s, ở P2 chờ 4.73 s. Latency của đường nóng tăng theo độ dài transaction của người đi trước. (c) Khoá mọi thứ ngay từ đầu nghĩa là giữ khoá lâu hơn, từ câu đầu tới COMMIT. (d) Không dùng được khi transaction không biết trước tập dòng sẽ chạm (dòng tìm ra giữa chừng, theo kết quả câu trước). Lúc đó cần khoá thô hơn (6.7) hoặc chấp nhận retry. (e) Sắp id không giải quyết thứ tự giữa các index của cùng một dòng trong InnoDB (M6): đó là chuyện của 6.3.

6.2. Lấy khoá mạnh ngay từ đầu

1. Nâng khoá từ chia sẻ lên độc quyền (P3). Hai session cùng SELECT ... FOR SHARE một dòng tồn kho (đọc để kiểm tra, tưởng là "khoá nhẹ"), rồi cùng UPDATE dòng đó. Mỗi bên muốn nâng lên khoá ghi nhưng phải chờ bên kia nhả khoá chia sẻ, nên deadlock 3/3. pgrowlocks cho thấy hai khoá chia sẻ được lưu chung thành một MultiXact (nhóm transaction cùng giữ một dòng). DETAIL của lỗi giống hệt P1 (ShareLock on transaction), chỉ server log, với cùng một câu UPDATE trên cùng một dòng ở cả hai bên, mới lộ ra đây là nâng khoá. Bản sửa: lấy khoá ghi ngay từ câu đọc.

BEGIN;
SELECT qty FROM tmp_dl_stock WHERE id = 1 FOR NO KEY UPDATE;   -- hoặc FOR UPDATE
UPDATE tmp_dl_stock SET qty = qty - 1 WHERE id = 1;
COMMIT;
Cách nói khi phỏng vấn: "Nếu đọc một dòng để rồi sửa nó, tôi khoá ở mức ghi ngay lúc đọc chứ không khoá chia sẻ rồi nâng cấp sau. Hai bên cùng cầm khoá chia sẻ rồi cùng muốn nâng lên khoá ghi là deadlock kinh điển, vì bên nào cũng chờ bên kia buông. Trên PostgreSQL tôi ưu tiên mức khoá ghi 'không đụng khoá chính' nếu không xoá dòng và không sửa cột khoá, vì mức đó không chặn các bảng con đang chèn bản ghi tham chiếu tới dòng này."
FOR SHARE run 1: {'S1': 'error 40P01', 'S2': 'ok'} final qty=9
FOR SHARE run 2: {'S1': 'error 40P01', 'S2': 'ok'} final qty=9
FOR SHARE run 3: {'S1': 'error 40P01', 'S2': 'ok'} final qty=9
FOR SHARE runs with exactly one 40P01: 3/3
FOR UPDATE run 1: {'S1': 'ok', 'S2': 'ok'} final qty=8
FOR UPDATE run 2: {'S1': 'ok', 'S2': 'ok'} final qty=8
FOR NO KEY UPDATE run 1: {'S1': 'ok', 'S2': 'ok'} final qty=8
FOR NO KEY UPDATE run 2: {'S1': 'ok', 'S2': 'ok'} final qty=8

Hai bản sửa đều 2/2, tồn kho 10 → 8 (cả hai đơn được trừ). Trong PostgreSQL nên chọn FOR NO KEY UPDATE thay vì FOR UPDATE khi không xoá dòng và không đổi cột khoá. Lý do: FK check của bảng con lấy FOR KEY SHARE trên dòng cha, mà FOR KEY SHARE chỉ xung đột với FOR UPDATE. Cybertec viết: "Unless you plan to delete a row or modify a key column, you should use SELECT FOR NO KEY UPDATE. That way you won't block concurrent INSERTs of a referencing row." InnoDB không có mức trung gian này: khoá dòng chỉ có S và X.

2. Foreign key: bảng con khoá ngầm dòng cha (P4, M5). Bối cảnh (ví dụ minh hoạ): ghi danh sinh viên vào khoá học, mỗi lần insert một dòng enrolment rồi tăng course.enrolled (counter cache, cột đếm lưu sẵn). Hai DB khác nhau ở chỗ này. Bảng tóm tắt từ hai lab:

Kịch bảnPostgreSQL 18.4MySQL 8.4.13
Hai session insert con rồi UPDATE cột không phải khoá của cha 0/3 deadlock (P4-A): FK lấy FOR KEY SHARE, UPDATE không đụng khoá lấy FOR NO KEY UPDATE, hai mức này không xung đột 1213 3/3 (M5): insert con lấy S,REC_NOT_GAP trên dòng cha, hai bên cùng muốn nâng lên X
Như trên nhưng UPDATE cột UNIQUE của cha, hoặc DELETE cha 40P01 3/3 (P4-B), 2/2 (P4-C)(không test riêng)
Khoá cha FOR UPDATE trước khi insert con0/2 (P4-B FIX)0/3 (M5-FIX1)
UPDATE cha trước, insert con sau(không test)0/3 (M5-FIX2)
A non-key UPDATE: runs with a 40P01: 0/3
B key UPDATE: runs with a 40P01: 3/3
C DELETE parent: runs with a 40P01: 2/2
B FIX parent FOR UPDATE first: runs with a 40P01: 0/2

Bản sửa M5-FIX1 trên MySQL, run 1/3:

[t=+ 0.20s][S1] SELECT id, enrolled FROM course WHERE id = 1 FOR UPDATE
[t=+ 0.40s][S2] SELECT id, enrolled FROM course WHERE id = 1 FOR UPDATE   -- waits (no S lock held yet)
[t=+ 0.70s][MON]    | waiting_trx | blocking_trx | waiting_mode  | waiting_data | blocking_mode |
[t=+ 0.70s][MON]    | 3318        | 3317         | X,REC_NOT_GAP | 1            | X,REC_NOT_GAP |
[t=+ 0.90s][S1] INSERT INTO enrolment (course_id, student) VALUES (1, 'an')
[t=+ 1.00s][S1] UPDATE course SET enrolled = enrolled + 1 WHERE id = 1
[t=+ 1.20s][S1] COMMIT
[t=+ 1.21s][S2]    | id | enrolled |
[t=+ 1.21s][S2]    | 1  | 1        |
[t=+ 1.21s][S2]   -> 1 row(s) (0.81s)
[t=+ 1.81s][MON]    | id | enrolled | real_children |
[t=+ 1.81s][MON]    | 1  | 2        | 2             |

Ở bản sửa MySQL, S2 chờ X,REC_NOT_GAP trên dòng cha trước khi giữ khoá S nào, nên không có gì để nâng cấp. Kết quả enrolled = real_children = 2. Cùng ý tưởng với P3: khoá mạnh nhất mình sẽ cần, ngay lần đầu chạm vào đối tượng.

3. Khoá bảng cũng vậy (P6-A). SELECT count(*) (AccessShareLock) rồi ALTER TABLE (AccessExclusiveLock) trong cùng transaction, ở hai session: 40P01 ngay lập tức. Sửa bằng LOCK TABLE ... IN ACCESS EXCLUSIVE MODE làm câu đầu tiên. Chi tiết ở 6.8.

Trade-off và lúc không dùng. Khoá mạnh sớm làm giảm concurrency: những người lẽ ra chỉ cần đọc chung giờ phải xếp hàng (P3-FIX: S2 chờ ngay ở câu SELECT). Ở M5, khoá dòng course trước nghĩa là mọi lượt ghi danh vào cùng một khoá học đi qua một hàng đợi duy nhất. Lúc mở đăng ký một môn hot, dòng đó thành hot row (dòng nóng, nơi mọi request cùng tranh). Khi counter cache là nguồn tranh chấp, phương án đáng cân nhắc là bỏ hẳn nó: đếm lúc đọc, hoặc cộng dồn bất đồng bộ. Đó là suy luận thiết kế, chưa verify trong lab. Không nên khoá mạnh sớm khi phần lớn transaction chỉ đọc và chỉ một phần nhỏ thực sự ghi: lúc đó optimistic locking (xem bài optimistic locking) hợp hơn.

6.3. Index đúng để khoá ít dòng hơn

1. Thiếu index thì InnoDB khoá mọi dòng nó quét (M4). Manual 8.4: "A locking read, an UPDATE, or a DELETE generally set record locks on every index record that is scanned ... It does not matter whether there are WHERE conditions in the statement that would exclude the row." Lab: bảng orders 200 dòng, mỗi customer_id có 10 đơn, không có index trên customer_id:

[t=+ 0.00s][MON]    | id | select_type | table  | partitions | type | possible_keys | key  | key_len | ref  | rows | filtered | Extra       |
[t=+ 0.00s][MON]    | 1  | SIMPLE      | orders | NULL       | ALL  | NULL          | NULL | NULL    | NULL | 200  | 10.0     | Using where |
[t=+ 0.20s][S1] UPDATE orders SET status = 'paid' WHERE customer_id = 1   -- 10 rows match
[t=+ 0.20s][S1]   -> OK, 10 row(s) affected (0.00s)
[t=+ 0.50s][MON]    | session | type   | mode | status  | n   |
[t=+ 0.50s][MON]    | S1      | RECORD | X    | GRANTED | 201 |
[t=+ 0.70s][S2] UPDATE orders SET status = 'paid' WHERE customer_id = 2   -- a DIFFERENT customer
[t=+ 1.21s][MON]    | session | type   | mode | status  | n   |
[t=+ 1.21s][MON]    | S1      | RECORD | X    | GRANTED | 201 |
[t=+ 1.21s][MON]    | S1      | TABLE  | IX   | GRANTED | 1   |
[t=+ 1.21s][MON]    | S2      | RECORD | X    | WAITING | 1   |
[t=+ 1.61s][S2]   -> OK, 10 row(s) affected (0.91s)

Sửa 10 dòng nhưng giữ 201 next-key lock (khoá dòng + khoảng trống trước nó): 200 dòng + supremum. S2 sửa khách hàng khác vẫn bị chặn 0.91 s. Với một thứ tự khéo léo (M4b) thì thành deadlock 3/3. Thêm index:

ALTER TABLE orders ADD INDEX idx_customer (customer_id);
Cách nói khi phỏng vấn: "InnoDB khoá theo những bản ghi index mà nó quét qua, không theo những dòng thực sự khớp điều kiện. Nếu không có index cho cột trong mệnh đề WHERE, nó quét cả bảng và khoá cả bảng, nên hai request về hai khách hàng khác nhau vẫn chặn nhau. Thêm index đúng cột thì nó chỉ quét và khoá đúng mười dòng cần sửa."
[t=+ 0.00s][MON]    | id | select_type | table  | partitions | type | possible_keys | key          | key_len | ref   | rows | filtered | Extra |
[t=+ 0.00s][MON]    | 1  | SIMPLE      | orders | NULL       | ref  | idx_customer  | idx_customer | 4       | const | 10   | 100.0    | NULL  |
[t=+ 0.20s][S1] UPDATE orders SET status = 'paid' WHERE customer_id = 1   -- 10 rows match
[t=+ 0.20s][S1]   -> OK, 10 row(s) affected (0.00s)
[t=+ 0.50s][MON]    | session | type   | mode          | status  | n  |
[t=+ 0.50s][MON]    | S1      | RECORD | X             | GRANTED | 10 |
[t=+ 0.50s][MON]    | S1      | RECORD | X,GAP         | GRANTED | 1  |
[t=+ 0.50s][MON]    | S1      | RECORD | X,REC_NOT_GAP | GRANTED | 10 |
[t=+ 0.50s][MON]    | S1      | TABLE  | IX            | GRANTED | 1  |
[t=+ 0.70s][S2] UPDATE orders SET status = 'paid' WHERE customer_id = 2   -- a DIFFERENT customer
[t=+ 0.70s][S2]   -> OK, 10 row(s) affected (0.00s)

EXPLAIN chuyển từ ALL (200 dòng) sang ref idx_customer (10 dòng). S1 giữ 10 next-key trên idx_customer, 1 gap lock, 10 record lock trên PRIMARY. S2 chạy trong (0.00s), bản deadlock 0/3.

2. Hai đường tới cùng một dòng: secondary index vs PRIMARY (M6). Câu UPDATE t SET v = v + 1 WHERE k = 10 khoá entry trong idx_k trước rồi mới khoá dòng trong PRIMARY. Câu UPDATE t SET k = 11 WHERE id = 1 khoá PRIMARY trước rồi phải sửa entry trong idx_k. Hai thứ tự ngược nhau trên cùng một dòng: 1213 3/3. Ngay cả hai câu autocommit đơn lẻ cũng deadlock (M6b: 11 và 13 lần trong 4 s). Bản sửa đã test cho dạng hai câu: tìm PK bằng một lần đọc không khoá, rồi UPDATE theo PK và kiểm tra lại điều kiện:

SELECT id FROM t WHERE k = 10;                       -- đọc thường, không khoá
UPDATE t SET v = v + 1 WHERE id = 1 AND k = 10;      -- khoá qua PRIMARY như bên kia, kiểm tra lại k
Cách nói khi phỏng vấn: "Trong InnoDB một dòng có thể bị khoá qua hai đường: qua index phụ rồi mới tới khoá chính, hoặc qua khoá chính trước. Hai câu đi hai đường ngược nhau thì deadlock ngay trên một dòng. Tôi cho mọi câu ghi đi chung một đường: đọc không khoá để lấy khoá chính, rồi cập nhật theo khoá chính và lặp lại điều kiện cũ. Nếu số dòng bị ảnh hưởng là không nghĩa là dòng đã đổi trong lúc đó, và ứng dụng phải xử lý trường hợp này."
[t=+ 0.20s][S2] SELECT id, k FROM t WHERE id = 1 FOR UPDATE
[t=+ 0.40s][S1] SELECT id FROM t WHERE k = 10   -- A: plain (non-locking) read to find the PK
[t=+ 0.41s][S1]    | id |
[t=+ 0.41s][S1]    | 1  |
[t=+ 0.52s][S1] UPDATE t SET v = v + 1 WHERE id = 1 AND k = 10   -- A: lock by PRIMARY, same path as B (waits)
[t=+ 1.20s][S2] UPDATE t SET k = 11 WHERE id = 1
[t=+ 1.20s][S2]   -> OK, 1 row(s) affected (0.00s)
[t=+ 1.60s][S1]   -> OK, 0 row(s) affected (1.08s)
RUN 1: outcome {'S1': 'no error', 'S2': 'no error'} | INNODB_METRICS delta: lock_deadlocks +0, lock_timeouts +0

0/3 deadlock. Nhưng chú ý dòng -> OK, 0 row(s) affected (1.08s): S1 chờ S2, và khi được chạy thì k đã thành 11, điều kiện k = 10 trượt, nên không sửa dòng nào. Đó là đúng ngữ nghĩa, nhưng app phải kiểm tra số dòng bị ảnh hưởng thay vì coi "không lỗi" là "đã sửa". Với dạng một câu autocommit (M6b) lab không có bản sửa SQL; ở đó retry là cách xử lý.

3. Dữ liệu và index phình cũng là nguyên nhân. Báo cáo availability tháng 9/2026 của GitHub (research §5): cluster issues/pull requests xoá khoảng 980 triệu dòng edit-history không cần thiết, và "Weekly deadlocks fell approximately 77% from their June peak". Báo cáo đặt hai sự kiện cạnh nhau mà không giải thích cơ chế. Cách đọc hợp lý (suy luận): quét ít hơn thì khoá ít hơn, transaction ngắn hơn, nên cơ hội chồng lên nhau ít hơn. Hai ticket Moodle cùng loại (chi tiết ở mục 8): MDL-79639 DELETE ... WHERE analysisid IN (SELECT ...) quét và khoá cả index, sửa bằng cách chọn id trước rồi xoá; MDL-63732 xin thêm index (modulename, instance, eventtype) cho bảng event.

Trade-off và lúc không dùng. Mỗi index thêm chi phí ghi và dung lượng, và quan trọng hơn ở đây, mỗi secondary index là thêm một đường khoá tới cùng một dòng, tức là thêm cơ hội cho M6. Đừng thêm index "cho chắc" trên cột bị UPDATE thường xuyên nếu không có câu ghi nào cần nó. Gap/next-key lock là cơ chế của InnoDB (so sánh với PG ở bài PostgreSQL vs MySQL, mục 4.4), nên tác động của index lên số khoá chủ yếu là chuyện của MySQL. Lab PG không có case tương ứng với M4, nên phía PG chưa verify. Một bẫy cuối: READ COMMITTED làm M4 hết chặn mà không cần index (6.5), nhưng câu lệnh vẫn quét cả bảng.

6.4. Transaction ngắn, không gọi mạng/I/O bên trong

Xác suất hai transaction chồng nhau tỉ lệ với thời gian chúng giữ khoá. Docs PG 18: "it is a bad idea for applications to hold transactions open for long periods of time (e.g., while waiting for user input)". Manual MySQL: "Keep transactions small and short in duration to make them less prone to collision." Lab cố tình chèn khoảng hở giữa hai lần lấy khoá để tái hiện ổn định (P1: 0.4 s; app lab: usleep 1–5 ms). Không có khoảng hở thì deadlock vẫn xảy ra (P2 burst không sleep 5/5, M7e một câu autocommit), nhưng hở càng dài thì cửa sổ chồng lấn càng rộng. Trong production, khoảng hở đó thường là một HTTP call, một lần đẩy queue, một lần render template, hay một vòng lặp N+1 query nằm giữa transaction.

Bằng chứng từ lab về giá của transaction "ngồi chơi" đã có ở mục 5: S1 idle in transaction chặn S2 ở 5.2, blocking_query: None ở 5.3, và ở P6-B một transaction đọc đứng yên đã chặn cả một ALTER TABLE lẫn mọi câu SELECT phía sau nó (6.8). Ngoài lab, incident của Sentry (research §5, vendor blog) là hai lần downtime nhỏ vì tranh chấp khoá, không phải deadlock được phát hiện: code billing giữ khoá trên một connection riêng hàng phút, và câu hỏi điều tra của họ là "why do 7 minutes elapse before the UPDATE is issued after the SAVEPOINT is taken?". Phía Moodle, MDL-86422 là trường hợp hook chạy khi transaction nộp bài quiz còn mở ("can lead to potential issues under high concurrency, particularly during simultaneous quiz submissions"); chi tiết ở mục 8.

Quy tắc thực hành, theo thứ tự ưu tiên:

  1. Đọc và tính toán xong trước BEGIN. Transaction chỉ bao các câu ghi và các câu khoá thật cần thiết.

  2. Không gọi mạng trong transaction. Email, webhook, đẩy job là side effect (tác dụng ra ngoài DB, rollback không thu hồi được): đăng ký chạy sau commit. Laravel có DB::afterCommit(); lab app (biến thể v5/v5fix, phân tích ở mục 7) cho thấy side effect đặt trong closure bị lặp lại theo mỗi lần retry, còn bản afterCommit thì không.

  3. Không giữ transaction qua nhiều request. Bước "người dùng suy nghĩ" không bao giờ nằm trong transaction.

  4. Đặt lưới an toàn ở DB. PG 18 có idle_in_transaction_session_timeout ("ensure that idle sessions do not hold locks for an unreasonable amount of time") và transaction_timeout, cả hai mặc định 0. Docs là nguồn duy nhất ở đây: lab chưa verify hành vi của hai biến này. Phía MySQL, research chỉ ghi nhận innodb_lock_wait_timeout per-session, là lưới an toàn cho người chờ. Research không ghi nhận biến nào giới hạn thời gian của kẻ giữ khoá; phần này chưa verify.

Trade-off và lúc không dùng. Cắt một transaction lớn thành nhiều transaction nhỏ là mất tính nguyên tử. Giữa hai transaction, người khác có thể thấy trạng thái nửa vời, và nếu bước 2 hỏng thì bước 1 đã commit. Muốn an toàn phải có idempotency (chạy lặp vẫn ra cùng kết quả) hoặc outbox (ghi "việc cần làm" vào cùng transaction rồi xử lý sau). Đẩy side effect ra sau commit cũng đổi bài toán: giờ phải xử lý trường hợp "đã commit nhưng email gửi lỗi". Không áp dụng máy móc cho batch job: chia batch thành nhiều transaction nhỏ theo thứ tự key (research §6.8) là đúng, nhưng chia tới mức một dòng một transaction thì chi phí commit sẽ chiếm phần lớn.

6.5. Isolation level: READ COMMITTED có phải thuốc chữa?

PostgreSQL: không áp dụng. Mặc định của PG đã là READ COMMITTED, và toàn bộ case P1–P7 ở mục 2 đều deadlock ở mức đó. Hạ isolation level không có gì để hạ. Nâng lên REPEATABLE READ / SERIALIZABLE thì thêm một loại lỗi khác (40001 serialization failure, mục 7), không bớt deadlock.

MySQL: có, với một nhóm case cụ thể. REPEATABLE READ là mặc định của InnoDB và dùng next-key lock cho search và scan. READ COMMITTED tắt gap lock cho search/scan ("is used only for foreign-key constraint checking and duplicate-key checking") và nhả khoá trên dòng không khớp. Lab cho thấy ranh giới rất rõ:

CaseREPEATABLE READREAD COMMITTEDVì sao
M2 check-then-insert, cùng key 151213 3/31062 3/3, 0 deadlockKhông còn gap lock để hai bên cùng giữ
M2 khác key (15 và 16) cùng một gap1213 3/3cả hai commit 2/2Như trên
M3 ba session insert cùng key, S1 rollback1213 3/3vẫn 1213 3/3Khoá của duplicate-key check vẫn còn ở RC
M4 thiếu indexchặn, 1213 3/3không chặn, 0/2Nhả khoá dòng không khớp + semi-consistent read; vẫn quét cả bảng
SET SESSION TRANSACTION ISOLATION LEVEL READ COMMITTED;   -- câu Moodle chạy ở mỗi begin_transaction()
Cách nói khi phỏng vấn: "Trên MySQL, chuyển session sang READ COMMITTED làm biến mất cả nhóm deadlock do khoá khoảng trống, vì InnoDB không còn khoá khoảng trống khi tìm kiếm và quét. Nó không cứu được deadlock khi nhiều session chèn trùng khoá, vì phần kiểm tra trùng khoá vẫn khoá ở mức này. Đổi lại, ứng dụng chấp nhận phantom row, và binlog bắt buộc phải ở dạng ROW. Moodle là ví dụ thật: driver MySQL của nó chạy câu này mỗi khi mở transaction."

M2c dưới READ COMMITTED: sau hai câu SELECT ... FOR UPDATE tìm id 15 không tồn tại, data_locks chỉ còn hai khoá bảng IX, không có gap lock. S2 chờ insert của S1 rồi nhận 1062:

[t=+ 0.20s][S1] SELECT * FROM slots WHERE id = 15 FOR UPDATE   -- not found -> 'safe to insert'
[t=+ 0.40s][S2] SELECT * FROM slots WHERE id = 15 FOR UPDATE   -- not found either
[t=+ 0.70s][MON] data_locks after both SELECT ... FOR UPDATE (session = connection mapped to tag):
[t=+ 0.70s][MON]    | session | trx  | tbl   | idx  | type  | mode | status  | data |
[t=+ 0.70s][MON]    | S1      | 2129 | slots | NULL | TABLE | IX   | GRANTED | NULL |
[t=+ 0.70s][MON]    | S2      | 2130 | slots | NULL | TABLE | IX   | GRANTED | NULL |
[t=+ 1.00s][S1] INSERT INTO slots (id, who) VALUES (15, 'S1')
[t=+ 1.00s][S1]   -> OK, 1 row(s) affected (0.00s)
[t=+ 1.80s][S2] INSERT INTO slots (id, who) VALUES (15, 'S2')
[t=+ 2.60s][S1] COMMIT
[t=+ 2.60s][S2]   -> ERROR 1062 (23000): Duplicate entry '15' for key 'slots.PRIMARY'  (0.80s)
[t=+ 2.61s][S2]      server view: SERVER_STATUS_IN_TRANS=True; INNODB_TRX: trx_id=2130 state=RUNNING rows_modified=0 rows_locked=1

Còn M3 dưới READ COMMITTED thì vẫn deadlock như cũ:

SUMMARY [M3d same as M3a under READ COMMITTED]: ERROR 1213 seen in 3/3 runs; outcomes per run: [{'S1': 'no error', 'S2': 'no error', 'S3': '1213'}, {'S1': 'no error', 'S2': '1213', 'S3': 'no error'}, {'S1': 'no error', 'S2': '1213', 'S3': 'no error'}]

Và M4c, thiếu index nhưng READ COMMITTED: S1 chỉ còn giữ 10 record lock thay vì 201, S2 không chờ nhờ semi-consistent read (InnoDB trả bản đã commit mới nhất để UPDATE tự kiểm tra điều kiện WHERE thay vì chờ khoá). Vấn đề bị che, chưa được sửa: EXPLAIN vẫn là ALL.

[t=+ 0.02s][MON]    | 1  | SIMPLE      | orders | NULL       | ALL  | NULL          | NULL | NULL    | NULL | 200  | 10.0     | Using where |
[t=+ 0.20s][S1] UPDATE orders SET status = 'paid' WHERE customer_id = 1   -- 10 rows match
[t=+ 0.20s][S1]   -> OK, 10 row(s) affected (0.00s)
[t=+ 0.50s][MON]    | session | type   | mode          | status  | n  |
[t=+ 0.50s][MON]    | S1      | RECORD | X,REC_NOT_GAP | GRANTED | 10 |
[t=+ 0.74s][S2] UPDATE orders SET status = 'paid' WHERE customer_id = 2   -- a DIFFERENT customer
[t=+ 0.75s][S2]   -> OK, 10 row(s) affected (0.01s)

Hai trang manual nói ngược nhau. innodb-deadlocks.html: "The possibility of deadlocks is not affected by the isolation level, because the isolation level changes the behavior of read operations, while deadlocks occur because of write operations." Trang innodb-deadlocks-handling.html lại khuyên "try using a lower isolation level such as READ COMMITTED", và trang isolation level viết RC "greatly reduces the probability of deadlocks, but they can still happen". Lab ủng hộ cách đọc thứ hai, có giới hạn: khả năng deadlock còn ở mọi mức (M3 vẫn 3/3 ở RC), nhưng tần suất giảm mạnh với case gap lock. Câu đúng để nói: "giảm xác suất, không loại bỏ".

Trade-off và lúc không dùng. (a) Phantom (dòng mới "hiện ra" giữa hai lần đọc trong cùng transaction): "Because gap locking is disabled, phantom row problems may occur". Logic kiểu "đếm số chỗ còn trống rồi insert" có thể vượt quota nếu chỉ dựa vào gap lock để chặn; ràng buộc phải chuyển sang UNIQUE constraint hoặc khoá tường minh. (b) Binlog (binary log, nguồn của replication): "Only row-based binary logging is supported with the READ COMMITTED isolation level". 8.4 mặc định ROW, MIXED tự chuyển ROW, còn STATEMENT với RC thì không chạy được. (c) INSERT ... SELECT ở RC đọc nguồn bằng consistent read, không khoá. (d) Ở RC, lỗi 1062 vẫn giữ transaction mở và giữ khoá (rows_locked=1 ở dòng trên), nên app phải xử lý tiếp chứ không phải "đã xong". Không nên chuyển cả server sang RC chỉ vì một endpoint bị gap-lock deadlock; đặt ở mức session cho đúng đường code đó (như Moodle làm) thì phạm vi ảnh hưởng nhỏ hơn. Lab gap lock chi tiết hơn nằm ở bài PostgreSQL vs MySQL, mục 4.4.

6.6. Insert trước, xử lý trùng sau

1. Bỏ "kiểm tra rồi chèn" (M2). Pattern sinh deadlock: SELECT ... WHERE id = 15 FOR UPDATE thấy không có, tưởng đã "giữ chỗ", rồi INSERT. Ở REPEATABLE READ, cả hai session cùng được cấp gap lock trên khoảng trống quanh 15, rồi insert intention (khoá ý định chèn) của mỗi bên chờ gap lock của bên kia. Cách sửa là để UNIQUE constraint làm trọng tài: cứ chèn, bên thua nhận lỗi trùng và xử lý. Hai bản đã test, đều ở REPEATABLE READ:

-- FIX1: chèn trước, bắt 1062 rồi đọc bản ghi của người thắng
INSERT INTO slots (id, who) VALUES (15, 'S2');          -- 1062 nếu đã có
SELECT * FROM slots WHERE id = 15 FOR UPDATE;           -- chỉ chạy khi nhận 1062

-- FIX2: để DB gộp hai bước
INSERT INTO slots (id, who) VALUES (15, 'S2') ON DUPLICATE KEY UPDATE cnt = cnt + 1;
Cách nói khi phỏng vấn: "Thay vì hỏi trước xem bản ghi có chưa rồi mới chèn, tôi cứ chèn và để ràng buộc duy nhất quyết định ai thắng. Bên thua nhận lỗi trùng khoá, rồi đọc bản ghi của bên thắng để xử lý tiếp. Nếu nghiệp vụ cho phép gộp thì tôi dùng luôn câu chèn kèm cập nhật khi trùng. Cách này bỏ hẳn bước kiểm tra, mà chính bước kiểm tra là chỗ tạo ra khoá khoảng trống gây deadlock."
[t=+ 0.20s][S1] INSERT INTO slots (id, who) VALUES (15, 'S1')   -- just try the insert
[t=+ 0.20s][S1]   -> OK, 1 row(s) affected (0.00s)
[t=+ 0.50s][S2] INSERT INTO slots (id, who) VALUES (15, 'S2')   -- same key: waits on S1's uncommitted row
[t=+ 0.90s][MON]    | S2      | 2248 | slots | PRIMARY | RECORD | S,REC_NOT_GAP | WAITING | 15   |
[t=+ 0.91s][MON]    | 2248        | 2247         | S,REC_NOT_GAP | 15           | X,REC_NOT_GAP |
[t=+ 1.22s][S2]   -> ERROR 1062 (23000): Duplicate entry '15' for key 'slots.PRIMARY'  (0.71s)
[t=+ 1.60s][S2] SELECT * FROM slots WHERE id = 15 FOR UPDATE   -- app handles 1062: read/lock the winner's row
[t=+ 1.60s][S2]    | id | who | cnt |
[t=+ 1.60s][S2]    | 15 | S1  | 1   |
[t=+ 0.20s][S1] INSERT INTO slots (id, who) VALUES (15, 'S1') ON DUPLICATE KEY UPDATE cnt = cnt + 1
[t=+ 0.50s][S2] INSERT INTO slots (id, who) VALUES (15, 'S2') ON DUPLICATE KEY UPDATE cnt = cnt + 1
[t=+ 0.90s][MON]    | S2      | 2319 | slots | PRIMARY | RECORD | X,REC_NOT_GAP | WAITING | 15   |
[t=+ 1.20s][S2]   -> OK, 2 row(s) affected (0.70s)

FIX1: S2 chờ với S,REC_NOT_GAP trên dòng mới của S1, nhận 1062 khi S1 commit, rồi đọc được bản ghi 15 | S1; 0 deadlock 3/3. FIX2: S2 chờ với X,REC_NOT_GAP rồi 2 row(s) affected (tức là đi nhánh UPDATE), dòng cuối cnt = 2; 0 deadlock 3/3. Ở PostgreSQL, P5-A (insert A, B ở một bên, B, A ở bên kia) được sửa bằng chèn theo thứ tự key + ON CONFLICT DO NOTHING, 2/2.

2. Ba session cùng chèn một key: PG không deadlock, MySQL có (P5-B, M3). S1 chèn key và chưa commit, S2 và S3 chèn cùng key và xếp hàng, rồi S1 ROLLBACK. Ở PostgreSQL:

[t=+  0.20s][S1] > INSERT INTO tmp_dl_u VALUES ('k', 'S1');
[t=+  0.20s][S1]   INSERT 0 1
[t=+  0.40s][S2] > INSERT INTO tmp_dl_u VALUES ('k', 'S2');
[t=+  0.60s][S3] > INSERT INTO tmp_dl_u VALUES ('k', 'S3');
[t=+  1.01s][MON]    S1  | 2051269 | Client          | ClientRead    | {}         | INSERT INTO tmp_dl_u VALUES ('k', 'S1');
[t=+  1.01s][MON]    S2  | 2051276 | Lock            | transactionid | {2051269}  | INSERT INTO tmp_dl_u VALUES ('k', 'S2');
[t=+  1.01s][MON]    S3  | 2051283 | Lock            | transactionid | {2051269}  | INSERT INTO tmp_dl_u VALUES ('k', 'S3');
[t=+  2.00s][S1] > ROLLBACK;
[t=+  2.00s][S1]   ROLLBACK
[t=+  2.00s][S2]   INSERT 0 1
[t=+  2.00s][S2]   (returned after 1.60s)
after S1 ROLLBACK: still waiting = ['S3']
[t=+  2.51s][MON]    S2  | Client          | ClientRead    | {}
[t=+  2.51s][MON]    S3  | Lock            | transactionid | {2051276}
[t=+  3.50s][S2] > COMMIT;
[t=+  3.50s][S2]   COMMIT
[t=+  3.50s][S3]   ERROR:  23505: duplicate key value violates unique constraint "tmp_dl_u_pkey"
[t=+  3.50s][S3]   DETAIL:  Key (k)=(k) already exists.

Sau khi S1 rollback, đúng một trong S2/S3 chèn được (ở run này là S2; ba run cho S2, S2, S3, tức là ngẫu nhiên), bên còn lại chuyển sang chờ transaction của người thắng (blocked_by đổi từ pid của S1 sang pid của S2), rồi nhận 23505 khi người thắng commit. 0/3 deadlock. MySQL với đúng hình dạng đó thì deadlock: hai waiter cùng giữ khoá S (khoá chia sẻ do duplicate-key check đặt) và cùng muốn X.

[t=+ 1.31s][MON]    | S1      | 2392 | t   | PRIMARY | RECORD | X,REC_NOT_GAP | GRANTED | 5    |
[t=+ 1.31s][MON]    | S2      | 2393 | t   | PRIMARY | RECORD | S,REC_NOT_GAP | WAITING | 5    |
[t=+ 1.31s][MON]    | S3      | 2394 | t   | PRIMARY | RECORD | S,REC_NOT_GAP | WAITING | 5    |
[t=+ 1.80s][S1] ROLLBACK
[t=+ 1.80s][S3]   -> ERROR 1213 (40001): Deadlock found when trying to get lock; try restarting transaction  (1.00s)
[t=+ 1.80s][S2]   -> OK, 1 row(s) affected (1.30s)
SUMMARY [M3-FIX? same as M3a but all three use INSERT ... ON DUPLICATE KEY UPDATE]: ERROR 1213 seen in 3/3 runs; outcomes per run: [{'S1': 'no error', 'S2': 'no error', 'S3': '1213'}, {'S1': 'no error', 'S2': '1213', 'S3': 'no error'}, {'S1': 'no error', 'S2': '1213', 'S3': 'no error'}]

Ở M3, cả IODKU lẫn READ COMMITTED đều không giúp (3/3). Hai cách còn lại: retry cả transaction của victim (M3-FIX retry: dòng cuối đếm đủ cả hai lần chèn, 3/3; mục 7), hoặc tránh pattern "giữ một insert chưa commit trên key nóng rồi rollback", ví dụ chèn key đó trong một transaction ngắn riêng. Theo ghi chú research, lỗi tạo trùng adhoc task trong Moodle (MDL-86422) cũng được sửa theo hướng UNIQUE key + chèn rồi xử lý trùng.

Trade-off và lúc không dùng. (a) Cần một UNIQUE constraint thể hiện đúng quy tắc nghiệp vụ. Nếu quy tắc không biểu diễn được bằng unique (ví dụ "tối đa 3 chỗ"), pattern này không áp dụng. (b) Luồng điều khiển dựa vào lỗi: phải phân biệt 1062/23505 "trùng như dự kiến" với lỗi trùng do bug. (c) Ở PostgreSQL, một INSERT lỗi 23505 bên trong transaction block làm cả transaction bị abort (mọi câu sau nhận 25P02, như đã thấy với 40P01 ở P1). Vì vậy trong PG nên dùng ON CONFLICT thay vì bắt 23505. Bắt lỗi qua savepoint cũng là một cách nhưng lab chưa verify. (d) Ở MySQL, 1062 giữ transaction mở và vẫn giữ khoá S trên bản ghi trùng (rows_locked=1), nên đừng để transaction đó tiếp tục làm việc lâu. (e) IODKU đổi ngữ nghĩa: lần thứ hai thành "cộng dồn". Chỉ dùng khi nghiệp vụ thật sự muốn thế.

6.7. Khoá thô hơn: advisory lock, hàng đợi, SKIP LOCKED

Khi không thể sắp thứ tự khoá (tập dòng không biết trước, logic phức tạp, nhiều bảng), cách cuối cùng là serialize (cho chạy tuần tự): mọi transaction chạm một "tài nguyên nghiệp vụ" (một khoá học, một quiz, một tài khoản) phải lấy một khoá thô duy nhất trước. Không có vòng vì chỉ có một khoá. Đổi lại, throughput của tài nguyên đó bị giới hạn ở một transaction tại một thời điểm.

1. PostgreSQL advisory lock (khoá do app tự đặt tên bằng một số).

BEGIN;
SELECT pg_advisory_xact_lock(1);   -- 1 = id của tài nguyên nghiệp vụ, tự nhả khi COMMIT/ROLLBACK
-- ... mọi câu ghi liên quan tới tài nguyên 1 ...
COMMIT;
Cách nói khi phỏng vấn: "Khi không sắp được thứ tự khoá các dòng, tôi cho mọi transaction đụng tới cùng một đối tượng nghiệp vụ lấy chung một khoá ứng dụng, đặt tên bằng id của đối tượng đó, ở mức transaction để nó tự nhả khi commit hoặc rollback. Không có vòng chờ vì chỉ có một khoá. Đổi lại, các thao tác trên đối tượng đó chạy lần lượt từng cái một. Nếu phải lấy hai khoá ứng dụng thì vẫn phải sắp theo thứ tự, vì PostgreSQL vẫn phát hiện deadlock trên loại khoá này như khoá thường."

Hai bài học từ lab P7. (a) Advisory lock nằm trong cùng deadlock detector: lấy hai key ngược thứ tự thì 40P01 3/3 (6.1), nên vẫn phải sắp key nếu lấy nhiều hơn một. (b) Bản session-level (pg_advisory_lock, không có _xact) sống sót qua 40P01: victim bị abort nhưng vẫn giữ key 1, nên "người thắng" vẫn bị chặn cho tới khi victim tự pg_advisory_unlock_all(). Docs đã cảnh báo: session-level advisory lock "do not honor transaction semantics".

[t=+  0.20s][S1] > SELECT pg_advisory_lock(1);
[t=+  0.40s][S2] > SELECT pg_advisory_lock(2);
[t=+  0.60s][S1] > SELECT pg_advisory_lock(2);
[t=+  1.00s][S2] > SELECT pg_advisory_lock(1);
[t=+  1.60s][S1]   ERROR:  40P01: deadlock detected
[t=+  1.60s][S1]   DETAIL:  Process 2047697 waits for ExclusiveLock on advisory lock [772093,0,2,1]; blocked by process 2047703.
[t=+  1.60s][S1]   Process 2047703 waits for ExclusiveLock on advisory lock [772093,0,1,1]; blocked by process 2047697.
victim = S1; the other session busy (still waiting)? True
[t=+  2.52s][MON]    app | classid | objid | objsubid |     mode      | granted
[t=+  2.52s][MON]    S1  |       0 |     1 |        1 | ExclusiveLock | t
[t=+  2.52s][MON]    S2  |       0 |     1 |        1 | ExclusiveLock | f
[t=+  2.52s][MON]    S2  |       0 |     2 |        1 | ExclusiveLock | t
[t=+  2.53s][MON]    app | state  | wait_event_type | wait_event | blocked_by
[t=+  2.53s][MON]    S1  | idle   | Client          | ClientRead | {}
[t=+  2.53s][MON]    S2  | active | Lock            | advisory   | {2047697}
[t=+  4.00s][S1] > SELECT pg_advisory_unlock_all();
[t=+  4.00s][S2]   (returned after 3.00s)

Với connection pool, connection không "đóng" sau mỗi request, nên một session-level advisory lock bị quên nhả sẽ đi theo connection sang request khác (suy luận từ hành vi trên, chưa test với pool thật). Gần như luôn nên dùng pg_advisory_xact_lock. Theo research, Moodle dùng advisory lock của PG cho lock factory, và bản sửa MDL-78580 (thống kê quiz) là đúng ý tưởng này: "we can never run the calculation for the same quiz concurrently".

2. MySQL: GET_LOCK, bảng semaphore, khoá bảng. Manual liệt kê hai cách cuối cùng: "serialize your transactions with table-level locks" và một bảng "semaphore" chỉ có một dòng mà transaction nào cũng UPDATE trước. Named lock GET_LOCK() có deadlock riêng (lỗi 3058 ER_USER_LOCK_DEADLOCK, theo docs "does not cause transactions to roll back"), và theo docs ProxySQL mà research trích, nó tắt multiplexing của connection đó. Cả ba cách đều chưa verify trong lab này.

3. Hàng đợi và SKIP LOCKED. Thay vì cho N worker tranh nhau một tài nguyên, đưa mọi thao tác ghi cho một key vào một hàng đợi và một worker xử lý tuần tự. SELECT ... FOR UPDATE SKIP LOCKED giúp nhiều worker lấy việc từ một bảng job mà không chờ nhau. Cả hai docs đều giới hạn nó cho bảng kiểu hàng đợi: PG viết "this is not suitable for general purpose work", MySQL viết "SKIP LOCKED is therefore not suitable for general transactional work". Cả hàng đợi theo key lẫn SKIP LOCKED đều chưa verify trong lab. Một biến thể theo hướng dữ liệu (vendor blog, chưa verify): Shopify đổi khoá chính thành (account, id) để gap lock của mỗi tiến trình ETL nằm trong vùng key của riêng nó, và nhờ vậy bỏ được các "artificial delays" từng dùng để né deadlock.

Trade-off và lúc không dùng. Throughput bị chặn trên bởi đoạn được serialize. Nếu kẻ giữ khoá chậm (gọi mạng bên trong, như Sentry), mọi người khác xếp thành convoy (đoàn chờ). Khoá thô là đổi deadlock lấy hàng đợi, nên luôn kết hợp với 6.4. Khoá thô cũng giấu thiết kế sai: hai đoạn code cùng serialize trên một key thì chạy đúng, đến ngày có đoạn thứ ba quên lấy khoá là sai dữ liệu chứ không phải deadlock. Không dùng khi tài nguyên bị tranh chấp là cả hệ thống (một khoá cho mọi request) hoặc khi throughput trên một key là yêu cầu chính.

6.8. DDL trong giờ chạy: lock_timeout và thứ tự khoá

1. Nâng khoá bảng trong cùng transaction thì deadlock tức thì (P6-A). Hai transaction cùng đọc bảng (AccessShareLock) rồi cùng ALTER TABLE (cần AccessExclusiveLock):

[t=+  0.20s][S1] > SELECT count(*) FROM tmp_dl_t;
[t=+  0.40s][S2] > SELECT count(*) FROM tmp_dl_t;
[t=+  0.60s][S1] > ALTER TABLE tmp_dl_t ADD COLUMN c1 int;
[t=+  0.81s][MON]    app | locktype |        mode         | granted |   rel    | blocked_by
[t=+  0.81s][MON]    S1  | relation | AccessShareLock     | t       | tmp_dl_t | {2047228}
[t=+  0.81s][MON]    S1  | relation | AccessExclusiveLock | f       | tmp_dl_t | {2047228}
[t=+  0.81s][MON]    S2  | relation | AccessShareLock     | t       | tmp_dl_t | {}
[t=+  1.00s][S2] > ALTER TABLE tmp_dl_t ADD COLUMN c2 int;
[t=+  1.00s][S2]   ERROR:  40P01: deadlock detected
[t=+  1.00s][S2]   DETAIL:  Process 2047228 waits for AccessExclusiveLock on relation 773700 of database 772093; blocked by process 2047221.
[t=+  1.00s][S2]   Process 2047221 waits for AccessExclusiveLock on relation 773700 of database 772093; blocked by process 2047228.
[t=+  1.00s][S1]   ALTER TABLE
[t=+  1.00s][S1]   (returned after 0.40s)

Bất ngờ: lỗi đến ngay lúc 1.00 s, không chờ 1 s deadlock_timeout, và victim là S2, bên khép vòng (3/3: 2 lần ALTER, 1 lần LOCK TABLE). Giải thích dựa trên đọc source ProcSleep(), không phải docs, nên là suy luận: S2 đã giữ một khoá xung đột với request đang xếp hàng của S1 trên cùng đối tượng khoá, nên PG phát hiện ngay trước khi ngủ. Sửa bằng cách lấy khoá mạnh nhất ngay câu đầu (6.2):

BEGIN;
LOCK TABLE tmp_dl_t IN ACCESS EXCLUSIVE MODE;   -- câu đầu tiên của transaction
SELECT count(*) FROM tmp_dl_t;
ALTER TABLE tmp_dl_t ADD COLUMN c1 int;
COMMIT;
Cách nói khi phỏng vấn: "Nếu một transaction vừa đọc bảng vừa đổi cấu trúc bảng, tôi xin khoá độc quyền cho bảng ngay từ câu đầu tiên thay vì đọc trước rồi mới nâng khoá. Hai transaction cùng đọc rồi cùng nâng khoá là deadlock, và PostgreSQL phát hiện ngay lập tức. Xin khoá mạnh trước thì bên đến sau chỉ phải xếp hàng chờ."
A FIX lock first: runs with a 40P01: 0/2

2. Lock queue: DDL chặn cả những câu đọc vô hại (P6-B). Đây không phải deadlock nhưng là sự cố DDL hay gặp nhất. S1 là một transaction đọc đang idle (giữ AccessShareLock). S2 là migration ALTER TABLE, phải chờ S1. S3 là một SELECT count(*) bình thường, đến sau. AccessShareLock của S3 tương thích với khoá của S1, nhưng S3 vẫn phải xếp hàng sau S2:

[t=+  0.40s][S2] > ALTER TABLE tmp_dl_t ADD COLUMN c_new int;
[t=+  0.80s][S3] > SELECT count(*) FROM tmp_dl_t;
[t=+  1.52s][MON]    app |        state        | wait_event_type | wait_event | blocked_by | xact_s |                   query
[t=+  1.52s][MON]    S1  | idle in transaction | Client          | ClientRead | {}         |    1.4 | SELECT count(*) FROM tmp_dl_t;
[t=+  1.52s][MON]    S2  | active              | Lock            | relation   | {2047411}  |    1.1 | ALTER TABLE tmp_dl_t ADD COLUMN c_new int;
[t=+  1.52s][MON]    S3  | active              | Lock            | relation   | {2047419}  |    0.7 | SELECT count(*) FROM tmp_dl_t;
[t=+  4.00s][S1] > COMMIT;
[t=+  4.00s][S2]   ALTER TABLE
[t=+  4.00s][S2]   (returned after 3.60s)
S3 (a plain SELECT) waited 3.21s
  | 2026-11-05 10:07:40.896 UTC [2047419] LOG:  process 2047419 still waiting for AccessExclusiveLock on relation 773730 of database 772093 after 1000.301 ms
  | 2026-11-05 10:07:40.896 UTC [2047419] DETAIL:  Process holding the lock: 2047411. Wait queue: 2047419, 2047426.
  | 2026-11-05 10:07:41.294 UTC [2047426] LOG:  process 2047426 still waiting for AccessShareLock on relation 773730 of database 772093 after 1000.163 ms at character 22
  | 2026-11-05 10:07:41.294 UTC [2047426] DETAIL:  Process holding the lock: 2047411. Wait queue: 2047419, 2047426.

pg_blocking_pids của S3 là pid của S2, không phải S1: đó là soft block (S2 đứng trước trong hàng đợi với một request xung đột). Docs mô tả đúng hai loại: "hard block" (giữ khoá xung đột) và "soft block" (đứng trước trong hàng). Trên một bảng nóng, mọi request đọc bảng đó đều xếp sau migration, và từ phía app trông như "database treo". Cách sửa là cho migration bỏ cuộc sớm:

SET lock_timeout = '2s';   -- chỉ trong session chạy migration
ALTER TABLE tmp_dl_t ADD COLUMN c_new int;
Cách nói khi phỏng vấn: "Câu đổi cấu trúc bảng phải chờ khoá độc quyền, và trong lúc chờ nó chặn luôn mọi câu đọc bình thường đến sau, kể cả những câu không xung đột với ai. Vì vậy trong session chạy migration tôi luôn đặt thời gian chờ khoá ngắn, khoảng vài giây. Nếu không lấy được khoá, migration tự bỏ cuộc và nhả hàng đợi cho request của người dùng, rồi script deploy thử lại sau. Tôi không đặt giá trị này cho toàn server vì nó sẽ đổi hành vi của mọi session khác."
[t=+  2.40s][S2]   ERROR:  55P03: canceling statement due to lock timeout
[t=+  2.40s][S2]   (returned after 2.00s)
[t=+  2.40s][S3]   (returned after 1.60s)
S3 (a plain SELECT) waited 1.60s
B lock queue (no lock_timeout): {'S1': 'ok', 'S2': 'ok', 'S3': 'ok'}  S3 SELECT waited 3.21s
B FIX lock_timeout=2s          : {'S1': 'ok', 'S2': 'error 55P03', 'S3': 'ok'}  S3 SELECT waited 1.6s

S2 nhận 55P03 sau 2 s và S3 được chạy ngay, chỉ chờ 1.60 s thay vì 3.21 s. Migration phải được thử lại, nên script deploy cần một vòng retry có giới hạn và có backoff (giãn dần thời gian chờ giữa các lần thử) cho bước DDL.

sequenceDiagram
  participant S1 as S1 app idle in txn
  participant DB as PostgreSQL
  participant S2 as S2 migration
  participant S3 as S3 SELECT thuong
  S1->>DB: BEGIN, SELECT (giu AccessShareLock)
  S2->>DB: SET lock_timeout 2s, ALTER TABLE
  Note over DB,S2: S2 cho AccessExclusiveLock sau S1
  S3->>DB: SELECT count (AccessShareLock)
  Note over DB,S3: S3 xep hang SAU S2 du tuong thich voi S1
  DB-->>S2: 55P03 sau 2s, migration bo cuoc
  DB-->>S3: chay ngay, cho 1.60s thay vi 3.21s

3. Incident GitLab (research §5). Ngày 2026-04-06, một post-deployment migration trên production "failed due to a PostgreSQL deadlock while attempting to add a foreign key to a partitioned CI table", và "The deadlock was triggered by locking conflicts with application queries during high traffic." Đội vận hành bỏ qua migration, đánh dấu đã chạy, rồi chạy lại job; nhãn incident ghi deploy bị chặn khoảng 3 giờ. Thêm foreign key cần khoá trên cả hai bảng, nên nó có hai đối tượng khoá để lấy theo một thứ tự, trong khi traffic của app cũng chạm hai bảng đó theo thứ tự của nó. Theo suy luận, đó là hình dạng của 6.1 nhưng ở mức khoá bảng. Bài học: deadlock ở DDL không chỉ làm hỏng vài query; nó chặn cả pipeline deploy.

Quy tắc cho migration. (a) lock_timeout ngắn trong session migration, kèm retry ở script (docs: không đặt trong postgresql.conf). Ở đây việc lock_timeout biến deadlock thành 55P03 (5.2) lại là điều mình muốn trong session migration (script đổi schema lúc deploy): migration nhường, app chạy tiếp. (b) Lấy khoá mạnh nhất ngay đầu transaction migration (P6-A). (c) Chạy DDL lấy khoá nặng ngoài giờ cao điểm, và kiểm tra trước xem có transaction dài nào đang mở trên bảng đích không (query ở 5.4), vì một kẻ idle in transaction là đủ để dựng hàng đợi. (d) Lab này không test DDL trên MySQL, nên hành vi khoá của DDL phía MySQL chưa verify ở đây. Xem thêm hai bài migration PostgreSQL zero-downtime và migration MySQL zero-downtime.

6.9. Ma trận chọn giải pháp

Bảng dưới gom các case đã tái hiện. Cột "sửa đầu tiên" là thứ nên thử trước vì rẻ nhất và đã được lab chứng minh. Cột cuối quan trọng không kém: khi nào cách đó không hợp.

Triệu chứng / caseSửa đầu tiênGiá phải trảKhi nào KHÔNG dùng
Hai transaction khoá cùng các dòng theo thứ tự ngược nhau (P1, M1, chuyển tiền) Khoá tất cả theo id tăng dần ở câu đầu (6.1); lab: 3/3 → 0/2, 0/3; app 167 → 0 Bên sau chờ thay vì lỗi; khoá giữ lâu hơn; mọi code path phải theo cùng quy ước Không biết trước tập dòng → khoá thô (6.7) hoặc retry
UPDATE nhiều dòng một câu, plan khác nhau (P2) WHERE id IN (SELECT ... ORDER BY id FOR UPDATE) (6.1); 5/5 → 0/5 Thêm một bước sort + LockRows; khoá cả tập trước khi sửa Tập dòng rất lớn: chia batch theo key thay vì khoá một lần
Batch upsert / multi-row IODKU, REPLACE (P5-D, M7, incident.io) Sắp key trước khi gửi (6.1); P5-D 3/3 → 0/3; IODKU 13 → 0 deadlock mỗi 4 s Chi phí sort ở app; phải sắp đúng theo kiểu của key Gần như luôn nên dùng; rẻ
FOR SHARE rồi UPDATE cùng dòng (P3) FOR NO KEY UPDATE / FOR UPDATE ngay lúc đọc (6.2); 3/3 → 0/2 Người chỉ đọc cũng phải xếp hàng Đa số chỉ đọc, hiếm khi ghi → optimistic locking
Insert con + sửa cha qua FK (P4-B/C, M5) Khoá dòng cha FOR UPDATE trước (6.2); PG 0/2, MySQL 0/3 Dòng cha thành điểm tuần tự; hot row khi cao điểm Counter cache trên cha rất nóng → bỏ counter, đếm khi đọc hoặc cộng dồn async (chưa verify)
Kiểm tra rồi chèn, gap lock (M2) Chèn trước, xử lý 1062; hoặc IODKU (6.6); 3/3 → 0/3 Luồng điều khiển dựa vào lỗi; IODKU đổi ngữ nghĩa Quy tắc không biểu diễn được bằng UNIQUE
Kiểm tra rồi chèn trên MySQL, không đổi được code READ COMMITTED cho session đó (6.5); 3/3 → 0/3 Phantom; binlog phải ROW; INSERT...SELECT không khoá nguồn Logic dựa vào gap lock để chặn phantom; không áp dụng cho PG
Ba session chèn cùng key, một bên rollback (M3) Retry cả transaction (mục 7); hoặc không để insert chưa commit của key nóng bị rollback Retry phải idempotent; IODKU và RC không giúp (3/3) Đừng tốn công tìm SQL "sửa" cho case này; PG không bị (0/3)
UPDATE theo cột không có index (M4) Thêm index đúng cột (6.3); 201 → 21 khoá; 3/3 → 0/3 Chi phí ghi và dung lượng; thêm một đường khoá Cột bị UPDATE liên tục mà không câu ghi nào cần index đó
Secondary index vs PRIMARY (M6) Đọc không khoá lấy PK, UPDATE theo PK + kiểm tra lại điều kiện (6.3); 3/3 → 0/3 App phải xử lý "0 row(s) affected" Dạng một câu autocommit (M6b): không có SQL fix đã test, dùng retry
Transaction dài, idle in transaction (Sentry, MDL-86422) Đưa I/O ra ngoài transaction, side effect sau commit (6.4) Mất tính nguyên tử; cần idempotency/outbox Không cắt nhỏ tới mức một dòng một transaction
Logic phức tạp, không sắp được thứ tự (MDL-78580) pg_advisory_xact_lock theo id nghiệp vụ (6.7) Throughput trên một key = 1; convoy nếu kẻ giữ chậm Không dùng bản session-level; không dùng một khoá cho cả hệ thống
Đọc rồi ALTER trong cùng transaction (P6-A) LOCK TABLE ... ACCESS EXCLUSIVE ở câu đầu (6.8); 3/3 → 0/2 Chặn mọi truy cập bảng trong suốt transaction Transaction dài; tách DDL ra transaction riêng, ngắn
DDL chặn cả câu đọc (P6-B, GitLab) SET lock_timeout ngắn trong session migration + retry ở script (6.8) Migration có thể thất bại, cần vòng retry Không đặt lock_timeout toàn server; không để lock_timeout < deadlock_timeout ở app nếu cần đếm deadlock
Advisory lock nhiều key (P7) Sắp key; dùng bản _xact (6.1, 6.7); 3/3 → 0/2 Như sắp thứ tự dòng Session-level với connection pool

Mọi dòng trong bảng đều giả định app đã có retry đúng cho phần còn sót lại. Mục 7 nói về retry: retry ở tầng nào, giữ side effect thế nào, và vì sao DB::transaction($cb, 5) của Laravel không phải lúc nào cũng đủ.

7. Xử lý khi deadlock đã xảy ra: retry đúng cách

Mục 6 làm giảm số deadlock, nhưng không đưa được về 0. Lab đã có ba loại deadlock mà không cách sắp thứ tự nào trong SQL chữa được: ba session INSERT trùng khoá rồi một bên ROLLBACK (M3, IODKU và READ COMMITTED đều vẫn 1213 3/3), hai câu UPDATE autocommit đơn lẻ đi hai đường index khác nhau (M6b), và PostgreSQL deadlock ngay ở khoá đầu tiên của transaction (mục 7.4). Vì vậy cả hai vendor đều viết retry (chạy lại) vào hướng dẫn chính thức. Manual MySQL: "Always be prepared to re-issue a transaction if it fails due to deadlock. Deadlocks are not dangerous. Just try again." Docs PostgreSQL 18: "deadlocks can be handled on-the-fly by retrying transactions that abort due to deadlocks".

Người vận hành lâu năm đọc chuyện này khắt khe hơn. Bài của incident.io (vendor blog, research §6.1) viết deadlock "are usually not seen as transient or load-inflicted errors, rather they are indications of poorly structured logic"; Cybertec viết "There is NO MAGIC CONFIGURATION PARAMETER to fix this problem". Hai cách đọc này không loại trừ nhau: retry để nuốt những deadlock hiếm và không tránh được; còn deadlock lặp đi lặp lại theo cùng một cặp câu lệnh thì là bug thiết kế, phải sửa theo mục 6. Mục 7 trả lời phần còn lại: chạy lại cái gì, với mã lỗi nào, bao nhiêu lần, framework (Laravel) làm hộ được đến đâu, và khi nào phải can thiệp bằng tay vào session đang chặn.

7.1. Vì sao phải chạy lại cả transaction

Đơn vị của retry là cả transaction, bắt đầu lại từ BEGIN, không phải "câu lệnh vừa lỗi". Có ba lý do, và mỗi lý do đứng được một mình.

1. PostgreSQL: transaction đã hỏng, không còn gì để chạy tiếp. Sau 40P01, mọi câu gửi tiếp trong cùng transaction block đều nhận 25P02 ("current transaction is aborted, commands ignored until end of transaction block"), còn COMMIT thì server trả lời ROLLBACK (output ở mục 4.2). Cách duy nhất để "giữ lại phần đã làm" là savepoint (điểm lưu giữa transaction). Nhưng docs PG 18 viết rõ phạm vi của nó: "if a lock is acquired after establishing a savepoint, the lock is released immediately if the savepoint is rolled back to". Khoá lấy trước savepoint thì vẫn giữ. Trong một deadlock hai dòng, khoá lấy trước đó thường chính là một nửa của vòng (S1 giữ dòng 1 rồi mới xin dòng 2). Lùi về savepoint rồi thử lại nửa sau nghĩa là vẫn ôm nguyên nửa vòng cũ (đây là suy luận từ câu docs trên). Laravel làm đúng kiểu "lùi về savepoint" này với transaction lồng trên PG, và lab cho thấy kết quả là một nghiệp vụ bị commit thiếu mà không có lỗi nào (mục 7.5).

2. MySQL: transaction đã bị rollback sẵn, câu tiếp theo chạy ngoài transaction. Với 1213, InnoDB đã rollback toàn bộ transaction của victim (nạn nhân, transaction bị huỷ) và session quay về autocommit (tự commit từng câu). Lab A1 ở mục 4.2 cho thấy hậu quả: câu INSERT 100 gửi sau lỗi tự commit, ROLLBACK sau đó không gỡ được. "Chạy tiếp phần còn lại" ở MySQL không phải là hoàn thành transaction; đó là ghi lẻ từng câu, mỗi câu một transaction riêng. Lab app ở 7.5 thấy lại đúng cái bẫy này qua Laravel: một dòng đánh dấu được ghi vĩnh viễn trong khi caller nhận exception.

3. Dữ liệu đã đọc trước lỗi có thể đã cũ. Victim bị huỷ để bên kia chạy tiếp, và bên kia thường commit ngay sau đó. Mọi quyết định của victim dựa trên lần đọc trước lỗi (số dư có đủ không, slot còn trống không, đơn đã được duyệt chưa) có thể không còn đúng. Retry đúng nghĩa là chạy lại cả các câu đọc và tính lại, không chỉ phát lại các câu ghi đã chuẩn bị sẵn. (Suy luận về thiết kế; trong lab app, điều kiện balance >= ? nằm ngay trong câu UPDATE của V4 nên mỗi lần chạy lại tự kiểm tra lại.)

Ví dụ minh hoạ: luồng chuyển tiền đọc số dư tài khoản A = 500, quyết định cho chuyển 400, khoá A, rồi bị chọn làm victim khi xin khoá B. Trong lúc đó transaction thắng đã trừ A 300 và commit. Nếu app "phát lại" câu trừ 400 đã chuẩn bị từ trước thay vì chạy lại từ đầu, A xuống âm. Chạy lại từ BEGIN thì lần đọc mới thấy 200 và từ chối giao dịch.

Lab app (app/out/03-nested.log, chế độ flat: một DB::transaction($cb, 5) duy nhất, S1 khoá dòng 1 rồi dòng 2, S2 khoá dòng 2 rồi dòng 1) cho thấy hình dạng của một lần retry đúng. Trên PostgreSQL:

[t=+0.02s][S1] inner closure entered (inner attempt #1), transactionLevel=1
[t=+0.02s][S1] SELECT * FROM accounts WHERE id = 1 FOR UPDATE
[t=+0.14s][S2] SELECT * FROM accounts WHERE id = 2 FOR UPDATE
[t=+0.52s][S1] SELECT * FROM accounts WHERE id = 2 FOR UPDATE
[t=+0.65s][S2] SELECT * FROM accounts WHERE id = 1 FOR UPDATE
[t=+1.54s][S1]   !! 40P01/7 SQLSTATE[40P01]: Deadlock detected: 7 ERROR:  deadlock detected | DETAIL:  Process 2045606 waits for ShareLock on transaction 723165; blocked by process 2045607. | Process 2045607 waits for ShareLock on transaction 723164; blocked by process 2045606. | HINT...
[t=+1.54s][S2]   -> row 1 locked
[t=+1.55s][S1] inner closure entered (inner attempt #2), transactionLevel=1
[t=+1.55s][S1] SELECT * FROM accounts WHERE id = 1 FOR UPDATE
[t=+1.56s][S2] COMMITTED (outer entries=0, inner entries=1)
[t=+2.07s][S1] SELECT * FROM accounts WHERE id = 2 FOR UPDATE
[t=+2.08s][S1] COMMITTED (outer entries=0, inner entries=2)
balance deltas (id=>delta): {"1":0,"2":0,"3":0,"4":0,"5":0,"6":0,"7":0,"8":0,"9":0,"10":0}  sum_delta=0

Đọc theo thời gian: S1 bắt đầu chờ dòng 2 lúc 0,52 s, S2 khép vòng lúc 0,65 s. Không có gì xảy ra cho tới 1,54 s, tức là 0,52 s + deadlock_timeout 1 s: bộ hẹn giờ của S1 hết trước nên S1 tự kiểm tra, thấy vòng và tự huỷ mình (cơ chế ở mục 1.3). Cùng mốc 1,54 s, S2 lấy được dòng 1, vì khoá của victim được thả ngay lúc báo lỗi. Một mili-giây sau, Laravel đã rollback và gọi lại closure (hàm ẩn danh chứa nghiệp vụ) của S1 từ đầu: inner attempt #2 khoá lại dòng 1 trước, rồi mới tới dòng 2. sum_delta=0: hai giao dịch ngược chiều đều được áp đúng một lần. Cùng kịch bản trên MySQL:

[t=+0.54s][S1] SELECT * FROM accounts WHERE id = 2 FOR UPDATE
[t=+0.62s][S2] SELECT * FROM accounts WHERE id = 1 FOR UPDATE
[t=+0.62s][S1]   -> row 2 locked
[t=+0.62s][S2]   !! 40001/1213 SQLSTATE[40001]: Serialization failure: 1213 Deadlock found when trying to get lock; try restarting transaction (Connection: mysql_lab, Host: tmp-dl-app-mysql, Port: 3306, Database: tmp_dl_app, SQL: select * from `accounts` where `id` = 1 limit 1 for update)
[t=+0.63s][S2] inner closure entered (inner attempt #2), transactionLevel=1
[t=+0.63s][S2] SELECT * FROM accounts WHERE id = 2 FOR UPDATE
[t=+1.31s][S2] COMMITTED (outer entries=0, inner entries=2)
server counter delta: {"deadlocks":1,"timeouts":0}

Ở MySQL không có khoảng chờ 1 s: S2 khép vòng lúc 0,62 s và bị chọn làm victim ngay (InnoDB phát hiện lúc xin khoá, mục 1.4), S1 lấy dòng 2 cùng mốc đó. S2 chạy lại từ đầu. Victim khác nhau giữa hai DB (PG: bên chờ trước, S1 trong 8/8 lần chạy của lab 03; MySQL: S2 trong 8/8 lần, hai transaction nặng ngang nhau). Đây là quan sát với timeline này, không phải quy tắc để dựa vào. Bất biến mà lab chứng minh là: đúng một bên nhận lỗi, bên kia chạy tiếp; bên nhận lỗi chạy lại cả closure thì cả hai giao dịch đều được áp.

Cách nói khi phỏng vấn: "Khi gặp deadlock tôi không bao giờ chạy lại câu lệnh vừa lỗi, tôi chạy lại cả transaction từ đầu. Ở PostgreSQL transaction đó đã hỏng, câu nào gửi thêm cũng bị từ chối. Ở MySQL nó đã bị rollback sẵn, câu gửi thêm sẽ chạy ngoài transaction và được ghi luôn. Ngoài ra dữ liệu tôi đọc trước lỗi có thể đã bị bên thắng sửa, nên lần chạy lại phải đọc lại và tính lại. Trong lab, làm đúng như vậy thì cả hai giao dịch ngược chiều đều được áp đúng một lần."
flowchart TD
  B["BEGIN"] --> R["Đọc lại dữ liệu cần thiết<br/>(không dùng kết quả của lần trước)"]
  R --> W["Khoá + ghi theo thứ tự cố định"]
  W --> C["COMMIT"]
  C --> OK["Xong: chạy side effect sau commit"]
  W -- "lỗi" --> K{"Mã lỗi có retry được?<br/>40P01, 40001, 1213, 1205"}
  K -- "không" --> F["ROLLBACK, ném lỗi lên trên"]
  K -- "có" --> RB["ROLLBACK<br/>(PG: bắt buộc. MySQL sau 1213: server đã rollback)"]
  RB --> N{"Còn lượt retry?"}
  N -- "không" --> F
  N -- "còn" --> S["Chờ ngẫu nhiên (jitter, mục 7.6)"]
  S --> B

Ngoài Laravel (code PDO thuần, script cron, plugin tự viết), khung retry tối thiểu trông như dưới đây. Đây là mẫu minh hoạ, không chạy trong lab; phần phân loại lỗi dùng đúng cặp SQLSTATE / mã driver mà lab in ra trong mọi log (40P01/7, 40001/1213, HY000/1205, 55P03/7, lấy từ errorInfo[0] và errorInfo[1]).

// Mẫu minh hoạ (không chạy trong lab): đơn vị retry là cả transaction.
function withRetry(PDO $pdo, callable $work, int $maxAttempts = 3): mixed
{
    for ($attempt = 1; ; $attempt++) {
        $pdo->beginTransaction();
        try {
            $result = $work($pdo);   // đọc lại, tính lại, khoá và ghi: luôn từ đầu
            $pdo->commit();
            return $result;          // side effect (email, HTTP, job) chạy SAU dòng này
        } catch (PDOException $e) {
            if ($pdo->inTransaction()) {
                $pdo->rollBack();    // PG bắt buộc; MySQL 1205 bắt buộc (mục 7.2)
            }
            if (!isRetryable($e) || $attempt >= $maxAttempts) {
                throw $e;
            }
            usleep(random_int(0, 10_000 * 2 ** ($attempt - 1)));   // full jitter, mục 7.6
        }
    }
}

function isRetryable(PDOException $e): bool
{
    [$sqlstate, $driverCode] = [$e->errorInfo[0] ?? null, $e->errorInfo[1] ?? null];
    return $sqlstate === '40P01'                 // PG deadlock
        || $sqlstate === '40001'                 // PG serialization failure, MySQL 1213
        || $driverCode === 1205;                 // MySQL lock wait timeout (SQLSTATE HY000)
}
Cách nói khi phỏng vấn: "Khung retry của tôi có bốn việc: mở transaction, chạy toàn bộ nghiệp vụ kể cả phần đọc, commit, và nếu lỗi thì rollback trước rồi mới quyết định. Tôi chỉ chạy lại khi lỗi thuộc nhóm tranh chấp khoá: deadlock của hai bên, lỗi serialization của PostgreSQL, và lỗi hết giờ chờ khoá của MySQL. Lỗi khác thì ném lên luôn. Mỗi lần chạy lại tôi chờ một khoảng ngẫu nhiên và có trần số lần. Mọi việc gửi ra ngoài database chỉ chạy sau khi commit thành công."

Ba chi tiết trong mẫu đáng nói kỹ. (a) Danh sách mã lỗi phải viết theo cặp SQLSTATE + mã driver, vì 40001 nghĩa là deadlock ở MySQL nhưng là serialization failure ở PG, còn MySQL 1205 lại mang SQLSTATE chung chung HY000 (mục 4.2). Lọc theo một mình SQLSTATE 40001 sẽ bỏ sót deadlock của PG và lock wait timeout của MySQL. (b) Có nên retry 55P03 (PG lock_timeout) hay không là một quyết định riêng, không có đáp án chung: đó không phải deadlock mà là "đã chờ quá lâu"; Laravel mặc định không retry nó (7.5). (c) Trạng thái inTransaction() của PDO sau một 1213 không phản ánh server: lab 03 in ra pdo->inTransaction()=true ngay sau 1213 trong khi InnoDB đã rollback (7.5). Gọi rollBack() lúc đó có an toàn trên mọi bản PDO không thì lab chưa verify; Laravel bọc lời gọi rollback trong try và nuốt lỗi, xem đoạn source ở 7.5.

Trường hợp "transaction" chỉ có một câu. M6b và M7e/f ở mục 3 là deadlock giữa hai câu autocommit đơn lẻ. Lúc đó "cả transaction" chính là câu lệnh ấy, nên chạy lại câu lệnh là đủ. Đừng nhầm thành "câu đơn thì không cần retry": M6b có 11 và 13 deadlock trong 4 giây ở hai lần chạy.

7.2. Bẫy 1205 của MySQL: chỉ rollback câu lệnh

Lỗi 1205 (ER_LOCK_WAIT_TIMEOUT, lock wait timeout: chờ khoá quá innodb_lock_wait_timeout) là lỗi khoá duy nhất trong bài mà sau lỗi transaction vẫn commit được phần đã làm: database không rollback nó, cũng không đánh dấu nó hỏng. Mục 4.2 đã có output lab A0: sau 50,04 s chờ, chỉ câu UPDATE đang chờ bị huỷ, transaction vẫn mở với các ghi trước đó, và COMMIT sau đó lưu một nghiệp vụ dở dang. Docs nói đúng như vậy: "the current statement is rolled back (not the entire transaction)", và thêm một câu ít người để ý: "Locks are not released in a rollback of a single SQL statement." Nghĩa là sau 1205, transaction của mình vẫn đang giữ mọi khoá cũ và vẫn đang chặn người khác (lab 11 ở 7.3 thấy rõ: S1 nhận 1205 nhưng S2 vẫn tiếp tục chờ khoá của S1 thêm một giây nữa).

Ở chỗ này hai nguồn đưa ra hai lời khuyên khác nhau, và cả hai đều có lý trong ngữ cảnh của mình.

  1. Manual MySQL: retry câu lệnh. "Retry the statement if using the default behavior, or the entire transaction if --innodb-rollback-on-timeout is enabled." Lý do: phần đầu transaction vẫn còn nguyên, khoá vẫn giữ, nên chỉ cần gửi lại câu vừa hết giờ. Cách này hợp với transaction dài mà phần đầu tốn kém (ví dụ một batch đã ghi hàng nghìn dòng) và holder (bên đang giữ khoá) chỉ chậm chứ không kẹt.

  2. Laravel (và mẫu ở 7.1): rollback rồi chạy lại cả transaction. Source 13.40.0 gọi $this->rollBack() trước khi quyết định retry (dòng 113, trích ở 7.5), nên 1205 bị đối xử như 1213. Lý do: code ứng dụng gần như không bao giờ được viết để "chạy lại đúng một câu ở giữa closure"; và nếu 1205 thực chất là một deadlock không ai phát hiện (khi innodb_deadlock_detect=OFF, 7.3) thì giữ nguyên khoá cũ rồi gửi lại câu đó là quay lại đúng cái vòng vừa kẹt (suy luận từ lab 11a: cả hai bên giữ khoá của mình cho tới hết timeout).

Lab app 04-lockwait đo đúng hành vi của Laravel với 1205: S1 giữ dòng 1 trong 2,5 s, S2 đặt innodb_lock_wait_timeout = 1 cho session rồi chạy DB::transaction($cb, 3) xin cùng dòng (app/out/04-lockwait.log, MySQL rep 1/2):

[t=-0.29s][S2] SET SESSION innodb_lock_wait_timeout = 1   (session level)
[t=+0.01s][S1] SELECT * FROM accounts WHERE id = 1 FOR UPDATE -> row 1 locked
[t=+0.01s][S1] holding the lock for 2.5s
[t=+0.20s][S2] closure entered (attempt #1)
[t=+1.21s][S2]   !! HY000/1205 SQLSTATE[HY000]: General error: 1205 Lock wait timeout exceeded; try restarting transaction (Connection: mysql_lab, Host: tmp-dl-app-mysql, Port: 3306, Database: tmp_dl_app, SQL: select * from `accounts` where `id` = 1 limit 1 for update)
[t=+1.21s][S2] closure entered (attempt #2)
[t=+2.21s][S2]   !! HY000/1205 SQLSTATE[HY000]: General error: 1205 Lock wait timeout exceeded; try restarting transaction (Connection: mysql_lab, Host: tmp-dl-app-mysql, Port: 3306, Database: tmp_dl_app, SQL: select * from `accounts` where `id` = 1 limit 1 for update)
[t=+2.21s][S2] closure entered (attempt #3)
[t=+2.51s][S1] COMMIT
[t=+2.52s][S2]   -> row 1 locked
[t=+2.52s][S2] COMMITTED after 3 closure entries
balance id=1 delta: 10
server counter delta: {"deadlocks":0,"timeouts":2}

Hai lần 1205 (khớp "timeouts":2 của counter lock_timeouts), mỗi lần Laravel rollback và gọi lại closure; lần thứ ba S1 commit lúc 2,51 s và S2 xong lúc 2,52 s. delta: 10 nghĩa là phần ghi +10 của S2 được áp đúng một lần dù closure chạy ba lần. Kết quả 2/2 lần chạy. Hai điều rút ra:

  1. Retry 1205 thực chất là "chờ thêm, chia thành nhiều đoạn". Ba lượt × 1 s timeout chỉ đủ vì holder giữ khoá 2,5 s. Holder giữ 4 s thì lượt thứ ba cũng 1205 và lỗi được ném lên. Ngân sách retry cho 1205 phải so với thời gian giữ khoá điển hình của holder, không phải với xác suất "đụng nhau" như deadlock. Nếu holder là một connection idle in transaction (mở transaction rồi ngồi yên) thì retry bao nhiêu cũng vô ích, phải xử lý holder (7.7).

  2. Mã lỗi khác nhau dù cùng một tình huống. Trên PG, cùng kịch bản với SET lock_timeout = '1s' cho 55P03, và Laravel không retry (laravelSaysConcurrency=false, ném lên sau 1 lượt, 2/2). Một service chạy được trên cả hai DB sẽ có hai hành vi khác nhau với cùng một đoạn code. Chi tiết và lý do ở 7.5.

Tuỳ chọn ở phía server: innodb_rollback_on_timeout=ON. Bật lên thì 1205 rollback cả transaction như 1213 (lab B0, mục 4.2: SERVER_STATUS_IN_TRANS=False). Được: ngữ nghĩa thống nhất, "commit sau 1205" không còn tạo ra transaction dở dang. Mất: biến này không đổi được lúc chạy (ERROR 1238 ... is a read only variable, phải restart server), và nó kéo theo đúng cái bẫy của 1213: câu nào app lỡ gửi tiếp sẽ tự commit ngoài transaction (B0, 2/2). Nó không thay được việc app phải rollback và chạy lại cả transaction; nó chỉ đổi một kiểu sai (commit dở dang) thành một kiểu sai khác (ghi lẻ ngoài transaction) khi app viết sai.

Cách nói khi phỏng vấn: "Lỗi hết giờ chờ khoá của MySQL là cái bẫy lớn nhất: mặc định nó chỉ huỷ đúng câu đang chờ, transaction vẫn mở và vẫn giữ khoá cũ. Nếu code bắt lỗi rồi commit, tôi lưu một nghiệp vụ dở dang. Vì vậy tôi đối xử với nó như deadlock: rollback, rồi chạy lại cả transaction, và Laravel cũng làm đúng như thế. Tôi giới hạn số lần thử theo thời gian giữ khoá thường gặp của bên đang chặn, vì thử lại ở đây thực chất chỉ là chờ thêm."
flowchart TD
  E["ERROR 1205 (HY000)"] --> Q{"innodb_rollback_on_timeout?"}
  Q -- "OFF, mặc định" --> O["Chỉ câu đang chờ bị huỷ,<br/>transaction mở, giữ khoá cũ"]
  Q -- "ON, lúc khởi động" --> X["Cả transaction rollback,<br/>về autocommit như 1213"]
  O --> C1["COMMIT:<br/>dở dang (A0)"]
  O --> C3["Gửi lại đúng câu đó<br/>(manual)"]
  O --> C2["ROLLBACK + chạy lại<br/>cả transaction: đúng"]
  X --> C2
  X --> C4["Gửi tiếp câu cũ:<br/>ghi lẻ (B0)"]

7.3. innodb_deadlock_detect=OFF: khi nào dùng và cái giá

innodb_deadlock_detect là biến global, dynamic, mặc định ON. Tắt nó đi thì InnoDB không đi tìm vòng nữa; docs: "InnoDB relies on the innodb_lock_wait_timeout setting to roll back transactions in case of a deadlock." Nói cách khác, mọi deadlock biến thành lock wait kéo dài tới timeout và trả về 1205, với toàn bộ ngữ nghĩa "chỉ rollback câu lệnh" của 7.2. Lab 11 dựng lại đúng cặp chuyển tiền ngược chiều của M1 trong container tạm, với cấu hình:

-- chỉ chạy trong container tạm tmp-dl-mysql; cuối script trả lại ON / 50
SET GLOBAL innodb_deadlock_detect = OFF;
SET GLOBAL innodb_lock_wait_timeout = 5;
-- S1: UPDATE acc ... - 10 WHERE id = 1;   rồi UPDATE acc ... + 10 WHERE id = 2;
-- S2: UPDATE acc ... - 20 WHERE id = 2;   rồi UPDATE acc ... + 20 WHERE id = 1;
Cách nói khi phỏng vấn: "Tôi tắt bộ phát hiện deadlock trong một container thử, hạ thời gian chờ khoá xuống năm giây, rồi cho hai giao dịch chuyển tiền ngược chiều nhau giống hệt case cơ bản. Mục đích là xem khi không ai phá vòng thì chuyện gì xảy ra với từng bên, và với dữ liệu nếu ứng dụng xử lý lỗi sai."

Kịch bản 11a, app "lỗi": bắt 1205 rồi vẫn COMMIT (mysql/out/11-deadlock-detect-off.log, run 1/2):

[t=+ 0.20s][S1] UPDATE acc SET balance = balance - 10 WHERE id = 1   -- transfer 1 -> 2
[t=+ 0.40s][S2] UPDATE acc SET balance = balance - 20 WHERE id = 2   -- transfer 2 -> 1
[t=+ 0.80s][S1] UPDATE acc SET balance = balance + 10 WHERE id = 2   -- waits
[t=+ 2.00s][S2] UPDATE acc SET balance = balance + 20 WHERE id = 1   -- cycle, but nobody detects it
[t=+ 3.01s][MON]    | waiting_trx | blocking_trx | waiting_mode  | waiting_data | blocking_mode |
[t=+ 3.01s][MON]    | 1815        | 1816         | X,REC_NOT_GAP | 2            | X,REC_NOT_GAP |
[t=+ 3.01s][MON]    | 1816        | 1815         | X,REC_NOT_GAP | 1            | X,REC_NOT_GAP |
[t=+ 3.01s][MON]    | 1815   | LOCK WAIT | 13  | 2026-11-05 10:25:53 |
[t=+ 3.01s][MON]    | 1816   | LOCK WAIT | 14  | 2026-11-05 10:25:54 |
[t=+ 6.01s][S1]   -> ERROR 1205 (HY000): Lock wait timeout exceeded; try restarting transaction  (5.21s)
[t=+ 6.01s][S1]      server view: SERVER_STATUS_IN_TRANS=True; INNODB_TRX: trx_id=1815 state=RUNNING rows_modified=1 rows_locked=2
[t=+ 7.01s][S2]   -> ERROR 1205 (HY000): Lock wait timeout exceeded; try restarting transaction  (5.01s)
[t=+ 7.01s][S2]      server view: SERVER_STATUS_IN_TRANS=True; INNODB_TRX: trx_id=1816 state=RUNNING rows_modified=1 rows_locked=2
[t=+ 8.00s][S2] COMMIT   -- buggy app: ignores 1205 and commits
[t=+ 8.00s][S1] COMMIT   -- buggy app: ignores 1205 and commits
SUMMARY [11a deadlock_detect=OFF, lock_wait_timeout=5, app COMMITs after 1205]: ERROR 1205 seen in 2/2 runs; outcomes per run: [{'S1': '1205', 'S2': '1205'}, {'S1': '1205', 'S2': '1205'}]

Đọc từng bước:

  1. Vòng tồn tại mà không ai phá. Lúc 3,01 s, data_lock_waits có đúng hai dòng chỉ vào nhau (1815 chờ 1816 trên id=2, 1816 chờ 1815 trên id=1), cả hai transaction ở trạng thái LOCK WAIT. Đây là deadlock theo đúng định nghĩa ở mục 1, nhưng counter lock_deadlocks đứng yên (+0, chỉ lock_timeouts +2, đã phân tích ở mục 5.3).

  2. Cả hai đều phải chờ hết timeout, không phải một. S1 nhận 1205 lúc 6,01 s (đã chờ 5,21 s). Nhưng S2 không được giải phóng lúc đó: nó chờ tiếp tới 7,01 s rồi cũng nhận 1205. Lý do nằm ở dòng server view: sau 1205, S1 vẫn SERVER_STATUS_IN_TRANS=True, rows_locked=2. Câu bị huỷ được rollback, khoá trên id=1 thì vẫn giữ, đúng câu docs "Locks are not released in a rollback of a single SQL statement". Một deadlock mà detector xử lý trong 0,01 s (M1) giờ tốn 5 s cho bên thứ nhất và 5 s cho bên thứ hai; với timeout mặc định 50 s thì là gần một phút treo cho mỗi bên.

  3. Commit sau 1205 làm mất tiền. Mỗi bên đã trừ tiền ở câu đầu, còn câu cộng tiền thì bị huỷ. Hai lệnh COMMIT lưu hai nửa giao dịch: id=1 còn 90, id=2 còn 80, tổng 170 thay vì 200 (bảng kết quả ở mục 5.3), 2/2 lần chạy. Tiền không "biến mất" vì database hỏng; nó biến mất vì app commit một transaction mà database đã nói rõ là chưa xong.

Kịch bản 11b, cùng cấu hình nhưng app đúng: gặp 1205 thì ROLLBACK (để retry sau):

[t=+ 6.00s][S1]   -> ERROR 1205 (HY000): Lock wait timeout exceeded; try restarting transaction  (5.20s)
[t=+ 6.20s][S1] ROLLBACK   -- correct app: on 1205 roll back (then retry later)
[t=+ 6.20s][S2]   -> OK, 1 row(s) affected (4.20s)
RUN 1: outcome {'S1': '1205', 'S2': 'no error'} | INNODB_METRICS delta: lock_deadlocks +0, lock_timeouts +1
[t=+ 8.02s][MON]    | id | balance | total_should_be_200 |
[t=+ 8.02s][MON]    | 1  | 120     | 200                 |
[t=+ 8.02s][MON]    | 2  | 80      | 200                 |
SUMMARY [11b deadlock_detect=OFF, lock_wait_timeout=5, app ROLLBACKs on 1205]: ERROR 1205 seen in 2/2 runs; outcomes per run: [{'S1': '1205', 'S2': 'no error'}, {'S1': '1205', 'S2': 'no error'}]

ROLLBACK của S1 lúc 6,20 s nhả khoá id=1, và câu đang chờ của S2 xong ngay cùng mốc (đã chờ 4,20 s). Tổng tiền 200, chỉ giao dịch của S2 được áp (id=1: 100 + 20, id=2: 100 − 20), 2/2. Giao dịch của S1 phải được app chạy lại. So với detection bật: cùng kết quả đúng, nhưng chậm hơn khoảng 5 s và chỉ đúng nếu app rollback.

Vậy khi nào có người tắt nó? Hai trường phái.

  1. Manual MySQL 8.4 cho phép: "On high concurrency systems, deadlock detection can cause a slowdown when numerous threads wait for the same lock. At times, it may be more efficient to disable deadlock detection and rely on the innodb_lock_wait_timeout setting for transaction rollback when a deadlock occurs." Bối cảnh là hot row (dòng nóng): hàng trăm thread cùng chờ một dòng, và mỗi lần chờ là một lần duyệt đồ thị. Người làm theo hướng này thường đi kèm timeout thấp; một câu trả lời trên DBA StackExchange (nguồn cộng đồng) viết "Be sure to keep innodb_lock_wait_timeout reasonably low, for example, 2 seconds."

  2. Kỹ sư viết lại lock system của InnoDB khuyên giữ ON. Blog chính thức của MySQL (Kuba Łopuszański, 2021, nói về bản 8.0.18 trở đi): "the new (8.0.18) implementation is not only faster, but more importantly runs from a dedicated background thread without blocking the transaction threads, so I see no reason to disable this mechanism. I recommend you keep the default (ON)." Research ghi nhận đoạn manual có từ thời 5.7.15, tức là cũ hơn bản viết lại đó. Cùng thread StackExchange cũng có phản biện: "Every time a would-be deadlock occurs, you are turning it into a connection that sits around taking up some resources for 50 seconds."

Quan điểm của một tech lead, dựa trên những gì lab đo được: không tắt detection như một cách "sửa" deadlock. Tắt nó không làm deadlock ít đi; nó đổi một lỗi phát hiện trong vài mili-giây thành hai lần treo tới timeout, làm counter deadlock mù, và biến mọi lỗi xử lý 1205 của app thành mất dữ liệu (11a). Lợi ích hiệu năng mà manual nói tới thì lab này chưa verify: nó cần hàng trăm thread cùng chờ một dòng, lab chỉ có hai session (mysql/RESULTS.md, mục S9). Nếu một hệ thống thật đo được detection là nút thắt dưới hot row, thứ cần sửa trước là chính cái hot row (tách counter, đẩy qua hàng đợi, cộng dồn bất đồng bộ; research §6.5, suy luận chưa lab). Chỉ khi đã làm vậy mà vẫn cần tắt, thì bốn điều kiện đi kèm là bắt buộc: (1) innodb_lock_wait_timeout thấp, theo giây chứ không phải 50; (2) mọi đường code rollback khi gặp 1205 (11b), tốt nhất kèm innodb_rollback_on_timeout=ON để sai sót của app không thành commit dở dang (suy luận từ B0 + 11a, chưa chạy tổ hợp này); (3) alert chuyển sang lock_timeouts và lỗi 1205, vì lock_deadlocks sẽ luôn bằng 0; (4) đo lại thông lượng trước và sau, vì đây là tối ưu có điều kiện chứ không phải mặc định tốt hơn.

Phía PostgreSQL không có công tắc tương đương. Thứ chỉnh được là deadlock_timeout: thời gian một backend chờ trước khi tự kiểm tra vòng. Docs khuyên hướng ngược với trực giác: "On a heavily loaded server you might want to raise it", vì mỗi lần kiểm tra khoá toàn bộ lock table (research §1.4). Hạ nó xuống thì deadlock được báo sớm hơn; lab app 06 đo được cái giá của việc không hạ (7.4). Một bẫy riêng của PG đã có ở mục 5.2: đặt lock_timeout ngắn hơn deadlock_timeout thì deadlock thành 55P03 và không còn được đếm.

Cách nói khi phỏng vấn: "Tắt bộ phát hiện deadlock của MySQL không làm deadlock biến mất, nó chỉ biến deadlock thành hai lần chờ tới hết giờ. Trong lab, mỗi bên treo năm giây, bộ đếm deadlock vẫn bằng không, và khi ứng dụng commit sau lỗi hết giờ thì tổng tiền từ hai trăm còn một trăm bảy mươi. Tài liệu MySQL có nói tắt có thể nhanh hơn khi rất nhiều thread cùng chờ một dòng, nhưng chính kỹ sư viết lại phần khoá khuyên giữ bật từ bản 8.0.18. Tôi sẽ sửa cái dòng nóng trước, và chỉ tắt khi đã đo được lợi ích, với thời gian chờ ngắn và ứng dụng luôn rollback khi gặp lỗi hết giờ."
sequenceDiagram
  participant S1
  participant DB as InnoDB, detect OFF
  participant S2
  S1->>DB: UPDATE id 1 rồi xin id 2
  S2->>DB: UPDATE id 2 rồi xin id 1
  Note over DB: vòng tồn tại, không ai phá, lock_deadlocks +0
  DB-->>S1: 6.01s ERROR 1205, câu bị huỷ, khoá id 1 vẫn giữ
  Note over S2: vẫn chờ khoá id 1 của S1
  DB-->>S2: 7.01s ERROR 1205
  S1->>DB: 11a COMMIT, lưu nửa giao dịch
  S2->>DB: 11a COMMIT, lưu nửa giao dịch
  Note over DB: tổng 170 thay vì 200
  Note over S1,S2: 11b S1 ROLLBACK lúc 6.20s, S2 xong ngay, tổng 200

7.4. Lab Laravel: 8 worker chuyển tiền

Các lab SQL ở mục 2–3 dựng deadlock bằng hai session và timeline có chủ đích. Lab này hỏi câu của người viết ứng dụng: với code Laravel bình thường, chạy song song thật, deadlock xảy ra bao nhiêu, retry cứu được bao nhiêu, và cách viết nào làm nó biến mất.

Môi trường (app/out/00-setup.log): PHP 8.4.27 (image tmp-dl-php, php:8.4-fpm-alpine + pdo_pgsql, pdo_mysql, pcntl), Laravel Framework 13.40.0. PostgreSQL 18.4 là container dùng chung my-test-pgdb với database riêng tmp_dl_app, không đổi tham số server nào (deadlock_timeout 1000 ms, lock_timeout 0, READ COMMITTED). MySQL 8.4.13 là container riêng tmp-dl-app-mysql với mặc định (innodb_lock_wait_timeout 50, innodb_deadlock_detect 1, REPEATABLE READ). Máy chạy lab là host production dùng chung 4 vCPU, load average dao động 0,5–6,7 trong lúc chạy, nên thời gian chỉ nên so theo bậc độ lớn.

Workload. Bảng accounts(id, balance) có 10 dòng, mỗi dòng 1.000.000. 8 worker PHP fork bằng pcntl (mỗi worker một connection) × 200 lần chuyển = 1600 giao dịch mỗi lần chạy. Mỗi giao dịch chọn ngẫu nhiên cặp (from, to) trong 10 dòng nóng, số tiền 1–100, ngủ 1–5 ms giữa hai lần lấy khoá, rồi cập nhật hai số dư và ghi một dòng vào bảng log transfers. Seed = run × 1000 + worker, nên run 1 (và run 2) phát lại cùng một chuỗi cặp cho mọi biến thể và cả hai DB. Sau mỗi lần chạy, lab kiểm tra ba bất biến: tổng số dư không đổi (sum_ok), số dòng log bằng số giao dịch commit (log=), và từng số dư khớp với sổ cái tính lại từ log (ledger_ok). Cả ba giữ đúng trong mọi lần chạy của mọi script.

Biến thểCode bên trong DB::transactionattempts
V1 v1lockForUpdate(from), ngủ, lockForUpdate(to), trừ/cộng, ghi log1
V2 v2như V1DB::transaction($cb, 5)
V2b v2backoffnhư V1, bọc bằng retry() có full jitter 0–10/20/40/80 ms (7.6)5
V3 v3khoá min(id) rồi max(id): thứ tự cố định1
V3b v3inwhereIn('id', [a, b])->orderBy('id')->lockForUpdate()->get()1
V4 v4không khoá tường minh: UPDATE … balance - ? WHERE id = from AND balance >= ?, ngủ, UPDATE … + ? WHERE id = to1
V4b v4onemột câu: UPDATE … SET balance = balance + (CASE WHEN id = ? THEN -1 ELSE 1 END) * ? WHERE id IN (?, ?)1
V5 v5V2 + một side effect (tác dụng ra ngoài DB) ghi qua connection autocommit riêng (đóng vai "gửi email") ngay sau khoá thứ nhất5
V5 fix v5fixV2 + cùng side effect đó đăng ký bằng DB::afterCommit(...)5

Phần thân giao dịch (trích app/src/app/Console/Commands/DlRun.php, bỏ các dòng ghi nhận số liệu):

switch ($v) {
    case 'v1': case 'v2': case 'v2backoff': case 'v5': case 'v5fix':
        $acc()->where('id', $from)->lockForUpdate()->first();
        if ($v === 'v5') {
            $sideEffect('in-closure');          // "gửi email" / gọi HTTP: KHÔNG rollback được
        } elseif ($v === 'v5fix') {
            DB::afterCommit(fn () => $sideEffect('after-commit'));
        }
        usleep($sleepUs);
        $acc()->where('id', $to)->lockForUpdate()->first();
        break;
    case 'v3':
        $acc()->where('id', min($from, $to))->lockForUpdate()->first();
        usleep($sleepUs);
        $acc()->where('id', max($from, $to))->lockForUpdate()->first();
        break;
    case 'v3in':
        $acc()->whereIn('id', [$from, $to])->orderBy('id')->lockForUpdate()->get();
        usleep($sleepUs);
        break;
    case 'v4':
        $ok = DB::update('update accounts set balance = balance - ? where id = ? and balance >= ?', [$amt, $from, $amt]);
        usleep($sleepUs);
        DB::update('update accounts set balance = balance + ? where id = ?', [$amt, $to]);
        // ... insert transfers, return
    case 'v4one':
        DB::update('update accounts set balance = balance + (case when id = ? then -1 else 1 end) * ? where id in (?, ?)',
            [$from, $amt, $from, $to]);
        // ... insert transfers, return
}
$acc()->where('id', $from)->decrement('balance', $amt);
$acc()->where('id', $to)->increment('balance', $amt);
DB::table('transfers')->insert([...]);

// gọi bên ngoài:
DB::transaction($cb, self::ATTEMPTS[$v]);   // 1 cho v1/v3/v3in/v4/v4one, 5 cho v2/v5/v5fix
Cách nói khi phỏng vấn: "Lab có tám tiến trình PHP, mỗi tiến trình hai trăm lần chuyển tiền ngẫu nhiên giữa mười tài khoản, cùng một chuỗi cặp tài khoản cho mọi biến thể và cả hai database. Biến thể đầu khoá tài khoản nguồn rồi tài khoản đích theo đúng chiều chuyển, không retry. Các biến thể sau lần lượt thêm retry của Laravel, khoá theo thứ tự id, bỏ khoá tường minh để dùng UPDATE nguyên tử, gộp thành một câu, và cuối cùng là đặt một việc gửi ra ngoài vào trong transaction để xem nó bị lặp thế nào."

Kết quả, MySQL 8.4.13 (app/out/02-variants-mysql.log; dòng V4b lấy từ 02, lần chạy lại 02b cũng 0 deadlock). Mỗi ô là run 1 / run 2. "deadlocks" = lỗi bắt được trong closure, tất cả đều là 1213, và bằng đúng delta của counter server trong mọi lần chạy. "retries" = số lần vào closure − 1600.

Biến thểcommitfaildeadlocksretrieshistogram số lượt (lượt:giao dịch) run1 ; run2bất biếnwall s
V11545 / 155055 / 5055 / 500 / 01:1600 ; 1:16002/29,49 / 20,17
V2 (attempts 5)1600 / 16000 / 063 / 5263 / 521:1537 2:63 ; 1:1550 2:48 3:22/27,13 / 12,42
V2b backoff1600 / 16000 / 081 / 4881 / 481:1522 2:75 3:3 ; 1:1554 2:44 3:22/27,46 / 10,29
V3 thứ tự cố định1600 / 16000 / 00 / 00 / 01:1600 ; 1:16002/29,85 / 13,69
V3b IN + ORDER BY1600 / 16000 / 00 / 00 / 01:1600 ; 1:16002/214,01 / 14,32
V4 hai UPDATE1533 / 153867 / 6267 / 620 / 01:1600 ; 1:16002/28,02 / 7,62
V4b một UPDATE1600 / 16000 / 00 / 00 / 01:1600 ; 1:16002/26,44 / 3,91 (02b: 4,66 / 6,33)
V5 side effect trong closure1600 / 16000 / 066 / 8366 / 831:1538 2:58 3:4 ; 1:1520 2:77 3:32/212,05 / 13,05
V5 fix afterCommit1600 / 16000 / 041 / 4441 / 441:1561 2:37 3:2 ; 1:1558 2:41 4:12/28,37 / 26,69

Kết quả, PostgreSQL 18.4 (app/out/02-variants-pgsql.log; V4b lấy từ 02b vì bản SQL đầu lỗi 42883 trên PG, xem điểm 4 bên dưới). Mọi lỗi bắt được là 40P01.

Biến thểcommitfaildeadlocksretrieshistogram run1 ; run2bất biếnwall s
V11433 / 1467167 / 133167 / 1330 / 01:1600 ; 1:16002/2103,82 / 84,27
V2 (attempts 5)1600 / 16000 / 0191 / 169191 / 1691:1443 2:130 3:22 4:3 5:2 ; 1:1453 2:130 3:14 4:1 5:22/2116,18 / 106,72
V2b backoff1600 / 16000 / 0186 / 174186 / 1741:1441 2:135 3:21 4:3 ; 1:1446 2:136 3:16 4:22/2115,58 / 110,53
V3 thứ tự cố định1600 / 16000 / 00 / 00 / 01:1600 ; 1:16002/24,88 / 7,47
V3b IN + ORDER BY1600 / 16000 / 00 / 00 / 01:1600 ; 1:16002/26,06 / 6,85
V4 hai UPDATE1475 / 1449125 / 151125 / 1510 / 01:1600 ; 1:16002/290,50 / 101,25
V4b một UPDATE (02b)1600 / 16000 / 00 / 00 / 01:1600 ; 1:16002/22,68 / 2,09
V5 side effect trong closure1600 / 16000 / 0206 / 209206 / 2091:1414 2:169 3:14 4:3 ; 1:1422 2:151 3:24 4:2 5:12/2126,32 / 125,88
V5 fix afterCommit1599 / 16001 / 0161 / 126160 / 1261:1468 2:111 3:15 4:5 5:1 ; 1:1499 2:85 3:8 4:7 5:12/2102,26 / 87,45

Vài dòng gốc của run 1 làm bằng chứng (mỗi dòng là một lần chạy 1600 giao dịch):

[mysql v1 run1] committed=1545/1600 failed=55 {"40001\/1213":55} entries=1600 retries=0 errs_in_closure={"40001\/1213":55} hist={"1":1600} wall=9.49s server={"deadlocks":55,"timeouts":0} sum_ok=yes log=1545 ledger_ok=yes side=[]
[mysql v2 run1] committed=1600/1600 failed=0 [] entries=1663 retries=63 errs_in_closure={"40001\/1213":63} hist={"1":1537,"2":63} wall=7.13s server={"deadlocks":63,"timeouts":0} sum_ok=yes log=1600 ledger_ok=yes side=[]
[mysql v3 run1] committed=1600/1600 failed=0 [] entries=1600 retries=0 errs_in_closure=[] hist={"1":1600} wall=9.85s server={"deadlocks":0,"timeouts":0} sum_ok=yes log=1600 ledger_ok=yes side=[]
[mysql v4 run1] committed=1533/1600 failed=67 {"40001\/1213":67} entries=1600 retries=0 errs_in_closure={"40001\/1213":67} hist={"1":1600} wall=8.02s server={"deadlocks":67,"timeouts":0} sum_ok=yes log=1533 ledger_ok=yes side=[]
[pgsql v1 run1] committed=1433/1600 failed=167 {"40P01\/7":167} entries=1600 retries=0 errs_in_closure={"40P01\/7":167} hist={"1":1600} wall=103.82s server={"deadlocks":167} sum_ok=yes log=1433 ledger_ok=yes side=[]
[pgsql v2 run1] committed=1600/1600 failed=0 [] entries=1791 retries=191 errs_in_closure={"40P01\/7":191} hist={"1":1443,"2":130,"3":22,"4":3,"5":2} wall=116.18s server={"deadlocks":191} sum_ok=yes log=1600 ledger_ok=yes side=[]
[pgsql v3 run1] committed=1600/1600 failed=0 [] entries=1600 retries=0 errs_in_closure=[] hist={"1":1600} wall=4.88s server={"deadlocks":0} sum_ok=yes log=1600 ledger_ok=yes side=[]
[pgsql v4 run1] committed=1475/1600 failed=125 {"40P01\/7":125} entries=1600 retries=0 errs_in_closure={"40P01\/7":125} hist={"1":1600} wall=90.50s server={"deadlocks":125} sum_ok=yes log=1475 ledger_ok=yes side=[]
Cách nói khi phỏng vấn: "Cùng một chuỗi giao dịch, bản không retry mất khoảng ba phần trăm giao dịch trên MySQL và tám đến mười phần trăm trên PostgreSQL, nhưng chưa lần nào làm sai số dư, vì database luôn huỷ trọn transaction của bên thua. Thêm retry năm lần của Laravel thì cả nghìn sáu trăm giao dịch đều qua. Khoá theo thứ tự id thì không còn deadlock nào ở cả hai database, còn bỏ khoá tường minh để dùng hai câu UPDATE nguyên tử thì vẫn deadlock nhiều gần như bản đầu."

1. Không retry: mất nghiệp vụ, nhưng không hỏng dữ liệu. V1 mất 55/1600 và 50/1600 giao dịch trên MySQL (3,1–3,4 %), 167/1600 và 133/1600 trên PG (8,3–10,4 %). Mỗi deadlock là một giao dịch người dùng nhận lỗi. Nhưng sum_ok=yes ... ledger_ok=yes ở mọi dòng: database rollback trọn transaction của victim, nên không có nửa giao dịch nào lọt vào. Đây là điểm khác hẳn với 1205 ở 7.2–7.3: deadlock "chỉ" làm mất việc, còn lỗi xử lý 1205 làm sai dữ liệu.

2. DB::transaction($cb, 5) cứu được hết, với một cái giá và một giới hạn. V2 đưa cả hai DB về 1600/1600, 2/2 lần chạy. Giá: closure chạy lại 52–63 lần (MySQL), 169–191 lần (PG). Giới hạn nằm ở histogram: trên PG, mỗi lần chạy có 2 giao dịch cần tới lượt thứ 5, tức là ngân sách retry gần cạn; ở V5 fix run 1, một giao dịch đã cạn thật (failed=1 {"40P01\/7":1}, hist ... "5":1). Retry là biện pháp giảm nhẹ, không phải bảo đảm. Mục 7.6 đẩy tranh chấp lên cao hơn và tỉ lệ thất bại sau 5 lượt lên tới 16–23 % trên PG.

3. Khoá theo thứ tự cố định: 0 deadlock, và trên PG nhanh hơn 11–24 lần. V3 và V3b cho 0 deadlock trong 4/4 lần chạy mỗi biến thể (2 mỗi DB), với cùng chuỗi cặp đã gây 133–167 deadlock ở V1. Trên PG, V3 xong trong 4,88–7,47 s so với 84–116 s của V1/V2. Lý do không phải V3 "làm ít việc hơn" mà là không còn victim nào phải ngồi chờ 1 s deadlock_timeout (điểm 6). Trên MySQL thời gian của V3 (9,85 / 13,69 s) không khác V1 có ý nghĩa: InnoDB phát hiện ngay, deadlock ở đây rẻ về thời gian, chỉ đắt về số giao dịch hỏng. Đây là bằng chứng ứng dụng của mục 6.1.

flowchart TD
  A["Cặp ngẫu nhiên (from, to)"] --> V1["V1: khoá from rồi to<br/>theo chiều chuyển tiền"]
  A --> V3["V3: khoá min(id) rồi max(id)<br/>mọi worker cùng một thứ tự"]
  V1 --> C1["W1: 3 rồi 7, W2: 7 rồi 3<br/>có thể khép vòng"]
  V3 --> C3["W1: 3 rồi 7, W2: 3 rồi 7<br/>W2 xếp hàng sau W1"]
  C1 --> R1["MySQL 50 đến 55, PG 133 đến 167<br/>deadlock mỗi 1600 giao dịch"]
  C3 --> R3["0 deadlock, 4/4 lần chạy mỗi biến thể"]

4. "UPDATE nguyên tử" không chống được deadlock; một câu nhiều dòng thì chống được (trong lab này). Nhiều người tin rằng bỏ SELECT … FOR UPDATE, viết thẳng UPDATE … SET balance = balance - ? là an toàn. V4 làm đúng như thế và vẫn có 67/62 deadlock trên MySQL, 125/151 trên PG, cùng bậc với V1. Câu UPDATE lấy đúng loại khoá dòng mà FOR UPDATE lấy, và hai câu UPDATE vẫn chạy theo chiều (from, to) ngẫu nhiên, nên vòng vẫn khép y như cũ. UPDATE nguyên tử chống lost update (mất cập nhật do đọc-sửa-ghi chồng nhau), không chống vòng thứ tự khoá. Ngược lại, V4b gom cả hai dòng vào một câu UPDATE … WHERE id IN (a, b): 0 deadlock trong 4/4 lần chạy (02b), và là biến thể nhanh nhất trên cả hai DB (PG 2,68 / 2,09 s, MySQL 4,66 / 6,33 s). Một câu lệnh khoá hai dòng theo một thứ tự quét mà mọi session đều dùng chung, nên không có lúc nào một transaction giữ một dòng của cặp rồi chờ dòng kia theo chiều ngược lại.

Hai cảnh báo đi kèm V4b. Thứ nhất, "một câu nhiều dòng thì an toàn" không phải quy luật: P2 (mục 2.2) là một câu UPDATE nhiều dòng deadlock 3/3 vì hai câu quét theo hai thứ tự khác nhau (index và heap), M7e/f là IODKU/REPLACE nhiều dòng deadlock khi thứ tự dòng ngược nhau. V4b an toàn ở đây vì hai câu cùng dạng cùng plan; với plan khác hay thứ tự heap khác thì chưa được chứng minh (plan không được ghi lại trong lab này, app/RESULTS.md mục "Not verified"). Thứ hai, chính câu SQL của V4b lần đầu chạy được trên MySQL nhưng hỏng 1600/1600 trên PG:

[pgsql v4one run1] committed=0/1600 failed=1600 {"42883\/7":1600} entries=1600 retries=0 errs_in_closure={"42883\/7":1600} hist={"1":1600} wall=1.39s server={"deadlocks":0} sum_ok=yes log=0 ledger_ok=yes side=[]
ERROR:  operator does not exist: bigint + text
HINT:  No operator matches the given name and argument types. You might need to add explicit type casts.
exit=1 (expected: 42883 on the old SQL)
PREPARE
[pgsql v4one run1 hot=10] committed=1600/1600 failed=0 [] entries=1600 retries=0 errs_in_closure=[] hist={"1":1600} wall=2.68s server={"deadlocks":0} sum_ok=yes log=1600 ledger_ok=yes side=[]
[pgsql v4one run2 hot=10] committed=1600/1600 failed=0 [] entries=1600 retries=0 errs_in_closure=[] hist={"1":1600} wall=2.09s server={"deadlocks":0} sum_ok=yes log=1600 ledger_ok=yes side=[]

Bản đầu viết balance + CASE WHEN id = ? THEN ? ELSE ? END: một CASE mà mọi nhánh đều là tham số trơn. pdo_pgsql gửi tham số không có kiểu, PG suy ra CASE đó là text, và bigint + text không có toán tử (SQLSTATE 42883). Lab tái hiện bằng PREPARE phía server (dòng exit=1), rồi sửa thành (CASE WHEN id = ? THEN -1 ELSE 1 END) * ?: CASE giờ có kiểu số, PREPARE qua, và cả hai lần chạy 1600/1600. Bài học không liên quan tới deadlock nhưng đáng nhớ: SQL "chạy tốt trên MySQL" chưa chắc chạy trên PG khi tham số không có kiểu; cho một nhánh có kiểu hoặc ép kiểu tham số.

flowchart TD
  P["Giao dịch W1: 3 sang 7, W2: 7 sang 3"] --> V4["V4: hai câu UPDATE<br/>UPDATE id 3 ... rồi UPDATE id 7 ...<br/>theo chiều chuyển tiền"]
  P --> V4b["V4b: một câu<br/>UPDATE ... WHERE id IN (3, 7)"]
  V4 --> X["Mỗi câu lấy khoá dòng như FOR UPDATE<br/>W1 giữ 3 chờ 7, W2 giữ 7 chờ 3"]
  X --> XR["Vẫn deadlock: MySQL 67/62, PG 125/151"]
  V4b --> Y["Một câu khoá cả hai dòng<br/>theo cùng thứ tự quét cho mọi session"]
  Y --> YR["0 deadlock 4/4, nhanh nhất<br/>(chưa chứng minh cho plan khác)"]

5. PostgreSQL deadlock nhiều hơn MySQL 2,5–3,5 lần, và deadlock cả ở khoá đầu tiên. Cùng seed, cùng code: V1 MySQL 50–55 so với PG 133–167; V2 52–63 so với 169–191. Lab 02c chạy lại V1 và V4 run 1, ghi lại câu lệnh nào nhận lỗi:

[mysql v1 run1 hot=10] committed=1550/1600 failed=50 {"40001\/1213":50} entries=1600 retries=0 errs_in_closure={"40001\/1213":50} by_stage={"2nd-lock 40001\/1213":50} hist={"1":1600} wall=9.66s server={"deadlocks":50,"timeouts":0} sum_ok=yes log=1550 ledger_ok=yes side=[]
[pgsql v1 run1 hot=10] committed=1450/1600 failed=150 {"40P01\/7":150} entries=1600 retries=0 errs_in_closure={"40P01\/7":150} by_stage={"2nd-lock 40P01\/7":104,"1st-lock 40P01\/7":46} hist={"1":1600} wall=86.18s server={"deadlocks":150} sum_ok=yes log=1450 ledger_ok=yes side=[]
[pgsql v4 run1 hot=10] committed=1461/1600 failed=139 {"40P01\/7":139} entries=1600 retries=0 errs_in_closure={"40P01\/7":139} by_stage={"2nd-update 40P01\/7":120,"1st-update 40P01\/7":19} hist={"1":1600} wall=99.84s server={"deadlocks":139} sum_ok=yes log=1461 ledger_ok=yes side=[]
DBBiến thểcommitdeadlocksở câu khoá thứ nhấtở câu khoá thứ hai
MySQLV11550/160050050 (2nd-lock)
MySQLV41539/160061061 (2nd-update)
PGV11450/160015046 (1st-lock)104
PGV41461/160013919 (1st-update)120

Một transaction đang ở câu khoá thứ nhất chưa giữ khoá dòng nào, vậy mà PG vẫn báo nó nằm trong vòng. DETAIL của mẫu 1st-lock (dòng SAMPLE bên dưới) cho lý do: Process 2070323 waits for AccessExclusiveLock on tuple (3,2) of relation 775487 .... PG xếp những người chờ một dòng đang bận phía sau tuple lock (khoá nặng trên phiên bản dòng) của người chờ đầu tiên. Người chờ đầu tiên giữ tuple lock đó trong lúc chờ xid của chủ dòng (ShareLock on transaction), nên người đến sau chờ nó, và một transaction "chưa giữ gì" trở thành một cạnh trong vòng. Cơ chế tuple lock của người chờ đầu đã thấy ở lab PG (08: S2 giữ tuple ExclusiveLock khi chờ, mục 5.2). MySQL 0/50 và 0/61 ở câu đầu: hàng đợi của InnoDB theo từng bản ghi, người chờ ở câu đầu không giữ thứ gì người khác cần. Phần chênh lệch còn lại (READ COMMITTED vs REPEATABLE READ, thứ tự đánh thức) lab chưa tách được, ghi ở mục 11.

SAMPLE [1st-lock 40P01/7] SQLSTATE[40P01]: Deadlock detected: 7 ERROR:  deadlock detected | DETAIL:  Process 2070321 waits for ShareLock on transaction 770888; blocked by process 2070323. | Process 2070323 waits for AccessExclusiveLock on tuple (3,2) of relation 775487 of database 772132; blocked by process 2070327. | Process 2070327 waits for ShareLock on transaction 770887; blocked by process 2070320. | Process 2070320 waits for AccessExclusiveLock on tuple (3,1) of relation 775487 of database 772132; blocked by process 2070321. | HINT:  See server log for query details. | CONTEXT:  while locking tuple (3,1) in relation "accounts" (Connection: pgsql_lab, Host: my-test-pgdb, Port: 5432, Database: tmp_dl_app, SQL: select * from "accounts" where "id" = 10 limit 1 for update)
flowchart TD
  V["2070321: đang ở câu khoá ĐẦU TIÊN (id 10)<br/>chưa giữ dòng nào, nhưng là người chờ đầu<br/>nên giữ tuple lock (3,1)"] -- "chờ ShareLock xid 770888" --> O["2070323: chủ dòng id 10"]
  O -- "chờ tuple lock (3,2)" --> Q["2070327: người chờ đầu của dòng (3,2)"]
  Q -- "chờ ShareLock xid 770887" --> R["2070320"]
  R -- "chờ tuple lock (3,1), xếp sau người chờ đầu" --> V
  V --> E["Vòng 4 tiến trình, 2070321 nhận 40P01 ở 1st-lock<br/>V1: 46/150 deadlock ở câu đầu, MySQL 0/50"]

6. Ở PostgreSQL, deadlock_timeout chiếm phần lớn thời gian chạy. V1/V2 trên PG mất 84–116 s, MySQL 7–20 s, cho cùng 1600 giao dịch. Lab 06 chạy lại đúng seed của 02, nhưng mỗi worker PG đặt deadlock_timeout ở mức session (server giữ nguyên 1 s):

-- mỗi connection worker PG, ngay sau khi kết nối (cần quyền superuser hoặc quyền SET trên tham số)
SET deadlock_timeout = '100ms';
Cách nói khi phỏng vấn: "Tôi chỉ hạ thời gian chờ trước khi kiểm tra deadlock trong phạm vi từng connection của worker, cấu hình server vẫn để một giây. Mục đích là tách xem bao nhiêu phần thời gian chạy là do chính khoảng chờ phát hiện, chứ không phải để khuyên dùng giá trị này trong production."
server deadlock_timeout (unchanged) = 1000ms
[pgsql v1 run1 hot=10] committed=1450/1600 failed=150 {"40P01\/7":150} entries=1600 retries=0 errs_in_closure={"40P01\/7":150} hist={"1":1600} wall=13.12s server={"deadlocks":150} sum_ok=yes log=1450 ledger_ok=yes side=[]
[pgsql v2 run1 hot=10] committed=1600/1600 failed=0 [] entries=1769 retries=169 errs_in_closure={"40P01\/7":169} hist={"1":1456,"2":121,"3":21,"4":2} wall=17.91s server={"deadlocks":169} sum_ok=yes log=1600 ledger_ok=yes side=[]
[pgsql v1 run2 hot=10] committed=1447/1600 failed=153 {"40P01\/7":153} entries=1600 retries=0 errs_in_closure={"40P01\/7":153} hist={"1":1600} wall=13.87s server={"deadlocks":153} sum_ok=yes log=1447 ledger_ok=yes side=[]
[pgsql v2 run2 hot=10] committed=1600/1600 failed=0 [] entries=1785 retries=185 errs_in_closure={"40P01\/7":185} hist={"1":1453,"2":120,"3":21,"4":1,"5":5} wall=15.57s server={"deadlocks":185} sum_ok=yes log=1600 ledger_ok=yes side=[]
server deadlock_timeout after = 1000ms
Biến thểdeadlock_timeoutcommitdeadlockswall s
V11 s (mặc định; 02 và 02c)1433 / 1467 / 1450167 / 133 / 150103,82 / 84,27 / 86,18
V1100 ms (session)1450 / 1447150 / 15313,12 / 13,87
V21 s (02)1600 / 1600191 / 169116,18 / 106,72
V2100 ms (session)1600 / 1600169 / 18517,91 / 15,57

Khoảng 83–87 % thời gian của V1/V2 trên PG là độ trễ phát hiện: mỗi deadlock bắt victim ngồi đủ deadlock_timeout trước khi được kiểm tra (mục 1.3). Hạ xuống 100 ms không đổi số deadlock (150–185 so với 133–191); nó chỉ làm mỗi deadlock rẻ hơn về thời gian. Đây cũng là lý do số deadlock của PG cao hơn MySQL không phải do bị phát hiện chậm (ý 5).

Có nên hạ deadlock_timeout trong production? Docs PG 18 trả lời gần như không: "The default is one second (1s), which is probably about the smallest value you would want in practice. On a heavily loaded server you might want to raise it." Mỗi lần kiểm tra khoá mọi partition của lock table (research §1.4), và với timeout thấp, mọi lần chờ khoá bình thường dài hơn 100 ms cũng phải trả chi phí đó, không riêng deadlock. Tham số này còn là PGC_SUSET: role ứng dụng thường không đặt được. Hướng đúng là ý 3: V3 bỏ hẳn chi phí đó (4,88–7,47 s) mà không cần đụng tham số nào. Một chỉnh nhỏ hợp lý hơn (suy luận, chưa đo) là hạ nó tạm thời trong một phiên chẩn đoán, như docs gợi ý: "If you are trying to investigate locking delays you might want to set a shorter than normal deadlock_timeout."

Cách nói khi phỏng vấn: "Trên PostgreSQL, phần lớn thời gian của các biến thể có deadlock không nằm ở việc xử lý mà nằm ở một giây chờ trước khi database kiểm tra vòng. Khi tôi hạ con số đó xuống một trăm mili-giây trong session, thời gian chạy giảm từ khoảng một trăm giây xuống khoảng mười lăm giây, nhưng số deadlock không đổi. Tôi không hạ tham số này trên production vì mỗi lần kiểm tra khá đắt và tài liệu khuyên giữ một giây trở lên; tôi sửa thứ tự khoá, và khi không còn deadlock thì cũng không còn ai phải chờ một giây đó."

7. Side effect trong closure bị lặp theo số lần retry; afterCommit thì không. V5 ghi một dòng side_effects qua một connection autocommit riêng, ngay sau khoá thứ nhất, đóng vai một email hay một lời gọi HTTP: thứ mà rollback của transaction chính không thu hồi được.

[mysql v5 run1] committed=1600/1600 failed=0 [] entries=1666 retries=66 errs_in_closure={"40001\/1213":66} hist={"1":1538,"2":58,"3":4} wall=12.05s server={"deadlocks":66,"timeouts":0} sum_ok=yes log=1600 ledger_ok=yes side={"in-closure":1666}
[mysql v5fix run1] committed=1600/1600 failed=0 [] entries=1641 retries=41 errs_in_closure={"40001\/1213":41} hist={"1":1561,"2":37,"3":2} wall=8.37s server={"deadlocks":41,"timeouts":0} sum_ok=yes log=1600 ledger_ok=yes side={"after-commit":1600}
[pgsql v5 run1] committed=1600/1600 failed=0 [] entries=1806 retries=206 errs_in_closure={"40P01\/7":206} hist={"1":1414,"2":169,"3":14,"4":3} wall=126.32s server={"deadlocks":206} sum_ok=yes log=1600 ledger_ok=yes side={"in-closure":1745}
[pgsql v5 run2] committed=1600/1600 failed=0 [] entries=1809 retries=209 errs_in_closure={"40P01\/7":209} hist={"1":1422,"2":151,"3":24,"4":2,"5":1} wall=125.88s server={"deadlocks":209} sum_ok=yes log=1600 ledger_ok=yes side={"in-closure":1756}
[pgsql v5fix run1] committed=1599/1600 failed=1 {"40P01\/7":1} entries=1760 retries=160 errs_in_closure={"40P01\/7":161} hist={"1":1468,"2":111,"3":15,"4":5,"5":1} wall=102.26s server={"deadlocks":161} sum_ok=yes log=1599 ledger_ok=yes side={"after-commit":1599}

Trade-off của afterCommit: nó chuyển bài toán từ "gửi trùng" sang "có thể không gửi". Nếu process chết giữa lúc commit xong và lúc callback chạy, hoặc callback gửi lỗi, side effect mất mà dữ liệu đã commit (suy luận, chưa lab). Với side effect bắt buộc phải xảy ra (email xác nhận thanh toán), cách bền hơn là outbox: ghi "việc cần gửi" vào một bảng trong cùng transaction, worker đọc bảng đó và gửi với khoá idempotency (chạy lặp vẫn cùng kết quả) ở phía nhận. Còn side effect "gửi trùng cũng không sao" (xoá cache, log) thì để trong closure cũng được, miễn là ý thức được nó sẽ chạy theo số lần retry.

sequenceDiagram
  participant W as Worker
  participant DB as Transaction chính
  participant M as Email, connection riêng
  W->>DB: lượt 1, khoá from
  W->>M: V5 gửi email ngay, autocommit
  W->>DB: khoá to
  DB-->>W: deadlock, rollback lượt 1
  W->>DB: lượt 2, khoá from
  W->>M: V5 gửi email lần nữa
  W->>DB: khoá to, ghi, COMMIT
  Note over M: V5 MySQL 1666 email cho 1600 commit
  Note over W,DB: V5 fix đăng ký afterCommit ở mỗi lượt, lượt bị rollback bị bỏ
  W->>M: V5 fix gửi đúng một lần sau COMMIT
Cách nói khi phỏng vấn: "Mọi thứ nằm trong closure của transaction sẽ chạy lại theo mỗi lần retry, còn database chỉ rollback được phần nằm trong nó. Trong lab, việc giả lập gửi email bên trong closure tạo ra một nghìn sáu trăm sáu mươi sáu email cho một nghìn sáu trăm giao dịch trên MySQL, và trên PostgreSQL con số trùng lại khác, vì deadlock rơi vào chỗ khác trong transaction. Đăng ký việc gửi để chạy sau khi commit thì số email bằng đúng số giao dịch thành công. Việc nào bắt buộc phải xảy ra thì tôi dùng bảng outbox ghi cùng transaction."

7.5. DB::transaction($cb, attempts) của Laravel làm gì và không làm gì

Docs Laravel 13 mô tả tham số thứ hai rất ngắn: "The transaction method accepts an optional second argument which defines the number of times a transaction should be retried when a deadlock occurs. Once these attempts have been exhausted, an exception will be thrown". Source thật vừa rộng hơn (retry cả những lỗi không phải deadlock) vừa hẹp hơn (không retry trong transaction lồng). Các đoạn dưới đây là source đã cài trong lab, laravel/framework v13.40.0, file src/Illuminate/Database/Concerns/ManagesTransactions.php (app/out/07-source.log, số đầu dòng là số dòng trong file).

Vòng lặp: mỗi lượt mở transaction, chạy closure; lỗi thì giao cho handleTransactionException rồi continue:

  26      public function transaction(Closure $callback, $attempts = 1)
  28          for ($currentAttempt = 1; $currentAttempt <= $attempts; $currentAttempt++) {
  29              $this->beginTransaction();
  35                  $callbackResult = $callback($this);
  41              catch (Throwable $e) {
  42                  $this->handleTransactionException(
  43                      $e, $currentAttempt, $attempts
  46                  continue;
  54                      $this->getPdo()->commit();

Xử lý lỗi: nhánh transaction lồng đứng trước, nhánh ngoài cùng đứng sau:

  88      protected function handleTransactionException(Throwable $e, $currentAttempt, $maxAttempts)
  90          // On a deadlock, MySQL rolls back the entire transaction so we can't just
  91          // retry the query. We have to throw this exception all the way out and
  92          // let the developer handle it in another way. We will decrement too.
  93          if ($this->causedByConcurrencyError($e) &&
  94              $this->transactions > 1) {
  96                  $this->performRollBack($this->transactions - 1);
  97              } catch (Throwable) {
 101              $this->transactions--;
 107              throw new DeadlockException($e->getMessage(), is_int($e->getCode()) ? $e->getCode() : 0, $e);
 113          $this->rollBack();
 115          if ($this->causedByConcurrencyError($e) &&
 116              $currentAttempt < $maxAttempts) {
 117              return;
 120          throw $e;

Thế nào là "lỗi tranh chấp": src/Illuminate/Database/ConcurrencyErrorDetector.php, cộng với việc không có lệnh ngủ nào trong vòng lặp retry:

  20          if ($e instanceof PDOException && ($e->getCode() === 40001 || $e->getCode() === '40001')) {
  27              'Deadlock found when trying to get lock',
  28              'deadlock detected',
  29              'The database file is locked',
  33              'has been chosen as the deadlock victim',
  34              'Lock wait timeout exceeded; try restarting transaction',
  35              'WSREP detected deadlock/conflict and aborted the transaction. Try restarting the transaction',
  36              'Record has changed since last read in table',
### any sleep/backoff in ManagesTransactions.php? (grep -n -i 'sleep\|backoff\|usleep')
(no match: retries are immediate)
Cách nói khi phỏng vấn: "Hàm transaction của Laravel là một vòng lặp: mỗi lượt mở transaction, chạy closure, lỗi thì rollback, và nếu lỗi được coi là tranh chấp khoá và còn lượt thì chạy lại ngay, không chờ. Lỗi được coi là tranh chấp nếu mã SQLSTATE là bốn không không không một, hoặc thông điệp chứa một trong khoảng mười chuỗi cố định, trong đó có deadlock của MySQL, deadlock của PostgreSQL và lỗi hết giờ chờ khoá của MySQL. Riêng khi đang ở trong một transaction lồng, nó không retry mà rollback về savepoint rồi ném một DeadlockException ra ngoài."

Những gì nó làm (đọc từ source, đối chiếu lab):

  1. Retry theo hai đường nhận dạng. Đường mã: PDOException có code 40001 (số hoặc chuỗi), tức MySQL 1213 và PG serialization failure. Đường thông điệp: chuỗi con trong danh sách, gồm deadlock detected (PG 40P01, vì mã của nó không phải 40001), Lock wait timeout exceeded; try restarting transaction (MySQL 1205), và các thông điệp của SQLite, SQL Server, Galera, MariaDB. Lab xác nhận: 40P01 và 1213 retry ở 7.1/7.4, 1205 retry ở 7.2, PG 40001 ở cấp ngoài cùng retry (COMMITTED after 2 closure entries, delta: 11, 2/2).

  2. Rollback cả transaction trước khi retry (dòng 113). Nhờ vậy 1205 của MySQL không thành commit dở dang (7.2).

  3. Retry cả lỗi lúc COMMIT (handleCommitTransactionException, dòng 238–261: lỗi tranh chấp lúc commit và còn lượt thì rollback PDO rồi chạy lại). Lab không có kịch bản nào lỗi ở commit (ví dụ PG SERIALIZABLE), nên nhánh này chưa verify.

  4. Mặc định $attempts = 1 (dòng 26): không truyền tham số thì không có retry nào. V1 ở 7.4 là đúng cái mặc định đó.

Những gì nó không làm:

1. Không giãn cách giữa các lượt. Grep cả file không có sleep/backoff/usleep; lượt sau gọi beginTransaction() ngay lập tức. Mục 7.6 đo xem điều này có quan trọng không.

2. Không retry lock_timeout của PostgreSQL. Cùng kịch bản 7.2 trên PG: S2 đặt lock_timeout = '1s' cho session, DB::transaction($cb, 3) (app/out/04-lockwait.log, PG rep 1/2):

[t=-0.28s][S2] SET lock_timeout = '1s'   (session level)
[t=+0.20s][S2] closure entered (attempt #1)
[t=+1.21s][S2]   !! 55P03/7 SQLSTATE[55P03]: Lock not available: 7 ERROR:  canceling statement due to lock timeout | CONTEXT:  while locking tuple (0,1) in relation "accounts" (Connection: pgsql_lab, Host: my-test-pgdb, Port: 5432, Database: tmp_dl_app, SQL: select...
[t=+1.21s][S2] THROWN to caller after 1 closure entries: QueryException code='55P03' laravelSaysConcurrency=false SQLSTATE[55P03]: Lock not available: 7 ERROR:  canceling statement due to lock timeout | CONTEXT:  while locking tuple (0,1) in relation "accounts" (Connecti...
SUMMARY {"who":"S2","outcome":"thrown:QueryException","closure_entries":1,"inner_entries":0}

Thông điệp canceling statement due to lock timeout không có trong danh sách và SQLSTATE là 55P03, không phải 40001, nên Laravel coi đây là lỗi thường: rollback, ném lên sau đúng 1 lượt, 2/2. Có thể đó là điều mình muốn (lock_timeout là "tôi không muốn chờ", retry ngay thì chờ lại), có thể không; điểm quan trọng là cùng code cho hai hành vi khác nhau trên hai DB (MySQL 1205 thì retry). Muốn thống nhất thì tự viết detector (điểm 5).

3. Không retry ở transaction lồng; chỉ cấp ngoài cùng retry được. Nhánh $this->transactions > 1 (dòng 93–107) không bao giờ return để chạy lại: nó rollback về savepoint, giảm level, và ném DeadlockException. Code comment giải thích lý do bằng chính hành vi của MySQL: "On a deadlock, MySQL rolls back the entire transaction so we can't just retry the query." Lab 03, chế độ nested: DB::transaction(fn () => DB::transaction($inner, 5), 1), cùng kịch bản khoá ngược chiều như 7.1 (PG rep 1/2):

[t=+0.02s][S1] outer closure entered (outer attempt #1), transactionLevel=1
[t=+0.02s][S1] inner closure entered (inner attempt #1), transactionLevel=2
[t=+1.55s][S1] THROWN to caller: DeadlockException code=0 SQLSTATE[40P01]: Deadlock detected: 7 ERROR:  deadlock detected | DETAIL:  Process 2045728 waits for ShareLock on transaction 723204; blocked by process 2045729. | Process 2045729 waits for ShareLo... (outer entries=1, inner entries=1)
SUMMARY S1 outcome=thrown:DeadlockException outer_entries=1 inner_entries=1

attempts = 5 của closure bên trong không có tác dụng gì: inner entries=1, caller nhận DeadlockException code=0. Giống hệt trên MySQL (2/2 mỗi DB). Đổi cấp ngoài thành attempts = 3 thì chính closure ngoài được chạy lại, kéo theo closure trong:

[t=+1.54s][S1] outer closure entered (outer attempt #2), transactionLevel=1
[t=+1.54s][S1] inner closure entered (inner attempt #2), transactionLevel=2
[t=+2.09s][S1] COMMITTED (outer entries=2, inner entries=2)
balance deltas (id=>delta): {"1":0,"2":0,"3":0,"4":0,"5":0,"6":0,"7":0,"8":0,"9":0,"10":0}  sum_delta=0

Hệ quả thiết kế: tham số attempts chỉ có nghĩa ở DB::transaction ngoài cùng. Một service method tự gói DB::transaction($cb, 5) trông có vẻ "tự retry được", nhưng khi controller hay job gọi nó bên trong một transaction khác thì retry đó biến mất. Retry phải được quyết định ở nơi sở hữu ranh giới transaction ngoài cùng (controller, job handler), không ở từng repository.

4. Bắt DeadlockException bên trong transaction ngoài: hỏng dữ liệu, mỗi DB hỏng một kiểu. Chế độ nested-catch: closure ngoài ghi marker A (+100 vào một tài khoản riêng), gọi closure trong, bắt exception của nó, ghi marker B (+100), rồi commit. Nếu transaction ngoài là all-or-nothing thì kết quả hợp lệ duy nhất là "A và B cùng +100, closure trong được áp" hoặc "không có gì". PostgreSQL rep 1/2:

[t=+0.02s][S1] UPDATE accounts SET balance = balance + 100 WHERE id = 5   (outer write before inner)
[t=+1.53s][S1] outer CAUGHT DeadlockException code=0; now transactionLevel=1 pdo->inTransaction()=true
[t=+1.54s][S1] UPDATE accounts SET balance = balance + 100 WHERE id = 6   (outer write after the inner call / catch)
[t=+1.54s][S1] COMMITTED (outer entries=1, inner entries=1)
balance deltas (id=>delta): {"1":1,"2":-1,"3":0,"4":0,"5":100,"6":100,"7":100,"8":100,"9":0,"10":0}  sum_delta=400
markers (expected +100 each if the outer txn is all-or-nothing and committed): S1 before=id5:+100 after=id6:+100 | S2 before=id7:+100 after=id8:+100

PG chỉ huỷ subtransaction (ROLLBACK TO SAVEPOINT hợp lệ trên PG), nên transaction ngoài commit không có phần việc của closure trong: S1 có A +100 và B +100 nhưng giao dịch chuyển 1→2 của nó không tồn tại ("1":1,"2":-1 chỉ là giao dịch của S2). Một nghiệp vụ bị commit thiếu, không có lỗi nào lên tới caller. MySQL rep 1/2:

[t=+0.12s][S2] UPDATE accounts SET balance = balance + 100 WHERE id = 7   (outer write before inner)
[t=+0.67s][S2]   !! 40001/1213 SQLSTATE[40001]: Serialization failure: 1213 Deadlock found when trying to get lock; try restarting transaction (Connection: mysql_lab, Host: tmp-dl-app-mysql, Port: 3306, Database: tmp_dl_app, SQL: select * from `accounts` where `id` = 1 limit 1 for update)
[t=+0.67s][S2] outer CAUGHT DeadlockException code=0; now transactionLevel=1 pdo->inTransaction()=true
[t=+0.68s][S2] UPDATE accounts SET balance = balance + 100 WHERE id = 8   (outer write after the inner call / catch)
[t=+0.69s][S2] THROWN to caller: PDOException code=0 There is no active transaction (outer entries=1, inner entries=1)
balance deltas (id=>delta): {"1":-1,"2":1,"3":0,"4":0,"5":100,"6":100,"7":0,"8":100,"9":0,"10":0}  sum_delta=300
markers (expected +100 each if the outer txn is all-or-nothing and committed): S1 before=id5:+100 after=id6:+100 | S2 before=id7:+0 after=id8:+100

InnoDB đã rollback cả transaction lúc 1213, nên marker A của S2 mất (id7:+0). Laravel vẫn tin mình ở level 1 và PDO vẫn báo inTransaction()=true, nhưng server đã về autocommit: câu UPDATE marker B tự commit (id8:+100) dù caller cuối cùng nhận There is no active transaction. Tức là caller thấy lỗi, nghĩ là "không có gì được ghi", trong khi một nửa nghiệp vụ đã nằm vĩnh viễn trong DB. Quan sát 2/2. Cơ chế "session về autocommit sau rollback của InnoDB, PDO không biết" là suy luận từ hệ quả, chưa kiểm tra bằng INNODB_TRX trong lab app (lab SQL A1 ở mục 4.2 thấy cùng hành vi ở mức server).

Chế độ (lab 03, 2/2 mỗi ô)PostgreSQL 18.4MySQL 8.4.13
flat: DB::transaction($inner, 5)victim retry, cả hai commit, sum_delta 0như PG
nested, ngoài attempts 1trong không retry; caller nhận DeadlockException code=0như PG
nested, ngoài attempts 3closure ngoài chạy lại (outer entries=2), cả hai commitnhư PG
nested-catchngoài COMMIT: A +100, B +100, phần trong mất lặng lẽ (sum_delta 400)A mất, B tự commit, caller nhận There is no active transaction (sum_delta 300)

Quy tắc rút ra: không bao giờ bắt lỗi tranh chấp khoá bên trong một transaction còn mở rồi đi tiếp. Muốn xử lý thì để exception đi ra khỏi DB::transaction ngoài cùng, và retry ở đó.

flowchart TD
  O["DB::transaction(outer, attemptsNgoai)"] --> I["DB::transaction(inner, 5)<br/>level 2 = savepoint"]
  I --> E["Deadlock / lỗi tranh chấp ở level 2"]
  E --> S["Laravel: rollback về savepoint (lỗi bị nuốt)<br/>level giảm 1, ném DeadlockException code=0<br/>KHÔNG retry inner"]
  S --> C{"Closure ngoài có bắt exception?"}
  C -- "có, rồi đi tiếp" --> BAD["PG: commit thiếu phần trong<br/>MySQL: phần trước mất, phần sau tự commit, lỗi khi commit"]
  C -- "không" --> T{"Cấp ngoài còn lượt?<br/>và detector nhận ra lỗi?"}
  T -- "có" --> R["Chạy lại cả closure ngoài"]
  T -- "không" --> U["Ném lên caller"]

5. PG 40001 ở transaction lồng không được cấp ngoài retry, và cách vá. Dòng 107 tạo new DeadlockException($e->getMessage(), is_int($e->getCode()) ? $e->getCode() : 0, $e). SQLSTATE của PDO là chuỗi '40001', không phải số, nên code của exception mới thành 0. Ở cấp ngoài, detector kiểm tra lại: code 0 không phải 40001, và thông điệp PG could not serialize access due to concurrent update không có trong danh sách. Deadlock thì vẫn qua được vì thông điệp (deadlock detected, Deadlock found) có trong danh sách; serialization failure của PG thì không. Lab 04 dựng đúng trường hợp: S1 giữ dòng 1 trong 1 s, S2 mở transaction REPEATABLE READ, UPDATE cùng dòng ở level 2, attempts = 3 ở cấp ngoài:

[t=+0.22s][S2] REPEATABLE READ snapshot taken (read id=2 -> 1000000)
[t=+0.40s][S2]   [level 2] UPDATE accounts SET balance = balance + 10 WHERE id = 1
[t=+1.03s][S1] COMMIT
[t=+1.04s][S2]   !! 40001/7 SQLSTATE[40001]: Serialization failure: 7 ERROR:  could not serialize access due to concurrent update (Connection: pgsql_lab, Host: my-test-pgdb, Port: 5432, Database: tmp_dl_app, SQL: update accounts set balance = balance + 10 where id ...
[t=+1.06s][S2] THROWN to caller after 1 closure entries: DeadlockException code=0 laravelSaysConcurrency=false SQLSTATE[40001]: Serialization failure: 7 ERROR:  could not serialize access due to concurrent update (Connection: pgsql_lab, Host: my-test-pgdb, Port: 5432,...
balance id=1 delta: 1

laravelSaysConcurrency=false và delta: 1: chỉ +1 của S1 được áp, +10 của S2 mất, dù cấp ngoài còn 2 lượt (2/2). Không lồng thì cùng lỗi đó được retry bình thường (COMMITTED after 2 closure entries, delta: 11). Trên MySQL kịch bản này không sinh lỗi nào (REPEATABLE READ của InnoDB cho UPDATE chờ rồi áp lên bản mới, delta 11), nên lỗ hổng này chỉ tái hiện được trên PG. Container của Laravel cho phép thay detector qua contract Illuminate\Contracts\Database\ConcurrencyErrorDetector (dòng 21–23 của DetectsConcurrencyErrors.php trong 07-source.log). Bản vá của lab:

// app/src/app/Lab/LabConcurrencyErrorDetector.php
class LabConcurrencyErrorDetector implements \Illuminate\Contracts\Database\ConcurrencyErrorDetector
{
    public function causedByConcurrencyError(Throwable $e): bool
    {
        $default = new \Illuminate\Database\ConcurrencyErrorDetector;

        return $default->causedByConcurrencyError($e)
            || ($e instanceof \Illuminate\Database\DeadlockException)              // đã được level trong phân loại
            || ($e->getPrevious() !== null && $default->causedByConcurrencyError($e->getPrevious()));
    }
}

// đăng ký (lab làm trong command; app thật đặt trong một ServiceProvider)
app()->bind(\Illuminate\Contracts\Database\ConcurrencyErrorDetector::class, \App\Lab\LabConcurrencyErrorDetector::class);
Cách nói khi phỏng vấn: "Khi lỗi xảy ra trong transaction lồng, Laravel bọc nó lại thành một DeadlockException mà mã lỗi bị đặt về không, nên ở cấp ngoài nó chỉ còn nhận ra được bằng thông điệp. Deadlock thì thông điệp có trong danh sách, còn lỗi serialization của PostgreSQL thì không, nên nó không được retry. Tôi thay bộ nhận dạng lỗi qua container: coi chính DeadlockException là lỗi tranh chấp, và kiểm tra cả exception gốc nằm bên trong. Với bản vá đó, lab chạy lại được và ghi đủ cả hai cập nhật."
== dl:lockwait db=pgsql mode=serialization attempts=3 timeout=1s hold=1s nested=yes detector=LabConcurrencyErrorDetector
[t=+1.02s][S2]   !! 40001/7 SQLSTATE[40001]: Serialization failure: 7 ERROR:  could not serialize access due to concurrent update (Connection: pgsql_lab, Host: my-test-pgdb, Port: 5432, Database: tmp_dl_app, SQL: update accounts set balance = balance + 10 where id ...
[t=+1.03s][S2] closure entered (attempt #2)
[t=+1.03s][S2] COMMITTED after 2 closure entries
balance id=1 delta: 11

Với detector tự viết: retry, commit sau 2 lượt, delta: 11, 2/2. Trade-off: detector này coi mọi DeadlockException là retry được ở cấp ngoài, kể cả loại mà Laravel tạo từ một lỗi tranh chấp khác trong tương lai; nó rộng hơn mặc định, và vì thế phải đi cùng giới hạn số lượt hợp lý (7.6). Cũng nên đặt detector cùng nơi với danh sách mã lỗi của mẫu ở 7.1 để chỉ có một định nghĩa "lỗi retry được" trong cả codebase.

6. Phụ thuộc vào thông điệp tiếng Anh. Deadlock của PG (40P01) chỉ được nhận ra qua chuỗi deadlock detected. Nếu server PG trả thông điệp đã dịch (lc_messages khác tiếng Anh) thì đường nhận dạng này hỏng; research ghi đây là suy luận từ source, lab chưa verify. Detector tự viết kiểm tra errorInfo[0] === '40P01' sẽ không phụ thuộc ngôn ngữ.

7. Không làm side effect idempotent hộ mình. Toàn bộ closure chạy lại, gồm mọi HTTP call, job dispatch thẳng, ghi cache (7.4, ý 7). Ngoài transaction, source cũng cho thấy detector được dùng lại ở Cache/DatabaseLock.php và Queue/DatabaseQueue.php (07-source.log, mục "who else uses"), tức là cache lock và database queue của Laravel cũng tự phân loại lỗi khoá theo cùng danh sách; lab không đo hai chỗ đó.

7.6. Backoff, jitter và giới hạn số lần retry

Backoff (giãn cách chờ giữa các lần thử) và jitter (cộng một độ lệch ngẫu nhiên vào khoảng chờ) là lời khuyên mặc định cho mọi retry. Bài kinh điển của AWS (vendor blog, research §6.1) viết "The solution isn’t to remove backoff. It’s to add jitter." và so sánh các kiểu jitter: "The “Full Jitter” approach uses less work, but slightly more time. Both approaches, though, present a substantial decrease in client work and server load." Laravel DB::transaction không có backoff (7.5). Muốn có thì bọc từ bên ngoài bằng helper retry(); lab V2b làm như sau:

// app/src/app/Console/Commands/DlRun.php, biến thể v2backoff
retry(
    5,                                                    // tối đa 5 lượt, giống V2
    fn () => DB::transaction($cb, 1),                     // MỖI lượt là một transaction mới, attempts 1
    fn (int $retry) => random_int(0, 10 * 2 ** ($retry - 1)),   // full jitter: 0-10, 0-20, 0-40, 0-80 ms
    fn (\Throwable $e) => Lab::isConcurrencyError($e)     // chỉ retry lỗi tranh chấp (dùng detector của Laravel)
);
Cách nói khi phỏng vấn: "Vì hàm transaction của Laravel thử lại ngay lập tức, tôi bọc nó bằng helper retry. Mỗi lượt là một transaction mới chỉ chạy một lần. Giữa hai lượt tôi chờ một khoảng ngẫu nhiên từ không tới một trần tăng gấp đôi mỗi lần, mười, hai mươi, bốn mươi rồi tám mươi mili-giây. Và tôi chỉ thử lại khi lỗi đúng là tranh chấp khoá, lỗi khác thì ném ra ngay. Thứ tự lồng quan trọng: retry nằm ngoài, transaction nằm trong."

Thứ tự lồng ở đây là điểm chính: retry() ở ngoài, DB::transaction($cb, 1) ở trong, để mỗi lượt là một transaction mới, đúng 7.1. Ngược lại (retry bên trong closure của transaction) thì lượt sau chạy trong một transaction đã hỏng (PG) hoặc đã không còn (MySQL). Tutorial Spring mà research trích (nguồn cộng đồng) nói cùng ý theo kiểu của Spring: đặt @Retryable ưu tiên cao hơn @Transactional để retry bao ngoài transaction, với Backoff(random = true, delay = 1000, maxDelay = 3000).

Ở mức tranh chấp bình thường (10 dòng nóng, 7.4): không đo được khác biệt. V2b so với V2: MySQL 81/48 retry so với 63/52, PG 186/174 so với 191/169, tất cả 1600/1600. Chênh lệch giữa hai lần chạy của cùng một biến thể lớn hơn chênh lệch giữa hai biến thể.

Ở mức tranh chấp cao: vẫn không. Lab 05 dồn 8 worker × 50 giao dịch vào 3 dòng nóng, 5–20 ms giữa hai khoá, attempts 5, 2 lần chạy (app/out/05-backoff-stress.log):

[mysql v2 run1 hot=3] committed=394/400 failed=6 {"40001\/1213":6} entries=646 retries=246 errs_in_closure={"40001\/1213":252} hist={"1":248,"2":100,"3":26,"4":10,"5":16} wall=9.40s server={"deadlocks":252,"timeouts":0} sum_ok=yes log=394 ledger_ok=yes side=[]
[mysql v2backoff run1 hot=3] committed=394/400 failed=6 {"40001\/1213":6} entries=644 retries=244 errs_in_closure={"40001\/1213":250} hist={"1":253,"2":84,"3":42,"4":8,"5":13} wall=11.90s server={"deadlocks":250,"timeouts":0} sum_ok=yes log=394 ledger_ok=yes side=[]
[pgsql v2 run1 hot=3] committed=307/400 failed=93 {"40P01\/7":93} entries=1158 retries=758 errs_in_closure={"40P01\/7":851} hist={"1":116,"2":82,"3":53,"4":26,"5":123} wall=346.52s server={"deadlocks":851} sum_ok=yes log=307 ledger_ok=yes side=[]
[pgsql v2backoff run1 hot=3] committed=327/400 failed=73 {"40P01\/7":73} entries=1068 retries=668 errs_in_closure={"40P01\/7":741} hist={"1":123,"2":92,"3":66,"4":32,"5":87} wall=310.62s server={"deadlocks":741} sum_ok=yes log=327 ledger_ok=yes side=[]
DBBiến thểcommit (/400)faildeadlocksretrieshistogram run 1wall s
MySQLV2 retry ngay394 / 3966 / 4252 / 238246 / 2341:248 2:100 3:26 4:10 5:169,40 / 10,64
MySQLV2b jitter 0–10..80 ms394 / 3956 / 5250 / 232244 / 2271:253 2:84 3:42 4:8 5:1311,90 / 10,89
PGV2307 / 33693 / 64851 / 746758 / 6821:116 2:82 3:53 4:26 5:123346,52 / 296,92
PGV2b327 / 32273 / 78741 / 861668 / 7831:123 2:92 3:66 4:32 5:87310,62 / 392,42

Ba điều đọc được. (1) Năm lượt không đủ dưới hot spot: PG fail 64–93 trên 400 giao dịch (16–23 %), và cột 5: của histogram (123, 87) nghĩa là cả trăm giao dịch dùng hết lượt cuối. MySQL fail 4–6 trên 400. (2) Jitter ngắn không tạo khác biệt nhất quán trên DB nào: PG V2b run 1 tốt hơn V2 run 1 (73 so với 93 fail) nhưng run 2 tệ hơn (78 so với 64); khoảng chênh giữa hai run lớn hơn khoảng chênh giữa hai biến thể. (3) Bất biến vẫn giữ 8/8 (sum_ok=yes ... ledger_ok=yes): retry không làm hỏng dữ liệu, nó chỉ không đủ.

Vì sao jitter không giúp ở đây, và khi nào nó giúp. Phần này là suy luận từ cơ chế, chưa có lab tách riêng. Jitter chữa một bệnh cụ thể: các bên thua đồng bộ với nhau, cùng thất bại một lúc rồi cùng thử lại một lúc, nên lại đụng nhau đúng như cũ. Trong lab này hai yếu tố đã phá sự đồng bộ đó sẵn: cặp tài khoản ngẫu nhiên, và khoảng ngủ ngẫu nhiên 1–20 ms giữa hai khoá. Vòng khép lại vì thứ tự khoá (from, to) ngẫu nhiên, không vì thời điểm thử lại trùng nhau, nên dời thời điểm thử lại không gỡ được nguyên nhân. Trên PG còn thêm một lý do: mỗi deadlock đã tốn ít nhất 1 s deadlock_timeout trước khi victim được báo lỗi (7.4, ý 6), nên vài chục mili-giây jitter là nhỏ so với nhịp tự nhiên đó. Jitter đáng tiền khi lỗi đến theo đợt: nhiều client cùng bị từ chối khi một holder lớn nhả khoá, một batch worker khởi động cùng giây, hay một tài nguyên dùng optimistic concurrency mà mọi client cùng đọc rồi cùng ghi. Moodle core cũng dùng nó đúng chỗ đó: db_record_lock_factory lặp UPDATE {lock_db} ... với usleep(rand(10000, 250000)), tức ngủ ngẫu nhiên 10–250 ms giữa các lần giành khoá (research §4.2).

Ngân sách retry. Số lượt không phải con số đẹp (3 hay 5) mà là hệ quả của thời gian tối đa mình chịu được cho một thao tác. Ước lượng cho từng loại lỗi (phép nhân từ cơ chế, mỗi hệ số lấy từ lab):

Từ đó, một chính sách hợp lý (ví dụ minh hoạ, không phải số đo): request web 3 lượt với jitter vài chục mili-giây, chặn bởi timeout của request; job nền thì đừng retry nhiều lượt bên trong worker mà để queue lo, vì queue của Laravel đã có --backoff (docs: "you may specify how many seconds Laravel should wait before retrying a job that has encountered an exception") và nhả worker cho việc khác trong lúc chờ. Quan trọng hơn con số là hai metric đi kèm (mục 5.4): số lượt retry trên mỗi commit và số lần cạn lượt. Lượt retry tăng dần theo tuần là dấu hiệu tranh chấp đang lớn lên; cạn lượt là lỗi người dùng nhìn thấy. Khi một trong hai tăng, việc cần làm là quay lại mục 6 tìm vòng thứ tự khoá, như V3/V4b đã làm từ 133–167 deadlock xuống 0, chứ không phải tăng attempts.

flowchart TD
  E["Lỗi tranh chấp khoá"] --> T{"Loại lỗi?"}
  T -- "PG 40P01" --> P["Mỗi lượt thua tốn ≥ deadlock_timeout (1 s)"]
  T -- "MySQL 1213" --> M["Mỗi lượt thua chỉ tốn phần việc đã làm"]
  T -- "MySQL 1205" --> L["Mỗi lượt tốn đủ innodb_lock_wait_timeout<br/>(mặc định 50 s: hạ cho đường web)"]
  P --> B["attempts × chi phí mỗi lượt ≤ timeout của request/job"]
  M --> B
  L --> B
  B --> J["Jitter giữa các lượt<br/>giúp khi lỗi đến theo đợt đồng bộ"]
  J --> X{"Retry/commit tăng hoặc cạn lượt?"}
  X -- "có" --> F["Quay lại mục 6: sửa thứ tự khoá, hot row<br/>(V3, V4b: 0 deadlock)"]
  X -- "không" --> K["Giữ nguyên, theo dõi"]
Cách nói khi phỏng vấn: "Tôi không chọn số lần retry theo cảm tính mà nhân ngược từ thời gian tối đa của thao tác. Một deadlock trên PostgreSQL tốn ít nhất một giây trước khi bên thua biết, MySQL thì gần như ngay, còn lỗi hết giờ chờ khoá của MySQL tốn trọn thời gian chờ, mặc định năm mươi giây. Trong lab, chờ ngẫu nhiên giữa các lượt không cải thiện gì khi chỉ có ba dòng nóng, vì vòng khoá đến từ thứ tự khoá chứ không phải từ việc các bên thử lại cùng lúc. Khi số lượt retry tăng hay bắt đầu có giao dịch cạn lượt, tôi coi đó là tín hiệu sửa thiết kế chứ không tăng số lượt."

7.7. Khi nào giết session đang chặn

Một deadlock thật không bao giờ cần ai giết session. Database tự phá vòng: InnoDB ngay lúc xin khoá, PG sau deadlock_timeout. Khoá của victim được thả đúng lúc báo lỗi, không phải lúc app gửi ROLLBACK (P1, mục 2.1: lỗi của S1 và câu đang chờ của S2 cùng trả về ở 1,60 s). Giết tay một thành viên của vòng chỉ thay một lỗi victim sạch (app biết retry) bằng một lỗi mất connection (app thường không retry), và thường chậm hơn chính detector (suy luận). Thứ người trực gặp lúc 3 giờ sáng và tưởng là deadlock thường là một chuỗi chờ khoá mà đầu chuỗi không tự kết thúc. Lab đã có ba dạng đầu chuỗi như vậy:

  1. Connection idle in transaction (mở transaction, ghi, rồi ngồi yên). PG lab 08: S1 idle in transaction, ClientRead, chặn UPDATE của S2 (mục 5.2). MySQL lab 09: blocking_query: None, processlist Sleep, trx_rows_modified 1 (mục 5.3). Không có câu lệnh nào đang chạy để huỷ; holder chỉ kết thúc khi app của nó commit/rollback, hoặc khi connection bị đóng. View sys.innodb_lock_waits gợi ý sẵn cả KILL QUERY 367 lẫn KILL 367; với holder đang Sleep, chỉ lệnh thứ hai có tác dụng vì lệnh thứ nhất không có câu nào để huỷ (kết luận của mysql/RESULTS.md từ trạng thái quan sát được; lab không chạy lệnh kill).

  2. Session-level advisory lock còn sống sau 40P01 (P7-B, mục 2.7): victim nhận lỗi nhưng vẫn giữ khoá 1, bên "thắng" tiếp tục chờ cho tới khi victim gọi pg_advisory_unlock_all() (S2 trả về sau 3,00 s). Nếu code của victim không có finally nhả khoá, session đó là holder vô thời hạn.

  3. DDL đứng trong hàng đợi (P6-B, mục 2.6 và 6.8): ALTER TABLE chờ một transaction đọc đang idle, và mọi câu SELECT đến sau phải xếp hàng sau chính câu ALTER đó. Ở đây thứ cần huỷ là câu DDL, không phải người đọc; lab dùng lock_timeout = '2s' và S3 được thả đúng lúc DDL bỏ cuộc (S3 chờ 1,60 s thay vì 3,21 s).

Quy trình trước khi kill. Tìm đầu chuỗi chứ không kill người đang chờ: PG dùng pg_blocking_pids() và pg_stat_activity, MySQL dùng sys.innodb_lock_waits (truy vấn đầy đủ ở mục 5.2 và 5.3). Mẫu truy vấn "ai là gốc, đã idle bao lâu" và lệnh kill tương ứng (lab chỉ chạy các view, chưa chạy lệnh kill):

-- PostgreSQL: các session đang chặn người khác, kèm tuổi transaction và thời gian ngồi yên
SELECT pid, usename, application_name, client_addr, state,
       now() - xact_start   AS xact_age,
       now() - state_change AS idle_for,
       left(query, 80)      AS last_query
  FROM pg_stat_activity
 WHERE pid IN (SELECT unnest(pg_blocking_pids(pid)) FROM pg_stat_activity)
 ORDER BY xact_start;

SELECT pg_terminate_backend(12345);   -- đóng connection của holder (lab chưa chạy lệnh này)

-- MySQL: đầu chuỗi theo sys.innodb_lock_waits (mục 5.3), rồi đóng connection của holder
SELECT blocking_pid, blocking_query, blocking_trx_age, blocking_trx_rows_modified,
       sql_kill_blocking_connection
  FROM sys.innodb_lock_waits;
KILL 367;                             -- KILL QUERY không có tác dụng khi holder đang Sleep
Cách nói khi phỏng vấn: "Trước khi giết một session, tôi tìm cho đúng đầu chuỗi chặn, chứ không giết những người đang xếp hàng phía sau. Tôi ghi lại nó là ai, ứng dụng nào, transaction đã mở bao lâu và câu cuối là gì, vì sau khi giết thì những bằng chứng đó mất. Với một connection đang ngồi yên trong transaction thì huỷ câu lệnh không có tác dụng, phải đóng hẳn connection, và mọi thứ nó đã ghi trong transaction sẽ bị rollback."

Quyết định kill: ba câu hỏi. (1) Holder có đang làm việc hợp lệ không? Một migration, một backup, một job báo cáo đang chạy dài là việc có chủ; kill nó có thể tệ hơn chờ (một migration chết giữa chừng phải xử lý lại bằng tay; sự cố GitLab ở mục 9 là một migration deadlock đã chặn deploy). (2) Holder có idle không? state = idle in transaction (PG) hay Sleep + blocking_query rỗng (MySQL) mà tuổi transaction tính bằng phút là gần như chắc lỗi app (quên commit, gọi mạng giữa transaction, connection bị trả về pool khi còn transaction). (3) Giá của rollback: transaction bị kill mất toàn bộ việc đã ghi mà chưa commit (ngữ nghĩa cơ bản của transaction; lab không chạy lệnh kill), và app của nó nhận lỗi connection, không phải lỗi deadlock; app có tự kết nối lại và chạy lại không thì phụ thuộc pool và code của nó (lab chưa verify). Nếu cả ba nghiêng về "kill", ghi lại bằng chứng (pid, user, application_name, client, tuổi, câu cuối) rồi mới kill, và mở ticket sửa nguồn trong cùng ngày, vì holder idle sẽ quay lại.

Lưới an toàn để không phải kill tay. PG 18 có idle_in_transaction_session_timeout: "Terminate any session that has been idle (that is, waiting for a client query) within an open transaction for longer than the specified amount of time." và "This option can be used to ensure that idle sessions do not hold locks for an unreasonable amount of time."; cùng với transaction_timeout ("Terminate any session that spans longer than the specified amount of time in a transaction."). Cả hai mặc định 0 (tắt). Đặt theo role của ứng dụng web (ví dụ minh hoạ: ALTER ROLE app_web SET idle_in_transaction_session_timeout = '30s') chứ không đặt toàn server, vì role chạy migration hay báo cáo có thể cần giữ transaction lâu hơn. Hành vi thật của hai biến này chưa verify trong lab (đã ghi ở 6.4), và phía MySQL research không ghi nhận biến tương đương để giới hạn thời gian của holder; MySQL chỉ có innodb_lock_wait_timeout giới hạn người chờ.

flowchart TD
  A["Alert: lỗi khoá tăng / request treo"] --> B{"Counter deadlock tăng?"}
  B -- "có" --> C["Deadlock thật: DB tự phá vòng<br/>KHÔNG kill. Kiểm tra retry, sửa thứ tự (mục 6)"]
  B -- "không, toàn lock wait / 1205 / 55P03" --> D["Tìm đầu chuỗi: pg_blocking_pids<br/>hoặc sys.innodb_lock_waits"]
  D --> E{"Đầu chuỗi là gì?"}
  E -- "DDL đang chờ (P6-B)" --> F["Huỷ DDL, chạy lại ngoài giờ<br/>với lock_timeout"]
  E -- "idle in transaction / Sleep" --> G["Ghi bằng chứng, rồi<br/>pg_terminate_backend / KILL id"]
  E -- "Job hợp lệ đang chạy dài" --> H["Cân nhắc chờ, hạ tải phía chờ<br/>không kill vội"]
  E -- "Advisory lock session-level còn giữ (P7-B)" --> G
  G --> I["Ticket sửa nguồn + idle_in_transaction_session_timeout theo role"]

8. Áp vào LMS Uni (Moodle + MySQL)

Mục này nối các cơ chế ở trên với hệ thống thật trong CV. Nguồn: phần LMS Uni của cv-v3.md (stack, topology, số liệu), source Moodle MOODLE_405_STABLE đối chiếu MOODLE_39_STABLE và tracker Moodle mà research đã đọc (research §4), cùng một sự thật Minato đã xác nhận về lock factory. Không có lab nào chạy trên Moodle; mọi hành vi Moodle dưới đây là đọc source, ghi rõ chỗ nào là suy luận.

8.1. Stack và chỗ nào dễ deadlock

Stack theo CV. LMS Uni (2020–2024) là hệ thống đào tạo cử nhân từ xa cho hơn 100.000 sinh viên làm quiz, assignment và học trực tuyến, cao điểm khoảng 400 request/giây. Stack: Moodle (PHP), NestJS, React, TypeScript, MySQL, Redis, Nginx. Sau giai đoạn scale out: Nginx load balancer trước 5 app node Moodle, MySQL primary/replica tách đọc/ghi với ProxySQL đứng trước, NFS cho moodledata, Redis tách instance riêng cho session và cache (MUC). Plugin tự viết dùng DML ($DB), event observer, scheduled/adhoc task, và external function (web service) để student portal "ghi dữ liệu quan trọng (nộp bài, ghi điểm) qua Moodle thay vì ghi thẳng DB". Student portal (NestJS API + React) dùng chung database, đọc schema Moodle qua view. Worker nền (gửi thông báo, rebuild cache, xuất báo cáo) được scale theo số job tồn đọng. Tóm tắt CV nhấn mạnh "tải đột biến cuối hạn làm bài".

Mọi luồng ghi hội tụ về một chỗ. Replica và ProxySQL chỉ chia tải đọc; mọi câu ghi, mọi SELECT … FOR UPDATE và mọi khoá dòng đều nằm trên MySQL primary. Vì vậy deadlock của LMS Uni chỉ có thể xảy ra trên primary, giữa năm nhóm writer: request web của 5 node Moodle, cron/adhoc task, worker nền, external function do portal gọi, và (nếu có) bảng riêng mà portal tự ghi. Đọc qua replica không lấy khoá dòng trên primary nên không tham gia vòng (suy luận từ topology, không có lab replica cho deadlock).

flowchart TD
  LB["Nginx LB"] --> N["5 app node Moodle (PHP-FPM)<br/>request web: nộp quiz, chấm điểm, forum"]
  CR["cron / scheduled + adhoc task<br/>(khoá task bằng mysql_lock_factory)"] --> PX
  W["Worker nền: thông báo,<br/>rebuild cache, xuất báo cáo"] --> PX
  P["Student portal NestJS"] -- "ghi quan trọng qua<br/>external function" --> N
  P -- "đọc qua view" --> PX
  N --> PX["ProxySQL: tách đọc/ghi"]
  PX -- "mọi câu ghi, FOR UPDATE, GET_LOCK" --> PR["MySQL primary<br/>nơi duy nhất có khoá dòng, deadlock xảy ra ở đây"]
  PX -- "đọc" --> RE["MySQL replica"]
  N --> R["Redis: session + MUC cache"]

Chỗ nóng, theo tracker Moodle. Research tìm project = MDL AND text ~ "deadlock" trên moodle.atlassian.net (52 issue, thêm các truy vấn "try restarting transaction", "Lock wait timeout") và giữ lại các issue liên quan MySQL/LMS. Các trích dẫn dưới đây nguyên văn từ issue:

IssueVùngBằng chứng (trích)Trạng tháiCơ chế tương ứng trong bài
MDL-51360regrade gradebook khi xem/đóng quiz"during this regrading, there is associated database locking, leading to database deadlock and lock timeout errors in the error log under load." Bình luận 2024: "Was around 1500 students and as the quiz closed we started seeing the above mentioned deadlocks. The site was almost inaccessible for a couple of hours"Fixed 4.3.5, 4.4.1transaction dài + nhiều writer cùng lúc (6.4)
MDL-78580thống kê quiz / question bank (MariaDB 10.6)"we had DEADLOCKs on our database concerning the tables mdl_question_response_count and mdl_question_response_analysis." Sửa: "I have implemented a lock on the calculation process, so that we can never run the calculation for the same quiz concurrently."Fixed 4.1.6, 4.2.3khoá thô hơn theo tài nguyên (6.7)
MDL-79639adhoc quiz_statistics\task\recalculate (MySQL 5.7)DELETE với subquery: "the whole analysisid index has to be scanned, and therefore locked, since MySQL can't tell ahead of time which ids will be returned from the subquery." Sửa: lấy id trước rồi mới DELETE. "241 executions ... with error"Fixed 4.1.7, 4.2.4, 4.3.1khoá theo vùng quét (M4, 6.3)
MDL-86422 / MDL-86903xếp adhoc task từ hook nộp quiz trong transaction đang mở"this hook is executed while the database transaction is still open, which can lead to potential issues under high concurrency, particularly during simultaneous quiz submissions."; MDL-86903: "even if you use locks, the lock will be acquired and released before the transaction is committed"86422 Fixed 5.3; 86903 Open (ảnh hưởng 4.5.6/4.5.7)việc ngoài DB trong transaction (6.4, V5)
MDL-84563 / MDL-87610adhoc task send_user_notifications của forum"Error writing to database (Deadlock found when trying to get lock; try restarting transaction INSERT INTO mdl_forum_read (userid, postid, discussionid, forumid, firstread, lastread) SELECT ?, p.id, p.discussion, d.forum, ?, ?"Open (4.5.1, 4.5.6)INSERT … SELECT, thứ tự dòng (M7)
MDL-66603 + MDL-63732calendar event khi lưu assignment"Deadlock found when trying to get lock; try restarting transaction DELETE FROM mdl_event WHERE modulename = ? AND instance = ? AND eventtype = ?"; tiêu đề MDL-63732: "Index needed for event table (modulename, instance, eventtype)"66603 Open; 63732 Duplicatethiếu index (M4)
MDL-79394AJAX nền của assignfeedback_editpdf (MariaDB Galera)"a teacher had the grading page opened multiple times, causing about 20 Ajax calls to the same resource, which lead to multiple deadlocks"Opennhiều writer cùng một tài nguyên
MDL-58019thêm module có điểm vào khoá học đông"the whole thing is wrapped in a DB transaction, which puts a lock on the course record in mdl_course while the module unrolls the grades for itself"; lỗi "Lock wait timeout exceeded; try restarting transaction"Won't Do (2022)1205, transaction dài (7.2)
MDL-86968xoá nhiều subsection (adhoc course_delete_modules)"Deadlock found when trying to get lock; try restarting transaction UPDATE m_course_sections SET section = ? WHERE id = ?"Waiting reviewthứ tự khoá dòng (M1)

Gom lại thành năm mẫu (tổng hợp của research từ bảng trên): (1) đỉnh tải lúc đóng quiz kéo theo nộp bài, cập nhật điểm, regrade cả khoá học và task thống kê, khớp với "tải đột biến cuối hạn làm bài" trong CV; (2) DELETE dựa trên subquery hoặc không có index khoá cả vùng index lớn; (3) việc không thuộc DB chạy trong transaction đang mở (hook, xếp adhoc task); (4) chống trùng task bằng UNIQUE + "insert rồi xử lý trùng" (MDL-86422), đúng ý của 6.6; (5) vì core Moodle không retry, mọi bản sửa đều là sửa cấu trúc (thứ tự, index, khoá tuần tự), không phải bọc retry.

Một giới hạn phải nói khi dùng bảng này trong phỏng vấn: phần lớn báo cáo trên tracker đến từ MariaDB hoặc MySQL 5.7 và Moodle 4.1–5.x, còn LMS Uni chạy Moodle khoảng 3.9–4.x trên MySQL (bản cụ thể CV không ghi). Mức liên quan là suy theo tương tự, không phải tái hiện (research §7, ý 13). Các vùng được hỏi tới nhưng tracker không xác nhận là điểm nóng deadlock: logstore_standard_log (ghi có buffer, mặc định 50 bản ghi), course completion, enrolment, bảng sessions (LMS Uni dùng Redis session handler), grade_grades (chỉ gián tiếp qua regrade).

Cách nói khi phỏng vấn: "Ở LMS Uni mọi câu ghi đều đi về MySQL primary, replica và ProxySQL chỉ gánh phần đọc, nên deadlock chỉ có thể xảy ra ở primary, giữa request web của năm node Moodle, cron và adhoc task, worker nền và các web service mà portal gọi. Theo tracker của Moodle, điểm nóng quen thuộc nhất là lúc đóng quiz: nộp bài, cập nhật điểm, regrade khoá học và task thống kê cùng dồn vào một lúc. Tiếp theo là các câu DELETE không có index hoặc dựa trên subquery, khiến InnoDB khoá cả một vùng index lớn. Tôi nói rõ đây là suy theo tương tự từ tracker, vì các báo cáo đó phần lớn ở bản Moodle và MySQL mới hơn bản chúng tôi chạy."

8.2. Moodle không retry: hệ quả và cách giảm

1. Một deadlock là một lỗi người dùng nhìn thấy. Research grep deadlock|retry|lock_wait trong lib/dml/moodle_database.php, mysqli_native_moodle_database.php, pgsql_native_moodle_database.php, moodle_transaction.php, lib/dmllib.php (MOODLE_405_STABLE): không có xử lý nào. Lỗi DB đi tới moodle_database::query_end() và bị ném theo loại câu lệnh:

// lib/dml/moodle_database.php, query_end() (MOODLE_405_STABLE, trích theo research §4.1)
case SQL_QUERY_INSERT:
case SQL_QUERY_UPDATE:
    throw new dml_write_exception($error, $sql, $params);
// SQL_QUERY_SELECT / SQL_QUERY_AUX -> dml_read_exception
Cách nói khi phỏng vấn: "Lớp DML của Moodle không có chỗ nào thử lại khi gặp deadlock. Lỗi của database được bọc thành một exception ghi dữ liệu và ném thẳng lên, nên với người dùng một deadlock là một trang lỗi "Error writing to database", còn với adhoc task là một lần chạy thất bại. Vì thế mọi bản sửa deadlock trong Moodle core đều là sửa cấu trúc, không phải thêm retry."

Thông điệp người dùng và log thấy là Error writing to database (Deadlock found when trying to get lock; …), đúng chuỗi trong các issue ở 8.1. Với adhoc task, MDL-79639 ghi "241 executions ... with error": cùng một task thất bại lặp lại. Hệ quả trực tiếp cho LMS Uni: những gì 7.4 đo được ở V1 (3–10 % giao dịch hỏng dưới tranh chấp) là đúng thứ người dùng Moodle gặp ở điểm nóng, không có lớp V2 nào đỡ.

2. Delegated transaction: lồng nhau gộp thành một, hỏng một phần là hỏng hết. Docblock của start_delegated_transaction: "Delegated database transactions can be nested, but only one actual database transaction is used for the outer-most delegated transaction." và "If any part of the transaction rolls back then the whole thing is rolled back."; rollback_delegated_transaction: "all open delegated transactions are rolled back automatically if exceptions not caught". Khác Laravel (mỗi cấp lồng là một savepoint), Moodle không có savepoint, nên kiểu "commit thiếu lặng lẽ" của Laravel trên PG (7.5) không xảy ra theo cùng cách. Nhưng plugin tự viết mà catch (dml_write_exception $e) rồi đi tiếp trên MySQL thì rơi vào đúng bẫy 1213 của 7.1: InnoDB đã rollback, câu tiếp theo chạy autocommit (suy luận từ hành vi MySQL ở mục 4.2, chưa chạy trên Moodle).

3. Moodle đặt READ COMMITTED cho mỗi transaction trên MySQL. Driver mysqli làm việc này trong begin_transaction(), có từ MOODLE_39_STABLE tới main, tức là suốt thời gian LMS Uni chạy:

// lib/dml/mysqli_native_moodle_database.php (MOODLE_405_STABLE; giống ở MOODLE_39_STABLE)
protected function begin_transaction() {
    ...
    $sql = "SET SESSION TRANSACTION ISOLATION LEVEL READ COMMITTED";
    ...
    $sql = "START TRANSACTION";
Cách nói khi phỏng vấn: "Mỗi lần Moodle mở transaction trên MySQL, nó đặt mức cô lập của session về read committed trước. Hệ quả là các transaction của Moodle gần như không có gap lock, nên kiểu deadlock "kiểm tra rồi chèn" của MySQL hiếm gặp trong core. Nhưng các deadlock do thứ tự khoá dòng, do chèn trùng khoá và do quét thiếu index vẫn còn nguyên, và tracker của Moodle cho thấy đúng các loại đó."

Áp với các lab ở mục 3, READ COMMITTED đổi bức tranh như sau. Mất đi: gap lock khi search/scan, tức M2 (check-then-insert cùng khoảng trống) không còn deadlock (M2c/d: 1062 hoặc cả hai commit). Vẫn còn: M3 ba session INSERT trùng khoá (READ COMMITTED vẫn 1213 3/3, vì khoá kiểm tra trùng được lấy ở mọi mức), M1/M7 thứ tự khoá dòng, M6 secondary vs PK. Bị che đi: M4 thiếu index không còn chặn khách khác (M4c, semi-consistent read), nhưng câu lệnh vẫn quét cả bảng, nên vấn đề hiệu năng vẫn nằm đó. Ba suy luận đi kèm (research §4.1, chưa verify): (a) SET SESSION còn hiệu lực trên connection sau COMMIT, nên các câu autocommit sau đó của Moodle trên cùng connection cũng chạy ở RC; (b) RC đòi binlog dạng ROW, khớp với docs Moodle cho replication ("you must add binlog_format = 'ROW'"); (c) connection của portal NestJS không đi qua driver Moodle nên chạy ở mặc định của server, REPEATABLE READ, trừ khi tự đặt; mọi bảng portal tự ghi vì vậy có đủ gap lock và next-key lock của mục 3. ProxySQL giữ trạng thái SET SESSION này thế nào khi chia sẻ connection backend là câu hỏi research chưa trả lời (chưa verify).

4. Khoá cho cron và task: \core\lock\mysql_lock_factory, tức named lock GET_LOCK của MySQL, không phải Redis. Đây là sự thật Minato đã xác nhận cho LMS Uni. Redis ở LMS Uni chỉ phục vụ session và MUC cache; distributed lock bằng Redis trong CV thuộc LMS Micro-learning, và CV đã được sửa cho đúng. Source khớp với điều đó: lock_config::get_lock_factory_class() chọn "\\core\\lock\\{$dbtype}_lock_factory" khi $CFG->lock_factory không đặt hoặc là auto (dbfamily mysql ra mysql_lock_factory), và config-dist.php viết "The default locking system to use is DB locking for Postgres, MySQL, MariaDB and file locking for Oracle and SQLServer." Core MOODLE_405_STABLE không có lock factory nào dùng Redis.

// lib/classes/lock/mysql_lock_factory.php (MOODLE_405_STABLE, trích theo research §4.2)
// get_lock($resource, $timeout): khoá tên = sha1(prefix . resource)
SELECT GET_LOCK(:resourcekey, :timeout) AS locked
// "Even though some versions of MySQL and MariaDB can support stacked locks
//  just never stack them and always fail fast."
// nhả khoá: release_lock(), và auto_release() qua shutdown handler

// lib/classes/task/manager.php (MOODLE_405_STABLE): timeout của từng khoá
get_lock('adhoc_' . $record->id, 0)     // fail fast
get_lock(($record->classname), 0)       // fail fast
get_lock('core_cron', 10)               // chờ tối đa 10 s
Cách nói khi phỏng vấn: "Trên LMS Uni, cron và task của Moodle khoá nhau bằng named lock của MySQL, tức hàm GET_LOCK, không phải bằng Redis. Mỗi task lấy một khoá đặt theo tên task hoặc id của adhoc task với thời gian chờ bằng không, nghĩa là node nào đến sau thì bỏ qua ngay, chỉ khoá cron tổng mới chờ tối đa mười giây. Moodle cố ý không bao giờ giữ hai khoá cùng lúc. Khoá này nằm trên đúng một server MySQL, và khi đi qua ProxySQL nó làm connection đó bị ghim vào một backend."

Ngữ nghĩa của GET_LOCK đáng nắm vì nó khác khoá dòng ở gần như mọi điểm:

  1. Khoá theo tên, gắn với connection, không gắn với transaction. Moodle nhả bằng release_lock() hoặc shutdown handler; commit hay rollback của transaction không liên quan. Giống session-level advisory lock của PG ở P7-B (mục 2.7): process nào giữ khoá mà không nhả và không chết thì khoá còn đó (suy luận theo tương tự; GET_LOCK chưa có lab).

  2. Chỉ có trên một mysqld. Docs: "Since GET_LOCK() establishes a lock only on a single mysqld, it is not suitable for use with NDB Cluster". Với primary/replica, khoá chỉ có nghĩa nếu mọi node cùng lấy nó trên primary. Một query rule ProxySQL kiểu "mọi câu bắt đầu bằng SELECT đi sang replica" sẽ đẩy cả SELECT GET_LOCK(...) sang replica; khi đó hai node cron có thể giữ "cùng một" khoá trên hai server khác nhau nếu có nhiều replica (suy luận, chưa verify với cấu hình ProxySQL thật của LMS Uni). Đây là câu nên tự kiểm tra trước khi interviewer hỏi.

  3. Tắt multiplexing của ProxySQL. Docs ProxySQL: "If GET_LOCK() is executed, multiplexing is disabled and is never enabled again." và "When a transaction is active in a connection, multiplexing is disabled until the transaction commits or rollbacks." Vì vậy mỗi process cron/adhoc ghim một connection backend tới primary suốt thời gian sống của connection client. Đây không phải deadlock, nhưng là một dòng phải có trong phép tính số connection của primary (suy luận từ docs).

  4. Deadlock riêng, mã riêng. Docs MySQL: "The capability of acquiring multiple locks means there is the possibility of deadlock among clients. When this happens, the server chooses a caller and terminates its lock-acquisition request with an ER_USER_LOCK_DEADLOCK error. This error does not cause transactions to roll back." Mã 3058 và hành vi "không rollback transaction" này chưa verify trong lab. Với Moodle, vì factory "never stack them", một vòng chỉ gồm named lock không có điều kiện để khép (cần một bên giữ một tên và chờ tên thứ hai); và task lock có timeout 0 nên không có ai đứng chờ. Một vòng trộn named lock với khoá dòng InnoDB có được phát hiện hay không thì chưa verify; timeout 0/10 s của task manager chặn trên thời gian của nó (suy luận).

  5. Không hiện trong data_locks. GET_LOCK là khoá ở tầng server, không phải khoá InnoDB, nên các view ở mục 5.3 không thấy nó (suy luận của research). Khi cron "không chạy" vì khoá, đừng tìm ở sys.innodb_lock_waits.

Lịch sử liên quan: MDL-50719 (fixed 3.1.5/3.2.2) là chuyện một task chạy lâu làm mỗi vòng cron chờ khoảng 10 s trên khoá của nó; bản sửa "reduce the task lock wait timeout from ten seconds to one second (while leaving the cron lock timeout at ten seconds)", và bản 4.5 hiện dùng timeout 0 như trên. Để so sánh: db_record_lock_factory, factory dự phòng dựa trên bảng lock_db, giành khoá bằng UPDATE … WHERE resourcekey = … AND (owner IS NULL OR expires < :now) lặp lại với ngủ ngẫu nhiên 10–250 ms: retry có jitter nằm sẵn trong core (7.6). Session của LMS Uni nằm trên Redis (\core\session\redis), nên GET_LOCK theo từng session mà handler DB của Moodle dùng không áp dụng ở đây.

flowchart TD
  C1["Node A: cron chạy task X"] --> G["SELECT GET_LOCK(sha1(prefix.X), 0)"]
  C2["Node B: cron chạy task X"] --> G
  G --> PX["ProxySQL"]
  PX -- "phải tới primary<br/>(rule SELECT sang replica là sai)" --> PR["MySQL primary: named lock X"]
  PX --> MX["Connection client bị ghim backend:<br/>multiplexing tắt sau GET_LOCK"]
  PR --> R1["Node đến trước: locked = 1, chạy task"]
  PR --> R2["Node đến sau: timeout 0, bỏ qua ngay"]
  R1 --> REL["release_lock() / shutdown handler<br/>(không gắn với COMMIT)"]

5. Portal NestJS ghi qua web service của Moodle, không ghi thẳng DB. Đây là quyết định trong CV, và nhìn từ góc deadlock nó có lý do vững:

Cái giá: Moodle không retry, nên khi external function gặp deadlock thì portal nhận về exception (dml_write_exception bọc trong lỗi web service). Gọi lại web service chính là chạy lại cả delegated transaction của nó, đúng 7.1, với điều kiện thao tác đó idempotent. "Đặt điểm = 8" chạy hai lần vẫn đúng; "nộp thêm một lần nộp bài" hay "tăng số lượt" chạy hai lần là sai. Vì vậy phía portal chỉ nên tự retry những external function idempotent, hoặc gửi kèm một khoá idempotency mà external function kiểm tra trong cùng transaction (thiết kế đề xuất, chưa verify trên LMS Uni). Đọc qua view trên replica không lấy khoá dòng nên không cần retry kiểu này.

6. Cách giảm deadlock cho LMS Uni, theo thứ tự hiệu quả trên công sức.

  1. Biết bản Moodle đang chạy có bản sửa chưa. MDL-51360 sửa ở 4.3.5/4.4.1, MDL-78580 ở 4.1.6/4.2.3, MDL-79639 ở 4.1.7/4.2.4/4.3.1. Một hệ thống chạy khoảng 3.9–4.x có thể chưa có bản nào. Nâng cấp hoặc backport là cách rẻ nhất cho điểm nóng của core.

  2. Plugin tự viết theo đúng các bài học của core. Index cho cột trong WHERE của UPDATE/DELETE (MDL-63732, M4); lấy danh sách id trước rồi xoá theo PK thay vì DELETE … WHERE … IN (SELECT …) (MDL-79639); ghi theo một thứ tự bảng và thứ tự id cố định (6.1); không gọi mạng hay xếp job khi delegated transaction còn mở (MDL-86903, V5); tuần tự hoá việc tính toán theo tài nguyên bằng lock factory của Moodle (MDL-78580, 6.7).

  3. Retry có chọn lọc trong code của mình. Bọc retry quanh delegated transaction ngoài cùng cho thao tác idempotent biết là nóng, bắt dml_write_exception có thông điệp deadlock, tối đa vài lượt có jitter. Không bao giờ đặt retry bên trong một delegated transaction khác, vì Moodle gộp mọi cấp thành một và rollback hết (ý 2). Đây là đề xuất thiết kế, chưa chạy trên Moodle.

  4. Giãn đỉnh lúc đóng quiz. Theo MDL-51360, regrade lúc đóng quiz với khoảng 1500 sinh viên đủ làm site "almost inaccessible for a couple of hours". Lệch giờ đóng giữa các lớp, đẩy tính toán thống kê sang adhoc task chạy tuần tự là các biện pháp vận hành (suy luận, không có số đo LMS Uni).

  5. Đo được trước đã. Grep log lỗi Moodle theo Error writing to database (Deadlock found và Lock wait timeout exceeded, kết hợp INNODB_METRICS.lock_deadlocks và events_errors_summary_global_by_error cho 1213/1205 (mục 5.3, 5.4). Khi tần suất cao, bật tạm innodb_print_all_deadlocks với log_error_verbosity = 3 để có cặp câu lệnh. Với ProxySQL ở giữa, host trong khối deadlock là ProxySQL, phải map tiếp qua query digest (mục 5.5).

flowchart TD
  U["Sinh viên nộp quiz<br/>(qua Moodle hoặc portal gọi external function)"] --> T["delegated transaction<br/>SET SESSION ... READ COMMITTED, START TRANSACTION"]
  T --> D{"InnoDB chọn transaction này làm victim?"}
  D -- "không" --> OK["COMMIT"]
  D -- "có: 1213" --> E["query_end(): dml_write_exception<br/>Moodle không retry"]
  E --> W["Web: trang lỗi Error writing to database"]
  E --> A["Adhoc task: lần chạy thất bại"]
  E --> P["Portal: lỗi web service<br/>chỉ retry nếu thao tác idempotent"]

8.3. Cách trả lời khi interviewer hỏi

Ba câu trả lời mẫu dưới đây chỉ dùng sự thật đã có trong CV, trong source Moodle mà research đọc và trong lab của bài này. Nếu có số liệu hay sự cố thật của LMS Uni (bao nhiêu lỗi deadlock mỗi tuần, ở bảng nào, sửa thế nào) thì thay vào; đừng kể một sự cố chưa xảy ra, vì câu hỏi tiếp theo sẽ là "lúc đó log ghi gì".

Câu hỏi 1: "Hệ thống LMS của anh có gặp deadlock không, anh xử lý thế nào?"

Cách nói khi phỏng vấn: "Với Moodle trên MySQL thì có, và điều đầu tiên tôi phải nói là Moodle core không retry: lớp DML bọc lỗi thành exception ghi dữ liệu và ném thẳng lên, nên mỗi deadlock là một trang lỗi hoặc một adhoc task thất bại. Theo tracker của Moodle, điểm nóng là lúc đóng quiz, khi nộp bài, regrade và task thống kê dồn vào cùng lúc, và các câu DELETE thiếu index. Cách tôi xử lý theo thứ tự: đo bằng counter deadlock của InnoDB và log lỗi Moodle, kiểm tra bản Moodle đã có các bản sửa cho điểm nóng đó chưa, trong plugin tự viết thì giữ thứ tự ghi cố định, có index cho mọi câu UPDATE và DELETE, không gọi ra ngoài khi transaction đang mở. Retry tôi chỉ thêm ở transaction ngoài cùng cho thao tác idempotent, vì tôi đã đo trong lab rằng retry cứu được giao dịch nhưng không làm số deadlock giảm, chỉ sửa thứ tự khoá mới đưa về không."

Câu hỏi 2: "Cron chạy trên năm node, làm sao không chạy trùng? Anh dùng Redis lock à?"

Cách nói khi phỏng vấn: "Ở LMS Uni thì không phải Redis. Moodle dùng lock factory mặc định cho MySQL, tức named lock GET_LOCK trên database: mỗi scheduled task và adhoc task lấy một khoá theo tên với thời gian chờ bằng không, node đến sau bỏ qua ngay. Redis của LMS Uni chỉ giữ session và cache; distributed lock bằng Redis là ở dự án LMS Micro-learning. Có hai điểm vận hành tôi phải để ý: khoá này chỉ tồn tại trên một server MySQL, nên ProxySQL phải đưa nó về primary chứ không được tách sang replica như một câu SELECT thường; và sau GET_LOCK, ProxySQL tắt multiplexing cho connection đó, nên mỗi process cron giữ riêng một connection tới primary, phải tính vào giới hạn connection."

Câu hỏi 3: "Portal NestJS dùng chung database, sao không ghi thẳng cho nhanh?"

Cách nói khi phỏng vấn: "Dữ liệu quan trọng như nộp bài và ghi điểm tôi cho đi qua web service của Moodle. Lý do về khoá là chỉ có một đường code ghi vào các bảng quiz và điểm, nên chỉ có một thứ tự khoá và một mức cô lập read committed mà Moodle tự đặt; nếu portal ghi thẳng thì nó chạy repeatable read mặc định của MySQL, có gap lock, và tạo ra những cặp deadlock giữa code portal và code Moodle mà không ai trên tracker gặp trước. Cái giá là Moodle không retry, nên portal nhận lỗi khi có deadlock và phải tự quyết định gọi lại; tôi chỉ gọi lại những thao tác idempotent, như đặt điểm, còn thao tác như tạo lần nộp mới thì cần khoá idempotency. Phần đọc thì portal đi qua view, phía replica, không dính khoá."

9. Anti-patterns và bài học thực tế

Mỗi mục dưới đây là một cách làm sai có bằng chứng: hoặc lab của bài này tái hiện được hậu quả, hoặc một tổ chức đã viết công khai về nó. Nguồn công khai lấy từ research §5; mức tin cậy ghi kèm (incident tracker của chính công ty, vendor blog).

1. Bắt lỗi khoá rồi chạy tiếp trong cùng transaction. Code kiểu try { ... } catch (QueryException $e) { log(); } rồi làm nốt phần còn lại. Ở PG mọi câu sau đó nhận 25P02, vô hại nhưng vô ích. Ở MySQL sau 1213, các câu sau tự commit ngoài transaction (mục 4.2, lab A1), và Laravel lồng nhau thì PG commit thiếu, MySQL ghi một nửa rồi báo lỗi (7.5, sum_delta 400 và 300). Làm thay: để exception thoát khỏi ranh giới transaction ngoài cùng, rollback, chạy lại từ đầu (7.1).

2. Commit sau lỗi 1205. MySQL mặc định chỉ huỷ câu đang chờ; COMMIT lúc đó lưu một nghiệp vụ dở dang (A0, mục 4.2). Khi deadlock detection tắt, đó là mất tiền: 170 thay vì 200 (11a, 7.3). Làm thay: 1205 = rollback + chạy lại cả transaction, hoặc ném lên; không bao giờ commit.

3. "Sửa" deadlock bằng cách vặn tham số để nó không còn tên là deadlock. Tắt innodb_deadlock_detect biến mỗi deadlock thành hai lần treo tới timeout (7.3). Đặt lock_timeout ngắn hơn deadlock_timeout trên PG biến deadlock thành 55P03 và pg_stat_database.deadlocks đứng yên (lab 08 phần 4: delta 0, 2/2; mục 5.2). Dashboard xanh lên, người dùng vẫn nhận lỗi. Cùng họ với lỗi này là theo dõi một counter không tồn tại: Innodb_deadlocks không có trong MySQL 8.4.13 (mục 5.3).

4. Coi retry là thuốc chữa cho hot spot. Ở 3 dòng nóng, 5 lượt retry vẫn để 16–23 % giao dịch thất bại trên PG, và jitter không đổi được điều đó (7.6). Retry giấu triệu chứng tới đúng ngày tải tăng gấp đôi. Làm thay: retry cho phần dư hiếm, còn tranh chấp lặp lại thì gỡ vòng thứ tự khoá (V3, V4b: 0 deadlock) hoặc gỡ hot row.

5. Side effect bên trong closure được retry. 1666 "email" cho 1600 giao dịch trên MySQL, 1745–1756 trên PG (7.4, V5). Làm thay: DB::afterCommit hay outbox; side effect phía nhận phải idempotent.

6. Đặt attempts ở transaction lồng và tưởng là có retry. Laravel không retry ở cấp lồng; lỗi PG 40001 ở cấp lồng còn không được cấp ngoài nhận ra (7.5). Làm thay: retry ở nơi sở hữu transaction ngoài cùng; detector tự viết nếu dùng PG REPEATABLE READ/SERIALIZABLE.

7. Tin rằng UPDATE nguyên tử không deadlock. V4 (UPDATE … SET balance = balance - ?) deadlock 62–67 lần trên MySQL, 125–151 trên PG mỗi 1600 giao dịch (7.4). Nguyên tử là chống lost update, không chống vòng.

8. Bulk insert/upsert không sắp thứ tự. incident.io (vendor blog, PostgreSQL) gặp deadlock giữa hai lần chạy cùng một đoạn code: "The rows being inserted were not sorted, in fact they were ordered randomly, having been placed in a map before being converted back into a slice", vì "Postgres acquires locks individually and sequentially during a bulk insert". Họ sửa bằng cách "simply sort before upserting, solving the deadlock". Lab tái hiện cả hai phía: P5-D batch upsert 3000 khoá tăng dần đấu giảm dần, deadlock 3/3, sắp cả hai bên thì 0/3 (mục 2.5); M7e/f IODKU/REPLACE nhiều dòng trong một câu, 12–13 và 44–113 deadlock mỗi 4 giây, sắp thứ tự thì 0 (mục 3.7). Nguồn: incident.io, Debugging deadlocks in Postgres. Một lưu ý khi trích bài này: nó mô tả PG "chỉ cho chờ khoá trong một khoảng" qua lock_timeout, nhưng lock_timeout mặc định 0 (chờ mãi), chỉ deadlock_timeout kích hoạt việc phát hiện (research §7, ý 6).

9. Transaction dài, hoặc mở thêm connection thứ hai trong cùng một nghiệp vụ. Sentry (vendor blog) kể hai lần sự cố nhỏ do tranh chấp khoá, không phải deadlock được phát hiện: code billing trên một connection riêng giữ khoá hàng phút, và câu hỏi điều tra của họ là "why do 7 minutes elapse before the UPDATE is issued after the SAVEPOINT is taken?". Lab có hai bản thu nhỏ: holder idle in transaction chặn người khác (mục 5.2, 5.3), và V5 dùng một connection thứ hai trong cùng giao dịch (7.4). Một connection thứ hai mà cùng chạm vào dữ liệu của connection thứ nhất còn có thể tự chờ chính mình mà không detector nào thấy vòng, vì hai connection là hai transaction khác nhau (suy luận; lab PG gặp đúng kiểu này ở orchestrator: harness chờ cả hai session rồi mới commit và tự thành một nửa vòng chờ, pg/RESULTS.md). Nguồn: Sentry, Fixing Sentry with Sentry: Lock-Contention Edition. Làm thay: transaction ngắn, không I/O bên trong, theo dõi tuổi transaction, đặt idle_in_transaction_session_timeout theo role (7.7).

10. DDL lấy khoá mạnh trên bảng nóng giữa giờ cao điểm. Sự cố production của GitLab ngày 2026-04-06 (tracker sự cố của chính GitLab): "A post-deployment migration on the production environment failed due to a PostgreSQL deadlock while attempting to add a foreign key to a partitioned CI table." và "The deadlock was triggered by locking conflicts with application queries during high traffic." Họ bỏ qua migration, đánh dấu là đã chạy rồi chạy lại job; nhãn sự cố ghi Deploys-blocked-gprd 3hr. Thêm khoá ngoại phải khoá cả hai bảng, và lab P6 cho thấy hai transaction cùng đọc rồi cùng xin ACCESS EXCLUSIVE deadlock ngay lập tức (mục 2.6), còn một DDL đứng chờ thì chặn mọi câu SELECT phía sau (P6-B, mục 6.8). Bài học riêng của sự cố này: deadlock ở migration không chỉ làm hỏng một query, nó chặn cả pipeline deploy. Nguồn: GitLab production incident 21712. Làm thay: DDL ngoài giờ cao điểm, lock_timeout ngắn + retry bước migration, lấy khoá mạnh nhất ngay câu đầu (mục 6.8).

11. Để bảng phình không giới hạn. Báo cáo availability tháng 9/2026 của GitHub (vendor blog): "The database cluster supporting issues and pull requests removed approximately 980 million unnecessary edit-history rows, creating an estimated 1.8 terabytes of recoverable storage. Weekly deadlocks fell approximately 77% from their June peak, and continuous cleanup now prevents the same unbounded growth from returning." Báo cáo đặt việc dọn dữ liệu và việc deadlock giảm cạnh nhau nhưng không giải thích cơ chế. Một cơ chế khớp với lab là M4: InnoDB khoá theo vùng đã quét, nên câu nào quét nhiều dòng hơn thì giữ nhiều khoá hơn và lâu hơn (201 khoá cho 10 dòng khớp, mục 3.4); nhưng đó là suy luận, không phải lời của GitHub. Nguồn: GitHub availability report, September 2026.

12. Khoá chính tuần tự cho nhiều writer song song theo phân vùng. Shopify Engineering (vendor blog, MySQL): các tiến trình ETL upsert song song theo từng account vào một bảng có khoá chính tăng dần, và deadlock trên gap lock dù mỗi tiến trình làm một lô riêng: "Deadlocks can happen even if the processes are operating on a different batch of records." Họ đổi khoá chính thành (account, id) để gap lock của mỗi tiến trình nằm trong vùng khoá của riêng nó, và "With this we were able to remove artificial delays on our processes that were originally used to mitigate the deadlocks." Lab M2b là bản thu nhỏ: hai khoá 15 và 16 không bao giờ trùng nhau nhưng cùng một khoảng trống, deadlock 3/3 (mục 3.2). Điều đáng học: những lần ngủ/retry chỉ giảm nhẹ, đổi thứ tự vật lý của khoá mới gỡ được. Nguồn: Shopify, Mitigating Deadlocks in high concurrency environments. Cách này không có lab riêng (chưa verify), và đổi khoá chính của bảng InnoDB là một migration lớn.

13. Thiết kế dựa vào việc ai sẽ là victim. Docs PG: "Exactly which transaction will be aborted is difficult to predict and should not be relied upon." Lab điều khiển được victim của PG bằng deadlock_timeout riêng từng session (V1–V4, 3/3 mỗi case) và của MySQL bằng khối lượng đã ghi (c1/c2), nhưng chỉ trong timeline dựng có chủ đích; P2 no-sleep và M3 thì victim đổi giữa các lần chạy. Mọi đường code đều phải chịu được việc mình là victim.

14. Khoá ứng dụng không gắn với transaction mà không có finally. Session-level advisory lock của PG sống qua 40P01 (P7-B, mục 2.7); GET_LOCK của MySQL gắn với connection, không với transaction (8.2). Quên nhả là tạo ra một holder vô thời hạn, và với connection pool thì khoá đi theo connection sang request khác (suy luận, chưa test với pool thật). Làm thay: pg_advisory_xact_lock; với GET_LOCK thì nhả trong finally như Moodle làm bằng shutdown handler.

Nguồn công khaiLoại nguồnChuyện gì xảy raBài họcTrong bài
GitLab incident 2026-04-06tracker sự cố của GitLabmigration thêm FK vào bảng CI phân vùng deadlock với query của app lúc tải cao; chặn deployDDL khoá mạnh ngoài giờ cao điểm, lock_timeout ngắn + retry migrationP6, 6.8
GitHub, availability report 09/2026vendor blogxoá ~980 triệu dòng edit-history; deadlock hằng tuần giảm ~77 % so với đỉnh tháng 6dọn dữ liệu cũng là sửa deadlock (cơ chế không được nêu)M4, 6.3
Shopify Engineeringvendor blogETL upsert song song deadlock trên gap lock của PK tuần tự; đổi PK thành (account, id)thứ tự vật lý của khoá quyết định vùng chồng lấnM2, 6.5
incident.iovendor blogbulk upsert từ một map không sắp thứ tự deadlock giữa hai lần chạy cùng codesắp khoá trước khi gửi câu nhiều dòngP5-D, M7e/f, 6.1
Sentryvendor blogconnection riêng của billing giữ khoá hàng phút, hai lần sự cố nhỏtheo dõi tuổi transaction, không mở connection thứ hai trong một nghiệp vụ6.4, 7.7
flowchart LR
  A["Gặp lỗi khoá"] --> X1["Bắt rồi chạy tiếp:<br/>MySQL ghi lẻ ngoài transaction"]
  A --> X2["Commit sau 1205:<br/>170 thay vì 200"]
  A --> X3["Vặn tham số:<br/>đổi tên lỗi, counter mù"]
  A --> X4["Chỉ tăng retry:<br/>PG 3 dòng nóng vẫn fail 16 đến 23 %"]
  A --> OK["Rollback, chạy lại cả transaction,<br/>side effect sau commit"]
  OK --> FIX["Lặp lại thì sửa thiết kế:<br/>thứ tự khoá, index, transaction ngắn"]
Cách nói khi phỏng vấn: "Những sai lầm tôi thấy nhiều nhất với deadlock đều là cách phản ứng chứ không phải cách viết câu lệnh: bắt lỗi rồi chạy tiếp, commit sau lỗi hết giờ chờ khoá, vặn tham số để lỗi đổi tên, và tăng số lần retry thay vì sửa thứ tự khoá. Các công ty lớn kể công khai đúng những chuyện đó: GitLab bị chặn deploy vì một migration deadlock lúc tải cao, incident.io deadlock vì upsert hàng loạt không sắp thứ tự, Shopify phải đổi khoá chính để gap lock của các tiến trình không chồng nhau. Tôi coi retry là lưới cho phần dư hiếm, còn deadlock lặp lại là một bug thiết kế cần ticket."

10. Câu hỏi phụ interviewer hay hỏi tiếp

1. Deadlock khác lock wait timeout ở đâu? Deadlock là vòng chờ khép kín, không bên nào tự xong được; database phát hiện và huỷ một bên (PG 40P01, MySQL 1213, cả transaction bị huỷ). Lock wait timeout là chờ một holder quá lâu, holder vẫn có thể xong; MySQL 1205 mặc định chỉ huỷ câu lệnh, PG 55P03 làm hỏng cả transaction block. Hai lỗi cần cách xử lý khác nhau (4.2, 7.2).

2. Vì sao PG báo deadlock sau một giây còn MySQL gần như ngay? PG chờ lạc quan: backend đang chờ chỉ tự kiểm tra vòng sau deadlock_timeout (1 s), vì mỗi lần kiểm tra khoá cả lock table. InnoDB kiểm tra lúc xin khoá, từ 8.0.18 bằng thread nền trên bản sao đồ thị. Hệ quả đo được: cùng 1600 giao dịch, PG V1 mất 84–104 s, với 100 ms còn 13 s, số deadlock không đổi (7.4).

3. Database chọn victim thế nào? Có điều khiển được không? PG: backend có bộ hẹn giờ hết trước trong lúc vòng tồn tại tự huỷ mình. InnoDB: transaction "nhẹ" hơn theo trx_weight (≈ số dòng đã sửa + số lock struct). Lab điều khiển được cả hai trong timeline dựng sẵn, nhưng docs PG nói không nên dựa vào, nên mọi đường code phải chịu được việc bị chọn (mục 1, mục 9 ý 13).

4. Chuyển MySQL sang READ COMMITTED có hết deadlock không? Không. RC bỏ gap lock khi search/scan nên M2 hết deadlock, nhưng M3 (chèn trùng, 3/3) và các vòng thứ tự khoá dòng vẫn còn; M4 thiếu index chỉ bị che. Manual vừa nói isolation "không ảnh hưởng khả năng" deadlock vừa nói RC "greatly reduces the probability": giảm xác suất, không loại bỏ. Moodle đã chạy RC trên MySQL (8.2).

5. UPDATE … SET stock = stock - 1 là nguyên tử, sao vẫn deadlock? Nguyên tử chống lost update. Câu UPDATE vẫn lấy khoá dòng và giữ tới hết transaction; hai transaction cập nhật hai dòng theo thứ tự ngược vẫn khép vòng. Lab V4: 62–67 deadlock (MySQL), 125–151 (PG) mỗi 1600 giao dịch. Một câu WHERE id IN (a, b) thì 0 trong lab (7.4).

6. Một câu lệnh đơn, autocommit, có deadlock được không? Có. M6b: hai câu UPDATE một dòng đi qua secondary index và PK theo thứ tự ngược, 11 và 13 deadlock trong 4 giây; M7e/f: IODKU/REPLACE nhiều dòng trong một câu. Retry câu đó là đủ vì nó là cả transaction (7.1).

7. Retry bao nhiêu lần, có cần backoff không? Tính ngược từ thời gian tối đa của thao tác: PG mỗi lượt thua ≥ 1 s, MySQL 1213 gần như 0, MySQL 1205 tốn trọn innodb_lock_wait_timeout. Jitter giúp khi lỗi đến theo đợt đồng bộ; trong lab với 3 dòng nóng nó không đổi kết quả. Tăng lượt retry không thay được việc sửa thứ tự khoá (7.6).

8. DB::transaction($cb, 5) của Laravel có đủ chưa? Đủ cho deadlock ở transaction ngoài cùng, và nó retry cả MySQL 1205 sau khi rollback. Không đủ nếu: transaction lồng (không retry, PG 40001 lồng còn không được nhận ra), PG lock_timeout 55P03 (không retry), có side effect trong closure (chạy lại theo số lượt), hay cần backoff (không có). Source 13.40.0 ở 7.5.

9. Sau deadlock ở MySQL có cần gửi ROLLBACK không? Server đã rollback rồi, ROLLBACK là no-op ở phía server. Điều quan trọng là không gửi tiếp câu nào của transaction cũ, vì chúng sẽ tự commit (mục 4.2). Ở PG thì bắt buộc ROLLBACK trước khi làm gì khác.

10. Làm sao biết deadlock đang tăng trên production? PG: pg_stat_database.deadlocks, server log deadlock detected (bật log_lock_waits, mặc định tắt ở PG 18). MySQL: INNODB_METRICS.lock_deadlocks (bật sẵn), events_errors_summary_global_by_error cho 1213; Innodb_deadlocks không tồn tại ở 8.4. Thêm metric phía app: số lượt retry và số lần cạn lượt. Cả hai counter đều mù khi deadlock bị đổi tên thành timeout (mục 5).

11. Có nên tắt innodb_deadlock_detect cho hệ thống tải cao? Manual cho phép khi rất nhiều thread chờ cùng một khoá; kỹ sư viết lại lock system khuyên giữ ON từ 8.0.18. Lab cho thấy cái giá: mỗi deadlock thành hai lần treo tới timeout, counter bằng 0, và commit sau 1205 thì mất tiền. Sửa hot row trước; chỉ tắt khi đo được lợi ích (7.3).

12. Một connection idle in transaction đang chặn cả hệ thống, anh làm gì? Tìm đầu chuỗi bằng pg_blocking_pids hoặc sys.innodb_lock_waits, ghi bằng chứng, rồi đóng connection đó (pg_terminate_backend, KILL <id>; KILL QUERY vô dụng với holder đang Sleep). Sau đó sửa nguồn trong app và đặt idle_in_transaction_session_timeout theo role (7.7).

11. Chưa verify

Gom mọi điểm "chưa verify" của cả bài (mục 1–10) và các danh sách "Not verified" của ba lab, theo chủ đề. Mỗi dòng ghi nguồn đang dựa vào, để lần sau có thể dựng một lab nhỏ cho đúng điểm đó và xoá nó khỏi danh sách.

A. Phát hiện deadlock và chọn victim

B. PostgreSQL

C. MySQL / InnoDB

D. Laravel

E. Moodle và LMS Uni

F. Vận hành và thiết kế

G. Môi trường và số liệu

Phụ lục: lab đã chạy

Toàn bộ lab nằm ở labs/database/deadlock/ trong repo: mỗi thư mục có script đánh số, files/ (orchestrator, code), out/ (log thật đã dán vào bài) và RESULTS.md. Mọi container/network/database tạm đều mang tiền tố tmp-dl-* / tmp_dl_*.

pg/, PostgreSQL 18.4 (container dùng chung my-test-pgdb, database riêng tmp_dl_pg; orchestrator files/dl.py chạy mỗi session là một psql sống lâu, case ở files/p1_*.py … p8_*.py)

ScriptLàm gìMục
00-setup.shtạo tmp_dl_pg, chụp tham số liên quan deadlock trước lab1.3, 5.2
01-row-opposite-order.shP1: hai UPDATE ngược thứ tự, 25P02 sau 40P01, retry cả transaction; fix ORDER BY FOR UPDATE2.1, 4.2
02-multirow-update-order.shP2: UPDATE nhiều dòng theo index vs heap; burst không sleep; fix khoá trước có ORDER BY2.2
03-lock-upgrade.shP3: FOR SHARE rồi UPDATE (MultiXact); fix FOR UPDATE / FOR NO KEY UPDATE2.3
04-foreign-key.shP4: insert con + UPDATE cha cột thường (0/3) vs cột khoá / DELETE cha2.4, 4.1
05-unique-insert.shP5: INSERT/ON CONFLICT ngược thứ tự, 3 session cùng khoá (0/3), batch upsert2.5, 4.1
06-table-lock.shP6: SELECT rồi ALTER/LOCK (phát hiện ngay), hàng đợi DDL + lock_timeout2.6, 6.8
07-advisory-lock.shP7: advisory lock ngược thứ tự; session-level lock sống qua 40P012.7, 6.7
08-detection-ops.shtham số, view chờ khoá, log_lock_waits, lock_timeout/statement_timeout/NOWAIT, điều khiển victim1.3, 4.2, 5.2
99-cleanup.shdrop tmp_dl_pg, in lại tham số để chứng minh server như cũ

mysql/, MySQL 8.4.13 (container riêng tmp-dl-mysql, network tmp-dl-net-mysql, không publish port; client là container python:3.12-slim + PyMySQL 1.2.3 từ wheel trong files/; orchestrator files/dl.py, case files/m1.py … m7.py, detect.py, waits.py, timeout.py, nodetect.py, victim.py)

ScriptLàm gìMục
00-up.shmật khẩu ngẫu nhiên vào .env, chạy container, in version và mặc định3
01-m1-opposite-order.shM1: 1213, LATEST DETECTED DEADLOCK, dòng lỗi của mysql CLI thật; fix thứ tự khoá3.0, 3.1
02-m2-gap-lock.shM2: gap lock check-then-insert, READ COMMITTED, fix insert trước / IODKU3.2
03-m3-duplicate-key.shM3: 3 session chèn trùng + ROLLBACK/DELETE; IODKU, RC không chữa; retry cả transaction3.3
04-m4-missing-index.shM4: thiếu index khoá 201 bản ghi; fix index; RC che vấn đề3.4
05-m5-foreign-key.shM5: insert con giữ S trên cha, cùng UPDATE cha; hai fix3.5
06-m6-secondary-vs-pk.shM6: secondary index vs PK, bản hai câu và bản câu đơn autocommit3.6
07-m7-upsert-replace.shM7: IODKU/REPLACE ngược thứ tự, câu nhiều dòng; fix sắp khoá3.7
08-detect-errorlog-counters.shcounter (INNODB_METRICS, P_S error summary), innodb_print_all_deadlocks + log_error_verbosity5.3
09-lock-wait-views.shlock wait thường: data_locks, data_lock_waits, sys.innodb_lock_waits, holder idle5.3, 7.7
10-timeout-vs-deadlock-scope.sh1205 vs 1213: rollback câu hay cả transaction; tạo lại server với innodb_rollback_on_timeout=ON4.2, 7.2
11-deadlock-detect-off.shinnodb_deadlock_detect=OFF: deadlock thành 1205, commit sau 1205 (170) vs rollback (200)5.3, 7.3
12-victim-choice.shchọn victim theo trx_weight1.4
run-all.shchạy 00..12 theo thứ tự (không gọi 99)
99-cleanup.shxoá container server, container client còn sót, network

app/, Laravel 13.40.0 trên cả hai DB (image tmp-dl-php từ files/Dockerfile, MySQL riêng tmp-dl-app-mysql, PG my-test-pgdb database tmp_dl_app được nối tạm vào network tmp-dl-net-app; code lab ở src/app/: Lab/Lab.php, Lab/LabConcurrencyErrorDetector.php, Console/Commands/DlRun.php, DlNested.php, DlLockwait.php; bảng tổng hợp do files/summary.py sinh)

ScriptLàm gìMục
00-setup.shnetwork, image PHP, container MySQL, database PG, skeleton Laravel (composer create-project), copy src/app, in version + tham số7.4
01-smoke.shchạy thử vài biến thể nhỏ trên cả hai DB (kiểm tra + ước lượng thời gian)
02-variants.sh <mysql|pgsql>V1–V5 (+ v2backoff, v3in, v4one) × 2 lần, 8 worker × 200, 10 dòng nóng7.4
02b-v4one.shchạy lại V4b sau khi sửa SQL 42883 trên PG7.4
02c-deadlock-stage.shV1/V4: deadlock rơi ở câu khoá thứ nhất hay thứ hai7.4
03-nested.sh2 session tất định: flat / nested / nested outer=3 / nested-catch, mỗi DB7.1, 7.5
04-lockwait.shLaravel có retry MySQL 1205 / PG 55P03 / PG 40001 (phẳng, lồng, detector tự viết) không7.2, 7.5
05-backoff-stress.sh3 dòng nóng: retry ngay vs full jitter7.6
06-pg-deadlock-timeout.shcùng seed với 02, SET deadlock_timeout = '100ms' mức session7.4
07-source.shin đúng các dòng source Laravel đã cài (file:dòng)7.5
08-summary.shgom các dòng RESULT của 02/02b/02c/05/06 thành bảng7.4, 7.6
99-cleanup.shxoá container tmp-dl-app-*, network, database tmp_dl_app; --image xoá cả image

video/: make_video.py dựng MP4 giải thích deadlock PG vs MySQL từ số liệu lab (mỗi con số có chú thích src: trỏ về RESULTS.md); Pillow vẽ khung hình, ffmpeg mã hoá H.264.

Chạy lại. Luôn chạy script 00 trước, 99-cleanup sau cùng; mỗi script ghi out/NN-*.log của nó.

# PostgreSQL (cần my-test-pgdb đang chạy, credentials trong /var/www/chiennn/postgresql-learning/.env)
cd /var/www/chiennn/cv/labs/database/deadlock/pg
bash 00-setup.sh
bash 01-row-opposite-order.sh      # ... lần lượt tới 08-detection-ops.sh
bash 99-cleanup.sh

# MySQL (tự dựng container tmp-dl-mysql)
cd /var/www/chiennn/cv/labs/database/deadlock/mysql
bash 00-up.sh
bash run-all.sh                    # hoặc: bash run-all.sh 10 11  (chỉ chạy vài script)
bash 99-cleanup.sh

# Laravel app lab (cả hai DB)
cd /var/www/chiennn/cv/labs/database/deadlock/app
bash 00-setup.sh
bash 02-variants.sh mysql; bash 02-variants.sh pgsql
bash 02b-v4one.sh; bash 02c-deadlock-stage.sh; bash 03-nested.sh; bash 04-lockwait.sh
bash 05-backoff-stress.sh; bash 06-pg-deadlock-timeout.sh; bash 07-source.sh; bash 08-summary.sh
bash 99-cleanup.sh                 # thêm --image để xoá image tmp-dl-php

# Video (python có Pillow, ví dụ venv của Hermes; cần ffmpeg)
python3 /var/www/chiennn/cv/labs/database/deadlock/video/make_video.py out.mp4
python3 /var/www/chiennn/cv/labs/database/deadlock/video/make_video.py still 12 frame.png
Cách nói khi phỏng vấn: "Mỗi lab có một script dựng môi trường chạy đầu tiên và một script dọn dẹp chạy cuối cùng, ở giữa là các script đánh số, mỗi script ghi log riêng. Lab PostgreSQL dùng container có sẵn với một database tạm, lab MySQL tự dựng container riêng trên network riêng, lab Laravel nối tạm container PostgreSQL vào network của nó rồi tháo ra khi xong. Mọi output trong bài đều lấy nguyên văn từ các file log đó."

Lưu ý khi chạy lại: app/00-setup.sh tạo skeleton Laravel kèm vendor/ trong app/laravel/ (đường dẫn $LAB_DIR/laravel của lib.sh); thư mục đó không thuộc lab, đừng commit, xoá sau khi xong. Script app chỉ nối my-test-pgdb vào tmp-dl-net-app trong lúc chạy và tháo ra ở trap EXIT, không đụng local_net. Đổi script xong thì đồng bộ lại mọi con số trích trong bài với log mới.

Phiên bản đã chạy (nguyên văn từ pg/out/00-setup.log, mysql/out/00-up.log, app/out/00-setup.log):

PostgreSQL 18.4 on x86_64-pc-linux-musl, compiled by gcc (Alpine 15.2.0) 15.2.0, 64-bit
psql (PostgreSQL) 18.4
### server: MySQL 8.4.13 (MySQL Community Server - GPL), isolation=REPEATABLE-READ, bp=128.00000000M, lock_wait_timeout=50, deadlock_detect=1, print_all_deadlocks=0, rollback_on_timeout=0
### image: mysql:8.4.13 client: python:3.12-slim + pymysql-1.2.3
PHP 8.4.27 ext: pdo_pgsql,pdo_mysql,pcntl
Laravel Framework 13.40.0
MySQL 8.4.13