MySQL проти PostgreSQL при високій конкуренції: хто першим впирається в стіну і чому

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

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

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

Що насправді означає «висока конкуренція» в продакшні

Коли кажуть «висока конкуренція», зазвичай мають на увазі одне з трьох:

  • Багато одночасних клієнтів (від сотень до десятків тисяч активних з’єднань).
  • Багато одночасних транзакцій (менша кількість з’єднань виконує багато роботи, операції сильно перекриваються).
  • Багато одночасних конфліктів (усі хочуть ті самі кілька рядків, ті самі сторінки індексів, ті самі лічильники, ту саму партицію, ту саму кеш-лінію).

Третій варіант — це місце, куди системи приходять плакати. Ви можете докупити ядра для «багатьох транзакцій». Можете додати пулер для «багатьох клієнтів». Але «багато конфліктів» — це проблема дизайну, що вдає із себе проблему ємності.

Також: конкуренція ≠ пропускна здатність. Може бути низька пропускна здатність, але система обмежена конкуренцією, якщо ви застрягли в очікуванні блокувань, переключеннях контексту, fsync-штормі або черзі ниток, що борються за ту саму спільну структуру даних.

Позиціювання з думкою: де який рушій зазвичай ламається першим

Якщо змушувати узагальнювати (а ви саме це і робите), зазвичай у реальних продакшнах це виглядає так:

PostgreSQL: спочатку ви вдаритесь у стіну з’єднань/процесів, потім — у прибирання MVCC, якщо ви неакуратно працюєте

  • Тисячі прямих з’єднань можуть швидко нашкодити, бо кожен бекенд — це процес з накладними витратами пам’яті та планування. Postgres може витримати велику кількість з’єднань, але робити це без пулінгу означає витрачати CPU на операційні витрати замість запитів.
  • Довгі транзакції породжують MVCC-сміття: старі версії рядків накопичуються, індекси роздуваються, autovacuum відстає, і раптом «прості» запити уповільнюються, бо сканують купу сміття.
  • Гарячі оновлення можуть створювати блоат і конкуренцію на сторінках. «У MVCC читання не блокують записи» — це правда, доки система не займеться прибиранням за вами.

MySQL (InnoDB): ви зустрінете конфлікти блокувань та стіни I/O/амліфікації записів, особливо на гарячих рядках

  • Гарячі рядки та гарячі індекси проявляються як очікування блокувань, дедлоки та крах пропускної здатності. InnoDB сильний, але не чарівний: «оновити рядок-лічильник» при великій конкуренції — це фактично DDoS, який ви написали самі.
  • Скидання брудних сторінок і тиск на redo/лог можуть домінувати під великими обсягами записів. Неправильне налаштування флашингу виглядає як випадкові спайки затримки та «чому диск зайнятий, коли CPU ледве працює?»
  • Реплікація під навантаженням записів (залежно від топології) стає наступною стіною: відставання зростає, failover лякає, і ви виявляєте, що додаток читав «переважно консистентні» дані та називав це функцією.

Хто вдариться першим? Той рушій, який ви експлуатуєте так, ніби він інший. Якщо ви запускаєте Postgres без пулінгу і з довгими транзакціями, він впаде раніше. Якщо ви запускаєте MySQL з оновленнями гарячих рядків, гігантськими вторинними індексами і оптимістичною вірою в буфер-пул, він теж впаде раніше.

Завжди правда тільки дві речі: базу спочатку звинувачують, і база частіше винна, ніж хочеться визнавати.

Жарт №1: База даних під високою конкуренцією — як ресторан з одним офіціантом і нескінченним меню: технічно всі можуть замовити, але практично ніхто не поїсть.

Факти та історичний контекст, які досі мають значення

Деяка «стара» історія досі операційно релевантна, бо вона сформувала дефолти, ментальні моделі та інструменти.

  1. Родовід PostgreSQL веде від POSTGRES в UC Berkeley (1980-ті). Культура «правильності передусім» відображається у таких можливостях, як MVCC, потужний SQL і серйозний планувальник.
  2. Рання домінантність MySQL (кінець 1990-х–2000-ні) прийшла від швидкості і простоти для веб-додатків, часто з MyISAM. Багато історій про кошмари конкуренції пов’язані з періодом до InnoDB або з неправильно зрозумілими перемиканнями рушіїв.
  3. InnoDB став дефолтом у MySQL 5.5 (2010). Якщо хтось і досі говорить, що «у MySQL таблиці блокуються завжди», то або він мандрує в часі, або працює з чимось проклятим.
  4. Autovacuum PostgreSQL з’явився, щоб зробити MVCC керованим у масштабі. Це не опційне прибирання — це збирач сміття для вашої моделі видимості даних.
  5. Історія реплікації MySQL включала проблеми зі statement-based реплікацією, потім row-based, потім змішану. Ненадійні write-heavy навантаження навчили багато команд скромності.
  6. Інструментація wait event у Postgres (особливо розширена з роками) змінила спосіб налагодження затримок: часто можна точно назвати вузьке місце замість здогадок.
  7. Performance Schema у MySQL дозрів до реального інструменту спостереження. Якщо ви ним не користуєтеся для діагностики контенції, ви налагоджуєте за відчуттями.
  8. Обидві спільноти винесли тяжкі уроки про «більше ниток» як пастку: тиск планувальника, конкуренція за м’ютекси й насичення I/O не домовляються.
  9. Пулери з’єднань стали стандартною практикою для Postgres в середовищах з високою конкуренцією, сформувавши сучасні розгортання (pgBouncer — практично частина меблів).

