MySQL і SQLite: конкуренція та чому операції запису перетворюються на трафіковий обрив

Було корисно?

Графіки виглядають нормально — допоки ні. Затримка рівна, CPU ледачий, а потім невелике зростання трафіку записів перетворює ваш додаток на музей черг:
усі стоять і чекають, коли їх впустять.

Якщо ви коли-небудь бачили, як прототип «SQLite швидкий» потрапляє в продакшен і раптом починає скидати помилки database is locked, ви зустріли трафіковий обрив.
Це не провина людей. Це фізика, блокування і набір компромісів, які SQLite і MySQL вирішують по-різному.

Трафіковий обрив: що ви реально бачите

«Трафіковий обрив» — це те відчуття, коли пропускна здатність системи не деградує плавно. Ви додаєте 10% більше навантаження записів і раптом отримуєте в 10× гірші хвилинні
затримки, тайм-аути, повторні спроби і гурт клієнтів, які ввічливо намагаються виконати те ж саме ще раз.

У SQLite обрив зазвичай спричинений конкуренцією за блокування й архітектурою «один записувач». Є нюанси (режим WAL змінює картину, а файловa система ОС має значення),
але головна ідея лишається: паралельні записувачі накопичуються за замком. Коли черга росте, затримки вибухають. Це не делікатно.

У MySQL (зокрема InnoDB) обрив теж можливий — але у вас більше передач. Блокування на рівні рядка, MVCC, фонове скидання в кеш і рушій, спроектований для паралельних
підключень, означають, що записи зазвичай деградують повільніше, і у вас є більше регулювань (хоча й більше способів зробити боляче самі собі).

Корисна ментальна модель: SQLite — це дуже швидкий, дуже надійний односмуговий міст з чудовими вказівниками. MySQL — це дорожня розв’язка з кількома зʼїздами,
обмеженнями швидкості і дивовижною кількістю способів спричинити затор, якщо поставити диван у лівому ряду.

Цікаві факти та історія, що важливі в експлуатації

  • SQLite почався у 2000 році як вбудована база даних для внутрішнього використання, спроєктована бути малою, надійною та не потребувати адміністрування — це й досі її сильна сторона.
  • SQLite у суспільному надбанні, що значно сприяло її поширенню: менше проблем з ліцензуванням, швидше «просто відправити» продукт.
  • Культура тестування SQLite надзвичайна: вона відома інтенсивним автоматизованим тестуванням, включно з інжекцією помилок і симуляцією I/O-збоїв — хороші новини, коли диск бреше.
  • Режим WAL з’явився пізніше (середина/кінець 2000-х) щоб покращити конкуренцію читачів, відділивши читання від записів через журнал попереднього запису.
  • InnoDB став типовим двигуном MySQL (історично після періоду, коли MyISAM був поширеним), що змінило «нормальну» поведінку у бік транзакцій і блокувань на рівні рядка.
  • InnoDB використовує MVCC (багатоваріантний контроль конкурентності): читачі не блокують записувачів так само, тому навантаження з великою часткою читань може лишатися стабільним під записами.
  • «Один записувач» у SQLite — це вибір дизайну, а не відсутність функціоналу. Це спрощує правильність поведінки на різних платформах і файлових системах.
  • Файлова семантика важить більше для SQLite, ніж багато хто очікує. Мережеві файлові системи можуть ламати припущення про блокування або робити їх неприпустимо повільними.
  • Надійність MySQL налаштовується через політики скидання журналів, тому ви можете обирати між «безпечно» і «швидко» й потім забути, що обрали.

Моделі блокувань: SQLite проти MySQL простими словами

SQLite: файл бази даних із протоколом блокувань

SQLite — це бібліотека, яка читає і записує один файл бази даних (плюс супутні файли, як WAL та спільна пам’ять у режимі WAL). Ваш додаток лінкує її (безпосередньо або через обгортку),
а запити виконуються через звичайний файловий I/O з механізмом координації, щоб тримати файл узгодженим.

Історія конкурентності зводиться до блокувань. У режимі rollback-journal (старіший стиль за замовчуванням) SQLite бере блокування файлу бази даних у таких станах:
shared, reserved, pending, exclusive. Деталі точні, але операційно це означає: у певний момент записувачу потрібне exclusive-блокування для коміту, і поки воно тримається,
інші записувачі блокуються — і, залежно від режиму та таймінгу, також можуть блокуватися читачі.

У режимі WAL для читачів стає краще: кілька читачів можуть читати основну базу, поки записувач додає у WAL. Але записи все ще серіалізуються: лише один записувач
одночасно може закомітити до WAL. WAL дає вам конкуренцію читання і часто кращу продуктивність при змішаних навантаженнях, але не перетворює SQLite на движок для багатьох записувачів.

MySQL InnoDB: сервер з механізмом паралельних транзакцій

MySQL — це клієнт/серверна СУБД. Ви підключаєтесь через сокет, а сервер керує конкурентністю всередині механізму зберігання (зазвичай InnoDB).
InnoDB використовує блокування на рівні рядка, транзакції, журнали undo/redo, фонове скидання в кеш і MVCC-снапшоти. Він спроєктований, щоб тримати багато клієнтів у русі одночасно.

