За свои 17+ лет в активной разработке я встречал много проблем, но одна преследовала меня постоянно: JSON. Нет, с самим форматом все ок, но вот с его чтением — не все норм.
Когда я только начинал работать с PHP, я списывал это на скриптовость языка. Отчасти из‑за этого я даже поменял стек. Но когда приходили по‑настоящему большие файлы, это всегда было больно. Иногда — очень. Был проект, где мы ждали не обработку информации бизнес‑логикой, а банального парсинга. Файлы доходили до десятков гигабайт и не всегда влезали в оперативку. Тогда я и заработал себе персональный todo — разобраться с этим раз и навсегда.
Сейчас, находясь в поиске новых возможностей, я решил вспомнить эту старую боль. Я уже давно не PHP‑разработчик, но проблема в индустрии всё та же. Объемы данных растут, требования тоже, а воз и ныне там. Нет, есть море крутых решений. Даже тут, на Хабре. Но для меня всё не то.
Мне нужно решение, а не костыль. То есть: никакой кодогенерации и никаких JIT (я не противник JIT, просто не хочу тянуть эту сложность).
Я ступил на тонкий лед: в Go есть классная штука — пакет unsafe. Почему классная? Потому что она позволяет обойти тяжелые ненужные проверки. Плюс побитовые операции для ускорения всего, до чего только смогли дотянуться руки. Пока изучал чужие парсеры, столкнулся с обманом в репозиториях, подкручиванием статистики (куда же без него?) и перекладыванием ответственности (и аллокаций) на сторону разработчиков.
Часть 1. Путь разочарований, или почему меня не устроили лидеры рынка
Когда стандартный encoding/json перестает справляться, люди обычно идут по одному из трех путей:
-
Кодогенерация (easyjson и аналоги). Скорость растет, но Developer Experience падает ниже нуля. Дополнительные шаги сборки, забытые команды
go:generate, конфликты в пайплайнах. Я хотел инструмент, который работает «из коробки» как стандартная библиотека, а не усложняет процесс разработки. -
JIT‑компиляция (Sonic). Выглядит потрясающе на бенчмарках, но имеет скрытую цену — «холодный старт». Каждый раз, когда парсер встречает новую структуру, он тратит время на компиляцию машинного кода в рантайме (скорость падает до ~800 MB/s). Пиковая скорость крутая, честно. Но цена — нестабильность задержек на рандомных данных, отсутствие чтения из потока и отсутствие генерации JSON.
-
C++ порты и SIMD (simdjson‑go). Невероятно быстро, но API основан на AST (Abstract Syntax Tree). Чтобы замапить данные в обычные Go‑структуры, разработчику приходится писать кучу ручного, низкоуровневого кода. Я прифигел и плюнул, когда увидел это безобразие. По сути, непосредственное конвертирование типов просто не учитывается в их бенчмарках. Это скрытие информации.
Часть 2. Идея: Zero‑Allocation, Zero‑Warmup и никакого ручного парсинга
Я понял, что нужен инструмент, который объединит удобство encoding/json и скорость C++ портов.
Многие статьи на Хабре, рассказывающие о «сверхбыстром парсинге», сводятся к одному трюку: авторы заставляют программиста вручную писать методы Decode для каждой структуры, жестко привязываясь к порядку полей. Если API на клиенте поменяет местами TraceID и Timestamp, такой парсер молча сломает данные.
Я пошел другим путем. silentjson использует Precomputed Registry. Библиотека использует reflect ровно один раз — на этапе старта приложения. Она строит внутреннюю карту структуры, а затем работает с ней без оглядки на то, в каком порядке прилетят ключи в JSON. Никакого JIT‑прогрева — максимальная пропускная способность с первого же запроса.
Часть 3. Технический хардкор и парадокс потокового чтения
Чтобы добиться скорости, я реализовал AVX2 Tape‑Scanner — сканер на битовых масках и SIMD‑инструкциях, который размечает JSON без скалярных циклов. А парсинг строк работает через unsafe.String (Zero‑Copy), ссылаясь прямо на исходный буфер.
|
Library |
Throughput (MB/s) |
Latency (ns/op) |
Memory Allocated |
Allocs/op |
|---|---|---|---|---|
|
SilentJSON |
1454.91 MB/s 👑 |
10,222,408 ns 👑 |
0 MB (Zero‑Alloc) 👑 |
0 👑 |
|
Sonic |
1400.53 MB/s |
11,342,853 ns |
78.18 MB |
37 |
|
Standard ( |
596.53 MB/s |
26,630,475 ns |
15.15 MB |
2 |
|
Protobuf |
452.45 MB/s |
15,042,191 ns |
6.49 MB |
1 |
Но самой интересной задачей стал потоковый парсинг (io.Reader).
Парадокс стриминга в мире Go заключается в том, что большинство библиотек (например, Jsoniter), заявляющих поддержку Stream, на самом деле буферизируют гигантские куски данных в памяти. Они ждут закрывающей скобки массива, накапливая состояния и создавая дикое давление на Garbage Collector (до 14.6M аллокаций в тестах).
В silentjson я сделал честный StreamDecoder.
-
NextRaw(): Позволяет «на лету» вырывать сырые JSON‑объекты из потока на скорости ~1.2 GB/s.
-
NextChan(): Асинхронный Producer‑Consumer режим, который под капотом использует Ring Buffer. Это дает возможность парсить данные в фоновой горутине без data races и с нулевыми дополнительными аллокациями, передавая объекты в основной поток. Таким образом, несмотря на чуть меньшую пиковую скорость в бенчмарке, в реальных приложениях это работает быстрее за счет отсутствия пауз и блокировок бизнес‑логики.
Сколько времени и сил ушло на постоянную отладку — не пересказать. Причем изначально я написал сканер на чистом Go. В тепличных микробенчмарках он даже показывал скорость чуть выше и давал меньше аллокаций. Но ассемблер дал главное — предсказуемое чтение данных и плоский, линейный график на выходе. В production предсказуемость задержки (tail latency) всегда дороже пиковой скорости.
На потоковых данных я вообще оторвался. Захотел сделать фишки, которые реально помогают в проектах. Пусть они не такие изящные внутри, как обычный Unmarshal, но это одни из самых быстрых вариантов на рынке, которые могут поспорить с решениями на C или Rust.
Ну и отдельное удовольствие — это сравнение с gRPC. По сути, бинарные форматы сейчас часто выступают не только как «тормоз» из‑за оверхеда на десериализацию структур, но и приносят постоянные траблы с версионностью и синхронизацией контрактов протокола.
|
Library |
Throughput (MB/s) |
Memory Allocated |
Allocs/op |
Notes |
|---|---|---|---|---|
|
SilentJSON (NextRaw) |
~1181 MB/s 🚀 |
526 MB |
3.0M |
Extreme speed raw stream chunk extraction |
|
SilentJSON (Decode) |
469.96 MB/s 👑 |
41 MB 👑 |
7.7M 👑 |
Full Go Struct Binding, zero alloc iteration |
|
Jsoniter (Stream) |
455.51 MB/s |
148 MB |
14.6M |
2x more GC pressure |
|
SilentJSON (NextChan) |
378.02 MB/s ⚡ |
41 MB 👑 |
7.7M 👑 |
Async Producer‑Consumer mode (Ring Buffer) |
|
Standard ( |
105.42 MB/s |
162 MB |
13.3M |
Slowest, highest memory usage |
Часть 4. Бенчмарки: плоская линия как признак качества
Я тестировал парсер на массивах из 100 000 сложных вложенных объектов (~18MB). Причем поля в объектах специально менялись местами, чтобы исключить читерство с порядком. Результаты:
|
Объем |
10k объектов |
25k объектов |
50k объектов |
100k объектов |
|
SilentJSON |
3050 |
3183 |
3320 |
3347 |
|
Sonic |
421 |
459 |
463 |
467 |
|
encoding/json |
106 |
106 |
107 |
107 |
-
Десериализация (Parallel): 3347 MB/s против 107 MB/s у
encoding/json. -
Аллокации: 4 allocs/op у нас против 10 002 у Sonic и 509 997 у стандарта.
-
Сериализация: 1454 MB/s (Zero‑Alloc).
Но моя главная гордость — это графики масштабирования. В отличие от других библиотек, которые деградируют при росте объема данных из‑за промахов кэша или работы GC, график производительности silentjson — это прямая горизонтальная линия. Это доказывает, что сложность нашего парсера строго O(N), и он абсолютно предсказуем под любой нагрузкой.
Вывод: unsafe — это не ругательство
Да, библиотека активно использует пакет unsafe. Да, Zero‑Copy означает, что вы не можете изменять исходный байтовый срез, пока работаете со строками из него.
Но в мире высоконагруженного бекенда производительность требует дисциплины. Если ваша система задыхается от объемов JSON, а покупка новых серверов больше не решает проблему — иногда нужно просто перестать генерировать мусор.
Проект полностью открыт, работает на Go 1.18+ (Generics) и готов к использованию.
Код можно посмотреть тут: https://github.com/GenshIv/silentjson
А покритиковать — в комментариях. Я знаю, вы это любите.
ссылка на оригинал статьи https://habr.com/ru/articles/1053528/