읽기 전용 데이터(Read-mostly data)
GitLab v19.1이 문서는 데이터베이스 확장성 워킹 그룹에서 도입된 read-mostly 패턴에 대해 설명합니다. 이름에서 알 수 있듯이, read-mostly 데이터는 업데이트보다 읽기가 훨씬 더 자주 발생하는 데이터를 의미합니다.
이 문서는 데이터베이스 확장성 워킹 그룹에서 도입된 read-mostly 패턴에 대해 설명합니다. read-mostly 데이터의 특성을 살펴보고, 이 맥락에서 GitLab 개발 시 고려해야 할 모범 사례를 제안합니다.
read-mostly 데이터의 특성#
이름에서 알 수 있듯이, read-mostly 데이터는 업데이트보다 읽기가 훨씬 더 자주 발생하는 데이터를 의미합니다. 업데이트, 삽입, 삭제를 통한 이 데이터의 쓰기는 읽기에 비해 매우 드문 이벤트입니다.
또한, 이 맥락에서 read-mostly 데이터는 일반적으로 작은 데이터셋입니다. 대용량 데이터셋은 "한 번 쓰고 자주 읽는" 특성을 가지는 경우가 많지만, 여기서는 대용량 데이터셋을 명시적으로 다루지 않습니다.
예시: 라이선스 데이터#
대표적인 예시를 소개합니다: GitLab의 라이선스 데이터.
GitLab 인스턴스에는 GitLab 엔터프라이즈 기능을 사용하기 위한 라이선스가 첨부될 수 있습니다.
이 라이선스 데이터는 인스턴스 전체에서 유지되며, 일반적으로 관련 레코드가 몇 개밖에 존재하지 않습니다.
이 정보는 매우 작은 licenses 테이블에 보관됩니다.
이 데이터를 read-mostly 데이터로 간주하는 이유는 위에서 설명한 특성을 따르기 때문입니다:
-
드문 쓰기: 라이선스 데이터는 라이선스를 삽입한 후에는 쓰기가 거의 발생하지 않습니다.
-
빈번한 읽기: 라이선스 데이터는 엔터프라이즈 기능 사용 여부를 확인하기 위해 매우 자주 읽힙니다.
-
작은 크기: 이 데이터셋은 매우 작습니다. GitLab.com에서는 전체 관계 크기가 50 kB 미만인 5개의 레코드만 존재합니다.
대규모 환경에서 read-mostly 데이터의 영향#
이 데이터셋은 작고 자주 읽히기 때문에, 데이터가 거의 항상 데이터베이스 캐시 및/또는 데이터베이스 디스크 캐시에 상주할 것으로 예상할 수 있습니다. 따라서 read-mostly 데이터의 문제는 일반적으로 데이터베이스 I/O 오버헤드에 관한 것이 아닙니다. 어차피 디스크에서 데이터를 읽지 않기 때문입니다.
그러나 높은 빈도의 읽기를 고려하면, 데이터베이스 CPU 부하 및 데이터베이스 컨텍스트 스위치 측면에서 오버헤드가 발생할 가능성이 있습니다. 또한 이러한 고빈도 쿼리는 전체 데이터베이스 스택을 통과합니다. 데이터베이스 연결 멀티플렉싱 구성 요소와 로드 밸런서에도 오버헤드를 유발합니다. 또한 애플리케이션은 쿼리를 준비하고 전송하여 데이터를 검색하고, 결과를 역직렬화하며, 수집된 정보를 나타내는 새 객체를 할당하는 데 사이클을 소비합니다. 이 모든 것이 고빈도로 발생합니다.
위의 라이선스 데이터 예시에서, 라이선스 데이터를 읽는 쿼리는 쿼리 빈도 측면에서 두드러진다고 확인되었습니다. 실제로 피크 시간대에 클러스터에서 초당 약 6,000개의 쿼리(QPS)가 발생하는 것을 확인했습니다. 당시 클러스터 크기에서, 각 레플리카에서 약 1,000 QPS, 피크 시간대에 primary에서 400 QPS 미만이 발생했습니다. 이 차이는 순수한 읽기 전용 트랜잭션에서 레플리카를 선호하는 읽기 확장을 위한 데이터베이스 로드 밸런싱으로 설명됩니다.
[
](/19.1/development/database/scalability/patterns/img/read_mostly_licenses_calls_v14_2.png)
당시 데이터베이스 primary의 전체 트랜잭션 처리량은 초당 50,000~70,000개의 트랜잭션(TPS) 사이에서 변동했습니다. 이에 비해 이 쿼리 빈도는 전체 쿼리 빈도의 작은 부분만을 차지합니다. 그러나 컨텍스트 스위치 측면에서는 여전히 상당한 오버헤드가 있을 것으로 예상됩니다. 가능하다면 이 오버헤드를 제거하는 것이 바람직합니다.
read-mostly 데이터를 인식하는 방법#
명확한 경우(위의 예시처럼)도 있지만, read-mostly 데이터를 인식하는 것이 어려울 수 있습니다.
한 가지 접근 방법은 예를 들어 primary에서 읽기/쓰기 비율과 통계를 확인하는 것입니다. 여기서는 60분 동안의 읽기/쓰기 비율 기준 상위 20개 테이블을 살펴봅니다(피크 트래픽 시간대에 측정):
bottomk(20,
avg by (relname, fqdn) (
(
rate(pg_stat_user_tables_seq_tup_read{env="gprd"}[1h])
+
rate(pg_stat_user_tables_idx_tup_fetch{env="gprd"}[1h])
) /
(
rate(pg_stat_user_tables_seq_tup_read{env="gprd"}[1h])
+ rate(pg_stat_user_tables_idx_tup_fetch{env="gprd"}[1h])
+ rate(pg_stat_user_tables_n_tup_ins{env="gprd"}[1h])
+ rate(pg_stat_user_tables_n_tup_upd{env="gprd"}[1h])
+ rate(pg_stat_user_tables_n_tup_del{env="gprd"}[1h])
)
) and on (fqdn) (pg_replication_is_replica == 0)
)
이를 통해 데이터베이스 primary에서 쓰기보다 읽기가 훨씬 더 자주 발생하는 테이블을 잘 파악할 수 있습니다:
[
](/19.1/development/database/scalability/patterns/img/read_mostly_readwriteratio_v14_2.png)
여기서 예를 들어 gitlab_subscriptions를 확대하면, 인덱스 읽기가 전체적으로 초당 10k 튜플 이상으로 최고치에 달함을 알 수 있습니다(순차 스캔 없음):
[
](/19.1/development/database/scalability/patterns/img/read_mostly_subscriptions_reads_v14_2.png)
테이블에 대한 쓰기는 매우 드뭅니다(순차 스캔 없음):
[
](/19.1/development/database/scalability/patterns/img/read_mostly_subscriptions_writes_v14_2.png)
또한 테이블 크기가 400 MB에 불과하여, 이 패턴에서 고려할 수 있는 또 다른 후보가 될 수 있습니다(#327483 참조).
대규모 환경에서 read-mostly 데이터를 처리하는 모범 사례#
read-mostly 데이터 캐시 처리#
데이터베이스 오버헤드를 줄이기 위해 데이터에 대한 캐시를 구현하여 데이터베이스 측의 쿼리 빈도를 크게 줄입니다. 사용 가능한 캐싱 범위는 다음과 같습니다:
-
RequestStore: 요청별 인메모리 캐시(request_storegem 기반) -
ProcessMemoryCache: 프로세스별 인메모리 캐시(ActiveSupport::Cache::MemoryStore) -
Gitlab::Redis::Cache및Rails.cache: Redis의 본격적인 캐시
위의 예시를 계속 이어가면, 요청별로 라이선스 정보를 캐시하는 RequestStore가 이미 적용되어 있었습니다.
그러나 이는 여전히 요청당 하나의 쿼리가 발생합니다.
1초 동안 프로세스 전체 인메모리 캐시를 사용하여 라이선스 정보를 캐시하기 시작했을 때, 쿼리 빈도가 급격히 감소했습니다:
[
](/19.1/development/database/scalability/patterns/img/read_mostly_licenses_fixed_v14_2.png)
캐싱 방법의 선택은 해당 데이터의 특성에 크게 의존합니다. 거의 업데이트되지 않는 라이선스 데이터와 같이 매우 작은 데이터셋은 인메모리 캐싱의 좋은 후보입니다. 프로세스별 캐시가 유리한 이유는 캐시 갱신 빈도를 들어오는 요청 빈도와 분리할 수 있기 때문입니다.
주의할 점은 현재 Redis 설정이 Redis 세컨더리를 사용하지 않고 캐싱을 단일 노드에 의존한다는 것입니다. 즉, 증가된 압력으로 인해 Redis가 과부하되지 않도록 균형을 맞춰야 합니다. 이에 비해 PostgreSQL 레플리카에서 데이터를 읽으면 여러 읽기 전용 레플리카에 분산할 수 있습니다. 데이터베이스에 대한 쿼리가 더 비쌀 수 있지만, 부하가 더 많은 노드에 분산됩니다.
레플리카에서 read-mostly 데이터 읽기#
캐싱 구현 여부에 관계없이, 가능하다면 데이터베이스 레플리카에서 데이터를 읽도록 해야 합니다. 이는 여러 데이터베이스 레플리카에 걸쳐 읽기를 확장하려는 노력을 지원하고, 데이터베이스 primary에서 불필요한 워크로드를 제거합니다.
GitLab의 읽기를 위한 데이터베이스 로드 밸런싱은 첫 번째 쓰기 이후 또는 명시적 트랜잭션을 열 때 primary에 고정됩니다. read-mostly 데이터의 맥락에서, 우리는 트랜잭션 범위 밖에서 그리고 쓰기 이전에 이 데이터를 읽으려고 합니다. 이 데이터는 자주 업데이트되지 않기 때문에(따라서 약간 오래된 데이터를 읽는 것에 대해 걱정하지 않아도 되는 경우가 많음), 이는 종종 가능합니다. 그러나 이전 쓰기나 트랜잭션 때문에 이 쿼리를 레플리카로 보낼 수 없다는 것이 명백하지 않을 수 있습니다. 따라서 read-mostly 데이터를 접할 때는 더 넓은 맥락을 확인하고 이 데이터를 레플리카에서 읽을 수 있는지 확인하는 것이 좋은 관행입니다.