![[번역] Uber가 Postgres에서 MySQL로 데이터베이스를 전환 한 이유](https://img1.daumcdn.net/thumb/R750x0/?scode=mtistory2&fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2F4mjPT%2FbtsLrklFbVC%2FlhIz4lqziKZC56LMUu74lk%2Fimg.jpg)
Why Uber Engineering Switched from Postgres to MySQL | Uber Blog
Why Uber Engineering Switched from Postgres to MySQL
Uber Engineering explains the technical reasoning behind its switch in database technologies, from Postgres to MySQL.
www.uber.com
위의 2016년 포스트를 번역했습니다.
Introduction
Uber의 초기 아키텍처는 Python으로 쓰여진 monolithic architecture 였습니다.
시간이 지나면서 microservice 와 새로운 데이터 플랫폼 모델로 크게 바뀌었으며 대부분의 경우 이전에는 Postgres를 사용하고 있었습니다.
하지만, 지금은 Schemaless하고 novel한 데이터베이스를 MySQL을 통해서 구축하고 있습니다.
Postgres의 Architecture
우리는 아래와 같은 postgres의 단점을 마주했습니다.
- 쓰기에 비 효율적인 아키텍처
- 비 효율적인 Data replication
- Table corruption 이슈
- replica MVCC support가 좋지 않음
- 새로운 버전으로 upgrade가 어려움
Postgres의 on-disk 저장 방식
Relational Database는 아래와 같은 중요 작업들을 반드시 수행해야합니다.
- Insert/Update/Delete 기능을 제공 해야 함.
- Schema 변경을 제공 해야 함.
- Multiversion concurrency control (MVCC) 메커니즘을 구현해서 서로 다른 연결이 제공하는 transactional view를 가질 수 있어야함.
이런 특징들을 봤을때 Data를 디스크에 쓰는 포맷이 상당히 중요합니다.
Postgres의 핵심 디자인 중 하나는 변경 불가능한 행 데이터 (immutable row data) 입니다.
이런 변경 불가능한 행들은 Postgres 용어로 "Tuples"로 불립니다.
이런 Tuple 들은 ctid 라는 값을 통해 Unique하게 Identified됩니다.
ctid는 튜플이 실제 디스크에 저장되는 위치를 의미합니다. (e.g. physical disk offset)
여러개의 ctid는 잠재적으로 하나의 행을 가리킬 수 있습니다.
이런 정돈된 튜플의 모음이 table을 형성합니다.
테이블은 B-tree 같은 data structure로 구성된 인덱스로 구성되고 ctid 값으로 index field를 mapping합니다.
일반적으로, ctid는 user에게는 보이지 않지만, Postgres 테이블의 on-disk structure를 이해하는데 도움이 됩니다.
아래와 같이 ctid 를 확인할 수 있습니다.
uber@[local] uber=> SELECT ctid, * FROM my_table LIMIT 1;
-[ RECORD 1 ]--------+------------------------------
ctid | (0,1)
...other fields here...
layout의 세부사항을 설명하기 위해 간단한 users table을 아래와 같이 정의합니다.
- auto-incrementing user ID Primary key
- user의 성/이름
- user의 생일
- 유저 full name에 대한 보조 합성 index
- user의 생일에 대한 보조 index
DDL 코드는 아래와 같습니다.
CREATE 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);
위 테이블에 대해서 예시를 아래와 같이 추가할 수 있습니다.
id | first | last | birth_year |
1 | Blaise | Pascal | 1623 |
2 | Gottfried | Leibniz | 1646 |
3 | Emmy | Noether | 1882 |
4 | Muhammad | al-Khwarizmi | 780 |
5 | Alan | Turing | 1912 |
6 | Srinivasa | Ramanujan | 1887 |
7 | Ada | Lovalace | 1815 |
8 | Henri | Poincare | 1854 |
그리고 ctid는 암묵적으로 각 행에 대해서 추가가됩니다.
ctid | id | first | last | birth_year |
A | 1 | Blaise | Pascal | 1623 |
B | 2 | Gottfried | Leibniz | 1646 |
C | 3 | Emmy | Noether | 1882 |
D | 4 | Muhammad | al-Khwarizmi | 780 |
E | 5 | Alan | Turing | 1912 |
F | 6 | Srinivasa | Ramanujan | 1887 |
G | 7 | Ada | Lovalace | 1815 |
H | 8 | Henri | Poincare | 1854 |
그러면 id->ctid로 mapping하는 primary key index는 아래와 같습니다.
id | ctid |
1 | A |
2 | B |
3 | C |
4 | D |
5 | E |
6 | F |
7 | G |
8 | H |
B-tree는 id 필드에 대해서 정의가 되어있고 각 b-tree의 node는 ctid를 가지고 있습니다.;
이런 경우 B-tree의 순서는 실제 테이블의 순서와 거의 동일합니다.
실제 테이블은 auto-incrementing ID를 쓰고 있기 때문이죠.
보조 인덱스도 비슷합니다.
주요한 차이점은 B-tree는 사전적으로 구성될 것이기에 다른 순서로 field를 저장한다는 것입니다.
아래와 같이 알파벳 순으로 저장할것입니다.
first | last | ctid |
Ada | Lovalace | G |
Alan | Turing | E |
Blaise | Pascal | A |
Emmy | al- Noether | C |
Gottfried | Leibniz | B |
Henri | Poincare | H |
Muhammad | al-Khwarizmi | 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 필드는 사전적으로 증가하지 않습니다.
만약 우리가 이 테이블에 데이터를 Update 한다고 가정해보겠습니다.
al-Khwarizmi의 birth-year를 770년으로 업데이트 한다고 가정하겠습니다.
위에서 언급했든 row tuple은 불변 속성을 가지고 있습니다.
따라서, 우리는 새로운 tuple을 테이블에 추가해야합니다.
이 Tuple은 새로운 ctid를 가지고 있으며 이를 I로 가정하겠습니다.
이에 따라 Postgres는 새로운 Tuple과 기존 Tuple을 구분해야합니다.
그래서 내부적으로 prev라는 column을 두었습니다.
그러면 아래와 같은 형태를 띕니다.
ctid | prev | id | first | last | 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-Khwarizmi | 780 |
E | null | 5 | Alan | Turing | 1912 |
F | null | 6 | Srinivasa | Ramanujan | 1887 |
G | null | 7 | Ada | Lovalace | 1815 |
H | null | 8 | Henri | Poincare | 1854 |
I | D | 9 | Muhammad | al-Khwarizmi | 770 |
이제 2개의 버전의 al-Khwarizmi가 존재하게 되고 index 또한 업데이트 되어야합니다.
first | last | ctid |
Ada | Lovalace | G |
Alan | Turing | E |
Blaise | Pascal | A |
Emmy | al- Noether | C |
Gottfried | Leibniz | B |
Henri | Poincare | H |
Muhammad | al-Khwarizmi | D |
Muhammad | al-Khwarizmi | 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는 row의 값이 업데이트 되는것을 확인하기 위해서 새로운 field를 사용합니다.
이 새로운 field는 데이터베이스로 하여금 가장 최신의 데이터를 볼 수 없는 transaction에 어떤 row tuple을 제공할지를 결정해 줍니다.
Replication
테이블에 새로운 데이터를 넣을때 Postgres는 replication이 enable되어있을경우 replication을 수행해야합ㄴ디ㅏ.
crash에 대한 recovery 목적으로 데이터베이스는 WAL(Write Ahead Log)와 two-phase commit을 사용합니다.
데이터베이스는 원자성과 내구성을 지키기 위해서 WAL을 반드시 유지해야합니다.
비정상적인 종료가 되고나서 다시 켜지면 WAL을 통해서 실제 데이터베이스와 비교해서 WAL로 새로 생겨야할 데이터가 데이터베이스에 없으면 그것을 생성하는 초기화 과정을 진행합니다.
WAL이 crash recovery 목적으로 개발되었기에 on-disk 업데이트에 대한 low-level 정보를 가지고있습니다.
따라서, WAL의 정보는 row tuple과 그들의 실제 disk offset (e.g. row ctid)를 포함합니다.
Postgres 디자인의 결과
이 결과로 우버의 데이터에 대한 비효율성과 어려움을 낳습니다.
Write Amplification
첫번째 문제는 write amplification입니다.
Write amplification은 SSD에 데이터를 쓸때 주로 나타나는 문제입니다.
위의 예제에서 al-Khwarizmi를 업데이트할때 실제로 발생하는 문제는 아래와 같습니다.
- 새로운 row tuple을 테이블에 씁니다.
- 새로운 tuple에 대한 primary key를 업데이트합니다.
- 새로운 tuple에 대한 첫번째 보조 인덱스를 업데이트합니다.
- 새로운 tuple에 대한 생일 관련 보조 인덱스를 업데이트합니다.
이런식으로 4개의 업데이트가 테이블에 반영됩니다.
이는 WAL도 추가적으로 써야함을 의미합니다.
따라서, 실제 디스크에 쓰는 양은 훨씬 많습니다.
주목할 만한 점은 2,3에 대한 update입니다.;
우리가 al-Khwarizmi의 birty-year를 업데이트 할때 실제로 primary-key는 업데이트 되지 않습니다.
또한 그의 이름도요. (둘 다 인덱스와 관련된 값입니다.)
그러나 이와 관련된 index들은 테이블에 새로운 row가 추가됨에 따라서 업데이트 되어야합니다.
테이블이 보조 인덱스가 많아지면 많아질수록 비효율이 커지고있는겁니다.
Replication
이런 write amplifcation 문제는 자연스럽게 repliaction layer에도 전파됩니다.
replication은 on-disk change level에서 발생하니까요.
"birth year를 770으로 변경해줘" 같은 작은 record의 replication이 아니라
위에서 언급한 4가지의 write를 모두 하게 되고 replication 또한 겪게 됩니다.
하나의 데이터센터에서 replication 한다면 큰 문제는 아닙니다.
그러나 여러개의 데이터 센터에 대해 replication이 일어난다면 문제는 커지게 됩니다.
내부 intra network를 사용할때는 비용문제는 덜 생길 수 있으나, 다양한 지역의 network로 전파를 해야한다면 이 또한 비용을 많이 초래할 수 있습니다. (주로 국가간의 데이터 replication 등)
Data Corruption
master database의 용량을 늘리려고 할 때 우리는 Postgres 9.2의 버그를 겪었습니다.
Replica들이 잘못된 timeline을 따르고 있었어서, 몇개가 WAL record를 잘못 적용하고 있었습니다.
이 버그로 인해 inactive 되지 않아야 할 일부 데이터가 inactive 된적이 있었습니다.
이 버그를 이해하기 위해 우리의 예제를 활용하자면 아래와 같습니다.
SELECT * FROM users WHERE id = 4;
이 쿼리는 2개의 레코드를 반환해야합니다.
원래의 데이터인 780년생 al-Kharizmi 데이터와 770년생 al-Kharizmi 데이터입니다.
일단 이 버그가 얼마나 많은 행에 영향을 미쳤는지 쉽게 알 수 있는 방법이 없었습니다.
데이터베이스에서 반환된 중복된 결과로 인해 여러 경우에 응용 프로그램의 동작이 실패했습니다.
따라서, 이를 감지하기 위해서 방어적인 프로그래밍 코드를 추가했습니다.
버그가 모든 서버에 영향을 미쳤기에 손상된 행은 복제 인스턴스마다 달랐습니다.
우리가 알 수 있었던 바에 따르면 문제는 데이터베이스당 몇개의 행에서만 나타나긴했지만 Replication이 물리적 수준에서 발생하기에 데이터베이스 인덱스가 완전히 손상 될 수 있다는 점에 대해 매우 우려했습니다.
B-tree의 필수적인 측면은 주기적으로 재조정한다는 것이며, 이런 재조정 작업은 하위 트리가 새로운 디스크로 이동될 때 트리의 구조를 완전히 변경할 수 있다는 것이였습니다.
우리가 만난 버그는 Postgres 9.2의 특정 릴리즈에서만 영향을 미쳤으며 오랫동안 수정되었습니다.
그러나 우리는 여전히 이런 종류의 버그가 발생할 수 있다는 것에 대해 우려하고있습니다.
Replica MVCC
Postgres는 실질적인 replica MVCC를 제공하지는 않습니다.
Replica들에 적용되는 WAL updates들은 master에 적용되는것과 동일하게 반영된다는 사실이 있습니다.
이런 디자인은 Uber에게 문제를 제기했습니다.
Postgres는 MVCC를 위한 old row version의 copy를 유지하는게 필요했습니다.
streaming replica가 open transaction을 가지고 있다면, 그 transaction에 영향을 주는 update는 막힙니다.
이 경우에 Postgres는 WAL application thread를 해당 transaciton이 끝날때 까지 일시 정지 합니다.
그러면 transaction이 오래 걸리는 경우 replica가 master에 까지 영향을 주기에 문제였습니다.
그러므로 Postgres는 그런 경우 timeout을 도입했습니다.
만약 transaction이 WAL application을 일정시간동안 막고 있다면, Postgres가 transaction을 kill 하는 방법으로 말이죠
이런 디자인은 마스터보다 replica가 몇초정도 뒤처질 수 있음을 의미합니다.그러므로 transaction을 죽이는 코드를 작성하기 쉽다는 의미죠.
이 문제는 application developer로 하여금 transaction이 어디서 시작해서 어디서 끝나는지를 모호하게 만듭니다.
예를들면, 작성 방법에 따라 코드에는 이메일 전송이 완료될 때까지 열려 있는 데이터베이스 트랜잭션이 암시적으로 있을 수 있습니다. 관련 없는 차단 I/O를 수행하는 동안 코드가 열린 데이터베이스 트랜잭션을 보유하도록 하는 것은 항상 좋지 않은 형식이지만, 현실은 대부분의 엔지니어가 데이터베이스 전문가가 아니며 특히 열린 트랜잭션과 같은 낮은 수준의 세부 정보를 모호하게 하는 ORM을 사용할 때 항상 이 문제를 이해하지 못할 수 있습니다.
Postgres Upgrades
Replication이 physical level로 작동하기 때문에 Postgres가 다른 버전을 가지면 data를 replicate하는게 불가능합니다.
9.3 버전의 master Postgres는 9.2 버전의 replica에게 데이터를 복사해줄 수 없는것이죠.
우리는 Postgres를 업그레이드할때 아래 과정을 거쳤습니다.
- master database를 종료
- pg_upgrade를 master에서 실행
이 과정은 데이터베이스가 큰 경우 몇시간씩 걸릴 수 있고, 이 과정이 끝나기 전까지 traffic은 허용되지 않습니다. - master를 다시 실행
- master의 snapshot을 생성
이 과정은 데이터베이스의 모든 데이터를 복사하기에, 데이터베이스가 큰 경우 몇시간씩 걸립니다. - 모든 replica를 삭제하고 master로부터 받은 snapshot으로 restore합니다.
- replication hierarchy로 모든 replica들을 다시 복구합니다.
마스터의 업데이트를 완전히 따라잡을때 까지 기다립니다.
시간이 너무 오래걸리기에 현재는 9.5가 최신이지만, 9.2를 쓰고있기도합니다.
MySQL의 아키텍처
InnoDB의 On-disk 저장 방식
InnoDB는 MVCC와 mutable data를 지원합니다.
Postgres와 가장 큰 차이는 index record를 on-disk location과 직접 mapping하는 Postgres와 반대로
InnoDB는 보조적인 구조를 가지고 있다는 것입니다.
Postgres의 ctid 같이 on-disk location을 가지고 있는 pointer를 가지지 않고, InnoDB의 보조 인덱스는 primary key value의 pointer를 가지고 있습니다.
따라서, index key를 primary key와 연관 시킵니다.
first | last | id (primary key) |
Ada | Lovalace | 7 |
Alan | Turing | 5 |
Blaise | Pascal | 1 |
Emmy | al- Noether | 3 |
Gottfried | Leibniz | 2 |
Henri | Poincare | 8 |
Muhammad | al-Khwarizmi | 4 |
Srinivasa | Ramanujan | 6 |
(first,last) 인덱스의 lookup을 수행하기 위해 우리는 두개의 lookup이 필요합니다.
1. 첫번째 lookup은 table과 primary key를 찾기
2. primary key가 찾아지면 두번째 lookup은 on-disk location을 찾기 위해 primary key index를 찾습니다.
이 디자인의 의미는 Postgres보다는 단점을 의미 할 수 있는데, 보조 key lookup시에 한번만 search를 수행하는 Postgres와 달리 InnoDB는 두번 Search를 해야한다는 의미입니다.
하지만, data가 정규화 되면 row update는 하나의 index record의 업데이트만 필요하게 됩니다.
추가로, InnoDB는 일반적으로 row update를 in-place 로 합니다.
만약 오래된 Transaction이 MVCC 목적으로 row를 reference 해야한다면, MySQL은 예전 row를 rollback segment라는 특정 영역으로 복사합니다.
al-Khwarizmi의 birth year를 업데이트하는 예제로 다시 가봅시다.
id 4번의 birth-year는 in-place로 업데이트 됩니다.
또한, birth-year와 연관된 인덱스도 업데이트 됩니다.
그리고 예전 데이터는 rollback segment로 복사되죠.
primary key는 업데이트가 필요없습니다.
또한, (first,last) index도 업데이트가 필요 없죠.
훨씬 더 많은 인덱스가 존재하더라도 birth-year와 관련된 인덱스 빼고는 업데이트가 필요없습니다.
그것이 Postgres와의 차이입니다.
Replication
MySQL은 다양한 replication mode를 지원합니다.
- Statement-based replication은 SQL statement를 복제합니다.
- Row-based replication은 변경된 row record를 복제합니다.
- Mixed replication은 두 가지를 혼합합니다.
각 방법에는 trade off가 있긴합니다.
Statement-based replication은 가장 compact하지만, 적은 데이터 변경에도 replica에 비싼 state 변경을 적용해야합니다.
반면, row-based replication은 Postgres의 WAL replication과 비슷한데, 더 장황하지만 복제본에 대해 더 예측가능하고 효율적인 업데이트를 지원합니다.
MySQL에서는 Primary index만이 on-disk offset에 대한 pointer를 가지고 있습니다.
이것은 replication 관점에서 큰 요소입니다.
MySQL replication stream은 오직 row에 대한 logical update 정보만을 포함합니다.
즉, MySQL의 replication은 PostgreSQL의 WAL stream보다 훨신 작다는 것입니다.
MySQL replication stream은 논리적인 update만 가지고 있기에, replica는 true MVCC semantics를 가질 수 있습니다.
그러므로 replica에 대한 read query는 replication stream을 막지 않습니다.
반면, Postgres WQL stream은 물리적인 변경을 포함하기에, Postgres replica는 read query와 충돌을 일으켜 repliaction을 적용할 수 없습니다.따라서, MVCC를 구현할 수 없습니다.
따라서, MySQL의 Replicaion 아키텍처는 버그로 인해 테이블 손상이 발생해도 문제가 치명적인 시랲를 일으킬 가능성이 낮습니다.
Replication은 논리적인 계층에서 발생하므로 B-tree 리밸런싱과 같은 작업으로 인해 인덱스가 손상하는 일이 발생하지 않습니다.
일반적인 MySQL Replication 문제는 명령문을 건너 뛰거나 2번 적용되는 경우인데 이로 인해 데이터가 누락되거나 유효하지 않을 수 있지만 데이터베이스 중단은 발생하지 않습니다.
마지막으로, MySQL의 복제 아키텍처는 서로 다른 MySQL 릴리즈간에 복제를 쉽게합니다.
MySQL은 복제 형식이 변경 되는 경우에만 버전을 증가시킵니다.
MySQL의 논리적 복제 형식은 스토리지 엔진 계층의 디스크 내 변경사항이 복제 형식에 영향을 미치지 않는다는 의미이기도합니다.
MySQL 업그레이드를 수행하는 일반적인 방법은 한번에 하나의 복제본에 업데이트를 적용하고 모든 복제본을 업데이트하면 그 중 하나를 새 마스터로 승격하는 것입니다.
이 작업은 다운타임이 거의 없이 수행될 수 있으며 MySQL을 최신 상태로 유지하는 것을 단순화 합니다.
Buffer Pool
캐싱은 Postgres와 다르게 작동합니다.
Postgres는 커널을 활용해서 캐시를 자동으로 적용합니다.
그래서 전체 머신이 가지고 있는 메모리보다 훨씬 작은 용량을 캐시로 사용하게됩니다.
예를들면, 768GB의 메모리가 있다고하면 25GB만을 사용할 수 있습니다.
반면, InnoDB의 스토리지 엔진은 자체 LRU를 구현하고 있으며 이를 InnoDB Buffer Pool이라고 합니다.
논리적으로 리눅스 page cache와 비슷하지만 userspace에 구현되어있습니다.
따라서, Postgres의 디자인보다 복잡하긴하지만, InnoDB는 더 큰 upside를 가지고 있습니다.
- custom LRU 디자인 구현을 가능하게 합니다.
- context switch가 적습니다 : InnoDB 버퍼 풀에 접근하는 데이터는 user/kernel context switch가 필요없습니다.
Connection Handling
MySQL은 연결당 쓰레드를 생성하여 동시 연결을 구현합니다.
상대적으로 낮은 오버헤드입니다.
각 스레드에는 스택 공간에 대한 약간의 메모리 오버헤드와 연결별 버퍼를 위해 힙에 할당된 일부 메모리가 있습니다.
MySQL은 10000개정도의 동시 연결로 확장하는것은 드문일이 아니며, 실제로도 일부는 그렇게 쓰고 있습니다.
그러나 Postgres는 연결당 프로세스를 사용합니다.
이것은 쓰레드를 사용하는것 보다 비싸고 메모리를 더 많이 차지합니다.
개발 및 IT 관련 포스팅을 작성 하는 블로그입니다.
IT 기술 및 개인 개발에 대한 내용을 작성하는 블로그입니다. 많은 분들과 소통하며 의견을 나누고 싶습니다.