MongoDB 샤드 클러스터 쓰기 경로 시리즈의 6편(마지막 편)이다.
이 글이 답하는 질문: 읽기를
secondaryPreferred로 secondary에 돌려 두면 primary가 멈춘 동안에도 조회는 안전한가? 결론부터 쓰면 안전하지 않다. 샤드 클러스터의 secondary는 "어느 데이터가 어느 shard 것인가"를 적은 소유권 목록(라우팅 메타데이터)을 자기 primary의 쓰기를 거쳐 받는다. 그래서 primary가 쓰기를 못 하는 shard에서 재시작한 secondary는 소유권 목록을 받지 못해 읽기를StaleConfig로 거부한다.
기준 버전: MongoDB 4.2(소스 태그r4.2.12). 최신 버전은 확인하지 않았다.
샤드 클러스터는 컬렉션을 chunk라는 샤드키 구간 단위로 나눠 여러 shard에 둔다. balancer는 shard 사이의 데이터 양을 맞추려고 chunk를 옮긴다. 옮기는 순서는 "새 shard에 복사한다 → 소유권을 넘긴다 → 옛 shard에서 지운다"이다.
마지막 삭제가 끝나기 전까지 옛 shard에는 이미 소유권이 넘어간 문서가 남아 있다. 이런 문서를 고아(orphan) 문서라고 부른다. 예를 들어 store_inventory 컬렉션의 문서 X가 들어 있는 chunk가 shardA에서 shardB로 옮겨 갔다고 하자. 시점별 상태는 다음과 같다.
| 시점 | shardA | shardB | 소유자 |
|---|---|---|---|
| 이동 전 | X | 없음 | shardA |
| 복사 후, 삭제 전 | X (고아) | X | shardB |
| 삭제 후 | 없음 | X | shardB |
가운데 시점에 모든 shard에 묻는 조회가 오면 두 shard에 모두 X가 있다. 각 shard가 소유권을 확인하지 않고 돌려주면 응답에 X가 두 번 나온다. 그래서 shard는 응답하기 전에 "이 문서가 내 소유인가"를 확인하고 고아 문서를 걸러야 한다.
걸러 내려면 노드가 소유권 목록을 들고 있어야 한다. 소유권 목록은 "어느 chunk가 어느 shard 소유인가"를 적은 정보다. 노드는 소유권 목록과 그 버전을 메모리에 둔다. MongoDB 용어와는 이렇게 이어진다.
이 글은 이후 소유권 목록이라는 이름만 쓴다.
mongos는 shard에 읽기를 보낼 때 자기가 아는 shard version을 붙인다. 받은 노드는 그 버전을 자기 버전과 맞춰 본다(collection_sharding_state.cpp:201-278). 3.6 릴리스 노트는 primary뿐 아니라 secondary도 chunk 메타데이터를 유지해, secondary 읽기가 고아 문서를 돌려주지 않게 한다고 적는다.
"Starting in 3.6, all members of a shard replica set, not just the primary, maintain the metadata regarding chunk metadata."
검사 결과는 세 가지다.
| 노드의 상태 | 노드의 처리 |
|---|---|
| 버전이 mongos와 같음 | 읽고 고아 문서를 걸러 응답함 |
| 버전이 다름 | 거부함. 낡은 쪽이 최신 소유권 목록을 받음 |
| 소유권 목록이 없음(UNKNOWN) | 거부하고 소유권 목록을 받으러 감 |
거부할 때 쓰는 오류가 StaleConfig다. 코드는 13388이다(error_codes.err:319). 소유권 목록이 없는 상태를 소스는 UNKNOWN이라 부르고 이렇게 정의한다(collection_sharding_runtime.h:85-86).
"all attempts to check for shard version match will fail with StaleConfig errors in order to trigger an update."
즉 StaleConfig는 "내 소유권 목록이 낡았거나 없으니 새로 받은 뒤 다시 하자"는 신호다. 평소에는 갱신이 바로 끝나고 mongos가 재시도해서 애플리케이션은 이 오류를 보지 못한다.
primary와 secondary는 소유권 목록을 받는 길이 다르다. primary는 config 서버에서 직접 받는다. secondary는 config 서버에 묻지 않고 자기 primary를 거친다. 4.2 소스의 순서는 다음과 같다(shard_server_catalog_cache_loader.cpp:268-290, 428-431, 585-591).
_flushRoutingTableCacheUpdates 명령을 보낸다. 소스는 이 명령을 옛 이름 forceRoutingTableRefresh로 보낸다. 옛 이름은 3.6.0 호환용 별칭이다(flush_routing_table_cache_updates_command.cpp:60-63). 제한 시간은 30초(Seconds{30})다.config.cache.* 컬렉션에 쓴다.waitUntilOpTimeForRead). 그 쓰기가 복제로 도착하면 로컬 config.cache.*를 읽어 메모리의 소유권 목록을 채운다.secondary의 읽기 하나가 소유권 목록을 갱신하려면 primary의 쓰기 하나와 그 복제가 먼저 끝나야 한다. 2번 단계가 쓰기이기 때문이다.
secondary가 config 서버에 직접 묻지 않는 이유는 소스 주석에 없다. 이 글은 이 구조를 복제된 데이터와 소유권 목록의 시점을 맞추려는 설계로 본다. secondary가 아직 복제받지 못한 chunk 이동을 소유권 목록으로만 먼저 알면 문서와 소유권 목록이 어긋난다. 같은 복제 흐름으로 둘을 받으면 그 어긋남이 생기지 않는다. 이 해석은 소스에서 끌어낸 추론이다.
소유권 목록은 메모리에 있다. mongod를 재시작하면 메모리의 소유권 목록은 비어서 시작한다(metadata_manager.cpp:246-252). 디스크의 config.cache.*는 남아 있지만 메모리의 소유권 목록은 첫 갱신 때 채워진다. 그래서 재시작한 노드는 컬렉션마다 첫 읽기를 StaleConfig로 거부하고 소유권 목록을 갱신한다.
여기서 primary가 쓰기를 못 하는 상황을 겹쳐 보자. 프로세스는 살아 있지만 쓰기가 멈춘 primary는 이 시리즈 5편에서 다뤘다. 그 shard의 secondary를 세 경우로 나눠 비교하면 다음과 같다.
| secondary | 메모리의 소유권 목록 | 읽기 결과 |
|---|---|---|
| 재시작하지 않음, 이미 읽던 컬렉션 | 있음 | 계속 읽음 |
| 재시작하지 않음, 처음 읽는 컬렉션 | 없음 | 갱신 실패로 거부 |
| 재시작함 | 모든 컬렉션에서 없음 | 컬렉션마다 첫 읽기에서 갱신 실패로 거부 |
재시작한 secondary는 갱신하려고 primary에 명령을 보낸다. 그런데 primary는 config.cache.*에 쓰지 못한다. secondary는 30초 제한에 걸리거나 복제를 기다리다 갱신에 실패한다. 소유권 목록이 계속 UNKNOWN이므로 다음 읽기도 같은 길을 밟는다. 정상 primary가 서기 전까지 이 노드는 그 shard의 읽기를 하나도 처리하지 못한다.
secondaryPreferred 같은 readPreference는 읽기를 보낼 노드를 고르는 규칙이다. 그 노드가 소유권 목록을 어디서 받는지는 정하지 않는다. 읽기를 secondary로 돌려도 소유권 목록 갱신은 여전히 primary의 쓰기에 묶여 있다.
StaleConfig는 mongod와 mongos 사이에서 오가는 오류다. mongos는 이 오류를 받으면 갱신을 기다렸다가 재시도한다. 그래서 애플리케이션에는 StaleConfig가 그대로 보이지 않을 수 있다. mongos가 재시도하는 동안 애플리케이션의 요청은 응답을 기다린다. 애플리케이션 쪽에서는 연결 타임아웃이나 소켓 타임아웃으로만 보일 수 있다.
이 상황에서 찾아볼 신호는 다음과 같다.
forceRoutingTableRefresh 또는 _flushRoutingTableCacheUpdates 실패가 남는다.StaleConfig(13388)가 반복된다.this shard no longer contains chunks for <ns>가 된다(collection_sharding_state.cpp, 소스 기반 추론).readConcern: "available"로 읽으면 노드가 shard version 검사를 건너뛴다. collection_sharding_state.cpp:210-212의 invariant가 이 전제를 확인한다. 소유권 목록이 없어도 노드는 읽기에 응답한다.
대가는 고아 문서다. 3.6 릴리스 노트는 secondary의 소유권 목록이 고아 문서를 막는 장치이고 "available"일 때는 그 보호가 없다고 적는다. 위 표의 가운데 시점이라면 문서 X가 두 번 나올 수 있다.
// mongos에 연결한 셸 예시
db.store_inventory.find({ "_id.productId": 1001 })
.readPref("secondaryPreferred")
.readConcern("available")
쓸지 말지는 조회 결과가 얼마나 정확해야 하는지로 정한다. 중복이나 이미 옮겨 간 문서가 섞여도 되는 화면(예: 대략적인 재고 표시)에는 고를 수 있다. 결제·정산처럼 건수가 맞아야 하는 조회에는 쓰지 않는다.
readConcern: "available"은 고아 문서를 받아들일 수 있는 조회에만 쓴다.함정: secondary 재시작은 흔한 "일단 살려 보기" 조치다. 이 상황에서는 읽던 secondary를 읽지 못하는 secondary로 바꾼다. 재시작하면 메모리의 소유권 목록이 빈다. 그 소유권 목록을 채우려면 멈춘 primary의 쓰기가 필요하다.
실행으로 검증하지 않은 절차다. 아래 절차와 결과는 소스를 근거로 한 예상이다.
준비물은 MongoDB 4.2 바이너리다. 운영자가 로컬에 config 서버 replica set 1개, shard 2개(각 3노드 replica set), mongos 1개를 띄운다.
store_inventory 컬렉션을 _id hashed 샤드키로 샤딩하고 문서를 넣는다.sh.enableSharding("demo")
sh.shardCollection("demo.store_inventory", { _id: "hashed" })
for (let p = 1; p <= 100; p++)
for (let s = 1; s <= 20; s++)
db.getSiblingDB("demo").store_inventory.insertOne({ _id: { productId: p, storeId: s }, qty: 10 })
{ node: "a1" }, { node: "a2" }). 같은 reconfig에서 두 secondary의 priority를 0으로 바꾼다. primary를 멈췄을 때 선거가 일어나지 않게 하려는 설정이다.find(...).explain()의 shard 이름으로 확인한다.db.store_inventory.find({ _id: { productId: 1, storeId: 1 } })
.readPref("secondary", [{ node: "a1" }]).maxTimeMS(60000)
kill -STOP <pid>를 보낸다.kill -CONT <pid>로 primary를 깨우고 a1 조회를 다시 보낸다.예상 결과는 다음과 같다.
| 단계 | a1(재시작함) | a2(재시작 안 함) |
|---|---|---|
| 7번 | 응답이 늦다가 실패함. a1 로그에 갱신 실패, mongos 로그에 StaleConfig가 남음 |
바로 응답함 |
| 8번 | 응답함 | 응답함 |
이 재현의 한계:
SIGSTOP은 프로세스 전체를 멈춘다. heartbeat 응답도 멈추므로 replica set은 primary를 "연락 안 됨"으로 본다. 반면 엔진이 livelock에 빠진 primary는 heartbeat에 답하면서 쓰기만 못 한다(5편). priority 0으로 선거는 막았지만 a1이 primary를 찾는 단계부터 실제와 달라진다. 그래서 7번의 오류가 30초 제한이 아니라 "primary를 찾지 못함" 계열로 나올 수 있다. 재시작한 노드만 막힌다는 차이를 보는 용도로 쓴다.
- 이 상황에서 mongos가 애플리케이션에 돌려주는 오류가
StaleConfig인지 타임아웃 계열인지 확인하지 못했다. "애플리케이션에는 타임아웃으로 보일 수 있다"는 일반론이다.- secondary가 config 서버에 직접 묻지 않는 이유는 소스에 적혀 있지 않다. 위 설명은 추론이다.
- 샤딩하지 않은 컬렉션이 재시작 직후 같은 길을 밟는지는 따로 확인하지 않았다.
- 4.2 이후 버전에서 secondary의 갱신 경로가 바뀌었는지는 확인하지 않았다.
- 「직접 재현해 보기」는 실행하지 않았다.