본 글은 노션 블로그에 작성되어 있는 Garrett Fidalgo의 코끼리 방목 : Notion에서 Postgres를 샤딩하면서 얻은 교훈 ("코끼리"는 PostgreSQL의 마스코트라, 코끼리 방목이라는 표현을 사용한 것 같습니다)을 읽고 번역한 글입니다. 참고로 Notion 팀에 허락을 받고 번역한 글이 아니며, 따라서 해당 글은 언제든지 내려갈 수 있습니다.
또한 원본의 글 의미를 살리고자 최대한 직역하고자 노력했지만, 직역한 경우 너무 이해가 안되는 일부분은 의역하거나 역자의 설명을 달아두었습니다.
올해 초에 우리는 예정된 유지 관리를 위해 5분동안 Notion을 중단했습니다. 우리는 "향상된 안정성과 성능"을 암시했고, 이를 위해서 몇달동안 매우 긴박하고 집중적으로 팀 규모의 작업을 했습니다. 바로, Notion의 PostgreSQL 모놀리스(monolith)를 수평 분할된 데이터 플릿으로 샤딩하는 것이었죠.
우리는 이런 전환을 진행하는 동안, 사소한 영향들에 관해 조용히 했습니다만, 기쁘게도 사용자들은 이런 개선을 빠르게 알아차렸습니다.
@notionhq가 최근에 얼마나 빨라졌는지 놀랍습니다. "말하지 않고 보여주기"는 강력합니다.
- Guillermo Rauch
하지만 단일 유지 관리 기간이 전체 스토리를 말해주지는 않습니다. 우리 팀은 향후 몇 년동안의 Notion을 더 빠르고 안정적으로 만들기 위해 해당 마이그레이션을 설계하는데 몇 달을 보냈습니다. 우리가 어떻게 샤딩을 했고, 우리가 무엇을 배웠는지 알려주겠습니다.
샤딩은 애플리케이션의 성능을 개선하기 위한 우리의 목표들 중 중요한 마일스톤이었습니다. 지난 몇 년간, 더 많은 사람들이 삶의 일부분에서 노션을 사용하는 것을 보며 뿌듯했습니다. 그리고 이는 당연히 회사들의 모든 위키들, 프로젝트 추적기, Pokédexes(역: 포켓몬 백과사전)와 같은 저장해야 할 수십 억 개의 새로운 블록, 파일, 공간들을 의미했습니다. 2020년 중반까지, 우리 서비스가 5년의 시간 동안 4배의 성장을 해온 것을 보니, 우리가 지원할 수 있는 Postgres 모놀리스의 능력을 곧 넘을 것은 분명했습니다. on-call 상태의 엔지니어는 DB CPU가 치솟아 잠에서 깨는 경우가 종종 있었고 간단한 catalog-only 마이그레이션들은 안전하지 않고 불확실해졌습니다.(역 : catalog는 Postgresql에서 메타데이터 시스템 DB를 말합니다. 즉 스키마에 컬럼을 추가한다던지 등의 간단한 작업도 실행할 때조차 엔지니어들이 불안해했다는 것으로 받아들이면 될 것 같습니다.)
빠르게 성장하는 스타트업은 샤딩과 관련한 trade-off를 섬세하게 헤쳐나가야 합니다. 그동안 여러 블로그 글에서는 유지 관리 부담의 증가 애플리케이션 수준 코드의 새로운 제약 조건, 아키텍처 경로 종속성 등 샤딩의 위험성을 이미 설명해 왔습니다.(1) 물론 우리 규모에서 샤딩은 불가피했고 이는 단순히 언제 샤딩할지에 대한 문제였습니다.
우리가 샤딩을 결정하게 만든 시점은, Postgres의 VACUUM
프로세스가 계속 멈춰서 DB가 죽은 튜플들로부터 디스크 공간을 회수하지 못하는 상황에 도달했을 때였습니다. 디스크 용량을 늘릴 수도 있었지만, Postgres가 기존 데이터에 영향이 가지 않도록 모든 쓰기 처리를 중지할 수 있는 안전 메커니즘인 transaction ID(TXID) wraparound에 대한 걱정이 더 컸습니다. TXID wraparound가 프로덕트에 실질적 위협이 될 수 있기에, 우리 인프라 팀은 힘을 내서 일을 시작했습니다.
⚒ 역자의 설명 ⚒
먼저 이를 이해하기 위해서는, Postgres에서 동시성 처리를 어떻게 하는지 알아야 합니다.
Postgres는 동시성을 제어하기 위해 사용하는 방법으로 MVCC(Multi Version Concurrency Control)를 채택했습니다. 간단히 설명하자면, 데이터를 읽을 때는 특정 버전의 snapshot을 읽고, 데이터를 쓸 때는 버전을 증가시키며 데이터를 생성하고 사용자는 마지막 버전의 데이터를 읽게 됩니다. 참고로 여기서 트랜잭션마다 ID를 주고 실행하게 되는데, 본글에서는 이를 TXID라고 부릅니다.
여기서 두 가지 문제가 생깁니다.
첫 째, MVCC를 채택한 DB에서는 불필요한 튜플이 디스크에 남게 됩니다.
둘 째, Postgres에서 TXID는 32비트이며 약 40억개의 트랜잭션만 지원할 수 있기 때문에, TXID가 최대값에 도달하면 wraparound(overflow 감지 후 처리), 즉 0부터 다시 시작하게 만듭니다. 당연히 wraparound가 일어나게 되면, 과거 트랜잭션들은 아주 미래의 트랜잭션처럼 보이게 됩니다. 이렇게 되면 과거 트랜잭션이 실행되고 있지만 실제로는 반영되지 않는 기이한 현상이 일어나게 됩니다.
이 두 가지 문제를 해결하기 위해서 주기적으로 VACUUM
프로세스(auto_vacuum)를 실행하면 된다고 합니다.
VACUUM
은 Postgres에서 사용하는 일종의 Disk 관련 GC 프로세스라고 생각하면 됩니다.
업데이트로 삭제되거나 더 이상 사용되지 않은 튜플은 테이블에서 물리적으로 바로 제거되지 않고, VACUUM을 통해 처리합니다. 따라서 이를 통해 첫 번째 문제를 해결할 수 있습니다.
두 번째 문제는 완벽하게 해결되지 않는데, Postgres는 일반 TXID와 Frozen TXID를 함께 사용합니다. VACUUM 프로세스는 데이터의 버전을 고정시키기 때문에 이 과정에서 Frozen TXID와 함께 해당 row를 고정시키게 됩니다. 그런데 이 Frozen TXID는 모든 Normal TXID보다 과거에 처리된 것이라고 여겨지기 때문에 고정이 되었다면, wrapaorund가 일어나도 문제 없이 반영됩니다. 그러나 두 번째 문제는 이런 방법에도 불구하고 쓰기량이 급증하게 되면 wraparound가 일어나고 나서 데이터가 손실될 수 있습니다.
관련 참고 문헌
https://devcenter.heroku.com/articles/postgresql-concurrency
https://blog.sentry.io/2015/07/23/transaction-id-wraparound-in-postgres
https://www.postgresql.org/docs/9.3/sql-vacuum.html
https://www.postgresql.org/docs/current/routine-vacuuming.html#VACUUM-FOR-WRAPAROUND
점진적으로 더 무거운 인스턴스로 DB를 수직 확장하는 대신, 여러 DB에 걸쳐 데이터를 분할하여 수평 확장하는 것이 샤딩의 아이디어입니다. 성장을 위해 추가적인 호스트를 쉽게 가동할 수 있습니다. 하지만 안타깝게도 이제 데이터가 분산되어 있기 때문에 설정을 통해 성능과 일관성(consistency)를 최대화하는 시스템을 설계해야 합니다.
우리는 자체 파티셔닝 체계를 구현하고 애플리케이션 로직에서 쿼리를 라우팅하기로 했습니다. 초기에는 Postgres용 Citus와 MySQL용 Vitess와 같은 샤딩/클러스터링 솔루션도 고려했습니다. 그러나 이런 솔루션들은 단순하고 즉시 사용할 수 있는 cross-shard 도구들을 제공하지만, 실제 클러스터링 원리는 불투명해서 우리는 우리가 직접 데이터 분산을 제어할 수 있는 방법을 더 원했습니다. (2)
애플리케이션 레벨의 샤딩을 사용하려면 다음과 같은 설계 결정을 내려야 했습니다.
block
테이블이 여러 요소(크기, 깊이 및 분기)가 있는 다양한 사용자의 생성 콘텐츠 트리를 반영한다는 것입니다. 예를 들어, 한 명의 대기업 고객은 여러 개인 작업 공간을 합친 것보다 평균적으로 더 많은 부하를 만듭니다. 우리는 관련 데이터의 지역성을 유지하면서 필요한 테이블만 샤딩하고 싶었습니다.
⚒ 역자의 설명 ⚒
노션은 문서 작성과 관련하여 여러 단위를 가지고 있습니다. block
, workspace
, comment
등등 인데, 노션의 비즈니스 레이어에서 도메인적 개념들이라고 생각하시면 편합니다.
https://www.notion.so/blog/data-model-behind-notion
Notion의 데이터 모델은 데이터베이스의 한 row를 차지하는 블록의 개념이 중심이기 떄문에 block
테이블은 샤딩의 최우선 순위였습니다. 그러나 블록은 space
(workspaces)나 discussion
(page 수준 및 인라인 토론 스레드들) 같은 다른 테이블을 참조할 수 있습니다. 결과적으로 discussion
은 comment
의 테이블을 참조할 수도 있고.. 이런 식입니다.
우리는 일종의 FK 관계를 통해 block
테이블으로부터 도달할 수 있는 모든 테이블을 샤딩하기로 결정했습니다. 이러한 모든 테이블을 샤딩할 필요는 없지만 관련 블록이 다른 물리적 샤드에 저장되어 있는 동안 레코드가 기본 DB에 저장되어 있으면 다른 데이터 저장소에 쓸 때 불일치가 발생할 수 있습니다.
🎈 불일치 예시
블록이 삭제된 경우, 해당 블록과 관련한 comment도 업데이트 되어야 하는데, 각각에 저장소에 요청하다 하나는 성공하고, 하나는 실패하는 경우
샤딩할 테이블을 결정한 후에는 테이블을 나눠야 했습니다. 좋은 파티션 스키마를 선택하는 것은 데이터의 연결성과 분포에 의해 크게 좌우됩니다. Notion은 팀 기반 제품이기 때문에 다음 결정은 데이터를 워크스페이스 ID로 파티셔닝하는 것이었습니다.(3)
각 워크스페이스에는 생성 시 UUID가 할당되므로, 우리는 UUID 공간을 균일한 버켓들로 파티셔닝할 수 있습니다. 샤딩된 테이블의 각 row는 블록이거나 다른 것과 관련이 있는 것이었고 각 블록은 정확히 한 워크스페이스에만 속하기 때문에 워크스페이스 ID를 파티션 키로 사용했습니다. 사용자는 일반적으로 한 번에 단일 워크스페이스에서 데이터를 쿼리하기 때문에, 우리는 대부분의 cross-shard 조인을 피합니다.
Postgres를 샤딩하기 :
"1M Requests를 하는 1명의 사용자와 1개의 Request를 하는 1M 사용자와 싸우시겠습니까?”
파티셔닝 스키마를 결정한 우리의 목표는 기존 데이터를 처리하고 적은 노력으로 2년 사용량 예측을 충족할 수 있도록 확장할 수 있는 샤드 설정을 설계하는 것이었습니다. 다음은 몇 가지 제약 사항이었습니다.
수치를 계산한 후, 우리는 32개의 물리적 데이터베이스에 고르게 분산된 480개의 논리적 샤드로 이루어진 아키텍처를 결정했습니다. 계층 구조는 다음과 같습니다 :
block
테이블 (논리적 샤드당 1개, 총 480개)collection
테이블 (논리적 샤드당 1개, 총 480개)space
테이블 (논리적 샤드당 1개, 총 480개)🎈 왜 480개의 샤드인가?
480은 많은 수로 나눌 수 있습니다. 균일한 샤드 분포를 유지하면서 물리적 호스트를 추가하거나 제거할 수 있는 유연성을 제공합니다. 예를 들어, 32개에서 40개로 물리적 DB를 확장할 수 있습니다.
2의 거듭제곱 수로 설정하는 것이 컴퓨터 과학에서의 전부였는 줄 알았는데, 아니었습니다.
Pick values with a lot of factors!
우리는 15개의 자식 테이블이 있는 DB당 단일 파티션 block
테이블을 유지관리하는 대신 schema001.block
, schema002.block
등을 별도의 테이블로 구성하기로 결정했습니다. 기본적으로 분할된 테이블은 또 다른 라우팅 로직을 도입합니다.
우리는 워크스페이스 ID에서 논리적 샤드로 라우팅하기 위한 single source of truth(SSOT)를 원했기 때문에 테이블을 별도로 구성하고 애플리케이션에서 모든 라우팅을 수행하기로 결정했습니다.
샤딩 계획을 수립한 후에는 이를 구현해야 했습니다. 모든 마이그레이션의 경우, 우리의 일반적인 프레임워크는 이렇게 진행됩니다.
이중 쓰기 단계는 새 DB가 아직 사용되지 않더라도 새 데이터가 이전 DB와 새 DB를 모두 채우도록 합니다. 이중 쓰기에는 몇 가지 옵션이 있습니다.
우리는 논리적 복제보다 감사 로그 전략을 선택했습니다. 논리적 복제는 초기 스냅샷 단계에서 block
테이블 쓰기 볼륨을 따라잡기 위해 고군분투했기 때문입니다.
들어오는 쓰기가 새 DB로 성공적으로 전파되면 기존 데이터를 모두 마이그레이션하기 위해 backfill 프로세스를 시작했습니다. 프로비저닝한 m5.24xlarge
인스턴스에 96개의 CPU로, 우리의 최종 스크립트는 프로덕션 환경을 backfill하는데 3일 정도 걸렸습니다.
그만한 가치가 있는 모든 backfill은 오래된 데이터를 쓰기 전에 레코드 버전들을 비교하고 최신 업데이트가 있는 레코드를 건너 뛰어야 합니다. 어떤 순서로든 캐치업 스크립트와 backfill을 실행하면 새 DB가 결국 모놀리스를 복제하도록 수렴됩니다.
⚒ 역자의 설명 ⚒
Backfill 이란 누락된 과거의 데이터를 채워넣어 데이터의 공백이 없이 채워지게 하는 것을 뜻합니다.
DB를 마이그레이션 하는 과정이기 때문에, double write를 하는 최신 데이터를 제외하고 과거의 데이터를 채워넣어야 한다는 맥락에서 backfill이라는 단어를 사용한 것으로 생각됩니다.
마이그레이션은 딱 기본 데이터의 무결성만큼만 우수하므로, 샤드가 모놀리스로 최신 상태가 된 후 우리는 정확성을 검증하는 프로세스를 시작했습니다.
예방 차원에서 마이그레이션 및 검증 로직은 다른 사람들에 의해 구현되었습니다. 그렇지 않으면 두 단계에서 동일한 오류를 범할 가능성이 높아져 검증의 전제가 약화됩니다.
샤딩 프로젝트의 많은 부분들이 Notion 엔지니어 팀을 최고로 사로잡았지만, 우리가 뒤늦게 재검토해야 할 결정이 많이 있었습니다. 다음은 몇 가지 예시입니다 :
id
와 현재의 파티션 키인 space_id
말이죠. 어쨌든 전체 테이블 스캔을 수행해야 했기 때문에 두 키를 하나의 새 컬럼으로 결합하여 애플리케이션 전체에 space_ids
를 전달해야 할 필요가 없었습니다.
이러한 가정에도 불구하고 샤딩은 엄청난 성공을 거두었습니다. Notion 사용자의 경우 몇 분의 가동 중지 시간으로 제품이 눈에 띄게 빨라졌습니다. 내부적으로, 주어진 우리는 시간에 민감한 목표 내에서 협력하는 팀워크와 결단력 있는 실행을 증명할 수 있었습니다.
(1) 불필요한 복잡성을 도입하는 것 외에도 미리 샤딩하는 것에 대한 과소 평가된 위험은 비즈니스 측면에서 잘 정의되기 전의 프로덕트 모델을 제한할 수 있다는 점입니다. 예를 들어, 개발팀이 사용자 별로 샤딩한 후, 팀 중심의 프로덕트 전략으로 전환하게 된다면 아키텍처 임피던스 불일치로 인해 상당한 기술적 문제가 발생하고 특정 피쳐들이 제한될 수 있습니다.
⚒ 역자의 설명 ⚒
임피던스 불일치(Impedance Mismatch)는 백엔드 개발자라면 보통 객체-관계 임피던스 불일치(DB 스키마와 실제 애플리케이션의 객체 간 불일치를 뜻한다)로 많이 들어봤을 것입니다. 일반적으로 내가 현재 가지고 있는 것과 원하는 것이 다르다는 의미로 임피던스 불일치라는 용어를 사용합니다. 본 글에서는 사용자 별로 샤딩해서 데이터를 분리해놨는데(내가 가지고 있는 데이터 구조), 팀 중심의 프로덕트로 변경하게 되면 팀 별로 샤딩되어 있길 원할 것이라는(내가 원하는 것) 의미로 사용되었습니다. 결론적으로, 가지고 있는 것과 원하는 것 사이의 스키마가 달라져서 생기는 불일치라고 생각하면 됩니다.
(2) 패키지 솔루션 이외에도 DynamoDB와 같은 다른 DB 시스템으로 전환(우리의 use case에서는 너무 위험해서 불가), 디스크 처리량 향상을 위해 베어메탈 NVMe 대용량 인스턴스에서 Postgres를 실행하는(백업 및 복제 유지 관리 비용 때문에 제외) 등 여러 대안을 고려했습니다.
(3) 일부 속성을 기반으로 데이터를 나누는 key-based 파티셔닝 외에도 서비스에 의한 수직 파티셔닝과 모든 읽기 및 쓰기를 라우팅하기 위해 중간 조회 테이블을 사용하는 디렉토리 기반 파티셔닝과 같은 접근 방식들도 있습니다.
🖌 역자의 말
해당 글을 소개해주고, 번역에 대한 피드백을 부탁했던 lmu 덕에 좀 더 읽기 쉬운 글이 되었습니다. 감사합니다.
Cache 파헤치기 (0) | 2022.02.15 |
---|---|
[Object] Chapter 02 ~ 05 정리 (0) | 2021.03.25 |
CQRS 패턴, 코드에 순식간에 적용해보기 (0) | 2021.02.14 |
댓글 영역