Перейти до вмісту
Статті

Бенчмарк продуктивності Cloudflare D1 (SQLite): Integer проти UUID v4 проти ULID

Порівняння продуктивності Cloudflare D1 (SQLite) для первинних ключів integer, UUID v4 та ULID на таблицях users (1 млн) і posts (10 млн).

Таблиці з результатами: час відповіді сервера та час наповнення бази для integer, ULID і UUID v4

Головна мета — порівняти час виконання запитів до таблиць users (1 млн записів) і posts (10 млн записів), коли колонка id — це integer, UUID v4 або ULID.

Передісторія

Як фронтенд-розробник, у своїх пет-проєктах я здебільшого працював з MongoDB. Зараз я працюю над більшим продуктом, і саме тому вирішив переглянути свої рішення щодо обраної інфраструктури, включно з базою даних. Я переходжу на інфраструктуру Cloudflare — коротко кажучи, тому що там зручний DX, прозорі ціни, щедріші безкоштовні тарифи, потрібні мені сервіси тощо. Тож перший крок — це міграція зі звичної MongoDB на базу даних Cloudflare D1 на основі SQLite. Як фронтенд-розробник, я поняття не маю, як писати хороші SQL-запити. Під час дослідження я натрапив на дописи про Prisma і про те, наскільки вона повільна. Це кумедно. Так я вийшов на Drizzle. У неї доволі приємна документація, вона безкоштовна й опенсорсна, за їхніми ж бенчмарками швидша за Prisma, і я знайшов чимало хороших відгуків про неї. Коли я почав писати свої перші схеми, то одразу почав думати про архітектуру, безпеку та продуктивність — просто тому, що зазвичай так і роблю. Перший момент: у мене застосунок на Nuxt. У мене є TypeScript-інтерфейс, який Drizzle генерує зі схеми таблиці. Цей інтерфейс і тип обʼєкта даних спільні для серверної частини, API та компонентів вітрини. Це означає, що всі поля будуть видимі у вкладці Network, щойно дані прийдуть. Я хочу уникнути ситуацій, коли в /api/users/1 явно видно значення поля id — з естетичних і безпекових міркувань (так, я знаю про сесійні токени). Ще з часів роботи з MongoDB я памʼятаю, що поле _id, яке MongoDB генерує автоматично, зазвичай має UUID-подібний формат. Не певен щодо точної версії UUID, але точно не просто автоінкрементний integer. Мені просто стало цікаво, чи може це негативно вплинути на продуктивність бази даних SQLite.

Підготовка до бенчмарку продуктивності

У мене застосунок на Nuxt 3 (з Nitro), nitro-dev-cloudflare, wrangler, Miniflare для локальної розробки з D1, Drizzle ORM, Drizzle Kit, Drizzle Studio та функціонал Drizzle Seed. Я вирішив наповнити базу даних 1 мільйоном users і 10 мільйонами posts. Але виявилося, що в D1 доволі жорсткий ліміт на кількість рядків у запиті. У моєму випадку вдавалося наповнити лише близько 10–15 users і posts, а потім я отримував таку помилку:

too many SQL variables at offset 425: SQLITE_ERROR
Помилка SQLITE_ERROR: too many SQL variables у стектрейсі D1

Тож замість пакета drizzle-seed я просто вручну наповнював таблиці в циклі. Також довелося підняти для Node ліміт оперативної памʼяті до 10 ГБ (за замовчуванням 2 ГБ) у моєму ~/.zshrc:

export NODE_OPTIONS="--max-old-space-size=10000"
Фатальна помилка Node.js: нестача памʼяті (JavaScript heap out of memory)

Приклад запиту

Він доволі схожий для всіх випадків:

Код запиту Drizzle: db.query.users.findFirst з підвантаженням posts

Бенчмарк продуктивності — Integer

Час відповіді ~480 мс:

Nuxt DevTools: запит /api/users/99999 з integer id виконано за 480 мс

Бенчмарк продуктивності — UUID v4

Час відповіді ~1789 мс:

Nuxt DevTools: запит з UUID v4 id виконано за 1769 мс

Бенчмарк продуктивності — ULID

Час відповіді ~878 мс.

Порівняно з UUID v4 (36 символів), у ULID менше символів (26), але при цьому він лексикографічно сортується і базується на часі. Я припускав, що так буде краще і для продуктивності, і для розміру бази, якщо вже лишати UUID-підхід для колонки id.

Nuxt DevTools: запит з ULID id виконано за 878 мс

Висновки

  1. Якщо вам справді важлива продуктивність і в таблицях вашого продукту очікується 1 млн+ записів, гадаю, краще залишитися на автоінкрементному integer. Якщо ні — не так вже й важливо, що ви оберете.
  2. Якщо хочете UUID-підхід, обирайте ULID замість UUID v4. Також погляньте на UUID v7. Можете взяти репозиторій нижче і потестувати інші варіанти, зокрема uuidv7.
  3. drizzle-seed треба допиляти під D1, щоб він міг наповнювати понад 20 записів.
  4. Drizzle Studio доволі швидка, зрозуміла і має приємний UX. Це вам не PhpMyAdmin, якщо ви такий самий старий, як я :D (мені 29, LOL)

Найкращий варіант

Андрій Шерман підказав мені найкращий підхід. Можна створити кастомний тип через Drizzle ORM, який так само повертає integer для колонки id. Тобто просто лишаємо автоінкрементний integer як первинний ключ. Але! Його можна кодувати лише під час select (для insert це не потрібно), щоб приховати точне значення, коли воно повертається з API.

Пояснення на скриншоті:

Код кастомного типу publicId у Drizzle з кодуванням Hashids, схеми таблиць і міграція з integer id

Приклад бази даних:

Drizzle Studio: таблиця users з автоінкрементними integer id та вкладеними posts

Відповідь API:

Відповідь API: отримання всіх користувачів з їхніми постами (обмежено), id закодовані
Відповідь API: отримання користувача за id (вже закодованим) з його постами (обмежено)

Тож швидкість така сама, як і з простим integer. Додатково я закодував значення через NPM-пакет Hashids . Це означає, що декодувати його можна, лише знаючи точне значення секретного ключа декодування. Це вирішує саме те, що мені й було потрібно. Ба більше, у відповіді API це виглядає значно краще, і при цьому лишається найшвидшим варіантом — саме те, що треба з огляду на вимоги до продуктивності.

Ось відповідний коміт: ea2d3a8 .

А ось репозиторій на GitHub :

Картка репозиторію sqlite-id-uuid-performance-test на GitHub

Підписуйтеся на мої профілі в GitHub та LinkedIn, щоб не пропустити майбутні дослідження та інсайти про продукт, над яким я працюю.

Подяки

Величезна подяка Андрію Шерману за час, допомогу, корисні поради та детальну інформацію, яка дуже знадобилася в цьому дослідженні.