Блокування в MariaDB і SQLite: як уникнути помилок «busy»

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

Ви випускаєте цілком розумну фічу. Вона працює в стейджингу. У продакшені ви отримуєте помилку, яка звучить так, ніби база даних надто ввічлива, щоб сказати те, що думає: 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 їх виявляє і відкочує одного учасника. Проблема виникає, коли ваш застосунок сприймає дедлок як «цього не повинно траплятися» і відповідає «паніка, повторити одразу, знову і знову».

Парафразована ідея (не цитата): Вернер Вогельс неодноразово наголошував, що ви повинні проектувати на відмови і будувати системи, які припускають, що компоненти можуть поводитися неналежним чином. Локи — один із таких компонентів.

Цікаві факти та коротка історія для наради

  1. SQLite старший за багато «сучасних» веб-стеків. Розробка почалася у 2000 році як самостійний SQL-рушій для вбудованого використання.
  2. Дизайн SQLite «без сервера» — буквальний. Немає демон-процесу. Кожен процес — свій клієнт і частковий «сервер», що конкурує за файлові локи.
  3. Режим WAL було введено, щоб покращити конкурентність шляхом відділення читань від записів через додатковий лог, але він вводить динаміку чекпоінтингу, якою потрібно керувати.
  4. InnoDB спочатку не був «за замовчуванням» в MySQL. Він став де-факто стандартом, бо приніс транзакції, відновлення після краху і блокування на рівні рядка, чого MyISAM не мав.
  5. MariaDB — це форк, створений після того, як Oracle придбав Sun, здебільшого щоб зберегти розвиток MySQL відкритим і керованим спільнотою.
  6. «database is locked» у SQLite часто — помилка застосунку, наприклад, відкриття транзакції через мережеві виклики або витік підключення в пулі.
  7. Gap-локи і next-key локи InnoDB існують, щоб запобігти фантомам при певних рівнях ізоляції; вони можуть здивувати інженерів, які думають, що блокуються лише «рядки, до яких торкався».
  8. SQLite використовує POSIX/Win32 механізми локування, тобто NFS та інші мережеві файлові системи можуть перетворити локування з детермінованого в «цікаве».
  9. SQLite використовується більше, ніж багато хто думає: браузери, мобільні ОС і незліченні десктопні додатки — через малий операційний слід і високу надійність при правильному використанні.

Жарт №1: SQLite — як однополосний міст: безпечно, просто, але лише одна вантажівка за раз.

Що насправді означає «busy» (і чого не означає)

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

Три звичайні винуватці

  1. Довгоживуча транзакція на запис. Фонова задача починає транзакцію, пише купу рядків і тягне її. Усі інші отримують «busy».
  2. Тиск на WAL-чекапоінтинг. WAL-файл росте. Зрештою чекпоінтинг потребує співпраці читачів. Довгий читач може перешкоджати просуванню чекпоінта.
  3. Кілька процесів на файловій системі, що «брешуть» про локи. Мережеві файлові системи і драйвери томів у контейнерах можуть перетворити коректне використання 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» цього тижня

  1. Підтвердьте, що зберігання локальне і стабільне. Якщо це NFS/віддалене/overlay-сторінки, пріоритетно перемістити. Busy на таких сховищах — стиль життя.
  2. Увімкніть WAL (якщо доречно). Підтвердіть через PRAGMA journal_mode;.
  3. Встановіть розумний busy timeout. Почніть з 2000–5000 ms для інтерактивних шляхів; підлаштуйте під бюджет запитів.
  4. Аудит меж транзакцій. Переконайтеся, що жодна транзакція не охоплює мережеві виклики, паузи або «обробні цикли».
  5. Ідентифікуйте своїх записувачів. Перелічіть процеси через lsof; переконайтеся, що немає несподіваних записувачів.
  6. Стежте за співвідношенням WAL до DB. Налаштуйте алерт, якщо WAL перевищує допустиме відношення для вашого дискового бюджету.
  7. Впровадьте патерн одного записувача для інтенсивних шляхів запису. Черги всередині процесу або IPC; серіалізація комітів.
  8. Додайте обмежені повтори з джитером. Лише для операцій, які можна повторити; логайте кількість і тривалість повторів, щоб вони не ховали проблему.

План B: Ви на MariaDB і очікування локів вбивають p95

  1. Захопіть блокуючий запит. Processlist + InnoDB status під час інциденту.
  2. Перевірте відсутні індекси на гарячих шляху оновлень через EXPLAIN.
  3. Зменшіть тривалість транзакцій. Перевірте, що код не тримає транзакцій відкритими під час не-БД роботи.
  4. Погодьте таймаути. Встановіть innodb_lock_wait_timeout так, щоб він відповідав бюджетам латентності і не накопичував потоки.
  5. Виправте повтори. Переконайтеся, що дедлоки/таймаути повторюються з експоненційним backoff і джитером. Обмежте кількість спроб.
  6. Відводьте гарячі рядки від централі. Якщо один рядок — ресурс конфлікту, масштабування його не вирішить. Змініть модель даних.
  7. Перевірте затримки зберігання. Якщо 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 дає вам ілюзію, що все гаразд довше.

Зробіть наступне:

  1. Пройдитесь швидким планом діагностики і вирішіть, чи ви заблоковані локами, чи I/O.
  2. Знайдіть найдовшу транзакцію і зробіть її коротшою. Це вирішує більше інцидентів, ніж будь-який регулятор.
  3. Зупиніть шторм повторів за допомогою backoff + jitter і жорсткого ліміту.
  4. Для SQLite: WAL + розумний busy_timeout + контрольовані записувачі + увага до чекпоінтів.
  5. Для MariaDB: правильні індекси, межі транзакцій, переробка гарячих рядків і таймаути, погоджені з реальними бюджетами латентності.

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

← Попередня
MariaDB vs MySQL: єдиний чекліст, що знаходить вузькі місця швидше за підлаштування параметрів
Наступна →
Несигновані драйвери: коли безпека зламала цілком справний апарат

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