MAATRIX / Блог / Что такое локаль и почему одинаковый список сортируется на двух серверах по-разному

Что такое локаль и почему одинаковый список сортируется на двух серверах по-разному

MAATRIX

Вы переносите приложение с одного сервера на другой, запускаете тот же код на тех же данных — и список городов, товаров или пользователей вдруг сортируется иначе. Ни строчки кода не менялось, база та же, а порядок другой. Причина почти всегда одна: разная локаль (locale) на серверах. Разбираемся, что это такое, почему она управляет не только языком интерфейса, но и правилами сравнения строк, и почему для баз данных это особенно болезненная тема.

Что такое локаль и из чего она состоит

Локаль — это набор правил, по которым операционная система и приложения интерпретируют «культурно зависимые» вещи: как сравнивать и сортировать текст, как записывать числа, даты, время и деньги, какой язык использовать для системных сообщений. В Linux это не один параметр, а набор переменных окружения, каждая из которых отвечает за свой кусок:

ПеременнаяЗа что отвечаетПример
LC_COLLATEпорядок сортировки строкрезультат ORDER BY, sort
LC_CTYPEклассификация символов (буква/цифра, верхний/нижний регистр)что считается «буквой» для toupper()
LC_NUMERICразделитель дробной части, группировка разрядов1234.5 или 1234,5
LC_TIMEформат дат, названия месяцев и дней недели08/09/2026 или 08.09.2026
LC_MONETARYсимвол валюты, формат денежных сумм$1,234.56 или 1 234,56 ₽
LC_MESSAGESязык системных сообщений и ошибокPermission denied или Отказано в доступе
LANGзначение по умолчанию для всех LC_*, если они не заданы явноru_RU.UTF-8
LC_ALLжёстко переопределяет все LC_*, даже если они заданы отдельноиспользуется для принудительной унификации

Посмотреть текущие настройки можно командой:

locale

а список локалей, установленных в системе, — командой:

locale -a

На свежем минимальном образе (особенно в Docker-контейнерах на базе debian-slim или alpine) locale -a часто показывает только C и C.UTF-8 — остальные локали физически не установлены. Если на одном сервере стоит полноценная ru_RU.UTF-8, а на другом — только C.UTF-8, поведение сортировки будет отличаться просто потому, что операционной системе неоткуда взять правила языковой сортировки.

Базовую настройку языка и раскладки на сервере (Ubuntu/Debian) мы разбирали в статье про локализацию сервера — здесь сосредоточимся на том, почему сортировка ведёт себя непредсказуемо.

Сортировка — это не про коды символов, а про языковые правила

Интуитивно кажется, что «сортировать по алфавиту» — простая и однозначная операция: сравнил байты (или коды символов) двух строк, у кого код меньше — тот раньше. Это и есть локаль C (она же POSIX) — сортировка строго по числовым значениям байтов в исходной кодировке. Никакой лингвистики, чистая математика.

Но реальные языки так не работают. Возьмём простой пример на латинице: слова apple, Apple, Äpple, banana. В байтовом сравнении (locale C, кодировка UTF-8) заглавные буквы имеют меньший код, чем строчные, а буква Ä (составной Unicode-символ) окажется где-то далеко от A — потому что её код в таблице символов физически другой, никак не связанный с «похожестью» на букву A для человека. В результате порядок будет примерно такой: Apple, apple, banana, Äpple — заглавная буква впереди всех строчных, а буква с диакритическим знаком уезжает в конец, хотя носитель немецкого языка ожидает увидеть её рядом с A.

Языковая локаль (например, en_US.UTF-8 или de_DE.UTF-8) применяет другие правила: регистр и диакритические знаки учитываются как «второстепенный» признак, а базовая буква — как «главный». Поэтому в языковой локали порядок будет ближе к интуитивному: apple, Apple, Äpple, banana — сначала группируются по базовой букве, и только при совпадении базовых букв в дело идут регистр и диакритика.

То же самое касается кириллицы: буква Ё в некоторых локалях и словарных традициях сортируется как обычная Е, а в других — как отдельная буква после Е. Дефисы, апострофы и цифры внутри строк («SSD-накопитель» против «SSD накопитель», «iPhone 9» против «iPhone 10») тоже обрабатываются по-разному: в byte-сортировке iPhone 10 встанет раньше iPhone 9, потому что символ 1 имеет меньший код, чем 9, и сравнение идёт посимвольно, без понимания, что это число.

Вывод простой: «отсортировать по алфавиту» — это не одна операция, а семейство разных операций, и какая именно из них выполнится, зависит от локали, активной в момент сортировки.

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

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

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

Locale C/POSIX против настоящей языковой локали — на практике

Проверить разницу можно за одну минуту прямо в терминале:

printf 'banana\nApple\napple\nÄpple\n' > /tmp/words.txt

LC_ALL=C sort /tmp/words.txt
# Apple
# apple
# banana
# Äpple

