Artykuł techniczny

Zapis strumieniowy HotXLS w zadaniach wsadowych Delphi

Powiedzmy, że nocna usługa w Delphi generuje jeden XLSX na klienta, kilkaset plików, część z nich szerokich na 400000 wierszy. Sprofiluj to, a niespodzianką rzadko jest pętla wypełniająca komórki. To wywołanie SaveAs. Przy domyślnym zapisującym każdy arkusz jest serializowany do jednego ciągu XML w pamięci, zanim ten ciąg zostanie skompresowany do zipa OOXML, a dla szerokiego arkusza ten tymczasowy ciąg potrafi przyćmić model komórek, z którego powstał. Więc zadanie, które komfortowo buduje swoje dane i siedzi na 800 MB, przy zapisie skoczy powyżej limitu 2 GB kontenera, a zabójca OOM składa raport o błędzie o 3:00, gdy nikt nie patrzy. HotXLS, natywna biblioteka arkuszowa losLab dla Delphi i C++Buildera, ma właściwość wycelowaną wprost w ten skok: StreamingWrite. Wokół niej siedzą dwie dalsze dźwignie decydujące o tym, czy proces wsadowy zostanie w swoim budżecie pamięci i czasu, mianowicie wywołania zwrotne zapisu na poziomie wiersza i to, jak pula stylów zachowuje się wewnątrz ciasnej pętli

Co buforuje domyślna ścieżka zapisu i co zmienia StreamingWrite

Domyślny zapisujący XLSX stawia na prostotę. Renderuje XML arkusza w całości, a potem podaje gotowy ciąg kompresorowi zip. To właściwy kompromis dla przytłaczającej większości skoroszytów, gdzie XML całego arkusza mieści się w kilku megabajtach. Przestaje być właściwy, gdy zserializowana postać jednego arkusza sięga setek megabajtów. XML arkuszowy jest gadatliwy: każda komórka liczbowa kosztuje dziesiątki znaków znaczników, a ciąg trzymający to wszystko musi być ciągły. Na wykresie pamięci sygnatura jest nie do przeoczenia. Długie płaskie plateau, gdy wiersze się wypełniają, potem ostry trójkątny skok podczas SaveAs, potem zapaść, gdy zip zostanie wypchnięty

Ustawienie Book.StreamingWrite := True przełącza SaveAs na zapisujący arkusze, który emituje XML arkusza wprost do strumienia zip w miarę jego generowania. Pośredni ciąg nigdy nie zostaje zaalokowany, a trójkątny skok spłaszcza się do poziomu szumu

Bądź precyzyjny co do tego, co to naprawdę daje, bo przesadzanie z obietnicami prowadzi do błędnych planów pojemności. Flaga zmienia tylko ścieżkę zapisu. Budowanie skoroszytu wciąż alokuje pełny model komórek w pamięci, więc plateau w fazie wypełniania jest dokładnie tak samo wysokie jak wcześniej. Znika skok serializacji, który dawniej piętrzył się na tym plateau w chwili zapisu, a dla zadania wypełniającego 400 tysięcy wierszy ten skok rutynowo stanowi całą różnicę między zmieszczeniem się w budżecie pamięci a jego wysadzeniem. Właściwość ma domyślnie False, by zachować historyczne zachowanie, więc wejście w nią to jedna jawna linia, którą piszesz świadomie

Pamięć zadania wsadowego w Delphi w czasie z HotXLS: domyślne SaveAs piętrzy tymczasowy skok ciągu XML arkusza na plateau wypełniania, podczas gdy Book.StreamingWrite := True utrzymuje profil płaski przez cały zapis
Plateau wypełniania jest identyczne w obu przypadkach, bo model komórek nadal budowany jest w pamięci; StreamingWrite usuwa tylko skok serializacji z chwili zapisu

Eksport masowy z włączoną flagą

Book := TXLSXWorkbook.Create;
try
  BoldIdx := Book.Fonts.Add('Calibri', 11, True, False); // indeks puli, od zera
  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;        // od jedynki po stronie komórki
  end;
  Book.StreamingWrite := True;   // strumieniuj XML arkusza prosto do zipa
  Book.SaveAs('bulk.xlsx');
finally
  Book.Free;
end;

Cells[R, C] tworzy komórki na żądanie, co utrzymuje ciało pętli czystym. Dwa limity siatki warto zapamiętać: 1048576 wierszy i 16384 kolumny, wystawione jako XlsxMaxRow i XlsxMaxCol. Strumień danych przekraczający limit wierszy musi zostać podzielony na arkusze przez twój własny kod. Nic niżej w łańcuchu nie zauważa przekroczenia ani nie naprawia go za ciebie, a plik po prostu kończy się ucięty na limicie