Стіни: поширені вузькі місця при екстремальній конкуренції

Стіна №1: керування з’єднаннями та перемикання контексту

PostgreSQL використовує модель «процес на з’єднання». Це не «погано»; це передбачувано і ізольовано. Але якщо у вас тисячі активних сесій, що виконують дрібні запити, планувальник ОС стає частиною вашого плану запиту. Накладні витрати пам’яті зростають, shared buffers — не вся історія, і ви починаєте платити за облік, а не за корисну роботу.

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

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

Стіна №2: конфлікти блокувань і «гарячі» рядки

Найшвидший шлях до провалу — створити вузол на один рядок: лічильники, оновлення «last_seen» в одному й тому самому користувацькому рядку, «глобальна таблиця послідовностей», «доступний інвентар» або черга, реалізована як «update one row where status=ready limit 1». Вітаємо, ви побудували генератор конфліктів блокувань.

InnoDB використовує блокування на рівні рядка, але точна поведінка залежить від рівня ізоляції та шляху доступу. При REPEATABLE READ (поширений дефолт) next-key locking може блокувати діапазони для певних патернів, що дивує тих, хто думав, що «тільки рядкові блокування». Гарячі вторинні індекси теж можуть послідовно серіалізувати вставки/оновлення.

PostgreSQL використовує MVCC, тому читання не блокують записи, але записи все ще блокують записи. Рядок, що постійно оновлюється, стає точкою серіалізації. Також конкуренція за сторінки індексу та сильний churn UPDATE може створити блоат і тиск на vacuum.

Головна думка: конкуренція колапсує, коли ви примушуєте серіалізований доступ. Бази можуть арбітрувати, але не можуть зробити один ресурс паралельним.

Стіна №3: прибирання MVCC та гігієна транзакцій

Обидва рушії реалізують поведінку схожу на MVCC, але вони платять «податок прибирання» по-різному.

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

InnoDB зберігає undo-інформацію для забезпечення консистентних читань. Довгі транзакції означають ріст undo-журналів і відставання purge, що може впливати на продуктивність і, у крайніх випадках, на стабільність. Інша форма, та сама гріховність: довгі транзакції під навантаженням дорожчі.

Висока конкуренція посилює проблеми гігієни транзакцій. На низькому навантаженні ви можете ігнорувати кілька неакуратних транзакцій. При високому навантаженні вони стають вузьким місцем і нагрівальним елементом.

Стіна №4: ампліфікація вводу/виводу та тиск контрольних точок

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

PostgreSQL контрольні точки записують брудні буфери на диск. Погано налаштовані параметри checkpoint можуть призводити до вибухових записів I/O: тихі періоди, за якими йде «всі зараз зливаються», і спайки затримки. Додайте важку генерацію WAL і отримаєте систему, що виглядає нормально до певного моменту, а потім — ні.

MySQL/InnoDB управляє брудними сторінками, redo-логом та фонoвим флашингом. Неправильні налаштування флашингу і маленькі redo/лог-файли можуть створити постійний тиск, де операції у foreground чекають на місце або на забезпечення стійкості.

При високій конкуренції підсистема зберігання стає полем арбітражу. Якщо нижчий рівень диска не витримує fsync-патернів і ампліфікації записів, обидві СУБД «вдаряться в стіну», лише з різною термінологією інструментації.

Стіна №5: індекси та ампліфікація записів

Індекси — це мультиплікатори конкуренції в обидва боки. Вони роблять читання дешевшим і записи дорожчими. При високій конкуренції дорогі записи стають фабриками контенції: більше зачеплених сторінок, більше засувок, більше чурну кешу, більше WAL/redo.

Вторинні індекси MySQL включають первинний ключ, тому широкий первинний ключ робить всі вторинні індекси важчими. Індекси Postgres зберігають ключ і вказівник на рядок; оновлення, що змінюють індексовані колонки, також створюють мертві кортежі в індексах. Різні механіки, та самий результат: якщо ви індексуєте все «на всяк випадок», ваша пропускна здатність записів рано чи пізно попросить розлучення.

Якщо вам потрібні високо конкурентні записи, будьте агресивно скептичні щодо кожного індексу. Нехай додаток заслужить їх.

Стіна №6: реплікація та відставання при навантаженні на запис

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

