惯性聚合 高效追踪和阅读你感兴趣的博客、新闻、科技资讯
阅读原文 在惯性聚合中打开

推荐订阅源

雷峰网
雷峰网
宝玉的分享
宝玉的分享
I
InfoQ
P
Privacy International News Feed
V
V2EX
IT之家
IT之家
S
SegmentFault 最新的问题
D
Darknet – Hacking Tools, Hacker News & Cyber Security
V2EX - 技术
V2EX - 技术
C
CERT Recently Published Vulnerability Notes
C
Check Point Blog
The Register - Security
The Register - Security
爱范儿
爱范儿
博客园 - 三生石上(FineUI控件)
AWS News Blog
AWS News Blog
M
MIT News - Artificial intelligence
C
Cyber Attacks, Cyber Crime and Cyber Security
F
Fortinet All Blogs
B
Blog
N
Netflix TechBlog - Medium
B
Blog RSS Feed
freeCodeCamp Programming Tutorials: Python, JavaScript, Git & More
Last Week in AI
Last Week in AI
T
Threatpost
Forbes - Security
Forbes - Security
U
Unit 42
A
Arctic Wolf
K
KPMG report finds enterprise disconnect between AI and its ROI | CIO
P
Palo Alto Networks Blog
Threat Intelligence Blog | Flashpoint
Threat Intelligence Blog | Flashpoint
Recorded Future
Recorded Future
L
Lohrmann on Cybersecurity
Exploit-DB.com RSS Feed
Exploit-DB.com RSS Feed
P
Proofpoint News Feed
月光博客
月光博客
Spread Privacy
Spread Privacy
MongoDB | Blog
MongoDB | Blog
Jina AI
Jina AI
I
Intezer
V
Visual Studio Blog
阮一峰的网络日志
阮一峰的网络日志
The Hacker News
The Hacker News
让小产品的独立变现更简单 - ezindie.com
让小产品的独立变现更简单 - ezindie.com
L
LangChain Blog
CTFtime.org: upcoming CTF events
CTFtime.org: upcoming CTF events
博客园_首页
MyScale Blog
MyScale Blog
腾讯CDC
cs.AI updates on arXiv.org
cs.AI updates on arXiv.org
量子位

Все публикации подряд на Хабре

