Published on

MySQL 8.0 히스토리 리스트 폭증으로 TPS 급락 대응

Authors

MySQL 8.0(InnoDB)에서 History list length가 갑자기 치솟고 TPS가 급락하는 장애는 대개 “정리(purge)가 따라가지 못해 언두(undo) 히스토리가 누적되는 상황”에서 시작합니다. 겉으로는 CPU가 애매하게 높거나, I/O가 튀거나, 락 대기가 늘어난 것처럼 보이지만, 실제 핵심은 긴 트랜잭션/긴 읽기 뷰(read view) 가 purge를 막아 언두 체인을 끝없이 늘리는 데 있습니다.

이 글은 다음 순서로 정리합니다.

  • History list length가 의미하는 것과 TPS가 떨어지는 이유
  • 장애 시 즉시 확인할 지표(어떤 쿼리가 purge를 막는지)
  • 즉시 완화(응급 처치)와 안전한 롤백/종료 기준
  • 재발 방지(트랜잭션 설계, 격리수준, 파라미터, 운영 가드레일)

운영 장애 분석/재발 방지 관점은 리눅스 서비스 트러블슈팅과도 결이 비슷합니다. 원인 추적 프레임은 아래 글도 참고할 만합니다.

1) History list length가 폭증하면 왜 TPS가 급락하나

InnoDB는 MVCC를 위해 변경 전 이미지(undo)를 유지합니다. 각 트랜잭션은 “읽기 뷰”를 가지고, 그 뷰가 참조할 수 있는 과거 버전이 남아있는 동안 InnoDB는 해당 undo를 지우지 못합니다. 이때 purge 스레드는 “더 이상 어떤 읽기 뷰에서도 필요하지 않은 undo”만 제거할 수 있습니다.

History list length는 간단히 말해 purge 대기 중인 undo 레코드(또는 그에 준하는 히스토리)의 누적량을 나타내는 대표 지표입니다. 이 값이 커지면 다음 문제가 연쇄적으로 발생합니다.

  • 일관 읽기(consistent read) 비용 증가: SELECT가 현재 버전을 바로 읽지 못하면 undo 체인을 따라가며 과거 버전을 재구성해야 합니다.
  • B+Tree 페이지 정리/버퍼풀 효율 저하: 변경이 많고 purge가 밀리면 버퍼풀에 더 많은 “정리 전 상태”가 남아 캐시 효율이 떨어집니다.
  • undo tablespace I/O 증가: undo가 쌓이면 undo 테이블스페이스가 커지고 읽기/쓰기 I/O가 증가합니다.
  • 락/대기 악화처럼 보이는 2차 증상: 실제 원인은 purge 정체인데, 결과적으로 쿼리 지연이 늘며 락 경합이 더 커지는 악순환이 생깁니다.

즉, TPS 급락은 “단일 원인”이 아니라 undo 누적이 만든 시스템 전반의 마찰 증가로 나타납니다.

2) 장애 시 5분 안에 보는 체크리스트

2.1 InnoDB 트랜잭션/히스토리 지표

가장 먼저 SHOW ENGINE INNODB STATUS를 봅니다.

SHOW ENGINE INNODB STATUS\G

여기서 확인 포인트는 다음입니다.

  • History list length 값이 비정상적으로 큰가
  • Purge done for trx's n:o 진행이 멈춘 것처럼 보이는가
  • TRANSACTIONS 섹션에 오래 열린 트랜잭션이 있는가

2.2 “purge를 막는 범인” 찾기: 오래 열린 트랜잭션

MySQL 8.0에서는 performance_schemainformation_schema.innodb_trx를 함께 봅니다.

SELECT
  t.trx_id,
  t.trx_state,
  t.trx_started,
  TIMESTAMPDIFF(SECOND, t.trx_started, NOW()) AS trx_age_sec,
  t.trx_mysql_thread_id,
  t.trx_query
FROM information_schema.innodb_trx AS t
ORDER BY trx_age_sec DESC
LIMIT 20;

여기서 trx_age_sec가 큰 세션이 purge를 막는 1순위 후보입니다. 특히 다음 패턴이 위험합니다.

  • 애플리케이션이 트랜잭션을 열고 커밋을 안 함(커넥션 풀 반환 누락)
  • 배치가 큰 범위를 갱신하면서 수 분~수십 분 커밋을 미룸
  • REPEATABLE READ에서 장시간 SELECT를 유지(긴 읽기 뷰)

2.3 어떤 SQL/클라이언트인지 매핑

스레드 ID로 프로세스리스트를 매핑합니다.

SELECT
  p.ID,
  p.USER,
  p.HOST,
  p.DB,
  p.COMMAND,
  p.TIME,
  p.STATE,
  LEFT(p.INFO, 200) AS info
FROM information_schema.PROCESSLIST AS p
WHERE p.ID IN (
  SELECT trx_mysql_thread_id FROM information_schema.innodb_trx
)
ORDER BY p.TIME DESC;

