우버가 Postgres의 한계(쓰기 증폭, 복제, 데이터 손상, 리플리카 MVCC, 업그레이드 어려움)를 겪은 경험을 바탕으로, MySQL(InnoDB) 기반의 Schemaless를 선택하게 된 배경과 아키텍처적 차이를 설명합니다.
URL: https://www.uber.com/en-KR/blog/postgres-to-mysql-migration/
Title: Why Uber Engineering Switched from Postgres to MySQL
우버 초기 아키텍처는 Python으로 작성된 모놀리식 백엔드 애플리케이션으로 구성되어 있었고, 데이터 영속성을 위해 Postgres를 사용했습니다. 그 이후 우버의 아키텍처는 마이크로서비스와 새로운 데이터 플랫폼 모델로 크게 변화했습니다. 특히 과거에 Postgres를 사용하던 많은 경우에서, 현재는 MySQL 위에 구축된 새로운 데이터베이스 샤딩(sharding) 계층인 Schemaless를 사용합니다. 이 글에서는 우리가 Postgres에서 발견한 몇 가지 단점을 살펴보고, Schemaless 및 기타 백엔드 서비스를 MySQL 위에 구축하기로 한 결정을 설명합니다.
우리는 Postgres에서 다음과 같은 여러 한계를 겪었습니다:
우리는 Postgres가 디스크에 테이블과 인덱스 데이터를 표현하는 방식을 분석해 위의 한계를 살펴볼 것입니다. 특히 MySQL이 InnoDB 스토리지 엔진으로 동일한 데이터를 표현하는 방식과 비교합니다. 참고로, 여기서 제시하는 분석은 주로 다소 오래된 Postgres 9.2 릴리스 계열에서의 경험에 기반합니다. 우리가 아는 한, 이 글에서 논의하는 내부 아키텍처는 더 최신 Postgres 릴리스에서도 크게 변하지 않았으며, 9.2의 디스크 상 표현 방식의 기본 설계는 최소한 Postgres 8.3 릴리스(현재 기준 약 10년 가까이 된) 이후로 크게 바뀌지 않았습니다.
관계형 데이터베이스는 몇 가지 핵심 작업을 수행해야 합니다:
이 모든 기능이 어떻게 함께 동작할지를 고려하는 것은, 데이터베이스가 디스크에 데이터를 표현하는 방식을 설계하는 데 필수적인 부분입니다.
Postgres의 핵심 설계 중 하나는 행(row) 데이터가 불변(immutable)이라는 점입니다. Postgres 용어로 이러한 불변 행을 “튜플(tuple)”이라고 부릅니다. 이 튜플은 Postgres에서 ctid라고 부르는 값으로 유일하게 식별됩니다. ctid는 개념적으로 튜플의 디스크 상 위치(즉 물리적 디스크 오프셋)를 나타냅니다. 하나의 행(row)을 여러 ctid가 설명할 수도 있습니다(예: MVCC 목적을 위해 행의 여러 버전이 존재하거나, 이전 버전이 autovacuum 프로세스에 의해 아직 회수되지 않은 경우). 정렬된(organized) 튜플의 집합이 테이블을 구성합니다. 테이블에는 인덱스가 있으며, 인덱스는 (보통 B-트리 같은) 데이터 구조로 조직되어 인덱스 필드를 ctid 페이로드에 매핑합니다.
일반적으로 이러한 ctid는 사용자에게 투명하지만, 동작 방식을 이해하면 Postgres 테이블의 디스크 상 구조를 이해하는 데 도움이 됩니다. 행의 현재 ctid를 보려면 WHERE 절의 컬럼 목록에 “ctid”를 추가하면 됩니다:
sqluber@[local] uber=> SELECT ctid, * FROM my_table LIMIT 1; -[ RECORD 1 ]--------+------------------------------ ctid | (0,1) ...other fields here...
레이아웃의 세부 사항을 설명하기 위해, 간단한 users 테이블 예제를 생각해 봅시다. 각 사용자에 대해 자동 증가(auto-increment) user ID 기본 키, 이름(first/last), 출생 연도(birth year)를 갖습니다. 또한 사용자의 전체 이름(first와 last)에 대한 복합( compound ) 보조 인덱스와, 출생 연도에 대한 또 다른 보조 인덱스를 정의합니다. 이런 테이블을 생성하는 DDL은 다음과 같을 수 있습니다:
sqlCREATE TABLE users ( id SERIAL, first TEXT, last TEXT, birth_year INTEGER, PRIMARY KEY (id) ); CREATE INDEX ix_users_first_last ON users (first, last); CREATE INDEX ix_users_birth_year ON users (birth_year);
이 정의에는 세 개의 인덱스가 있음을 유의하세요: 기본 키 인덱스 1개와 우리가 정의한 보조 인덱스 2개입니다.
이 글의 예제에서는 다음과 같은 데이터(영향력 있는 역사적 수학자 일부)를 테이블에 넣고 시작하겠습니다:
idfirstlast****birth_year 1 Blaise Pascal 1623 2 Gottfried Leibniz 1646 3 Emmy Noether 1882 4 Muhammad al-Khwārizmī 780 5 Alan Turing 1912 6 Srinivasa Ramanujan 1887 7 Ada Lovelace 1815 8 Henri Poincaré 1854
앞서 설명했듯이, 각 행은 암묵적으로 유일하고 불투명한 ctid를 가집니다. 따라서 테이블의 내부 표현은 다음과 같다고 생각할 수 있습니다:
ctididfirstlastbirth_year A 1 Blaise Pascal 1623 B 2 Gottfried Leibniz 1646 C 3 Emmy Noether 1882 D 4 Muhammad al-Khwārizmī 780 E 5 Alan Turing 1912 F 6 Srinivasa Ramanujan 1887 G 7 Ada Lovelace 1815 H 8 Henri Poincaré 1854
기본 키 인덱스( id 를 ctid 로 매핑)는 다음과 같이 정의됩니다:
id****ctid 1 A 2 B 3 C 4 D 5 E 6 F 7 G 8 H
B-트리는 id 필드에 대해 정의되며, 각 노드는 ctid 값을 보유합니다. 이 경우 자동 증가 id를 사용했기 때문에 B-트리의 필드 순서가 테이블의 순서와 우연히 동일하지만, 반드시 그럴 필요는 없습니다.
보조 인덱스도 비슷해 보이는데, 주요 차이는 B-트리가 사전식(lexicographic)으로 조직되어야 하므로 필드가 다른 순서로 저장된다는 점입니다. (first, last) 인덱스는 알파벳 앞쪽의 first name부터 시작합니다:
firstlastctid Ada Lovelace G Alan Turing E Blaise Pascal A Emmy Noether C Gottfried Leibniz B Henri Poincaré H Muhammad al-Khwārizmī D Srinivasa Ramanujan F
마찬가지로 birth_year 인덱스는 오름차순으로 클러스터링됩니다:
birth_year****ctid 780 D 1623 A 1646 B 1815 G 1854 H 1887 F 1882 C 1912 E
보시다시피 두 경우 모두, 해당 보조 인덱스의 ctid 필드는 자동 증가 기본 키의 경우와 달리 사전식으로 증가하지 않습니다.
이제 이 테이블의 레코드를 업데이트해야 한다고 가정해 봅시다. 예를 들어 al-Khwārizmī의 출생 연도를 서기 770년으로 추정해 birth_year 필드를 업데이트한다고 합시다. 앞서 언급했듯이 행 튜플은 불변입니다. 따라서 레코드를 업데이트하려면 테이블에 새 튜플을 추가합니다. 이 새 튜플은 새로운 불투명 ctid를 가지며, 이를 I 라고 부르겠습니다. Postgres는 I 에 있는 새 활성 튜플과 D 에 있는 이전 튜플을 구분할 수 있어야 합니다. 내부적으로 Postgres는 각 튜플 안에 버전 필드와(이전 튜플이 있으면) 이전 튜플에 대한 포인터를 저장합니다. 따라서 테이블의 새로운 구조는 다음과 같습니다:
ctidprevidfirstlast****birth_year A null 1 Blaise Pascal 1623 B null 2 Gottfried Leibniz 1646 C null 3 Emmy Noether 1882 D null 4 Muhammad al-Khwārizmī 780 E null 5 Alan Turing 1912 F null 6 Srinivasa Ramanujan 1887 G null 7 Ada Lovelace 1815 H null 8 Henri Poincaré 1854 I D 4 Muhammad al-Khwārizmī 770
al-Khwārizmī 행의 두 버전이 모두 존재하는 동안에는, 인덱스도 두 행에 대한 엔트리를 모두 보유해야 합니다. 간단히 하기 위해 기본 키 인덱스는 생략하고 보조 인덱스만 보여주면 다음과 같습니다:
firstlastctid Ada Lovelace G Alan Turing E Blaise Pascal A Emmy Noether C Gottfried Leibniz B Henri Poincaré H Muhammad al-Khwārizmī D Muhammad al-Khwārizmī I Srinivasa Ramanujan F
birth_year****ctid 770 I 780 D 1623 A 1646 B 1815 G 1854 H 1887 F 1882 C 1912 E
여기서는 이전 버전을 빨간색, 새 행 버전을 초록색으로 표현했습니다. 내부적으로 Postgres는 가장 최신 튜플이 무엇인지 결정하기 위해 행 버전을 담는 또 다른 필드를 사용합니다. 이 추가 필드를 통해, 최신 행 버전을 볼 수 없는 트랜잭션에 어떤 행 튜플을 제공해야 하는지 판단할 수 있습니다.

