Ви випускаєте цілком розумну фічу. Вона працює в стейджингу. У продакшені ви отримуєте помилку, яка звучить так, ніби база даних надто ввічлива, щоб сказати те, що думає: database is locked, SQLITE_BUSY або купу повідомлень «Lock wait timeout exceeded».
Блокування — це не баг. Це механізм, який не дає подвійно списати гроші чи перетворити рядки на сучасне мистецтво. Але модель блокувань, яку ви обираєте, змінює режими відмов. SQLite «видає» помилку швидко й голосно. MariaDB зазвичай падає пізніше — коли потоки накопичуються, і ваш застосунок ніби рухається крізь мокрий цемент.
Швидкий план діагностики
Коли ви на колі (on-call), вам не потрібна філософська лекція. Потрібно «що перевірити в першу чергу, щоб зупинити кровотечу?» Ось послідовність триажу, яку я використовую для помилок «busy» у SQLite і контенції в MariaDB.
Перше: підтвердьте, чи ви заблоковані через локи або через I/O
- SQLite: «busy» часто означає контенцію блокувань, але також може бути «writer не може провести checkpoint», що виглядає як контенція і відчувається як затримка.
- MariaDB: очікування локів і затримки диска можуть виглядати однаково на шарі застосунку (запити таймаутяться). Треба розділити «потоки чекають локів» від «потоки чекають підсистеми зберігання».
Друге: знайдіть ту одну транзакцію, яка тримає двері зачиненими
- SQLite: знайдіть довгоживучі транзакції, відкриті підключення або фонові задачі, що пишуть, поки застосунок читає.
- MariaDB: ідентифікуйте блокуючий потік/транзакцію та граф очікувань (InnoDB status і processlist).
Третє: виправте обсяг і паралелізм перед налаштуванням таймаутів
Таймаути — це пластир. Іноді правильний пластир. Але це все одно пластир.
- Зменшіть обсяг транзакцій.
- Зменшіть частоту записів або пакуйте їх розумно.
- Додайте правильний індекс, щоб уникнути посилення блокувань.
- Лише потім: налаштуйте
busy_timeout(SQLite) абоinnodb_lock_wait_timeout(MariaDB), плюс логіку повторних спроб із джитером.
Одне надійне правило: якщо ваш «фікс» — «збільшити таймаут», ви просто обираєте, скільки часу чекати до відмови.
Моделі блокувань: що саме блокується і коли
SQLite: один записувач одночасно, і це серйозно
SQLite — це вбудована база даних. Це бібліотека, що лінкується в ваш процес, яка записує у файл. Цей файл — спільний ресурс. Коли ви бачите «busy», SQLite каже, що не може отримати потрібний лок на цей файл (або на файли WAL/спільної пам’яті, коли ввімкнено режим WAL).
Основна операційна реальність:
- Багато читачів можуть співіснувати.
- Лише один записувач може комітити одночасно. Навіть якщо кілька підключень стають у чергу, лише один стане «writer».
- Транзакції визначають час утримання локів. «Маленьке оновлення» всередині транзакції, що відкрито кілька секунд, стає «локом, утримуваним кілька секунд».
SQLite має кілька режимів журналювання. Для продакшен-обговорень важливі два:
- Rollback journal (старіший за замовчуванням у багатьох уявленнях): записувачі можуть більше блокувати читачів залежно від стану локів, а чекпоінтинг простіший, але може бути руйнівним при конкуренції.
- WAL (Write-Ahead Logging): читачі зазвичай не блокують записувачів і навпаки, але з’являється новий режим відмов: WAL-файл росте до моменту чекпоінту, а сам чекпоінт може стати точкою контенції.
Конкурентність SQLite не «погана». Вона явна. Вона змушує чесно дивитися на патерни записів. Це перевага, поки ви не прикидаєтеся, що це мережевий OLTP-сервер.
MariaDB (InnoDB): блокування рядків, gap-locks і тихе чергування
MariaDB з InnoDB — класична клієнт-серверна база. В неї є buffer pool, менеджер локів, бекграунд-потоки і багато коду, що дозволяє багатьом сесіям працювати паралельно, не наступаючи одна одній на пальці.
Операційна реальність тут:
- Кілька записувачів можуть працювати одночасно, поки вони не торкаються тих самих рядків (або діапазонів, завдяки next-key locking).
- Очікування локів часто тихе. Ваш запит виконується, а потім зависає. Клієнт бачить «повільно», а не «busy».
- Погані індекси призводять до посилення блокувань. Відсутній індекс може перетворити операцію на рівні рядка в скан діапазону, що блокує цілу околицю.
InnoDB також має дедлоки, які не є апокаліпсисом — InnoDB їх виявляє і відкочує одного учасника. Проблема виникає, коли ваш застосунок сприймає дедлок як «цього не повинно траплятися» і відповідає «паніка, повторити одразу, знову і знову».
Парафразована ідея (не цитата): Вернер Вогельс неодноразово наголошував, що ви повинні проектувати на відмови і будувати системи, які припускають, що компоненти можуть поводитися неналежним чином. Локи — один із таких компонентів.
Цікаві факти та коротка історія для наради
- SQLite старший за багато «сучасних» веб-стеків. Розробка почалася у 2000 році як самостійний SQL-рушій для вбудованого використання.
- Дизайн SQLite «без сервера» — буквальний. Немає демон-процесу. Кожен процес — свій клієнт і частковий «сервер», що конкурує за файлові локи.
- Режим WAL було введено, щоб покращити конкурентність шляхом відділення читань від записів через додатковий лог, але він вводить динаміку чекпоінтингу, якою потрібно керувати.
- InnoDB спочатку не був «за замовчуванням» в MySQL. Він став де-факто стандартом, бо приніс транзакції, відновлення після краху і блокування на рівні рядка, чого MyISAM не мав.
- MariaDB — це форк, створений після того, як Oracle придбав Sun, здебільшого щоб зберегти розвиток MySQL відкритим і керованим спільнотою.
- «database is locked» у SQLite часто — помилка застосунку, наприклад, відкриття транзакції через мережеві виклики або витік підключення в пулі.
- Gap-локи і next-key локи InnoDB існують, щоб запобігти фантомам при певних рівнях ізоляції; вони можуть здивувати інженерів, які думають, що блокуються лише «рядки, до яких торкався».
- SQLite використовує POSIX/Win32 механізми локування, тобто NFS та інші мережеві файлові системи можуть перетворити локування з детермінованого в «цікаве».
- SQLite використовується більше, ніж багато хто думає: браузери, мобільні ОС і незліченні десктопні додатки — через малий операційний слід і високу надійність при правильному використанні.
Жарт №1: SQLite — як однополосний міст: безпечно, просто, але лише одна вантажівка за раз.
Що насправді означає «busy» (і чого не означає)
SQLITE_BUSY — це спосіб SQLite сказати: «Я спробував отримати потрібний лок, але не зміг, і я не чекатиму вічно, якщо ви не сказали». Поведінка за замовчуванням у багатьох биндінгах фактично «не чекати». Ось чому помилки «busy» з’являються відразу, як тільки конкуренція трохи зростає.
Три звичайні винуватці
- Довгоживуча транзакція на запис. Фонова задача починає транзакцію, пише купу рядків і тягне її. Усі інші отримують «busy».
- Тиск на WAL-чекапоінтинг. WAL-файл росте. Зрештою чекпоінтинг потребує співпраці читачів. Довгий читач може перешкоджати просуванню чекпоінта.
- Кілька процесів на файловій системі, що «брешуть» про локи. Мережеві файлові системи і драйвери томів у контейнерах можуть перетворити коректне використання SQLite на випадкові відмови.
Чого це не означає
- Це не головним чином означає «SQLite повільний». SQLite може бути надзвичайно швидким на локальному SSD, особливо для робочих навантажень з переважно читанням.
- Це не вирішується «просто додайте повторні спроби», якщо транзакції довгі і конкуренція постійна. Ви просто отримаєте шторм повторних спроб.
- Це не те, що слід заспокоювати. Трактуйте «busy» як сигнал про невідповідність моделі конкуренції.
Контенція в MariaDB/InnoDB: тихіший, підступніший родич
MariaDB рідко кидає вам в обличчя «busy». Вона дозволяє ставати в чергу. Це здається приємнішим, доки потоки вашого застосунку не накопичуються, ніби літаки в зоні очікування. Ваш p95 латентності йде вгору. Потім пул підключень насичується. Потім клієнти починають повторювати запити. І тепер ви фактично створили власний розподілений DDoS.
Як зазвичай проявляється біль від локів у MariaDB
- Повільні запити без завантаження CPU (потоки «Sending data» або «Waiting for row lock»).
- Таймаути очікування локів (зрештою) і дедлоки (час від часу, але у сплесках).
- Лаг реплікації через те, що SQL-потік репліки заблокований за великою транзакцією або очікуванням локу.
Типові корені проблем
- Транзакції занадто великі (масові оновлення, зміни схеми або код, що «дбайливо» обгортає все в транзакцію).
- Відсутні індекси, які перетворюють цільові оновлення в скани діапазонів і широке блокування.
- Плутанина із рівнем ізоляції, що вводить gap-locks і блокує вставки в «порожній простір».
- Гарячі рядки (лічильники, «last_seen», рейтинги), які викликають контенцію на записах незалежно від якості СУБД.
Жарт №2: дедлоки InnoDB — як офісні наради: хтось повинен піти раніше, щоб щось зрушилося з місця.
Практичні завдання: команди, виводи та рішення
Це ті завдання, які я дійсно виконую під час діагностики контенції локів. Кожне містить: команду, яку ви можете виконати, що означає її вивід, і яке рішення прийняти далі.
Завдання для SQLite (поведінка локів у файлі)
Завдання 1: Підтвердьте режим журналювання і чи ввімкнено WAL
cr0x@server:~$ sqlite3 /var/lib/myapp/app.db "PRAGMA journal_mode;"
wal
Значення: WAL ввімкнено. У загальному випадку читачі не повинні блокувати записувачів, але чекпоінтинг стає фактором.
Рішення: Якщо ви не в WAL і маєте будь-яку конкуренцію, перейдіть на WAL, якщо ви не на файловій системі з ненадійними локами або не маєте суворих вимог до rollback journal.
Завдання 2: Перевірте налаштований busy timeout (на стороні SQLite)
cr0x@server:~$ sqlite3 /var/lib/myapp/app.db "PRAGMA busy_timeout;"
0
Значення: SQLite відразу поверне SQLITE_BUSY, якщо не зможе отримати лок.
Рішення: Встановіть розумний busy timeout (часто 2000–10000 ms) і виправте обсяг транзакцій. Сам по собі таймаут — не стратегія.
Завдання 3: Перевірте поріг авточекпоінту WAL
cr0x@server:~$ sqlite3 /var/lib/myapp/app.db "PRAGMA wal_autocheckpoint;"
1000
Значення: SQLite спробує провести чекпоінт після ~1000 сторінок у WAL (залежить від розміру сторінки).
Рішення: Якщо ви бачите ріст WAL і зупинки чекпоінтингу, налаштуйте це і додайте контрольований чекпоінт під час низької навантаженості.
Завдання 4: Перевірте, чи росте WAL-файл (симптом, що чекпоінт не встигає)
cr0x@server:~$ ls -lh /var/lib/myapp/app.db*
-rw-r----- 1 myapp myapp 64M Dec 30 11:40 /var/lib/myapp/app.db
-rw-r----- 1 myapp myapp 512M Dec 30 11:41 /var/lib/myapp/app.db-wal
-rw-r----- 1 myapp myapp 32K Dec 30 11:41 /var/lib/myapp/app.db-shm
Значення: WAL великий відносно основного файлу DB; чекпоінтинг, ймовірно, не завершується.
Рішення: Шукайте довгоживучих читачів і розгляньте ручний чекпоінт, скорочення читальних транзакцій або переміщення важких аналітичних читань кудись інше.
Завдання 5: Знайдіть процеси, що тримають файли бази відкритими
cr0x@server:~$ sudo lsof /var/lib/myapp/app.db | head
COMMAND PID USER FD TYPE DEVICE SIZE/OFF NODE NAME
myapp 2134 myapp 12u REG 253,0 67108864 91234 /var/lib/myapp/app.db
myapp 2134 myapp 13u REG 253,0 536870912 91235 /var/lib/myapp/app.db-wal
worker 2201 myapp 10u REG 253,0 67108864 91234 /var/lib/myapp/app.db
Значення: Два процеси мають DB відкриту; це нормально, але тепер ви знаєте, кого «звинувачувати», коли виникають локи.
Рішення: Якщо бачите несподівані процеси (скрипти бекапу, cron, «швидкий звіт»), зупиніть їх або направте на репліку/експорт.
Завдання 6: Підтвердіть тип файлової системи (SQLite на NFS — постійна трагедія)
cr0x@server:~$ findmnt -no FSTYPE,TARGET /var/lib/myapp
nfs4 /var/lib/myapp
Значення: Ваша SQLite DB живе на NFS. Семантика локів може бути ненадійною або повільною; помилки busy можуть бути випадковими.
Рішення: Перемістіть DB на локальне сховище або переведіть навантаження на MariaDB/Postgres. SQLite на NFS — «працює, поки не перестане».
Завдання 7: Виміряйте, чи «busy» корелює з повільними записами на диск
cr0x@server:~$ iostat -x 1 3
Linux 6.8.0 (server) 12/30/2025 _x86_64_ (8 CPU)
avg-cpu: %user %nice %system %iowait %steal %idle
3.10 0.00 1.20 22.50 0.00 73.20
Device r/s w/s rkB/s wkB/s await svctm %util
nvme0n1 5.0 420.0 200.0 5200.0 48.2 1.1 92.0
Значення: Високе %iowait, високе await, велике %util. Записувачі можуть тримати локи довше через повільний fsync.
Рішення: Спершу виправте зберігання (насиченість диска, шумний сусід, обмеження), перш ніж переписувати логіку транзакцій. Локи не звільняються, поки записи не завершаться.
Завдання 8: Перевірте опції компіляції SQLite (іноді важливо для вбудованих дистрибутивів)
cr0x@server:~$ sqlite3 -cmd ".compile_options" ":memory:" | head
COMPILER=gcc-13.2.0
ENABLE_FTS5
ENABLE_RTREE
THREADSAFE=1
USE_URI
Значення: Увімкнено безпеку потоків; добре. У рідкісних випадках нестандартні збірки можуть змінювати поведінку локів.
Рішення: Якщо ви на дивній вбудованій збірці, уніфікуйте пакет SQLite між середовищами, щоб зменшити «відбувається лише в проді» загадки.
Завдання для MariaDB (локи InnoDB і очікування)
Завдання 9: Подивіться, хто виконується і хто чекає
cr0x@server:~$ mysql -uroot -p -e "SHOW FULL PROCESSLIST\G" | sed -n '1,60p'
*************************** 1. row ***************************
Id: 12431
User: app
Host: 10.0.2.15:53320
db: mydb
Command: Query
Time: 28
State: Waiting for row lock
Info: UPDATE accounts SET balance=balance-10 WHERE id=42
*************************** 2. row ***************************
Id: 12405
User: app
Host: 10.0.2.14:53112
db: mydb
Command: Query
Time: 61
State: Updating
Info: UPDATE accounts SET balance=balance+10 WHERE id=42
Значення: Одна сесія заблокована в очікуванні локу на рядку, поки інша активно оновлює той самий рядок.
Рішення: Знайдіть блокувальника і скоротіть ту транзакцію; також подумайте про дизайн для гарячих рядків (шардинг лічильників, батчування або перенесення в атомарні примітиви).
Завдання 10: Перевірте стан двигуна InnoDB на предмет очікувань локів і дедлоків
cr0x@server:~$ mysql -uroot -p -e "SHOW ENGINE INNODB STATUS\G" | sed -n '1,120p'
------------------------
LATEST DETECTED DEADLOCK
------------------------
*** (1) TRANSACTION:
TRANSACTION 987654, ACTIVE 3 sec starting index read
...
*** (2) TRANSACTION:
TRANSACTION 987655, ACTIVE 3 sec updating or deleting
...
WE ROLL BACK TRANSACTION (1)
------------
TRANSACTIONS
------------
Trx id counter 987700
History list length 1240
Значення: Сталася дедлок; InnoDB обрав жертву. Також зверніть увагу на history list length (purge lag), що може вказувати на довгі транзакції.
Рішення: Переконайтеся, що застосунок повторює транзакції після дедлоків із backoff; також зменшіть розмір транзакцій і забезпечте послідовний порядок блокувань у коді.
Завдання 11: Перевірте innodb_lock_wait_timeout і чи відповідає він реальності
cr0x@server:~$ mysql -uroot -p -e "SHOW VARIABLES LIKE 'innodb_lock_wait_timeout';"
+--------------------------+-------+
| Variable_name | Value |
+--------------------------+-------+
| innodb_lock_wait_timeout | 50 |
+--------------------------+-------+
Значення: Сесії можуть чекати до 50 секунд перед відмовою очікування локу.
Рішення: Якщо таймаути вище по стеку 5–10 с, 50-секундне очікування просто займатиме потоки. Зменшіть його, щоб погодити з бюджетом латентності, але тільки після виявлення шаблону блокувань.
Завдання 12: Перевірте рівень ізоляції транзакцій (gap-locks дивують людей)
cr0x@server:~$ mysql -uroot -p -e "SHOW VARIABLES LIKE 'transaction_isolation';"
+-----------------------+-----------------+
| Variable_name | Value |
+-----------------------+-----------------+
| transaction_isolation | REPEATABLE-READ |
+-----------------------+-----------------+
Значення: Рівень ізоляції за замовчуванням — repeatable read, що може використовувати next-key локи і блокувати вставки в діапазони.
Рішення: Якщо ви бачите контенцію вставок у діапазони і можете терпіти наслідки, розгляньте READ-COMMITTED для OLTP-навантажень — після тестування на коректність.
Завдання 13: Ідентифікуйте відсутні індекси, що спричиняють широке блокування
cr0x@server:~$ mysql -uroot -p -e "EXPLAIN UPDATE orders SET status='paid' WHERE customer_id=123 AND status='pending'\G"
*************************** 1. row ***************************
id: 1
select_type: UPDATE
table: orders
partitions: NULL
type: ALL
possible_keys: NULL
key: NULL
key_len: NULL
ref: NULL
rows: 842113
filtered: 10.00
Extra: Using where
Значення: Повний скан таблиці (type: ALL) при оновленні. Це може блокувати набагато більше, ніж ви думаєте, і займати вічність.
Рішення: Додайте композитний індекс (наприклад, (customer_id, status)) і перевірте план ще раз. Це одне з найвищих за ROI виправлень блокувань.
Завдання 14: Моніторьте метрики InnoDB для очікувань локів (швидкий сигнал)
cr0x@server:~$ mysql -uroot -p -e "SHOW GLOBAL STATUS LIKE 'Innodb_row_lock%';"
+-------------------------------+----------+
| Variable_name | Value |
+-------------------------------+----------+
| Innodb_row_lock_current_waits | 12 |
| Innodb_row_lock_time | 184223 |
| Innodb_row_lock_time_avg | 15351 |
| Innodb_row_lock_time_max | 60000 |
| Innodb_row_lock_waits | 48 |
+-------------------------------+----------+
Значення: Очікування локів відбуваються зараз (current_waits) і були дорогими в середньому.
Рішення: Перейдіть до аналізу запитів/транзакцій. Якщо current_waits залишається підвищеним під час інцидентів, ви маєте справу з реальною контенцією, а не випадковістю.
Завдання 15: Перевірте, чи сервер не насичений потоками через очікування
cr0x@server:~$ mysql -uroot -p -e "SHOW GLOBAL STATUS LIKE 'Threads_running'; SHOW GLOBAL STATUS LIKE 'Max_used_connections';"
+-----------------+-------+
| Variable_name | Value |
+-----------------+-------+
| Threads_running | 187 |
+-----------------+-------+
+----------------------+-------+
| Variable_name | Value |
+----------------------+-------+
| Max_used_connections | 498 |
+----------------------+-------+
Значення: Багато потоків виконуються (часто «running» включає очікування). Підключення були інтенсивно задіяні.
Рішення: Якщо застосунок агресивно повторює запити, негайно обмежте швидкість повторів і додайте джитер. Потім знайдіть і виправте блокуючий запит.
Завдання 16: Підтвердження на рівні ОС: чи ми завантажені CPU або чекаємо?
cr0x@server:~$ top -b -n 1 | sed -n '1,12p'
top - 11:44:12 up 17 days, 3:28, 1 user, load average: 22.15, 20.97, 18.44
Tasks: 312 total, 5 running, 307 sleeping, 0 stopped, 0 zombie
%Cpu(s): 4.3 us, 1.1 sy, 0.0 ni, 72.8 id, 21.7 wa, 0.0 hi, 0.1 si, 0.0 st
MiB Mem : 32112.0 total, 1120.3 free, 10234.8 used, 20756.9 buff/cache
MiB Swap: 2048.0 total, 1980.0 free, 68.0 used. 18940.2 avail Mem
Значення: Високе IO wait (wa). Це часто подовжує час утримання локів, бо коміти чекають на fsync.
Рішення: Дослідіть затримки зберігання. Налаштування локів не виправить повільні надійні записи.
Три міні-історії з корпоративного життя (анонімізовано, болісно правдоподібно)
Інцидент через неправильне припущення: «SQLite впорається; ми лише трохи пишемо»
У середній компанії команда випустила внутрішній сервіс «ops notebook». Він зберігав нотатки інцидентів, руни та невеликий аудит-лог. Хтось обрав SQLite, бо це один бінарник, без додаткової інфраструктури, і записи були «дрібні». Він працював на ВМ з загальним файловим монтуванням, щоб два інстанси застосунку могли доступатися до того самого файлу БД.
Неправильне припущення було не в тому, що «SQLite не витримує записів». SQLite витримує багато записів. Неправильне припущення — що файлова система і модель процесів не мають значення. При низькому навантаженні сервіс був чудовим. Під час навантажень інциденту — коли кілька інженерів редагували нотатки і аудит-лог зростав — запити почали падати з database is locked. Звісно, це сталося під час інциденту, коли інструмент був найбільш потрібен.
Перший фікс був класичний: додати повтори. Це перетворило «декілька запитів падають швидко» в «запити зависають, потім падають». Пул підключень наповнився. Латентність стала нелінійною. Інженери почали копіпастити нотатки в чат, бо нотатник таймаутився. Продуктивність впала. Постмортем містив фразу «інструмент став єдиною точкою відмови», що є ввічливою формою «ми збудували пастку».
Реальний фікс був нудний, але правильний: перемістити файл БД на локальний диск і запускати один екземпляр записувача (або перейти на MariaDB). Також змінили аудит-лог на батчовані записи і скоротили транзакції в коді. Помилки блокувань впали до майже нуля і, що важливіше, залишкові помилки стали керованими сигналами замість фонового шуму.
Оптимізація, що обернулася проти: WAL скрізь, назавжди, з вічним читачем
Інша компанія мала локальну SQLite в edge-агенті. Вони ввімкнули WAL по всьому флоту, щоб покращити конкурентність: агент читав конфіг і одночасно писав метрики. Початкові бенчмарки виглядали краще. Всі були задоволені. Потім за кілька місяців польові пристрої почали дивно закінчувати диск.
Проблема була не в основному файлі DB. Це був WAL-файл. Він продовжував рости, бо чекпоінти не могли завершитися надійно. У агента був «watcher»-потік, що тримав відкриту читальну транзакцію, поки стрімив зміни в інший компонент. Ця читальна транзакція перешкоджала переробці старих WAL-фреймів. WAL став повільною «витоком диска».
Операція помітила, що пристрої стали повільними. Сигнали по диску спрацювали. Деякі пристрої досягли 100% дискового простору і почали відмовляти в інших підсистемах. Дебаг був болісним, бо агент був «нормальний», поки не став ненормальним. Коли межа була перетнута, все впало одночасно.
Фікс: переробити watcher, щоб не тримати транзакцію відкритою, і явно проводити чекпоінти у часи спокою. Вони також ввели політику ліміту росту WAL і алерти на співвідношення WAL до DB. WAL не був ворогом. Безмежний WAL плюс вічний читач — ось ворог.
Нудна, але правильна практика, що врятувала день: послідовні межі транзакцій і backoff
Команда, що працює поруч із платежами, використовувала MariaDB. Вони мали періодичні спайки блокувань, але інциденти були рідкісні. Причина не в щасті. У них був внутрішній стандарт: у кожному шляху запису був максимум обсягу транзакції, жодних мережевих викликів усередині транзакції, і всі помилки, що можна повторити, використовували експоненційний backoff з джитером.
Одного дня батчова reconciliation job була задеплойена з випадковим cross-join оновленням. Вона почала блокувати більше рядків, ніж мала б. У багатьох організаціях це перетворюється на гуркітний натовп: повтори клієнтів б’ють по БД, БД стає недоступною, і інцидент розростається.
Тут робота все одно спричинила біль, але радіус ураження був обмежений. Застосунок відступив замість того, щоб підсилювати навантаження. Інші сервіси деградували плавно. Інженери встигли знайти запит, вбити його і задеплоїти фікс. Пункти дій у постмортемі були простими: додати запобіжник, покращити рев’ю запитів для батчів.
Це неприваблива правда надійності: практики, що здаються повільними в розробці, саме й рятують продакшен від перетворення на перформанс-арт.
Як запобігти помилкам busy (SQLite) і накопиченню блокувань (MariaDB)
Обирайте правильний рушій для топології записів
Якщо у вас один процес, локальний диск і помірна конкуренція — SQLite може бути відмінним. Якщо у вас кілька інстансів застосунку, багато хостів і «записи звідусіль» — SQLite стає проблемою координації, яку ви не хотіли брати.
- SQLite — чудовий вибір для вбудованих, одновузлових, локально-дискових навантажень; систем з переважним читанням; кешів; надійних черг з контрольованими записувачами.
- MariaDB — чудовий вибір коли потрібні реальні паралельні записувачі, віддалений доступ, операційні інструменти і передбачувана поведінка при багатьох клієнтах.
SQLite: конкретні тактики, що дійсно працюють
- Увімкніть WAL для паралельного читання/запису (зазвичай). Потім моніторте ріст WAL і поведінку чекпоінтів.
- Встановіть
busy_timeoutі/або busy handler, щоб коротка контенція не ставала помилкою. Погодьте його з бюджетом запиту (не ставте 60 секунд і вважайте це нормою). - Тримайте транзакції короткими. Не тримайте транзакцію відкритою під час мережевих викликів, парсингу JSON або очікування користувача.
- Пакетуйте записи, але не створюйте «мега-транзакцій». Батчируйте у невеликі коміти (наприклад, сотні чи тисячі), а не в один нескінченний коміт.
- Використовуйте патерн одного записувача, якщо можете. Виділений процес/потік писання, що серіалізує записи, може усунути контенцію і спростити повторні спроби.
- Уникайте розміщення файлів SQLite на мережевих файлових системах. Якщо вимушені — тестуйте поведінку локів під навантаженням. Більшість команд дізнаються істину занадто пізно.
- Будьте явними щодо чекпоінтингу для довготривалих сервісів з постійними читачами. Розгляньте періодичний
PRAGMA wal_checkpoint(TRUNCATE);у періоди низького трафіку, але тестуйте уважно.
MariaDB/InnoDB: конкретні тактики, що дійсно працюють
- Спершу виправте індекси. Більшість «проблем з блокуваннями» — це насправді «ми скануємо занадто багато і тому блокуємо занадто багато».
- Зменшіть обсяг транзакцій. Комітіть раніше. Розбийте великі оновлення. Уникайте патернів «read-modify-write» на гарячих рядках.
- Проектуйте навколо гарячих рядків. Лічильники і поля «last seen» можуть писатися багатьма потоками. Використовуйте шардинг лічильників, append-only таблиці або періодичну агрегацію.
- Зробіть поведінку повторів цивільною. Дедлоки трапляються. Ваш застосунок має повторювати з експоненційним backoff і джитером, і обмеженою кількістю спроб.
- Згодуйте таймаути з верхнім шаром. Якщо ваш API таймаутиться за 8 секунд, дозволяти DB сесіям чекати 50 секунд — це просто накопичення ресурсів.
- Будьте обережні зі зміною ізоляції. Перехід на
READ-COMMITTEDможе знизити контенцію, але перевірте коректність (фантоми, повторні читання) для вашого навантаження.
Типові помилки: симптом → корінь проблеми → виправлення
1) Спайки «database is locked» в SQLite після додавання фонового завдання
Симптоми: Помилки busy корелюють з виконанням cron/worker; логи застосунку показують відмови навіть при низькому трафіку.
Корінь проблеми: Задача відкриває транзакцію і виконує багато записів (або тримає її відкритою під час іншої роботи), блокуючи інших записувачів.
Виправлення: Комітьте меншими порціями; перемістіть роботу в чергу з одним записувачем; додайте busy_timeout; переконайтеся, що задача не запускається паралельно сама з собою.
2) WAL-файл росте безмежно
Симптоми: app.db-wal продовжує зростати; використання диска зростає; періодичні зупинки під час спроб чекпоінту.
Корінь проблеми: Довгоживуча читальна транзакція перешкоджає чекпоінтингу і звільненню рамок WAL.
Виправлення: Уникайте довгих читальних транзакцій; змініть читача на менші сніпшоти; плануйте чекпоінти; перевірте, що код не тримає курсор відкритим через довгі операції.
3) «busy» в SQLite переважно на певних хостах
Симптоми: Те саме навантаження, але різна поведінка локів залежно від хоста або контейнера.
Корінь проблеми: Файл БД на іншій файловій системі (особливості overlayfs, NFS, мережевий блоковий пристрій) або латентність зберігання, що подовжує час утримання локів.
Виправлення: Уніфікуйте місце зберігання та файлову систему; перемістіть на локальний SSD; виміряйте fsync-латентність; якщо потрібен доступ з кількох хостів — переходьте на MariaDB.
4) Таймаути очікування локів у MariaDB при «малих» оновленнях
Симптоми: Lock wait timeout exceeded при оновленні одного рядка; періодично, але повторювано.
Корінь проблеми: Контенція на гарячому рядку (багато сесій оновлюють той самий рядок) або транзакція, що торкається рядка, а потім тримає лок під час іншої роботи.
Виправлення: Перепроектуйте запис на гарячі рядки (шардуйте, використовуйте append-only, кеш плюс агрегація); скоротіть тривалість транзакцій; уникайте зовнішніх викликів усередині транзакцій.
5) MariaDB раптово «повсюдно повільна» після зміни індексу
Симптоми: Зросли очікування локів; лаг реплікації; CPU не завантажений, але латентність висока.
Корінь проблеми: Регрес плану запиту, що спричинив ширші скани і більше блокувань, або онлайн DDL, що викликає метаданну контенцію в зайнятій схемі.
Виправлення: Перевіряйте плани з EXPLAIN до і після; впроваджуйте зміни індексів поетапно; переконайтеся, що запити використовують очікувані індекси; обмежуйте зміни схеми.
6) Повторні спроби погіршують ситуацію
Симптоми: Після додавання повторів інциденти тривають довше; БД бачить вищий QPS під час подій локів.
Корінь проблеми: Миттєві повтори створюють натовп. Кожен клієнт повторює одночасно, подовжуючи вікно контенції.
Виправлення: Експоненційний backoff + джитер; обмеження спроб; швидкий фейл для неідемпотентних операцій, якщо вони не спроектовані відповідно.
Контрольні списки / покроковий план
План A: Ви на SQLite і хочете менше «busy» цього тижня
- Підтвердьте, що зберігання локальне і стабільне. Якщо це NFS/віддалене/overlay-сторінки, пріоритетно перемістити. Busy на таких сховищах — стиль життя.
- Увімкніть WAL (якщо доречно). Підтвердіть через
PRAGMA journal_mode;. - Встановіть розумний busy timeout. Почніть з 2000–5000 ms для інтерактивних шляхів; підлаштуйте під бюджет запитів.
- Аудит меж транзакцій. Переконайтеся, що жодна транзакція не охоплює мережеві виклики, паузи або «обробні цикли».
- Ідентифікуйте своїх записувачів. Перелічіть процеси через
lsof; переконайтеся, що немає несподіваних записувачів. - Стежте за співвідношенням WAL до DB. Налаштуйте алерт, якщо WAL перевищує допустиме відношення для вашого дискового бюджету.
- Впровадьте патерн одного записувача для інтенсивних шляхів запису. Черги всередині процесу або IPC; серіалізація комітів.
- Додайте обмежені повтори з джитером. Лише для операцій, які можна повторити; логайте кількість і тривалість повторів, щоб вони не ховали проблему.
План B: Ви на MariaDB і очікування локів вбивають p95
- Захопіть блокуючий запит. Processlist + InnoDB status під час інциденту.
- Перевірте відсутні індекси на гарячих шляху оновлень через
EXPLAIN. - Зменшіть тривалість транзакцій. Перевірте, що код не тримає транзакцій відкритими під час не-БД роботи.
- Погодьте таймаути. Встановіть
innodb_lock_wait_timeoutтак, щоб він відповідав бюджетам латентності і не накопичував потоки. - Виправте повтори. Переконайтеся, що дедлоки/таймаути повторюються з експоненційним backoff і джитером. Обмежте кількість спроб.
- Відводьте гарячі рядки від централі. Якщо один рядок — ресурс конфлікту, масштабування його не вирішить. Змініть модель даних.
- Перевірте затримки зберігання. Якщо fsync повільний, локи тримаються довше; ви будете звинувачувати менеджер локів за проблему з диском.
Контрольний список рішення: коли переходити з SQLite на MariaDB
- Вам потрібно кілька інстансів застосунку з одночасними записами і ви не можете/не хочете форвардити записи через single-writer.
- Потрібен віддалений доступ з багатьох хостів до однієї БД.
- Потрібні операційні можливості: тонкі привілеї, вбудована реплікація, online schema change, зріла телеметрія локів.
- Ви зараз «вирішуєте» помилки busy більшими таймаутами і більшою кількістю повторів.
Питання та відповіді
1) Чи завжди «database is locked» у SQLite — це баг?
Зазвичай це невідповідність дизайну або помилка в обсязі транзакції. Іноді це легка короткочасна контенція, що потребує busy timeout. Якщо це часто — розглядайте як системну проблему проектування.
2) Чи варто перемикати WAL за замовчуванням?
Для більшості локально-дискових навантажень з паралельними читаннями/записами: так. Якщо ви на файловій системі з сумнівною семантикою локів (поширено для мережевого сховища), тестуйте уважно або уникайте WAL.
3) Якщо я встановлю busy_timeout, чи все закінчилося?
Ні. Ви просто вирішили чекати замість того, щоб відразу падати. Якщо writer тримає лок 5 секунд, а ваш timeout 2 секунди — ви все одно отримаєте помилку. Якщо встановите 60 секунд — застосунок просто висітиме довше.
4) Чому WAL іноді робить все гірше?
WAL покращує конкурентність читачів і записувачів, але вводить поведінку чекпоінту. Довгоживучі читачі можуть блокувати чекпоінтинг, дозволяючи WAL розростатися і іноді спричиняючи зупинки.
5) Чи може SQLite обробляти кілька записувачів?
Кілька підключень можуть намагатися писати, але лише один може комітити одночасно. При постійній конкуренції записів ви отримаєте чергування і помилки busy, якщо не координувати записувачів.
6) Чому в MariaDB бачимо блокування вставок, коли ніхто не торкається тих самих рядків?
Gap/next-key локи при REPEATABLE-READ можуть блокувати діапазони, а не тільки окремі рядки. Відсутні індекси також можуть розширити скани і блокувати діапазони, яких ви не очікували.
7) Чи варто зменшити innodb_lock_wait_timeout, щоб уникнути накопичень?
Часто так — після підтвердження робочого навантаження. Низький таймаут запобігає накопиченню потоків і змушує швидше отримувати відмову, що може захистити сервер. Але якщо ви зменшите його без виправлення блокувальника, ви обміняєте латентність на помилки.
8) Яка правильна стратегія повторів для таймаутів локів і дедлоків?
Повторюйте дедлоки і таймаути локів з експоненційним backoff і джитером, з обмеженою кількістю спроб і з урахуванням ідемпотентності. Ніколи не повторюйте одразу в щільному циклі — це перетворить невелику подію на відмову.
9) Чи можна запускати SQLite в Kubernetes із загальним томом?
Можна, але «можна» ≠ «треба». Якщо спільний том мережевий, локи й латентність можуть бути непередбачуваними. Якщо потрібні multi-pod writers — використовуйте MariaDB або інший клієнт-серверний рушій, або забезпечте архітектуру з одним записувачем.
10) Як зрозуміти, чи ми заблоковані локами або I/O?
Шукайте високе IO wait (top, iostat) і довгі commit-часи проти багатьох сесій, що застрягли в стані очікування локів (processlist, InnoDB status). Часто це комбіновано: повільний I/O подовжує час утримання локів, що збільшує контенцію.
Висновок: наступні кроки, що реально знижують тривожні виклики
Якщо запам’ятаєте одне: помилки блокувань зазвичай говорять правду про спільні ресурси. SQLite каже швидко і прямо. MariaDB дає вам ілюзію, що все гаразд довше.
Зробіть наступне:
- Пройдитесь швидким планом діагностики і вирішіть, чи ви заблоковані локами, чи I/O.
- Знайдіть найдовшу транзакцію і зробіть її коротшою. Це вирішує більше інцидентів, ніж будь-який регулятор.
- Зупиніть шторм повторів за допомогою backoff + jitter і жорсткого ліміту.
- Для SQLite: WAL + розумний
busy_timeout+ контрольовані записувачі + увага до чекпоінтів. - Для MariaDB: правильні індекси, межі транзакцій, переробка гарячих рядків і таймаути, погоджені з реальними бюджетами латентності.
Потім виберіть базу даних, яка відповідає реаліям вашої конкуренції, а не організаційній схемі. Ваш майбутній «я» оцінить можливість нормально спати ночами.