MAATRIX / Блог / Откуда берутся кракозябры: путь одного байта от базы до браузера

Откуда берутся кракозябры: путь одного байта от базы до браузера

MAATRIX

Вы открываете страницу — и вместо «Привет» видите «Ð Ð¿Ñ€Ð¸Ð²ÐµÑ‚» или ряд ромбиков с вопросом. Первая реакция — «сломалась база». На деле текст почти всегда цел: байты долетели до браузера без потерь. Сломалось не хранение, а согласование — кто-то в цепочке записал байты одним способом, а кто-то другой прочитал их иначе. Разберём путь текста от строки в базе до пикселей на экране и покажем, в какой именно точке чаще всего расходятся договорённости.

Почему кракозябры — это не повреждение, а неправильное чтение

Компьютер не хранит буквы — он хранит числа. Кодировка — таблица соответствия между числами (байтами) и символами. UTF-8 говорит: «байты 0xD0 0x9F — это буква П». Windows-1251 говорит: «байт 0xCF — это тоже П, но другой». Если строку записали в UTF-8, а прочитали как Windows-1251 (или Latin-1, он же ISO-8859-1), получится не пустота и не ошибка, а другой, вполне валидный, но бессмысленный набор символов. Это и есть кракозябры, mojibake.

Сам байт не помнит, в какой кодировке его записали — эта информация либо передаётся рядом (в заголовке, в настройке соединения), либо подразумевается по умолчанию. Как только подразумеваемое умолчание на каком-то шаге расходится с фактической кодировкой байтов — начинаются кракозябры.

Отдельный частый случай — двойное перекодирование: строку в UTF-8 по ошибке приняли за Latin-1 и перегнали обратно в UTF-8. Каждый многобайтовый символ (кириллица — 2 байта, часть иероглифов и эмодзи — 3-4) разваливается на несколько ложных символов — отсюда характерные цепочки вида привет вместо «привет».

Путь символа от базы до экрана проходит минимум через пять точек, где кодировка объявляется заново: соединение с БД, колонка/таблица на диске, файл с исходным кодом, заголовок Content-Type, кодировка внутри HTML. Разберём каждую и почему сбой хотя бы в одной ломает весь путь.

Кодировка соединения с базой данных

У базы есть минимум два разных параметра: в чём хранятся данные на диске и в чём идёт обмен с клиентом по сети — это не одно и то же. Если данные в UTF-8, а сессия объявлена как Latin-1, сервер честно перекодирует байты при передаче — и на выходе кракозябры, хотя в таблице всё хранится верно.

В MySQL/MariaDB это видно сразу:

SHOW VARIABLES LIKE 'character_set%';
-- character_set_client, character_set_connection,
-- character_set_results, character_set_database

Задать кодировку соединения явно сразу после коннекта:

SET NAMES utf8mb4;

Одной командой это выставляет client, connection и results в одно значение, убирая рассинхрон между ними. Драйверы почти всегда позволяют указать это прямо в строке подключения — и делать это стоит именно там:

mysql://user:pass@host:3306/db?charset=utf8mb4
postgresql://user:pass@host:5432/db?client_encoding=UTF8

В PostgreSQL логика другая: server_encoding задаётся один раз при создании базы, client_encoding можно менять на лету (SHOW server_encoding;, SET client_encoding = 'UTF8';). PostgreSQL здесь безопаснее MySQL: при несовпадении encoding он обычно громко падает с ошибкой на символах, которые нельзя однозначно перекодировать, вместо того чтобы тихо испортить строку — как может сделать MySQL с несовпадающими character_set_client и кодировкой колонки, отработав без единой ошибки.

Правило: кодировку соединения нужно задавать явно в каждом клиенте — не только в основном приложении, но и в cron-скриптах, консольных клиентах, дампах (mysqldump --default-character-set=utf8mb4). Терминал с локалью C при ручном дампе — классический источник «данные битые именно отсюда».