Ловим музу за клавиатуру: как айтишнику стать автором Что умеет Midjourney в 2026? Мой немного грустный разбор этого шикарного инструмента Никто не любит писать тесты, но ИИ может исправить это IPv8 выглядит как мечта. Поэтому почти наверняка не взлетит Производители вернули в продажу материнки с DDR3. Что происходит? Управление агентом с телефона через Telegram теперь в KodaCode От координации к лидерству: как меняется роль руководителя разработки Я сделала родителям бизнес вместо пенсии: зарабатываем 70 тысяч, мама не даёт продать В три раза быстрее приемка товара и оптимизация трудозатрат на 73%: как «РСТ-Инвент» помог Gulliver Group ИИ-шечный мир победил? О влиянии искусственного интеллекта на игропром Кремль снижает давление на Телеграмм пока Европа строит интернет по паспорту Как CEO, CTO и CIO за 8 часов собрали ИИ-директора, который умеет держать позицию под давлением Как (не) потерять домен за выходные Вместо 8 разных VPS: как я организовал практику студентам на одном сервере Почему твой Open Source проект не замечают? R&D: искусство управления неопределенностью в разработке AI-дефляция: вакансий для разработчиков больше, а рост зарплат — худший за 15 лет Мы отдали управление роботами OpenClaw. Что из этого вышло Галактический ID: система идентификации для всех форм разумной жизни Шесть основ бизнес-анализа: начинаем с вопроса «Кто в игре?» Код-ревью, в котором дело не в коде Данные переехали. Команда — нет Системной подход к сдаче OSWE в 2025 Почему комната управления реактором покрашена в цвет морской пены 4 YAML-файла вместо PySpark: как аналитикам строить пайплайны без разработчиков LLM-агент для поиска свободных доменов: автоматизируем подбор Когда, зачем и как правильно начинать новую сессию в Claude Code? Как я заставил нейросеть писать макросы для FreeCAD Анатомия ИИ‑агента для подбора персонала. От тысячи резюме к топ‑10 за минуты Опыт разработчика как экономика внимания Автономность как точка невозврата: кто будет субъектом в цифровом будущем Обучение ИИ в «диких» условиях: как рутинные действия превращаются в датасеты Как измерить LLM для задач кибербеза: обзор открытых бенчмарков Где хранить код? Сравнение GitHub, GitLab и Bitbucket Математика объясняет, почему нормальное распределение встречается повсюду Почему ваш FinOps не работает: 12 тезисов от практиков Как подписать проектную документацию УКЭП с использованием бесплатных лицензий Pilot Адаптивное администрирование Sigla Vision Я грузил уран в бочки, а потом 20 лет строил ИТ в атомной отрасли Чем позвонить с Эвереста? История и обзор спутниковой связи. Часть 2 Как языковая модель помогает контролировать качество инструктажей по охране труда в металлургии Как не передать на desktop свой IP в РКН Анатомия SAP Privileges: как устроено управление правами в macOS MoneyDev: Сказка про три главных слова Обновлённый токенизатор видео K-VAE 2.0 от Сбера Как сделать диспетчеризацию дома на 1284 квартиры почти бесплатно Как мы разогнали железную дорогу Мы дали агентам рутину. Теперь надо решить — что делать с освободившимся временем Токсичный контент, промпт-хакинг и защита ИИ — всё о Guardrails для LLM Умный город начинается с точного взгляда: как «Фалькон Тех» меняет пространство к лучшему Навайбкодил приложение для анализа графов Почему Дюну так интересно читать? Упрощаем работу с рутиной или как стать Гендальфом Белым Деконструкция Go: CPU, RAM и что там происходит. Go Assembler база. Часть 1.1 Какие профессии исчезнут из-за ИИ, а какие появятся? И что с этим делать Как мы построили IT-отдел, где хочется расти: архитектурные встречи, прозрачные метрики и книжные подарки Rufler: Делаем из Claude Code автономный рой через один YAML-конфиг Sing-box и белый список приложений Как построить надёжный обмен сообщениями в микросервисах: лучшие практики для enterprise OpenAI строит MLM-пирамиду, а McKinsey и Accenture помогают ей в этом Дом, который не построил Фишер (Часть 2) «Сверхзвуковой математик» против «Вдумчивого логиста»: битва алгоритмов 3D-упаковки Мультимодальные модели – грубый и дорогой инструмент Разговоры ничего не стоят. Код тоже Проверки физических лиц: с кого начнет ФНС Топ-10 бесплатных нейросетей для создания видео в 2026 году Первые слои кода: как наши решения сегодня определяют архитектуру ИИ на десятилетия Разработка нового статического анализатора: PVS-Studio JavaScript Поиск уязвимостей ПО: базовый минимум или роскошный максимум Почему оценка персонала не работает как инструмент управления Как мы разработали ИИ-ассистента и сократили рутину продуктовой команды на 50% Как я ушел из найма, нажарил косточек и продал на маркетплейсах на 168 млн в год Когда 1С:ERP уже внедрена, а нормального производственного плана всё ещё нет Как я сделал Claude мультимодальным, подключив к нему Qwen Omni Как приглашение на вакансию мечты превращается в атаку Infrastructure as Code: философия и лучшие практики IaC Тестируем Yandex Code Assistant на задаче, в которой нужно хранить секреты nxs-universal-chart v3.0: новое поколение универсального Helm-чарта Callback Injection: Техника, которая отправила Microsoft Defender в глухой нокаут «Все идеи на стол»: митап как способ вывести проект из тупика Сегодня я узнал нечто новое о GPU благодаря багу в своей игре Как заставить LLM ̶ ̶г̶а̶л̶л̶ю̶ ̶ эволюционировать Карта событий как фундамент аналитики: практический кейс для E-commerce Что выбрать для AI: x86, ARM или RISC-V? Дайджест железа за март Роль соматических мутаций в развитии аутоиммунных заболеваний: путь к избирательной терапии Mythos от Anthropic — тревожный сигнал для всех, а не только для банков Guardrails для LLM на Java: как приручить промпт‑инъекции и токсичные ответы Green-VLA: как мы собрали VLA-модель для реального антропоморфного робота и не потеряли обобщение Финансовая гонка вооружений: почему умные люди добровольно в ней участвуют Эра ИИ-агентов наступила: выбираем лучшего цифрового сотрудника # Практический опыт внедрения WinCC Redundancy на производственном предприятии Сделал MVP за 3 дня, а потом неделю прикручивал оплату. Оно того стоило? Физика против Маска: почему Starship V3 может оказаться ещё одной катастрофой Нефть Венесуэлы: крупнейшие запасы в мире, но не крупнейшая нефтяная держава JPA 4. Переосмысление Hibernate Почему зеркальная фотокамера Nikon D5 десятилетней давности идеально подошла для миссии «Артемида-2» Проект «Уровень-Спутник» или как мы сделали платформу для гидрологов «Замедлиться, чтобы ускориться»: почему ИИ повышает цену ошибок в требованиях и архитектуре Как с нуля поднять трафик IT-компании на 1657% при бюджете 55 тыс. и выжить Pixel-perfect Downsampling — идеальная отрисовка 50 миллионов точек без потерь
Когда bottleneck не в БД: ускоряем генерацию Excel — отчетов в Go
ssharifzoda · 2026-05-14 · via Все публикации подряд на Хабре

