MongoDB 샤드 클러스터 쓰기 경로 시리즈 1/6편이다.
이 글이 답하는 질문: 샤딩 컬렉션에 bulk 쓰기 1,000건을 ORDERED로 보내면 mongos와 shard 사이에서 왕복이 몇 번 일어나는가? 왜 shard 하나만 느려져도 bulk 전체가 느려지는가? UNORDERED로 바꾸면 무엇을 얻고 무엇을 대가로 치르는가?
기준 버전: MongoDB 4.2 (소스r4.2.12태그, 4.2 공식 문서). 4.2는 2023-04-30에 지원이 끝났다(EOL). 4.4 이후 버전의 mongos 동작은 확인하지 않았다(2026-11 기준).
ORDERED bulk를 받은 mongos는 같은 shard로 가는 연속 구간만 한 묶음으로 보내고 그 응답을 받은 뒤에야 다음 묶음을 만든다. hashed 샤드키는 bulk 안에서 이웃한 문서를 여러 shard에 흩어 놓으므로 묶음이 거의 문서마다 끊긴다. 문서 n건, shard S개일 때 기대 왕복 수는 1 + (n − 1)(1 − 1/S)다. 1,000건을 shard 5개에 보내면 약 800번이다. 순서에 의존하지 않는 bulk는 BulkMode.UNORDERED(드라이버 옵션 ordered(false))로 바꾸면 mongos가 shard별 묶음을 한 번에 동시에 보낸다.
bulk 쓰기는 insert·update·replace·delete 여러 건을 요청 하나에 담아 보내는 방법이다. 셸에서는 db.collection.bulkWrite()를 쓴다. Spring Data MongoDB에서는 BulkOperations를 쓴다. 클라이언트는 요청을 한 번 보내고 응답을 한 번 받는다.
bulk에는 두 가지 모드가 있다. 4.2 공식 문서 "Ordered vs Unordered Operations" 절의 설명은 다음과 같다.
| 모드 | 실행 방식 | 중간에 오류가 나면 |
|---|---|---|
| ordered (기본값) | "MongoDB executes the operations serially" | 남은 작업을 처리하지 않고 반환한다 |
| unordered | 병렬로 실행할 수 있다(보장하지 않음) | 남은 작업을 계속 처리한다 |
샤드 클러스터에서 클라이언트는 mongos(라우터)에 접속한다. mongos는 bulk에 든 쓰기 하나하나의 샤드키를 보고 어느 shard로 갈지 정한다(targeting). 그다음 같은 shard로 가는 쓰기를 모아 shard마다 하위 묶음(child batch)을 만들어 보낸다.
클라이언트 입장에서는 요청이 한 번이다. 하지만 mongos와 shard 사이에서는 하위 묶음 수만큼 왕복이 생긴다. 이 글에서 말하는 왕복은 이 mongos ↔ shard 왕복이다.
hashed 샤드키는 필드 값을 해시한 값으로 청크(샤드키 값의 범위로 나눈 데이터 단위)를 나눈다. 4.2 공식 문서 "Hashed Sharding"은 이렇게 쓴다.
Post-hash, documents with "close" shard key values are unlikely to be on the same chunk or shard
예를 들어 _id: { productId, storeId }를 hashed 샤드키로 쓰는 store_inventory 컬렉션을 생각한다. 상품 하나를 매장 수만큼 문서로 펼친 컬렉션이다. 같은 상품의 매장 1번 문서와 매장 2번 문서는 키가 비슷하지만 해시 뒤에는 서로 다른 shard에 있을 가능성이 높다. hashed 샤드키가 데이터를 고르게 퍼뜨리는 대신 치르는 비용이다.
mongos는 src/mongo/s/write_ops/batch_write_op.cpp의 BatchWriteOp::targetBatch에서 하위 묶음을 만든다. 263-292행 주석은 ordered 규칙을 이렇게 적는다.
// Targeting of ordered batches is a bit more complex - to respect the ordering of the
// batch, we can only send:
// A) a single targeted batch to one shard endpoint
// B) multiple targeted batches, but only containing targeted writes for a single write op
한 쓰기가 한 shard로만 가는 보통의 경우에는 A가 적용된다. ordered 모드에서는 한 번에 shard 하나로만 묶음을 보낸다. 다음 쓰기가 지금 묶음에 없는 shard로 가면 isNewBatchRequiredOrdered(96-106행)가 true를 돌려준다. 그러면 mongos는 묶음을 끊는다.
실행 루프는 batch_write_exec.cpp에 있다. mongos는 while (!batchOp.isFinished() && !abortBatch)(120행) 안에서 묶음을 만들어 보낸다. 그다음 ars.next()로 응답을 기다린다. 255행 주석은 "Block until a response is available."이다. 응답을 다 받아야 다음 묶음을 만든다. 이 글에서는 이 루프가 묶음을 보내고 응답을 다 받기까지 한 번 도는 것을 바퀴라고 부른다.
그래서 ordered 모드에서는 같은 shard로 가는 연속 구간 하나가 왕복 한 번이다.
unordered 모드는 남은 쓰기를 전부 targeting해서 shard마다 하나씩 묶는다. 한 바퀴에 최대 S개(S는 shard 수)의 하위 묶음을 동시에 보낸다. 묶음을 자르는 조건은 셋이다.
kMaxWriteBatchSize(100,000건, write_ops.h:62)에 이르면 자른다BSONObjMaxUserSize)를 넘으면 자른다isNewBatchRequiredUnordered, 112-125행)1,000건짜리 bulk는 보통 이 조건에 걸리지 않으므로 한 바퀴로 끝난다.
shard가 A·B·C 셋이고 문서 8건이 아래처럼 배정됐다고 하자. 배정은 예시값이다.
| 순서 | 문서 | shard |
|---|---|---|
| 1 | d1 | A |
| 2 | d2 | B |
| 3 | d3 | B |
| 4 | d4 | C |
| 5 | d5 | A |
| 6 | d6 | C |
| 7 | d7 | C |
| 8 | d8 | B |
두 모드가 만드는 묶음은 다음과 같다.
| 바퀴 | ORDERED | UNORDERED |
|---|---|---|
| 1 | A: [d1] | A: [d1, d5], B: [d2, d3, d8], C: [d4, d6, d7] (동시에 보냄) |
| 2 | B: [d2, d3] | 없음 |
| 3 | C: [d4] | 없음 |
| 4 | A: [d5] | 없음 |
| 5 | C: [d6, d7] | 없음 |
| 6 | B: [d8] | 없음 |
ORDERED는 6바퀴를 차례로 돈다. 각 바퀴는 앞 바퀴의 응답을 기다린다. UNORDERED는 1바퀴에 하위 묶음 3개를 동시에 보낸다.
ORDERED의 왕복 수는 묶음이 끊긴 횟수에 1을 더한 값이다. 묶음은 이웃한 두 문서의 shard가 다를 때 끊긴다. 위 예시에서 이웃한 쌍 7곳 중 5곳에서 shard가 바뀌었으므로 1 + 5 = 6바퀴다.
hashed 샤드키로 문서가 S개 shard에 고르게, 서로 독립적으로 배정된다고 가정한다. 그러면 이웃한 두 문서가 같은 shard에 있을 확률은 1/S이고 다를 확률은 1 − 1/S다. 1,000건이면 문서 사이 경계가 999곳이다. shard가 5개면 그중 4/5인 약 799곳에서 묶음이 끊긴다. 여기에 첫 묶음 1번을 더하면 약 800번이다.
일반식은 다음과 같다.
기대 왕복 수 = 1 + (n − 1) × (1 − 1/S)
n = 1,000, S = 5 → 1 + 999 × 0.8 = 800.2
shard 수에 따른 계산값은 다음과 같다(n = 1,000).
| shard 수 S | ORDERED 기대 왕복 수 | UNORDERED 바퀴 수 (동시 요청 수) |
|---|---|---|
| 2 | 500.5 | 1 (2) |
| 3 | 667 | 1 (3) |
| 4 | 750.25 | 1 (4) |
| 5 | 800.2 | 1 (5) |
| 6 | 833.5 | 1 (6) |
| 8 | 875.125 | 1 (8) |
shard를 늘릴수록 ORDERED 왕복 수는 n에 가까워진다. 쓰기 처리량을 늘리려고 shard를 추가하면 ORDERED bulk 하나는 오히려 더 잘게 쪼개진다.
write ticket은 동시에 스토리지 엔진에 들어갈 수 있는 쓰기 수를 제한하는 입장권이다. shard의 mongod는 쓰기를 실행할 때 글로벌 락을 IX(intent exclusive) 모드로 잡고 이때 write ticket을 받는다. 이 동작은 lock_state.cpp에 있다. 글로벌 락을 잡는 함수 _lockGlobalBegin이 먼저 _acquireTicket을 부른다. 하위 묶음은 그 shard에서 실행되는 동안에만 ticket을 쥔다. mongos가 응답을 받고 다음 묶음을 만드는 사이에는 쥐지 않는다.
그래서 ORDERED bulk가 ticket을 오래 쥐지는 않는다. 대신 bulk 하나의 수명이 모든 shard에 걸친다. shard 하나가 느려지면 그 bulk 전체가 멈춘다. shard 5개면 bulk 하나가 각 shard를 평균 160번(800 ÷ 5) 거친다. 그중 한 shard의 응답이 10배 느려지면 bulk는 그 느린 응답을 160번 차례로 기다린다.
리틀의 법칙에 따르면 시스템에 동시에 떠 있는 작업 수는 초당 도착하는 작업 수에 작업 하나가 머무는 시간을 곱한 값과 같다. 아래 숫자는 모두 예시값이다.
| 상황 | bulk 하나의 시간 | 동시에 떠 있는 bulk 수 |
|---|---|---|
| ORDERED, 정상 | 800 × 1ms + 1,000 × 0.2ms = 1.0초 | 20 |
| ORDERED, shard 하나 느림 | 약 2.8초 | 약 56 |
| UNORDERED, 정상 | 1ms + 200 × 0.2ms = 41ms | 약 0.8 |
| UNORDERED, shard 하나 느림 | 10ms + 200 × 2ms = 410ms | 약 8 |
ORDERED·느림 행의 계산은 이렇다. 느린 shard로 가는 160바퀴와 문서 200건이 1.6초 + 0.4초 = 2.0초를 쓴다. 나머지 640바퀴와 문서 800건이 0.64초 + 0.16초 = 0.8초를 쓴다.
UNORDERED도 가장 느린 shard를 기다린다. 그래도 그 대기를 한 번만 치른다.
동시에 떠 있는 bulk가 많을수록 클라이언트 스레드와 커넥션도 그 수만큼 묶인다. bulk를 보내는 writer 서비스가 Kafka 컨슈머라면 컨슈머 스레드가 그 시간 동안 다음 메시지를 처리하지 못한다.
data class StoreInventory(
@Id val id: InventoryId, // { productId, storeId }, hashed 샤드키
val stock: Int,
val price: Long,
)
fun saveAll(items: List<StoreInventory>) {
val ops = mongoTemplate.bulkOps(BulkOperations.BulkMode.ORDERED, StoreInventory::class.java)
items.forEach { item ->
ops.replaceOne(
Query.query(Criteria.where("_id").`is`(item.id)),
item,
FindAndReplaceOptions.options().upsert(),
)
}
ops.execute()
}
fun saveAll(items: List<StoreInventory>) {
// 같은 _id가 두 번 들어가면 순서가 결과를 바꾼다. 마지막 값만 남긴다.
val deduped = items.associateBy { it.id }.values.toList()
val ops = mongoTemplate.bulkOps(BulkOperations.BulkMode.UNORDERED, StoreInventory::class.java)
deduped.forEach { item ->
ops.replaceOne(
Query.query(Criteria.where("_id").`is`(item.id)),
item,
FindAndReplaceOptions.options().upsert(),
)
}
try {
ops.execute()
} catch (e: BulkOperationException) {
val failed = e.errors.map { deduped[it.index] }
retryOrDeadLetter(failed)
}
}
각 replaceOne이 서로 다른 문서를 건드리면 실행 순서가 결과를 바꾸지 않는다. 그래서 두 코드는 같은 최종 상태를 만든다. 순서 의존은 한 bulk 안에 같은 문서를 두 번 넣을 때 생긴다. UNORDERED 코드는 bulk를 만들기 전에 _id별로 마지막 값만 남겨 이 경우를 없앤다.
UNORDERED로 바꾸는 대가는 부분 실패 처리다. ORDERED는 첫 오류에서 멈추므로 어디까지 처리됐는지가 한 위치로 정해진다. UNORDERED는 오류가 나도 나머지를 계속 처리한다. 그래서 실패한 쓰기가 bulk 곳곳에 흩어질 수 있다. BulkOperationException.errors의 각 항목에는 그 쓰기가 bulk에서 몇 번째인지 나타내는 index가 있다. 실패한 위치의 문서만 골라 다시 처리한다.
공식 Java 드라이버를 직접 쓰면 옵션 한 줄로 바꾼다.
collection.bulkWrite(models, BulkWriteOptions().ordered(false))
_id가 겹칠 수 있으면 bulk를 만들기 전에 합친다.index만 재시도하거나 별도 큐로 보낸다.db.collection.getShardDistribution()과 sh.status()로 본다. 특정 키가 가는 shard를 보려면 그 키로 조회하는 find()에 explain()을 붙인다. 결과의 winningPlan.shards에 나온 shard 이름이 그 키의 shard다. 4.0부터는 convertShardKeyToHashed()로 해시값도 볼 수 있다.함정
- Spring Data MongoDB
bulkOps와 셸bulkWrite()의 기본 모드는 ordered다. 모드를 적지 않았다면 ORDERED로 돌고 있다.- UNORDERED도 가장 느린 shard를 기다린다. 느린 shard의 영향을 한 번으로 줄일 뿐 없애지는 않는다.
- UNORDERED에서 한 bulk에 같은 문서가 두 번 들어가면 어느 쓰기가 마지막에 반영될지 기대할 수 없다.
- 왕복은 mongos와 shard 사이에서 일어난다. 그래서 클라이언트 쪽 요청 수만 보면 보이지 않는다.
실행으로 검증하지 않은 절차다. 아래 결과는 소스 기반의 예상이다.
mlaunch init --replicaset --nodes 1 --sharded 5를 실행한다. docker compose로 구성해도 된다.sh.enableSharding("demo") 다음에 sh.shardCollection("demo.store_inventory", { _id: "hashed" })를 실행한다. 빈 컬렉션을 hashed로 샤딩하면 청크가 shard마다 미리 나뉜다.db.serverStatus().metrics.commands.update.total 값을 기록한다._id가 서로 다른 replace(upsert) 1,000건을 db.store_inventory.bulkWrite(ops, { ordered: true })로 보낸다.{ ordered: false }로 보내고 3~5단계를 반복한다.예상(소스 기반) 결과는 이렇다. ORDERED의 증가분 합은 위 계산 표의 값 근처에 놓인다. UNORDERED의 증가분 합은 shard 수와 같다. 하위 묶음 하나가 update 명령 하나로 shard에 도착한다고 가정한 예상이다.
- 실제 청크 분포: 위 식은 문서가 모든 shard에 고르게, 독립적으로 배정된다고 가정한다. 청크가 한 shard에 몰리면 같은 shard가 이어질 확률이 커져 왕복 수가 줄어든다. 분포가 어느 정도 기울면 얼마나 줄어드는지는 따로 계산하지 않았다.
- 4.4 이후 버전의 mongos: 근거는 4.2 소스(
r4.2.12)와 4.2 문서다. 4.4 이후 mongos의 묶음 규칙이 같은지는 확인하지 않았다.- 재현 절차의 카운터:
metrics.commands.update.total이 하위 묶음 하나마다 정확히 1씩 오르는지, 프로파일러가 하위 묶음 단위로 남는지 쓰기 하나(statement) 단위로 남는지는 실행으로 확인하지 않았다.- 코드 예시: Kotlin 예시는 Spring Data MongoDB
BulkOperationsAPI로 작성했지만 실행하지 않았다.