Записи теж конкурують — на «гарячих» рядках, на індексах, на автозбільшуванні (тепер менше), на метаданих таблиць і на скиданні redo-журналів.
Але більшість болю можна розподілити. За гарної схеми та запитів паралельні записувачі можуть рухатися одночасно, якщо вони не навалюються на ті самі записи.

SQLite питає: «Як зберегти файл узгодженим і портативним?» InnoDB питає: «Як утримати 500 клієнтів задоволеними, поки диск горить?»

Чому записи в SQLite обриваються: пояснення з чергою

Поговорімо про те, чому це відбувається раптово, а не поступово.

Коли у вас є одна критична секція (writer lock), ви створюєте чергу. Допоки інтенсивність надходження транзакцій запису менша за швидкість обслуговування
(наскільки швидко єдиний записувач може завершувати кожну транзакцію), все виглядає нормально. Як тільки ви наближаєтесь до насичення, довжина черги починає швидко рости.
При випадкових надходженнях і змінному часі транзакцій хвости затримок стають дуже поганими.

Записи в SQLite мають тенденцію бути «сплесковими» через межі транзакцій і поведінку fsync. Багато додатків роблять крихітні транзакції:
вставити один рядок, закомітити; оновити один рядок, закомітити. Кожен коміт — це подія надійності, яка може вимагати синку. Це додає велику, пікову складову часу обслуговування.
Раптом ваш односмуговий міст має платний пункт, який інколи зупиняє рух, щоб порахувати монети.

Обрив посилюється поведінкою клієнтів при повторних спробах. Коли SQLite повертає SQLITE_BUSY або database is locked, бібліотеки й додатки часто повторюють спробу.
Повторення працює, коли конкуренція короткочасна. При тривалому навантаженні це стає самопошкодженням: ви збільшуєте швидкість надходження якраз тоді, коли система насичена.

Ось ваш перший жарт: однописна база даних при піковому навантаженні — це як зустріч одного учасника, де всі перебивають — ніхто не просувається, але всі втомлені.

Як змінює картину WAL (але не знімає її)

Режим WAL дозволяє читачам не блокуватися на записувачеві. Це велике покращення для робочих навантажень з великою часткою читань: обрив може зміститися вправо.
Але записувач усе одно один. Гірше, WAL вводить чекпойнти — відкладену роботу: ви додаєте у WAL, поки не вирішите зробити чекпойнт і злити зміни в основну базу. Чекпойнт потребує I/O і може блокувати або уповільнювати залежно від налаштувань і тиску.

Якщо не пощастить, ви обміняли «кожен запис іноді блокує читачів» на «читання плавне, поки не прокинеться монстр чекпойнта».
Це може виглядати як періодичні піки затримки або як обрив, що з’являється за розкладом.

Файлова система та стек зберігання: прихована залежність SQLite

Блокування в SQLite залежать від семантики файлової системи ОС. На локальних дисках зі здоровими ФС це передбачувано. На деяких мережевих ФС — або зламано,
або погано емульовано, або надзвичайно повільно. Навіть коли «підтримується», варіації затримки можуть перетворити незначну конкуренцію на постійний цикл очікування.

MySQL теж піклується про затримку I/O, зрозуміло, але його механізм контролю конкурентності не будується на «кожен клієнт координує доступ через файлові блокування одного файлу».
Він будується на виділеному серверному процесі, який контролює доступ до структур зберігання.

Чому MySQL зазвичай не обривається так само

InnoDB у MySQL має інший профіль відмов. Він цілком може впасти, але типовий шлях відмінний від «одне писання-блокує-всіх».
Натомість це суміш:

  • Контенція на гарячих рядках/індексах: багато записувачів оновлюють ті самі рядки або листи індекса.
  • Тиск на журнал транзакцій: скидання redo-журналів стає обмежувачем, особливо при строгій надійності.
  • Буферний пул і скидання: брудні сторінки накопичуються; скидання стає терміновим; пропускна здатність падає, якщо I/O не встигає.
  • Очікування блокувань і дедлоки: немає глобального write-lock, але достатньо очікувань, щоб затримати додаток.
  • Шторми підключень: надто багато потоків клієнтів, перемикання контексту, пам’ятні вибухи.

Ключова різниця: якщо 50 клієнтів записують у 50 різних рядків, InnoDB часто може це робити одночасно. Якщо 50 клієнтів пишуть у той самий «лічильник», це серіалізується.
Але ви можете перепроєктувати це (шардити лічильники, використовувати таблиці для накопичення, пакетні оновлення). У SQLite перепроєктування часто означає «зменшити кількість записувачів або сильніше пакетувати».

Цитата, обережно передана:
Надія — не стратегія — часто приписується операційній культурі (парафразована ідея).
Вона ідеально тут підходить: «ми не матимемо одночасних записів» — це не архітектура.

WAL, чекпойнти і «сюрпризні хвилі записів»