Нужен сервер под эту задачу?

Разверните VPS MAATRIX за пару минут: NVMe, AMD EPYC, root-доступ, локации UK, США, Франция и РФ. Оплата картой РФ и по СБП.

Арендовать сервер

Кодировка колонки, таблицы и базы данных

Даже при верном соединении у данных на диске есть собственная объявленная кодировка — и она может расходиться с тем, что туда когда-то реально записали. Годами дефолтом MySQL был latin1, и базы без явного указания получали именно его, даже если приложение всегда писало UTF-8.

Проверка в MySQL:

SELECT default_character_set_name FROM information_schema.SCHEMATA
WHERE schema_name = 'your_db';

SHOW CREATE TABLE your_table;  -- ищите CHARSET=... по таблице и колонкам

MySQL позволяет колонке иметь собственную кодировку, отличную от таблицы и базы — удобно (бинарные хеши иногда держат в latin1 намеренно), но легко забывается, пока туда не попробуют записать эмодзи.

Частая ловушка именно в MySQL — разница между utf8 и utf8mb4. Исторический utf8 в MySQL — усечённый вариант, максимум 3 байта на символ: он не хранит символы за пределами Basic Multilingual Plane, включая большинство эмодзи. Такие символы обрезаются или вызывают ошибку вставки. Полноценный UTF-8 в MySQL — это utf8mb4. CHARSET=utf8 (без mb4) в схеме — повод проверить, не режутся ли эмодзи при записи.

Перевод существующей таблицы:

ALTER TABLE your_table CONVERT TO CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci;

Если данные на диске уже испорчены (реально перекодированы дважды), CONVERT TO только закрепит испорченность в новом виде — сначала нужно восстановить исходные байты (часто iconv в обратную сторону над экспортом), и только затем менять объявленную кодировку. Подробный разбор именно такого случая — когда latin1 в MySQL необратимо повредил уже записанные данные — в статье про latin1 в MySQL: хорошо показывает, почему кодировку колонок стоит проверять до того, как в таблицу попадут реальные данные.

Про базовую настройку сервера БД с явным выбором кодировки при создании — в материалах про установку MySQL и установку PostgreSQL: кодировку базы разумно закрепить в момент создания, а не чинить постфактум.

Кодировка файла с исходным кодом приложения

Третья точка — редко обсуждаемая, но реальная: в каком байтовом виде сохранён сам файл на диске сервера. Если в коде есть литеральная строка на кириллице, она при сохранении файла превращается в конкретные байты — в UTF-8, в Windows-1251, а иногда (при копипасте из старого документа или из терминала с чужой локалью) в файле оказывается смесь двух кодировок сразу, невидимая глазу в обычном редакторе.

Большинство языков считают файл исходников UTF-8 по умолчанию — это допущение, а не гарантия, и его стоит проверять явно:

file -i src/templates/email.txt
# text/plain; charset=utf-8  -- или charset=iso-8859-1, что и будет проблемой

hexdump -C src/templates/email.txt | head

В части экосистем кодировку файла и вовсе стоит объявлять явно в коде, потому что интерпретатор может брать её не из содержимого файла, а из локали окружения:

  • Python: # -*- coding: utf-8 -*- в начале файла (для Python 3 UTF-8 — умолчание, но на смешанных legacy-окружениях явное указание не лишнее);
  • Java: кодировка исходников задаётся флагом -encoding UTF-8, а не берётся автоматически — по умолчанию используется локаль системы, что на сервере с локалью C может оказаться вовсе не UTF-8;
  • PHP: файл должен быть сохранён в UTF-8 без BOM, а работа со строками — через mb_*-функции с явным UTF-8, потому что обычные строковые функции PHP байт-ориентированы и не знают о многобайтовых символах.

BOM (EF BB BF в начале UTF-8-файла) — отдельная головная боль: часть редакторов добавляет его автоматически, а самописные построчные парсеры воспринимают эти байты как часть первой строки — первая строка CSV-экспорта или первого заголовка в шаблоне оказывается испорчена, хотя весь остальной файл читается нормально.

