카테고리 : MySQL/기술노트

Replica lag 원인 분석: 수신 지연·적용 지연·worker 병목 구분

MySQL 복제 지연을 수신, relay log 적체, worker 실행·잠금 대기로 분해하고 GTID와 Performance Schema로 원인을 판별한다.

저자: MySQL 기술 노트 작성: 2026.09.12 약 14분 8,140자
다운로드

복제 지연은 하나의 숫자로 감시할 수 있지만, 하나의 원인으로 설명할 수는 없다. 네트워크가 느려 binlog를 받지 못하는 replica와, binlog를 모두 받았으나 하나의 대형 트랜잭션을 적용하느라 뒤처진 replica는 조치가 다르다. 후자에서 네트워크 대역폭을 늘리거나 전자에서 병렬 worker 수를 늘려도 병목은 해소되지 않는다.

이 글은 source에서 생성된 변경 → replica가 수신한 변경 → 적용을 완료한 변경이라는 세 경계를 기준으로 지연을 분석한다. worker lag는 별도의 세 번째 복제 프로토콜이 아니라, 적용 지연을 worker 수준으로 세분화한 관측이라는 점이 출발점이다.

대상은 Community MySQL 8.0의 후기 버전과 8.4 LTS의 비동기 binlog 복제다. 진단 SQL은 MySQL 8.0.46의 독립 테스트 인스턴스에서 실행했고, 별도 두 인스턴스의 GTID 복제에서 수신 중단·적용 중단·worker의 행 잠금 대기를 재현했다. Aurora의 클러스터 내부 복제와 외부 binlog 복제는 뒤에서 구분한다.

1. 복제 파이프라인과 세 개의 진행 경계

source는 커밋된 변경을 binlog로 제공한다. replica의 receiver, 즉 I/O thread는 source의 binlog dump thread와 통신하여 이벤트를 relay log에 기록한다. 다중 스레드 적용에서는 coordinator가 트랜잭션 의존성과 큐 상태를 고려해 worker에 작업을 배분하고, worker는 스토리지 엔진에 변경을 적용한다.

flowchart LR
    A["source 커밋 및 binlog"] --> B["dump thread와 네트워크"]
    B --> C["receiver: relay log 기록"]
    C --> D["coordinator: 의존성 확인과 배분"]
    D --> E["worker: 행 변경과 잠금 대기"]
    E --> F["커밋 순서 조정과 적용 완료"]
    A -. "생성 경계 S" .-> G["수신 지연: S와 수신 경계 비교"]
    C -. "수신 경계 R" .-> G
    C -. "수신 경계 R" .-> H["적용 적체: 수신했으나 미실행인 변경"]
    F -. "실행 집합 E" .-> H
구분 실제로 확인할 경계 대표적인 원인
수신 지연, IO lag source의 생성 진도와 replica 수신 진도 연결 실패, 전송 대역폭, source의 binlog 읽기 병목, relay log 기록 지연
적용 지연, SQL lag 수신 완료한 트랜잭션과 실행 완료한 트랜잭션 적용 중단, 대형 트랜잭션, DDL, 잠금, CPU·스토리지 부족
worker 병목 실행 중 GTID, worker별 상태와 대기 관계 특정 worker의 긴 작업, 행 충돌, 커밋 순서 대기, 불충분한 독립 작업

receiver는 채널마다 하나이므로 replica_parallel_workers를 늘려도 수신 스레드는 늘지 않는다. 또 relay log 적체가 커졌다고 항상 디스크가 원인인 것은 아니다. 적용이 막혀 로그가 쌓일 수도 있고, relay log 공간 제한에 도달해 receiver까지 멈출 수도 있다. 두 단계가 동시에 뒤처졌다면 먼저 발생한 병목을 시계열로 찾아야 한다.

2. Seconds_Behind_Source를 단독 판정 기준으로 쓰지 않는다

