В современных корпоративных системах обработки отчетности (например, XBRL-форматы для регуляторов или банков) одна из ключевых задач э��о эффективное хранение и загрузка больших объёмов структу­рированных данных. В экосистеме .NET Core такие данные часто представлены в виде объектов с комплексными связями, что требует продуманной стратегии сериализации и десериализации.

Одним из таких случаев является работа с объектами ReportItem объединяющими в себе названия бизнес-факты отчета и координаты в гиперкубе (временной охват, измерения и атрибуты). При создании отчетов данные загружаются в память в виде словаря Dictionary<ReportItem, ReportValue>, который связывает каждый объект ReportItem с соответствующим значением факта, так как один факт может быть в нескольких ячейках отчета.

Однако такая реализация при обработке больших XBRL-файлов (до 1 ГБ входных данных) приводит к серьёзной нагрузке на оперативную память до 600 МБ при размере исходного файла 100 МБ. Это ограничивает масштабируемость и повышает риск сбоев при нехватке ресурсов, особенно в окружениях с 4 ГБ ОЗУ и без возможности использовать внешние базы данных.

Цель исследования, поиск способа последовательного (streaming) сохранения и чтения объектов ReportItem, который:

  • Минимизирует использование оперативной памяти,

  • Использует диск как основное хранилище промежуточных данных,

  • Сохраняет совместимость с существующей архитектурой,

  • Не требует привлечения сторонних баз данных.

В рамках работы были проанализированы текущие механизмы сериализации и предложены пять альтернативных подходов, от батчевой обработки и memory-mapped файлов до потоковой сериализации JSON. После сравнения по ресурсо��отреблению, сложности интеграции и устойчивости был выбран оптимальный вариант, потоковая сериализация в JSON, обеспечивающая возможность работы с крупными отчетами без перегрузки памяти.

Что такое ReportItem?

ReportItem - это составной ключ, однозначно идентифицирующий ячейку в отчете. Пример структуры:

public class ReportItem
{
    public string MetricName { get; set; }
    public TimeScope TimeScope { get; set; }
    public List<DimensionValue> Dimensions{ get; set; }
    public List<AttributeValue> Attributes { get; set; }
    // Переопределены Equals и GetHashCode для корректной работы в Dictionary
}

Каждый факт из XBRL преобразуется в пару (ReportItem, ReportValue), где ReportValue содержит значение, единицу измерения и точность.

Что такое ReportItem

ReportValue - это значение ячейки отчета, привязанное к конкретному ReportItem. Оно содержит не только числовое или строковое значение, но и метаданные, необходимые для корректного отображения и валидации в XBRL.

public class ReportValue
{
    public string Value { get; set; }      // Например: "1000000", "true", "Текст"
    public string UnitCode { get; set; }   // Код единицы измерения в таксономии (например, "USD","shares")
    public string Precision { get; set; }  // Точность: "2", "INF", "-3" (для научной нотации)
}

Важно: хотя XBRL формально хранит значения как строки, в прикладной реализации нельзя позволять пользователю вводить произвольные строки там, где ожидаются числа, даты или справочные коды.

Почему пара (ReportItem, ReportValue) основа отчета?

  • ReportItem отвечает: «Где?» какая ячейка, в каком контексте.

  • ReportValue отвечает: «Что?» какое значение и с какой точностью.

Вместе они образуют плоскую карту значений, которую можно:

  • Сериализовать в JSON;

  • Валидировать по таксономии;

  • Отображать в таблицах или UI.

Почему это важно для .NET-разработчиков?

  • Практическая задача: обработка больших структурированных данных без OOM;

  • Архитектурный вызов: переход от in-memory к streaming-под apпроachu;

  • Реальный кейс: оптимизация под жесткие ограничения (4 ГБ RAM, нет БД);

  • Полезные паттерны: JsonTextWriter, FileStream, временные файлы, ручное управление JSON.

Анализ текущего механизма

Чтобы понять оптимальный путь оптимизации, необходимо было досконально разобрать существующий конвейер сохранения и чтения отчетов. Его работу можно разделить на три ключевых этапа, которые мы и проанализируем.

1. Загрузка и парсинг XBRL-файла

Процесс начинался с загрузки файла. Входной XBRL-файл, представленный в виде Stream, передавался парсеру , который извлекал метаданные отчета. Затем этот поток использовался для загрузки и разбора содержимого XBRL-файла в объектную модель. Этот процесс преобразовывал XML-структуру в память, создавая коллекции фактов, единиц измерений, контекстов и пространств имен.