Режим WAL у SQLite зазвичай рекомендують — і не без причини: він покращує конкуренцію читання/запису, дозволяючи читачам читати стабільну базу, поки записи йдуть у журнал.
Але WAL вводить експлуатаційну роботу, яку не можна ігнорувати: чекпойнти.

Чекпойнт зливає вміст WAL назад у основний файл бази. Це інтенсивна операція I/O. Якщо він не встигає, WAL росте. Якщо WAL росте, це може сповільнити читання
(бо читачі можуть звертатися до WAL), збільшити використання диску й подовжити час відновлення після аварій.

При постійному навантаженні записів ви можете отримати періодичні сплески, коли спрацьовує чекпойнт. При сплесковому навантаженні може виникнути «буря записів», коли система намагається наздогнати,
і раптово ваша раніше плавна продуктивність перетворюється на пилку.

Типова картина обриву виглядає так:

  • Низьке/помірне навантаження: усе швидко, WAL малий, чекпойнти дешеві.
  • Наближення до насичення: записувачі чекають більше, WAL росте, вартість чекпойнта збільшується.
  • Після насичення: записувачі накопичуються, чекпойнти конкурують за ту саму смугу диска, очікування блокувань зростає, клієнти повторюють спроби, і вас розбудять о 02:00.

Транзакції, autocommit і шаблон «смерть від дрібних записів»

Писочний обрив SQLite часто самостійно спричинений формою транзакцій у застосунку.

Багато додатків працюють у режимі autocommit: кожен INSERT/UPDATE — окрема транзакція. Це означає, що кожен запис платить повну ціну «коміту»: захоплення блокування, робота журналу/WAL
і синхронізація надійності. Якщо ви робите 1 000 оновлень по одному — ви не робите 1 000 оновлень, ви робите 1 000 комітів. Ваш диск перетворюється на метроном.

Об’єднуйте записи в явні транзакції. Це не опція. Якщо ваш додаток не може цього робити, SQLite — не той інструмент, коли важлива конкуренція та пропускна здатність.

MySQL теж виграє від пакетування, але він краще витримує дрібні транзакції, бо спроєктований навколо паралельних комітів, групових комітів і фонового I/O.
Ви все одно хочете дотримуватися гігієни транзакцій, але ймовірність миттєвого обриву від «звичної» багатокористувацької поведінки нижча.

Практичні завдання: команди, виводи та рішення (12+)

Ось перевірки, які я реально виконую, коли хтось каже «записи стали повільні» або «SQLite завис» або «MySQL таймаутиться». Кожне завдання містить:
команду, що означає вивід, і яке рішення робити далі.

Завдання 1: Підтвердити режим журналу SQLite і критичні PRAGMA

cr0x@server:~$ sqlite3 /var/lib/app/app.db 'PRAGMA journal_mode; PRAGMA synchronous; PRAGMA busy_timeout;'
wal
2
0

Значення: journal_mode — WAL. synchronous=2 — FULL (надійно, повільніше). busy_timeout=0 означає «швидко відмовитись» при конфлікті.
Рішення: Встановіть адекватний busy_timeout (наприклад, 2000–10000 мс), щоб зменшити негайні відмови, і перегляньте synchronous згідно вимог надійності.

Завдання 2: Перевірити розмір WAL і тиск на чекпойнти

cr0x@server:~$ ls -lh /var/lib/app/app.db*
-rw-r----- 1 app app 1.2G Dec 30 01:12 /var/lib/app/app.db
-rw-r----- 1 app app 768M Dec 30 01:13 /var/lib/app/app.db-wal
-rw-r----- 1 app app  32K Dec 30 01:12 /var/lib/app/app.db-shm

Значення: WAL 768M вказує, що чекпойнти не встигають або не запускаються.
Рішення: Перевірте налаштування чекпойнтів і чи додаток не тримає тривалі транзакції читання (вони можуть перешкоджати чекпойнту).

Завдання 3: Перевірити довготривалі транзакції SQLite (симптоми на рівні додатку)

cr0x@server:~$ lsof /var/lib/app/app.db | head
COMMAND   PID USER   FD   TYPE DEVICE SIZE/OFF     NODE NAME
app      2140  app   17u   REG  252,0 1291845632 1048577 /var/lib/app/app.db
app      2140  app   18u   REG  252,0  805306368 1048578 /var/lib/app/app.db-wal
app      2199  app   17u   REG  252,0 1291845632 1048577 /var/lib/app/app.db

Значення: Кілька процесів/потоків мають відкриту БД; це нормально. Це прямо не показує блокувань, але каже, хто «в грі».
Рішення: Якщо бачите несподівані процеси — зупиніть їх. Якщо багато воркерів ділять одну БД на мережевому сховищі — перегляньте архітектуру негайно.

Завдання 4: Швидка репродукція конкуренції з busy timeout

cr0x@server:~$ sqlite3 /var/lib/app/app.db 'PRAGMA busy_timeout=2000; BEGIN IMMEDIATE; SELECT 1;'
1

