데이터베이스 로드 밸런싱
GitLab v19.2요약
데이터베이스 로드 밸런싱을 사용하면 읽기 전용 쿼리를 여러 PostgreSQL 노드에 분산할 수 있습니다. 모든 로드 밸런싱 클래스는 Gitlab::Database::LoadBalancing 네임스페이스에 속합니다: 각 워크로드는 로드 밸런싱 대상 데이터베이스마다 새로운 Session 인스턴스로 시작합니다.
데이터베이스 로드 밸런싱을 사용하면 읽기 전용 쿼리를 여러 PostgreSQL 노드에 분산할 수 있습니다. 구성 및 관리에 대해서는 데이터베이스 로드 밸런싱을 참고하세요.
용어#
| 용어 | 정의 |
|---|---|
| Host | 데이터베이스 호스트. 프라이머리 또는 레플리카일 수 있습니다. |
| Primary | 모든 쓰기 작업 및 읽기-쓰기 트랜잭션에 사용되는 기본 PostgreSQL 호스트입니다. |
| Replica | 읽기 전용 쿼리에 사용되는 보조 PostgreSQL 호스트입니다. |
| Workload | 데이터베이스 연결이 필요한 Rails 요청 또는 Sidekiq job입니다. |
| Sticking | 쓰기 이후 레플리카가 따라잡을 때까지 워크로드를 프라이머리로 라우팅하는 동작입니다. |
주요 클래스#
모든 로드 밸런싱 클래스는 Gitlab::Database::LoadBalancing 네임스페이스에 속합니다:
| 클래스 | 역할 |
|---|---|
| ConnectionProxy | ActiveRecord 연결 요청을 가로채 LoadBalancer로 라우팅합니다. |
| LoadBalancer | 호스트(프라이머리 또는 레플리카)를 선택하고 해당 풀에서 연결을 제공합니다. |
| Host | 단일 데이터베이스 호스트를 나타냅니다. online 상태와 복제 지연을 추적합니다. |
| Session | 워크로드별 데이터베이스 상태를 추적합니다: 쓰기가 발생했는지, 워크로드가 프라이머리에 고정되었는지 여부. |
| SessionMap | 워크로드 내에서 로드 밸런싱 대상 데이터베이스 각각을 고유한 Session 인스턴스에 매핑합니다. |
| Sticking | 네임스페이스와 ID를 키로 하여 Redis에 프라이머리 고정 상태를 관리합니다. |
쿼리 라우팅#
각 워크로드는 로드 밸런싱 대상 데이터베이스마다 새로운 Session 인스턴스로 시작합니다. Session은
해당 워크로드의 모든 데이터베이스 작업을 추적하며, 연결이 프라이머리로 향해야 하는지 레플리카로 향해야
하는지를 결정합니다.
ActiveRecord가 연결을 요청하면 ConnectionProxy는 다음 항목을 순서대로 평가합니다:
-
작업이 쓰기(insert, update, delete)인가? 프라이머리로 라우팅하고 세션을 쓰기 발생으로 표시합니다.
-
세션이 (이전 쓰기로 인해) 이미 프라이머리에 고정되어 있는가? 프라이머리로 라우팅합니다.
-
쿼리가
SELECT ... FOR UPDATE또는 유사한 잠금 읽기인가? 프라이머리로 라우팅합니다. -
use_primary블록이 활성 상태인가? 프라이머리로 라우팅합니다. -
그 외의 경우: 레플리카로 라우팅합니다.
특수 라우팅 블록#
다음 블록은 기본 라우팅 동작을 재정의합니다:
| 블록 | 효과 |
|---|---|
| use_primary | 블록 내 모든 쿼리를 프라이머리로 강제합니다. |
| use_primary! | 현재 세션을 남은 워크로드 동안 프라이머리에 고정합니다. |
| ignore_writes | 프라이머리에서 쓰기를 수행하되 프라이머리 고정을 트리거하지 않습니다. |
| use_replicas_for_read_queries | 세션이 프라이머리에 고정된 경우에도 읽기를 레플리카로 강제합니다. |
| fallback_to_replicas_for_ambiguous_queries | 쓰기가 발생하지 않았다면 트랜잭션과 모호한 쿼리가 레플리카를 사용하도록 허용합니다. |
트랜잭션#
기본적으로 트랜잭션은 프라이머리로 라우팅됩니다. fallback_to_replicas_for_ambiguous_queries가
활성 상태이면, 트랜잭션 내에서 쓰기가 수행되지 않은 한 트랜잭션이 레플리카를 사용할 수 있습니다.
트랜잭션 내부의 모든 쓰기는 프라이머리로 라우팅되며 고정을 트리거합니다.
use_replicas_for_read_queries로 표시된 블록 내부의 쓰기는
WriteInsideReadOnlyTransactionError를 발생시킵니다.
프라이머리 고정#
쓰기 이후 GitLab은 레플리카가 따라잡을 때까지 워크로드를 프라이머리에 고정합니다. 고정은 단순한 타임아웃이 아니라 PostgreSQL LSN(Log Sequence Number) 위치를 사용해 Redis에서 추적됩니다.
고정 범위#
고정은 (namespace, id) 쌍으로 범위가 지정됩니다. 웹 요청의 경우 namespace는 :user이고 id는
현재 사용자 ID입니다. 여러 네임스페이스가 동시에 고정될 수 있으며 각각 독립적으로 추적됩니다. Redis
키 형식은 lib/gitlab/database/load_balancing/sticking.rb의 Sticking#sticking_key를 참고하세요.
API 토큰이나 세션 토큰과 같은 민감한 식별자의 경우, 고정 메서드에 hash_id: true를 전달하세요.
이렇게 하면 원본 값 대신 ID의 SHA-256 해시를 저장합니다:
Gitlab::Database::LoadBalancing::Sticking.stick(:api_key, token, hash_id: true)
해제 메커니즘#
다음 중 하나가 발생하면 고정이 해제됩니다:
-
레플리카의 LSN이 기록된 쓰기 LSN에 도달하거나 이를 넘어섰을 때. GitLab은 전체 30초 타임아웃을 기다리지 않고 즉시 고정을 해제합니다.
-
복제 상태와 무관하게 30초 만료 시간에 도달했을 때.
LSN 비교는 동일 사용자의 동시 요청 간 경쟁 조건을 피하기 위해 Redis의 원자적 Lua 스크립트를 사용합니다.
Sidekiq 고정#
Sidekiq은 백그라운드 job에서의 오래된 읽기를 방지하기 위해 WAL 기반 방식을 사용합니다:
-
큐 등록 시 (
SidekiqClientMiddleware): 로드 밸런싱 대상 데이터베이스 각각의 현재 LSN이 해당 LSN이 프라이머리에서 왔는지 레플리카에서 왔는지 여부와 함께 job 페이로드에 기록됩니다. -
실행 시 (
SidekiqServerMiddleware): job이 실행되기 전에 GitLab은 레플리카가 기록된 LSN을 따라잡았는지 확인합니다.
높은 긴급도의 워커: 0.5초 동안 최대 5회 시도합니다.
-
일반 워커: 1.5초 동안 최대 3회 시도합니다.
-
시간 내에 따라잡는 레플리카가 없으면 job은 프라이머리에서 읽는 방식으로 폴백합니다.
레플리카 지연 확인#
레플리카로 읽기를 라우팅하기 전에, LoadBalancer는 레플리카가 프라이머리와 충분히 동기화되어 있는지
확인합니다.
확인 간격#
지연 확인은 replica_check_interval과 2 * replica_check_interval초 사이의 무작위 간격으로
실행됩니다. 상태 점검 부하를 분산하기 위해 간격은 프로세스마다 무작위로 정해집니다.
2단계 지연 평가#
확인은 두 가지 방법을 순차적으로 사용합니다:
-
시간 기반:
pg_last_wal_replay_lsn()을 프라이머리의 현재 LSN과 비교합니다. 레플리카가max_replication_lag_time초를 초과하여 지연되면 건너뜁니다. -
데이터 크기 폴백: 시간 기반 확인으로 지연을 판단할 수 없는 경우(예: 프라이머리에 최근 쓰기가 없었던 경우), GitLab은 바이트 단위 LSN 차이를
max_replication_difference와 비교하는 방식으로 폴백합니다. 이는 쓰기가 적은 시기에 레플리카가 잘못 offline으로 표시되는 것을 방지합니다.
논리적 레플리카#
논리적 레플리카의 경우, GitLab은 복제 지연을 판단하기 위해 pg_last_wal_replay_lsn 대신
pg_replication_origin_status를 사용합니다. GitLab은 쿼리를 실행하는 사용자가
pg_replication_origin_status에 대한 SELECT 권한을 가진 경우 논리적 레플리카를 자동으로
감지합니다. 논리적 레플리카 감지에는 PostgreSQL 14 이상이 필요합니다.
기능 플래그#
다음 기능 플래그는 런타임에 레플리카 지연 동작에 영향을 줍니다:
| 플래그 | 효과 |
|---|---|
| load_balancer_ignore_replication_lag_time | 시간 기반 지연 확인을 전면 비활성화합니다. |
| load_balancer_double_replication_lag_time | 구성된 max_replication_lag_time의 최대 2배까지 허용합니다. |
| load_balancer_low_statement_timeout | 오버헤드를 제한하기 위해 상태 점검 쿼리에 100ms statement 타임아웃을 사용합니다. |
페일오버 처리#
로드 밸런서는 실패한 작업이 읽기였는지 쓰기였는지에 따라 실패를 다르게 처리합니다.
읽기 페일오버#
연결 오류로 읽기가 실패하면, LoadBalancer는 해당 호스트를 offline으로 표시하고 다음으로 사용
가능한 레플리카로 재시도합니다. 모든 레플리카가 offline이면 읽기는 프라이머리로 폴백합니다.
쿼리 충돌(직렬화 실패)로 읽기가 실패하면, LoadBalancer는 프라이머리로 폴백하기 전에 모든 레플리카에
걸쳐 최대 host_list.length * 3회 재시도합니다.
load_balancer_force_release_hosts 기능 플래그는 현재 호스트만이 아니라 모든 호스트에서 연결을
해제하도록 강제하며, 이는 특정 페일오버 시나리오에서 도움이 될 수 있습니다.
쓰기 페일오버#
쓰기 재시도는 트랜잭션 상태에 따라 달라집니다:
-
열린 트랜잭션 외부: 지수 백오프(2초, 4초, 8초)로 최대 3회 재시도합니다.
-
열린 트랜잭션 내부: 재시도하지 않습니다. 트랜잭션은 안전하게 재실행할 수 없으므로 오류가 즉시 표면화됩니다.
서비스 디스커버리#
서비스 디스커버리를 사용할 때, LoadBalancer는 현재 레플리카 주소 목록을 얻기 위해 구성된 DNS
레코드를 주기적으로 확인합니다. 목록은 DNS 응답이 변경되거나 오래된 연결에 대해 disconnect_timeout에
도달하면 업데이트됩니다.
max_replica_pools#
max_replica_pools가 설정되면, Sampler 클래스가 각 GitLab 프로세스가 연결하는 레플리카 수를
제한합니다. 이는 서비스 디스커버리에만 적용됩니다. 정적 호스트 목록은 항상 구성된 모든 호스트에
연결합니다.
샘플러는 일관된 시드를 사용해 프로세스마다 결정론적으로 레플리카를 선택하여, 반환된 모든 호스트명에
연결을 고르게 분산합니다. 레플리카 수가 max_replica_pools를 초과하면, 샘플러는 어떤 레플리카가
제외되었는지 로그로 기록합니다.
쓰기 위치 쿼리#
GitLab은 현재 쓰기 LSN을 판단하기 위해 두 가지 SQL 쿼리 중 하나를 사용하며, 이는
USE_NEW_LOAD_BALANCER_QUERY 환경 변수(기본값: true)로 제어됩니다. 기본 쿼리는 활성 복제 슬롯을
가진 스탠바이를 올바르게 처리하고, 레거시 쿼리는 무조건 pg_current_wal_insert_lsn()을 사용합니다.
구현은 lib/gitlab/database/load_balancing/load_balancer.rb의 LoadBalancer#query_for_location을
참고하세요.
USE_NEW_LOAD_BALANCER_QUERY=false는 오래된 PostgreSQL 설정에서 예기치 않은 LSN 동작을 진단할
때만 설정하세요.
배포 전략#
기능 플래그 뒤에서 로드 밸런싱 변경 사항을 롤아웃할 때는 Sidekiq 워커에 먼저 배포하세요.
Sidekiq에 먼저 배포하는 이유:
-
API 파드가 안정적으로 유지되어, 필요 시 플래그를 비활성화할 수 있도록 ChatOps를 계속 사용할 수 있습니다.
-
백그라운드 job은 사용자 영향 없이 자동으로 재시도됩니다.
예시:
if Feature.enabled?(:my_flag) && Gitlab::Runtime.sidekiq?
new_changes
else
existing_changes
end