2. Трансформация в объект отчёта

Следующим этапом было преобразование подготовленной объектной модели в итоговый объект отчета. Именно здесь происходило ключевое событие, проводившее к проблемам с памятью:

  • Создание основной структуры данных: В памяти инициализировался словарь для хранения связей между элементами данных и их значениями.
    (Инициализировался словарь report.Data = new Dictionary<ReportItem, ReportValue>().)

  • Обработка каждого элемента данных: В цикле для каждого элемента данных (количество которых в файле на 1 Гб могло достигать миллионов) создавался комплексный объект-ключ. Этот объект не был простым; он содержал такие сложные поля, название метрики, временной охват и что особенно важно, списки измерений и атрибутов, описывающих контекст показателя.

  • Наполнение словаря: Для каждого такого объекта-ключа в словарь добавлялась новая запись, содержащая само значение элемента, ссылку на единицы измерения и информацию о точности.

Ключевая проблема производительности: словарь становился монолитом, хранящим в оперативной памяти все объекты-ключи со всеми их вложенными коллекциями и все связанные с ними значения. В .NET подобные структуры данных имеют дополнительную служебную нагрузку (примерно 24 байта на элемент), а также хранят ссылки на свои элементы. Для миллионов записей совокупный объем памяти легко превышал доступные 4 Гб, создавая экстремальную нагрузку на сборщик мусора и приводя к исчерпанию памяти.

Дополнительно заполнялись другие свойства отчета (такие как коллекции единиц измерений и информации об измерениях), что также вносило свой вклад в общее потребление памяти.

3. Сериализация и сохранение

После завершения трансформации полностью сформированный объект отчета, включая все его данные, передавался на этап сохранения. Здесь происходила стандартная JSON-сериализация, которая требовала, чтобы весь объект отчета (включая тот самый гигантский словарь с данными) целиком находился в оперативной памяти для формирования итоговой JSON-строки перед записью на диск.

Главный узкий момент это использование словаря: Dictionary<ReportItem, ReportValue> Каждый ключ ReportItem это объект с собственными коллекциями и строковыми полями.

Кроме того, сериализация через ObjectJsonSerializer выполняется целиком, без возможности частичной выгрузки. Это усиливает нагрузку на память и GC (garbage collector), особенно при параллельных задачах.

Итоговые проблемы архитектуры

  • Пиковое потребление памяти: Общий объем потребляемой ОЗУ мог в 6 раз превышать размер исходного файла. Это делало обработку файлов свыше 100 Мб невозможной в условиях ограничения в 4 Гб.

  • Отсутствие масштабируемости: Архитектура "все в памяти" фундаментально ограничивала максимальный размер обрабатываемых данных.

  • Неэффективное использование диска: Хотя в системе было доступно 10 Гб дискового пространства, оно не использовалось для промежуточного хранения данных, что могло бы разгрузить оперативную память.

  • Механизм сериализации требует полной загрузки объекта в память, что исключает обработку отчётов размером более 100 Мб.

  • Для решения необходим переход к потоковой или батчевой обработке, при этом архитектура должна сохранить совместимость с текущими классами.

Переход к потоковой сериализации объектов

Осознав ограничения текущего механизма, была сформулирована цель: реализовать последовательное сохранение объектов ReportItem без их полной загрузки в оперативную память. Критически важным было соблюдение существующих ограничений проекта: отсутствие базы данных, 4 Гб ОЗУ и 10 Гб дискового пространства.

Панорама возможностей: 5 первоначальных вариантов

Прежде чем углубиться в детали, был рассмотрен спектр из пяти принципиально разных подходов:

  1. Потоковая сериализация в JSON это обработка фактов по одному с прямой записью в JSON-поток с помощью JsonTextWriter.

  2. Батчевая обработка с временными файлами это разбиение миллионов фактов на чанки (например, по 10-100k элементов), сериализация каждого чанка в отдельный временный файл с последующим слиянием в финальный JSON.

  3. Использование дисковой БД (SQLite) это сохранение каждой пары ReportItem и ReportValue как записи в легковесной файловой базе данных с последующим экспортом в JSON.

  4. Изменение модели данных это фундаментальный рефакторинг с заменой Dictionary<ReportItem, ReportValue> на список или иную структуру, более подходящую для потоковой записи.

  5. Асинхронная обработка с Memory-Mapped Files это использование низкоуровневого API операционной системы для отображения файла в виртуальную память и прямого байтового доступа к данным через MemoryMappedViewAccessor.

