Artículo técnico

HotXLS: escritura en streaming para lotes en servidor Delphi

Supongamos que un servicio Delphi nocturno genera un XLSX por cliente, unos cientos de archivos, algunos de ellos con 400.000 filas. Al perfilarlo, la sorpresa rara vez está en el bucle que rellena las celdas. Está en la llamada a SaveAs. Con el escritor predeterminado, cada hoja de cálculo se serializa en una única cadena XML en memoria antes de que esa cadena se comprima dentro del zip OOXML, y en una hoja ancha la cadena transitoria puede empequeñecer el modelo de celdas a partir del cual se construyó. Así que un trabajo que construye sus datos con holgura y se queda en 800 MB se disparará por encima del límite de 2 GB del contenedor durante el guardado, y el OOM killer presentará el informe de error a las 03:00, cuando nadie está mirando. HotXLS, la biblioteca nativa de hojas de cálculo de losLab para Delphi y C++Builder, tiene una propiedad dirigida precisamente a ese pico: StreamingWrite. A su alrededor hay otras dos palancas que deciden si un worker por lotes se mantiene dentro de su presupuesto de memoria y de tiempo, a saber, los callbacks de escritura por fila y la forma en que se comporta el pool de estilos dentro de un bucle cerrado

Qué almacena en búfer la ruta de guardado predeterminada, y qué cambia StreamingWrite

El escritor XLSX predeterminado favorece la simplicidad. Renderiza el XML de la hoja por completo y luego entrega la cadena terminada al compresor zip. Es la elección correcta para la inmensa mayoría de los libros, donde el XML de toda la hoja cabe en unos pocos megabytes. Deja de serlo cuando la forma serializada de una sola hoja alcanza cientos de megabytes. El XML de hoja de cálculo es verboso: cada celda numérica cuesta decenas de caracteres de marcado, y la cadena que lo contiene todo tiene que ser contigua. En una gráfica de memoria la firma es difícil de pasar por alto. Una meseta larga y plana mientras se rellenan las filas, luego un pico triangular agudo durante SaveAs, y después el desplome una vez que se vuelca el zip

Establecer Book.StreamingWrite := True cambia SaveAs a un escritor de hojas que emite el XML de la hoja directamente en el flujo zip a medida que se genera. La cadena intermedia nunca llega a asignarse, y el pico triangular se aplana hasta confundirse con el ruido

Sea preciso sobre lo que eso compra realmente, porque exagerarlo conduce a planes de capacidad equivocados. El indicador solo cambia la ruta de guardado. Construir el libro sigue asignando el modelo de celdas completo en memoria, así que la meseta durante la fase de relleno es exactamente igual de alta que antes. Lo que desaparece es el pico de serialización que antes se apilaba sobre esa meseta en el momento de guardar, y para un trabajo que rellena 400k filas ese pico es habitualmente toda la diferencia entre caber en un presupuesto de memoria y reventarlo. La propiedad tiene False como valor predeterminado para conservar el comportamiento histórico, así que activarla es una línea explícita que usted escribe a propósito

Memoria de un lote Delphi a lo largo del tiempo con HotXLS: el SaveAs predeterminado apila un pico transitorio de la cadena XML de la hoja sobre la meseta de relleno, mientras que Book.StreamingWrite := True mantiene el perfil plano durante el guardado
La meseta de relleno es idéntica en ambos casos porque el modelo de celdas se sigue construyendo en memoria; StreamingWrite elimina solo el pico de serialización del guardado

Una exportación masiva con el indicador activado

Book := TXLSXWorkbook.Create;
try
  BoldIdx := Book.Fonts.Add('Calibri', 11, True, False); // índice del pool, base 0
  Sheet := Book.Sheets.Add('Bulk');
  for R := 1 to 100000 do
  begin
    Sheet.Cells[R, 1].Value := R;
    Sheet.Cells[R, 2].Value := 'Row ' + IntToStr(R);
    Sheet.Cells[R, 3].Value := R * 1.5;
    if (R mod 1000) = 0 then
      Sheet.Cells[R, 2].FontIndex := BoldIdx + 1;        // base 1 en la celda
  end;
  Book.StreamingWrite := True;   // emite el XML de la hoja directamente al zip
  Book.SaveAs('bulk.xlsx');
