Una hoja de cálculo con un millón de filas y una docena de columnas es una exportación perfectamente normal de un trabajo de reporte de base de datos. Ábrala de la manera habitual, cargando todo el libro en un TXLSWorkbook, y el proceso tiene que materializar cada una de esas doce millones de celdas como un objeto vivo antes de que se ejecute su primera línea de lógica de negocios. El archivo en el disco puede ser sesenta megabytes de XML comprimido. El árbol de objetos en el que se expande es varias veces mayor, y todo tiene que residir a la vez porque el modelo es de acceso aleatorio por diseño. Para un reporte que tiene la intención de leer de arriba a abajo y desechar, eso es una gran cantidad de memoria gastada en una estructura que nunca necesitó
Hay una segunda ruta a través del mismo archivo. En lugar de construir un modelo, escanea el XML de la hoja de cálculo solo hacia adelante, una celda a la vez, y deja que cada celda pase una vez que la haya mirado. Nada se acumula. La memoria se mantiene casi constante independientemente de si la hoja tiene mil filas o diez millones, porque el lector nunca retiene más que la parte que está analizando actualmente más un par de pequeñas tablas de búsqueda. Esto es lo que hace el lector directo de HotXLS, y el resto de este artículo trata sobre por qué se mantiene pequeño y qué le da a cambio
Por qué el modelo en memoria no escala
Un archivo XLSX es un paquete ZIP de partes XML descritas por ECMA-376. Cada hoja de cálculo es su propia parte, xl/worksheets/sheetN.xml, y dentro de ella cada fila es un elemento <row> que contiene elementos de celda <c>. La ruta de carga normal lee esa parte y construye un objeto direccionable para cada celda de modo que luego pueda solicitar Cells[12345, 7] y obtener una respuesta en tiempo constante. El acceso aleatorio es el objetivo principal de un modelo de libro de trabajo, y es exactamente lo que hace conveniente la edición, la evaluación de fórmulas y el estilo
El costo es que el acceso aleatorio requiere que todo esté presente simultáneamente. No puede indexar en una estructura que solo ha construido parcialmente. Por lo tanto, el pico de memoria de una carga completa es una función del recuento de celdas, y en una hoja con millones de celdas pobladas esa función aterriza en un lugar donde su servicio no quiere estar, especialmente si varios de estos trabajos se ejecutan a la vez en un equipo compartido. Cuando el patrón de acceso que realmente necesita es secuencial, pagar por el acceso aleatorio es pagar por una capacidad que no utilizará
Un escaneo SAX de solo avance que no construye ningún árbol
El lector directo abre el paquete ZIP y recorre cada parte de la hoja de cálculo con un analizador de extracción de estilo SAX. SAX aquí significa que el analizador informa los eventos de análisis a medida que los encuentra, un elemento de inicio, una ejecución de texto, un elemento final y luego continúa. No guarda un árbol de nodos detrás de él. El lector rastrea la fila y columna actual desde los atributos r, recopila el tipo de la celda, el índice de estilo, el valor y el texto de la fórmula a medida que llegan los eventos, y cuando se ve la etiqueta de cierre </c>, emite una celda y la olvida. La siguiente celda reutiliza las mismas variables locales
Debido a que no se retiene nada entre celdas, la huella de memoria no crece con el número de celdas. Esa es la propiedad a la que vale la pena aferrarse. Una hoja de doscientas filas y una hoja de veinte millones de filas le cuestan al lector la misma memoria residente, y la diferencia entre ellas es solo el tiempo que dura el escaneo. Usted renuncia al acceso aleatorio, la característica principal del modelo, y a cambio obtiene un límite de memoria que el recuento de celdas no puede superar
Qué se queda residente, y por qué esas dos partes
El escaneo no carece por completo de estado, y las excepciones son instructivas. Se deben mantener dos pequeñas tablas en la memoria mientras dura, porque una celda por sí sola no lleva suficiente información para interpretarse sin ellas
La primera es la tabla de cadenas compartidas. En SpreadsheetML, una celda de texto no almacena su propio texto. Lleva t="s" y una carga numérica que es un índice a xl/sharedStrings.xml, una sola lista deduplicada de cada cadena distinta en el libro. Este es un buen intercambio de espacio para archivos donde las mismas etiquetas se repiten a lo largo de miles de filas, pero significa que el lector tiene que cargar esa tabla de cadenas por adelantado y mantenerla residente, porque cualquier celda en cualquier parte de cualquier hoja puede hacer referencia a cualquier entrada en ella. El tamaño de la tabla se basa en la cantidad de cadenas distintas, no en el recuento de celdas, por lo que se mantiene modesta incluso en hojas enormes
La segunda es el mapeo de formato de número de la parte de estilos. Una celda numérica y una celda de fecha son iguales byte por byte en el cable: ambas son un número simple, porque una fecha en SpreadsheetML es solo un conteo de días en serie. Lo único que las distingue es el estilo de la celda, que apunta a través de cellXfs en xl/styles.xml a un id de formato de número. Para informar una fecha como fecha en lugar de como el número de serie sin procesar, el lector carga esa tabla de estilo a formato y la mantiene residente. Todo lo demás en el archivo, los datos reales de la celda que constituyen la mayor parte de los bytes, se transmiten sin almacenarse
Cada celda informa un tipo y un valor
Cada celda emitida llega como un registro TXLSDirectCell. Lleva el índice y el nombre de la hoja, la fila y la columna base 1, una Kind semántica, el Value como un Variant, el texto Formula sin su signo igual inicial y el StyleIndex sin procesar. El tipo es uno de xdkNumber, xdkString, xdkBoolean, xdkDate o xdkError, por lo que puede ramificar según lo que significa la celda en lugar de volver a derivarlo de los atributos. Una celda de fórmula informa el tipo de su resultado almacenado en caché, con el texto de la fórmula al lado, por lo que un total calculado llega como un número que también le indica cómo se produjo
type
TReportScan = class
procedure OnCell(Sender: TObject; const Cell: TXLSDirectCell;
var Abort: Boolean);
end;
procedure TReportScan.OnCell(Sender: TObject; const Cell: TXLSDirectCell;
var Abort: Boolean);
begin
case Cell.Kind of
xdkString: AccumulateLabel(Cell.Row, Cell.Col, VarToStr(Cell.Value));
xdkNumber: AddToTotals(Cell.Col, Double(Cell.Value));
xdkDate: NoteWhen(Cell.Row, VarToDateTime(Cell.Value));
xdkBoolean: FlagRow(Cell.Row, Boolean(Cell.Value));
xdkError: LogBadCell(Cell.Row, Cell.Col, VarToStr(Cell.Value));
end;
end;
Distinguir una fecha de un número
La cuestión de la fecha merece una mirada más de cerca porque es donde la mayoría de los escáneres ingenuos se equivocan. No hay un tipo de fecha en una celda numérica. Una celda con el valor de serie 46000 podría ser una cantidad, un precio o el 17 de febrero de 2025, y el archivo le dice cuál es solo a través del id del formato de número al que se llega a través del estilo de la celda. ECMA-376 reserva un bloque de identificadores de formato integrados cuyo significado es fijo en todos los productores conformes, y los identificadores que llevan fechas se encuentran en dos rangos: del 14 al 22 para los formatos estándar de fecha y hora, y del 45 al 47 para los formatos de tiempo transcurrido, como [h]:mm:ss. Cuando DetectDates está activado, que lo está de forma predeterminada, el lector resuelve el estilo de cada celda numérica a su id de formato, y una celda cuyo id cae en esos rangos reservados se reporta como xdkDate con su Value ya convertido a un TDateTime de Delphi. También se verifican los formatos personalizados inspeccionando el código de formato en busca de tokens de fecha y hora, pero los rangos reservados son la columna vertebral confiable. Desactive DetectDates y la tabla de estilos ni siquiera se cargará, cada celda numérica aparecerá como xdkNumber y el escaneo será un poco más ligero
Saltar hojas y cancelar temprano
El escaneo secuencial tiene una ventaja silenciosa que el acceso aleatorio no puede igualar: usted puede detenerse. El evento OnSheet se dispara antes de que se abra cada hoja de cálculo, y le da dos interruptores. Configure SkipSheet y toda esa parte nunca se analiza, que es la forma en que escanea solo las hojas que le interesan en un libro de varias hojas sin pagar para leer el resto. Configure Abort y todo el escaneo terminará inmediatamente. El evento OnCell lleva su propio Abort, por lo que puede detenerse en el momento en que haya encontrado lo que estaba buscando, una fila en particular, un valor centinela, el final de un bloque de encabezado, sin leer los millones de celdas restantes. En un escaneo de solo avance, abortar es genuinamente gratuito, porque el trabajo que omite es trabajo que aún no había sucedido
procedure TReportScan.OnSheet(Sender: TObject; SheetIndex: Integer;
const SheetName: WideString; var SkipSheet: Boolean; var Abort: Boolean);
begin
// Escanear solo la hoja "Data"; dejar el resto sin leer
SkipSheet := SheetName <> 'Data';
end;
Contar celdas sin un manejador
Vale la pena destacar un refinamiento reciente porque convierte una pregunta común en una sola llamada económica. El lector cuenta cada celda poblada que pasa, y lo hace sin importar si se adjunta un controlador OnCell. Antes, sin controlador configurado, el recuento de celdas pobladas volvía como cero, ya que el conteo era un efecto secundario de la emisión. Ahora, el conteo es independiente de la emisión. Eso significa que puede hacer una pregunta, cuántas celdas pobladas contiene realmente este libro de trabajo, y obtener la respuesta por el precio de un escaneo sin ninguna devolución de llamada. ReadFile y ReadStream devuelven ese total como un Int64, y el mismo número está disponible después como la propiedad CellCount. Un retorno de -1 indica que el archivo no se pudo abrir o no es un paquete OOXML
var
Reader: TXLSDirectReader;
Populated: Int64;
begin
Reader := TXLSDirectReader.Create;
try
// Sin manejador OnCell: un censo puro de celdas pobladas, sigue siendo una memoria casi constante
Populated := Reader.ReadFile('quarterly_export.xlsx');
if Populated < 0 then
raise Exception.Create('No es un paquete XLSX legible')
else
Writeln(Format('%d celdas pobladas (CellCount = %d)',
[Populated, Reader.CellCount]));
finally
Reader.Free;
end;
end;
Para el escaneo completo, adjunte el controlador y llame a ReadFile exactamente de la misma manera. El contraste con una carga completa es el punto principal: mientras que cargar quarterly_export.xlsx en un libro de trabajo expandiría cada celda en un objeto residente y las mantendría todas, el lector directo retiene solo las cadenas compartidas y la tabla de estilos mientras las doce millones de celdas fluyen a través de su OnCell una a la vez. La aritmética que se ejecutó por celda no deja nada atrás, por lo que el pico de memoria está establecido por el conteo de cadenas distintas del libro, no por su conteo de filas
El lector directo es la herramienta adecuada cuando el trabajo consiste en leer un libro de trabajo grande una vez y extraerlo o resumirlo. Cuando en cambio necesita el acceso aleatorio del modelo completo pero desea que se comporte bien en archivos grandes, el ajuste en nuestras notas sobre el rendimiento de libros grandes en Delphi cubre esa ruta. Y cuando la dirección se invierte, produciendo grandes salidas en lugar de consumirlas, el recorrido de escritura en streaming para trabajos por lotes de servidor aplica la misma disciplina de memoria constante a la escritura. Los tres se envían como parte de HotXLS Component para Delphi y C++Builder, junto con las API de lectura, escritura, fórmulas y formatos cubiertas en otros lugares de este blog