Streaming replication PostgreSQL — фізична та базована на WAL. Вона надійна, але репліки застосовують WAL; при великих обсягах запису відставання росте, якщо застосування не встигає. Конфлікти у hot standby можуть скасовувати запити на репліках, якщо вони блокують відновлення, що виглядає як «репліка випадково вбиває запити».

Реплікація MySQL залежить від налаштування: класична асинхронна, semi-sync, group replication тощо. Відставання — поширене явище при важких записах, і багатопотокове застосування на репліках допомагає, але не безкоштовно. Чим паралельніше навантаження, тим обережніше треба ставитися до схеми і транзакцій, щоб дозволити паралелізм застосування.

У будь-якому випадку: якщо ваша стратегія масштабування — «просто додайте репліки для читання», спочатку перевірте, чи дійсно навантаження читальне, або ж просто блокується через запис і блокування.

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

Мета — визначити обмежений ресурс за менше ніж 15 хвилин. Не ідеалізувати систему. Не виграти суперечку. Зупинити кровотечу.

Перше: підтвердіть, чи ви завантажені CPU, I/O чи заблоковані

  • CPU-завантаження: високий використаний CPU, черга runnable зростає, час запитів зростає рівномірно.
  • I/O-завантаження: високе завантаження диска, спайки latency fsync або записів, контрольні точки/флашинг корелюють зі зупинками.
  • Блокування: багато сесій «активні», але не споживають CPU; вони чекають.
  • Обмеження з’єднань: висока кількість з’єднань, велике перемикання контексту, тиск пам’яті, виснаження пулу.

Друге: знайдіть головну причину очікування, а не найдовший запит

При високій конкуренції список «повільних запитів» часто бреше. Повільна річ — це черга. Визначте, на що чекають усі: блокування, буферний pin, flush WAL, місце в redo-лозі, конкретна сторінка індексу, файловий sync.

Третє: визначте гарячу точку (таблиця, індекс, рядок або шлях коду)

Коли ви знаєте клас очікування, знайдіть гарячу точку. Виправлення зазвичай одне з:

  • зменшити конкуренцію (шардінг hotspot, змінити алгоритм, уникати оновлень гарячих рядків)
  • зменшити роботу (прибрати індекси, пакетувати записи, кешувати читання, уникати даремних транзакцій)
  • збільшити місткість (швидше сховище, більше пам’яті, більше CPU) — лише коли ви знаєте, що годуєте
  • додати ізоляцію (пулер, черга, зворотний тиск)

Четверте: зробіть одну зміну, яка знизить тиск конкуренції

Найшвидші стабілізаційні зміни часто нудні: знизити max активних транзакцій, увімкнути пулінг, збільшити таймаути в додатку, розбити великі пакетні роботи, і припинити довгі транзакції.

Жарт №2: Додавати більше серверів додатків до бази, що обмежена блокуваннями — це як кричати на затор: голосніше не означає швидше.

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

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

Завдання 1: Перевірити системне навантаження та насичення CPU (Linux)

cr0x@server:~$ uptime
 14:07:21 up 32 days,  6:12,  2 users,  load average: 22.44, 19.10, 13.02

Значення: Середнє навантаження значно вище за кількість ядер CPU вказує на чергу runnable або очікування неперервного I/O.

Рішення: Якщо навантаження високе і CPU завантажений, шукайте дорогі запити. Якщо навантаження високе, а CPU помірний, підозрюйте очікування блокувань чи I/O.

Завдання 2: Виявити I/O wait і тиск swap

cr0x@server:~$ vmstat 1 5
procs -----------memory---------- ---swap-- -----io---- -system-- ------cpu-----
 r  b   swpd   free   buff  cache   si   so    bi    bo   in   cs us sy id wa st
12  3      0  52124  43120 918420    0    0   112  9840 9400 21000 35 10 35 20  0
18  5      0  49812  42980 915220    0    0   120 11240 9900 24000 30 12 28 30  0

Значення: Високий wa вказує, що CPU чекає на диск. Високий cs також може натякати на занадто багато активних ниток/процесів.

Рішення: Якщо wa постійно високий, швидко переходьте до діагностики флашингу/контрольних точок бази даних і латентності сховища.

Завдання 3: Швидко виміряти латентність зберігання (Linux)

cr0x@server:~$ iostat -x 1 3
Device            r/s     w/s   r_await   w_await  aqu-sz  %util
nvme0n1         120.0   980.0     1.20    18.50    9.40   97.0

Значення: Високий w_await і %util близько 100% вказують на насичення пристрою під записами.

Рішення: Розглядайте це як вузьке місце зберігання, поки не доведено протилежне. Зменшіть швидкість записів (пакетуйте, менше індексів, менше fsync подій) або апгрейдьте сховище.

Завдання 4: Порахуйте з’єднання та знайдіть головних говорунів (PostgreSQL)

cr0x@server:~$ psql -XAt -c "select state, count(*) from pg_stat_activity group by 1 order by 2 desc;"
active|412
idle|1870
idle in transaction|37

