Опасности первичных ключей UUID в SQLite и оптимизация данных

от автора

В базах данных в качестве первичных ключей часто используют случайные UUID. Один из известных недостатков случайных UUID заключается в том, что их неупорядоченность (UUID4) может вызывать большое количество дополнительных обращений к страницам кластеризованных индексов (clustered index), потому что строки вставляются в случайные места B-дерева, и его приходится постоянно перебалансировать. В этой статье я попытаюсь помочь вам выработать более интуитивное понимание того, как влияют на производительность все эти дополнительные операции со страницами.

Хотя статья посвящена конкретно SQLite, проблема случайных UUID касается и других баз данных, использующих кластеризованные индексы.

Что такое кластеризованные индексы?

Кластеризованные индексы определяют физический порядок хранения строк в таблице. Из-за этого:

  • Для каждой таблицы может существовать только один кластеризованный индекс (строки можно физически отсортировать только одним способом).

  • Кластеризованный индекс — это таблица.

  • Некластеризованный индекс хранит только индексированные столбцы плюс указатель на сами данные строк, которые находятся в другом месте.

Rowid

У каждой обычной таблицы SQLite есть внутренний 64-битный целочисленный первичный ключ, называющийся rowid. Данные таблицы хранятся в структуре B-дерева, содержащей по одному элементу на каждую строку таблицы и использующей в качестве ключа значение rowid. По сути, это кластеризованный индекс SQLite. Порядок физического хранения строк соответствует порядку rowid.

Без rowid

SQLite также поддерживает таблицы WITHOUT ROWID. У этих таблиц нет внутреннего rowid. Вместо него кластеризованным индексом становится объявляемый вами первичный ключ. Зачем использовать таблицы без rowid? Просто потому что благодаря этому не нужно хранить индекс rowid и индекс первичных ключей, что снижает объём записи. Но при этом для получения самих данных всё равно требуется дополнительный поиск даже в случае, когда в качестве rowid используется первичный ключ (если только индекс не покрывающий).

Примечание: в SQLite таблицы с rowid реализованы в виде B+-деревьев, в которых всё содержимое хранится в листьях, а таблицы WITHOUT ROWID реализованы в виде обычных B-деревьев, где содержимое хранится и в листьях, и в промежуточных узлах.

Отправная точка

Давайте зададим отправную точку производительности с обычным целочисленным первичным ключом rowid. Вставим 10 миллионов строк группами по 1 миллиону.

(d/q writer  ["CREATE TABLE IF NOT EXISTS event(id INTEGER PRIMARY KEY, data BLOB)"])      (dotimes [_ 10]  (time    (d/with-write-tx [db writer]      (dotimes [_ 1000000]        (d/q db ["INSERT INTO event (data) values (?)" data])))))

Результаты:

Общее количество строк

Время в миллисекундах

1000000

838

2000000

762

3000000

819

4000000

713

5000000

721

6000000

757

7000000

692

8000000

702

9000000

696

10000000

715

Примерно миллион вставок в секунду.

UUID4 без ROWID

А теперь попробуем UUID4.

(d/q writer  ["CREATE TABLE IF NOT EXISTS event(id BLOB PRIMARY KEY, data BLOB) WITHOUT ROWID"])(dotimes [_ 10]  (time    (d/with-write-tx [db writer]      (dotimes [_ 1000000]        (d/q db ["INSERT INTO event (id, data) values (?, ?)"                 (random-uuid4-bytes) data])))))

Результаты:

Общее количество строк

Время в миллисекундах

1000000

2649

2000000

5644

3000000

7137

4000000

8352

5000000

9359

6000000

9817

7000000

10490

8000000

11130

9000000

11668

10000000

12586

О, нет! Что произошло, почему вставки стали медленнее в 14-16 раз?!

Профилирование

Разница получилась большой, но давайте не будем гадать о причинах, ведь можно выполнить профилирование.

Ниже показан нормализованный diffgraph. Он сравнивает два снэпшота профилирования (в данном случае INTEGER и UUID4) и показывает различия в структуре flame-графика. В отличие от обычного diffgraph, показывающего абсолютные изменения, нормализованный вид согласует общее количество сэмплов между двумя сравниваемыми профилями. Благодаря этому мы можем увидеть относительные различия в виде процентов. Это важно, потому что наши профили будут выполняться в течение разного времени.

diffgraph

Цвет обозначает направление изменения: синий означает, что в этой функции потрачено меньше времени во втором профиле (UUID4), чем в первом (INTEGER); красный означает, что больше времени потрачено во втором профиле. Яркость цвета обозначает относительное изменение в количестве сэмплов для самого кадра (собственная дельта времени).

Из diffgraph видно, то мы тратим гораздо больше времени на балансировку дерева, чтение и запись. Это вызвано тем, что из-за неупорядоченности UUID4 они упорядочиваются случайно, что заставляет SQLite постоянно перебалансировать B-дерево.

UUID7 без ROWID

Теоретически, мы можем исправить ситуацию при помощи упорядоченного по времени UUID7. Давайте посмотрим, насколько всё улучшится.

(d/q writer  ["CREATE TABLE IF NOT EXISTS event(id BLOB PRIMARY KEY, data BLOB) WITHOUT ROWID"])(dotimes [_ 10]  (time    (d/with-write-tx [db writer]      (dotimes [_ 1000000]        (d/q db ["INSERT INTO event (id, data) values (?, ?)"                 (random-uuid7-bytes) data])))))

Результаты:

Общее количество строк

Время в миллисекундах

1000000

1372

2000000

1280

3000000

1365

4000000

1250

5000000

1256

6000000

1270

7000000

1246

8000000

1257

9000000

1245

10000000

