donghakim.dev — zsh
← ls ../blog

행이 크면 같은 버퍼풀에 50분의 1만 캐시됩니다

InnoDB는 16KB 페이지 단위로 메모리에 적재하기 때문에, 큰 원문 컬럼을 같은 테이블에 두면 버퍼풀 대부분을 원문 XML이 점유합니다.

backend · cover image

전문(電文)을 원본 XML 그대로 한 컬럼에 적재하는 테이블이 있습니다. 보존기간을 결정해야 해서 용량을 계산했는데, 계산을 완료하고 나니 스토리지 비용은 보존기간을 단축할 근거가 되지 못한다는 결론이 먼저 도출됐습니다. 테이블은 아직 수백 MB 이고 Aurora 스토리지는 GB 단위로 과금되어서, 3년 치를 전부 보관해도 증가하는 비용이 보존기간을 단축할 이유가 되지 못했습니다. 이 표를 근거로 제시하면 "그 정도 비용을 아끼려고 왜 지우느냐"는 반론이 제기됩니다.

논점은 다른 데 있었습니다. 스토리지는 GB 당 과금이라 얼마든 증가하지만, 그 데이터를 실제로 처리할 메모리는 인스턴스 크기에 따라 고정되어 있습니다.


주소 블록이 본문보다 40배 큽니다

행 하나의 구성을 확인해 보니 이랬습니다.

구성비중
원본 XML (raw_xml)전체 바이트의 97.6%
본문 텍스트평균 280 B
나머지 메타나머지

원본 XML 은 평균 11KB 입니다. 그런데 그 안에서 실제 정보는 280B 짜리 본문 하나뿐이고, 나머지는 수신자 주소 블록입니다. 이 프로토콜의 주소 형식은 국가·기관·조직·부서·이름을 전부 개별 XML 엘리먼트로 나열하는데, 이 블록이 수신자 수만큼 반복됩니다. 수신자가 평균 수십 명이라 280B 본문 하나에 10KB를 초과하는 주소 블록이 함께 저장되고 있었습니다.

실제 디스크 점유는 논리 바이트의 1.54배 였습니다. 이 차이에는 페이지 채움률, 단편화, off-page LOB, 인덱스가 포함됩니다.

용량을 확인할 때 information_schema.tables.data_length 를 참조하면 안 됩니다. InnoDB off-page LOB 을 집계하지 않아서, 큰 텍스트 컬럼이 있으면 실제의 절반 이하로 산출됩니다. information_schema.innodb_tablespaces.file_size 를 확인해야 합니다.


버퍼풀은 16KB 페이지 단위로 채워집니다

MySQL 은 디스크 데이터를 직접 읽거나 수정하지 못합니다. 반드시 메모리로 적재해야 합니다. 그런데 둘의 속도 차가 약 1만 배입니다. 메모리는 약 100ns, Aurora 스토리지 읽기는 약 1ms가 소요됩니다. Aurora 는 로컬 디스크가 없어서 버퍼풀 미스가 전부 스토리지 계층으로 향하는 네트워크 왕복입니다.

버퍼풀은 읽기 캐시로만 사용되지 않습니다. 쓰기도 버퍼풀에서 수행됩니다. UPDATE 는 디스크에 바로 기록하지 않고 메모리의 페이지를 수정한 뒤(dirty page), 나중에 모아서 디스크에 기록합니다.

중요한 건 메모리에 적재되는 단위입니다. 행 단위가 아니라 16KB 페이지 단위로 적재됩니다. 280B 한 줄이 필요해도 그 줄이 포함된 16KB 페이지 전체가 적재됩니다. 그래서 페이지 하나에 몇 행이 저장되느냐가 캐시 효율을 그대로 결정합니다.

페이지당 행 수같은 버퍼풀로 캐시 가능한 행
지금 (11KB 원문 포함)약 1건N
원문 분리 후 (280B 본문)약 50건약 50N

같은 메모리로 50배 많은 행을 캐시할 수 있습니다. 스토리지 절감액보다 이 차이가 실제로 의미 있는 효과였습니다.

그리고 지금은 이 문제가 표면화되지 않고 있습니다. 이 테이블을 조회하는 코드가 아직 없어서 버퍼풀에 거의 적재돼 있지 않습니다. 화면과 파서가 연결되어 이 테이블을 조회하기 시작하면 그때부터 문제가 됩니다. 특정 날짜가 아니라 이 테이블을 조회하는 기능이 추가되는 시점이 기준입니다.


young / old 를 왜 나눴나

버퍼풀 이야기가 나온 김에 LRU 구조도 정리해 뒀습니다. 먼저 언급해 둘 점이 있습니다. 리스트는 하나입니다. young/old 는 별도 공간이 아니라 한 LRU 리스트에 경계 지점을 하나 설정한 것이고, 기본값 37% 는 그 경계의 위치입니다. 페이지가 구역 사이를 이동하는 것이 아니라, 리스트 안에서 앞뒤로 이동하다 보면 경계를 넘나들게 됩니다.

