Connect with us

Разработка

Спустя 3 года и 200 миллионов строк я наконец-то перестал использовать UUID в качестве ключа

Тот же запрос, та же таблица, тот же код, который мы выпустили три года назад, — только теперь он незаметно перестал работать быстро.

Опубликовано

/

     
     

Дело было не в этом. Тот же запрос, та же таблица, тот же код, который мы выпустили три года назад, — только теперь он незаметно перестал работать быстро.

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

Index Scan using orders_pkey on orders
  Buffers: shared hit=812 read=94331
  Execution Time: 3841.226 ms

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

Раньше тот же запрос выполнялся менее чем за 20 миллисекунд. Теперь, когда в таблице было 200 миллионов строк, он занимал почти четыре секунды. И единственная причина, по которой проблему вообще заметили, заключалась в том, что дашборд клиента завис прямо во время демонстрации.

Руководитель попросил меня объяснить ситуацию на следующей ежедневной встрече. Я честно ответил:

Первичный ключ — это UUID, и, кажется, никто не понимал, во что это обойдётся нам в будущем.

Если вы когда-либо использовали UUID в качестве первичного ключа в PostgreSQL или MySQL, то уже, вероятно, понимаете, к чему всё идёт.

А если ещё не использовали — продолжайте читать. Версия вашего приложения, которая будет существовать через три года, уже внимательно слушает.

Почему годами всё работало нормально

Когда наше приложение было небольшим, UUID казались зрелым и ответственным выбором.

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

Каждый senior инженер, которого я спрашивал, повторял одну и ту же фразу: «Используйте UUID — они лучше масштабируются».

Но никто не рассказал мне, что три года случайных вставок делают с индексом.

Не уверен, что большинство из них вообще когда-либо заглядывали внутрь собственного индекса.

Большинство инженеров никогда не изучали собственные индексы

Следующая часть может быть немного неприятной.

Большинство разработчиков выбирают UUID в качестве первичного ключа не потому, что что-то измерили. Они делают это потому, что в статье, на учебном курсе или от уверенного коллеги услышали, что это современный и правильный подход.

Я поступил точно так же. А расплачиваться пришлось на ежедневной встрече перед руководителем, предъявляя в качестве доказательства зависший дашборд клиента.

Первичный ключ — это не просто идентификатор.

Он определяет физический порядок хранения строк, а этот порядок со временем определяет, насколько бережно или жестоко база данных будет обращаться сама с собой.

Что на самом деле ломается при масштабировании

Индекс первичного ключа представляет собой B-дерево. Строки внутри дерева упорядочиваются по ключу, а база данных хранит их в аккуратно организованных дисковых страницах.

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

Это аккуратно, предсказуемо и хорошо работает с кешем.

UUID версии 4 по своей природе случаен. Каждая новая запись попадает в совершенно другое место дерева.

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

Умножьте это на миллионы строк — и индекс превратится в лоскутное одеяло из наполовину пустых страниц.

Примерно так это выглядит изнутри.

Sequential integer inserts, tree stays tight:
[1..500] -> [501..1000] -> [1001..1500] -> [1501..2000]
   full         full            full          filling

Random UUID inserts, tree gets shredded:
[a1, f8, 22, c9] [90, b3, 1e, d4] [f0, 33, a7, 6c]
   half full         half full        half full
        ^                 ^                ^
   new row lands here, then there, then back here

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

Таблица, с которой всё началось

-- what we shipped three years ago
CREATE TABLE orders (
    id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
    user_id UUID NOT NULL,
    total NUMERIC(10,2),
    created_at TIMESTAMPTZ DEFAULT now()
);

Во время каждого code review всё выглядело нормально. Решение проходило все наши нагрузочные испытания, потому что объём тестовых данных никогда не превышал нескольких миллионов строк.

Никто не проверяет, как будет работать версия приложения, которая появится через три года.

Решение, которое мы действительно выпустили

Мы не отказались от UUID полностью — просто перестали использовать их в качестве первичного ключа.

Теперь база данных добавляет записи последовательно, используя bigint, а UUID хранится в отдельном публичном столбце с собственным индексом. Он используется только тогда, когда действительно нужен клиенту.

-- what runs today
CREATE TABLE orders (
    id BIGINT GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
    public_id UUID NOT NULL DEFAULT gen_random_uuid(),
    user_id BIGINT NOT NULL,
    total NUMERIC(10,2),
    created_at TIMESTAMPTZ DEFAULT now()
);
CREATE UNIQUE INDEX orders_public_id_idx ON orders (public_id);

Внешние ключи по всей схеме уменьшились с 16 до 8 байт. Соединения таблиц стали легче. Индекс первичного ключа снова стал похож на прямую линию, а не на диаграмму рассеяния.

В качестве промежуточного варианта стоит рассмотреть UUID версии 7. Он сохраняет глобальную уникальность, за которую все ценят UUID, но сначала упорядочивается по метке времени. Благодаря этому вставки ведут себя почти как при использовании обычного автоинкрементного ключа, при этом идентификаторы по-прежнему остаются уникальными во всей системе.

Если бы я начинал новый проект сегодня, я бы начал именно с UUID версии 7.

Как изменились показатели

На копии той же таблицы с 200 миллионами строк идентичный запрос, который раньше выполнялся почти четыре секунды, теперь выглядел так:

Index Scan using orders_pkey on orders
  Buffers: shared hit=4 read=3
  Execution Time: 0.041 ms

Четыре попадания в общий буфер вместо девяноста четырёх тысяч чтений блоков.

Размер индекса этой таблицы уменьшился почти на 40%, а еженедельная переиндексация, которая раньше занимала несколько часов, теперь завершается, пока я ещё читаю первое письмо.

Что я сказал бы себе три года назад

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

UUID по-прежнему полезны. Просто не стоит использовать их как значение, по которому база данных каждую секунду каждого дня сортирует миллионы строк.

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

Источник

Если вы нашли опечатку - выделите ее и нажмите Ctrl + Enter! Для связи с нами вы можете использовать info@apptractor.ru.
Telegram

Популярное

Сообщить об опечатке

Текст, который будет отправлен нашим редакторам: