Если вы просто обновили тулчейн до Go 1.27 и не тронули ни строки в кодовой базе — вы уже переехали на новый JSON-движок. encoding/json в 1.27 работает поверх encoding/json/v2 по умолчанию; откатить его на прежнюю реализацию можно только явным флагом сборки GOEXPERIMENT=nojsonv2. Обычно вопрос «переходить ли на v2» подразумевает выбор. Здесь выбор уже сделан за вас — вопрос в том, что именно изменилось у кода, который вы не трогали, и вам не понравится ответ: на стендовой машине и в этой серии замеров decode стал медленнее в полтора-два раза, а поведение не изменилось вовсе. Величина замедления — результат конкретного прогона на конкретной машине (замер блочный, подробности ниже), так что прежде чем переносить её на свой сервис, стоит перепроверить на месте.
В статье
- Вы уже переехали
- Цена обновления против цены переписывания
- Строгость: что теперь отвергается
- Потоковая обработка через jsontext
- Миграция на явный v2
- Когда откатываться
- Демо и версии
- Документация и первоисточники
Вы уже переехали
До 1.27 у encoding/json было два известных узких места: декодирование в any тратит время на рефлексию и лишние аллокации, а строгость парсера — с дублирующимися ключами, неверным регистром полей, битым UTF-8 — была скорее вопросом вкуса, чем контракта: пакет либо принимал такой JSON, либо принимал его тоже, просто молча. Go 1.27 не чинит это внутри encoding/json — вместо этого появляется отдельный пакет encoding/json/v2 с новым API и низкоуровневый encoding/json/jsontext для потоковой работы с токенами. А сам encoding/json тем временем переводится на движок v2 изнутри — но со старым API и со старым (нестрогим) поведением снаружи.
Это разделение и создаёт нынешнюю ситуацию. Стенд к статье снял три конфигурации на одном payload — v1-old, v1-on-v2, v2-api — чтобы развести три разных вопроса, которые обычно сливаются в один «v1 или v2»:
- что происходит с кодом, который просто оказался на новом тулчейне (
v1-old→v1-on-v2); - что даёт явный переход на новый API (
v1-on-v2/v1-old→v2-api); - и отдельно — что теряется при откате назад (
v1-on-v2→v1-old), потому что откат — не бесплатная операция без последствий.
Цена обновления против цены переписывания
Здесь важна одна оговорка про саму колонку v1-old: это Go 1.27, собранный с флагом отката GOEXPERIMENT=nojsonv2, а не Go 1.26. Замер отвечает на вопрос «что даёт флаг отката в текущем тулчейне», а не «чем 1.27 отличается от 1.26» — это разные вопросы, и первый результат легко принять за второй.
Три конфигурации гонялись пять полными прогонами каждая (двух прогонов не хватило, чтобы отличить эффект от шума машины) на четырёх формах payload: small — плоская структура, large-array — массив из нескольких тысяч элементов, deep-nested — вложенные структуры, map-heavy — декодирование в карты. Ниже — медианы серии:
| Операция / форма | v1-old | v1-on-v2 | v2-api | обновление тулчейна | разброс серии |
|---|---|---|---|---|---|
| Decode/small | 1064 нс/оп | 2114 нс/оп | 946.5 нс/оп | 1.99× медленнее | ~20% |
| Decode/large-array | 1 015 398 нс/оп | 1 988 132 нс/оп | 861 622 нс/оп | 1.96× медленнее | ~20% |
| Decode/deep-nested | 49 489 нс/оп | 79 966 нс/оп | 46 481 нс/оп | 1.62× медленнее | 11–31% |
| Decode/map-heavy | 3 200 386 нс/оп | 4 891 576 нс/оп | 2 960 816 нс/оп | 1.53× медленнее | 13–24% |
| Encode/small | 278.5 нс/оп | 466.5 нс/оп | 495.6 нс/оп | 1.68× медленнее | 1.8–15.9% |
Первая строка каждой пары чисел — то, что получает читатель, который просто обновил тулчейн, не написав ни строки кода: v1-old → v1-on-v2. На этой машине и в этой серии замедление проявляется на всех четырёх формах — и на decode, и на encode мелкой структуры — и превышает разброс серии внутри каждой отдельной конфигурации. Разброс 15–31%, заметный на части форм (сильнее всего на deep-nested), — это не нестабильность какой-то одной конфигурации: на пяти прогонах он проявился во всех трёх, включая v1-old и v2-api. Похоже на свойство стендовой машины (Windows, шумная ОС без изоляции ядер), а не движка — но утверждать это с уверенностью мешает устройство самого замера: три конфигурации гонялись последовательными блоками — пять прогонов v1-old подряд, потом пять v1-on-v2, потом пять v2-api, а не вперемешку и не рандомизированно, — так что время каждой конфигурации смешано с её положением на оси времени и с дрейфом состояния машины (тепловой режим CPU, фоновая нагрузка ОС). То, что v2-api шёл последним и всё же оказался быстрее, эту проблему не снимает: это другой API и другой набор бенчмарков, а не контрольная точка того же измерения. Итог — величина замедления 1.5–2× верна для этой машины и этой конкретной серии; для выводов о своём сервисе её стоит перепроверить замером с чередованием или рандомизацией конфигураций, а не принимать как универсальное число.
Столбец v2-api на первый взгляд выглядит быстрее v1-old по времени — например, Decode/small 946.5 против 1064 нс/оп. Но это ровно тот вывод, который не подтверждён: разница 7.5–11% на разных формах меньше разброса серии в 10–20%. Пять прогонов не позволяют заявить «v2 API быстрее прежнего движка по времени» — только то, что явный переход на v2 возвращает время decode примерно к уровню, который был до обновления тулчейна, после того как это же обновление без правок кода его удвоило.
Зато по аллокациям выигрыш v2 API доказан и нагляден. Аллокации детерминированы (разброса между прогонами нет):
| Метрика | v1-old | v1-on-v2 | v2-api |
|---|---|---|---|
| Encode/map-heavy, allocs/op | 4002 | 2005 | 4 |
| Decode/large-array, allocs/op | 15 005 | 15 014 | 11 012 |
Промежуточная точка v1-on-v2 уже вдвое экономнее v1-old по аллокациям кодирования карт — движок под неизменным encoding/json стал здесь эффективнее сам по себе. Явный переход на v2 API убирает почти все аллокации кодирования этой формы — эффект на три порядка (4 против 4002). На decode выигрыш v2-api по аллокациям тоже есть на всех формах, но там счёт идёт на десятки процентов, а не на порядки.
Есть ещё одна находка про сам откат, не про числа. bench_v2_test.go со стенда собирается только под тегом goexperiment.jsonv2:
//go:build goexperiment.jsonv2
package jsonv2bench_test
import (
jsonv2 "encoding/json/v2"
"testing"
"tech.khorost/json-v2-cookbook/payload"
)Под GOEXPERIMENT=nojsonv2 пакеты encoding/json/v2 и encoding/json/jsontext исключаются build-ограничениями целиком, а не просто ведут себя иначе. Файл, который их импортирует, в режиме отката не собрался бы вовсе и утащил бы за собой весь тестовый пакет, включая уже рабочие бенчи v1 — поэтому на стенде BenchmarkV2* и потоковый замер разведены по отдельным файлам с этим тегом. Практический вывод: откат на прежний движок отнимает не только новое поведение encoding/json — он отнимает сами пакеты v2 и jsontext из сборки, вместе с возможностью вообще сравнить их с прежним движком в одном бинарнике.
Строгость: что теперь отвергается
Проверено на шести случаях — это выборка, а не полная матрица поведения JSON-парсера: duplicate-keys, unknown-field, case-insensitive-match, invalid-utf8, trailing-data, null-into-int.
| Случай | encoding/json, откат (v1-old) | encoding/json, умолчание 1.27 | encoding/json/v2, явный |
|---|---|---|---|
| duplicate-keys | accepted | accepted | rejected |
| unknown-field | accepted | accepted | accepted |
| case-insensitive-match | accepted | accepted | accepted |
| invalid-utf8 | accepted | accepted | rejected |
| trailing-data | rejected | rejected | rejected |
| null-into-int | accepted | accepted | accepted |
Первый вывод — про то, что не изменилось. Столбцы «откат» и «умолчание 1.27» совпадают по всем шести случаям: encoding/json, работающий поверх нового движка, сохраняет прежнюю нестрогую семантику Unmarshal. Код, который не переписывали под v2 API, ведёт себя как раньше — обновление тулчейна не делает существующий парсинг ни строже, ни мягче.
Второй вывод — про то, что реально строже, и это не encoding/json, а явный encoding/json/v2. В той же сборке (v1-on-v2, где пакет v2 доступен) он расходится с encoding/json по двум случаям из шести: duplicate-keys и invalid-utf8 v2 отвергает, а encoding/json по-прежнему принимает. trailing-data отвергают оба — но здесь важна точная атрибуция: демо вызывает json.Unmarshal, и это поведение именно Unmarshal — он отвергает данные, оставшиеся после первого значения. json.Decoder.Decode устроен иначе: он штатно читает одно значение и оставляет хвост для следующего вызова Decode — на этом построено потоковое чтение JSON (см. раздел про jsontext ниже), и приписывать этот же отказ Decoder в целом было бы неверно. Остальные три случая (unknown-field, case-insensitive-match, null-into-int) оба API принимают одинаково.
//go:build goexperiment.jsonv2
package main
import jsonv2 "encoding/json/v2"
const v2Available = true
func v2Verdict(data string) string {
var v target
if err := jsonv2.Unmarshal([]byte(data), &v); err != nil {
return "rejected"
}
return "accepted"
}Итог раздела короткий: строгость v2 реальна и проверяема — но живёт в API (encoding/json/v2), а не в движке (encoding/json поверх v2). Она ловит баги вроде дублирующихся ключей и битого UTF-8, но требует явного перехода на новый пакет, а не подтягивается автоматически при обновлении тулчейна.
Потоковая обработка через jsontext
Отдельный замер — 10 000 строк NDJSON, декодированных двумя способами: encoding/json.Decoder в map[string]any построчно (полноценная материализация Go-значений) и jsontext.NewDecoder.ReadValue — проход по сырым JSON-значениям без распаковки в структуры.
func decodeAllV1(data []byte) int {
dec := stdjson.NewDecoder(bytes.NewReader(data))
n := 0
for {
var v map[string]any
if err := dec.Decode(&v); err != nil {
return n
}
n++
}
}
func decodeAllJSONText(data []byte) int {
dec := jsontext.NewDecoder(bytes.NewReader(data))
n := 0
for {
val, err := dec.ReadValue()
if err != nil {
return n
}
if len(val) > 0 {
n++
}
}
}Результат на 10 000 строках — разница на порядок и по времени, и по аллокациям:
| Бенчмарк | нс/оп | B/оп | allocs/оп |
|---|---|---|---|
BenchmarkStreamV1Decoder |
19.6M / 19.4M | 4 814 327 | 150 018 |
BenchmarkStreamJSONText |
1.73M / 1.88M | 8 744 | 18 |
Но важно, что именно тут сравнивается: это не «v2 декодирует быстрее», а «проход по токенам без материализации значений дешевле, чем материализация в map[string]any» — ожидаемый результат, который показывает, для какой задачи годится jsontext (фильтрация, пересылка, выборочный разбор без полного анмаршалинга), а не общее ускорение decode в v2.
Отдельный тест на стенде (TestStreamMemoryScaling) проверяет не абсолютные числа, а масштабирование расхода при десятикратном росте входа (1000 строк против 10 000):
v1 Decoder: 496 552 -> 4 823 744 байт (x9.71)
jsontext: 8 752 -> 8 752 байт (x1.00)Расход памяти у json.Decoder растёт почти линейно с числом строк — каждая строка материализуется в map[string]any и остаётся в куче до GC. У jsontext.ReadValue расход не растёт вовсе: буфер переиспользуется на каждое значение, ничего не накапливается. Здесь тоже нужна оговорка: замер снят на однородном NDJSON фиксированной длины строки — он не доказывает, что расход останется константным на другом профиле данных (переменная длина строк, вложенные структуры, редкие большие записи). И TotalAlloc/allocs/op считают все выделения за время работы, а не то, что осталось живым к концу — про пиковое или итоговое потребление памяти процессом (retained-память после GC) замер ничего не говорит.
Миграция на явный v2
Опыт кодирования и декодирования в encoding/json/v2 намеренно похож на v1 — Marshal/Unmarshal с той же сигнатурой на уровне вызова, поэтому переход на уровне отдельной точки кода дешёвый:
import jsonv2 "encoding/json/v2"
func BenchmarkV2Encode(b *testing.B) {
for _, c := range payload.All() {
b.Run(c.Name, func(b *testing.B) {
b.ReportAllocs()
for b.Loop() {
if _, err := jsonv2.Marshal(c.Value); err != nil {
b.Fatal(err)
}
}
})
}
}
func BenchmarkV2Decode(b *testing.B) {
for _, c := range payload.All() {
b.Run(c.Name, func(b *testing.B) {
b.ReportAllocs()
for b.Loop() {
var v any
if err := jsonv2.Unmarshal(c.JSON, &v); err != nil {
b.Fatal(err)
}
}
})
}
}Но за похожей сигнатурой стоит другое поведение по умолчанию — раздел про строгость выше показывает это на конкретных случаях. Прямая замена импорта encoding/json на encoding/json/v2 без ревизии данных, которые парсер видел раньше, — это не рефакторинг, а смена контракта: код, который раньше молча проглатывал дублирующиеся ключи или невалидный UTF-8 от чужого источника, начнёт на них падать. Перед переключением стоит прогнать существующие интеграционные данные (реальные payload’ы от внешних API, логи с продакшна) через v2Verdict-подобную проверку — так же, как это делает strictness-demo на стенде, только на своих данных, а не на шести синтетических случаях.
Второй момент — сборка. Оба пакета, encoding/json/v2 и encoding/json/jsontext, доступны только когда goexperiment.jsonv2 включён (это умолчание Go 1.27 без флага отката). Если в проекте есть CI-матрица, которая всё ещё собирает и с GOEXPERIMENT=nojsonv2 — например, ради сравнения производительности, как на этом стенде, — код, использующий v2 API, придётся разводить по build-тегам так же, как это сделано в bench_v2_test.go и stream_test.go, иначе сборка под откатом просто перестанет компилироваться.
Когда откатываться
Дальше — рекомендация, а не результат замера: стенд не проверял, как каждый из трёх сценариев ведёт себя на конкретном сервисе, это выводы из чисел выше, а не отдельное измерение. Три сценария, и они разные не по вкусу, а по цене.
Ничего не делать имеет смысл, если сервис не декодирует JSON на горячем пути и текущая просадка 1.5–2× на decode не бьёт по латентности заметно — например, конфиги парсятся раз при старте, а не на каждый запрос. Строгость encoding/json при этом не изменилась ни на бит, так что поведенческих сюрпризов обновление тулчейна тоже не приносит.
Переписывать код под явный encoding/json/v2 имеет смысл там, где реально нужна либо строгость (валидация входящих данных, где дубли ключей или битый UTF-8 — это баг, а не особенность источника), либо аллокации на горячем пути кодирования map-heavy структур, где выигрыш измеряется в порядках, а не в процентах. Это осознанная работа с ревизией данных, а не флаг компиляции.
Откатываться флагом GOEXPERIMENT=nojsonv2 имеет смысл только как временная мера — если decode на горячем пути и просадка производительности критична прямо сейчас, а переписать под v2 API ещё не успели. У отката есть своя цена, которая обычно не всплывает при первом чтении релиз-ноутов: он не просто возвращает прежнюю скорость, он убирает из сборки сами пакеты encoding/json/v2 и encoding/json/jsontext — то есть параллельно закрывает дверь к постепенной миграции в этой же кодовой базе. Если стратегия — «откатиться сейчас, переписать код на v2 API позже», держать это как временное решение с явным сроком, а не как постоянную конфигурацию: постоянный откат означает постоянный отказ от строгости и от jsontext, а не только от медленного decode.
Демо и версии
digital-cookbook/go/json-v2Стенд закреплён на GA-тулчейне go1.27.0 (не RC): GOTOOLCHAIN=go1.27.0 go version. Внутри — три конфигурации бенчей (v1-old/v1-on-v2/v2-api) на четырёх формах payload, strictness-demo на шести случаях в трёх колонках и потоковый замер NDJSON через jsontext. Режим сборки бенч читает не из переменной окружения, а из debug.ReadBuildInfo() самого бинарника — так исключается ситуация, когда флаг GOEXPERIMENT не долетел до конкретной команды сборки и бенч тихо меряет не ту конфигурацию.
Числа сняты на одной машине (Windows, AMD Ryzen 7 5800X3D), одной блочной серией без чередования и рандомизации конфигураций — абсолютные значения, наблюдаемый разброс и сама величина замедления decode (1.5–2×, раздел выше) характерны для этой машины и этой серии, а не для движка вообще: чередующийся или рандомизированный замер может дать другое число, и переносить эти цифры на Linux-продакшн с другой политикой планировщика без переизмерения не стоит. CPU-профиль внутри decode/encode (где именно уходит время — аллокатор, парсинг, рефлексия) стенд не снимал.
Документация и первоисточники
- Обзор Go 1.27 целиком: VictoriaMetrics — Go 1.27 interactive tour.
- Официальные заметки о релизе: go.dev/doc/go1.27 — раздел про
encoding/json/v2иencoding/json/jsontext. - Пакет
encoding/json/v2: pkg.go.dev/encoding/json/v2. - Пакет
encoding/json/jsontext: pkg.go.dev/encoding/json/jsontext.
Смежные материалы на сайте: профиль утекших горутин в Go 1.27 — вторая крупная новинка того же релиза; профилирование и бенчмарки в Go — методика измерений, которой снят этот стенд; тестирование в Go — про build-теги и таблично управляемые тесты, на которых построен strictness-demo.
Комментарии