100 Errores en SQL Server y Cómo Evitarlos

Resumen interactivo del libro de Peter A. Carter (Manning, 2025)
para desarrolladores, DBA y arquitectos de datos.

⏱️ 15 min de lectura 📦 20+ ejemplos de código 🎯 Quiz incluido 🌙 Modo oscuro

Si alguna vez has escuchado "es solo una base de datos, ¿no?", prepárate: esa mentalidad es la raíz del "Error #0" que origina casi todos los problemas en entornos SQL Server. En su obra 100 SQL Server Mistakes and How to Avoid Them, Peter A. Carter recopila dos décadas de experiencia real trabajando en proyectos FTSE 100 en Londres, exponiendo los tropiezos más comunes que cometen desde desarrolladores hasta DBA accidentales.

14 Capítulos temáticos
100 Errores identificados
20+ Ejemplos prácticos
2025 Edición Manning

Este resumen interactivo está diseñado para que, en menos de 15 minutos, absorbas los pilares fundamentales del libro y puedas aplicarlos inmediatamente. Lo organizamos en 4 ejes estratégicos:

  1. 🏗️ Diseño y Estándares de Desarrollo
  2. ⚙️ Administración, Instalación y Mantenimiento
  3. 🚀 Rendimiento y Optimización
  4. 🛡️ Seguridad, Backups y Alta Disponibilidad
💡 Tip de navegación: Usa la barra superior para saltar entre secciones. Cada bloque de código tiene un botón 📋 Copiar. Al final hay un quiz para auto-evaluarte.

🏗️ 1. Diseño y Estándares de Desarrollo

El código bien escrito y un esquema bien pensado son la base de cualquier sistema escalable. Carter insiste en que la arquitectura y los estándares no son opcionales, incluso en metodologías ágiles donde la prisa por entregar suele sacrificar calidad.

🔹 Nomenclatura: el peligroso prefijo sp_

El prefijo sp_ está reservado para procedimientos del sistema. Cuando llamas a un procedimiento con ese prefijo, SQL Server busca primero en la base de datos master, generando latencia innecesaria y, peor aún, ejecutando el procedimiento equivocado si existe colisión de nombres.

❌ Mal: prefijos reservados
-- ❌ SQL Server buscará primero en master
CREATE PROCEDURE sp_addUser
    @UserDetails XML
AS
BEGIN
    INSERT INTO dbo.customers (...)
    VALUES (...);
END;
✅ Bien: nombre descriptivo
-- ✅ Sin prefijo reservado, claro y rápido
CREATE PROCEDURE dbo.AddCustomer
    @UserDetails XML
AS
BEGIN
    INSERT INTO dbo.customers (...)
    VALUES (...);
END;

🔹 Tipos de datos: cada byte cuenta

Usar INT o NVARCHAR(MAX) "por defecto" desperdicia memoria y degrada el rendimiento del buffer pool. En tablas con millones de filas la diferencia es gigabytes de RAM y disco.

-- ❌ Antipatrón: todo NVARCHAR(MAX), todo INT
CREATE TABLE dbo.Employees (
    EmployeeID    NVARCHAR(MAX) NOT NULL,
    FirstName     NVARCHAR(MAX) NOT NULL,
    Salary        NVARCHAR(MAX) NOT NULL,
    IsActive      NVARCHAR(MAX) NOT NULL  -- "0" o "1" como texto 😱
);

-- ✅ Tipos ajustados al dominio real
CREATE TABLE dbo.Employees (
    EmployeeID    SMALLINT      NOT NULL PRIMARY KEY,  -- hasta 32K empleados
    FirstName     NVARCHAR(32)  NOT NULL,
    LastName      NVARCHAR(32)  NOT NULL,
    DateOfBirth   DATE          NOT NULL,
    Salary        MONEY         NOT NULL,
    DepartmentCode NCHAR(2)     NOT NULL,  -- siempre 2 chars
    IsActive      BIT           NOT NULL   -- 8 BITs = 1 byte
);
💡 Regla práctica: usa TINYINT (0-255), SMALLINT (±32K), INT (±2.1B) o BIGINT según el rango real. Para textos de longitud fija (códigos ISO, country codes), prefiere CHAR/NCHAR: ahorras 2 bytes por fila del column offset array.

🔹 Jerarquías: olvida los CTEs recursivos

El tipo HIERARCHYID incluye métodos nativos que simplifican enormemente la lógica jerárquica (organigramas, categorías de productos, BOMs).

-- ✅ Encontrar todos los reportes directos e indirectos de un manager
DECLARE @Manager HIERARCHYID;

SELECT @Manager = ManagerHierarchyID
FROM dbo.Employees
WHERE EmployeeID = 2;

SELECT EmployeeID, FirstName, LastName, ManagerHierarchyID.ToString()
FROM dbo.Employees
WHERE ManagerHierarchyID.IsDescendantOf(@Manager) = 1;

-- ✅ Encontrar al jefe del jefe (escalación)
SELECT FirstName, LastName, Role
FROM dbo.Employees
WHERE ManagerHierarchyID = (
    SELECT ManagerHierarchyID.GetAncestor(2)
    FROM dbo.Employees
    WHERE EmployeeID = 22
);

🔹 Manejo correcto de NULL

SQL Server usa lógica de tres valores. NULL significa "desconocido", por lo que NULL = NULL devuelve UNKNOWN, no TRUE.

❌ Nunca devuelve filas
SELECT COUNT(*)
FROM dbo.ProductSubcategories
WHERE Description = NULL;
-- Resultado: 0 (siempre)

SELECT COUNT(*)
FROM dbo.ProductSubcategories
WHERE Description <> NULL;
-- Resultado: 0 (también)
✅ Sintaxis correcta
SELECT COUNT(*)
FROM dbo.ProductSubcategories
WHERE Description IS NULL;

SELECT COUNT(*)
FROM dbo.ProductSubcategories
WHERE Description IS NOT NULL;

