golang

И снова самый быстрый парсер JSON. Очередной

  • вторник, 30 июня 2026 г. в 00:00:20
https://habr.com/ru/articles/1053528/

За свои 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 (encoding/json)

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 (json.NewDecoder)

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

А покритиковать — в комментариях. Я знаю, вы это любите.