MySQL проти PostgreSQL для multi-tenant SaaS: ізоляція орендарів, що витримує зростання

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

Multi-tenant SaaS починається як ощадний трюк: один додаток, одна база даних, багато клієнтів. Потім ви зростаєте.
Великий орендар запускає звіт опівдні, p95 латентності подвоюється, і ваша архітектура «усе спільне» перетворюється на
спільні страждання.

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

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

«Ізоляція орендарів» — це не просто «tenant_id є в кожній таблиці». Це маркування даних. Ізоляція — це властивість, за якою один
орендар не може:

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

Ізоляція також операційна. Потрібно вміти відповісти швидко й упевнено:

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

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

Моделі tenancy, що масштабуються (і ті, що тихо не витримують)

Модель A: Спільні таблиці, спільна схема (стовпець tenant_id)

Один набір таблиць. Кожен рядок містить tenant_id. Це найдешевше на старті й найскладніше підтримувати коректно.
Може масштабуватися, якщо закласти це з першого дня: суворі межі доступу (RLS або перевірені шляхи запитів),
партиціювання та план переміщення орендарів.

Коли працює:

  • Багато дрібних орендарів з відносно однорідними навантаженнями.
  • Потрібні прості міграції (одна схема).
  • Продукт не вимагає розширень на рівні орендаря або індивідуальної індексації.

Як це ламається:

  • Кілька «слонів-орендарів» домінують по I/O та конфліктам блокувань.
  • Кожен запит має бути ідеальним завжди (спойлер: так не буде).
  • Бекфіли та аналітика випадково перетворюються на глобальні повні скани таблиць.

Модель B: Схема на орендаря (одна база, багато схем)

Кожен орендар має власну схему: tenant_123.orders, tenant_456.orders. Це сильна операційна межа
для імен об’єктів, міграцій та часткових переміщень. PostgreSQL у цьому плані помітно кращий; у MySQL «schema» фактично
означає «database», тож форма відрізняється.

Коли працює:

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

Як це ламається:

  • Занадто багато об’єктів (таблиць/індексів) може уповільнити операції з метаданими й вікна обслуговування.
  • Пулінг підключень ускладнюється, якщо ви покладаєтесь на SET search_path.
  • Крос-орендарська аналітика стає дорожчою та заплутаною.

Модель C: База даних на орендаря (або кластер на орендаря)

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

Коли працює:

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

Як це ламається:

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

Модель D: Шардовані орендарі (мапа орендар → шарда)

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

Коли працює:

  • Ви маєте чіткі межі орендарів і мало потреби в крос-орендарських джойнах.
  • Можете побудувати та підтримувати шарувальний рівень (на боці додатку або проксі).
  • Плануєте переміщення орендарів (ребаланс) як рутинну операцію.

Як це ламається:

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

MySQL vs PostgreSQL: механіки ізоляції, які мають значення

Обидві системи можуть керувати великими SaaS-флотами. Обидві можуть завдати болю. Різниця в тому, як вони схиляють вас до безпечних налаштувань
(або дозволяють будувати «зброю самопошкодження» з відмінним аптаймом).

Ізоляція даних: інструменти примусового забезпечення

PostgreSQL: Row Level Security (RLS) — першокласний інструмент ізоляції

RLS у PostgreSQL дозволяє нав’язувати фільтри орендаря всередині бази. Правильно налаштована, вона перетворює «ми завжди додаємо tenant_id»
на «база відхилить ваш запит, якщо ви забули». Це не розкіш — це риса виживання.

RLS не чарівна. Політики можна обійти ролями з привілеєм BYPASSRLS, і погано спроєктовані політики можуть вбити продуктивність.
Але це дає декларативний захисний паркан.

MySQL: ізоляція здебільшого — проблема дисципліни

У MySQL немає RLS у стилі PostgreSQL. Ви можете апроксимувати це за допомогою представлень, збережених процедур, прав definer або
дуже суворого рівня запитів. Насправді більшість команд покладаються на фільтрацію на рівні додатку та права доступу.

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

Паралелізм і блокування: як народжуються галасливі сусіди

PostgreSQL: MVCC з реаліями autovacuum

