MongoDB 샤드 클러스터 쓰기 경로 시리즈 3/6편이다.
이 글이 답하는 질문: MongoDB의 CPU는 100%인데 쓰기가 하나도 끝나지 않는다. 디스크도 한가하고 데드락도 아니다. 무엇이 CPU를 쓰고 있는가?
짧은 답: WiredTiger는 트랜잭션을 시작할 때마다 스냅샷을 만든다. 이때 다른 세션이 트랜잭션 ID를 받는 중이면 스냅샷을 만드는 스레드는 x86pause명령만 반복하며 기다린다. ID를 받던 스레드가 CPU에서 밀려나면 기다리는 스레드들이 CPU를 차지해서 그 스레드가 돌아오기 어렵다. 모두 바쁘지만 아무도 전진하지 못하는 livelock이다.
기준 버전: MongoDB 4.2(소스 태그r4.2.12, 동봉 WiredTigermongodb-4.2브랜치). WiredTigerdevelop브랜치는 2026-11-05에 확인했다.
write ticket은 MongoDB가 스토리지 엔진(WiredTiger)에 동시에 들여보내는 작업 수의 상한이다. 입장권처럼 표를 받은 작업만 엔진 안으로 들어가고 나머지는 줄을 선다.
| 항목 | 4.2 기준 |
|---|---|
| 기본값 | 쓰기 128, 읽기 128 (wiredtiger_kv_engine.cpp:474-475) |
| 설정 | wiredTigerConcurrentWriteTransactions |
| 받는 때 | 글로벌 락 획득 시 (lock_state.cpp:262-266) |
| 지표 | wiredTiger.concurrentTransactions.write |
설정과 지표 이름에 WiredTiger가 붙어 있지만 관리 주체는 MongoDB다. 4.2 공식 문서도 "These settings are MongoDB-specific."이라고 적는다. 설정은 실행 중에 setParameter로 바꿀 수 있다.
insert·update·delete처럼 쓰기 의도 락(IX)을 잡는 작업이 write ticket을 하나씩 쓴다.
serverStatus의 wiredTiger.concurrentTransactions.write는 세 값을 낸다.
out: 지금 쓰고 있는 ticket 수available: 남은 ticket 수totalTickets: 전체 ticket 수줄을 선 작업 수는 globalLock.currentQueue로 본다. 4.2 소스에서 이 값은 ticket을 기다리는 작업과 락을 기다리는 작업을 함께 센다(lock_state.cpp:296-303, lock_state.cpp:341). 이 값만으로는 둘을 구분할 수 없다.
스냅샷은 트랜잭션이 시작한 순간에 아직 커밋되지 않은 트랜잭션 ID의 목록이다. 트랜잭션은 이 목록에 든 트랜잭션이 쓴 값을 보지 않는다. 읽기 트랜잭션도 시작할 때 스냅샷을 만든다.
스냅샷을 만드는 함수는 __wt_txn_get_snapshot이다(txn.c:148-248, r4.2.12). 순서는 다음과 같다.
txn_global의 읽기 락(txn_global->rwlock)을 잡는다.진행 중인 쓰기 트랜잭션이 없으면 2~3단계를 건너뛴다(소스 주석 "For pure read-only workloads, avoid scanning."). 쓰기가 섞인 부하에서는 매번 배열 전체를 훑는다.
쓰기 트랜잭션은 락 없이 ID를 받는다(txn.i:912-918).
is_allocating 표시를 올린다.is_allocating 표시를 내린다.스냅샷을 만드는 스레드는 배열을 훑다가 is_allocating이 올라간 세션을 만나면 그 자리에서 기다린다. 아래는 src/third_party/wiredtiger/src/txn/txn.c (r4.2.12) 발췌다.
/* We're going to scan the table: wait for the lock. */
__wt_readlock(session, &txn_global->rwlock);
…
for (i = 0, s = txn_global->states; i < session_cnt; i++, s++) {
…
while (s != txn_state && (id = s->id) != WT_TXN_NONE && WT_TXNID_LE(prev_oldest_id, id) &&
WT_TXNID_LT(id, current_id)) {
/*
* If the transaction is still allocating its ID, then we spin here until it gets its
* valid ID.
*/
WT_READ_BARRIER();
if (!s->is_allocating) {
…
if (id == s->id) {
txn->snapshot[n++] = id;
…
break;
}
}
WT_PAUSE();
}
}
WT_PAUSE()는 x86에서 pause 명령 한 줄이다(gcc.h:160).
#define WT_PAUSE() __asm__ volatile("pause\n" ::: "memory")
이 루프에는 yield(운영체제에 CPU를 돌려주는 호출)도 sleep도 반복 횟수 상한도 없다. 그래서 기다리는 스레드는 운영체제가 강제로 내릴 때까지 CPU를 쥔 채 pause만 반복한다. 오래된 트랜잭션 ID를 계산하는 __txn_oldest_scan에도 같은 루프가 있다(txn.c:278-299).
같은 WiredTiger 안에서도 rwlock 자체를 기다리는 코드는 단계를 밟아 CPU를 내놓는다(mtx_rw.c:244-253).
| 대기 대상 | 1단계 | 2단계 | 3단계 |
|---|---|---|---|
| rwlock 획득 | pause 1,000번 |
yield 200번 | 조건변수 대기(최대 10ms) 반복 |
| 다른 세션의 ID 할당 | pause 무한 반복 |
없음 | 없음 |
ID 할당은 명령 몇 개면 끝난다. 평소에는 스냅샷을 만드는 스레드도 pause 몇 번이면 루프를 빠져나온다. 실행 대기 스레드(CPU를 받을 차례를 기다리는 실행 가능 상태의 스레드)가 코어보다 많으면 아래 순서가 생길 수 있다.
is_allocating을 올린다.pause 루프를 돈다.is_allocating을 내릴 수 있다. 그런데 코어를 차지한 스레드들이 모두 A를 기다린다.A가 CPU를 다시 받으면 livelock이 풀린다. 하지만 4~5단계가 되풀이되면 처리량이 바닥인 상태가 오래 이어질 수 있다.
이 순서는 WiredTiger 이슈 WT-8670 작성자가 적어 두었다. 원문은 "My understanding is that"으로 시작하는 가설이다.
"if an allocating thread gets scheduled out, it could take a long time for it to resume execution because all threads creating a snapshot will be spinning on that loop"
같은 이슈에 실린 측정값은 약 4%의 처리량 손실이다. 비교 대상은 스냅샷 생성을 뮤텍스로 직렬화한 경우다. 조건은 YCSB 50read50update, 스레드 32개, CPU 16개였다. 이 조건에서 처리량은 붕괴하지 않았다. 스레드가 코어보다 훨씬 많을 때 위 순서가 처리량 붕괴로 이어지는지 잰 공개 자료는 찾지 못했다.
| 구분 | 데드락 | 이 글의 livelock |
|---|---|---|
| 기다리는 방식 | 잠든 채 상대의 락을 기다림 | pause 루프로 깨어서 기다림 |
| CPU | 거의 안 씀 | 코어를 다 씀 |
| 락 순환 | 있음 | 없음 (기다리는 대상은 is_allocating 표시) |
| 풀리는 조건 | 한쪽을 강제로 끊어야 함 | 밀려난 스레드가 CPU를 다시 받으면 풀림 |
CPU가 100%인데 락 순환 대기가 보이지 않으면 이 livelock을 의심한다.
아래는 메커니즘에서 끌어낸 일반 경향이다. 수치 기준은 두지 않는다.
pause는 디스크를 기다리지 않는 사용자 영역 명령이다.pause 루프는 시스템 콜을 하지 않아서 스스로 CPU를 내놓지 않는다. rwlock 대기였다면 yield와 조건변수 대기 때문에 문맥 전환이 늘었어야 한다.pause 루프를 돌며 실행 가능 상태로 남는다. vmstat의 r 열과 concurrentTransactions.write.out을 나란히 본다.currentQueue 증가는 CPU 포화보다 늦게 올 수 있다. 끝나지 않은 작업이 ticket을 쥐고 있어서다.해석할 때 두 가지를 함께 본다.
perf나 pstack으로 다수 스레드가 __wt_txn_get_snapshot에 있는지 확인한다.txn global 락 대기 지표도 함께 오를 수 있다. 스냅샷을 만드는 스레드는 txn_global->rwlock 읽기 락을 쥔 채 pause 루프를 돈다. 오래된 ID를 갱신하는 쪽은 같은 rwlock의 쓰기 락을 기다린다(txn.c:392-396). 이 지표만 보고 rwlock 경합을 원인으로 단정하지 않는다.MongoDB 7.0부터는 ticket 수를 고정하지 않고 처리량을 보며 조절한다. 알고리즘은 storageEngineConcurrencyAdjustmentAlgorithm의 기본값 throughputProbing이다. 상한은 128이다. 공식 문서는 목적을 "to optimize database throughput during overload"라고 적는다.
이 기능이 이 글의 livelock을 줄인다는 공식 서술이나 측정은 찾지 못했다. ticket이 줄면 동시에 스냅샷을 만드는 스레드도 줄어서 livelock이 생길 확률이 낮아질 수는 있다. 이것은 추론이다.
pause 루프의 수정 상태는 2026-11-05에 다음과 같았다.
develop 브랜치의 src/txn/txn.c에도 is_allocating을 기다리는 같은 루프가 남아 있다.MongoDB 4.2는 2023-04-30에 지원이 끝났다(EOL).
perf top -p <mongod pid>로 __wt_txn_get_snapshot 비중을 본다.ticket 소진을 원인으로 단정하지 않는다. ticket 소진은 CPU 포화 뒤에 따라오는 결과일 수 있다. ticket만 늘리면
pause루프를 도는 스레드가 늘어 상황이 나빠질 수 있다(추론).
maxTimeMS와killOp은pause루프 안의 스레드를 끊지 못한다. 둘 다 작업이 스스로 중단 여부를 확인할 때만 효과가 있는데 이 루프에는 그런 확인이 없다(4/6편).
failover가 일어나지 않을 수 있다. heartbeat는 엔진을 거치지 않는다(5/6편).
실행으로 검증하지 않은 절차다. 아래 결과는 소스에서 끌어낸 예상이다.
코어 수가 적은 VM에 MongoDB 4.2 계열 mongod를 하나 띄운다(예: 2~4 vCPU, 예시값).
YCSB로 코어 수보다 훨씬 많은 동시 쓰기를 건다(예: 스레드 256개, 예시값).
./bin/ycsb load mongodb -s -P workloads/workloada -p mongodb.url=mongodb://<host>:27017/ycsb
./bin/ycsb run mongodb -s -P workloads/workloada -p mongodb.url=mongodb://<host>:27017/ycsb -threads 256
다른 터미널에서 sudo perf top -p $(pgrep -x mongod)로 핫스팟을 본다.
또 다른 터미널에서 vmstat 1을 띄워 r(실행 대기)과 cs(문맥 전환) 열을 본다.
비교를 위해 wiredTigerConcurrentWriteTransactions를 코어 수 근처로 낮추고(setParameter) 같은 부하를 다시 건다.
예상 결과는 다음과 같다.
perf top 상위에 __wt_txn_get_snapshot이 보인다.vmstat에서 r이 코어 수보다 훨씬 크고 wa는 0에 가깝다. cs는 부하 초반보다 낮아진다.__wt_txn_get_snapshot 비중과 r이 줄어든다.WT-8670의 측정처럼 손실이 몇 %에 그치고 livelock까지 가지 않을 수도 있다.
- 스레드가 코어보다 몇 배 많아야 이 루프가 처리량 붕괴로 이어지는지에 대한 공개 측정
- MongoDB 7.0
throughputProbing이 이 livelock을 실제로 줄이는지- MongoDB 7.0·8.0 릴리스에 동봉된 WiredTiger의 해당 코드. 이 글은 4.2.12와 WiredTiger
develop만 확인했다perf에 보이는 실제 심볼 이름. 컴파일러가__wt_txn_get_snapshot을 인라인하면 다른 함수 이름으로 보일 수 있다