Wypełnianie wierszy bez narzutu Variantów na każdej komórce

Każde przypisanie Cells[R, C].Value płaci za wyszukanie komórki i konwersję Variantu. Przy dziesięciu tysiącach wierszy nikt tego nie zauważa. Przy milionie wierszy po dwadzieścia kolumn każdy ten narzut na wywołanie staje się dominującym kosztem fazy wypełniania, a profiler wskaże wprost na niego. Interfejsy wsadowe pozwalają podać zapisującemu cały wiersz naraz. WriteRows napędza wywołanie zwrotne dostarczające jeden wiersz na wywołanie:

Przepływ wywołania zwrotnego WriteRows w HotXLS w Delphi: kursor zapytania podaje jeden wiersz na wywołanie do procedury FillRow, która wypełnia tablicę wariantową wartościami albo podnosi Skip i Cancel, a arkusz wypełnia się wiersz po wierszu
WriteRows oddaje pętlę do HotXLS, a wywołanie zwrotne dostarcza jeden wiersz tablicy wariantowej na wywołanie, ze Skip jako rezygnacją z pojedynczego wiersza i Cancel jako czystym zatrzymaniem całego przebiegu
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;              // źródło danych wyczerpane: zatrzymaj czysto
    Exit;
  end;
  Values := VarArrayCreate([FirstCol, LastCol], varVariant);
  Values[FirstCol]     := FReader.RecordId;
  Values[FirstCol + 1] := FReader.CustomerName;
  Values[FirstCol + 2] := FReader.Amount;
end;

// wypełnij wiersze 2..100001, kolumny A..C, pobierając z czytnika
Sheet.WriteRows(2, 1, 100001, 3, FillRow);

Flaga Cancel jest tym, co zamienia stały zakres wierszy w "do N wierszy", czyli w naturalny kształt, gdy liczba wierszy pochodzi z zapytania, którego nie skończyłeś wykonywać. Skip to lżejsze dotknięcie: zostawia pojedynczy wiersz pustym, nie zatrzymując przebiegu. Poza wypełnianiem komórek wywołanie zwrotne okazuje się dobrym domem dla spraw operacyjnych, które inaczej doczepia się do pętli wypełniającej w niezręczny sposób. Licznik postępu tykający co tysiąc wierszy, żeton anulowania odpytywany z harmonogramu zadań, ogranicznik tempa odczytów ze źródłowej bazy danych: wszystko to mieszka w jednym miejscu, zamiast być przewlekane przez kod zapisujący komórki. Po stronie odczytu ForEachRow i ForEachCell odzwierciedlają ten sam wzorzec, co ma znaczenie, gdy zadanie wsadowe zarówno konsumuje, jak i produkuje duże pliki

Pule stylów nagradzają wynoszenie poza pętlę

Model stylowania XLSX to zestaw współdzielonych pul. Fonts.Add, Fills.AddSolid i Borders.Add zwracają indeks puli liczony od zera, a komórka odwołuje się do czcionki, przechowując ten indeks plus jeden w FontIndex, gdzie zero jest zarezerwowane dla domyślnej wartości skoroszytu. To +1 stoi wprost w powyższym przykładzie masowym. Zapomnij o nim, a komórka po cichu bierze niewłaściwy styl, bo błąd o jeden w indeksie puli stylów nadal jest poprawnym indeksem i nic się nie podnosi

Wynikającą z tego dyscypliną jest tworzenie każdego obiektu stylu przed pętlą wierszy i odwoływanie się do jego indeksu wewnątrz pętli. Fonts.Add deduplikuje identyczne definicje, więc wołanie go raz na wiersz marnuje tylko procesor. Alignments.Add jest pułapką, bo zwraca świeży wpis przy każdym wywołaniu. Wewnątrz pętli o 100 tysiącach wierszy to zasypuje styles.xml setką tysięcy duplikatów rekordów wyrównania, co rozdyma plik na dysku i spowalnia każde późniejsze otwarcie w Excelu, gdy duplikaty są parsowane od nowa. Zbuduj każdy styl raz poza pętlą, a potem odwołuj się do jego indeksu tyle razy, ile trzeba

Strumienie, katalogi tymczasowe i pętla wsadowa wokół tego wszystkiego

