InnoDB 기본키 UUIDv4가 느려지는 이유와 UUIDv7·BIGINT 비교
모바일 앱의 화면 조회 기록을 쌓는 테이블이 있었다. 앱은 오프라인일 때도 기록을 남겼다가 연결되면 한꺼번에 올려야 했다. 그래서 식별자는 서버가 아니라 앱이 만들었다. 앱 라이브러리가 기본으로 주는 UUIDv4를 받아 CHAR(36) 기본키에 그대로 넣었다. 처음 몇 주는 아무 일도 없었다.
석 달쯤 지나자 업로드 API의 응답 시간이 조금씩 길어졌다. 이 API가 하는 일은 INSERT뿐이라 실행 계획을 볼 거리도 없었다. 그런데 모니터링 화면의 디스크 읽기 그래프가 INSERT 양을 따라 오르내리고 있었다. 쓰기만 하는 테이블에서 읽기가 늘어나는 게 처음엔 이해가 되지 않았다.
그때 원인을 파고든 과정과 나중에 BIGINT, UUIDv4, UUIDv7을 같은 조건에서 넣어 본 측정을 함께 적는다. 기준은 MySQL 8.4의 InnoDB다.
테이블이 곧 기본키 인덱스다
InnoDB는 행을 기본키 순서로 정렬한 B-트리, 곧 클러스터드 인덱스의 잎 페이지에 저장한다. 행 데이터가 따로 있고 인덱스가 그 위치를 가리키는 구조가 아니다. 그래서 기본키 값이 어떤 순서로 들어오는지가 새 행이 어느 페이지에 쓰일지를 그대로 정한다.
BIGINT AUTO_INCREMENT처럼 값이 계속 커지면 새 행은 언제나 B-트리의 맨 오른쪽 잎 페이지로 간다. 그 페이지가 차면 오른쪽에 새 페이지를 하나 붙이면 끝이다. MySQL 문서에 따르면 InnoDB는 나중의 갱신에 대비해 페이지의 1/16을 비워 두고, 순서대로 넣으면 페이지가 15/16쯤 찬다.
UUIDv4는 122비트가 무작위다. 새 값이 기존 값 사이 어디로 끼어들지 알 수 없다. 이미 꽉 찬 페이지 한가운데로 들어가야 하면 InnoDB는 그 페이지를 둘로 나누고 행 일부를 새 페이지로 옮긴다. 이것이 페이지 분할이다. 나뉜 두 페이지는 각각 반쯤 빈 채로 남는다. 문서에도 무작위 순서로 넣으면 페이지가 1/2에서 15/16 사이로 찬다고 적혀 있다.
버퍼 풀이 무작위 쓰기를 감당하지 못할 때
페이지가 반만 차는 것보다 더 아팠던 쪽은 버퍼 풀이었다. 순차 삽입에서 자주 건드리는 페이지는 오른쪽 끝의 몇 개뿐이다. 테이블이 아무리 커져도 메모리에 붙잡아 둘 페이지 수는 거의 늘지 않는다.
무작위 삽입은 다르다. 다음 행이 들어갈 페이지가 테이블 전체에 고르게 퍼져 있으니 모든 잎 페이지가 언제든 필요해진다. 테이블이 버퍼 풀보다 작을 때는 티가 나지 않는다. 테이블이 버퍼 풀보다 커지는 순간부터 INSERT 한 번이 디스크에서 페이지를 읽어 오는 일과 나중에 그 페이지를 다시 쓰는 일로 바뀐다. Percona의 Yves Trudeau는 이를 INSERT 한 건에 I/O 두 번으로 설명했다. 내가 본 그래프가 딱 그 모양이었다.
반쯤 빈 페이지가 여기에 겹친다. 같은 행 수를 담는 데 페이지가 더 필요하니 버퍼 풀 한 칸에 실리는 쓸모 있는 데이터가 줄어든다. 캐시 효율이 양쪽에서 같이 깎인다.
같은 조건에서 세 가지 키를 넣어 보기
원인을 짐작한 뒤 직접 재 보기로 했다. 테스트 서버의 innodb_buffer_pool_size는 설정 파일에서 128MB로 작게 잡았다. 그리고 기본키만 다른 테이블 세 개에 같은 행을 넣었다. UUID는 둘 다 BINARY(16)으로 저장했다. CHAR(36)으로 두면 키 길이 차이가 섞여서 순서의 영향만 따로 보기 어렵다.
MySQL의 UUID()는 버전 1 값을 돌려준다. v4나 v7을 만드는 내장 함수는 없어서 RANDOM_BYTES()로 비트를 직접 짰다. v7은 RFC 9562의 정의대로 앞 48비트에 유닉스 밀리초 타임스탬프를 넣고 버전 자리에 7을 넣었다. 문장 하나 안에서 시각이 고정되지 않도록 NOW(3) 대신 SYSDATE(3)을 썼다.
CREATE DATABASE bench;
USE bench;
CREATE TABLE t_bigint (
id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT PRIMARY KEY,
created_at DATETIME(3) NOT NULL,
payload VARCHAR(200) NOT NULL,
KEY idx_created (created_at)
) ENGINE=InnoDB;
CREATE TABLE t_v4 (
id BINARY(16) NOT NULL PRIMARY KEY,
created_at DATETIME(3) NOT NULL,
payload VARCHAR(200) NOT NULL,
KEY idx_created (created_at)
) ENGINE=InnoDB;
CREATE TABLE t_v7 LIKE t_v4;
CREATE TABLE bench_log (kind VARCHAR(10), batch INT, ms DECIMAL(12,3));
-- 1만 행짜리 숫자 테이블
CREATE TABLE seq (n INT PRIMARY KEY);
SET SESSION cte_max_recursion_depth = 10000;
INSERT INTO seq
WITH RECURSIVE s(n) AS (SELECT 1 UNION ALL SELECT n + 1 FROM s WHERE n < 10000)
SELECT n FROM s;
DELIMITER //
CREATE PROCEDURE load_rows(IN kind VARCHAR(10), IN batches INT)
BEGIN
DECLARE i INT DEFAULT 0;
DECLARE t0 DATETIME(6);
WHILE i < batches DO
SET t0 = SYSDATE(6);
IF kind = 'bigint' THEN
INSERT INTO t_bigint (created_at, payload)
SELECT SYSDATE(3), REPEAT('x', 100) FROM seq;
ELSEIF kind = 'v4' THEN
INSERT INTO t_v4 (id, created_at, payload)
SELECT UNHEX(CONCAT(
HEX(RANDOM_BYTES(6)), '4',
SUBSTR(HEX(RANDOM_BYTES(2)), 1, 3),
HEX(128 | (ASCII(RANDOM_BYTES(1)) & 63)),
HEX(RANDOM_BYTES(7)))),
SYSDATE(3), REPEAT('x', 100)
FROM seq;
ELSE
INSERT INTO t_v7 (id, created_at, payload)
SELECT UNHEX(CONCAT(
LPAD(HEX(FLOOR(UNIX_TIMESTAMP(SYSDATE(3)) * 1000)), 12, '0'), '7',
SUBSTR(HEX(RANDOM_BYTES(2)), 1, 3),
HEX(128 | (ASCII(RANDOM_BYTES(1)) & 63)),
HEX(RANDOM_BYTES(7)))),
SYSDATE(3), REPEAT('x', 100)
FROM seq;
END IF;
INSERT INTO bench_log
VALUES (kind, i, TIMESTAMPDIFF(MICROSECOND, t0, SYSDATE(6)) / 1000);
SET i = i + 1;
END WHILE;
END //
DELIMITER ;
CALL load_rows('bigint', 500); -- 500만 행
CALL load_rows('v7', 500);
CALL load_rows('v4', 500);
테이블마다 호출 앞뒤로 SHOW GLOBAL STATUS LIKE 'Innodb_buffer_pool_reads'를 찍어서 디스크에서 읽어 온 페이지 수를 비교했다. 크기는 통계를 새로 모은 뒤 mysql.innodb_index_stats에서 읽었다.
ANALYZE TABLE t_bigint, t_v4, t_v7;
SELECT table_name, index_name,
MAX(IF(stat_name = 'size', stat_value, NULL)) AS pages,
MAX(IF(stat_name = 'n_leaf_pages', stat_value, NULL)) AS leaf_pages
FROM mysql.innodb_index_stats
WHERE database_name = 'bench'
AND stat_name IN ('size', 'n_leaf_pages')
GROUP BY table_name, index_name;
-- 50묶음(50만 행)마다 평균 삽입 시간
SELECT kind, batch DIV 50 AS bucket, ROUND(AVG(ms)) AS avg_ms
FROM bench_log GROUP BY kind, bucket ORDER BY kind, bucket;
측정에서 본 것
| 항목 | BIGINT | UUIDv7 | UUIDv4 |
|---|---|---|---|
| 키 길이 | 8바이트 | 16바이트 | 16바이트 |
| 새 행이 가는 곳 | 오른쪽 끝 | 오른쪽 끝 근처 | 테이블 전체 |
| 잎 페이지 채움 | 15/16 안팎 | 15/16 안팎 | 절반을 조금 넘음 |
| 버퍼 풀을 넘긴 뒤 삽입 시간 | 거의 평평 | 거의 평평 | 묶음마다 늘어남 |
| 디스크 읽기 | 거의 없음 | 거의 없음 | 행 수를 따라 증가 |
결과는 자료에서 읽은 방향과 같았다. BIGINT와 v7은 넣는 내내 묶음당 시간이 거의 평평했다. v4만 테이블이 버퍼 풀보다 커진 뒤로 묶음 시간이 계단처럼 늘었고, Innodb_buffer_pool_reads도 v4를 돌릴 때만 크게 뛰었다. v4 클러스터드 인덱스의 잎 페이지 수는 v7의 두 배 가까이 됐다. 2015년 Percona에서 Michael Coburn이 한 실험에서도 무작위 UUID 테이블은 페이지가 50%를 조금 넘게 찼고, 정렬된 UUID 테이블보다 두 배 넘게 컸다.
v7은 같은 밀리초 안에서는 뒤쪽 비트가 무작위라 완벽한 오름차순은 아니다. 그래도 새 값은 언제나 오른쪽 끝 몇 페이지 안에 떨어지고, 그 페이지들은 이미 버퍼 풀에 올라와 있다. v4처럼 오래된 페이지를 디스크에서 불러올 일이 없다.
보조 인덱스에서 한 번 더
v7과 BIGINT의 차이는 보조 인덱스에서 드러난다. InnoDB 보조 인덱스의 레코드는 행을 찾아가려고 기본키 값을 함께 들고 있다. idx_created의 레코드마다 BIGINT면 8바이트가, UUID면 16바이트가 붙는다. 보조 인덱스가 다섯 개면 이 차이도 다섯 번 쌓인다. 처음 로그 테이블을 CHAR(36)으로 만들었을 때는 36글자짜리 문자열이 모든 인덱스에 따라다니고 있었다.
헛짚었던 것과 옮긴 방법
처음 손댄 곳은 저장 형식이었다. CHAR(36)을 BINARY(16)으로 바꾸면 키가 절반 이하로 줄어드니 문제가 풀릴 줄 알았다. 크기는 줄었지만 삽입이 갈수록 느려지는 모양은 그대로였다. 무작위 순서는 바이트 수와 상관이 없다.
다음으로 UUID_TO_BIN(값, 1)의 swap_flag를 걸어 봤다. 시간 부분을 앞으로 옮겨 인덱스에 유리하게 만든다는 설명만 보고 기대를 걸었다. 그런데 이 플래그는 버전 1 UUID의 시간 필드를 바꿔 놓는 기능이다. v4에는 시간 필드가 없으니 아무 효과가 없다. 문서에 그렇게 적혀 있다는 걸 배포까지 해 보고 나서야 읽었다. 허탈했다.
결국 앱의 ID 생성 라이브러리를 v7로 바꿨다. 오프라인에서 ID를 만든다는 요구는 그대로 지키면서 새 값은 시간 순으로 들어오게 됐다. 이미 쌓인 v4 행은 저절로 다시 정렬되지 않으므로 사용량이 적은 시간에 OPTIMIZE TABLE로 테이블을 한 번 다시 만들었다. 그 뒤로 디스크 읽기 그래프는 INSERT 양을 따라 움직이지 않았다. 그제야 안도했다.
덤도 있었다. 키가 시간 순이 되니 최근 기록부터 끊어 읽는 키셋 페이지네이션을 기본키 하나로 짤 수 있게 됐다.
지금 기본키를 고르는 방식
요즘은 새 테이블을 만들 때 이렇게 고른다.
- ID를 DB 밖에서 먼저 만들 일이 없으면
BIGINT AUTO_INCREMENT를 쓴다. 가장 작고, 보조 인덱스도 가장 가볍다. - 앱이나 여러 서비스가 저장 전에 ID를 정해야 하면 UUIDv7을
BINARY(16)에 넣는다. - UUIDv4는 기본키로 쓰지 않는다. 남이 짐작하기 어려운 공개용 토큰이 필요하면 따로 컬럼을 두고 유니크 인덱스를 건다.
v7에도 따져 볼 점은 있다. 앞부분이 타임스탬프라 ID만 보고도 언제 만든 행인지 알 수 있다. 외부에 그대로 내보내는 ID라면 그 정보가 새어도 괜찮은지 먼저 정한다.
기본키는 한번 정하면 가장 바꾸기 어려운 컬럼이다. 테이블이 버퍼 풀 안에 들어가는 동안에는 어떤 키를 골라도 빠르다. 그래서 잘못 고른 대가는 늘 몇 달 뒤에 온다. 나는 이제 스키마를 그릴 때 이 테이블의 행 수가 버퍼 풀을 넘어서는 날을 먼저 떠올려 본다. 그날 새 행이 오른쪽 끝에 붙을지, 오래된 페이지를 깨우러 디스크까지 내려갈지를 생각해 보고 나서 키 타입을 적는다.
참고한 자료
- MySQL UUIDs – Bad For Performance (Percona, Yves Trudeau, 2019)
- Illustrating Primary Key models in InnoDB and their impact on disk usage (Percona, Michael Coburn, 2015)
- MySQL 8.4 Reference Manual: The Physical Structure of an InnoDB Index
- MySQL 8.4 Reference Manual: Miscellaneous Functions (UUID_TO_BIN)
- RFC 9562: Universally Unique IDentifiers (UUIDs)