순수 LRU 의 문제는 새 페이지가 맨 앞에 삽입된다는 것입니다. 그러면 축출되기까지 리스트 전체를 통과해야 합니다. 접근 이력이 가장 적은 페이지가 가장 오래 캐시에 잔류합니다.

큰 테이블을 스캔한다고 가정하고 두 방식을 비교하면 이렇습니다.

순수 LRUmidpoint insertion
진입 위치맨 앞뒤에서 37% 지점
축출까지 통과 거리리스트 전체약 37%
결과스캔 앞부분을 읽는 순간 캐시 전체가 스캔 데이터로 교체old 구역이 차면 스캔이 자기가 방금 삽입한 페이지를 축출하기 시작

축출 대상은 항상 리스트 꼬리에서 나오고, 신규 페이지는 그 꼬리에 가깝게 삽입됩니다. 그래서 스캔이 삽입한 페이지가 같은 스캔이 앞서 삽입한 페이지를 축출합니다. 스캔 대상이 얼마나 크든 영향이 old 구역 크기로 제한됩니다.

두 번째 이유는 "자주 쓰인다"는 이력을 확인하는 것입니다. 순수 LRU 는 한 번 접근한 페이지를 가장 우선해서 보호합니다. 그런데 한 번 접근했다는 사실만으로는 자주 쓰인다고 간주할 수 없습니다. 스캔도 모든 페이지에 한 번은 접근합니다. 구역을 분리하면 young 에 있다는 사실이 "old 에 있는 동안 최소 한 번 더 접근됐다" 는 이력이 됩니다.


Aurora 는 DELETE 로 과금이 줄지 않습니다

여기가 제일 많이 틀리는 지점이었습니다.

연산공간
DELETE테이블스페이스 안에서 재사용만. 볼륨·과금 그대로
UPDATE ... SET raw_xml = NULL위와 동일 (행 갱신입니다)
OPTIMIZE TABLE재구성 → 반환
ALTER TABLE ... DROP PARTITION반환
TRUNCATE TABLE첫 fragment 제외하고 반환
DROP TABLE전량 반환

DELETE 는 청구 볼륨을 감소시키지 않습니다. 다만 빈 자리를 새 행이 채우므로 볼륨이 더 증가하지 않게 하는 효과는 있습니다.

볼륨을 실제로 반환하려면 dynamic resizing 조건을 갖춰야 합니다. 저희는 Aurora MySQL 3.x, innodb_file_per_table=1 이라 조건을 충족합니다. 반환은 점진적이고, 실제로 감소했는지는 CloudWatch VolumeBytesUsed 로 확인합니다.

여기서 주의할 점이 두 가지 있습니다.

하나. 볼륨은 "지금 데이터" 가 아니라 "역대 최대" 로 결정됩니다. 수집 에이전트가 반나절 중단됐다가 재시작하면 상대 시스템이 밀린 데이터를 한꺼번에 전송합니다. 그날 증가한 볼륨은 그 데이터를 나중에 삭제해도 감소하지 않습니다. 다음 OPTIMIZE 까지 그 요금을 지불합니다. 이 파이프라인은 구조상 이런 스파이크가 발생합니다.

둘. OPTIMIZE TABLE 은 늦을수록 비쌉니다. InnoDB 온라인 재구축이라 원본만큼 임시 공간을 더 사용합니다. 작은 인스턴스에서 수십 GB 를 정리하려면 그 두 배의 여유 공간이 잠깐 필요하고 시간도 오래 소요됩니다. 테이블이 수백 MB 일 때 구조를 변경하는 것과 수십 GB 가 된 뒤에 변경하는 것의 차이가 이것입니다.


그래서 정한 것

폐기할 대상은 행이 아니라 원문 컬럼입니다.

전문 원본 보존은 관련 규정에 하한(30일)이 있어서 행 자체는 삭제하지 못합니다. 그런데 그 하한을 초과한 뒤에 필요한 건 식별용 메타데이터이고, 원본 XML 전체는 필요하지 않습니다. 그래서 일정 기간이 지나면 원문 컬럼만 비우고 행·메타·본문은 보존하는 방식으로 결정했습니다. 파서가 생성한 정규화 테이블은 건당 1KB 미만이고 운영에 필요한 데이터가 거기 있어서 삭제 대상에서 제외했습니다.

원문 컬럼을 별도 테이블로 분리하는 안도 같이 검토 중입니다. 페이지당 행 수를 위에서 계산한 만큼(약 50건) 증가시키려면 원문 컬럼을 분리해야 하고, 테이블이 작은 지금이 이관 비용이 가장 적은 시점입니다.

#aurora#buffer-pool#innodb#mysql#performance#schema-design