SHOW REPLICA STATUSSeconds_Behind_Source는 현재 복제 상태를 빠르게 살펴보는 지표다. 그러나 source의 모든 최신 커밋과 replica의 모든 조회 사이에 생기는 실제 지연을 직접 측정한 값은 아니다.

  • 수신이 느리면 0이어도 뒤처질 수 있다. applier가 receiver가 전달한 이벤트를 모두 처리했어도, receiver 자체가 source를 따라잡지 못했을 수 있다.
  • NULL은 정상 지연 값이 아니다. 스레드 중단이나 측정할 수 없는 상태를 먼저 확인해야 하며 0으로 치환하면 안 된다.
  • 병렬 적용에서는 대표 진도와 개별 worker 상태가 다르다. 앞선 트랜잭션이 늦고 뒤의 작업이 진행 중이면 단일 위치만으로 worker 분포를 설명할 수 없다.
  • 시계와 경로의 영향을 받는다. 서버 간 시계 편차와 시간 보정, 중간 source를 거치는 연쇄 복제는 타임스탬프 기반 해석을 복잡하게 만든다.
  • 의도된 지연을 먼저 제외해야 한다. SQL_DelaySQL_Remaining_Delay를 확인하지 않으면 지연 복제를 장애로 오인할 수 있다.

알람에는 지연 초뿐 아니라 receiver/applier 실행 여부, 마지막 오류, 수신·적용 경계의 변화, 서비스의 읽기 신선도 요구를 함께 사용한다. heartbeat 테이블은 한 가지 보조 수단이지만, 해당 heartbeat 트랜잭션의 가시성을 보여줄 뿐 모든 채널과 모든 테이블의 일관성을 대신 보증하지 않는다.

3. 첫 관측은 버전·채널·오류에서 시작한다

다음 SQL은 버전과 주요 복제 설정을 확인한다. 전역 병렬 worker 설정이 현재 실행 중인 모든 채널의 실제 worker 구성을 곧바로 의미하지는 않는다. 설정 변경의 적용 시점과 실행 중 worker 목록을 함께 확인한다.

SELECT VERSION() AS mysql_version;
SELECT VARIABLE_NAME, VARIABLE_VALUE
FROM performance_schema.global_variables
WHERE VARIABLE_NAME IN (
  'gtid_mode', 'replica_parallel_workers',
  'replica_preserve_commit_order', 'relay_log_space_limit'
)
ORDER BY VARIABLE_NAME;

실행 결과(MySQL 8.0.x):

mysql> SELECT VERSION() AS mysql_version;

+---------------+
| mysql_version |
+---------------+
| 8.0.46        |
+---------------+
1 row in set (0.00 sec)

mysql> SELECT VARIABLE_NAME, VARIABLE_VALUE
    -> FROM performance_schema.global_variables
    -> WHERE VARIABLE_NAME IN (
    ->   'gtid_mode', 'replica_parallel_workers',
    ->   'replica_preserve_commit_order', 'relay_log_space_limit'
    -> )
    -> ORDER BY VARIABLE_NAME;

+-------------------------------+----------------+
| VARIABLE_NAME                 | VARIABLE_VALUE |
+-------------------------------+----------------+
| gtid_mode                     | ON             |
| relay_log_space_limit         | 0              |
| replica_parallel_workers      | 4              |
| replica_preserve_commit_order | ON             |
+-------------------------------+----------------+
4 rows in set (0.00 sec)

운영 replica에서는 SHOW REPLICA STATUS를 실행해 Channel_Name, Replica_IO_Running, Replica_SQL_Running, Last_IO_Errno, Last_SQL_Errno, 오류 메시지를 먼저 보존한다. 오류 메시지에는 접속 정보가 포함될 수 있으므로 외부 공유 전 비식별화한다. 채널이 여러 개라면 수신·적용 진도와 오류를 반드시 같은 채널끼리 비교한다.

아래 실행 결과는 별도 두 인스턴스 실험에서 replica의 행 잠금이 worker를 막고 있는 동안 실제 진단 SQL을 실행한 값이다. 빈 CHANNEL_NAME은 기본 채널이며, GTID·접속 ID·경과 시간은 해당 실험의 관측값이다. 채널을 구성하지 않은 독립 인스턴스에서는 같은 상태 쿼리가 Empty set을 반환할 수 있지만, 이를 운영 복제가 정상이라는 증거로 삼을 수는 없다. 운영에서는 Performance Schema 접근 권한이 필요하고, SHOW REPLICA STATUS에는 적절한 복제 상태 조회 권한이 필요하다.

4. 수신 지연: source와 receiver가 같은 곳을 보고 있는가

수신 스레드의 상태와 relay log 기록 시간