MVCC у PostgreSQL означає, що читання не блокують записи, що чудово для змішаних навантажень. Але мертві tuple накопичуються і
мають прибиратися через vacuum. У multi-tenant системах часто виникає нерівномірний churn: один орендар інтенсивно оновлює,
і автовакаум працює понаднормово, поки інші дивуються, чому латентність зросла.

MySQL (InnoDB): MVCC з іншими гострими кутами

InnoDB також використовує MVCC, але поведінка блокувань і gap lock під певними рівнями ізоляції може дивувати команди,
особливо при запитах по діапазонах та вторинних індексах. Довгі транзакції — універсальний ворог, але «форма» болю інша.

Операційна ізоляція: переміщення орендарів, відновлення, обмеження

Переваги PostgreSQL

  • Schema-per-tenant природна та добре підтримується.
  • RLS може зробити «спільні таблиці» безпечнішими у масштабі.
  • Логічна реплікація дозволяє вибіркові схеми реплікації/міграції (за умови обережного проєктування).

Переваги MySQL

  • Екосистема реплікації дуже зріла; операційні інструменти широко відомі.
  • Передбачувана продуктивність для певних OLTP-патернів сильна, і у багатьох організацій є глибока експертиза MySQL.
  • Database-per-tenant простий, якщо ви вже розглядаєте «schema = database».

Якщо хочете прямолінійної рекомендації: якщо ви налаштовані на shared-table multi-tenancy і бажаєте, щоб база сама
нав’язувала межі, PostgreSQL — більш поблажливий партнер. Якщо ви робите database-per-tenant з сильною автоматизацією
операцій і стабільним шаром запитів, MySQL може бути цілком прийнятним. Модель tenancy — ключове рішення; рушій лише посилює
наслідки помилок.

Як ізоляція ламається під час зростання

Режим відмови 1: «Ми завжди додамо tenant_id пізніше»

Додавати межі орендарів заднім числом — як встановлювати ремені безпеки після початку гонки. Можна зробити, але ви дізнаєтесь,
від чого залежали відсутні обмеження: глобальні припущення про унікальність, спільні sequence, фонoві джоби, кеші з неправильними ключами
та аналітичні запити, які раптом стають дорогими.

Режим відмови 2: Одна міграція стає N міграцій

При schema-per-tenant або database-per-tenant зміни схеми стають операцією на флоті. Інструмент міграції, який працював
для однієї БД, тепер потребує батчування, повторів, идемпотентності та спостережуваності. Це не опціонально; це ціна ізоляції.

Режим відмови 3: База даних стає вашим планувальником

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

Жарт #1: База даних — погана черга, але відмінне місце, щоб зберегти докази того, що ви все ж таки спробували.

Режим відмови 4: Переміщення орендарів вважають рідкісними

Якщо ви шардуєте, ви повинні вміти переміщувати орендарів рутинно. Ставте переміщення рутинними, і ви побудуєте інструменти, контрольні суми,
подвійні записи (якщо потрібно), playbook’и cutover та шляхи відкату. Якщо вважати їх рідкісними, кожне переміщення стає індивідуальним,
стресовим інцидентом із керівництвом, що «приходить послухати».

Режим відмови 5: Ваша ізоляція працює, але ваша спостережуваність — ні

Навіть якщо межі даних коректні, вам потрібно приписувати навантаження до орендаря. Без метрик і логів з тегами орендаря
кожна проблема продуктивності — гра в здогадки. Здогадки дорогі. І вони зазвичай відбуваються під час аутейджів, коли всі емоційно
зацікавлені помилятися швидко.