Значення: BEGIN IMMEDIATE намагається отримати резерв на запис зарані. Якщо це блокується або швидко повертає BUSY — у вас є конкуренція записувачів.
Рішення: Якщо це постійно проблемно під час інцидентів — зменшіть кількість одночасних записувачів або пакетируйте записи; сам по собі WAL вас не врятує.

Завдання 5: Перевірити тип файлової системи (SQLite це більше турбує, ніж ви хочете)

cr0x@server:~$ findmnt -no SOURCE,FSTYPE,OPTIONS /var/lib/app
/dev/nvme0n1p2 ext4 rw,relatime

Значення: Локальний ext4: хороший базовий варіант. Якщо бачите nfs/cifs/fuse — вважайте це великим ризиком для блокувань і затримок.
Рішення: Перенесіть SQLite на локальне сховищe або перейдіть на серверну базу даних.

Завдання 6: Виміряти затримку диска під час піків записів

cr0x@server:~$ iostat -xz 1 5
Linux 6.8.0 (server)  12/30/2025  _x86_64_ (8 CPU)

avg-cpu:  %user   %nice %system %iowait  %steal   %idle
           6.12    0.00    2.10    9.80    0.00   81.98

Device            r/s     w/s   rKB/s   wKB/s  avgrq-sz avgqu-sz   await r_await w_await  svctm  %util
nvme0n1         120.0   980.0   6400  51200      97.5     8.20    8.40    2.10    9.20   0.62  68.0

Значення: iowait високий і write await ≈9 мс. Це не катастрофа, але якщо стрибає до десятків/сотень мс, коміти затримуються і черги блокувань ростуть.
Рішення: Якщо латентність диска корелює з помилками блокувань — пріоритезуйте I/O: швидший диск, менше синків, більші транзакції, менше записувачів.

Завдання 7: Перевірити движок MySQL і базову конфігурацію

cr0x@server:~$ mysql -uroot -p -e "SHOW VARIABLES LIKE 'default_storage_engine'; SHOW VARIABLES LIKE 'innodb_flush_log_at_trx_commit';"
+------------------------+-------+
| Variable_name          | Value |
+------------------------+-------+
| default_storage_engine | InnoDB |
+------------------------+-------+
+--------------------------------+-------+
| Variable_name                  | Value |
+--------------------------------+-------+
| innodb_flush_log_at_trx_commit | 1     |
+--------------------------------+-------+

Значення: InnoDB з flush-at-commit=1 — надійно, але чутливо до fsync-латентності.
Рішення: Якщо ви можете допустити втрату близько ~1 секунди комітів при краху, розгляньте значення 2; інакше інвестуйте в низьколатентне сховище і групові коміти.

Завдання 8: Перевірити MySQL на очікуваннях блокувань і конкуренцію

cr0x@server:~$ mysql -uroot -p -e "SHOW ENGINE INNODB STATUS\G" | sed -n '1,120p'
*************************** 1. row ***************************
  Type: InnoDB
  Name:
Status:
=====================================
2025-12-30 01:15:02 0x7f1c6c0d9700 INNODB MONITOR OUTPUT
=====================================
------------
TRANSACTIONS
------------
Trx id counter 5829101
Purge done for trx's n:o < 5829000 undo n:o < 0 state: running
History list length 2113
LIST OF TRANSACTIONS FOR EACH SESSION:
---TRANSACTION 5829088, ACTIVE 12 sec
2 lock struct(s), heap size 1136, 1 row lock(s)
MySQL thread id 118, OS thread handle 139759, query id 99102 app 10.0.0.24 updating
UPDATE counters SET value=value+1 WHERE id=1

Значення: Транзакція, що оновлює один і той самий рядок-лічильник — класична «гаряча» контенція. Active 12 секунд — поганий симптом.
Рішення: Виправте схему/навантаження: шардінг лічильників, буферні інкременти або перепроєкт, щоб уникнути одного гарячого рядка.

Завдання 9: Перевірити список процесів MySQL на накопичення

cr0x@server:~$ mysql -uroot -p -e "SHOW FULL PROCESSLIST;" | head
Id	User	Host	db	Command	Time	State	Info
118	app	10.0.0.24:51122	prod	Query	12	updating	UPDATE counters SET value=value+1 WHERE id=1
119	app	10.0.0.25:51140	prod	Query	11	Waiting for row lock	UPDATE counters SET value=value+1 WHERE id=1
120	app	10.0.0.26:51188	prod	Query	11	Waiting for row lock	UPDATE counters SET value=value+1 WHERE id=1

Значення: Багато сесій чекають рядкового блокування: це не «MySQL повільний», це проблема контенції в додатку.
Рішення: Зменшіть конкуренцію до цього гарячого місця, змініть шаблон запитів або перейдіть на append-only з агрегацією.

Завдання 10: Швидко перевірити детектовані дедлоки InnoDB

cr0x@server:~$ mysql -uroot -p -e "SHOW ENGINE INNODB STATUS\G" | grep -n "LATEST DETECTED DEADLOCK" -A25
247:LATEST DETECTED DEADLOCK
248:------------------------
249:2025-12-30 01:10:44 0x7f1c6c1da700
250:*** (1) TRANSACTION:
251:TRANSACTION 5829051, ACTIVE 1 sec starting index read
252:mysql tables in use 1, locked 1
253:LOCK WAIT 2 lock struct(s), heap size 1136, 1 row lock(s)