SELECT CHANNEL_NAME, SERVICE_STATE, LAST_ERROR_NUMBER,
       LAST_QUEUED_TRANSACTION,
       CASE
         WHEN LAST_QUEUED_TRANSACTION <> ''
          AND LAST_QUEUED_TRANSACTION_START_QUEUE_TIMESTAMP > '2000-01-01'
          AND LAST_QUEUED_TRANSACTION_END_QUEUE_TIMESTAMP > '2000-01-01'
         THEN ROUND(TIMESTAMPDIFF(MICROSECOND,
                LAST_QUEUED_TRANSACTION_START_QUEUE_TIMESTAMP,
                LAST_QUEUED_TRANSACTION_END_QUEUE_TIMESTAMP) / 1000000, 6)
       END AS last_queue_seconds
FROM performance_schema.replication_connection_status
ORDER BY CHANNEL_NAME;

실행 결과(MySQL 8.0.x):

mysql> SELECT CHANNEL_NAME, SERVICE_STATE, LAST_ERROR_NUMBER,
    ->        LAST_QUEUED_TRANSACTION,
    ->        CASE
    ->          WHEN LAST_QUEUED_TRANSACTION <> ''
    ->           AND LAST_QUEUED_TRANSACTION_START_QUEUE_TIMESTAMP > '2000-01-01'
    ->           AND LAST_QUEUED_TRANSACTION_END_QUEUE_TIMESTAMP > '2000-01-01'
    ->          THEN ROUND(TIMESTAMPDIFF(MICROSECOND,
    ->                 LAST_QUEUED_TRANSACTION_START_QUEUE_TIMESTAMP,
    ->                 LAST_QUEUED_TRANSACTION_END_QUEUE_TIMESTAMP) / 1000000, 6)
    ->        END AS last_queue_seconds
    -> FROM performance_schema.replication_connection_status
    -> ORDER BY CHANNEL_NAME;

+--------------+---------------+-------------------+-----------------------------------------+--------------------+
| CHANNEL_NAME | SERVICE_STATE | LAST_ERROR_NUMBER | LAST_QUEUED_TRANSACTION                 | last_queue_seconds |
+--------------+---------------+-------------------+-----------------------------------------+--------------------+
|              | ON            |                 0 | de4c0cd5-ae3d-11f1-90f4-0242ac190002:13 |             0.0000 |
+--------------+---------------+-------------------+-----------------------------------------+--------------------+
1 row in set (0.00 sec)

SERVICE_STATE와 마지막 오류는 수신 스레드의 생존·연결 상태를 판단하는 출발점이다. last_queue_seconds는 마지막 트랜잭션의 첫 이벤트를 큐에 넣기 시작한 시점부터 relay log에 큐잉을 끝낸 시점까지의 시간이다. 전송과 기록 구간을 포함할 수 있지만, source가 커밋한 뒤 receiver가 첫 이벤트를 받기까지의 전체 대기 시간은 아니다. 마지막 한 건의 값으로 처리량이나 지속 병목을 단정하지 않는다.

파일/위치 방식에서는 source의 현재 binlog 파일·위치와 replica의 Source_Log_File·Read_Source_Log_Pos를 비교한다. source 쪽 조회 명령은 8.0의 SHOW MASTER STATUS, 8.4의 SHOW BINARY LOG STATUS로 버전에 맞게 선택한다. 파일명이 다르면 position 값만 빼지 않는다. position은 파일 내부 바이트 오프셋이므로 파일 경계를 넘어선 시간 차이나 트랜잭션 수가 아니다.

GTID 환경에서는 source에서 채취한 실행 집합과 replica의 수신 집합·실행 집합을 비교한다. 다만 수신 집합은 replica의 전체 이력과 동일하지 않다. 백업으로 이미 가져온 GTID, relay log 이력과 초기화 여부를 고려하지 않고 source 실행 집합 - 수신 집합을 전부 네트워크 backlog라고 부르면 안 된다. 이미 적용한 집합도 함께 고려하고, 대상 source의 계보와 비교 시점을 고정한다.

