Módulo de facturación con años en producción. Un campo guardaba boletas asociadas como string delimitado:
{
_id: "...",
facturaId: "F-1042",
boletasAsociadas: "B001|B002|B003|B045"
}
Funcionaba mientras nadie necesitara consultar, filtrar o hacer un $lookup real sobre esos valores. En cuanto el negocio pidió reportes por boleta individual, ese diseño se volvió una pared.
Por qué el string delimitado es una trampa
No puedes indexar contenido dentro de un string delimitado de forma útil. No puedes hacer $elemMatch, ni $lookup con otra colección, ni agregaciones que agrupen por boleta. Cualquier consulta termina trayendo el documento completo a memoria y parseando el string en la capa de aplicación — exactamente el tipo de trabajo que MongoDB debería hacer por ti con un índice.
El objetivo: arrays embebidos reales
{
_id: "...",
facturaId: "F-1042",
facturacionBoletas: [
{ boletaId: "B001", monto: 15000, fechaAsociacion: ISODate("...") },
{ boletaId: "B002", monto: 15000, fechaAsociacion: ISODate("...") },
{ boletaId: "B003", monto: 22000, fechaAsociacion: ISODate("...") }
]
}
Con esto, $unwind + $group para reportes por boleta es trivial, y un índice sobre facturacionBoletas.boletaId hace las búsquedas directas.
El problema real: no puedes migrar todo de golpe
Con el sistema en producción y facturas nuevas creándose cada minuto, un script de migración de una sola pasada corre el riesgo de dejar facturas nuevas en el formato viejo si se crean durante la migración, o de reventar si el script tarda horas y algo se cae a la mitad.
Estrategia: escritura dual + migración progresiva + corte
Fase 1 — Escritura dual. El DAO escribe en ambos formatos simultáneamente durante la transición:
public void guardarFactura(Factura factura) {
Document doc = new Document()
.append("facturaId", factura.getId())
.append("boletasAsociadas", String.join("|", factura.getBoletaIds())) // formato viejo
.append("facturacionBoletas", convertirAArrayEmbebido(factura.getBoletas())); // formato nuevo
collection.insertOne(doc);
}
Fase 2 — Migración por lotes de lo histórico, no todo de una vez:
public void migrarLote(int tamanoLote) {
FindIterable<Document> pendientes = collection
.find(Filters.exists("facturacionBoletas", false))
.limit(tamanoLote);
for (Document doc : pendientes) {
List<Document> arrayEmbebido = parsearStringDelimitado(doc.getString("boletasAsociadas"));
collection.updateOne(
Filters.eq("_id", doc.get("_id")),
Updates.set("facturacionBoletas", arrayEmbebido)
);
}
}
Corre este lote en un job programado (o manual, controlado) hasta que find(Filters.exists("facturacionBoletas", false)) devuelva cero. Nunca bloqueas la colección completa, nunca hay downtime.
Fase 3 — Cambiar lecturas al nuevo campo, código de consulta y reportes apuntan solo a facturacionBoletas.
Fase 4 — Corte. Cuando confirmas que el 100% de los documentos tiene el array embebido y ningún código lee el string viejo, eliminas la escritura dual y el campo legacy:
db.facturas.updateMany(
{ facturacionBoletas: { $exists: true } },
{ $unset: { boletasAsociadas: "" } }
)
Por qué no un "big bang" migration
Un script único que toca millones de documentos en una sola transacción larga bloquea recursos, es difícil de revertir a mitad de camino, y si falla en el documento 800,000 de 1,000,000 no sabes con certeza qué quedó a medias sin auditar. La migración progresiva con bandera de existencia ($exists: false como criterio de "pendiente") es autoreanudable: si se cae, la vuelves a correr y retoma donde quedó.
Resumen para copiar y pegar
- Nunca migres de golpe una colección en producción activa — usa escritura dual primero.
- Migra en lotes usando
$exists: falsecomo filtro de "pendiente", así el proceso es reanudable. - Cambia las lecturas al campo nuevo solo después de confirmar cobertura completa.
- El
$unsetdel campo legacy es el último paso, no el primero.
Top comments (0)