-- Para reemplazar NULL en proyecciones:
SELECT ISNULL(Description, 'Sin descripción')
FROM dbo.ProductSubcategories;
"El tipo de dato es la primera línea de defensa para la integridad. Un BIT para estados booleanos o un DATE para fechas no es solo ahorro de espacio: es documentación implícita."

⚙️ 2. Administración, Instalación y Mantenimiento

La infraestructura define los límites del rendimiento y la estabilidad. Muchos errores nacen de instalaciones manuales, configuraciones por defecto y falta de automatización.

🚫 Nunca instales TODAS las features

Cada servicio (SSAS, SSRS, PolyBase, Master Data Services) consume RAM/CPU y aumenta la superficie de ataque. Solo instala lo que realmente vas a usar.

# ✅ Instalación silenciosa solo con Database Engine
.\setup.exe /ACTION=Install `
    /FEATURES=SQLENGINE `
    /INSTANCENAME=MSSQLSERVER `
    /SQLSVCACCOUNT="DOMAIN\sqlservice" `
    /SQLSVCPASSWORD="********" `
    /SQLSYSADMINACCOUNTS="DOMAIN\sqladmins" `
    /SECURITYMODE=SQL `
    /SAPWD="********" `
    /PID="22222-00000-00000-00000-00000" `
    /IACCEPTSQLSERVERLICENSETERMS `
    /Q

# Después: deshabilitar acceso remoto y renombrar 'sa'
Invoke-Sqlcmd -Query "EXEC sp_configure 'remote access', 0; RECONFIGURE;"
Invoke-Sqlcmd -Query "ALTER LOGIN sa WITH NAME = SQLAdmin;"

🚫 NUNCA actives AUTO_SHRINK

Genera fragmentación cercana al 99% en todos los índices y bloquea constantemente. El shrink manual solo debe usarse en casos excepcionales, seguido obligatoriamente de un rebuild de índices.

-- ❌ Ver si tienes AUTO_SHRINK activado (¡no debería!)
SELECT name, is_auto_shrink_on
FROM sys.databases
WHERE is_auto_shrink_on = 1;

-- ✅ Desactivarlo en todas las BBDD
ALTER DATABASE Marketing SET AUTO_SHRINK OFF WITH NO_WAIT;

-- Si DEBES hacer shrink manual (caso excepcional):
DBCC SHRINKFILE('MarketingArchive', 838) WITH WAIT_AT_LOW_PRIORITY;

-- ⚠️ Y SIEMPRE reconstruir índices después:
EXEC sp_msforeachtable 'ALTER INDEX ALL ON ? REBUILD';

🔍 Detecta corrupción antes que tus usuarios

Es mucho más doloroso enterarse de la corrupción a las 3 AM por un usuario que en una revisión proactiva. Automatiza DBCC CHECKDB semanalmente.

-- ✅ Verificación completa de integridad
DBCC CHECKDB('MarketingArchive') WITH NO_INFOMSGS, ALL_ERRORMSGS;

-- ✅ Verificación más rápida (solo estructura física)
DBCC CHECKDB('MarketingArchive') WITH PHYSICAL_ONLY;

-- ⚠️ ÚLTIMO RECURSO si no hay backups (puede haber pérdida de datos)
ALTER DATABASE MarketingArchive SET SINGLE_USER;
DBCC CHECKDB('MarketingArchive', REPAIR_ALLOW_DATA_LOSS);
ALTER DATABASE MarketingArchive SET MULTI_USER;

🛠️ Parchear sin downtime

"Es 24/7, no podemos parar" no es excusa. Usa Availability Groups para hacer rolling updates con apenas segundos de interrupción durante el failover.

-- ✅ Rolling patch con Availability Groups:
-- 1. Patcha el secundario (sin afectar producción)
-- 2. Failover manual al secundario
ALTER AVAILABILITY GROUP [AG_Marketing] FAILOVER;

-- 3. Patcha el antiguo primario (ahora secundario)
-- 4. Failover de vuelta si lo deseas
-- Resultado: ~30 segundos de "blip" en lugar de 2h de downtime

🔹 Backups y AutoGrow

-- ❌ Default: crece en 64 MB cada vez (fragmentación masiva)
-- ✅ Configurar crecimiento predecible según uso real
ALTER DATABASE MarketingArchive
MODIFY FILE (
    NAME = 'MarketingArchive',
    SIZE = 200GB,           -- Pre-dimensionar
    FILEGROWTH = 5GB        -- Crecimientos grandes y predecibles
);

-- Para múltiples archivos del filegroup, crecer todos juntos
ALTER DATABASE MarketingArchive
MODIFY FILEGROUP [PRIMARY] AUTOGROW_ALL_FILES;
⚠️ Trace flags obsoletos: T1117 y T1118 fueron deprecados en SQL Server 2016. Usa AUTOGROW_ALL_FILES y MIXED_PAGE_ALLOCATION en su lugar. Si los ves en tu instancia, ¡quítalos!

🚀 3. Rendimiento y Optimización

"Tirar más hardware" es la solución más cara y temporal. La optimización inteligente reduce costos, mejora la experiencia y alarga la vida útil de tu infraestructura.

🔹 SELECT *: el enemigo silencioso

Consume red innecesaria, rompe índices covering y crea dependencias frágiles. Solo úsalo en queries ad-hoc o exploratorias, nunca en código de producción.

❌ Antipatrón
-- Trae 60+ bytes/fila cuando solo necesitas 9
SELECT *
FROM dbo.SalesOrderHeaders
WHERE SalesOrderDate = '2025-06-16';
✅ Específico + índice covering
-- ✅ Crea índice covering
CREATE NONCLUSTERED INDEX IX_OrderDate_DueActual
ON dbo.SalesOrderHeaders (SalesOrderDate)
INCLUDE (DeliveryDueDate, DeliveryActualDate);

-- ✅ Solo las columnas necesarias
SELECT
    SalesOrderDate,
    DeliveryDueDate,
    DeliveryActualDate
