SQL Server Hekaton: OLTP en memoria sin perder ACID
Cuando una tabla se vuelve el cuello de botella de un sistema transaccional, la respuesta reflejo suele ser “metamos un caché delante” — casi siempre Redis. Es una solución razonable, pero no es la única, y para un equipo que ya vive en el ecosistema .NET y SQL Server, hay una alternativa que rara vez se evalúa en serio: mover la tabla completa a memoria sin salir de SQL Server. Ese es el trabajo de Hekaton, el nombre en clave del motor In-Memory OLTP que Microsoft introdujo en SQL Server 2014.
El problema que Hekaton resuelve
Un motor relacional tradicional asume que los datos viven en disco: usa locks para proteger la concurrencia, latches para proteger las estructuras internas en memoria, y páginas que hay que leer y escribir. Bajo concurrencia extrema — miles de sesiones actualizando las mismas filas por segundo — esa maquinaria es exactamente lo que te frena, sin importar cuántos índices agregues.
Hekaton ataca el problema desde la raíz: las tablas memory-optimized no usan locks ni latches en absoluto. La concurrencia se resuelve con control de versiones optimista (cada actualización crea una nueva versión de la fila; las versiones viejas se recolectan después) y con estructuras de datos diseñadas para vivir enteramente en RAM. El resultado no es “una tabla normal pero más rápida”: es un motor de ejecución distinto por debajo, con las mismas garantías transaccionales de siempre.
Cómo son las tablas en memoria
Antes de crear una tabla memory-optimized, la base de datos necesita un filegroup dedicado para datos en memoria (aunque la tabla no persista en disco, SQL Server necesita este filegroup para los checkpoints de las tablas durables):
ALTER DATABASE Ventas
ADD FILEGROUP Ventas_InMemory CONTAINS MEMORY_OPTIMIZED_DATA;
ALTER DATABASE Ventas
ADD FILE (NAME = 'Ventas_InMemory_dir', FILENAME = 'C:\Data\Ventas_InMemory')
TO FILEGROUP Ventas_InMemory;
Con eso resuelto, una tabla memory-optimized durable se ve así — nótese el índice hash, obligatorio
para lookups por igualdad, con un BUCKET_COUNT fijado en la creación:
CREATE TABLE dbo.Sesiones
(
SesionId UNIQUEIDENTIFIER NOT NULL
CONSTRAINT PK_Sesiones PRIMARY KEY NONCLUSTERED HASH WITH (BUCKET_COUNT = 1000000),
UsuarioId INT NOT NULL,
CreadaEn DATETIME2 NOT NULL DEFAULT SYSUTCDATETIME(),
UltimaActividad DATETIME2 NOT NULL DEFAULT SYSUTCDATETIME(),
Payload NVARCHAR(4000) NULL,
INDEX IX_Sesiones_Usuario NONCLUSTERED (UsuarioId)
)
WITH (MEMORY_OPTIMIZED = ON, DURABILITY = SCHEMA_AND_DATA);
DURABILITY = SCHEMA_AND_DATA es el modo “normal”: las filas se registran en el log de
transacciones y sobreviven un reinicio, igual que una tabla en disco. Pero hay un segundo modo que
es el que de verdad compite con Redis en su propio terreno:
CREATE TABLE dbo.ContadorRateLimit
(
ClienteId VARCHAR(100) NOT NULL
CONSTRAINT PK_ContadorRateLimit PRIMARY KEY NONCLUSTERED HASH WITH (BUCKET_COUNT = 500000),
Tokens FLOAT NOT NULL,
Actualizado DATETIME2 NOT NULL
)
WITH (MEMORY_OPTIMIZED = ON, DURABILITY = SCHEMA_ONLY);
Con DURABILITY = SCHEMA_ONLY no hay logging ni checkpoint de datos: si el servidor se reinicia, la
tabla queda vacía (solo se conserva la definición). A cambio, las escrituras no tocan disco en
absoluto. Es, funcionalmente, un caché — el mismo caso de uso que resolvería un HSET en Redis,
pero con tipos, constraints y BUCKET_COUNT en vez de una convención de nombres de key.
Índices y compilación nativa
Las tablas memory-optimized soportan dos tipos de índice: hash (para igualdad, como el de
Sesiones arriba) y range (un nonclustered normal, respaldado por un Bw-tree, para
ORDER BY o comparaciones por rango). Ambos viven en memoria junto con los datos; no hay páginas de
índice que leer desde disco.
La otra pieza distintiva es la posibilidad de compilar un procedimiento almacenado directo a código máquina, evitando por completo el intérprete de T-SQL:
CREATE OR ALTER PROCEDURE dbo.RegistrarActividadSesion
@SesionId UNIQUEIDENTIFIER
WITH NATIVE_COMPILATION, SCHEMABINDING
AS
BEGIN ATOMIC WITH
(
TRANSACTION ISOLATION LEVEL = SNAPSHOT,
LANGUAGE = N'Spanish'
)
UPDATE dbo.Sesiones
SET UltimaActividad = SYSUTCDATETIME()
WHERE SesionId = @SesionId;
END;
Un procedimiento nativamente compilado tiene restricciones reales (sin SQL dinámico, subconjunto limitado de funciones integradas, todas las tablas referenciadas deben ser memory-optimized), pero para una operación caliente y repetitiva — como actualizar una sesión en cada request — el resultado es la latencia más baja que SQL Server puede ofrecer para esa operación.
Desde .NET, sigue siendo solo SQL
Esta es la parte que casi no se menciona cuando se compara con Redis: desde el código de
aplicación, una tabla memory-optimized es indistinguible de una tabla normal. Mismo SqlConnection,
mismo pool de conexiones, mismo ORM:
await using var connection = new SqlConnection(connectionString);
await connection.OpenAsync();
await using var command = new SqlCommand(
"EXEC dbo.RegistrarActividadSesion @SesionId", connection);
command.Parameters.AddWithValue("@SesionId", sesionId);
await command.ExecuteNonQueryAsync();
No hay un cliente nuevo que instalar, ni un formato de serialización que decidir, ni un segundo connection string que rotar en un vault. Es exactamente el mismo código que ya escribís para cualquier otra tabla.
Hekaton frente a Redis
Redis y las tablas memory-optimized resuelven el mismo síntoma — datos calientes que no deberían tocar disco — desde arquitecturas opuestas:
| Redis | Hekaton (In-Memory OLTP) | |
|---|---|---|
| Ubicación | Proceso separado, con red o socket de por medio | Dentro del mismo motor SQL Server |
| Transacciones | MULTI/EXEC, sin rollback real ante errores en ejecución |
ACID completo, mismos niveles de aislamiento que el resto de SQL Server |
| Consultas | Por key, estructuras de datos específicas (hash, set, sorted set) | T-SQL completo, incluyendo JOIN con tablas en disco |
| Consistencia con la base principal | La mantiene la aplicación (cache-aside, invalidación manual) | No aplica: es la misma base de datos |
| Modelo de datos | Clave-valor y estructuras derivadas | Relacional, con tipos y constraints |
Redis gana en escenarios que Hekaton no está diseñado para cubrir: escalar horizontalmente entre muchos nodos, pub/sub, estructuras de datos como sorted sets para leaderboards, o simplemente compartir estado entre servicios escritos en distintos lenguajes que no comparten una base de datos relacional.
Nota: esta comparación está deliberadamente enfocada en por qué elegir Hekaton. Redis tiene ventajas reales que no entran en este artículo — las voy a cubrir a fondo en un próximo post dedicado a Redis, sin la carga de estar contestándole a Hekaton.
Por qué, si ya sos un equipo .NET, esto suele ganar
Ninguna de estas razones es “Hekaton es más rápido que Redis” — depende del caso, y no lo es siempre. Son razones operativas, y son las que más pesan en la práctica:
- Una sola fuente de verdad. Con Redis como caché, la aplicación es responsable de mantenerlo sincronizado con la base de datos. Con Hekaton, la tabla en memoria y la tabla en disco pueden vivir en la misma transacción — no hay ventana de inconsistencia que resolver a mano.
- Un solo sistema que operar. Sin un servicio adicional que desplegar, monitorear, respaldar y asegurar por separado. Backups, Always On, permisos: todo lo que ya tenés para SQL Server aplica también a las tablas memory-optimized.
- Sin capa de serialización. Redis guarda bytes o strings; tu objeto de dominio tiene que ir y volver de ese formato, y esa capa es donde aparecen bugs de versión de esquema. Una tabla memory-optimized ya tiene tipos.
- El equipo ya sabe T-SQL. No hay una API nueva que aprender ni un cliente nuevo que auditar en el árbol de dependencias.
Esto no hace a Redis una mala elección — lo hace la elección correcta para problemas que Hekaton no resuelve. Pero si el problema real es “esta tabla necesita ser más rápida” y ya estás en SQL Server, vale la pena evaluar Hekaton antes de sumar una pieza más a la infraestructura.