https://github.com/github/gh-ost
GitHub - github/gh-ost: GitHub's Online Schema-migration Tool for MySQL
GitHub's Online Schema-migration Tool for MySQL. Contribute to github/gh-ost development by creating an account on GitHub.
github.com
1. 소개
gh-ost는 github에서 제작한 MySQL용 트리거리스 온라인 스키마 마이그레이션 솔루션이다.
온라인 스키마 변경 도구들은 모두 비슷한 패턴으로 동작한다. 원본 테이블과 똑같이 생긴 ghost 테이블을 만들고, 비어 있는 그 테이블에 스키마 변경을 먼저 적용한다. 그다음 원본의 데이터를 천천히, 점진적으로 ghost 테이블에 복사한다 — 그 사이 원본에 계속 들어오는 INSERT/UPDATE/DELETE도 놓치지 않고 고스트 테이블에 전파하면서. 복사와 동기화가 끝나면, 적절한 순간에 원본과 고스트 테이블을 맞바꾼다.
gh-ost도 이 패턴을 따른다. 하지만 결정적인 지점에서 기존 도구들과 갈라진다: 트리거를 쓰지 않는다. GitHub 팀은 트리거가 수많은 제약과 위험의 근원이라고 판단했다. 트리거 대신 바이너리 로그 스트림에서 변경분을 읽어 고스트 테이블에 비동기로 적용한다. 다른 도구들이 데이터베이스(트리거)에 떠넘기던 일을 gh-ost는 직접 수행하고, 그 대가로 마이그레이션에 대한 완전한 제어권을 얻는다 — 진짜로 일시정지할 수 있고, 마이그레이션의 쓰기 부하를 마스터의 서비스 부하로부터 진짜로 분리할 수 있다.