Значення: Дедлоки — це не «погано», це сигнал про конкурентність. InnoDB обирає жертву і відкочує її.
Рішення: Робіть транзакції коротшими і послідовними за порядком блокувань; додайте індекси; обробляйте повторні спроби дедлоків в додатку.

Завдання 11: Спостерігати пропускну здатність MySQL і тиск на скидання

cr0x@server:~$ mysql -uroot -p -e "SHOW GLOBAL STATUS LIKE 'Innodb_buffer_pool_pages_dirty'; SHOW GLOBAL STATUS LIKE 'Innodb_log_waits';"
+-------------------------------+-------+
| Variable_name                 | Value |
+-------------------------------+-------+
| Innodb_buffer_pool_pages_dirty| 8421  |
+-------------------------------+-------+
+------------------+-------+
| Variable_name    | Value |
+------------------+-------+
| Innodb_log_waits | 187   |
+------------------+-------+

Значення: Брудні сторінки свідчать про очікуване скидання; log waits показують транзакції, що чекають місця для скидання redo-журналу.
Рішення: Якщо log waits ростуть, ви обмежені I/O redo-журналу або його розміром; розгляньте налаштування розміру журналу і покращення сховища.

Завдання 12: Перевірити кількість підключень і поведінку потоків у MySQL

cr0x@server:~$ mysql -uroot -p -e "SHOW GLOBAL STATUS LIKE 'Threads_connected'; SHOW VARIABLES LIKE 'max_connections';"
+-------------------+-------+
| Variable_name     | Value |
+-------------------+-------+
| Threads_connected | 812   |
+-------------------+-------+
+-----------------+-------+
| Variable_name   | Value |
+-----------------+-------+
| max_connections | 1000  |
+-----------------+-------+

Значення: 812 підключень — багато, якщо ви це не планували. Шторм підключень може виглядати як «база повільна», коли насправді це планування потоків.
Рішення: Використовуйте пул підключень, обмежуйте конкуренцію і перевіряйте, чи додаток не створює підключення під кожен запит.

Завдання 13: Перевірити версію SQLite (компіляція/рантайм)

cr0x@server:~$ sqlite3 --version
3.45.2 2024-03-12 11:06:23 df5c253c0b3dd24916e4ec7cf77d3db5294cc9fd3f24a94d9c0b6ec8d1c4a2c7

Значення: Версія показує, які виправлення WAL і блокувань доступні. Старі версії можуть мати інші значення за замовчуванням і продуктивні характеристики.
Рішення: Якщо ви на застарілому бінарнику, що постачається з образом ОС, оновіть бібліотеку/рантайм, де це можливо.

Завдання 14: Визначити, чи ви помилково працюєте на мережевій файловій системі

cr0x@server:~$ mount | grep -E ' /var/lib/app |nfs|cifs|fuse' | head
/dev/nvme0n1p2 on /var/lib/app type ext4 (rw,relatime)

Значення: Локальний монтування. Добре. Якби ви бачили NFS/CIFS/FUSE — мали б негайне завдання.
Рішення: Якщо це віддалено — зупиніться і переархітектуруйте: локальний диск для SQLite або перехід на MySQL/Postgres.

Завдання 15: Виявити «смерть від autocommit» в логах додатку (перевірка шаблону)

cr0x@server:~$ grep -E "SQLITE_BUSY|database is locked|BEGIN|COMMIT" /var/log/app/app.log | tail -n 8
2025-12-30T01:12:10Z db=sqlite msg="BEGIN"
2025-12-30T01:12:10Z db=sqlite msg="COMMIT"
2025-12-30T01:12:10Z db=sqlite err="database is locked"
2025-12-30T01:12:10Z db=sqlite msg="BEGIN"
2025-12-30T01:12:10Z db=sqlite msg="COMMIT"

Значення: BEGIN/COMMIT на операцію і негайні помилки блокувань: ви робите крихітні транзакції і зіштовхуєтесь.
Рішення: Пакетуйте записи в явні транзакції; додайте зворотний тиск; зменшіть конкуренцію воркерів.

Швидкий план діагностики

Коли продуктивність записів падає, часу на філософію про ACID немає. Потрібен швидкий розгалужений шлях.
Ось порядок, який зазвичай швидко знаходить вузьке місце.

Перше: визначте тип блокування і місце конкуренції

  • SQLite: Чи бачили ви SQLITE_BUSY / database is locked? Перевірте PRAGMA busy_timeout, розмір WAL і чи є кілька записувачів.
  • MySQL: Чи запити «Waiting for row lock» або «Waiting for table metadata lock»? Використовуйте SHOW FULL PROCESSLIST і SHOW ENGINE INNODB STATUS.

