# MySQL

InnoDB deadlock이 나면 MySQL은 누구를 롤백할까?

@Hong2026-09-29

이 글에 담긴 코드와 결과는 직접 실행해 측정한 값을 바탕으로 작성했습니다. 실측은 Hong과 팀원들이 진행했고, 그 결과를 바탕으로 AI가 글을 쓰고 Hong이 검토합니다.

실습 코드 내려받기

질문
MySQL InnoDB는 deadlock이 나면 어느 트랜잭션을 롤백할까?
답
바꾼 행이 적은 트랜잭션을 롤백한다. 행 1개를 바꾼 트랜잭션이 롤백됐고, 순환을 마지막에 완성했지만 21행을 바꾼 트랜잭션은 커밋했다.
조건
MySQL 8.4(mysql:8.4 이미지), Docker, Python 3.12와 PyMySQL, 계좌 2행, 이체 기록 20행

환경

MySQL
mysql:8.4 이미지, Docker Compose 컨테이너 하나
재시도 예제
Python 3.12(python:3.12-slim 컨테이너)와 PyMySQL
데이터
계좌 2행, 이체 기록 20행
목차
  1. 실습 준비
  2. 실습 1: deadlock 만들기
  3. 실습 2: 가장 최근 deadlock은 InnoDB 상태에서 봅니다
  4. 실습 3: 애플리케이션은 트랜잭션 전체를 다시 실행합니다
  5. 정리

이번 시간에는 InnoDB가 deadlock에서 어떤 트랜잭션을 롤백하는지, 그 기록을 어디서 읽는지, 애플리케이션은 어떻게 대응해야 하는지를 MySQL과 Python 예제로 직접 만들어 보며 배워가는 시간입니다.

예를 들어서 주문 API 로그에 이런 오류가 찍혔다고 해볼게요.

ERROR 1213 (40001): Deadlock found when trying to get lock; try restarting transaction

두 트랜잭션이 서로 상대가 가진 잠금을 기다리다 둘 다 움직이지 못하는 상태를 deadlock이라고 해요.

InnoDB는 잠금을 기다리는 관계를 계속 살피다가 순환을 발견하면, 순환에 걸린 트랜잭션 가운데 하나를 희생 트랜잭션으로 골라 롤백합니다. 롤백된 쪽은 ERROR 1213을 받고, 잠금이 풀린 다른 쪽은 이어서 커밋까지 진행해요.

그럼 어느 쪽이 희생 트랜잭션이 될까요? InnoDB는 INSERT, UPDATE, DELETE로 바꾼 행이 적은 쪽을 롤백하려고 해요. 롤백해야 할 양이 적기 때문이에요.

실습 준비

이 글의 명령은 모두 실습 코드의 mysql-deadlock/ 디렉터리에서 실행합니다.

  • compose.yaml: MySQL 8.4 컨테이너 정의
  • setup.sql: bank 데이터베이스에 계좌, 이체 기록, 주문 테이블을 만드는 SQL
  • transfer.py: 이체 두 개를 동시에 실행하고 deadlock이 나면 트랜잭션 전체를 다시 실행하는 예제

Docker로 MySQL 8.4를 띄울게요. compose.yaml은 이렇습니다.

services:
  mysql:
    image: mysql:8.4
    environment:
      MYSQL_ROOT_PASSWORD: root
    ports:
      - "3331:3306"

setup.sql은 bank 데이터베이스에 테이블 세 개를 만들어요. 계좌 accounts에는 1번(kim)과 2번(lee) 계좌가 잔액 10000으로 들어 있고, 이체 기록 history 20행은 한 트랜잭션을 일부러 크게 만드는 데 씁니다. 주문 테이블 orders는 이 글에서는 쓰지 않아요.

긴 명령을 줄이려고 셸 함수를 만들고, 컨테이너를 띄운 뒤 SQL을 실행할게요. MYSQL_PWD 환경 변수로 비밀번호를 넘기는 방식은 실습에서만 써 주세요.

아래 블록은 파일로 만들지 말고 실습 디렉터리에서 연 터미널에 그대로 붙여 넣어 주세요. 이후 명령은 모두 이 터미널에서 실행하고, 터미널을 새로 열면 다시 붙여 넣으시면 됩니다.