Эволюция решения: от общего к частному

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

Вариант 3 (Дисковая БД) был отвергнут как избыточный. Несмотря на всю мощь SQLite, он вводил внешнюю зависимость, добавлял накладные расходы на индексацию и транзакции, не давая решающих преимуществ перед чистым файловым IO в нашем сценарии "запись-один-раз-чтение-много".

Вариант 4 (Изменение модели данных) был признан слишком инвазивным. Замена словаря на список нарушила бы контракты во всей кодовой базе, потребовала бы переписывания логики валидации и поиска, лишив нас преимуществ O(1) при доступе к данным по ключу.

Для глубокого анализа было оставлено три наиболее жизнеспособных варианта: 1 (Потоковая сериализация), 2 (Батчи) и 5 (Memory-Mapped Files).

Вариант 2 (Батчи): Хотя батчинг дает точный контроль над памятью, он создает значительные накладные расходы из-за необходимости создания, записи и последующего чтения десятков или сотен временных файлов, а также сложности их корректного "сшивания" в валидный JSON. Наши оценки показали, что это могло увеличить общее время обработки на 10-30%.

Вариант 5 (Memory-Mapped Files): Несмотря на эффективность на низком уровне, этот подход требовал ручного управления байтами, позициями в файле и буферами, что делало его крайне неудобным для генерации структурированного JSON. Риск ошибок форматирования, сложность отладки и низкоуровневость перевешивали потенциальные выгоды.

Вариант 1 обусловлен следующими преимуществами:

  • Минимальному потреблению памяти: В ОЗУ одновременно находится только один обрабатываемый факт (~1-10 Кб).

  • Простоте реализации: Требует ~100 строк кода против значительно больших затрат на другие варианты.

  • Совместимости: Итоговый JSON-файл идентичен файлу, сгенерированному старым механизмом, что обеспечивает обратную совместимость.

  • Производительности: Потоковая запись с использованием JsonTextWriter обеспечивает высокую скорость и минимальные накладные расходы.

Ключевые идеи реализации

1. Потоковая запись JSON напрямую в отчёт

Вместо создания Dictionary< ReportItem, ReportValue> для накопления всех данных сериализация выполняется сразу в целевой файл отчёта через JsonTextWriter.
Запись происходит последовательно, каждая пара ReportItem и ReportValue записывается по мере обработки фактов.

var reportFilePath = IOProvider.IO.Combine(reportFolder, $"{report.Id}.json");
using var stream = new FileStream(reportFilePath, FileMode.Create, FileAccess.Write, FileShare.None);
using var writer = new JsonTextWriter(new StreamWriter(stream))
{
    Formatting = Formatting.None
};

writer.WriteStartObject();
writer.WritePropertyName("Data");
writer.WriteStartObject();

foreach (var fact in instance.Facts)
{
    var reportItem = BuildReportItem(fact);
    var reportValue = new ReportValue(fact.Value, fact.UnitCode, fact.Precision);

    string key = JsonConvert.SerializeObject(reportItem);
    string value = JsonConvert.SerializeObject(reportValue);

    writer.WritePropertyName(key);
    writer.WriteRawValue(value);
}

writer.WriteEndObject(); // Data
writer.WriteEndObject(); // Root

Результат: JSON создаётся прямо на диске, без накопления данных в памяти. Это уменьшает пиковое потребление ОЗУ до десятков мегабайт даже при больших XBRL-файлах.

2. Интеграция в существующую архитектуру

Изменения были точечными и не затронули общую структуру приложения.

  • Процесс формирования отчёта теперь сразу записывает данные в файл, не создавая промежуточных коллекций.

  • Этап сохранения отчёта работает как прежде: метаданные (описание, единицы измерения, пространства имён) сохраняются стандартным образом, а содержимое отчёта уже находится на диске и не требует дополнительных операций копирования.

Таким образом, система сохраняет полную совместимость с текущими компонентами и интерфейсами для работы с файлами. С точки зрения внешнего поведения, отчёт создаётся и сохраняется так же, как раньше разница лишь в том, что теперь этот процесс выполняется значительно эффективнее по памяти.

3. Потоковое чтение (streaming-deserialization)

При открытии отчёта используется потоковое чтение JSON-файла. Это позволяет извлекать данные по мере чтения, не загружая весь файл в память:

using var reader = new JsonTextReader(new StreamReader(reportFilePath));
while (reader.Read())
{
    if (reader.TokenType == JsonToken.PropertyName && reader.Depth == 2)
    {
        var reportItem = JsonConvert.DeserializeObject<ReportItem>(reader.Value.ToString());
        reader.Read(); // Move to value
        var reportValue = JsonConvert.DeserializeObject<ReportValue>(reader.ReadAsString());
        // Обработка данных по мере чтения
    }
}

Такой способ открывает возможности для ленивой загрузки данных (lazy loading) когда в память подгружаются только нужные части отчёта. Процесс можно сравнить с конвейерной сборкой, где детали не накапливаются на складе, а сразу поступают в упаковку, что исключает необходимость в большом складском помещении. Именно так работает потоковая сериализация, мы не строим весь объект в памяти, а записываем каждую пару ReportItem в ReportValue сразу в JSON-файл. Никаких временных словарей. Никаких temp-файлов. Только один проход и готово.

Преимущества решения

После внедрения потоковой сериализации система стала заметно эффективнее и масштабируемее.

  1. Удалось радикально снизить использование памяти: теперь при обработке отчёта на 100 МБ пиковое потребление ОЗУ составляет менее 50 МБ, тогда как раньше оно достигало 600 МБ. Это позволило уверенно работать с XBRL-файлами размером до 1 ГБ даже в среде с 4 ГБ оперативной памяти.

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

  3. Производительность стала более предсказуемой: время записи отчёта теперь зависит только от скорости диска, а не от частоты сборок мусора и объёма выделенной памяти.

Это делает процесс сохранения линейным по времени и устойчивым при росте объёмов данных. И наконец, решение полностью сохраняет совместимость с существующими инструментами: нет необходимости в сторонних базах данных, дополнительных пакетах или модификации API.

Возможные риски и меры предотвращения

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

Основной риск связан с ошибками форматирования, например с неправильным расположением запятых или незакрытых скобок. Эта проблема решается за счёт строгого тестирования, проверки структуры JSON после генерации и гарантированного закрытия потоков в блоках using.

На медленных дисках (особенно HDD) возможна некоторая задержка при записи большого количества данных. Для минимизации влияния этой проблемы используется буферизация и асинхронная запись (WriteAsync), что обеспечивает стабильную скорость даже при больших объёмах отчёта.

Другой потенциальный риск это прерывание записи из-за ошибки или остановки процесса. В этом случае может образоваться неполный файл отчёта. Чтобы предотвратить подобные ситуации, реализован контроль завершённости операции записи и механизм атомарного сохранения, который проверяет целостность JSON после завершения.

Кроме того, для поддержки обратной совместимости между версиями добавляется версия формата в заголовке JSON. Это позволяет новым версиям приложения корректно открывать старые файлы, а старым безопасно отказываться от неподдерживаемых форматов.

Вывод

Переход к потоковой сериализации напрямую в файл отчёта позволил:

  • Устранить пиковое потребление памяти;

  • Повысить масштабируемость и надёжность при больших объёмах данных;

  • Сохранить архитектурную целостность и интерфейсы приложения;

  • Внедрить решение без сложных зависимостей и внешних инструментов.

Результат: система, способная стабильно обрабатывать крупные XBRL-отчёты в рамках существующих ограничений по ресурсам.

Тестирование и результаты

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

Тестирование проводилось в контролируемом окружении:

  • Оперативная память: 4 ГБ

  • Дисковое пространство: 10 ГБ

  • Тип носителя: HHD

  • Формат входных данных: XBRL-файлы объёмом от 10 Мб до 1 Гб

  • Платформа: .NET Core 7.0

Основные метрики пиковое использование памяти, время обработки, стабильность сохранённого формата и совместимость с существующими модулями чтения отчётов.

Методика тестирования

  1. Синтетические данные: Сгенерировали XBRL-файлы различных размеров от небольших (10 Мб) до предельных (1 Гб), чтобы оценить поведение системы в разных условиях.

  2. Мониторинг ресурсов:

    • Process.GetCurrentProcess().WorkingSet64 для отслеживания потребления ОЗУ

    • GC.GetTotalMemory(true) для контроля нагрузки на сборщик мусора

    • Stopwatch для измерения времени обработки

    • Профилировщик dotTrace для глубокого анализа производительности

  3. Функциональные тесты: Осуществлялась корректность формирования и последующего открытия отчёта. Каждый отчёт после записи загружался обратно для верификации структуры JSON и данных ReportItem и ReportValue.

  4. Нагрузочные тесты: Проводились с несколькими наборами XBRL-файлов различного размера. Для каждого замерялись:

    • Время формирования отчёта;

    • Пиковое использование памяти (через GC.GetTotalMemory);

    • Объём итогового JSON-файла на диске.

  5. Тесты на устойчивость: Проверялись случаи прерывания записи (искусственное исключение в середине процесса) и корректность восстановления после повторного запуска.

  6. Совместимость: Проверялось открытие отчётов старыми модулями чтения и обрат��ую совместимость формата.

