golang

От «быстрого JSON» к потоковой обработке данных: смена парадигмы в оценке производительности проток…

  • понедельник, 28 сентября 2026 г. в 00:00:13
https://habr.com/ru/articles/1087126/

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

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

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

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

И да – весь этот подход и сам пакет я публикую бесплатно. Мне интереснее поделиться самой идеей и накопленным практическим опытом, чем просто показать очередной benchmark JSON parser’а.

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

получить данные → parse → object → обработка

Так строится большинство benchmark’ов сериализации. Но для системы, работающей с непрерывным потоком, есть более интересный вопрос: насколько рано после получения данных мы можем начать что-то с ними делать?

Если полезное событие находится в начале потока, ожидание полного сообщения становится отдельной задержкой. Поэтому для потоковой обработки важны уже не только MB/s parser’а и количество аллокаций, но и время до первого полезного события, объём памяти и возможность одновременно получать, распознавать и обрабатывать данные.

Свойство JSON, которое привыкли считать недостатком

У JSON структура непосредственно находится в самом потоке:

{ } [ ] , : "

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

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

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

Например, в protobuf часть этой информации находится в tag и wire type. Parser должен интерпретировать их, обработать varint или длину поля и определить, как перейти к следующему элементу. Это вполне эффективная модель, но она отличается от ситуации, когда структурные признаки непосредственно присутствуют в потоке.

SilentJSON: от decode к потоку

SilentJSON создавался как быстрый JSON encoder/decoder для Go, но потоковый режим позволяет использовать тот же механизм иначе.

сетевой поток
     ↓
StreamDecoder
     ↓
очередной объект
     ↓
обработка

Например:

registry := silentjson.NewRegistry[Employee]()

decoder := silentjson.NewStreamDecoder(reader, registry)

for {
    employee, err := decoder.Next()
    if err == io.EOF {
        break
    }
    if err != nil {
        return err
    }

    process(employee)
}

Граница Read() здесь не обязана совпадать с границей JSON-объекта. Один объект может прийти несколькими физическими chunks, а один chunk может содержать несколько объектов. Parser сохраняет состояние между чтениями.

Это позволяет отделить транспортный поток от логической структуры данных.

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

NextRawBlock: когда декодирование не требуется

Во многих задачах JSON вообще не нужно превращать в Go-структуру. Нужно только определить границы очередного блока и передать его дальше.

Для этого в SilentJSON есть NextRawBlock.

JSON stream
     ↓
structural scan
     ↓
raw block
     ↓
output

В таком режиме parser не тратит время на mapping полей, создание объектов и последующее преобразование обратно в JSON.

На этой модели построен silent-chunker. Он может разбивать большой JSON-массив на отдельные блоки со скоростью более 4 GB/s. Например:

./silent-chunker -file input.json -count 1000 -out chunk_

или:

cat large.json | ./silent-chunker -size 10485760

Для файла в 10 GB это уже принципиально другой класс обработки: parser занимается структурой потока, а не построением промежуточного представления каждого объекта.

Registry отвечает уже за другое

Registry не нужен для того, чтобы понять саму структуру JSON. Он нужен, чтобы сопоставить найденное поле с конкретным местом в Go-структуре и его типом.

JSON bytes
    ↓
структура
    ↓
поле
    ↓
Go field / type

В registry заранее находятся необходимые метаданные: offsets, типы, вложенные registry и другая информация для записи результата.

Таким образом, две задачи разделены: JSON сообщает parser’у, где находятся данные, а registry – что с ними делать в Go.

Это позволяет убрать значительную часть работы reflection-heavy generic path с hot path.

RawJson: данные как вложенный протокол

RawJson позволяет оставить часть JSON в исходном виде и передать её следующему уровню обработки.

Protocol A
    ↓
  RawJson
    ↓
Protocol B
    ↓
  RawJson
    ↓
Protocol C

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

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

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

От сообщений к событиям

Отсюда возникает ещё одна возможность.

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

Например:

data received
     ↓
event detected
     ↓
event registered
     ↓
normal processing

Вместо:

initialize → receive → parse → process → detect

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

receive → detect → register

Это особенно интересно для систем, где само событие важно зафиксировать как можно раньше.

