Gitlab의 Postgres 스키마 디자인에 대한 나의 노트 (2022)
(shekhargulati.com)GitLab Postgres 스키마 디자인에 대한 나의 노트
- GitLab의 Postgres 스키마를 살펴봄으로써, 자신이 설계하는 스키마와 비교하고, GitLab의 스키마 정의에서 모범 사례를 배우고자 함.
- GitLab은 오픈 소스 DevOps 플랫폼으로, GitHub의 대안이며 자체 호스팅이 가능함.
올바른 기본 키 유형 사용하기
- 데이터베이스가 작을 때는 눈에 띄지 않지만, 성장함에 따라 기본 키가 저장 공간, 쓰기 속도, 읽기 속도에 영향을 미침.
- GitLab은 573개의 테이블 중 380개는
bigserial기본 키 유형을, 170개는serial4를 사용하고, 나머지 23개는 복합 기본 키를 사용함.
내부 및 외부 ID의 사용
- 기본 키를 외부 세계에 노출하지 않는 것이 좋은 관행임.
- GitLab은
issues,ci_pipelines,deployments,epics등의 테이블에서 내부 ID(id)와 외부 ID(iid)를 모두 사용함.
text 문자 유형과 검사 제약 조건 사용하기
- GitLab 스키마는
character varying(n)과text를 모두 사용하지만,text유형을 더 자주 사용함. text유형은 길이 제약을 가지고 있지 않으며,CHECK를 사용하여 길이 제약을 정의함.
명명 규칙
- 모든 테이블은 복수형을 사용하며, 모듈 이름 접두사를 사용하여 네임스페이스를 제공함.
- 테이블과 열 이름은 snake_case 규칙을 따름.
타임스탬프의 시간대 사용
- GitLab은
timestamp with timezone과timestamp without timezone을 모두 사용함. - 시스템 작업에는
timestamp without timezone을, 사용자 작업에는timestamp with timezone을 사용함.
외래 키 제약 조건
- GitLab은 대부분의 테이블에서 외래 키 제약 조건을 사용하지만,
audit_events,abuse_reports,web_hooks_logs,spam_logs와 같은 몇몇 테이블에서는 사용하지 않음.
큰 테이블의 파티셔닝
- GitLab은 쿼리 성능을 향상시키기 위해 크기가 커질 수 있는 테이블을 파티셔닝함.
Trigrams과 gin_trgm_ops를 사용하여 LIKE 검색 사용 사례 지원하기
- GitLab은 GIN(Generalized Inverted Index) 인덱스를 사용하여 효율적인 검색을 수행함.
jsonb의 사용
- GitLab 스키마는 여러 테이블에서
jsonb데이터 유형을 사용함.
기타 팁
- 수정 가능한 테이블에는
updated_at과 같은 감사 필드를 사용하고, 수정할 수 없는 로그 테이블에는 사용하지 않음. - Enums는
character varying대신smallint로 저장되어 공간을 절약함.
GN⁺의 의견:
- GitLab의 스키마 디자인은 데이터베이스 설계에 대한 통찰력을 제공하며, 특히 대규모 시스템을 위한 스키마 최적화에 대한 중요한 교훈을 담고 있음.
- GitLab이 오픈 소스이기 때문에, 이러한 스키마 설계 결정들은 다른 개발자들이 자신의 프로젝트에 적용할 수 있는 실질적인 예시를 제공함.
- GitLab의 스키마에서 배울 수 있는 점은, 데이터 유형 선택, 인덱싱 전략, 파티셔닝, 외래 키 제약 조건의 사용 등 데이터베이스 성능과 유지 관리에 중요한 영향을 미치는 요소들을 신중하게 고려해야 한다는 것임.
댓글과 토론
Hacker News 의견들
-
기본 키를 외부에 노출하지 말라는 관행이 왜 필요한지 의문임. 요청이 어차피 인증되어야 한다면, ID 추측을 막는 게 어떤 가치가 있는지도 모르겠음
추측한 ID만으로 인증·인가 없이 뭔가 유용한 일을 할 수 있다면 이미 다른 곳이 심각하게 망가진 것이고, 스키마에 불필요한 복잡성을 더하기보다 그 부분에 집중해야 함. 경쟁사가 고객 수를 추정하지 못하게 하는 경쟁 정보 은닉 정도는 가치가 있을 수 있지만, GitLab이 그걸 크게 신경 썼을 것 같지는 않음. GitLab의id + iid결정은 내부 ID 추측 방지보다 질의 성능 요구가 더 컸을 가능성이 높아 보임- 맞지만, ID를 추측할 수 있음은 보안 취약점을 끔찍하게 만들거나 훨씬 더 악화시킬 수 있음
UUID를 사용자에게 노출했다면 같은 취약점이 있어도 공격자가 UUID를 맞혀야 하므로 훨씬 어렵고, 2차 출처가 필요할 수 있음. 데이터 유출은 있어도 대응할 시간이 생기고 유출량도 산정 가능함. 반대로 순차 ID라면 문제가 즉시 전면 유출로 커지고, 개인정보 감독기관에 의무 신고해야 할 대규모 사고가 될 수 있음. 이런 건 쓸모없어야 하는 심층 방어지만, 실제로 이런 방식으로 뚫린 끔찍한 소프트웨어가 존재함 - 글에도 나오듯이 보안보다는 경쟁 정보에 가까움. 단순 자동 증가 ID는 테이블의 전체 레코드 수나 증가율을 드러냄
이슈 테이블의 기본 키id를 노출하면 프로젝트에서 이슈를 만들 때 1부터 시작하지 않으므로, GitLab 전체에 이슈가 얼마나 있는지 쉽게 추측할 수 있음 - 보안 연극이라는 표현은 너무 남용됨. 보안은 여러 층으로 구성될 수 있고 그래야 하며, 인증 같은 한 계층이 깨졌다고 해서 나머지까지 쉽게 접근 가능해지면 안 됨
물론 추측한 ID만으로 인증·인가 없이 뭔가 가능하다면 큰 문제가 맞음. 하지만 그 시점에 다른 계층이 없으면 이미 끝난 게임. 버그는 미리 알려주지 않고, 특히 미묘한 버그는 더 그렇다. 그런 버그가 나타났을 때 ID라도 추측 불가능하게 해뒀다면, 시스템의 모든 사용자 계정까지 쉽게 접근되는 상황을 피했다며 감사하게 될 것임 - 접근 제어에도 버그는 생김. 추측 불가능한 ID는 그런 버그 일부를 악용하기 훨씬 어렵게 만듦
당연히 먼저 올바른 접근 제어를 보장하는 데 집중해야 하지만, 추측 불가능한 ID는 끔찍한 재난과 아슬아슬한 사고의 차이를 만들 수 있음. UUID가 맞지 않는다면 자동 증가 데이터베이스 ID를 쓰고 이를 암호화하는 방법도 가능하며, 적절한 소프트웨어 계층이 있으면 암호화 ID도 거의 자동으로 동작함 - 사소한 용어 차이지만, 이건 보통 회사 내부 데이터 활용을 뜻하는 “비즈니스 인텔리전스”보다는 경쟁 정보라고 부르는 게 맞아 보임. https://en.wikipedia.org/wiki/Competitive_intelligence
- 맞지만, ID를 추측할 수 있음은 보안 취약점을 끔찍하게 만들거나 훨씬 더 악화시킬 수 있음
-
공개 저장소 1억 2,800만 개 중 대다수는 다른 저장소의 포크이고, 주 저장소에 풀 리퀘스트를 만들기 위해 존재할 뿐이라 실수하지 않는 한 이슈가 없을 것 같음
작은 장난감 프로젝트나 금방 버려진 프로젝트도 이슈가 없거나 아주 적을 가능성이 큼. 수백·수천 개 이슈가 있는 프로젝트가 분명 있겠지만, 1억 2,800만 저장소 전체 평균은 꽤 낮아서 20억 한도 아래에 머물 가능성이 큼. 그래도 해당 테이블에 4바이트 타입, 정확히는 31비트를 쓰는 건 github.com을 포함한 일부 조직에는 시한폭탄이라는 데 동의함- 현재도 저장소 362,107,148개와 고유 이슈·풀 리퀘스트 818,516,506개로 아직 한도 아래임
https://play.clickhouse.com/play?user=play#U0VMRUNUIHVuaXEoc... - GitHub가 Rails에서 멀어진 결정에는 ActiveRecord의 큰 결함, 즉 복합 기본 키 지원 부족도 일부 영향을 줬다고 봄
PRIMARY KEY(repo_id, issue_id)처럼 기본적인 요구가 ActiveRecord 안에서는 불필요하게 복잡해지고, 단일 기본 키 컬럼을 요구하는 ActiveRecord에 맞추기 위해 고유 키와 별도 기본 키를 함께 쓰는 우회가 필요해짐. UUID 기본 키도 우회처럼 보이지만(repo_id, issue_id)쌍의 고유 제약은 여전히 필요해 데이터베이스 크기와 오버헤드가 커짐. 더 넓게 보면 Ruby on Rails의 단일 모델·컨트롤러·뷰 계층에 기반한 단일체 MVC 구조도 애플리케이션이 커질수록 확장성과 유지보수성 문제를 만들며, MVC는 모듈형 또는 컴포넌트 기반 구조에서 더 잘 맞는다고 봄 - GitLab Cloud가 다중 테넌트 데이터베이스를 쓰는지, 사용자·고객·조직별 데이터베이스를 쓰는지 확실히 아는지 궁금함
자체 호스팅과 클라우드를 모두 제공하는 제품은 고객별 데이터베이스를 선호하는 경우가 많았음. 호스팅 유형과 관계없이 같은 질의를 쓰도록 코드베이스 공유 부분이 훨씬 단순해지기 때문임. 고객별 데이터베이스라면 그런 사용량 한도에 접근할 일이 거의 없고, 접근한다면 자체 호스팅이 더 적합할 것임 - 그 시한폭탄은 11초짜리 마이그레이션으로 해체되는 폭탄임
- 기본 키를
int에서bigint로 마이그레이션하는 건 가능함. 준비와 커스텀 코드가 좀 필요하지만 무중단으로 할 수 있음
대체로 이 절차를 따라 큰 마이그레이션을 관리 중이고, 약간 수정해서 쓰고 있음: http://zemanta.github.io/2021/08/25/column-migration-from-in...
외래 키, 인덱스, 제약 조건 전반이 과정을 어렵게 만들지만 불가능하진 않음. 내 경우 데이터 마이그레이션은 몇 시간 걸렸지만 빠를 필요는 없었음. GitLab은 버전 업그레이드 중 어디서든 동작하도록 업그레이드 후 작업을 실행하는 도구를 갖고 있는 것으로 앎
- 현재도 저장소 362,107,148개와 고유 이슈·풀 리퀘스트 818,516,506개로 아직 한도 아래임
-
UUID 컬럼의 저장 크기 이야기는 설득력이 약함. 테이블에 다른 컬럼이 5개 있다면 128비트 대 64비트 차이는 크지 않음
더 중요한 우려는 성능임. UUIDv4는 널리 지원되지만 완전 무작위라 인덱스 성능에 이상적이지 않음. UUIDv7[0]은 Snowflake[1]에 가까워 시간적 지역성이 있지만 구현체가 덜 널리 퍼져 있음. 별도 접근으로bigserial을 쓰고 키를 암호화하는 방법도 있음: https://github.com/abevoelker/gfc64
하지만 이 방식은 1) 비밀 값을 회전할 수 없고 2) 한 번 유출되면 누구나 테이블 크기를 페르미 추정할 수 있음. 공개 ID와 내부 ID를 분리하는 건 번거롭고, 공개 ID가 UUIDv4라면 성능도 희생함. UUIDv7이 가장 많은 조건을 만족하는 해법이라고 봄
[0]: https://uuid7.com/
[1]: https://en.wikipedia.org/wiki/Snowflake_ID- 그 컬럼 하나의 크기만 문제가 아니라, 그
id가 외래 키로 쓰이는 모든 위치와 해당 외래 키 컬럼에 필요한 인덱스 크기도 문제임
사용자 ID처럼 데이터베이스 전반에서 수십 개, 수백 개의 외래 키가 참조할 수 있는 값을 생각해보면 됨 - 문제는 다른 5개 컬럼이 인덱싱되지 않았다는 것임
데이터베이스 성능에는 세 단계가 있음. 1) 인덱스와 데이터가 모두 메모리에 들어감. 2) 인덱스는 메모리에 들어가지만 데이터는 안 들어감. 3) 인덱스와 데이터가 모두 메모리에 안 들어감. 1번이면 좋지만 아니라면 미친 듯이 2번을 지켜야 함. 인덱스 크기 두 배는 그걸 더 어렵게 만듦 - 데이터베이스의 기본 키를
typedef void*같은 기본 포인터로 생각하면 됨. 크기가 메모리·디스크 사용량, 처리량 병목, 조인과 조회의 가장 깊은 내부 루프에서 키를 비교하는 CPU 시간까지 전반적인 성능에 영향을 줌
x86-64 CPU가 새로 나왔을 때 64비트 포인터 전환의 성능 영향이 너무 커서 x32/ilp32가 만들어졌고, .NET이 지금도 “prefer 32-bit”를 기본값으로 두는 이유도 거기에 있음. 데이터베이스 기본 키로 128비트 UUID를 쓰는 건 끔찍한 실수임 - UUIDv7도 만능은 아님. 많은 경우 리소스의 생성 시각을 유출하고 싶지 않음
예를 들어 영상을 공개 한 달 전에 업로드하되, 관객이 그 사실을 알지 못하게 하고 싶을 수 있음 - 이 접근의 다른 변형도 있음: https://pgxn.org/dist/permuteseq/
URL, 이메일 등에 넣어 표시할 때 값을 암호화하는 것도 가능함: https://wiki.postgresql.org/wiki/Pseudo_encrypt
이렇게 하면 순차 인덱스의 장점을 많이 유지하면서 키 변경도 가능함. 다만 키를 바꾸면 북마크가 깨지고 과거 이메일에 보낸 링크가 무효화되며, 사실상 모든 이름을 바꾸는 것과 같은 효과가 남
- 그 컬럼 하나의 크기만 문제가 아니라, 그
-
사소한 트집이지만,
text대varchar섹션은 짚고 싶음
글쓴이는 존재하지 않는 성능 차이를 증명하려고 많은 말을 쓰고 나서 “두 타입 사이에 큰 성능 차이는 없다”고 결론 내림. 이건 오래전에 결론난 주제고, “크지 않다”가 아니라 “없다”임. PostgreSQL 위키[1]는 아주 좋은 이유가 없다면text를 쓰라고 명시하고, 문서[2]도 “많은 목적에서character varying은text위의 도메인처럼 동작한다”고 하며 초록색 Tip 박스에서 “이 세 타입 사이에 성능 차이는 없다”고 말함. 따라서 GitLab이 대부분text를 쓰는 건 문서를 읽고, 어설픈 “이식 가능한” 스키마가 아니라 PostgreSQL에 맞춰 스키마를 설계했다는 뜻으로 보임
[1] https://wiki.postgresql.org/wiki/Don%27t_Do_This#Don.27t_use...
[2] https://www.postgresql.org/docs/current/datatype-character.h...- 실제로 저장 문자열 길이 변경에 맞춰 스키마를 마이그레이션해야 할 때는 상당한 성능 차이가 생김
varchar(300)을varchar(200)으로 바꾸려면 모든 행을 다시 써야 하지만,text컬럼의 제약 조건을 갱신하는 건 기본적으로 거의 공짜이고 기존 값이 새 제약을 만족하는지 확인하는 전체 테이블 스캔만 필요함. 글에서도text타입과CHECK제약을 쓰면 길이 검사가 있을 때character varying이나varchar(n)보다 스키마 진화가 쉽다고 함
- 실제로 저장 문자열 길이 변경에 맞춰 스키마를 마이그레이션해야 할 때는 상당한 성능 차이가 생김
-
외래 키는 비싸다는 말은 자주 반복되지만 벤치마크는 드문 주장임
잘못 구현하는 방법은 많지만, 어차피 스택 어딘가에서는 무결성을 강제하고 있음. 이를 재구현하기보다 데이터베이스를 활용하려면 지식과 실험이 필요하고, 대개 큰 사고를 막아줌 -
GitLab과 GitHub의 성능 차이를 누가 정리했거나 눈여겨본 적이 있는지 궁금함
둘 다 Rails 기반 애플리케이션인데, 전반적인 페이지 로드 시간은 GitLab이 GitHub에 비해 끔찍하다고 느껴짐- 몇 년 전 GitLab을 썼을 때 큰 풀 리퀘스트에서 클라이언트 측 성능 문제가 심했음. GitHub도 이상적이진 않지만 그래도 괜찮은 수준으로 처리함
- GitHub와 비교하는 건 Chrome과 다른 브라우저, 심지어 Chromium 기반 브라우저를 비교하는 것과 비슷함
Chrome과 GitHub는 사용자를 망치더라도 온갖 요령을 다 쓸 것임. 예를 들어 회사 GitHub에서 병합 diff를 열고Ctrl F로 검색해 결과가 없으면 다음으로 넘기며 Git 기록을 수작업으로 뒤지다가, 100번째 diff에서야 깊은 곳에 가장 중요한 파일이 숨겨져 있었다는 걸 알게 된 적이 있음. 누군가 페이지 로드 지표를 맞추고 승진하기 더 편했기 때문일 것임 - GitHub는 작년 두 번의 장애를 제외하면 대체로 안정적이고 보통 꽤 빠름. 그렇지 않았다면 키보드 단축키를 쓰지 않았을 것임
GitLab의 문화와 성능 경시를 이해하는 데 도움이 될 수 있는 전 개발자의 글이 여기에 있음: https://news.ycombinator.com/item?id=39303323
나는 GitLab을 성능 문제를 체감할 만큼 충분히 쓰지는 않지만, 이 글이 도움이 될 수 있다고 생각함
-
CI 변수
CI_PIPELINE_IID와CI_MERGE_REQUEST_IID에 붙은 추가 I가 무슨 뜻인지 항상 궁금했음
데이터베이스 관련 선택일 거라고 짐작했는데, 이 글이 확인해줌 -
“1 quintillion은 1,000,000,000 billion과 같다”는 걸 보면 우리가 대체로
int32와int64사이에서만 고르는 게 꽤 이상함. 약 1조 개의 카디널리티를 지원하는 5바이트 정수 타입이 있어야 할 것 같음- 값을 꽉 채워 포장하는 게 아니라면 2의 거듭제곱이 아닌 크기를 고르는 건 말이 안 됨
-
자동 증가 ID를 안 쓰는 건 합리적일 수 있지만, 내부용과 외부용 ID를 2개 두는 이점은 잘 모르겠음
컬럼과 인덱스 수가 늘고, 항상 먼저 조회를 해야 하며, 내부 키를 바꾸면서 외부 키는 안 바꿀 보안 시나리오도 떠오르지 않음. 내가 놓친 게 있나?- 프로젝트 단위로 뭔가 할 때는 어차피 필요한 정보를 이미 갖고 있음. 또 각 프로젝트의 이슈가 2조 7천억 3억 571만 7천 325 같은 숫자에서 시작하는 것보다 1부터 시작하는 편이 사용자에게 더 친절함
-
PostgreSQL 네이티브 UUID v4 타입을
bigserial대신 쓰면 테이블 크기가 25% 커지고 삽입률이bigserial의 25%로 떨어진다고 하는데, 왜 UUIDv4가 그렇게 나쁜지 궁금함
UUID는 그냥 128비트 숫자 아닌가? 생성 비용이 엄청 비싼 건지, 무슨 일이 일어나는 건지 모르겠음- UUIDv4는 완전 무작위이고, B-트리 인덱스는 합리적인 순서를 가진 “오른쪽으로 치우친” 값을 기대함
그래서 UUIDv4 컬럼의 인덱싱 작업이 느려지고, 이것이 UUIDv6와 UUIDv7 개발 동기가 됨 - 크기 25% 증가는 맞지만 행당 8바이트라는 작고 예측 가능한 선형 증가임. 행의 나머지 데이터와 비교하면 크게 걱정할 정도는 아님
더 큰 문제는 삽입률임. UUID의 경우 삽입률은 사용 가능한 RAM 양에 의해 제한됨. 자동 증가 정수는 그렇지 않음. 정수는 시간과 상관관계가 있지만 UUID4는 무작위라, 규모가 커지면 성능 특성이 근본적으로 달라짐. 작은 테이블에서는 삽입 페널티가 거의 무시 가능하지만, B-트리 인덱스 크기가 메모리 한계에 닿으면 PostgreSQL은 UUID B-트리를 전부 메모리에 둘 수 없어 디스크 페이지 교체에 의존함. 자동 증가 정수는 시간상 가까운 행들이 같은 인덱스 페이지를 쓰므로 같은 부하에서 디스크를 칠 필요가 없음. 이 규모에 도달하면 차이는 일정한 25% 저하가 아니라 25배 성능 절벽이며, 스키마 마이그레이션을 제외하면 RAM을 더 사는 수밖에 없음 - B-트리 때문이라고 봄. B-트리와 페이지는 마지막 페이지만 많이 쓰일 때 더 잘 동작함
UUID는 정렬되지 않은 쓰기를 많이 만들어 페이지 팽창으로 이어짐 - 정렬 순서가 무작위로 분포하면 B-트리의 캐시 지역성이 나빠짐. 삽입이 마지막 페이지로 가지 않고 여기저기로 흩어짐
배치 삽입의 지역성도 나중에 조회할 때 나빠져, 관련 레코드를 무작위로 찾아야 함. 결국 삽입 시점과 이후 선택 시점 모두에서 비용을 냄
- UUIDv4는 완전 무작위이고, B-트리 인덱스는 합리적인 순서를 가진 “오른쪽으로 치우친” 값을 기대함