Desgravamen LBC · Base de datos

Producción vs. ScriptDHLBC.sql

Comparación entre el backup de producción del 13 de julio de 2026 y el script de base versionado en el repositorio, generado el 30 de diciembre de 2025. Estructura, datos paramétricos y riesgos de transaccionalidad.

Base DHLBC Servidor VNALPSQL07 SQL Server 2017 · compat 140 188 MB · 439.631 filas

01Resumen ejecutivo

Tres conclusiones, en orden de importancia.

La estructura no cambió. En absoluto.

Seis meses y medio de producción y el esquema es idéntico: mismas 36 tablas, 308 columnas, 36 primary keys, 57 foreign keys y 3 defaults. Verificado dos veces por caminos independientes — consultas al catálogo del motor y un diff DDL con sqlpackage, que salió vacío.

La parametría tampoco cambió.

Los 12 catálogos (175 filas) coinciden celda por celda con el script. Lo único que se movió son los usuarios: un alta y tres reasignaciones de banco.

🔴
Pero la base tiene un problema estructural serio de concurrencia.

Ninguna de las 57 foreign keys tiene índice de soporte, y no existe un solo índice no-clustered en toda la base. Sumado a que el aislamiento por snapshot está apagado y no hay control de concurrencia optimista, esto produce bloqueos y pérdida silenciosa de escrituras a medida que crece el volumen. Es lo que hay que atender.

02Qué se comparó, y cómo

No se compararon archivos de texto. Se restauró el backup y se cargó el script en dos bases vivas del mismo motor, para comparar contra el catálogo real de SQL Server.

 ProducciónScriptDHLBC.sql
OrigenBackup FULL nativo (.bak)Script generado por SSMS
Fecha2026-07-13 12:10:272025-12-30 16:14:37
ServidorVNALPSQL07
Tamaño187 MB52 MB (UTF-16)
Contenido439.631 filas56.645 INSERTs
IntegridadRESTORE VERIFYONLY OKcarga sin errores

Ambas bases se levantaron en un SQL Server 2022 en Docker. Con las dos vivas y lado a lado, la comparación se hace contra las vistas de sistema (sys.tables, sys.columns, sys.foreign_keys) y con EXCEPT sobre los datos — no a ojo.

⚠️
Nota sobre el archivo de backup

El backup llegó con extensión .sql, pero no es un script SQL: es un backup nativo de SQL Server (formato MTF). Se renombró a .bak, que es lo que realmente es. Además, la copia que estaba en el repositorio venía truncada al 77% (faltaban 45 MB) y era irrestaurable; se reemplazó por una copia íntegra verificada por SHA-256.

03Estructura: sin cambios

Cero diferencias. Ni una columna nueva, ni un tipo cambiado, ni un default distinto.

36 = 36
Tablas
308 = 308
Columnas
57 = 57
Foreign keys
36 = 36
Primary keys

Como verificación independiente se corrió un diff DDL con sqlpackage — la herramienta de Microsoft para comparar esquemas. Extrajo el esquema de producción y lo comparó contra la base del script:

sqlpackage /Action:Script /SourceFile:DHLBC_PROD.dacpac
           /TargetConnectionString:"...DHLBC_SCRIPT..."

→ 0 sentencias CREATE / ALTER / DROP

El script de migración que generó está vacío. Sin conocer nada de nuestro análisis, la herramienta llega a la misma conclusión: las dos bases tienen el mismo esquema.

Las dos únicas diferencias, y son menores

HallazgoDetalleSeveridad
ClaimTreasuryGroupDocument Su primary key tiene nombre autogenerado (PK__ClaimTre__3214EC07…), porque se creó sin nombrarla. Es la única tabla del esquema así. El sufijo aleatorio cambia en cada entorno, lo que ensucia cualquier comparación futura. menor
Usuarios de base de datos Producción tiene los usuarios DHLBC y UsuarioConsultasSQL, que el script no versiona. Los permisos de producción no están bajo control de versiones. a revisar

04Datos paramétricos: idénticos

Los 12 catálogos coinciden fila por fila y celda por celda con el script de diciembre.

