Skip to content

Split View: 데드락 진단과 예방 — 로그에서 두 쿼리를 특정하는 법

✨ Learn with Quiz
|

데드락 진단과 예방 — 로그에서 두 쿼리를 특정하는 법

들어가며 — deadlock detected 로그를 처음 만났다면

운영 로그에 이런 줄이 뜹니다.

ERROR:  deadlock detected
DETAIL:  Process 24188 waits for ShareLock on transaction 98211; blocked by process 24193.
         Process 24193 waits for ShareLock on transaction 98209; blocked by process 24188.
HINT:  See server log for query details.
CONTEXT:  while updating tuple (128,17) in relation "orders"

처음 보면 심각한 장애처럼 보이지만, 이 로그가 찍혔다는 것은 데이터베이스가 문제를 감지하고 이미 해결했다는 뜻입니다. 한쪽 트랜잭션이 롤백되었고 나머지 한쪽은 정상적으로 진행했습니다. 데이터는 일관됩니다.

진짜 문제는 두 가지입니다. 첫째, 애플리케이션이 이 오류를 재시도하지 않고 사용자에게 그대로 노출하고 있을 가능성. 둘째, 데드락이 반복적으로 발생한다면 트랜잭션 설계에 구조적 결함이 있다는 신호라는 점.

이 글은 로그에서 어느 두 쿼리가 엮였는지 특정하는 방법부터 시작해서, 반복되는 세 가지 패턴과 예방 원칙, 그리고 데드락과 단순 락 대기를 혼동하지 않는 법을 다룹니다.

데드락은 장애가 아니라 정상 동작이다

데드락은 두 트랜잭션이 서로가 쥔 잠금을 기다릴 때 발생합니다.

시간 ──────────────────────────────────────────▶

트랜잭션 A:  row 1 잠금 획득 ─────── row 2 잠금 요청 ─── 대기
트랜잭션 B:          row 2 잠금 획득 ────── row 1 잠금 요청 ─── 대기
                                                       두 쪽 모두 영원히 대기

이 상태에서 데이터베이스가 아무것도 하지 않으면 두 트랜잭션은 영원히 멈춥니다. 그래서 PostgreSQL과 MySQL 모두 잠금 대기 그래프에서 순환을 탐지하면 한쪽을 골라 중단시킵니다. 이것이 정상 설계이고, 다른 선택지는 없습니다.

핵심은 여기서 나옵니다. 데드락을 완전히 없애는 것은 목표가 될 수 없습니다. 어떤 설계에서도 동시성이 있는 한 발생 확률을 0으로 만들 수는 없습니다. 목표는 두 가지입니다. 빈도를 실용적인 수준까지 낮추는 것, 그리고 발생했을 때 애플리케이션이 조용히 재시도하는 것.

# PostgreSQL: 40P01 (deadlock_detected)
# MySQL:      1213 (ER_LOCK_DEADLOCK)
RETRYABLE = {"40001", "40P01"}

재시도할 때 두 가지를 지켜야 합니다. 트랜잭션 전체를 처음부터 다시 실행할 것, 그리고 지연에 지터를 섞을 것. 고정 지연으로 재시도하면 두 트랜잭션이 같은 간격으로 다시 충돌합니다.

PostgreSQL 로그에서 데드락 리포트 읽기

기본 설정으로는 앞의 로그처럼 "서버 로그를 보라"는 힌트만 나옵니다. 실제 쿼리를 보려면 로그 설정이 되어 있어야 합니다.

-- 데드락과 오래 걸린 잠금 대기를 로그에 남긴다
ALTER SYSTEM SET log_lock_waits = on;
ALTER SYSTEM SET deadlock_timeout = '1s';
ALTER SYSTEM SET log_min_error_statement = 'error';
ALTER SYSTEM SET log_line_prefix = '%m [%p] %u@%d app=%a ';
SELECT pg_reload_conf();

log_line_prefix에 프로세스 ID와 애플리케이션 이름을 넣는 것이 결정적입니다. 데드락 리포트는 프로세스 번호로 상대를 지목하므로, 그 번호로 다른 로그 줄을 찾아야 쿼리를 복원할 수 있습니다. 애플리케이션 쪽에서 커넥션 문자열에 application_name을 설정해 두면 어느 서비스가 관여했는지 바로 드러납니다.

설정 후의 로그는 이렇게 나옵니다.

2026-07-26 14:02:11.442 KST [24188] api@shop app=order-service ERROR:  deadlock detected
2026-07-26 14:02:11.442 KST [24188] api@shop app=order-service DETAIL:  Process 24188 waits for ShareLock on transaction 98211; blocked by process 24193.
	Process 24193 waits for ShareLock on transaction 98209; blocked by process 24188.
	Process 24188: UPDATE inventory SET stock = stock - 1 WHERE sku = 'B-200';
	Process 24193: UPDATE inventory SET stock = stock - 1 WHERE sku = 'A-100';
2026-07-26 14:02:11.442 KST [24188] api@shop app=order-service HINT:  See server log for query details.
2026-07-26 14:02:11.442 KST [24188] api@shop app=order-service CONTEXT:  while updating tuple (128,17) in relation "inventory"
2026-07-26 14:02:11.442 KST [24188] api@shop app=order-service STATEMENT:  UPDATE inventory SET stock = stock - 1 WHERE sku = 'B-200';

