남기다
홈 › 스키마 설계 › InnoDB 기본키 UUIDv4가 느려지는 이유와 UUIDv7·BIGINT 비교

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 사이로 찬다고 적혀 있다.

순차 키는 B-트리 오른쪽 끝 페이지에만 행을 붙이고, 무작위 키는 꽉 찬 중간 페이지를 둘로 나눠 반쯤 빈 페이지 두 개를 남기는 모습
순차 삽입은 오른쪽 끝에 페이지를 붙이고, 무작위 삽입은 중간 페이지를 쪼갠다

버퍼 풀이 무작위 쓰기를 감당하지 못할 때

페이지가 반만 차는 것보다 더 아팠던 쪽은 버퍼 풀이었다. 순차 삽입에서 자주 건드리는 페이지는 오른쪽 끝의 몇 개뿐이다. 테이블이 아무리 커져도 메모리에 붙잡아 둘 페이지 수는 거의 늘지 않는다.

무작위 삽입은 다르다. 다음 행이 들어갈 페이지가 테이블 전체에 고르게 퍼져 있으니 모든 잎 페이지가 언제든 필요해진다. 테이블이 버퍼 풀보다 작을 때는 티가 나지 않는다. 테이블이 버퍼 풀보다 커지는 순간부터 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;

측정에서 본 것

항목BIGINTUUIDv7UUIDv4
키 길이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 양을 따라 움직이지 않았다. 그제야 안도했다.

덤도 있었다. 키가 시간 순이 되니 최근 기록부터 끊어 읽는 키셋 페이지네이션을 기본키 하나로 짤 수 있게 됐다.

지금 기본키를 고르는 방식

요즘은 새 테이블을 만들 때 이렇게 고른다.

v7에도 따져 볼 점은 있다. 앞부분이 타임스탬프라 ID만 보고도 언제 만든 행인지 알 수 있다. 외부에 그대로 내보내는 ID라면 그 정보가 새어도 괜찮은지 먼저 정한다.

기본키는 한번 정하면 가장 바꾸기 어려운 컬럼이다. 테이블이 버퍼 풀 안에 들어가는 동안에는 어떤 키를 골라도 빠르다. 그래서 잘못 고른 대가는 늘 몇 달 뒤에 온다. 나는 이제 스키마를 그릴 때 이 테이블의 행 수가 버퍼 풀을 넘어서는 날을 먼저 떠올려 본다. 그날 새 행이 오른쪽 끝에 붙을지, 오래된 페이지를 깨우러 디스크까지 내려갈지를 생각해 보고 나서 키 타입을 적는다.

참고한 자료

PostgreSQL ALTER TABLE, 다시 쓰는 DDL과 안 쓰는 DDL

PostgreSQL 11부터 ADD COLUMN DEFAULT가 순식간에 끝나는 원리와, int를 bigint로 바꾸는 것처럼 여전히 테이블 전체를 다시 쓰는 타입 변경을 구분합니다. 수천만 건 테이블에서 파일 번호와 잠금을 직접 확인하는 방법도 담았습니다.

스키마 설계 ·