Почему один символ занимает от одного до четырёх байт и что от этого ломается
Разработчик привык думать «один символ — один байт», потому что в англоязычном мире так и есть. Но стоит проекту заговорить по-русски, добавить эмодзи в чат или принять имя пользователя на китайском — и вылезают обрезанные слова, «битые» кракозябры в базе и падения там, где раньше всё работало. Причина не в баге конкретного сервиса, а в том, как устроена кодировка UTF-8: один и тот же символ может занимать от одного до четырёх байт, и почти всё остальное — следствие этого факта.
Содержание
- Что вообще значит «переменная длина» в UTF-8
- Как кодируется байт: зачем нужны «продолжающие» байты
- Почему обрезка по байтам режет символ пополам
- Три способа посчитать «длину строки» — и все по-своему правы
- Где это реально аукается на сервере: лимиты полей и имена файлов
- Практика: как проверять и не наступать на грабли
Что вообще значит «переменная длина» в UTF-8
UTF-8 — это способ записать номер символа (кодовую точку Unicode) последовательностью байт. Первые 128 кодовых точек Unicode (0–127) — это классический ASCII: латиница без диакритики, цифры, знаки препинания, управляющие символы. Для них UTF-8 использует ровно один байт, причём тот же самый байт, что и в ASCII. Это сделано намеренно: любой текст, написанный только латиницей и цифрами, в UTF-8 выглядит побайтово так же, как в ASCII.
Дальше начинается разница:
- 1 байт — базовая латиница, цифры, большинство знаков препинания (коды 0–127).
- 2 байта — кириллица, греческий, иврит, арабский, латиница с диакритикой (é, ñ, ü) — коды примерно до 2047.
- 3 байта — основная часть иероглифов CJK (китайский, японский, корейский), большинство современных алфавитов не из предыдущих групп, символы валют.
- 4 байта — редкие исторические письменности, математические символы вне базового набора и почти все эмодзи.
То есть строка «Привет» — это не 6 байт, а 12: каждая кириллическая буква весит 2 байта. А строка из трёх эмодзи может весить 12 байт при том, что «символов» в бытовом смысле там всего три. Проверить это можно одной командой:
echo -n "Привет" | wc -c # 12
echo -n "Hello" | wc -c # 5
echo -n "🔥" | wc -c # 4
Ключевая мысль: байт и символ — это две разные единицы измерения, и почти все проблемы дальше растут именно из привычки их путать.
Как кодируется байт: зачем нужны «продолжающие» байты
Механика простая и стоит того, чтобы её один раз увидеть. Первый байт многобайтовой последовательности своими старшими битами говорит, сколько байт всего займёт символ, а каждый следующий байт начинается с битов 10 — это байт-«продолжение», который сам по себе ничего не значит:
| Байт | Битовый шаблон | Значение |
|---|---|---|
| 1-байтовый символ | 0xxxxxxx | ASCII, коды 0–127 |
| Начало 2-байтовой последовательности | 110xxxxx | далее один продолжающий байт |
| Начало 3-байтовой последовательности | 1110xxxx | далее два продолжающих байта |
| Начало 4-байтовой последовательности | 11110xxx | далее три продолжающих байта |
| Продолжающий байт | 10xxxxxx | не самостоятелен, часть предыдущего символа |
Из этой таблицы сразу видно главное свойство UTF-8: программа может отличить начало символа от его середины, просто взглянув на первые биты байта. Это то, чего не было в старых однобайтовых кодировках вроде windows-1251 или koi8-r, где байт вне контекста ничего не говорит о том, где заканчивается символ. Это же свойство UTF-8 и спасает от полной катастрофы при обрезке — но не спасает от повреждения данных, о чём ниже.
Разработчику не нужно вручную собирать эти биты — этим занимается стандартная библиотека языка. Но понимание механики объясняет, почему следующая проблема вообще возможна.
Нужен сервер под эту задачу?
Разверните VPS MAATRIX за пару минут: NVMe, AMD EPYC, root-доступ, локации UK, США, Франция и РФ. Оплата картой РФ и по СБП.
Арендовать серверПочему обрезка по байтам режет символ пополам
Вот источник самого частого практического сюрприза. Многие низкоуровневые операции — обрезка строки в БД, лимит на длину поля, буфер сокета, substr() в старом коде — исторически считают именно байты, а не символы. Пока текст был на латинице, это было не важно: один символ — один байт, обрезка в любом месте безопасна.
С многобайтовыми символами всё иначе. Если строка «Привет, мир!» обрезается до первых 10 байт, разрез может прийтись ровно на середину буквы — между первым и вторым байтом двухбайтовой пары. Результат — «битая» последовательность: один валидный байт-начало символа без своего продолжения. Что происходит дальше, зависит от того, кто читает эти данные:
- Строгий парсер (многие JSON- и XML-парсеры,
str.decode('utf-8')в Python без флага) выбросит исключение видаinvalid start byteилиunexpected end of data. - Терминал или браузер обычно покажет символ замены — квадратик или ромб с вопросом (кодовая точка U+FFFD REPLACEMENT CHARACTER) вместо оборванного байта.
- Некоторые «мягкие» декодеры молча отбросят хвост — и текст останется коротким и без ошибки, что хуже всего, потому что баг никак не заявляет о себе.
Именно поэтому нельзя просто взять text[:255] там, где под капотом работает байтовое усечение, или LEFT(column, 255) в SQL без понимания, что считает движок — байты или символы (для MySQL это зависит от типа колонки и версии, для PostgreSQL LEFT() всегда работает по символам). Разница между «обрезать по символам» и «обрезать по байтам» — это разница между аккуратно укороченной строкой и повреждённым концом файла.
Три способа посчитать «длину строки» — и все по-своему правы
Второй источник путаницы: слово «длина» в разных инструментах значит разное. Возьмём одно и то же слово из четырёх кириллических букв плюс один эмодзи-флаг (который физически состоит из двух кодовых точек Unicode, соединённых в один видимый символ):
- Длина в байтах — сколько места строка реально занимает на диске или в сети. Это то, что считает
wc -c,strlen()в C,len(s.encode('utf-8'))в Python. - Длина в кодовых точках — сколько «юникодовых номеров» в строке. Это то, что считает
len()в Python 3,.lengthв большинстве современных языков после перехода на Unicode-строки. - Длина в «видимых символах» (графемных кластерах) — сколько отдельных знаков реально увидит человек на экране. Флаг страны, эмодзи с тоном кожи, буква с отдельным диакритическим знаком — это несколько кодовых точек, но один видимый символ.
Эти три числа для одной и той же строки почти никогда не совпадают, и все три «правильные» — просто отвечают на разные вопросы. Проблема на практике в том, что разработчик выбирает не тот вопрос: считает через len() (кодовые точки), когда на самом деле его интересует, сколько байт займёт запись в колонку VARCHAR(100) — или наоборот, ограничивает поле по символам, а движок БД считает в байтах. О том, как из-за этого расходится сортировка и сравнение строк на разных серверах, стоит отдельного разговора — здесь важно только запомнить сам факт тройного несовпадения.
Где это реально аукается на сервере: лимиты полей и имена файлов
Это не теоретическая тонкость — вот конкретные места, где переменная длина UTF-8 бьёт по живому.
Лимиты колонок в БД считаются по-разному. В PostgreSQL VARCHAR(n) и CHAR(n) всегда ограничивают число символов, что удобно и предсказуемо. В MySQL то же самое верно для VARCHAR, но здесь важнее не длина одной колонки, а суммарный размер строки таблицы: у InnoDB жёсткий лимит в 65535 байт на строку (без учёта TEXT/BLOB, которые хранятся отдельно). Если таблица спроектирована с расчётом на однобайтовую кодировку (latin1) и потом переведена на utf8mb4 (полный Unicode, включая эмодзи, до 4 байт на символ), тот же набор колонок может физически перестать помещаться в строку — MySQL откажется создавать таблицу с ошибкой Row size too large. Ещё один частый капкан — колонки, объявленные как utf8 вместо utf8mb4 в MySQL: utf8 в MySQL исторически означает усечённую 3-байтовую реализацию, в которую 4-байтовые символы (в первую очередь эмодзи) просто не влезают и обрезаются или вызывают ошибку записи. О похожем классе проблем — когда колонка тихо осталась в однобайтовой кодировке и молча превращала кириллицу в вопросительные знаки — есть отдельный разбор: latin1 в MySQL испортил 300 тысяч имён.
Идентификаторы в PostgreSQL режутся по байтам, а не по символам. Ограничение NAMEDATALEN на длину имени таблицы, колонки или индекса — 64 байта (63 полезных байта плюс завершающий нуль-байт внутри). Для английских имён это 63 символа. Для имени на кириллице — уже около 31 символа, потому что каждая буква весит 2 байта. На практике это редко всплывает в именах таблиц, но регулярно — в автогенерируемых именах индексов и constraint'ов, если в них подставляется что-то нелатинское.
Имена файлов на диске тоже ограничены байтами. У большинства линуксовых файловых систем (ext4, XFS) лимит на длину имени файла — 255 байт, а не 255 символов. Файл с кириллическим именем упрётся в этот потолок вдвое раньше, чем с латинским, а с именем из редких иероглифов или эмодзи — вчетверо раньше. Загрузка пользовательских файлов с оригинальными именами на нелатинице — то место, где это стоит проверить заранее, а не когда пользователь пришлёт файл с длинным названием на японском.
HTTP-заголовки и структурированные логи обычно ограничивают длину поля тоже в байтах — буфер сокета или строка лога режется по фиксированному числу байт, и если в этот момент в потоке идёт многобайтовый символ, хвост лога или заголовка превращается в мусор, который либо ломает парсер, либо (что коварнее) просто тихо теряет часть текста без видимой ошибки.
Практика: как проверять и не наступать на грабли
Несколько привычек, которые снимают большую часть боли.
Проверяйте кодировку файла перед тем, как гадать, почему текст «битый»:
file -i имя_файла.txt
# text/plain; charset=utf-8
Если файл пришёл не в UTF-8 (например, старый экспорт из 1С или Windows-приложения в windows-1251), конвертируйте явно, а не надейтесь, что редактор сам разберётся:
iconv -f windows-1251 -t utf-8 old.csv > new.csv
В PostgreSQL проверьте кодировку базы и клиентское соединение отдельно — это разные настройки, и рассинхрон между ними даёт кракозябры даже при формально правильной БД:
SHOW server_encoding; -- кодировка хранения данных в базе
SHOW client_encoding; -- кодировка, в которой клиент шлёт и получает текст
В MySQL при создании таблицы фиксируйте utf8mb4, а не utf8, если есть шанс, что в колонку попадут эмодзи или редкие символы:
CREATE TABLE messages (
id BIGINT PRIMARY KEY AUTO_INCREMENT,
body TEXT CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci
);
При обрезке строк в коде используйте функции, которые явно работают по символам, а не по байтам. В PHP это mb_substr(), а не substr(). В Python str[:n] уже режет по кодовым точкам (в отличие от bytes[:n], который режет по байтам) — но помните, что кодовая точка не всегда равна видимому символу, так что для флагов и составных эмодзи даже это не гарантирует целый видимый знак. Если нужна именно «человеческая» длина строки — с учётом графемных кластеров — придётся явно подключать библиотеку, которая это умеет (например, grapheme в Python или Intl.Segmenter в современном JavaScript), потому что штатный подсчёт длины в большинстве языков этого не делает.
И последнее: при валидации длины пользовательского ввода на бэкенде решите заранее, что именно вы ограничиваете — байты (важно для места на диске и лимитов БД) или символы (важно для UX и честного «осталось 20 знаков»). Одно из этих чисел почти всегда меньше другого, и путаница между ними — источник половины багов, обсуждённых выше. Если колонка в БД реально ограничивает байты, а фронтенд считает символы, пользователь с длинным именем на кириллице получит ошибку записи, которую фронтенд не смог предсказать.
Нужен сервер под эту задачу?
Разверните VPS MAATRIX за пару минут: NVMe, AMD EPYC, root-доступ, локации UK, США, Франция и РФ. Оплата картой РФ и по СБП.
Арендовать серверНужны сами нейросети для контента?
Генерируйте изображения, видео и озвучку нейросетями на falapi.io — десятки моделей в одном окне. Оплата картой РФ и по СБП.
Частые вопросы
Может ли строка на 100% латинице повести себя иначе в UTF-8, чем в ASCII?
Нет — для символов с кодами 0–127 UTF-8 побайтово идентичен ASCII, поэтому старый код, работающий только с латиницей и цифрами, обычно продолжает работать без изменений даже после перехода проекта на UTF-8.
Почему в консоли иногда видно вместо буквы вопросительный знак в ромбике?
Это символ-заменитель U+FFFD, который декодер подставляет вместо байтовой последовательности, которую не смог разобрать как валидный символ — чаще всего из-за обрезки строки посередине многобайтового символа или из-за смешения кодировок при чтении файла.
utf8mb4 в MySQL — это то же самое, что UTF-8 везде?
По сути да, utf8mb4 — это полная реализация UTF-8 с поддержкой всех 4-байтовых символов. Отдельная кодировка utf8 в MySQL — историческое название усечённой 3-байтовой версии, оставшейся от раннего Unicode, и её стоит избегать в новых проектах.
Как узнать, сколько байт займёт конкретная строка, не считая руками?
В большинстве языков есть прямой способ: len(s.encode('utf-8')) в Python, Buffer.byteLength(s, 'utf8') в Node.js, strlen($s) в PHP уже считает байты по умолчанию (в отличие от mb_strlen(), который считает символы).
Стоит ли хранить длину строки в символах в отдельном поле БД, чтобы не пересчитывать каждый раз?
Обычно нет смысла — операция вычисления длины дешёвая, а хранение производного значения добавляет риск рассинхронизации при любом редактировании строки в обход этого поля. Исключение — очень горячие пути с миллионами обращений в секунду, где расчёт делается заранее один раз при записи.
Обсудить статью, задать вопрос или начать новую тему
Есть вопрос по этой статье, идея для обсуждения или просто хотите поделиться опытом? Сообщество MAATRIX ждёт. Для общения, пожалуйста, зарегистрируйтесь в нашем личном кабинете.
Перейти в сообщество →