읽는 순서는 이렇습니다.

  1. Process 24188 waits ... blocked by process 24193 — 순환의 방향을 파악합니다. 두 줄이면 2자 순환이고, 세 줄 이상이면 세 개 이상의 트랜잭션이 얽힌 것입니다.
  2. Process 24188:Process 24193: 줄 — 각 프로세스가 마지막으로 기다리고 있던 문장입니다.
  3. CONTEXT — 어느 테이블의 어느 튜플에서 막혔는지 알려 줍니다.

여기서 가장 중요한 오해를 짚고 갑니다. 리포트에 찍힌 두 문장은 데드락의 마지막 조각일 뿐 원인이 아닙니다. 24188은 B-200을 기다리고 있지만, 그가 이미 쥐고 있던 잠금은 A-100입니다. 그 A-100 잠금을 만든 문장은 리포트에 나오지 않습니다. 즉 원인 규명에는 두 트랜잭션이 그 이전에 실행한 문장 전체가 필요합니다.

그래서 실무에서는 이런 조합을 씁니다.

-- 관련 프로세스가 그 시각에 실행한 다른 문장을 로그에서 역추적
-- (log_line_prefix에 %p가 있어야 가능하다)
grep -E '\[24188\]|\[24193\]' /var/log/postgresql/postgresql.log \
  | awk '$1 " " $2 >= "2026-07-26 14:01:50"' \
  | head -60

데드락이 이미 지나간 뒤라면 로그가 유일한 증거입니다. 하지만 지금 진행 중인 잠금 대기를 보고 싶다면 카탈로그를 조회하면 됩니다.

SELECT a.pid,
       a.application_name,
       a.state,
       now() - a.xact_start        AS tx_age,
       now() - a.state_change      AS state_age,
       pg_blocking_pids(a.pid)     AS blocked_by,
       left(a.query, 80)           AS query
FROM pg_stat_activity a
WHERE a.backend_type = 'client backend'
  AND (cardinality(pg_blocking_pids(a.pid)) > 0
       OR a.state = 'idle in transaction')
ORDER BY tx_age DESC;
  pid  | application_name |        state        |    tx_age    |  state_age   | blocked_by |              query
-------+------------------+---------------------+--------------+--------------+------------+--------------------------------
 24193 | order-service    | idle in transaction | 00:04:12.881 | 00:04:10.220 | {}         | SELECT ... FOR UPDATE
 24188 | order-service    | active              | 00:00:08.114 | 00:00:08.101 | {24193}    | UPDATE inventory SET stock ...

pg_blocking_pids가 비어 있는데 상태가 idle in transaction인 프로세스가 있다면 그것이 근원입니다. 트랜잭션을 열어 놓고 애플리케이션이 다른 일을 하고 있다는 뜻입니다.

MySQL의 SHOW ENGINE INNODB STATUS 읽기

MySQL은 마지막 데드락 하나만 상태 출력에 보관합니다.

SHOW ENGINE INNODB STATUS\G
------------------------
LATEST DETECTED DEADLOCK
------------------------
2026-07-26 14:02:11 0x7f2a1c0d5700
*** (1) TRANSACTION:
TRANSACTION 4821094, ACTIVE 3 sec starting index read
mysql tables in use 1, locked 1
LOCK WAIT 4 lock struct(s), heap size 1136, 2 row lock(s)
MySQL thread id 8812, query id 2210934 10.0.3.21 api updating
UPDATE inventory SET stock = stock - 1 WHERE sku = 'B-200'

*** (1) HOLDS THE LOCK(S):
RECORD LOCKS space id 421 page no 5 n bits 80 index PRIMARY of table `shop`.`inventory`
trx id 4821094 lock_mode X locks rec but not gap
Record lock, heap no 3 PHYSICAL RECORD: n_fields 4; ...  0: len 5; hex 412d313030; asc A-100;

*** (1) WAITING FOR THIS LOCK TO BE GRANTED:
RECORD LOCKS space id 421 page no 5 n bits 80 index PRIMARY of table `shop`.`inventory`
trx id 4821094 lock_mode X locks rec but not gap waiting
Record lock, heap no 4 PHYSICAL RECORD: n_fields 4; ...  0: len 5; hex 422d323030; asc B-200;

*** (2) TRANSACTION:
TRANSACTION 4821096, ACTIVE 2 sec starting index read
MySQL thread id 8815, query id 2210941 10.0.3.22 api updating
UPDATE inventory SET stock = stock - 1 WHERE sku = 'A-100'

*** (2) HOLDS THE LOCK(S):
... asc B-200;

*** (2) WAITING FOR THIS LOCK TO BE GRANTED:
... asc A-100;

*** WE ROLL BACK TRANSACTION (2)

PostgreSQL 리포트보다 정보가 많습니다. 특히 HOLDS THE LOCK(S) 절이 있어서 각 트랜잭션이 이미 쥐고 있던 잠금까지 보여 줍니다. 위 출력만으로 순환이 완전히 재구성됩니다. 트랜잭션 1은 A-100을 쥐고 B-200을 기다리고, 트랜잭션 2는 B-200을 쥐고 A-100을 기다립니다.

