Este es el artículo 4/6 de la serie sobre la ruta de escritura en clústeres fragmentados de MongoDB.
Preguntas que responde este artículo: cuando las escrituras del primary se detienen, ¿por qué no funcionan killOp·maxTimeMS·replSetStepDown ni el apagado normal (SIGTERM)? ¿Qué hay que hacer entonces y en qué orden?
Versión de referencia: MongoDB 4.2. Se leyeron la ramav4.2de la documentación oficial y la etiquetar4.2.12del código fuente del servidor. No se verificaron las versiones más recientes.
La mayoría de los medios que tiene MongoDB para detener operaciones son cooperativos. Dejan una marca de terminación y esperan a que el hilo objetivo la compruebe. Un hilo que se ejecuta dentro del storage engine (WiredTiger) no puede comprobar la marca, así que ni killOp, ni replSetStepDown, ni el apagado normal detienen ese hilo. El único medio que queda es kill -9, que la documentación oficial prohíbe. Por eso hay que respetar el orden: primero detener a los agentes que escriben (los servidores de API·lotes·consumidores que envían escrituras al clúster) y después bajar el primary.
Un interrupt point es un lugar donde la operación puede interrumpirse de forma segura. Es la definición del glosario de 4.2.
"A point in an operation's lifecycle when it can safely abort. MongoDB only terminates an operation at designated interrupt points."
En el código fuente del servidor, cada operación es un objeto OperationContext. killOp solo llama a markKilled sobre este objeto. Según el comentario del código fuente, a partir de ahí, cada vez que el hilo de la operación llama a checkForInterrupt, recibe el código de error indicado (src/mongo/db/operation_context.h:261-272 (r4.2.12)).
La documentación de killOp lo expresa así.
"Operations may not actually be terminated until they reach an appropriate interruption point."
Si la operación terminó o no hay que volver a comprobarlo con db.currentOp(). El ok: 1 que devuelve killOp es la respuesta de que se logró poner la marca.
Antes de entrar en el motor, una operación de escritura recibe un write ticket (un pase de entrada para escrituras simultáneas; 128 por defecto en 4.2). Mientras espera el ticket, se puede interrumpir. El código fuente de 4.2 pasa el OperationContext a la espera del ticket para que compruebe la marca de terminación también mientras espera (src/mongo/db/concurrency/lock_state.cpp:343-349 (r4.2.12)).
maxTimeMS funciona igual. La documentación dice que, pasado el plazo, "MongoDB interrupts the operation at the next interrupt point".
killOp y maxTimeMS solo cortan hasta las operaciones que hacen cola esperando un ticket. No pueden cortar las operaciones que ya recibieron un ticket y entraron en el motor. El bucle de espera del snapshot de WiredTiger no llama a checkForInterrupt (src/third_party/wiredtiger/src/txn/txn.c:196-235 (r4.2.12)). Cómo da vueltas sin fin este bucle se explica en Livelock en el bucle de pausa de snapshot de WiredTiger.
En un clúster fragmentado hay una trampa más. Un killOp enviado desde mongos no se propaga a los shards cuando la operación es una escritura.
"The killOp command from the mongos does not propagate to the shards when the operation to be killed is a write operation."
Las operaciones de escritura hay que cortarlas conectándose directamente al mongod primary de ese shard.
Primero, los dos modos de lock.
| Modo | Significado | ¿Pueden tenerlo varias operaciones a la vez? |
|---|---|---|
| IX (intent exclusive) | Modo compartido que declara la intención de escribir | Sí |
| X (exclusive) | Modo exclusivo | Solo una |
Quien quiere X espera hasta que todas las operaciones que tienen IX lo suelten. Por ejemplo, si 128 operaciones tienen IX, solo obtiene X cuando las 128 lo sueltan. Aunque terminen 127, si queda 1 no lo obtiene.
En 4.2, para que un primary pase a secondary tiene que tener el RSTL (ReplicationStateTransitionLock, lock de transición de estado de replicación) en X. Todas las operaciones de lectura·escritura toman el RSTL en IX antes de tomar el lock global (el lock que abarca toda la instancia mongod).
secondaryCatchUpPeriodSecs (10 segundos por defecto) es el tiempo que se espera a que un secondary alcance al primary.
El flujo de stepDown en replication_coordinator_impl.cpp (r4.2.12) es el siguiente.
force, es hasta secondaryCatchUpPeriodSecs; con force: true, es todo el periodo de stepDown (líneas 2054-2058).Starting to kill user operations y pone la marca de terminación a las operaciones que chocan con las escrituras (líneas 1887-1913, línea 1946).LockTimeout (Unable to acquire lock ... within ...) (lock_state.cpp:964-966).La marca del paso 3 también es cooperativa, como killOp. Los hilos dentro del motor no pueden comprobar la marca, así que no sueltan el RSTL IX. Por eso el paso 4 choca con la hora límite. force: true solo alarga el plazo; sigue haciendo falta RSTL X.
Según la documentación, stepDown también da error si ningún secondary se pone al día dentro de secondaryCatchUpPeriodSecs. Si el mensaje de error es Unable to acquire lock, lo que lo bloqueó no fue el catch-up sino RSTL X.
La documentación de 4.2 dice que, cuando el primary recibe SIGTERM, intenta un stepDown antes de apagarse. Aunque el stepDown falle, el apagado continúa (introducido en 3.6.15·4.0.8·4.1.10 con SERVER-38994).
La ruta de apagado de src/mongo/db/db.cpp (r4.2.12) avanza en el siguiente orden.
Enqueuing the ReplicationStateTransitionLock for shutdown # RSTL X 요청
Killing all operations for shutdown # 전체 종료 표시
Acquiring the ReplicationStateTransitionLock for shutdown # RSTL X 대기, Date_t::max() (1013-1029행)
Acquiring the global lock for shutdown # 글로벌 X 대기, Date_t::max() (1102-1106행)
Date_t::max() es un valor que no pone plazo. Por eso, si los hilos dentro del motor no sueltan el RSTL y el lock global, el apagado normal no termina. Es una inferencia hecha leyendo el código fuente.
Con SIGKILL, el sistema operativo termina el proceso de inmediato sin pedir cooperación. La documentación de 4.2 lo dice así en un recuadro de advertencia.
"Never use kill -9 (i.e. SIGKILL) to terminate a mongod instance."
No se verificó el comportamiento de recuperación del journal tras kill -9. Por eso kill -9 queda como último recurso, para usarlo después de terminar el diagnóstico (paso 1 de «Orden de respuesta», más abajo).
| Comando | Qué hace | ¿Llega a los hilos dentro del motor? | Lo que sí corta |
|---|---|---|---|
killOp |
Marca la operación objetivo para terminar | No llega | Operaciones esperando un ticket |
maxTimeMS |
Interrumpe en el siguiente interrupt point tras el plazo | No llega | Operaciones en la cola de espera de tickets |
replSetStepDown |
Marca las escrituras para terminar y espera RSTL X | No llega (LockTimeout tras el plazo) |
Escrituras que llegan a un interrupt point |
| SIGTERM | Intenta stepDown, marca todo para terminar, espera locks sin plazo | No llega (el apagado no termina, inferencia) | Operaciones que llegan a un interrupt point |
kill -9 |
El sistema operativo termina el proceso de inmediato | Llega | Todas las operaciones en curso |
Cuando los medios cooperativos no logran detener el hilo objetivo, se ve lo siguiente.
ok: 1, pero el mismo opid sigue en db.currentOp().replSetStepDown falla después del plazo (por defecto secondaryCatchUpPeriodSecs). El error es LockTimeout y en el log queda Starting to kill user operations.Si parece que el primary se detuvo dentro del motor, el operador sigue este orden.
kill -9 al mongod primary detenido. Se confirma viendo que las métricas de procesamiento de cada agente llegaron a 0. Si no hay certeza, posponer kill -9 y esperar. Tras kill -9 se corta el heartbeat, así que los secondary inician una elección (electionTimeoutMillis, 10 segundos por defecto).rs.status(), ver que otro miembro es PRIMARY. Después, encender los agentes que escriben uno a uno. Cada vez que se enciende uno, comprobar el margen de write tickets y el throughput antes de pasar al siguiente.Hay tres cosas que no se deben hacer.
No bajar solo el primary dejando escrituras pendientes. Si se salta el paso 2 y se empieza por kill -9, la misma detención puede trasladarse al nuevo primary.
Tener preparada una lista de una página con "quién escribe en este clúster y cómo se detiene cada uno". Abajo hay un ejemplo de formato; todos los valores son ficticios.
| Agente que escribe | Tipo | Cómo detenerlo | Orden de reanudación |
|---|---|---|---|
store-inventory-writer |
Consumidor de Kafka | Reducir a 0 el número de réplicas del consumidor | 2 |
| Conector CDC de eventos de cambio | CDC | Pausar el conector | 1 |
En la lista se anotan también las métricas que confirman que se detuvo. Un agente que escribe y no está en la lista sigue escribiendo en el nuevo primary incluso durante la respuesta.
Es un procedimiento que no se ha verificado ejecutándolo. Los resultados de abajo son lo esperado según la documentación y el código fuente.
La reproducción del spin dentro del motor está en el artículo 3/6, Livelock en el bucle de pausa de snapshot de WiredTiger. Aquí se ve, en un replica set de prueba de 3 miembros, cómo reacciona cada medio ante una escritura larga que pasa por interrupt points.
Insertar 1 millón de documentos (valor de ejemplo) en una colección.
use test
for (let b = 0; b < 100; b++)
db.store_inventory.insertMany(Array.from({ length: 10000 },
(_, j) => ({ _id: { productId: j % 1000, storeId: b * 10000 + j }, qty: 0 })))
En el shell A, ejecutar una escritura que tarde mucho.
db.store_inventory.updateMany({}, { $inc: { qty: 1 } })
En el shell B, buscar el opid y enviar killOp.
db.currentOp({ op: "update", ns: "test.store_inventory" }).inprog.map(o => o.opid)
db.killOp(<opid>)
Resultado esperado: killOp devuelve ok: 1. El updateMany del shell A termina con un error de interrupción en el siguiente interrupt point.
Volver a ejecutar el paso 2 y enviar stepDown desde el shell B.
db.adminCommand({ replSetStepDown: 60, secondaryCatchUpPeriodSecs: 10 })
Resultado esperado: en el log queda Starting to kill user operations. updateMany recibe la marca de terminación y termina. El primary pasa a secondary.
Volver a ejecutar el paso 2 y enviar SIGTERM (kill -15) a ese mongod.
Resultado esperado: las cuatro líneas de la ruta de apagado quedan en el log en orden y el proceso termina.
En un clúster fragmentado, probar los pasos 2·3 desde mongos. El resultado esperado es que la operación siga en el primary del shard.
- No se verificó hasta dónde recupera mongod con el journal y el último checkpoint tras
kill -9.- La conclusión de que el apagado por SIGTERM no termina es una inferencia hecha leyendo
db.cpp. No se vieron logs reales.- No se verificaron las rutas de stepDown·apagado de versiones posteriores a 4.2.