FROM dbo.SalesOrderHeaders
WHERE SalesOrderDate = '2025-06-16';

🔹 NOLOCK vs concurrencia optimista

NOLOCK permite dirty reads: puedes leer datos que nunca llegaron a confirmarse. Es como ver el saldo de tu cuenta justo cuando un retiro está siendo revertido.

🚨 Caso real del libro: Una vendedora consulta direcciones de entrega con NOLOCK mientras un admin actualiza una dirección. El admin hace ROLLBACK, pero la vendedora ya envió el pedido a una dirección que nunca existió.
-- ✅ Mejor solución: Read Committed Snapshot
ALTER DATABASE Marketing
SET READ_COMMITTED_SNAPSHOT ON WITH NO_WAIT;

-- ✅ O para casos específicos: Snapshot Isolation
ALTER DATABASE Marketing SET ALLOW_SNAPSHOT_ISOLATION ON;

-- En la sesión:
SET TRANSACTION ISOLATION LEVEL SNAPSHOT;
BEGIN TRAN;
    SELECT * FROM dbo.Addresses WHERE CustomerID = @id;
    -- Lectura consistente sin bloqueos ni dirty reads
COMMIT;

🔹 Adiós a los cursores: usa PIVOT

Caso real del libro: un proceso ETL con cursor que tardaba 3 horas se redujo a 48 segundos al reescribirlo con PIVOT.

-- ✅ PIVOT en una sola consulta set-based
SELECT
    [Raw Ingredients],
    [Machine Parts],
    [Misc],
    [Confectionary Products],
    [Non-confectionary Products]
FROM (
    SELECT
        pc.ProductCategoryName,
        ISNULL(p.ProductStockLevel, 0) AS Stock
    FROM dbo.ProductCategories pc
    INNER JOIN dbo.ProductSubcategories ps
        ON pc.ProductCategoryID = ps.ProductCategoryID
    LEFT JOIN dbo.Products p
        ON ps.ProductSubcategoryID = p.ProductSubcategoryID
) AS WorkingTable
PIVOT (
    SUM(Stock)
    FOR ProductCategoryName IN (
        [Raw Ingredients], [Machine Parts], [Misc],
        [Confectionary Products], [Non-confectionary Products]
    )
) AS PivotTable;

🔹 Borrado masivo sin llenar el log

Borrar millones de filas en una sola transacción llenará el transaction log y disparará un error 9002. Hazlo en lotes.

-- ✅ Borrado por lotes de 250K filas
DECLARE @RowCounter BIGINT = 1;

WHILE @RowCounter > 0
BEGIN
    DELETE TOP (250000)
    FROM dbo.VeryLargeTable
    WHERE FechaCreacion < DATEADD(YEAR, -3, GETDATE());

    SET @RowCounter = @@ROWCOUNT;

    -- Pequeña pausa para permitir checkpoint
    WAITFOR DELAY '00:00:01';
END;

🔹 Particionamiento + Compresión

Para tablas grandes (>100M filas), particiona por fecha y aplica ROW o PAGE compression. Reduce I/O hasta 70% y permite eliminación de particiones en queries con WHERE en la clave de partición.

-- ✅ 1. Crear función y esquema de partición
CREATE PARTITION FUNCTION ImpressionDatesPF (DATETIME)
AS RANGE RIGHT FOR VALUES (
    '20230101', '20240101', '20250101', '20260101'
);

CREATE PARTITION SCHEME ImpressionDatesPS
AS PARTITION ImpressionDatesPF
ALL TO ([PRIMARY]);

-- ✅ 2. Estimar el ahorro ANTES de aplicar compresión
EXEC sp_estimate_data_compression_savings
    @schema_name = 'dbo',
    @object_name = 'ImpressionsArchive',
    @index_id = 1,
    @partition_number = NULL,
    @data_compression = 'PAGE';

-- ✅ 3. Aplicar compresión si vale la pena
ALTER TABLE dbo.ImpressionsArchive
REBUILD WITH (DATA_COMPRESSION = PAGE);

🔹 Columnstore: 100x más rápido en analytics

-- ✅ Para data warehouses: clustered columnstore
CREATE CLUSTERED COLUMNSTORE INDEX CCI_Impressions
ON dbo.ImpressionsArchive;

-- ✅ Para OLTP con analytics ocasional: nonclustered columnstore
CREATE NONCLUSTERED COLUMNSTORE INDEX NCCI_Sales
ON dbo.Sales (SalesDate, ProductID, Quantity, Amount);

-- Comparación real del libro en una agregación:
-- B-tree clásico: 1067 ms
-- Columnstore:    45 ms (casi 24x más rápido)
"El optimizador es brillante, pero no adivina tu negocio. Ayúdalo con buenos índices, estadísticas frescas y consultas que expresen claramente qué necesitas, no cómo obtenerlo."

🛡️ 4. Seguridad, Backups y Alta Disponibilidad

Los datos son el activo más valioso. Protegerlos requiere estrategia, no solo herramientas. Un backup sin probar no es un backup; una réplica sin failover probado no es alta disponibilidad.

🔹 Principio del menor privilegio

Más del 70% de los ataques son internos. Crea roles personalizados en lugar de regalar sysadmin.

-- ✅ Rol custom para failover de Availability Groups
CREATE SERVER ROLE AvailabilityGroupsFailover;

ALTER SERVER ROLE AvailabilityGroupsFailover
    ADD MEMBER [NT AUTHORITY\SYSTEM];

GRANT ALTER ANY AVAILABILITY GROUP TO AvailabilityGroupsFailover;
GRANT CONNECT SQL TO AvailabilityGroupsFailover;
GRANT VIEW SERVER STATE TO AvailabilityGroupsFailover;

-- ✅ Rol de DB para ejecutar SPs (no existe out-of-the-box)
USE model;  -- aplicará a todas las nuevas BBDD
GO
CREATE ROLE ExecSP;
GRANT EXECUTE TO ExecSP;

