UltraHost · base de producción

Optimización de dbo.oTrx

La búsqueda de operación original presentaba bloqueos y demoras de hasta 30 minutos. La tabla estaba 96,5% vacía y no tenía índices útiles para esa búsqueda. Se compactó, se agregaron índices y la consulta medida pasó a responder en 1 ms.

2 y 3 de noviembre de 2026 · SQL Server 2022 Standard Edition (16.0.4205.1) · Base UltraHost

27 veces menospáginas de datos en oTrx
1 msconsulta de original inexistente, con 3 lecturas lógicas
10 GBdevueltos al disco, de 14.084 a 4.096 MB
0líneas de código de UltraHost modificadas

1. Problema

La búsqueda por NroPV + NroRI (mensaje posOp_BUSCAOPERACIONORIGINAL, protocolo binario) sigue este camino: client.cpp / client_emv.cpp → TrxCommon::BuscarOperacionOriginal (trx_common.cpp:746) → TRX::ReadSQL (trx_sql.cpp:1445).

Ejecuta hasta cinco consultas sobre oTrx:

#FiltroObjetivo
1iTicketPOSOri + iNroPVVerificar si ya tiene anulación
2iTicketPOS + iNroPV + filtro de iNroMensajeVerificar si es anulación o devolución
3iTicketPOS + iNroPV + datediff(...)Límite de días, si MAXDIASDEVOLUCION > 0
4iTicketPOS + iNroPVBuscar la operación original
5iNroPVVerificar si el punto de venta existe

2. Diagnóstico previo

3. Causa técnica

Confirmado

Sin índices útiles, las consultas recorrían oTrx. Por estar inflada, cada recorrido completo podía leer hasta 9,6 GB.

Inferido, no medido

No se midió el disco ni se capturó un bloqueo durante una demora. Que esta sea toda la causa de los 30 minutos sigue siendo una hipótesis.

4. Cambios aplicados

No se modificó el código de UltraHost. Los cambios fueron, en este orden:

-- 4.1 Create the clustered index (compacts the table and rebuilds its indexes)
SET LOCK_TIMEOUT 10000;

CREATE UNIQUE CLUSTERED INDEX CX_oTrx_iSQLId
ON dbo.oTrx (iSQLId)
WITH (FILLFACTOR = 90, SORT_IN_TEMPDB = ON);

-- 4.2 Index for query 1
CREATE NONCLUSTERED INDEX IX_oTrx_TicketOri
ON dbo.oTrx (iTicketPOSOri, iNroPV, iSQLId DESC)
WITH (FILLFACTOR = 80, SORT_IN_TEMPDB = ON);

-- 4.3 Index for queries 2 to 5
CREATE NONCLUSTERED INDEX IX_oTrx_PV_Ticket
ON dbo.oTrx (iNroPV, iTicketPOS, iSQLId DESC)
INCLUDE (iNroMensaje, dInsertDate)
WITH (FILLFACTOR = 80, SORT_IN_TEMPDB = ON);

-- 4.4 Shrink the data file
DBCC SHRINKFILE (TiHost8583_Data, 4096);

El primer SHRINKFILE, con objetivo de 6144 MB, falló con Msg 3140 mientras había un BACKUP DATABASE en curso. El reintento con 4096 MB, sin backup en curso, funcionó. El backup es la causa probable, no confirmada.

5. Por qué estas decisiones

DecisiónMotivo
Clustered en vez de ALTER TABLE ... REBUILDREBUILD compacta, pero la tabla sigue siendo heap y las páginas vacías vuelven a acumularse. Con clustered, las páginas totalmente vacías se liberan y se puede usar REORGANIZE.
iSQLId como claveEs identity, creciente y NOT NULL.
Dos índices nonclusteredLas consultas filtran por columnas distintas: iTicketPOSOri e iTicketPOS.
iSQLId DESC en la claveResuelve el ORDER BY iSQLId DESC sin ordenar.
INCLUDE (iNroMensaje, dInsertDate)Permite evaluar esos filtros sin ir a la tabla por cada fila.
SET LOCK_TIMEOUTAborta si no obtiene el bloqueo, en vez de encolar a los demás.
SHRINK después del clusteredSHRINK mueve páginas, pero no compacta las filas dentro de ellas. Antes de compactar no habría resuelto el problema.

6. Resultados medidos

Páginas de datos de oTrx

Antes
1.226.700
Después
44.881
MedidaAntesDespués
Espacio de datos de oTrx9.585 MB351 MB
KB por fila331
EstructuraHeapClustered
Archivo de datos14.084 MB4.096 MB
Espacio en la base10,2 GB reservados782 MB usados
Fragmentación de índices de oTrxNo medidaMenor a 0,25%

Consulta 4 con original inexistente: 3 lecturas lógicas, 1 ms. Chequeo de 9 casos a las 00:19: 4 a 6 ms cada uno, incluida la copia de la fila a una tabla temporal, sin bloqueos y sin transacciones abiertas de más de 60 segundos.

7. Consultas de control

Estado de la tabla

