namalzi

← 글 목록

MariaDB 소스 파헤치기 11: InnoDB 2 — 트랜잭션, MVCC, undo, 잠금

MariaDB · 게시

COMMIT 한 줄이 서버 계층의 ha_commit_trans()에서 InnoDB의 trx_t::commit()까지 가는 길, REPEATABLE READ가 옛 값을 보여 주는 ReadView와 undo 사슬, 아무도 안 보는 옛 판을 치우는 purge, 그리고 행 잠금·갭 잠금·잠금 대기·교착 탐지를 기준 커밋의 소스로 따라갑니다. 두 세션 예제로 스냅샷, 잠금 대기, 교착, history list length를 직접 확인합니다.

이 글에서 다루는 것

"MariaDB 소스 파헤치기" 연재의 열한 번째 글입니다. 10편에서는 InnoDB가 데이터를 16KB 페이지에 담고, 페이지를 버퍼 풀에 올려 B-트리로 찾는 방법을 봤습니다. 거기서는 "행 하나를 어디에 어떻게 두나"만 물었습니다. 이번 글은 같은 행을 여러 연결이 동시에 읽고 고칠 때 InnoDB가 무엇을 하는지 봅니다.

2편 지도로 말하면 ⑧ 엔진 칸 안의 InnoDB를 계속 확대하는데, 하나가 더 붙습니다. COMMIT은 엔진만의 일이 아니라서, 서버 계층(sql/handler.cc)이 엔진들에게 "커밋하라"고 지시하는 트랜잭션 조정 부분을 먼저 봅니다.

2편 지도 위의 위치. ① 연결, ② THD·명령, ③ 파싱, ④ 준비, ⑤ 최적화, ⑥ 실행, ⑦ handler 경계, ⑧ 엔진(InnoDB)이 차례로 이어진다. 이 글은 두 칸을 다룬다. 하나는 ④ 준비 칸의 mysql_execute_command(case SQLCOM_COMMIT)에서 갈라져 나오는 서버 계층의 트랜잭션 조정 칸(sql/handler.cc의 trans_commit과 ha_commit_trans)이고, 다른 하나는 handlerton::commit으로 불리는 ⑧ 엔진 칸의 InnoDB(trx_t, ReadView, undo, purge, 행 잠금)이다. 준비 칸에는 서버 계층의 테이블 잠금인 MDL이 7편이라고 적혀 있다. 파란 칸이 이 글에서 읽는 곳입니다. 7편의 MDL과 이 글의 행 잠금은 서로 다른 층의 잠금입니다.

답하려는 질문은 네 가지입니다.

항목 내용
대상 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);

sql/handler.h 1341–1350행

주석이 이 글의 첫 번째 함정을 알려 줍니다. 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행).

COMMIT이 지나는 길을 그린 시퀀스 그림. mysql_execute_command의 case SQLCOM_COMMIT이 trans_commit을 거쳐 ha_commit_trans(thd, true)를 부른다. 쓰기를 한 엔진이 하나뿐이고 binlog가 꺼져 있으면 ha_commit_one_phase로 가서, commit_one_phase_2가 목록의 엔진마다 ht->commit(thd, all)을 부른다. InnoDB의 innobase_commit은 innobase_commit_ordered_2를 거쳐 trx_commit_for_mysql을 부르고, trx_t::commit이 commit_persist에서 undo를 history로 옮기고 mtr을 커밋한 뒤 commit_in_memory에서 읽기 뷰를 닫고 trx_sys에서 빠지고 release_locks로 잠금을 한꺼번에 푼다. 마지막으로 innobase_commit이 trx_commit_complete_for_mysql로 redo 로그를 flush하고 OK 패킷이 나간다. 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;
      }

sql/handler.cc 2282–2289행

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 쪽 트랜잭션의 일생