Уровень сложностиСредний

Время на прочтение8 мин

Охват и читатели1.5K

Кейс

Всем доброго времени суток!

Недавно пережитый опыт работы с отчетом хочу выставить на всеобщее обозрение,

вдруг кому-то будет полезен. Чтобы не томить и сохранить более технический подход, давайте сразу начнем "разбор полёта".

В какой-то момент у нас появился вот такой запрос:

  • выгрузить Excel-отчёт

  • около 150k строк данных

  • Go + excelize

  • обычный HTTP endpoint

SQL-запросы работали быстро. Индексы были в порядке. Памяти серверу хватало.

Но сам Excel-отчёт генерировался больше минуты.

На локальной машине выгрузка занимала около 16 секунд. На тестовом сервере — 35-40 секунд, после чего nginx начинал отдавать 502 Bad Gateway.

Проблема оказалась совсем не там, где ожидалось.

Bottleneck был:

  • не в БД

  • не в сети

  • не в JSON

  • а в генерации Excel.

Причём основное время уходило не на запись данных, а на:

  • стили

  • XML-сериализацию

  • allocations

  • постоянные вызовы SetCellStyle

  • и создание тысяч объектов внутри циклов.

В статье покажу:

  • почему наивная генерация Excel медленная

  • как SetCellStyle убивает производительность

  • зачем нужен StreamWriter

  • почему StreamWriter ломает шаблоны

  • как совместить потоковую генерацию и Excel template

  • и почему большие Excel-экспорты лучше вообще выносить из HTTP lifecycle.

Наивная реализация

Первое, с чего начинается любая задача подобного рода — это стандартный подход через excelize.NewFile() и последовательное заполнение ячеек.

На этом этапе всё выглядит абсолютно нормально: есть файл, есть цикл по данным, есть запись значений в ячейки.

Пример упрощённой реализации выглядел примерно так:

xlsx := excelize.NewFile()

sheet := "Report"
index, _ := xlsx.NewSheet(sheet)
xlsx.DeleteSheet("Sheet1")

startRow := 4

for i, v := range payments {
    row := startRow + i

    xlsx.SetCellValue(sheet, fmt.Sprintf("A%d", row), i+1)
    xlsx.SetCellValue(sheet, fmt.Sprintf("B%d", row), v.Account)
    xlsx.SetCellValue(sheet, fmt.Sprintf("C%d", row), v.Amount)
    xlsx.SetCellValue(sheet, fmt.Sprintf("D%d", row), v.Reward)
    xlsx.SetCellValue(sheet, fmt.Sprintf("E%d", row), v.ServiceName)
}

На первый взгляд — ничего подозрительного. Обычный код, который делает ровно то, что ожидается: заполняет Excel-таблицу построчно.

Дальше добавляется оформление, потому что “без стилей Excel выглядит плохо”.

И именно здесь начинается деградация производительности:

xlsx.SetCellStyle(sheet, fmt.Sprintf("A%d", row), fmt.Sprintf("D%d", row), someStyle)

И, что важно — это делается внутри цикла на каждую строку.

В итоге одна строка превращается не просто в запись данных, а в:

  • создание координат ячеек

  • применение стилей

  • генерацию XML-узлов внутри файла

  • множество внутренних allocations в библиотеке

На объёмах в 100k+ строк это начинает играть критическую роль.