Цікаві факти та історичний контекст (те, що змінює рішення)

  1. Походження PostgreSQL сягає проєкту POSTGRES в UC Berkeley у 1980-х; він перейняв дослідницьку культуру, що відображається у можливостях на кшталт MVCC та розширюваності.
  2. MySQL став веб-стандартом на ранній LAMP-епохі переважно тому, що його було просто запускати і він був швидким для звичайних патернів, а не тому, що мав найсуворіший набір реляційних функцій.
  3. InnoDB не завжди був за замовчуванням; історично MySQL постачався з MyISAM як дефолт, який не мав транзакцій — історія, яка все ще впливає на фольклор і старі деплойменти.
  4. RLS у PostgreSQL з’явився у v9.5 (середина 2010-х), тому старі SaaS-стеки часто будували власні шари ізоляції за допомогою представлень або конвенцій ORM.
  5. Autovacuum існує через MVCC; модель конкурентності PostgreSQL потужна, але «сміттєзбирання» — це операційний податок, який треба закладати при churn у multi-tenant системах.
  6. Реплікація MySQL еволюціонувала поетапно: statement-based → row-based → mixed, що змінювало ступінь безпеки і передбачуваності переміщень даних на рівні орендаря під різними навантаженнями.
  7. Партиціювання дорослішало з часом в обох рушіях; ранні реалізації були обмежені, тому старі дизайни часто уникали партицій і пізніше платили за це великими таблицями.
  8. Обробка підключень різниться культурно: PostgreSQL очікує важчих з’єднань і заохочує пулінг; MySQL має довгу історію багатьох коротких з’єднань у веб-стеках, що формує дефолти та інструменти.

Три корпоративні міні-історії (і їхні уроки)

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

Середній B2B SaaS використовував модель спільних таблиць у MySQL. У кожній таблиці був tenant_id. Їхній ORM мав «default scope»,
який автоматично фільтрував по орендарю. Здавалося безпечно. Було швидко. Всі заспокоїлись.

Потім додали фонoвий джоб «очистити старі сесії». Його написали в окремому сервісі, що не використовував ORM.
Інженер використав простий delete-запит у таблиці sessions, припускаючи, що фільтр по орендарю «опрацьований інакше». Ні.
Запит видалив сесії в усіх орендарів, які відповідали умовам віку.

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

Виправлення не було просто «будь обережним». Вони реалізували безпечний шар доступу до БД для фонoвих сервісів, додали lint для запитів щодо фільтрів орендаря
та ввели жорстке правило: будь-який крос-орендарський запит має бути явним та проходити рев’ю.
Також вони запланували застосувати PostgreSQL RLS для найризикованіших спільних таблиць — бо люди творчі, особливо втомлені.

Міні-історія #2: Оптимізація, що повернулась бумерангом

Інша компанія використовувала PostgreSQL зі спільними таблицями та RLS. Один орендар швидко виріс і почав генерувати величезні обсяги подій.
Інженери вирішили «оптимізувати», додавши частковий індекс під запити того орендаря.
Здавалося розумно: швидші запити для великого клієнта, менше загального навантаження.

Частковий індекс був визначений за предикатом, що включав tenant_id і статус. Він прискорив цільові запити.
Але також збільшив write-amplification і витрати на обслуговування саме для таблиць з найбільшим churn. Autovacuum почав відставати,
збільшився bloat, і інші орендарі — які навіть не отримали вигоди від індексу — зауважили зростання латентності.

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

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

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

Фінансовий SaaS використовував schema-per-tenant в PostgreSQL. Це не було гламурно і вимагало раннера міграцій, який міг
застосовувати зміни по орендарях з суворим порядком. Кожна схема мала ті самі таблиці, ті самі обмеження та той самий набір розширень. Без винятків.

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

Одне розгортання внесло баг у міграцію, який видаляв індекс і переставляв його concurrently, але таймаут і повтор спричинили неконсистентний стан
в частині схем орендарів. Деякі орендарі бачили повільні запити; інші — ні. Система була «up», але досвід клієнтів — ні.

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

Практичні завдання: команди, виводи та рішення, які ви приймаєте

Ізоляцію орендарів не отримують аргументами на дизайн-рев’ю. Її доводять у 02:00 з шелом і холодним відчуттям зради. Нижче — практичні завдання, які можна виконати сьогодні.

Завдання 1: Знайдіть найгучніші запити в MySQL

