← Posts

Database · Scaling · Replication

복제 - 방금 쓴 걸 왜 못 읽는가

읽기를 늘리려고 복제했더니 시간이 뒤엉킨다. 그리고 격리 수준은 이걸 막아주지 않는다.

목차
  1. 왜 복제하나
  2. 대신 시차가 생긴다
  3. 여기서 격리 수준을 떠올렸다면
  4. 시차가 만드는 세 가지
  5. 그래서 어떻게 막나
  6. 리더는 팔로워를 기다리나
  7. 리더가 죽으면
  8. 복제는 쓰기를 못 늘린다
  9. 정리

글을 썼다. 저장 버튼을 누르고 목록으로 돌아왔다. 내 글이 없다.

새로고침하니 나온다. 한 번 더 하니 또 사라진다.

버그처럼 보이지만 코드에는 문제가 없다. 읽기를 여러 대로 늘린 대가다.

왜 복제하나

앞 글에서 샤딩이 무엇을 잃는지 봤다. ACID도, 조인도 잃는다. 그렇게까지 하고 싶지 않다.

그런데 대부분의 서비스는 읽기가 압도적으로 많다. 글 하나를 쓰면 수천 명이 읽는다. 이럴 때 굳이 데이터를 나눌 필요가 없다. 같은 데이터를 여러 대에 복사해두고 읽기만 나눠 보내면 된다.

쓰기를 받는 한 대를 리더, 복사본을 들고 읽기를 받는 나머지를 팔로워라고 부른다. 리더는 자기가 바꾼 내용을 로그로 만들어 팔로워들에게 흘려보낸다.

샤딩과 결정적으로 다른 점이 있다. 팔로워는 전부 같은 데이터를 갖는다. 그래서 어느 팔로워로 보내도 된다. 앱은 “어디 있는지”를 몰라도 된다. 훨씬 싸게 먹히는 이유다.

대신 시차가 생긴다

리더에 쓰고 팔로워에 도착하기까지 시간이 걸린다. 밀리초 단위일 때도 있고, 밀리면 몇 초씩 벌어지기도 한다. 그 사이에 팔로워를 읽으면 과거를 본다.

맨 앞의 그 장면이 이것이다.

diagramdiagram

쓰기는 리더로 갔고 읽기는 팔로워로 갔다. 둘은 다른 서버다. 저장 응답을 받은 시점에 팔로워는 아직 그 사실을 모른다.

여기서 격리 수준을 떠올렸다면

격리 수준에서 비슷한 걸 봤다. 남이 쓴 걸 읽어서 생기는 문제들, REPEATABLE READ, SERIALIZABLE.

주의

격리 수준은 이걸 막아주지 않는다. 완전히 다른 축이다.

격리 수준은 한 DB 안에서 동시에 도는 트랜잭션들 사이의 약속이다. 복제 지연은 애초에 다른 서버를 읽는 문제다. SERIALIZABLE을 걸어도 팔로워에서 읽으면 팔로워가 아는 만큼만 보인다. 그 트랜잭션은 완벽하게 격리된 채로 과거를 정확하게 읽는다.

MVCC도 마찬가지다. 어느 버전을 볼지 고르는 건 그 서버가 가진 버전 중에서다. 안 받은 버전은 고를 수가 없다.

한 대에서 통하던 도구가 여기서는 안 통한다. 앞 글에서 ACID가 한 DB 안의 약속이었던 것과 같은 이야기다.

시차가 만드는 세 가지

내가 쓴 걸 못 읽는다. 맨 앞의 장면이다. 남이 못 보는 건 참을 만한데, 자기가 방금 한 일이 안 보이면 사람은 고장 났다고 판단한다.

시간이 거꾸로 간다. 팔로워마다 지연이 다르다. 첫 요청은 빠른 팔로워로 가서 새 댓글이 보이고, 다음 요청은 느린 팔로워로 가서 그 댓글이 사라진다. 있다가 없어지는 게 처음부터 없는 것보다 나쁘다.

인과가 뒤집힌다. 질문과 답변이 다른 순서로 도착하면 답변이 질문보다 먼저 보인다. 대화가 말이 안 되게 된다.

셋 다 뿌리는 하나다. 읽는 쪽이 어느 시점을 보는지 아무도 정해주지 않았다.

그래서 어떻게 막나

증상방법
내가 쓴 걸 못 읽음내가 바꾼 것은 리더에서 읽는다. 또는 쓰고 나서 몇 초간은 리더로 보낸다
시간이 거꾸로 감같은 사용자는 늘 같은 팔로워로 보낸다. 지연이 있어도 뒤로 가진 않는다
인과가 뒤집힘인과가 걸린 것들을 같은 팔로워에서 읽는다

공통점이 보인다. 전부 “어디로 보낼지”를 앱이 정하는 것이다. DB가 해주지 않는다.

그리고 이건 반정규화FK를 빼는 것과 같은 종류의 이야기다. DB가 보장하던 게 사라진 게 아니라 사람에게 넘어온 것이다. 지금 짜는 코드뿐 아니라 6개월 뒤 누가 추가할 조회 코드까지 이 규칙을 알아야 한다.

참고

그래서 실무에서는 읽기를 통째로 팔로워에 보내지 않는다. “내 글 목록”이나 “결제 직후 화면”처럼 최신이어야 하는 것만 리더로 보내고, “인기 글”이나 “통계”처럼 몇 초 늦어도 되는 것만 팔로워로 보낸다. 어떤 조회가 얼마나 낡아도 되는지를 화면 단위로 정하는 일이다.

리더는 팔로워를 기다리나

여기가 복제의 진짜 선택지다. 리더가 커밋할 때 팔로워가 받았는지 확인하고 응답할 것인가.

