В этой статье я расскажу о том, как эффективно парсить большие объемы JSON-данных используя Go.

Мы рассмотрим библиотеку go-faster/jx, легковесного форка jsoniter, созданной для высокопроизводительной низкоуровневой работы с JSON.


Входные данные

Для примера возьмем следующий JSON-объект:

{
  "Timestamp": "1586960586000000000",
  "Attributes": {
    "http.status_code": 500,
    "http.url": "http://example.com",
    "my.custom.application.tag": "hello"
  },
  "Resource": {
    "service.name": "donut_shop",
    "service.version": "2.0.0",
    "k8s.pod.uid": "1138528c-c36e-11e9-a1a7-42010a800198"
  },
  "TraceId": "13e2a0921288b3ff80df0a0482d4fc46",
  "SpanId": "43222c2d51a7abe3",
  "SeverityText": "INFO",
  "SeverityNumber": 9,
  "Body": "20200415T072306-0700 INFO I like donuts"
}

Это JSON-представление записи лога из модели данных OpenTelemetry.

Go-представление

Чтобы эффективно парсить JSON, нужно иметь подходящую структуру данных в Go, которая позволит минимизировать накладные расходы при декодировании. Чтобы этого добиться, мы можем использовать наши знания о модели данных. Например, мы знаем, что TraceId всегда представлен как 32-х символьная шестнадцатеричная строка, а SeverityText может быть опущен, если нам известно числовое значение SeverityNumber.

Вот пример такой структуры:

type OTEL struct {
	Timestamp  jx.Num
	Attributes Map
	Resource   Map
	TraceID    [16]byte
	SpanID     [8]byte
	Severity   byte
	Body       Raw
}

Map

Обратим внимание на тип Map, который используется для полей Attributes и Resource. Он особенно важен для эффективного декодирования.

Но начнем мы с типа Bytes, который хранит срез байтов и позиции ключей/значений внутри этого среза. По сути это представление []string, но без лишних аллокаций:

type Pos struct {
	Start int
	End   int
}

type Bytes struct {
	Buf []byte
	Pos []Pos
}

Таким образом, Bytes позволяет нам хранить массив строк в компактном и локальном по памяти виде. Тип Pos указывает на начало и конец каждой строки внутри буфера Buf.

Определим несколько методов для работы с Bytes:

func (b Bytes) Elem(i int) []byte {
	p := b.Pos[i]
	return b.Buf[p.Start:p.End]
}

func (b Bytes) ForEachBytes(f func(i int, b []byte) error) error {
	for i, p := range b.Pos {
		if err := f(i, b.Buf[p.Start:p.End]); err != nil {
			return err
		}
	}
	return nil
}

func (b *Bytes) Append(v []byte) {
	start := len(b.Buf)
	b.Buf = append(b.Buf, v...)
	end := len(b.Buf)
	b.Pos = append(b.Pos, Pos{Start: start, End: end})
}

func (b *Bytes) Reset() {
	b.Buf = b.Buf[:0]
	b.Pos = b.Pos[:0]
}

Метод Elem позволяет получить i-й элемент, ForEachBytes итерирует по всем элементам Bytes, используя коллбек и избегая аллокаций. Метод Append добавляет новый элемент в Bytes, а Reset очищает содержимое.

Теперь мы можем определить тип Map, который использует Bytes для хранения ключей и значений:

type Map struct {
    Keys   Bytes
    Values Bytes
}

Пары ключ-значение мы сможем получить, используя индексы из Keys.Pos и Values.Pos, а запись в такую структуру будет выглядеть следующим образом:

func (m *Map) Append(k, v []byte) {
	m.Keys.Append(k)
	m.Values.Append(v)
}

Декодирование

Теперь, когда у нас есть структура данных, мы можем приступить к декодированию JSON.

Опять же, начнем с типа Map. Для простоты и последовательности, мы будем использовать следующую сигнатуру:

type Decoder interface {
    Decode(d *jx.Decoder) error
}

Map

Метод Decode для типа Map будет выглядеть вот так:

func (m *Map) Decode(d *jx.Decoder) error {
	return d.ObjBytes(func(d *jx.Decoder, k []byte) error {
		v, err := d.Raw()
		if err != nil {
			return errors.Wrap(err, "value")
		}
		m.Append(k, v)
		return nil
	})
}

Тут используется метод ObjBytes, который итерирует по всем полям объекта, предоставляя ключи в виде срезов байтов. Ключи ссылаются на исходный буфер, что позволяет избежать лишних аллокаций.

Метод Raw возвращает сырое json значение в виде среза байтов, что также помогает минимизировать накладные расходы.

Основной тип

Теперь мы вручную декодируем каждое поле основного типа OTEL.

Выглядеть это будет примерно так:

func (o *OTEL) Decode(d *jx.Decoder) error {
	return d.ObjBytes(func(d *jx.Decoder, key []byte) error {
		switch string(key) {
		case "Body":
			v, err := d.RawAppend(o.Body[:0])
			if err != nil {
				return errors.Wrap(err, "body")
			}
			o.Body = v
			return nil
		case "Attributes":
			// ...
		default:
			return errors.Errorf("unknown key %q", key)
		}
	})
}

Body

Поле Body это просто сырое значение JSON, поэтому мы можем использовать метод RawAppend, который совершает append сырого значения в переданный срез байтов.

v, err := d.RawAppend(o.Body[:0])
if err != nil {
    return errors.Wrap(err, "body")
}
o.Body = v

SeverityNumber

Поле SeverityNumber содержит значения от 1 до 24, поэтому мы можем хранить его в одном байте. Используем метод Uint8 для декодирования:

v, err := d.Uint8()
if err != nil {
    return errors.Wrap(err, "severity number")
}
o.Severity = v

SeverityText

Поле SeverityText может быть опущено, если известно числовое значение SeverityNumber.
Мы используем оптимизированный метод d.Skip() для пропуска этого поля.

Timestamp

Для работы с числовым полем Timestamp мы используем специальный тип jx.Num и его метод декодера NumAppend.

v, err := d.NumAppend(o.Timestamp[:0])
if err != nil {
    return errors.Wrap(err, "timestamp")
}
o.Timestamp = v

Тип jx.Num и методы декодера Num, NumAppend специализированы на эффективной работе с числами, представленными как строки или числа в JSON.

TraceId и SpanId

Поля TraceID и SpanID это шестнадцатеричные строки фиксированной длины.
Мы используем метод StrBytes для получения байтового представления строки, а затем декодируем его с помощью hex.Decode из пакета encoding/hex:

v, err := d.StrBytes()
if err != nil {
    return errors.Wrap(err, "trace id")
}
if _, err := hex.Decode(o.TraceID[:], v); err != nil {
    return errors.Wrap(err, "trace id decode")
}
return nil

Attributes и Resource

Эти поля мы декодируем с помощью метода Decode нашего типа Map, который мы определили ранее.

Итог

В итоге мы получаем эффективный парсер JSON, который минимизирует аллокации и работает с большими объемами данных.
На AMD Ryzen 9 7950X мы получаем следующие показатели производительности (на одно ядро):

Тест

Скорость

Аллокации

Decode

1279 MB/s

0 allocs/op

Validate

1914 MB/s

0 allocs/op

Encode

1202 MB/s

0 allocs/op

Write

2055 MB/s

0 allocs/op

Описание Write и Encode выходит за рамки этой статьи, но вы можете ознакомиться с ними в тестах библиотеки jx.

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


  1. yegreS
    18.11.2025 20:14

    не хватает сравнения с simdjson-go. Думаю simdjson будет быстрее и нет необходимости с приседаниями писать самому декодер