1258

Показатели стали более приемлемыми, но всё равно результаты хуже, чем у отправной точки. Первичные ключи блоба UUID занимают 16 байт, а целочисленные первичные ключи — 8 байт.

UUID4 с ROWID

А теперь попробуем UUID4 с ROWID. Это значит, что кластеризованным индексом будет скрытый rowid. Плюс этого в том, что rowid последователен. Недостаток в том, что теперь у таблицы будет два индекса, поэтому объём записываемых данных увеличится.

(d/q writer  ["CREATE TABLE IF NOT EXISTS event(id BLOB PRIMARY KEY, data BLOB)"])(dotimes [_ 10]  (time    (d/with-write-tx [db writer]      (dotimes [_ 1000000]        (d/q db ["INSERT INTO event (id, data) values (?, ?)"                 (random-uuid4-bytes) data])))))

Результаты:

Общее количество строк

Время в миллисекундах

1000000

2003

2000000

2324

3000000

3285

4000000

4399

5000000

5194

6000000

5659

7000000

6215

8000000

6467

9000000

6924

10000000

7119

Показатели не такие хорошие, как у UUID7 без ROWID; частично это вызвано тем, что мы всё равно создаём индекс со случайными вставками (даже несмотря на то, что это не кластеризованный индекс).

Заключение

Надеюсь, этим постом мне удалось продемонстрировать недостатки первичных ключей SQLite в UUID и способы работы с ними.

Полный код бенчмарков выложен на Github.

Повышение производительности SQLite предварительной сортировкой

Выше мы показали, что UUID4 может сильно влиять на скорость вставок и продемонстрировали, как может помочь UUID7. Но что насчёт других данных, которые могут содержать случайность? В этом случае мы не можем использовать UUID7 для решения наших проблем.

Случайные данные

Мы будем использовать 160-битные (20 байт) случайные значения, сгенерированные SecureRandom, аналогичные тем, которые описывались в статье Нила Мэддена. Почему? Потому что для таких вещей, как токены сессий, вероятно, не стоит использовать UUID7 (они могут приводить к утечкам информации, не обладать достаточной энтропией для вашего сценария использования и так далее). Кроме того, такие значения могут представлять любые данные со случайным первичным ключом (или неупорядоченные более специфичным образом).

Вот код для их генерации:

(defn random-unguessable-uid []  (let [buffer (byte-array 20)]    (.nextBytes secure-random buffer)))

Давайте посмотрим на производительность.

(d/q writer  ["CREATE TABLE IF NOT EXISTS event(id BLOB PRIMARY KEY, data BLOB) WITHOUT ROWID"])  (dotimes [_ 10]  (time    (d/with-write-tx [db writer]      (dotimes [_ 1000000]        (d/q db ["INSERT INTO event (id, data) values (?, ?)"                 (random-unguessable-id) data])))))

Результаты:

Общее количество строк

Время в миллисекундах

1000000

2478

2000000

4927

3000000

6262

4000000

7195

5000000

8257

6000000

8704

7000000

9244

8000000

9771

9000000

10387

10000000

11103

Примерно сотня тысяч вставок в секунду. Всё медленно, как и с UUID4.

Предварительная сортировка

Определяющая характеристика B+-дерева заключается в его упорядоченности. Последовательные операции записи происходят быстро. Случайные данные противоречат этому, вызывая разделения страниц и перебалансировку деревьев.

Но мы ведь уже группируем данные. Что, если сортировать их перед вставкой?

Во-первых, нам понадобится быстрый способ сравнения наших случайных id. Они имеют размер 20 байт, поэтому не хочется выполнять итерации по ним, а значит, просто возьмём первые 8 байт и преобразуем их в long. Вероятно, для обеспечения достаточно хорошей сортировки нам не нужно сравнивать весь байтовый массив. Важно здесь то, что для согласованности с SQLite это сравнение беззнаковое.

При сравнении двух значений BLOB результат определяется при помощи memcmp().

Примечание: вероятно, это не самый быстрый способ сортировки случайных данных. Я писал пост без подключения к Интернету (он слишком отвлекает), поиск в наши дни тоже ужасен, поэтому в основном я работаю со старой доброй офлайн-документацией и использую нечёткий поиск текста при помощи dash (аналог zeal из linux). Если вы знаете более быстрый или оптимальный способ сортировки байтовых массивов на Java/Clojure, то напишите мне!

(defn bytes->long [^bytes bytes]  (-> (ByteBuffer/wrap bytes 0 8)    (ByteBuffer/.getLong 0)))    (defn byte-compare  "Compares the first 8 most significant bytes of a byte array.  Big Endian (matches SQLites blob sort)."  [a b]    (Long/compareUnsigned      (bytes->long a)      (bytes->long b)))

Давайте взглянем на производительность:

(d/q writer  ["CREATE TABLE IF NOT EXISTS event(id BLOB PRIMARY KEY, data BLOB) WITHOUT ROWID"])(dotimes [_ 10]  (time    (d/with-write-tx [db writer]      (->> (repeatedly 1000000 random-unguessable-id)        (sort byte-compare)        (run!          (fn [id]            (d/q db ["INSERT INTO event (it, data) values (?, ?)"                     id data])))))))

Результаты:

Общее количество строк

Время в миллисекундах

1000000

1987

2000000

2251

3000000

2296

4000000

2614

5000000

2687

6000000

3244

7000000

3118

8000000

3311

9000000

3485

10000000

3835

Интересно! Несмотря на оверхед, сортировка группы данных повышает производительность примерно в 2-3 раза.

Заключение

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

Полный код бенчмарков доступен на Github.

ссылка на оригинал статьи https://habr.com/ru/articles/1045870/