trx_t와 둘레 구조체의 관계를 그린 클래스 그림. THD는 ha_data의 InnoDB 칸으로 trx_t를 가리킨다(thd_to_trx). trx_t는 id, state, isolation_level, undo_no, rsegs.m_redo.undo를 갖고, ReadView 하나(read_view)와 trx_lock_t 하나(lock)를 품고 있다. ReadView에는 m_low_limit_id, m_up_limit_id, m_ids, m_creator_trx_id와 changes_visible(id)가 있다. trx_lock_t에는 trx_locks, wait_lock, wait_trx가 있다. trx_sys_t는 m_max_trx_id, rw_trx_hash, trx_list, history_size()를 갖고 trx_list로 모든 trx_t를 모은다. lock_sys_t는 rec_hash와 wait_mutex를 갖고 잠금 객체 lock_t들을 해시에 담는다. 이 글에 나오는 구조체만 그렸습니다. 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의 상태 그림. 처음에 check_trx_exists()로 trx_t를 THD에 붙이면 NOT_STARTED다. 첫 행 읽기나 쓰기에서 trx_start_low()로 ACTIVE가 된다. ACTIVE는 COMMIT이나 롤백이 끝나면 commit_state()로 COMMITTED_IN_MEMORY가 되고, XA PREPARE나 2단계 커밋에서는 PREPARED를 거친다. COMMITTED_IN_MEMORY는 잠금을 다 푼 뒤 commit_cleanup()에서 NOT_STARTED로 돌아온다. 교착 등으로 이미 롤백된 경우 innodb_transaction_abort()가 NOT_STARTED를 ABORTED로 바꾸고, 서버가 롤백을 알아차리면 다시 NOT_STARTED가 된다. 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행

  1. 읽기 뷰를 닫습니다(1417행). 이 트랜잭션은 더는 옛 판을 볼 필요가 없습니다.
  2. 상태를 COMMITTED_IN_MEMORY로 바꾸고(commit_state()), 쓰기 트랜잭션이었으면 trx_sys의 진행 중 목록에서 뺍니다(deregister_rw()). 이 순간부터 새로 만들어지는 읽기 뷰는 이 트랜잭션의 변경을 봅니다.
  3. 잠금을 한꺼번에 풉니다(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_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행

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행

이미 열려 있으면 아무것도 하지 않습니다. 격리 수준의 차이는 이 한 줄과, 뷰를 언제 닫느냐로 만들어집니다.

	} 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행

	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 사슬을 따라 옛 판을 찾는 흐름. 클러스터드 인덱스의 레코드는 id=1, val='v3', DB_TRX_ID=30이고 DB_ROLL_PTR이 undo 레코드 ①을 가리킨다. changes_visible(30)이 참이면 지금 판을 그대로 돌려준다. 거짓이면 undo 레코드 ①('val은 v2였다', 만든 트랜잭션 25, 그 전은 ②)로 가서 changes_visible(25)을 따진다. 보이면 v2로 되돌린 판을 만들어 돌려주고(row_vers_build_for_consistent_read), 안 보이면 undo 레코드 ②('val은 v1이었다', 만든 트랜잭션 20)로 한 칸 더 간다. 노란 칸은 모두 버퍼 풀의 페이지입니다. 레코드는 인덱스 페이지에, 옛 값은 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 = 켜짐)입니다.

  1. REPEATABLE READ에서 A는 B가 커밋한 v2를 보지 못하고, 자기가 커밋해 뷰가 닫힌 뒤에야 봅니다. 그 사이 A가 본 v1은 B의 UPDATE가 남긴 undo로 만든 옛 판입니다.
  2. READ COMMITTED에서는 같은 트랜잭션 안에서도 두 번째 SELECT가 v3을 봅니다. 문장마다 뷰를 닫았다 다시 열기 때문입니다.
  3. REPEATABLE READ인데도 BEGIN 뒤 첫 읽기 전에 B가 커밋한 v4는 보입니다. 뷰는 BEGIN이 아니라 첫 읽기에서 열렸고, 그 뒤의 v5만 안 보입니다.
  4. WITH CONSISTENT SNAPSHOT은 문장을 보낸 그 자리에서 뷰를 여니, 첫 읽기 전에 커밋된 v6도 안 보입니다.
  5. 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이면 위 702행처럼 lock_wait()으로 잠들었다가, 잠금을 받으면 문장을 이어 갑니다(701–709행). 6절의 예제 5가 바로 이 702행에서 멈춘 모습입니다.

오류에 따라 되돌리는 범위가 갈리는 그림. row_mysql_handle_errors() 는 trx->error_state 로 갈라진다. DB_LOCK_WAIT 이면 lock_wait(thr) 로 잠들었다가 잠금을 받으면 문장을 이어 가고, 시간 초과나 교착으로 끝나면 handle_new_error 로 돌아와 다시 갈라진다. DB_LOCK_WAIT_TIMEOUT(1205)은 innodb_rollback_on_timeout 이 OFF(기본)면 rollback_to_savept 로 이 문장만 되돌리고 트랜잭션은 살아 있으며, ON 이면 트랜잭션 전체를 되돌린다. DB_DEADLOCK(1213), DB_RECORD_CHANGED(1020), DB_LOCK_TABLE_FULL, DB_TEMP_FILE_WRITE_FAIL 은 rollback: 의 trx->rollback() 으로 트랜잭션 전체를 되돌린다 노란 칸은 문장만, 빨간 칸은 트랜잭션 전체를 되돌립니다. 갈래는 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의 흐름. UPDATE나 DELETE가 trx_undo_report_row_operation()으로 옛 값을 undo 페이지에 적는다. COMMIT 때 write_serialisation_history()가 트랜잭션 번호를 받고, trx_purge_add_undo_to_history()가 undo를 롤백 세그먼트마다의 history list에 넣으며 history_size를 늘린다. purge 코디네이터의 trx_purge()는 purge_sys.clone_oldest_view()로 가장 오래된 읽기 뷰를 복사해, 그 뷰가 더는 안 볼 undo만 골라 purge 작업자(purge_worker_task)에게 나눠 주고, 작업자는 row_purge_del_mark() 등으로 지우기 표시된 레코드를 실제로 지운다. 빨간 칸의 오래 열린 트랜잭션은 뷰가 닫히지 않아 history가 쌓이게 만든다. 빨간 칸이 운영에서 자주 만나는 문제입니다. 가장 오래된 뷰 하나가 전체 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

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()