CatálogoFilasEstadoCatálogoFilasEstado
DocumentField45idénticoClaimRejectionType13idéntico
ClaimRequiredDocument44idénticoClaimState8idéntico
ClaimStatus22idénticoBankParameter5idéntico
DocumentType22idénticoBank4idéntico
ClaimRequirement4idénticoRole4idéntico
ClaimType3idénticoSystemSource1idéntico

Lo único que cambió: usuarios

No es parametría pura, pero es el único dato no transaccional que se movió en seis meses.

CambioUsuarioDetalle
altajose.avilaRol Analyst, asignado a Banco Ecofuturo
reasignaciónmarcela.floresBanco Fie → Banco Sol · reactivada (0→1)
reasignaciónanalista.extra4Banco Sol → Banco Ecofuturo · desactivada (1→0)
reasignaciónanalista.extra5Banco Fie → Banco Ecofuturo · reactivada (0→1)
⚠️
Hay cuentas de prueba en producción

Los usuarios test y analista.extra1 a analista.extra5 están activos en la base productiva, con logins recientes. Convendría revisar si siguen siendo necesarios.

Volumen transaccional (medio año de operación)

TablaDic 2025Jul 2026CrecimientoTamaño
DocumentAttributeValue22.627246.888×10,950 MB
Document10.054106.655×10,692 MB
ClaimHistory14.64439.541×2,717 MB
Claim1.1573.255×2,82 MB

05Riesgos de transaccionalidad

Acá está lo importante. Los hallazgos van ordenados por gravedad.

críticoLas 57 foreign keys no tienen índice de soporte

La base tiene exactamente 36 índices de usuario para 36 tablas. Uno por tabla, y ese uno es la primary key clustered. No existe un solo índice no-clustered en toda la base.

Esto significa que toda consulta que filtre o haga JOIN por una foreign key — que es prácticamente toda la aplicación — recorre la tabla entera.

Tabla hijaColumna FKReferenciaFilas escaneadasON DELETE
DocumentAttributeValueDocumentIdDocument246.888NO_ACTION
DocumentAttributeValueDocumentFieldIdDocumentField246.888CASCADE
DocumentClaimIdClaim106.655NO_ACTION
DocumentDocumentTypeIdDocumentType106.655CASCADE
ClaimHistoryClaimIdClaim39.541NO_ACTION
…y 52 foreign keys más, todas sin índice.