여기서 HOST(애플리케이션 서버), USER(서비스 계정), INFO(쿼리)로 “누가 잡고 있나”를 특정합니다.

2.4 undo 테이블스페이스/파일 증가 확인

운영체제 레벨에서 undo 파일이 커지고 있는지, 디스크가 압박받는지 확인합니다. 디스크가 100%에 근접하면 장애가 다른 형태로 폭발합니다(쓰기 실패, fsync 지연 등). 로그 파일 삭제 후 공간 회수 이슈처럼 “보이는데 안 줄어드는” 상황도 같이 점검해야 합니다.

3) 즉시 완화(응급 처치) 시나리오

핵심 목표는 하나입니다.

  • purge를 막는 오래 열린 트랜잭션을 종료하거나 정상 커밋/롤백시키기

3.1 가장 안전한 1순위: 범인 세션 종료

오래 열린 트랜잭션이 “읽기 전용 SELECT”라면 종료 비용이 상대적으로 낮습니다.

KILL 12345;

하지만 “대량 UPDATE/DELETE 중인 트랜잭션”을 죽이면 롤백이 오래 걸릴 수 있고, 오히려 부하가 더 커질 수 있습니다. 이때는 다음을 같이 판단합니다.

  • 해당 트랜잭션이 얼마나 많은 row를 변경했는가
  • 롤백 시 undo를 더 쓰며 I/O를 폭발시키지 않는가
  • 서비스가 이미 죽어있는가, 부분 장애인가

실무 팁: 대량 쓰기 트랜잭션이 원인이라면, 무작정 KILL하기보다 애플리케이션/배치 측에서 즉시 커밋/중단 로직을 넣어 “정상 종료”를 유도하는 편이 더 안전한 경우가 많습니다.

3.2 트래픽/쓰기 부하를 일시적으로 줄이기

purge가 따라잡으려면 “새로운 변경”이 줄어야 합니다.

  • 쓰기 API를 일시 차단(큐잉, 429, 기능 플래그)
  • 배치 중단
  • 핫 테이블에 대한 대량 갱신 작업 중지

읽기 트래픽도 문제를 키울 수 있습니다. undo 체인이 길어지면 읽기 쿼리도 느려져 CPU를 태우며 purge가 더 밀릴 수 있습니다. 상황에 따라 캐시 우회/강제 캐시 같은 임시 조치도 고려합니다.

3.3 purge 스레드 관련 파라미터는 “마지막 카드”

innodb_purge_threads를 늘리면 도움이 되는 케이스가 있지만, purge를 막는 트랜잭션이 존재하는 상태에서는 근본 해결이 아닙니다.

  • 막고 있는 읽기 뷰가 계속 살아있으면 purge 스레드를 늘려도 지울 수 있는 게 없습니다.
  • 오히려 CPU 경쟁만 늘 수 있습니다.

즉, 파라미터 튜닝은 “범인 제거 후 회복 속도”를 높이는 용도로 접근하는 게 안전합니다.

4) 재발 방지: 원인별 설계/운영 가드레일

여기서부터가 진짜입니다. History list length 폭증은 대부분 “긴 트랜잭션을 허용하는 설계/운영”에서 재발합니다.

4.1 애플리케이션 트랜잭션 경계 재점검

가장 흔한 원인 TOP 3:

  1. 커넥션 풀에서 트랜잭션을 연 채로 반환
  2. 예외 처리 누락으로 커밋/롤백이 실행되지 않음
  3. 외부 호출(HTTP, RPC)을 트랜잭션 안에서 수행

트랜잭션 안에서 외부 호출을 하면 네트워크 지연이 곧 트랜잭션 시간으로 늘어납니다. 아래처럼 트랜잭션 범위를 DB 작업으로만 최소화합니다.

# 나쁜 예: 외부 호출이 트랜잭션 안에 있음
BEGIN;
SELECT ... FOR UPDATE;
call_external_api();
UPDATE ...;
COMMIT;

# 좋은 예: 외부 호출은 트랜잭션 밖
call_external_api();
BEGIN;
SELECT ... FOR UPDATE;
UPDATE ...;
COMMIT;

위 의사코드는 BEGIN/COMMIT을 보여주기 위한 것이고, 실제 구현에서는 프레임워크(예: Spring) 트랜잭션 어노테이션 범위를 줄이는 식으로 적용합니다.

4.2 긴 읽기(리포트/덤프/ETL) 쿼리의 격리

REPEATABLE READ에서 장시간 SELECT는 오래된 읽기 뷰를 유지해 purge를 막을 수 있습니다. 다음과 같이 분리합니다.

  • 리포트/분석 쿼리는 리드 레플리카로 분리
  • 온라인 서비스 트래픽과 다른 계정/리소스 그룹으로 격리
  • 필요 시 세션 격리수준을 READ COMMITTED로 낮춰 읽기 뷰 지속 시간을 줄임

세션 단위로 격리수준을 바꾸는 예:

SET SESSION TRANSACTION ISOLATION LEVEL READ COMMITTED;
START TRANSACTION READ ONLY;
SELECT ...;
COMMIT;