Локаль окружения, в котором запускается сборка или сам сервер приложения, — не абстракция: LANG=C иногда меняет то, как интерпретатор трактует байты файлов без явно указанной кодировки. Проверить: locale в шелле.

HTTP-заголовок Content-Type и кодировка в HTML

Дальше строка уходит клиенту — здесь появляется четвёртая точка: заголовок ответа Content-Type. Браузер получает поток байт и заголовок вида Content-Type: text/html; charset=utf-8 — и именно ему доверяет в первую очередь при разборе тела. Если charset не указан, начинается угадывание — ненадёжное по своей природе.

Проверка без браузера:

curl -sI https://example.com/page | grep -i content-type

Без явного charset в заголовке у браузера остаются эвристики: часть контента угадывается по байтовым паттернам, часть — по системной локали пользователя. Разные браузеры и версии могут угадать по-разному — отсюда «у меня нормально, у коллеги в другом браузере кракозябры на той же странице».

В nginx charset для отдаваемых файлов стоит задавать явно:

http {
    charset utf-8;
    charset_types text/plain text/html application/json;
}

Директива charset_types важна отдельно: без неё charset utf-8 в некоторых версиях nginx применяется только к text/html, а JSON-ответ API может уходить вообще без charset в заголовке. Если между приложением и клиентом стоит обратный прокси, он тоже может терять заголовки — это разбирается в материале про nginx как обратный прокси.

Отдельно от HTTP-заголовка существует пятая точка — кодировка внутри самого HTML:

<meta charset="utf-8">