읽을 때 눈여겨볼 항목은 이렇습니다.

  • asc 뒤의 문자열 — 잠긴 레코드의 인덱스 키 값입니다. 어떤 행인지 바로 알 수 있습니다.
  • index PRIMARY of table — 어느 인덱스에 잠금이 걸렸는지 나옵니다. 보조 인덱스 이름이 보인다면 그 인덱스 경로로 접근했다는 뜻입니다.
  • lock_mode X locks rec but not gap — 레코드만 잠근 상태입니다. locks gap before reclock_mode X만 있으면 갭 락 또는 넥스트 키 락입니다. 갭 락이 보인다면 잠금 범위가 넓다는 신호입니다.
  • WE ROLL BACK TRANSACTION (2) — InnoDB는 되돌릴 작업량이 적은 쪽, 즉 변경한 행 수가 적은 쪽을 희생시킵니다.

문제는 이 출력이 마지막 하나만 남는다는 것입니다. 운영에서는 전부 로그로 남겨야 합니다.

SET GLOBAL innodb_print_all_deadlocks = ON;

이 설정을 켜면 모든 데드락이 에러 로그에 기록됩니다. 부하가 걱정되는 수준으로 데드락이 발생한다면 그 자체가 이미 고쳐야 할 문제입니다.

실무에서 반복되는 세 가지 패턴

수많은 데드락 사례를 분해해 보면 대개 세 가지로 수렴합니다.

패턴로그에서 보이는 모습해법
다중 행 갱신 순서 불일치두 트랜잭션이 같은 테이블의 서로 다른 키를 교차로 대기항상 같은 기준으로 정렬한 순서대로 잠금
인덱스 부재로 락 범위 확대잠긴 행 수가 실제 대상보다 훨씬 많고 갭 락이 보임조건 컬럼에 인덱스 추가
외래 키의 부모 행 잠금자식 테이블 INSERT인데 부모 테이블 레코드에서 대기부모 갱신 트랜잭션 축소, 필요시 제약 검증 지연

패턴 1 — 갱신 순서 불일치

가장 흔합니다. 두 요청이 같은 두 행을 반대 순서로 건드립니다.

-- 요청 A
BEGIN;
UPDATE inventory SET stock = stock - 1 WHERE sku = 'A-100';
UPDATE inventory SET stock = stock - 1 WHERE sku = 'B-200';
COMMIT;

-- 요청 B (동시에)
BEGIN;
UPDATE inventory SET stock = stock - 1 WHERE sku = 'B-200';
UPDATE inventory SET stock = stock - 1 WHERE sku = 'A-100';
COMMIT;

애플리케이션 코드에서는 이 순서가 보통 장바구니에 담긴 순서, 즉 사용자마다 다른 순서입니다. 해결은 잠글 대상을 미리 정렬하는 것입니다.

-- 한 문장으로 처리하고 순서를 명시한다
UPDATE inventory
SET stock = stock - v.qty
FROM (VALUES ('B-200', 1), ('A-100', 2)) AS v(sku, qty)
WHERE inventory.sku = v.sku;

주의할 점이 있습니다. 단일 UPDATE 문의 잠금 획득 순서는 실행 계획에 따라 결정되므로 SQL만으로 완전히 보장되지는 않습니다. 확실히 하려면 잠금을 먼저 정렬된 순서로 잡습니다.

BEGIN;
SELECT sku FROM inventory
WHERE sku IN ('B-200', 'A-100')
ORDER BY sku
FOR UPDATE;
-- 이후 갱신은 순서에 상관없이 안전하다
UPDATE inventory SET stock = stock - 1 WHERE sku = 'B-200';
UPDATE inventory SET stock = stock - 1 WHERE sku = 'A-100';
COMMIT;

배치 작업이라면 애플리케이션 쪽에서 키를 정렬해서 넘기는 것이 원칙입니다. 정렬 기준은 무엇이든 상관없고, 모든 코드 경로에서 같은 기준을 쓰는 것만이 중요합니다.

패턴 2 — 인덱스 부재로 락 범위가 넓어진 경우

MySQL에서 특히 치명적입니다. 조건 컬럼에 인덱스가 없으면 InnoDB는 훑는 모든 레코드에 잠금을 겁니다.

-- order_no에 인덱스가 없다면
UPDATE orders SET status = 'cancelled' WHERE order_no = 'ORD-20260726-001';
-- 실제로는 테이블 전체 스캔 + 스캔한 모든 행에 잠금

대상은 한 행인데 백만 행이 잠깁니다. 이 상태에서는 무관한 두 요청도 서로 충돌합니다. 데드락 로그에 등장하는 두 쿼리가 논리적으로 아무 관계가 없어 보인다면 이 패턴을 의심하십시오.

CREATE INDEX idx_orders_order_no ON orders (order_no);

PostgreSQL은 인덱스 없이 스캔한 행에 무조건 잠금을 걸지는 않지만, 갱신 대상이 되는 행에는 걸립니다. 그래도 인덱스가 없으면 갱신 대상을 찾는 시간이 길어지고 트랜잭션이 길어지므로 결국 충돌 확률이 올라갑니다.

패턴 3 — 외래 키가 유발하는 부모 행 잠금

자식 테이블에 INSERT하면 참조 무결성을 지키기 위해 부모 행을 잠급니다. PostgreSQL은 부모 행에 FOR KEY SHARE 수준의 잠금을 걸고, InnoDB는 공유 잠금을 겁니다.