Значення: Багато idle-з’єднань вказує на необхідність пулінгу. Будь-яка нетривіальна кількість idle in transaction — червоний прапорець.

Рішення: Якщо idle багато, поставте pgBouncer (transaction pooling для багатьох OLTP-додатків) і обмежте серверні з’єднання. Якщо є idle-in-transaction під навантаженням — шукайте таких клієнтів першими.

Завдання 5: Знайти, на що чекають сесії Postgres

cr0x@server:~$ psql -X -c "select wait_event_type, wait_event, count(*) from pg_stat_activity where state='active' group by 1,2 order by 3 desc limit 10;"
 wait_event_type |  wait_event  | count
-----------------+--------------+-------
 Lock            | transactionid |   120
 IO              | DataFileRead  |    64
 LWLock          | buffer_content|    38

Значення: Ваша конкуренція обмежена класами очікувань (transactionid locks, I/O reads, buffer-content LWLocks).

Рішення: Накопичення transactionid lock часто означає гарячі оновлення рядків або важкі перевірки FK. DataFileRead вказує на пропуски кешу або роздування сканів. buffer_content натякає на конкуренцію в shared buffers/сторінках.

Завдання 6: Виявити ланцюжок блокувань у Postgres

cr0x@server:~$ psql -X -c "select blocked.pid as blocked_pid, blocker.pid as blocker_pid, blocked.query as blocked_query, blocker.query as blocker_query from pg_stat_activity blocked join pg_stat_activity blocker on blocker.pid = any(pg_blocking_pids(blocked.pid)) where blocked.state='active' limit 5;"
 blocked_pid | blocker_pid | blocked_query              | blocker_query
------------+-------------+---------------------------+-------------------------------
      18421 |       17210 | update counters set v=v+1 | update counters set v=v+1

Значення: Класичне нагромадження оновлень одного і того ж рядка.

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

Завдання 7: Перевірити стан autovacuum і «мертві» кортежі (PostgreSQL)

cr0x@server:~$ psql -X -c "select relname, n_live_tup, n_dead_tup, last_autovacuum from pg_stat_user_tables order by n_dead_tup desc limit 5;"
 relname  | n_live_tup | n_dead_tup |     last_autovacuum
----------+------------+------------+---------------------------
 events   |   52000000 |   18000000 | 2025-12-29 12:11:03+00
 orders   |    9000000 |    2100000 | 2025-12-29 13:40:21+00

Значення: Багато мертвих кортежів означає блоат і тиск на vacuum; читання уповільнюються і кеші стають менш ефективними.

Рішення: Якщо autovacuum не встигає, налаштуйте параметри autovacuum для конкретних таблиць, зменшіть churn оновлень і усуньте довгі транзакції, що блокують vacuum.

Завдання 8: Знайти довго працюючі транзакції (PostgreSQL)

cr0x@server:~$ psql -X -c "select pid, now()-xact_start as xact_age, state, left(query,80) from pg_stat_activity where xact_start is not null order by xact_age desc limit 5;"
  pid  | xact_age |        state        | left
-------+----------+---------------------+----------------------------------------
 20311 | 01:12:09 | idle in transaction | SELECT * FROM customer WHERE id=$1

Значення: Транзакція, що стоїть годину в стані idle, утримує snapshot і підвищує ризик блоату.

Рішення: Вбити її, якщо це безпечно, і виправити додаток. Додайте таймаути (idle_in_transaction_session_timeout) і переконайтеся, що клієнти не тримають транзакції під час викликів зовнішніх сервісів.

Завдання 9: Перевірити поведінку контрольних точок Postgres

cr0x@server:~$ psql -X -c "select checkpoints_timed, checkpoints_req, checkpoint_write_time, checkpoint_sync_time, buffers_checkpoint from pg_stat_bgwriter;"
 checkpoints_timed | checkpoints_req | checkpoint_write_time | checkpoint_sync_time | buffers_checkpoint
------------------+-----------------+-----------------------+----------------------+-------------------
              128 |             412 |               983210  |               210992 |           8420012

Значення: Багато вимушених контрольних точок (checkpoints_req) вказує на тиск WAL, що змушує чекпоінти, часто корелюючи зі спайками записів і різким зростанням затримок.

Рішення: Розгляньте налаштування чекпоінтів і розмір WAL, зменшіть ампліфікацію записів (індекси, патерни оновлень). Також перевірте, чи сховище може витримувати сталий потік записів.

Завдання 10: Подивитись нитки/з’єднання та виконувані запити (MySQL)

cr0x@server:~$ mysql -e "SHOW GLOBAL STATUS LIKE 'Threads_%';"
+-------------------+-------+
| Variable_name     | Value |
+-------------------+-------+
| Threads_cached    | 120   |
| Threads_connected | 1850  |
| Threads_running   | 210   |
+-------------------+-------+

