복제 지연은 왜 생기는가

MySQL의 비동기 복제에서 소스(source)는 트랜잭션을 병렬로 처리하지만, 레플리카(replica)는 전통적으로 릴레이 로그를 단일 SQL 스레드가 순차적으로 재생(replay)한다. 소스에서 수십 개 커넥션이 동시에 쓴 부하를 레플리카는 한 개 스레드로 따라가야 하므로, 쓰기가 몰리는 시간대에 Seconds_Behind_Source가 수십 초에서 수 분까지 벌어진다.

복제 지연은 단순한 지표 문제가 아니다. 읽기 트래픽을 레플리카로 분산한 서비스라면 지연된 레플리카가 오래된 데이터를 반환해 "방금 저장했는데 안 보인다"는 정합성 문제가 되고, 페일오버 시점에는 데이터 손실 폭을 키운다.

병렬 복제의 핵심 아이디어

해법은 레플리카의 SQL 재생을 여러 워커 스레드로 병렬화하는 것이다. 관건은 "어떤 트랜잭션들을 안전하게 동시에 적용할 수 있는가"이며, MySQL은 replica_parallel_type으로 이 판단 기준을 정한다.

방식병렬화 단위특징
DATABASE스키마별서로 다른 DB만 병렬. 단일 DB 서비스엔 효과 없음
LOGICAL_CLOCK커밋 그룹소스에서 동시에 커밋된 트랜잭션을 병렬 적용. 사실상 표준

LOGICAL_CLOCK은 소스에서 함께 커밋된(같은 그룹 커밋에 속한) 트랜잭션은 서로 락 충돌이 없었다는 사실을 이용한다. 이 정보를 바이너리 로그의 last_committed/sequence_number로 전달받아 레플리카가 안전하게 병렬 적용한다.

레플리카 설정

MySQL 8.0 기준 설정 예시다. 워커 수는 코어 수와 쓰기 동시성을 고려해 정한다.

-- 레플리카에서 실행 (SQL 스레드 정지 후 적용)
STOP REPLICA SQL_THREAD;

SET GLOBAL replica_parallel_type = 'LOGICAL_CLOCK';
SET GLOBAL replica_parallel_workers = 8;
-- 커밋 순서 보존: 정합성/GTID 일관성에 필수
SET GLOBAL replica_preserve_commit_order = ON;

START REPLICA SQL_THREAD;

영구 적용은 my.cnf에 둔다.

# /etc/mysql/my.cnf  (replica)
[mysqld]
replica_parallel_type            = LOGICAL_CLOCK
replica_parallel_workers         = 8
replica_preserve_commit_order    = ON
# 크래시 안전 복제
relay_log_recovery               = ON
relay_log_info_repository        = TABLE

replica_preserve_commit_order = ON은 병렬로 적용하되 커밋만은 소스와 같은 순서로 확정하게 만든다. 이걸 끄면 워커 간 커밋 순서가 뒤바뀌어 GTID 기반 복제와 시점 정합성이 깨질 수 있으므로 사실상 항상 켠다.

소스에서 병렬성을 키우는 법

레플리카 워커를 8개로 늘려도 소스의 그룹 커밋이 잘게 쪼개져 있으면 병렬화될 여지가 없다. 한 그룹에 더 많은 트랜잭션이 묶이도록 소스에서 커밋을 약간 지연시켜 그룹을 키운다.

# /etc/mysql/my.cnf  (source)
[mysqld]
# 그룹 커밋을 최대 이 시간(마이크로초)만큼 대기
binlog_group_commit_sync_delay        = 100
# 이 개수가 모이면 delay 이전에 즉시 flush
binlog_group_commit_sync_no_delay_count = 20

sync_delay는 소스 커밋에 지연(위 예시 100µs)을 더하는 트레이드오프가 있다. 쓰기 지연에 민감한 서비스라면 작은 값부터 올리며 레플리카 병렬 효율과 균형을 맞춘다.

효과 측정

설정 후에는 실제로 워커가 일하고 지연이 주는지 확인해야 한다. Seconds_Behind_Source만 보지 말고 워커별 처리량을 함께 본다.

import subprocess, time, re

def lag():
    out = subprocess.check_output(
        ["mysql", "-N", "-e", "SHOW REPLICA STATUS\\G"], text=True)
    m = re.search(r"Seconds_Behind_Source:\s*(\d+)", out)
    return int(m.group(1)) if m else None

while True:
    print(f"lag={lag()}s")
    time.sleep(5)

병렬화가 실제로 걸렸는지는 performance_schema.replication_applier_status_by_worker에서 워커별 LAST_APPLIED_TRANSACTION이 여러 스레드에 골고루 갱신되는지로 확인한다. 한 워커만 바쁘다면 소스 그룹 커밋이 작다는 신호다.

주의점

  • 단일 핫 테이블: 대부분의 쓰기가 한 테이블·한 로우에 집중되면 커밋 그룹이 작아 병렬 효과가 제한된다. 병렬 복제 이전에 애플리케이션 쓰기 패턴을 먼저 본다.
  • 워커 과다: 코어 수를 넘는 워커는 컨텍스트 스위칭과 preserve_commit_order의 순서 대기 때문에 오히려 손해다. 코어 수 안쪽에서 튜닝한다.
  • 긴 트랜잭션: 대량 UPDATE/DELETE 하나가 워커를 오래 점유하면 커밋 순서 보존 때문에 뒤 트랜잭션이 밀린다. 배치는 잘게 나눠 커밋한다.
  • 모니터링 지표: Seconds_Behind_Source는 릴레이 로그 수신 지연을 반영하지 못할 때가 있다. GTID 기반이라면 소스와 레플리카의 gtid_executed 차이를 함께 보는 편이 정확하다.

병렬 복제는 설정 몇 줄로 켤 수 있지만, 실제 이득은 소스의 그룹 커밋 크기와 쓰기 분산에 달려 있다. 레플리카 워커 수만 늘리기 전에 소스 쪽 병렬성부터 확보하는 것이 순서다.