Выявленные риски и их смягчение

В процессе тестирования были выявлены и успешно устранены несколько потенциальных проблем:

1. Риск: Ошибки форматирования JSON

Проявление: Некорректная расстановка запятых, скобок при потоковой записи

Решение: Реализован строгий unit-тест, проверяющий валидность итогового JSON через JsonDocument.Parse

Код проверки:

[TestMethod]
public void StreamingSerialization_ProducesValidJson()
{
    var testReport = GenerateTestReport();
    string jsonPath = SaveReportWithStreaming(testReport);

    // Проверяем, что JSON валиден
    string jsonContent = File.ReadAllText(jsonPath);
    using JsonDocument doc = JsonDocument.Parse(jsonContent);
    Assert.IsTrue(doc.RootElement.TryGetProperty("Data", out _));
}

2 Риск: Производительность при конкурентном доступе

  • Проявление: Блокировки файлов при параллельной обработке нескольких отчетов;

  • Решение: Использовали SemaphoreSlim для координации доступа к дисковым операциям в конкурентных сценариях.

3 Риск: Совместимость с существующим кодом чтения

  • Проявление: Старые методы десериализации ожидают полный объект в памяти

  • Решение: Разработали lazy-десериализацию с использованием JsonTextReader:

public class LazyReportReader
{
    public IEnumerable<KeyValuePair<ReportItem, ReportValue>> ReadDataStreaming(string filePath)
    {
        using var streamReader = new StreamReader(filePath);
        using var jsonReader = new JsonTextReader(streamReader);
        while (jsonReader.Read())
        {
             if (jsonReader.TokenType == JsonToken.PropertyName)
             {
                var reportItem = JsonSerializer.Deserialize<ReportItem>(jsonReader.Value.ToString());
                jsonReader.Read();
                var reportValue = JsonSerializer.Deserialize<ReportValue>(jsonReader.Value.ToString());
                yield return new KeyValuePair<ReportItem, ReportValue>(reportItem, reportValue);
            }
        }
    }
}

Результаты

1. Потребление памяти.

Новая реализация показала значительное снижение нагрузки: при обработке отчёта объёмом 100 МБ пиковое использование оперативной памяти составило менее 50 МБ. Для сравнения, прежний механизм достигал 600 МБ. Даже при обработке 1 ГБ входных данных приложение стабильно удерживало нагрузку в пределах 200–250 МБ, что укладывается в доступные 4 ГБ.

2. Время обработки.

Скорость генерации отчётов улучшилась за счёт отсутствия лишних аллокаций. Даже с учётом последовательной записи на диск общая продолжительность формирования отчёта уменьшилась примерно на 15–20% по сравнению с исходной реализацией. На SSD-носителях разница была особенно заметна, а на HDD — нейтральной (без деградации производите��ьности).

3. Стабильность.

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

4. Совместимость.

Отчёты, сохранённые новым механизмом, успешно открывались существующими модулями чтения, поскольку структура JSON осталась прежней. Это подтвердило совместимость решения с текущей архитектурой без необходимости обновлять другие компоненты.

Ключевые наблюдения

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

  • При переходе от 100 МБ к 1 ГБ входного файла нагрузка на память увеличивается предсказуемо, пропорционально размеру активных объектов ReportItem, а не всему объёму отчёта.

  • Производительность ограничивается исключительно скоростью записи на диск, что делает систему стабильной даже под высокой нагрузкой.

Выводы тестирования

Результаты подтверждают, что новая реализация:

  • Полностью сохраняет функциональность и формат данных;

  • Сокращает использование памяти более чем в 10-15 раз;

  • Обеспечивает устойчивую обработку файлов до 1 ГБ Гб в условиях ограничения 4 Гб RAM;

  • Сохраняет совместимость и предсказуемое время выполнения операций.

  • Готов к промышленной эксплуатации основные риски идентифицированы и устранены

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