cr0x@server:~$ mysql -e "SELECT DIGEST_TEXT, COUNT_STAR, SUM_TIMER_WAIT/1000000000000 AS total_s, AVG_TIMER_WAIT/1000000000000 AS avg_s FROM performance_schema.events_statements_summary_by_digest ORDER BY SUM_TIMER_WAIT DESC LIMIT 5;"
+--------------------------------------------+------------+----------+---------+
| DIGEST_TEXT                                | COUNT_STAR | total_s  | avg_s   |
+--------------------------------------------+------------+----------+---------+
| SELECT * FROM orders WHERE tenant_id = ?   |      92831 |  1842.11 | 0.01984 |
| SELECT ... JOIN ... WHERE created_at > ?   |       8421 |  1201.33 | 0.14266 |
| UPDATE events SET status = ? WHERE id = ?  |    2419921 |   992.77 | 0.00041 |
| SELECT ... WHERE tenant_id = ? ORDER BY ?  |     182003 |   774.55 | 0.00425 |
| DELETE FROM sessions WHERE expires_at < ?  |      12044 |   701.62 | 0.05825 |
+--------------------------------------------+------------+----------+---------+

Що це означає: загальний час, витрачений на кожен digest. Великий total_s означає значний агрегований вплив; великий avg_s — повільний виклик у середньому.

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

Завдання 2: Виявити очікування блокувань у MySQL (хто блокує кого)

cr0x@server:~$ mysql -e "SELECT r.trx_id waiting_trx, r.trx_mysql_thread_id waiting_thread, b.trx_id blocking_trx, b.trx_mysql_thread_id blocking_thread, TIMESTAMPDIFF(SECOND, b.trx_started, NOW()) blocking_s FROM information_schema.innodb_lock_waits w JOIN information_schema.innodb_trx b ON b.trx_id=w.blocking_trx_id JOIN information_schema.innodb_trx r ON r.trx_id=w.requesting_trx_id ORDER BY blocking_s DESC LIMIT 5;"
+-------------+---------------+-------------+----------------+-----------+
| waiting_trx | waiting_thread| blocking_trx| blocking_thread| blocking_s|
+-------------+---------------+-------------+----------------+-----------+
| 4519281     | 3221          | 4519012     | 3189           | 97        |
+-------------+---------------+-------------+----------------+-----------+

Що це означає: транзакція блокує інших приблизно 97 секунд. Зазвичай це довга транзакція або відсутній індекс.

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

Завдання 3: Перевірити довжину історії InnoDB (undo-pressure)

cr0x@server:~$ mysql -e "SHOW ENGINE INNODB STATUS\G" | grep -E "History list length|TRANSACTIONS"
TRANSACTIONS
History list length 138429

Що це означає: велика довжина history list вказує, що purge відстає, часто через довгі транзакції.

Рішення: Шукайте довгі транзакції; фіксуйте пакетні джоби та звіти, що утримують снапшоти занадто довго. Класичний сценарій multi-tenant: один клієнт запустив звіт на годину.

Завдання 4: Знайти запити без фільтра орендаря в PostgreSQL через pg_stat_statements

cr0x@server:~$ psql -d appdb -c "SELECT query, calls, total_exec_time::int AS total_ms, mean_exec_time::int AS mean_ms FROM pg_stat_statements ORDER BY total_exec_time DESC LIMIT 5;"
                                                     query                                                      | calls | total_ms | mean_ms
----------------------------------------------------------------------------------------------------------------+-------+----------+---------
 SELECT * FROM orders WHERE created_at > $1 ORDER BY created_at DESC LIMIT $2                                     | 12021 |  8123340 |     675
 SELECT * FROM orders WHERE tenant_id = $1 AND created_at > $2 ORDER BY created_at DESC LIMIT $3                 | 99311 |  4901221 |      49
 UPDATE events SET status = $1 WHERE id = $2                                                                       | 89122 |   811220 |       9
 SELECT ... JOIN ... WHERE tenant_id = $1 AND state = $2                                                           | 24011 |   644331 |      26
 DELETE FROM sessions WHERE tenant_id = $1 AND expires_at < now()                                                   |  8012 |   499112 |      62

Що це означає: верхній запит не має фільтра по орендарю. У моделі спільних таблиць це червоний сигнал.

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

Завдання 5: Перевірити, чи RLS увімкнено і політики існують (PostgreSQL)

cr0x@server:~$ psql -d appdb -c "\dp+ public.orders"
                                     Access privileges
 Schema | Name   | Type  | Access privileges | Column privileges | Policies