수신이 실제로 뒤처진 경우의 점검 순서는 다음과 같다.

  1. 연결 재시도·인증·TLS·source binlog 보존 문제 등 오류를 확인한다.
  2. source의 생성 진도와 receiver 진도를 일정 간격으로 두 번 이상 채취한다.
  3. source의 binlog 읽기와 dump thread 대기, 네트워크 재전송·대역폭, replica의 relay log 기록 지연을 분리한다.
  4. relay_log_space_limit과 디스크 여유를 확인한다. 적용 정체가 공간 제한을 통해 수신까지 막았는지 살핀다.
  5. 쓰기 유입량이 수신 처리량을 계속 초과하는지 관찰한다. 누적 적체가 줄지 않으면 단순한 일시적 지연이 아니다.

5. 적용 지연: 받았지만 끝내지 못한 트랜잭션 찾기

다음 쿼리는 채널별 수신 집합에서 서버 전체의 실행 집합을 제외한다. 결과를 200자까지만 표시하므로 긴 집합의 일부가 생략될 수 있다. 전체 집합은 별도의 제한된 진단 파일로 수집하며, GTID 문자열 길이를 트랜잭션 개수로 해석하지 않는다.

SELECT CHANNEL_NAME,
       GTID_SUBTRACT(RECEIVED_TRANSACTION_SET,
                     @@GLOBAL.gtid_executed) = '' AS no_received_backlog,
       LEFT(GTID_SUBTRACT(RECEIVED_TRANSACTION_SET,
                          @@GLOBAL.gtid_executed), 200) AS pending_gtid_prefix
FROM performance_schema.replication_connection_status
ORDER BY CHANNEL_NAME;

실행 결과(MySQL 8.0.x):

mysql> SELECT CHANNEL_NAME,
    ->        GTID_SUBTRACT(RECEIVED_TRANSACTION_SET,
    ->                      @@GLOBAL.gtid_executed) = '' AS no_received_backlog,
    ->        LEFT(GTID_SUBTRACT(RECEIVED_TRANSACTION_SET,
    ->                           @@GLOBAL.gtid_executed), 200) AS pending_gtid_prefix
    -> FROM performance_schema.replication_connection_status
    -> ORDER BY CHANNEL_NAME;

+--------------+---------------------+-----------------------------------------+
| CHANNEL_NAME | no_received_backlog | pending_gtid_prefix                     |
+--------------+---------------------+-----------------------------------------+
|              |                   0 | de4c0cd5-ae3d-11f1-90f4-0242ac190002:13 |
+--------------+---------------------+-----------------------------------------+
1 row in set (0.00 sec)

no_received_backlog = 1관측한 수신 집합에 아직 실행되지 않은 GTID가 없다는 뜻이다. source에서 아직 받지 않은 변경이 없다는 뜻은 아니다. receiver가 중단된 상태에서도 이 값은 1일 수 있다. GTID를 사용하지 않는 채널에는 이 판정법을 적용하지 않는다.

반대로 미실행 집합이 남아 있으면 적용 중·대기 중 트랜잭션을 조사한다. 이 집합에는 현재 worker가 처리하는 트랜잭션도 포함될 수 있어 전부 coordinator 앞에 대기하는 작업이라고 보아서는 안 된다. 필터, 다른 채널에서 이미 실행된 GTID, 의도적으로 건너뛴 이력도 데이터 일관성 판단에 영향을 준다. 집합 일치 자체가 행 내용의 완전한 동일성을 증명하지 않는다.

파일 기반 진도는 Source_Log_File·Read_Source_Log_PosRelay_Source_Log_File·Exec_Source_Log_Pos를 비교한다. 다중 스레드 적용에서 실행 위치는 모든 worker의 가장 앞선 위치가 아니라, 앞부분에 미완료 구간이 없는 경계로 해석해야 한다. Relay_Log_Space 역시 보유 중인 relay log 크기이며 정확한 미적용 트랜잭션 수나 남은 복구 시간을 뜻하지 않는다.

처리량을 비교할 때는 같은 단위를 사용한다. 작은 트랜잭션과 수백만 행을 바꾸는 트랜잭션을 모두 한 건으로 세면 유입 TPS와 적용 TPS만으로 catch-up 시간을 예측하기 어렵다. 초당 적용량뿐 아니라 대형 트랜잭션 분포, 바이트량, 잠금·I/O 대기 시간을 함께 본다.

6. worker 병목: 실행 시간과 기다리는 시간을 분리한다

coordinator와 worker를 연결해 보기

