В 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 |
|---|---|---|---|---|
| 1,39 мс / 594 КБ / 8006 | 1,50 мс / 402 КБ / 5007 | 1,44 мс / 594 КБ / 8006 | 1,39 мс / 353 КБ / 2007 |
| 4,20 мс / 1256 КБ / 23021 | 2,89 мс / 1136 КБ / 11072 | 4,01 мс / 1256 КБ / 23021 | 2,55 мс / 1136 КБ / 11072 |
| 4,21 мс / 2286 КБ / 56007 | 6,59 мс / 2477 КБ / 60167 | 4,06 мс / 2286 КБ / 56007 | 3,53 мс / 2196 КБ / 45064 |
Запись в | 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 |
весь набор | 6,36 мс | 60167 |
только | 6,12 мс | 60167 |
только | 3,48 мс | 45064 |
только | 3,55 мс | 45064 |
только | 3,74 мс | 45064 |
только | 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 |
|---|---|---|
|
|
|
битый base64 в |
|
|
превышение глубины |
|
|
BOM в начале входа |
|
|
Типы ошибок при этом сохранились: *json.SyntaxError и *json.UnmarshalTypeError возвращаются там же, где и раньше. Ломается только код, который сравнивает err.Error() со строкой, и такого кода в проде неприлично много, особенно в тестах на валидацию входящих запросов.
Чем v2 отличается по семантике
Все строки таблицы прогнаны на 1.27, слева результат encoding/json, справа encoding/json/v2 на тех же данных.
Случай | v1 | v2 |
|---|---|---|
|
|
|
|
| ошибка маршалинга |
массив |
|
|
символы |
| как есть |
поле | попадёт | не попадёт, поле останется пустым |
дубликаты имён | берётся последнее | ошибка |
невалидный UTF-8 | заменяется на U+FFFD | ошибка |
| значение не трогается | значение обнуляется |
массив короче объявленного | добивается нулями | ошибка |
порядок ключей мапы | отсортирован | недетерминированный |
Первая строка меняет контракт вашего 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. Интересно, насколько мой синтетический пример на тысяче объектов совпадает с реальными полезными нагрузками.
Ссылки
Go-канал в Telegram — разборы рантайма, инструментов и практик Go: то, что пригождается в ежедневной работе

