Комментарии 10
У меня была похожая задача, сначала тоже хотел json сохранять, но потом подумал, что читать целиком его всё равно никто не будет, зато нужно несколько приседаний при записи и чтении, что видно в статье. И всё ради чего? Ради того, чтобы это был валидных json. Автору нужно поддержать старый контракт, а я перешёл на .jsonl, в котором каждый айтем данных хранится на отдельной строке в файле в виде цельного json-объекта.
Даже курсорное чтение из s3 прикрутил, когда АПИ вместе с данными возвращало смещение в файле, чтобы можно было батчами вычитать все данные.
А если рядом положить файл с смещением каждой, скажем, каждой сотой строки, то вообще с произвольного места читать можно будет :D
Да, согласен, .json здесь очень хорош как формат для append-only/батчевого чтения, особенно если рядом держать индекс offset’ов. В моём кейсе главным ограничением была обратная совместимость: старые потребители ожидали цельный JSON прежней структуры, поэтому я оптимизировал запись/чтение внутри существующего контракта. Но для v2 формата я бы как раз рассматривал meta.json + data.jsonl + index, это выглядит более естественно для больших отчётов.
>В .NET подобные структуры данных имеют дополнительную служебную нагрузку (примерно 24 байта на элемент), а также хранят ссылки на свои элементы.
А у вас есть контроль над типами ReportItem/ReportValue? Нельзя ли там какие-то элементы уложить собственно в структуры (struct) и посмотреть, какая экономия памяти из этого получится?
Да, это хорошее направление для отдельного бенчмарка. Для простых объектов вроде ReportValue readonly struct потенциально может дать экономию. Но ReportItem в моём случае довольно тяжёлый: строки, временной контекст, списки измерений и атрибутов. Если сделать его struct, большая часть содержимого всё равно останется ссылками на другие объекты, а взамен можно получить копирование крупной структуры и дополнительные риски с Equals/GetHashCode, особенно когда это ключ словаря. Поэтому я бы рассматривал это как второй этап оптимизации. В статье фокус был на более крупном выигрыше: не уменьшить каждый элемент на N байт, а вообще не держать миллионы таких элементов в памяти одновременно.
>Именно так работает потоковая сериализация, мы не строим весь объект в памяти, а записываем каждую пару ReportItem в ReportValue сразу в JSON-файл. Никаких временных словарей. Никаких temp-файлов. Только один проход и готово.
Всего кода не видно, но по описанию мне кажется, что у вас в цикле за чтением следует запись, а тут напрашивается вариант разделения чтения и записи на два потока с использованием какой-нибудь потокобезопасной очереди в качестве буфера обмена (system.threading.channels - наверное, проще всего). Интересно, принесло бы в вашем случае это какие-то существенные выгоды?
Да, Channels хороший кандидат для следующего эксперимента. Я бы делал это через bounded channel: producer читает/готовит пары ReportItem -> ReportValue, consumer последовательно пишет JSON. Bounded чтобы очередь сама не превратилась во второй “временный словарь” в памяти. Но ожидаю, что выигрыш сильно зависит от bottleneck’а. Если чтение и запись ждут разные ресурсы, например БД/сеть с одной стороны и S3/диск с другой, то pipeline может перекрыть ожидания и ускорить обработку. Если же основная стоимость в сериализации, вычислении данных или одном и том же I/O-ресурсе, то можно получить только усложнение кода плюс накладные расходы на очередь.
В моём кейсе главная оптимизация была про память: не держать весь отчёт целиком. А вот разделение чтения и записи на producer/consumer это уже следующий уровень оптимизации throughput, который я бы проверял только бенчмарком.
Вы в key в json держите List? Современный System.Text.Json сериализует через pipewriter, нету никакой полной буферизации в памяти, зато есть частичная буферизация чтоб не обращаться часто к диску. Также оптимальней чем у Вас организована работа с памятью.
Да, справедливое замечание.
В key у меня фактически лежит сериализованный ReportItem, а внутри него действительно могут быть списки dimensions/attributes. Это не самый красивый формат, но он был продиктован обратной совместимостью со старым контрактом, где данные представлялись как Dictionary<ReportItem, ReportValue>.
Про System.Text.Json согласен: современный Utf8JsonWriter/PipeWriter не требует собирать весь итоговый JSON в памяти. Тут важное уточнение: проблема старой реализации была не в JSON-сериализаторе как таковом, а в том, что до сериализации уже был полностью построен огромный Dictionary<ReportItem, ReportValue>.
В моём примере ещё остаются лишние аллокации: JsonConvert.SerializeObject(reportItem) JsonConvert.SerializeObject(reportValue). ReportValue можно писать напрямую через writer без промежуточной строки. С ReportItem сложнее, потому что по старому контракту он является именем свойства JSON, а значит его всё равно нужно сначала получить как строковое представление ключа.
Если бы контракт можно было менять, я бы вообще не держал сериализованный объект в имени свойства, а сделал бы массив {item, value} или .jsonl. Но в рамках сохранения старого формата да, переход на System.Text.Json/Utf8JsonWriter был бы хорошим следующим улучшением.
Непонятно в чем заключается обработка отчетов.
Согласен этот момент можно было разяснить. Под “обработкой отчётов” имел в виду технический пайплайн: получить данные отчёта, сформировать пары ReportItem -> ReportValue, сериализовать их в JSON, сохранить в хранилище и потом уметь читать обратно. Фокус статьи был не на бизнес-логике расчёта отчёта, а на проблеме хранения/сериализации: старая реализация сначала собирала весь отчёт в памяти, например в большой Dictionary<ReportItem, ReportValue>, и только потом записывала его. При больших объёмах это давало высокий расход памяти. Оптимизация как раз в том, чтобы писать и читать потоково, не держа весь отчёт целиком.

Оптимизация обработки больших отчетов в .NET Core: от памяти к потокам