CREATE TABLE users (id bigserial PRIMARY KEY, name text);
CREATE TABLE orders (
  id      bigserial PRIMARY KEY,
  user_id bigint REFERENCES users(id),
  total   numeric
);
-- 세션 A
BEGIN;
INSERT INTO orders (user_id, total) VALUES (7, 1000);   -- users(7)에 KEY SHARE
UPDATE users SET name = 'Kim' WHERE id = 7;             -- 자기 잠금이라 통과
-- 세션 B (동시)
BEGIN;
INSERT INTO orders (user_id, total) VALUES (7, 2000);   -- KEY SHARE 획득 (공유라 가능)
UPDATE users SET name = 'Lee' WHERE id = 7;             -- 세션 A의 잠금 대기

두 세션이 같은 부모 행을 참조하면서 그 부모 행을 갱신하려 들면 데드락이 됩니다. 공유 잠금은 여러 트랜잭션이 동시에 쥘 수 있으므로, 그 상태에서 배타 잠금으로 승격하려는 순간 서로를 막습니다.

실무에서 이 패턴은 "주문을 넣으면서 사용자의 마지막 주문 시각을 갱신"하는 코드에서 자주 나옵니다. 해법은 부모 갱신을 없애거나, 별도 테이블로 분리하거나, 순서를 강제해 부모를 먼저 배타적으로 잠그는 것입니다.

-- 부모를 먼저 배타적으로 잠가 승격 경합을 없앤다
BEGIN;
SELECT id FROM users WHERE id = 7 FOR UPDATE;
INSERT INTO orders (user_id, total) VALUES (7, 1000);
UPDATE users SET last_ordered_at = now() WHERE id = 7;
COMMIT;

PostgreSQL에서는 참조 컬럼을 갱신하지 않는 UPDATE라면 FOR KEY SHARE와 충돌하지 않으므로 이 문제가 덜합니다. 그럼에도 같은 부모 행을 놓고 배타 잠금을 다투는 구조라면 마찬가지입니다.

예방 원칙 — 순서, 길이, 인덱스

세 가지 패턴에서 예방 원칙이 그대로 도출됩니다.

항상 같은 순서로 접근합니다. 여러 행을 잠근다면 기본 키 오름차순 같은 결정적 기준으로 정렬합니다. 여러 테이블을 잠근다면 테이블 순서도 코딩 규약으로 고정합니다.

트랜잭션을 짧게 유지합니다. 데드락 확률은 잠금 보유 시간에 비례합니다. 트랜잭션 안에 외부 API 호출, 파일 입출력, 사용자 입력 대기가 들어가면 안 됩니다. 이 규칙 하나만 지켜도 대부분의 데드락이 사라집니다.

-- 커넥션이 트랜잭션을 열어 놓고 방치하는 것을 강제로 끊는다
ALTER SYSTEM SET idle_in_transaction_session_timeout = '30s';
SELECT pg_reload_conf();

인덱스로 잠금 범위를 좁힙니다. 갱신 조건과 잠금 읽기 조건은 반드시 인덱스로 처리되어야 합니다. 조회 성능 문제가 아니라 동시성 문제입니다.

배치는 정렬하고 잘게 나눕니다. 10만 건을 한 트랜잭션에서 갱신하면 그 시간 내내 10만 행이 잠깁니다.

-- 정렬된 순서로, 작은 덩어리로
WITH batch AS (
  SELECT id FROM jobs
  WHERE status = 'queued'
  ORDER BY id
  LIMIT 1000
  FOR UPDATE SKIP LOCKED
)
UPDATE jobs SET status = 'processing'
WHERE id IN (SELECT id FROM batch);

SKIP LOCKED는 여러 워커가 같은 큐를 소비할 때 데드락을 구조적으로 없애 줍니다. 잠긴 행을 기다리지 않고 건너뛰기 때문에 순환 자체가 생기지 않습니다.

deadlock_timeout과 lock_timeout, 그리고 락 대기와의 구분

세 가지 타임아웃이 있고 역할이 전혀 다릅니다.

-- 데드락 탐지를 시작하기까지 기다리는 시간. 탐지 비용이 크므로 바로 하지 않는다.
SHOW deadlock_timeout;              -- 기본 1s

-- 잠금 획득을 포기하기까지의 시간. 기본은 무제한.
SHOW lock_timeout;                  -- 기본 0 (무제한)

-- 문장 전체의 실행 시간 상한.
SHOW statement_timeout;             -- 기본 0 (무제한)

deadlock_timeout을 줄이면 데드락이 더 빨리 해소되지만, 순환이 없는 평범한 잠금 대기에 대해서도 매번 탐지 알고리즘이 돌아 CPU를 씁니다. 기본값 1초는 대부분의 환경에서 합리적입니다. 이 값을 100ms 같은 값으로 낮추라는 조언을 종종 보는데, 데드락이 잦아 응답 지연이 실제로 문제가 되는 상황이 아니라면 이득보다 비용이 큽니다.

정말 설정해야 할 것은 lock_timeout입니다.

-- 사용자 응답 경로에서는 잠금을 오래 기다리지 않는다
SET lock_timeout = '3s';

-- DDL은 특히 짧게. 대기 중에 뒤따르는 모든 쿼리가 함께 막히기 때문이다.
SET lock_timeout = '2s';
ALTER TABLE orders ADD COLUMN memo text;

MySQL에서 대응되는 설정은 innodb_lock_wait_timeout이고 기본값이 50초입니다. 웹 요청 경로에는 지나치게 깁니다.

SET SESSION innodb_lock_wait_timeout = 5;