Lee parte de la tabla. Es preferible correrla con poca operación.

SELECT alloc_unit_type_desc, page_count,
       avg_page_space_used_in_percent, forwarded_record_count
FROM sys.dm_db_index_physical_stats(DB_ID(), OBJECT_ID('dbo.oTrx'), 1, NULL, 'SAMPLED');

Bloqueos

Ejecutar mientras haya una demora. sql_texto puede traer datos de la operación: revisalo antes de compartirlo.

SELECT r.session_id, r.blocking_session_id, r.wait_type,
       r.wait_time/1000 AS espera_seg, r.command,
       DB_NAME(r.database_id) AS base, t.text AS sql_texto
FROM sys.dm_exec_requests r
CROSS APPLY sys.dm_exec_sql_text(r.sql_handle) t
WHERE r.session_id <> @@SPID;

Compactación

Usar si baja mucho la ocupación de páginas.

ALTER INDEX ALL ON dbo.oTrx REORGANIZE;
Chequeo completo de salud (solo lectura, no muestra datos de tarjeta)

Ejecutar con la base UltraHost seleccionada. La sección 5 puede incluir valores dentro del texto SQL: revisala antes de compartirla.

-- UltraHost health check. Read only: no permanent changes, shows no card data.
-- Run with the UltraHost database selected.
SET NOCOUNT ON;
SET LOCK_TIMEOUT 5000;  -- report a blocked search instead of waiting

DECLARE @dias int = 30;  -- use the MAXDIASDEVOLUCION value from UltraHost.ini
DECLARE @pv int, @ori varchar(21);
SELECT TOP 1 @pv = iNroPV, @ori = iTicketPOS FROM dbo.oTrx
WHERE iNroPV > 0 AND iTicketPOS > '' ORDER BY iSQLId DESC;
SELECT @pv = ISNULL(@pv, 1), @ori = ISNULL(@ori, '1');

DECLARE @casos TABLE (id int IDENTITY, caso varchar(20), consulta varchar(30), filtro nvarchar(400));
DECLARE @pvs nvarchar(12) = CAST(@pv AS nvarchar(12));
INSERT @casos (caso, consulta, filtro) VALUES
('Original existe',   'Ya tiene anulacion', N'iTicketPOSOri=''' + @ori + N''' and iNroPV=''' + @pvs + N''' order by iSQLId desc'),
('Original existe',   'Es devolucion',      N'iTicketPOS=''' + @ori + N''' and iNroPV=''' + @pvs + N''' and (NOT((iNroMensaje=1) or (iNroMensaje=101) or (iNroMensaje=123))) order by iSQLId desc'),
('Original existe',   'Limite de dias',     N'iTicketPOS=''' + @ori + N''' and iNroPV=''' + @pvs + N''' and (datediff(day,dinsertdate,getdate())<=' + CAST(@dias AS nvarchar(12)) + N') order by iSQLId desc'),
('Original existe',   'Busca original',     N'iTicketPOS=''' + @ori + N''' and iNroPV=''' + @pvs + N''' order by iSQLId desc'),
('Original no existe','Ya tiene anulacion', N'iTicketPOSOri=''999999999'' and iNroPV=''' + @pvs + N''' order by iSQLId desc'),
('Original no existe','Es devolucion',      N'iTicketPOS=''999999999'' and iNroPV=''' + @pvs + N''' and (NOT((iNroMensaje=1) or (iNroMensaje=101) or (iNroMensaje=123))) order by iSQLId desc'),
('Original no existe','Limite de dias',     N'iTicketPOS=''999999999'' and iNroPV=''' + @pvs + N''' and (datediff(day,dinsertdate,getdate())<=' + CAST(@dias AS nvarchar(12)) + N') order by iSQLId desc'),
('Original no existe','Busca original',     N'iTicketPOS=''999999999'' and iNroPV=''' + @pvs + N''' order by iSQLId desc'),
('PV desconocido',    'Existe el PV',       N'iNroPV=''-1''');

DECLARE @res TABLE (id int, ms int, resultado varchar(60));
DECLARE @i int = 1, @n int, @w nvarchar(400), @ini datetime2, @r varchar(60);
SELECT @n = COUNT(*) FROM @casos;
WHILE @i <= @n
BEGIN
	SELECT @w = filtro FROM @casos WHERE id = @i;
	SET @ini = SYSDATETIME();
	BEGIN TRY
		EXEC (N'SELECT TOP 1 * INTO #r FROM dbo.oTrx WHERE ' + @w);
		SET @r = 'OK';
	END TRY
	BEGIN CATCH
		SET @r = CASE WHEN ERROR_NUMBER() = 1222 THEN 'BLOQUEADA' ELSE 'ERROR ' + CAST(ERROR_NUMBER() AS varchar(10)) END;
	END CATCH
	INSERT @res VALUES (@i, DATEDIFF(ms, @ini, SYSDATETIME()), @r);
	SET @i += 1;
END

