Read-mostly data

이 문서는 Database Scalability Working Group에서 소개된 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, 그리고 주요 서버에서는 피크 시간에 400개 미만의 QPS를 보았습니다. 이 차이는 순수 읽기 전용 트랜잭션을 촉진하기 위해 스케일링을 위한 데이터베이스 로드 밸런싱을 수행했기 때문입니다.

라이선스 콜

당시 데이터베이스 기본 서버의 전반적인 트랜잭션 처리량은 50,000에서 70,000 거래/초로 변동했습니다. 이에 비교하여, 이 쿼리 빈도는 전체 쿼리 빈도의 일부분을 차지하였습니다. 그러나 이것이 컨텍스트 스위치 측면에서 상당한 부하를 여전히 가질 것으로 예상됩니다. 우리는 이 부하를 제거하는 것이 중요하다고 여깁니다.

read-mostly 데이터를 인식하는 방법

예시와 같이 명확한 경우들을 제외하고는 read-mostly 데이터를 인식하는 것은 어려울 수 있습니다.

하나의 방법은, 예를 들어, primary에서 읽기/쓰기 비율과 통계를 살펴보는 것입니다. 여기서, 우리는 피크 트래픽 시간에 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)
)

이를 통해 primary에서 읽기보다 쓰기가 훨씬 더 자주 발생하는 테이블의 인상적인 인상을 얻을 수 있습니다:

읽기/쓰기 비율 TOP20

여기서, 우리는 예를 들어 gitlab_subscriptions확대할 수 있으며, 인덱스 읽기가 10,000개의 튜플/초를 위로 넘는 것을 발견할 수 있습니다(시퀀스 스캔은 없습니다):

구독: 읽기

우리는 이 테이블에 매우 드물게 쓰며(시퀀스 스캔은 없습니다):

구독: 쓰기

게다가, 이 테이블은 400MB 크기 입니다 - 따라서 이것은 이 패턴에 고려할 수 있는 또 다른 후보일 수 있습니다(#327483).

대규모 read-mostly 데이터 처리를 위한 모범 사례

Read-mostly 데이터 캐시화

데이터베이스 부하를 줄이기 위해 데이터에 대한 캐시를 구현하고, 따라서 데이터베이스 쪽의 쿼리 빈도를 크게 줄일 수 있습니다. 여러 가지 캐싱 범위가 있습니다:

앞에서 언급한 예시를 계속하면, 우리는 요청당 라이선스 정보를 캐시하기 위해 RequestStore를 사용했습니다. 그러나 그래도 요청당 한 쿼리로 이어졌습니다. 라이선스 정보를 캐시하기 시작한 후, 프로세스 전체에서 인메모리 캐시를 통해 1초 동안 쿼리 빈도가 급격히 감소하였습니다:

라이선스 콜 - 수정됨

여기서 캐싱 선택은 질문되는 데이터의 특성에 크게 의존합니다. 거의 업데이트되지 않는 매우 작은 데이터셋인 라이선스 데이터는 인메모리 캐싱을 위한 좋은 후보입니다. 여기서 프로세스 당 캐시가 선호됩니다. 왜냐하면 이것은 요청률로부터 캐시 갱신율을 분리시킵니다.

그러나 예외적으로, 우리의 Redis 설정은 현재 Redis 보조를 사용하지 않고 하나의 노드에 의존하고 있습니다. 즉, Redis가 증압으로 인해 다운되는 것을 방지하기 위해 균형을 잡아야 합니다. 이와 비교하여, PostgreSQL 레플리카에서 데이터를 읽는 것은 여러 읽기 전용 레플리카에 분산될 수 있습니다. 데이터베이스로부터 읽기는 더 비용이 들 수 있지만, 부하가 더 많은 노드에 분산됩니다.

레플리카로부터 읽기 전용 데이터 읽기

캐시를 구현하든 구현하지 않든, 가능한 경우에는 데이터베이스 레플리카에서 데이터를 읽는 것이 중요합니다. 이는 많은 데이터베이스 레플리카에 걸쳐 읽기를 확장하고 데이터베이스 기본 서버로부터 불필요한 작업을 줄이는 데 도움이 됩니다.

GitLab의 읽기용 데이터베이스 로드 밸런싱은 첫 번째 쓰기 작업 후나 명시적 트랜잭션을 열 때 기본 서버에 유지됩니다. 읽기 전용 데이터의 맥락에서, 이러한 데이터는 주로 트랜잭션 범위 밖에서 쓰기보다 먼저 읽으려고 노력합니다. 이는 이러한 데이터가 드물게 업데이트되기 때문에 (따라서 약간 이전의 데이터를 읽는 데 크게 문제가 되지 않는 경우) 종종 가능합니다. 그러나 이 쿼리가 이전 쓰기나 트랜잭션으로 인해 레플리카로 보내면 안 된다는 점은 명확하지 않을 수 있습니다. 그래서 읽기 전용 데이터를 만나면 이 데이터가 레플리카에서 읽힐 수 있는지에 대한 넓은 맥락을 확인하고 확실하게 하는 것이 좋은 실천입니다.