В банковских операциях это может быть регистрация этапов транзакции по мере их появления. В логировании – фиксация критического события до запуска более тяжёлого logging pipeline. В мониторинге – обнаружение роста задержек, retry, проблем с соединением или деградации сервиса до того, как они начнут распространяться по цепочке микросервисов.

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

Как меняется измерение производительности

Для обычного parser’а достаточно спросить:

Сколько MB/s он декодирует?

Для потоковой системы этого недостаточно.

Имеют значение:

  • время до первого полезного события;

  • время до первой обработанной записи;

  • throughput непрерывного потока;

  • рабочий объём памяти;

  • количество аллокаций;

  • стоимость обработки структурных событий;

  • возможность перекрывать получение и обработку;

  • время обнаружения проблемы.

Две системы могут обработать одинаковые 10 GB за одинаковое время, но одна начнёт работу только после получения всего объёма, а другая будет обрабатывать первые записи уже во время поступления остальных.

Для throughput это может быть одинаковый результат. Для latency и поведения системы под нагрузкой – совершенно разный.

Результаты SilentJSON

В одном из benchmark’ов StreamDecoder.Next показывает около 614 MB/s при 41 MB аллокаций. NextRawBlock, где отсутствует полноценное mapping в структуры, – около 4009 MB/s при примерно 0.9 MB аллокаций.

NextChan, использующий producer-consumer модель, показывает около 447 MB/s и 41 MB аллокаций.

Режим

Throughput

Allocations

Что измеряется

StreamDecoder.Next

~614 MB/s

~41 MB

декодирование объектов

NextRawBlock

~4009 MB/s

~0.9 MB

структурное извлечение

NextChan

~447 MB/s

~41 MB

потоковый producer/consumer

В другом тесте на 100 000 объектов SilentJSON в parallel mode показал около 24 670 MB/s против примерно 644 MB/s для Sonic и 110 MB/s для encoding/json.

Scalar fallback без AVX2 показал около 810 MB/s. Это важно хотя бы потому, что сама модель не сводится к использованию SIMD: SIMD ускоряет структурный поиск, но основная архитектура остаётся работоспособной и без него.

Эти цифры относятся к конкретным тестам, данным и реализации, поэтому их нельзя напрямую переносить на любой JSON workload. Но они показывают разницу между полноценным декодированием и операциями, которым вообще не требуется построение Go-объектов.

Zero-copy

Потоковая обработка также меняет отношение к строкам.

Если возвращаемая строка является представлением исходного JSON buffer, её можно получить без отдельного копирования. Но lifetime такой строки связан с lifetime исходных данных.

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

strings.Clone(s)

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

От быстрого parser’а к потоковой архитектуре

В итоге SilentJSON интересен не только как попытка сделать Unmarshal быстрее.

Главное изменение – в самой единице обработки.

JSON можно рассматривать не как одно неделимое сообщение, а как поток структурных событий и логических блоков:

byte stream
    ↓
structural scan
    ↓
logical block
    ↓
RawJson / typed object
    ↓
event / processing
    ↓
next layer

Не каждый уровень обязан декодировать всё сообщение. Если нужны границы – достаточно структурного сканирования. Если нужен объект – используется registry. Если нужен вложенный протокол – можно передать RawJson. Если требуется только событие – его можно зарегистрировать отдельно от полной обработки.

Именно поэтому классический вопрос “насколько быстрый у нас JSON parser?” становится недостаточным.

Для непрерывного потока важнее другое:

как рано мы можем получить полезную информацию из данных и сколько работы требуется до первой реакции системы?

В этом смысле производительность протокола – это не только MB/s.

Это время до первого события, объём промежуточного состояния, стоимость перехода между уровнями и способность получать, распознавать, передавать и обрабатывать данные одновременно.

И тогда JSON становится не просто форматом сериализации. Его структурные байты становятся частью механизма управления потоком.

А SilentJSON – не просто быстрым JSON parser’ом, а экспериментом с другой моделью обработки:

получать
   ↓
распознавать
   ↓
реагировать
   ↓
передавать
   ↓
обрабатывать

Причём эти этапы не обязаны ждать завершения друг друга.