export COMPOSE_PROJECT_NAME=deadlock
docker compose up -d
sql() { docker compose exec -T -e MYSQL_PWD=root mysql mysql -uroot "$@"; }
until sql -e "SELECT 1" > /dev/null 2>&1; do sleep 2; done
sql < setup.sql

실습 1: deadlock 만들기

두 트랜잭션을 거의 동시에 실행해 볼게요.

A는 1번 계좌만 바꾸는 작은 트랜잭션이에요. B는 먼저 이체 기록 20행을 바꾼 뒤 2번 계좌를 잠그는 큰 트랜잭션이고요.

DO SLEEP(2)는 두 트랜잭션이 서로의 잠금을 기다리는 순간을 확실히 만들려고 넣었어요.

sql -e "BEGIN; UPDATE bank.accounts SET balance = balance - 100 WHERE id = 1; DO SLEEP(2); UPDATE bank.accounts SET balance = balance + 100 WHERE id = 2; COMMIT;" &
sleep 0.5
sql -e "BEGIN; UPDATE bank.history SET memo = 'moved' WHERE id <= 20; UPDATE bank.accounts SET balance = balance - 300 WHERE id = 2; DO SLEEP(2); UPDATE bank.accounts SET balance = balance + 300 WHERE id = 1; COMMIT;" &
wait
ERROR 1213 (40001) at line 1: Deadlock found when trying to get lock; try restarting transaction

계좌 잔액을 보면 어느 쪽이 남았는지 알 수 있어요.

sql -t -e "SELECT * FROM bank.accounts;"
+----+-------+---------+
| id | owner | balance |
+----+-------+---------+
|  1 | kim   |   10300 |
|  2 | lee   |    9700 |
+----+-------+---------+

B의 이체(300원)만 반영됐어요.

여기서 눈여겨볼 건, 순환을 마지막에 완성한 쪽은 B였는데 롤백된 쪽은 행을 1개만 바꾼 A였다는 점이에요.

실습 2: 가장 최근 deadlock은 InnoDB 상태에서 봅니다

SHOW ENGINE INNODB STATUS의 LATEST DETECTED DEADLOCK 부분에 가장 최근 deadlock이 남아요. 행 내용을 16진수로 풀어 쓴 줄은 빼고 보겠습니다.

sql -e "SHOW ENGINE INNODB STATUS\G" | sed -n '/LATEST DETECTED DEADLOCK/,/WE ROLL BACK/p' | grep -vE '^ *[0-9]+: len|^Record lock|^$'
LATEST DETECTED DEADLOCK
------------------------
2026-09-29 07:19:09 281472879423232
*** (1) TRANSACTION:
TRANSACTION 1833, 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 10, OS thread handle 281473099345664, query id 27 localhost root updating
UPDATE bank.accounts SET balance = balance + 100 WHERE id = 2
*** (1) HOLDS THE LOCK(S):
RECORD LOCKS space id 2 page no 4 n bits 72 index PRIMARY of table `bank`.`accounts` trx id 1833 lock_mode X locks rec but not gap
*** (1) WAITING FOR THIS LOCK TO BE GRANTED:
RECORD LOCKS space id 2 page no 4 n bits 72 index PRIMARY of table `bank`.`accounts` trx id 1833 lock_mode X locks rec but not gap waiting
*** (2) TRANSACTION:
TRANSACTION 1834, ACTIVE 2 sec starting index read
mysql tables in use 1, locked 1
LOCK WAIT 5 lock struct(s), heap size 1128, 23 row lock(s), undo log entries 21
MySQL thread id 11, OS thread handle 281472971370240, query id 28 localhost root updating
UPDATE bank.accounts SET balance = balance + 300 WHERE id = 1
*** (2) HOLDS THE LOCK(S):
RECORD LOCKS space id 2 page no 4 n bits 72 index PRIMARY of table `bank`.`accounts` trx id 1834 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 `bank`.`accounts` trx id 1834 lock_mode X locks rec but not gap waiting
*** WE ROLL BACK TRANSACTION (1)

