왜 버퍼풀이 병목이 되는가

InnoDB는 데이터와 인덱스 페이지를 버퍼풀(buffer pool)에 올려두고 처리한다. 워킹셋이 버퍼풀보다 커지면 자주 쓰는 페이지조차 디스크에서 다시 읽어야 하고, 새 페이지를 올리기 위해 기존 페이지를 쫓아내는 이빅션(eviction)이 잦아진다. 이 순간 읽기 지연이 튀고, 랜덤 I/O가 스토리지를 포화시키며, 쿼리 응답시간 분산이 커진다. 튜닝의 목표는 단순히 크기를 키우는 것이 아니라 "핫 페이지를 메모리에 유지하고 이빅션을 예측 가능하게 만드는 것"이다.

먼저 상태를 측정한다

추측 대신 지표부터 본다. 핵심은 히트율과 읽기 대기, 그리고 LRU 리스트의 구조다.

-- 버퍼풀 히트율(1000회 요청당 디스크 read 횟수)
SELECT
  variable_value AS reads
FROM performance_schema.global_status
WHERE variable_name = 'Innodb_buffer_pool_reads';

-- 인스턴스별 페이지 구성과 이빅션 관찰
SELECT pool_id, pool_size, database_pages, old_database_pages,
       pages_made_young, pages_made_not_young, number_pages_read
FROM information_schema.innodb_buffer_pool_stats;

Innodb_buffer_pool_reads / Innodb_buffer_pool_read_requests 비율이 0.1%를 크게 넘으면 워킹셋이 버퍼풀을 벗어났다는 신호다. pages_made_young이 과도하게 높으면 old 영역에서 young으로 승격이 남발되어, 스캔성 쿼리가 핫 페이지를 밀어내고 있을 가능성이 크다.

LRU와 old/young 영역 이해

InnoDB의 LRU는 young(자주 쓰는)과 old(새로 들어온) 두 구역으로 나뉜다. 새 페이지는 old 헤드에 삽입되고, innodb_old_blocks_time(밀리초) 안에 다시 접근되지 않으면 young으로 올라가지 못한 채 이빅션 대상이 된다. 이 설계 덕분에 대량 테이블 스캔이 한 번 훑고 지나가는 페이지가 핫 데이터를 몰아내지 못한다.

파라미터역할과다/과소 증상
innodb_old_blocks_pctold 영역 비율(기본 37)낮추면 스캔 방어↑, 너무 낮으면 정상 읽기도 승격 실패
innodb_old_blocks_time승격 유예 시간(ms)0이면 스캔 방어 사라짐
innodb_buffer_pool_instances내부 뮤텍스 분할1이면 고동시성에서 락 경합

크기와 인스턴스 설정

전용 DB 서버라면 물리 메모리의 60~75%를 버퍼풀에 할당하되, OS 캐시·연결 버퍼·정렬 버퍼 여유를 남긴다. 버퍼풀이 크면(수십 GB 이상) 인스턴스를 쪼개 뮤텍스 경합을 줄인다. 인스턴스당 최소 1GB를 넘기도록 한다.

[mysqld]
innodb_buffer_pool_size = 48G
innodb_buffer_pool_instances = 8      # 인스턴스당 6G
innodb_old_blocks_pct = 20            # 스캔이 많으면 낮춤
innodb_old_blocks_time = 1000         # 1초 유예로 스캔 방어 강화

# 재기동 시 워킹셋 복원
innodb_buffer_pool_dump_at_shutdown = ON
innodb_buffer_pool_load_at_startup = ON
innodb_buffer_pool_dump_pct = 40      # 핫 페이지 상위 40%만 덤프

덤프/로드 옵션은 재기동 직후의 콜드 스타트 구간을 줄여, 서비스 재개 초반의 지연 스파이크를 완화한다.

이빅션이 심할 때의 진단 흐름

지연이 튈 때는 SHOW ENGINE INNODB STATUS의 BUFFER POOL AND MEMORY 섹션에서 Buffer pool hit rateevicted without access를 본다. "access 없이 이빅션"이 지속적으로 발생하면, 읽어온 페이지를 쓰지도 못하고 버리는 상태 — 워킹셋 초과의 전형이다. 이를 주기적으로 수집해 시계열로 남기면 배치 시간대와의 상관을 볼 수 있다.

import subprocess, re, time

def sample():
    out = subprocess.check_output(
        ["mysql", "-N", "-e",
         "SELECT variable_value FROM performance_schema.global_status "
         "WHERE variable_name IN "
         "('Innodb_buffer_pool_read_requests','Innodb_buffer_pool_reads')"]
    ).decode().split()
    return int(out[0]), int(out[1])   # requests, reads

r0, d0 = sample()
time.sleep(10)
r1, d1 = sample()
miss = (d1 - d0) / max(1, (r1 - r0)) * 100
print(f"miss rate: {miss:.3f}%")     # 0.1% 이상 지속이면 워킹셋 초과 의심

주의점

버퍼풀 크기는 온라인으로 변경(SET GLOBAL innodb_buffer_pool_size)할 수 있지만, 청크 단위(innodb_buffer_pool_chunk_size)로 리사이즈되며 진행 중 락과 CPU를 소모하므로 트래픽이 낮은 시간에 한다. old_blocks_pct를 과하게 낮추면 정상적인 반복 읽기도 승격에 실패해 오히려 히트율이 떨어질 수 있으니, 스캔성 워크로드가 실제로 확인될 때만 조정한다. 마지막으로 히트율 99.9%가 항상 좋은 것은 아니다 — 버퍼풀이 필요 이상으로 크면 그 메모리를 정렬·조인 버퍼나 다른 인스턴스에 쓰는 편이 나을 수 있다. 지표는 반드시 응답시간 분산과 함께 해석한다.