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.
oTrx1. 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:
| # | Filtro | Objetivo |
|---|---|---|
| 1 | iTicketPOSOri + iNroPV | Verificar si ya tiene anulación |
| 2 | iTicketPOS + iNroPV + filtro de iNroMensaje | Verificar si es anulación o devolución |
| 3 | iTicketPOS + iNroPV + datediff(...) | Límite de días, si MAXDIASDEVOLUCION > 0 |
| 4 | iTicketPOS + iNroPV | Buscar la operación original |
| 5 | iNroPV | Verificar si el punto de venta existe |
2. Diagnóstico previo
- Heap:
oTrxno tenía índice clustered. Sus índices nonclustered no ordenan la tabla ni liberan páginas. - Sin índices útiles: ninguno comenzaba por
iTicketPOSOri,iTicketPOSoiNroPV.IX_oTrx(iSQLId) eIX_oTrx_BusquedaPOS(f41_terminal, ...) no permiten un Seek para estos filtros. - Tabla inflada: 291.275 filas en 1.226.700 páginas (9.585 MB), unos 33 KB por fila, con 3,45% de ocupación promedio. Los datos reales eran unos 330 MB. Forwarded records: 0.
- Lecturas sin protección:
READ_COMMITTED_SNAPSHOTySNAPSHOT ISOLATIONen OFF.ReadSQLno usaNOLOCKni define timeout, así que una lectura bloqueada espera indefinidamente.
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
- Un
DELETEnormal sobre un heap no libera páginas vacías. El código borra filas deoTrx(por ejemplo,trx_reversas.cpp:285), pero no se midió cuánto aporta cada origen. - Una búsqueda sin resultado puede hacer hasta 4 recorridos completos, unos 38 GB. A unos 20 MB/s equivale a unos 30 minutos.
- En
READ COMMITTED, un recorrido completo además puede chocar con cualquier fila bloqueada.
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ón | Motivo |
|---|---|
Clustered en vez de ALTER TABLE ... REBUILD | REBUILD 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 clave | Es identity, creciente y NOT NULL. |
| Dos índices nonclustered | Las consultas filtran por columnas distintas: iTicketPOSOri e iTicketPOS. |
iSQLId DESC en la clave | Resuelve el ORDER BY iSQLId DESC sin ordenar. |
INCLUDE (iNroMensaje, dInsertDate) | Permite evaluar esos filtros sin ir a la tabla por cada fila. |
SET LOCK_TIMEOUT | Aborta si no obtiene el bloqueo, en vez de encolar a los demás. |
SHRINK después del clustered | SHRINK 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
| Medida | Antes | Después |
|---|---|---|
Espacio de datos de oTrx | 9.585 MB | 351 MB |
| KB por fila | 33 | 1 |
| Estructura | Heap | Clustered |
| Archivo de datos | 14.084 MB | 4.096 MB |
| Espacio en la base | 10,2 GB reservados | 782 MB usados |
Fragmentación de índices de oTrx | No medida | Menor 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
- Sin creación online: Standard Edition no permite
ONLINE = ON. Crear un índice bloqueaoTrx, así que cualquier reconstrucción requiere una ventana sin operaciones. - Riesgo durante el bloqueo: una compra aprobada puede quedar sin grabar mientras
oTrxestá bloqueada. El POS puede recibir error por timeout aunque la compra termine aprobada. - Límite del clustered: una página con alguna fila viva sigue asignada. Como
iSQLIdsiempre crece, esos huecos no se reutilizan solos. IX_oTrx: quedó redundante con el clustered y se mantuvo. Se puede evaluar quitarlo después de confirmar que nada lo usa por nombre.- Índices sugeridos: no crear por ahora los que sugiere SQL Server para
mTerminalyoTrxErr. Tienen muy pocos usos, y varias sugerencias deoTrxErrincluyenf2_panyf35_track2. SHRINK: no repetirlo de forma rutinaria, porque fragmenta los índices.
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
- Prueba desde el POS: una compra y una búsqueda de original.
- Repetir el chequeo en horario pico. El de las 00:19 fue en horario de baja operación.
- Observar la operación normal durante algunos días. Que
oTrxfuera la causa de los 30 minutos no queda demostrado hasta confirmar que las demoras no vuelven. - Si reaparecen, capturar la consulta de bloqueos durante el evento.
- 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.