Database · Backend · Schema

백필 - 이미 쌓인 5천만 행을 채운다

새 컬럼을 만들면 기존 행은 전부 비어 있다. 한 번에 채우면 왜 안 되고, 끊어 채우면 무엇을 더 챙겨야 하나.

·멈추지 않고 스키마를 바꾼다 3편
목차
  1. 새 진열대는 비어 있다
  2. 한 번에 채우면 무슨 일이 나나
  3. 끊어서 옮긴다
  4. 어디까지 했는지 남긴다
  5. 속도를 조절한다
  6. 채우는 동안에도 새 행은 들어온다
  7. 다 됐는지 세어 본다
  8. 여기서 비유가 깨진다
  9. 실무에서: 백필은 배포가 아니라 운영이다
  10. 정리

앞 글에서 컬럼을 무사히 더했다. 그런데 새 컬럼은 비어 있다. 채우는 일이 남는다.

새 진열대는 비어 있다

가게에 새 진열대를 들여놨다. 통로도 안 막고 무사히 놓았다. 그런데 진열대는 비어 있고, 물건은 여전히 옛 진열대에 있다.

데이터베이스에서도 똑같다. display_name 컬럼을 만들었지만 기존 5천만 행의 값은 전부 NULL이다. 이걸 채우는 작업을 백필이라 부른다. 새로 들어오는 주문은 코드가 알아서 두 컬럼에 다 쓰지만, 이미 들어와 있던 것들은 아무도 안 채워준다.

그래서 이런 문장을 쓰고 싶어진다.

sql
UPDATE users SET display_name = nickname WHERE display_name IS NULL;

한 줄이고, 논리도 맞다. 그리고 이걸 운영 DB에서 그대로 실행하면 사고가 난다.

한 번에 채우면 무슨 일이 나나

5천만 행을 건드리는 UPDATE 하나는 5천만 행짜리 트랜잭션 하나다. 여기서 네 가지가 동시에 나빠진다.

  • 락을 너무 많이 쥔다. 행을 하나씩 잠그다 어느 선을 넘으면 DB가 테이블 락 하나로 바꿔버린다. 그 순간 이 작업과 아무 상관 없는 조회까지 전부 멈춘다.
  • 옛 버전이 쌓인다. 갱신된 행마다 이전 버전이 남는다. 5천만 개가 쌓이고, 그 트랜잭션이 열려 있는 동안에는 청소도 못 한다.
  • 복제가 밀린다. 이 변경이 팔로워로 넘어가 재생되는 동안 시차가 벌어진다. 읽기를 팔로워로 보내고 있었다면 사용자에게 낡은 데이터가 보인다.
  • 중간에 실패하면 전부 없던 일이 된다. 40분간 돌다가 타임아웃 나면 4천만 행을 되돌리는 데 또 시간이 걸린다. 그리고 처음부터 다시 해야 한다.

물건을 옮기겠다고 가게 전체를 한 번에 뒤집는 것이다. 손님이 들어올 자리가 없다.

끊어서 옮긴다

답은 단순하다. 한 번에 조금씩, 사이를 띄워서 옮긴다.

한 번에UPDATE 한 문장으로 5천만 행
트랜잭션 열려 있는 트랜잭션 하나 - 끝날 때까지 안 닫힌다 전부
되돌린다
한계 여기까지 한 것을 전부 잃는다 여기서 실패 쌓인 양
끊어서같은 5천만 행을 청크 14번으로
트랜잭션 하나만
다시 한다
한계 이 청크만 잃는다 여기서 실패 쌓인 양

같은 5천만 행인데 한 번에 하면 쌓인 것이 한 번도 안 풀려 한계를 넘고, 끊으면 청크마다 0으로 떨어진다. 실패했을 때 잃는 넓이도 그만큼 다르다.

한 트랜잭션이 짧아지니 락을 오래 안 쥐고, 옛 버전은 매 청크가 끝날 때마다 청소될 수 있고, 복제도 따라올 틈이 생긴다. 실패해도 그 청크 하나만 잃는다.

청크 크기는 정답이 없고 한 청크가 얼마나 걸리는지로 정한다. 수백 밀리초 안에 끝나는 크기가 무난하다. 그보다 길어지면 위의 문제들이 축소판으로 다시 나타나기 시작한다.

주의

끊었다고 범위를 매번 처음부터 훑으면 소용이 없다. WHERE display_name IS NULL LIMIT 1000은 매번 이미 채운 행들을 지나쳐 가며 안 채운 행을 찾는다. 뒤로 갈수록 느려지고, 마지막 청크가 가장 오래 걸린다. 기본키 범위로 끊는다 - WHERE id BETWEEN 1000001 AND 1001000처럼 어디를 훑을지가 미리 정해져야 한다.

어디까지 했는지 남긴다

끊어서 도는 작업은 중간에 멈춘다는 걸 전제로 만들어야 한다. 배포가 끼거나, 장애가 나거나, 사람이 껐거나.

그래서 진행 위치를 어딘가에 적는다. 마지막으로 처리한 기본키 값 하나면 충분하다. 다시 켰을 때 거기부터 이어서 하면 된다.