И если на локальной машине это ещё терпимо, то на сервере под нагрузкой это легко превращается в десятки секунд генерации и, как следствие, timeout на уровне nginx.

Где начинается проблема

Сначала подозрения стандартные — БД, сеть, сериализация. Запустили explain analyse и показатели были хорошими с учетом объема:

Результат анализа
Limit  (cost=2.16..64407.86 rows=155169 width=461) (actual time=0.951..1432.129 rows=157770 loops=1)
  ->  Nested Loop  (cost=2.16..64407.86 rows=155169 width=461) (actual time=0.949..1405.383 rows=157770 loops=1)
        ->  Nested Loop  (cost=1.88..60261.77 rows=155169 width=436) (actual time=0.840..1245.680 rows=157770 loops=1)
              ->  Nested Loop  (cost=1.73..56479.45 rows=155169 width=412) (actual time=0.786..1106.804 rows=157770 loops=1)
                    ->  Nested Loop  (cost=1.59..52987.72 rows=155169 width=388) (actual time=0.728..963.468 rows=157770 loops=1)
                          ->  Nested Loop  (cost=1.43..49112.43 rows=155169 width=354) (actual time=0.676..820.912 rows=157770 loops=1)
                                ->  Nested Loop  (cost=1.15..44973.22 rows=155169 width=314) (actual time=0.503..666.356 rows=157770 loops=1)
                                      ->  Nested Loop  (cost=1.00..41403.40 rows=155169 width=290) (actual time=0.359..530.395 rows=157770 loops=1)
                                            ->  Nested Loop  (cost=0.72..37512.22 rows=155169 width=271) (actual time=0.304..390.222 rows=157770 loops=1)
                                                  ->  Index Scan using idx_payments_export on payments p  (cost=0.43..33525.35 rows=155169 width=220) (actual time=0.199..192.239 rows=157770 loops=1)
                                                        Index Cond: ((created_at >= '2025-07-01 00:00:00+00'::timestamp with time zone) AND (created_at < '2026-04-30 23:59:00+00'::timestamp with time zone))
                                                  ->  Memoize  (cost=0.29..0.31 rows=1 width=59) (actual time=0.000..0.000 rows=1 loops=157770)
                                                        Cache Key: p.dealer_id
                                                        Cache Mode: logical
                                                        Hits: 157443  Misses: 327  Evictions: 0  Overflows: 0  Memory Usage: 54kB
                                                        ->  Index Scan using dealers_pkey on dealers d  (cost=0.28..0.30 rows=1 width=59) (actual time=0.012..0.012 rows=1 loops=327)
                                                              Index Cond: (id = p.dealer_id)
                                            ->  Memoize  (cost=0.29..0.30 rows=1 width=35) (actual time=0.000..0.000 rows=1 loops=157770)
                                                  Cache Key: p.srv_id
                                                  Cache Mode: logical
                                                  Hits: 157718  Misses: 52  Evictions: 0  Overflows: 0  Memory Usage: 8kB
                                                  ->  Index Scan using services_pkey on services s  (cost=0.28..0.29 rows=1 width=35) (actual time=0.042..0.042 rows=1 loops=52)
                                                        Index Cond: (id = p.srv_id)
                                      ->  Memoize  (cost=0.14..0.17 rows=1 width=40) (actual time=0.000..0.000 rows=1 loops=157770)
                                            Cache Key: d.region_id
                                            Cache Mode: logical
                                            Hits: 157765  Misses: 5  Evictions: 0  Overflows: 0  Memory Usage: 1kB
                                            ->  Index Scan using regions_pkey on regions r  (cost=0.13..0.16 rows=1 width=40) (actual time=0.030..0.030 rows=1 loops=5)
                                                  Index Cond: (id = d.region_id)
                                ->  Memoize  (cost=0.29..0.31 rows=1 width=56) (actual time=0.000..0.000 rows=1 loops=157770)
                                      Cache Key: p.user_id
                                      Cache Mode: logical
                                      Hits: 157174  Misses: 596  Evictions: 0  Overflows: 0  Memory Usage: 96kB
                                      ->  Index Scan using tusers_pkey on tusers u  (cost=0.28..0.30 rows=1 width=56) (actual time=0.013..0.013 rows=1 loops=596)
                                            Index Cond: (id = p.user_id)
                          ->  Memoize  (cost=0.15..0.27 rows=1 width=50) (actual time=0.000..0.000 rows=1 loops=157770)
                                Cache Key: d.branch_id
                                Cache Mode: logical
                                Hits: 157745  Misses: 25  Evictions: 0  Overflows: 0  Memory Usage: 4kB
                                ->  Index Scan using branches_pkey on branches b  (cost=0.14..0.26 rows=1 width=50) (actual time=0.015..0.015 rows=1 loops=25)
                                      Index Cond: (id = d.branch_id)
                    ->  Memoize  (cost=0.14..0.16 rows=1 width=40) (actual time=0.000..0.000 rows=1 loops=157770)
                          Cache Key: p.partner_id
                          Cache Mode: logical
                          Hits: 157768  Misses: 2  Evictions: 0  Overflows: 0  Memory Usage: 1kB
                          ->  Index Scan using partners_pkey on partners pr  (cost=0.13..0.15 rows=1 width=40) (actual time=0.028..0.028 rows=1 loops=2)
                                Index Cond: (id = p.partner_id)
              ->  Memoize  (cost=0.15..0.19 rows=1 width=40) (actual time=0.000..0.000 rows=1 loops=157770)
                    Cache Key: s.category_id
                    Cache Mode: logical
                    Hits: 157760  Misses: 10  Evictions: 0  Overflows: 0  Memory Usage: 2kB
                    ->  Index Scan using categories_pkey on categories c  (cost=0.14..0.18 rows=1 width=40) (actual time=0.009..0.009 rows=1 loops=10)
                          Index Cond: (id = s.category_id)
        ->  Memoize  (cost=0.29..0.32 rows=1 width=16) (actual time=0.000..0.000 rows=1 loops=157770)
              Cache Key: p.term_id
              Cache Mode: logical
              Hits: 157180  Misses: 590  Evictions: 0  Overflows: 0  Memory Usage: 70kB
              ->  Index Scan using terminals_pkey on terminals t  (cost=0.28..0.31 rows=1 width=16) (actual time=0.017..0.017 rows=1 loops=590)
                    Index Cond: (id = p.term_id)