마지막으로, 가장 흔한 오진 하나를 정리합니다. 락 대기와 데드락은 다른 문제이고 대응도 다릅니다.

  • 데드락은 순환입니다. 데이터베이스가 1초 안에 감지해 한쪽을 죽입니다. 오류 코드는 PostgreSQL 40P01, MySQL 1213입니다. 증상은 간헐적 오류이지 지연이 아닙니다.
  • 락 대기는 순환이 아닙니다. 누군가 잠금을 오래 쥐고 있고 나머지가 줄을 섭니다. 아무도 죽지 않고, 대신 응답 시간이 길어지다가 커넥션 풀이 고갈되어 서비스 전체가 멈춥니다. 오류 코드는 55P03이거나 타임아웃입니다.

"데드락 때문에 서비스가 느려졌다"는 진단은 대개 틀립니다. 데드락은 빠르게 해소되므로 지연을 만들지 않습니다. 느려졌다면 락 대기, 그리고 그 뒤에 있는 긴 트랜잭션이나 방치된 idle in transaction 커넥션을 봐야 합니다.

-- 잠금 대기 사슬의 뿌리를 찾는다
WITH RECURSIVE chain AS (
  SELECT pid, unnest(pg_blocking_pids(pid)) AS blocker, 1 AS depth
  FROM pg_stat_activity
  WHERE cardinality(pg_blocking_pids(pid)) > 0
  UNION ALL
  SELECT c.blocker, unnest(pg_blocking_pids(c.blocker)), c.depth + 1
  FROM chain c
  WHERE cardinality(pg_blocking_pids(c.blocker)) > 0 AND c.depth < 10
)
SELECT DISTINCT a.pid, a.state, now() - a.xact_start AS tx_age, left(a.query, 60)
FROM chain c
JOIN pg_stat_activity a ON a.pid = c.blocker
WHERE cardinality(pg_blocking_pids(c.blocker)) = 0;

마치며 — 데드락은 없애는 것이 아니라 재시도 가능하게 만드는 것

정리하면 이렇습니다.

로그를 볼 때는 리포트에 찍힌 두 문장이 원인이 아니라는 점부터 기억하십시오. 그것은 각 트랜잭션이 마지막으로 기다린 문장일 뿐이고, 진짜 원인은 그 이전에 이미 잡아 둔 잠금입니다. MySQL이라면 HOLDS THE LOCK(S) 절이 그 정보를 주고, PostgreSQL이라면 같은 프로세스 번호의 앞선 로그를 찾아야 합니다.

예방은 순서, 길이, 인덱스 세 단어로 요약됩니다. 같은 순서로 잠그고, 트랜잭션을 짧게 유지하고, 잠금 조건에 인덱스를 두십시오. 여기에 lock_timeoutidle_in_transaction_session_timeout을 설정해 두면 사고가 전면 장애로 번지지 않습니다.

그리고 이 모든 것을 다 하더라도 데드락은 발생합니다. 40P01과 1213을 잡아 백오프와 지터를 섞어 재시도하는 코드가 있어야 합니다. 그 코드가 없다면 예방 작업의 성과는 사용자에게 전달되지 않습니다.

Diagnosing and Preventing Deadlocks — How to Pin Down the Two Queries from the Log

Introduction — When You Meet a deadlock detected Log for the First Time

A line like this shows up in the production log.

ERROR:  deadlock detected
DETAIL:  Process 24188 waits for ShareLock on transaction 98211; blocked by process 24193.
         Process 24193 waits for ShareLock on transaction 98209; blocked by process 24188.
HINT:  See server log for query details.
CONTEXT:  while updating tuple (128,17) in relation "orders"

At first glance it looks like a serious incident, but the fact that this log was written means the database detected the problem and has already resolved it. One transaction was rolled back and the other proceeded normally. The data is consistent.

The real problems are two. First, the possibility that the application is not retrying this error and is exposing it straight to the user. Second, the fact that repeated deadlocks are a signal of a structural flaw in the transaction design.

This article starts with how to pin down which two queries got tangled from the log, then covers the three recurring patterns and the prevention principles, and finally how not to confuse a deadlock with a plain lock wait.

A Deadlock Is Not an Incident but Normal Operation

A deadlock occurs when two transactions wait for locks each other is holding.

time ──────────────────────────────────────────▶

transaction A:  acquires lock on row 1 ─────── requests lock on row 2 ─── waits
transaction B:          acquires lock on row 2 ────── requests lock on row 1 ─── waits
                                                       both sides wait forever

If the database does nothing in this state, the two transactions stop forever. So both PostgreSQL and MySQL detect a cycle in the lock wait graph and pick one side to abort. This is the correct design, and there is no other option.

The key point follows from this. Eliminating deadlocks entirely cannot be the goal. Under any design, as long as there is concurrency you cannot drive the probability of occurrence to zero. The goals are two: lowering the frequency to a practical level, and having the application quietly retry when one does occur.

# PostgreSQL: 40P01 (deadlock_detected)
# MySQL:      1213 (ER_LOCK_DEADLOCK)
RETRYABLE = {"40001", "40P01"}

Two things must be honoured when retrying: re-run the entire transaction from the beginning, and mix jitter into the delay. Retrying with a fixed delay makes the two transactions collide again at the same interval.

Reading the Deadlock Report in the PostgreSQL Log

With the default settings all you get is the hint "see the server log," as in the log above. To see the actual queries, logging has to be configured.

-- Record deadlocks and long lock waits in the log
ALTER SYSTEM SET log_lock_waits = on;
ALTER SYSTEM SET deadlock_timeout = '1s';
ALTER SYSTEM SET log_min_error_statement = 'error';
ALTER SYSTEM SET log_line_prefix = '%m [%p] %u@%d app=%a ';
SELECT pg_reload_conf();

