encoding/json/v2 в Go 1.27: вы уже переехали, не написав ни строки

encoding/json в Go 1.27 работает поверх нового движка v2 по умолчанию — обновивший тулчейн уже переехал, ничего не переписывая. На бенчах стенда это стоит decode в полтора-два раза дороже, а поведение при этом не меняется вовсе: строгость и выигрыш по аллокациям даёт только явный переход на encoding/json/v2 и потоковый jsontext.

Если вы просто обновили тулчейн до Go 1.27 и не тронули ни строки в кодовой базе — вы уже переехали на новый JSON-движок. encoding/json в 1.27 работает поверх encoding/json/v2 по умолчанию; откатить его на прежнюю реализацию можно только явным флагом сборки GOEXPERIMENT=nojsonv2. Обычно вопрос «переходить ли на v2» подразумевает выбор. Здесь выбор уже сделан за вас — вопрос в том, что именно изменилось у кода, который вы не трогали, и вам не понравится ответ: на стендовой машине и в этой серии замеров decode стал медленнее в полтора-два раза, а поведение не изменилось вовсе. Величина замедления — результат конкретного прогона на конкретной машине (замер блочный, подробности ниже), так что прежде чем переносить её на свой сервис, стоит перепроверить на месте.

Гараж: синий фургон с надписью encoding/json на борту, за рулём гофер в лётном шлеме смотрит вперёд. Капот открыт, внутри новый двигатель с клеймом v2 и болтающейся биркой «Go 1.27»; на приборе спереди стрелка decode ушла в красную зону. Справа тяжёлая дверь «encoding/json/v2 API» с табличкой «rewrite required», рядом гофер-механик с ключом; за приоткрытой дверью светлая кладовая с несколькими аккуратными ящиками. Слева до потолка громоздится гора старых ящиков с пометкой v1

В статье

Вы уже переехали

До 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-oldv1-on-v2);
  • что даёт явный переход на новый API (v1-on-v2/v1-oldv2-api);
  • и отдельно — что теряется при откате назад (v1-on-v2v1-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 — вторая крупная новинка того же релиза; профилирование и бенчмарки в Go — методика измерений, которой снят этот стенд; тестирование в Go — про build-теги и таблично управляемые тесты, на которых построен strictness-demo.

Обсуждение в Telegram

Присоединиться →

Комментарии