🔹 Cuenta sa: deshabilitar o renombrar

-- ✅ Opción A: deshabilitar (recomendado)
ALTER LOGIN sa DISABLE;

-- ✅ Opción B: renombrar (si por compatibilidad debe existir)
ALTER LOGIN sa WITH NAME = SQLAdmin_2025;

-- ✅ Y siempre forzar políticas de contraseña
ALTER LOGIN SQLAdmin_2025
WITH CHECK_POLICY = ON,
     CHECK_EXPIRATION = ON;

🔹 Trampa para detectar activación de xp_cmdshell

No basta con desactivarlo: pon una alarma para detectar intentos de activación.

-- ✅ Mensaje custom para alertar
EXEC sp_addmessage 50001, 16,
    'Intento de activar xp_cmdshell detectado.';

-- ✅ Trigger DDL que bloquea + alerta
CREATE TRIGGER prevent_xp_cmdshell ON ALL SERVER
FOR ALTER_INSTANCE
AS
BEGIN
    DECLARE @Statement NVARCHAR(4000);
    SET @Statement = (
        SELECT EVENTDATA().value(
            '(/EVENT_INSTANCE/TSQLCommand/CommandText)[1]',
            'nvarchar(4000)')
    );

    IF (CHARINDEX('sp_configure', @Statement) > 0)
       AND (CHARINDEX('xp_cmdshell', @Statement) > 0)
    BEGIN
        RAISERROR(50001, 16, 1) WITH LOG;  -- Alerta al event log
        ROLLBACK;  -- Bloquea la activación
    END
END;

🔹 Backup ad-hoc seguro: COPY_ONLY + cifrado

Un backup manual no debe romper la cadena diferencial. Y siempre cifrado.

-- ✅ 1. Crear infraestructura de cifrado (una sola vez)
USE master;
CREATE MASTER KEY ENCRYPTION BY PASSWORD = '********';
CREATE CERTIFICATE BackupCert
    WITH SUBJECT = 'Backup Encryption Certificate';

-- ⚠️ ¡BACKUP DEL CERTIFICADO! (sin esto, los backups son inútiles)
BACKUP CERTIFICATE BackupCert
    TO FILE = 'C:\Certs\BackupCert.cer'
    WITH PRIVATE KEY (
        FILE = 'C:\Certs\BackupCertPK.pvk',
        ENCRYPTION BY PASSWORD = '********'
    );

-- ✅ 2. Backup ad-hoc seguro
BACKUP DATABASE Marketing
TO DISK = 'D:\Backups\Marketing_Adhoc.bak'
WITH
    COPY_ONLY,                    -- No rompe la cadena diferencial
    CHECKSUM,                     -- Verifica integridad
    COMPRESSION,                  -- Reduce tamaño 50-80%
    ENCRYPTION (
        ALGORITHM = AES_256,
        SERVER CERTIFICATE = BackupCert
    );

-- ✅ 3. Verificar sin restaurar
RESTORE VERIFYONLY
FROM DISK = 'D:\Backups\Marketing_Adhoc.bak'
WITH CHECKSUM;

🔹 Modelo de recuperación: cuándo usar cada uno

Para: Bases de datos OLTP en producción.
Permite: Backups full + diferenciales + log (point-in-time recovery).
Trade-off: Logging completo (más I/O), pero RPO de minutos.

ALTER DATABASE Sales SET RECOVERY FULL;
-- ⚠️ Después del cambio: full backup OBLIGATORIO
BACKUP DATABASE Sales TO DISK = 'D:\Backups\Sales_Init.bak';
-- A partir de aquí, los log backups funcionarán

Para: Data warehouses, BBDD de desarrollo, staging.
Permite: Solo full + diferenciales. NO point-in-time.
Ventaja: Logging mínimo (mejor rendimiento ETL), log se trunca automáticamente.

ALTER DATABASE MarketingArchive SET RECOVERY SIMPLE;
-- Sin necesidad de log backups
-- El log se trunca en cada CHECKPOINT

Para: Ventanas temporales de carga masiva (ETL nocturno).
Permite: Operaciones bulk minimamente logged.
Cuidado: No permite point-in-time durante el bulk.

-- Patrón típico de ETL nocturno:
-- 1. Antes del ETL
ALTER DATABASE DataWarehouse SET RECOVERY BULK_LOGGED;
-- 2. Ejecutar bulk inserts, indexes, etc.
-- 3. Volver a FULL al terminar
ALTER DATABASE DataWarehouse SET RECOVERY FULL;
-- 4. Backup de log para cerrar la ventana
BACKUP LOG DataWarehouse TO DISK = '...';

🔹 Cifrado a nivel de celda con autenticador

Sin autenticador, un atacante con permisos UPDATE puede copiar el valor cifrado de otro registro (ataque de sustitución de valor completo). El autenticador vincula el dato cifrado a una columna específica.

-- ✅ Setup de cifrado
CREATE MASTER KEY ENCRYPTION BY PASSWORD = '********';
CREATE CERTIFICATE SalaryCert
    WITH SUBJECT = 'Salarios encriptados';
CREATE SYMMETRIC KEY SalaryKey
    WITH ALGORITHM = AES_256
    ENCRYPTION BY CERTIFICATE SalaryCert;

-- ✅ Cifrado CON autenticador (EmployeeID como vínculo)
OPEN SYMMETRIC KEY SalaryKey
    DECRYPTION BY CERTIFICATE SalaryCert;

UPDATE dbo.Employees
SET SalarioCifrado = ENCRYPTBYKEY(
    KEY_GUID('SalaryKey'),
    CAST(Salario AS VARBINARY),
    1,                                           -- ✅ Habilitar autenticador
    CAST(EmployeeID AS VARBINARY(8000))          -- ✅ Vínculo único
);

-- ✅ Descifrado: si alguien copió el valor, devuelve NULL
SELECT
    EmployeeID,
    CAST(DECRYPTBYKEY(
        SalarioCifrado,
        1,
        CAST(EmployeeID AS VARBINARY(8000))
    ) AS MONEY) AS Salario
