Este es el artículo 2/6 de la serie sobre la ruta de escritura en clústeres fragmentados de MongoDB.
Preguntas que responde este artículo: ¿una escritura que sufrió un timeout de socket se detiene en el servidor? Si están activados a la vez retryWrites del driver y los reintentos de la aplicación, ¿cuántas veces llega una escritura al servidor? ¿Cuántas se ejecutan a la vez en el servidor? ¿Cuánto de este solapamiento evita maxTimeMS?
Versión de referencia: MongoDB 4.2 (código fuente en la etiquetar4.2.12, documentación oficial de 4.2), driver Java de MongoDBr5.4.0, especificaciones de retryable writes·driver sessions (edición obtenida el 2026-11-05). No se ha verificado el comportamiento de las versiones de servidor más recientes.
Los reintentos de escritura ocurren por separado en dos capas. Ante un error de red, el driver vuelve a enviar la misma escritura una vez más según las reglas de retryable writes. La aplicación (por ejemplo, un consumidor de Spring Kafka) recibe la excepción que el driver termina lanzando y vuelve a ejecutar la escritura desde el principio. Ambos números se multiplican.
socketTimeoutMS es el tiempo tras el cual el cliente deja de esperar la respuesta. El servidor no conoce este timeout. En MongoDB 4.2, aunque se corte la conexión del cliente, insert·update·delete·findAndModify siguen ejecutándose en el servidor.
El reenvío del driver lleva el mismo id de sesión (lsid) y el mismo número de transacción (txnNumber) que la petición original. El servidor los trata como la misma escritura y la ejecuta una sola vez. El reintento de la aplicación es una escritura nueva, con una sesión nueva o un número nuevo. En este artículo llamamos copia a cada ejecución separada de la misma escritura en el motor del servidor. Los envíos al servidor son como máximo el doble del número de ejecuciones de la aplicación. Las copias que se ejecutan solapadas son como máximo tantas como ejecuciones de la aplicación.
Ambos ajustes son límites de tiempo, pero quien sostiene el reloj es distinto.
| Elemento | socketTimeoutMS |
maxTimeMS |
|---|---|---|
| Quién sostiene el reloj | El cliente (driver) | El servidor |
| Cuando se agota el tiempo | El driver deja de leer el socket y lanza una excepción | El servidor detiene la operación en el siguiente punto de comprobación de interrupción |
| La operación en el servidor | No se entera. Sigue ejecutándose | Termina con el error MaxTimeMSExpired |
| Dónde se configura | Cadena de conexión, MongoClientSettings |
Argumento que se añade a cada comando |
El documento de 4.2 "Terminate Running Operations" describe maxTimeMS así.
When the operation reaches the specified time limit, MongoDB interrupts the operation at the next interrupt point.
Un interrupt point (punto de comprobación de interrupción) es una posición del código en la que la operación comprueba por sí misma si hay una marca que le pide detenerse. La operación solo se detiene cuando llega a esa posición. Esta forma de funcionar se llama interrupción cooperativa. Se trata en detalle en el artículo 4/6.
El servidor lee el argumento general maxTimeMS sin importar el tipo de comando y fija un plazo para la operación (src/mongo/db/service_entry_point_common.cpp:817-830 (r4.2.12)). Por eso el plazo se aplica también si se añade maxTimeMS a un comando insert.
Desde MongoDB 4.2, algunos comandos reciben una marca de terminación (la misma marca que killOp) cuando se corta el cliente. La lista de la sección "Client Disconnection" de las notas de la versión 4.2 tiene 10 comandos. Son comandos de lectura como find, aggregate (cuando no hay $out·$merge), count y distinct, y comandos de autenticación y de listado.
insert·update·delete·findAndModify no están en la lista. En el código fuente, los únicos archivos que llaman a handleMarkKillOnClientDisconnect, la función que pone la marca de terminación al cortarse la conexión, son los de esos comandos de lectura. El archivo de los comandos de escritura, write_commands.cpp, no llama a esta función.
Las lecturas que reciben la marca de terminación también se detienen de forma cooperativa. Si la operación no llega a un punto de comprobación de interrupción, la lectura también sigue ejecutándose.
La primera frase del documento oficial "Retryable Writes" fija el alcance de retryable writes.
Retryable writes let drivers retry specific write operations once after network errors or if they cannot find a healthy primary in the replica set or sharded cluster.
Los drivers compatibles con MongoDB 4.2 o superior activan esta función por defecto. Para desactivarla se pone retryWrites=false en la cadena de conexión. Se reintentan las escrituras que cambian un solo documento.
| Categoría | Ejemplo |
|---|---|
| Se reintenta | insertOne, insertMany, updateOne, replaceOne, deleteOne, findOneAndUpdate, etc. |
| Se reintenta | bulkWrite que solo contiene las escrituras de un documento anteriores (tanto ordered como unordered) |
| No se reintenta | updateMany, deleteMany, un bulk que contenga al menos un update multi: true o un delete limit: 0 |
| No se reintenta | Escrituras con write concern {w: 0} |
El timeout de lectura del socket también es un error que se reintenta. En el driver Java r5.4.0, MongoSocketReadTimeoutException hereda de MongoSocketException. CommandOperationHelper.isRetryableException devuelve true si es MongoSocketException. La especificación de retryable writes también establece que se añada la etiqueta RetryableWriteError a los errores de red.
La especificación de retryable writes fija así la regla del reenvío.
When retrying a write command, drivers MUST resend the command with the same transaction ID.
El ID de transacción es el par formado por lsid (id de sesión del servidor) y txnNumber (un número que crece dentro de esa sesión). Cuando mongod recibe una petición con txnNumber, primero hace checkout de esa sesión. El checkout reserva la sesión para que la use una sola operación. El comentario de service_entry_point_common.cpp:397-399 (r4.2.12) dice "only requests with a transaction number will check out the session".
Como la petición original y el reenvío compiten por la misma sesión, el resultado es uno de dos.
En ambos casos, la copia que realmente escribe es una sola. El reenvío llega al servidor, pero no genera más trabajo.
Si también falla el reenvío del driver, la excepción sube a la aplicación. Si la aplicación vuelve a ejecutar la misma escritura, el driver la trata como una escritura que ve por primera vez.
lsid.txnNumber sube a un número nuevo.Por eso, si la operación original sigue ejecutándose, las dos operaciones se ejecutan en paralelo dentro del motor del servidor.
Por ejemplo, DefaultErrorHandler(FixedBackOff(0L, 3L)) de Spring Kafka reintenta 3 veces con intervalo 0. A la primera ejecución se suman 3 reintentos, así que un registro se ejecuta 4 veces. Un DefaultErrorHandler sin ninguna configuración tiene por defecto FixedBackOff(0L, 9), así que un registro se ejecuta 10 veces.
| Elemento | retryWrites del driver | Reintento de la aplicación (ejemplo con Spring Kafka) |
|---|---|---|
| Configuración | retryWrites=true (valor por defecto en drivers compatibles con 4.2) |
BackOff de DefaultErrorHandler |
| Cuándo | Error de red, cuando no encuentra el primary | Cuando el listener lanza una excepción |
| Cuántas veces | 1 vez más (cuando no se usa timeoutMS) |
Tantas como reintentos configurados |
| Qué se repite | El comando que falló (si es un bulk, ese batch) | Todo el procesamiento del listener |
| Intervalo | En cuanto vuelve a elegir servidor | La espera que fija BackOff |
| Para el servidor | El mismo lsid·txnNumber. La misma escritura |
Un lsid nuevo o un txnNumber nuevo. Una escritura nueva |
Lo siguiente es un ejemplo hipotético. Los números también son valores para la explicación.
store_inventory.socketTimeoutMS es 30 segundos y retryWrites tiene el valor por defecto (activado).DefaultErrorHandler(FixedBackOff(0L, 3L)) y, si todo falla, envía el registro a la DLQ (topic de dead letter).| Momento | En el cliente | En el servidor (supuesto) |
|---|---|---|
| 0 s | 1.ª ejecución de la aplicación. Envía el bulk (sesión A, número 1) | La copia 1 empieza a ejecutarse |
| 30 s | Timeout de socket. El driver reenvía el mismo bulk (A, 1) | El reenvío espera la sesión A. 1 copia |
| 60 s | También el reenvío llega al timeout. La excepción sube hasta la aplicación. 2.ª ejecución de la aplicación (B, 1) | La copia 2 empieza a ejecutarse. La copia 1 también sigue |
| 90 s | Reenvío del driver (B, 1) | Espera la sesión B. 2 copias |
| 120 s | 3.ª ejecución de la aplicación (C, 1) | 3 copias |
| 180 s | 4.ª ejecución de la aplicación (D, 1) | 4 copias |
| 240 s | También el reenvío de la 4.ª ejecución llega al timeout. Reintentos agotados. Se envía a la DLQ | Las 4 copias siguen ejecutándose |
En el segundo 240, cuando la aplicación renuncia al registro, en el servidor quedan 4 copias del mismo bulk y 4 reenvíos esperando su sesión. Cuando la operación del servidor se desbloquea, se ejecutan las 4 copias.
서버로 간 전송 최대 = 애플리케이션 실행 수 × 2
엔진에서 겹쳐 도는 사본 최대 = 애플리케이션 실행 수
En el ejemplo anterior, la aplicación se ejecuta 4 veces. Los envíos son como máximo 8 y las copias como máximo 4. Con el valor por defecto FixedBackOff(0L, 9), son 20 envíos y 10 copias.
En dos casos hay menos reintentos y no se llega al máximo.
Este cálculo es para un solo registro. Si varios consumidores caen en la misma situación, el número de copias se vuelve a multiplicar por el número de consumidores.
Si maxTimeMS es menor que el timeout de socket, el servidor detiene la operación antes de que el cliente desista y devuelve MaxTimeMSExpired. Este error no es un error de red, así que el driver no reenvía. El solapamiento de reintentos se reduce en esa medida.
Hay tres fundamentos para afirmar que MaxTimeMSExpired (código 50) no se reintenta.
ExceededTimeLimit (262) que aparece en la tabla es otro código.RetryableWriteError a errores de la categoría RetriableError (src/mongo/db/error_labels.cpp, r4.4.0). En el error_codes.yml de la misma etiqueta, las categorías de MaxTimeMSExpired son Interruption y ExceededTimeLimitError.RETRYABLE_ERROR_CODES del driver Java r5.4.0 no incluye el 50.maxTimeMS también limpia las operaciones en cola. MongoDB limita con tickets (pases de entrada) el número de escrituras que entran a la vez en el motor. La espera de un ticket o del lock global se puede interrumpir, así que, cuando pasa el plazo, la operación en espera sale.
Las operaciones que dentro del motor no llegan a un punto de comprobación de interrupción no se pueden cortar ni con maxTimeMS. El bucle de WiredTiger del artículo 3/6 es un ejemplo. En ese caso el servidor no cumple el plazo, así que primero salta el timeout de socket y la multiplicación anterior ocurre tal cual.
En Spring Data MongoDB solo se puede dar maxTimeMS a las consultas, con Query.maxTime(Duration). BulkOperations no tiene ninguna opción para pasarlo. En el código fuente de BulkOperations y DefaultBulkOperations de Spring Data MongoDB 4.4.0, maxTime no aparece ni una vez.
CSOT (Client-Side Operation Timeout, timeoutMS) del driver Java es un límite de tiempo que abarca también la ejecución en el servidor. La documentación oficial dice que, si se configura timeoutMS, la escritura puede reintentarse varias veces. En ese caso no se cumple el cálculo del doble anterior.
currentOp del servidor se ven más escrituras sobre la misma colección que ejecuciones de la aplicación. Si se agrupan por lsid y txnNumber, el número de grupos es el número de copias.FixedBackOff con intervalo 0 apila copias de inmediato. Aumentar el intervalo con ExponentialBackOffWithMaxRetries y, si hay muchos consumidores, añadir jitter (variación aleatoria). Aunque haya intervalo, si el servidor está detenido las copias se acumulan. El intervalo frena la velocidad de acumulación y da tiempo al servidor para recuperarse.maxTimeMS por debajo del timeout de socket. Por ejemplo, maxTimeMS de 20 segundos y socketTimeoutMS de 30 segundos. Si el servidor se detiene primero, no hay reenvío del driver.max.poll.interval.ms (valor por defecto de Kafka: 300000ms). Si lo supera, el consumidor sale del grupo y falla el commit de offsets (coreano). El BackOffHandler por defecto de Spring Kafka duerme el hilo del consumidor durante la espera. La fórmula de referencia, con margen, es la siguiente.max.poll.records × (한 번 실행 최대 시간 × 실행 수 + 대기 합) < max.poll.interval.ms
El tiempo máximo de una ejecución se toma como timeout de socket × 2. En el ejemplo anterior, un registro consume 240 segundos en 4 ejecuciones._id da el mismo resultado por mucho que se solape. Con un insert cuyo _id genera el driver cada vez, cada reintento de la aplicación añade un documento más.updateMany·deleteMany. Si fallan, no se sabe si se aplicaron solo en parte, así que hay que analizar aparte si el reintento de la aplicación es seguro.DefaultErrorHandler.addNotRetryableExceptions(...).
- No interpretar el timeout de socket como una cancelación. La escritura de la que desistió el cliente sigue ejecutándose en el servidor.
- Desactivar retryWrites no elimina el solapamiento. El número de copias en el servidor lo fija el número de ejecuciones de la aplicación. Si se desactiva retryWrites, solo se reducen a la mitad los envíos al servidor.
- retryWrites no protege
deleteMany. Ante un error de red, el driver lanza la excepción sin reenviar, y no se puede saber cuántos documentos se borraron.- No usar
maxTimeMScomo un plazo infalible. Las operaciones dentro del motor que no llegan a un punto de comprobación de interrupción siguen ejecutándose aunque pase el plazo.
Es un procedimiento que no se ha verificado ejecutándolo. Los resultados de abajo son lo esperado tras leer el código fuente y las especificaciones.
Hay que preparar dos cosas.
--setParameter enableTestCommands=1. En 4.2.12, configureFailPoint se registra como comando de prueba (MONGO_REGISTER_TEST_COMMAND en src/mongo/db/commands/fail_point_cmd.cpp).El procedimiento es el siguiente.
failCommand. Retiene durante 60 segundos los insert de las conexiones cuyo appName sea retry-lab.db.adminCommand({
configureFailPoint: "failCommand",
mode: { times: 8 },
data: { failCommands: ["insert"], blockConnection: true,
blockTimeMS: 60000, appName: "retry-lab" }
})
socketTimeoutMS=5000&retryWrites=true&appName=retry-lab._id.for (let i = 1; i <= 4; i++) {
try { db.retry_lab.insertOne({ productId: 1, storeId: 7, attempt: i }); break; }
catch (e) { print(`attempt ${i}: ${e.name}`); }
}
db.getSiblingDB("admin").aggregate([
{ $currentOp: { allUsers: true } },
{ $match: { "command.insert": "retry_lab" } },
{ $group: { _id: { lsid: "$command.lsid.id", txn: "$command.txnNumber" },
n: { $sum: 1 } } }
])
db.retry_lab.countDocuments({ productId: 1 }).Si el driver del shell reenvía tras el timeout de socket, el resultado esperado es el siguiente.
attempt 1 a attempt 4, uno cada 10 segundos aproximadamente. Cada ejecución consume 5 segundos del envío original y 5 del reenvío.n es 2. El fail point retiene el comando en service_entry_point_common.cpp:715. El checkout de la sesión ocurre después, dentro de la llamada a runCommandImpl de la línea 912. Por eso, en este experimento, el reenvío tampoco espera la sesión: queda retenido en el fail point junto con la petición original._id distintos. Los 4 reenvíos del driver no añaden documentos.
- Código fuente del servidor para la espera de sesión: la afirmación de que el reenvío espera a que termine la petición original se basa en la especificación de driver sessions. No se contrastó directamente con
session_catalog.cppdel servidor 4.2.- Ruta en el clúster fragmentado: no se siguió en el código fuente la ruta por la que mongos pasa
lsidytxnNumberal shard y el mongod del shard hace checkout de la sesión. Lo que este artículo dice sobre el comportamiento del servidor se refiere a mongod.- Alcance de CSOT: no se verificó en qué versión se introdujo
timeoutMSdel driver Java, a qué partes de las escrituras bulk se aplica ni hasta cuántas veces se reintenta en ese caso.- Soporte de jitter: no se verificó, versión por versión, si la familia
ExponentialBackOffde Spring admite jitter.- Driver del procedimiento de reproducción: no se verificó en el código fuente del driver Node.js que usa mongosh si reenvía la escritura tras el timeout de socket. Si no reenvía, el
ndel paso 4 es 1.- Ubicación de los campos en
$currentOp: no se confirmó ejecutándolo silsidytxnNumberaparecen tal cual dentro del documentocommand.- Versiones de servidor más recientes: no se verificó si después de 4.2 cambió la lista de comandos que reciben la marca de terminación al cortarse la conexión. 4.2 es una versión sin soporte.