Один сервер, різні способи зберігати таблицю
MySQL складається з двох шарів. Сервер розбирає SQL, будує план і виконує його, а самі рядки читає й пише . Рушій задають кожній таблиці окремо, рядком ENGINE=InnoDB у CREATE TABLE, тож в одній базі можуть жити таблиці з різною поведінкою. Від рушія залежать транзакції, рівень блокування, зовнішні ключі й те, що буде після збою.
З MySQL 5.5 рушій за замовчуванням InnoDB. До того ним був MyISAM, і в старих базах його таблиці ще трапляються. InnoDB зберігає таблицю як : рядки лежать прямо в листках B-tree первинного ключа. MyISAM, як і PostgreSQL, кладе рядки в , окремо від індексів.
Надійність InnoDB тримається на . Зміну спершу дописують у журнал, і COMMIT повертається, коли журнал на диску; файли таблиці оновлюються пізніше. Після збою InnoDB програє журнал і відкочує незакомічені транзакції. У MyISAM журналу немає: перерваний UPDATE лишає таблицю наполовину зміненою, і нижче видно це на справжньому збої.
PostgreSQL і SQLite мають по одному способу зберігання, тож вибір рушія це тема MySQL і MariaDB. Але наслідки кластерного індексу для вибору первинного ключа варто знати всім, хто пише міграції.
Блокування рядка проти блокування таблиці
Найпомітніша на живому сайті різниця. InnoDB блокує лише рядки, які змінює, MyISAM на час запису блокує всю таблицю. Поки на таблиці лічильника переглядів іде довгий UPDATE, на MyISAM у черзі стоїть кожен інший запис, навіть у зовсім інший рядок.
BEGIN BEGIN UPDATE … WHERE id = 1 UPDATE … WHERE id = 2 🔒 рядок 1 🔒 рядок 2 COMMIT COMMIT UPDATE … WHERE id = 1 (3 с) 🔒 уся таблиця UPDATE … WHERE id = 2 🔒 уся таблиця ⏳ Waiting for table level lock готово UPDATE виконано state info User sleep UPDATE users_m SET visits = visits + SLEEP(3) WHERE id = 1 Waiting for table level lock UPDATE users_m SET visits = visits + 1 WHERE id = 2
Сценарії підготовлені заздалегідь. Стан Waiting for table level lock знято з SHOW PROCESSLIST на MySQL 8.0.46.
InnoDB, MyISAM і MEMORY поруч
Виберіть два-три рушії. PostgreSQL тут для порівняння: у нього один спосіб зберігання, купа з окремими індексами, як у MyISAM, але з транзакціями й журналом, як в InnoDB.
| InnoDB | MyISAM | MEMORY | PostgreSQL | |
|---|---|---|---|---|
| Транзакції | ✓ | ✗ ROLLBACK ігнорує | ✗ | ✓ |
| Блокування на запис | рядок | уся таблиця | уся таблиця | рядок |
| Зовнішні ключі | ✓ | ✗ мовчки ігнорує | ✗ | ✓ |
| Після збою | ✓ redo log і відкат | ✗ marked as crashed | ✗ порожня після рестарту | ✓ WAL |
| Де лежать рядки | у листках PRIMARY KEY | .MYD окремо, індекси в .MYI | в оперативній памʼяті, на диску лише опис | купа, індекси окремо |
| COUNT(*) без WHERE | читає найменший індекс | ✓ лічильник у метаданих | ✓ лічильник у метаданих | перебирає таблицю або індекс |
| Повнотекстовий індекс | ✓ FULLTEXT | ✓ FULLTEXT | ✗ | ✓ tsvector і GIN |
| Коли брати | Майже завжди. Рушій за замовчуванням з MySQL 5.5, на ньому тримаються транзакції, зовнішні ключі й Laravel-міграції. | Лише старі таблиці, які ще не перевели. Нових на ньому не створюйте. | Невеликі довідники й проміжні дані, які легко відновити з іншого джерела. Не для даних, які шкода втратити. | Для порівняння: один спосіб зберігання для всіх таблиць, а тимчасові дані без журналу дає UNLOGGED. |
Дані лежать прямо в листках дерева первинного ключа, у порядку ключа. Пошук за PK одразу дає рядок. Вторинний індекс зберігає значення PK, і пошук за ним проходить два дерева, тому PK варто робити коротким і зростаючим.
Індекс в одному файлі, дані в іншому, у порядку вставки. Індекс зберігає адресу рядка у файлі даних, і первинний ключ тут нічим не відрізняється від будь-якого іншого індексу.
Схоже на MyISAM: купа й окремі індекси, які ведуть на ctid, тобто номер сторінки й позицію в ній. UPDATE у PostgreSQL пише нову версію рядка з новим ctid, а стару прибирає VACUUM.
Перевірено на MySQL 8.0.46: зовнішній ключ на MyISAM мовчки перетворюється на звичайний KEY, ROLLBACK на MyISAM дає попередження 1196 і нічого не відкочує, MEMORY відмовляє в TEXT і JSON помилкою 1163, а після рестарту сервера порожня. COUNT(*) без WHERE на MyISAM і MEMORY EXPLAIN показує як Select tables optimized away, на InnoDB як перебір найменшого індексу.
Кластерний індекс любить зростаючий ключ
В InnoDB таблиця відсортована за первинним ключем. Зростаючий ключ дописує рядки в кінець дерева, і сторінки заповнюються щільно. Випадковий ключ вставляє кожен рядок у середину: сторінки діляться, лишаються напівпорожніми, і в памʼяті доводиться тримати все дерево, а не лише його край.
Друга ціна прихована у вторинних індексах. Кожен із них зберігає значення PK, тож CHAR(36) замість BIGINT робить важчим кожен індекс таблиці.
id BIGINT UNSIGNED AUTO_INCREMENT
найкраще
Вісім байтів, кожне нове значення більше за попереднє. 500 000 рядків вставились за 5,3 с, файл таблиці 52 МБ.
id BINARY(16) · UUID_TO_BIN(UUID(), 1) або UUIDv7
працює
Значення ростуть у часі, вставка теж у кінець дерева: 6,5 с і 64 МБ. Ключ удвічі ширший за BIGINT, але його можна згенерувати ще до INSERT, у застосунку.
id CHAR(36) · випадковий UUID v4
дорого
Вставка в випадкові місця дерева: 17,4 с і 120 МБ на тих самих рядках. Індекс на company_id важчий утричі, 41,6 МБ проти 16,5 МБ, бо носить 36 символів ключа в кожному записі.
email VARCHAR(255) PRIMARY KEY
обережно
Природний ключ довгий і змінюється. Зміна PK в InnoDB переносить рядок в інше місце дерева й переписує його запис у кожному вторинному індексі.
без PRIMARY KEY
ні
InnoDB однаково кластеризує таблицю, але за прихованим ключем GEN_CLUST_INDEX, яким не можна шукати. sql_require_primary_key = ON забороняє такі таблиці: ERROR 3750.
Час і розмір виміряно на MySQL 8.0.46: 500 000 рядків одним INSERT … SELECT у таблицю з вторинним індексом на company_id. Розмір це файл .ibd.
Ті самі рядки, різний ключ
Перевірка з матриці вище на справжніх базах: по 500 000 вакансій у таблицях, які відрізняються лише первинним ключем. В InnoDB data_length це сама таблиця, тобто дерево первинного ключа, а index_length вторинні індекси.
У PostgreSQL порядок вставки купі байдужий, тож таблиця більша лише на ширину ключа. Але індекс первинного ключа на випадкових UUID теж росте вшир і вставка повільніша вдвічі.
-- INSERT 500 000 рядків: 5,3 с · 17,4 с · 6,5 с table_name data_mb index_mb pk_bigint 27.6 16.5 pk_uuid4 67.8 41.6 pk_uuid_ord 31.6 22.6
Випадковий UUID удвічі збільшив і саму таблицю, і вторинний індекс. Числа з information_schema це оцінка за статистикою, файли .ibd на диску: 52, 120 і 64 МБ.
-- INSERT 500 000 рядків: 2,1 с · 4,7 с relname pg_size_pretty pk_bigint 32 MB pk_bigint_pkey 11 MB pk_uuid4 36 MB pk_uuid4_pkey 19 MB
Купа майже однакова, різниця в індексі первинного ключа. Тип uuid займає 16 байтів, а не 36 символів, як CHAR(36) у MySQL. Функція uuidv7() зʼявилась у PostgreSQL 18, до того зростаючий UUID генерують у застосунку.
Що лишається від перерваного UPDATE
Дві таблиці по два мільйони рядків з hits = 0, InnoDB і MyISAM. Обидві запускають UPDATE … SET hits = hits + 1 на всю таблицю, а через 2,5 секунди сервер падає по kill -9. Після запуску InnoDB відкотила незакомічений UPDATE повністю, а в MyISAM лишилось те, що встигли записати: п’ята частина рядків змінена, решта ні.
Така таблиця MyISAM формально ціла, CHECK TABLE її пропускає. Зіпсовані тут дані, і жоден журнал не скаже, які рядки встигли оновитись.
-- views_innodb hits COUNT(*) 0 2000000 -- views_myisam hits COUNT(*) 1 400142 0 1599858 -- error.log [ERROR] [MY-013134] [Server] Table './lab/views_myisam' is marked as crashed and should be repaired
InnoDB відновлювалась 11 секунд: програвала redo log і відкочувала UPDATE. MyISAM лише позначила таблицю як пошкоджену, і сервер перевірив її при першому зверненні, бо в Ubuntu myisam_recover_options = BACKUP.
hits | count
-----+---------
0 | 2000000
LOG: database system was not properly shut down;
automatic recovery in progress
LOG: redo starts at 0/1EEFD0A8
LOG: redo done at 0/27C91FA0 … elapsed: 0.89 s
LOG: database system is ready to accept connections PostgreSQL програє WAL і бачить, що транзакція не закомічена: нові версії рядків лишаються в таблиці, але невидимі, їх прибере VACUUM. Відкочувати нічого не треба, тому відновлення швидке.
Швидко і до першого рестарту
MEMORY тримає таблицю в оперативній памʼяті, на диску лишається лише її опис. Індекс за замовчуванням HASH: точний пошук миттєвий, а діапазон чи ORDER BY по ньому не працюють. Розмір обмежує max_heap_table_size, 16 МБ за замовчуванням, і TEXT, BLOB чи JSON у такій таблиці не бувають.
Внутрішні тимчасові таблиці для GROUP BY і сортувань MySQL 8.0 тримає вже не в MEMORY, а в TempTable. Тож MEMORY лишилась для рідкісних випадків: довідник, який завантажують при старті, або проміжні дані важкого звіту. У PostgreSQL ту саму нішу займають UNLOGGED-таблиці, але працюють вони інакше.
ERROR 1163 (42000): The used table type doesn't support BLOB/TEXT columns ERROR 1114 (HY000): The table 'rates' is full COUNT(*): 1088 type: ALL key: NULL rows: 1088 Extra: Using where COUNT(*): 0
INSERT, який не вліз, не відкотився: 1088 рядків лишились, бо транзакцій у MEMORY немає. Діапазон за HASH-індексом став перебором. Індекс USING BTREE це виправляє, але рестарт однаково спорожнить таблицю.
count: 2 -- після звичайного рестарту count: 0 -- після збою
UNLOGGED-таблиця лежить на диску, але не пише WAL, тому запис швидший. Звичайний рестарт її не чіпає, а після збою PostgreSQL її очищає: без журналу він не може довести, що вона ціла. На репліки вона теж не потрапляє.
Рушій і ключ у міграції
Зазвичай рушій у міграціях не згадують зовсім: у config/database.php для mysql стоїть engine => null, і таблиця отримує default_storage_engine сервера, тобто InnoDB. Явно рушій пишуть, коли переводять старі таблиці або коли сервер налаштований інакше.
Сайт на PostgreSQL, міграції готові до InnoDB
Сайт працює на PostgreSQL 17 у контейнері з docker-compose.yml, тож рушіїв MySQL на проді немає. Але міграції написані так, що на MySQL отримали б хороший кластерний ключ. $table->id() на PostgreSQL дає bigserial з окремим індексом _pkey, а на InnoDB дав би зростаючий BIGINT, найкращий кластерний ключ. З нього починаються майже всі таблиці, а рядкові первинні ключі мають лише службові: cache і cache_locks з ключем key, sessions і job_batches з випадковим id, password_reset_tokens з email і packages з назвою пакета. На InnoDB саме вони вставлялися б у випадкові місця дерева, але це невеликі таблиці.
config/database.phpПідключення mysql має engine => null: рушій бере default_storage_engine сервера. За замовчуванням (DB_CONNECTION) стоїть sqlite, але .env.example і deploy/.env.docker.example перемикають на pgsql.database/migrations/2026_09_02_000002_create_vacancies_table.php$table->id() і жодного UUID: вакансії, компанії й відгуки мають зростаючі ключі.docker-compose.ymlСервіс db це образ postgres:17-alpine з томом phpukraine-db. Резервну копію щодня робить pg_dump з deploy/server-setup.sh.Що ламається через рушій
Транзакція на таблиці MyISAM
DB::transaction() на MyISAM нічого не гарантує. ROLLBACK повертає лише попередження 1196, Some non-transactional changed tables couldn't be rolled back, а вставлені рядки лишаються.
Натомість: Перевірте рушій: SELECT table_name, engine FROM information_schema.tables WHERE table_schema = DATABASE(). Таблиці MyISAM переведіть на InnoDB.
Зовнішній ключ, якого немає
MyISAM приймає FOREIGN KEY без помилки й без попередження, але створює звичайний індекс. Каскадне видалення не працює, сирітські рядки накопичуються.
Натомість: Після міграції дивіться SHOW CREATE TABLE: справжній ключ має рядок CONSTRAINT … FOREIGN KEY. Обидві таблиці звʼязку мають бути InnoDB.
Випадковий UUID як первинний ключ InnoDB
Str::uuid() дає UUID v4. Кожна вставка в випадкове місце дерева: таблиця й індекси вдвічі більші, вставка в рази повільніша, і в buffer pool доводиться тримати все дерево.
Натомість: id() або зростаючий UUID: HasUuids генерує UUIDv7, Str::orderedUuid() теж починається з часу. Зберігайте його в BINARY(16), якщо таблиця велика.
MEMORY як кеш, який не можна втратити
Таблиця MEMORY спорожніє після кожного рестарту чи падіння сервера, а при переповненні частина INSERT лишається записаною без відкату.
Натомість: Для кешу беріть Cache у Laravel з драйвером database чи Redis, для проміжних даних тимчасову таблицю InnoDB.
COUNT(*) на великій таблиці InnoDB
Після переходу з MyISAM SELECT COUNT(*) FROM views раптом повільний: MyISAM брав число з метаданих, InnoDB рахує рядки, бо кожна транзакція може бачити свою кількість. На двох мільйонах рядків 0,36 с проти 0,01 с.
Натомість: Якщо потрібна приблизна цифра, беріть table_rows з information_schema.tables. Якщо точна й часто, тримайте лічильник в окремій таблиці або в кеші.
- Рушій зберігання
- Частина MySQL, яка фізично зберігає рядки й індекси таблиці. Список доступних показує SHOW ENGINES, рушій конкретної таблиці видно в SHOW CREATE TABLE.
- Кластерний індекс
- Індекс, у листках якого лежать самі рядки. В InnoDB це первинний ключ: таблиця і є цим деревом, а вторинні індекси зберігають значення PK як адресу рядка.
- Купа
- Файл з рядками в порядку вставки, без сортування. Індекси лежать окремо й зберігають фізичну адресу рядка: зсув у файлі .MYD у MyISAM, ctid (сторінка, позиція) у PostgreSQL.
- Redo log
- Журнал змін InnoDB. Запис у нього послідовний і дешевий, тож COMMIT не чекає, поки оновляться сторінки таблиці. Після збою за журналом відновлюють те, що встигли закомітити. У PostgreSQL ту саму роль грає WAL.
Питання зі співбесід
Laravel виконає ROLLBACK, але MyISAM транзакцій не підтримує: усе, що встигли записати, лишиться в таблиці, а MySQL поверне лише попередження 1196. Помилки не буде, тож це легко не помітити.
InnoDB зберігає рядки в листках дерева первинного ключа, відсортованими за ним. Випадкове значення вставляє рядок у середину дерева: сторінки діляться й лишаються напівпорожніми, а кешувати доводиться все дерево. До того ж кожен вторинний індекс зберігає PK, і широкий ключ робить важчими всі індекси. Рішення: BIGINT AUTO_INCREMENT або зростаючий UUID, наприклад v7.
Листок первинного ключа і є рядком, тож пошук за PK проходить одне дерево. Вторинний індекс зберігає значення PK, і після нього база спускається ще й деревом первинного ключа. Якщо запиту вистачає колонок вторинного індексу разом з PK, другий спуск не потрібен: це покриваючий індекс.
InnoDB програє redo log, щоб відновити закомічене, і відкотить незакомічене за undo log: таблиця буде такою, як до UPDATE. PostgreSQL програє WAL, а нові версії рядків від незакоміченої транзакції просто лишаться невидимими до VACUUM. MyISAM журналу не має: частина рядків буде оновлена, частина ні, а таблицю позначать як пошкоджену й перевірятимуть REPAIR TABLE чи автоматично через myisam_recover_options.