Putting the process ID and the application name into log_line_prefix is decisive. The deadlock report identifies the other side by process number, so you have to find other log lines by that number to reconstruct the queries. If the application sets application_name in the connection string, which service was involved becomes immediately obvious.

After configuring, the log comes out like this.

2026-07-26 14:02:11.442 KST [24188] api@shop app=order-service ERROR:  deadlock detected
2026-07-26 14:02:11.442 KST [24188] api@shop app=order-service DETAIL:  Process 24188 waits for ShareLock on transaction 98211; blocked by process 24193.
	Process 24193 waits for ShareLock on transaction 98209; blocked by process 24188.
	Process 24188: UPDATE inventory SET stock = stock - 1 WHERE sku = 'B-200';
	Process 24193: UPDATE inventory SET stock = stock - 1 WHERE sku = 'A-100';
2026-07-26 14:02:11.442 KST [24188] api@shop app=order-service HINT:  See server log for query details.
2026-07-26 14:02:11.442 KST [24188] api@shop app=order-service CONTEXT:  while updating tuple (128,17) in relation "inventory"
2026-07-26 14:02:11.442 KST [24188] api@shop app=order-service STATEMENT:  UPDATE inventory SET stock = stock - 1 WHERE sku = 'B-200';

The reading order goes like this.

  1. Process 24188 waits ... blocked by process 24193 — work out the direction of the cycle. Two lines means a two-party cycle; three or more lines means three or more transactions are entangled.
  2. The Process 24188: and Process 24193: lines — the statement each process was last waiting on.
  3. CONTEXT — tells you which tuple of which table it got stuck on.

Here we should address the most important misunderstanding. The two statements printed in the report are merely the last piece of the deadlock, not the cause. 24188 is waiting on B-200, but the lock it was already holding is A-100. The statement that created that A-100 lock does not appear in the report. In other words, identifying the cause requires all of the statements the two transactions executed before that point.

So in practice you use a combination like this.

-- Trace back in the log for other statements the processes involved ran at that time
-- (only possible if log_line_prefix contains %p)
grep -E '\[24188\]|\[24193\]' /var/log/postgresql/postgresql.log \
  | awk '$1 " " $2 >= "2026-07-26 14:01:50"' \
  | head -60

Once a deadlock has passed, the log is the only evidence. But if you want to see the lock waits currently in progress, you can just query the catalog.

SELECT a.pid,
       a.application_name,
       a.state,
       now() - a.xact_start        AS tx_age,
       now() - a.state_change      AS state_age,
       pg_blocking_pids(a.pid)     AS blocked_by,
       left(a.query, 80)           AS query
FROM pg_stat_activity a
WHERE a.backend_type = 'client backend'
  AND (cardinality(pg_blocking_pids(a.pid)) > 0
       OR a.state = 'idle in transaction')
ORDER BY tx_age DESC;
  pid  | application_name |        state        |    tx_age    |  state_age   | blocked_by |              query
-------+------------------+---------------------+--------------+--------------+------------+--------------------------------
 24193 | order-service    | idle in transaction | 00:04:12.881 | 00:04:10.220 | {}         | SELECT ... FOR UPDATE
 24188 | order-service    | active              | 00:00:08.114 | 00:00:08.101 | {24193}    | UPDATE inventory SET stock ...

If there is a process whose pg_blocking_pids is empty but whose state is idle in transaction, that one is the root. It means the application opened a transaction and went off to do something else.

Reading SHOW ENGINE INNODB STATUS in MySQL

MySQL keeps only the most recent deadlock in its status output.

SHOW ENGINE INNODB STATUS\G
------------------------
LATEST DETECTED DEADLOCK
------------------------
2026-07-26 14:02:11 0x7f2a1c0d5700
*** (1) TRANSACTION:
TRANSACTION 4821094, ACTIVE 3 sec starting index read
mysql tables in use 1, locked 1
LOCK WAIT 4 lock struct(s), heap size 1136, 2 row lock(s)
MySQL thread id 8812, query id 2210934 10.0.3.21 api updating
UPDATE inventory SET stock = stock - 1 WHERE sku = 'B-200'

*** (1) HOLDS THE LOCK(S):
RECORD LOCKS space id 421 page no 5 n bits 80 index PRIMARY of table `shop`.`inventory`
trx id 4821094 lock_mode X locks rec but not gap
Record lock, heap no 3 PHYSICAL RECORD: n_fields 4; ...  0: len 5; hex 412d313030; asc A-100;

*** (1) WAITING FOR THIS LOCK TO BE GRANTED:
RECORD LOCKS space id 421 page no 5 n bits 80 index PRIMARY of table `shop`.`inventory`
trx id 4821094 lock_mode X locks rec but not gap waiting
Record lock, heap no 4 PHYSICAL RECORD: n_fields 4; ...  0: len 5; hex 422d323030; asc B-200;

*** (2) TRANSACTION:
TRANSACTION 4821096, ACTIVE 2 sec starting index read
MySQL thread id 8815, query id 2210941 10.0.3.22 api updating
UPDATE inventory SET stock = stock - 1 WHERE sku = 'A-100'

*** (2) HOLDS THE LOCK(S):
... asc B-200;

*** (2) WAITING FOR THIS LOCK TO BE GRANTED:
... asc A-100;

