Бази данни под натоварване: заявки, индекси, мащабиране и компромиси
Историите за производителност са скучни, докато не спрат да бъдат. Таблото е зелено на деветдесет милисекунди, после идва кампания, справка събира шест джойна и изведнъж плащането и нулирането на парола чакат на една и съща опашка зад едно и също хранилище. Рядко помага едно копче — по-често са по-малко round-trip-ове, индекси според реалните WHERE, честна сметка за капацитет и понякога признание, че една логическа база не е безкрайна.
Свързани материали: Високонатоварени потоци от събития · Сравнение на опашки и брокери · Sail: бази и Docker услуги
Съдържание
- Първо мерете, после „оптимизирайте“
- Оптимизация на заявки с примери
- Схема, която не отмъщава след година
- Видове индекси и кога помагат
- Защо тежката логика в базата удря екипа
- MySQL срещу PostgreSQL на практика
- Пътища за мащабиране и проблемите им
- Декомпозиция без приказки
- Шардиране: ключ, кръстосано-шардова болка, ребаланс
- Чеклист преди шардове и железо
Първо мерете, после „оптимизирайте“
Перцентилите на латентността са по-информативни от средната стойност. Средно 40 ms може да крие опашка, където един процент от заявките минават две секунди заради заключвания или студен кеш.
Практични сигнали:
- Лог на бавни заявки с праг, който преглеждате с растежа на данните — не „всичко“, за да не свикнете с шума.
EXPLAIN(Postgres) /EXPLAIN(MySQL 8+) с реалните параметри от продукшън.- Брой връзки и пълен пул; много инциденти „базата е бавна“ са чакане за connection.
Базата е споделено състояние. Натоварването на CPU, заключвания и I/O на първичния възел се надпреварва с всичко критично.
Оптимизация на заявки с примери
Вземайте само нужните колони
Широкият SELECT * по „дебели“ редове кара СУБД да влачи байтове, които приложението хвърля. Изричен списък от колони е важен при голям текст или JSON.
-- По-слабо: всички колони
SELECT * FROM users WHERE country = 'DE' LIMIT 50;
-- По-добре
SELECT id, email, display_name, created_at
FROM users
WHERE country = 'DE'
ORDER BY created_at DESC
LIMIT 50;
Форма на джойна и мощност на връзките
Планировъкът избира стратегия според двигателя и статистиката. Ако джойнът надува редове заради много-към-много без ограничения, оправете модела, не само таймаута.
Пагинация без сканиране на целия офсет
При голям OFFSET двигателят често обхожда прескочените редове.
SELECT id, title, created_at
FROM posts
ORDER BY created_at DESC, id DESC
LIMIT 20 OFFSET 100000;
Keyset пагинация с последния ключ на сортиране:
SELECT id, title, created_at
FROM posts
WHERE (created_at, id) < (:last_created_at, :last_id)
ORDER BY created_at DESC, id DESC
LIMIT 20;
Нужен е индекс в същия ред (напр. (created_at DESC, id DESC)).
EXISTS срещу IN за „има ли ред“
SELECT o.id, o.total_cents
FROM orders o
WHERE EXISTS (
SELECT 1 FROM order_items i
WHERE i.order_id = o.id AND i.sku = 'GOLD-001'
);
Сляпо пренаписване на всеки IN (подзаявка) не е правило — потвърдете с EXPLAIN.
Агрегации и отчети
Тежък GROUP BY върху сурови OLTP таблици удря опашката на латентността. Предварително обобщение в обобщени таблици, materialized views (Postgres) или OLAP слой (вж. гайда за потоци от събития).
Схема, която не отмъщава след година
- Типове за пари — малки единици като цяло число или
DECIMALс ясна точност, не float, ако има сверка. - NULL — когато „неизвестно“ е част от домейна; като мързелив default усложнява индекси и статистика.
- Външни ключове — малко струват на запис и дават референтна честност. Изключването „за скорост“ често води до сираци и недетерминизъм в приложението.
- Нормализация срещу четене — чистата теория не знае честотата на заявките ви. Съзнателна денормализация с документация може да победи джойнове — цената е сложност при запис и инварианти.
Видове индекси и кога помагат
| Идея | Типична употреба | Бележки |
|---|---|---|
| B-tree (подразбиране) | Равенство и интервал | Повечето индекси; при съставен редът на колоните е критичен — водещите трябва да съвпадат с типичните WHERE / ORDER BY. |
| Hash | Точно равенство (където се поддържа) | При Postgres в стари версии имаше нюанси с репликация — вижте версията. При MySQL hash е по-близо до MEMORY и вътрешности на InnoDB. |
| Пълнотекст | Търсене по токени | InnoDB FTS срещу tsvector + GIN в Postgres. |
| GIN / GiST (Postgres) | JSONB, масиви, пълнотекст, част от геото | Мощно; следете раздуване и цена на построяване. |
| Пространствени | Геозаявки | Често еталонът е PostGIS в Postgres; MySQL има свои правила за типове. |
Съставен индекс: ред на колоните
Ако почти винаги филтрирате по tenant_id, той обикновено е първи:
-- Добре за WHERE tenant_id = ? AND status = 'open'
CREATE INDEX orders_tenant_status_idx ON orders (tenant_id, status);
Заявка само по status може да не използва индекса ефективно — понякога е нужен втори индекс или съзнателен компромис.
Покриващ индекс
Ако индексът съдържа всички колони от SELECT, планът може да се задоволи само с него (в Postgres — Index Only Scan; в InnoDB — при подходящ клъстерен ключ).
CREATE INDEX sessions_lookup_idx ON sessions (user_id) INCLUDE (last_seen_at);
-- INCLUDE е Postgres 11+; в MySQL често съставен (user_id, last_seen_at).
Частичен индекс (Postgres)
CREATE INDEX invoices_open_due_idx
ON invoices (due_at)
WHERE status = 'open' AND due_at IS NOT NULL;
В MySQL 8+ има индекси по изрази; частичните индекси са по-удобни в Postgres — наближаване с генерирана колона + индекс.
Защо тежката логика в базата удря екипа
Съхранени процедури, тригъри и сложни изгледи не са „зло“ — те са избор за деплой и собственост.
Какво често се чупи:
- Версиониране — кодът на приложението минава през Git и rolling deploy; рутините живеят отделно; дрейф между среди боли.
- Тестване — бизнес правилата в PHP/Java се покриват по-лесно; вериги от тригъри са по-трудни за изолация.
- Преносимост — логиката в приложението оцелява при смяна на СУБД; PL/pgSQL или MySQL процедури вързват и усложняват blue/green.
- Наблюдаемост — APM е центриран в приложението; тригърите дават непрозрачна латентност без допълнителна инструментация.
Кога логиката в СУБД има смисъл: CHECK, външни ключове, уникалност по бизнес ключ, тънък одит с документация и тестове на миграции.
Полезен лозунг: не „никога в базата“, а „тежките бизнес процеси са в код със същия release pipeline като останалата система“.
MySQL срещу PostgreSQL на практика
И двете са production-grade. Разликите ухапват, ако ги третирате като взаимозаменяеми.
| Тема | MySQL (обикновено InnoDB) | PostgreSQL |
|---|---|---|
| Физическо подреждане | Клъстериран първичен ключ; вторичните индекси сочат към PK | Heap таблици; индексите са отделно; CLUSTER е операция по поддръжка |
| MVCC / боклук | Undo; закъснение на purge при дълги транзакции | Мъртви версии; VACUUM / autovacuum е част от експлоатацията |
| Възможности на SQL | Солидно ядро | По-богати CTE, прозоречни функции, LATERAL, оператори за JSONB, диапазонни типове |
| Разширения | По-тесни в ядрото | PostGIS, pgvector, citext и др. |
| Репликация | Зрял binlog стрийминг | Физическа и логическа; публикации/абонаменти за избирателни потоци |
| Изолация | Подразбиранията понякога изненадват идващите от Postgres | Позната на мнозина MVCC картина; остават аномалии при сериализуемост |
| JSON | JSON; в 8.x — функционални индекси | JSONB, GIN, богати оператори за съдържание |
Postgres — съдържание в JSONB:
SELECT id FROM events
WHERE payload @> '{"kind":"purchase"}'::jsonb;
MySQL 8.x (често с generated STORED колона + индекс под натоварване):
SELECT id FROM events
WHERE JSON_UNQUOTE(JSON_EXTRACT(payload, '$.kind')) = 'purchase';
Евристики за избор
- Postgres — богат SQL, JSONB заявки, гео, разширения, логическа репликация между слоеве.
- MySQL/MariaDB — ако хостингът, екипът или екосистемата вече са там.
И двете възнаграждават дисциплина с индекси и поддръжка (vacuum/purge).
Пътища за мащабиране и проблемите им
Вертикално мащабиране
Просто, докато не ударите еднопоточно прилагане на репликация, пропускливост на диска и NUMA. Плюс един домен на отказ.
Реплики за четене
Плюсове: разтоварване на четене, бекъпи, клонове за отчети. Минуси: лаг на репликацията — четенията са остарели; маршрутизацията трябва да е явна (Laravel може да раздели read/write в конфигурацията).
Ако кодът очаква да прочете собствената си запис от реплика — потребителят вижда „запазих — опресних — изчезна“.
Пул на връзките
Отваряне на TCP+auth на всяка HTTP заявка не се мащабира. PgBouncer (Postgres) или пулери при managed MySQL стоят между приложението и базата. Внимание към prepared statements и transaction pooling — ORM понякога предполага сесийна връзка.
Кеш (Redis и др.)
Кешът маскира горещи ключове, не лекува усилване на записа върху primary. Нужни са TTL, инвалидация и защита срещу cache stampede.
Декомпозиция без приказки
Отделна база на сервис означава по-малко случайни джойнове и по-ясен радиус на отказ. Цената са разпределени транзакции или саги, когато едно действие на потребителя реално минава през няколко хранилища.
Работещи модели: таблица outbox при собственика на събитието и пренасочване към брокер (връзка с гайда за опашки); read модели, преизградени от събития, с честен eventual consistency в UX.
Антипатерн: два сервиса пишат една таблица — това е разпределен монолит с излишни мрежови извиквания отгоре.
Шардиране: ключ, кръстосано-шардова болка, ребаланс
Шардирането разпределя редове между няколко първични възела по ключ (често user_id, tenant_id, регион).
Какво подобрява: таван на запис на машина; blast radius може да се свие при изолирани сбои — ако приложението понася частична деградация.
Какво влошава: кръстосано-шардови джойнове и глобална уникалност — нужни са координация, 2PC (скъпо) или правила в приложението; ребаланс при горещ наемател е миграционен проект; оперативна сложност — N копия на бекъп, кръпки и миграции на схема.
-- Еднаква схема на всеки шард; данни за tenant_id = 42 на шард от hash(tenant_id).
-- Отчет „за всички наематели“ става fan-out по всички шардове със сливане — скъпо и крехко.
Повечето екипи трябва да изчерпат индекси, заявки, кеш, реплики и архив/партициониране преди клиентско шардирано OLTP.
Чеклист преди шардове и железо
- Показва ли
EXPLAINпоследователно сканиране, което честен индекс премахва? - Има ли N+1 на ниво ORM независимо от скоростта на диска?
- Тесното място връзки, CPU на базата или заключвания от дълги транзакции?
- Измерен ли е лагът на репликите, ако четенето отиде на реплики?
- Може ли ограничаване на растежа чрез задържане и архив на партиции по-евтино от нова топология?
Базите награждават скучна коректност и измерване. Индексите и изборът на двигател купуват време; архитектурата — опашки, отделни read модели и понякога шардове — купува таван. Изберете най-малката промяна, която премахва измерения тесен участък, и мерете отново — утрешният bottleneck рядко съвпада с вчерашния.