Long transaction 관리: undo 증가, purge 지연, replication lag 영향
MySQL 장기 트랜잭션이 undo history와 purge, 잠금, 복제 지연에 미치는 영향과 진단·통제 절차를 정리한다.
1. 긴 트랜잭션은 느린 SQL 한 문장과 다르다
Long transaction은 단순히 실행 시간이 긴 SQL을 뜻하지 않는다. InnoDB에서 중요한 기준은 트랜잭션이 시작된 뒤 COMMIT 또는 ROLLBACK으로 끝나지 않은 시간이다. SQL 한 문장이 빠르게 끝났더라도 애플리케이션이 트랜잭션을 연 채 외부 API, 사용자 입력, 파일 처리, 메시지 전송을 기다리면 그 세션은 long transaction이 된다. 반대로 대용량 SELECT가 오래 실행되더라도 autocommit 경계가 명확하고 일관된 읽기 범위가 통제되어 있다면 위험의 성격은 다르다.
긴 트랜잭션이 운영 장애로 이어지는 대표 경로는 네 가지다.
- 변경 트랜잭션이 record lock과 metadata lock을 오래 보유하여 다른 세션을 대기시킨다.
- 오래된 Read View가 purge의 저수위 경계보다 앞에 남아 undo history 정리를 지연시킨다.
- undo tablespace와 buffer pool, 디스크 I/O에 누적 압력을 만든다.
- 큰 트랜잭션이 source에서 한꺼번에 기록·전송·적용되어 replica applier를 오래 점유하고 replication lag을 키운다.
따라서 “몇 초 이상이면 long transaction인가”라는 단일 숫자만으로 운영해서는 안 된다. 온라인 트랜잭션 처리에서는 수 초도 비정상일 수 있고, 배치나 논리 백업에서는 수십 분이 의도된 동작일 수 있다. 핵심은 업무별 허용 시간, 변경 행 수, 잠금 범위, undo history 변화, replica 적용 영향을 함께 관리하는 것이다.
2. InnoDB 내부에서 어떤 일이 이어지는가
2.1 변경 전 버전과 undo log
InnoDB가 행을 변경하면 기존 값을 즉시 버리는 대신 undo record를 남긴다. 이 정보는 트랜잭션 롤백과 MVCC consistent read에 사용된다. 다른 트랜잭션이 과거 시점의 Read View로 행을 읽어야 한다면 InnoDB는 현재 레코드에서 undo chain을 따라가 그 시점에 보였어야 할 버전을 재구성한다.
COMMIT은 변경 내용을 논리적으로 확정하지만, 관련 undo record를 즉시 모두 제거한다는 뜻이 아니다. 아직 오래된 버전을 볼 수 있는 Read View가 있으면 purge thread는 그 경계보다 최신인 history를 정리할 수 없다. 장기 읽기 트랜잭션도 직접 행을 변경하지 않으면서 purge를 붙잡을 수 있는 이유다.
flowchart LR
A[트랜잭션 A가 오래된 Read View 유지] --> B[트랜잭션 B·C·D가 행 변경 후 COMMIT]
B --> C[undo history 증가]
C --> D{A의 Read View에서 필요할 수 있는가}
D -- 예 --> E[purge 보류]
E --> F[undo chain 탐색 증가]
E --> G[undo tablespace·I/O 압력]
D -- 아니요 --> H[purge thread가 history 정리]
A --> I[COMMIT 또는 ROLLBACK]
I --> H
History list length는 아직 purge되지 않은 undo history의 양을 보는 대표 신호다. 그러나 이 값은 바이트 수가 아니며, 서버 크기가 다른 환경에서 절대값만 비교하기도 어렵다. 순간값보다 증가 기울기, 장기 트랜잭션 유무, 쓰기 처리량, purge 처리량을 함께 보아야 한다.
2.2 purge 경계와 지연의 증폭
purge는 더 이상 어떤 활성 Read View에서도 필요하지 않은 delete-marked record와 undo history를 정리한다. 오래된 Read View 하나가 남으면 그 이후 커밋된 다수 트랜잭션의 이전 버전이 계속 보존될 수 있다. 이때 쓰기 부하가 유지되면 다음과 같은 증폭이 나타난다.
- undo history가 쓰기 속도만큼 빠르게 늘어난다.
- consistent read가 긴 undo chain을 탐색하여 CPU와 페이지 읽기를 더 사용한다.
- delete-marked record 정리가 늦어져 인덱스 페이지의 논리적 찌꺼기가 오래 남는다.
- purge가 다시 진행될 때 정리 I/O가 집중되어 정상 쿼리와 자원을 경쟁한다.
- 서버 재시작이나 장애 복구 뒤 처리해야 할 미정리 작업이 커질 수 있다.
innodb_purge_threads를 늘리면 purge의 병렬 처리 능력이 좋아질 수 있지만, 가장 오래된 Read View가 경계를 막고 있다면 thread 수만 늘려서는 해결되지 않는다. 먼저 경계를 붙잡은 트랜잭션을 찾아 종료 여부를 판단해야 한다.
2.3 변경 트랜잭션은 잠금과 롤백 비용도 키운다
대량 변경을 하나의 트랜잭션으로 묶으면 트랜잭션이 끝날 때까지 행 잠금이 유지된다. 장애 대응 중 KILL 또는 애플리케이션 취소를 실행해도 즉시 자원이 회수되는 것은 아니다. InnoDB는 이미 수행한 변경을 undo해야 하므로 롤백 시간이 실행 시간만큼 길거나 더 길 수 있다. 운영자가 ROLLING BACK 상태를 다시 강제 종료하려고 반복해서 서버를 재시작하면 복구 시간을 더 예측하기 어렵게 만든다.
대량 작업은 원자성 요구를 먼저 확인한 뒤, 가능하면 기본 키 범위를 기준으로 작은 트랜잭션으로 나누어야 한다. 각 chunk는 재실행 가능하고 완료 지점을 기록해야 하며, chunk 사이에는 replica lag과 잠금 대기를 확인할 여지를 둔다.
3. purge 관련 설정과 현재 신호 확인
다음 쿼리는 MySQL 8.0 검증 인스턴스에서 purge thread 수, purge lag 제어 변수, rollback segment history length metric의 존재와 상태를 확인한다. innodb_max_purge_lag는 history가 일정 수준을 넘을 때 DML에 지연을 가하는 제어 변수이며, 기본값이 0이면 이 제한을 사용하지 않는다는 뜻이다. 값을 바꾸기 전에 업무 지연에 미칠 영향을 검토해야 한다.
SELECT VERSION() AS mysql_version,
@@GLOBAL.innodb_purge_threads AS purge_threads,
@@GLOBAL.innodb_max_purge_lag AS max_purge_lag,
@@GLOBAL.innodb_max_purge_lag_delay AS max_purge_lag_delay_us;
SELECT NAME,
COUNT AS metric_value,
STATUS
FROM information_schema.INNODB_METRICS
WHERE NAME = 'trx_rseg_history_len';
실행 결과(MySQL 8.0.x):
mysql> SELECT VERSION() AS mysql_version,
-> @@GLOBAL.innodb_purge_threads AS purge_threads,
-> @@GLOBAL.innodb_max_purge_lag AS max_purge_lag,
-> @@GLOBAL.innodb_max_purge_lag_delay AS max_purge_lag_delay_us;
+---------------+---------------+---------------+------------------------+
| mysql_version | purge_threads | max_purge_lag | max_purge_lag_delay_us |
+---------------+---------------+---------------+------------------------+
| 8.0.46 | 4 | 0 | 0 |
+---------------+---------------+---------------+------------------------+
1 row in set (0.00 sec)
mysql> SELECT NAME,
-> COUNT AS metric_value,
-> STATUS
-> FROM information_schema.INNODB_METRICS
-> WHERE NAME = 'trx_rseg_history_len';
+----------------------+--------------+---------+
| NAME | metric_value | STATUS |
+----------------------+--------------+---------+
| trx_rseg_history_len | 5 | enabled |
+----------------------+--------------+---------+
1 row in set (0.00 sec)
trx_rseg_history_len의 COUNT는 조회 시점의 관측값이다. 운영 대시보드에서는 1분 또는 그보다 짧은 간격으로 수집하되, 값 하나에 고정 임계치를 적용하기보다 다음 조건을 조합하는 편이 안전하다.
- history length가 여러 수집 구간에 걸쳐 계속 증가하는가
- 쓰기 TPS가 감소했는데도 history가 줄지 않는가
- 오래된
INNODB_TRX행이 함께 존재하는가 - purge 관련 I/O와 CPU가 포화되어 있는가
- replica lag 또는 Aurora reader lag이 같은 시점에 증가하는가
4. 현재 활성 long transaction 찾기
4.1 INNODB_TRX와 Performance Schema를 함께 본다
information_schema.INNODB_TRX는 현재 InnoDB 트랜잭션의 시작 시각, 상태, 잠금 대기 여부, 수정 행 수를 제공한다. performance_schema.threads를 연결하면 MySQL connection ID, 사용자, 현재 명령 상태를 찾을 수 있다. trx_query가 NULL이더라도 트랜잭션이 끝났다는 뜻은 아니다. SQL 실행을 마친 뒤 애플리케이션에서 쉬고 있는 Sleep 세션이 트랜잭션과 잠금을 계속 보유할 수 있다.
다음 재현은 Event Scheduler를 별도 서버 세션으로 사용해 세 행을 변경한 뒤 10초 동안 COMMIT하지 않는다. 운영 서버에서 이 예제를 실행하기 위해 Event Scheduler를 활성화해서는 안 된다. 전용 검증 인스턴스에서만 사용하는 축소 재현이다.
SET GLOBAL event_scheduler = ON;
DROP EVENT IF EXISTS ev_long_transaction;
DROP TABLE IF EXISTS long_trx_demo;
CREATE TABLE long_trx_demo (
id BIGINT PRIMARY KEY,
payload VARCHAR(30) NOT NULL
) ENGINE=InnoDB;
INSERT INTO long_trx_demo (id, payload)
VALUES (1, 'before'), (2, 'before'), (3, 'before');
DELIMITER //
CREATE EVENT ev_long_transaction
ON SCHEDULE AT CURRENT_TIMESTAMP + INTERVAL 2 SECOND
DO
BEGIN
START TRANSACTION;
UPDATE long_trx_demo SET payload = 'uncommitted';
DO SLEEP(10);
COMMIT;
END//
DELIMITER ;
DO SLEEP(4);
SELECT trx.TRX_STATE,
TIMESTAMPDIFF(SECOND, trx.TRX_STARTED, NOW()) AS age_seconds,
trx.TRX_ROWS_MODIFIED,
trx.TRX_TABLES_LOCKED,
th.PROCESSLIST_COMMAND,
th.PROCESSLIST_STATE
FROM information_schema.INNODB_TRX AS trx
JOIN performance_schema.threads AS th
ON th.PROCESSLIST_ID = trx.TRX_MYSQL_THREAD_ID
WHERE trx.TRX_ROWS_MODIFIED = 3;
SELECT OBJECT_NAME,
INDEX_NAME,
LOCK_TYPE,
LOCK_MODE,
LOCK_STATUS,
COUNT(*) AS lock_count
FROM performance_schema.data_locks
WHERE OBJECT_SCHEMA = DATABASE()
AND OBJECT_NAME = 'long_trx_demo'
GROUP BY OBJECT_NAME, INDEX_NAME, LOCK_TYPE, LOCK_MODE, LOCK_STATUS
ORDER BY LOCK_TYPE, INDEX_NAME;
DO SLEEP(9);
SELECT id, payload FROM long_trx_demo ORDER BY id;
DROP EVENT IF EXISTS ev_long_transaction;
DROP TABLE long_trx_demo;
실행 결과(MySQL 8.0.x):
이벤트 생성과 대기용 SLEEP, 정리 문장의 반복 출력은 줄이고, 활성 트랜잭션과 보유 잠금, COMMIT 후 값을 보여 주는 핵심 결과만 발췌했다.
mysql> CREATE TABLE long_trx_demo (...);
Query OK, 0 rows affected (0.00 sec)
mysql> INSERT INTO long_trx_demo (id, payload)
-> VALUES (1, 'before'), (2, 'before'), (3, 'before');
Query OK, 3 rows affected (0.00 sec)
Records: 3 Duplicates: 0 Warnings: 0
mysql> SELECT trx.TRX_STATE,
-> TIMESTAMPDIFF(SECOND, trx.TRX_STARTED, NOW()) AS age_seconds,
-> trx.TRX_ROWS_MODIFIED,
-> trx.TRX_TABLES_LOCKED,
-> th.PROCESSLIST_COMMAND,
-> th.PROCESSLIST_STATE
-> FROM information_schema.INNODB_TRX AS trx
-> ...
-> WHERE trx.TRX_ROWS_MODIFIED = 3;
+-----------+-------------+-------------------+-------------------+---------------------+-------------------+
| TRX_STATE | age_seconds | TRX_ROWS_MODIFIED | TRX_TABLES_LOCKED | PROCESSLIST_COMMAND | PROCESSLIST_STATE |
+-----------+-------------+-------------------+-------------------+---------------------+-------------------+
| RUNNING | 2 | 3 | 1 | Connect | User sleep |
+-----------+-------------+-------------------+-------------------+---------------------+-------------------+
1 row in set (0.01 sec)
mysql> SELECT OBJECT_NAME, INDEX_NAME, LOCK_TYPE, LOCK_MODE,
-> LOCK_STATUS, COUNT(*) AS lock_count
-> FROM performance_schema.data_locks
-> ...;
+---------------+------------+-----------+-----------+-------------+------------+
| OBJECT_NAME | INDEX_NAME | LOCK_TYPE | LOCK_MODE | LOCK_STATUS | lock_count |
+---------------+------------+-----------+-----------+-------------+------------+
| long_trx_demo | PRIMARY | RECORD | X | GRANTED | 4 |
| long_trx_demo | NULL | TABLE | IX | GRANTED | 1 |
+---------------+------------+-----------+-----------+-------------+------------+
2 rows in set (0.00 sec)
mysql> SELECT id, payload FROM long_trx_demo ORDER BY id;
+----+-------------+
| id | payload |
+----+-------------+
| 1 | uncommitted |
| 2 | uncommitted |
| 3 | uncommitted |
+----+-------------+
3 rows in set (0.00 sec)
이 예제에서 주목할 점은 현재 문장이 DO SLEEP(10)이어도 TRX_ROWS_MODIFIED=3인 활성 트랜잭션과 잠금이 남는다는 사실이다. 진단 시 PROCESSLIST_INFO나 현재 SQL만 보고 “아무 일도 하지 않는 세션”으로 분류해서는 안 된다.
4.2 운영 진단 쿼리
실제 서버에서는 가장 오래된 트랜잭션부터 좁혀 본다. 다음 형태로 조회하되 사용자명, host, SQL text는 민감 정보가 될 수 있으므로 외부 로그나 티켓에 원문을 그대로 복사하지 않는다.
SELECT trx.TRX_MYSQL_THREAD_ID AS connection_id,
trx.TRX_STATE AS trx_state,
TIMESTAMPDIFF(SECOND, trx.TRX_STARTED, NOW()) AS age_seconds,
trx.TRX_ROWS_MODIFIED AS rows_modified,
trx.TRX_ROWS_LOCKED AS rows_locked,
trx.TRX_TABLES_LOCKED AS tables_locked,
th.PROCESSLIST_COMMAND AS command,
th.PROCESSLIST_STATE AS process_state,
LEFT(th.PROCESSLIST_INFO, 160) AS current_sql
FROM information_schema.INNODB_TRX AS trx
LEFT JOIN performance_schema.threads AS th
ON th.PROCESSLIST_ID = trx.TRX_MYSQL_THREAD_ID
ORDER BY trx.TRX_STARTED
LIMIT 20;
실행 결과(MySQL 8.0.x):
mysql> SELECT trx.TRX_MYSQL_THREAD_ID AS connection_id,
-> trx.TRX_STATE AS trx_state,
-> TIMESTAMPDIFF(SECOND, trx.TRX_STARTED, NOW()) AS age_seconds,
-> trx.TRX_ROWS_MODIFIED AS rows_modified,
-> trx.TRX_ROWS_LOCKED AS rows_locked,
-> trx.TRX_TABLES_LOCKED AS tables_locked,
-> th.PROCESSLIST_COMMAND AS command,
-> th.PROCESSLIST_STATE AS process_state,
-> LEFT(th.PROCESSLIST_INFO, 160) AS current_sql
-> FROM information_schema.INNODB_TRX AS trx
-> LEFT JOIN performance_schema.threads AS th
-> ON th.PROCESSLIST_ID = trx.TRX_MYSQL_THREAD_ID
-> ORDER BY trx.TRX_STARTED
-> LIMIT 20;
Empty set (0.00 sec)
조회 결과는 다음 순서로 해석한다.
age_seconds가 업무별 정상 범위를 넘었는지 확인한다.rows_modified와rows_locked가 계속 증가하는지 시계열로 비교한다.command='Sleep'이면 애플리케이션이 트랜잭션 경계를 잃었는지 조사한다.TRX_WAIT_STARTED또는data_lock_waits가 있으면 blocker와 waiter를 구분한다.- 읽기 전용 트랜잭션도 오래된 Read View를 유지할 수 있으므로
rows_modified=0만으로 무해하다고 판단하지 않는다.
INNODB_TRX는 실행 시점에 따라 빈 결과가 정상이다. 또한 내부 TRX_ID의 존재나 크기만으로 snapshot 보유 여부를 단정하지 않는다. 시작 시각, 문장 종류, isolation level, 실제 Read View 수명, history length 추세를 함께 보아야 한다.
5. 오래된 Read View가 history 정리를 막는 축소 재현
다음 예제는 REPEATABLE READ 트랜잭션이 첫 consistent read로 Read View를 만든 뒤 잠시 유지하는 동안, 다른 세션 역할을 하는 주 연결이 같은 행을 여러 번 커밋한다. 장기 읽기 트랜잭션은 행을 수정하지 않지만 이전 버전이 필요할 수 있으므로 history가 즉시 제거되지 않는다.
trx_rseg_history_len은 백그라운드 purge 시점과 서버 부하에 따라 값이 달라진다. 이 재현은 값의 절대 크기를 보장하기 위한 것이 아니라, 오래된 Read View와 history 보존의 관계를 관찰하기 위한 전용 테스트다.
SET GLOBAL event_scheduler = ON;
DROP EVENT IF EXISTS ev_old_read_view;
DROP PROCEDURE IF EXISTS generate_versions;
DROP TABLE IF EXISTS long_read_demo;
CREATE TABLE long_read_demo (
id BIGINT PRIMARY KEY,
version_no INT NOT NULL,
payload VARCHAR(200) NOT NULL
) ENGINE=InnoDB;
INSERT INTO long_read_demo
VALUES (1, 0, RPAD('a', 200, 'a'));
DELIMITER //
CREATE EVENT ev_old_read_view
ON SCHEDULE AT CURRENT_TIMESTAMP + INTERVAL 2 SECOND
DO
BEGIN
SET SESSION TRANSACTION ISOLATION LEVEL REPEATABLE READ;
START TRANSACTION WITH CONSISTENT SNAPSHOT;
SELECT COUNT(*) INTO @row_count FROM long_read_demo;
DO SLEEP(12);
COMMIT;
END//
CREATE PROCEDURE generate_versions()
BEGIN
DECLARE i INT DEFAULT 1;
WHILE i <= 30 DO
UPDATE long_read_demo
SET version_no = i,
payload = RPAD(CHAR(97 + MOD(i, 20)), 200, CHAR(97 + MOD(i, 20)))
WHERE id = 1;
SET i = i + 1;
END WHILE;
END//
DELIMITER ;
DO SLEEP(4);
CALL generate_versions();
SELECT trx.TRX_STATE,
trx.TRX_ROWS_MODIFIED,
TIMESTAMPDIFF(SECOND, trx.TRX_STARTED, NOW()) AS age_seconds
FROM information_schema.INNODB_TRX AS trx
WHERE trx.TRX_ROWS_MODIFIED = 0
ORDER BY trx.TRX_STARTED
LIMIT 5;
SELECT NAME, COUNT AS history_during_old_view, STATUS
FROM information_schema.INNODB_METRICS
WHERE NAME = 'trx_rseg_history_len';
DO SLEEP(11);
SELECT NAME, COUNT AS history_after_reader_commit, STATUS
FROM information_schema.INNODB_METRICS
WHERE NAME = 'trx_rseg_history_len';
SELECT id, version_no, CHAR_LENGTH(payload) AS payload_length
FROM long_read_demo;
DROP EVENT IF EXISTS ev_old_read_view;
DROP PROCEDURE IF EXISTS generate_versions;
DROP TABLE long_read_demo;
실행 결과(MySQL 8.0.x):
준비·정리 문장의 출력은 줄이고, 읽기 전용 트랜잭션의 존재와 두 시점의 history metric, 최종 행 상태를 발췌했다. 다음 수치는 한 번의 검증 실행값이며 고정 임계치가 아니다.
mysql> SELECT trx.TRX_STATE,
-> trx.TRX_ROWS_MODIFIED,
-> TIMESTAMPDIFF(SECOND, trx.TRX_STARTED, NOW()) AS age_seconds
-> FROM information_schema.INNODB_TRX AS trx
-> WHERE trx.TRX_ROWS_MODIFIED = 0
-> ORDER BY trx.TRX_STARTED
-> LIMIT 5;
+-----------+-------------------+-------------+
| TRX_STATE | TRX_ROWS_MODIFIED | age_seconds |
+-----------+-------------------+-------------+
| RUNNING | 0 | 2 |
+-----------+-------------------+-------------+
1 row in set (0.00 sec)
mysql> SELECT NAME, COUNT AS history_during_old_view, STATUS
-> FROM information_schema.INNODB_METRICS
-> WHERE NAME = 'trx_rseg_history_len';
+----------------------+-------------------------+---------+
| NAME | history_during_old_view | STATUS |
+----------------------+-------------------------+---------+
| trx_rseg_history_len | 47 | enabled |
+----------------------+-------------------------+---------+
1 row in set (0.00 sec)
mysql> SELECT NAME, COUNT AS history_after_reader_commit, STATUS
-> FROM information_schema.INNODB_METRICS
-> WHERE NAME = 'trx_rseg_history_len';
+----------------------+-----------------------------+---------+
| NAME | history_after_reader_commit | STATUS |
+----------------------+-----------------------------+---------+
| trx_rseg_history_len | 48 | enabled |
+----------------------+-----------------------------+---------+
1 row in set (0.00 sec)
mysql> SELECT id, version_no, CHAR_LENGTH(payload) AS payload_length
-> FROM long_read_demo;
+----+------------+----------------+
| id | version_no | payload_length |
+----+------------+----------------+
| 1 | 30 | 200 |
+----+------------+----------------+
1 row in set (0.00 sec)
이 검증 실행에서는 reader 종료 후 11초가 지난 두 번째 값이 47에서 48로 소폭 증가하여 즉시 감소하지 않았다. 트랜잭션 종료는 purge가 진행될 수 있는 경계를 열 뿐, metric의 즉시 하락을 보장하지 않는다는 점도 함께 보여 준다. 중요한 운영 신호는 실제 purge 처리량이 신규 undo 생성량을 따라잡는지다. 읽기 트랜잭션 종료 뒤에도 값이 계속 증가하면 쓰기량, purge thread 상태, I/O 포화, 추가 장기 트랜잭션을 조사한다.
6. replication lag이 커지는 경로
6.1 source에서 COMMIT하기 전과 후를 나누어 본다
큰 트랜잭션은 실행 중과 COMMIT 후에 서로 다른 방식으로 복제에 영향을 준다.
COMMIT 전에는 source의 변경이 아직 하나의 확정된 트랜잭션으로 replica에 적용될 수 없다. source는 undo와 잠금을 누적하고, binary log 관련 메모리·임시 파일과 commit 처리 부담도 키울 수 있다. 트랜잭션이 갑자기 COMMIT하면 큰 이벤트 묶음이 binary log와 replica로 넘어간다.
COMMIT 후에는 replica applier가 그 트랜잭션을 적용해야 한다. 병렬 복제가 활성화되어도 하나의 트랜잭션 자체를 임의의 작은 원자 단위로 쪼개어 독립 커밋할 수는 없다. 큰 트랜잭션 하나가 worker를 오래 점유하고, 의존 관계가 있는 후속 트랜잭션이 기다리며, 적용 완료 시점까지 replica의 가시성이 크게 뒤처질 수 있다.
sequenceDiagram
participant App as 애플리케이션
participant Source as Source
participant Binlog as Binary Log
participant Replica as Replica Applier
App->>Source: 대량 UPDATE를 한 트랜잭션으로 실행
Note over Source: undo·잠금 누적
App->>Source: COMMIT
Source->>Binlog: 큰 트랜잭션 기록·flush
Binlog->>Replica: relay log로 전송
Note over Replica: 한 worker가 긴 적용 단위 처리
Replica-->>Replica: 후속 의존 트랜잭션 대기
Replica-->>App: lag 증가 또는 stale read 기간 확대
6.2 무엇을 관찰할 것인가
MySQL 비동기 복제에서는 다음 상태를 같은 시각축으로 수집한다.
SHOW REPLICA STATUS\G
SELECT *
FROM performance_schema.replication_applier_status_by_worker;
SELECT *
FROM performance_schema.replication_connection_status;
운영에서는 전체 컬럼을 매번 덤프하기보다 다음 항목을 대시보드와 장애 기록에 남긴다.
- receiver I/O thread와 applier thread가 모두 동작 중인지
Seconds_Behind_Source추세와 relay log 증가량- worker별 마지막 오류, 적용 중인 트랜잭션, queue 편중
- source의 transaction size 분포와 commit latency
- replica의 disk I/O, redo pressure, CPU 포화
Seconds_Behind_Source 하나만으로 원인을 단정해서는 안 된다. network 지연, applier 중단, DDL, 자원 포화, 큰 트랜잭션이 같은 숫자로 나타날 수 있다. 특히 큰 트랜잭션은 적용 중 한동안 lag이 커지다가 COMMIT 경계에서 갑자기 줄어드는 계단형 패턴을 만들 수 있다.
6.3 Aurora MySQL에서는 지표 의미를 구분한다
Aurora MySQL도 트랜잭션과 MVCC 관점에서 long transaction의 영향을 받는다. 분산 스토리지를 사용한다고 해서 오래된 Read View, undo history, writer의 잠금 보유 문제가 사라지는 것은 아니다. Performance Insights 또는 Database Insights에서 오래 실행되는 transaction과 wait event를 확인하고, CloudWatch의 RollbackSegmentHistoryListLength, commit latency, DML 처리량을 같은 시간대에 비교한다.
Aurora Replica는 일반 MySQL의 binary log 기반 replica와 적용 경로가 같지 않으므로 Seconds_Behind_Source만 대응시켜 해석하지 않는다. 같은 cluster의 reader는 AuroraReplicaLag, AuroraReplicaLagMaximum 추세와 reader의 재시작·가용성 이벤트를 함께 확인한다. 외부 MySQL replica나 binary log CDC, Aurora Global Database가 연결되어 있다면 각각의 전송·적용 지표를 별도로 본다. “Aurora이므로 복제 지연이 없다”거나 “모든 lag이 대형 binlog transaction 때문이다”라는 두 극단을 모두 피해야 한다.
7. 종료와 강제 개입의 판단 기준
활성 long transaction을 찾았다고 즉시 KILL하는 것은 안전하지 않다. 먼저 다음 질문에 답해야 한다.
- 정상적인 백업, 배치, 스키마 작업, 데이터 검증 세션인가?
- 읽기 전용인가, 변경 중인가, 잠금 대기를 만들고 있는가?
- 이미 변경한 행 수와 예상 rollback 시간이 어느 정도인가?
- 종료하면 애플리케이션이 안전하게 재시도하는가?
- replica lag, undo history, 디스크 여유가 계속 악화되고 있는가?
개입이 필요하면 일반적으로 새 쓰기 유입을 줄이고 문제 세션의 소유자를 확인한 뒤, connection ID를 정확히 대조하여 종료한다. KILL QUERY는 현재 문장만 중단할 수 있으며 트랜잭션을 끝내지 못할 수 있다. 연결 자체를 종료하면 서버가 트랜잭션을 롤백하지만, 롤백 완료까지 모니터링해야 한다. connection ID 재사용 가능성 때문에 과거 캡처의 ID를 늦게 실행해서는 안 된다.
긴 트랜잭션이 이미 rollback 중이면 다음을 관찰한다.
INNODB_TRX.TRX_STATE와 수정 행 수 변화- 잠금 대기 세션이 차례로 풀리는지
- undo history와 디스크 사용량의 추세
- replica/reader lag이 회복되는지
- 애플리케이션 재시도 폭주가 발생하지 않는지
8. 예방 설계
8.1 애플리케이션 트랜잭션 경계를 짧게 만든다
- DB 트랜잭션 안에서 HTTP 호출, 사용자 입력, 파일 업로드, 메시지 소비 대기를 수행하지 않는다.
- connection pool 반환 전에 COMMIT 또는 ROLLBACK이 보장되도록 프레임워크 경계를 점검한다.
- 오류 경로와 timeout 경로에서도 전체 업무 트랜잭션을 명시적으로 종료한다.
- 조회 API가 불필요하게
autocommit=0세션을 오래 보유하지 않게 한다. - 대량 작업은 기본 키 범위와 checkpoint를 이용해 멱등한 chunk로 나눈다.
8.2 임계치는 업무 클래스별로 둔다
온라인 요청, 관리자 도구, 배치, 백업 세션은 정상 시간이 다르다. 하나의 전역 기준 대신 다음처럼 운영 정책을 구분한다.
| 업무 클래스 | 시간 기준의 예 | 함께 볼 신호 | 기본 조치 |
|---|---|---|---|
| 온라인 쓰기 | 요청 SLA보다 긴 수 초 | rows modified, lock waits | 요청 취소·전체 rollback·원인 수정 |
| 온라인 읽기 | API timeout 초과 | Read View, rows examined | 쿼리·pagination·timeout 점검 |
| chunk 배치 | chunk 목표 시간 초과 | chunk 크기, lag, undo trend | chunk 축소·속도 제한 |
| 백업·검증 | 승인된 작업 창 초과 | history length, I/O, reader lag | 작업 지속 필요성 재평가 |
표의 시간은 보편적 권장값이 아니라 정책을 설계하는 형식의 예다. 실제 임계치는 서비스 SLA와 쓰기량, 장애 복구 목표를 기준으로 정한다.
8.3 관측과 알림을 원인 중심으로 묶는다
단일 transaction age 알림보다 다음 조합이 유용하다.
- oldest transaction age 증가
trx_rseg_history_len지속 증가- undo tablespace 또는 스토리지 사용 증가
- lock waiter 수와 timeout 증가
- replica/reader lag 증가
- 큰 transaction 또는 큰 commit의 빈도 증가
알림에는 connection ID만 넣지 말고 업무 식별자, 애플리케이션 이름, 시작 시각, 읽기/쓰기 여부, 수정 행 수, blocker 여부를 포함한다. SQL text는 개인정보나 업무 데이터를 포함할 수 있으므로 접근 통제된 관측 시스템에만 저장한다.
9. 운영 체크리스트
배포·설계 전
평상시 관측
- 가장 오래된
INNODB_TRX의 age와rows_modified -
trx_rseg_history_len -
Sleep
장애 대응
-
KILL QUERY
10. 결론
Long transaction은 한 세션의 지연으로 끝나지 않는다. 변경 트랜잭션은 잠금과 rollback 비용을 누적하고, 오래된 Read View는 purge 경계를 붙잡아 undo history를 키우며, 큰 commit 단위는 replica 적용과 reader freshness에 충격을 전달한다. purge thread 수나 timeout 하나를 조정하는 방식보다 트랜잭션 경계를 짧게 설계하고, oldest transaction age·history length·lock wait·replication lag을 함께 관찰하는 방식이 근본적이다.
다음 단계에서는 대량 DML을 chunk로 나눌 때 원자성, 재시도, checkpoint, replica lag 기반 속도 제한을 어떻게 설계할지 더 구체적으로 다룰 수 있다.