-- 1) BuscarOperacionOriginal searches: all should be well under 100 ms
SELECT c.caso, c.consulta, r.ms, r.resultado,
       CASE WHEN r.resultado <> 'OK' OR r.ms >= 1000 THEN 'REVISAR' WHEN r.ms >= 100 THEN 'MIRAR' ELSE 'BIEN' END AS estado
FROM @casos c JOIN @res r ON r.id = c.id ORDER BY c.id;

-- 2) Bloated tables: a high kb_por_fila points to empty pages. Heaps cannot release them.
SELECT TOP 10 OBJECT_NAME(ps.object_id) AS tabla,
       CASE WHEN MAX(CASE WHEN ps.index_id = 0 THEN 1 ELSE 0 END) = 1 THEN 'HEAP' ELSE 'CLUSTERED' END AS tipo,
       SUM(CASE WHEN ps.index_id IN (0,1) THEN ps.row_count ELSE 0 END) AS filas,
       SUM(CASE WHEN ps.index_id IN (0,1) THEN ps.reserved_page_count ELSE 0 END)/128 AS mb_datos,
       SUM(ps.reserved_page_count)/128 AS mb_con_indices,
       SUM(CASE WHEN ps.index_id IN (0,1) THEN ps.reserved_page_count ELSE 0 END)*8
         / NULLIF(SUM(CASE WHEN ps.index_id IN (0,1) THEN ps.row_count ELSE 0 END),0) AS kb_por_fila
FROM sys.dm_db_partition_stats ps
WHERE OBJECTPROPERTY(ps.object_id,'IsUserTable') = 1
GROUP BY ps.object_id
ORDER BY mb_datos DESC;

-- 3) Blocking right now: should return no rows
SELECT r.session_id, r.blocking_session_id, r.wait_type, r.wait_time/1000 AS espera_seg, r.command,
       DB_NAME(r.database_id) AS base, s.host_name, s.program_name
FROM sys.dm_exec_requests r
JOIN sys.dm_exec_sessions s ON s.session_id = r.session_id
WHERE r.blocking_session_id <> 0;

-- 4) Open transactions older than 60 s: should return no rows
SELECT st.session_id, s.host_name, s.program_name, at.transaction_begin_time,
       DATEDIFF(second, at.transaction_begin_time, GETDATE()) AS abierta_seg
FROM sys.dm_tran_session_transactions st
JOIN sys.dm_tran_active_transactions at ON at.transaction_id = st.transaction_id
JOIN sys.dm_exec_sessions s ON s.session_id = st.session_id
WHERE DATEDIFF(second, at.transaction_begin_time, GETDATE()) > 60;

-- 5) Slowest statements since the plan cache started. Review the text before sharing it.
SELECT TOP 10 qs.execution_count AS veces,
       qs.total_elapsed_time/qs.execution_count/1000 AS prom_ms,
       qs.max_elapsed_time/1000 AS max_ms,
       qs.total_logical_reads/qs.execution_count AS prom_lecturas,
       qs.last_execution_time AS ultima,
       SUBSTRING(t.text, qs.statement_start_offset/2 + 1, 120) AS sql_texto
FROM sys.dm_exec_query_stats qs
CROSS APPLY sys.dm_exec_sql_text(qs.sql_handle) t
WHERE (t.dbid = DB_ID() OR t.text LIKE '%oTrx%')
  AND t.text NOT LIKE '%UltraHost health check%'
ORDER BY qs.max_elapsed_time DESC;

-- 6) Indexes SQL Server thinks are missing: a hint to evaluate, not an order
SELECT TOP 10 OBJECT_NAME(d.object_id) AS tabla, s.user_seeks, CAST(s.avg_user_impact AS int) AS impacto_pct,
       d.equality_columns, d.inequality_columns, d.included_columns
FROM sys.dm_db_missing_index_details d
JOIN sys.dm_db_missing_index_groups g ON g.index_handle = d.index_handle
JOIN sys.dm_db_missing_index_group_stats s ON s.group_handle = g.index_group_handle
WHERE d.database_id = DB_ID()
ORDER BY s.user_seeks * s.avg_user_impact DESC;

8. Consideraciones

9. Reversión

Requiere una ventana sin operaciones. La tabla vuelve a ser heap, sin recuperar las páginas vacías originales.

DROP INDEX IX_oTrx_PV_Ticket ON dbo.oTrx;
DROP INDEX IX_oTrx_TicketOri ON dbo.oTrx;
DROP INDEX CX_oTrx_iSQLId ON dbo.oTrx;

10. Pendiente y sin confirmar

  1. Prueba desde el POS: una compra y una búsqueda de original.
  2. Repetir el chequeo en horario pico. El de las 00:19 fue en horario de baja operación.
  3. Observar la operación normal durante algunos días. Que oTrx fuera la causa de los 30 minutos no queda demostrado hasta confirmar que las demoras no vuelven.
  4. Si reaparecen, capturar la consulta de bloqueos durante el evento.
  5. Revisar una vez por mes avg_page_space_used_in_percent.

No se vio la salida de REORGANIZE ni el backup previo al cambio. La fragmentación final sí está medida.