읽기 우선 데이터

이 문서는 데이터베이스 확장성 작업 그룹에서 도입된 읽기 우선 패턴을 설명합니다. 우리는 읽기 우선 데이터의 특성을 논의하고 이 맥락에서 GitLab 개발이 고려해야 할 모범 사례를 제안합니다.

읽기 우선 데이터의 특성

이름에서 알 수 있듯이, 읽기 우선 데이터는 업데이트보다 훨씬 더 자주 읽히는 데이터를 의미합니다. 이 데이터를 업데이트, 삽입 또는 삭제를 통해 작성하는 것은 이 데이터를 읽는 것과 비교할 때 매우 드문 경우입니다.

또한, 이 맥락에서 읽기 우선 데이터는 일반적으로 작은 데이터 집합입니다. 우리는 이곳에서 대규모 데이터 집합을 다루지 않으며, 비록 그런 데이터 집합이 “한 번 작성하고 자주 읽는” 특성을 가지는 경우가 많더라도 마찬가지입니다.

예시: 라이선스 데이터

여기에서 대표적인 예시를 소개하겠습니다: GitLab의 라이선스 데이터. GitLab 인스턴스는 GitLab 엔터프라이즈 기능을 사용하기 위해 붙어 있는 라이선스가 있을 수 있습니다. 이 라이선스 데이터는 인스턴스 전체에 걸쳐 보관되며, 일반적으로 몇 가지 관련 레코드만 존재합니다. 이 정보는 매우 작은 테이블 licenses에 저장됩니다.

우리는 이것을 읽기 우선 데이터로 간주하는데, 이는 위에서 설명한 특성을 따르기 때문입니다:

  • 드문 쓰기: 라이선스 데이터는 라이선스가 삽입된 후에는 매우 드물게 쓰기가 발생합니다.
  • 빈번한 읽기: 라이선스 데이터는 엔터프라이즈 기능을 사용할 수 있는지 확인하기 위해 매우 자주 읽힙니다.
  • 작은 크기: 이 데이터 집합은 매우 작습니다. GitLab.com에서는 총 관계 크기가 50kB 미만인 5개의 레코드가 있습니다.

대규모에서의 읽기 우선 데이터의 영향

이 데이터 집합이 작고 매우 자주 읽히기 때문에, 데이터는 거의 항상 데이터베이스 캐시와/또는 데이터베이스 디스크 캐시에 존재할 것으로 예상할 수 있습니다. 따라서 읽기 우선 데이터와 관련된 우려는 일반적으로 데이터베이스 I/O 오버헤드와 관련이 없으며, 우리는 일반적으로 디스크에서 데이터를 읽지 않기 때문입니다.

그러나 빈번한 읽기를 고려할 때, 이는 데이터베이스 CPU 부하와 데이터베이스 컨텍스트 전환 측면에서 오버헤드를 발생시킬 가능성이 있습니다. 또한, 이러한 빈번한 쿼리는 전체 데이터베이스 스택을 통과합니다. 이들은 데이터베이스 연결 다중화 구성 요소와 로드 밸런서에서 오버헤드도 발생시킵니다. 또한, 애플리케이션은 쿼리를 준비하고 데이터를 검색하기 위해 쿼리를 보내고, 결과를 역직렬화하며, 수집된 정보를 나타내기 위해 새로운 객체를 할당하는 데 사이클을 사용합니다 - 모두 높은 빈도로 이루어집니다.

위의 라이선스 데이터 예시에서, 라이선스 데이터를 읽기 위한 쿼리는 쿼리 빈도 측면에서 식별되었습니다. 사실, 우리는 피크 시간 동안 클러스터에서 초당 약 6,000개의 쿼리가 발생하는 것을 보고했습니다. 그 당시에 클러스터 크기로 인해 각 복제본에서는 약 1,000 QPS가 발생하고, 피크 시간 동안에는 기본에서 400 QPS 미만이 발생했습니다. 이러한 차이는 읽기 작업을 위한 데이터베이스 로드 밸런싱으로 설명되며, 이는 순수 읽기 전용 트랜잭션을 위해 복제본을 선호합니다.

라이선스 호출

그 당시 데이터베이스 기본의 전체 트랜잭션 처리량은 초당 50,000에서 70,000 트랜잭션(TPS) 사이에서 변동했습니다. 이와 비교했을 때, 이 쿼리 빈도는 전체 쿼리 빈도의 작은 부분만을 차지합니다. 그러나 우리는 여전히 컨텍스트 전환 측면에서 상당한 오버헤드가 발생할 것으로 예상합니다. 가능하다면 이러한 오버헤드를 제거하는 것이 좋습니다.

