Módulo 6: Migrar datos sin downtime
Dual-write: escribir a los dos almacenes
Descripción
La lección 2 te dio el marco —expand-contract, agregar antes de quitar—. Esta lección pone en movimiento su primera pieza sobre los datos reales: el dual-write. La idea es simple y es puro "expand": mientras dura la transición, cada escritura de la app va a ambos almacenes. Escribes al viejo (old_db) como siempre, y además escribes al nuevo (new_db), traduciendo la fila con el anti-corruption layer. No quitas el destino viejo; agregas el nuevo al lado. Así, todo lo que se escribe de ahora en adelante queda reflejado en los dos, y el almacén nuevo empieza a mantenerse al día con la realidad sin que nadie note el cambio.
El dual-write resuelve un problema muy concreto: el frente móvil. Mientras migras, el sistema no se detiene —entran productos, cambian precios, se ajusta el stock—. Si solo copiaras el estado actual del viejo al nuevo y luego movieras las lecturas, cada escritura ocurrida durante la migración quedaría fuera del nuevo, y el nuevo serviría datos rancios desde el primer día. El dual-write cierra esa fuga hacia el futuro: desde el instante en que se enciende, ninguna escritura nueva se le escapa al almacén nuevo.
Pero el dual-write tiene un límite exacto, y entenderlo es la mitad de esta lección: solo cubre lo que pasa desde que se enciende. Lo que ya estaba en el viejo antes —el histórico acumulado en años— el dual-write ni lo mira. Encender el dual-write no copia el pasado; solo garantiza el futuro. Ese hueco histórico es real y es grande, y cerrarlo es el trabajo de la lección siguiente (el backfill). Por eso el dual-write y el backfill son una pareja: uno cubre el frente (lo de ahora), el otro cubre la retaguardia (lo de antes), y juntos dejan el nuevo completo.
Conexión con el módulo. Esta lección es el "expand" de las escrituras: agregar el segundo destino sin quitar el primero (el marco de la lección 2, instanciado). La lección 4 (backfill) llena el hueco histórico que el dual-write deja abierto —y ahí verás por qué el orden es primero dual-write, después backfill—. La lección 5 (parallel-run) comprobará que lo que el dual-write y el backfill escribieron en el nuevo de verdad cuadra con el viejo. Fíjate en la frontera: aquí el dual-write es una función en memoria que escribe a dos diccionarios; en producción, escribir a dos almacenes de forma consistente (¿qué pasa si el segundo write falla?) es un problema de fondo que las herramientas de CDC del ecosistema Data Engineering resuelven de otra manera —leyendo el log de transacciones del primario en vez de escribir dos veces desde la app—. Aquí aprendes el patrón; la profundización explica esa alternativa.
Una analogía: el reenvío de correo al mudarte
Te mudas de casa. Sigues recibiendo correo —cartas del banco, recibos, revistas— y no puedes darte el lujo de perder ninguno mientras haces el cambio. La oficina de correos te ofrece un servicio: el reenvío. A partir del día que lo activas, cada carta nueva que llegue a tu dirección vieja se copia y se manda también a la nueva. Desde ese instante, no importa dónde la envíen, la carta te llega a la casa nueva.
Fíjate en las dos cosas que el reenvío hace y no hace, porque son exactamente las del dual-write:
Lo que sí hace: cubre todo el correo nuevo, desde que lo activas. Una vez encendido el reenvío, cualquier carta que alguien te mande —aunque la dirija a la casa vieja— aparece también en la nueva. El "frente" de tu correspondencia queda cubierto: nada nuevo se pierde. Y no dejaste de recibir en la vieja: sigues teniendo las dos direcciones activas, por si acaso. Es aditivo, no destructivo.
Lo que no hace: no mueve las cartas que ya estaban en tus cajones. El reenvío empieza a operar el día que lo activas y solo sobre el correo que llega después. Todas las cartas que ya recibiste y guardaste en la casa vieja —tus años de correspondencia archivada— siguen en la casa vieja. El reenvío no entra a tus cajones a copiarlas; solo mira hacia adelante. Si quieres tener ese histórico en la casa nueva, tienes que llevarlo tú, en cajas —y eso es el backfill de la lección 4—.
Y hay un detalle que corresponde al anti-corruption layer: si la casa nueva usa un formato de dirección distinto (otro código postal, otro sistema de numeración), la oficina de correos traduce la dirección al reenviar. La carta sale con el formato viejo y llega con el formato nuevo. En el dual-write, esa traducción es el ACL: la escritura llega en el modelo viejo (sku, price_cents) y se guarda en el nuevo (id, price_usd).
Así que el dual-write es el reenvío de correo: enciéndelo y, desde ese instante, cada escritura nueva cae en los dos almacenes, traducida al formato de cada uno. Pero lo que ya estaba archivado antes sigue solo en el viejo hasta que lo lleves en cajas. Vamos a ver exactamente eso, ejecutado.
Ejemplo trabajado: el dual-write encendiéndose a mitad del flujo
Vamos a correr un flujo de escrituras donde el dual_write se enciende a la mitad, para ver con claridad qué cae dónde. Las tres primeras escrituras ocurren con el dual-write apagado (caen solo en el viejo); a partir de la cuarta, el dual-write está encendido (caen en los dos). Incluimos a propósito un update de un producto que ya existía (ssd-1tb baja de precio) para ver que el dual-write no solo sincroniza altas, sino también cambios.
old_db = {}
new_db = {}
dual_write_on = False
def to_new_model(old_row):
return {
"id": old_row["sku"],
"title": old_row["name"],
"price_usd": old_row["price_cents"] / 100,
"in_stock": old_row["stock"] > 0,
}
# --- La app escribe SIEMPRE al viejo; y al nuevo SOLO si dual_write esta ON. ---
def write_product(row):
old_db[row["sku"]] = row
landed = "old"
if dual_write_on:
new_db[row["sku"]] = to_new_model(row)
landed = "old + new"
return landed
# --- Un flujo de escrituras. Las 3 primeras ocurren ANTES de dual_write. ---
events = [
("ssd-1tb", "SSD 1TB", 8999, 12),
("usb-c-hub", "USB-C Hub", 3499, 40),
("webcam", "Webcam HD", 5999, 0),
# --- aqui se enciende dual_write ---
("hdmi-cable", "HDMI 2m", 1299, 30),
("ssd-1tb", "SSD 1TB", 8499, 12), # update de precio: 89.99 -> 84.99
("kbd-mech", "Mech Kbd", 7999, 5),
]
print("Flujo de escrituras: dual_write se enciende antes del write #4\n")
print(f"{'#':>2} {'dual_write':<11}{'sku':<12}{'cayo en':<12}")
print("-" * 40)
for i, (sku, name, cents, stock) in enumerate(events, start=1):
if i == 4:
dual_write_on = True # <- se enciende dual_write
landed = write_product({"sku": sku, "name": name, "price_cents": cents, "stock": stock})
flag = "ON" if dual_write_on else "OFF"
print(f"{i:>2} {flag:<11}{sku:<12}{landed:<12}")
print("-" * 40)
only_in_old = [sku for sku in old_db if sku not in new_db]
in_both = [sku for sku in old_db if sku in new_db]
print(f"\n Filas solo en old (historicas, pre-dual-write): {len(only_in_old)} -> {only_in_old}")
print(f" Filas en ambos (escritas con dual_write ON): {len(in_both)} -> {in_both}")
print("\n Fijate en 'ssd-1tb': su update (write #5, con dual_write ON) SI llego")
print(" al nuevo -> new['ssd-1tb'] = 84.99. dual_write sincroniza lo de AHORA.")
print(f" new['ssd-1tb'].price_usd = {new_db['ssd-1tb']['price_usd']}")
print("\n Pero usb-c-hub y webcam (escritas ANTES) siguen SOLO en el viejo.")
print(" dual_write no mira atras: cerrar ese hueco historico es el backfill (L4).")
Qué esperar. Al correr el archivo, la salida es exactamente esta:
Flujo de escrituras: dual_write se enciende antes del write #4
# dual_write sku cayo en
----------------------------------------
1 OFF ssd-1tb old
2 OFF usb-c-hub old
3 OFF webcam old
4 ON hdmi-cable old + new
5 ON ssd-1tb old + new
6 ON kbd-mech old + new
----------------------------------------
Filas solo en old (historicas, pre-dual-write): 2 -> ['usb-c-hub', 'webcam']
Filas en ambos (escritas con dual_write ON): 3 -> ['ssd-1tb', 'hdmi-cable', 'kbd-mech']
Fijate en 'ssd-1tb': su update (write #5, con dual_write ON) SI llego
al nuevo -> new['ssd-1tb'] = 84.99. dual_write sincroniza lo de AHORA.
new['ssd-1tb'].price_usd = 84.99
Pero usb-c-hub y webcam (escritas ANTES) siguen SOLO en el viejo.
dual_write no mira atras: cerrar ese hueco historico es el backfill (L4).
Lee la tabla y las dos listas del final, porque ahí está la lección entera.
La columna cayo en cuenta la historia. Las escrituras 1, 2 y 3 ocurrieron con el dual_write en OFF: cayeron solo en el viejo (old). La escritura 4 en adelante ocurrió con el dual_write en ON: cada una cayó en los dos (old + new). El punto de encendido divide el flujo en dos: antes, solo el viejo; después, ambos. Eso es el reenvío activándose: todo lo que llega después se copia a la casa nueva.
Fíjate en la escritura 5, el update de ssd-1tb. ssd-1tb ya se había escrito en la escritura 1 (con el dual-write apagado, a 89.99), pero su actualización de precio a 84.99 ocurrió con el dual-write ya encendido. Resultado: ese cambio sí llegó al nuevo —new['ssd-1tb'].price_usd = 84.99—. El dual-write no distingue entre altas y cambios: cualquier escritura, mientras esté encendido, cae en los dos. Sincroniza el estado presente.
Ahora lee las dos listas del final, que son el corazón de la lección:
- Filas solo en el viejo:
['usb-c-hub', 'webcam']. Son las que se escribieron en las escrituras 2 y 3, antes de encender el dual-write. Nunca llegaron al nuevo, y el dual-write —que solo mira hacia adelante— no las va a llevar. Son el histórico que quedó atrás: el correo en tus cajones que el reenvío no toca. - Filas en ambos:
['ssd-1tb', 'hdmi-cable', 'kbd-mech'].ssd-1tbllegó al nuevo por su update (escritura 5);hdmi-cableykbd-mechpor ser altas nuevas (escrituras 4 y 6). Todas escritas con el dual-write encendido.
La conclusión, en una frase: el dual-write cierra la fuga hacia el futuro, pero deja abierto el hueco del pasado. usb-c-hub y webcam demuestran que encender el dual-write no basta —el nuevo aún no tiene todo—. Llenar ese hueco es el backfill, la lección 4. Y hay una razón por la que el dual-write va primero y el backfill después: verás en la lección 4 que ese orden es lo que garantiza que no se pierda ninguna escritura en la transición.
Profundización: qué garantiza el dual-write y qué no
El dual-write tiene una garantía precisa y un par de sutilezas que conviene ver de frente, porque en producción son lo que decide si funciona.
La garantía: cero fugas hacia adelante. Desde el instante en que el dual-write está encendido, no existe una escritura que caiga en el viejo y no en el nuevo. El "frente móvil" de los datos queda cubierto. Esto es lo que hace posible que, más tarde, el nuevo pueda tomar el relevo: no se está quedando atrás mientras migras.
La no-garantía: el histórico. Ya lo viste: el dual-write no copia lo anterior. Es importante no confundir "el dual-write está encendido" con "el nuevo está completo". Lo primero es cierto desde que lo enciendes; lo segundo solo tras el backfill. Confundirlos lleva a hacer el read-switch demasiado pronto, con el nuevo lleno de huecos históricos.
El orden de las dos escrituras. En el ejemplo, write_product escribe primero al viejo y después al nuevo. Ese orden importa: el viejo es la fuente de verdad durante toda la transición (es de donde aún se lee), así que su escritura es la que no puede fallar. La escritura al nuevo es "de más" por ahora —el nuevo aún no sirve lecturas—, así que si de las dos una va a ir primero, va la del viejo. Se escribe a la verdad primero, a la copia después.
El problema difícil: ¿qué pasa si la segunda escritura falla? Aquí está la parte incómoda del dual-write, la que el ejemplo en memoria esconde. En producción, escribir a dos almacenes no es atómico: puede que el write al viejo tenga éxito y el write al nuevo falle (una caída de red, un timeout). Ahora el viejo tiene el dato y el nuevo no —una inconsistencia—. ¿Qué haces? Tienes opciones, todas con costos: reintentar el write al nuevo, encolarlo para después, o registrar la falla y dejar que el parallel-run la detecte más tarde. Ninguna es gratis. Esta es precisamente la razón por la que, a escala, muchos equipos no hacen dual-write desde la app, sino que usan change data capture (CDC): en vez de que la app escriba dos veces, una herramienta lee el log de transacciones del almacén viejo (donde ya quedaron registradas todas las escrituras que sí tuvieron éxito) y lo reproduce en el nuevo. Así solo hay una escritura desde la app (al viejo), y la copia al nuevo se deriva de un registro confiable. El dual-write desde la app es más simple de entender y de simular —por eso lo usamos aquí—, pero su fragilidad ante fallos parciales es real, y el CDC es la respuesta de producción. Esa herramienta y su operación son del ecosistema Data Engineering; el patrón que implementan es el que ejecutas en esta lección.
El parallel-run como red bajo el dual-write. Justamente porque el dual-write puede fallar parcialmente (o el ACL puede traducir mal), no se confía en él a ciegas. El parallel-run de la lección 5 vuelve a leer de ambos y compara: si una escritura al nuevo se perdió o se guardó mal, la comparación lo delata. El dual-write hace el trabajo; el parallel-run lo verifica. Nunca uno sin el otro.
Errores comunes
Creer que encender el dual-write completa el almacén nuevo. Qué pasa: el equipo enciende el dual-write, ve que las escrituras nuevas caen en ambos, y asume que el nuevo "ya tiene los datos", listo para leer de él. Por qué pasa: es fácil ver el flujo de escrituras nuevas llenando el nuevo y olvidar el enorme histórico que nunca pasó por ahí. Cómo detectarlo: en el nuevo faltan todos los registros que no han sido escritos desde que se encendió el dual-write —para un catálogo, los productos que nadie editó recientemente; para pedidos, todo lo anterior a la fecha de encendido—. Cómo corregirlo: recuerda la pareja. El dual-write cubre el frente; el backfill cubre el histórico. El nuevo está completo solo cuando corrieron los dos, y la prueba de que está completo es el parallel-run verde (lección 5), no "el dual-write lleva encendido un rato". Encender el dual-write es el principio del expand, no su final.
Ignorar los fallos parciales del segundo write. Qué pasa: el dual-write se implementa como "escribe al viejo, escribe al nuevo", sin considerar qué pasa si el segundo falla; cuando falla en producción (red, timeout), el nuevo queda con un dato menos que el viejo y nadie se entera. Por qué pasa: en un ejemplo en memoria las dos escrituras nunca fallan, así que el problema es invisible hasta producción. Cómo detectarlo: el parallel-run empieza a reportar discrepancias que "aparecen solas" —filas donde el viejo tiene el valor nuevo y el nuevo tiene el viejo, porque su update se perdió—. Cómo corregirlo: decide explícitamente la política ante un fallo del segundo write (reintentar, encolar, o registrar para reconciliar), y —sobre todo— no confíes en el dual-write como única fuente de sincronización: el parallel-run está ahí para atrapar justo estas fugas. A escala, considera CDC en vez de dual-write desde la app, precisamente para eliminar el fallo parcial (una sola escritura, la copia derivada del log).
Escribir al nuevo primero y al viejo después. Qué pasa: por descuido, el dual-write escribe al almacén nuevo antes que al viejo; si el write al viejo falla, el nuevo tiene un dato que el viejo —la fuente de verdad, de donde aún se lee— no tiene. Por qué pasa: el orden de dos líneas parece irrelevante. Cómo detectarlo: un usuario escribe algo, la escritura al viejo falla silenciosamente, la lectura (que aún sale del viejo) no muestra su cambio, pero el nuevo sí lo tiene —una incoherencia confusa—. Cómo corregirlo: durante la transición, el viejo es la fuente de verdad y su escritura es la prioritaria: va primero, y es la que no puede perderse. La del nuevo va después, como la copia. Cuando en la lección 7 se haga el read-switch y el nuevo pase a ser la fuente de verdad, ese orden se invertirá —pero eso es al final, no ahora—.
Ejercicios
Ejercicio 1 — Lee dónde cayó cada escritura. En la salida, usb-c-hub quedó "solo en old" pero kbd-mech quedó "en ambos", aunque los dos son productos del catálogo. (a) ¿Qué los diferencia? (b) ¿Por qué el update de ssd-1tb (escritura 5) sí llegó al nuevo, si ssd-1tb se había creado antes del dual-write? (c) Si después de encender el dual-write alguien volviera a escribir usb-c-hub (un update cualquiera), ¿aparecería en el nuevo?
Ver solución
(a) Los diferencia el momento en que se escribieron respecto al encendido del dual-write. usb-c-hub se escribió en la escritura 2, con el dual-write apagado: cayó solo en el viejo. kbd-mech se escribió en la escritura 6, con el dual-write encendido: cayó en ambos. No es una propiedad del producto, sino del instante de su escritura.
(b) Porque el dual-write actúa sobre cada escritura mientras está encendido, sin importar si el registro ya existía. El update de ssd-1tb (bajar el precio a 84.99) es una escritura que ocurrió en el paso 5, con el dual-write ya en ON, así que se aplicó a los dos almacenes. Lo que importa no es cuándo se creó el registro, sino cuándo ocurrió esta escritura.
(c) Sí. Si usb-c-hub recibe cualquier escritura nueva con el dual-write encendido, esa escritura caería en los dos almacenes y usb-c-hub aparecería en el nuevo. De hecho, si todos los registros históricos recibieran una escritura después del encendido, el dual-write terminaría llenando el nuevo por completo... pero no puedes contar con eso: hay registros que nadie edita en años (un producto descontinuado, un pedido viejo). Por eso el backfill es necesario: garantiza que el histórico llegue al nuevo sin depender de que alguien lo vuelva a tocar.
Ejercicio 2 — El reenvío de correo. Con la analogía del reenvío al mudarte: (a) ¿qué parte del correo cubre el reenvío y qué parte no? (b) traduce eso a "qué cubre el dual-write y qué no"; (c) ¿qué acción de la mudanza corresponde al backfill, y por qué es necesaria además del reenvío?
Ver solución
(a) El reenvío cubre todo el correo que llega después de activarlo: cada carta nueva se copia a la casa nueva. No cubre las cartas que ya estaban en tus cajones de la casa vieja —esas se quedan donde están—. El reenvío mira solo hacia adelante, desde el día de activación.
(b) Igual que el dual-write: cubre todas las escrituras que ocurren desde que se enciende (caen en los dos almacenes), pero no cubre lo que se escribió antes de encenderlo (sigue solo en el viejo). El dual-write sincroniza el frente, no el histórico.
(c) El backfill corresponde a llevar tú, en cajas, las cartas archivadas de la casa vieja a la nueva. Es necesario además del reenvío porque el reenvío nunca va a mover esas cartas viejas —solo mira hacia adelante—; si quieres tener el histórico completo en la casa nueva, alguien tiene que ir a los cajones, sacar lo archivado y llevarlo. En datos: el backfill copia al nuevo los registros históricos que el dual-write, por definición, no toca. Sin el backfill, el nuevo tendría solo el correo reciente y le faltaría todo el archivo.
Ejercicio 3 — El fallo parcial. En producción, el dual-write escribe al viejo y luego al nuevo, pero el write al nuevo falla por un timeout de red en una de cada mil escrituras. (a) ¿En qué estado queda esa fila (viejo vs nuevo)? (b) ¿Por qué el ejemplo en memoria de esta lección nunca muestra este problema? (c) ¿Qué mecanismo del módulo terminará detectando esa fila descuadrada, y por qué el CDC la evita de raíz?
Ver solución
(a) Queda descuadrada: el viejo tiene el valor nuevo (su write tuvo éxito) y el nuevo tiene el valor anterior o no tiene la fila (su write falló). Como durante la transición se lee del viejo, el usuario ve el dato correcto, pero el nuevo quedó atrás en esa fila —una inconsistencia silenciosa que no lanza ningún error visible—.
(b) Porque en memoria las dos escrituras a diccionarios de Python nunca fallan: no hay red, ni timeouts, ni indisponibilidad. El ejemplo simula el patrón (escribir a los dos), pero no la infraestructura (dos almacenes reales que pueden fallar independientemente). El fallo parcial solo aparece cuando los almacenes son sistemas separados que pueden estar arriba o abajo por su cuenta.
(c) El parallel-run (lección 5) terminará detectándola: al leer de ambos y comparar, esa fila saldrá como VALUE_MISMATCH (o MISSING_IN_NEW si el write falló por completo), y la reconciliación (lección 6) la arreglará re-migrándola. El CDC la evita de raíz porque elimina la segunda escritura desde la app: en vez de que la app escriba dos veces (y una pueda fallar), la app escribe una sola vez al viejo, y una herramienta lee el log de transacciones del viejo —donde solo quedan registradas las escrituras que sí tuvieron éxito— y las reproduce en el nuevo. No hay un "segundo write" que pueda fallar independientemente: la copia se deriva de un registro confiable de lo que realmente se escribió.
Resumen y siguiente paso
En esta lección pusiste en movimiento la primera pieza de la migración sobre los datos: el dual-write. Viste, con el reenvío de correo al mudarte, que encender el dual-write hace que cada escritura nueva caiga en los dos almacenes —traducida por el ACL al formato de cada uno—, cerrando la fuga hacia el futuro. Y viste su límite exacto, ejecutado: el dual-write no mira hacia atrás. usb-c-hub y webcam, escritas antes del encendido, se quedaron solo en el viejo; solo lo escrito con el dual-write encendido (ssd-1tb, hdmi-cable, kbd-mech) llegó al nuevo. El dual-write sincroniza el frente, no el histórico.
Antes de avanzar deberías poder: explicar qué garantiza el dual-write (cero fugas hacia adelante) y qué no (el histórico); leer una tabla de escrituras y decir cuáles cayeron en ambos y por qué; argumentar por qué la escritura al viejo va primero (es la fuente de verdad); y reconocer el problema del fallo parcial del segundo write y por qué el CDC lo resuelve a escala.
La lección 4 cierra el hueco que el dual-write deja abierto: el backfill. Vas a llenar el almacén nuevo con los registros históricos —usb-c-hub, webcam y todos los demás que quedaron atrás—, y vas a descubrir sus dos reglas duras, ejecutadas: no pisar las escrituras frescas que el dual-write ya puso (un backfill descuidado sobrescribe un precio recién actualizado con su valor viejo) y la idempotencia (correrlo dos veces no corrompe ni duplica). Y verás, por fin con el código en la mano, por qué el backfill va después de encender el dual-write y no antes: ese orden es lo que garantiza que no se pierda ni una escritura en la transición.
Recursos
- Sam Newman, Monolith to Microservices (O'Reilly, 2019), cap. 4, "Decomposing the Database" — la sección sobre sincronizar datos entre el monolito y el servicio nuevo durante la migración, incluyendo el patrón de escribir a ambos y sus riesgos de consistencia. La referencia directa de esta lección. En inglés.
- Martin Fowler, "ParallelChange" — martinfowler.com/bliki/ParallelChange.html. El dual-write es el "expand" de las escrituras: agregar el segundo destino sin quitar el primero. La entrada del patrón que le da marco. En inglés.
- Debezium, "Documentation: Change Data Capture" — debezium.io/documentation. La herramienta de CDC de referencia: cómo leer el log de transacciones de una base de datos y publicar sus cambios, la alternativa de producción al dual-write desde la app. Para entender qué hay debajo cuando esto se hace a escala (ecosistema Data Engineering). En inglés.
- Chris Richardson, "Pattern: Database per service" — microservices.io/patterns/data/database-per-service.html. El contexto de por qué un servicio extraído necesita sus propios datos, y los patrones para mantenerlos sincronizados durante la transición desde una base compartida. En inglés.