Postgres에서는 기본 인덱스와 보조 인덱스가 모두 디스크 상 튜플 오프셋을 직접 가리킵니다. 튜플 위치가 바뀌면 모든 인덱스를 업데이트해야 합니다.
테이블에 새 행을 삽입하면, 스트리밍 복제(streaming replication)가 활성화된 경우 Postgres는 이를 복제해야 합니다. 충돌 복구(crash recovery)를 위해 데이터베이스는 이미 WAL(write-ahead log)을 유지하며, 2단계 커밋(two-phase commit) 구현에도 WAL을 사용합니다. WAL은 스트리밍 복제가 비활성화되어 있어도 유지해야 하는데, WAL이 ACID의 원자성(atomicity)과 내구성(durability)을 가능하게 하기 때문입니다.
WAL은 데이터베이스가 예기치 않게(예: 갑작스러운 정전) 크래시했을 때 무엇이 일어나는지를 생각해 보면 이해할 수 있습니다. WAL은 데이터베이스가 테이블과 인덱스의 디스크 상 내용에 적용하려고 계획한 변경 사항을 기록한 원장(ledger)입니다. Postgres 데몬이 처음 시작할 때, 프로세스는 이 원장에 있는 데이터와 실제 디스크 데이터를 비교합니다. 원장에 있는 내용이 디스크에 반영되어 있지 않다면, 데이터베이스는 WAL이 가리키는 데이터에 맞게 튜플 또는 인덱스 데이터를 수정합니다. 그 다음, WAL에 있으나 부분적으로만 적용된 트랜잭션(즉 커밋되지 않은 트랜잭션)에서 온 데이터는 롤백합니다.
Postgres는 마스터 데이터베이스의 WAL을 리플리카로 전송하는 방식으로 스트리밍 복제를 구현합니다. 각 리플리카 데이터베이스는 사실상 크래시 복구 상태에 있는 것처럼 동작하며, 크래시 후 시작 시처럼 지속적으로 WAL 업데이트를 적용합니다. 스트리밍 복제와 실제 크래시 복구의 차이는, “hot standby” 모드의 리플리카는 스트리밍 WAL을 적용하면서도 읽기 쿼리를 처리하지만, 실제 크래시 복구 중인 Postgres 데이터베이스는 보통 복구가 끝날 때까지 쿼리를 거부한다는 점입니다.
WAL은 원래 크래시 복구 목적이므로 디스크 상 업데이트에 대한 저수준(low-level) 정보를 담습니다. WAL의 내용은 행 튜플의 실제 디스크 표현과 디스크 오프셋(즉 행의 ctid) 수준에 있습니다. 리플리카가 완전히 따라잡은 상태에서 마스터와 리플리카를 멈추면, 리플리카의 디스크 내용은 마스터와 바이트 단위로 정확히 일치합니다. 따라서 rsync 같은 도구로 마스터와 어긋난 손상된 리플리카를 복구할 수 있습니다.
Postgres의 설계는 우버의 데이터에 대해 비효율과 어려움을 초래했습니다.
Postgres 설계의 첫 번째 문제는 다른 맥락에서 쓰기 증폭(write amplification)으로 알려져 있습니다. 일반적으로 쓰기 증폭은 SSD에 데이터를 쓸 때의 문제를 뜻합니다. 작은 논리적 업데이트(예: 몇 바이트 쓰기)가 물리 계층에서는 훨씬 더 크고 비용이 큰 업데이트로 변환되는 현상입니다. Postgres에서도 동일한 문제가 발생합니다. 앞선 예제에서 al-Khwārizmī의 출생 연도를 작은 논리 변경으로 수정했지만, 최소한 네 가지 물리 업데이트를 수행해야 했습니다:
사실 이 네 번의 업데이트는 메인 테이블스페이스에 대한 쓰기만 반영한 것입니다. 각 쓰기는 WAL에도 반영되어야 하므로, 실제 디스크 쓰기 횟수는 더 많습니다.
여기서 주목할 점은 2와 3입니다. al-Khwārizmī의 출생 연도를 업데이트했을 때, 실제로는 기본 키도 바뀌지 않았고 이름(first/last)도 바뀌지 않았습니다. 하지만 행 레코드를 위해 새 튜플이 만들어지는 순간, 이 인덱스들도 새 튜플의 ctid를 반영해야 하므로 업데이트가 필요합니다. 보조 인덱스가 많은 테이블에서는 이런 불필요한 단계가 엄청난 비효율을 유발할 수 있습니다. 예를 들어 인덱스가 12개 정의된 테이블에서 단 하나의 인덱스만 커버하는 필드를 업데이트하더라도, 새 행의 ctid를 반영하기 위해 12개 인덱스 모두에 전파해야 합니다.
이 쓰기 증폭 문제는 복제 계층에서도 자연스럽게 증폭됩니다. 복제는 디스크 상 변경 단위에서 발생하기 때문입니다. “ctid D 의 birth_year를 770으로 바꿔라” 같은 작은 논리 레코드를 복제하는 대신, 방금 설명한 네 가지 쓰기에 대한 WAL 엔트리를 모두 기록하고, 그 네 엔트리가 네트워크를 통해 전파됩니다. 따라서 쓰기 증폭 문제는 복제 증폭(replication amplification) 문제로 이어지고, Postgres 복제 데이터 스트림은 빠르게 매우 장황해져 많은 대역폭을 점유할 수 있습니다.
Postgres 복제가 단일 데이터센터 내에서만 이뤄지는 경우에는 대역폭이 큰 문제가 아닐 수도 있습니다. 현대 네트워킹 장비와 스위치는 많은 대역폭을 처리할 수 있고, 많은 호스팅 제공자가 데이터센터 내 대역폭을 무료 혹은 저렴하게 제공하기도 합니다. 그러나 데이터센터 간 복제가 필요해지면 문제는 빠르게 커질 수 있습니다. 예를 들어 우버는 원래 서부 해안의 코로케이션(colocation) 공간에 물리 서버를 사용했습니다. 재해 복구(disaster recovery)를 위해 동부 해안의 두 번째 코로케이션 공간에 서버를 추가했습니다. 이 설계에서 서부 데이터센터에는 마스터 Postgres 인스턴스(및 리플리카들)가, 동부에는 리플리카 세트가 존재했습니다.
캐스케이딩 복제(cascading replication)는 동부 데이터센터에 리플리카가 많더라도, 데이터센터 간 대역폭 요구를 마스터와 단 하나의 리플리카 간 복제량으로 제한해 줍니다. 하지만 인덱스를 많이 사용하는 데이터베이스에서는 Postgres 복제 프로토콜의 장황함 때문에 여전히 압도적인 데이터량이 발생할 수 있습니다. 대륙 횡단(서부-동부) 고대역폭 링크 구매는 비싸고, 설령 비용 문제가 없더라도 로컬 인터커넥트만큼의 대역폭을 갖는 링크를 확보하는 것은 불가능합니다. 이 대역폭 문제는 WAL 아카이빙에서도 문제를 일으켰습니다. 서부에서 동부로 WAL 업데이트를 보내는 것 외에도, 재해 시 데이터 복구를 더 확실히 하고 아카이브된 WAL로 스냅샷에서 새 리플리카를 부팅할 수 있게 하려고 모든 WAL을 파일 스토리지 웹 서비스에 아카이빙했습니다. 초창기 트래픽 피크 동안에는, 웹 서비스로의 대역폭이 WAL이 기록되는 속도를 따라갈 만큼 충분히 빠르지 않았습니다.
데이터베이스 용량을 늘리기 위한 정기적인 마스터 승격(master promotion) 과정에서 Postgres 9.2 버그를 만났습니다. 리플리카가 타임라인 전환(timeline switches)을 잘못 따라가 일부 WAL 레코드를 잘못 적용했습니다. 이 버그 때문에 버저닝 메커니즘에 의해 비활성(inactive)으로 표시되어야 할 일부 레코드가 실제로는 비활성으로 표시되지 않았습니다.
다음 쿼리는 이 버그가 users 테이블 예제에 어떻게 영향을 미치는지 보여줍니다:
sqlSELECT * FROM users WHERE id = 4;
이 쿼리는 두 개의 레코드를 반환합니다. 780년 출생 연도를 가진 원래 al-Khwārizmī 행과 770년 출생 연도를 가진 새 al-Khwārizmī 행입니다. WHERE 목록에 ctid를 추가하면, 두 결과에 서로 다른 ctid가 나타나며 이는 서로 다른 행 튜플이라는 점과 일치합니다.
이 문제는 몇 가지 이유로 매우 골치 아팠습니다. 우선, 이 문제가 얼마나 많은 행에 영향을 미쳤는지 쉽게 알 수 없었습니다. 데이터베이스가 중복 결과를 반환하면서 애플리케이션 로직이 여러 경우에 실패했습니다. 우리는 이 문제가 있는 것으로 알려진 테이블에 대해 해당 상황을 감지하는 방어적 프로그래밍(defensive programming) 문을 추가해야 했습니다. 또한 이 버그는 모든 서버에 영향을 미쳤고, 손상된 행이 리플리카 인스턴스마다 달랐습니다. 어떤 리플리카에서는 행 X 가 문제이고 행 Y 는 정상이지만, 다른 리플리카에서는 행 X 가 정상이고 행 Y 가 문제일 수 있었습니다. 실제로는 손상된 데이터가 있는 리플리카 수와, 문제가 마스터에까지 영향을 미쳤는지조차 확신할 수 없었습니다.
우리가 확인한 바로는 데이터베이스당 몇 개 행에서만 문제가 드러났지만, 복제가 물리 레벨에서 이뤄지기 때문에 데이터베이스 인덱스가 완전히 손상될 수 있다는 점이 매우 걱정스러웠습니다. B-트리의 필수 특성 중 하나는 주기적으로 리밸런싱(rebalanced)되어야 한다는 점인데, 리밸런싱 작업은 서브트리가 새로운 디스크 위치로 이동하면서 트리 구조를 완전히 바꿀 수 있습니다. 잘못된 데이터가 이동되면 트리의 큰 부분이 완전히 무효가 될 수 있습니다.
결국 우리는 실제 버그를 추적해, 새로 승격된 마스터에는 손상된 행이 없다는 것을 확인할 수 있었습니다. 리플리카의 손상은 마스터의 새 스냅샷에서 모든 리플리카를 재동기화(resync)해 해결했는데, 이는 매우 노동 집약적인 과정이었습니다. 한 번에 로드 밸런싱 풀에서 일부 리플리카만 빼낼 수 있을 정도로 여유 용량이 부족했기 때문입니다.
우리가 겪은 버그는 Postgres 9.2의 특정 릴리스에만 영향을 미쳤고 오래전에 수정되었습니다. 하지만 이런 종류의 버그가 “가능하다”는 사실 자체가 여전히 우려스럽습니다. 언제든 유사한 버그가 있는 새 Postgres 버전이 나올 수 있고, 복제 방식 때문에 이런 문제는 복제 계층 전체로 확산될 잠재력이 있습니다.
Postgres는 MVCC를 위해 이전 행 버전의 사본을 유지해야 합니다. 스트리밍 리플리카에 열린(open) 트랜잭션이 있으면, 해당 트랜잭션이 열어둔 행에 영향을 주는 업데이트가 데이터베이스에서 블록됩니다. 이 상황에서 Postgres는 그 트랜잭션이 끝날 때까지 WAL 적용 스레드를 일시 정지합니다. 트랜잭션이 오래 걸리면 리플리카가 마스터에 심각하게 뒤처질 수 있으므로 문제가 됩니다. 따라서 Postgres는 이런 상황에서 타임아웃을 적용합니다. 트랜잭션이 WAL 적용을 정해진 시간 이상 막으면, Postgres는 해당 트랜잭션을 종료(kill)합니다.
이 설계는 리플리카가 일상적으로 마스터보다 수 초씩 뒤처질 수 있음을 의미하며, 결과적으로 트랜잭션이 종료되는 코드를 작성하기 쉽습니다. 특히 트랜잭션 시작/종료가 어디인지 숨겨지는 코드를 작성하는 애플리케이션 개발자에게는 이 문제가 눈에 띄지 않을 수 있습니다. 예를 들어 개발자가 사용자에게 영수증 이메일을 보내야 하는 코드를 작성한다고 합시다. 작성 방식에 따라 이메일 전송이 끝날 때까지 데이터베이스 트랜잭션이 암묵적으로 열린 채로 유지될 수 있습니다. 관련 없는 블로킹 I/O를 수행하면서 DB 트랜잭션을 열어두는 것은 항상 좋지 않은 방식이지만, 현실적으로 대부분의 엔지니어는 DB 전문가가 아니고, 특히 ORM을 사용하면 열린 트랜잭션 같은 저수준 디테일이 가려지므로 이런 문제를 늘 인지하지 못할 수 있습니다.
복제 레코드가 물리 레벨에서 동작하기 때문에, Postgres의 서로 다른 GA(general availability) 릴리스 간에는 데이터를 복제할 수 없습니다. Postgres 9.3 마스터는 9.2 리플리카로 복제할 수 없고, 9.2 마스터도 9.3 리플리카로 복제할 수 없습니다.
우리는 한 Postgres GA 릴리스에서 다른 릴리스로 업그레이드하기 위해 다음 단계를 따랐습니다:
우리는 Postgres 9.1에서 시작해 9.2로 업그레이드를 성공적으로 수행했습니다. 하지만 과정이 너무 많은 시간을 소요해 다시는 같은 과정을 감당할 수 없었습니다. Postgres 9.3가 출시될 즈음 우버의 성장으로 데이터셋이 크게 늘어 업그레이드는 훨씬 더 길어졌을 것입니다. 이 때문에 레거시 Postgres 인스턴스는 현재까지도 Postgres 9.2를 실행하고 있습니다(당시 최신 Postgres GA 릴리스는 9.5였음에도).
Postgres 9.4 이상을 사용한다면 Postgres용 논리 복제(logical replication) 계층을 구현하는 pglogical 같은 것을 사용할 수 있습니다. pglogical을 사용하면 서로 다른 Postgres 릴리스 간에도 데이터 복제가 가능해, 9.4에서 9.5로 업그레이드하는 등의 작업을 큰 다운타임 없이 수행할 수 있습니다. 그러나 이 기능은 Postgres 메인라인 트리에 통합되어 있지 않다는 점에서 여전히 문제가 있고, pglogical은 구버전 Postgres를 사용하는 사람들에게는 선택지가 되지 못합니다.
Postgres의 한계를 설명하는 것뿐 아니라, Schemaless 같은 우버 엔지니어링의 신규 스토리지 프로젝트에서 MySQL이 왜 중요한 도구인지도 설명합니다. 많은 경우 MySQL이 우리 사용 사례에 더 유리하다고 판단했습니다. 차이를 이해하기 위해 MySQL의 아키텍처와 Postgres와의 대비점을 살펴봅니다. 특히 InnoDB 스토리지 엔진을 사용할 때 MySQL이 어떻게 동작하는지 분석합니다. 우버는 InnoDB를 사용하며, InnoDB는 아마도 가장 인기 있는 MySQL 스토리지 엔진일 것입니다.
Postgres처럼 InnoDB도 MVCC와 가변(mutable) 데이터 같은 고급 기능을 지원합니다. InnoDB의 디스크 상 포맷을 포괄적으로 다루는 것은 이 글의 범위를 벗어나므로, 핵심 차이에 집중하겠습니다.
가장 중요한 아키텍처 차이는, Postgres가 인덱스 레코드를 디스크 상 위치에 직접 매핑하는 반면 InnoDB는 보조 구조(secondary structure)를 유지한다는 점입니다. Postgres에서 ctid가 디스크 상 행 위치를 가리키는 포인터를 담는 것과 달리, InnoDB의 보조 인덱스 레코드는 기본 키(primary key) 값에 대한 포인터를 담습니다. 즉 MySQL의 보조 인덱스는 인덱스 키를 기본 키에 연관시킵니다:
firstlastid (primary key) Ada Lovelace 7 Alan Turing 5 Blaise Pascal 1 Emmy Noether 3 Gottfried Leibniz 2 Henri Poincaré 8 Muhammad al-Khwārizmī 4 Srinivasa Ramanujan 6
(first, last) 인덱스로 인덱스 조회를 수행하려면 실제로 두 번의 조회가 필요합니다. 첫 번째 조회는 테이블을 검색해 레코드의 기본 키를 찾고, 기본 키를 찾은 뒤 두 번째 조회에서 기본 키 인덱스를 검색해 행의 디스크 상 위치를 찾습니다.
이 설계는 보조 키 조회 시 InnoDB가 Postgres보다 약간 불리함을 의미합니다( InnoDB는 두 인덱스를 검색해야 하는 반면 Postgres는 하나만 검색). 그러나 데이터가 정규화되어 있기 때문에, 행 업데이트는 실제로 업데이트로 인해 변경되는 인덱스 레코드만 갱신하면 됩니다. 또한 InnoDB는 보통 행을 제자리(in place)에서 업데이트합니다. MVCC 목적을 위해 오래된 트랜잭션이 행을 참조해야 한다면, MySQL은 오래된 행을 rollback segment라는 특수 영역에 복사합니다.
al-Khwārizmī의 출생 연도를 업데이트할 때 일어나는 일을 따라가 봅시다. 공간이 있다면 id 4 행의 birth_year 필드는 제자리에서 업데이트됩니다(실제로 이 업데이트는 항상 제자리에서 일어납니다. birth_year는 고정 크기 공간을 차지하는 정수이기 때문입니다). birth_year 인덱스도 새 날짜를 반영하도록 제자리에서 업데이트됩니다. 오래된 행 데이터는 rollback segment로 복사됩니다. 기본 키 인덱스는 업데이트할 필요가 없고, (first, last) 이름 인덱스도 업데이트할 필요가 없습니다. 이 테이블에 많은 인덱스가 있어도, 실제로 birth_year 필드를 인덱싱하는 인덱스만 업데이트하면 됩니다. 예를 들어 signup_date, last_login_time 같은 필드에 대한 인덱스가 있더라도 이를 업데이트할 필요가 없습니다. 반면 Postgres에서는 필요합니다.
이 설계는 vacuuming과 compaction도 더 효율적으로 만듭니다. vacuum 대상 행은 모두 rollback segment에 직접 존재합니다. 반면 Postgres의 autovacuum은 삭제된 행을 식별하기 위해 전체 테이블 스캔을 수행해야 합니다.