Planning Time: 21.857 ms
Execution Time: 1445.455 ms

SQL отрабатывал быстро, данные приходили мгновенно, а общий ответ всё равно занимал 30–40 секунд.

При этом под нагрузкой nginx начинал отдавать 502.

После профилирования стало видно: время уходит не на получение данных, а на генерацию Excel через excelize.

Основной вклад давали операции внутри цикла — особенно SetCellStyle и работа с ячейками.

Стало очевидно: проблема не в данных, а в построении Excel-файла.

Почему Excel оказался медленным

Формат .xlsx — это не “таблица”, а ZIP-архив с набором XML-файлов внутри (недавно сам узнал).

Каждая запись в Excel через excelize фактически превращается в:

  • генерацию XML-узлов

  • упаковку структуры в архив

На небольших объёмах это незаметно.

Но на 100k+ строк начинают проявляться две ключевые проблемы:

1. Работа с ячейками

Когда мы используем методы вроде SetCellValue или SetCellStyle, они изменяют только одну конкретную ячейку. Каждое обращение — это отдельная операция: библиотека открывает внутренний XML‑файл Excel, находит нужную ячейку и вносит правку.

2. Стили внутри цикла
Применение стиля не “кэшируется по строке”, а повторно обрабатывается для каждого вызова.

В итоге генерация становится не просто записью данных, а большим количеством мелких операций над XML-структурой.

И чем больше данных — тем сильнее растёт стоимость не самих данных, а их “обёртки” в Excel.

Первая оптимизация: убираем очевидные потери

После профилирования стало понятно, что проблема не в одном месте, а в накопленных мелочах внутри цикла.

Первым делом убрали самое очевидное:

1. Форматирование строк
fmt.Sprintf в цикле для адресации ячеек оказалось лишним. Его заменили на более дешёвую сборку координат.

2. Повторяющиеся вычисления
Значения вроде статусов и типов терминалов вынесли в map и простые условия, чтобы не пересчитывать их на каждой итерации.

3. Лишние allocations
Слайсы и буферы начали переиспользовать, чтобы снизить нагрузку на GC.

После этого код стал легче, но ключевая проблема осталась — работа с excelize на уровне ячеек и стилей всё ещё доминировала по времени.

