В этой статье я расскажу о том, как эффективно парсить большие объемы 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.
yegreS
не хватает сравнения с simdjson-go. Думаю simdjson будет быстрее и нет необходимости с приседаниями писать самому декодер