coordinator의 존재와 상태를 확인한 다음 worker별 현재 트랜잭션을 조회한다. 단일 스레드 적용에서는 coordinator 테이블이 비어 있을 수 있다. worker의 SERVICE_STATE = ON도 지금 CPU에서 변경을 처리 중이라는 뜻이 아니라, 스레드가 존재하며 활성 또는 유휴 상태일 수 있다는 뜻이다.

SELECT c.CHANNEL_NAME, c.SERVICE_STATE, c.LAST_ERROR_NUMBER,
       t.PROCESSLIST_STATE
FROM performance_schema.replication_applier_status_by_coordinator c
LEFT JOIN performance_schema.threads t ON t.THREAD_ID = c.THREAD_ID
ORDER BY c.CHANNEL_NAME;

SELECT w.CHANNEL_NAME, w.WORKER_ID, w.SERVICE_STATE,
       w.LAST_ERROR_NUMBER, w.APPLYING_TRANSACTION,
       CASE
         WHEN w.APPLYING_TRANSACTION <> ''
          AND w.APPLYING_TRANSACTION_START_APPLY_TIMESTAMP > '2000-01-01'
         THEN ROUND(TIMESTAMPDIFF(MICROSECOND,
                w.APPLYING_TRANSACTION_START_APPLY_TIMESTAMP,
                NOW(6)) / 1000000, 3)
       END AS current_apply_seconds,
       t.PROCESSLIST_STATE
FROM performance_schema.replication_applier_status_by_worker w
LEFT JOIN performance_schema.threads t ON t.THREAD_ID = w.THREAD_ID
ORDER BY w.CHANNEL_NAME, w.WORKER_ID;

실행 결과(MySQL 8.0.x):

mysql> SELECT c.CHANNEL_NAME, c.SERVICE_STATE, c.LAST_ERROR_NUMBER,
    ->        t.PROCESSLIST_STATE
    -> FROM performance_schema.replication_applier_status_by_coordinator c
    -> LEFT JOIN performance_schema.threads t ON t.THREAD_ID = c.THREAD_ID
    -> ORDER BY c.CHANNEL_NAME;

+--------------+---------------+-------------------+----------------------------------------------------------+
| CHANNEL_NAME | SERVICE_STATE | LAST_ERROR_NUMBER | PROCESSLIST_STATE                                        |
+--------------+---------------+-------------------+----------------------------------------------------------+
|              | ON            |                 0 | Replica has read all relay log; waiting for more updates |
+--------------+---------------+-------------------+----------------------------------------------------------+
1 row in set (0.00 sec)

mysql> SELECT w.CHANNEL_NAME, w.WORKER_ID, w.SERVICE_STATE,
    ->        w.LAST_ERROR_NUMBER, w.APPLYING_TRANSACTION,
    ->        CASE
    ->          WHEN w.APPLYING_TRANSACTION <> ''
    ->           AND w.APPLYING_TRANSACTION_START_APPLY_TIMESTAMP > '2000-01-01'
    ->          THEN ROUND(TIMESTAMPDIFF(MICROSECOND,
    ->                 w.APPLYING_TRANSACTION_START_APPLY_TIMESTAMP,
    ->                 NOW(6)) / 1000000, 3)
    ->        END AS current_apply_seconds,
    ->        t.PROCESSLIST_STATE
    -> FROM performance_schema.replication_applier_status_by_worker w
    -> LEFT JOIN performance_schema.threads t ON t.THREAD_ID = w.THREAD_ID
    -> ORDER BY w.CHANNEL_NAME, w.WORKER_ID;

+--------------+-----------+---------------+-------------------+-----------------------------------------+-----------------------+----------------------------------------+
| CHANNEL_NAME | WORKER_ID | SERVICE_STATE | LAST_ERROR_NUMBER | APPLYING_TRANSACTION                    | current_apply_seconds | PROCESSLIST_STATE                      |
+--------------+-----------+---------------+-------------------+-----------------------------------------+-----------------------+----------------------------------------+
|              |         1 | ON            |                 0 | de4c0cd5-ae3d-11f1-90f4-0242ac190002:13 |                 0.726 | Applying batch of row changes (update) |
|              |         2 | ON            |                 0 |                                         |                  NULL | Waiting for an event from Coordinator  |
|              |         3 | ON            |                 0 |                                         |                  NULL | Waiting for an event from Coordinator  |
|              |         4 | ON            |                 0 |                                         |                  NULL | Waiting for an event from Coordinator  |
+--------------+-----------+---------------+-------------------+-----------------------------------------+-----------------------+----------------------------------------+
4 rows in set (0.00 sec)

