LabHub

스키마 변경 — 배포가 서비스를 멈추지 않게 · 잠금과 배포 · 이론

마이그레이션이 서비스를 멈추는 방식

LabHub 에서 이어서 보기

한 줄 요약

마이그레이션 사고는 작업이 오래 걸려서가 아니라 잠금을 기다리는 동안 뒤가 줄을 서서 난다.

옛날 조언은 이제 틀렸다

"기본값 있는 컬럼을 추가하면 테이블을 통째로 다시 쓴다" 는 오래된 조언이다. PostgreSQL 11 부터는 기본값을 메타데이터에만 적어 두고 읽을 때 채운다.

30만 행 테이블에서 재 보면 이렇다.

alter table big add column status text not null default 'new';Time: 0.510 ms

밀리초다. 그러니 이 작업 자체는 문제가 아니다.

진짜 문제는 잠금 대기다

ALTER TABLEACCESS EXCLUSIVE 잠금을 잡는다. 이건 가장 센 잠금이라 읽기까지 막는다.

작업이 0.5ms 라도 그 잠금을 얻기까지 기다려야 한다. 앞에 긴 쿼리 하나가 있으면 그게 끝날 때까지 기다린다.

그리고 여기서 진짜 사고가 난다.

> 기다리는 ALTER 뒤에 오는 모든 쿼리가 같이 줄을 선다.

잠금 요청은 순서대로 처리된다. ALTER 가 큐 앞에서 기다리는 동안, 뒤에 들어온 단순 SELECT 도 그 뒤에 붙는다. 원래라면 ALTER 와 아무 상관 없이 통과했을 쿼리들이다.

실습에서 재현하면 이렇게 된다.

긴 SELECT 실행 중  →  ALTER 가 기다림  →  단순 SELECT 도 막힘ERROR:  canceling statement due to lock timeout

서비스 전체가 멈춘 것처럼 보인다. 그런데 DB 지표는 멀쩡하다 — CPU 도 디스크도 놀고 있고 쿼리 하나하나는 다 빠르다. 그래서 원인을 찾는 데 오래 걸린다.

그래서 규칙은 하나다

set lock_timeout = '3s';alter table ...;

마이그레이션 앞에는 반드시 lock_timeout 을 건다. 3초 안에 못 얻으면 포기하고 나중에 다시 한다. 실패한 마이그레이션은 다시 돌리면 되지만, 줄 세운 5분은 되돌릴 수 없다.

statement_timeout 과 헷갈리지 말 것. 그건 실행 시간 제한이고 lock_timeout기다리는 시간 제한이다. 마이그레이션에는 뒤가 필요하다.

인덱스는 쓰기를 막는다

CREATE INDEX 는 SHARE 잠금을 잡는데 이건 INSERT·UPDATE·DELETE 와 충돌한다. 읽기는 되고 쓰기가 막힌다. 큰 테이블이면 몇 분씩 간다.

create index concurrently idx_n on big(n);

CONCURRENTLY 는 테이블을 두 번 훑으면서 쓰기를 막지 않는다. 대신 세 가지를 받아들여야 한다.

1. 더 느리다 — 두 번 훑는다
2. 트랜잭션 안에서 못 쓴다 — 마이그레이션 도구가 자동으로 트랜잭션을 감싸면 실패한다
3. 실패하면 못 쓰는 인덱스가 남는다indisvalid = false. 지우고 다시 만들어야 한다

3번을 모르면 "인덱스를 만들었는데 안 쓰인다" 로 한참 헤맨다. 확인은 이렇게 한다.

select indexrelid::regclass, indisvalid from pg_index where not indisvalid;

컬럼을 지우는 것도 위험하다

drop column 자체는 빠르다(메타데이터만 바꾼다). 문제는 아직 그 컬럼을 읽는 코드가 도는 중일 때다.

그래서 순서를 나눈다.

1. 배포 A — 코드에서 그 컬럼을 안 쓰게 한다
2. 배포 B — 컬럼을 지운다

둘을 같은 배포에 넣으면 그 사이 몇 초 동안 오류가 난다. 컬럼 추가도 같다 — 컬럼을 먼저 만들고 그다음 배포에서 쓴다.

백필은 나눠서

update big set note = 'x';        -- 30만 행을 한 트랜잭션에

이러면 그 행들이 전부 잠기고 WAL 이 한꺼번에 불어나고 복제 지연이 튄다.

update big set note = 'x' where id between 1 and 10000;-- 커밋하고, 잠깐 쉬고, 다음 구간

나눠서 커밋하면 잠금이 짧게 유지되고 복제도 따라온다. 느려 보이지만 서비스가 안 멈춘다.

정리

| 작업 | 잠금 | 위험 |
|---|---|---|
| add column(기본값 포함) | ACCESS EXCLUSIVE | 작업은 빠름, 대기가 위험 |
| create index | SHARE | 쓰기가 막힘 |
| create index concurrently | 약함 | 느리고 트랜잭션 불가 |
| set not null | ACCESS EXCLUSIVE | 전체 스캔 |
| 대량 update | 행 잠금 다수 | WAL·복제 지연 |

전부 lock_timeout 을 걸면 최악을 피한다.

현장에서

이 실패는 배포 창 안에서 일어나기 때문에 유난히 눈에 띕니다. 마이그레이션을 실행한 사람은 명령이 안 끝나는 것만 보고, 다른 사람들은 서비스가 통째로 멈춘 것을 봅니다. 그 사이에 데이터베이스 지표는 전부 초록이라 원인을 찾기까지 몇 분이 그냥 흘러갑니다.

그래서 팀에서 정해 두면 좋은 것이 세 가지 있습니다. 첫째, 마이그레이션 세션에는 반드시 lock_timeout 을 건다 — 실패하는 편이 전체를 멈추는 것보다 낫습니다. 둘째, 실행 전에 그 테이블에 오래 도는 트랜잭션이 있는지 확인한다. 셋째, 되돌릴 방법을 먼저 적어 둔다 — 컬럼 추가는 쉽지만 타입 변경은 되돌리기 어렵습니다.

마이그레이션 도구를 쓴다면 그 도구가 문장을 트랜잭션으로 감싸는지도 확인해야 합니다. 감싼다면 CREATE INDEX CONCURRENTLY 는 그 안에서 실행되지 않으므로, 그 한 문장만 따로 빼서 돌려야 합니다.