MySQL Snapshot Read의 원리: Read View, trx_id, undo chain
InnoDB snapshot read가 Read View와 트랜잭션 ID, undo chain으로 과거 행 버전을 선택하는 원리와 운영 진단 기준을 설명한다.
InnoDB의 snapshot read는 테이블을 특정 시점에 통째로 복사해 두는 기능이 아니다. 일반 SELECT가 레코드의 최신 물리 버전을 읽을 수 있는지 Read View로 판정하고, 보이지 않으면 undo chain을 거슬러 올라가 해당 snapshot에 보이는 과거 버전을 재구성하는 실행 방식이다. 이 구조 덕분에 읽기와 쓰기가 상당 부분 서로를 막지 않으면서도 문장 또는 트랜잭션 단위의 일관된 결과를 제공할 수 있다.
운영에서는 이 원리를 정확히 알아야 한다. 오래 열린 읽기 전용 트랜잭션은 record lock을 거의 잡지 않아도 purge의 진행을 늦출 수 있고, REPEATABLE READ에서 일반 조회가 과거 값을 보는 현상을 replica lag로 오인할 수도 있다. 반대로 SELECT ... FOR UPDATE와 DML은 snapshot에 머무르지 않는 current read이므로, 같은 트랜잭션에서 일반 SELECT와 다른 값을 볼 수 있다. 이 글은 Read View의 경계값, 레코드의 DB_TRX_ID와 DB_ROLL_PTR, undo 탐색 경로를 하나의 가시성 판정 과정으로 연결한다.
1. Snapshot read가 해결하는 문제
두 트랜잭션이 같은 행에 접근한다고 가정하자. 세션 A가 잔액 100을 기준으로 보고서를 읽는 동안 세션 B는 그 잔액을 110, 120, 130으로 차례로 변경하고 커밋한다. 세션 A가 매번 최신 레코드만 읽으면 보고서 안에서 기준 시점이 흔들린다. 그렇다고 세션 A가 조회 대상 전체에 공유 잠금을 걸면 쓰기 처리량이 크게 떨어진다.
InnoDB는 다음 세 요소를 결합해 이 문제를 해결한다.
- Read View: 어떤 트랜잭션의 변경을 볼 수 있는지 정하는 가시성 규칙이다.
- 레코드의 트랜잭션 정보: clustered index 레코드의 숨은
DB_TRX_ID는 해당 버전을 마지막으로 생성하거나 변경한 트랜잭션을 가리킨다. - undo chain:
DB_ROLL_PTR가 가리키는 undo record를 따라가며 이전 버전을 재구성한다.
flowchart TD
Q[일반 SELECT의 snapshot read] --> R[clustered index 레코드 접근]
R --> T[현재 버전의 DB_TRX_ID 확인]
T --> V{Read View에서 보이는가}
V -->|예| OUT[현재 버전 반환]
V -->|아니요| U[DB_ROLL_PTR로 undo record 접근]
U --> P[직전 버전 재구성]
P --> D{삽입·갱신·삭제 상태와<br/>DB_TRX_ID가 보이는가}
D -->|예| OUT2[과거 버전 반환 또는 행 제외]
D -->|아니요| U2[더 오래된 undo record 탐색]
U2 --> P
여기서 snapshot은 데이터 파일의 복제본이 아니라 가시성 판정에 필요한 논리적 경계다. 실제 행 값은 최신 레코드와 undo record를 조합해 필요할 때 재구성한다.
2. Read View의 구성과 가시성 경계
Read View 내부 필드명은 MySQL 소스 버전에 따라 세부 구현이 달라질 수 있지만, 개념적으로 다음 정보가 핵심이다.
| 개념 | MySQL 소스에서 흔히 보이는 이름 | 의미 |
|---|---|---|
| 생성자 트랜잭션 ID | m_creator_trx_id |
Read View를 만든 트랜잭션 자신의 변경을 식별한다. |
| 활성 read-write 트랜잭션 ID 집합 | m_ids |
Read View 생성 시점에 아직 커밋하지 않은 read-write 트랜잭션 목록이다. |
| 확실히 보이는 하한 경계 | m_up_limit_id |
이 값보다 작은 트랜잭션 ID의 버전은 원칙적으로 보인다. |
| 보이지 않는 상한 경계 | m_low_limit_id |
이 값 이상인 트랜잭션 ID의 버전은 Read View 생성 뒤의 변경이므로 보이지 않는다. |
up과 low라는 이름은 숫자 크기와 직관적으로 반대로 느껴질 수 있다. 운영자가 기억해야 할 것은 이름보다 판정 순서다. 행 버전을 만든 트랜잭션 ID를 version_trx_id라고 하면 다음과 같이 이해할 수 있다.
version_trx_id가 Read View 생성자 자신이면 보인다. 이를 read-your-writes라고 볼 수 있다.version_trx_id < m_up_limit_id이면 Read View가 생성되기 전에 이미 완료된 오래된 트랜잭션의 버전이므로 보인다.version_trx_id >= m_low_limit_id이면 Read View가 생성된 뒤 시작된 트랜잭션의 버전이므로 보이지 않는다.- 두 경계 사이에 있으면
m_ids를 확인한다. 목록에 있으면 당시 미커밋 상태였으므로 보이지 않고, 목록에 없으면 당시 이미 커밋됐으므로 보인다.
flowchart TD
A[version_trx_id 판정] --> O{내 트랜잭션의 변경인가}
O -->|예| Y[보임]
O -->|아니요| U{version_trx_id가<br/>m_up_limit_id보다 작은가}
U -->|예| Y
U -->|아니요| L{version_trx_id가<br/>m_low_limit_id 이상인가}
L -->|예| N[보이지 않음]
L -->|아니요| I{활성 ID 집합 m_ids에 있는가}
I -->|예| N
I -->|아니요| Y
이 규칙은 트랜잭션 ID의 숫자만 비교해 “작으면 무조건 보인다”라고 단순화할 수 없음을 보여 준다. Read View 생성 시점에 더 일찍 시작했지만 아직 커밋하지 않은 트랜잭션이 있을 수 있기 때문에 활성 ID 집합 확인이 필요하다.
2.1 자신의 변경은 왜 보이는가
세션 A가 snapshot을 만든 뒤 같은 트랜잭션에서 행을 추가하거나 수정하면, 이후 일반 조회에는 자신의 변경이 보여야 한다. 그렇지 않으면 하나의 트랜잭션이 자신이 수행한 작업을 확인할 수 없다. Read View는 다른 트랜잭션의 가시성을 제한하면서도 생성자 자신의 변경은 예외로 처리한다.
다만 자신의 변경과 다른 트랜잭션의 변경이 섞인 복잡한 집계에서는 결과를 “벽시계의 한 시점에 존재했던 완전한 데이터 복사본”이라고 표현하기 어렵다. snapshot에 자신의 후속 변경을 더한 논리적 결과이기 때문이다. 감사·정산처럼 엄격한 기준 시점이 필요하면 읽기 전용 트랜잭션 경계를 명확히 하고 중간 DML을 섞지 않는 편이 안전하다.
2.2 읽기 전용 트랜잭션의 trx_id = 0
MySQL 8.0의 information_schema.INNODB_TRX.TRX_ID에서 읽기 전용 트랜잭션이 0으로 보일 수 있다. InnoDB는 행을 변경하지 않는 트랜잭션에 불필요한 read-write 트랜잭션 ID를 즉시 할당하지 않을 수 있기 때문이다. 이것을 “트랜잭션이 없다” 또는 “Read View가 없다”라고 해석하면 안 된다.
다음 세 개념을 구분해야 한다.
- 레코드 버전에 기록되는 변경 주체의
DB_TRX_ID - 실행 중인 트랜잭션을 관찰할 때 보이는
INNODB_TRX.TRX_ID - Read View가 관리하는 활성 read-write 트랜잭션 ID와 경계값
이름에 모두 trx_id가 들어가지만 관찰 위치와 역할이 다르다.
3. Undo chain에서 행 버전을 재구성하는 과정
InnoDB 테이블의 행은 clustered index에 저장된다. 레코드에는 사용자 컬럼 외에 MVCC 처리를 위한 숨은 시스템 필드가 있다.
DB_TRX_ID: 레코드 버전을 마지막으로 삽입하거나 변경한 트랜잭션 IDDB_ROLL_PTR: 해당 변경을 되돌리는 데 필요한 undo record의 위치DB_ROW_ID: 명시적 primary key나 적합한 unique key가 없을 때 내부적으로 사용할 수 있는 행 식별자
UPDATE는 새 행 복사본을 별도 snapshot 공간에 계속 쌓는 방식이 아니다. clustered index의 현재 레코드가 최신 값으로 바뀌고, 이전 값을 재구성할 정보가 undo에 남는다. 같은 행을 여러 트랜잭션이 순서대로 변경하면 최신 레코드에서 더 오래된 undo record로 이어지는 논리적 사슬이 형성된다.
현재 clustered record
balance = 130, DB_TRX_ID = 430, DB_ROLL_PTR ─┐
▼
undo record: balance를 120으로 복원, trx_id = 420 ─┐
▼
undo record: balance를 110으로 복원, trx_id = 410 ─┐
▼
undo record: balance를 100으로 복원, trx_id = 400
이 숫자는 구조를 설명하기 위한 예시이며 실제 운영 측정값이 아니다. Read View가 430, 420, 410의 변경을 모두 보이지 않는다고 판정하면 InnoDB는 undo chain을 반복해서 따라가 100인 버전을 재구성한다.
3.1 INSERT, UPDATE, DELETE의 가시성
- INSERT: snapshot 생성 뒤 다른 트랜잭션이 삽입한 행은 보이는 이전 버전이 없으므로 결과에서 제외된다.
- UPDATE: 최신 버전이 보이지 않으면 undo record로 직전 컬럼 값을 복원하고, 그 버전의 생성 트랜잭션을 다시 판정한다.
- DELETE: 최신 레코드가 delete-marked 상태여도 삭제 트랜잭션이 snapshot에서 보이지 않으면 undo를 이용해 삭제 전 행을 반환할 수 있다. 삭제가 snapshot에서 보이면 행을 제외한다.
Secondary index만으로 모든 이전 사용자 컬럼 값을 재구성할 수 있다고 가정해서는 안 된다. MVCC 판정과 필요한 이전 버전 복원을 위해 clustered index 레코드와 undo 접근이 발생할 수 있다. 따라서 오래된 snapshot에서 변경이 잦은 행을 넓게 읽으면 단순한 index-only 조회 예상보다 더 많은 버전 탐색 비용이 생길 수 있다.
3.2 Undo log와 redo log를 혼동하지 않는다
Undo는 MVCC의 이전 버전 재구성과 트랜잭션 rollback에 사용된다. Redo는 커밋된 변경을 crash recovery에서 다시 적용할 수 있도록 물리적 변경 기록을 보존한다. 둘 다 복구와 관련되지만 목적과 소비 경로가 다르다.
| 구분 | Undo | Redo |
|---|---|---|
| 주요 목적 | rollback, 과거 행 버전 재구성 | crash recovery, 변경 내구성 |
| Snapshot read와의 관계 | 직접 사용해 이전 버전을 만든다 | 일반 조회의 과거 버전 선택에 직접 사용하지 않는다 |
| 정리 조건 | 필요한 snapshot과 rollback 가능성을 고려해 purge | checkpoint와 재사용 정책에 따라 log 공간 관리 |
| 운영 위험 | 긴 snapshot이 purge를 지연시켜 history가 증가 | 쓰기 폭증과 느린 flush가 checkpoint age에 부담 |
4. 실행 재현: 세 번의 커밋 뒤에도 과거 버전 읽기
다음 예제는 MySQL Event Scheduler를 별도 서버 세션으로 사용해 하나의 행에 세 번의 커밋된 변경을 만든다. 세션 A는 REPEATABLE READ에서 먼저 100을 읽어 Read View를 확정한 뒤 기다린다. 세 이벤트가 값을 110, 120, 130으로 순서대로 바꿔도 일반 조회는 계속 100을 반환한다. 반면 FOR SHARE는 current read이므로 최신 커밋 값 130을 반환한다.
이 예제는 Docker 검증 인스턴스처럼 격리된 환경을 전제로 한다. 운영 서버에서 재현을 위해 event_scheduler를 임의로 켜거나 전역 설정을 바꾸지 않는다.
DROP EVENT IF EXISTS snapshot_update_110;
DROP EVENT IF EXISTS snapshot_update_120;
DROP EVENT IF EXISTS snapshot_update_130;
DROP TABLE IF EXISTS snapshot_account;
CREATE TABLE snapshot_account (
account_id BIGINT PRIMARY KEY,
balance INT NOT NULL
) ENGINE = InnoDB;
INSERT INTO snapshot_account VALUES (1, 100);
SET GLOBAL event_scheduler = ON;
CREATE EVENT snapshot_update_110
ON SCHEDULE AT CURRENT_TIMESTAMP + INTERVAL 2 SECOND
ON COMPLETION NOT PRESERVE
DO UPDATE snapshot_account SET balance = 110 WHERE account_id = 1;
CREATE EVENT snapshot_update_120
ON SCHEDULE AT CURRENT_TIMESTAMP + INTERVAL 3 SECOND
ON COMPLETION NOT PRESERVE
DO UPDATE snapshot_account SET balance = 120 WHERE account_id = 1;
CREATE EVENT snapshot_update_130
ON SCHEDULE AT CURRENT_TIMESTAMP + INTERVAL 4 SECOND
ON COMPLETION NOT PRESERVE
DO UPDATE snapshot_account SET balance = 130 WHERE account_id = 1;
SET SESSION TRANSACTION ISOLATION LEVEL REPEATABLE READ;
START TRANSACTION WITH CONSISTENT SNAPSHOT;
SELECT balance AS snapshot_before
FROM snapshot_account
WHERE account_id = 1;
DO SLEEP(5);
SELECT balance AS snapshot_after_three_commits
FROM snapshot_account
WHERE account_id = 1;
SELECT trx_id AS observed_trx_id,
trx_state,
trx_isolation_level,
trx_rows_modified
FROM information_schema.INNODB_TRX
WHERE trx_mysql_thread_id = CONNECTION_ID();
SELECT balance AS current_value
FROM snapshot_account
WHERE account_id = 1
FOR SHARE;
COMMIT;
SELECT balance AS value_after_commit
FROM snapshot_account
WHERE account_id = 1;
DROP TABLE snapshot_account;
준비·정리 DDL과 세 이벤트의 반복 출력은 줄이고, snapshot과 current read의 차이를 보여 주는 결과를 발췌했다. observed_trx_id는 실행마다 달라지는 내부 관찰값이다.
실행 결과(MySQL 8.0.x):
mysql> CREATE TABLE snapshot_account (...);
Query OK, 0 rows affected (0.00 sec)
mysql> INSERT INTO snapshot_account VALUES (1, 100);
Query OK, 1 row affected (0.00 sec)
mysql> CREATE EVENT snapshot_update_110 ...;
Query OK, 0 rows affected (0.00 sec)
mysql> CREATE EVENT snapshot_update_120 ...;
Query OK, 0 rows affected (0.00 sec)
mysql> CREATE EVENT snapshot_update_130 ...;
Query OK, 0 rows affected (0.00 sec)
mysql> START TRANSACTION WITH CONSISTENT SNAPSHOT;
Query OK, 0 rows affected (0.00 sec)
mysql> SELECT balance AS snapshot_before
-> FROM snapshot_account WHERE account_id = 1;
+-----------------+
| snapshot_before |
+-----------------+
| 100 |
+-----------------+
1 row in set (0.00 sec)
mysql> SELECT balance AS snapshot_after_three_commits
-> FROM snapshot_account WHERE account_id = 1;
+------------------------------+
| snapshot_after_three_commits |
+------------------------------+
| 100 |
+------------------------------+
1 row in set (0.00 sec)
mysql> SELECT trx_id AS observed_trx_id, trx_state,
-> trx_isolation_level, trx_rows_modified
-> FROM information_schema.INNODB_TRX
-> WHERE trx_mysql_thread_id = CONNECTION_ID();
+-----------------+-----------+---------------------+-------------------+
| observed_trx_id | trx_state | trx_isolation_level | trx_rows_modified |
+-----------------+-----------+---------------------+-------------------+
| 421328858541272 | RUNNING | REPEATABLE READ | 0 |
+-----------------+-----------+---------------------+-------------------+
1 row in set (0.00 sec)
mysql> SELECT balance AS current_value
-> FROM snapshot_account WHERE account_id = 1 FOR SHARE;
+---------------+
| current_value |
+---------------+
| 130 |
+---------------+
1 row in set (0.00 sec)
mysql> COMMIT;
Query OK, 0 rows affected (0.00 sec)
mysql> SELECT balance AS value_after_commit
-> FROM snapshot_account WHERE account_id = 1;
+--------------------+
| value_after_commit |
+--------------------+
| 130 |
+--------------------+
1 row in set (0.00 sec)
mysql> DROP TABLE snapshot_account;
Query OK, 0 rows affected (0.00 sec)
핵심 관찰 결과는 다음과 같아야 한다.
snapshot_before = 100: 첫 consistent read가 snapshot의 기준을 확정한다.snapshot_after_three_commits = 100: 최신 레코드가130이어도 Read View에서 보이지 않는 버전들을 undo chain으로 건너뛴다.observed_trx_id는trx_rows_modified = 0인 읽기 트랜잭션에서도 버전과 실행 경로에 따라0또는 내부 ID로 관찰될 수 있다. 이번 MySQL 8.0.46 검증에서는 큰 내부 ID가 표시됐으므로, 값의 존재 여부만으로 snapshot 보유 여부나 변경 여부를 판정하지 않는다.current_value = 130: locking read는 기존 snapshot이 아니라 최신 커밋 버전을 읽는다.COMMIT뒤 새 일반 조회는130을 반환한다.
이 재현은 행의 숨은 DB_TRX_ID나 undo record 내용을 SQL로 직접 덤프한 것이 아니다. 일반 SQL 인터페이스에서는 Read View의 활성 ID 배열과 각 행의 undo chain을 안정적인 공개 테이블로 노출하지 않는다. 검증 결과는 세 번의 외부 커밋 이후 snapshot read와 current read의 가시성 차이를 관찰한 것이며, 내부 chain 구조는 InnoDB의 MVCC 동작 원리로 해석한다.
5. Read View는 언제 만들어지고 얼마나 유지되는가
5.1 REPEATABLE READ
InnoDB 기본 격리 수준인 REPEATABLE READ에서는 보통 첫 consistent read가 만든 Read View를 트랜잭션 동안 재사용한다. 단순히 START TRANSACTION을 실행한 시각과 snapshot 기준 시각이 항상 같다고 생각하면 안 된다. 일반 START TRANSACTION 뒤 첫 일반 조회가 늦게 실행되면 Read View 생성도 그때까지 지연될 수 있다.
기준 시점을 트랜잭션 시작과 함께 명시해야 하는 읽기 업무에서는 START TRANSACTION WITH CONSISTENT SNAPSHOT을 사용할 수 있다. 그래도 트랜잭션을 오래 유지해도 된다는 뜻은 아니다. 조회가 끝난 뒤 애플리케이션 계산, 파일 전송, 사용자 입력, 외부 API 응답을 기다리면서 트랜잭션을 열어 두면 purge horizon이 불필요하게 오래 고정될 수 있다.
5.2 READ COMMITTED
READ COMMITTED의 일반 SELECT도 snapshot read이지만, Read View를 문장마다 새로 사용한다. 첫 문장과 두 번째 문장 사이에 커밋된 변경은 두 번째 문장에서 보일 수 있다. 한 문장 안에서는 일관된 snapshot을 사용하므로 스캔 도중 같은 행의 앞부분과 뒷부분이 임의로 섞이는 방식은 아니다.
문장 단위 Read View는 오래된 snapshot의 수명을 줄이는 데 도움이 될 수 있지만, 대량 DML이 생성한 undo 양이나 장시간 열린 쓰기 트랜잭션 자체를 없애지는 않는다. 격리 수준 변경은 업무 정합성, gap lock, 복제와 CDC, connection pool의 세션 상태까지 함께 검증해야 한다.
5.3 Consistent snapshot과 current read를 섞을 때
SELECT ... FOR SHARE, SELECT ... FOR UPDATE, UPDATE, DELETE는 최신 버전을 대상으로 잠금과 조건 판정을 수행하는 current read다. current read가 130을 봤다고 해서 기존 Read View가 130 시점으로 이동하지 않는다. 그 뒤의 일반 SELECT는 다시 기존 snapshot의 100을 볼 수 있다.
이 특성은 “트랜잭션 안의 모든 문장은 같은 DB 시점을 공유한다”는 애플리케이션 가정을 깨뜨린다. 재고·잔액처럼 최신 값을 기준으로 변경해야 하는 업무는 다음 중 하나로 설계한다.
- 조건과 계산을 하나의 원자적
UPDATE로 표현한다. SELECT ... FOR UPDATE로 최신 행을 잠그고 같은 트랜잭션에서 판단과 변경을 끝낸다.- version column을 조건에 포함한 optimistic locking으로 오래된 쓰기를 거부한다.
- 여러 행 제약은 명시적인 충돌 지점과 일관된 잠금 순서를 설계한다.
6. Purge가 undo를 바로 지우지 못하는 이유
Undo record는 rollback 가능성뿐 아니라 아직 살아 있는 Read View가 과거 버전을 요구할 가능성 때문에 즉시 제거할 수 없다. InnoDB purge는 활성 Read View가 더는 필요로 하지 않는 버전까지 안전 경계를 계산해 정리한다. 오래된 snapshot 하나가 남아 있으면 그 시점 이후에 많은 다른 트랜잭션이 만든 history가 정리되지 못할 수 있다.
sequenceDiagram
participant A as 긴 snapshot 세션 A
participant B as OLTP 세션들
participant U as Undo history
participant P as Purge
A->>A: Read View 생성
loop 짧은 변경 트랜잭션
B->>U: UPDATE/DELETE undo 생성
B->>B: COMMIT
end
P->>U: 정리 가능 버전 계산
U-->>P: A의 Read View가 과거 버전을 요구할 수 있음
P--xU: 일부 history 정리 보류
A->>A: COMMIT 또는 ROLLBACK
P->>U: 안전 경계 전진 후 정리
History list length는 undo record나 변경 행 수를 정확히 일대일로 세는 값이 아니다. rollback segment의 history list에 연결된 커밋 이력 규모를 나타내는 전역 방향성 지표로 해석해야 한다. 특정 트랜잭션의 비용을 이 값 하나로 정확히 배분하거나, 고정 임계값만으로 장애를 선언해서는 안 된다.
다음 쿼리는 대상 버전, 현재 격리 수준, 열린 트랜잭션, purge 관련 history metric을 확인하는 기본 진단이다. INNODB_TRX 결과는 조회 시점과 실행 방식에 따라 자기 진단 세션이 한 행으로 보이거나 업무 트랜잭션이 없으면 비어 있을 수 있다. History count도 백그라운드 purge 시점에 따라 달라진다.
SELECT VERSION() AS mysql_version;
SELECT @@session.transaction_isolation AS session_isolation,
@@session.autocommit AS autocommit;
SELECT trx_id,
trx_state,
trx_started,
trx_mysql_thread_id,
trx_isolation_level,
trx_rows_modified
FROM information_schema.INNODB_TRX
ORDER BY trx_started
LIMIT 20;
SELECT NAME,
SUBSYSTEM,
COUNT,
STATUS
FROM information_schema.INNODB_METRICS
WHERE NAME = 'trx_rseg_history_len';
SELECT requesting_engine_transaction_id,
blocking_engine_transaction_id,
requesting_thread_id,
blocking_thread_id
FROM performance_schema.data_lock_waits
LIMIT 20;
다음은 격리된 검증 인스턴스에서 얻은 한 번의 실행 결과다. trx_id, trx_started, thread ID, history count는 시점마다 달라지므로 고정 기준값으로 사용하지 않는다.
실행 결과(MySQL 8.0.x):
mysql> SELECT VERSION() AS mysql_version;
+---------------+
| mysql_version |
+---------------+
| 8.0.46 |
+---------------+
1 row in set (0.00 sec)
mysql> SELECT @@session.transaction_isolation AS session_isolation,
-> @@session.autocommit AS autocommit;
+-------------------+------------+
| session_isolation | autocommit |
+-------------------+------------+
| REPEATABLE-READ | 1 |
+-------------------+------------+
1 row in set (0.00 sec)
mysql> SELECT trx_id, trx_state, trx_started, trx_mysql_thread_id,
-> trx_isolation_level, trx_rows_modified
-> FROM information_schema.INNODB_TRX
-> ORDER BY trx_started LIMIT 20;
+-----------------+-----------+---------------------+---------------------+---------------------+-------------------+
| trx_id | trx_state | trx_started | trx_mysql_thread_id | trx_isolation_level | trx_rows_modified |
+-----------------+-----------+---------------------+---------------------+---------------------+-------------------+
| 421328858541272 | RUNNING | 2026-07-31 00:07:41 | 11 | REPEATABLE READ | 0 |
+-----------------+-----------+---------------------+---------------------+---------------------+-------------------+
1 row in set (0.00 sec)
mysql> SELECT NAME, SUBSYSTEM, COUNT, STATUS
-> FROM information_schema.INNODB_METRICS
-> WHERE NAME = 'trx_rseg_history_len';
+----------------------+-------------+-------+---------+
| NAME | SUBSYSTEM | COUNT | STATUS |
+----------------------+-------------+-------+---------+
| trx_rseg_history_len | transaction | 19 | enabled |
+----------------------+-------------+-------+---------+
1 row in set (0.00 sec)
mysql> SELECT requesting_engine_transaction_id,
-> blocking_engine_transaction_id,
-> requesting_thread_id, blocking_thread_id
-> FROM performance_schema.data_lock_waits LIMIT 20;
Empty set (0.00 sec)
운영에서는 한 번의 숫자보다 추세와 원인을 연결한다.
- History list가 지속해서 증가하는가, 부하가 줄었을 때 다시 감소하는가?
INNODB_TRX에서 오래 열린 트랜잭션이 있는가?- 그 세션이 실제 잠금 blocker인가, 잠금 없이 오래된 snapshot만 유지하는가?
- 대량
UPDATE나DELETE, 배치, logical dump가 같은 시간대에 실행됐는가? - 트랜잭션을 종료하면 purge 처리량이 회복되고 history가 감소하는가?
SHOW ENGINE INNODB STATUS의 History list length도 현장에서 널리 사용되지만 출력 전체를 자동 수집할 때는 섹션 파싱과 버전 차이를 고려해야 한다. 정형 쿼리에는 INNODB_METRICS를 우선 사용하고, InnoDB status는 semaphore, transaction, purge 상태를 함께 읽는 보조 자료로 활용하는 편이 좋다.
7. 성능 비용: Snapshot read도 공짜가 아니다
일반 snapshot read가 레코드 잠금을 거의 잡지 않는다는 사실과 비용이 없다는 말은 다르다. 다음 조건이 겹치면 읽기 지연과 스토리지 부담이 커질 수 있다.
7.1 긴 undo chain 탐색
오래된 snapshot이 최근에 자주 갱신된 행을 읽으면 최신 레코드에서 여러 undo record를 따라가야 한다. 좁은 primary key 조회는 영향이 작을 수 있지만, 변경이 집중된 테이블을 넓게 스캔하는 보고서에서는 버전 재구성 CPU와 페이지 접근이 누적된다.
7.2 Purge 지연과 공간 사용
필요한 undo가 오래 유지되면 undo tablespace의 사용량과 I/O가 늘 수 있다. 공간이 내부적으로 재사용되더라도 파일 크기가 즉시 줄어드는 것은 아니며, purge가 따라잡는 동안 buffer pool과 스토리지 대역폭을 소비한다. innodb_max_purge_lag 계열 설정은 쓰기 지연을 유도할 수 있으므로 원인을 해결하지 않은 채 임계값만 조정해서는 안 된다.
7.3 Secondary index와 clustered record 접근
조회가 covering index처럼 보여도 MVCC 확인에 필요한 정보와 보이지 않는 변경 상태에 따라 clustered record 접근이 필요할 수 있다. 실행 계획의 Using index만으로 실제 버전 탐색 비용이 항상 사라진다고 단정하지 않는다. 버퍼 적중률, 읽은 행 수, 실제 실행 시간, 변경률을 함께 측정한다.
7.4 긴 트랜잭션 종료의 후폭풍
오래된 snapshot 세션을 종료하면 purge가 즉시 모든 밀린 작업을 무비용으로 끝내는 것은 아니다. 안전 경계가 전진한 뒤 밀린 history를 처리하면서 I/O와 CPU가 증가할 수 있다. 대량 변경 트랜잭션을 강제 종료하면 긴 rollback이 추가될 수도 있다. trx_rows_modified = 0인 읽기 트랜잭션과 대량 변경 트랜잭션의 종료 위험을 구분해야 한다.
8. 운영 진단 절차
오래된 값, undo 증가, 조회 지연이 발생했을 때 다음 순서로 범위를 좁힌다.
8.1 오래된 값이 보일 때
- 접속 대상이 writer인지 replica/reader인지 확인한다.
- 세션의
@@session.transaction_isolation과 autocommit을 확인한다. - 트랜잭션 시작 시각과 첫 consistent read 시점을 애플리케이션 trace에서 구분한다.
- 일반
SELECT인지FOR SHARE/FOR UPDATE인지 확인한다. - connection pool이 이전 요청의 열린 트랜잭션이나 세션 설정을 재사용하지 않았는지 확인한다.
- writer의 snapshot 문제와 replica 가시성 지연을 별도 가설로 검증한다.
8.2 History list가 증가할 때
INNODB_TRX를trx_started순으로 확인한다.- 오래된 세션의 계정, 호스트, 현재·최근 SQL, 애플리케이션 요청 ID를 연결한다.
trx_rows_modified와data_lock_waits로 읽기 snapshot인지 쓰기 blocker인지 구분한다.- logical backup, ETL, cursor 기반 대량 조회, 유휴 connection의 미종료 트랜잭션을 점검한다.
- 세션 종료 전 업무 영향과 rollback 비용을 평가한다.
- 종료 후 purge 처리와 history 감소 추세를 관찰한다.
8.3 Snapshot 문제와 lock 문제를 분리한다
오래된 snapshot은 다른 세션의 일반 DML을 직접 record lock으로 막지 않으면서 purge를 지연시킬 수 있다. 반면 current read blocker는 data_lock_waits에 요청자와 차단자 관계가 나타난다. 두 문제를 모두 “긴 트랜잭션”이라고만 부르면 대응 우선순위를 잘못 정할 수 있다.
- 오래된 snapshot: 과거 값, history 증가, undo 보존, 긴 consistent read
- 잠금 blocker: waiting transaction, timeout, deadlock, 특정 record/gap lock
- metadata lock blocker: DDL 대기, 열린 트랜잭션이 참조한 테이블의 MDL 유지
실제 세션 종료는 SQL 문장 하나가 아니라 운영 변경이다. 요청 주체와 재시도 가능성, rollback 예상량, failover 중인지 여부를 확인한 뒤 승인된 runbook으로 수행한다.
9. Aurora MySQL에서의 해석
Aurora MySQL도 MySQL 호환 트랜잭션 계층에서 Read View와 undo 기반 MVCC 의미를 유지한다. 다만 분산 스토리지와 reader endpoint 때문에 “오래된 값”의 원인을 한 층 더 분리해야 한다.
- writer endpoint의 동일 세션·동일 트랜잭션에서 과거 값이 반복되면 먼저 격리 수준과 Read View 수명을 확인한다.
- reader endpoint에서 오래된 값이 보이면 세션 snapshot과 replica의 가시성 지연을 각각 검증한다.
- writer에서 쓴 뒤 reader endpoint로 이동하는 read-after-write 흐름은 하나의 InnoDB snapshot만으로 최신 읽기를 보장하지 않는다.
- Aurora Replica의 장시간 보고서 트랜잭션도 실패, 재시작, 쿼리 취소, failover 시 애플리케이션이 처음부터 재실행할 수 있어야 한다.
- Performance Insights나 Database Insights에서 긴 SQL을 찾더라도 DB 내부 트랜잭션 수명과 정확히 같다고 가정하지 말고
INNODB_TRX, 세션 속성, 애플리케이션 trace를 대조한다. - 파라미터 그룹 변경으로 격리 수준이나 purge 관련 설정을 조정하기 전에 cluster/instance 범위, 재부팅 필요 여부, writer와 reader의 적용 상태를 확인한다.
Aurora의 스토리지 계층이 다르다는 이유로 오래된 Read View의 논리적 비용이 사라지는 것은 아니다. 반대로 Community MySQL의 로컬 파일 크기 해석을 Aurora 스토리지 비용과 일대일로 대응시키는 것도 적절하지 않다. 공통으로 적용할 원칙은 트랜잭션을 짧게 유지하고, endpoint와 격리 수준을 기록하며, engine metric의 추세를 실제 workload와 함께 해석하는 것이다.
10. 흔한 오해와 실패 패턴
10.1 “Snapshot은 트랜잭션 시작 순간에 항상 만들어진다”
일반 START TRANSACTION에서는 첫 consistent read까지 Read View 생성이 지연될 수 있다. 시작 시점 고정이 목적이면 적합한 격리 수준에서 WITH CONSISTENT SNAPSHOT을 검토한다.
10.2 “작은 trx_id의 버전은 모두 보인다”
두 경계 사이의 트랜잭션은 Read View 생성 당시 활성 목록에 있었는지 확인해야 한다. 일찍 시작했지만 늦게 커밋한 트랜잭션의 변경은 snapshot에서 보이지 않을 수 있다.
10.3 “읽기 전용 트랜잭션의 trx_id가 0이면 purge에 영향이 없다”
INNODB_TRX.TRX_ID = 0은 read-write ID를 할당하지 않았다는 뜻일 수 있다. 오래된 Read View가 유효하면 행을 변경하지 않아도 과거 버전 보존에 영향을 줄 수 있다.
10.4 “Undo는 rollback에만 사용된다”
Undo는 snapshot read가 보이는 이전 행 버전을 재구성하는 핵심 자료다. rollback이 발생하지 않아도 MVCC를 위해 읽힌다.
10.5 “History list length는 undo 행 개수다”
전역 history list의 방향성 지표이지 특정 테이블의 이전 행 버전 개수나 bytes가 아니다. 변화율, workload, 오래된 트랜잭션을 함께 봐야 한다.
10.6 “일반 SELECT와 FOR UPDATE는 같은 snapshot을 읽는다”
일반 SELECT는 consistent read이고 FOR UPDATE는 current read다. 같은 트랜잭션 안에서도 다른 커밋 시점의 값을 볼 수 있다.
10.7 “오래된 값은 항상 replica lag다”
Writer에서도 REPEATABLE READ의 오래된 Read View 때문에 과거 값이 보일 수 있다. 접속 endpoint와 트랜잭션 상태를 먼저 확인한다.
10.8 “긴 snapshot 세션을 종료하면 문제가 즉시 끝난다”
Purge 안전 경계는 전진하지만 밀린 history 정리에는 시간이 필요하다. 대량 변경 트랜잭션이면 rollback 비용까지 발생한다. 종료 뒤의 I/O와 CPU 추세도 관찰해야 한다.
11. 설계 및 운영 점검표
트랜잭션 설계
-
REPEATABLE READ의 첫 consistent read 시점과WITH CONSISTENT SNAPSHOT - 일반
SELECT - Connection pool 반환 시
COMMIT또는ROLLBACK
성능과 용량
장애 대응
-
INNODB_TRX,data_lock_waits -
trx_id = 0
12. 정리
InnoDB snapshot read의 핵심은 데이터 복사본이 아니라 Read View의 가시성 규칙과 undo chain의 버전 재구성이다. Read View는 트랜잭션 ID 경계와 생성 당시 활성 read-write 트랜잭션 집합을 이용해 현재 레코드가 보이는지 판정한다. 보이지 않으면 clustered index 레코드의 DB_ROLL_PTR를 따라 이전 버전을 만들고, 보이는 버전을 찾을 때까지 판정을 반복한다.
이 구조는 일반 조회와 쓰기의 충돌을 줄이지만 운영 비용을 없애지는 않는다. 오래된 snapshot은 purge가 필요한 undo를 보존하게 만들고, 변경이 잦은 행에서는 긴 undo chain 탐색 비용을 증가시킬 수 있다. INNODB_TRX.TRX_ID = 0인 읽기 전용 트랜잭션도 오래된 Read View를 유지할 수 있으며, current read는 기존 snapshot과 다른 최신 값을 볼 수 있다.
운영자는 trx_id라는 이름 하나에 의존하지 말고 레코드 버전의 생성자, Read View의 경계, 실행 중 트랜잭션, purge horizon을 구분해야 한다. 다음 단계에서는 purge가 rollback segment의 history를 어떤 안전 경계로 정리하는지와 장시간 트랜잭션이 undo tablespace에 미치는 영향을 더 구체적으로 연결할 수 있다.