Значення: Threads_running — це реальний тиск конкуренції. Сотні running ниток можуть означати CPU-контенцію, очікування блокувань або те й інше разом.

Рішення: Якщо running threads високо і затримки високі, знайдіть, на що вони чекають (блоки, I/O, redo). Якщо connected дуже багато — виправляйте пулінг і таймаути.

Завдання 11: Перевірити InnoDB статус блокувань і дедлоки

cr0x@server:~$ mysql -e "SHOW ENGINE INNODB STATUS\G" | sed -n '1,80p'
=====================================
2025-12-29 14:09:56 INNODB MONITOR OUTPUT
=====================================
LATEST DETECTED DEADLOCK
------------------------
*** (1) TRANSACTION:
TRANSACTION 8912231, ACTIVE 0 sec updating or deleting
...

Значення: Дедлоки під навантаженням — нормальне явище; часті дедлоки — ознака поганого дизайну (гарячі рядки, непослідовний порядок блокувань, відсутні індекси).

Рішення: Якщо дедлоки стрибнули — пріоритетно переробити запити/шляхи і зробити порядок блокувань послідовним. Додайте/підрегулюйте індекси, щоб уникнути range locks від повних сканів.

Завдання 12: Виявити очікування блокувань у MySQL через Performance Schema

cr0x@server:~$ mysql -e "select object_schema, object_name, count_star, sum_timer_wait/1000000000000 as seconds_waited from performance_schema.table_lock_waits_summary_by_table order by sum_timer_wait desc limit 5;"
+--------------+-------------+-----------+----------------+
| object_schema| object_name  | count_star| seconds_waited |
+--------------+-------------+-----------+----------------+
| appdb        | counters     |  1203321  |  842.21        |
| appdb        | orders       |   214220  |  190.44        |
+--------------+-------------+-----------+----------------+

Значення: Які таблиці спричиняють очікування блокувань, в кількісному вираженні. «counters» — практично визнання провини.

Рішення: Виправляйте гарячу точку першою. Жодне налаштування buffer pool не зробить масштабовано один рядок-лічильник.

Завдання 13: Перевірити сигнали тиску redo-логу MySQL

cr0x@server:~$ mysql -e "SHOW GLOBAL STATUS LIKE 'Innodb_log_waits';"
+------------------+-------+
| Variable_name    | Value |
+------------------+-------+
| Innodb_log_waits | 1842  |
+------------------+-------+

Значення: Ненульовий і зростаючий Innodb_log_waits означає, що транзакції чекають місця/флашу в redo-лозі.

Рішення: Дослідіть розмір redo-логу і налаштування флашингу; зменшіть обсяг записів; підтвердіть латентність сховища. Класична стіна записів при високій конкуренції.

Завдання 14: Побачити топ запитів MySQL за загальною латентністю (Performance Schema)

cr0x@server:~$ mysql -e "select digest_text, count_star, round(sum_timer_wait/1000000000000,2) as total_s, round(avg_timer_wait/1000000000000,6) as avg_s from performance_schema.events_statements_summary_by_digest order by sum_timer_wait desc limit 3;"
+-------------------------------------------+------------+---------+---------+
| digest_text                               | count_star | total_s | avg_s   |
+-------------------------------------------+------------+---------+---------+
| UPDATE counters SET v = v + ? WHERE id=?  |  9202211   |  912.11 | 0.000099|
| SELECT * FROM orders WHERE user_id = ?    |   821220   |  410.44 | 0.000500|
+-------------------------------------------+------------+---------+---------+

Значення: «Маленький» UPDATE домінує загальну латентність, бо він виконується мільйони разів і створює контенцію.

Рішення: Зменшіть частоту, пакетизуйте, шардьте або переробіть дизайн. Не ганяйтеся за мікрооптимізаціями SELECT, коли UPDATE — справжня проблема.

Завдання 15: Швидка перевірка ознак блоату індексів Postgres (наближено)

cr0x@server:~$ psql -X -c "select relname, pg_size_pretty(pg_relation_size(relid)) as table_sz, pg_size_pretty(pg_total_relation_size(relid)) as total_sz from pg_catalog.pg_statio_user_tables order by pg_total_relation_size(relid) desc limit 5;"
 relname | table_sz | total_sz
---------+----------+----------
 events  | 58 GB    | 110 GB
 orders  | 12 GB    | 28 GB

Значення: Загальний розмір значно більший за розмір таблиці — натяк на багато індексів та/або блоат. Не доказ, але сильний запах.

Рішення: Дослідіть патерни оновлень, autovacuum і необхідність індексів. Розгляньте REINDEX/обслуговування в вікні технічного обслуговування, якщо блоат підтвердиться і шкодить кеш-хітам.

Завдання 16: Перевірка базового відставання реплікації (Postgres)

cr0x@server:~$ psql -X -c "select client_addr, state, write_lag, flush_lag, replay_lag from pg_stat_replication;"
 client_addr |   state   | write_lag | flush_lag | replay_lag
