Усе починається невинно: «Давайте просто зберігати лічильники в базі даних і відхиляти запити, коли користувачі перевищують ліміт.» Через два тижні ви дивитесь на зростаючі p95 затримки, «гарячі» рядки, очікування блокувань і канал інцидентів, забитий повідомленнями на кшталт «чому CPU бази даних на 95%, якщо трафік навіть не такий великий?»
Обмеження швидкості — це питання контрольної площини. Ваша база даних — робоча конячка для обробки даних. Коли ви змушуєте її ще й поліціювати трафік, ви просите її одночасно бігти в забігу й судити забіг — і продавати снеки на трибунах.
Теза: чому «лімітування в SQL» не витримує продакшну
Обмеження швидкості на боці бази даних привабливе, бо централізоване і транзакційне. Ви можете застосувати ліміти «точно», на користувача, на API-ключ, на орендаря тощо. І у вас вже є база даних. Тому ви додаєте таблицю на кшталт rate_limits, робите UPDATE або INSERT ... ON CONFLICT/ON DUPLICATE KEY, перевіряєте вікно по timestamp і відхиляєте, якщо ліміт перевищено.
У продакшні такий підхід зазвичай провалюється з кількох повторюваних причин:
- Ви створюєте точку серіалізації. Обмеження швидкості по суті про спільний стан. Спільний стан у транзакційній системі стає приводом для контенції. Контенція породжує хвостову латентність. Латентність породжує таймаути. Таймаути породжують повтори. Повтори — це незаплановане навантажувальне тестування.
- Ви перетворюєте «дешеві відхили» на «дорогі відхили». Хороший лімітатор відхиляє швидко, до того як дорогі системи виконають роботу. Лімітатор у БД просить дорогу систему (БД) виконати роботу, щоб вирішити, чи варто їй виконувати роботу.
- Ви підсилюєте сплески трафіку. Саме під час сплесків лімітування має значення. Під час сплесків контенція в БД найгірша. Лімітування всередині БД означає, що ви потрапляєте в найгірший сценарій саме тоді, коли потрібна найкраща поведінка.
- Ви зв’язуєте доступність додатка з доступністю БД. Якщо лімітатор залежить від звернень до БД, то деградація бази даних перетворюється на деградацію API, навіть для ендпоінтів, які могли б деградувати плавно.
- Ви отримуєте «коректні» результати ціною коректності в інших місцях. Ваші бізнес-запити конкурують з поліцейськими запитами. Коли лімітатор «гріється», він краде CPU, кеш буфера та I/O у бізнес-навантаження.
Ось сухо-жартівлива підсумкова думка: використовувати вашу базу даних для лімітування — це як використовувати пожежну сигналізацію для приготування вечері: технічно можливо, але буде погано.
Цікаві факти та історичний контекст (щоб не повторювати помилок)
- MVCC-модель PostgreSQL (з лінії проекту Postgres 1990-х) уникaє блокувань читання в багатьох навантаженнях, але блокування записів усе ще залишаються; «гарячі» оновлення можуть досі серіалізуватися на тому самому tuple/page.
- InnoDB (дефолтний движок у MariaDB/MySQL) побудований навколо кластеризованих індексів; «гарячий» первинний ключ означає, що ви б’єтеся по тих самих B-tree сторінках, що може призвести до contention latch-ів і флуктуацій у buffer pool.
- Алгоритми token bucket і leaky bucket з’явилися в літературі з мереж ще за десятиліття до більшості веб-API; вони були спроектовані для роутерів, а не для OLTP-баз.
- Широке запровадження pgbouncer виникло через практичну реальність: з’єднання PostgreSQL не безкоштовні, і «занадто багато клієнтів» — класичний самонанесений режим відмови.
- Рання репутація MySQL за швидкість частково пояснюється простішими дефолтними налаштуваннями і меншою кількістю захисних механізмів; люди потім заново вбудовували складність на рівні додатка. Цикл повторюється з «давайте лімітувати в SQL».
- Advisory locks у PostgreSQL потужні й легко зловживати ними; це примітив для синхронізації, а не інструмент формування трафіку.
- Великі системи популяризували «bulkheads» і «budgets» (окремі пули і ізоляція ресурсів по орендарю), оскільки ідеальна справедливість дорога; передбачувана сервісність — реальна мета.
- Шторми повторів не новина; розподілені системи пере-навчаються тому, що «повтори можуть бути шкідливі», ще з ери ранніх RPC.
MariaDB vs PostgreSQL: що змінюється, а що ні
Що однакове: контенція — це фізика
Чи ви запускаєте MariaDB, чи PostgreSQL, лімітатор, реалізований як «один рядок на суб’єкт, оновлювати при кожному запиті», створює гарячу точку. Базі даних потрібно координувати ці оновлення. Координація — це основна робота БД, але вона не безкоштовна — особливо при QPS, де лімітування має значення.
Що відрізняється: як проявляється біль
PostgreSQL зазвичай проявляє проблему як:
- Очікування блокувань на рівні рядка, коли кілька транзакцій оновлюють той самий рядок (видно через
pg_locks,pg_stat_activity). - Високий об’єм WAL через безперервні оновлення, навіть якщо «бізнес»-дані мало змінюються.
- Тиск autovacuum через швидке оновлення; часті оновлення створюють мертві tuples, які треба прибирати, а vacuum потребує I/O.
- В екстремальних випадках: CPU витрачається на LWLocks або контенцію навколо shared buffers/checkpoint-ів.
MariaDB (InnoDB) зазвичай проявляє проблему як:
- Очікування блокувань і дедлоки навколо одного і того ж первинного ключа або унікальних індексів (
SHOW ENGINE INNODB STATUSдопоможе і нашкодить одночасно). - Тиск у undo log / purge від постійних оновлень, що може перерости у зростання history list і погіршення продуктивності.
- Контенція buffer pool і «гарячі» сторінки індексу, коли той самий діапазон ключів піддається ударам.
- Затримка реплікації, якщо ви логируєте кожне оновлення лімітеру в binlog; ваші репліки стають «споживачами оновлень лімітерів» замість того, щоб обслуговувати читання.
Інші дашборди. Та сама корінна проблема: лімітатор тепер конкурує з вашим навантаженням за транзакційну пропускну здатність.
Як обмеження в БД ламається: блокування, гарячі рядки і невидима робота
1) Проблема «гарячого рядка» (т.зв. «один рядок, що керує усім»)
Більшість наївних схем використовують один рядок на користувача/ключ API. При вибухоподібному трафіку багато воркерів додатка оновлюють той самий рядок. Конкурентність вироджується в серіалізацію. Ваш «ліміт» стає «здатністю БД оновлювати цей рядок за секунду», що не те обмеження, яке ви мали на увазі. Гірше: ви не зможете підняти цей ліміт без збільшення пропускної здатності записів БД.
2) Інфляція хвостової латентності (тихий вбивця)
Лімітування часто виконується на кожному запиті. Тому навіть невелике збільшення латентності лімітеру множиться по всьому трафіку. Ви додаєте не просто 2 мс; ви додаєте 2 мс до всього, потім додаєте повтори, потім — черги. Отримуєте довгий, повільний простій, який виглядає як «усе стало трохи гірше», доки не стає раптово катастрофою.
3) WAL/binlog і vacuum/purge: «податок на невидиму роботу»
Кожне оновлення має наслідки:
- PostgreSQL пише WAL; реплікація споживає WAL; checkpoints скидають сторінки; autovacuum прибирає.
- InnoDB пише redo/undo; purge threads прибирають; реплікація відтворює binlog-події.
Ваш лімітатор тепер — двигун посилення записів. Система витрачає більше часу на підтримку журналу, ніж на бізнес.
4) Зв’язування відмов: коли БД хворіє, ваш API хворіє
Лімітатор має захищати вашу базу від перевантаження. Якщо він працює всередині БД, ви побудували ремінь безпеки, який працює лише коли машина не падає. При деградації БД лімітатор уповільнюється, що збільшує кількість одночасних запитів і ще більше погіршує стан БД. Це петля зворотного зв’язку з приємною для фінансів назвою: «незапланований простій».
5) «Але ми шардимо таблицю лімітерів» (оптимістична фаза)
Команди часто намагаються вирішити проблему «гарячого рядка», шардуючи лічильники по N рядків і сумуючи їх. Це зменшує контенцію на один рядок, але додає більше читань, більше складності і все ще багато навантаження на запис. До того ж: підрахунок лічильників при кожному запиті — чудовий спосіб перетворити лімітатор на query engine.
Друга сухо-жартівлива істина: шардований SQL-лімітер — це спосіб перетворити одну «гарячу» рядок на кілька трохи менш «гарячих» рядків, наче розподіляєш головний біль по всій голові.
Швидкий план діагностики: знайдіть вузьке місце за кілька хвилин
Коли підозрюєте, що «лімітування в БД» плавить систему, не починайте з переписування алгоритмів. Почніть із доведення, куди йде час.
По-перше: підтвердіть, чи лімітатор у критичному шляху
- Перевірте трейси додатка: чи є виклик до БД для кожного запиту навіть при відхиленні?
- Порівняйте латентності дозволених і відхилених запитів. Відхилення має бути дешевшим. Якщо воно дорожче — ви вже знайшли проблему.
По-друге: ідентифікуйте спільний ресурс, що контенціюється
- Очікування БД на блокування (row locks, transaction locks).
- Насичення пулу з’єднань (потоки чекають на з’єднання).
- Тиск write-ahead logging або binlog (стрибки об’єму WAL/бінлогів).
- Черги I/O (високий await, брудні буфери, повільні flush-и).
По-третє: визначте, чи є петля зворотного зв’язку
- Чи є повтори на 429/5xx, що збільшують навантаження?
- Чи таймаути додатка коротші за час відновлення БД після стрибків?
- Чи автоскейл додає більше клієнтів, погіршуючи контенцію?
По-четверте: оберіть найшвидшу безпечну міру
- Перемістіть обмеження на edge (API gateway / ingress) для найгарячіших ендпоінтів.
- Застосуйте обмеження з’єднань та черги поза БД (пулери, семафори, воркер-черги).
- Тимчасово відключіть або обійдіть лімітатор у БД, якщо він спричиняє системну відмову (за умови компенсаційного ліміту в іншому місці).
Парафразую ідею Вернера Фогельса (CTO Amazon): «Усе падає постійно — проєктуйте системи, що цього очікують і продовжують обслуговувати.»
Практичні завдання: команди, виводи і рішення
Ось перевірки, які я виконую, коли заходжу в систему, де хтось з гордістю каже «ми лімітуємо в SQL». Кожне завдання включає: команду, приклад виводу, що це означає і рішення, яке вона підказує.
Завдання 1: перевірте, чи БД обмежена CPU чи I/O (Linux)
cr0x@server:~$ mpstat -P ALL 1 3
Linux 6.5.0 (db01) 12/31/2025 _x86_64_ (16 CPU)
12:01:11 PM CPU %usr %nice %sys %iowait %irq %soft %steal %idle
12:01:12 PM all 72.10 0.00 17.22 2.10 0.00 0.88 0.00 7.70
12:01:13 PM all 76.55 0.00 16.40 1.90 0.00 0.70 0.00 4.45
12:01:14 PM all 74.80 0.00 18.00 2.40 0.00 0.60 0.00 4.20
Значення: Високий %usr+%sys, низький %iowait: тиск на CPU, ймовірно контенція або важке виконання запитів.
Рішення: Якщо лімітатор інтенсивно пише, зосередьтесь на контенції/WAL/binlog і «гарячих» оновленнях, а не спочатку на дисках.
Завдання 2: перевірте затримки диска і черги (Linux)
cr0x@server:~$ iostat -xz 1 3
Linux 6.5.0 (db01) 12/31/2025 _x86_64_ (16 CPU)
avg-cpu: %user %nice %system %iowait %steal %idle
71.4 0.0 17.9 2.1 0.0 8.6
Device r/s w/s rkB/s wkB/s aqu-sz await svctm %util
nvme0n1 12.0 980.0 210.0 18340.0 5.20 5.10 0.25 26.0
Значення: Записів багато, але await низький; немає насичення I/O. БД виконує багато дрібних записів (типово для оновлень лічильників).
Рішення: Не купуйте швидші диски, щоб виправити дизайн лімітеру. Виправте архітектуру.
Завдання 3: перевірте активні запити PostgreSQL і події очікування
cr0x@server:~$ psql -h db01 -U postgres -d app -c "select pid, usename, state, wait_event_type, wait_event, left(query,80) as q from pg_stat_activity where state<>'idle' order by now()-query_start desc limit 8;"
pid | usename | state | wait_event_type | wait_event | q
------+---------+--------+-----------------+---------------+-----------------------------------------------
8121 | app | active | Lock | tuple | update rate_limits set count=count+1 where key=
8177 | app | active | Lock | tuple | update rate_limits set count=count+1 where key=
8204 | app | active | Lock | tuple | update rate_limits set count=count+1 where key=
8290 | app | active | IO | DataFileRead | select * from orders where tenant_id=$1 order by
Значення: Кілька сесій чекають на tuple lock для однакового патерну оновлень. Це ваші рядки лімітеру, що працюють як м’ютекс.
Рішення: Припиніть робити оновлення на кожен запит в SQL. Перемістіть стан лімітеру з БД або пакетизуйте його.
Завдання 4: знайдіть топ-запити PostgreSQL за загальним часом (потребує pg_stat_statements)
cr0x@server:~$ psql -h db01 -U postgres -d app -c "select calls, round(total_exec_time::numeric,1) as total_ms, round(mean_exec_time::numeric,3) as mean_ms, left(query,70) as q from pg_stat_statements order by total_exec_time desc limit 5;"
calls | total_ms | mean_ms | q
-------+-----------+---------+----------------------------------------------------------------------
92000 | 580000.2 | 6.304 | update rate_limits set count=count+1, reset_at=$1 where key=$2
41000 | 210000.7 | 5.122 | insert into rate_limits(key,count,reset_at) values($1,$2,$3) on conflict
8000 | 90000.4 | 11.250 | select * from orders where tenant_id=$1 order by created_at desc limit 50
Значення: Ваші запити лімітеру домінують у загальному виконуваному часі. Вони не «дрібний оверхед». Це — навантаження.
Рішення: Розглядайте це як функціональний баг, а не просто «тонке налаштування продуктивності». Приберіть записи лімітеру з критичного шляху БД.
Завдання 5: перевірте швидкість генерації WAL у PostgreSQL
cr0x@server:~$ psql -h db01 -U postgres -d app -c "select now(), pg_size_pretty(pg_wal_lsn_diff(pg_current_wal_lsn(), '0/0')) as wal_since_start;"
now | wal_since_start
------------------------------+----------------
2025-12-31 12:03:10.123+00 | 148 GB
Значення: Великий об’єм WAL за час роботи свідчить про інтенсивні записи. Лічильники лімітеру — головні підозрювані.
Рішення: Якщо репліки відстають або архівування накопичується, негайно зменшіть об’єм записів (edge-ліміти, кеш, пакетні оновлення).
Завдання 6: перевірте тиск vacuum для таблиці лімітерів у PostgreSQL
cr0x@server:~$ psql -h db01 -U postgres -d app -c "select relname, n_live_tup, n_dead_tup, last_autovacuum from pg_stat_user_tables where relname='rate_limits';"
relname | n_live_tup | n_dead_tup | last_autovacuum
------------+------------+------------+---------------------------
rate_limits| 120000 | 9800000 | 2025-12-31 11:57:42+00
Значення: Кількість мертвих tuples значно перевищує живі: постійні оновлення. Vacuum працює понаднормово, щоб прибирати записи лімітеру.
Рішення: Припиніть оновлювати ці рядки на кожен запит. Налаштування vacuum — це пластир; рану має лікувати дизайн.
Завдання 7: перевірте deadlock-и і очікування блокувань в MariaDB/InnoDB
cr0x@server:~$ mariadb -h db01 -u root -p -e "SHOW ENGINE INNODB STATUS\G" | sed -n '1,120p'
...
LATEST DETECTED DEADLOCK
------------------------
*** (1) TRANSACTION:
TRANSACTION 34588921, ACTIVE 0 sec starting index read
mysql tables in use 1, locked 1
LOCK WAIT 5 lock struct(s), heap size 1136, 3 row lock(s)
MySQL thread id 7123, OS thread handle 140377, query id 991233 app 10.0.3.41 updating
UPDATE rate_limits SET count=count+1, reset_at=FROM_UNIXTIME(173564...) WHERE k='tenant:913'
*** (2) TRANSACTION:
TRANSACTION 34588922, ACTIVE 0 sec starting index read
...
Значення: Дедлоки у таблиці лімітерів. Ваш «механізм безпеки» став джерелом ролбеків і повторів.
Рішення: Видаліть лімітатор з InnoDB. Якщо потрібно тримати якийсь стан — зберігайте його в системі з низькою контенцією (Redis/memcache) з експірацією.
Завдання 8: перевірте топ-запити MariaDB (performance_schema увімкнено)
cr0x@server:~$ mariadb -h db01 -u root -p -e "SELECT DIGEST_TEXT, COUNT_STAR, ROUND(SUM_TIMER_WAIT/1e12,1) AS total_s FROM performance_schema.events_statements_summary_by_digest ORDER BY SUM_TIMER_WAIT DESC LIMIT 3;"
DIGEST_TEXT COUNT_STAR total_s
UPDATE `rate_limits` SET `count` = `count` + ? WHERE `k` = ? 210344 980.2
INSERT INTO `rate_limits` (`k`,`count`,`reset_at`) VALUES (...) 50321 220.5
SELECT * FROM `orders` WHERE `tenant_id` = ? ORDER BY ... 21000 190.0
Значення: Лімітатор на вершині чарту. БД витрачає більшість часу на поліціювання трафіку.
Рішення: Перенесіть виконання на ранню стадію запиту. Залиште БД для даних.
Завдання 9: перевірте насичення з’єднань (PostgreSQL)
cr0x@server:~$ psql -h db01 -U postgres -d app -c "select count(*) as total, sum(case when state='active' then 1 else 0 end) as active from pg_stat_activity;"
total | active
-------+--------
480 | 220
Значення: Багато з’єднань і багато активних. Якщо ви неправильно пулите, ви платите за кожне з’єднання і підвищуєте контенцію.
Рішення: Додайте/перевірте пулер (pgbouncer) і обмежуйте паралелізм на рівні ендпоінту/орендаря поза БД.
Завдання 10: перевірте стан пулу pgbouncer
cr0x@server:~$ psql -h pgb01 -U pgbouncer -d pgbouncer -c "show pools;"
database | user | cl_active | cl_waiting | sv_active | sv_idle | maxwait
---------+------+-----------+------------+----------+---------+---------
app | app | 180 | 45 | 40 | 0 | 12.3
Значення: Клієнти чекають; сервери вичерпані. Додаток генерує більше паралельної роботи з БД, ніж БД може спокійно обробити.
Рішення: Виконуйте обмеження паралелізму в аплікації (semaphores), перемістіть лімітування в шлюз і зменшіть раунди до БД на запит.
Завдання 11: перевірте ефективність NGINX-лімітування на edge
cr0x@server:~$ sudo nginx -T 2>/dev/null | grep -n "limit_req"
134: limit_req_zone $binary_remote_addr zone=perip:10m rate=20r/s;
201: limit_req zone=perip burst=40 nodelay;
Значення: Edge-лімітування налаштовано. Якщо БД все ще перевантажена, або ліміти занадто високі, або ключі неправильно задані, або трафік обходить обмеження.
Рішення: Перевірте ключі (по API-ключу/tenant, а не по IP при NAT), і додайте бюджети на маршрут.
Завдання 12: перевірте, чи додаток не повторює 429/помилки блокувань агресивно
cr0x@server:~$ rg -n "retry|backoff|429|Too Many Requests" /etc/myapp/config.yaml
118:retry:
119: max_attempts: 6
120: backoff: "fixed"
121: backoff_ms: 50
122: retry_on_status: [429, 500, 502, 503, 504]
Значення: Фіксований backoff 50ms з 6 спробами на 429 — генератор шторму повторів. 429 — сигнал сповільнитись, а не спробувати сервер знову на швидкість.
Рішення: Припиніть автоматично повторювати 429, або використовуйте експоненційний backoff з джиттером і суворим лімітом. Віддавайте перевагу згладженню на стороні клієнта.
Завдання 13: ідентифікуйте гарячі ключі в таблиці лімітерів (приклад PostgreSQL)
cr0x@server:~$ psql -h db01 -U postgres -d app -c "select key, count(*) as updates_last_min from rate_limit_audit where ts > now()-interval '1 minute' group by key order by updates_last_min desc limit 5;"
key | updates_last_min
--------------+------------------
tenant:913 | 18400
tenant:1442 | 10210
tenant:77 | 9800
Значення: Декілька орендарів домінують у обсязі оновлень. Тут важливіше ізоляція і справедливість, ніж ідеальна глобальна точність.
Рішення: Запровадьте бюджети на орендаря і bulkheads поза БД; розгляньте окремі воркер-пули або черги для різних рівнів.
Завдання 14: перевірте відставання реплікації (MariaDB репліка)
cr0x@server:~$ mariadb -h db-rep01 -u root -p -e "SHOW SLAVE STATUS\G" | egrep "Seconds_Behind_Master|Slave_IO_Running|Slave_SQL_Running"
Slave_IO_Running: Yes
Slave_SQL_Running: Yes
Seconds_Behind_Master: 187
Значення: Репліка відстає. Якщо оновлення лімітерів реплікуються, ви витрачаєте пропускну здатність реплікації на поліціювання, а не на бізнес-дані.
Рішення: Тримайте стан лімітеру поза реплікованою OLTP, або хоча б не реплікуйте його (окремий інстанс/схема з іншою надійністю), і вирішуйте на edge.
Три корпоративні міні-історії з передової
Міні-історія 1: інцидент через неправильне припущення
Команда SaaS хотіла пер-орендарні ліміти. Вимога продукту звучала як «жорсткий ліміт, точно, не більше ніж N запитів на хвилину». Інженери почули «транзакційно». Вони побудували таблицю PostgreSQL, індексову по tenant ID, оновлювали її при кожному запиті і використовували UPDATE ... RETURNING, щоб вирішити allow/deny. Це пройшло навантажувальні тести, бо тести використовували рівномірно розподілених орендарів і ввічливе нарощування.
Потім справжній клієнт запустив скрипт міграції, який навалив один орендар із високою конкуренцією. Таблиця лімітерів стала найчастіше оновлюваною таблицею в базі. Симптоми були класичні: p95 латентність піднялася, потім p99 обвалився. База даних не була «вимкнена», вона просто постійно чекала на блокування. Додаток тайм-аутнувся, повторив запити і подвоїв трафік. Інцидент-менеджер питав, чому лімітатор не захистив БД від перевантаження. Тиша, потім повільне усвідомлення: лімітатор і є перевантаженням.
Вони вирішили проблему, перенісши перший рівень лімітування в шлюз (по API-ключу) і використавши пер-орендарні черги для дорогих міграційних ендпоінтів. Вимогу «жорсткий точний за хвилину» переписали в щось, чим можна управляти: «наближені ліміти з обмеженим сплеском, пріоритезація критичних ендпоінтів.» Усі вижили. Постмортем ніхто не любив, але графіки були повчальними.
Міні-історія 2: оптимізація, що зіграла проти
Інша компанія працювала на MariaDB і мала таблицю лімітерів з одним рядком на API-ключ. Вони помітили очікування блокувань і вирішили «оптимізувати», зробивши рядок меншим, перетворивши ключ у ціле число і запхавши лічильники в меншу кількість байтів. Потім додали покриваючий індекс, бо «індекси роблять пошук швидким».
Що сталося: оновлення все одно повинно було змінити запис кластеризованого індексу (primary key) і вторинний індекс. Вони збільшили write amplification і зробили гарячу сторінку ще «гарячішою». CPU піднявся, а не впав. З’явилися дедлоки, бо патерн доступу змінився під навантаженням, а логіка повторів перетворювала кожен дедлок на невеликий сплеск.
Справжнє виправлення не полягало у зменшенні рядків. Воно полягало в зміні архітектури: використати розподілений in-memory лічильник з TTL для поліції, а MariaDB залишити для надійних бізнес-записів. Вони зберігали грубий «щоденний спожитий квотум» у MariaDB для аудиту і білінгу, оновлюючи асинхронно. Лімітатор перестав боротися з чек-аут потоком, що загалом вважається хорошими манерами.
Міні-історія 3: нудна, але правильна практика, що врятувала день
Ще одна команда вже обпеклася на «хитромудрих control plane у БД». Вони працювали на PostgreSQL з pgbouncer і мали просте правило: база даних не робить admission control запитів. Прийом відбувається перед БД і використовує бюджети: обмеження паралелізму на маршруті, справедливість по орендарях і глобальний захист від перевантаження на шлюзі.
Вони все ще відслідковували використання для білінгу, але робили це асинхронно. Запити емитували події в чергу; воркер агрегував їх кожну хвилину і записував підсумковий рядок на орендар. БД бачила один запис на орендар за хвилину, а не один запис на запит. Коли трафік зріс, глибина черги росла. Це було прийнятно; ніхто не вставав о 3-й ранку.
Одного разу маркетингова кампанія створила сплеск. Шлюз почав повертати 429 для частини аб’юзивних клієнтів, додаток зберігав стабільну латентність, а база майже не помітила. Їхній інцидент-тікет був одним рядком: «Зросла частка 429 через кампанію; без впливу на клієнтів.» Це мрія: нудно, передбачувано і трохи самовдоволено.
Що робити натомість: розумні патерни, що масштабуются
Принцип: відхиляйте рано, дешево і незалежно
Завдання лімітеру — захистити дорогі ресурси. Тому він має жити перед ними і залишатися працездатним, коли вони деградують.
Патерн 1: Edge rate limiting (шлюз / ingress)
Використовуйте API-шлюз або ingress controller для базових обмежень:
- По ідентифікатору клієнта (API-ключ, JWT subject, tenant ID).
- По маршруту (ендпоінти логіну і пошуку — різні).
- З обмеженими сплесками (token bucket) і розумними дефолтними відповідями (429 з retry-after).
Це не потребує ідеальної глобальної точності. Потрібні швидкість і передбачуваність.
Патерн 2: Обмеження з’єднань і конкурентності (bulkheads)
Більшість «лімітувань у БД» насправді про «нам потрібно менше одночасних запитів». Вирішіть це прямо:
- Використовуйте пулер (pgbouncer для PostgreSQL, ProxySQL або пулінг на боці аплікації для MariaDB).
- Обмежуйте паралелізм по маршруту (напр., дорогі генерації звітів) за допомогою семафорів.
- Обмежуйте паралелізм по орендарю, щоб уникнути noisy neighbors.
Це інженерія контрольної площини, яка справді контролює щось: одночасну роботу.
Патерн 3: Ставте важку роботу в чергу
Якщо ендпоінт тригерить важку DB-активність, не дозволяйте HTTP-конкурентності диктувати DB-конкурентність. Поставте важку роботу за чергу. Нехай глибина черги поглинає сплески. Запускайте воркерів з фіксованим паралелізмом і відомими патернами запитів. БД скаже вам «дякую», залишившись живою.
Патерн 4: Використовуйте in-memory сховище для короткочасних лічильників
Якщо вам потрібен спільний лічильник з TTL, використовуйте систему, створену для цього (Redis/memcached-подібну). Ви отримуєте:
- Швидкі атомарні інкременти.
- Семантику експірації без drama vacuum/purge.
- Ізоляцію від write path вашого OLTP.
Проте дотримуйтеся гігієни: уникайте одиночних гарячих ключів, додавайте джиттер у TTL і використовуйте партиціювання по орендарях, якщо потрібно.
Патерн 5: Зберігайте довговічні квоти асинхронно
Білінг і відповідність часто потребують довговічних записів. Чудово — зберігайте їх, але робіть це поза гострим шляхом:
- Емітуйте події використання.
- Агрегуйте у часові відрізки (за хвилину/годину/день).
- Записуйте зведення в MariaDB/PostgreSQL.
БД залишається базою даних, а не магазином лічильників високої частоти.
Патерн 6: Явно «бюджетуйте» базу даних
Замість «N запитів на хвилину» визначайте бюджети, прив’язані до реального обмеженого ресурсу:
- Бюджет паралельних запитів: макс активних DB-запитів на орендар/сервіс.
- Бюджет CPU: обмежуйте дорогі ендпоінти по часу виконання (там, де можна виміряти).
- Бюджет I/O: уповільнюйте масові експорт/міграції.
Ідеальну справедливість за дешеві гроші не купити. Можна купити передбачувану латентність і виживання під перевантаженням. Обирайте виживання.
Типові помилки: симптом → корінна причина → виправлення
Помилка 1: «429 зростає, але БД теж повільна»
Симптоми: Зростання частки 429, зростання CPU БД, збільшення очікувань блокувань, p95 латентність зростає по всіх ендпоінтах.
Корінна причина: Відхилення залежить від записів/блокувань у БД; сам лімітатор створює навантаження.
Виправлення: Перенесіть лімітатор на шлюз/в пам’ять. Зробіть шлях відхилення вільним від БД. Додайте обмеження паралельності на боці аплікації.
Помилка 2: «Тільки один орендар повільний, але страждають усі»
Симптоми: Один великий клієнт спричиняє глобальне уповільнення. БД показує очікування на таблиці лімітерів; пул з’єднань насичений.
Корінна причина: Поділені ресурси лімітеру або спільний пул БД без ізоляції по орендарях.
Виправлення: Впровадьте bulkheads: обмеження паралельності по орендарю, окремі воркер-пули для важких ендпоінтів і черги для важких задач.
Помилка 3: «Ми додали індекси до таблиці лімітерів і стало гірше»
Симптоми: Більший CPU, більше I/O на запис, кількість дедлоків зростає, загальна пропускна здатність падає.
Корінна причина: Write amplification: вторинні індекси треба підтримувати при кожному оновленні; гарячі сторінки стають ще гарячішими.
Виправлення: Видаляйте дизайн, а не тільки індекси. Якщо таблиця потрібна, записуйте рідше (батч/роллап).
Помилка 4: «Autovacuum не встигає / purge lag зростає»
Симптоми: PostgreSQL накопичує мертві tuples; у MariaDB росте history list length; продуктивність погіршується з часом.
Корінна причина: Часті оновлення тих самих рядків створюють турбулентність, яку обслуговування не може компенсувати.
Виправлення: Усуньте оновлення на кожен запит; використайте TTL-лічильники в пам’яті; агрегуйте записи.
Помилка 5: «В staging працює, а в production ламається»
Симптоми: Навантажувальні тести проходять; справжній трафік викликає очікування блокувань і хвостову латентність.
Корінна причина: Staging не має скію (гарячих орендарів), не має повторів, не має реальної вибухоподібності і часто не відтворює реальні патерни конкуренції.
Виправлення: Тестуйте зі скію, сплесками, повторами і реалістичною клієнтською конкуренцією. І перестаньте використовувати БД як лімітатор.
Помилка 6: «Ми використали advisory locks для реалізації лімітеру» (PostgreSQL)
Симптоми: Продуктивність падає під навантаженням; сесії накопичуються у черзі на advisory locks; з’являються важкі для налагодження зупинки.
Корінна причина: Advisory locks серіалізують роботу і легко перетворюються на глобальний м’ютекс. Це не формування трафіку.
Виправлення: Замість цього використовуйте обмеження на шлюзі і паралелізм по орендарю. Використовуйте advisory locks лише для рідкісної координації, а не для per-request gating.
Чеклісти / покроковий план
Покроковий план: мігрувати з DB-based rate limiting без катастроф
- Проведіть інвентаризацію того, що лімітується. Ідентифікуйте ендпоінти і ключі (по IP, API-ключу, орендарю). Якщо ви не можете це пояснити, ви не зможете керувати цим.
- Виміряйте поточну вартість лімітеру. Використовуйте
pg_stat_statementsабо MariaDB performance_schema, щоб кількісно визначити час у запитах лімітеру. - Припиніть повтори 429 за замовчуванням. Виправте клієнтів/мідлвар. 429 має зменшувати навантаження, а не множити його.
- Додайте edge-лімітування для найгірших ендпоінтів. Почніть з топ-1–3 ендпоінтів за частотою запитів або вартістю для БД.
- Додайте обмеження паралелізму по маршруту в аплікації. Обмежте дорогі DB-операції напряму. Це часто найбільший виграш.
- Впровадьте чергу для важких задач. Міграції, експорт, генерація звітів: якщо важко, це повинно бути воркером.
- Перенесіть короткочасні лічильники в in-memory сховище, якщо потрібно. Використовуйте TTL-лічильники і семантику token bucket поза БД.
- Зберігайте довговічні роллапи в БД для білінгу/аудиту. Записуйте по одному рядку на орендар за хвилину/годину, а не за запит.
- Впровадьте поведінку при перевантаженні. Визначте, що робити, коли магазин лімітеру недоступний (відкриття для низькоризикових ендпоінтів? закриття для аб’юзу?). Вирішіть, документуйте і тестуйте.
- Видаліть старий шлях лімітування. Видалення через feature-flag краще, ніж «залишимо на всяк випадок». «На всяк випадок» — якраз те, що пізніше загноюється.
Операційний чекліст: захистіть базу даних
- Наявні шлюзові ліміти для клієнта і маршруту.
- Пулінг з’єднань налаштований і моніториться (active, waiting, maxwait).
- Явні бюджети конкурентності по сервісу і по орендарю.
- Таймаути вирівняні: gateway < app < DB statement timeout (з наміром).
- Політика повторів включає експоненційний backoff + джиттер; немає автоматичного повтору на 429.
- Панелі контролю відстежують: очікування блокувань, відставання реплікації, швидкість WAL/binlog, глибину черги, частку 429.
FAQ
1) Чи прийнятне лімітування в базі даних взагалі?
Лише для низького QPS, низької конкуренції back-office навантажень, де коректність важливіша за латентність і де запис лімітеру не конкуруватиме з бізнес-трафіком. Для публічних API або високого QPS це пастка.
2) Що «краще» для DB-based rate limiting: MariaDB чи PostgreSQL?
Жодна з них не «краще» у бажаному сенсі. Обидві — відмінні OLTP-бази. Обидві постраждають, коли ви примусите їх робити часті узгоджені оновлення лічильників. Моди відмов відрізняються в інструментації та поведінці обслуговування, але результат однаковий.
3) Що робити, якщо мені потрібні суворі глобальні ліміти?
Поставте собі запитання «чому». Більшість «суворих» вимог — про контроль витрат або запобігання зловживань. Використайте розподілений лімітатор, спроектований для цього, або прийміть наближені ліміти з обмеженим сплеском і сильним моніторингом. Якщо вам потрібна справжня суворість, готуйтеся до витрат на координацію — покладіть цю координацію в систему, побудовану для цього, а не в ваш OLTP.
4) Можна просто використовувати окремий інстанс БД для лімітеру?
Можна, і це краще, ніж контамінувати основну OLTP. Але це все ще база даних, що виконує координацію записів на кожен запит. Ви, ймовірно, знову натрапите на ті ж межі масштабування, просто на меншому й дешевшому вогні.
5) Чому «таблиці-лічильники з TTL» шкодять саме PostgreSQL?
Часті оновлення створюють мертві tuples і WAL. Autovacuum повинен їх прибирати; якщо воно відстає — продуктивність падає. Навіть якщо воно встигає, ви витрачаєте I/O і CPU на обслуговування, що не дає бізнес-цінності.
6) Чому вони шкодять саме MariaDB/InnoDB?
InnoDB має управляти redo/undo, page latches і потенційними гарячими точками в кластеризованих індексах. При високій конкуренції ви побачите очікування блокувань, дедлоки і тиск purge. Лімітатор стане «гарячою» точкою записів з побічною шкодою.
7) Хіба використання Redis для лімітування не переносить проблему?
Воно переносить проблему в систему, спроектовану для швидких атомарних операцій і TTL. Ви все ще маєте проектувати для гарячих ключів і сплесків, але ви більше не навантажуєте вашу транзакційну базу даних і її машини надійності при кожному запиті.
8) Який найефективніший захист для БД?
Обмежте паралельність для бази даних. Ліміти запитів в секунду — непрямий механізм; обмеження паралелізму — прямий. Якщо ви запобіжите 500 одночасним дорогим запитам, БД залишиться на ногах.
9) Як обробляти відмови магазину лімітеру (наприклад Redis down)?
Вирішіть для кожного ендпоінта: fail open для низькоризикових read-only маршрутів, fail closed для чутливих до аб’юзу маршрутів (login, дорогі пошуки). Впровадьте circuit breaker і безпечний дефолт. Тестуйте це в робочий час, а не під час інциденту.
10) А як щодо «лімітування всередині БД» тільки для внутрішніх сервісів?
Внутрішній трафік часто найгірший: повтори, фан-аут, batch jobs і бажання «потерпіть». Використовуйте бюджети і bulkheads і внутрішньо. БД не відрізняє, чи перевантаження «внутрішнє» чи ні.
Висновок: практичні наступні кроки
Якщо ви запам’ятаєте одну річ: база даних — не швейцар на вході. Вона — танцпол. Не змушуйте її перевіряти документи при вході, поки грає музика.
Наступні кроки, що дійсно змінюють результати:
- Доведіть це даними: ідентифікуйте запити лімітеру в
pg_stat_statementsабо MariaDB digest-ах і кількісно оцініть їхню вартість. - Виключіть БД з шляху відхилення: впровадьте edge-ліміти для топ-ендпоінтів і припиніть повтори 429.
- Обмежте конкурентність БД: пуліть з’єднання і впровадьте per-route та per-tenant bulkheads.
- Ставте важку роботу в чергу: запобігайте перетворенню HTTP-сплесків у DB-сплески.
- Тримайте надійність там, де потрібно: агрегуйте використання в роллапи і зберігайте їх у БД асинхронно.
Ви будете швидше доставляти фічі, більше спати, і ваша база даних повернеться до того, що вміє добре: зберігати дані, а не приборкувати людські амбіції.