LC_ALL=en_US.UTF-8 sort /tmp/words.txt
# apple
# Apple
# Äpple
# banana

Если на сервере нужной локали нет, sort тихо откатится к C (иногда с предупреждением про unsupported locale), и вы получите byte-порядок, даже не подозревая об этом. Отсюда частая ситуация: разработчик тестирует сортировку на ноутбуке с установленной ru_RU.UTF-8, всё выглядит правильно, а на минимальном production-образе, где локаль не сгенерирована, тот же код сортирует иначе.

Важный нюанс: C.UTF-8 — это не «языковая локаль в UTF-8», а всё та же байтовая сортировка C, только с поддержкой многобайтовых символов на уровне кодировки. Она не добавляет лингвистических правил сравнения, лишь умеет корректно читать многобайтовые символы, не разрубая их посередине. Администратор видит LANG=C.UTF-8, думает «UTF-8 — значит всё в порядке», а сортировка при этом всё равно строго байтовая.

Что ещё меняет локаль, кроме порядка строк

Сортировка — самая незаметная часть проблемы, потому что она не выбрасывает ошибку, а просто тихо меняет порядок. Но локаль отвечает и за другие вещи, где несовпадение настроек между окружениями ломает данные более явно:

  • Разделитель дробной части. LC_NUMERIC=ru_RU.UTF-8 ожидает запятую (1234,56), LC_NUMERIC=en_US.UTF-8 — точку (1234.56). Если приложение парсит числа функциями, чувствительными к локали (в некоторых языках это atof, strtod, форматирование через printf с локальными флагами), один и тот же файл экспорта может распарситься по-разному на двух серверах.
  • Формат дат. LC_TIME определяет, идёт ли сначала день или месяц, как называются месяцы, короткие они или полные. Скрипты, которые парсят вывод date или логов текстовым способом (а не через явный формат strftime), особенно уязвимы: смена локали меняет строковое представление даты, и парсер с жёстко прописанным форматом ломается.
  • Формат денег. LC_MONETARY определяет символ валюты и расположение знака ($100 против 100 ₽), группировку разрядов. Если сумма форматируется системной локалью, а не явно заданным форматом, отчёты на разных серверах могут выглядеть по-разному.

Практический вывод: везде, где формат зависит от локали, а не задан явно в коде (ISO 8601 для дат, точка как разделитель с форматированием на уровне приложения), вы полагаетесь на совпадение настроек окружения. В инфраструктуре с несколькими серверами это совпадение никто не гарантирует по умолчанию.

Почему это особенно опасно для баз данных

С обычным текстовым выводом несовпадение локали — неприятность: список отсортирован не так, отчёт выглядит непривычно. С базами данных всё серьёзнее, потому что локаль (там это называется collation, «правило сопоставления») влияет не только на вывод запроса, но и на структуру данных на диске.

Индексы строятся с учётом collation. Когда СУБД создаёт B-tree индекс по текстовому столбцу, порядок значений в дереве определяется тем же правилом сравнения строк, что действует для этого столбца или базы в целом. Индекс не хранит абстрактный «алфавитный порядок» — он хранит порядок, зафиксированный под конкретное правило сравнения на момент построения.

Отсюда две конкретные проблемы:

  1. Разные результаты ORDER BY без индекса. Если запрос с сортировкой строк выполняется без подходящего индекса (сортировка «на лету»), СУБД использует collation базы или столбца, а она может отличаться между окружениями — например, если базу разворачивали на серверах с разной локалью ОС или создавали с разными явными параметрами. Тот же SELECT ... ORDER BY name, тот же набор строк — а порядок вывода разный.
  2. Несовпадение collation между окружениями ломает предположения о порядке индекса. Если индекс построен с одним collation, а запрос (или сравнение при вставке/проверке уникальности) неявно рассчитывает на другое — от «просто другого порядка» до реальной порчи данных один шаг. Мы уже разбирали в блоге похожий случай — смена collation привела к тому, что уникальный индекс перестал ловить дубликаты: значения, которые по старым правилам сравнения считались разными, по новым стали равными (или наоборот), и индекс, построенный под старые правила, перестал соответствовать реальности данных.

Отдельно стоит риск при репликации и миграции: если мастер и реплика (или старый и новый сервер при переезде) работают с разным collation базы, порядок результата на них может не совпадать, хотя данные идентичны. Приложение, которое неявно рассчитывает на стабильный порядок (например, пагинация без явного ORDER BY по уникальному ключу), может показывать пользователю несогласованные страницы в зависимости от того, с какого узла обслужен запрос.

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

Как проверить и синхронизировать локаль на разных серверах

Чтобы не ловить эту проблему постфактум, стоит явно контролировать локаль на уровне ОС и на уровне СУБД, а не полагаться на «то, что было при установке».

На уровне операционной системы (Debian/Ubuntu):

# какие локали установлены
locale -a