-------------+-----------+-----------+-----------+------------
 10.0.2.18    | streaming | 00:00:00.2| 00:00:01.1| 00:00:03.8

Значення: Зростаюче replay lag під навантаженням вказує, що репліки не встигають застосовувати WAL, часто через обмеження I/O або CPU.

Рішення: Якщо репліки відстають і обслуговують читання, ви подаєте застарілу істину. Виправте потужність реплік, зменшіть churn або направляйте критичні читання на primary.

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

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

Середня SaaS-компанія мігрувала сервіс аналітики клієнтів з MySQL на PostgreSQL. У команди були вагомі причини: кращі SQL-фічі, зручніший JSON для їхніх payload, і планувальник, що краще обробляв ad hoc звіти. Вони прогнали навантажувальне тестування. Все виглядало добре.

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

Неправильне припущення було простим: «Postgres витримає багато з’єднань, тож ми просто відкриватимемо по одному на запит, як раніше». У них було тисячі короткоживих з’єднань, що трясли систему. Накладні витрати аутентифікації, процес-чурн, фрагментація пам’яті і переключення контексту перетворилися на податок на кожен запит.

Гірше, межі транзакцій були нестримні. Деякі запити відкривали транзакцію, робили SELECT, викликали зовнішній сервіс, а потім робили UPDATE. Під навантаженням ті idle-in-transaction сесії почали накопичуватись і vacuum відстав. Система була не просто повільною; вона повільно погіршувалась.

Виправлення було безславним: pgBouncer в режимі transaction pooling, розумний ліміт серверних з’єднань і жорсткі таймаути для idle-транзакцій. Потім вони рефакторили шлях «виклик зовнішнього сервісу під час відкритої транзакції». Конкуренція стабілізувалась, і той же хардвер обробляв більше роботи. База не стала «швидшою». Система стала менш дурною.

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

Платформа e-commerce на MySQL (InnoDB) мала ціль: зменшити читання сторінки продукту під піковим трафіком. Доброзичливий інженер додав колонку «view_count» до таблиці products і оновлював її при кожному перегляді. Один рядок на продукт. Що могло піти не так?

Все, але поступово. У звичайні дні все працювало. Під час розпродажів гарячі продукти отримували шквал оновлень. База почала показувати зростання очікувань блокувань і дедлоків. Повторні спроби в додатку помножувались. Додаткові повтори підвищували навантаження записів. Навантаження записів підвищувало тиск на флашинг. Тиск флашингу підвищував латентність. Латентність підвищувала таймаути. Таймаути підвищували повтори. Ви знаєте кінцівку.

Команда спочатку ганялась за «налаштуванням MySQL»: розмір buffer pool, конкуренція ниток, параметри флашингу. Отримали маргінальні покращення, але не стабільність. Гаряча точка була в дизайні: високочастотне, високо конфліктне оновлення на малій кількості рядків. Вони створили глобальний м’ютекс, замаскований під фічу.

Справжній фікс: припинити оновлення одного рядка на кожен перегляд. Перенесли на append-only таблицю подій (або зовнішню систему лічильників) і агрегували асинхронно. Раптом дедлоки заспокоїлись, відставання реплікації зменшилось, і база перестала займатись кардіо.

Урок не в тому, що «ніколи не зберігати лічильники». Урок у тому, що якщо запис гарячіший за об’єкт, що рахується, потрібен патерн запису, який масштабується з конкуренцією, а не проти неї.

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

Команда фінтеху працювала на PostgreSQL для OLTP і мала щотижневу пакетну задачу на важкі оновлення для звірки. Задача була повільна, але передбачувана. У них була звичка, що виглядала параноїдально: вони відстежували поведінку autovacuum по таблицях, налаштовували per-table vacuum/analyze для відомих churn-них відношень, і мали строгу політику — будь-яка транзакція старша за поріг викликала алерт.

Одного тижня новий деплой мікросервісу ввів баг: витік з’єднань і відсутній commit в обробці помилки. Сесії накопичувались як «idle in transaction», утримуючи snapshots. Пакетна задача стартувала, створила мертві кортежі, і autovacuum не встигав їх звільняти. Латентності запитів росли, але не миттєво — достатньо, щоб викликати підозру.

On-call інженер не почав з перегляду CPU графіків. Він виконав два запити: active wait events і найстарші транзакції. Найстарша транзакція була кілька годин. Це був курящий пістолет. Вони вбили найгірших порушників, пом’якшили витік, відкотивши deployment, і система відновилась до того, як блоат перетворився на багатоденне прибирання.

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

Поширені помилки: симптом → корінна причина → виправлення

1) «CPU низький, але все повільно»

Симптоми: висока затримка запитів, низьке використання CPU, багато активних сесій.

Корінна причина: контенція блокувань або I/O-столи; нитки/процеси чекають, а не виконуються.

Виправлення: визначте wait events (Postgres) або lock/redo waits (MySQL). Усуньте патерни гарячих рядків, виправте відсутні індекси, що спричиняють range-locks, зменшіть ампліфікацію записів і обмежте конкуренцію через пулінг/зворотний тиск.