주의: 격리수준 변경은 정합성 요구사항과 트레이드오프입니다. “팬텀/반복읽기” 요구가 있는 기능에는 그대로 REPEATABLE READ가 필요할 수 있습니다.

4.3 대량 UPDATE/DELETE는 “작게 쪼개서 커밋”

대량 갱신을 한 방에 커밋하면 undo가 폭발하고, 롤백도 지옥이 됩니다. 다음 패턴을 권장합니다.

  • PK 범위 또는 인덱스를 이용해 배치를 청크로 나눔
  • 청크마다 커밋
  • 청크 사이에 짧은 sleep로 I/O 버스트 완화

예시(청크 삭제):

DELETE FROM events
WHERE created_at < NOW() - INTERVAL 30 DAY
LIMIT 5000;

이 쿼리를 애플리케이션/배치에서 반복 실행하면서 매 반복마다 커밋되게 만듭니다(오토커밋 또는 명시 커밋). LIMIT 삭제는 실행 계획/락 범위가 환경마다 달라질 수 있으니, 가능하면 “PK 범위 기반”이 더 예측 가능합니다.

4.4 운영 가드레일: 타임아웃/관측/킬 스위치

(1) 관측: 임계치 기반 알람

다음 지표를 최소 알람 세트로 추천합니다.

  • History list length (급증률 포함)
  • 오래 열린 트랜잭션 수(예: 60초 초과)
  • undo tablespace 크기 증가율
  • p95/p99 쿼리 지연, row lock time

performance_schema를 기반으로 “트랜잭션 나이”를 주기적으로 수집해도 좋습니다.

(2) 애플리케이션 타임아웃

  • DB 트랜잭션 타임아웃(프레임워크 레벨)
  • 커넥션 풀 leak detection
  • 쿼리 타임아웃

이 조합이 없으면 “조용히 오래 열린 트랜잭션”이 계속 생깁니다.

(3) 킬 스위치

장애 시 배치/관리 작업을 즉시 중단할 수 있는 스위치를 둡니다.

  • 배치 실행 플래그
  • 쓰기 기능 플래그
  • 관리용 계정의 권한 분리

5) 실전 진단 흐름(Runbook) 예시

장애 대응 문서에 그대로 붙여넣기 좋은 형태로 정리하면 다음과 같습니다.

  1. SHOW ENGINE INNODB STATUS\G에서 History list length와 오래 열린 트랜잭션 징후 확인
  2. information_schema.innodb_trxtrx_age_sec 상위 세션 식별
  3. PROCESSLIST로 서비스/호스트/쿼리 매핑
  4. 읽기 트랜잭션이면 우선 KILL로 제거
  5. 대량 쓰기 트랜잭션이면 롤백 비용 평가 후
    • 가능하면 애플리케이션에서 정상 종료(커밋/중단) 유도
    • 불가하면 KILL 후 롤백 부하 모니터링
  6. 쓰기 트래픽/배치 일시 차단으로 purge가 따라잡게 함
  7. 정상화 후 재발 방지 항목(트랜잭션 경계, 배치 청크, 리포트 격리, 알람) 적용

6) 자주 묻는 함정

6.1 “SELECT만 했는데 왜 히스토리가 늘지?”

SELECT 자체가 undo를 만들지는 않지만, 긴 읽기 뷰는 “지워도 되는 undo”를 지우지 못하게 만듭니다. 즉, 쓰기는 다른 곳에서 발생하고 있는데, 오래 열린 SELECT가 purge를 막아 히스토리가 누적될 수 있습니다.

6.2 “purge 스레드 늘리면 해결 아닌가?”

막고 있는 트랜잭션이 있으면 purge는 지울 수 있는 게 제한됩니다. 원인 제거가 먼저이고, 그 다음이 purge 처리량 튜닝입니다.

6.3 “레플리카 지연이 같이 커졌는데 관련 있나?”

있습니다. 마스터에서 대량 변경과 undo 누적이 발생하면 쿼리 지연과 I/O 경합이 커지고, 바이너리 로그 생성/플러시도 영향을 받아 레플리카 지연으로 이어질 수 있습니다. 또한 레플리카에서 리포트 쿼리가 길게 돌며 자체적으로도 purge가 밀릴 수 있습니다.

7) 마무리

MySQL 8.0에서 History list length 폭증은 “InnoDB purge가 막히는 구조”를 이해하면 대응이 단순해집니다.

  • 지표로 현상을 확인하고
  • 오래 열린 트랜잭션(특히 긴 읽기 뷰)을 찾아 제거하고
  • 쓰기/배치/리포트를 분리하며
  • 배치 작업은 청크 커밋으로 바꾸고
  • 관측/타임아웃/킬 스위치로 재발을 막습니다.

TPS 급락은 결과이고, 원인은 거의 항상 “트랜잭션을 오래 잡는 습관”입니다. 이 습관을 설계와 운영 가드레일로 교정하면 같은 유형의 장애를 크게 줄일 수 있습니다.