--------+--------+-------+-------------------+-------------------+-------------------------------
 public | orders | table | app_user=arwdDxt/app_owner           |                   | tenant_isolation (RLS)

Що це означає: політики присутні, і RLS активна для цієї таблиці (показано у полі політик).

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

Завдання 6: Перевірити, чи роль може обходити RLS (PostgreSQL)

cr0x@server:~$ psql -d appdb -c "SELECT rolname, rolbypassrls FROM pg_roles WHERE rolname IN ('app_user','app_owner');"
  rolname  | rolbypassrls
-----------+-------------
 app_user  | f
 app_owner | t

Що це означає: app_owner може обходити RLS. Це може бути нормально для міграцій, але катастрофічно для runtime-доступу додатку.

Рішення: Переконайтеся, що додаток використовує роль з rolbypassrls = false. Відокремте ролі для міграцій/адміну від ролей для runtime.

Завдання 7: Знайти довгі транзакції в PostgreSQL

cr0x@server:~$ psql -d appdb -c "SELECT pid, usename, now()-xact_start AS xact_age, state, left(query,80) AS q FROM pg_stat_activity WHERE xact_start IS NOT NULL ORDER BY xact_start ASC LIMIT 5;"
 pid  | usename  |   xact_age   | state  | q
------+----------+--------------+--------+--------------------------------------------------------------------------------
 8421 | app_user | 01:22:11     | active | SELECT * FROM orders WHERE created_at > $1 ORDER BY created_at DESC

Що це означає: транзакція відкрита 82 хвилини. Це може блокувати vacuum і спричиняти bloat таблиць.

Рішення: Якщо це звіт/експорт орендаря — перенесіть на репліку, додайте таймаути або перепроєктуйте (keyset-пагінація, інкрементні експорти).

Завдання 8: Перевірити тиск autovacuum і сигнали bloat (PostgreSQL)

cr0x@server:~$ psql -d appdb -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    |   81299311 |   22099122 | 2025-12-31 10:42:11.12345+00
 orders    |    9921121 |    2200122 | 2025-12-31 10:39:01.01234+00

Що це означає: велика кількість мертвих tuple: churn. У multi-tenant системах це часто доміновано невеликою групою орендарів.

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

Завдання 9: Підтвердити, що запит використовує індекс орендаря (PostgreSQL)

cr0x@server:~$ psql -d appdb -c "EXPLAIN (ANALYZE, BUFFERS) SELECT * FROM orders WHERE tenant_id = 42 AND created_at > now()-interval '7 days' ORDER BY created_at DESC LIMIT 50;"
                                                                      QUERY PLAN
------------------------------------------------------------------------------------------------------------------------------------------------------
 Limit  (cost=0.43..102.55 rows=50 width=128) (actual time=0.214..1.992 rows=50 loops=1)
   Buffers: shared hit=391
   ->  Index Scan Backward using orders_tenant_created_at_idx on orders  (cost=0.43..18233.22 rows=8931 width=128) (actual time=0.212..1.978 rows=50 loops=1)
         Index Cond: ((tenant_id = 42) AND (created_at > (now() - '7 days'::interval)))
 Planning Time: 0.311 ms
 Execution Time: 2.041 ms

Що це означає: скан через індекс, що включає tenant_id. Buffers — hits, не reads: кеш-френдлі.

Рішення: Якщо бачите sequential scans на спільних таблицях для запитів по орендарю, виправте індекси або партиціювання перед тим, як масштабувати кількість орендарів.

Завдання 10: Підтвердити, що запит використовує індекс орендаря (MySQL)

cr0x@server:~$ mysql -e "EXPLAIN SELECT * FROM orders WHERE tenant_id=42 AND created_at > NOW() - INTERVAL 7 DAY ORDER BY created_at DESC LIMIT 50\G"
*************************** 1. row ***************************
           id: 1
  select_type: SIMPLE
        table: orders
   partitions: NULL
         type: range
possible_keys: idx_tenant_created_at
          key: idx_tenant_created_at
      key_len: 12
          ref: NULL
         rows: 8500
     filtered: 100.00
        Extra: Using where; Using index