*** WE ROLL BACK TRANSACTION (2)

There is more information here than in the PostgreSQL report. In particular, the HOLDS THE LOCK(S) section shows even the locks each transaction was already holding. The cycle is fully reconstructed from this output alone. Transaction 1 holds A-100 and waits for B-200; transaction 2 holds B-200 and waits for A-100.

The items worth paying attention to when reading are these.

  • The string after asc — the index key value of the locked record. You can tell immediately which row it is.
  • index PRIMARY of table — tells you which index the lock was taken on. If a secondary index name appears, the access went through that index path.
  • lock_mode X locks rec but not gap — only the record is locked. If you see locks gap before rec or just lock_mode X, it is a gap lock or a next-key lock. Seeing a gap lock is a signal that the lock scope is wide.
  • WE ROLL BACK TRANSACTION (2) — InnoDB sacrifices the side with less work to undo, that is, the side that changed fewer rows.

The problem is that only the most recent one remains in this output. In production you have to log all of them.

SET GLOBAL innodb_print_all_deadlocks = ON;

Turning this setting on records every deadlock in the error log. If deadlocks occur at a rate where the logging load is a concern, that by itself is already a problem you need to fix.

Three Patterns That Keep Recurring in Practice

If you take apart a large number of deadlock cases, they generally converge to three.

PatternHow it looks in the logRemedy
Inconsistent multi-row update orderTwo transactions cross-waiting on different keys of the same tableAlways lock in an order sorted by the same criterion
Lock scope widened by a missing indexFar more rows locked than the actual target, with gap locks visibleAdd an index on the condition column
Parent row locking from a foreign keyA child table INSERT waiting on a parent table recordShrink the parent-updating transaction, defer constraint checking if needed

Pattern 1 — Inconsistent Update Order

This is the most common. Two requests touch the same two rows in opposite order.

-- Request A
BEGIN;
UPDATE inventory SET stock = stock - 1 WHERE sku = 'A-100';
UPDATE inventory SET stock = stock - 1 WHERE sku = 'B-200';
COMMIT;

-- Request B (at the same time)
BEGIN;
UPDATE inventory SET stock = stock - 1 WHERE sku = 'B-200';
UPDATE inventory SET stock = stock - 1 WHERE sku = 'A-100';
COMMIT;

In application code this order is usually the order items were added to the cart, that is, a different order for every user. The fix is to sort the lock targets up front.

-- Handle it in one statement and make the order explicit
UPDATE inventory
SET stock = stock - v.qty
FROM (VALUES ('B-200', 1), ('A-100', 2)) AS v(sku, qty)
WHERE inventory.sku = v.sku;

There is a caveat. The lock acquisition order of a single UPDATE statement is determined by the query plan, so SQL alone does not fully guarantee it. To be certain, take the locks first in sorted order.

BEGIN;
SELECT sku FROM inventory
WHERE sku IN ('B-200', 'A-100')
ORDER BY sku
FOR UPDATE;
-- after this, the updates are safe in any order
UPDATE inventory SET stock = stock - 1 WHERE sku = 'B-200';
UPDATE inventory SET stock = stock - 1 WHERE sku = 'A-100';
COMMIT;

For batch jobs, the principle is to sort the keys on the application side before passing them along. The sort criterion can be anything; all that matters is using the same criterion in every code path.

Pattern 2 — Lock Scope Widened by a Missing Index

This is especially lethal in MySQL. If the condition column has no index, InnoDB locks every record it scans.

-- If there is no index on order_no
UPDATE orders SET status = 'cancelled' WHERE order_no = 'ORD-20260726-001';
-- in reality: a full table scan plus a lock on every row scanned

The target is one row, but a million rows get locked. In this state even two unrelated requests collide with each other. If the two queries appearing in a deadlock log look logically unrelated, suspect this pattern.

CREATE INDEX idx_orders_order_no ON orders (order_no);

PostgreSQL does not unconditionally lock every row it scans without an index, but it does lock the rows being updated. Even so, without an index it takes longer to find the update targets and the transaction gets longer, so the collision probability rises in the end anyway.

Pattern 3 — Parent Row Locking Caused by Foreign Keys

INSERTing into a child table locks the parent row in order to preserve referential integrity. PostgreSQL takes a FOR KEY SHARE level lock on the parent row, and InnoDB takes a shared lock.

CREATE TABLE users (id bigserial PRIMARY KEY, name text);
CREATE TABLE orders (
  id      bigserial PRIMARY KEY,
  user_id bigint REFERENCES users(id),
  total   numeric
);
-- Session A
BEGIN;
INSERT INTO orders (user_id, total) VALUES (7, 1000);   -- KEY SHARE on users(7)
UPDATE users SET name = 'Kim' WHERE id = 7;             -- its own lock, so it passes
-- Session B (at the same time)
BEGIN;
INSERT INTO orders (user_id, total) VALUES (7, 2000);   -- acquires KEY SHARE (possible, it is shared)
UPDATE users SET name = 'Lee' WHERE id = 7;             -- waits on the lock of session A

When two sessions reference the same parent row and then try to update that parent row, it becomes a deadlock. A shared lock can be held by several transactions at once, so the moment each tries to upgrade to an exclusive lock, they block each other.

In practice this pattern shows up often in code that "places an order while also updating the timestamp of the last order of the user." The remedy is to remove the parent update, split it into a separate table, or force an order so the parent is locked exclusively first.

