Este es el artículo 6 (el último) de la serie sobre la ruta de escritura en clústeres fragmentados de MongoDB.
Pregunta que responde este artículo: si se desvían las lecturas a los secondary con
secondaryPreferred, ¿las consultas siguen siendo seguras mientras el primary está detenido? La conclusión, por adelantado: no lo son. En un clúster fragmentado, el secondary recibe la lista de propiedad (metadatos de enrutamiento), que indica "qué datos pertenecen a qué shard", a través de escrituras de su propio primary. Por eso, un secondary reiniciado en un shard cuyo primary no puede escribir no logra recibir la lista de propiedad y rechaza las lecturas conStaleConfig.
Versión de referencia: MongoDB 4.2 (etiqueta de código fuenter4.2.12). No se verificaron las versiones más recientes.
Un clúster fragmentado divide la colección en chunks, que son intervalos de la shard key, y los reparte entre varios shards. El balancer mueve chunks para igualar la cantidad de datos entre shards. El orden de la migración es "copiar al shard nuevo → transferir la propiedad → borrar del shard antiguo".
Hasta que termina el borrado final, en el shard antiguo quedan documentos cuya propiedad ya se transfirió. A estos documentos se les llama documentos huérfanos (orphan). Por ejemplo, supongamos que el chunk que contiene el documento X de la colección store_inventory se movió de shardA a shardB. El estado en cada momento es el siguiente.
| Momento | shardA | shardB | Propietario |
|---|---|---|---|
| Antes de la migración | X | Ninguno | shardA |
| Tras copiar, antes de borrar | X (huérfano) | X | shardB |
| Tras borrar | Ninguno | X | shardB |
Si en el momento intermedio llega una consulta que pregunta a todos los shards, X está en los dos shards. Si cada shard lo devuelve sin comprobar la propiedad, X aparece dos veces en la respuesta. Por eso, antes de responder, el shard tiene que comprobar "¿este documento es mío?" y filtrar los documentos huérfanos.
Para filtrar, el nodo tiene que tener la lista de propiedad. La lista de propiedad es la información que dice "qué chunk pertenece a qué shard". El nodo guarda en memoria la lista de propiedad y su versión. Así se relaciona con la terminología de MongoDB.
De aquí en adelante, este artículo solo usa el nombre lista de propiedad.
Cuando mongos envía una lectura a un shard, adjunta la shard version que conoce. El nodo que la recibe la compara con su propia versión (collection_sharding_state.cpp:201-278). Las notas de la versión 3.6 dicen que no solo el primary, sino también los secondary mantienen los metadatos de chunks, para que las lecturas en secondary no devuelvan documentos huérfanos.
"Starting in 3.6, all members of a shard replica set, not just the primary, maintain the metadata regarding chunk metadata."
La comprobación tiene tres resultados.
| Estado del nodo | Qué hace el nodo |
|---|---|
| La versión coincide con la de mongos | Lee, filtra los documentos huérfanos y responde |
| La versión es distinta | Rechaza. El lado desactualizado recibe la lista de propiedad más reciente |
| No hay lista de propiedad (UNKNOWN) | Rechaza y va a buscar la lista de propiedad |
El error que se usa al rechazar es StaleConfig. El código es 13388 (error_codes.err:319). El código fuente llama UNKNOWN al estado sin lista de propiedad y lo define así (collection_sharding_runtime.h:85-86).
"all attempts to check for shard version match will fail with StaleConfig errors in order to trigger an update."
Es decir, StaleConfig es la señal de "mi lista de propiedad está desactualizada o no existe, así que la recibo de nuevo y lo intentamos otra vez". Normalmente la actualización termina enseguida y mongos reintenta, así que la aplicación no ve este error.
El primary y el secondary reciben la lista de propiedad por caminos distintos. El primary la recibe directamente del config server. El secondary no pregunta al config server: pasa por su propio primary. El orden en el código fuente de 4.2 es el siguiente (shard_server_catalog_cache_loader.cpp:268-290, 428-431, 585-591).
_flushRoutingTableCacheUpdates. El código fuente envía este comando con su nombre antiguo, forceRoutingTableRefresh. El nombre antiguo es un alias por compatibilidad con 3.6.0 (flush_routing_table_cache_updates_command.cpp:60-63). El límite de tiempo es de 30 segundos (Seconds{30}).config.cache.*.waitUntilOpTimeForRead). Cuando esa escritura llega por replicación, lee el config.cache.* local y rellena la lista de propiedad en memoria.Para que una lectura del secondary actualice la lista de propiedad, antes tienen que terminar una escritura del primary y su replicación. Es porque el paso 2 es una escritura.
El motivo por el que el secondary no pregunta directamente al config server no está en los comentarios del código fuente. Este artículo interpreta esta estructura como un diseño para alinear en el tiempo los datos replicados y la lista de propiedad. Si el secondary se enterara antes, solo por la lista de propiedad, de una migración de chunk que todavía no ha recibido por replicación, los documentos y la lista de propiedad no coincidirían. Si recibe ambos por el mismo flujo de replicación, ese desajuste no ocurre. Esta interpretación es una inferencia deducida del código fuente.
La lista de propiedad está en memoria. Cuando se reinicia mongod, la lista de propiedad en memoria empieza vacía (metadata_manager.cpp:246-252). El config.cache.* del disco sigue ahí, pero la lista de propiedad en memoria se rellena en la primera actualización. Por eso, un nodo reiniciado rechaza con StaleConfig la primera lectura de cada colección y actualiza la lista de propiedad.
Ahora superpongamos la situación en que el primary no puede escribir. El primary con el proceso vivo pero las escrituras detenidas se trató en el artículo 5 de esta serie. Si se comparan los secondary de ese shard en tres casos, queda así.
| secondary | Lista de propiedad en memoria | Resultado de la lectura |
|---|---|---|
| Sin reiniciar, colección que ya estaba leyendo | Existe | Sigue leyendo |
| Sin reiniciar, colección que lee por primera vez | No existe | Rechaza por fallo de actualización |
| Reiniciado | No existe para ninguna colección | Rechaza la primera lectura de cada colección por fallo de actualización |
El secondary reiniciado envía un comando al primary para actualizar. Pero el primary no puede escribir en config.cache.*. El secondary choca con el límite de 30 segundos o falla la actualización mientras espera la replicación. Como la lista de propiedad sigue en UNKNOWN, la siguiente lectura sigue el mismo camino. Hasta que haya un primary sano, este nodo no puede procesar ni una sola lectura de ese shard.
Una readPreference como secondaryPreferred es una regla para elegir a qué nodo enviar la lectura. No decide de dónde recibe ese nodo la lista de propiedad. Aunque las lecturas se desvíen a los secondary, la actualización de la lista de propiedad sigue atada a las escrituras del primary.
StaleConfig es un error que se intercambian mongod y mongos. Cuando mongos recibe este error, espera la actualización y reintenta. Por eso puede que la aplicación no vea StaleConfig tal cual. Mientras mongos reintenta, la petición de la aplicación espera la respuesta. Desde la aplicación puede verse solo como un timeout de conexión o un timeout de socket.
Las señales que hay que buscar en esta situación son las siguientes.
forceRoutingTableRefresh o de _flushRoutingTableCacheUpdates.StaleConfig (13388).this shard no longer contains chunks for <ns> (collection_sharding_state.cpp, inferencia basada en el código fuente).Si se lee con readConcern: "available", el nodo se salta la comprobación de shard version. El invariant de collection_sharding_state.cpp:210-212 comprueba esta premisa. Aunque no haya lista de propiedad, el nodo responde a la lectura.
El precio son los documentos huérfanos. Las notas de la versión 3.6 dicen que la lista de propiedad del secondary es el mecanismo que evita los documentos huérfanos y que con "available" no existe esa protección. En el momento intermedio de la tabla anterior, el documento X podría aparecer dos veces.
// mongos에 연결한 셸 예시
db.store_inventory.find({ "_id.productId": 1001 })
.readPref("secondaryPreferred")
.readConcern("available")
Usarlo o no se decide según la exactitud que necesite el resultado de la consulta. Se puede elegir para pantallas en las que se toleran duplicados o documentos ya migrados (por ejemplo, una indicación aproximada de existencias). No se usa en consultas en las que el número de registros tiene que cuadrar, como pagos·liquidaciones.
readConcern: "available" solo en consultas que puedan aceptar documentos huérfanos.Trampa: reiniciar un secondary es una medida habitual de "a ver si así revive". En esta situación, convierte un secondary que estaba leyendo en uno que no puede leer. Al reiniciar, la lista de propiedad en memoria se vacía. Para rellenar esa lista de propiedad hace falta una escritura del primary detenido.
Es un procedimiento que no se ha verificado ejecutándolo. El procedimiento y los resultados de abajo son lo esperado con base en el código fuente.
Lo que hace falta son los binarios de MongoDB 4.2. El operador levanta en local 1 replica set de config server, 2 shards (cada uno un replica set de 3 nodos) y 1 mongos.
store_inventory con la shard key hashed _id e insertar documentos.sh.enableSharding("demo")
sh.shardCollection("demo.store_inventory", { _id: "hashed" })
for (let p = 1; p <= 100; p++)
for (let s = 1; s <= 20; s++)
db.getSiblingDB("demo").store_inventory.insertOne({ _id: { productId: p, storeId: s }, qty: 10 })
{ node: "a1" }, { node: "a2" }). En el mismo reconfig, cambiar a 0 el priority de ambos secondary. Es una configuración para que no haya elección cuando se detenga el primary.find(...).explain().db.store_inventory.find({ _id: { productId: 1, storeId: 1 } })
.readPref("secondary", [{ node: "a1" }]).maxTimeMS(60000)
kill -STOP <pid> al PID del primary de shardA.kill -CONT <pid> y volver a enviar la consulta a a1.El resultado esperado es el siguiente.
| Paso | a1 (reiniciado) | a2 (sin reiniciar) |
|---|---|---|
| Paso 7 | Tarda en responder y falla. Queda un fallo de actualización en el log de a1 y StaleConfig en el log de mongos |
Responde enseguida |
| Paso 8 | Responde | Responde |
Límites de esta reproducción:
SIGSTOPdetiene todo el proceso. Como también se detiene la respuesta al heartbeat, el replica set considera el primary "sin contacto". En cambio, un primary cuyo motor cayó en un livelock responde al heartbeat y solo deja de poder escribir (artículo 5). Con priority 0 se evitó la elección, pero desde el paso en que a1 busca el primary ya difiere de la situación real. Por eso, el error del paso 7 puede salir no por el límite de 30 segundos sino de la familia "no se encuentra el primary". Sirve para ver la diferencia de que solo se bloquea el nodo reiniciado.
- No se pudo confirmar si el error que mongos devuelve a la aplicación en esta situación es
StaleConfigo de la familia de los timeouts. "Desde la aplicación puede verse como un timeout" es una afirmación general.- El motivo por el que el secondary no pregunta directamente al config server no está escrito en el código fuente. La explicación anterior es una inferencia.
- No se verificó por separado si una colección no fragmentada sigue el mismo camino justo después de un reinicio.
- No se verificó si en versiones posteriores a 4.2 cambió la ruta de actualización del secondary.
- «Reproducirlo uno mismo» no se ejecutó.