UTF-8 и Unicode Почему тот эмодзи сломал вашу базу данных

Обновлено

Ваше приложение вставило эмодзи, и MySQL выдало ошибку неверного значения строки. Вот почему — кодовые точки против байтов, ложь MySQL utf8 по сравнению с utf8mb4, пары заменителей JavaScript и как на самом деле это исправить.

UTF-8 и Unicode: Почему эмодзи сломал вашу базу данных 1
Реклама · УДАЛИТЬ?

Ваша приложение работало нормально. Потом пользователь ввел эмодзи в текстовое поле, и MySQL выдало Incorrect string value: '\xF0\x9F\x98\x80' for column 'bio'. Или, возможно, эмодзи исчез без следа. Или полный ввод данных не удался и вы потеряли данные. Все из-за четырёхбайтового символа, который не ожидал ваша база данных.

Это не особенность MySQL или бага PHP. Это следствие того, как работает кодировка Unicode → UTF-8, и как только вы это понимаете, вы больше не будете удивляться этому.

Кодовые точки против байтов: реальное различие

Unicode присваивает каждому символу кодовую точку — число. Буква A — U+0041. Знак евро — U+20AC. Эмодзи 😀 — U+1F600. Это абстрактная идентичность символа.

UTF-8 — это кодирование — способ хранения кодовых точек в байтах. Техника заключается в том, что UTF-8 имеет переменную ширину: он использует от 1 до 4 байт в зависимости от значения кодовой точки. Это позволяет ему оставаться совместимым с ASCII (все символы ASCII занимают 1 байт в UTF-8) при одновременном кодировании всех существующих символов.

Правила кодировки:

  • U+0000 до U+007F (ASCII) → 1 байт
  • U+0080 до U+07FF (расширенный латинский, арабский, иврит и т.д.) → 2 байта
  • U+0800 до U+FFFF (большая часть CJK, пунктуации, символов) → 3 байта
  • U+10000 до U+10FFFF (эмодзи, редкие письменности, математические символы) → 4 байта

Поэтому эмодзи 😀 (U+1F600) занимает 4 байта: его кодовая точка превышает U+FFFF.

Размеры байтов UTF-8: справочная таблица

Вот сколько байт реально занимают распространённые символы:

СимволОписаниеКодовая точка UnicodeБайты UTF-8 (шестнадцатеричный)Количество байт
АЛатинская заглавная AU+0041411
éЛатинская e с острым знакомU+00E9C3 A92
Знак евроU+20ACE2 82 AC3
Китайский символ «средний»U+4E2DE4 B8 AD3
😀Эмодзи с улыбкойU+1F600F0 9F 98 804
🔥Эмодзи огняU+1F525F0 9F 94 A54
𝕳Математическая фрактурская HU+1D573F0 9D 95 B34

Чтобы проверить это самостоятельно, используйте Калькулятор длины строки — она показывает как количество символов, так и количество байтов для любого вставленного текста. Вставьте 😀 и вы увидите 1 символ, но 4 байта.

Ложь MySQL по поводу UTF-8

Здесь разработчики подвергаются удару. MySQL имеет кодировку под названием utf8. Звучит правильно. Это неверно — MySQL поддерживает только последовательности до 3 байт. Эмодзи (4 байта) не поддерживаются. utf8 Фактическая полная кодировка UTF-8 в MySQL — это

(введена в MySQL 5.5.3, выпущена в 2010 году). Если ваша колонка использует utf8mb4 и кто-то вводит эмодзи, MySQL либо тихо усекает данные, либо выдаёт: utf8 Также обновите настройки подключения к базе данных в вашем приложении. В MySQL PDO:

Incorrect string value: '\xF0\x9F\x98\x80' for column 'bio' at row 1

(байты). В Laravel, используйте

-- Convert the table
ALTER TABLE users CONVERT TO CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci;

-- Or for a specific column
ALTER TABLE users MODIFY bio TEXT CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci;

-- And set your connection charset
SET NAMES utf8mb4;

Ловушка VARCHAR(255)

$pdo = new PDO($dsn, $user, $pass, [
    PDO::MYSQL_ATTR_INIT_COMMAND => "SET NAMES utf8mb4"
]);

в MySQL означает 255

VARCHAR(255) символов , а не 255 байт — но ограничение по хранению в одной строке рассчитывается в байтах. При использованиикаждый символ может занимать до 4 байт, поэтому колонка может занимать до 1020 байт. Это важно при использовании предельного значения индекса InnoDB (767 байт) для индексации колонок varchar: utf8mb4Проблема с суррогатными парами в JavaScript VARCHAR(255) JavaScript использует UTF-16 внутренне, а не UTF-8. И UTF-16 имеет собственную многобайтовую кодировку для кодовых точек выше U+FFFF:

-- This fails on older MySQL with default innodb_large_prefix=OFF
CREATE INDEX idx_email ON users (email);  -- email is VARCHAR(255) utf8mb4

-- Fix: use a prefix index
CREATE INDEX idx_email ON users (email(191));  -- 191 * 4 = 764 bytes, under 767

-- Or enable large prefixes (MySQL 5.7+, on by default in 8.0)
-- Set innodb_large_prefix = ON in my.cnf

суррогатные пары

— два 16-битных кода, которые вместе представляют один символ. Это означает, что в JavaScript считает количество UTF-16 кодов, а не символов:

Для операций со строками, требующих учёта символов, используйте оператор расширения или String.length Пример эмодзи в семье стоит остановиться.

'😀'.length        // → 2 (two UTF-16 surrogate code units)
[...'😀'].length   // → 1 (spread operator uses Unicode code points)

// Checking the character at index 0
'😀'[0]            // → '\uD83D' (the high surrogate, not the emoji)
'😀'.codePointAt(0) // → 128512 (0x1F600, correct)

состоит из четырёх эмодзи, соединённых нулевыми соединителями (U+200D). Простой Intl.Segmenter:

// Count actual grapheme clusters
const segmenter = new Intl.Segmenter();
const chars = [...segmenter.segment('👨‍👩‍👧‍👦')];
chars.length     // → 1 (family emoji is one grapheme cluster)
'👨‍👩‍👧‍👦'.length  // → 11 (UTF-16 code units)

даёт 11. Фактические графемные кластеры: 1. Это важно, если вы реализуете ограничения на количество символов — ограничение, основанное на 👨‍👩‍👧‍👦 поведёт себя неожиданно, когда пользователи вводят последовательности эмодзи. .length Как проверить кодировку на практике String.length в PHP считает байты, а не символы. Это постоянно ловит разработчиков PHP при работе с многобайтовыми строками — строка из 10 эмодзи покажет длину 40. Используйте

при необходимости подсчёта количества символов.

Питон

s = '😀'
print(len(s))                    # 1 (Python 3 counts code points)
print(len(s.encode('utf-8')))    # 4 bytes
print(s.encode('utf-8').hex())   # f09f9880

PHP

$s = '😀';
echo strlen($s);          // 4 (bytes, not characters)
echo mb_strlen($s);       // 1 (characters)
echo mb_strlen($s, '8bit'); // 4 (bytes, explicit)

strlen() Быстрый проверочный список mb_strlen() Если вы хотите увидеть количество байт и символов для произвольного текста без написания кода, сервис

MySQL

-- Check charset of a table
SHOW CREATE TABLE users\G

-- Check charset of a specific column
SELECT CHARACTER_SET_NAME, COLLATION_NAME
FROM INFORMATION_SCHEMA.COLUMNS
WHERE TABLE_NAME = 'users' AND COLUMN_NAME = 'bio';

-- Character count vs byte count
SELECT CHAR_LENGTH('😀'), LENGTH('😀');
-- → 1, 4

обрабатывает это мгновенно — вставьте любой текст, и он показывает количество символов, слов и байт рядом.

Проверочный список ошибок кодировки Калькулятор длины строки Кодировка MySQL:

Это

  • ? Проверьте с помощью Подключение к MySQL: utf8mb4, а не utf8Используете ли вы приложение SHOW CREATE TABLE.
  • ? Проверьте ваш DSN или конфигурацию подключения. PHP strlen против mb_strlen: SET NAMES utf8mb4Используете ли вы функции подсчёта байтов там, где нужно подсчитывать символы?
  • JavaScript .length: Подсчитываете ли вы кодовые единицы там, где нужно подсчитывать графемные кластеры?
  • HTTP-заголовки: Отправляете ли вы в ответе
  • Файловой кодировке: Сохраняются ли ваши исходные файлы и SQL-дампы в формате UTF-8 без BOM? Content-Type: text/html; charset=utf-8?
  • UTF-8 и Unicode: Почему эмодзи сломали вашу базу данных 2 UTF-8 и Unicode: Почему эмодзи сломали вашу базу данных 1
Хотите убрать рекламу? Откажитесь от рекламы сегодня

Установите наши расширения

Добавьте инструменты ввода-вывода в свой любимый браузер для мгновенного доступа и более быстрого поиска

в Расширение Chrome в Расширение края в Расширение Firefox в Расширение Opera

Табло результатов прибыло!

Табло результатов — это интересный способ следить за вашими играми, все данные хранятся в вашем браузере. Скоро появятся новые функции!

Реклама · УДАЛИТЬ?
Реклама · УДАЛИТЬ?
Реклама · УДАЛИТЬ?

новости с техническими моментами

Примите участие

Помогите нам продолжать предоставлять ценные бесплатные инструменты

Купи мне кофе
Реклама · УДАЛИТЬ?