위 그림을 구체적인 순서로 풀면 다음과 같다.
- ghost 테이블 생성 — 원본과 같은 구조의 _orders_gho를 만들고, 여기에 ALTER를 적용한다. 빈 테이블이라 ALTER는 즉시 끝난다.
- 기존 데이터 복사 — 원본의 행을 청크(기본 1,000행) 단위로 천천히 ghost 테이블에 복사한다.
- 변경분 따라잡기 — 복사하는 동안 서비스가 원본에 가하는 INSERT/UPDATE/DELETE를 binlog에서 읽어 ghost 테이블에 재적용한다.
- cut-over — 둘이 동기화되면 원자적 RENAME으로 테이블 이름을 맞바꾼다. 이 순간(1~3초)만 쿼리가 잠시 대기한다.
비슷한 도구들이 트리거로 해결하던 "변경분 따라잡기"를 gh-ost는 binlog로 해결한 것이 결정적 차이다.
사용 테이블 3개
| _orders_gho | ghost 테이블. 새 스키마가 적용된 미래의 orders. |
| _orders_ghc | changelog 테이블. gh-ost의 내부 신호용 — 주기적 heartbeat를 여기 쓰고 binlog로 되돌아오는 시간을 재서 자신의 적용 지연(Lag)을 정밀 측정한다. cut-over 직전 "여기까지의 이벤트를 다 처리했다"는 마커(AllEventsUpToLockProcessed)도 이 테이블을 통해 전달된다. |
| _orders_del | cut-over 후 남는 원본 백업. cut-over 중에는 "magic table"이라는 안전장치로도 쓰인다(5장). |
2. 정합성의 비밀 — row copy와 binlog apply가 충돌하지 않는 이유
두 개의 쓰기 흐름이 ghost 테이블에 동시에 들어오게 된다.
- row copy: INSERT IGNORE INTO _orders_gho SELECT ... FROM orders WHERE id BETWEEN a AND b LOCK IN SHARE MODE — PK 범위를 순회하며 청크 복사
- binlog apply: 서비스의 INSERT → REPLACE INTO _orders_gho, UPDATE → UPDATE _orders_gho, DELETE → DELETE FROM _orders_gho
같은 행이 두 경로로 도착하면 순서가 어떻게 꼬여도 결과가 옳도록 연산 자체에 비대칭 규칙을 심어놨다:
| binlog INSERT가 먼저, row copy가 나중 | row copy는 INSERT IGNORE — 이미 있는(더 최신인) 행을 덮어쓰지 않고 조용히 스킵 |
| row copy가 먼저, binlog INSERT가 나중 | binlog apply는 REPLACE INTO — 복사된 옛 행을 최신 값으로 덮어씀 |
| 아직 복사 안 된 행의 UPDATE가 binlog로 도착 | UPDATE는 대상이 없으면 no-op. 그 행은 나중에 row copy가 원본의 이미 갱신된 값을 읽어 복사 |
| 복사된 행의 DELETE가 binlog로 도착 | ghost 테이블에서도 삭제. 복사 전이었다면 no-op이고, 원본에서 사라졌으니 row copy도 못 읽음 |
위의 시나리오는 binlog 이벤트는 항상 이기고(REPLACE), row copy는 항상 양보한다(INSERT IGNORE). 여기에 row copy가 LOCK IN SHARE MODE로 읽는 순간의 스냅샷 일관성이 더해져 어떤 인터리빙에서도 최종 상태는 원본과 일치한다. 트랜잭션으로 전체를 묶지 않고도 정합성이 보장된다.
원본 테이블이 감당하는 비용은 청크당 짧은 공유 락(읽기)으로 사용시 관찰된 간헐적 응답 스파이크(max 200~500ms)가 발생 할 수 도 있다.
3. cut-over — 원자적 바꿔치기의 트릭
RENAME TABLE orders TO _orders_del, _orders_gho TO orders를 실행하며 binlog를 끝까지 다 적용했다는 보장이 필요하다. gh-ost는 락과 RENAME을 두 세션으로 나눠 이를 원자적으로 처리한다:
- 세션 A: magic table _orders_del을 만들고 LOCK TABLES orders, _orders_del WRITE — 이 순간부터 서비스 쿼리는 대기(실패 아님)
- gh-ost가 changelog에 마커를 쓰고, binlog에서 그 마커가 돌아올 때까지 남은 이벤트를 전부 적용 — "락 시점까지의 변경 완전 반영" 보장
- 세션 B: RENAME 실행 → 세션 A의 락에 걸려 대기
- 세션 A: magic table을 DROP하고 UNLOCK — 그 순간 MySQL은 대기 중이던 RENAME을 일반 쿼리보다 먼저 통과시킴
- 대기하던 서비스 쿼리들이 풀리면 이미 새 orders(구 _orders_gho)를 보고 있다
magic table은 안전장치다. 이 과정 중 gh-ost가 죽으면 _orders_del이 남아 있어 RENAME이 실패하고, 원본 테이블은 무사하다.
4. 스로틀링 내부 동작
운영 영향 최소화의 나머지 절반은 스로틀링이다. gh-ost는 쓰기 주체가 자신뿐이므로 언제든 스스로 멈출 수 있다 (트리거 기반 도구는 서비스 쓰기에 얹혀가므로 불가능)
| heartbeat 지연 (--max-lag-millis, 기본 1500ms) | changelog 테이블에 쓴 heartbeat가 binlog로 돌아오는 시간 > 임계치 → 자동 스로틀 |
| MySQL 부하 (--max-load=Threads_running=25) | 임계 초과 → 자동 스로틀. --critical-load는 초과 시 아예 중단(panic) |
| 플래그 파일 (--throttle-flag-file) | 파일 존재 → 스로틀. touch/rm으로 사람이 제어 |
| 소켓 명령 (--serve-socket-file) | throttle/no-throttle/status/chunk-size=N/unpostpone/panic 등 실행 중 주입 |
'server > 아키텍쳐' 카테고리의 다른 글
| gh-ost로 무중단 스키마 변경하기 — 500만 건 테이블에서 ALTER TABLE vs gh-ost 직접 비교 실습기 (0) | 2026.07.30 |
|---|---|
| 2. airflow + dbt (1) | 2025.08.31 |
| 1. dbt tutorial (0) | 2025.08.30 |
| 로또 시스템 아키텍처 (3) | 2025.08.03 |
| rabbitmq 심화 (persistent / cluster) (0) | 2024.05.05 |