---
title: 'Бази данни под натоварване: заявки, индекси, MySQL vs Postgres, скалиране | DevSense'
description: 'Как да оптимизирате SQL и схемите, да изберете видовете индекси, кога логиката на ниво база данни се превръща в пречка, как се различават MySQL и PostgreSQL в продакшън и каква е цената на репликите, декомпозицията и шардинга.'
faq:
    - { question: 'Защо keyset (seek) пагинацията е по-бърза от OFFSET пагинацията при големи масиви от данни?', answer: 'OFFSET пагинацията изисква от базата данни да сканира и отхвърли всички прескочени редове (напр. сканиране на 100 000 реда, за да върне 20). Keyset пагинацията използва филтриращо условие (напр. `WHERE (created_at, id) < (?, ?)`) на базата на последния видян ред, което позволява на базата да премине директно към целевите редове чрез B-tree индекс без излишно сканиране.' }
    - { question: 'Кога трябва да използвате покриващ индекс (covering index) вместо обикновен?', answer: 'Покриващият индекс (често с помощта на клаузата `INCLUDE` или съставен индекс, съдържащ всички избрани полета) се използва, когато високочестотна заявка чете само няколко колони. Това позволява на базата данни да върне резултата директно от структурата на индекса (Index Only Scan), без да прави допълнително търсене по диска за целия ред.' }
    - { question: 'Какви са основните компромиси при използване на реплики за четене (read replicas)?', answer: 'Репликите за четене освобождават ресурс от основната база данни (master), увеличавайки капацитета за четене. Въпреки това, забавянето при репликация (replication lag) означава, че репликите могат да върнат остарели данни (eventual consistency). Разработчиците трябва да проектират маршрутизацията на заявките така, че критичните четения да отиват към основната база.' }
    - { question: 'Защо шардингът се счита за крайна мярка при скалиране на бази данни?', answer: 'Шардингът разделя данните на физически отделни инстанции на бази данни, което драстично увеличава оперативната сложност. Той предотвратява използването на JOIN заявки между шардове, глобална трансакционна цялост (без бавни двуфазни комити) и прости глобални ограничения за уникалност, като същевременно усложнява ребалансирането на данните.' }
published: '2026-05-31'
---
# Бази данни под натоварване: оптимизация на заявки, индекси, двигатели и скалиране

Повечето истории за производителността на приложенията са скучни, докато изведнъж не спрат да бъдат такива. Таблото за управление изглежда добре при 90 милисекунди време за реакция, след което стартира маркетингова кампания, някой отчет свързва шест таблици и изведнъж **количката за плащане** и **смяната на парола** засядат в обща опашка пред диска. Решенията рядко се свеждат до едно копче: те изискват **намаляване на мрежовите заявки (round-trips)**, **индекси, които съответстват на реалните филтри**, **честни изчисления на капацитета** и признанието, че една логическа база данни не може да расте безкрайно.

**Свързани ръководства:** [Сбор на събития при високо натоварване](high-load-event-ingestion) · [Сравнение на опашки от съобщения](message-queues-compared) · [Наблюдаемост и мониторинг](observability-monitoring-laravel)

## Съдържание

