golang

Go 1.27 подменил движок encoding/json. Замерил три конфигурации и нашёл, где стало хуже

  • пятница, 4 сентября 2026 г. в 00:00:12
https://habr.com/ru/articles/1078082/

В Go 1.27 пакет encoding/json остался тем же по API и перестал быть тем же внутри: теперь это тонкий слой совместимости поверх нового encoding/json/v2. Обещание Go 1 выполняется честно, старый код компилируется и ведёт себя как раньше, но цифры под ним поменялись, причём в обе стороны. Я собрал go1.27.0 из исходников, взял старый go1.24.7 для контроля и прогнал одинаковые бенчмарки в четырёх конфигурациях. Разбор ниже: где выиграли, где потеряли полтора раза на ровном месте, что именно уже изменилось в выводе v1, и какие грабли ждут при переходе на v2.

Коротко для тех, кто спешит: анмаршалинг в структуры ускорился примерно в полтора раза без единой правки в коде; анмаршалинг в any и map[string]any замедлился в полтора раза, причина найдена и сводится к одному флагу совместимости; маршалинг стал чуть медленнее по времени, но заметно экономнее по аллокациям; байты на выходе v1 в редких случаях изменились, тексты ошибок изменились чаще; переход на v2 даёт лучшие цифры во всех сценариях, включая any, но ломает семантику в шести местах, из которых одно ломается тихо.

Все замеры на go1.27.0 и go1.24.7, linux/amd64, GOMAXPROCS=2, -benchtime 2s -count 3, полезная нагрузка около 310 КБ (структура из тысячи пользователей с вложенными объектами, слайсами, мапами и временем).

Что произошло под капотом

Пакет encoding/json в 1.27 стал набором вызовов в encoding/json/v2 с включённым режимом старой семантики. Этот режим задаётся ровно двадцатью одним булевым флагом, они перечислены в internal/jsonflags/flags.go и включаются все разом через DefaultOptionsV1():

AllowDuplicateNames, AllowInvalidUTF8, EscapeForHTML, EscapeForJS,
PreserveRawStrings, Deterministic, FormatNilMapAsNull, FormatNilSliceAsNull,
MatchCaseInsensitiveNames, CallMethodsWithLegacySemantics, FormatByteArrayAsArray,
FormatBytesWithLegacySemantics, FormatDurationAsNano, MatchCaseSensitiveDelimiter,
MergeWithLegacySemantics, OmitEmptyWithLegacySemantics, ParseBytesWithLooseRFC4648,
ParseTimeWithLooseRFC3339, ReportErrorsWithLegacySemantics, StringifyWithLegacySemantics,
UnmarshalArrayFromAnyLength

Список стоит прочитать целиком, потому что это исчерпывающая карта различий между v1 и v2, составленная авторами пакета. Каждый флаг доступен как отдельная опция и переключается по одному, что и делает миграцию управляемой. Отключить новый движок целиком можно сборкой с GOEXPERIMENT=nojsonv2, эту заглушку обещают убрать в одном из следующих релизов.

Замеры

Четыре конфигурации на одном и том же коде и одних и тех же данных. Первая колонка это go1.24.7, вторая тот же код на go1.27.0, третья go1.27.0 со сборкой GOEXPERIMENT=nojsonv2 в роли контроля, четвёртая это прямой вызов API encoding/json/v2. Медиана из трёх прогонов.

Операция

1.24, v1

1.27, v1

1.27, nojsonv2

1.27, v2

Marshal структуры

1,39 мс / 594 КБ / 8006

1,50 мс / 402 КБ / 5007

1,44 мс / 594 КБ / 8006

1,39 мс / 353 КБ / 2007

Unmarshal в структуру

4,20 мс / 1256 КБ / 23021

2,89 мс / 1136 КБ / 11072

4,01 мс / 1256 КБ / 23021

2,55 мс / 1136 КБ / 11072

Unmarshal в any

4,21 мс / 2286 КБ / 56007