current_apply_seconds는 현재 트랜잭션의 첫 적용 시도부터 흐른 시간이다. CPU 실행 시간뿐 아니라 잠금·I/O·커밋 순서 대기와 재시도 구간이 섞일 수 있다. 시스템 시계 보정의 영향도 받는다. 값이 크다는 이유만으로 해당 트랜잭션의 SQL이 CPU를 많이 쓴다고 단정하지 않는다.

완료된 작업은 LAST_APPLIED_TRANSACTION_START_APPLY_TIMESTAMPLAST_APPLIED_TRANSACTION_END_APPLY_TIMESTAMP로 처리 구간을 볼 수 있다. original source의 커밋 시각과 비교하면 연쇄 복제 전체 경로, immediate source의 커밋 시각과 비교하면 바로 앞 source 이후의 경로를 관찰하는 데 도움이 된다. 서로 다른 서버 시각을 빼는 계산에는 시계 동기화 확인이 선행되어야 한다.

유휴 worker의 마지막 완료 시각이 오래되었다고 지연 알람을 울리는 것도 잘못이다. 배분된 작업이 없었던 worker는 마지막 값이 그대로 남는다. 현재 작업 유무와 새 트랜잭션의 진행 여부를 함께 판단한다.

worker의 실제 행 잠금 대기 확인

SELECT w.CHANNEL_NAME, w.WORKER_ID,
       r.OBJECT_SCHEMA, r.OBJECT_NAME, r.INDEX_NAME,
       r.LOCK_MODE AS waiting_lock_mode,
       bt.PROCESSLIST_ID AS blocking_connection_id
FROM performance_schema.replication_applier_status_by_worker w
JOIN performance_schema.data_lock_waits d
  ON d.REQUESTING_THREAD_ID = w.THREAD_ID
JOIN performance_schema.data_locks r
  ON r.ENGINE = d.ENGINE
 AND r.ENGINE_LOCK_ID = d.REQUESTING_ENGINE_LOCK_ID
LEFT JOIN performance_schema.threads bt
  ON bt.THREAD_ID = d.BLOCKING_THREAD_ID
ORDER BY w.CHANNEL_NAME, w.WORKER_ID
LIMIT 10;

실행 결과(MySQL 8.0.x):

mysql> SELECT w.CHANNEL_NAME, w.WORKER_ID,
    ->        r.OBJECT_SCHEMA, r.OBJECT_NAME, r.INDEX_NAME,
    ->        r.LOCK_MODE AS waiting_lock_mode,
    ->        bt.PROCESSLIST_ID AS blocking_connection_id
    -> FROM performance_schema.replication_applier_status_by_worker w
    -> JOIN performance_schema.data_lock_waits d
    ->   ON d.REQUESTING_THREAD_ID = w.THREAD_ID
    -> JOIN performance_schema.data_locks r
    ->   ON r.ENGINE = d.ENGINE
    ->  AND r.ENGINE_LOCK_ID = d.REQUESTING_ENGINE_LOCK_ID
    -> LEFT JOIN performance_schema.threads bt
    ->   ON bt.THREAD_ID = d.BLOCKING_THREAD_ID
    -> ORDER BY w.CHANNEL_NAME, w.WORKER_ID
    -> LIMIT 10;

+--------------+-----------+---------------+-------------+------------+-------------------+------------------------+
| CHANNEL_NAME | WORKER_ID | OBJECT_SCHEMA | OBJECT_NAME | INDEX_NAME | waiting_lock_mode | blocking_connection_id |
+--------------+-----------+---------------+-------------+------------+-------------------+------------------------+
|              |         1 | lag_demo      | t           | PRIMARY    | X,REC_NOT_GAP     |                     41 |
+--------------+-----------+---------------+-------------+------------+-------------------+------------------------+
1 row in set (0.00 sec)