* [Измервайте преди да „оптимизирате“](#measure)
* [Оптимизация на ниво заявки с примери](#queries)
* [Дизайн на схеми, който остарява добре](#schema)
* [Видове индекси и кога помагат](#indexes)
* [Защо тежката бизнес логика в базата данни пречи на екипите](#db-logic)
* [MySQL срещу PostgreSQL на практика](#mysql-vs-postgres)
* [Пътища за скалиране и проблемите, които носят](#scaling)
* [Декомпозиция на бази данни без илюзии](#decomposition)
* [Шардинг: ключове, проблеми с междинни JOIN заявки и ребаланс](#sharding)
* [Чести грешки](#common-mistakes)
* [Контролен списък преди шардинг или купуване на хардуер](#checklist)
* [Тест за самопроверка](#self-test-quiz)

---

<a id="measure"></a>
## Измервайте преди да „оптимизирате“

**Перцентилите на латентност** са по-важни от средните стойности. Средно време от 40 ms може да крие опашка, при която **един процент** от заявките отнема над две секунди поради изчакване на заключвания или студен кеш.

Практически сигнали:
* **Логове за бавни заявки (slow query logs)** с праг, който преразглеждате с растежа на данните — не записвайте всичко, за да не свикнат хората да игнорират шума.
* **`EXPLAIN` (Postgres / MySQL 8+)** върху реалните заявки, изпълнявани в продакшън, а не само върху тестови примери.
* **Брой връзки** и **натоварване на пула**; много инциденти от тип „бавна база данни“ всъщност са **изчакване за свободна връзка**, а не бавен диск.

> [!NOTE]
> **Споделено състояние**
> Базата данни е споделено състояние. Всичко, което увеличава **натоварването на CPU, заключванията или дисковия I/O** на основния сървър, се конкурира с всички останали процеси.

---

<a id="queries"></a>
## Оптимизация на ниво заявки с примери

### Извличайте само това, което ви трябва

Широките `SELECT *` заявки над тежки редове карат базата данни да пренася байтове, които след това просто ще изхвърлите в кода. Предпочитайте изрично изписване на колоните, особено за таблици с големи текстови или JSON полета.

```sql
-- database/queries/fetch_users.sql
-- Избягвайте (пренася всяка колона, включително тежки текстови полета)
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;
```

### Структура на JOIN заявките и кардиналност

Вложените цикли (nested loops) са евтини, когато вътрешната таблица **използва индекс** и връща малко редове. Hash joins работят по-добре при големи обеми от данни. Базата данни избира стратегия въз основа на вътрешна статистика. Ако даден JOIN увеличава драстично броя на редовете поради връзка **много-към-много без външни ключове**, коригирайте модела, а не таймаутите.

### Пагинация без сканиране на цялата таблица

Използването на `OFFSET` изглежда просто, но при големи отмествания базата данни **все пак сканира** и отхвърля всички предходни редове.

```sql
-- database/queries/offset_pagination.sql
-- Забавя се пропорционално на стойността на :offset
SELECT id, title, created_at
FROM posts
ORDER BY created_at DESC, id DESC
LIMIT 20 OFFSET 100000;
```

**Keyset (seek) пагинацията** използва стойностите на последно прочетения ред:

```sql
-- database/queries/seek_pagination.sql
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`

За проверка на съществуване на записи, полусъединенията (semi-joins) обикновено работят по-бързо:

```sql
-- database/queries/exists_lookup.sql
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'
);
```

### Агрегации и анализи

Тежките групирания `GROUP BY` над активни OLTP таблици са класически начин да **сринете производителността**. Смятайте агрегатите предварително в **обобщаващи таблици (summary tables)**, **материализирани изгледи (materialized views)** в Postgres или ги прехвърляйте в **OLAP база** (ClickHouse и др.), ако бизнесът изисква интерактивни анализи в реално време.

---

<a id="schema"></a>
## Дизайн на схеми, който остарява добре

* **Типове данни, съответстващи на реалността** — съхранявайте пари в **копейки/центове (цели числа)** или във формат **decimal** с фиксирана точност, но не и във float/double.
* **Отношение към NULL** — nullable колоните усложняват индексирането и статистиката. Използвайте ги само когато липсата на стойност има ясен бизнес смисъл.
* **Външни ключове (Foreign keys)** — те леко забавят записа, но гарантират **целостността на данните**. Премахването им „за скорост“ почти винаги води до **невалидни данни** и логически грешки в приложението.
* **Денормализация** — чистата теория на нормализацията често отстъпва пред реалността при четене на данни. Разумната, задокументирана **денормализация на полета** може да спести тежки JOIN заявки, но изисква **контрол на консистентността** от страна на приложението.

---

<a id="indexes"></a>
## Видове индекси и кога помагат

Не всички индекси са еднакви и B-Tree невинаги е най-доброто решение.

| Тип индекс | Употреба | Забележки |
|------|-------------|--------|
| **B-Tree (дърво)** | Сравнение, диапазони, сортиране | Основен индекс. **Порядокът на колоните е важен** при съставни индекси: водещите колони трябва да съответстват на най-честите филтри. |
| **Hash (хеш)** | Точно съвпадение (`=`) | По-малко възможности спрямо B-Tree. В MySQL се използва предимно за таблици в паметта или като вътрешен адаптивен индекс. |
| **Full-text (пълнотекстов)** | Търсене на думи в текст | Различни реализации в InnoDB FTS и Postgres `tsvector` + GIN. |
| **GIN / GiST (Postgres)** | Търсене в JSONB, масиви, гео-данни | Мощен инструмент, но изисква контрол върху **раздуването на индекса (bloat)** и скоростта на запис. |
| **Spatial (пространствен)** | Географски заявки | Зависи от базата данни (разширение PostGIS за Postgres, нативна поддръжка в MySQL). |

### Поредност на колоните в съставен индекс

Ако заявките почти винаги се филтрират по `tenant_id`, тази колонка трябва да бъде **първа** в индекса:

```sql
-- database/migrations/create_composite_index.sql
-- Подходящо за заявки като: WHERE tenant_id = ? AND status = 'open'
CREATE INDEX orders_tenant_status_idx ON orders (tenant_id, status);
```

### Покриващи индекси (Covering indexes)

Ако индексът **съдържа всички заявени полета**, базата данни може да върне резултата директно от индекса, без да чете самия ред от таблицата на диска (в Postgres това се нарича **Index Only Scan**).

```sql
-- database/migrations/create_covering_index.sql
CREATE INDEX sessions_lookup_idx ON sessions (user_id) INCLUDE (last_seen_at);
```

### Частични / филтрирани индекси (Partial indexes)

Ако филтърът в заявката е статичен и изключва по-голямата част от таблицата, индексирайте само необходимия сегмент:

```sql
-- database/migrations/create_partial_index.sql
-- Пример за PostgreSQL
CREATE INDEX invoices_open_due_idx
ON invoices (due_at)
WHERE status = 'open' AND due_at IS NOT NULL;
```

---

<a id="db-logic"></a>
## Защо тежката бизнес логика в базата данни пречи на екипите

Съхраняваните процедури, тригерите и сложните изгледи (views) не са „лоша технология“. Те са **въпрос на собственост на кода и внедряване**.

Основни проблеми:
* **Версиониране** — кодът на приложението преминава през Git, CI/CD и постепенно внедряване. Процедурите в базата данни се обновяват по-трудно, което води до несъответствие на схемите между средите.
* **Тестване** — бизнес логиката в приложението се тества лесно с unit тестове. Логиката в тригерите се тества изолирано изключително трудно.
* **Преносимост** — кодът на приложението е преносим. Логиката на PL/pgSQL ви обвързва трайно с конкретна база данни.
* **Наблюдаемост (observability)** — трасетата и APM виждат добре границите на приложението. Веригите от тригери изглеждат като **необяснимо забавяне на базата данни**, докато не направите детайлен анализ.

Къде логиката в базата данни **все още е полезна**:
* **Ограничения за цялост** — `CHECK`, външни ключове и уникални индекси гарантират съхранението на данните по-надеждно, отколкото надеждата, че нито една услуга няма да пропусне валидация.
* **Идемпотентност** — уникалният индекс на ниво база данни решава проблема със състезание за ресурси (race conditions) при паралелни заявки.

---

<a id="mysql-vs-postgres"></a>
## MySQL срещу PostgreSQL на практика

И двете бази данни са готови за продакшън, но са устроени различно.

| Тема | MySQL (InnoDB) | PostgreSQL |
|-------|-------------------------|------------|
| **Съхранение на данни** | Таблици с клъстериран **първичен ключ** (данните лежат в листата на PK); вторичните индекси сочат към PK | Данните лежат в купчина (**heap**); индексите сочат към физическия адрес на реда (TID) |
| **MVCC и отпадъци** | История на промените в **Undo лог**; почистването става автоматично в фонов режим | Мъртвите редове остават в таблицата; изисква се редовен **VACUUM** (autovacuum) |
| **SQL възможности** | Добър стандарт; исторически по-толерантен към типове данни | Богати CTE, прозоречни функции, **LATERAL JOIN**, пълноценен **JSONB**, диапазонни типове |
| **Разширения** | Почти липсват в ядрото | Огромна екосистема (**PostGIS**, **pgvector**, **citext** и др.) |
| **Репликация** | Зряла репликация на базата на **binlog** | Физическа и логическа репликация |

Примери за работа с JSON, показващи разликата в синтаксиса:

Postgres (оператор **containment** за JSONB):
```sql
-- database/queries/postgres_jsonb_containment.sql
SELECT id FROM events WHERE payload @> '{"kind":"purchase"}'::jsonb;
```

MySQL (извличане от JSON в версия 8.x):
```sql
-- database/queries/mysql_json_extract.sql
SELECT id FROM events WHERE JSON_UNQUOTE(JSON_EXTRACT(payload, '$.kind')) = 'purchase';
```

---

<a id="scaling"></a>
## Пътища за скалиране и проблемите, които носят

### Вертикално скалиране (ъпгрейд на хардуера)
Най-лесният път, докато не се сблъскате с хардуерни ограничения, пропускателна способност на шината или висока цена. Освен това се увеличава рискът от единична точка на отказ (Single Point of Failure).

### Реплики за четене (Read replicas)
* **Плюсове:** разтоварват master сървъра от тежки SELECT заявки, използват се за резервни копия.
* **Минуси:** **забавянето на репликацията** води до четене на остарели данни.

### Пулери на връзки (Connection poolers)
Моделът „една TCP връзка на заявка“ не се скалира при високо натоварване. Специализирани пулери като **PgBouncer** (Postgres) или ProxySQL (MySQL) намаляват оверхеда.

### Кеширане (Redis, Memcached)
Кешът маскира честите четения, но не решава проблема с **увеличаване на записите** при висок поток от обновявания.

---

<a id="decomposition"></a>
## Декомпозиция на бази данни без илюзии

**Декомпозицията на бази данни по услуги (database-per-service)** не е просто мода, а начин за разделяне на контекста и намаляване на свързаността. При това губите възможността да правите лесни JOIN заявки между таблиците на различните услуги, преминавайки към **разпределени трансакции** (Saga и др.).

Антипаттерн: **няколко услуги да пишат в една и съща таблица** — това е класически разпределен монолит, при който базата данни се превръща в скрита точка на интеграция.

---

<a id="sharding"></a>
## Шардинг: ключове, проблеми с междинни JOIN заявки и ребаланс

**Шардингът** е разпределяне на редовете от таблицата по **различни физически бази данни** въз основа на **ключ за шардиране** (напр. `user_id` или `tenant_id`).

Какво се подобрява:
* Премахват се ограниченията за диск и CPU на един сървър при запис.
* Сривът на един шард засяга само част от потребителите (blast radius).

С какви трудности ще се сблъскате:
* Невъзможно е да направите JOIN между шардове без извличане на данните в приложението.
* Трудно е да се гарантира уникалност по полета, които не съдържат ключа за шардиране.
* **Ребалансиране** — ако един клиент претовари своя шард, прехвърлянето на данни към друг сървър е сложна операция.

---

<a id="common-mistakes"></a>
## Чести грешки

1. **Използване на OFFSET при големи таблици**: Използване на пагинация от тип `LIMIT ... OFFSET`, която кара базата данни да сканира милиони редове преди връщане на резултата.
2. **Неправилен ред на колоните в съставен индекс**: Поставяне на филтър за диапазон (напр. `created_at`) преди точните филтри за равенство в дефиницията на индекса.
3. **Обща база данни за микроуслуги**: Директен достъп на различни услуги до едни и същи таблици, което води до неявна свързаност и невъзможност за независима промяна на схемите.
4. **Сложна бизнес логика в тригери**: Извеждане на валидации и изчисления в тригери на СУБД, което лишава екипа от тестове, контрол на версиите и мониторинг.

---

<a id="checklist"></a>
## Контролен списък преди шардинг или купуване на хардуер

1. **Показва ли `EXPLAIN`** последователно сканиране (Seq Scan), което може да се коригира с обикновен индекс?
2. **Не са ли закъсненията** причинени от N+1 заявки на ниво ORM на приложението?
3. **Кое е тясното място:** дисков I/O, CPU или време за изчакване на блокировки поради дълги трансакции?
4. **Готово ли е приложението** за закъснения при репликация при пренасочване на четенето към реплики?
5. **Може ли да се намали размерът на базата** чрез архивиране на стари данни в студено хранилище, вместо усложняване на топологията?

---

## Извод

Базите данни обичат предвидимост, правилен дизайн и постоянен мониторинг. Индексите и изборът на двигател позволяват да се спечели време; **архитектурата** — опашки, разделяне на моделите за четене и запис (CQRS) и шардинг — определя лимитите на скалиране на вашата система.

---

<a id="self-test-quiz"></a>
## Тест за самопроверка

### Въпрос 1: Защо заявка от типа `SELECT * FROM users ORDER BY created_at DESC LIMIT 1` може да работи бавно при таблица с 10 млн. реда, дори ако колоната `created_at` е индексирана?
- А) B-Tree индексите по принцип не поддържат сортиране в низходящ ред.
- Б) Ако колоната `created_at` позволява NULL стойности, базата данни може да сканира цялата таблица за търсене на NULL стойности, игнорируя индекса.
- C) Задължително трябва да се използва покриващ индекс.

<details>
<summary>Кликнете, за да видите отговора</summary>

**Отговор: Б**
Ако колоната позволява NULL и в заявката няма филтър като `WHERE created_at IS NOT NULL`, планировчикът на базата данни може да реши, че е по-евтино да сканира цялата таблица (Seq Scan), за да подреди правилно NULL стойностите при сортирането.
</details>

### Въпрос 2: Кой тип индекс е най-подходящ за търсене на ключове вътре в произволни JSONB документи в PostgreSQL?
- А) B-Tree индекс.
- Б) GIN индекс (Generalized Inverted Index).
- C) Hash индекс.

<details>
<summary>Кликнете, за да видите отговора</summary>

**Отговор: Б**
GIN индексите са проектирани специално за индексиране на съставни стойности като масиви и JSONB структури, осигурявайки бързо търсене по вложени ключове и стойности.
</details>