6,59 мс / 2477 КБ / 60167

4,06 мс / 2286 КБ / 56007

3,53 мс / 2196 КБ / 45064

Запись в io.Writer

1,32 мс / 273 КБ / 8005

1,40 мс / 82 КБ / 5006

1,31 мс / 273 КБ / 8005

1,34 мс / 32 КБ / 2005

Колонка с nojsonv2 совпадает с 1.24 до последней аллокации, так что все различия между первой и второй колонками порождены новым движком, рантайм и компилятор тут ни при чём. Главный подарок достаётся тем, кто парсит в типизированные структуры: минус треть по времени и вдвое меньше аллокаций просто от смены тулчейна. Запись в io.Writer через Encoder стала чуть дольше, но требует втрое меньше памяти, а на v2 через MarshalWrite в восемь раз меньше, чем было в 1.24. Маршалинг просел на 5-8 процентов по времени при падении числа аллокаций с восьми тысяч до пяти; на сервисе, где GC и так дышит тяжело, этот размен скорее в плюс.

Ловушка any

Строка про any выбивается из общей картины: те же данные, тот же вызов, в полтора раза дольше и на четыре тысячи аллокаций больше. Под удар попадает всё, что разбирает JSON без схемы: вебхуки, прокси, миддлвари с логированием тела запроса, динамические конфиги, тесты, которые сравнивают map[string]any.

Причину я искал перебором: прогнал разбор в any через v2 с включением флагов совместимости по одному.

Конфигурация

Время

Аллокации

v2 по умолчанию

3,43 мс

45064

весь набор DefaultOptionsV1

6,36 мс

60167

только AllowDuplicateNames(true)

6,12 мс

60167

только MergeWithLegacySemantics(true)

3,48 мс

45064

только MatchCaseInsensitiveNames(true)

3,55 мс

45064

только AllowInvalidUTF8(true)

3,74 мс

45064

только ReportErrorsWithLegacySemantics(true)

4,11 мс

45064

Один флаг воспроизводит всю разницу целиком, включая точное число аллокаций. Дальше всё объясняется десятью строчками в v2/arshal_default.go:

// Optimize for the any type if there are no special options.
// Duplicate name check must be enforced since unmarshalValueAny
// does not implement merge semantics.
if optimizeCommon &&
	t == anyType && !uo.Flags.Get(jsonflags.AllowDuplicateNames|jsonflags.FormatTag) &&
	(uo.Unmarshalers == nil || !uo.Unmarshalers.(*Unmarshalers).fromAny) {
	v, err := unmarshalValueAny(dec, uo)
	...

У v2 есть специализированный путь для any, написанный без рефлексии. Работает он только тогда, когда дубликаты имён запрещены, потому что при дубликатах нужна семантика слияния, которой в быстром пути нет. Спецификация v1 обязана дубликаты разрешать, значит быстрый путь для неё закрыт всегда, и разбор уезжает в общий рефлексивный декодер, который в этой роли медленнее старого специализированного кода v1. Выхода два: перейти на v2 в тех местах, где вы парсите в any, и получить 3,5 мс вместо старых 4,2, либо оставить v1 и потерять полтора раза. Промежуточных вариантов нет: AllowDuplicateNames(false) на v1 API не выставить, там этот флаг зашит.

Что уже изменилось в выводе v1

Тут стоит различать семантику и байты. Семантика сохранена, я прогнал два десятка краевых случаев на обоих тулчейнах и расхождений в значениях не нашёл: дубликаты по-прежнему берут последнее значение, регистр имён по-прежнему игнорируется, null в структуру по-прежнему ничего не меняет, nil слайс по-прежнему сериализуется в null, порядок ключей мапы по-прежнему отсортирован. А вот байты и тексты ошибок разъехались.

Строка с невалидным UTF-8 при маршалинге в 1.24 давала экранированную запись "\ufffd\ufffd", а в 1.27 даёт те же символы замены сырыми байтами, без экранирования. Значение после разбора одинаковое, длина и содержимое байтов разные. Если у вас есть golden-файлы, подписи над телом ответа или хеши от сериализованного JSON, проверьте этот случай отдельно.

Тексты ошибок поменялись в нескольких местах:

Случай

1.24

1.27

,string на числовом значении

invalid use of ,string struct tag, trying to unmarshal unquoted value into int64

cannot unmarshal number into Go struct field Str.n of type int64

битый base64 в []byte

illegal base64 data at input byte 0

json: cannot unmarshal string into Go value of type []uint8: illegal base64 data at input byte 0

превышение глубины

invalid character '[' exceeded max depth

exceeded max depth

BOM в начале входа

invalid character 'ï' looking for beginning of value

invalid character '\ufeff' looking for beginning of value

Типы ошибок при этом сохранились: *json.SyntaxError и *json.UnmarshalTypeError возвращаются там же, где и раньше. Ломается только код, который сравнивает err.Error() со строкой, и такого кода в проде неприлично много, особенно в тестах на валидацию входящих запросов.

Чем v2 отличается по семантике

Все строки таблицы прогнаны на 1.27, слева результат encoding/json, справа encoding/json/v2 на тех же данных.

Случай

v1

v2

nil слайс и мапа

{"s":null,"m":null}

{"s":[],"m":{}}

time.Duration

90000000000

ошибка маршалинга

массив [2]byte

[1,2]

"AQI="

символы <, >, &

<>&

как есть

поле NAME в поле name

попадёт

не попадёт, поле останется пустым

дубликаты имён

берётся последнее

ошибка

невалидный UTF-8

заменяется на U+FFFD

ошибка

null в непустую структуру

значение не трогается

значение обнуляется

массив короче объявленного

добивается нулями

ошибка

порядок ключей мапы

отсортирован

недетерминированный

Первая строка меняет контракт вашего API: клиенты, которые различали null и [], увидят другое. Про Duration разговор отдельный ниже. Строка про регистр имён единственная в таблице, которая ломается молча: ошибки нет, поле просто остаётся нулевым, и если сервис принимает JSON от чужого клиента с полями в другом регистре, вы узнаете об этом от пользователей.

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

порядок 0 {"a":1,"b":2,"c":3,"d":4,"e":5}
порядок 1 {"a":1,"b":2,"c":3,"d":4,"e":5}
порядок 2 {"d":4,"e":5,"a":1,"b":2,"c":3}
с Deterministic: {"a":1,"b":2,"c":3,"d":4,"e":5}

Тесты, сравнивающие сериализованный JSON построчно, начнут мигать не сразу и не у всех, что делает диагностику особенно неприятной. Лечится опцией json.Deterministic(true), за неё придётся заплатить сортировкой на каждом маршалинге мапы.

Duration и удалённый тег format

time.Duration в v2 не сериализуется вообще: у типа нет однозначного JSON-представления, и авторы решили не выбирать за вас. В ранней редакции пакета, той, что жила под GOEXPERIMENT, формат задавался тегом вида json:"t,format:units". В 1.27 опция format из тегов удалена, так что этот путь закрыт. Осталось два рабочих варианта. Либо включить старое поведение опцией, тогда получите наносекунды числом, как в v1:

b, err := json.Marshal(cfg, jsonv1.FormatDurationAsNano(true))
// {"t":90000000000}

Либо описать формат явно через маршалер для конкретного типа, что заодно даёт человекочитаемый вид:

b, err := json.Marshal(cfg, json.WithMarshalers(
	json.MarshalFunc(func(d time.Duration) ([]byte, error) {
		return []byte(strconv.Quote(d.String())), nil
	})))
// {"t":"1m30s"}

Ради чего это всё затевалось

Опции задаются на каждый вызов, что снимает привязку правил сериализации к типу и меняет всю работу с чужими типами. Раньше, чтобы поменять сериализацию time.Duration или структуры из внешней библиотеки, приходилось заводить тип-обёртку и протаскивать её через все слои. Теперь правило описывается функцией и передаётся туда, где оно нужно:

type ExternalID struct{ Hi, Lo uint64 }

b, err := json.Marshal(ids, json.WithMarshalers(
	json.MarshalFunc(func(id ExternalID) ([]byte, error) {
		return []byte(strconv.Quote(fmt.Sprintf("%016x%016x", id.Hi, id.Lo))), nil
	})))
// ["00000000000000010000000000000002","00000000000000030000000000000004"]

Симметричный json.UnmarshalFunc разбирает это обратно в []ExternalID без единой правки в самом типе. Есть и потоковые варианты MarshalToFunc и UnmarshalFromFunc, которые получают *jsontext.Encoder и работают на уровне токенов.

Второе приобретение это честный стриминг. MarshalWrite и UnmarshalRead пишут и читают через io.Writer и io.Reader без промежуточного буфера на весь документ: 32 КБ аллокаций против 273 КБ у Encoder из 1.24 на той же нагрузке. Для больших ответов это разница между спокойной кучей и пилой на графике.

Третье это omitzero рядом с omitempty, с внятно разделёнными ролями. omitempty в v2 опускает поле, если оно кодируется в пустой JSON: пустая строка, пустой слайс, пустая мапа. omitzero опускает поле, если значение равно нулевому значению своего типа. На структуре разница видна сразу:

type Omit struct {
	C Sub `json:"c,omitempty"`
	E Sub `json:"e,omitzero"`
}
// v2: {"c":{"x":0}}

Пустая структура кодируется в непустой объект, поэтому omitempty её оставляет, а omitzero убирает. Годами написанные omitempty на структурах наконец получили работающего напарника.

Отдельно стоит пакет encoding/json/jsontext: синтаксический слой без всякой рефлексии, Encoder и Decoder поверх потока токенов и значений. Для трансформации чужого JSON на лету, для валидации и для написания собственных кодеков это гораздо приятнее, чем json.RawMessage и ручной разбор.

Как мигрировать, не устроив аврал

Порядок, который у меня получился рабочим. Сначала обновляетесь на 1.27 без единой правки и прогоняете тесты: семантика v1 сохранена, поймаете вы только сравнения строк ошибок и golden-файлы с невалидным UTF-8. Затем прогоняете бенчмарки на своих горячих путях и смотрите, есть ли у вас разбор в any; если есть, это первый кандидат на перевод. Дальше начинаете с самого нового и изолированного места, меняете импорт на encoding/json/v2 и сразу передаёте jsonv1.DefaultOptionsV1() первым аргументом: поведение остаётся прежним, а API уже новый. После этого выключаете флаги совместимости по одному, начиная с безопасных вроде AllowDuplicateNames и AllowInvalidUTF8, и заканчивая теми, что меняют контракт: FormatNilSliceAsNull, MatchCaseInsensitiveNames, EscapeForHTML. Каждый шаг это отдельный коммит с прогоном тестов, и когда что-то ломается, вы точно знаете, какой именно флаг это сделал. Официальный гайд по миграции лежит на go.dev и содержит полную таблицу соответствий.

Что я про это думаю

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

Смущает единственное место, и оно же самое частое: разбор в any теперь наказывается за то, что v1 обязан разрешать дубликаты имён. Формально всё честно, быстрый путь несовместим со слиянием. Практически это значит, что обновление тулчейна замедляет самый распространённый способ работы с JSON у тех, кто не читает release notes целиком. Я бы предпочёл, чтобы v1 научился разрешать дубликаты в быстром пути, потому что случай с дубликатами в реальном трафике исчезающе редок, а расплачиваются за него все.

Если вы уже обновились, прогоните свои бенчмарки на горячих путях и напишите в комментариях, что показала строка с any. Интересно, насколько мой синтетический пример на тысяче объектов совпадает с реальными полезными нагрузками.

Ссылки