2) Autovacuum PostgreSQL «таємно» не встигає

Симптоми: збільшення розміру таблиць/індексів, погіршення cache hit rate, зростання часу запитів за дні.

Корінна причина: довгі транзакції, що утримують сніпшоти; таблиці з великою кількістю оновлень без налаштованого autovacuum на рівні таблиці.

Виправлення: вбивайте/уникати довгих транзакцій, ставте таймаути, налаштовуйте autovacuum для конкретних таблиць і зменшуйте churn оновлень (використовуйте INSERT-only підходи з періодичною компакцією/агрегацією, якщо можливо).

3) Дедлоки MySQL стрибнули після релізу

Симптоми: зростання логів дедлоків, шторм повторів, крах пропускної здатності.

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

Виправлення: зробіть порядок блокувань послідовним; додайте необхідні індекси; зменшіть контенцію шляхом шардінгу hotspot-ів; обмежте повтори з jitter і caps.

4) «Додавання реплік не допомогло»

Симптоми: primary все ще перевантажений, репліки відстають, читальна латентність непослідовна.

Корінна причина: навантаження обмежене записами/блокуваннями, а не читаннями; репліки не встигають застосовувати зміни.

Виправлення: виправте шлях записів спочатку (індекси, пакетування, контенція). Направляйте лише безпечні/толерантні до відставання читання на репліки. Оновіть I/O реплік, якщо застосування — обмеження.

5) Виснаження пулу з’єднань викликає каскадні відмови

Симптоми: таймаути додатків, сплеск помилок, на базі — спалахи з’єднань.

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

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

6) «Ми налаштували пам’ять, але все ще повільно»

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

Корінна причина: блоат, погані плани запитів через параметризацію, або робочий набір більший за пам’ять через забагато індексів або великі рядки.

Виправлення: зменшіть блоат і непотрібні індекси; вимірюйте показники кеш-хітів; виправте патерни запитів і статистику; розгляньте партиціювання або архівацію.

Чеклісти / план по кроках

План по кроках: вибір між MySQL і PostgreSQL для OLTP з високою конкуренцією

  1. Визначте модель контенції: чи є гарячі точки (лічильники, інвентар, черги) або здебільшого незалежні рядки?
  2. Визначте форму транзакцій: короткі і часті, чи довгі і складні? Якщо довгі транзакції поширені, зразу плануйте контролі і таймаути.
  3. Вирішіть стратегію з’єднань: для Postgres передбачайте пулінг; для MySQL також пуліть; не ставте БД як звичайний сокет-сервер.
  4. Виміряйте ампліфікацію записів: порахуйте індекси, ширину рядка і очікувану частоту оновлень. Якщо багато записів, будьте безжально суворі щодо індексів.
  5. Обирайте ізоляцію свідомо: дефолтні рівні ізоляції існують з історичних причин, не тому що вони найкращі для вашого навантаження.
  6. Проектуйте з урахуванням зворотного тиску: ваш додаток має деградувати граціозно при насиченні БД. Без цього будь-яка СУБД зазнає «драматичного» провалу.
  7. Плануйте семантику реплікації: які читання можуть терпіти відставання? Якщо відповідь «жодні», архітектура має відобразити це.
  8. Операціоналізуйте обслуговування: vacuum і блоат (Postgres), purge і тиск redo (MySQL), бекапи, failover і зміни схеми під навантаженням.

Чекліст: стабілізація бази, що обмежена блокуваннями, під час інциденту

  • Підтвердьте, що домінуючий клас очікувань — lock waits (Postgres wait events / MySQL lock wait summaries).
  • Визначте найбільш контендентну таблицю/індекс і точний патерн запитів.
  • Тимчасово зменшіть конкуренцію: знизьте кількість воркерів додатку, зменшіть кількість споживачів черг, звузьте розмір пулу або скиньте навантаження.
  • Зупиніть шторм повторів: обмежте повтори, додайте jitter і швидко відмовляйтесь для некритичних шляхів.
  • Застосуйте прицільні пом’якшення: додайте відсутній індекс, змініть порядок транзакцій, відключіть неважливу фічу, що пише.
  • Після стабілізації: переробіть hotspot (шардовані лічильники, переїзд на append-only, редизайн черги, партиціювання).

Чекліст: запобігання MVCC-блоату Postgres

  • Алертуйте на idle in transaction сесії і вік транзакцій.
  • Щотижня переглядайте топ-таблиці за churn: мертві кортежі, частота vacuum, ріст таблиць.
  • Налаштовуйте autovacuum для таблиць з високим churn; не покладайтесь на глобальні дефолти.
  • Віддавайте перевагу INSERT-only патернам + періодичній компакції/агрегації для стрімів з високою частотою записів.
  • Тримайте транзакції короткими; не утримуйте сніпшоти під час викликів зовнішніх сервісів.

