Este es el artículo 3/6 de la serie sobre la ruta de escritura en clústeres fragmentados de MongoDB.
Pregunta que responde este artículo: la CPU de MongoDB está al 100%, pero no termina ni una sola escritura. El disco está ocioso y tampoco es un deadlock. ¿Qué está consumiendo la CPU?
Respuesta corta: WiredTiger crea un snapshot cada vez que empieza una transacción. Si en ese momento otra sesión está recibiendo un ID de transacción, el hilo que crea el snapshot espera repitiendo solo la instrucción x86pause. Si el hilo que recibía el ID es desalojado de la CPU, los hilos que esperan ocupan la CPU y a ese hilo le cuesta volver. Es un livelock: todos están ocupados, pero nadie avanza.
Versión de referencia: MongoDB 4.2 (etiqueta de código fuenter4.2.12, ramamongodb-4.2del WiredTiger incluido). La ramadevelopde WiredTiger se revisó el 2026-11-05.
El write ticket es el límite superior del número de operaciones que MongoDB deja entrar a la vez en el storage engine (WiredTiger). Como con una entrada, solo las operaciones que reciben un ticket entran en el motor y las demás hacen cola.
| Elemento | En 4.2 |
|---|---|
| Valor por defecto | 128 de escritura, 128 de lectura (wiredtiger_kv_engine.cpp:474-475) |
| Configuración | wiredTigerConcurrentWriteTransactions |
| Cuándo se recibe | Al adquirir el lock global (lock_state.cpp:262-266) |
| Métrica | wiredTiger.concurrentTransactions.write |
Aunque los nombres de la configuración y de la métrica llevan WiredTiger, quien los gestiona es MongoDB. La documentación oficial de 4.2 también dice "These settings are MongoDB-specific.". La configuración se puede cambiar en ejecución con setParameter.
Las operaciones que toman un lock de intención de escritura (IX), como insert·update·delete, usan un write ticket cada una.
wiredTiger.concurrentTransactions.write de serverStatus da tres valores.
out: número de tickets en uso ahoraavailable: número de tickets restantestotalTickets: número total de ticketsEl número de operaciones en cola se ve con globalLock.currentQueue. En el código fuente de 4.2, este valor cuenta juntas las operaciones que esperan un ticket y las que esperan un lock (lock_state.cpp:296-303, lock_state.cpp:341). Con este valor solo, no se pueden distinguir.
Un snapshot es la lista de IDs de transacciones que aún no habían hecho commit en el momento en que empezó la transacción. La transacción no ve los valores escritos por las transacciones de esta lista. Las transacciones de lectura también crean un snapshot al empezar.
La función que crea el snapshot es __wt_txn_get_snapshot (txn.c:148-248, r4.2.12). El orden es el siguiente.
txn_global->rwlock) del estado global de transacciones txn_global.Si no hay transacciones de escritura en curso, se saltan los pasos 2~3 (comentario del código fuente "For pure read-only workloads, avoid scanning."). Con una carga que mezcla escrituras, recorre todo el array cada vez.
Una transacción de escritura recibe su ID sin lock (txn.i:912-918).
is_allocating en el estado de su sesión.is_allocating.El hilo que crea el snapshot, al recorrer el array, espera en el sitio cuando encuentra una sesión con is_allocating activado. Abajo hay un extracto de 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();
}
}
En x86, WT_PAUSE() es una sola instrucción pause (gcc.h:160).
#define WT_PAUSE() __asm__ volatile("pause\n" ::: "memory")
Este bucle no tiene yield (una llamada que devuelve la CPU al sistema operativo), ni sleep, ni límite de iteraciones. Por eso el hilo que espera repite solo pause con la CPU en la mano hasta que el sistema operativo lo desaloja a la fuerza. __txn_oldest_scan, que calcula el ID de transacción más antiguo, tiene el mismo bucle (txn.c:278-299).
Dentro del mismo WiredTiger, el código que espera el propio rwlock cede la CPU por etapas (mtx_rw.c:244-253).
| Qué se espera | Etapa 1 | Etapa 2 | Etapa 3 |
|---|---|---|---|
| Adquirir el rwlock | pause 1,000 veces |
yield 200 veces | Repite la espera en una variable de condición (máximo 10ms) |
| La asignación de ID de otra sesión | pause en repetición infinita |
No hay | No hay |
La asignación de ID termina en unas pocas instrucciones. Normalmente, el hilo que crea el snapshot también sale del bucle tras unos pocos pause. Si hay más hilos listos para ejecutarse (hilos en estado ejecutable que esperan su turno de CPU) que núcleos, puede darse la siguiente secuencia.
is_allocating.pause.is_allocating. Pero todos los hilos que ocupan los núcleos esperan a A.Cuando A vuelve a recibir CPU, el livelock se deshace. Pero si los pasos 4~5 se repiten, el estado con el throughput por los suelos puede prolongarse.
Esta secuencia la dejó escrita el autor de la incidencia WT-8670 de WiredTiger. El texto original empieza con "My understanding is that", así que es una hipótesis.
"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"
La medición publicada en la misma incidencia es una pérdida de throughput de alrededor del 4%. La comparación es con el caso en que la creación de snapshots se serializa con un mutex. Las condiciones fueron YCSB 50read50update, 32 hilos y 16 CPU. En esas condiciones el throughput no se desplomó. No se encontró material público que mida si esta secuencia lleva a un desplome del throughput cuando hay muchos más hilos que núcleos.
| Categoría | Deadlock | El livelock de este artículo |
|---|---|---|
| Forma de esperar | Espera dormido el lock del otro | Espera despierto en el bucle de pause |
| CPU | Casi no la usa | Consume todos los núcleos |
| Ciclo de locks | Sí | No (lo que se espera es la marca is_allocating) |
| Condición para salir | Hay que cortar uno de los lados a la fuerza | Sale cuando el hilo desalojado vuelve a recibir CPU |
Si la CPU está al 100% pero no se ve una espera circular de locks, hay que sospechar de este livelock.
Lo siguiente son tendencias generales deducidas del mecanismo. No se fijan umbrales numéricos.
pause es una instrucción de espacio de usuario que no espera al disco.pause no hace llamadas al sistema, así que no cede la CPU por sí mismo. Si fuera una espera de rwlock, los cambios de contexto deberían haber aumentado por los yield y las esperas en variables de condición.pause y se quedan en estado ejecutable. Se miran en paralelo la columna r de vmstat y concurrentTransactions.write.out.currentQueue pueden llegar más tarde que la saturación de CPU. Es porque las operaciones sin terminar retienen los tickets.Al interpretarlas se miran dos cosas a la vez.
perf o pstack se comprueba si muchos hilos están en __wt_txn_get_snapshot.txn global. El hilo que crea el snapshot da vueltas en el bucle de pause con el lock de lectura de txn_global->rwlock en la mano. El lado que actualiza el ID más antiguo espera el lock de escritura del mismo rwlock (txn.c:392-396). No hay que concluir que la causa es la contención del rwlock solo por esta métrica.Desde MongoDB 7.0, el número de tickets no es fijo: se ajusta mirando el throughput. El algoritmo es throughputProbing, el valor por defecto de storageEngineConcurrencyAdjustmentAlgorithm. El límite superior es 128. La documentación oficial describe su propósito como "to optimize database throughput during overload".
No se encontró ninguna afirmación oficial ni medición de que esta función reduzca el livelock de este artículo. Si bajan los tickets, también bajan los hilos que crean snapshots a la vez, y la probabilidad de livelock podría bajar. Esto es una inferencia.
El estado de la corrección del bucle de pause el 2026-11-05 era el siguiente.
src/txn/txn.c de la rama develop de WiredTiger sigue estando el mismo bucle que espera is_allocating.El soporte de MongoDB 4.2 terminó el 2023-04-30 (EOL).
__wt_txn_get_snapshot con perf top -p <mongod pid>.No concluir que la causa es el agotamiento de tickets. El agotamiento de tickets puede ser una consecuencia que llega después de la saturación de CPU. Si solo se aumentan los tickets, pueden aumentar los hilos que dan vueltas en el bucle de
pausey la situación puede empeorar (inferencia).
maxTimeMSykillOpno pueden cortar los hilos que están dentro del bucle depause. Ambos solo tienen efecto cuando la operación comprueba por sí misma si debe interrumpirse, y este bucle no tiene esa comprobación (artículo 4/6).
Puede que no ocurra el failover. El heartbeat no pasa por el motor (artículo 5/6).
Es un procedimiento que no se ha verificado ejecutándolo. Los resultados de abajo son lo esperado deducido del código fuente.
Levantar un mongod de la serie MongoDB 4.2 en una VM con pocos núcleos (por ejemplo, 2~4 vCPU, valor de ejemplo).
Con YCSB, lanzar muchas más escrituras simultáneas que núcleos (por ejemplo, 256 hilos, valor de ejemplo).
./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
En otra terminal, ver los puntos calientes con sudo perf top -p $(pgrep -x mongod).
En otra terminal más, abrir vmstat 1 y mirar las columnas r (listos para ejecutarse) y cs (cambios de contexto).
Para comparar, bajar wiredTigerConcurrentWriteTransactions a un valor cercano al número de núcleos (setParameter) y lanzar de nuevo la misma carga.
El resultado esperado es el siguiente.
__wt_txn_get_snapshot aparece en la parte alta de perf top.vmstat, r es mucho mayor que el número de núcleos y wa está cerca de 0. cs baja respecto al principio de la carga.__wt_txn_get_snapshot y r.Como en la medición de WT-8670, puede que la pérdida se quede en unos pocos % y no se llegue al livelock.
- Una medición pública de cuántas veces más hilos que núcleos hacen falta para que este bucle lleve a un desplome del throughput
- Si
throughputProbingde MongoDB 7.0 reduce de verdad este livelock- El código correspondiente del WiredTiger incluido en las versiones MongoDB 7.0·8.0. Este artículo solo revisó 4.2.12 y
developde WiredTiger- El nombre real del símbolo que se ve en
perf. Si el compilador hace inline de__wt_txn_get_snapshot, puede aparecer con el nombre de otra función