FROM dbo.Employees;

CLOSE SYMMETRIC KEY SalaryKey;

🔹 Manejo profesional de errores

-- ✅ Plantilla recomendada para SPs en producción
CREATE PROCEDURE dbo.RegisterTransaction
    @Amount DECIMAL(10,2),
    @AccountID INT
AS
BEGIN
    SET NOCOUNT ON;
    SET XACT_ABORT ON;  -- Rollback automático en errores graves

    BEGIN TRANSACTION;
    BEGIN TRY
        UPDATE dbo.Accounts
        SET Balance = Balance - @Amount
        WHERE ID = @AccountID;

        INSERT INTO dbo.Transactions (AccountID, Amount, CreatedAt)
        VALUES (@AccountID, @Amount, GETDATE());

        COMMIT TRANSACTION;
    END TRY
    BEGIN CATCH
        IF @@TRANCOUNT > 0
            ROLLBACK TRANSACTION;

        -- Log a tabla de errores (opcional)
        INSERT INTO dbo.ErrorLog (ErrorMessage, ErrorNumber, ErrorSeverity)
        VALUES (ERROR_MESSAGE(), ERROR_NUMBER(), ERROR_SEVERITY());

        -- Re-lanzar el error para que la app lo capture
        THROW;
    END CATCH
END;

🔹 SQL Injection: la última línea de defensa eres tú

Aunque el desarrollador frontend "valide" inputs, el DBA debe asumir lo peor. Fuerza el uso de stored procedures con parámetros tipados.

❌ Vulnerable a inyección
// C#: SQL dinámico concatenado
string qry = "SELECT * FROM Users " +
    "WHERE UserName='" + userInput + "' " +
    "AND Pwd='" + pwdInput + "'";

// Si userInput = "' OR 1=1--"
// La query se convierte en:
// SELECT * FROM Users
// WHERE UserName='' OR 1=1--' AND Pwd='...'
// → Devuelve TODOS los usuarios 💀
✅ Stored procedure parametrizado
CREATE PROCEDURE dbo.LoginUser
    @UserName NVARCHAR(128),
    @Password NVARCHAR(128)
AS
BEGIN
    SET NOCOUNT ON;
    SELECT UserID, UserName
    FROM dbo.Users
    WHERE UserName = @UserName
      AND UserPassword = @Password;
END;
-- Los parámetros se tratan como datos,
-- nunca como código SQL.

🔹 HA ≠ DR: confundirlos es un error caro

Aspecto High Availability Disaster Recovery
Failover Automático Manual
Ubicación Mismo datacenter Datacenters dispersos
Replicación Síncrona (sin pérdida) Asíncrona (puede haber pérdida)
Protege contra Fallo de servidor/rack Pérdida total de site
⚠️ Niveles de disponibilidad ("9s"): 99% = 7h/mes downtime · 99.9% = 43min/mes · 99.99% = 4min/mes · 99.999% = 26seg/mes. Cada "9" multiplica el costo. Calcula el costo real del downtime antes de pedir 5-nines.

📦 Galería Interactiva: Patrones para Producción

Busca, expande y copia los patrones que necesites. Más de 15 ejemplos categorizados.

🔍 Crear índice covering para evitar key lookups
-- ✅ Índice que "cubre" toda la query (sin lookups)
CREATE NONCLUSTERED INDEX IX_Email_IncludeNames
ON dbo.CustomerContacts (CustomerContactEmail)
INCLUDE (CustomerContactFirstName, CustomerContactLastName);

-- Esta query usará el índice sin tocar la tabla base
SELECT
    CustomerContactFirstName,
    CustomerContactLastName,
    CustomerContactEmail
FROM dbo.CustomerContacts
ORDER BY CustomerContactEmail;
🔧 Mantenimiento inteligente de índices
-- ✅ Solo reconstruye lo necesario (no todo indiscriminadamente)
DECLARE @SQL NVARCHAR(MAX);

SET @SQL = (
    SELECT
        'ALTER INDEX ' + i.name +
        ' ON ' + s.name + '.' + o.name +
        ' REBUILD WITH (ONLINE = ON, MAXDOP = 4); '
    FROM sys.dm_db_index_physical_stats(
        DB_ID(), NULL, NULL, NULL, 'DETAILED'
    ) ips
    INNER JOIN sys.indexes i
        ON i.object_id = ips.object_id
        AND i.index_id = ips.index_id
    INNER JOIN sys.objects o
        ON i.object_id = o.object_id
    INNER JOIN sys.schemas s
        ON s.schema_id = o.schema_id
    WHERE i.type_desc <> 'HEAP'
      AND o.type_desc = 'USER_TABLE'
      AND ips.avg_fragmentation_in_percent > 20
      AND ips.page_count > 1000
      AND ips.index_level = 0
    FOR XML PATH('')
);

EXEC sp_executesql @SQL;
🚫 Reemplazar cursores DBA con XML trick
-- ❌ Antipatrón: cursor para iterar tablas
DECLARE Tables CURSOR READ_ONLY FOR
    SELECT name FROM sys.tables;
-- ... 20+ líneas de FETCH/EXEC ...

-- ✅ Set-based con XML PATH (mucho más rápido)
DECLARE @SQL NVARCHAR(MAX);

SET @SQL = (
    SELECT 'ALTER INDEX ALL ON ' + QUOTENAME(name) + ' REBUILD; '
    FROM sys.tables
    FOR XML PATH('')
);

EXEC sp_executesql @SQL;
📸 Snapshot de BD para rollback rápido
-- ✅ Snapshot ANTES de un cambio crítico
CREATE DATABASE Marketing_Snapshot ON
(
    NAME = Marketing,
    FILENAME = 'D:\Snapshots\Marketing_PreDeploy.ss'
)
AS SNAPSHOT OF Marketing;