비동기는 안 기다린다. 리더는 자기 디스크에 쓰고 바로 “됐습니다”라고 답한다. 팔로워는 알아서 따라온다. 빠르고, 팔로워가 몇 대든 느려지든 리더는 상관없다. 대신 지연이 생기고, 아래에서 볼 문제가 따라온다.

동기는 기다린다. 팔로워가 받았다고 해야 커밋이 끝난다. 대신 팔로워 한 대가 느려지면 쓰기 전체가 그만큼 느려지고, 죽으면 쓰기가 멈춘다. 팔로워를 늘릴수록 쓰기가 위태로워진다. 가용성을 늘리려고 붙인 게 가용성을 깎는다.

주의

“동기로 바꾸면 방금 쓴 걸 읽을 수 있다”는 성급하다. 여기서 기다리는 건 대개 팔로워가 로그를 받아서 안전하게 적어뒀다는 확인까지지, 그 로그를 반영해서 조회에 보이게 됐다는 확인이 아니다. 그래서 동기로 묶어놓고도 팔로워에 물으면 옛 값이 나오는 일이 생긴다. 조회에 보이는 것까지 기다리게 하는 설정이 따로 있고(PostgreSQL의 remote_apply 같은), 그건 커밋을 훨씬 더 느리게 만든다. 어디까지 기다리는 설정인지를 문서에서 확인해야 한다.

그래서 대개 하나만 동기로 두고 나머지는 비동기로 둔다. 최소 한 벌은 최신임을 보장하면서 리더가 여러 대에 발목 잡히진 않게 하는 절충이다. 이걸 **반동기(semi-synchronous)**라고 부른다.

다만 이것도 문제를 없애는 게 아니라 옮긴다. 동기로 지정한 그 한 대가 죽으면, 다른 후보가 붙을 때까지 쓰기가 멈추거나(PostgreSQL) 일정 시간 뒤 조용히 비동기로 내려앉는다(MySQL). 뒤쪽이 특히 위험하다. 내구성 보장이 사라졌는데 아무 일도 없는 것처럼 계속 돌기 때문이다.

리더가 죽으면

팔로워 하나를 리더로 승격시킨다. 그런데 비동기였다면 그 팔로워는 마지막 몇 개를 못 받았다.

리더는 그걸 이미 커밋했고, 사용자에게 “저장됐습니다”라고 답했다. 그리고 승격된 새 리더에는 그게 없다.

주의

ACID의 D는 “커밋했으면 살아남는다”였다. 그건 그 한 대의 약속이었다. 그 한 대가 사라지면 약속도 같이 사라진다. 여기서도 앞 글과 같은 결론이다. 한 대 안에서 공짜로 받던 보장은 대수가 늘어나는 순간 공짜가 아니다.

옛 리더가 나중에 살아 돌아오면 더 곤란하다. 그 안에는 새 리더가 모르는 변경이 들어 있다. 대개는 버린다. 커밋된 데이터를 버리는 것이다.

그리고 순번이 여기서 걸린다. 옛 리더가 1005번까지 발급하고 죽었는데 새 리더는 1003번까지만 안다면, 새 리더는 1004번을 다시 내준다. 이미 나간 번호다. 키 설계 글에서 순번의 대가로 “채번이 한 곳에 묶인다”고 했는데, 그 한 곳이 바뀌면 이렇게 된다.

복제는 쓰기를 못 늘린다

가장 중요한 한계다. 그리고 자주 잊는다.

모든 쓰기는 여전히 리더 한 대로 간다. 팔로워를 열 대로 늘려도 쓰기 처리량은 그대로다.

오히려 나빠진다. 팔로워가 열 대면 리더는 같은 변경을 열 번 내보내야 한다. 읽기를 덜어주는 대가로 리더에게 새 일을 준 것이다. 어느 지점을 넘으면 팔로워를 붙일수록 리더가 힘들어진다.

그래서 순서가 나온다. 읽기가 문제면 복제로 푼다. 쓰기가 문제면 복제로 안 풀린다. 그때는 샤딩이다. 그리고 실제로는 둘을 같이 쓴다. 샤드로 나누고, 샤드마다 리더와 팔로워를 둔다.

정리

  • 복제는 같은 데이터를 여러 대에 둔다. 샤딩과 달리 앱이 “어디 있는지” 몰라도 된다. 그래서 훨씬 싸다.
  • 격리 수준도 MVCC도 복제 지연을 막지 못한다. 다른 축이다. SERIALIZABLE 트랜잭션이 과거를 완벽하게 격리된 채로 읽는다.
  • 시차는 세 가지로 나타난다. 내 글이 안 보이고, 시간이 거꾸로 가고, 인과가 뒤집힌다. 뿌리는 하나 - 어느 시점을 볼지 아무도 안 정했다.
  • 막는 방법은 전부 “어디로 보낼지 앱이 정하는 것” 이다. 어떤 조회가 얼마나 낡아도 되는지를 화면 단위로 정한다.
  • 동기 복제는 가용성을 늘리려다 깎는다. 그래서 하나만 동기로 둔다.
  • 지속성은 한 대의 약속이었다. 비동기 복제 중 승격하면 커밋된 데이터가 사라진다.
  • 복제는 쓰기를 못 늘린다. 팔로워를 붙일수록 리더는 오히려 바빠진다.
참고

다음은 B-tree와 LSM-tree다. 조회 성능을 이야기한 대목은 전부 B-tree를 깔고 한 것이었다. 쓰기가 많은 세상에서는 아예 다른 자료구조를 쓴다. B-tree와 LSM-tree, 그리고 읽기와 쓰기 중 무엇을 포기하는가.

관련 글