적어 두지 않으면 두 가지 중 하나를 하게 된다. 처음부터 다시 돌리거나(느리다), 어디까지 됐는지 세어 보려고 또 전체를 훑거나(마찬가지로 느리다). 진행 위치 하나를 적는 값이 그 둘보다 싸다.

속도를 조절한다

끊었어도 쉬지 않고 몰아치면 DB는 여전히 힘들다. 백필은 대개 급하지 않은 작업이라 이 점을 이용할 수 있다.

  • 청크 사이에 쉰다. 고정된 간격을 두는 가장 단순한 방법이다.
  • 밀리면 늦춘다. 복제 시차나 응답 시간을 보고, 기준을 넘으면 쉬는 시간을 늘린다. 회복되면 다시 당긴다.
  • 한가할 때 돈다. 트래픽이 적은 시간대에만 돌린다.

세 번째는 잘 듣지만 함정도 있다. 밤에만 돌리면 5천만 행을 채우는 데 2주가 걸릴 수 있다. 그동안 코드는 계속 “양쪽에 쓰는” 상태로 남아 있어야 한다. 백필이 길어질수록 그 임시 상태로 사는 기간이 길어진다 - 그 자체가 비용이다.

채우는 동안에도 새 행은 들어온다

여기가 순서를 틀리기 쉬운 자리다. 백필을 시작하기 전에 코드가 이미 두 컬럼에 다 쓰고 있어야 한다.

순서를 뒤집으면 이렇게 된다. 백필이 앞에서부터 채워 나가는 동안 뒤에서는 새 행이 옛 컬럼만 채운 채 들어온다. 끝까지 다 돌아도 백필 중에 들어온 행들이 비어 있다. 다시 돌려야 하고, 다시 도는 동안 또 들어온다.

가게로 치면 한쪽에서 물건을 옮기는데 다른 쪽에서 계속 옛 진열대에 물건을 채워 넣는 것이다. 옮기는 속도가 채우는 속도보다 빠르길 기도하는 수밖에 없다.

그래서 순서가 고정된다. 먼저 새로 들어오는 것부터 막고, 그다음에 쌓인 것을 옮긴다.

다 됐는지 세어 본다

백필은 “다 끝났다”가 자명하지 않다. 진행 위치가 끝에 닿았다고 다 채워진 게 아니다 - 중간에 실패한 청크가 있었을 수 있고, 코드가 두 곳에 쓰기 시작한 시점이 백필 시작보다 늦었을 수도 있다.

그래서 지우기 전에 남은 게 0인지 확인한다.

sql
SELECT COUNT(*) FROM users WHERE display_name IS NULL;

이 숫자가 0이 되기 전에는 다음 단계로 못 간다. 값이 남아 있는데 옛 컬럼을 지우면 그 행들의 값은 되돌릴 수 없다.

참고

NULL이 아닌 정상적인 빈 값이 섞여 있으면 이 검사가 거짓말을 한다. 이 확인 쿼리는 “안 채운 것”과 “채웠는데 비어 있는 것”을 구별할 수 있게 짜야 한다. 구별이 안 되면 백필 대상에 표시 컬럼을 따로 두는 게 낫다.

여기서 비유가 깨진다

가게에서는 물건을 옮기면 옛 진열대가 빈다. 옮긴 만큼 원래 자리가 비니까 진행 상황이 눈에 보이고, 다 옮겼는지도 보인다.

백필은 복사다. 옛 컬럼의 값은 그대로 있고 새 컬럼에 같은 값이 하나 더 생긴다. 그래서 진행 상황이 안 보이고, 위처럼 일부러 세어 봐야 한다. 그리고 두 벌이 공존하는 동안 둘이 어긋날 수 있다 - 옮기는 공사에는 없던 문제다.

실무에서: 백필은 배포가 아니라 운영이다

백필을 배포 스크립트에 넣어 돌리려는 시도가 흔하다. 배포 파이프라인이 몇 시간짜리 작업을 물고 있게 되고, 실패하면 배포 전체가 실패로 뜨고, 재시도하면 처음부터 다시 돈다.

그래서 큰 백필은 배포에서 떼어낸다.

배포백필
걸리는 시간분시간~주
실패하면되돌린다이어서 한다
도는 곳파이프라인별도 작업·워커
볼 것성공/실패진행률과 DB 부하

마지막 줄이 핵심이다. 백필은 성공/실패가 아니라 얼마나 갔고 DB가 견디고 있는가로 본다. 그래서 지표를 남기고 진행률을 확인할 수 있게 만든다. 며칠짜리 작업이 아무 표시 없이 돌면 멈춘 건지 느린 건지 알 수 없다.

정리

  • 컬럼을 더해도 기존 행은 비어 있다. 채우는 일이 별도로 남는다.
  • 한 번에 UPDATE 하나로 채우면 락·옛 버전·복제 지연·되돌리기가 전부 최악이 된다.
  • 끊어서 채우되 기본키 범위로 끊고, 진행 위치를 적어 둔다. 멈추는 걸 전제로 만든다.
  • 백필 전에 코드가 먼저 양쪽에 쓰고 있어야 한다. 순서를 뒤집으면 끝나도 안 끝난다.
  • 지우기 전에 남은 개수가 0인지 확인한다. 세어 보지 않고 다음 단계로 가지 않는다.