이 쿼리는 worker의 InnoDB 데이터 잠금 대기를 연결한다. Empty set이어도 metadata lock, 디스크 I/O, 다른 트랜잭션의 커밋을 기다리는 상태까지 배제되는 것은 아니다. DDL이 포함되면 performance_schema.metadata_locksthreads를 추가로 확인한다. 차단 세션 ID가 NULL이면 스레드가 종료되었거나 일반 접속과 다른 형태일 수 있으므로 즉시 세션 종료 명령을 만들지 않는다.

replica에서 실행되는 일반 consistent read가 언제나 applier의 행 잠금을 막는 것은 아니다. 반면 로컬 쓰기, locking read, 장시간 metadata lock 보유 작업은 적용에 영향을 줄 수 있다. 읽기 전용 정책과 권한 우회를 함께 점검한다.

worker 수를 늘리기 전의 판단 기준

관측 우선 해석 우선 조치
한 worker가 같은 GTID에서 오래 머물고 나머지는 유휴 대형 단일 작업 또는 낮은 병렬성 트랜잭션 크기·DDL·의존 관계 확인
여러 worker가 앞선 커밋을 기다림 선행 트랜잭션이 진행 경계를 붙잡음 가장 오래 막힌 선행 작업 추적
worker와 행 잠금 대기가 직접 연결됨 충돌 또는 로컬 차단 세션 잠금 소유자와 변경 업무 확인
대부분의 worker가 일하고 CPU·스토리지도 포화 자원 처리량 한계 가능성 읽기 부하 분리, 자원·쿼리·인덱스 검토
적체는 큰데 worker가 대부분 유휴 배분 의존성·직렬 구간·설정 문제 가능성 coordinator 상태와 source의 병렬성 메타데이터 점검

하나의 트랜잭션을 worker 여러 개로 임의 분할하는 방식은 아니다. replica_preserve_commit_order를 끄면 관측되는 순서와 가시성 의미가 달라질 수 있으므로, 지연만 줄이기 위한 첫 조치로 삼지 않는다. 8.0과 8.4의 병렬 복제 설정·기본값 차이를 확인하고 해당 버전의 지원 변수만 사용한다.

7. 실제 두 인스턴스에서 확인한 세 가지 상태

별도 폐기용 MySQL 8.0.46 두 인스턴스에 ROW 형식 binlog, GTID 자동 위치 지정, worker 4개, 커밋 순서 보존을 설정했다. 작은 InnoDB 테이블로 아래 상황을 순서대로 재현했다. 운영 환경의 지연 측정값이 아니라 복제 경계와 상태 해석을 검증한 실험 결과다.

재현 조건 receiver 상태 / 오류 번호 수신했으나 미실행인 GTID 실제 데이터 차이
applier만 중단하고 source에 한 행 추가 ON / 0 한 트랜잭션이 남음 source 2행, replica 1행
따라잡은 뒤 receiver만 중단하고 한 행 추가 OFF / 0 빈 집합 source 3행, replica 2행
따라잡은 뒤 replica에서 행 잠금을 잡고 source가 같은 행 갱신 ON / 0 갱신 트랜잭션이 남음 source 값 10, replica 값 0
차단 세션을 종료해 잠금 해제 정상 수신 지속 빈 집합으로 복귀 replica 값 10으로 변경

세 번째 실험에서 worker 1은 SERVICE_STATE = ON, LAST_ERROR_NUMBER = 0인 채 갱신 GTID를 적용 중이었다. data_lock_waits에 해당 worker의 대기 관계가 1건 있었고, worker 2~4는 현재 적용 GTID가 비어 있었다. 오류 없이 살아 있는 worker도 잠금 때문에 멈출 수 있다는 직접적인 증거다. 실험에서는 잠금 소유자를 정확히 식별한 뒤 폐기용 replica의 해당 접속만 종료했다. 운영에서 동일 조치를 자동 적용해서는 안 된다.

두 번째 실험은 더 중요한 반례다. 수신 backlog가 비어 있는데도 replica의 데이터는 한 행 뒤처져 있었다. receiver가 새 트랜잭션을 받지 않았기 때문에 수신 집합과 실행 집합만 비교해서는 이 차이가 드러나지 않았다. 이때 source에서 채취한 실행 집합과 replica 실행 집합의 차이에는 미반영 트랜잭션이 남아 있었다.