MySQL은 추가적인 간접 계층(indirection layer)을 사용합니다. 보조 인덱스 레코드는 기본 인덱스 레코드를 가리키고, 기본 인덱스는 디스크 상 행 위치를 보유합니다. 행 오프셋이 변경되면 기본 인덱스만 업데이트하면 됩니다.
UPDATE users SET birth_year=770 WHERE id = 4 같은 문장을 그대로 복제)각 모드에는 여러 트레이드오프가 있습니다. 구문 기반 복제는 보통 가장 컴팩트하지만, 리플리카가 소량의 데이터 변경을 위해 비용이 큰 문장을 적용해야 할 수 있습니다. 반면 행 기반 복제는 Postgres WAL 복제와 유사하게 더 장황하지만, 리플리카에서 더 예측 가능하고 효율적인 업데이트를 제공합니다.
MySQL에서는 기본 인덱스만 행의 디스크 오프셋에 대한 포인터를 가집니다. 이는 복제에서 중요한 결과를 낳습니다. MySQL 복제 스트림에는 행에 대한 논리적 업데이트 정보만 포함하면 됩니다. 복제 업데이트는 “행 X 의 타임스탬프를 T_1 에서 T_2 로 바꿔라” 같은 형태입니다. 리플리카는 이러한 문장의 결과로 필요한 인덱스 변경을 자동으로 추론합니다.
반면 Postgres 복제 스트림은 “디스크 오프셋 8,382,491에 바이트 XYZ 를 써라” 같은 물리적 변경을 담습니다. Postgres에서는 디스크에 가해지는 모든 물리적 변경이 WAL 스트림에 포함되어야 합니다. 작은 논리 변경(예: 타임스탬프 업데이트)도 많은 디스크 상 변경을 필요로 합니다. Postgres는 새 튜플을 삽입하고, 모든 인덱스를 업데이트해 그 튜플을 가리키도록 해야 합니다. 따라서 많은 변경이 WAL 스트림에 포함됩니다. 이 설계 차이로 인해 MySQL 복제 바이너리 로그(binary log)는 PostgreSQL WAL 스트림보다 훨씬 더 컴팩트합니다.
각 복제 스트림의 동작 방식은 리플리카에서 MVCC가 동작하는 방식에도 중요한 영향을 줍니다. MySQL 복제 스트림은 논리적 업데이트이므로 리플리카가 진정한 MVCC 시맨틱을 가질 수 있어, 리플리카의 읽기 쿼리가 복제 스트림을 막지 않습니다. 반면 Postgres WAL 스트림은 물리적 디스크 변경을 포함하므로, Postgres 리플리카는 읽기 쿼리와 충돌하는 복제 업데이트를 적용할 수 없어서 MVCC를 구현할 수 없습니다.
MySQL의 복제 아키텍처는 버그로 인해 테이블 손상이 발생하더라도 치명적 장애로 이어질 가능성이 낮음을 의미합니다. 복제는 논리 계층에서 이뤄지기 때문에, B-트리 리밸런싱 같은 작업이 인덱스 손상을 일으키는 일은 없습니다. 일반적인 MySQL 복제 문제는 어떤 문장이 스킵되거나(또는 드물게 두 번 적용되는) 경우입니다. 이는 데이터 누락이나 무효를 야기할 수 있지만, 데이터베이스 아웃리지를 초래하지는 않습니다.
마지막으로, MySQL의 복제 아키텍처는 서로 다른 MySQL 릴리스 간 복제를 아주 쉽게 만듭니다. MySQL은 복제 포맷이 바뀔 때만(여러 MySQL 릴리스 사이에서는 드문 일) 버전을 증가시킵니다. 또한 MySQL의 논리 복제 포맷은 스토리지 엔진 계층에서의 디스크 상 변화가 복제 포맷에 영향을 주지 않음을 의미합니다. 일반적인 MySQL 업그레이드 방법은 리플리카를 하나씩 업데이트하고, 모든 리플리카를 업데이트한 뒤 그중 하나를 새 마스터로 승격하는 것입니다. 이는 거의 무중단(zero downtime)에 가깝게 수행될 수 있으며, MySQL을 최신 상태로 유지하는 일을 단순화합니다.
지금까지는 Postgres와 MySQL의 디스크 상 아키텍처에 집중했습니다. MySQL 아키텍처의 다른 중요한 측면들도 Postgres보다 훨씬 더 좋은 성능을 내게 합니다.
먼저 두 데이터베이스는 캐싱 방식이 다릅니다. Postgres는 내부 캐시를 위해 일부 메모리를 할당하지만, 이 캐시는 보통 머신의 총 메모리에 비해 작습니다. 성능을 높이기 위해 Postgres는 커널이 페이지 캐시(page cache)를 통해 최근 접근한 디스크 데이터를 자동으로 캐시하도록 합니다. 예를 들어, 가장 큰 Postgres 리플리카는 768GB의 메모리를 사용할 수 있지만, Postgres 프로세스가 실제로 페이지 폴트로 적재(faulted in)한 RSS 메모리는 약 25GB 정도에 불과합니다. 이는 700GB 이상의 메모리가 Linux 페이지 캐시에 비어 있게 됨을 의미합니다.
이 설계의 문제는, 페이지 캐시를 통해 데이터에 접근하는 것이 RSS 메모리에 접근하는 것보다 비용이 다소 크다는 점입니다. 디스크에서 데이터를 조회하기 위해 Postgres 프로세스는 lseek(2)와 read(2) 시스템 콜을 호출해 데이터를 찾습니다. 각 시스템 콜은 컨텍스트 스위치를 발생시키며, 이는 메인 메모리 접근보다 더 비쌉니다. 사실 Postgres는 이 부분에서 완전히 최적화되어 있지도 않습니다. Postgres는 seek + read를 하나의 시스템 콜로 합치는 pread(2) 시스템 콜을 사용하지 않습니다.
반면 InnoDB 스토리지 엔진은 InnoDB 버퍼 풀(buffer pool)이라고 부르는 공간에 자체 LRU를 구현합니다. 이는 Linux 페이지 캐시와 논리적으로 비슷하지만 사용자 공간(userspace)에 구현되어 있습니다. Postgres 설계보다 훨씬 복잡하지만, InnoDB 버퍼 풀 설계에는 큰 장점이 있습니다:
MySQL은 커넥션당 스레드(thread-per-connection)를 생성해 동시 커넥션을 구현합니다. 이는 상대적으로 오버헤드가 낮습니다. 각 스레드는 스택 공간에 대한 약간의 메모리 오버헤드가 있고, 커넥션별 버퍼를 위해 힙에 일부 메모리를 할당합니다. MySQL을 10,000개 정도의 동시 커넥션으로 스케일하는 것은 드문 일이 아니며, 실제로 우리는 오늘날 일부 MySQL 인스턴스에서 이 커넥션 수에 근접해 있습니다.
반면 Postgres는 커넥션당 프로세스(process-per-connection) 설계를 사용합니다. 이는 여러 이유로 스레드당 커넥션 설계보다 훨씬 비쌉니다. 새 프로세스를 fork하는 것은 새 스레드를 만드는 것보다 더 많은 메모리를 점유합니다. 또한 프로세스 간 IPC는 스레드 간 IPC보다 훨씬 비쌉니다. Postgres 9.2는 스레드를 사용할 때 가벼운 futex 대신 System V IPC 프리미티브를 사용합니다. futex는 경쟁(contended)이 없는 일반적인 경우 컨텍스트 스위치가 필요 없기 때문에 System V IPC보다 더 빠릅니다.
Postgres 설계의 메모리/IPC 오버헤드 외에도, Postgres는 충분한 메모리가 있어도 큰 커넥션 수를 처리하는 지원이 좋지 않은 것으로 보입니다. 우리는 활성 커넥션 수가 수백 개를 넘어서면 Postgres 스케일링에 상당한 문제를 겪었습니다. 문서가 그 이유를 자세히 설명하진 않지만, Postgres에서 큰 커넥션 수로 스케일하려면 프로세스 외부(out-of-process)의 커넥션 풀링 메커니즘을 사용할 것을 강하게 권장합니다. 따라서 우리는 Postgres에 대해 pgbouncer를 사용한 커넥션 풀링을 전반적으로 성공적으로 활용해 왔습니다. 하지만 백엔드 서비스에서 가끔 애플리케이션 버그로 인해 서비스가 필요 이상으로 많은 활성 커넥션(보통 “idle in transaction” 커넥션)을 열기도 했고, 이러한 버그가 장시간 다운타임을 유발했습니다.
Postgres는 우버 초기 시절에 큰 도움이 되었지만, 성장 과정에서 Postgres를 스케일링하는 데 상당한 문제를 겪었습니다. 현재도 일부 레거시 Postgres 인스턴스가 존재하지만, 대부분의 데이터베이스는 MySQL(보통 Schemaless 계층을 사용) 위에 구축되어 있거나, 특수한 경우에는 Cassandra 같은 NoSQL 데이터베이스를 사용합니다. 우리는 전반적으로 MySQL에 매우 만족하고 있으며, 향후 우버에서의 더 고급 활용 사례를 설명하는 글을 더 게시할 수도 있습니다.
Evan Klitzke는 Uber Engineering 코어 인프라 그룹의 스태프 소프트웨어 엔지니어입니다. 그는 데이터베이스 애호가이기도 하며 2012년 9월 우버에 ‘엔지니어링 얼리 버드’로 합류했습니다.