Давненько я ничего не писал. Надо сдуть пыль с блога и написать что-нибудь полезное :)
Когда люди начинают работать с PostgreSQL, они временами допускают ошибки, которые потом очень сложно исправить. Например, в момент инициализации первой базы ты слабо понимаешь, зачем нужно включать контрольные суммы для данных. Тем более, что по-умолчанию они выключены, а в документации написано, что они могут сильно просадить производительность.
А когда у тебя уже больше сотни баз с сотнями терабайт данных на самом разном
железе или (ещё хуже) в разных системах виртуализации, ты понимаешь, что готов
заплатить немножко производительности для определения тихого повреждения данных.
Но проблема в том, что дёшево включить контрольные суммы ты не можешь. Это
одна из тех вещей, которая задаётся один раз при выполнении команды initdb
. В
светлом будущем надеемся на логическую репликацию, а пока единственный способ
это поменять — это сделать pg_dump
, initdb
, pg_restore
, т.е. с простоем.
И если контрольные суммы могут вам и не пригодиться (вдруг у вас безупречно
работающее аппаратное обеспечение и ОС без багов), то lc_collate
, о котором
пойдёт речь, касается каждого. И сейчас я вам это докажу.
Порядок сортировки
Допустим, вы поставили PostgreSQL из пакетов или собрали из исходников и самостоятельно инициализировали базу. Скорее всего, в современном мире победившего UTF-8 вы увидите нечто такое:
d0uble ~ $ psql -l
List of databases
Name | Owner | Encoding | Collate | Ctype | Access privileges
-----------+--------+----------+-------------+-------------+-------------------
postgres | d0uble | UTF8 | en_US.UTF-8 | en_US.UTF-8 |
template0 | d0uble | UTF8 | en_US.UTF-8 | en_US.UTF-8 | =c/d0uble +
| | | | | d0uble=CTc/d0uble
template1 | d0uble | UTF8 | en_US.UTF-8 | en_US.UTF-8 | =c/d0uble +
| | | | | d0uble=CTc/d0uble
(3 rows)
d0uble ~ $
Если явно не указано другого, то initdb
возьмёт настройки для столбцов 3-5
из операционной системы. И скорее всего, вам будет казаться, что если там есть
UTF-8
, то всё будет хорошо. Однако, в некоторых случаях вы вполне себе можете
в этом засомневаться. Взгляните на следующий запрос, выполненный на linux-машине:
linux> SELECT name FROM unnest(ARRAY[
'MYNAME', ' my_name', 'my-image.jpg', 'my-third-image.jpg'
]) name ORDER BY name;
name
--------------------
my-image.jpg
my_name
MYNAME
my-third-image.jpg
(4 rows)
linux>
Такой порядок сортировки кажется очень странным. И это при том, что клиент пришёл в базу с вполне себе адекватными настройками:
linux> SELECT name, setting FROM pg_settings WHERE category ~ 'Locale';
name | setting
----------------------------+--------------------
client_encoding | UTF8
DateStyle | ISO, MDY
default_text_search_config | pg_catalog.english
extra_float_digits | 0
IntervalStyle | postgres
lc_collate | en_US.UTF-8
lc_ctype | en_US.UTF-8
lc_messages | en_US.UTF-8
lc_monetary | en_US.UTF-8
lc_numeric | en_US.UTF-8
lc_time | en_US.UTF-8
server_encoding | UTF8
TimeZone | Europe/Moscow
timezone_abbreviations | Default
(14 rows)
linux>
Результат не зависит от дистрибутива — по крайней мере в RHEL 6 и Ubuntu 14.04 он одинаковый. Ещё более странным является тот факт, что тот же запрос с теми же настройками сервера и клиента в Mac OS X даст другой результат:
macos> SELECT name FROM unnest(ARRAY[
'MYNAME', ' my_name', 'my-image.jpg', 'my-third-image.jpg'
]) name ORDER BY name;
name
--------------------
my_name
MYNAME
my-image.jpg
my-third-image.jpg
(4 rows)
macos>
На первый взгляд кажется, что linux серьёзно сломан в этом месте. Но проблема не в этом, а в том, что результат, зависящий от операционной системы, - очень плохой результат. К счастью, мы обнаружили странное поведение на этапе тестирования - тесты на ноутбуке разработчика проходили нормально, а на тестовом linux-сервере падали.
Причиной тому является тот факт, что правила сортировки PostgreSQL берёт из ОС, и (сюрприз!) UTF-8 бывает разный ¯\_(ツ)_/¯ Если поискать, то можно найти множество тредов про различное поведение в Linux и Mac OS X ( 1, 2, 3, 4, 5, 6).
На вопрос “кто виноват?” мнения расходятся, но можно уверенно сказать, что Mac OS X точно учитывает не все региональные специфики. Это видно по ссылкам выше или, например, можно продемонстрировать вот таким примером для русского языка:
macos> SELECT name FROM unnest(ARRAY[
'а', 'д', 'е', 'ё', 'ж', 'я'
]) name ORDER BY name;
name
------
а
д
е
ж
я
ё
(6 rows)
macos>
Linux при этом с таким запросом справляется логично с моей точки зрения. И даже
вполне себе можно объяснить результат первого запроса, показанный им - linux
просто игнорирует символы пробела, -
и _
при сортировке. Т.е. если немного
разобраться, то сломанной уже выглядит Mac OS X.
В конце концов мы унесли тесты в docker, чтобы не зависеть от особенностей ОС и
получать детерменированные результаты, но есть и другие способы это сделать.
Самым простым из них является использование LC_COLLATE = C
, потому что это
единственный collation, который поставляется вместе с PostgreSQL и не зависит
от ОС (см.
документацию).
linux> SELECT name FROM unnest(ARRAY[
'MYNAME', ' my_name', 'my-image.jpg', 'my-third-image.jpg'
]) name ORDER BY name COLLATE "C";
name
--------------------
my_name
MYNAME
my-image.jpg
my-third-image.jpg
(4 rows)
linux>
Как видно, в таком случае результаты будут одинаковыми в обеих ОС. Но нетрудно заметить, что такими же как в Mac OS X, а это значит, что тоже с граблями для мультибайтных кодировок, например:
linux> SELECT name FROM unnest(ARRAY[
'а', 'д', 'е', 'ё', 'ж', 'я'
]) name ORDER BY name COLLATE "C";
name
------
а
д
е
ж
я
ё
(6 rows)
linux>
Не стоит при этом думать, что результат сортировки с LC_COLLATE=en_US.UTF-8
в
Mac OS X всегда будет таким же как с LC_COLLATE=C
в любой ОС. Наверняка можно
быть уверенным лишь в том, что одинаковый результат гарантирует collation C
,
потому что он поставляется вместе с PostgreSQL и не зависит от ОС.
При этом мне с чисто обывательской точки зрения обычного пользователя кажется
странным не учитывать пробельные символы, дефисы и другие неалфавитные символы
в сортировке, но эти правила когда-то кто-то придумал, стандартизировал и не
мне их менять. Впрочем, в исходной задаче эти правила оказались недопустимыми
и мы стали использовать collation C
.
Запросы по префиксу
Тот факт, что postgres опирается на glibc в вопросах сортировки, имеет ещё ряд нюансов, о которых стоит сказать. Для примера создадим следующую табличку с двумя текстовыми полями и вставим в неё один миллион случайных строчек:
linux> CREATE TABLE sort_test (
a text,
b text COLLATE "C");
CREATE TABLE
linux> INSERT INTO sort_test SELECT md5(n::text), md5(n::text)
FROM generate_series(1, 1000000) n;
INSERT 0 1000000
linux> CREATE INDEX ON sort_test USING btree (a);
CREATE INDEX
linux> CREATE INDEX ON sort_test USING btree (b);
CREATE INDEX
linux> ANALYZE sort_test ;
ANALYZE
linux> SELECT * FROM sort_test LIMIT 2;
a | b
----------------------------------+----------------------------------
c4ca4238a0b923820dcc509a6f75849b | c4ca4238a0b923820dcc509a6f75849b
c81e728d9d4c2f636f067f89cc14862c | c81e728d9d4c2f636f067f89cc14862c
(2 rows)
linux>
Одно поле создано с collation по-умолчанию (en_US.UTF-8
в моём примере),
а второе с collation C
, значения в них одинаковые. Посмотрим на планы
запросов по префиксу каждого из полей:
linux> explain SELECT * FROM sort_test WHERE a LIKE 'c4ca4238a0%';
QUERY PLAN
----------------------------------------------------------------
Seq Scan on sort_test (cost=0.00..24846.00 rows=100 width=66)
Filter: (a ~~ 'c4ca4238a0%'::text)
(2 rows)
linux> explain SELECT * FROM sort_test WHERE b LIKE 'c4ca4238a0%';
QUERY PLAN
------------------------------------------------------------------------------------
Index Scan using sort_test_b_idx on sort_test (cost=0.42..8.45 rows=100 width=66)
Index Cond: ((b >= 'c4ca4238a0'::text) AND (b < 'c4ca4238a1'::text))
Filter: (b ~~ 'c4ca4238a0%'::text)
(3 rows)
linux>
Как видно, PostgreSQL не использует индекс для выполнения первого запроса, но
использует для второго. Причину этого можно увидеть в выводе EXPLAIN (см.
Index Cond
) - во втором случае PostgreSQL знает порядок символов и
преобразовывает условие выборки по индексу с b LIKE 'c4ca4238a0%'
в
b >= 'c4ca4238a0' AND b < 'c4ca4238a1'
, а эти две операции хорошо
покрываются B-Tree (и только потом полученные результаты postgres уже
дофильтрует по исходному условию).
Как видно, стоимость такого запроса при collation C
примерно в 2500 раз меньше.
Abbreviated keys
Одной из хороших оптимизаций, которая появилась с выходом PostgreSQL 9.5, были т.н. abbreviated keys, что можно перевести на русский как “сокращённые ключи”. Лучше всего об этом почитать в посте автора этой оптимизации, Peter Geoghegan. Если коротко, то эта оптимизация значительно ускорила сортировку текстовых полей и создание индексов по ним, примеры можно посмотреть, например, тут.
К сожалению, в 9.5.2 эту оптимизацию
выключили
для всех collation кроме C
. Причиной тому стал баг в glibc (а как мы помним,
для всех сollation кроме C
PostgreSQL опирается на glibc), в результате
которого индексы могли получаться неконсистентными.
Вместо заключения
В задаче, с которой всё началось, мы в конце концов пришли к использованию
lc_collate = C
, потому что данные предполагают использование самых разных
языков мира и этот collation кажется самым правильным для таких случаев. Да,
он не будет учитывать некоторые пограничные случаи в каждом из языков, но зато
будет работать вмеру хорошо для всех.
При этом грустно, что серебряной пули не бывает и когда все твои данные, например, на русском, ты вынужден выбирать между производительностью и правильностью сортировки с учётом специфики русского языка.
Comments
comments powered by Disqus