읽는 순서는 이렇게 보시면 됩니다.

  • *** (1) TRANSACTION, *** (2) TRANSACTION: deadlock에 걸린 두 트랜잭션과, 각각이 잠금을 기다리던 SQL이에요.
  • undo log entries: 트랜잭션이 바꾼 행 수예요. (1)은 1, (2)는 21이에요.
  • HOLDS THE LOCK(S): 이미 잡고 있던 잠금이에요. 어느 테이블의 어느 인덱스인지, 어떤 모드인지 나와요.
  • WAITING FOR THIS LOCK TO BE GRANTED: 기다리던 잠금이에요.
  • WE ROLL BACK TRANSACTION (1): 롤백한 트랜잭션이에요. 바꾼 행이 적은 (1)이 골라졌어요.

이 크기는 행 수일 뿐이라서, 어느 트랜잭션이 비즈니스에서 더 중요한지는 반영하지 않아요. 결제처럼 중요한 트랜잭션도 작다는 이유로 롤백될 수 있습니다.

실습 3: 애플리케이션은 트랜잭션 전체를 다시 실행합니다

deadlock은 동시에 들어온 요청끼리 부딪혀서 생기는 거라, 같은 작업을 다시 실행하면 대부분 성공해요.

다만 이때 실패한 SQL 한 줄만 다시 보내면 안 돼요. 롤백으로 트랜잭션 안의 모든 변경이 이미 취소됐기 때문이에요. 그래서 BEGIN부터 트랜잭션 전체를 다시 실행해야 합니다.

transfer.py는 Python 스레드 두 개로 이체 A(1번 → 2번, 금액 100)와 B(2번 → 1번, 금액 300)를 0.3초 차이로 실행해요. 그리고 deadlock이 나면 트랜잭션 전체를 다시 실행하죠.

두 UPDATE 사이에는 1초를 쉬고, 최대 3번 시도해요.

  • MAX_ATTEMPTS: 재시도 횟수를 제한해요. 계속 실패하면 오류를 그대로 올려 보내요.
  • wait: 바로 다시 실행하면 같은 순서로 또 부딪힐 수 있어서, 짧고 조금씩 다른 시간만큼 기다려요.
  • deadlock(1213)이 아닌 오류는 재시도하지 않아요.
docker run --rm --network deadlock_default -v "$PWD":/app -w /app python:3.12-slim sh -c "pip install -q pymysql cryptography 2> /dev/null; python transfer.py" 2>&1 | grep -v -E "notice|WARNING"
B: 1번째 시도 deadlock, 0.10초 뒤 트랜잭션 전체를 다시 실행
A: 1번째 시도에서 커밋
B: 2번째 시도에서 커밋

B가 처음에는 deadlock으로 롤백됐고, 잠시 기다렸다가 트랜잭션 전체를 다시 실행해서 커밋했어요.

정리

범위: MySQL 8.4(mysql:8.4 이미지), Python 3.12와 PyMySQL, 계좌 2행, 이체 기록 20행.

  • 행 1개를 바꾼 A(undo log entries 1)가 롤백됐고, 순환을 마지막에 완성했지만 21행을 바꾼 B는 커밋했습니다. InnoDB는 바꾼 행 수만 보고 고르니까, 결제처럼 중요한 트랜잭션도 롤백된다고 보고 코드를 짜 주세요.
  • 이 글에 싣지 않은 추가 측정에서는 두 번째 deadlock을 만들자 SHOW ENGINE INNODB STATUS의 LATEST DETECTED DEADLOCK에서 첫 번째 기록이 사라졌습니다. 반복되는 deadlock은 deadlock마다 Error log에 기록하는 innodb_print_all_deadlocks를 켜고, 롤백한 쪽까지 남도록 log_error_verbosity를 3으로 올려 주세요.
  • transfer.py에서 B는 첫 시도에 deadlock으로 롤백되고 0.10초 뒤 두 번째 시도에 커밋했습니다. ERROR 1213을 받으면 실패한 SQL 한 줄이 아니라 BEGIN부터 트랜잭션 전체를, 횟수 제한과 짧은 무작위 대기를 두고 다시 실행하세요.
내 링크 만들기