이 글에서 다루는 것
"MariaDB 소스 파헤치기" 연재의 열한 번째 글입니다. 10편에서는 InnoDB가 데이터를 16KB 페이지에 담고, 페이지를 버퍼 풀에 올려 B-트리로 찾는 방법을 봤습니다. 거기서는 "행 하나를 어디에 어떻게 두나"만 물었습니다. 이번 글은 같은 행을 여러 연결이 동시에 읽고 고칠 때 InnoDB가 무엇을 하는지 봅니다.
2편 지도로 말하면 ⑧ 엔진 칸 안의 InnoDB를 계속 확대하는데, 하나가 더 붙습니다. COMMIT은 엔진만의 일이 아니라서, 서버 계층(sql/handler.cc)이 엔진들에게 "커밋하라"고 지시하는 트랜잭션 조정 부분을 먼저 봅니다.
파란 칸이 이 글에서 읽는 곳입니다. 7편의 MDL과 이 글의 행 잠금은 서로 다른 층의 잠금입니다.
답하려는 질문은 네 가지입니다.
COMMIT한 줄은 서버 계층에서 InnoDB까지 어떤 함수를 지나고, InnoDB는 커밋 때 무엇을 하는가- REPEATABLE READ에서 다른 연결이 커밋한 값이 왜 안 보이는가 — 그때 보이는 옛 값은 어디서 오는가
- 쓸모가 끝난 옛 값은 누가 언제 지우는가, 오래 열어 둔 트랜잭션은 왜 문제인가
SELECT … FOR UPDATE가 다른 연결을 기다리게 하는 잠금은 어디에 어떻게 저장되고, 교착은 어떻게 찾는가
| 항목 | 내용 |
|---|---|
| 대상 | MariaDB Server |
| 버전 | 13.2.0 (main, alpha) |
| 커밋 | c894d97781 (2026-10-19) |
| 주로 읽는 파일 | sql/handler.cc, storage/innobase/ 의 handler/ha_innodb.cc, trx/trx0trx.cc, read/read0read.cc, row/row0vers.cc, trx/trx0rec.cc, trx/trx0purge.cc, lock/lock0lock.cc |
글에 나오는 줄 번호는 모두 이 커밋 기준입니다. 같은 커밋의 소스를 받고 예제용 도커 이미지를 만드는 방법은 1편에 있습니다.
lock0lock.cc와 row0sel.cc는 각각 7천 줄이 넘고, trx0trx.cc도 2천 줄이 넘습니다. 이 글은 처음 여는 사람이 따라올 수 있는 큰 줄기만 골랐습니다. 빼놓은 것은 글 끝의 "이 글에서 뺀 것"에 이름과 위치만 적어 두었습니다.
먼저 알아 둘 말
handlerton은 엔진 하나에 하나씩 있는 함수 포인터 묶음입니다(2편, 3편). 이 글에서는 그 가운데 트랜잭션 쪽 칸인 commit·rollback·prepare만 씁니다. 기준 커밋에서 이 칸들은 struct transaction_participant에 있고, struct handlerton이 그것을 상속합니다.
trx_t 는 InnoDB 쪽의 트랜잭션 객체입니다(include/trx0trx.h). 서버의 THD(2편 — 연결 하나의 모든 상태)는 엔진마다 메모 칸(ha_data)을 하나씩 갖고 있는데, InnoDB는 거기에 자기 trx_t를 걸어 둡니다. 연결 하나에 trx_t 하나가 붙어 있고, 트랜잭션이 끝나도 버리지 않고 다음 트랜잭션에 다시 씁니다.
트랜잭션 ID(trx id) 는 쓰기를 하는 트랜잭션이 받는 일련번호입니다. trx_sys(trx_sys_t, 트랜잭션 시스템 전체를 쥔 전역 객체)가 나눠 줍니다. information_schema.INNODB_TRX의 trx_id, SHOW ENGINE INNODB STATUS의 TRANSACTION 23이 이 번호입니다.
읽기 뷰(ReadView) 는 "이 트랜잭션은 어느 트랜잭션의 변경까지 볼 수 있나"를 적은 작은 표입니다. 만드는 순간 아직 커밋되지 않은 트랜잭션 ID들을 기억해 두었다가, 행을 읽을 때 그 행을 마지막으로 고친 트랜잭션이 이 표에 비추어 보이는지 따집니다. MVCC(다중 버전 동시성 제어)는 이렇게 "보이지 않는 변경이면 옛 판을 보여 주는" 방식을 말합니다.
undo 로그는 행을 고치기 전의 값을 적어 두는 기록입니다. 롤백할 때 되돌리는 재료이자, 다른 트랜잭션에게 옛 판을 만들어 보여 주는 재료입니다. undo는 undo 페이지에 적히고, 그 페이지들은 롤백 세그먼트(rollback segment, trx_rseg_t)가 관리합니다. 페이지에 쓰는 일이므로 10편의 미니 트랜잭션(mtr_t) 으로 묶여 redo 로그도 남습니다(10편).
purge는 더는 아무도 볼 필요가 없는 옛 판(undo)과, 지우기 표시만 해 둔 레코드를 실제로 치우는 백그라운드 작업입니다.
행 잠금은 InnoDB가 인덱스 레코드 단위로 거는 잠금입니다. 레코드 하나만 잠그는 레코드 잠금, 레코드 앞의 빈틈(갭)을 잠가 그 사이에 끼워 넣기를 막는 갭 잠금, 둘을 합친 넥스트 키 잠금이 있습니다. 7편의 메타데이터 잠금(MDL)은 서버 계층이 테이블 정의에 거는 잠금이라 이것과 층이 다릅니다.
1. 서버 쪽 트랜잭션 — COMMIT이 엔진에 닿기까지
서버는 "누가 이 트랜잭션에 참여했나"만 기억합니다
한 트랜잭션 안에서 InnoDB 테이블과 Aria 테이블을 같이 고칠 수도 있습니다. 그래서 서버 계층은 트랜잭션을 직접 구현하지 않고, 이번 트랜잭션에 참여한 엔진의 목록만 들고 있다가 COMMIT이 오면 그 목록의 엔진들에게 차례로 커밋을 시킵니다. 엔진이 해 줄 일은 transaction_participant의 함수 포인터로 정해져 있습니다.
/*
'all' is true if it's a real commit, that makes persistent changes
'all' is false if it's not in fact a commit but an end of the
statement that is part of the transaction.
NOTE 'all' is also false in auto-commit mode where 'end of statement'
and 'real commit' mean the same event.
*/
int (*commit)(THD *thd, bool all);
int (*rollback)(THD *thd, bool all);
int (*prepare)(THD *thd, bool all);
주석이 이 글의 첫 번째 함정을 알려 줍니다. all이 true면 진짜 커밋, false면 "트랜잭션 안의 문장 하나가 끝났다"는 뜻입니다. 그런데 autocommit 모드에서는 문장 끝과 진짜 커밋이 같은 사건이라 all=false로 부릅니다. 엔진은 all만 보지 말고 autocommit인지도 봐야 합니다(아래 innobase_commit()이 그렇게 합니다).
InnoDB는 기동할 때 이 칸에 자기 함수를 꽂습니다.
innobase_hton->commit_ordered= innobase_commit_ordered;
innobase_hton->commit = innobase_commit;
innobase_hton->rollback = innobase_rollback;
innobase_hton->prepare = innobase_xa_prepare;
storage/innobase/handler/ha_innodb.cc 4313–4316행
목록에 이름을 올리는 일은 InnoDB가 직접 합니다. 문장이 테이블을 처음 사용할 때 서버가 부르는 ha_innobase::external_lock()이 innobase_register_trx()를 부르고(16834행), 이것이 서버의 trans_register_ha()를 부릅니다.
const trx_id_t trx_id= trx->id;
trans_register_ha(thd, false, hton, trx_id);
if (!trx->is_registered)
{
trx->is_registered= true;
if (thd_test_options(thd, OPTION_NOT_AUTOCOMMIT | OPTION_BEGIN))
trans_register_ha(thd, true, hton, trx_id);
}
storage/innobase/handler/ha_innodb.cc 3206–3215행
서버 쪽에는 목록이 두 개 있습니다. thd->transaction->stmt(지금 문장)와 thd->transaction->all(트랜잭션 전체)입니다. trans_register_ha(thd, false, …)는 문장 목록에, trans_register_ha(thd, true, …)는 전체 목록에 올립니다. BEGIN으로 연 트랜잭션이거나 autocommit이 꺼져 있을 때만(OPTION_NOT_AUTOCOMMIT | OPTION_BEGIN) 전체 목록에도 올라갑니다.
COMMIT의 길
COMMIT 문장은 6편의 파서가 SQLCOM_COMMIT으로 만들고, mysql_execute_command()의 case SQLCOM_COMMIT이 trans_commit()을 부릅니다(sql_parse.cc 5590–5600행). trans_commit()은 ha_commit_trans(thd, TRUE)를 부릅니다(transaction.cc 271행).
binlog가 꺼진 기본 설정의 길입니다. binlog가 켜지면 가운데에 2단계 커밋이 끼어드는데, 그것은 12편에서 봅니다.
ha_commit_trans()는 쓰기를 한 엔진이 둘 이상이거나 binlog가 켜져 있으면 2단계 커밋(먼저 모두에게 prepare, 그다음 commit)을 하고, 그렇지 않으면 ha_commit_one_phase()로 바로 갑니다(handler.cc 1978–2022행의 if (trans->no_2pc || (rw_ha_count <= 1))). 실제로 엔진의 함수 포인터를 부르는 곳은 commit_one_phase_2()의 이 반복문입니다.
for (; ha_info; ha_info= ha_info_next)
{
transaction_participant *ht= ha_info->ht();
if ((err= ht->commit(thd, all)))
{
my_error(ER_ERROR_DURING_COMMIT, MYF(0), err);
error=1;
}
InnoDB 쪽 innobase_commit()은 앞에서 말한 "all=false인데 실제로는 커밋"을 이렇게 가립니다.
if (commit_trx
| (!thd_test_options(thd, OPTION_NOT_AUTOCOMMIT | OPTION_BEGIN))) {
/* Run the fast part of commit if we did not already. */
if (!trx->active_commit_ordered) {
innobase_commit_ordered_2(trx, thd);
// ...
/* Now do a write + flush of logs. */
trx_commit_complete_for_mysql(trx);
trx_deregister_from_2pc(trx);
} else {
/* We just mark the SQL statement ended and do not do a
transaction commit */
lock_unlock_table_autoinc(trx);
if (UNIV_UNLIKELY(end_of_statement(trx))) {
DBUG_RETURN(1);
}
storage/innobase/handler/ha_innodb.cc 4798–4825행
commit_trx(서버가 넘긴 all)가 참이거나, autocommit이 켜져 있고 BEGIN도 안 했으면 진짜로 커밋합니다. 그 밖의 경우는 "문장 하나가 끝났다"는 표시만 하고(end_of_statement()) 돌아갑니다. 커밋은 두 단계로 나뉘어 있습니다. innobase_commit_ordered_2()가 커밋 순서를 정하는 빠른 부분을 하고, trx_commit_complete_for_mysql()이 디스크에 로그를 쓰는 느린 부분(innodb_flush_log_at_trx_commit에 따라)을 합니다. 느린 부분을 따로 떼어 둔 것은 여러 트랜잭션이 로그 쓰기 한 번에 같이 실려 가는 그룹 커밋을 위해서입니다(12편).
예제 1: COMMIT의 백트레이스
정말 이 길로 오는지 gdb로 확인했습니다. 이 예제와 이 글의 다른 예제는 기준 커밋(c894d97781)을 그대로 Debug 빌드한 MariaDB 13.2.0(13.2.0-MariaDB-debug)으로 도커 컨테이너(Ubuntu 24.04)에서 실행했습니다. 예제 폴더는 ex/u11-gdb-commit입니다. 세션 A가 BEGIN; UPDATE t SET val = 'A' WHERE id = 20;을 해 둔 채, gdb -p $(cat /tmp/mariadbd.pid) -batch -ex 'break trx_t::release_locks' -ex 'continue' -ex 'bt' -ex 'delete' -ex 'detach'를 뒤에서 붙여 두고 A가 COMMIT을 보냅니다.
주소와 인자 값을 지우고 do_command까지만 남긴 결과입니다.
#0 trx_t::release_locks at storage/innobase/trx/trx0trx.cc:508
#1 trx_t::commit_in_memory at storage/innobase/trx/trx0trx.cc:1481
#2 trx_t::commit_persist at storage/innobase/trx/trx0trx.cc:1641
#3 trx_t::commit at storage/innobase/trx/trx0trx.cc:1650
#4 trx_commit_for_mysql at storage/innobase/trx/trx0trx.cc:1765
#5 innobase_commit_low at storage/innobase/handler/ha_innodb.cc:4565
#6 innobase_commit_ordered_2 at storage/innobase/handler/ha_innodb.cc:4657
#7 innobase_commit at storage/innobase/handler/ha_innodb.cc:4803
#8 commit_one_phase_2 at sql/handler.cc:2285
#9 ha_commit_one_phase at sql/handler.cc:2226
#10 ha_commit_trans at sql/handler.cc:2014
#11 trans_commit at sql/transaction.cc:271
#12 mysql_execute_command at sql/sql_parse.cc:5600
#13 mysql_parse at sql/sql_parse.cc:8005
#14 dispatch_command at sql/sql_parse.cc:1903
#15 do_command at sql/sql_parse.cc:1437
아래에서 위로 읽으면 그림과 똑같습니다. #8의 handler.cc:2285가 위 반복문의 ht->commit(thd, all)이고, 그 위부터가 InnoDB입니다. 서버 계층에서 엔진으로 넘어가는 경계가 함수 포인터 호출 한 줄입니다.
헷갈리기 쉬운 곳: 문장 하나도 "트랜잭션"입니다
autocommit 모드의 UPDATE 한 줄도 InnoDB 안에서는 trx_t가 시작되고 커밋되는 완전한 트랜잭션입니다. 서버는 문장이 끝날 때 trans_commit_stmt()로 ha_commit_trans(thd, FALSE)를 부르고(transaction.cc 499행), 위의 innobase_commit() 조건이 "autocommit이니 진짜 커밋"으로 판단합니다. BEGIN … COMMIT으로 감싸지 않아도 문장마다 커밋 비용(로그 flush)이 드는 이유가 여기 있습니다.
2. trx_t — InnoDB 쪽 트랜잭션의 일생
이 글에 나오는 구조체만 그렸습니다. ReadView의 네 멤버는 실제로는 부모 클래스 ReadViewBase에 있습니다.
trx_t에서 이 글이 쓰는 멤버는 다섯 가지입니다(trx0trx.h 597행부터).
| 멤버 | 뜻 |
|---|---|
id |
트랜잭션 ID. 읽기만 하는 동안은 0이고, 쓰기를 시작할 때(대개 테이블에 IX 잠금을 걸 때) 받는다 |
state |
TRX_STATE_NOT_STARTED, ACTIVE, PREPARED, COMMITTED_IN_MEMORY … |
read_view |
이 트랜잭션의 읽기 뷰(3절) |
lock |
이 트랜잭션이 가진 잠금 목록과 기다리는 잠금(6절) |
undo_no, rsegs |
지금까지 쓴 undo 레코드 수와, undo를 쓰는 롤백 세그먼트(4절) |
상태가 바뀌는 길은 소스의 긴 주석(trx0trx.h 700–752행)에 정리되어 있습니다. 그림으로 옮기면 이렇습니다.
trx_t는 연결마다 하나를 계속 다시 씁니다. 그래서 끝 상태가 따로 없고 NOT_STARTED로 돌아옵니다.
시작은 늦게, ID는 더 늦게
BEGIN을 보내도 InnoDB의 트랜잭션은 시작되지 않습니다. 서버는 BEGIN에서 OPTION_BEGIN 깃발만 세우고(transaction.cc 205행), InnoDB는 처음으로 행을 읽거나 쓸 때 trx_start_if_not_started() → trx_start_low()로 ACTIVE가 됩니다. 이것이 3절 예제의 "BEGIN은 읽기 뷰를 열지 않는다"로 이어집니다.
트랜잭션 ID도 바로 받지 않습니다. trx_start_low()는 쓰기가 확실할 때만 롤백 세그먼트를 붙이고 ID를 받습니다.
if (!trx->read_only
&& (!trx->mysql_thd || read_write || trx->dict_operation)) {
/* Temporary rseg is assigned only if the transaction
updates a temporary table */
if (!high_level_read_only && !recv_sys.rpo) {
trx_assign_rseg_low(trx);
}
} else {
storage/innobase/trx/trx0trx.cc 971–978행
사용자 트랜잭션은 대개 read_write가 false로 시작해 else 쪽으로 갑니다. 그러다 테이블에 쓰기용 의도 잠금(IX, 6절)을 걸 때 lock_table()이 비로소 trx_set_rw_mode()를 불러 ID와 롤백 세그먼트를 받습니다.
if ((mode == LOCK_IX || mode == LOCK_X) &&
!trx->read_only && !trx->rsegs.m_redo.rseg)
trx_set_rw_mode(trx);
storage/innobase/lock/lock0lock.cc 4187–4189행
예외도 있습니다. 아직 시작되지 않은 트랜잭션이 곧바로 INSERT를 하면 row_insert_for_mysql()이 trx_start_if_not_started_xa(trx, true)로 트랜잭션을 시작시키므로(row0mysql.cc 1275행) trx_start_low()의 if 쪽에서 곧바로 ID를 받습니다. UPDATE·DELETE나 SELECT … FOR UPDATE는 먼저 row_search_mvcc()가 read_write=false로 시작시킨 뒤 IX 잠금에서 ID를 받습니다.
읽기만 하는 트랜잭션에는 ID가 없으니 trx_sys의 "진행 중인 쓰기 트랜잭션" 목록에도 들어가지 않습니다. 다른 트랜잭션이 읽기 뷰를 만들 때 모으는 ID 목록(m_ids, 3절)이 그만큼 짧아집니다. 읽기 위주 부하를 위해 아낀 것입니다.
커밋 때 InnoDB가 하는 일
예제 1의 #0~#4가 InnoDB 안의 커밋입니다. trx_t::commit() → commit_persist() → commit_in_memory() 순서입니다.
commit_persist()는 이 트랜잭션의 undo 로그를 history list(커밋된 트랜잭션의 undo를 모아 두는 목록, 5절)로 옮기고, 그 일을 담은 미니 트랜잭션을 커밋합니다(write_serialisation_history(), trx0trx.cc 1147–1211행). 이 미니 트랜잭션의 LSN이 곧 이 트랜잭션의 커밋 LSN(commit_lsn)입니다. 디스크로 보면 커밋은 이 순간 일어납니다.
commit_in_memory()는 메모리 쪽 정리입니다.
TRANSACTIONAL_INLINE inline void trx_t::commit_in_memory(mtr_t *mtr)
{
/* We already detached from rseg in write_serialisation_history() */
ut_ad(!rsegs.m_redo.undo);
read_view.close();
// ...
commit_state();
DEBUG_SYNC_C("trx_after_commit_state");
if (id)
{
trx_sys.deregister_rw(this);
// ...
if (UNIV_LIKELY(!dict_operation))
release_locks();
storage/innobase/trx/trx0trx.cc 1413–1481행
- 읽기 뷰를 닫습니다(1417행). 이 트랜잭션은 더는 옛 판을 볼 필요가 없습니다.
- 상태를
COMMITTED_IN_MEMORY로 바꾸고(commit_state()), 쓰기 트랜잭션이었으면trx_sys의 진행 중 목록에서 뺍니다(deregister_rw()). 이 순간부터 새로 만들어지는 읽기 뷰는 이 트랜잭션의 변경을 봅니다. - 잠금을 한꺼번에 풉니다(
release_locks()→lock_release()). 트랜잭션 도중에는 잠금을 하나도 풀지 않다가 끝에서 모두 푸는 방식을 엄격한 2단계 잠금(strict two-phase locking)이라고 합니다.
그 뒤 서버로 돌아가는 길에 trx_commit_complete_for_mysql()이 commit_lsn까지 로그를 디스크에 씁니다(trx0trx.cc 1776–1791행). innodb_flush_log_at_trx_commit=0이면 여기서 아무것도 하지 않습니다. 로그와 설정 값의 뜻은 12편에서 봅니다.
3. 읽기 뷰와 일관 읽기 — 옛 판은 어디서 오나
행마다 붙은 숨은 열 두 개
InnoDB의 클러스터드 인덱스(기본 키 인덱스 = 테이블 그 자체, 10편) 레코드에는 사용자가 만든 열 말고 시스템 열이 붙습니다. 테이블 정의를 메모리에 올릴 때 마지막 열로 더해집니다.
dict_mem_table_add_col(table, heap, "DB_TRX_ID", DATA_SYS,
DATA_TRX_ID | DATA_NOT_NULL,
DATA_TRX_ID_LEN);
compile_time_assert(DATA_TRX_ID == 1);
dict_mem_table_add_col(table, heap, "DB_ROLL_PTR", DATA_SYS,
DATA_ROLL_PTR | DATA_NOT_NULL,
DATA_ROLL_PTR_LEN);
compile_time_assert(DATA_ROLL_PTR == 2);
storage/innobase/dict/dict0dict.cc 1163–1170행
DB_TRX_ID(6바이트): 이 레코드를 마지막으로 고친 트랜잭션의 IDDB_ROLL_PTR(7바이트): 고치기 전 값을 적어 둔 undo 레코드의 위치
(DB_ROW_ID는 기본 키가 없는 테이블에서만 쓰는 숨은 기본 키입니다.) 이 두 열은 6절의 교착 예제(예제 6) 출력에서 눈으로 볼 수 있습니다. SHOW ENGINE INNODB STATUS가 레코드를 찍을 때 0: 기본 키, 1: DB_TRX_ID, 2: DB_ROLL_PTR, 3: 사용자 열 순서로 나옵니다.
ReadView — 보이는지 정하는 표
읽기 뷰의 핵심은 숫자 두 개와 목록 하나입니다.
class ReadViewBase
{
/**
The read should not see any transaction with trx id >= this value.
In other words, this is the "high water mark".
*/
trx_id_t m_low_limit_id= 0;
/**
The read should see all trx ids which are strictly
smaller (<) than this value. In other words, this is the
low water mark".
*/
trx_id_t m_up_limit_id;
/** Set of RW transactions that was active when this snapshot was taken */
trx_ids_t m_ids;
// ...
bool changes_visible(trx_id_t id) const
MY_ATTRIBUTE((warn_unused_result))
{
if (id >= m_low_limit_id)
return false;
return id < m_up_limit_id ||
m_ids.empty() ||
!std::binary_search(m_ids.begin(), m_ids.end(), id);
}
storage/innobase/include/read0types.h 38–137행
m_ids: 뷰를 만들 때 아직 커밋되지 않았던 쓰기 트랜잭션 ID들(정렬해 둔다)m_up_limit_id: 그 가운데 가장 작은 ID. 이보다 작은 ID는 뷰를 만들기 전에 모두 커밋되었다m_low_limit_id: 뷰를 만들 때 아직 아무에게도 주지 않은 다음 ID. 이 이상은 뷰보다 나중에 시작했다
changes_visible(id)는 이 셋으로 판정합니다. ① id가 m_low_limit_id 이상이면 안 보입니다. ② m_up_limit_id보다 작으면 보입니다. ③ 그 사이면 m_ids에 없을 때만(이미 커밋된 것) 보입니다. 자식 클래스 ReadView::changes_visible()은 여기에 "내가 고친 것은 보인다"(id == m_creator_trx_id)를 하나 더합니다(251–252행).
헷갈리기 쉬운 곳: low_limit가 위쪽 한계입니다
이름이 거꾸로 보입니다. m_low_limit_id는 주석대로 "high water mark"(위쪽 한계)이고, m_up_limit_id가 "low water mark"(아래쪽 한계)입니다. "이보다 낮은(low) ID만 볼 수 있다는 한계", "이 위(up)부터는 따져 봐야 한다는 한계"로 읽으면 덜 헷갈립니다. SHOW ENGINE INNODB STATUS도 이 둘을 Trx read view will not see trx with id >= <low_limit>, sees < <up_limit>로 찍습니다(print_limits(), 5절 예제).
뷰는 언제 열리고 언제 닫히나
뷰를 여는 함수는 ReadView::open()입니다.
void ReadView::open(trx_t *trx)
{
ut_ad(this == &trx->read_view);
if (is_open())
ut_ad(!srv_read_only_mode);
else if (likely(!srv_read_only_mode))
{
m_creator_trx_id= trx->id;
if (trx->is_autocommit_non_locking() && empty() &&
low_limit_id() == trx_sys.get_max_trx_id())
m_open.store(true, std::memory_order_relaxed);
else
{
m_mutex.wr_lock();
snapshot(trx);
m_open.store(true, std::memory_order_relaxed);
m_mutex.wr_unlock();
}
}
}
storage/innobase/read/read0read.cc 229–248행
이미 열려 있으면 아무것도 하지 않습니다. 격리 수준의 차이는 이 한 줄과, 뷰를 언제 닫느냐로 만들어집니다.
- 여는 곳: 잠그지 않는 읽기(평범한
SELECT,select_lock_type == LOCK_NONE)가 문장의 첫 행을 찾을 때row_search_mvcc()가 엽니다.
} else {
prebuilt->sql_stat_start = FALSE;
trx_start_if_not_started(trx, false);
if (prebuilt->select_lock_type == LOCK_NONE) {
trx->read_view.open(trx);
storage/innobase/row/row0sel.cc 4815–4820행
- 닫는 곳: READ COMMITTED 이하에서는 문장이 시작될 때(
ha_innobase::store_lock())와 문장이 끝날 때(ha_innobase::external_lock()의F_UNLCK) 닫습니다.
if (lock_type != TL_IGNORE
&& trx->n_mysql_tables_in_use == 0) {
switch ((trx->isolation_level
= innodb_isolation_level(thd) & 3)) {
case ISO_REPEATABLE_READ:
break;
case ISO_READ_COMMITTED:
case ISO_READ_UNCOMMITTED:
/* At low transaction isolation levels we let
each consistent read set its own snapshot */
trx->read_view.close();
break;
storage/innobase/handler/ha_innodb.cc 17102–17113행
if (!not_autocommit) {
if (!not_started) {
innobase_commit(thd, TRUE);
}
} else if (trx->isolation_level <= TRX_ISO_READ_COMMITTED) {
trx->read_view.close();
}
storage/innobase/handler/ha_innodb.cc 16812–16818행
그래서 READ COMMITTED는 문장마다 새 뷰를 열고, REPEATABLE READ는 트랜잭션의 첫 읽기에서 연 뷰를 커밋할 때까지(2절의 commit_in_memory()) 씁니다. START TRANSACTION WITH CONSISTENT SNAPSHOT은 서버가 ha_start_consistent_snapshot()(transaction.cc 217–224행)을 거쳐 InnoDB의 innobase_start_trx_and_assign_read_view()를 불러 그 자리에서 엽니다(ha_innodb.cc 4579–4617행). SERIALIZABLE은 autocommit이 아닐 때 평범한 SELECT에도 공유 잠금(LOCK_S)을 걸게 바꿉니다(16836–16850행).
옛 판 만들기 — undo 사슬 거슬러 오르기
읽은 레코드의 DB_TRX_ID가 뷰에 보이지 않으면, row_search_mvcc()는 row_sel_clust_sees()로 그것을 알아내고(row0sel.cc 961–981행) row_vers_build_for_consistent_read()로 옛 판을 만듭니다. DB_ROLL_PTR을 따라 undo 레코드를 하나씩 거슬러 올라가며 보이는 판이 나올 때까지 되돌립니다.
노란 칸은 모두 버퍼 풀의 페이지입니다. 레코드는 인덱스 페이지에, 옛 값은 undo 페이지에 있습니다. 숫자는 설명을 위해 고른 것입니다.
err = trx_undo_prev_version_build(
version, index, *offsets, heap,
&prev_version, mtr, 0, NULL, vrow);
// ...
if (prev_version == NULL) {
/* It was a freshly inserted version */
*old_vers = NULL;
ut_ad(!vrow || !(*vrow));
break;
}
// ...
trx_id = row_get_rec_trx_id(prev_version, index, *offsets);
if (view->changes_visible(trx_id)) {
storage/innobase/row/row0vers.cc 760–785행
trx_undo_prev_version_build()가 undo 레코드 하나를 적용해 한 판 앞의 레코드(prev_version)를 만들고, 그 판의 DB_TRX_ID가 뷰에 보이면 멈춥니다. prev_version이 NULL이면 "이 행은 뷰를 만든 뒤에 INSERT되었다"는 뜻이라, 이 트랜잭션에게는 행이 없는 것으로 칩니다. 옛 판은 페이지를 고치지 않고 따로 잡은 메모리(in_heap)에 만들어집니다.
보조 인덱스로 읽을 때는 보조 인덱스 레코드에 DB_TRX_ID가 없어서, 페이지 머리의 "이 페이지를 고친 가장 큰 트랜잭션 ID"(page_get_max_trx_id())로 먼저 걸러 보고, 확실하지 않으면 클러스터드 인덱스로 가서 같은 판정을 합니다(row0sel.cc 5540–5578행).
예제 2: 두 세션으로 본 읽기 뷰
ex/u11-mvcc-snapshot입니다. 세션 A는 FIFO로 열어 둔 mariadb 클라이언트 하나로 트랜잭션을 들고 있고, 세션 B는 문장마다 새로 붙어 autocommit으로 바로 커밋합니다.
mkfifo /tmp/a.in
mariadb -n -f -N lab < /tmp/a.in 2>&1 &
exec 3> /tmp/a.in
A() { echo "$1" >&3; sleep 1; } # 세션 A 에 문장 하나
B() { mariadb lab -e "$1"; echo " (B: $1)"; } # 세션 B 가 바로 커밋
테이블은 t(id, val)에 행 (1, 'v1') 하나입니다. A의 SELECT는 'A first read' AS step, val처럼 단계 이름과 값을 한 줄로 찍습니다(-N이라 열 이름 없이 탭으로 나뉩니다).
settings REPEATABLE-READ 1
--- 1. REPEATABLE READ: the view is opened by the first read and kept
A first read v1
(B: UPDATE t SET val = 'v2' WHERE id = 1)
A after B commit v1
A after own COMMIT v2
--- 2. READ COMMITTED: every statement gets a fresh view
A first read v2
(B: UPDATE t SET val = 'v3' WHERE id = 1)
A after B commit v3
--- 3. REPEATABLE READ, but BEGIN does not open the view
(B: UPDATE t SET val = 'v4' WHERE id = 1)
A first read v4
(B: UPDATE t SET val = 'v5' WHERE id = 1)
A second read v4
--- 4. START TRANSACTION WITH CONSISTENT SNAPSHOT opens it at once
(B: UPDATE t SET val = 'v6' WHERE id = 1)
A first read v5
--- 5. A now writes the row it cannot see (innodb_snapshot_isolation=ON)
--------------
UPDATE t SET val = 'A-wins' WHERE id = 1
--------------
ERROR 1020 (HY000) at line 20: Record has changed since last read in table 't'; try restarting transaction
A after error 0
final v6
첫 줄은 기본 격리 수준(REPEATABLE-READ)과 innodb_snapshot_isolation(1 = 켜짐)입니다.
- REPEATABLE READ에서 A는 B가 커밋한
v2를 보지 못하고, 자기가 커밋해 뷰가 닫힌 뒤에야 봅니다. 그 사이 A가 본v1은 B의 UPDATE가 남긴 undo로 만든 옛 판입니다. - READ COMMITTED에서는 같은 트랜잭션 안에서도 두 번째
SELECT가v3을 봅니다. 문장마다 뷰를 닫았다 다시 열기 때문입니다. - REPEATABLE READ인데도
BEGIN뒤 첫 읽기 전에 B가 커밋한v4는 보입니다. 뷰는BEGIN이 아니라 첫 읽기에서 열렸고, 그 뒤의v5만 안 보입니다. WITH CONSISTENT SNAPSHOT은 문장을 보낸 그 자리에서 뷰를 여니, 첫 읽기 전에 커밋된v6도 안 보입니다.- 5단계는 아래에서 따로 봅니다.
헷갈리기 쉬운 곳: 스냅샷은 BEGIN에서 찍히지 않습니다
"REPEATABLE READ는 트랜잭션이 시작된 시점의 데이터를 본다"는 설명은 반만 맞습니다. 위 3단계처럼 기준 시점은 첫 번째 잠그지 않는 읽기입니다. 애플리케이션에서 BEGIN 직후 다른 일을 하다가 한참 뒤에 처음 읽으면, 그 사이의 커밋이 다 보입니다. BEGIN 시점에 맞추고 싶으면 START TRANSACTION WITH CONSISTENT SNAPSHOT을 씁니다.
안 보이는 행을 고치려 하면 — 오류 1020
예제 2의 5단계는 4단계의 트랜잭션(뷰에는 v5)에서, B가 이미 v6으로 바꿔 커밋한 행을 A가 고치려 합니다. 결과는 오류 1020이고, 그 뒤 @@in_transaction이 0이라 A의 트랜잭션은 이미 끝나 있습니다. 행은 B가 넣은 v6 그대로입니다.
UPDATE는 옛 판이 아니라 지금 판을 잠그고 고칩니다. 그런데 지금 판은 A의 뷰에 보이지 않는 트랜잭션이 고친 것입니다. 이것을 그대로 덮어쓰면 A는 "본 적 없는 값"을 덮어쓰게 되므로(잃어버린 갱신), 기준 커밋의 InnoDB는 잠그기 직전에 이렇게 막습니다.
if (heap_no > PAGE_HEAP_NO_SUPREMUM && gap_mode != LOCK_GAP
&& trx->snapshot_isolation
&& trx->read_view.is_open()) {
trx_id = trx_read_trx_id(rec + row_trx_id_offset(rec, index));
if (!trx->read_view.changes_visible(trx_id)
&& IF_WSREP(!(trx->is_wsrep()
&& wsrep_thd_skip_locking(trx->mysql_thd)), true)) {
/* Our record was last modified by a transaction that
we should not see. If that transaction has been
committed, we can return an error immediately,
without waiting for a record lock. */
if (!trx_sys.is_registered(trx, trx_id)) {
return DB_RECORD_CHANGED;
}
storage/innobase/lock/lock0lock.cc 6648–6661행
trx->snapshot_isolation은 세션 변수 innodb_snapshot_isolation에서 오고, 기준 커밋에서 기본값은 TRUE입니다.
static MYSQL_THDVAR_BOOL(snapshot_isolation, PLUGIN_VAR_OPCMDARG,
"Use snapshot isolation (write-write conflict detection)",
NULL, NULL, TRUE);
storage/innobase/handler/ha_innodb.cc 905–907행
DB_RECORD_CHANGED는 서버에 HA_ERR_RECORD_CHANGED(오류 1020)로 올라가고, 트랜잭션 전체가 롤백됩니다(예제 2에서 @@in_transaction이 0). 롤백하는 곳은 4절에서 봅니다. 이 변수가 생긴 판과 판마다의 기본값은 소스로 확인하지 않았습니다. 기준 커밋에서는 켜져 있다는 것까지만 말할 수 있습니다.
4. undo 로그 — 되돌리기와 옛 판의 재료
고치기 전에 undo부터 씁니다
행을 INSERT·UPDATE·DELETE할 때마다 InnoDB는 클러스터드 인덱스를 고치기 전에 trx_undo_report_row_operation()으로 undo 레코드를 씁니다(trx0rec.cc 1828–2088행). INSERT는 "이 키를 넣었다"(trx_undo_page_report_insert()), UPDATE·DELETE는 "고치기 전 값은 이랬다"(trx_undo_page_report_modify())를 적습니다. 끝나면 그 위치를 DB_ROLL_PTR 값으로 만들어 돌려줍니다.
} else {
/* Success */
undo->top_page_no = undo_block->page.id().page_no();
mtr.commit();
undo->old_offset = offset;
undo->top_offset = offset;
undo->top_undo_no = trx->undo_no++;
// ...
if (!bulk) {
*roll_ptr = trx_undo_build_roll_ptr(
!rec, trx_sys.rseg_id(rseg, !is_temp),
undo->top_page_no, offset);
}
storage/innobase/trx/trx0rec.cc 2023–2051행
undo 페이지 쓰기는 자기 미니 트랜잭션(mtr)으로 묶여 redo 로그가 남습니다. 그래서 크래시가 나도 undo가 복구되고, 복구가 끝난 뒤 미완료 트랜잭션을 undo로 되돌릴 수 있습니다(12편). trx->undo_no++가 "이 트랜잭션이 쓴 undo 레코드의 번호"입니다. SAVEPOINT는 이 번호를 기억해 두는 것이고(innobase_hton->savepoint_offset = sizeof(undo_no_t), 4302행), 6절 교착 탐지에서 "롤백 비용"을 잴 때도 이 번호를 씁니다.
DELETE는 레코드를 바로 지우지 않고 지우기 표시(delete-mark)만 합니다. 다른 트랜잭션의 뷰에는 아직 그 행이 보여야 하기 때문입니다. 실제로 지우는 것은 5절의 purge입니다.
롤백 — undo를 거꾸로 적용
ROLLBACK이면 서버의 ha_rollback_trans()가 innobase_rollback()을 부르고, InnoDB는 trx_t::rollback() → rollback_low()에서 undo 레코드를 마지막 것부터 거꾸로 적용합니다(trx0roll.cc 95–161행). 실제 되돌리기는 row/row0uins.cc(INSERT 되돌리기 — 레코드 삭제)와 row/row0umod.cc(UPDATE·DELETE 되돌리기)에 있습니다. 끝나면 rollback_finish()가 커밋과 같은 commit()을 불러 정리합니다. 롤백도 마지막에는 "변경이 없는 트랜잭션의 커밋"으로 끝나는 셈입니다.
오류가 나면 무엇을 되돌리나
오류의 종류에 따라 되돌리는 범위가 다릅니다. 갈림길은 row_mysql_handle_errors()입니다.
case DB_LOCK_WAIT_TIMEOUT:
extern my_bool innobase_rollback_on_timeout;
if (innobase_rollback_on_timeout) {
goto rollback;
}
/* fall through */
// ...
case DB_LOCK_WAIT:
err = lock_wait(thr);
// ...
case DB_DEADLOCK:
case DB_RECORD_CHANGED:
case DB_LOCK_TABLE_FULL:
case DB_TEMP_FILE_WRITE_FAIL:
rollback:
/* Roll back the whole transaction; this resolution was added
to version 3.23.43 */
trx->rollback();
break;
storage/innobase/row/row0mysql.cc 657–719행
- 잠금 대기 시간 초과(
DB_LOCK_WAIT_TIMEOUT, 오류 1205):innodb_rollback_on_timeout이 꺼져 있으면(기본값FALSE, 19762–19765행) 그 문장만 되돌립니다(rollback_to_savept). 트랜잭션은 살아 있어서 애플리케이션이 다시 시도하거나 직접 롤백해야 합니다. - 교착(
DB_DEADLOCK, 오류 1213)과 3절의DB_RECORD_CHANGED(오류 1020): 트랜잭션 전체를 되돌립니다(trx->rollback()).
DB_LOCK_WAIT이면 위 702행처럼 lock_wait()으로 잠들었다가, 잠금을 받으면 문장을 이어 갑니다(701–709행). 6절의 예제 5가 바로 이 702행에서 멈춘 모습입니다.
노란 칸은 문장만, 빨간 칸은 트랜잭션 전체를 되돌립니다. 갈래는 row0mysql.cc 657–719행에서 확인했습니다(lock_wait() 이 실패하면 handle_new_error 로 돌아가는 것은 701–705행).
5. purge — 아무도 안 보는 옛 판 치우기
커밋한 트랜잭션의 undo는 곧바로 버릴 수 없습니다. 3절처럼 아직 열려 있는 다른 뷰가 그 undo로 옛 판을 만들어야 할 수 있기 때문입니다. 그래서 커밋 때 undo는 롤백 세그먼트의 history list로 옮겨지고(trx_purge_add_undo_to_history()에서 rseg->history_size++, trx0purge.cc 190행), 백그라운드의 purge가 "이제 아무도 안 볼" 것만 골라 치웁니다.
빨간 칸이 운영에서 자주 만나는 문제입니다. 가장 오래된 뷰 하나가 전체 purge를 붙잡습니다.
purge 코디네이터의 trx_purge()는 한 번 돌 때마다 먼저 purge_sys.clone_oldest_view()로 지금 열려 있는 모든 뷰를 합친 "가장 오래된 뷰" 를 만듭니다(trx0purge.cc 1451–1456행). 합치는 일은 trx_sys가 합니다.
void trx_sys_t::clone_oldest_view(ReadViewBase *view) const
{
view->snapshot(nullptr);
/* Find oldest view. */
trx_list.for_each([view](const trx_t &trx) {
trx.read_view.append_to(view);
});
}
storage/innobase/read/read0read.cc 258–265행
trx_list의 모든 트랜잭션을 돌며 열린 뷰를 append_to()로 합칩니다. 합친 뷰의 m_low_limit_no보다 오래된 undo는 어떤 뷰도 필요로 하지 않으니 지워도 됩니다(read0types.h 56–61행의 주석). 거꾸로 말하면, REPEATABLE READ 트랜잭션 하나가 뷰를 연 채 오래 있으면 그 뒤로 커밋된 모든 undo가 쌓입니다. history가 너무 길어지면 innodb_max_purge_lag가 설정된 경우 DML을 일부러 늦추는 장치도 있습니다(srv_dml_needed_delay, trx0purge.cc 1468–1480행).
예제 3: history list length가 늘었다 줄어드는 것
ex/u11-purge-history입니다. A가 REPEATABLE READ 뷰를 연 채로 있는 동안 B가 행 하나를 300번 고치고(한 번에 한 트랜잭션), SHOW ENGINE INNODB STATUS에서 두 줄만 골라 봅니다.
history() {
mariadb -e "SHOW ENGINE INNODB STATUS\G" | grep -E "History list length|read view will not see"
}
--- 0. idle server
History list length 0
--- 1. A opens a REPEATABLE READ view and keeps it
A sees 0
--- 2. B commits 300 small updates (one transaction each)
History list length 300
Trx read view will not see trx with id >= 21, sees < 21
A still sees 0
--- 3. A commits; purge may now remove the old versions
History list length 0
final 300
- B의 트랜잭션 300개가 커밋될 때마다 history가 하나씩 늘어 300이 됩니다. A의 뷰가 열려 있어 purge가 하나도 못 치웠습니다.
Trx read view will not see trx with id >= 21, sees < 21은 A의 뷰입니다(21은 새로 띄운 서버가 그때까지 나눠 준 ID 수로 정해지는 값입니다. 예제는 매번 새 컨테이너라 다시 돌려도 21이 나왔지만, 오래 돈 서버라면 훨씬 큰 수가 나옵니다). A의SELECT가 여전히0을 보려면 지금 판(300)에서 undo 300개를 거슬러 올라가야 합니다. 오래된 뷰는 purge를 막을 뿐 아니라 그 뷰의 읽기 자체도 느리게 만듭니다.- A가 커밋해 뷰가 닫히자 5초 안에 history가 0으로 돌아왔습니다.
History list length를 찍는 곳은 lock_print_info_summary()입니다(lock0lock.cc 5469행).
6. 잠금 — 행 잠금, 갭, 기다림, 교착
MVCC 덕분에 평범한 SELECT는 잠금 없이 읽습니다. 잠금이 필요한 것은 쓰기와 잠그는 읽기(SELECT … FOR UPDATE, … LOCK IN SHARE MODE)입니다. 이들은 지금 판을 읽고 고치므로, 같은 행을 동시에 고치지 못하게 막아야 합니다.
잠금 모드와 호환표
잠금 모드는 enum lock_mode의 LOCK_IS, LOCK_IX, LOCK_S, LOCK_X, LOCK_AUTO_INC 다섯 가지입니다(lock0types.h 39–49행). 행에는 S(공유)와 X(배타)만 겁니다. 테이블에는 보통 의도 잠금인 IS·IX를 겁니다. "이 테이블의 어떤 행에 S(X)를 걸 생각이다"라는 표시로, LOCK TABLES처럼 테이블 전체를 잠그려는 쪽과 행을 잠그려는 쪽이 서로를 빨리 알아보게 해 줍니다. 호환표는 이렇습니다.
static const byte lock_compatibility_matrix[5][5] = {
/** IS IX S X AI */
/* IS */ { TRUE, TRUE, TRUE, FALSE, TRUE},
/* IX */ { TRUE, TRUE, FALSE, FALSE, TRUE},
/* S */ { TRUE, FALSE, TRUE, FALSE, FALSE},
/* X */ { FALSE, FALSE, FALSE, FALSE, FALSE},
/* AI */ { TRUE, TRUE, FALSE, FALSE, FALSE}
};
storage/innobase/include/lock0priv.h 367–374행
행 잠금은 페이지마다 비트맵으로
행 잠금 하나하나를 객체로 만들면 메모리가 모자랍니다. InnoDB는 페이지 하나, 트랜잭션 하나, 모드 하나마다 lock_t 하나를 만들고, 그 안의 비트맵에서 레코드의 heap 번호(페이지 안의 레코드 순번, 10편)에 해당하는 비트를 켭니다. 이 lock_t들은 페이지 번호로 찾는 해시 lock_sys.rec_hash에 들어 있습니다(lock0lock.h 752–757행). 레코드 잠금을 거는 lock_rec_lock()의 뼈대는 이렇습니다.
const page_id_t id{block->page.id()};
LockGuard g{lock_sys.rec_hash, id};
if (lock_t *lock= lock_sys_t::get_first(g.cell(), id))
// ...
if (!held_lock)
{
conflicting_lock_info c_lock_info=
lock_rec_other_has_conflicting(mode, g.cell(), id, heap_no, trx);
if (c_lock_info.conflicting)
/*
If another transaction has a non-gap conflicting
request in the queue, as this transaction does not
have a lock strong enough already granted on the
record, we have to wait.
*/
err= lock_rec_enqueue_waiting(c_lock_info, mode, id,
block->page.frame, heap_no, index, thr,
nullptr);
// ...
/* Simplified and faster path for the most common cases */
if (!impl)
lock_rec_create(null_c_lock_info, mode, id, block->page.frame, heap_no,
index, trx, false);
return DB_SUCCESS_LOCKED_REC;
storage/innobase/lock/lock0lock.cc 1962–2042행
페이지 번호로 해시 칸을 잠그고(LockGuard), 그 페이지에 잠금이 있으면 이미 충분히 센 잠금을 가졌는지(lock_rec_has_expl()), 다른 트랜잭션과 충돌하는지(lock_rec_other_has_conflicting())를 따져 충돌하면 기다릴 잠금을 줄에 세웁니다(lock_rec_enqueue_waiting()). 그 페이지에 잠금이 하나도 없는 가장 흔한 경우에는 따질 것 없이 lock_rec_create()로 만듭니다. 예제 4의 SHOW ENGINE INNODB STATUS 출력에서 범위를 FOR UPDATE로 읽은 트랜잭션이 2 lock struct(s), heap size 1144, 2 row lock(s), 곧 테이블 잠금(IX) 하나와 레코드 두 개를 비트로 담은 레코드 잠금 객체 하나를 가진 것을 볼 수 있습니다.
INSERT한 행에는 잠금 객체가 없습니다. 새로 넣은 레코드는 DB_TRX_ID가 아직 진행 중인 트랜잭션이라는 사실만으로 "그 트랜잭션이 X 잠금을 쥔 것"으로 칩니다(암묵적 잠금). 다른 트랜잭션이 그 행을 잠그려 할 때에야 lock_rec_convert_impl_to_expl()이 진짜 잠금 객체로 바꿔 줍니다(lock0lock.cc 6277–6303행의 주석).
갭 잠금과 넥스트 키 잠금
행의 모드(S/X)에 "어디를 잠그나"를 나타내는 비트가 더해집니다(lock0types.h 110–135행).
| 비트 | 값 | 잠그는 곳 |
|---|---|---|
LOCK_ORDINARY |
0 | 레코드와 그 앞의 갭 — 넥스트 키 잠금. 아무 비트가 없으면 이것 |
LOCK_GAP |
512 | 레코드 앞의 갭만 |
LOCK_REC_NOT_GAP |
1024 | 레코드만 (고유 키로 한 행을 찾을 때, READ COMMITTED의 잠그는 읽기) |
LOCK_INSERT_INTENTION |
2048 | "이 갭에 넣으려고 기다린다" — INSERT가 거는 갭 잠금 |
REPEATABLE READ에서 범위를 잠그는 읽기는 지나간 레코드마다 넥스트 키 잠금을 걸어, 그 범위 안에 새 행이 끼어들지 못하게 합니다(같은 범위를 다시 읽으면 같은 결과가 나오도록 — "팬텀" 막기). 갭끼리는 충돌 규칙이 특이합니다.
if ((lock_is_on_supremum || (type_mode & LOCK_GAP))
&& !(type_mode & LOCK_INSERT_INTENTION)) {
/* Gap type locks without LOCK_INSERT_INTENTION flag
do not need to wait for anything. This is because
different users can have conflicting lock types
on gaps. */
return false;
}
storage/innobase/lock/lock0lock.cc 777–786행
갭 잠금은 서로 기다리지 않습니다. 두 트랜잭션이 같은 갭에 갭 잠금을 동시에 가질 수 있습니다. 갭 잠금이 막는 것은 오직 INSERT(LOCK_INSERT_INTENTION)입니다. 이 뒤로 "레코드 잠금은 갭 잠금을 기다리지 않는다", "누구도 insert intention을 기다리지 않는다" 같은 규칙이 이어집니다(788–817행).
기다리기 — lock_wait()
교착 탐지는 따로 도는 감시 스레드가 아니라, 기다리기 시작하는 스레드가 잠들기 직전에 직접 합니다.
lock_wait()의 핵심은 두 군데입니다. 잠들기 전에 교착을 확인하고,
wait_lock= Deadlock::check_and_resolve(trx, wait_lock);
if (wait_lock == reinterpret_cast<lock_t*>(-1))
{
trx->error_state= DB_DEADLOCK;
goto end_wait;
storage/innobase/lock/lock0lock.cc 2353–2358행
교착이 아니면 트랜잭션마다 있는 조건 변수 trx->lock.cond에서 my_cond_timedwait()로 innodb_lock_wait_timeout(기본 50초, ha_innodb.cc 924–926행)까지 잡니다(lock0lock.cc 2395–2409행). 앞 트랜잭션이 잠금을 풀면 lock_grant()가 이 조건 변수를 깨웁니다.
잠들기 전에 thd_wait_begin()(2324행)으로 서버에 "이 스레드는 잠금 때문에 잔다"고 알리는 것도 눈여겨볼 만합니다. 스레드 풀(4편)은 이 알림을 받아 그룹의 활성 작업자 수를 줄이고, 그 수가 0이 되었는데 처리할 일이 남아 있거나 연결을 지켜보는 listener 스레드가 없으면 다른 작업자를 깨우거나 새로 만듭니다(threadpool_generic.cc 1283–1300행).
예제 4: 잠금 대기를 information_schema로 보기
ex/u11-lock-wait입니다. 테이블 t에 id 10, 20, 30이 있습니다. 1단계는 A가 SELECT * FROM t WHERE id = 20 FOR UPDATE로 잡아 둔 행을 B가 UPDATE하려는 경우, 2단계는 A가 id BETWEEN 11 AND 25를 FOR UPDATE로 읽은 뒤 B가 15를 INSERT하려는 경우입니다. 기다리는 동안 information_schema의 세 표와, 기다리는 문장을 보려고 PROCESSLIST를 찍었습니다.
SELECT trx_id, trx_state, trx_rows_locked, LEFT(trx_query, 40) AS trx_query
FROM information_schema.INNODB_TRX ORDER BY trx_started;
SELECT lock_trx_id, lock_mode, lock_type, lock_index, lock_data
FROM information_schema.INNODB_LOCKS ORDER BY lock_trx_id;
SELECT requesting_trx_id, blocking_trx_id FROM information_schema.INNODB_LOCK_WAITS;
SELECT ID, STATE, LEFT(INFO, 40) AS INFO FROM information_schema.PROCESSLIST
WHERE INFO LIKE 'INSERT%' OR INFO LIKE 'UPDATE%';
1단계의 출력은 2단계와 모양이 같아 줄였습니다(B(24)가 A(23)를 기다리고, 두 잠금 모두 X, lock_data 20). 2단계와 3단계의 출력입니다(2단계의 제목 줄과 그 단계에서 A가 찍은 행 출력은 뺐습니다). 2단계에서는 기다리는 동안 SHOW ENGINE INNODB STATUS의 트랜잭션 목록도 찍었습니다(예제 처음에 SET GLOBAL innodb_status_output_locks = ON, 레코드는 기본 키 필드 0:만 남겼습니다).
+--------+-----------+-----------------+----------------------------------+
trx_id | trx_state | trx_rows_locked | trx_query |
+--------+-----------+-----------------+----------------------------------+
26 | RUNNING | 2 | NULL |
27 | LOCK WAIT | 1 | INSERT INTO t VALUES (15, 'gap') |
+--------+-----------+-----------------+----------------------------------+
+-------------+-----------+-----------+------------+-----------+
lock_trx_id | lock_mode | lock_type | lock_index | lock_data |
+-------------+-----------+-----------+------------+-----------+
26 | X | RECORD | PRIMARY | 20 |
27 | X,GAP | RECORD | PRIMARY | 20 |
+-------------+-----------+-----------+------------+-----------+
+-------------------+-----------------+
requesting_trx_id | blocking_trx_id |
+-------------------+-----------------+
27 | 26 |
+-------------------+-----------------+
+----+--------+----------------------------------+
ID | STATE | INFO |
+----+--------+----------------------------------+
8 | Update | INSERT INTO t VALUES (15, 'gap') |
+----+--------+----------------------------------+
---TRANSACTION 27, ACTIVE 2 sec inserting
LOCK WAIT 2 lock struct(s), heap size 1144, 1 row lock(s)
RECORD LOCKS space id 8 page no 3 n bits 320 index PRIMARY of table `lab`.`t` trx id 27 lock_mode X locks gap before rec insert intention waiting
0: len 4; hex 80000014; asc ;;
TABLE LOCK table `lab`.`t` trx id 27 lock mode IX
RECORD LOCKS space id 8 page no 3 n bits 320 index PRIMARY of table `lab`.`t` trx id 27 lock_mode X locks gap before rec insert intention waiting
0: len 4; hex 80000014; asc ;;
---TRANSACTION 26, ACTIVE 3 sec
2 lock struct(s), heap size 1144, 2 row lock(s)
TABLE LOCK table `lab`.`t` trx id 26 lock mode IX
RECORD LOCKS space id 8 page no 3 n bits 320 index PRIMARY of table `lab`.`t` trx id 26 lock_mode X
0: len 4; hex 80000014; asc ;;
0: len 4; hex 8000001e; asc ;;
C: INSERT 35 went through
--------------
INSERT INTO t VALUES (15, 'gap')
--------------
ERROR 1205 (HY000) at line 1: Lock wait timeout exceeded; try restarting transaction
B: INSERT 15 returned
--- 3. same thing under READ COMMITTED
+----+------+
id | val |
+----+------+
20 | B |
+----+------+
B: INSERT 15 went through
- 1단계: B(24)가 A(23)를 기다리다가, A가 커밋하자 끝났습니다(
B: UPDATE finished). 기다리는 문장은PROCESSLIST에서Update(1단계에서는Updating)로만 보입니다. 엔진 안에서 잠금 때문에 자고 있다는 것은INNODB_TRX의LOCK WAIT으로 알아봐야 합니다. - 2단계: A(26)의 범위 읽기는 20(
80000014)과, 범위를 넘어 멈춘 30(8000001e)에 넥스트 키 잠금(아무 수식어 없는lock_mode X)을 걸었습니다. 잠금 객체는 테이블IX하나와 레코드 잠금 하나, 두 레코드는 같은 객체의 비트 두 개입니다. 15를 넣으려면 "20 앞의 갭"에 insert intention을 걸어야 해서 B(27)가locks gap before rec insert intention waiting으로 기다리고(B의 기다리는 잠금은TRX HAS BEEN WAITING … FOR THIS LOCK TO BE GRANTED구역과 잠금 목록에 두 번 찍힙니다. 그 머리 줄은 grep 으로 뺐습니다,lock0lock.cc5498–5513행),innodb_lock_wait_timeout = 4가 지나 오류 1205로 그 문장만 실패했습니다. 반면 35는 30 뒤의 갭이라 아무도 잠그지 않아 바로 들어갔습니다. - 3단계: READ COMMITTED에서는 같은 범위 읽기가 갭을 잠그지 않아(레코드만,
LOCK_REC_NOT_GAP) 15가 바로 들어갔습니다.
넥스트 키 잠금 하나는 레코드와 그 앞의 갭을 함께 잠급니다. 키, 트랜잭션 번호, 결과는 예제 4 2단계의 실제 출력입니다.
트랜잭션 ID(23, 24, 26, 27)와 연결 ID는 새로 띄운 서버에서 몇 번째 트랜잭션·연결인지로 정해지는 값이라, 매번 새 컨테이너인 이 예제에서는 다시 돌려도 같았습니다. 다른 환경에서는 달라집니다. 반면 ACTIVE 3 sec 같은 초 수는 타이밍에 따라 달라서, 다시 돌렸을 때는 ACTIVE 4 sec이 나왔습니다.
헷갈리기 쉬운 곳: INNODB_LOCKS가 보여 주지 않는 것
이 표에는 두 가지 한계가 있습니다.
- 기다림에 얽힌 잠금만 나옵니다. A가 쥔
IX테이블 잠금이나 30의 잠금은SHOW ENGINE INNODB STATUS에는 있지만INNODB_LOCKS에는 없습니다. 표를 채우는add_trx_relevant_locks_to_cache()가 기다리는 잠금과, 그것을 막고 있는 잠금만 넣기 때문입니다(trx0i_s.cc882–952행). lock_mode는 아홉 가지 값뿐입니다(S,S,GAP,X,X,GAP,IS…AUTO_INC,i_s.cc566–577행). 그래서 넥스트 키 잠금과 레코드만 잠근 잠금이 둘 다X로, B의 insert intention이X,GAP으로 보입니다. 정확한 종류는 위 출력처럼SHOW ENGINE INNODB STATUS(innodb_status_output_locks=ON)의locks rec but not gap,locks gap before rec,insert intention같은 문구로 봐야 합니다(lock0lock.cc5334–5359행).
예제 5: lock_wait()에서 멈춘 백트레이스
예제 1과 같은 ex/u11-gdb-commit의 앞 단계입니다. A가 20을 고쳐 둔 채로 gdb를 lock_wait에 걸고 B가 같은 행을 UPDATE합니다. 가운데 #6~#13(범위 읽기와 Multi-Range Read 감싸개, handler::read_range_first()부터 READ_RECORD::read_record()까지)은 ...으로 줄였습니다.
#0 lock_wait at storage/innobase/lock/lock0lock.cc:2237
#1 row_mysql_handle_errors at storage/innobase/row/row0mysql.cc:702
#2 row_search_mvcc<InnoDBPolicy<true, true> > at storage/innobase/row/row0sel.cc:6063
#3 ha_innobase::index_read at storage/innobase/handler/ha_innodb.cc:9220
#4 handler::index_read_map at sql/handler.h:4443
#5 handler::ha_index_read_map at sql/handler.cc:4215
...
#14 Sql_cmd_update::update_single_table at sql/sql_update.cc:993
#15 Sql_cmd_update::execute_inner at sql/sql_update.cc:3324
#16 Sql_cmd_dml::execute at sql/sql_select.cc:36000
#17 mysql_execute_command at sql/sql_parse.cc:4493
#18 mysql_parse at sql/sql_parse.cc:8005
#19 dispatch_command at sql/sql_parse.cc:1903
#20 do_command at sql/sql_parse.cc:1437
UPDATE는 고칠 행을 먼저 읽습니다. 서버의 UPDATE 실행(#14)이 범위 읽기로 handler::ha_index_read_map()(9편의 ha_ 감싸개, #5)을 부르고, InnoDB의 row_search_mvcc()(#2)가 그 행에 X 잠금을 요청했다가 DB_LOCK_WAIT을 받아, 4절의 row_mysql_handle_errors() 702행(#1)에서 lock_wait()으로 잠들었습니다. 쓰기가 잠금을 기다리는 곳이 "쓰는 함수"가 아니라 "읽는 함수"라는 점이 처음에는 낯섭니다.
교착 찾기 — wait_trx 사슬의 고리
기다리는 트랜잭션은 trx->lock.wait_trx에 "내가 기다리는 트랜잭션"을 적어 둡니다. 이 화살표를 따라가다 처음으로 돌아오면 교착입니다. Deadlock::find_cycle()은 이 고리를 Brent의 고리 찾기 알고리즘(거북이와 토끼 두 포인터로 메모리 없이 고리를 찾는 방법)으로 찾습니다.
inline trx_t *find_cycle(trx_t *trx)
{
mysql_mutex_assert_owner(&lock_sys.wait_mutex);
trx_t *tortoise= trx, *hare= trx;
for (unsigned power= 1, l= 1; (hare= hare->lock.wait_trx) != nullptr; l++)
{
if (tortoise == hare)
{
ut_ad(l > 1);
/* Note: Normally, trx should be part of any deadlock cycle
that is found. However, if innodb_deadlock_detect=OFF had been
in effect in the past, it is possible that trx will be waiting
for a transaction that participates in a pre-existing deadlock
cycle. In that case, our victim will not be trx. */
return hare;
}
// ...
}
return nullptr;
}
storage/innobase/lock/lock0lock.cc 314–342행
고리를 찾으면 Deadlock::report()가 고리 안에서 희생자를 고릅니다(lock0lock.cc 7310–7395행). 기준은 calc_victim_weight()로, 비트 몇 개를 겹쳐 하나의 숫자로 만든 다음 가장 작은 쪽을 고릅니다(7283–7302행). 주된 무게는 이 매크로입니다.
#define TRX_WEIGHT(t) ((t)->undo_no + UT_LIST_GET_LEN((t)->lock.trx_locks))
storage/innobase/include/trx0trx.h 260행
undo 레코드 수 + 잠금 수, 곧 되돌리기 싼 쪽이 희생됩니다. 트랜잭션이 아닌 테이블(MyISAM 등)을 고친 트랜잭션은 되돌릴 수 없는 일을 했으니 희생자에서 피하고(비트 62), 무게가 같으면 지금 막 잠금을 요청한 트랜잭션이 희생됩니다(비트 0). innodb_deadlock_detect=OFF면(기본 ON, 19963–19968행) 이 탐지를 건너뛰고 시간 초과에 맡깁니다.
예제 6: 교착을 만들고 LATEST DETECTED DEADLOCK 보기
ex/u11-deadlock입니다. A가 1을, B가 2를 고친 뒤, A가 2를, B가 1을 고치려 합니다. 두 세션 모두 FIFO로 열어 출력 앞에 A|, B|를 붙였습니다.
--- 1. A locks 1, B locks 2, then each wants the other's row
B| --------------
B| UPDATE t SET val = 'B' WHERE id = 1
B| --------------
B|
B| ERROR 1213 (40001) at line 2: Deadlock found when trying to get lock; try restarting transaction
B| step @@in_transaction
B| B in trx 0
A| step @@in_transaction
A| A in trx 1
--- 2. result and deadlock counter
+----+------+
id | val |
+----+------+
1 | A |
2 | A |
+----+------+
+------------------+-------+
Variable_name | Value |
+------------------+-------+
Innodb_deadlocks | 1 |
+------------------+-------+
B가 오류 1213을 받고 트랜잭션 전체가 롤백되었으며(B in trx 0), A는 B의 잠금이 풀리자 2를 고치고 커밋했습니다. SHOW ENGINE INNODB STATUS의 LATEST DETECTED DEADLOCK 구역은 이렇습니다(일부만 실었고 줄인 곳은 ...로 표시했습니다. 위 결과와 같은 실행의 출력입니다. 시각, 스레드 핸들, ACTIVE … sec의 초 수는 실행마다 다릅니다).
...
*** (1) TRANSACTION:
TRANSACTION 24, ACTIVE 2 sec starting index read
mysql tables in use 1, locked 1
LOCK WAIT 3 lock struct(s), heap size 1144, 2 row lock(s), undo log entries 1
MariaDB thread id 6, OS thread handle 139695959480000, query id 11 localhost root Updating
UPDATE t SET val = 'B' WHERE id = 1
*** WAITING FOR THIS LOCK TO BE GRANTED:
RECORD LOCKS space id 8 page no 3 n bits 320 index PRIMARY of table `lab`.`t` trx id 24 lock_mode X locks rec but not gap waiting
Record lock, heap no 2 PHYSICAL RECORD: n_fields 4; compact format; info bits 0
0: len 4; hex 80000001; asc ;;
1: len 6; hex 000000000017; asc ;;
2: len 7; hex 060000002e0110; asc . ;;
3: len 1; hex 41; asc A;;
*** CONFLICTING WITH:
RECORD LOCKS space id 8 page no 3 n bits 320 index PRIMARY of table `lab`.`t` trx id 23 lock_mode X locks rec but not gap
...
*** (2) TRANSACTION:
TRANSACTION 23, ACTIVE 3 sec starting index read
mysql tables in use 1, locked 1
LOCK WAIT 3 lock struct(s), heap size 1144, 2 row lock(s), undo log entries 1
...
UPDATE t SET val = 'A' WHERE id = 2
*** WAITING FOR THIS LOCK TO BE GRANTED:
RECORD LOCKS space id 8 page no 3 n bits 320 index PRIMARY of table `lab`.`t` trx id 23 lock_mode X locks rec but not gap waiting
...
*** WE ROLL BACK TRANSACTION (1)
- 기본 키로 한 행을 찾는
UPDATE라서locks rec but not gap(LOCK_REC_NOT_GAP)입니다. - 레코드의 네 필드는 3절의 숨은 열 그대로입니다.
0:기본 키 1(80000001, 부호 비트를 뒤집어 저장),1:DB_TRX_ID=0x17= 23(A가 고쳤다),2:DB_ROLL_PTR,3:A가 넣은'A'. - 두 트랜잭션의 무게가 같아(undo 1개, 잠금 객체 3개) 마지막에 잠금을 요청한 B, 곧 (1)이 희생되었습니다.
이 글에서 뺀 것
- XA와 2단계 커밋의 InnoDB 쪽(
innobase_xa_prepare(),innobase_xa_recover(),TRX_STATE_PREPARED):storage/innobase/handler/ha_innodb.cc, 12편 - SAVEPOINT 구현:
innobase_savepoint(),innobase_rollback_to_savepoint()(ha_innodb.cc5060행 부근) - AUTO_INC 테이블 잠금과
innodb_autoinc_lock_mode:lock_table()의LOCK_AUTO_INC,lock/lock0lock.cc - 공간 인덱스의 술어 잠금:
lock/lock0prdt.cc lock_sys의 래치 나누기(lock_sys_t::hash_latch,LockGuard)와 트랜잭션 메모리(TMLockGuard):include/lock0lock.htrx_sys의rw_trx_hash(잠금 없는 해시)와 스냅샷용rw_trx_ids:include/trx0sys.h,trx/trx0sys.cc- READ COMMITTED의 "semi-consistent read"(UPDATE가 잠긴 행의 마지막 커밋 판을 먼저 보고 조건이 안 맞으면 건너뛰기):
row_search_mvcc()의did_semi_consistent_read SKIP LOCKED(prebuilt->skip_locked, 위row0sel.cc발췌 둘레)- undo 테이블스페이스와
innodb_undo_log_truncate:trx/trx0purge.cc의trx_purge_truncate_history() - purge가 레코드를 실제로 지우는 쪽:
row/row0purge.cc - Galera의 BF abort(
wsrep_*블록): 이 빌드에서는 빼고 빌드했습니다
정리
| 개념 | 한 줄 요약 |
|---|---|
ha_commit_trans() |
서버 계층의 커밋 조정. 참여한 엔진 목록을 돌며 ht->commit(thd, all). 엔진 둘 이상이나 binlog면 2단계 커밋 |
all=false |
문장 끝. 단 autocommit에서는 진짜 커밋이라 innobase_commit()이 따로 가린다 |
trx_t |
연결마다 하나, 재사용. 첫 행 접근에서 ACTIVE, 쓰기를 시작할 때(대개 IX 잠금) 비로소 ID |
| 커밋 | commit_persist()(undo를 history로, mtr 커밋 = 커밋 LSN) → commit_in_memory()(뷰 닫기, deregister_rw(), 잠금 모두 풀기) → 로그 flush |
DB_TRX_ID·DB_ROLL_PTR |
레코드마다 붙은 숨은 열. 마지막으로 고친 트랜잭션과 옛 값의 undo 위치 |
ReadView |
뷰를 만들 때 진행 중이던 ID 목록. changes_visible()로 보이는지 판정 |
| 격리 수준 | RR은 첫 읽기에서 연 뷰를 커밋까지, RC는 문장마다 새 뷰. BEGIN은 뷰를 열지 않는다 |
| 일관 읽기 | 안 보이면 row_vers_build_for_consistent_read()가 undo 사슬을 거슬러 옛 판을 만든다 |
| snapshot isolation | 기준 커밋 기본 ON. 안 보이는 판을 고치려 하면 오류 1020, 트랜잭션 전체 롤백 |
| undo | 고치기 전에 trx_undo_report_row_operation(). 롤백과 옛 판의 재료 |
| purge | 가장 오래된 뷰보다 오래된 undo만 치운다. 오래 열린 뷰가 history list를 늘린다 |
| 행 잠금 | 페이지·트랜잭션·모드마다 lock_t 하나, 레코드는 비트. INSERT한 행은 암묵적 잠금 |
| 갭 잠금 | 갭끼리는 충돌하지 않고 INSERT(insert intention)만 막는다. 예제의 RC 범위 읽기는 갭을 잠그지 않았다 |
lock_wait() |
잠들기 전에 교착 확인, innodb_lock_wait_timeout까지 대기. 시간 초과는 (기본 설정에서) 문장만 롤백 |
| 교착 | wait_trx 사슬의 고리를 Brent 알고리즘으로. undo+잠금 수가 작은 쪽이 희생 |
다음에 볼 것
이 글에서 "로그를 flush한다", "크래시 뒤 undo로 되돌린다", "binlog가 켜지면 2단계 커밋"이라고만 하고 넘어간 것이 셋 있습니다. 미니 트랜잭션이 남긴 redo 로그가 어떻게 로그 버퍼와 ib_logfile0에 쓰이고 innodb_flush_log_at_trx_commit이 그 어디를 바꾸는지, 크래시 뒤 redo를 다시 적용하고 미완료 트랜잭션을 되돌리는 복구, 그리고 InnoDB의 redo와 서버의 binlog를 맞추는 2단계 커밋을 12편: 로그 두 개 — redo 로그와 binlog, 크래시 복구, 그리고 연재를 마치며에서 봅니다.
애플리케이션 쪽에서 보던 트랜잭션은 Spring 17편에서 봤습니다. @Transactional 메서드가 끝나면 Spring의 트랜잭션 관리자가 JDBC Connection.commit()을 부르고, 그것이 결국 이 글 1절의 COMMIT → ha_commit_trans()가 됩니다. Spring의 isolation 속성은 JDBC Connection.setTransactionIsolation()으로 넘어가 연결의 격리 수준이 되고, 3절의 ha_innobase::store_lock()이 그것을 trx->isolation_level로 옮겨 적습니다(JDBC 드라이버가 서버에 실제로 보내는 문장은 이 글에서 확인하지 않았습니다). Spring은 "언제 열고 언제 닫나"를 정하고, 그 사이에 무엇이 보이고 누가 기다리는지는 여기 InnoDB가 정하는 셈입니다.
같은 "기다림"이라도 7편의 메타데이터 잠금(Waiting for table metadata lock)은 서버 계층의 MDL_context가 테이블 정의를 지키는 잠금이고, 이 글의 행 잠금은 InnoDB가 레코드를 지키는 잠금입니다. 열린 트랜잭션이 ALTER TABLE을 막는 것은 앞의 것, SELECT … FOR UPDATE가 다른 UPDATE를 막는 것은 뒤의 것입니다.