Що це означає: MySQL обрав ваш композитний індекс. type: range очікуваний для сканів по часовому вікну. «Using index» вказує на поведінку покриття індексом.

Рішення: Якщо з’являється type: ALL (повний скан), у вас немає ізоляції в масштабі — у вас майбутній аутейдж.

Завдання 11: Атрибутувати навантаження за користувачем/хостом (MySQL) як проксі для орендаря

cr0x@server:~$ mysql -e "SELECT user, host, SUM_TIMER_WAIT/1000000000000 AS total_s FROM performance_schema.events_statements_summary_by_user_by_event_name ORDER BY SUM_TIMER_WAIT DESC LIMIT 5;"
+----------+-----------+----------+
| user     | host      | total_s  |
+----------+-----------+----------+
| app_user | 10.0.2.%  | 9921.22  |
| app_user | 10.0.9.%  | 6211.55  |
+----------+-----------+----------+

Що це означає: підмножина хостів додатку відповідає за більшу частину часу БД. Часто ці хости виконують специфічне навантаження (експорти, бекфіли).

Рішення: Використовуйте це, щоб звузити пошук сервісу/джоба, а потім корелюйте з логами по орендарях.

Завдання 12: Перевірити накопичення з’єднань (PostgreSQL)

cr0x@server:~$ psql -d appdb -c "SELECT state, count(*) FROM pg_stat_activity GROUP BY state ORDER BY count DESC;"
 state  | count
--------+-------
 idle   |   412
 active |    62

Що це означає: сотні idle-з’єднань. Це пам’ять і накладні витрати; часто сигналізує про поганий пулінг або патерни підключень на орендаря.

Рішення: Поставте pooler перед базою (і налаштуйте його правильно), або зменшіть кількість з’єднань. Multi-tenant системи вмирають від тисячі «тільки ще одного з’єднання» ран.

Завдання 13: Перевірити гарячі таблиці за розміром (PostgreSQL)

cr0x@server:~$ psql -d appdb -c "SELECT relname, pg_size_pretty(pg_total_relation_size(relid)) AS total_size FROM pg_catalog.pg_statio_user_tables ORDER BY pg_total_relation_size(relid) DESC LIMIT 5;"
 relname | total_size
---------+------------
 events  | 412 GB
 orders  | 126 GB

Що це означає: одна таблиця домінує за зберіганням і, ймовірно, по I/O. У multi-tenant системах найбільша таблиця зазвичай там, де живе найбільший орендар.

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

Завдання 14: Перевірити відставання репліки (MySQL), щоб вирішити, де запускати важкі читання

cr0x@server:~$ mysql -e "SHOW REPLICA STATUS\G" | egrep "Seconds_Behind_Source|Replica_IO_Running|Replica_SQL_Running"
Replica_IO_Running: Yes
Replica_SQL_Running: Yes
Seconds_Behind_Source: 37

Що це означає: 37 секунд відставання. Запуск експорту орендаря на цій репліці може дати неконсистентні «найновіші» результати.

Рішення: Якщо продукт обіцяє near-real-time експорти, не відвантажуйте на відсталу репліку; замість цього обмежте або ізолюйте навантаження орендаря.

Швидкий playbook діагностики (знайдіть пляшку перед тим, як вона знайде вас)

Коли multi-tenant SaaS гальмує, потрібно швидко відповісти на два питання:
(1) чи база даних справді є вузьким місцем? (2) якщо так, який орендар або навантаження це спричиняє?

Перше: база пересичена чи просто чекає?

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

Друге: ідентифікуйте головного винуватця

  • Топ-запити за загальним часом (digest / pg_stat_statements).
  • Топ-чекання (блокування, I/O, contention буферів).
  • Довгі транзакції та ланцюги блокувань.

Третє: приписати до орендаря і вирішити, як обмежити

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

Жарт #2: Найшвидший спосіб зменшити навантаження на базу — перестати запускати запит, що його спричиняє. Революційно, знаю.