Impacto en transaccionalidad. El problema no es solo que sea lento. Un scan de 246.888 filas toma locks de rango sobre toda la tabla mientras dura. Con la configuración actual (ver hallazgo #2), esos locks bloquean a cualquiera que quiera escribir. Cuanto más crecen los datos, más larga la ventana de bloqueo — y ese crecimiento es de ×10 en seis meses.

Es también el escenario clásico de deadlock: dos transacciones que escalan locks sobre tablas grandes en distinto orden terminan matándose entre sí.

Qué hacer

Crear un índice no-clustered sobre la columna de cada foreign key. Es la corrección de mayor impacto y menor riesgo que se le puede hacer a esta base. Empezando por las de mayor volumen:

CREATE NONCLUSTERED INDEX IX_DocumentAttributeValue_DocumentId
    ON dbo.DocumentAttributeValue (DocumentId);

CREATE NONCLUSTERED INDEX IX_DocumentAttributeValue_DocumentFieldId
    ON dbo.DocumentAttributeValue (DocumentFieldId);

CREATE NONCLUSTERED INDEX IX_Document_ClaimId
    ON dbo.Document (ClaimId);

CREATE NONCLUSTERED INDEX IX_ClaimHistory_ClaimId
    ON dbo.ClaimHistory (ClaimId);

Se pueden crear en caliente. El costo es espacio en disco y una escritura marginalmente más lenta; el beneficio es de órdenes de magnitud en lectura y una reducción drástica de bloqueos.

críticoSin control de concurrencia optimista: se pueden perder escrituras

Ninguna tabla tiene una columna rowversion (ni ningún otro token de concurrencia).

Qué significa en la práctica. Sin token de concurrencia, Entity Framework no puede detectar que un registro cambió entre que lo leyó y lo grabó. Dos analistas que abran el mismo siniestro y guarden: el último pisa al primero, en silencio. Nadie recibe un error, nadie se entera. El trabajo del primero simplemente desaparece.

En un sistema donde se liquidan siniestros de desgravamen, con montos e indemnizaciones, una escritura perdida no es un detalle técnico — es un dato incorrecto que nadie sabe que es incorrecto.

Qué hacer

Agregar una columna rowversion a las tablas que edita el usuario (Claim, ClaimAnalysisData, TechnicalSheet, DocumentAttributeValue) y mapearla en EF Core como token de concurrencia:

-- SQL
ALTER TABLE dbo.Claim ADD RowVersion rowversion NOT NULL;

// EF Core
builder.Property(c => c.RowVersion).IsRowVersion();

A partir de ahí, un guardado sobre datos desactualizados lanza DbUpdateConcurrencyException y la aplicación puede resolverlo — avisar al usuario, recargar o mergear. Lo que no puede seguir pasando es que se pierda sin ruido.

altoCascadas de borrado colgando de tablas de catálogo

Hay ON DELETE CASCADE desde tablas de catálogo — pequeñas, editables desde la administración — hacia las tablas más grandes del sistema.

Si se borra una fila de…que tienese borran en cascada…filas
SystemSource1 filaDocument → DocumentAttributeValue353.543
DocumentType22 filasDocument → DocumentAttributeValue353.543
DocumentField45 filasDocumentAttributeValue246.888
ClaimStatus22 filasClaim → TechnicalSheet, ClaimAnalysisData, …12.621

El caso extremo. SystemSource tiene una sola fila. Un DELETE sobre ella — un clic equivocado en una pantalla de administración — borraría los 106.655 documentos del sistema y sus 246.888 valores asociados. Sin confirmación, sin vuelta atrás salvo restaurar el backup.

Y como esas foreign keys tampoco tienen índice (hallazgo #1), la cascada además haría un table scan y mantendría la base bloqueada mientras dura.

Qué hacer

Los catálogos no deberían cascadear. Cambiar esas FKs a ON DELETE NO ACTION: si alguien intenta borrar un tipo de documento en uso, el motor lo rechaza — que es exactamente el comportamiento que uno quiere. La cascada tiene sentido de Claim hacia sus hijos (borrar un siniestro borra su análisis), no de un catálogo hacia los datos.

medioLos lectores bloquean a los escritores (RCSI apagado)
OpciónValorConsecuencia
READ_COMMITTED_SNAPSHOTOFFCada SELECT toma shared locks; los escritores esperan.
ALLOW_SNAPSHOT_ISOLATIONOFFTampoco hay aislamiento por snapshot disponible.
RECOVERY MODELFULLCorrecto — requiere backups de log periódicos.
PAGE_VERIFYCHECKSUMCorrecto.

Por qué importa acá y no en cualquier base. Con RCSI apagado, un reporte que lee Document bloquea a quien esté cargando un documento nuevo. Normalmente eso dura milisegundos. Pero como ninguna consulta tiene índice (hallazgo #1), esas lecturas son scans completos de cientos de miles de filas. Los dos problemas se multiplican: scans largos × locks bloqueantes = usuarios esperando.

Qué hacer

ALTER DATABASE DHLBC SET READ_COMMITTED_SNAPSHOT ON
    WITH ROLLBACK IMMEDIATE;

Con RCSI, los lectores ven una versión consistente en tempdb en lugar de bloquear. Requiere una ventana breve (desconecta las sesiones activas) y carga adicional sobre tempdb. Ordenar primero los índices — con el hallazgo #1 resuelto, la presión sobre tempdb es mucho menor.

medioEl backup de producción se toma sin verificación de integridad

El header del backup declara HasBackupChecksums = 0: se generó sin WITH CHECKSUM.

Sin checksums, nadie puede verificar que el backup sea restaurable hasta el día que intente restaurarlo. Si hay corrupción de página en el archivo, se descubre en el peor momento posible. Notar la inconsistencia: la base tiene PAGE_VERIFY CHECKSUM activo, o sea que los checksums existen — simplemente el backup no los está grabando.

Qué hacer

BACKUP DATABASE DHLBC TO DISK = '...'
WITH CHECKSUM, COMPRESSION, STATS = 10;

RESTORE VERIFYONLY FROM DISK = '...' WITH CHECKSUM;
bajoDetalles de modelado
ObservaciónDetalle
PK nvarchar(26) Las 36 tablas usan ULID en nvarchar(26) = 52 bytes por clave. ULID es ASCII (Crockford base32): char(26) gastaría la mitad. Como es la clave clustered, ese ancho se paga en cada página y se repite en cada FK. A favor: el ULID es monótono, así que los inserts van al final del índice y no lo fragmentan — esa parte está bien elegida.
3 defaults en 308 columnas Solo Claim.Active, Claim.CreationDate y ClaimTreasuryGroupDocument.UploadedDate tienen valor por defecto. El resto de las columnas de auditoría (CreatedAt, CreationDate, ModificationDate) dependen de que la aplicación siempre las setee. Cualquier INSERT por fuera de la app deja auditoría inconsistente.
Cero CHECK constraints No hay una sola validación de dominio en la base. Toda la integridad más allá de las FKs vive en la aplicación.
User.BatchRunId / User.InsuredId La tabla de usuarios tiene foreign keys hacia BatchRun e Insured. Es una relación llamativa para una tabla de identidad — vale la pena revisar si es intencional o un artefacto del modelado de EF.

06Plan de acción sugerido

En este orden. El primer paso es el que más rinde y el que menos riesgo tiene.

#AcciónRiesgoImpacto
1Crear índices sobre las 57 foreign keys. Se puede hacer en caliente, no requiere cambios en la aplicación.bajomuy alto
2Quitar el CASCADE de los catálogos (SystemSource, DocumentType, DocumentField, ClaimStatus…). Elimina el riesgo de borrado masivo accidental.bajomuy alto
3Agregar WITH CHECKSUM a los backups. Un cambio en el job de backup.nuloalto
4Agregar rowversion + token de concurrencia en EF. Requiere migración y manejar DbUpdateConcurrencyException en la aplicación.mediomuy alto
5Activar RCSI. Hacerlo después de los índices, en ventana de mantenimiento.medioalto
6Revisar cuentas de prueba en producción y versionar los usuarios de base de datos.bajomedio

07Entregables

Tres scripts generados desde el backup de producción, en Docs/backup-bd-prod/. Los tres fueron validados ejecutándolos en bases vacías y comparando el resultado contra producción.

ArchivoTamañoContenidoVerificación
…-schema.sql38 KB Solo estructura: 36 tablas, 57 FKs, defaults. reproduce el esquema
…-parametrica.sql73 KB Estructura + los 12 catálogos (175 filas). Sin datos personales. Es el que sirve para levantar un ambiente DEV/QA limpio. catálogos idénticos
…-full.sql156 MB Estructura + las 429.715 filas. Contiene datos personales reales de asegurados. 0 diferencias / 36 tablas
🔒
El script completo contiene datos personales

…-full.sql incluye nombres de asegurados, números de documento y causas de fallecimiento en texto plano. La carpeta Docs/backup-bd-prod/ está en .gitignore, así que no entra al repositorio — pero no debe compartirse ni publicarse fuera del equipo. Para ambientes de desarrollo y pruebas, usar el script paramétrico.

Cómo se validaron

No alcanza con que un script "parezca" correcto. Cada uno se ejecutó contra una base vacía y el resultado se comparó con producción usando EXCEPT en ambas direcciones, sobre todas las columnas de las 36 tablas:

-- Para cada tabla, en ambos sentidos:
SELECT ... FROM DHLBC_PROD.dbo.[T]
EXCEPT
SELECT ... FROM DHLBC_RECREADA.dbo.[T]

→ 0 filas de diferencia · 36 tablas · 429.715 filas

En el camino, esa validación encontró cuatro bugs reales en la generación que una revisión visual no habría detectado nunca: un ISNULL que truncaba el literal 'NULL' a 'N', un float que perdía precisión al convertirse a texto, saltos de línea embebidos que partían los INSERT al medio, y un orden de inserción que violaba las foreign keys. Todos corregidos y verificados.