-- Si algo sale mal, revertir es casi instantáneo:
USE master;
RESTORE DATABASE Marketing
FROM DATABASE_SNAPSHOT = 'Marketing_Snapshot';

-- ⚠️ Recuerda: NO sustituye a un backup
-- (depende de la BD origen)
DROP DATABASE Marketing_Snapshot;
🔄 MERGE/UPSERT atómico
-- ✅ Insertar o actualizar agregaciones diarias
MERGE INTO reporting.ImpressionAggregates AS Target
USING (
    SELECT
        CampaignID,
        CountryCode,
        AVG(BidPrice) AS AvgBid,
        AVG(CostPerMille) AS AvgCPM,
        CAST(EventTime AS DATE) AS EventDate
    FROM marketing.Impressions
    WHERE EventTime >= DATEADD(DAY, -1, GETDATE())
    GROUP BY CampaignID, CountryCode, CAST(EventTime AS DATE)
) AS Source
ON Source.CampaignID = Target.CampaignID
   AND Source.CountryCode = Target.CountryCode
   AND Source.EventDate = Target.EventDate
WHEN MATCHED THEN
    UPDATE SET
        AvgBidPrice = Source.AvgBid,
        AvgCostPerMille = Source.AvgCPM
WHEN NOT MATCHED THEN
    INSERT (CampaignID, CountryCode, EventDate, AvgBidPrice, AvgCostPerMille)
    VALUES (Source.CampaignID, Source.CountryCode, Source.EventDate,
            Source.AvgBid, Source.AvgCPM);
📡 Trabajar con JSON en SQL Server
DECLARE @json NVARCHAR(MAX) = N'{
  "EmployeeAddress": [{
    "EmployeeID": 1,
    "Address": {
      "Line1": "5331 Rexford Court",
      "City": "Montgomery",
      "State": "AL",
      "ZipCode": "36116"
    }
  }]
}';

-- ✅ Extraer un valor escalar
SELECT JSON_VALUE(@json, '$.EmployeeAddress[0].EmployeeID');

-- ✅ Convertir JSON → tabla relacional
SELECT *
FROM OPENJSON(@json, '$.EmployeeAddress[0]')
WITH (
    EmployeeID SMALLINT '$.EmployeeID',
    Line1      NVARCHAR(64)  '$.Address.Line1',
    City       NVARCHAR(64)  '$.Address.City',
    [State]    NCHAR(2)      '$.Address.State',
    ZipCode    NVARCHAR(10)  '$.Address.ZipCode'
);

-- ✅ Generar JSON desde resultado
SELECT
    1 AS 'EmployeeID',
    '5331 Rexford Court' AS 'Address.Line1',
    'Montgomery' AS 'Address.City'
FOR JSON PATH, ROOT('EmployeeAddress');
📦 Compresión inteligente con preview
-- ✅ 1. Estimar antes de aplicar
EXEC sp_estimate_data_compression_savings
    @schema_name      = 'dbo',
    @object_name      = 'ImpressionsArchive',
    @index_id         = NULL,    -- Todos los índices
    @partition_number = NULL,
    @data_compression = 'PAGE';  -- Probar también 'ROW'

-- ✅ 2. Aplicar si la ratio compensa la CPU extra
ALTER TABLE dbo.ImpressionsArchive
REBUILD WITH (
    DATA_COMPRESSION = PAGE,
    ONLINE = ON,
    MAXDOP = 2
);

-- ✅ 3. Verificar el ahorro real
SELECT
    OBJECT_NAME(object_id) AS Tabla,
    in_row_used_page_count AS Paginas,
    (in_row_used_page_count * 8) / 1024 AS MB
FROM sys.dm_db_partition_stats
WHERE object_id = OBJECT_ID('dbo.ImpressionsArchive')
  AND index_id = 1;
📋 Auditoría de actividad administrativa
-- ✅ 1. Crear el audit (escribe al Security Log de Windows)
USE master;
CREATE SERVER AUDIT AdminActivityAudit
    TO SECURITY_LOG
    WITH (ON_FAILURE = CONTINUE);

-- ✅ 2. Especificar QUÉ auditar
CREATE SERVER AUDIT SPECIFICATION AdminActivitySpec
FOR SERVER AUDIT AdminActivityAudit
    ADD (SERVER_ROLE_MEMBER_CHANGE_GROUP),
    ADD (AUDIT_CHANGE_GROUP),
    ADD (DBCC_GROUP),
    ADD (SERVER_PERMISSION_CHANGE_GROUP),
    ADD (SERVER_PRINCIPAL_CHANGE_GROUP),
    ADD (SERVER_PRINCIPAL_IMPERSONATION_GROUP),
    ADD (SERVER_OBJECT_CHANGE_GROUP),
    ADD (SERVER_STATE_CHANGE_GROUP);

-- ✅ 3. Activar
ALTER SERVER AUDIT AdminActivityAudit
    WITH (STATE = ON);
ALTER SERVER AUDIT SPECIFICATION AdminActivitySpec
    WITH (STATE = ON);
📊 Análisis de capacidad: ¿cuánto crece tu BD?
-- ✅ Tamaño actual y espacio libre por archivo
SELECT
    DB_NAME(database_id)                 AS DataBase_Name,
    Name                                  AS Logical_Name,
    Physical_Name                         AS Physical_Name,
    (size * 8) / 1024                     AS TotalSpace_MB,
    (FILEPROPERTY(name, 'SpaceUsed') * 8) / 1024 AS UsedSpace_MB,
    ((size - FILEPROPERTY(name, 'SpaceUsed')) * 8) / 1024 AS FreeSpace_MB,
    growth                                AS Growth_Setting,
    is_percent_growth                     AS Is_Percent_Growth
FROM sys.master_files
WHERE database_id = DB_ID()
ORDER BY DataBase_Name, Logical_Name;

-- ✅ VLFs activos en el log (detectar fragmentación)
SELECT
    total_vlf_count,
    active_vlf_count,
    log_truncation_holdup_reason