잠금을 요청해 받거나 기다리는 흐름. row_search_mvcc()가 sel_set_rec_lock()을 거쳐 lock_clust_rec_read_check_and_lock()을 부르고, lock_rec_lock()이 lock_sys.rec_hash에서 그 페이지의 잠금들을 본다. lock_rec_has_to_wait()로 충돌을 따져 충돌이 없으면 lock_t를 만들거나 있던 lock_t에 비트를 켜고 끝난다. 충돌하면 lock_rec_enqueue_waiting()이 LOCK_WAIT 표시를 단 lock_t를 줄에 세우고, lock_wait()이 thd_wait_begin()을 알린 뒤 Deadlock::check_and_resolve()로 wait_trx 사슬에서 고리를 찾는다. 고리가 있으면 DB_DEADLOCK(오류 1213)으로 희생자 트랜잭션 전체를 롤백한다. 고리가 없으면 my_cond_timedwait()으로 innodb_lock_wait_timeout까지 잔다. 앞 트랜잭션이 커밋하거나 롤백하면 lock_grant()가 깨우고, 시간이 지나면 DB_LOCK_WAIT_TIMEOUT(오류 1205)으로 그 문장만 롤백한다. 교착 탐지는 따로 도는 감시 스레드가 아니라, 기다리기 시작하는 스레드가 잠들기 직전에 직접 합니다.

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

예제 4의 2단계에서 잎 페이지(space id 8, page no 3)의 레코드와 갭이 어떻게 잠겼는지 키 순서로 그린 그림. A(trx 26)가 SELECT … WHERE id BETWEEN 11 AND 25 FOR UPDATE 로 20 과 범위 밖 30 에 넥스트 키 잠금(lock_mode X)을 걸었고, 이것은 레코드 잠금 객체 하나의 비트 둘과 테이블 IX 하나다. 그래서 위에서부터 갭 ~10 과 레코드 10 은 잠기지 않았고, 갭 10~20 은 A 가 잠가 B(trx 27)의 INSERT 15 가 insert intention 으로 기다리다 4초 뒤 ERROR 1205 를 받았으며, 레코드 20, 갭 20~30, 레코드 30 은 A 가 잠갔고, 갭 30~ 은 아무도 잠그지 않아 C 의 INSERT 35 가 바로 들어갔다 넥스트 키 잠금 하나는 레코드와 그 앞의 갭을 함께 잠급니다. 키, 트랜잭션 번호, 결과는 예제 4 2단계의 실제 출력입니다.

트랜잭션 ID(23, 24, 26, 27)와 연결 ID는 새로 띄운 서버에서 몇 번째 트랜잭션·연결인지로 정해지는 값이라, 매번 새 컨테이너인 이 예제에서는 다시 돌려도 같았습니다. 다른 환경에서는 달라집니다. 반면 ACTIVE 3 sec 같은 초 수는 타이밍에 따라 달라서, 다시 돌렸을 때는 ACTIVE 4 sec이 나왔습니다.

헷갈리기 쉬운 곳: INNODB_LOCKS가 보여 주지 않는 것

이 표에는 두 가지 한계가 있습니다.

  1. 기다림에 얽힌 잠금만 나옵니다. A가 쥔 IX 테이블 잠금이나 30의 잠금은 SHOW ENGINE INNODB STATUS에는 있지만 INNODB_LOCKS에는 없습니다. 표를 채우는 add_trx_relevant_locks_to_cache()가 기다리는 잠금과, 그것을 막고 있는 잠금만 넣기 때문입니다(trx0i_s.cc 882–952행).
  2. lock_mode는 아홉 가지 값뿐입니다(S, S,GAP, X, X,GAP, IS … AUTO_INC, i_s.cc 566–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.cc 5334–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)

이 글에서 뺀 것

정리

개념 한 줄 요약
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를 막는 것은 뒤의 것입니다.

다른 글