Друге: перевірте затримки I/O і налаштування надійності

  • Запустіть iostat -xz. Якщо write await стрибає — коміти будуть стояти.
  • SQLite: перевірте PRAGMA synchronous. MySQL: перевірте innodb_flush_log_at_trx_commit.

Третє: підтвердьте форму транзакцій і ліміти конкурентності

  • Шукайте цикли autocommit: багато дрібних транзакцій.
  • Перевірте розміри пулів воркерів, конкуренцію запитів і поведінку при повторних спробах.
  • У MySQL перевірте кількість підключень і чи є пул.

Четверте: вирішити «налаштувати» чи «перепроєктувати»

  • Якщо це SQLite і вам потрібні множинні паралельні записувачі: рахуйтесь з необхідністю перепроєктування/міграції.
  • Якщо це MySQL і у вас гарячий рядок/індекс: перепроєктуйте патерни запитів і модель даних; одне лише тюнінг не переможе фізику.
  • Якщо це I/O: купіть нижчі затримки, безпечно зменшіть частоту синків або зменшіть обсяг записів.

Типові помилки: симптом → корінь → фікс

1) «database is locked» з’являється лише при піковому трафіку

Симптоми: Спорадичні помилки блокувань, повторні спроби, тайм-аути і раптовий стрибок хвостових затримок.

Корінь: Конкуренція за writer lock і черги; надто багато одночасних записувачів; busy_timeout занадто малий або нуль.

Фікс: Пакетуйте записи в явні транзакції; встановіть розумний busy_timeout; зменшіть конкуренцію записувачів; перейдіть на MySQL, якщо потрібні мульти-райтер можливості.

2) WAL файл росте безконтрольно

Симптоми: .db-wal росте, використання диска повзе, періодичні піки затримки.

Корінь: Чекпойнти не виконуються або блокуються довготривалими читачами; налаштування чекпойнта не відповідають навантаженню.

Фікс: Переконайтесь, що читачі не тримають відкриті транзакції; плануйте/тригерьте чекпойнти; розгляньте коротші транзакції читання або інші підходи до знімків.

3) «SQLite швидкий на моєму ноуті», але повільний у контейнерах

Симптоми: Чудова продуктивність у dev; продакшен показує тайм-аути блокувань і I/O-стали.

Корінь: Інше сховище: накладки файлових систем, мережеві томи, обмежені IOPS або вища latency fsync.

Фікс: Розмістіть SQLite на локальному низьколатентному сховищі; виміряйте витрати fsync; або перейдіть на MySQL, де сервер може краще абсорбувати варіабельність.

4) MySQL затримки записів без очевидних блокувань

Симптоми: Запити «виконуються», але займають значно більше часу; CPU помірний; диск зайнятий.

Корінь: Тиск на скидання redo-журналу або агресивне скидання брудних сторінок; затримки сховища.

Фікс: Покращіть латентність диска; налаштуйте розмір redo-журналу; перевірте політики скидання; зменшіть частоту комітів через пакетування.

5) MySQL «падає», а насправді справа в підключеннях

Симптоми: Помилки про надто багато підключень; час відповіді погіршується по всьому фронту.

Корінь: Поведінка «підключення на запит», неправильно налаштований пул або шторм повторних спроб.

Фікс: Впровадьте пулінг, лімітуйте конкуренцію та застосуйте зворотний тиск; моніторьте Threads_connected і глибину черги додатку.

6) «Додали індекс і записи стали гіршими»

Симптоми: Читання покращилось, але вставки/оновлення впали; час блокувань зріс.

Корінь: Додаткове підтримання індексу збільшує write amplification і конкуренцію (особливо на монотонних ключах).

Фікс: Тримайте індекси мінімальними; ретельно продумуйте складні індекси; уникайте зайвих індексів; використовуйте ключі, дружні до додавання, коли це можливо.

Три корпоративні міні-історії з передової

Міні-історія 1: Інцидент через неправильне припущення

Середня продуктова команда побудувала job-runner, який обробляв події з черги повідомлень. Кожний воркер писав рядок статусу в SQLite: час постановки в чергу, час старту, час завершення
і кілька лічильників. На стейджингу працювало чудово. У продакшені перший місяць теж було добре.

Припущення було тихим і смертельним: «воркери незалежні, отже записи в базу незалежні». Насправді — ні. Усі воркери писали в один файл на одному томі, і кожна задача створювала кілька дрібних транзакцій,
бо ORM був у режимі autocommit.

На піковому трафіку (передбачувано — коли маркетинг запустив кампанію) воркерів стало десятки. Черга зростала швидше, ніж обробка. Воркери повторювали спроби при помилках блокувань,
що підвищувало кількість записів. CPU залишався низьким. Сервіс виглядав «нормально», якщо дивитись лише на CPU.

Вказівкою були логи: стіна database is locked і дрібні транзакції. Виправлення не вимагало героїки: зменшити конкуренцію записувачів, пакетувати записи кожної задачі в одну транзакцію
і перемістити стан у MySQL для мульти-райтерної надійності. Решту тижня пояснювали лідерам, чому «швидка вбудована БД» не означає «серверна БД для спільного запису».

Міні-історія 2: Оптимізація, яка відкотилась