Практична драбина обмеження (від найлегшого до найінвазивнішого)

  1. Скасувати запит / вбити сесію, що спричиняє негайну шкоду.
  2. Обмежити орендаря на грані додатку (rate limit експортів, пакетні джоби).
  3. Перенести важкі читання на репліку (якщо лаг і узгодженість дозволяють).
  4. Додати відсутній індекс або переписати запит (з explain-планами, а не надіями).
  5. Розділити партицією або шаpдом, щоб відокремити галасливих орендарів.
  6. Змінити модель tenancy (болісно, але іноді правильно).

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

1) Симптом: інколи в звітах витік даних між орендарями

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

Виправлення: PostgreSQL: впровадити RLS і забезпечити, щоб runtime-ролі не могли його обходити. MySQL: впровадити суворий шар доступу до даних, заборонити raw SQL для таблиць орендаря і додати lint-тести для предикатів орендаря.

2) Симптом: один орендар спричиняє p95-сплески для всіх під час «експорту»

Корінна причина: експорт виконується на primary, скануючи великі діапазони без індексів або пагінації.

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

3) Симптом: PostgreSQL гіршає тижнями, потім покращується після обслуговування

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

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

4) Симптом: відставання реплікації MySQL зростає під час пакетних джобів орендаря

Корінна причина: великі транзакції та сплески записів; SQL-потік репліки не встигає застосувати.

Виправлення: розбити пакетні джоби на менші транзакції, додати потрібні індекси і графік запуску важких робіт. Для великих орендарів розгляньте виділену інфраструктуру.

5) Симптом: міграції стають ризиковими та повільними з ростом кількості орендарів

Корінна причина: schema-per-tenant або database-per-tenant без інструментів міграції рівня флоту; немає батчування, спостережуваності, плану відкату.

Виправлення: побудуйте раннер міграцій з: відстеженням стану по орендарях, повторами, таймаутами, управлінням конкурентністю та post-migration перевірками. Ставте це як систему розгортання.

6) Симптом: «Ми підняли інстанс, але все одно повільно»

Корінна причина: contention та неефективність, а не сире ресурcне обмеження. Великі машини не вирішать відсутні індекси або очікування блокувань.

Виправлення: ідентифікуйте топ-запити та очікування; виправляйте корінні причини. Масштабуйте лише після того, як зможете пояснити, чому це допоможе.

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

Покроковий план: оберіть модель tenancy, що переживе зростання

  1. Класифікуйте орендарів за навантаженням: дрібні/середні/слони; read-heavy vs write-heavy; пакетні проти інтерактивних.
  2. Визначте ціль ізоляції: лише межа даних, чи також ресурсна/відмовна межа. Вимоги відповідності часто примушують до останнього.
  3. Оберіть початкову модель:
    • Якщо потрібне сильне in-DB забезпечення з shared tables: PostgreSQL + RLS — прагматичний дефолт.
    • Якщо потрібні жорсткі межі на рівні орендаря: database-per-tenant або shard-per-tenant, незалежно від рушія.
  4. Проєктуйте ключі для мобільності: уникайте глобальних sequence, які ускладнюють переміщення орендарів; віддавайте перевагу UUID або ретельно обмеженим ID, якщо шардінг у планах.
  5. Робіть переміщення орендарів рутинними: реалізуйте інструменти для експорту/імпорту орендаря, перевірки підрахунків/контрольних сум та cutover.
  6. Реалізуйте спостережуваність з атрибуцією орендаря: кожен шлях запиту до БД має бути приписуваний до орендаря (принаймні на межах запиту/джоба).
  7. Визначте ліміти на орендаря: rate limits, максимальні розміри експорту, таймаути, обмеження конкурентності.
  8. Відпрацюйте відновлення: регулярні тести відновлення на рівні орендаря.

Чекліст: shared-table multi-tenancy (рекомендується PostgreSQL)

  • RLS увімкнено на кожній таблиці, що стосується орендаря.
  • Runtime-роль не може обходити RLS; роль адміну/міграціям відокремлена.
  • Кожен запит орендаря має композитний індекс, що починається з tenant_id (або стратегія партиціювання, що робить доступ по орендарю дешевим).
  • pg_stat_statements увімкнено і моніториться; топ-запити переглядаються регулярно.
  • Алерти на довгі транзакції; встановлені statement timeouts.
  • Autovacuum налаштовано для гарячих таблиць; слідкування за bloat.