Рекомендации по внедрению

  1. Внедрение рекомендуется осуществлять поэтапно:

    • Сначала сохранение

    • Потом чтение через IAsyncEnumerable

  2. Добавьте метрики:

    Metrics.Counter("report.processed").Increment();
    Metrics.Histogram("report.memory.peak").Observe(peakMemory);

  3. Логируйте путь к файлу, если Data = null

  4. Тестируйте на реальных отчётах, синтетика не покажет edge-кейсы

Заключение

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

Переход к потоковой сериализации JSON позволил устранить это узкое место без радикальных изменений архитектуры и логики приложения. Новая реализация формирует отчёт напрямую на диске, записывая каждую пару данных последовательно без промежуточных коллекций и временных файлов. Это позволило сократить пиковое использование оперативной памяти более чем в десять раз и обеспечить возможность стабильной обработки XBRL-файлов объёмом до 2 ГБ.

Выводы:

  1. Эффективность: потоковая запись минимизирует расход памяти

    Потоковая сериализация позволила сократить пиковое использование ОЗУ более чем в 10 раз, данные записываются сразу на диск, а не аккумулируются в больших коллекциях в памяти. Это делает обработку больших XBRL-файлов предсказуемой и устойчивой в окружениях с ограниченной памятью.

  2. Производительность и предсказуемость, дисковая скорость важнее объёма памяти

    В новой модели производительность формирования отчёта линейно зависит от скорости записи на диск, а не от поведения сборщика мусора и объёма аллокаций в RAM. На SSD это даёт выигрыш по времени; на медленных носителях время записи можно контролировать буферизацией и асинхронными операциями.

  3. Совместимость и невмешательство в модель данных

    Формат итогового JSON сохранён, поэтому существующие компоненты чтения и интеграции продолжают работать без изменений. Решение не требует рефакторинга доменных моделей или изменения API, это облегчает внедрение и снижает риск регрессий.

  4. Простота, выигрыш над сложностью

    Из пяти рассмотренных подходов наиболее практичным оказался самый простой: последовательная запись JSON. Более сложные схемы (батчи + объединение, memory-mapped files, временная БД) добавляли сложность и операционные риски, но не давали сопоставимого преимущества в реальном окружении.

  5. Память новый диск: использование файловой системы как ресурса

    В эпоху больших данных важно балансировать между скоростью доступа и объёмом хранения. Файловая система и stream-ориентированные API (.NET: JsonTextWriter, Utf8JsonWriter, и т.д.) это мощные инструменты, позволяющие переносить нагрузку с ОЗУ на диск при сохранении удобного и совместимого формата данных. Это открывает дополнительные пути развития: сжатие, ленивое чтение, инкрементальные обновления и встроенная потоковая валидация.

Практические выводы из эксперимента

  1. В условиях, когда объем дискового пространства 10 ГБ, а объем ОЗУ не более 4 ГБ, данные не требующие оперативного доступа, должны выноситься на диск.

  2. JsonTextWriter + WriteRawValue = потоковая сериализация без боли, никаких StringBuilder, запятых и скобок вручную.

  3. Совместимость важнее перфекционизма, JSON-структура не изменилась, потребители кода не заметили рефакторинга.

  4. Чтение, тоже можно лениво, IAsyncEnumerable<KeyValuePair> загружаем только нужное.

  5. GC не враг, если не перегружать его, снижение Gen 2 сборок в 15 раз.

Рекомендации для других проектов

На основе практического опыта можно рекомендовать:

  • Рассматривать потоковые подходы при работе с наборами данных, превышающими 100 МБ;

  • Профилировать потребление памяти так же тщательно, как и время выполнения;

  • Тестировать на предельных объемах данных на ранних этапах разработки;

  • Не бояться использовать низкоуровневые API сериализации когда стандартные подходы неэффективны.

Комментарии (1)


  1. Gromilo
    18.11.2025 07:05

    У меня была похожая задача, сначала тоже хотел json сохранять, но потом подумал, что читать целиком его всё равно никто не будет, зато нужно несколько приседаний при записи и чтении, что видно в статье. И всё ради чего? Ради того, чтобы это был валидных json. Автору нужно поддержать старый контракт, а я перешёл на .jsonl, в котором каждый айтем данных хранится на отдельной строке в файле в виде цельного json-объекта.

    Даже курсорное чтение из s3 прикрутил, когда АПИ вместе с данными возвращало смещение в файле, чтобы можно было батчами вычитать все данные.

    А если рядом положить файл с смещением каждой, скажем, каждой сотой строки, то вообще с произвольного места читать можно будет :D