[번역] Uber Engineering이 Postgres에서 MySQL로 전환한 이유
https://eng.uber.com/postgres-to-mysql-migration/를 번역.
소개
Uber의 초기 아키텍처는 데이터 지속성 을 위해 Postgres 를 사용하는 Python으로 작성된 모놀리식 백엔드 애플리케이션으로 구성되었습니다 . 그 이후로 Uber의 아키텍처는 마이크로서비스 및 새로운 데이터 플랫폼 모델로 크게 변경되었습니다 . 특히, 이전에 Postgres를 사용했던 많은 경우에 이제 MySQL 위에 구축된 새로운 데이터베이스 샤딩 레이어인 Schemaless를 사용 합니다. 이 기사에서는 Postgres에서 발견한 몇 가지 단점을 살펴보고 MySQL을 기반으로 Schemaless 및 기타 백엔드 서비스를 구축하기로 한 결정에 대해 설명합니다.
Postgres의 아키텍처
많은 Postgres 제한 사항이 발생했습니다.
- 쓰기를 위한 비효율적인 아키텍처
- 비효율적인 데이터 복제
- 테이블 손상 문제
- 열악한 복제본 MVCC 지원
- 최신 릴리스로 업그레이드하기 어려움
특히 MySQL이 InnoDB 스토리지 엔진으로 동일한 데이터를 표현하는 방식과 비교할 때 디스크에서 Postgres의 테이블 및 인덱스 데이터 표현 분석을 통해 이러한 모든 제한 사항을 살펴볼 것 입니다. 여기에서 제공하는 분석은 주로 다소 오래된 Postgres 9.2 릴리스 시리즈에 대한 경험을 기반으로 합니다. 우리가 아는 한, 이 기사에서 논의하는 내부 아키텍처는 최신 Postgres 릴리스에서 크게 변경되지 않았으며 9.2에서 온디스크 표현의 기본 디자인은 최소한 Postgres 8.3 릴리스(현재 거의 10 살이에요).
온디스크 형식
관계형 데이터베이스는 몇 가지 주요 작업을 수행해야 합니다.
- 삽입/업데이트/삭제 기능 제공
- 스키마 변경을 위한 기능 제공
- 서로 다른 연결이 작업하는 데이터에 대한 트랜잭션 보기를 갖도록 다중 버전 동시성 제어 (MVCC) 메커니즘을 구현합니다.
이러한 모든 기능이 함께 작동하는 방식을 고려하는 것은 데이터베이스가 디스크의 데이터를 나타내는 방식을 설계하는 데 필수적인 부분입니다.
Postgres의 핵심 디자인 측면 중 하나는 변경할 수 없는 행 데이터입니다. 이러한 변경 불가능한 행을 Postgres 용어로 "튜플"이라고 합니다. 이 튜플은 Postgres가 ctid 라고 부르는 것으로 고유하게 식별됩니다 . CTID는 개념적 튜플 디스크상의 위치 (즉, 물리적 디스크 오프셋)를 나타낸다. 여러 ctid 는 잠재적으로 단일 행을 설명할 수 있습니다(예: MVCC 목적으로 행의 여러 버전이 존재하거나 이전 버전의 행이 autovacuum 프로세스에 의해 아직 회수되지 않은 경우 ). 조직화된 튜플 모음이 테이블을 형성합니다. 테이블 자체에는 인덱스 필드를 ctid 페이로드에 매핑하는 데이터 구조(일반적으로 B-트리)로 구성된 인덱스가 있습니다 .
일반적으로 이러한 ctid 는 사용자에게 투명하지만 작동 방식을 알면 Postgres 테이블의 디스크 구조를 이해하는 데 도움이 됩니다. 행 의 현재 ctid 를 보려면 WHERE 절의 열 목록에 " ctid "를 추가 하면 됩니다 .
uber@[로컬] uber=> ctid 선택, * FROM my_table LIMIT 1;
-[ 레코드 1 ]--------+------------------------------
ctid | (0,1)
...여기에 다른 필드...
레이아웃의 세부 사항을 설명하기 위해 간단한 사용자 테이블의 예를 살펴보겠습니다. 각 사용자에 대해 자동 증가 사용자 ID 기본 키, 사용자의 이름과 성, 사용자의 출생 연도가 있습니다. 또한 사용자의 전체 이름(이름 및 성)에 대한 복합 보조 색인과 사용자의 출생 연도에 대한 또 다른 보조 색인을 정의합니다. 이러한 테이블을 생성하기 위한 DDL 은 다음과 같을 수 있습니다.
|
이 정의에서 세 개의 인덱스, 즉 기본 키 인덱스와 우리가 정의한 두 개의 보조 인덱스에 주목하십시오.
이 기사의 예를 위해 영향력 있는 역사적 수학자들로 구성된 표의 다음 데이터부터 시작하겠습니다.
| ID | 첫 번째 | 마지막 | 출생 년도 |
| 1 | 블레즈 | 파스칼 | 1623 |
| 2 | 고트프리트 | 라이프니츠 | 1646 |
| 삼 | 에미 | 뇌테르 | 1882년 |
| 4 | 무함마드 | 알 콰리즈미 | 780 |
| 5 | 앨런 | 튜링 | 1912년 |
| 6 | 스리니바사 | 라마누잔 | 1887년 |
| 7 | 에이다 | 난봉꾼 | 1815년 |
| 8 | 앙리 | 푸앵카레 | 1854년 |
앞에서 설명한 것처럼 이러한 각 행에는 암시적으로 고유하고 불투명한 ctid 가 있습니다. 따라서 다음과 같이 테이블의 내부 표현을 생각할 수 있습니다.
| ctid | ID | 첫 번째 | 마지막 | 출생 년도 |
| NS | 1 | 블레즈 | 파스칼 | 1623 |
| NS | 2 | 고트프리트 | 라이프니츠 | 1646 |
| 씨 | 삼 | 에미 | 뇌테르 | 1882년 |
| NS | 4 | 무함마드 | 알 콰리즈미 | 780 |
| 이자형 | 5 | 앨런 | 튜링 | 1912년 |
| NS | 6 | 스리니바사 | 라마누잔 | 1887년 |
| NS | 7 | 에이다 | 난봉꾼 | 1815년 |
| 시간 | 8 | 앙리 | 푸앵카레 | 1854년 |
id 를 ctids 에 매핑하는 기본 키 인덱스 는 다음과 같이 정의됩니다.
| ID | ctid |
| 1 | NS |
| 2 | NS |
| 삼 | 씨 |
| 4 | NS |
| 5 | 이자형 |
| 6 | NS |
| 7 | NS |
| 8 | 시간 |
B-트리는 id 필드 에 정의되며 B-트리의 각 노드는 ctid 값을 보유합니다 . 이 경우 B-트리의 필드 순서는 자동 증가 id 사용으로 인해 테이블의 순서와 동일하게 발생 하지만 반드시 그럴 필요는 없습니다.
보조 인덱스는 비슷해 보입니다. 주요 차이점은 B-트리가 사전순으로 구성되어야 하므로 필드가 다른 순서로 저장된다는 것입니다. ( first , last ) 인덱스는 알파벳의 위쪽을 향하는 이름으로 시작합니다.
| 첫 번째 | 마지막 | ctid |
| 에이다 | 난봉꾼 | NS |
| 앨런 | 튜링 | 이자형 |
| 블레즈 | 파스칼 | NS |
| 에미 | 뇌테르 | 씨 |
| 고트프리트 | 라이프니츠 | NS |
| 앙리 | 푸앵카레 | 시간 |
| 무함마드 | 알 콰리즈미 | NS |
| 스리니바사 | 라마누잔 | NS |
마찬가지로, birth_year 인덱스는 다음과 같이 오름차순으로 클러스터링됩니다.
| 출생 년도 | ctid |
| 780 | NS |
| 1623 | NS |
| 1646 | NS |
| 1815년 | NS |
| 1854년 | 시간 |
| 1887년 | NS |
| 1882년 | 씨 |
| 1912년 | 이자형 |
보시다시피, 이 두 경우 모두 자동 증가 기본 키의 경우와 달리 각 보조 인덱스 의 ctid 필드는 사전순으로 증가하지 않습니다.
이 테이블의 레코드를 업데이트해야 한다고 가정합니다. 예를 들어, al-Khwārizmī의 출생 연도인 770 CE의 또 다른 추정치를 위해 출생 연도 필드를 업데이트한다고 가정해 보겠습니다. 앞에서 언급했듯이 행 튜플은 변경할 수 없습니다. 따라서 레코드를 업데이트하기 위해 테이블에 새 튜플을 추가합니다. 이 새로운 튜플에는 새로운 불투명한 ctid가 있으며 , 이를 I 이라고 합니다. Postgres는 I 의 새로운 활성 튜플과 D 의 이전 튜플 을 구별할 수 있어야 합니다 . 내부적으로 Postgres는 각 튜플 내에 버전 필드와 이전 튜플(있는 경우)에 대한 포인터를 저장합니다. 따라서 테이블의 새로운 구조는 다음과 같습니다.
| ctid | 이전 | ID | 첫 번째 | 마지막 | 출생 년도 |
| NS | 없는 | 1 | 블레즈 | 파스칼 | 1623 |
| NS | 없는 | 2 | 고트프리트 | 라이프니츠 | 1646 |
| 씨 | 없는 | 삼 | 에미 | 뇌테르 | 1882년 |
| NS | 없는 | 4 | 무함마드 | 알 콰리즈미 | 780 |
| 이자형 | 없는 | 5 | 앨런 | 튜링 | 1912년 |
| NS | 없는 | 6 | 스리니바사 | 라마누잔 | 1887년 |
| NS | 없는 | 7 | 에이다 | 난봉꾼 | 1815년 |
| 시간 | 없는 | 8 | 앙리 | 푸앵카레 | 1854년 |
| NS | NS | 4 | 무함마드 | 알 콰리즈미 | 770 |
al-Khwārizmī 행의 두 가지 버전이 존재하는 한 인덱스는 두 행에 대한 항목을 보유해야 합니다. 간결함을 위해 기본 키 인덱스를 생략하고 다음과 같은 보조 인덱스만 여기에 표시합니다.
| 첫 번째 | 마지막 | ctid |
| 에이다 | 난봉꾼 | NS |
| 앨런 | 튜링 | 이자형 |
| 블레즈 | 파스칼 | NS |
| 에미 | 뇌테르 | 씨 |
| 고트프리트 | 라이프니츠 | NS |
| 앙리 | 푸앵카레 | 시간 |
| 무함마드 | 알 콰리즈미 | NS |
| 무함마드 | 알 콰리즈미 | NS |
| 스리니바사 | 라마누잔 | NS |
| 출생 년도 | ctid |
| 770 | NS |
| 780 | NS |
| 1623 | NS |
| 1646 | NS |
| 1815년 | NS |
| 1854년 | 시간 |
| 1887년 | NS |
| 1882년 | 씨 |
| 1912년 | 이자형 |
이전 버전은 빨간색으로, 새 행 버전은 녹색으로 표시했습니다. 내부적으로 Postgres는 행 버전을 보유하는 다른 필드를 사용하여 가장 최근의 튜플을 결정합니다. 이 추가된 필드를 통해 데이터베이스는 최신 행 버전을 볼 수 없는 트랜잭션에 제공할 행 튜플을 결정할 수 있습니다.
Postgres에서 기본 인덱스와 보조 인덱스는 모두 디스크 상의 튜플 오프셋을 직접 가리킵니다. 튜플 위치가 변경되면 모든 인덱스를 업데이트해야 합니다.
복제
새 행을 테이블에 삽입할 때 스트리밍 복제가 활성화된 경우 Postgres는 이를 복제해야 합니다. 충돌 복구를 위해 데이터베이스는 이미 미리 쓰기 로그 (WAL)를 유지 관리하고 이를 사용하여 2단계 커밋 을 구현 합니다. WAL은 ACID 의 원자성 및 내구성 측면을 허용하기 때문에 스트리밍 복제가 활성화되지 않은 경우에도 데이터베이스는 이 WAL을 유지해야 합니다 .
갑작스러운 정전과 같이 데이터베이스가 예기치 않게 충돌하는 경우 어떻게 되는지 고려하여 WAL을 이해할 수 있습니다. WAL은 테이블 및 인덱스의 디스크 내용에 대해 데이터베이스가 계획하는 변경 사항의 원장을 나타냅니다. Postgres 데몬이 처음 시작될 때 프로세스는 이 원장의 데이터를 디스크의 실제 데이터와 비교합니다. 원장에 디스크에 반영되지 않은 데이터가 포함된 경우 데이터베이스는 WAL에 표시된 데이터를 반영하도록 모든 튜플 또는 인덱스 데이터를 수정합니다. 그런 다음 WAL에 나타나지만 부분적으로 적용된 트랜잭션(트랜잭션이 커밋되지 않았음을 의미)에서 가져온 모든 데이터를 롤백합니다.
Postgres는 마스터 데이터베이스의 WAL을 복제본으로 전송하여 스트리밍 복제를 구현합니다. 각 복제본 데이터베이스는 충돌 복구 중인 것처럼 효과적으로 작동하여 충돌 후 시작하는 것처럼 WAL 업데이트를 지속적으로 적용합니다. 스트리밍 복제와 실제 크래시 복구의 유일한 차이점은 "핫 스탠바이" 모드의 복제본은 스트리밍 WAL을 적용하는 동안 읽기 쿼리를 제공하는 반면, 실제로 크래시 복구 모드에 있는 Postgres 데이터베이스는 일반적으로 데이터베이스 인스턴스가 완료될 때까지 쿼리 제공을 거부합니다. 충돌 복구 프로세스.
WAL은 실제로 크래시 복구 목적으로 설계되었기 때문에 디스크 업데이트에 대한 낮은 수준의 정보가 포함되어 있습니다. WAL의 내용은 행 튜플과 디스크 오프셋(즉, 행 ctids ) 의 실제 디스크 상의 표현 수준에 있습니다. 복제본이 완전히 따라잡힐 때 Postgres 마스터 및 복제본을 일시 중지하면 복제본의 실제 디스크 콘텐츠는 마스터 바이트의 내용과 정확히 일치합니다. 따라서 rsync 와 같은 도구 는 손상된 복제본이 마스터와 함께 오래된 경우 손상된 복제본을 수정할 수 있습니다.
Postgres 디자인의 결과
Postgres의 설계 는 Uber 의 데이터 에 비효율성과 어려움을 초래했습니다 .
쓰기 증폭
Postgres 설계의 첫 번째 문제는 다른 맥락에서 쓰기 증폭 으로 알려져 있습니다. 일반적으로 쓰기 증폭은 SSD 디스크에 데이터를 쓰는 것과 관련된 문제를 나타냅니다. 작은 논리적 업데이트(예: 몇 바이트 쓰기)는 물리적 계층으로 변환될 때 훨씬 더 크고 비용이 많이 드는 업데이트가 됩니다. Postgres에서도 동일한 문제가 발생합니다. 이전 예에서 al-Khwārizmī의 출생 연도에 대한 작은 논리적 업데이트를 만들 때 최소 4개의 물리적 업데이트를 발행해야 했습니다.
- 새 행 튜플을 테이블스페이스에 씁니다.
- 새 튜플에 대한 레코드를 추가하도록 기본 키 인덱스를 업데이트합니다.
- ( 첫 번째 , 마지막 ) 인덱스를 업데이트하여 새 튜플에 대한 레코드를 추가합니다.
- 새 튜플에 대한 레코드를 추가 하려면 birth_year 인덱스를 업데이트하십시오.
실제로 이 네 가지 업데이트는 기본 테이블스페이스에 대한 쓰기만 반영합니다. 이러한 쓰기 각각은 WAL에도 반영되어야 하므로 디스크의 총 쓰기 횟수는 훨씬 더 많습니다.
여기서 주목할만한 것은 업데이트 2와 3입니다. 우리가 al-Khwārizmī의 출생 연도를 업데이트했을 때 실제로 그의 기본 키를 변경하지 않았으며 그의 성과 이름도 변경하지 않았습니다. 그러나 이러한 인덱스는 여전히 행 레코드에 대한 데이터베이스에 새 행 튜플을 생성하여 업데이트해야 합니다. 많은 수의 보조 인덱스가 있는 테이블의 경우 이러한 불필요한 단계로 인해 엄청난 비효율이 발생할 수 있습니다. 예를 들어, 12개의 인덱스가 정의된 테이블이 있는 경우 단일 인덱스로만 처리되는 필드에 대한 업데이트 는 새 행에 대한 ctid 를 반영하기 위해 12개 인덱스 모두로 전파되어야 합니다 .
복제
이 쓰기 증폭 문제는 복제가 디스크상의 변경 수준에서 발생하기 때문에 복제 계층으로 자연스럽게 변환됩니다. " ctid D 의 출생 연도 를 현재 770으로 변경하십시오"와 같은 작은 논리적 레코드를 복제하는 대신 데이터베이스는 방금 설명한 4개의 쓰기 모두에 대해 WAL 항목을 기록하고 이러한 WAL 항목 4개는 모두 다음으로 전파됩니다. 네트워크. 따라서 쓰기 증폭 문제는 복제 증폭 문제로 변환되고 Postgres 복제 데이터 스트림은 빠르게 매우 장황해져서 잠재적으로 많은 양의 대역폭을 차지할 수 있습니다.
Postgres 복제가 단일 데이터 센터 내에서만 발생하는 경우 복제 대역폭은 문제가 되지 않을 수 있습니다. 최신 네트워킹 장비 및 스위치는 많은 양의 대역폭을 처리할 수 있으며 많은 호스팅 제공업체는 데이터 센터 내 대역폭을 무료 또는 저렴한 가격으로 제공합니다. 그러나 데이터 센터 간에 복제가 발생해야 하는 경우 문제가 빠르게 확대될 수 있습니다. 예를 들어 Uber는 원래 서부 해안의 코로케이션 공간에서 물리적 서버를 사용했습니다. 재해 복구를 위해 두 번째 동부 해안 코로케이션 공간에 서버를 추가했습니다. 이 설계에서는 서부 데이터 센터에 마스터 Postgres 인스턴스(복제본 포함)가 있고 동부 데이터 센터에 복제본 세트가 있습니다.
계단식 복제두 번째 데이터 센터에 많은 복제본이 있는 경우에도 데이터 센터 간 대역폭 요구 사항을 마스터와 단일 복제본 간에 필요한 복제 양으로 제한합니다. 그러나 Postgres 복제 프로토콜의 장황함으로 인해 많은 인덱스를 사용하는 데이터베이스에 대해 여전히 압도적인 양의 데이터가 발생할 수 있습니다. 매우 높은 대역폭의 국가 간 링크를 구입하는 것은 비용이 많이 들고, 돈이 문제가 되지 않는 경우에도 로컬 상호 연결과 동일한 대역폭을 가진 국가 간 네트워킹 링크를 얻는 것이 불가능합니다. 이 대역폭 문제는 WAL 보관 문제도 발생시켰습니다. 모든 WAL 업데이트를 West Coast에서 East Coast로 보내는 것 외에도 모든 WAL을 파일 저장소 웹 서비스에 보관했습니다. 재해 발생 시 데이터를 복원할 수 있고 보관된 WAL이 데이터베이스 스냅샷에서 새 복제본을 가져올 수 있다는 추가 보증을 위해. 초기 트래픽이 최고조에 달했을 때 스토리지 웹 서비스에 대한 대역폭은 WAL이 기록되는 속도를 따라갈 만큼 충분히 빠르지 않았습니다.
데이터 손상
데이터베이스 용량을 늘리기 위한 일상적인 마스터 데이터베이스 승격 중에 Postgres 9.2 버그가 발생했습니다. 복제본이 타임라인 전환을 잘못 따랐기 때문에 일부 WAL 레코드가 잘못 적용되었습니다. 이 버그로 인해 버전 관리 메커니즘에 의해 비활성으로 표시되어야 하는 일부 레코드가 실제로 비활성으로 표시되지 않았습니다.
다음 쿼리는 이 버그가 사용자 테이블 예제에 어떤 영향을 미치는지 보여줍니다.
SELECT * FROM 사용자 WHERE id = 4;
이 쿼리는 780년 출생 연도가 있는 원래 al-Khwārizmī 행과 770년 출생 연도가 있는 새로운 al-Khwārizmī 행이라는 두 개의 레코드를 반환합니다. 우리가 추가한다면 CTID를 받는 WHERE 목록, 우리는 다른 볼 것이다 CTID의 하나는 두 가지 행 튜플에 대한 기대로, 두 반환 된 레코드의 값을.
이 문제는 몇 가지 이유로 매우 골치 아픈 문제였습니다. 먼저 이 문제가 영향을 미친 행 수를 쉽게 알 수 없었습니다. 데이터베이스에서 반환된 중복된 결과로 인해 여러 경우에 애플리케이션 논리가 실패했습니다. 우리는 결국 이 문제가 있는 것으로 알려진 테이블의 상황을 감지하기 위해 방어 프로그래밍 문을 추가했습니다. 버그가 모든 서버에 영향을 미쳤기 때문에 손상된 행은 서로 다른 복제본 인스턴스에서 달랐습니다. 즉, 한 복제본에서는 X 행 이 불량이고 Y 행 이 양호할 수 있지만 다른 복제본에서는 X 행 이 양호하고 Y 행이 양호 할 수 있습니다. 나쁜. 사실 우리는 손상된 데이터가 있는 복제본의 수와 문제가 마스터에 영향을 미쳤는지 여부에 대해 확신이 없었습니다.
우리가 알 수 있는 바에 따르면 문제는 데이터베이스당 몇 개의 행에서만 나타났지만 복제가 물리적 수준에서 발생하기 때문에 데이터베이스 인덱스가 완전히 손상될 수 있다는 점에 대해 매우 걱정했습니다. B-트리의 필수 측면은 주기적으로 균형을 재조정 해야 하며 , 이러한 재조정 작업은 하위 트리가 디스크의 새 위치로 이동될 때 트리의 구조를 완전히 변경할 수 있습니다. 잘못된 데이터가 이동되면 트리의 많은 부분이 완전히 무효화될 수 있습니다.
결국 우리는 실제 버그를 추적하고 이를 사용하여 새로 승격된 마스터에 손상된 행이 없는지 확인할 수 있었습니다. 우리는 마스터의 새로운 스냅샷에서 모든 복제본을 재동기화하여 복제본의 손상 문제를 수정했습니다. 이는 힘든 과정입니다. 로드 밸런싱 풀에서 한 번에 몇 개의 복제본을 가져갈 수 있는 충분한 용량만 있었습니다.
우리가 만난 버그는 Postgres 9.2의 특정 릴리스에만 영향을 미치며 오랫동안 수정되었습니다. 그러나 우리는 여전히 이러한 종류의 버그가 발생할 수 있다는 점에 대해 우려하고 있습니다. 이러한 특성의 버그가 있는 새 버전의 Postgres는 언제든지 릴리스될 수 있으며 복제 작동 방식 때문에 이 문제는 복제 계층 구조의 모든 데이터베이스로 확산될 가능성이 있습니다.
복제본 MVCC
Postgres에는 진정한 복제본 MVCC 지원이 없습니다. 복제본이 WAL 업데이트를 적용한다는 사실은 주어진 시점에 마스터 와 동일한 온디스크 데이터 복사본을 갖게 됩니다. 이 디자인은 Uber에 문제를 제기합니다.
Postgres는 MVCC에 대한 이전 행 버전의 복사본을 유지 관리해야 합니다. 스트리밍 복제본에 열린 트랜잭션이 있는 경우 트랜잭션에 의해 열려 있는 행에 영향을 미치는 경우 데이터베이스 업데이트가 차단됩니다. 이 상황에서 Postgres는 트랜잭션이 종료될 때까지 WAL 애플리케이션 스레드를 일시 중지합니다. 이는 복제본이 마스터보다 심하게 지연될 수 있으므로 트랜잭션에 오랜 시간이 걸리는 경우 문제가 됩니다. 따라서 Postgres는 다음과 같은 상황에서 시간 초과를 적용합니다. 트랜잭션이 설정된 시간 동안 WAL 애플리케이션을 차단하면 Postgres는 해당 트랜잭션을 종료합니다.
이 디자인은 복제본이 정기적으로 마스터보다 몇 초 뒤떨어질 수 있음을 의미하므로 종료된 트랜잭션을 초래하는 코드를 작성하기 쉽습니다. 이 문제는 트랜잭션 시작 및 종료 위치를 모호하게 하는 코드를 작성하는 애플리케이션 개발자에게는 분명하지 않을 수 있습니다. 예를 들어 개발자가 사용자에게 영수증을 이메일로 보내야 하는 코드가 있다고 가정해 보겠습니다. 작성 방법에 따라 코드에는 이메일 전송이 완료될 때까지 열린 상태로 유지되는 데이터베이스 트랜잭션이 암시적으로 있을 수 있습니다. 관련 없는 차단 I/O를 수행하는 동안 코드가 열린 데이터베이스 트랜잭션을 유지하도록 하는 것은 항상 나쁜 형식이지만, 현실은 대부분의 엔지니어가 데이터베이스 전문가가 아니며 특히 낮은 수준의 세부 정보를 모호하게 하는 ORM을 사용할 때 이 문제를 항상 이해하지 못할 수도 있습니다. 열린 거래처럼.
포스트그레스 업그레이드
복제 레코드는 물리적 수준에서 작동하기 때문에 Postgres의 다른 일반 공급 릴리스 간에 데이터를 복제할 수 없습니다. Postgres 9.3을 실행하는 마스터 데이터베이스는 Postgres 9.2를 실행하는 복제본에 복제할 수 없으며 9.2를 실행하는 마스터는 Postgres 9.3을 실행하는 복제본에 복제할 수 없습니다.
우리는 다음 단계를 하나의 포스트 그레스 GA 릴리스에서 다른 업그레이드 :
- 마스터 데이터베이스를 종료합니다.
- 마스터에서 pg_upgrade 라는 명령을 실행 하여 마스터 데이터를 제자리에서 업데이트합니다. 이것은 큰 데이터베이스의 경우 쉽게 몇 시간이 걸릴 수 있으며 이 프로세스가 진행되는 동안 마스터에서 트래픽을 제공할 수 없습니다.
- 마스터를 다시 시작합니다.
- 마스터의 새 스냅샷을 만듭니다. 이 단계는 마스터의 모든 데이터를 완전히 복사하므로 대용량 데이터베이스의 경우에도 많은 시간이 걸립니다.
- 각 복제본을 지우고 마스터에서 복제본으로 새 스냅샷을 복원합니다.
- 각 복제본을 복제 계층으로 다시 가져옵니다. 복제본이 복원되는 동안 마스터가 적용한 모든 업데이트를 복제본이 완전히 따라잡을 때까지 기다립니다.
Postgres 9.1로 시작하여 Postgres 9.2로 이동하기 위한 업그레이드 프로세스를 성공적으로 완료했습니다. 그러나 그 과정은 너무 많은 시간이 걸렸기 때문에 우리는 그 과정을 다시 할 여유가 없었습니다. Postgres 9.3이 나올 즈음에는 Uber의 성장으로 데이터 세트가 크게 증가했기 때문에 업그레이드 시간이 훨씬 더 길어졌을 것입니다. 이러한 이유로 기존 Postgres 인스턴스는 현재 Postgres GA 릴리스가 9.5임에도 불구하고 현재까지 Postgres 9.2를 실행합니다.
당신이 포스트 그레스 9.4 이상을 실행하는 경우, 당신은 같은 것을 사용할 수 pglogical , 포스트 그레스에 대한 논리적 복제 계층을 구현합니다. pglogical을 사용하면 다른 Postgres 릴리스 간에 데이터를 복제할 수 있습니다. 즉, 상당한 다운타임 없이 9.4에서 9.5로 업그레이드를 수행할 수 있습니다. 이 기능은 Postgres 메인라인 트리에 통합되지 않았고 pglogical은 여전히 이전 Postgres 릴리스에서 실행하는 사람들을 위한 옵션이 아니기 때문에 여전히 문제가 있습니다.
MySQL의 아키텍처
Postgres의 몇 가지 제한 사항을 설명하는 것 외에도 MySQL이 Schemaless와 같은 최신 Uber Engineering 스토리지 프로젝트에 중요한 도구인 이유도 설명합니다. 많은 경우에 MySQL이 우리의 용도에 더 적합하다는 것을 알게 되었습니다. 차이점을 이해하기 위해 MySQL의 아키텍처와 이것이 Postgres의 아키텍처와 어떻게 대조되는지 살펴봅니다. MySQL이 InnoDB 스토리지 엔진 과 어떻게 작동하는지 구체적으로 분석합니다 . Uber에서 InnoDB를 사용할 뿐만 아니라; 아마도 가장 인기 있는 MySQL 스토리지 엔진일 것입니다.
InnoDB 온디스크 표현
Postgres와 마찬가지로 InnoDB는 MVCC 및 변경 가능한 데이터와 같은 고급 기능을 지원합니다. InnoDB의 온디스크 형식에 대한 철저한 논의는 이 기사의 범위를 벗어납니다. 대신 Postgres와의 핵심적인 차이점에 중점을 둘 것입니다.
가장 중요한 아키텍처 차이점은 Postgres가 인덱스 레코드를 디스크 상의 위치에 직접 매핑하는 반면 InnoDB는 2차 구조를 유지한다는 것입니다. 디스크 상의 행 위치에 대한 포인터를 보유하는 대신( Postgres 에서 ctid 가 수행 하는 것처럼 ), InnoDB 보조 인덱스 레코드는 기본 키 값에 대한 포인터를 보유합니다. 따라서 MySQL의 보조 인덱스는 인덱스 키를 기본 키와 연결합니다.
| 첫 번째 | 마지막 | id(기본 키) |
| 에이다 | 난봉꾼 | 7 |
| 앨런 | 튜링 | 5 |
| 블레즈 | 파스칼 | 1 |
| 에미 | 뇌테르 | 삼 |
| 고트프리트 | 라이프니츠 | 2 |
| 앙리 | 푸앵카레 | 8 |
| 무함마드 | 알 콰리즈미 | 4 |
| 스리니바사 | 라마누잔 | 6 |
(첫 번째, 마지막) 인덱스에 대한 인덱스 조회를 수행하려면 실제로 두 번의 조회를 수행해야 합니다. 첫 번째 조회는 테이블을 검색하고 레코드의 기본 키를 찾습니다. 기본 키가 발견되면 두 번째 조회는 기본 키 인덱스를 검색하여 행에 대한 디스크 상의 위치를 찾습니다.
이 디자인은 InnoDB가 Postgres에 대해 하나만 검색하는 것과 비교하여 InnoDB로 두 개의 인덱스를 검색해야 하기 때문에 보조 키 조회를 수행할 때 Postgres에 약간 불리하다는 것을 의미합니다. 그러나 데이터가 정규화되기 때문에 행 업데이트는 행 업데이트에 의해 실제로 변경된 인덱스 레코드만 업데이트하면 됩니다. 또한 InnoDB는 일반적으로 제자리에서 행 업데이트를 수행합니다. 오래된 트랜잭션이 MVCC 목적으로 행을 참조해야 하는 경우 MySQL은 이전 행을 롤백 세그먼트 라고 하는 특수 영역에 복사합니다 .
al-Khwārizmī의 출생 연도를 업데이트하면 어떻게 되는지 알아보겠습니다. 공백이 있으면 ID가 4인 행의 출생 연도 필드가 제자리에서 업데이트됩니다(사실 이 업데이트는 출생 연도가 고정된 공간을 차지하는 정수이기 때문에 항상 제자리에서 발생합니다). 출생 연도 인덱스도 새 날짜를 반영하도록 업데이트됩니다. 이전 행 데이터는 롤백 세그먼트에 복사됩니다. 기본 키 인덱스를 업데이트해야 않으며, (하지 않는 첫 번째 , 마지막 ) 이름 인덱스를. 이 테이블에 많은 수의 인덱스가 있는 경우에도 실제로 birth_year 필드를 인덱스하는 인덱스만 업데이트하면 됩니다. signup_date 와 같은 필드에 대한 인덱스가 있다고 가정해 보겠습니다. , last_login_time ., 등등. 우리는 이러한 인덱스를 업데이트할 필요가 없지만 Postgres는 업데이트해야 합니다.
이 디자인은 또한 진공 청소 및 압축을 보다 효율적으로 만듭니다. 진공 처리할 수 있는 모든 행은 롤백 세그먼트에서 직접 사용할 수 있습니다. 그에 비해 Postgres autovacuum 프로세스는 삭제된 행을 식별하기 위해 전체 테이블 스캔을 수행해야 합니다.
MySQL은 간접 참조의 추가 계층을 사용합니다. 보조 인덱스 레코드는 기본 인덱스 레코드를 가리키고 기본 인덱스 자체는 디스크의 행 위치를 보유합니다. 행 오프셋이 변경되면 기본 인덱스만 업데이트하면 됩니다.
복제
MySQL은 다양한 복제 모드를 지원 합니다 .
- 문 기반 복제는 논리적 SQL 문을 복제합니다(예: 문자 그대로 다음과 같은 리터럴 문을 복제합니다. 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 복제 바이너리 로그가 PostgreSQL WAL 스트림보다 훨씬 더 컴팩트하다는 것을 의미합니다.
각 복제 스트림이 작동하는 방식은 MVCC가 복제본과 작동하는 방식에 중요한 영향을 미칩니다. MySQL 복제 스트림에는 논리적 업데이트가 있으므로 복제본은 진정한 MVCC 의미를 가질 수 있습니다. 따라서 복제본에 대한 읽기 쿼리는 복제 스트림을 차단하지 않습니다. 대조적으로 Postgres WAL 스트림에는 물리적 디스크 상의 변경 사항이 포함되어 있으므로 Postgres 복제본은 읽기 쿼리와 충돌하는 복제 업데이트를 적용할 수 없으므로 MVCC를 구현할 수 없습니다.
MySQL의 복제 아키텍처는 버그로 인해 테이블 손상이 발생하더라도 그 문제로 인해 치명적인 오류가 발생하지 않을 가능성이 있습니다. 복제는 논리적 계층에서 발생하므로 B-트리 재조정과 같은 작업으로 인해 인덱스가 손상될 수 없습니다. 일반적인 MySQL 복제 문제는 명령문이 건너뛰는 경우(또는 덜 자주 두 번 적용됨)입니다. 이로 인해 데이터가 누락되거나 유효하지 않을 수 있지만 데이터베이스 중단은 발생하지 않습니다.
마지막으로 MySQL의 복제 아키텍처는 다른 MySQL 릴리스 간에 복제하는 것을 간단하게 만듭니다. MySQL은 복제 형식이 변경되는 경우에만 버전을 증가시킵니다. 이는 다양한 MySQL 릴리스에서 드문 일입니다. MySQL의 논리적 복제 형식은 또한 스토리지 엔진 계층의 디스크 상의 변경 사항이 복제 형식에 영향을 미치지 않는다는 것을 의미합니다. MySQL 업그레이드를 수행하는 일반적인 방법은 한 번에 하나의 복제본에 업데이트를 적용하고 모든 복제본을 업데이트한 후 그 중 하나를 새 마스터로 승격하는 것입니다. 이것은 거의 다운타임 없이 수행할 수 있으며 MySQL을 최신 상태로 유지하는 것을 단순화합니다.
기타 MySQL 설계 이점
지금까지 Postgres 및 MySQL용 온디스크 아키텍처에 중점을 두었습니다. MySQL 아키텍처의 다른 중요한 측면도 Postgres보다 훨씬 더 나은 성능을 제공합니다.
버퍼 풀
첫째, 캐싱은 두 데이터베이스에서 다르게 작동합니다. Postgres는 내부 캐시에 일부 메모리를 할당하지만 이러한 캐시는 일반적으로 시스템의 총 메모리 양에 비해 작습니다. 성능을 향상시키기 위해 Postgres는 커널이 페이지 캐시 를 통해 최근에 액세스한 디스크 데이터를 자동으로 캐시할 수 있도록 합니다 . 예를 들어, 가장 큰 Postgres 복제본은 768GB의 메모리를 사용할 수 있지만 이 메모리 중 약 25GB만 실제로 Postgres 프로세스에서 오류가 발생한 RSS 메모리 입니다. 이렇게 하면 Linux 페이지 캐시에 700GB 이상의 메모리를 사용할 수 있습니다.
이 디자인의 문제는 페이지 캐시를 통해 데이터에 액세스하는 것이 RSS 메모리에 액세스하는 것에 비해 실제로 다소 비용이 많이 든다는 것입니다. 디스크에서 데이터를 조회하기 위해 Postgres 프로세스는 lseek(2) 및 read(2) 시스템 호출을 실행하여 데이터를 찾습니다. 이러한 각 시스템 호출은 컨텍스트 전환을 발생시키며, 이는 주 메모리에서 데이터에 액세스하는 것보다 비용이 많이 듭니다. 사실, Postgres는 이와 관련하여 완전히 최적화되지도 않았습니다. Postgres는 단일 시스템 호출로 탐색 + 읽기 작업을 통합 하는 pread(2) 시스템 호출 을 사용하지 않습니다 .
이에 비해 InnoDB 스토리지 엔진은 InnoDB 버퍼 풀이 라고 하는 자체 LRU를 구현 합니다 . 이것은 논리적으로 Linux 페이지 캐시와 유사하지만 사용자 공간에서 구현됩니다. Postgres의 설계보다 훨씬 더 복잡하지만 InnoDB 버퍼 풀 설계에는 몇 가지 큰 장점이 있습니다.
- 이를 통해 맞춤형 LRU 설계를 구현할 수 있습니다. 예를 들어, LRU를 날려버릴 병리학적 접근 패턴을 감지하고 너무 많은 피해를 입히는 것을 방지할 수 있습니다.
- 결과적으로 컨텍스트 전환이 줄어듭니다. InnoDB 버퍼 풀을 통해 액세스되는 데이터에는 사용자/커널 컨텍스트 전환이 필요하지 않습니다. 최악 의 행동 은 TLB 미스 의 발생 으로 상대적 으로 저렴 하고 거대한 페이지 를 사용 하여 최소화할 수 있습니다 .
연결 처리
MySQL은 연결당 스레드를 생성하여 동시 연결을 구현합니다. 이것은 상대적으로 낮은 오버헤드입니다. 각 스레드에는 스택 공간에 대한 약간의 메모리 오버헤드와 연결별 버퍼에 대해 힙에 할당된 메모리가 있습니다. MySQL을 10,000개 정도의 동시 연결로 확장하는 것은 드문 일이 아니며 실제로 오늘날 일부 MySQL 인스턴스에서 이 연결 수에 가깝습니다.
그러나 Postgres는 연결당 프로세스 설계를 사용합니다. 이것은 여러 가지 이유로 연결당 스레드 설계보다 훨씬 더 비쌉니다. 새 프로세스를 분기하면 새 스레드를 생성하는 것보다 더 많은 메모리를 차지합니다. 또한 IPC는 스레드 간보다 프로세스 간 비용이 훨씬 더 많이 듭니다. Postgres 9.2는 경량 퓨텍스 대신 IPC용 System V IPC 프리미티브를 사용합니다. 스레드를 사용할 때 사용합니다. Futex가 경쟁하지 않는 일반적인 경우에는 컨텍스트 전환을 만들 필요가 없기 때문에 Futex는 System V IPC보다 빠릅니다.
Postgres의 설계와 관련된 메모리 및 IPC 오버헤드 외에도 Postgres는 사용 가능한 메모리가 충분하더라도 많은 연결 수를 처리하는 데 지원이 좋지 않은 것 같습니다. 수백 개의 활성 연결을 넘어서 Postgres를 확장하는 데 심각한 문제가 있었습니다. 하지만 문서는 이유에 대한 매우 구체적인하지 않습니다 , 그것은 강하게 포스트 그레스 큰 연결 카운트로 확장 할 아웃 - 오브 - 프로세스를 연결 풀링 메커니즘을 사용하는 것이 좋습니다 않습니다. 따라서 사용 pgbouncer를Postgres로 연결 풀링을 수행하는 것은 일반적으로 성공적이었습니다. 그러나 백엔드 서비스에서 서비스가 사용해야 하는 것보다 더 많은 활성 연결(일반적으로 "트랜잭션 유휴" 연결)을 열도록 하는 애플리케이션 버그가 있었고 이러한 버그로 인해 다운타임이 연장되었습니다.
결론
Postgres는 Uber의 초창기에 우리에게 큰 도움이 되었지만 성장에 따라 Postgres를 확장하는 데 심각한 문제에 부딪쳤습니다. 오늘날에는 일부 레거시 Postgres 인스턴스가 있지만 대부분의 데이터베이스는 MySQL(일반적으로 Schemaless 계층 사용) 위에 구축 되거나 일부 특수한 경우에는 Cassandra와 같은 NoSQL 데이터베이스에 구축됩니다. 우리는 일반적으로 MySQL에 매우 만족하며 Uber에서 MySQL의 고급 사용 중 일부를 설명하는 블로그 기사를 앞으로 더 많이 만들 수 있습니다.