Чекліст: schema-per-tenant (сильна позиція PostgreSQL)

  • Раннер міграцій підтримує батчування орендарів, идемпотентність та верифікацію.
  • Дотримується конвенція іменування схем; немає ручних «сніжинок».
  • Стратегія пулінгу підключень уникає вибуху підключень; обережне використання search_path.
  • Бекапи та вправи відновлення можуть чисто відновити одну схему орендаря.

Чекліст: database-per-tenant (MySQL або PostgreSQL)

  • Автоматичне provision: створення БД, користувачів, грантів, моніторингу, бекапів.
  • Інструменти міграції флоту; поетапні rollout-и; canary-деплойменти.
  • Центральний каталог для маршрутизації (орендар → база) з аудиторським логуванням.
  • Контроль витрат: уникати одного крихітного орендаря на надмірно великий інстанс, якщо лише вимоги відповідності не примушують.

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

1) Чи слід використовувати PostgreSQL RLS для multi-tenant SaaS?

Якщо ви використовуєте shared tables і серйозно ставитеся до запобігання доступу між орендарями — так. RLS перетворює відсутні фільтри
орендаря з латентної вразливості в помилку запиту. Це не безкоштовно — політики потрібно проєктувати і тестувати — але це реальний інструмент забезпечення.

2) Чи може MySQL безпечно робити shared-table multi-tenancy?

Може, але ви більше покладаєтесь на дисципліну додатку та процеси рев’ю. Можна побудувати безпечні патерни (представлення, збережені процедури,
обмежені облікові записи), але СУБД не буде природно нав’язувати фільтри орендаря, як PostgreSQL з RLS.

3) Чи надто багато об’єктів при schema-per-tenant у PostgreSQL?

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

4) Яка найкраща модель для орендарів з високими вимогами відповідності?

Database-per-tenant або shard-per-tenant — звичні відповіді, бо вони дають чіткі межі для бекапів, відновлення, області шифрування та контролю доступу.
Shared-table може пройти аудит, але потребує суворих контролів і чітких доказів.

5) Як запобігти інцидентам «галасливого сусіда»?

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

6) Чи вирішує шардінг проблему ізоляції орендарів?

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

7) Яка стратегія пулінгу підключень найкраща для multi-tenant PostgreSQL?

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

8) Чи повинен tenant_id бути частиною кожного первинного ключа?

Часто так для shared-table дизайнів, принаймні як частина стратегії ключів, бо це покращує локальність і селективність індексів.
Але не слід автоматично ускладнювати всі ключі. Обирайте залежно від патернів запитів, вимог унікальності та майбутніх планів шардінгу/переміщення.

9) Чи гарна ідея партиціювання за орендарем?

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

10) Коли варто перемістити орендаря в окрему базу?

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

Наступні кроки, які ви справді можете виконати

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

  1. Оберіть модель tenancy, якою зможете оперувати, а не ту, що гарно виглядає на фланелі-борді.
  2. Якщо ви на PostgreSQL зі спільними таблицями, реалізуйте RLS для найризикованіших таблиць спочатку і відділіть runtime-ролі від ролей адміну.
  3. Якщо ви на MySQL зі спільними таблицями, формалізуйте безпечний шар запитів, додайте linting/тести для предикатів орендаря і побудуйте обмеження по орендарю.
  4. Інструментуйте атрибуцію орендаря (логи, метрики, трейси). Якщо ви не можете назвати орендаря, що спричиняє навантаження — ви не зможете його ізолювати.
  5. Зробіть переміщення орендарів рутинним. Напишіть playbook, автоматизуйте його і програвайте вправи, поки ніхто не в вогні.
  6. Заплануйте нудну роботу: тести відновлення, алерти на довгі транзакції, ревізії індексів та canary-міграції. Це не «пізніше»; це плата за житло.

Цитата, яку варто мати на стіні — перефразована ідея від John Allspaw: надійність походить від системи, а не від індивідуальних геройств.
Multi-tenancy — остаточне підтвердження цього.

← Попередня
Zero Trust для офісного VPN: замініть плоскі мережі доступом за ролями
Наступна →
Чому два GPU часто гірші за один (справжні причини)

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