Інша команда хотіла «менше мережевих стрибків» і перенесла дрібну особливість з великою кількістю записів з MySQL у вбудований SQLite в API-сервісі. Логіка була привабливою:
без мережі, без TCP, без сервера, без пулів підключень. Просто файл. Тест продуктивності виглядав добре — на одній інстансі.

Потім фіча стала популярною. Вони масштабували API горизонтально — як роблять зі stateless сервісом. Кожен інстанс вказував на той самий спільний том (керована мережева ФС), щоб «ділитися базою».
Там оптимізм зіткнувся з реальністю файлової системи.

Система не просто сповільнилась. Вона набрала характер: випадкові паузи, тайм-аути блокувань і дивна поведінка, коли деплой «вирішував» проблему на годину.
WAL файли росли, чекпойнти стали нестабільними, а семантика блокувань мережевої ФС додавала змінність затримок. Налагодження було жахливе, бо кожен інстанс технічно «здоровий», просто заблокований.

Вони відкочували зміни. Не тому, що SQLite поганий, а тому, що намагалися використовувати його як розподілену базу. «Оптимізація» прибрала мережевий хоп і замінила його на розподілене блокування і непередбачувану латентність сховища.
Ви не перехитрите фізику; можна лише обирати, за яку частину фізики платити.

Міні-історія 3: Нудна, але правильна практика, що врятувала ситуацію

Команда, що працювала з платежами, використовувала MySQL для транзакцій і SQLite на периферійних пристроях для локального кешування. Вони мали правило: жодний файл SQLite не міг мати більше одного активного записувача на групу процесів,
і кожен шлях запису мав явні межі транзакції та обмежену чергу.

Це було нудно. Потрібні були code review з неприємними питаннями на кшталт «яка максимальна тривалість транзакції?» і «де застосовується зворотний тиск?».
Вони також мали синтетичні навантажувальні тести, які моделювали пікові ставки записів і штучно створювали конкуренцію у передпроді.

Одного дня нова фіча випадково ввела патерн запису при читанні: endpoint читання оновлював поле «last_seen» на кожному запиті. В MySQL це було б гучно, але переживалося б; на edge-кешах SQLite це перетворило читання на запис і могло посеріалізувати все.

Захисні заходи спрацювали. Обмежена черга заповнилась, сервіс деградував передбачувано (деякі оновлення пропускались, читання залишалось швидким), і алерти спрацювали до того, як пристрої стали непридатними.
Вони відкотили зміни і замінили «write last_seen» на періодичне пакетне оновлення. Ніхто не отримав дзвінка о 02:00. Ось що купує «нудна» практика.

Контрольні списки / покроковий план

Якщо наполягаєте на запуску SQLite під конкурентним навантаженням

  1. Увімкніть WAL, якщо немає специфічної причини не робити цього. Перевірте через PRAGMA journal_mode;.
  2. Пакетуйте записи: обгорніть кілька операторів в одну явну транзакцію. Вимірюйте швидкість комітів, а не запитів.
  3. Встановіть busy_timeout, щоб мати контрольоване очікування замість негайних відмов; потім обмежте повторні спроби на рівні додатку.
  4. Забезпечте одного записувача через чергу (в процесі або через виділений сервіс-писар). Багато записувачів — це не «паралельно», це «конкуренція».
  5. Тримайте транзакції короткими; не тримайте читабельні транзакції відкритими через повільну бізнес-логіку.
  6. Прив’яжіть базу до локального сховища. Уникайте мережевих файлових систем для спільних записів.
  7. Стежте за WAL і чекпойнтами: налаштуйте алерти на ріст WAL і затримки чекпойнта.
  8. Тестуйте навантаження з реальною конкуренцією, включно з повторними спробами і таймаутами, а не лише «однопотокові бенчмарки».

Якщо ви обираєте MySQL для конкуренції

  1. Використовуйте InnoDB (так, варто повторити) і підтвердіть, що налаштування надійності відповідають бізнес-вимогам.
  2. Відводьте гарячі точки: уникайте глобальних лічильників; уникайте глобальних таблиць статусу.
  3. Індексуйте відповідально: кожен індекс — це податок на запис; платіть тільки за те, що потрібно.
  4. Пулінгуйте підключення і обмежуйте конкуренцію; не дозволяйте кожному запиту створювати нову сесію.
  5. Моніторьте очікування блокувань і дедлоки і сприймайте їх як зворотний звʼязок про дизайн транзакцій.
  6. Тестуйте під сплески записів, а не лише в усталеному стані. Багато відмов відбувається, коли буфери заповнюються і скидання стає терміновим.

Контрольний список для міграції: коли SQLite — не та гора, на якій варто вмирати

  • Потрібні множинні паралельні записувачі між потоками/процесами і ви не можете їх посеріалізувати без шкоди для затримки.
  • Потрібне горизонтальне масштабування зі спільним станом між вузлами.
  • Ви залежите від мережевого сховища для файлу бази.
  • Ви не можете пакетувати записи, бо продукт вимагає миттєвого коміту для великої кількості подій.
  • Вам потрібні онлайнові зміни схеми і операційні інструменти, розраховані на серверну базу даних.