# сгенерировать нужную локаль, если её нет
sudo locale-gen ru_RU.UTF-8 en_US.UTF-8

# задать локаль по умолчанию для системы
sudo update-locale LANG=ru_RU.UTF-8 LC_ALL=ru_RU.UTF-8

# интерактивная настройка со списком локалей
sudo dpkg-reconfigure locales

После смены системной локали новые сессии подхватят изменения из /etc/default/locale, но уже запущенные процессы (в том числе сервис СУБД) нужно перезапустить явно.

В Docker-образах локали часто не устанавливают вовсе, чтобы не раздувать образ — тогда доступна только C.UTF-8. Если приложению нужна конкретная языковая сортировка, локаль придётся ставить явно в Dockerfile (для Debian-based образов — через locales пакет и locale-gen), иначе контейнер в проде будет тихо сортировать иначе, чем окружение разработки на хосте с уже установленными локалями.

На уровне PostgreSQL — локаль базы фиксируется при её создании и не меняется на лету:

-- посмотреть collation конкретной базы
SELECT datname, datcollate, datctype FROM pg_database;

-- посмотреть collation конкретного столбца/выражения
SELECT pg_collation_for(name) FROM users LIMIT 1;

-- создать базу с явно зафиксированным collation,
-- не завязанным на локаль хостовой ОС
CREATE DATABASE mydb
  TEMPLATE template0
  LC_COLLATE 'en_US.UTF-8'
  LC_CTYPE 'en_US.UTF-8';

-- принудительно указать collation в конкретном запросе
SELECT name FROM users ORDER BY name COLLATE "C";

Начиная с PostgreSQL 12 доступны ICU-collations (CREATE COLLATION ... (provider = icu, ...)) — они не зависят от локалей, установленных в ОС, и ведут себя одинаково на любом сервере, где собран PostgreSQL с поддержкой ICU. Это снимает саму причину проблемы: вместо «доверять локали хоста» вы явно фиксируете правило сравнения на уровне базы.

На уровне MySQL/MariaDB локаль подменяется понятием character set + collation, задаваемым на уровне сервера, базы, таблицы и даже столбца:

SHOW VARIABLES LIKE 'collation%';
SHOW VARIABLES LIKE 'character_set%';

-- collation конкретной таблицы
SHOW TABLE STATUS LIKE 'users';

-- явно задать при создании таблицы
CREATE TABLE users (
  name VARCHAR(255) CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci
);

Разные _ci (case-insensitive) collation в MySQL реализуют разные правила сортировки для одной кодировки: utf8mb4_general_ci — упрощённая и быстрая сортировка, utf8mb4_unicode_ci (и более новые ICU-based варианты) — точнее соответствует лингвистическим правилам, utf8mb4_bin — сортировка по бинарному значению, аналог C в PostgreSQL. Смешивание таблиц с разным collation в одной базе — частый источник разного порядка сортировки между таблицами одного проекта, даже без переезда на другой сервер.

Простое правило: если сортировка и сравнение строк важны для бизнес-логики (алфавитные списки, уникальность имён, поиск по префиксу), не полагайтесь на «локаль по умолчанию» ни на уровне ОС, ни на уровне СУБД — фиксируйте collation явно там, где он задаётся: в CREATE DATABASE, в определении столбца или в самом запросе через COLLATE.

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

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

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

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

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

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

Как быстро проверить, из-за локали ли отличается сортировка?

Выполните на обоих серверах locale и сравните переменную LC_COLLATE. Если она отличается (или на одном сервере локаль не установлена и используется C/C.UTF-8 по умолчанию) — велика вероятность, что дело именно в этом.

Можно ли поменять collation базы данных без пересоздания?

В PostgreSQL — нет: LC_COLLATE/LC_CTYPE фиксируются при создании базы и требуют пересоздания (CREATE DATABASE ... TEMPLATE template0 с новыми параметрами и переносом данных) либо ICU-collation на уровне столбцов. В MySQL/MariaDB collation меняется командой ALTER TABLE ... CONVERT TO CHARACTER SET ... COLLATE ..., но на больших таблицах это тяжёлая операция — стоит тестировать на копии, а не на прод-базе.

Почему на ноутбуке сортировка правильная, а на сервере — нет?

Скорее всего, на ноутбуке установлена полноценная языковая локаль (её обычно ставят при установке десктопной ОС), а на минимальном серверном образе или в контейнере — только C/C.UTF-8. Установите нужную локаль явно через locale-gen, не полагайтесь на то, что она «должна быть».

Что использовать вместо системной локали, если нужна предсказуемость между окружениями?

Для баз данных — ICU-collation в PostgreSQL или явно зафиксированный collation в MySQL/MariaDB, а не локаль ОС. Для приложений — библиотеки сравнения строк, не зависящие от окружения (например, Intl.Collator в JavaScript с явно указанной локалью), вместо системных функций, подхватывающих LC_COLLATE из окружения процесса.

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

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

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