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

잠금 대기가 줄을 세우는 모습. 긴 SELECT 가 실행 중이라 ALTER TABLE 이 기다리고, 잠금 요청은 순서대로 처리되므로 그 뒤에 들어온 단순 SELECT 두 건도 함께 막힌다. 긴 SELECT 가 끝나면 ALTER 의 실제 작업은 0.5밀리초로 끝난다

서비스 전체가 멈춘 것처럼 보인다. 그런데 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 는 그 안에서 실행되지 않으므로, 그 한 문장만 따로 빼서 돌려야 합니다.