Шаг 2: попытка через шаблон

После ускорения генерации данных логичным шагом стало использование Excel-шаблона.

Идея была простая: взять готовый .xlsx файл с оформлением и заполнять его данными, не трогая стили в коде.

Пример инициализации выглядел так:

xlsx, err := excelize.OpenFile("templates/cash-in.xlsx")
if err != nil {
    return nil, err
}

Далее мы заполняли служебные поля (например, период отчёта):

_ = xlsx.SetCellValue(templateSheet, "B2",
    "C: "+dateFrom.Format("02-01-2006 15:04:05"))
_ = xlsx.SetCellValue(templateSheet, "C2",
    "По: "+dateTo.Format("02-01-2006 15:04:05"))

И уже после этого пытались писать данные.

На этом этапе отчёт выглядел правильно — стили и оформление полностью сохранялись.

Но при объёме ~150k строк производительность оставалась проблемой:
генерация по-прежнему занимала десятки секунд.

Шаг 3: переход на StreamWriter

Следующим шагом мы попробовали StreamWriter, чтобы уйти от cell-level API и лишних операций над ячейками.

Базовая схема выглядела так:

sw, err := xlsx.NewStreamWriter("Data")
if err != nil {
    return err
}

row := make([]interface{}, 0, 20)
	for i, v := range payments {
		terminalType := "Тип1"
		if v.TerminalType == 2 {
			terminalType = "Тип2"
		}
		row = row[:0] // очищаем
		row = append(row,
			i+1,
			v.Account,
			v.Amount,
			v.Reward,
			...
		)
		cell, _ := excelize.CoordinatesToCellName(1, startRow+i)
		if err := sw.SetRow(cell, row); err != nil {
			return err
		}
	}

После перехода на потоковую запись:

  • снизилась нагрузка на память

  • ускорилась генерация данных

  • уменьшилось количество операций внутри цикла

Но появился новый эффект: StreamWriter не работает с шаблоном напрямую, и часть оформления терялась.

Это и стало причиной перехода к гибридной схеме.

Финальная схема: шаблон + потоковая генерация

В итоге мы пришли к гибридному решению, которое объединило сильные стороны обоих подходов:

  • шаблон отвечает за внешний вид отчёта

  • StreamWriter — за производительность

Идея заключалась в разделении ответственности:

1. Работа с шаблоном

Шаблон используется как источник оформления и служебных данных:

xlsx, err := excelize.OpenFile("templates/example.xlsx")
if err != nil {
    return nil, err
}

_ = xlsx.SetCellValue("Основной лист", "B2",
    "C: "+dateFrom.Format("02-01-2006 15:04:05"))
_ = xlsx.SetCellValue("Основной лист", "C2",
    "По: "+dateTo.Format("02-01-2006 15:04:05"))

Шаблон сохраняет:

  • заголовки

  • стили

  • ширины колонок

  • оформление отчёта

2. Потоковая запись данных

Данные пишутся отдельно через StreamWriter, без работы со стилями:

sw, err := xlsx.NewStreamWriter("Data")
if err != nil {
    return err
}

for i, v := range payments {
    terminalType := "Тип1"
    if v.TerminalType == 2 {
        terminalType = "Тип2"
    }

    row := []interface{}{
        i + 1,
        v.Account,
        v.Amount,
        ...
    }

    cell, _ := excelize.CoordinatesToCellName(1, startRow+i)
    if err := sw.SetRow(cell, row); err != nil {
        return err
    }
}

3. Связывание шаблона и данных

После генерации данных выполняется финальная сборка:

  • перенос заголовков из шаблона

  • копирование ширин колонок

  • применение стилей

  • замена или переименование sheet

headers, _ := xlsx.GetRows("Основной лист")

if len(headers) >= 3 {
    cell, _ := excelize.CoordinatesToCellName(1, 3)
    _ = xlsx.SetSheetRow("Data", cell, &headers[2])
}

Итоговая архитектура

В результате получили:

  • быстрый потоковый рендер данных

  • сохранение шаблонного оформления

  • стабильное потребление памяти даже на 150k+ строках

Итог

Главный вывод оказался неожиданно простым:

bottleneck в Excel-отчётах чаще всего не в данных, а в способе их записи.

После перехода на гибридную модель генерация стала стабильной и перестала упираться в таймауты nginx даже на больших объёмах.