-- Lock the parent exclusively first to eliminate upgrade contention
BEGIN;
SELECT id FROM users WHERE id = 7 FOR UPDATE;
INSERT INTO orders (user_id, total) VALUES (7, 1000);
UPDATE users SET last_ordered_at = now() WHERE id = 7;
COMMIT;

In PostgreSQL, an UPDATE that does not modify the referenced column does not conflict with FOR KEY SHARE, so this problem is milder. Even so, if the structure has transactions contending for an exclusive lock on the same parent row, it is the same story.

Prevention Principles — Order, Length, Index

The prevention principles fall directly out of the three patterns.

Always access in the same order. If you lock several rows, sort them by a deterministic criterion such as ascending primary key. If you lock several tables, fix the table order as a coding convention too.

Keep transactions short. Deadlock probability is proportional to how long locks are held. External API calls, file I/O, and waiting for user input must not go inside a transaction. Following this one rule alone makes most deadlocks disappear.

-- Forcibly cut off connections that leave a transaction open and abandon it
ALTER SYSTEM SET idle_in_transaction_session_timeout = '30s';
SELECT pg_reload_conf();

Narrow the lock scope with indexes. Update conditions and locking read conditions must be handled by an index. This is not a query performance issue but a concurrency issue.

Sort batches and split them small. Update 100 thousand rows in one transaction and 100 thousand rows stay locked for that entire time.

-- In sorted order, in small chunks
WITH batch AS (
  SELECT id FROM jobs
  WHERE status = 'queued'
  ORDER BY id
  LIMIT 1000
  FOR UPDATE SKIP LOCKED
)
UPDATE jobs SET status = 'processing'
WHERE id IN (SELECT id FROM batch);

SKIP LOCKED structurally eliminates deadlocks when several workers consume the same queue. Because it skips locked rows instead of waiting on them, no cycle can form in the first place.

deadlock_timeout, lock_timeout, and Telling Them Apart from Lock Waits

There are three timeouts and their roles are completely different.

-- How long to wait before starting deadlock detection. Detection is expensive, so it is not immediate.
SHOW deadlock_timeout;              -- default 1s

-- How long before giving up on acquiring a lock. Unlimited by default.
SHOW lock_timeout;                  -- default 0 (unlimited)

-- An upper bound on the execution time of a whole statement.
SHOW statement_timeout;             -- default 0 (unlimited)

Lowering deadlock_timeout resolves deadlocks faster, but the detection algorithm then runs every time even for ordinary lock waits with no cycle, burning CPU. The default of 1 second is reasonable in most environments. You sometimes see advice to lower this value to something like 100ms, but unless deadlocks are frequent enough that the added response delay is genuinely a problem, the cost outweighs the benefit.

What you really should set is lock_timeout.

-- On the user response path, do not wait long for a lock
SET lock_timeout = '3s';

-- Especially short for DDL, because every query queued behind it gets blocked while it waits.
SET lock_timeout = '2s';
ALTER TABLE orders ADD COLUMN memo text;

The corresponding setting in MySQL is innodb_lock_wait_timeout, and its default is 50 seconds. That is excessively long for a web request path.

SET SESSION innodb_lock_wait_timeout = 5;

Finally, let us sort out the most common misdiagnosis. A lock wait and a deadlock are different problems, and the responses differ too.

  • A deadlock is a cycle. The database detects it within a second and kills one side. The error codes are 40P01 in PostgreSQL and 1213 in MySQL. The symptom is an intermittent error, not latency.
  • A lock wait is not a cycle. Somebody holds a lock for a long time and everyone else queues up. Nobody dies; instead response times stretch out until the connection pool is exhausted and the whole service stops. The error code is 55P03, or a timeout.

The diagnosis "the service got slow because of deadlocks" is usually wrong. Deadlocks resolve quickly, so they do not create latency. If things got slow, look at lock waits, and behind them the long transactions or the abandoned idle in transaction connections.

-- Find the root of the lock wait chain
WITH RECURSIVE chain AS (
  SELECT pid, unnest(pg_blocking_pids(pid)) AS blocker, 1 AS depth
  FROM pg_stat_activity
  WHERE cardinality(pg_blocking_pids(pid)) > 0
  UNION ALL
  SELECT c.blocker, unnest(pg_blocking_pids(c.blocker)), c.depth + 1
  FROM chain c
  WHERE cardinality(pg_blocking_pids(c.blocker)) > 0 AND c.depth < 10
)
SELECT DISTINCT a.pid, a.state, now() - a.xact_start AS tx_age, left(a.query, 60)
FROM chain c
JOIN pg_stat_activity a ON a.pid = c.blocker
WHERE cardinality(pg_blocking_pids(c.blocker)) = 0;

Closing — Deadlocks Are Not to Be Eliminated but Made Retryable

To summarize.

When you look at the log, start by remembering that the two statements printed in the report are not the cause. They are merely the statement each transaction was last waiting on, and the real cause is the lock already taken before that. In MySQL the HOLDS THE LOCK(S) section gives you that information; in PostgreSQL you have to find the earlier log lines with the same process number.

Prevention comes down to three words: order, length, index. Lock in the same order, keep transactions short, and put an index on the locking condition. Add lock_timeout and idle_in_transaction_session_timeout on top and an accident will not spread into a total outage.

And even if you do all of this, deadlocks will still happen. You need code that catches 40P01 and 1213 and retries with backoff and jitter. Without that code, the results of your prevention work never reach the user.