FROM sys.dm_db_log_stats(DB_ID());
📈 Activar Query Store para análisis histórico
-- ✅ Activar Query Store (gratis, low-overhead)
ALTER DATABASE Marketing
SET QUERY_STORE = ON (
    OPERATION_MODE      = READ_WRITE,
    CLEANUP_POLICY      = (STALE_QUERY_THRESHOLD_DAYS = 30),
    DATA_FLUSH_INTERVAL_SECONDS = 900,
    MAX_STORAGE_SIZE_MB = 1000,
    INTERVAL_LENGTH_MINUTES = 60
);

-- ✅ Activar feedback automático de paralelismo (SQL 2022+)
ALTER DATABASE SCOPED CONFIGURATION
    SET DOP_FEEDBACK = ON;

-- ✅ Top 10 queries más costosas en CPU
SELECT TOP 10
    qsq.query_id,
    qsqt.query_sql_text,
    SUM(qsrs.count_executions)     AS executions,
    AVG(qsrs.avg_duration / 1000.0) AS avg_duration_ms,
    AVG(qsrs.avg_cpu_time / 1000.0) AS avg_cpu_ms
FROM sys.query_store_query qsq
INNER JOIN sys.query_store_query_text qsqt
    ON qsq.query_text_id = qsqt.query_text_id
INNER JOIN sys.query_store_plan qsp
    ON qsq.query_id = qsp.query_id
INNER JOIN sys.query_store_runtime_stats qsrs
    ON qsp.plan_id = qsrs.plan_id
GROUP BY qsq.query_id, qsqt.query_sql_text
ORDER BY AVG(qsrs.avg_cpu_time) DESC;
✅ Activar verificación de integridad de páginas
-- ✅ CHECKSUM en cada página al escribirla
ALTER DATABASE Marketing SET PAGE_VERIFY CHECKSUM;

-- Verificar la configuración actual
SELECT name, page_verify_option_desc
FROM sys.databases
WHERE page_verify_option_desc <> 'CHECKSUM';
-- Cualquier resultado aquí es un problema.
🤖 Instalación con PowerShell DSC
Configuration InstallSql {
    param (
        $SqlInstanceName = 'MSSQLSERVER',
        $Edition
    )

    Import-DscResource -ModuleName SqlServerDsc

    node localhost {
        SqlSetup 'InstallInstance' {
            InstanceName        = $SqlInstanceName
            Features            = 'SQLENGINE'
            SourcePath          = 'C:\SQL Media'
            SQLSysAdminAccounts = @('Administrator')
            ProductKey          = $ProductKey
        }

        SqlConfiguration 'RemoteAccess' {
            InstanceName = $SqlInstanceName
            OptionName   = 'remote access'
            OptionValue  = 0
            DependsOn    = '[SqlSetup]InstallInstance'
        }

        SqlLogin 'AddSqlAdmin' {
            Ensure       = 'Present'
            Name         = 'DOMAIN\sqladmins'
            InstanceName = $SqlInstanceName
            LoginType    = 'WindowsGroup'
            DependsOn    = '[SqlSetup]InstallInstance'
        }
    }
}
🐳 SQL Server en contenedor con persistencia
# ✅ 1. Crear volumen Docker para persistir datos
sudo docker volume create sqldata

# ✅ 2. Levantar contenedor con volumen montado
sudo docker run \
    -e "ACCEPT_EULA=Y" \
    -e "MSSQL_SA_PASSWORD=Pa\$\$w0rd!" \
    -p 1433:1433 \
    --name production-sql \
    --hostname production-sql \
    -v sqldata:/var/opt/mssql \
    -d mcr.microsoft.com/mssql/server:2022-latest

# ✅ 3. Conectarte
docker exec -it production-sql /opt/mssql-tools/bin/sqlcmd \
    -S localhost -U sa -P 'Pa$$w0rd!'
🔔 Alertas automáticas con SQL Agent
-- ✅ 1. Crear operador (destinatario)
EXEC msdb.dbo.sp_add_operator
    @name           = 'DBA_Team',
    @enabled        = 1,
    @email_address  = 'dba@empresa.com';

-- ✅ 2. Alerta para errores de severidad alta (>= 19)
EXEC msdb.dbo.sp_add_alert
    @name             = 'High Severity Errors',
    @severity         = 19,
    @enabled          = 1,
    @delay_between_responses = 60,
    @include_event_description_in = 1;

EXEC msdb.dbo.sp_add_notification
    @alert_name         = 'High Severity Errors',
    @operator_name      = 'DBA_Team',
    @notification_method = 1;  -- email

-- ✅ 3. Alerta para xp_cmdshell (custom error 50001)
EXEC msdb.dbo.sp_add_alert
    @name        = 'xp_cmdshell Activation Attempt',
    @message_id  = 50001,
    @enabled     = 1;

EXEC msdb.dbo.sp_add_notification
    @alert_name         = 'xp_cmdshell Activation Attempt',
    @operator_name      = 'DBA_Team',
    @notification_method = 1;
🔬 Diagnóstico de bloqueos en vivo
-- ✅ Sesiones bloqueadas + bloqueador + query SQL
SELECT
    blocking.session_id    AS BlockerSPID,
    blocked.session_id     AS BlockedSPID,
    blocked.wait_type,
    blocked.wait_time / 1000 AS WaitSeconds,
    blocked.last_wait_type,
    bl_text.text           AS BlockerSQL,
    bd_text.text           AS BlockedSQL,
    DB_NAME(blocked.database_id) AS DBName
FROM sys.dm_exec_requests blocked
INNER JOIN sys.dm_exec_sessions blocker_sess
    ON blocked.blocking_session_id = blocker_sess.session_id
INNER JOIN sys.dm_exec_requests blocking
    ON blocking.session_id = blocker_sess.session_id
CROSS APPLY sys.dm_exec_sql_text(blocking.sql_handle) bl_text
CROSS APPLY sys.dm_exec_sql_text(blocked.sql_handle)  bd_text
WHERE blocked.blocking_session_id <> 0;