Другий жарт: SQLite — фантастичний інструмент, але використовувати його як спільну базу для записів — це як використовувати викрутку як долото — працює, поки не стане історією для ознайомлення на онбордингу.

FAQ

1) Чи справді SQLite — одно-писач?

Фактично, так: лише один записувач може закомітити одночасно. WAL покращує конкуренцію читачів, а не пропускну здатність для багатьох записувачів.
Ви можете мати кілька підключень, що виконують записи, але вони серіалізуються на writer lock.

2) Чи робить режим WAL SQLite безпечним для високої конкуренції?

Він робить його безпечнішим для робочих навантажень, орієнтованих на читання, і для змішаних навантажень, бо читачі не блокуються на записувачеві так само.
Він не знімає вузьке місце «одного записувача»; часто він просто зміщує або змінює його форму (піки чекпойнтів).

3) Чому записи обриваються замість поступової деградації?

Тому що ви насичуєте серіалізований ресурс. Теорія черг: коли використання підходить до 100%, час очікування вибуховий.
Додайте повторні спроби та змінний час коміту — і вибух стає драматичним.

4) Чи виправить конкуренцію SQLite більше CPU?

Зазвичай ні. Обмежувач — серіалізація блокувань і затримка fsync/I/O, а не CPU. Швидше сховище і менше комітів допоможуть краще, ніж більше ядер.

5) Чи завжди «database is locked» — це погано?

Це сигнал. Кілька відповідей busy під час сплесків можуть бути прийнятні, якщо у вас є зворотний тиск і обмежені повторні спроби.
Постійні помилки блокувань означають, що швидкість надходження записів перевищує можливості єдиного записувача, або транзакції надто довгі.

6) Чому MySQL краще справляється з паралельними записами?

InnoDB підтримує блокування на рівні рядка і MVCC, тому незалежні записи можуть виконуватися одночасно, якщо вони не торкаються тих самих рядків/сторінок індекса.
Він все ще має вузькі місця, але вони зазвичай локалізовані і піддаються перепроєктуванню.

7) Коли MySQL все ще поводиться як «трафіковий обрив»?

Гарячі рядки (один лічильник), великі вторинні індекси при інтенсивному вставленні, тиск на redo-журнал або шторми підключень.
MySQL може деградувати поступово, але також може впасти в обрив, якщо ви створите гарячу точку і додасте повторні спроби.

8) Чи варто зменшувати надійність, щоб покращити пропускну здатність записів?

Іноді, але робіть це свідомо. SQLite PRAGMA synchronous і MySQL innodb_flush_log_at_trx_commit — це бізнес-рішення.
Якщо ви не можете терпіти втрату даних, не «оптимізуйте» себе до інциденту з відповідністю.

9) Чи можна запускати SQLite на NFS, якщо дуже обережно?

Можна спробувати, і вам може пощастити деякий час. Але семантика блокувань і варіабельність затримки роблять це високим ризиком.
Якщо потрібні спільні записи між вузлами — обирайте серверну СУБД.

10) Який найпростіший безпечний патерн для SQLite у продакшені?

Один записувач (черга), режим WAL, пакетні транзакції, локальний диск, короткотривалі читальні транзакції та обмежені повторні спроби зі зворотним тиском.
Якщо це виглядає як побудова серверної БД — ви робите правильні висновки.

Наступні кроки, які ви дійсно можете зробити цього тижня

Якщо ви вже на SQLite і відчуваєте обрив:

  • Виміряйте: підтвердіть режим журналу, synchronous, busy_timeout; перевірте розмір WAL; зіставте помилки блокувань із затримкою диска.
  • Змініть форму: пакетизуйте записи в явні транзакції; обмежте конкуренцію записувачів; додайте зворотний тиск; припиніть нескінченні повторні спроби.
  • Стабілізуйте: забезпечте локальне сховище; не допускайте довготривалих читальних транзакцій; стежте за поведінкою чекпойнтів.
  • Вирішіть: якщо вам потрібні мульти-писачі між процесами/вузлами — плануйте міграцію на MySQL (або іншу серверну БД), замість «жорсткішого тюнінгу».

Якщо ви обираєте між MySQL і SQLite для нової системи:

  • Використовуйте SQLite, коли хочете вбудовану простоту, переважно читання, обмежену конкуренцію записів і локальну надійність.
  • Використовуйте MySQL, коли потрібні багато одночасних записувачів, централізований стан між інстансами, операційні інструменти та прогнозоване масштабування під навантаженням.

Обрив не містить таємниці. Це контракт, який ви випадково підписали: «один записувач одночасно». Якщо цей контракт відповідає вашому навантаженню — SQLite блискучий.
Якщо ні — обрив не баг. Це рахунок.

← Попередня
Контроль доступу між офісами: забезпечте правило «лише сервери, не вся локальна мережа»
Наступна →
MariaDB проти Percona Server: швидкість резервного копіювання та відновлення на маленькому VPS

Залишити коментар