finally
  Book.Free;
end;

Cells[R, C] crea las celdas bajo demanda, lo que mantiene limpio el cuerpo del bucle. Conviene memorizar dos topes de la cuadrícula: 1.048.576 filas y 16.384 columnas, expuestos como XlsxMaxRow y XlsxMaxCol. Un flujo de datos que desborde el tope de filas tiene que repartirse entre varias hojas en su propio código. Nada aguas abajo advierte el desbordamiento ni lo corrige por usted, y el archivo simplemente acaba truncado en el límite

Rellenar filas sin la sobrecarga de Variant por celda

Cada asignación Cells[R, C].Value paga una búsqueda de celda y una conversión de Variant. Con diez mil filas nadie lo nota. Con un millón de filas de veinte columnas cada una, esa sobrecarga por llamada se convierte en el coste dominante de la fase de relleno, y el perfilador la señalará directamente. Las interfaces por lotes le permiten entregar al escritor una fila entera de cada vez. WriteRows dirige un callback que suministra una fila por invocación:

Flujo del callback WriteRows de HotXLS en Delphi: un cursor de consulta entrega una fila por llamada al callback FillRow, que rellena un array variant de valores o activa Skip y Cancel, y la hoja se rellena fila a fila
WriteRows cede el bucle a HotXLS mientras el callback suministra una fila en forma de array variant por invocación, con Skip como exclusión por fila y Cancel como parada limpia de toda la ejecución
procedure TBulkExporter.FillRow(Sender: TObject; SheetIndex, Row, FirstCol,
  LastCol: Integer; var Values: Variant; var Skip: Boolean;
  var Cancel: Boolean);
begin
  if not FReader.Next then
  begin
    Cancel := True;              // fuente de datos agotada: detener limpiamente
    Exit;
  end;
  Values := VarArrayCreate([FirstCol, LastCol], varVariant);
  Values[FirstCol]     := FReader.RecordId;
  Values[FirstCol + 1] := FReader.CustomerName;
  Values[FirstCol + 2] := FReader.Amount;
end;

// rellena las filas 2..100001, columnas A..C, extrayendo del lector
Sheet.WriteRows(2, 1, 100001, 3, FillRow);

El indicador Cancel es lo que convierte un rango fijo de filas en «hasta N filas», que es la forma natural cuando el número de filas procede de una consulta que aún no ha terminado de ejecutar. Skip es el toque más ligero: deja vacía una fila individual sin detener la ejecución. Más allá de rellenar celdas, el callback resulta ser un buen hogar para las preocupaciones operativas que, de otro modo, se atornillan a un bucle de relleno de formas incómodas. Un contador de progreso que avanza cada mil filas, un token de cancelación consultado desde el planificador de trabajos, un limitador de velocidad en las lecturas de la base de datos de origen: todo vive en un solo lugar en vez de enhebrarse por el código que escribe celdas. En el lado de la lectura, ForEachRow y ForEachCell reflejan el mismo patrón, lo que importa cuando un trabajo por lotes consume y produce a la vez archivos grandes

Los pools de estilos recompensan sacarlos del bucle

El modelo de estilos de XLSX es un conjunto de pools compartidos. Fonts.Add, Fills.AddSolid y Borders.Add devuelven todos un índice de pool en base 0, y una celda referencia una fuente almacenando ese índice más uno en FontIndex, donde el cero está reservado para el valor predeterminado del libro. El +1 está justo ahí en el ejemplo masivo de arriba. Olvídelo y la celda adoptará silenciosamente el estilo equivocado, porque un error de uno en un índice del pool de estilos sigue siendo un índice válido y nada lanza una excepción

La disciplina que se deriva es crear cada objeto de estilo antes del bucle de filas y referenciar su índice dentro del bucle. Fonts.Add deduplica las definiciones idénticas, así que llamarlo una vez por fila solo desperdicia CPU. Alignments.Add es la trampa, porque devuelve una entrada nueva en cada llamada. Dentro de un bucle de 100k filas eso sepulta styles.xml bajo cien mil registros de alineación duplicados, lo que hincha el archivo en disco y ralentiza cada apertura posterior en Excel mientras se vuelven a analizar los duplicados. Construya cada estilo una vez fuera del bucle y luego referencie su índice tantas veces como necesite