이 실험은 연결 중단·적용 중단·행 잠금의 구분을 확인한 것이다. 실제 네트워크 포화, 장시간 대형 트랜잭션, Aurora 장애, 처리량 한계를 재현한 벤치마크는 아니다. 따라서 결과로부터 운영 지연 임계치나 권장 worker 개수를 도출하지 않는다.

8. Aurora MySQL에서는 먼저 복제 경로를 구분한다

같은 Aurora 클러스터의 writer와 Aurora Replica는 공유 클러스터 볼륨을 사용하는 구조다. 이 경로를 Community MySQL의 receiver → relay log → SQL worker 파이프라인과 동일하게 진단하면 안 된다. 클러스터 내부 reader 지연은 AuroraReplicaLag 등의 지표와 해당 reader의 부하·상태를 먼저 확인한다. 이 지표는 밀리초 단위이므로 초 단위 binlog 지연 지표와 그래프를 합칠 때 단위도 맞춰야 한다.

반면 외부 MySQL 또는 다른 클러스터에서 binlog 복제를 받는 Aurora MySQL에는 수신과 적용을 나누는 접근이 유효하다. AWS가 제공하는 복제 상태 조회·제어 프로시저, 엔진 버전, 파라미터 그룹의 적용 범위를 먼저 확인한다. 관리형 권한 제약 때문에 Community MySQL의 제어 명령을 그대로 실행할 수 있다고 가정하지 않는다.

Aurora Global Database의 리전 간 복제도 별도 경로다. Global Database 지연, 동일 클러스터 reader 지연, 외부 binlog replica 지연을 같은 원인이나 같은 알람으로 묶지 않는다. 공통 질문은 “어느 변경이 어느 경계까지 도착했는가”이지만, 이를 보여주는 관측 지표와 복구 절차는 다르다.

9. 장애 대응 순서와 피해야 할 조치

  1. 영향을 먼저 제한한다. 읽기 신선도 요구를 충족하지 못하면 해당 replica를 읽기 라우팅에서 제외한다. source 전환 시 증가할 부하도 평가한다.
  2. 증거를 남긴다. 시각, 채널, source·수신·적용 경계, 오류, worker 상태를 같은 조사 묶음으로 수집한다. 단일 스냅샷보다 연속 관측이 중요하다.
  3. 중단과 느림을 분리한다. 오류로 멈춘 스레드, 정상 실행 중이나 처리량이 부족한 스레드, 의도된 지연은 대응이 다르다.
  4. 단계를 좁힌다. source와 수신 진도의 차이인지, 수신과 실행 집합의 차이인지, 특정 worker의 대기인지 확인한다.
  5. 최소 범위로 원인을 제거한다. 잘못된 배치·로컬 차단 세션·읽기 경쟁·공간 부족을 업무 영향과 함께 처리한다.
  6. 복귀를 증명한다. 미실행 집합 감소, source 진도 추종, 신규 변경의 조회 가시성을 확인한 뒤 라우팅을 복원한다.

복제를 재시작하면 진행 중이던 큰 작업이 롤백되어 복구 시간이 늘 수 있다. 오류를 건너뛰거나 GTID를 임의 주입해 지연 숫자만 낮추면 데이터 불일치가 남을 수 있다. relay log 초기화와 무조건적인 재구축 역시 원인과 복구 가능성을 확인한 뒤 선택한다. “초 단위 지연이 0이 되었다”와 “서비스가 요구하는 데이터가 정확하게 반영되었다”를 같은 성공 기준으로 쓰지 않는다.

10. 운영 점검표

  • Seconds_Behind_Source

정리

Replica lag 분석의 핵심은 숫자를 낮추는 설정을 찾는 것이 아니라, source에서 발생한 변경이 어느 경계에 멈춰 있는지 증명하는 것이다. 수신 경계가 뒤처졌으면 연결과 전송·relay log 기록을 보고, 수신한 변경이 남아 있으면 적용 경로를 본다. 적용 경로 안에서는 worker의 실행·잠금·의존성과 커밋 대기를 분리한다.

이 구분이 명확해야 병렬 worker 조정, 트랜잭션 분할, 읽기 부하 분리 같은 개선책의 효과를 검증할 수 있다. 다음 단계는 source가 제공하는 트랜잭션 의존성 정보와 replica의 스케줄링을 연결하여, 실제로 병렬화할 수 있는 작업량과 처리량의 상한을 이해하는 것이다.

참고 문서