읽기 전용 데이터 인식 방법

읽기 전용 데이터를 인식하는 것은 어려울 수 있지만, 우리의 예와 같이 명확한 사례가 있습니다.

하나의 접근 방법은 읽기/쓰기 비율 및 통계를, 예를 들어, 기본 데이터베이스에서 확인하는 것입니다. 여기서는 60분 동안의 읽기/쓰기 비율에 따라 TOP20 테이블을 살펴봅니다 (트래픽이 가장 많은 시간대에 측정됨):

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)
)

이것은 어떤 테이블이 데이터베이스 기본에서 읽기가 쓰기보다 훨씬 더 자주 발생하는지에 대한 좋은 인상을 줍니다:

읽기 쓰기 비율 TOP20

여기서 우리는 예를 들어 gitlab_subscriptions줌인하여 인덱스 읽기가 전체적으로 초당 10,000개 이상의 튜플로 피크를 찍는 것을 확인할 수 있습니다 (순차 스캔은 없습니다):

구독: 읽기

테이블에 쓰는 경우는 거의 없으며 (순차 스캔이 없습니다):

구독: 쓰기

추가적으로, 테이블 크기는 400MB에 불과하므로 이 패턴에서 고려해볼 수 있는 또 다른 후보일 수 있습니다 (참조: #327483).

대규모 읽기 전용 데이터 처리 모범 사례

읽기 전용 데이터 캐시

데이터베이스 오버헤드를 줄이기 위해 데이터 캐시를 구현하여 데이터베이스 측 쿼리 빈도를 상당히 줄입니다. 캐싱에 사용할 수 있는 다양한 범위가 있습니다:

위의 예를 계속하면, 요청별로 라이센스 정보를 캐시하기 위해 RequestStore를 사용했습니다. 하지만, 여전히 요청당 하나의 쿼리가 발생했습니다. 라이센스 정보를 프로세스 전체 인메모리 캐시로 1초 동안 캐시하기 시작했을 때, 쿼리 빈도가 극적으로 감소했습니다:

라이센스 호출 - 수정됨

여기서 캐시 선택은 문제의 데이터 특성에 크게 의존합니다. 거의 업데이트되지 않는 매우 작은 데이터 세트인 라이센스 데이터는 인메모리 캐싱에 적합한 후보입니다.

여기서 프로세스별 캐시는 들어오는 요청 비율과 캐시 새로고침 비율을 분리하기 때문에 유리합니다.

한 가지 주의할 점은 현재 우리의 Redis 설정이 Redis 보조 노드를 사용하지 않고 단일 노드에 의존하고 있다는 것입니다. 즉, 증가하는 압력으로 인해 Redis가 과부하되는 것을 방지하기 위해 균형을 맞출 필요가 있습니다. 반면, PostgreSQL 복제본에서 데이터를 읽는 것은 여러 읽기 전용 복제본에 분산될 수 있습니다. 데이터베이스에 대한 쿼리가 더 비쌀 수 있지만, 부하는 더 많은 노드에 걸쳐 균형을 이룹니다.

복제본에서 읽기 주로 데이터 읽기

캐싱을 구현했든 하지 않았든, 우리는 가능한 경우 데이터베이스 복제본에서 데이터를 읽는 것이 중요합니다.

이것은 여러 데이터베이스 복제본에 걸쳐 읽기를 확장하려는 우리의 노력을 지원하고, 데이터베이스 주요 작업에서 불필요한 작업 부담을 제거합니다.

GitLab 읽기를 위한 데이터베이스 로드 밸런싱은 첫 번째 쓰기 이후 또는 명시적 트랜잭션을 열 때 주요 데이터베이스에 고착합니다.

읽기 주로 데이터의 맥락에서 우리는 이 데이터를 트랜잭션 범위 밖에서 읽고, 어떤 쓰기를 수행하기 전에 읽도록 노력합니다.

이 데이터는 거의 업데이트되지 않기 때문에 이는 종종 가능합니다(따라서 약간 오래된 데이터를 읽는 것에 대해 별로 걱정하지 않게 됩니다).

하지만 이전 쓰기나 트랜잭션 때문에 이 쿼리를 복제본에 보낼 수 없다는 것이 명확하지 않을 수 있습니다.

따라서 읽기 주로 데이터를 접하게 되면, 넓은 맥락을 확인하고 이 데이터를 복제본에서 읽을 수 있는지 확인하는 것이 좋은 실천입니다.