Nic z tego nie wymaga systemu plików. Obie fasady niosą przeciążenia z TStream w całej swojej powierzchni wejścia-wyjścia, między innymi Open, SaveAs, SaveAsCSV, SaveAsHTML i SaveAsODS, więc proces wsadowy może renderować prosto do TMemoryStream przeznaczonego do magazynu obiektów albo do odpowiedzi HTTP, nigdy nie dotykając dysku. Jest tu jedna ostra krawędź do zapamiętania. SaveAs(Stream) zapisuje od bieżącej pozycji strumienia i potem go nie przewija, więc ustaw Position := 0 sam, zanim podasz strumień temu, co go dostarcza, albo konsument odczyta zero bajtów. Fasada XLS dokłada dwa własne pokrętła. SetTempDir kieruje pliki tymczasowe zapisującego BIFF na wolumen, który ma miejsce i zapas wydajności wejścia-wyjścia, by je wchłonąć, co ma znaczenie na serwerach, gdzie domyślna ścieżka tymczasowa siedzi na ciasnym dysku systemowym. UseSharedFormulas składa powtarzające się ciała formuł w grupy współdzielone, co realnie zmniejsza rozmiar w klasycznym kształcie raportu, gdzie jedna formuła jest kopiowana przez całą kolumnę

Sama pętla wsadowa celowo pozostaje nudna:

for FileName in SourceFiles do
begin
  Book := TXLSXWorkbook.Create;        // świeża instancja: żadnych wycieków stanu
  try
    Book.StreamingWrite := True;
    if Book.Open(FileName) <> 1 then
      Continue;                        // jedno złe wejście nie może zabić partii
    Book.SaveAsCSV(ChangeFileExt(FileName, '.csv'), 0, ',');
  finally
    Book.Free;
  end;
end;

Świeża instancja skoroszytu na plik kosztuje mikrosekundy i usuwa całą kategorię błędów skażenia między plikami: style, nazwy zdefiniowane i właściwości dokumentu z pliku 17 nie mają jak przeciec do pliku 18. Pominięcie i kontynuowanie po nieudanym Open zarabia na siebie w tym samym stopniu, bo jedna obcięta wysyłka w sześćsetplikowej partii powinna kosztować cię jedną linię logu, a nie resztę przebiegu. Warto zaznaczyć też to, czego odcinek CSV celowo nie robi. SaveAsCSV wypisuje formuły jako dosłowny tekst i nigdy ich nie wylicza, więc partia konwersji, której konsumenci oczekują policzonych liczb, musi najpierw uruchomić Calculate na odpowiednich komórkach albo wystartować ze skoroszytów, które już niosą buforowane wyniki z wcześniejszego obliczenia

Model współbieżności: jeden skoroszyt na wątek

Obiekty żadnej z fasad nie są bezpieczne wątkowo i projekt nigdy nie udawał inaczej. Ponieważ między instancjami nie ma współdzielonego stanu globalnego, reguła skalowania brzmi po prostu: jeden skoroszyt na wątek roboczy, bez dzielenia skoroszytu między wątkami. Pula N procesów roboczych, z których każdy ma własny TXLSXWorkbook, skaluje się blisko liniowo, dopóki sufitem nie stanie się pamięć, a ten sufit można ująć liczbą: największy równoczesny model komórek pomnożony przez liczbę procesów roboczych plus ten narzut z chwili zapisu, który spłaszczył StreamingWrite. Kiedy kolejka robi się głęboka, nakładaj przeciwciśnienie na kolejce zadań, a nie wewnątrz zapisującego. Zagłodzony wątek, który zapisał skoroszyt do połowy, nie wyprodukował nic użytecznego, podczas gdy zadanie, które poczekało kilka sekund na wolny proces roboczy, kończy się w całości

Model współbieżności HotXLS dla zadań wsadowych na serwerze w Delphi: kolejka zadań zasila wątki robocze, z których każdy ma prywatną instancję TXLSXWorkbook, z przeciwciśnieniem nakładanym na kolejce i pamięcią jako sufitem skalowania
Instancje skoroszytu nie dzielą żadnego stanu globalnego, więc jeden skoroszyt na wątek skaluje się, dopóki równoczesne modele komórek nie sięgną sufitu pamięci

Szerszy obraz strojenia, wraz z formułami współdzielonymi, pomijaniem grafiki po stronie odczytu i dźwigniami specyficznymi dla XLS, znajdziesz w przewodniku po wydajności dużych skoroszytów. Zadania wsadowe, których wiersze pochodzą wprost z zapytania, omawiają osobno wzorce eksportu z bazy danych do raportów w Delphi

HotXLS kompiluje się do twojej usługi w Delphi albo C++Builderze jako natywny Object Pascal bez zewnętrznych zależności; edycje i licencjonowanie są na stronie produktu HotXLS Delphi Component