Flujos, directorios temporales y el bucle por lotes que lo envuelve todo

Nada de esto requiere un sistema de archivos. Ambas fachadas incluyen sobrecargas de TStream en toda su superficie de E/S, entre ellas Open, SaveAs, SaveAsCSV, SaveAsHTML y SaveAsODS, así que un worker por lotes puede renderizar directamente en un TMemoryStream destinado al almacenamiento de blobs o a una respuesta HTTP sin tocar nunca el disco. Hay una arista afilada que recordar. SaveAs(Stream) escribe desde la posición actual del flujo y no lo rebobina después, así que establezca usted mismo Position := 0 antes de entregar el flujo a lo que vaya a servirlo, o el consumidor leerá cero bytes. La fachada XLS añade dos mandos propios. SetTempDir dirige los archivos temporales del escritor BIFF a un volumen que tenga el espacio y el margen de E/S para absorberlos, lo que importa en servidores donde la ruta temporal predeterminada está en un disco de sistema escaso. UseSharedFormulas pliega los cuerpos de fórmula repetidos en grupos compartidos, una reducción de tamaño real para la forma clásica de informe en la que una fórmula se copia hacia abajo por toda una columna

El propio bucle por lotes se mantiene aburrido a propósito:

for FileName in SourceFiles do
begin
  Book := TXLSXWorkbook.Create;        // instancia nueva: sin fugas de estado
  try
    Book.StreamingWrite := True;
    if Book.Open(FileName) <> 1 then
      Continue;                        // una entrada mala no debe matar el lote
    Book.SaveAsCSV(ChangeFileExt(FileName, '.csv'), 0, ',');
  finally
    Book.Free;
  end;
end;

Una instancia de libro nueva por archivo cuesta microsegundos y elimina toda una categoría de errores de contaminación entre archivos: los estilos, los nombres definidos y las propiedades del documento del archivo 17 no tienen ninguna vía para filtrarse al archivo 18. El saltar y continuar ante un Open fallido se gana el sueldo igualmente, porque una subida truncada en un lote de 600 archivos debería costarle una sola línea de registro y no el resto de la ejecución. También merece la pena señalar lo que el tramo CSV deliberadamente no hace. SaveAsCSV escribe las fórmulas como texto literal y nunca las evalúa, así que un lote de conversión cuyos consumidores esperan números calculados tiene que ejecutar Calculate primero sobre las celdas pertinentes, o partir de libros que ya lleven resultados en caché de un cálculo anterior

Modelo de concurrencia: un libro por hilo

Los objetos de ninguna de las dos fachadas son seguros para hilos, y el diseño nunca pretendió lo contrario. Como no hay estado global compartido entre instancias, la regla de escalado es simplemente un libro por hilo de trabajo, sin compartir ningún libro entre hilos. Un pool de N workers, cada uno dueño de su propio TXLSXWorkbook, escala de forma casi lineal hasta que la memoria se convierte en el techo, y a ese techo se le puede poner un número: el modelo de celdas concurrente más grande multiplicado por el número de workers, más la sobrecarga de guardado que StreamingWrite haya aplanado. Cuando la cola se llena, aplique contrapresión en la cola de trabajos y no dentro del escritor. Un hilo hambriento que ha escrito la mitad de un libro no ha producido nada útil, mientras que un trabajo que esperó unos segundos a un worker libre se completa intacto

Modelo de concurrencia de HotXLS para trabajos por lotes en servidor Delphi: una cola de trabajos alimenta hilos de trabajo que poseen cada uno una instancia privada de TXLSXWorkbook, con contrapresión aplicada en la cola y la memoria como techo de escalado
Las instancias de libro no comparten ningún estado global, así que un libro por hilo escala hasta que los modelos de celdas concurrentes alcanzan el techo de memoria

Para la visión de ajuste más amplia, incluidas las fórmulas compartidas, la omisión de gráficos en el lado de la lectura y las palancas específicas de XLS, consulte la guía de rendimiento para libros grandes. Los trabajos por lotes cuyas filas salen directamente de una consulta se tratan por separado en los patrones de exportación de bases de datos para informes Delphi

HotXLS se compila dentro de su servicio Delphi o C++Builder como Object Pascal nativo sin dependencias externas; las ediciones y las licencias están en la página del producto HotXLS Delphi Component