Чекліст: запобігання InnoDB lock storms у MySQL

  • Визначайте і усувайте гарячі оновлення рядків і «глобальні лічильники».
  • Переконайтесь, що критичні WHERE-клаузи індексовані, щоб уникнути широких блокувань і сканів.
  • Тримайте транзакції короткими; уникайте великих батчів, що утримують блокування довго.
  • Слідкуйте за Innodb_log_waits і поведінкою флашингу при сталих записах.
  • Валідуйте поведінку відставання реплікації під навантаженням; не відкривайте це в продакшні.

Часті питання

1) Чи PostgreSQL «гірший» при високій конкуренції тому, що використовує процеси?

Ні. Він гірший при неакуратних патернах з’єднань. З пулінгом і адекватною кількістю сесій Postgres відмінно витримує високу транзакційну конкуренцію. Без пулінгу ви даремно платите накладними витратами ОС.

2) Чи MySQL «кращий», бо він швидший?

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

3) Хто впирається першим при 10 000 з’єднаннях?

Без пулінгу: зазвичай Postgres пожалиться раніше через накладні витрати на процеси. З пулінгом: обидва можуть вижити, і стіна зміщується до блокувань, I/O або дизайну запитів.

4) Чому «прості» UPDATE стають топ-споживачем латентності?

Бо під контенцією вони не прості. UPDATE 0.1ms, що виконується десять мільйонів разів з очікуваннями блокувань, стає вашою домінуючою вартістю. Частота плюс контенція перемагає хитрість щоразу.

5) Чи читальні репліки можуть вирішити проблему високої конкуренції?

Тільки якщо ви справді читально-залежні і ваші читання можуть терпіти відставання. Якщо ви обмежені записами або блокуваннями, репліки не виправлять вузьке місце primary і можуть додати операційної складності.

6) Яка єдина найкраща практика для Postgres під високою конкуренцією?

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

7) Яка єдина найкраща практика для MySQL під високою конкуренцією?

Позбавтесь гарячих точок і переконайтесь, що індекси підтримують ваші write-запити. Також стежте за Innodb_log_waits; якщо робота на забезпечення стійкості блокує foreground-записи, ви побачите крах конкуренції.

8) Чи є SERIALIZABLE ізоляція поганою ідеєю для конкуренції?

Не обов’язково, але вона дорога і може підвищувати кількість повторів/помилок сериалізації під контенцією. Використовуйте її, коли це потрібно, і проектуйте обробку повторів навмисно. Не вмикайте її «для безпеки» і не сподівайтесь.

9) Як зрозуміти, чи потрібно збільшувати апаратні ресурси чи переробляти запити?

Якщо очікування домінують навколо однієї блокувальної гарячої точки або сканів через блоат, спочатку переробіть. Якщо ви насичуєте сховище сталими, неминучими записами і зменшили ампліфікацію, тоді масштабування обладнання має сенс.

10) Який один метрик надійно прогнозує майбутній інцидент?

Аномалії у віці транзакцій. Одна довга транзакція може отруїти конкуренцію, блокуючи прибирання (Postgres) або purge (InnoDB) і утримуючи блокування довше, ніж очікувалось.

Наступні кроки (що робити в понеділок вранці)

Якщо ви обираєте між MySQL і PostgreSQL для системи з високою конкуренцією, обирайте, виходячи з очікуваних режимів відмов і вашої операційної дисципліни:

  • Якщо ви можете забезпечити пулінг і гігієну транзакцій, PostgreSQL — сильний дефолт з відмінною спостережливістю і багатством SQL.
  • Якщо ваше навантаження — простий OLTP зі суворою схемою/індексною дисципліною, MySQL/InnoDB може бути вкрай ефективним — поки ви не введете гарячі точки і не переконаєтесь, що це «нічого страшного».

Потім виконайте нудні налаштування, що запобігають майбутнім героям на зміні:

  1. Впровадьте пулінг з’єднань і встановіть жорсткий ліміт DB-з’єднань.
  2. Додайте таймаути: таймаут запиту, таймаут транзакції і таймаут idle-in-transaction (або еквіваленти на боці додатку).
  3. Побудуйте дашборди за очікуваннями (Postgres wait events; MySQL lock/redo waits), а не тільки за CPU.
  4. Запустіть навантажувальний тест, що включає гарячі точки: лічильники, черги й пакетні роботи. Якщо ви не тестуєте огидні частини — продакшн зробить це за вас.
  5. Складіть план для гарячих точок: шардовані лічильники, append-only події, редизайн черг і стратегії партиціювання.

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

Виберіть базу. Потім керуйте нею так, ніби ви це серйозно маєте.

← Попередня
Ubuntu 24.04 tmpfs/ramdisk вийшов із-під контролю: як зупинити поїдання RAM (не ламаючи програми)
Наступна →
Debian 13: «Text file busy» — чому розгортання не вдаються і як безпечно виправити (випадок №57)

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