По спецификации HTML5 приоритет у HTTP-заголовка выше, но заголовок может отсутствовать (документ открыт как файл, file://, или отдан прокси, теряющим заголовки) — тогда браузер целиком полагается на meta-тег. Спецификация требует, чтобы <meta charset> находился в первых 1024 байтах документа — если перед ним длинный комментарий или огромный inline-стиль, браузер может не успеть его найти и снова уйти в угадывание. Правильная защита — объявлять кодировку в обоих местах одинаково, а не выбирать одно вместо другого.

Как найти точку, где кодировка разошлась

Когда кракозябры уже есть, полезно проверять цепочку с конца, а не с начала — конец проверяется быстрее и сразу отсекает половину гипотез.

1. Смотрим, что реально ушло по сети. Вкладка Network в devtools показывает и заголовки ответа, и сырое тело — если Response Headers верный, а Preview всё равно кракозябры, проблема не в HTTP-уровне, а глубже.

2. Сверяем сырые байты напрямую, минуя интерпретацию браузером:

curl -s https://example.com/page | hexdump -C | head -20

Если байты уже неверные — проблема на стороне сервера или базы.

3. Проверяем напрямую в базе, минуя приложение:

SELECT HEX(name) FROM users WHERE id = 42;
-- сверьте начальные байты с ожидаемой UTF-8-последовательностью

Если HEX() уже показывает неверные байты — данные испорчены на записи, а не на выводе.

4. Проверяем локаль процесса приложения: env | grep -i lang и locale. Расхождение локали контейнера и локали, в которой писался и тестировался код на машине разработчика, — частый источник разницы «у меня работает, на проде кракозябры», особенно в минимальных Docker-образах без установленных локалей.

Пройдя эти шаги по порядку от клиента к базе, обычно можно точно локализовать один узел с неверной кодировкой — вместо того чтобы менять настройки во всех пяти точках сразу «на всякий случай».

Правило одного языка: кодировка на каждом шаге — явно

Кодировка нигде не выводится автоматически из содержимого байтов — её нужно либо передать явно, либо она берётся из умолчания, которое может не совпасть с умолчанием соседнего звена. Умолчания в разных частях стека выбирались разными людьми в разное время, и совпадение всех умолчаний по всей цепочке — везение, а не гарантия.

Сводная таблица точек объявления:

Точка цепочкиЧто проверитьКак посмотреть
Соединение с БДcharset клиента/сессииSHOW VARIABLES LIKE 'character_set%' / SHOW client_encoding
Колонка/таблица/базаобъявленная кодировка на дискеSHOW CREATE TABLE ..., information_schema.SCHEMATA
Файл исходного кодабайтовая кодировка файлаfile -i filename, hexdump -C
HTTP-ответзаголовок Content-Type`curl -sI url \grep -i content-type`
HTML-документтег <meta charset>исходный код страницы, первые 1024 байта

Практический минимум для нового проекта — не чинить кракозябры постфактум, а с самого начала явно задать одну кодировку (в подавляющем большинстве случаев UTF-8, а в MySQL — конкретно utf8mb4) в каждой из пяти точек и держать это как чек-лист при разворачивании нового сервера, а не как то, что «и так сработает по умолчанию». Отдельно стоит перепроверять кодировку при миграции между СУБД — нюансы разного поведения MySQL и PostgreSQL при несовпадении client/server encoding разобраны в статье про миграцию с MySQL на PostgreSQL — это ровно момент, когда умолчания одной СУБД перестают действовать, а умолчания другой ещё не настроены.

Нужен сервер под эту задачу?

Разверните VPS MAATRIX за пару минут: NVMe, AMD EPYC, root-доступ, локации UK, США, Франция и РФ. Оплата картой РФ и по СБП.

Арендовать сервер

Нужны сами нейросети для контента?

Генерируйте изображения, видео и озвучку нейросетями на falapi.io — десятки моделей в одном окне. Оплата картой РФ и по СБП.

Частые вопросы

Можно ли починить кракозябры на лету, просто указав правильную кодировку при выводе?

Только если исходные байты в базе не повреждены, а лишь неверно интерпретируются при чтении. Если данные уже были дважды перекодированы при записи, переключение charset при выводе не поможет — нужно восстанавливать байты через iconv в обратную сторону, и не всегда без потерь.

Почему в одной части сайта текст нормальный, а в другой — кракозябры?

Обычно потому, что разные части отдаются разным кодом с разными настройками: статические HTML-шаблоны отдаёт nginx с корректным charset utf-8, а JSON от API генерируется без явного charset в заголовке — это две разные точки из пяти, настроенные независимо.

Чем отличается utf8 от utf8mb4 в MySQL и стоит ли мигрировать существующие таблицы?

utf8 — усечённая реализация, максимум 3 байта на символ, без поддержки символов вне Basic Multilingual Plane. utf8mb4 — полный UTF-8, до 4 байт. Мигрировать стоит, если приложение может получать такие символы на вход, и только через CONVERT TO CHARACTER SET, предварительно убедившись, что данные на диске не повреждены.

Достаточно ли указать <meta charset="utf-8"> в HTML, не трогая заголовки сервера?

Формально нет: приоритет у HTTP-заголовка, если он присутствует. Meta-тег — подстраховка на случай, когда заголовка нет. Правильный подход — объявлять кодировку в обоих местах одинаково.

Почему одна и та же строка иногда портится только частично — кириллица, а латиница и цифры целы?

Латинские буквы и цифры в ASCII-диапазоне (0-127) кодируются одинаково почти во всех однобайтовых и многобайтовых кодировках, включая UTF-8, Latin-1 и Windows-1251. Расхождение проявляется только за пределами ASCII — на кириллице, диакритике, эмодзи, — поэтому английский текст с цифрами может выглядеть нормально даже при неверно объявленной кодировке.

Обсудить статью, задать вопрос или начать новую тему

Есть вопрос по этой статье, идея для обсуждения или просто хотите поделиться опытом? Сообщество MAATRIX ждёт. Для общения, пожалуйста, зарегистрируйтесь в нашем личном кабинете.

Перейти в сообщество →