-- ✅ Top 5 wait types desde el último restart
SELECT TOP 5
    wait_type,
    waiting_tasks_count,
    wait_time_ms / 1000 AS wait_seconds,
    (wait_time_ms / waiting_tasks_count) AS avg_wait_ms
FROM sys.dm_os_wait_stats
WHERE waiting_tasks_count > 0
  AND wait_type NOT IN (
      'CLR_AUTO_EVENT', 'WAITFOR', 'SLEEP_TASK',
      'BROKER_TASK_STOP', 'LAZYWRITER_SLEEP'
  )
ORDER BY wait_time_ms DESC;
📥 Configuración óptima de Data Flow en SSIS
<!-- ✅ Properties recomendadas en el Data Flow Task -->
<DataFlow>
    <DefaultBufferMaxRows>50000</DefaultBufferMaxRows>
    <AutoAdjustBufferSize>True</AutoAdjustBufferSize>
    <EngineThreads>10</EngineThreads>
</DataFlow>

<!-- ✅ OLE DB Destination Fast Load -->
<OleDbDestination>
    <FastLoadOptions>TABLOCK,CHECK_CONSTRAINTS</FastLoadOptions>
    <MaximumInsertCommitSize>50000</MaximumInsertCommitSize>
    <Rows per batch>50000</Rows per batch>
</OleDbDestination>

<!-- ⚠️ Antes de bulk load: deshabilitar índices NCI -->
🔄 Schema Compare con SSDT (sin sorpresas en deploy)
# ✅ Generar script de deploy desde DACPAC sin aplicarlo
SqlPackage.exe `
    /Action:Script `
    /SourceFile:"C:\Builds\Marketing.dacpac" `
    /TargetServerName:"SQLPROD01" `
    /TargetDatabaseName:"Marketing" `
    /OutputPath:"C:\Deploy\preview.sql" `
    /p:BlockOnPossibleDataLoss=true `
    /p:GenerateSmartDefaults=true

# ✅ Aplicar tras revisión humana
SqlPackage.exe `
    /Action:Publish `
    /SourceFile:"C:\Builds\Marketing.dacpac" `
    /TargetServerName:"SQLPROD01" `
    /TargetDatabaseName:"Marketing" `
    /p:BlockOnPossibleDataLoss=true
📊 Estadísticas: cuándo actualizar (y cuándo NO)
-- ✅ Activar auto-update asíncrono (no bloquea queries)
ALTER DATABASE Marketing SET AUTO_UPDATE_STATISTICS ON;
ALTER DATABASE Marketing SET AUTO_UPDATE_STATISTICS_ASYNC ON;

-- ❌ NO hagas esto después de un REBUILD:
-- UPDATE STATISTICS ... -- empeora el muestreo (el rebuild ya las tiene al 100%)

-- ✅ Si necesitas actualizar manualmente, solo COLUMNS:
UPDATE STATISTICS marketing.Impressions
WITH FULLSCAN, COLUMNS;

-- ✅ Ver edad de las estadísticas
SELECT
    OBJECT_NAME(s.object_id) AS TableName,
    s.name AS StatName,
    sp.last_updated,
    sp.rows,
    sp.rows_sampled,
    sp.modification_counter
FROM sys.stats s
CROSS APPLY sys.dm_db_stats_properties(s.object_id, s.stats_id) sp
WHERE OBJECTPROPERTY(s.object_id, 'IsUserTable') = 1
ORDER BY sp.last_updated;

¿No encuentras lo que buscas? Usa el buscador arriba 👆

🎯 Pon a Prueba lo Aprendido

5 preguntas rápidas para auto-evaluar tu comprensión.

Pregunta 1 / 5

¿Cuál es el principal problema de usar el prefijo sp_ en procedimientos de usuario?

Pregunta 2 / 5

Tienes una BD OLTP con RPO de 1 hora. ¿Qué modelo de recuperación es el correcto?

Pregunta 3 / 5

¿Qué hace el query hint NOLOCK?

Pregunta 4 / 5

Acabas de hacer DBCC SHRINKFILE a un archivo de datos. ¿Qué debes hacer DESPUÉS?

Pregunta 5 / 5

¿Cuál es la mejor opción para un backup ad-hoc antes de un cambio sin romper la cadena diferencial?

📝 Reglas de Oro para Llevar a Producción

  1. Automatiza todo lo repetitivo: instalaciones, mantenimiento, backups, parches y configuración. Usa PowerShell DSC, Ansible o Azure Automation.
  2. Prueba antes de desplegar: Schema Compare, unit tests y CI/CD. El "script manual de producción" es una bomba de tiempo.
  3. Monitorea, no adivines: Query Store, DOP Feedback, sys.dm_db_index_usage_stats y wait types te dicen la verdad.
  4. Seguridad por capas: menor privilegio, features mínimas, auditoría, cifrado y gMSAs.
  5. Documenta y estandariza: nombres, tipos, modelos de recuperación y políticas de backup. La consistencia ahorra horas de debugging.
  6. Capacity planning real: proyecta crecimiento, incluye TempDB y log, suma 20% de margen. En cloud, monitorea OPEX.
  7. Prueba el DR anualmente: sincronización ≠ failover funcional. Verifica permisos, latencia y rendimiento post-failover.
  8. Patchea siempre: el riesgo de no parchear (ransomware, exploits) supera el de parchear. Usa rolling updates con AGs/FCIs.

🔍 ¿Por qué leer el libro completo?

Este resumen condensa los conceptos clave, pero el libro de Peter A. Carter incluye scripts listos para usar, diagramas C4, explicaciones paso a paso de SSIS, AlwaysOn, Columnstore, y casos reales de producción con métricas antes/después. Si tu rol toca SQL Server, 100 SQL Server Mistakes es el manual de supervivencia que evita que aprendas a base de incidentes en sábado por la noche.

📕 Comprar el libro en Manning 📦 Volver a la galería