SQL Теория — Часть 4




SQL Теория 4

Теория 4 — Транзакции, Архитектура PostgreSQL, Паттерны
Введение: От группировки к аналитике
В предыдущих темах мы научились хранить данные, извлекать их и оптимизировать запросы. Но что происходит, когда несколько операций должны выполниться как единое целое? Что если посреди перевода денег между счетами произойдёт сбой?
Добро пожаловать в мир транзакций и внутренней архитектуры PostgreSQL — самую «инженерную» часть работы с базами данных.

Аналогия с реальным миром: Представь банковский перевод: снять деньги с одного счёта и зачислить на другой. Эти две операции — неделимы. Если снятие произошло, а зачисление нет — деньги исчезли! Транзакции гарантируют: либо обе операции выполнятся, либо ни одна.

Что ты узнаешь:
  • Как работают транзакции и что такое ACID
  • Уровни изоляции и их проблемы
  • MVCC — как PostgreSQL обеспечивает параллельность без блокировок
  • Внутреннее устройство: WAL, VACUUM, checkpoint
  • Партиционирование, шардирование и репликация
  • Практические паттерны для production
1. Транзакции и ACID
Аналогия: Транзакция — это как атомная операция в программировании. Помнишь AtomicInteger? Здесь та же идея, но для группы SQL-команд.

📅 Историческая справка: Концепция транзакций и ACID была формализована Джимом Греем в 1970-х. За эту работу он получил премию Тьюринга в 1998 году. Интересно, что термин ACID придумал не он, а Андреас Рейтер в 1983 году — специально чтобы было легко запомнить!

1. Что такое транзакция?

Ответ Транзакция — логическая единица работы с базой данных, которая либо выполняется полностью, либо не выполняется вовсе («всё или ничего»).

Аналогия: Представь перевод денег между счетами. Либо деньги сняты с одного счёта И зачислены на другой, либо вообще ничего не произошло. «Половинчатых» состояний (деньги сняты, но не зачислены) быть не должно!
🔥 Производственная катастрофа БЕЗ транзакций
Ноябрь 2023, платёжная система:
Функция перевода денег БЕЗ транзакции:

14:32:15.123 - Запрос: перевести 10,000₽ от user_id=1 к user_id=2
14:32:15.124 - UPDATE accounts SET balance = balance - 10000 WHERE user_id = 1;
                ✅ Выполнено! Баланс user 1: 50000₽ → 40000₽

14:32:15.125 - 💥 СБОЙ СЕРВЕРА (out of memory)

14:32:45.000 - Сервер перезапущен

Результат:
• user_id=1: баланс 40000₽ (было 50000₽) ← деньги списаны ✅
• user_id=2: баланс 20000₽ (было 20000₽) ← деньги НЕ зачислены ❌
• 10,000₽ исчезли из системы! 💸

15:00 - Звонок клиента: "Куда делись мои 10,000₽?!"
15:15 - Техподдержка: "В логах нет операции зачисления..."
15:30 - Бухгалтерия: "Общий баланс не сходится на -10,000₽!"

Итог: Потеря денег, репутационный ущерб, ручное восстановление данных
Причина: Две операции выполнялись отдельно, без транзакции → сбой посреди процесса привёл к неконсистентному состоянию.

Решение с транзакцией:
BEGIN;  -- ← начало транзакции (все изменения пока "черновик")
    UPDATE accounts SET balance = balance - 10000 WHERE user_id = 1;
    -- Если здесь произойдёт сбой → АВТОМАТИЧЕСКИЙ ROLLBACK
    UPDATE accounts SET balance = balance + 10000 WHERE user_id = 2;
COMMIT;  -- ← фиксация (все изменения применяются атомарно)

-- Если сбой случится до COMMIT → всё откатится автоматически!
-- Либо обе операции выполнятся, либо ни одна.
📊 Жизненный цикл транзакции
┌─────────────────────────────────────────────────────────────┐
│ 1. BEGIN (начало транзакции)                                │
│    Получает Transaction ID (xid)                            │
│    Изменения видны только внутри транзакции                 │
└──────────────────┬──────────────────────────────────────────┘
                   ↓
┌─────────────────────────────────────────────────────────────┐
│ 2. Выполнение SQL команд                                    │
│    UPDATE, INSERT, DELETE...                                │
│    Изменения в памяти (shared buffers) + WAL                │
│    Другие транзакции НЕ видят изменения                     │
└──────────────────┬──────────────────────────────────────────┘
                   ↓
         ┌─────────┴─────────┐
         │                   │
         ↓                   ↓
┌────────────────┐  ┌────────────────┐
│ 3a. COMMIT     │  │ 3b. ROLLBACK   │
│ Применить      │  │ Отменить       │
│ изменения      │  │ изменения      │
└────────┬───────┘  └────────┬───────┘
         │                   │
         ↓                   ↓
┌────────────────┐  ┌────────────────┐
│ Изменения      │  │ Изменения      │
│ видны ВСЕМ     │  │ отброшены      │
└────────────────┘  └────────────────┘
💻 Java примеры с транзакциями
Сценарий: банковский перевод
@Service
public class TransferService {
    @Autowired
    private DataSource dataSource;

    // ❌ БЕЗ транзакции (ОПАСНО!):
    public void transferMoneyWrong(Long fromUserId, Long toUserId, BigDecimal amount) {
        try (Connection conn = dataSource.getConnection();
             Statement stmt = conn.createStatement()) {

            // Операция 1: снять деньги
            stmt.executeUpdate(String.format(
                "UPDATE accounts SET balance = balance - %s WHERE user_id = %d",
                amount, fromUserId
            ));

            // 💥 Если здесь произойдёт ошибка/сбой → деньги исчезнут!
            // (первая операция уже выполнена, вторая — нет)

            // Операция 2: зачислить деньги
            stmt.executeUpdate(String.format(
                "UPDATE accounts SET balance = balance + %s WHERE user_id = %d",
                amount, toUserId
            ));

        } catch (SQLException e) {
            // Слишком поздно! Первая операция уже применена.
            throw new RuntimeException("Transfer failed", e);
        }
    }

    // ✅ С транзакцией (ПРАВИЛЬНО):
    public void transferMoneyCorrect(Long fromUserId, Long toUserId, BigDecimal amount) {
        Connection conn = null;
        try {
            conn = dataSource.getConnection();
            conn.setAutoCommit(false);  // Отключить autocommit!

            try (PreparedStatement stmt1 = conn.prepareStatement(
                    "UPDATE accounts SET balance = balance - ? WHERE user_id = ?");
                 PreparedStatement stmt2 = conn.prepareStatement(
                    "UPDATE accounts SET balance = balance + ? WHERE user_id = ?")) {

                // Операция 1: снять деньги
                stmt1.setBigDecimal(1, amount);
                stmt1.setLong(2, fromUserId);
                int rows1 = stmt1.executeUpdate();

                if (rows1 == 0) {
                    throw new BusinessException("Счёт отправителя не найден");
                }

                // Проверка баланса:
                BigDecimal balance = getBalance(conn, fromUserId);
                if (balance.compareTo(BigDecimal.ZERO) < 0) {
                    throw new BusinessException("Недостаточно средств");
                }

                // Операция 2: зачислить деньги
                stmt2.setBigDecimal(1, amount);
                stmt2.setLong(2, toUserId);
                int rows2 = stmt2.executeUpdate();

                if (rows2 == 0) {
                    throw new BusinessException("Счёт получателя не найден");
                }

                // Если всё ОК → фиксируем:
                conn.commit();  // ✅ Обе операции применяются атомарно!

            } catch (Exception e) {
                // При любой ошибке → откатываем ВСЁ:
                conn.rollback();  // ❌ Обе операции отменяются!
                throw new RuntimeException("Transfer failed: " + e.getMessage(), e);
            }

        } catch (SQLException e) {
            throw new RuntimeException("Database error", e);
        } finally {
            if (conn != null) {
                try {
                    conn.setAutoCommit(true);  // Вернуть autocommit
                    conn.close();
                } catch (SQLException ignored) {}
            }
        }
    }

    // Вспомогательный метод:
    private BigDecimal getBalance(Connection conn, Long userId) throws SQLException {
        try (PreparedStatement stmt = conn.prepareStatement(
                "SELECT balance FROM accounts WHERE user_id = ?")) {
            stmt.setLong(1, userId);
            ResultSet rs = stmt.executeQuery();
            return rs.next() ? rs.getBigDecimal("balance") : BigDecimal.ZERO;
        }
    }
}
С Spring @Transactional (декларативно):
@Service
public class TransferService {
    @Autowired
    private JdbcTemplate jdbcTemplate;

    @Transactional  // ← Spring автоматически управляет транзакцией!
    public void transferMoney(Long fromUserId, Long toUserId, BigDecimal amount) {
        // Операция 1: снять деньги
        int rows1 = jdbcTemplate.update(
            "UPDATE accounts SET balance = balance - ? WHERE user_id = ?",
            amount, fromUserId
        );

        if (rows1 == 0) {
            throw new BusinessException("Счёт отправителя не найден");
        }

        // Проверка баланса:
        BigDecimal balance = jdbcTemplate.queryForObject(
            "SELECT balance FROM accounts WHERE user_id = ?",
            BigDecimal.class, fromUserId
        );

        if (balance.compareTo(BigDecimal.ZERO) < 0) {
            throw new BusinessException("Недостаточно средств");
            // При исключении Spring автоматически сделает ROLLBACK!
        }

        // Операция 2: зачислить деньги
        int rows2 = jdbcTemplate.update(
            "UPDATE accounts SET balance = balance + ? WHERE user_id = ?",
            amount, toUserId
        );

        if (rows2 == 0) {
            throw new BusinessException("Счёт получателя не найден");
        }

        // Если метод завершился без исключений → Spring сделает COMMIT
        // Если произошло исключение → автоматический ROLLBACK
    }
}
🎯 Важные концепции транзакций
1️⃣ Autocommit mode:
-- По умолчанию в PostgreSQL autocommit = ON:
UPDATE accounts SET balance = balance - 100 WHERE id = 1;
-- ✅ Автоматически закоммичено сразу после выполнения

-- С явной транзакцией:
BEGIN;
    UPDATE accounts SET balance = balance - 100 WHERE id = 1;
    -- Изменения пока не видны другим транзакциям
COMMIT;  -- Теперь видны всем
2️⃣ Вложенные транзакции (SAVEPOINT):
BEGIN;
    UPDATE accounts SET balance = balance - 100 WHERE id = 1;

    SAVEPOINT sp1;  -- Точка сохранения
        UPDATE accounts SET balance = balance + 100 WHERE id = 2;
        -- Ошибка!
    ROLLBACK TO sp1;  -- Откат до точки сохранения (первый UPDATE остаётся)

    UPDATE accounts SET balance = balance + 100 WHERE id = 3;  -- Попробовать другой счёт
COMMIT;
Java с SAVEPOINT:
@Transactional
public void complexOperation() {
    // Операция 1 (важная):
    updateCriticalData();

    // Попытка необязательной операции:
    TransactionStatus savepoint = transactionManager.getTransaction(
        new DefaultTransactionDefinition()
    );

    try {
        updateOptionalData();  // Может упасть
    } catch (Exception e) {
        transactionManager.rollback(savepoint);  // Откатить только это
        log.warn("Optional operation failed, continuing", e);
    }

    // Операция 3 (важная):
    updateMoreCriticalData();
}
3️⃣ Уровни изоляции (isolation levels):
-- READ COMMITTED (по умолчанию):
BEGIN;  -- каждый SELECT видит последние закоммиченные данные

-- REPEATABLE READ:
BEGIN ISOLATION LEVEL REPEATABLE READ;  -- "замораживает" данные на момент старта транзакции

-- SERIALIZABLE (самый строгий):
BEGIN ISOLATION LEVEL SERIALIZABLE;  -- полная изоляция, как если бы транзакции выполнялись последовательно
Java:
@Transactional(isolation = Isolation.REPEATABLE_READ)
public void generateReport() {
    // Все SELECT видят данные на момент начала транзакции
    List<Order> orders = getOrders();
    BigDecimal total = calculateTotal();
    // Даже если другая транзакция добавит заказы → мы их не увидим
}
🚨 Частые ошибки с транзакциями
1️⃣ Забыли COMMIT (данные не сохранились):
-- ❌ ОШИБКА:
BEGIN;
    UPDATE accounts SET balance = 1000 WHERE id = 1;
-- Забыли COMMIT → при закрытии соединения → ROLLBACK!

-- ✅ ПРАВИЛЬНО:
BEGIN;
    UPDATE accounts SET balance = 1000 WHERE id = 1;
COMMIT;
2️⃣ Долгие транзакции (блокировки!):
// ❌ ПЛОХО: длинная транзакция блокирует строки
@Transactional
public void processOrder(Long orderId) {
    Order order = getOrder(orderId);  // Блокировка строки
    sendEmail(order);  // 5 секунд! Строка заблокирована!
    updateStatus(order);
}
// Другие транзакции ждут 5+ секунд...

// ✅ ХОРОШО: короткие транзакции
public void processOrder(Long orderId) {
    Order order;

    // Транзакция 1: быстрое чтение
    order = transactionTemplate.execute(status -> getOrder(orderId));

    // Длинная операция БЕЗ транзакции:
    sendEmail(order);  // 5 секунд, но БД не заблокирована

    // Транзакция 2: быстрое обновление
    transactionTemplate.execute(status -> {
        updateStatus(order);
        return null;
    });
}
3️⃣ @Transactional на private методе (не работает!):
@Service
public class OrderService {
    // ❌ НЕ РАБОТАЕТ: @Transactional на private
    @Transactional
    private void processOrder() {
        // Транзакция НЕ создаётся! Spring не может проксировать private методы
    }

    // ✅ РАБОТАЕТ: @Transactional на public
    @Transactional
    public void processOrder() {
        // Транзакция создаётся через Spring AOP proxy
    }
}
✅ Чеклист использования транзакций
✅ Используй транзакции для ВСЕХ операций, изменяющих несколько строк/таблиц
✅ ВСЕГДА явно вызывай COMMIT (или полагайся на @Transactional)
✅ Используй @Transactional в Spring (декларативно) вместо ручного управления
✅ Держи транзакции короткими (< 1 секунды)
✅ Выноси длинные операции (отправка email, HTTP-запросы) ЗА пределы транзакции
⚠️ @Transactional работает ТОЛЬКО на public методах
⚠️ Исключения RuntimeException → автоматический ROLLBACK
⚠️ Исключения CheckedException → НЕ откатывают транзакцию (если не указать rollbackFor)
❌ НЕ держи транзакцию открытой во время I/O операций
🏆 Золотое правило: Транзакция должна быть атомарной единицей работы. Всё или ничего!
2. Что такое ACID?
ACID — четыре свойства надёжных транзакций:

Свойство

Описание

Аналогия

Atomicity (Атомарность)

Всё или ничего

Либо деньги переведены, либо нет — нельзя «наполовину»

Consistency (Согласованность)

БД переходит из одного корректного состояния в другое

Сумма всех счетов до и после перевода одинакова

Isolation (Изолированность)

Параллельные транзакции не видят промежуточные состояния друг друга

Два кассира не мешают друг другу

Durability (Долговечность)

После COMMIT данные сохраняются даже при сбое

Выключили свет — данные на месте


Механизмы поддержки ACID в PostgreSQL:

Свойство

Механизмы в БД

Как работает

Atomicity

WAL (Write-Ahead Log) + Transaction ID

При ROLLBACK откатываются все изменения по Transaction ID. WAL гарантирует восстановление при сбое

Consistency

Constraints (PRIMARY KEY, FOREIGN KEY, CHECK) + Triggers

Проверки на уровне схемы БД + кастомная логика в триггерах

Isolation

MVCC (Multi-Version Concurrency Control) + Locks

Каждая транзакция видит свой «снимок» данных. Блокировки предотвращают конфликты

Durability

WAL + Checkpoints + fsync

Данные сначала в WAL (на диск), потом в таблицы. Checkpoint сохраняет состояние. fsync гарантирует запись на диск


Детали механизмов:

  • WAL (Write-Ahead Log): Все изменения сначала пишутся в лог-файл, затем применяются к таблицам. Это позволяет восстановить данные после сбоя
  • MVCC: Каждая строка имеет версии с метками транзакций (xmin, xmax). Транзакция видит только те версии, которые были зафиксированы до её старта
  • Locks: Row-level locks (блокировки строк), Table-level locks (блокировки таблиц), Advisory locks (пользовательские блокировки)
  • Constraints: Проверяются автоматически при INSERT/UPDATE. Нарушение → откат операции
  • Checkpoints: Периодическое сохранение всех изменений из WAL в файлы таблиц (по умолчанию каждые 5 минут или при заполнении WAL)
🎯 Мнемоника: ACID — «кислота» надёжности баз данных. Запомни: Атомарность, Согласованность, Изолированность, Долговечность.
ACID vs BASE
Аналогия: ACID — «кислота», BASE — «щёлочь». Это два противоположных подхода к согласованности данных.

ACID (PostgreSQL, MySQL, Oracle) — гарантирует строгую согласованность. После COMMIT все клиенты видят одни и те же данные мгновенно. Подходит для финансов, банков, систем бронирования.

BASE (Cassandra, DynamoDB, MongoDB) — допускает временную рассогласованность ради доступности и масштабирования:

Свойство

Расшифровка

Описание

BAsically Available

Базовая доступность

Система всегда отвечает, даже если данные устарели

Soft state

Мягкое состояние

Данные могут находиться в промежуточном (неконсистентном) состоянии

Eventual consistency

Согласованность в конечном счёте

Со временем все узлы придут к одинаковому состоянию


Согласованность в конечном счёте (Eventual Consistency) — запись достигнет согласованности не сразу, а после завершения всех одновременных обновлений. После этого все приложения, запрашивающие эту запись, увидят одно и то же значение.

[!example]- Пример: распределённый документ Пользователь A и пользователь B одновременно редактируют один и тот же раздел документа. Их локальные копии временно различаются, пока не завершатся процессы распространения и синхронизации. Однако со временем, «в конечном счёте», система достигает согласованности, распространяя и объединяя все изменения от разных пользователей.

Сравнение:

Критерий

ACID

BASE

Согласованность

Строгая (мгновенная)

В конечном счёте (eventual)

Доступность

Может быть недоступна при сбое

Всегда отвечает

Масштабирование

Вертикальное (мощнее сервер)

Горизонтальное (больше серверов)

Скорость

Медленнее (ждём подтверждения)

Быстрее (запись без ожидания)

Пример

PostgreSQL, MySQL

Cassandra, DynamoDB, Redis

Когда использовать

Финансы, банки, бронирование

Лайки, счётчики, ленты соцсетей


[!warning] Важно Consistency в ACID и Consistency в CAP-теореме — это разные вещи:
  • ACID Consistency — данные соответствуют ограничениям схемы (NOT NULL, FOREIGN KEY, CHECK). Это гарантия корректности данных
  • CAP Consistency — все узлы видят одни и те же данные в один момент времени. Это гарантия одинаковости данных в распределённой системе
Eventual Consistency относится к CAP, а не к ACID.
3. Какие уровни изоляции транзакций существуют?
Ответ Уровни изоляции — это компромисс между надёжностью и производительностью:

Уровень

Dirty Read

Non-Repeatable Read

Phantom Read

Скорость

READ UNCOMMITTED

Да

Да

Да

🚀🚀🚀

READ COMMITTED

Нет

Да

Да

🚀🚀

REPEATABLE READ

Нет

Нет

Да*

🚀

SERIALIZABLE

Нет

Нет

Нет

🐢


*В PostgreSQL REPEATABLE READ также предотвращает phantom read благодаря MVCC.
-- Установить уровень:
BEGIN ISOLATION LEVEL REPEATABLE READ;
-- или
SET TRANSACTION ISOLATION LEVEL SERIALIZABLE;
🎯 Золотое правило: READ COMMITTED — хорош для 90% случаев. SERIALIZABLE — когда важна абсолютная корректность (финансы, бронирования).
4. Что такое Dirty Read?
Ответ Dirty Read («грязное чтение») — чтение незакоммиченных данных другой транзакции.
Аналогия: Ты читаешь черновик документа коллеги, а он потом удаляет весь текст. Ты принял решение на основе того, чего никогда не было!
TX1: UPDATE accounts SET balance = 0 WHERE id = 1;
TX2: SELECT balance FROM accounts WHERE id = 1;  -- видит 0
TX1: ROLLBACK;  -- откат!
TX2: -- использовал "грязные" данные, которых никогда не существовало
✅ PostgreSQL не допускает dirty read даже на READ UNCOMMITTED (работает как READ COMMITTED).
5. Что такое Non-Repeatable Read?
Ответ Non-Repeatable Read («неповторяемое чтение») — повторное чтение той же строки даёт другой результат.

Аналогия: Ты смотришь на цену товара — 1000₽. Пока думаешь, её меняют на 500₽. Смотришь снова — уже другая цена!
TX1: SELECT balance FROM accounts WHERE id = 1;  -- 1000
TX2: UPDATE accounts SET balance = 500 WHERE id = 1; COMMIT;
TX1: SELECT balance FROM accounts WHERE id = 1;  -- 500 (изменилось!)
Решение: REPEATABLE READ или выше — транзакция «замораживает» данные на момент своего начала.
6. Что такое Phantom Read?
Ответ Phantom Read («чтение фантомов») — повторный запрос возвращает новые строки.
Аналогия: Ты считаешь людей в комнате — 10 человек. Кто-то заходит. Считаешь снова — 11! Откуда взялся «фантом»?
TX1: SELECT * FROM orders WHERE status = 'pending';  -- 10 строк
TX2: INSERT INTO orders (status) VALUES ('pending'); COMMIT;
TX1: SELECT * FROM orders WHERE status = 'pending';  -- 11 строк!
Решение: SERIALIZABLE (или REPEATABLE READ в PostgreSQL благодаря MVCC).

💡 Отличие от Non-Repeatable Read: там изменяется существующая строка, здесь — появляется новая.
7. Какой уровень изоляции по умолчанию в PostgreSQL?
Ответ READ COMMITTED — каждый запрос видит данные, закоммиченные на момент начала запроса (не транзакции).
SHOW default_transaction_isolation;  -- read committed

-- Изменить глобально:
ALTER SYSTEM SET default_transaction_isolation = 'repeatable read';
✅ PostgreSQL не допускает dirty read даже на READ UNCOMMITTED (работает как READ COMMITTED).
8. Что такое Write Skew?
Ответ Write Skew («перекос записи») — аномалия при REPEATABLE READ, когда две транзакции читают одни данные, изменяют разные строки, но нарушают инвариант.

Аналогия: Два врача дежурят в больнице. Правило: минимум один должен остаться. Оба одновременно смотрят — нас двое, можно уйти! И оба уходят. Больница без врачей.
-- Правило: минимум 1 дежурный
-- TX1: видит 2 дежурных (Анна и Борис), Анна уходит
-- TX2: видит 2 дежурных (Анна и Борис), Борис уходит
-- Обе транзакции COMMIT
-- Результат: 0 дежурных! Инвариант нарушен.
Почему REPEATABLE READ не спасает? Каждая транзакция изменяет РАЗНЫЕ строки, конфликта нет.
Решение:
  1. SERIALIZABLE — PostgreSQL обнаружит конфликт
  2. SELECT FOR UPDATE — явно заблокировать все строки при чтении
9. Как работает SERIALIZABLE в PostgreSQL?
Ответ PostgreSQL использует SSI (Serializable Snapshot Isolation):

  • Отслеживает зависимости между транзакциями
  • При обнаружении конфликта откатывает одну из транзакций
  • Требуется retry-механизм в приложении
BEGIN ISOLATION LEVEL SERIALIZABLE;
-- критическая бизнес-логика
COMMIT;

-- При конфликте получите ошибку:
-- ERROR: could not serialize access due to read/write dependencies
10. Что такое SAVEPOINT?
Ответ SAVEPOINT — точка сохранения внутри транзакции для частичного отката.
BEGIN;
INSERT INTO orders VALUES (1, 'pending');
SAVEPOINT sp1;
INSERT INTO orders VALUES (2, 'pending');  -- ошибка!
ROLLBACK TO sp1;  -- откат только до savepoint
INSERT INTO orders VALUES (3, 'pending');
COMMIT;  -- сохранятся id=1 и id=3
2. MVCC и блокировки
Аналогия: MVCC — это как Git для базы данных. Каждая транзакция работает со своей «веткой» данных. Изменения видны другим только после «коммита». Читатели никогда не ждут писателей!

🧬 Интересный факт: MVCC в PostgreSQL появился ещё в 1980-х в проекте POSTGRES! Это одна из причин, почему Postgres изначально назывался именно так — Post-Ingres. Oracle добавил MVCC только в 1990-х. А MySQL InnoDB — аж в 2001 году. PostgreSQL был пионером!
11. Что такое MVCC?
Ответ MVCC (Multi-Version Concurrency Control) — механизм, при котором каждая транзакция видит свой «снимок» данных.
┌──────────────────────────────────────────────────────────────────────────┐
│  MVCC — Git для базы данных:                                             │
│  • Каждая строка хранит несколько версий (xmin, xmax)                    │
│  • Каждая транзакция видит СВОЙ snapshot данных                          │
│  • Читатели НЕ блокируют писателей                                       │
│  • Писатели НЕ блокируют читателей                                       │
└──────────────────────────────────────────────────────────────────────────┘
🎯 Ключевое преимущество: Высокая параллельность без блокировок на чтение. SELECT никогда не ждёт UPDATE!

Цена MVCC: "Мёртвые" версии строк накапливаются → нужен VACUUM.
12. Как MVCC хранит версии строк?
Ответ Каждая строка (tuple) содержит системные колонки:

Колонка

Описание

Аналогия

xmin

XID транзакции, создавшей версию строки

«Дата рождения»

xmax

XID транзакции, удалившей/обновившей (0 если активна)

«Дата смерти» (0 = жив)

cmin

Порядковый номер INSERT внутри TX

Порядок создания

cmax

Порядковый номер DELETE/UPDATE внутри TX

Порядок удаления

ctid

Физическое расположение (page, offset)

«Адрес» на диске

-- Просмотр системных колонок:
SELECT xmin, xmax, ctid, * FROM users WHERE id = 1;
Что здесь происходит: PostgreSQL показывает «метаданные» строки — когда создана, когда удалена, где физически лежит.
13. Что такое Visibility Map?
Ответ Visibility Map (VM) — битовая карта, показывающая какие страницы содержат ТОЛЬКО видимые всем строки.

Зачем нужен VM:

  1. Index-Only Scan — если страница all-visible, не нужно читать heap
  2. VACUUM — пропускает all-visible страницы
  3. FREEZE — отслеживает какие страницы нужно freeze
-- Проверить VM через pageinspect:
CREATE EXTENSION pageinspect;
SELECT * FROM pg_visibility('users');
14. Что такое HOT (Heap-Only Tuples)?
Ответ HOT — оптимизация UPDATE, когда новая версия хранится на ТОЙ ЖЕ странице и индексы НЕ обновляются.

Условия для HOT:
  1. На странице есть свободное место
  2. Изменяемые колонки НЕ входят в индексы
  3. Строка не была уже HOT-обновлена
-- Проверить HOT статистику:
SELECT relname, n_tup_upd, n_tup_hot_upd,
      ROUND(100.0 * n_tup_hot_upd / NULLIF(n_tup_upd, 0), 2) AS hot_ratio
FROM pg_stat_user_tables WHERE n_tup_upd > 0;

-- hot_ratio > 90% — отлично
-- hot_ratio < 50% — проблема

-- Оптимизация для HOT:
ALTER TABLE users SET (fillfactor = 70);  -- 30% страницы для обновлений
14.1. Как работает Snapshot в MVCC?
Ответ Snapshot — это «фотография» состояния базы на момент начала транзакции (или запроса, в зависимости от уровня изоляции).

Что содержит snapshot:
┌─────────────────────────────────────────────────────────────────┐
│  Snapshot = {                                                   │
│    xmin: 100,        // Все TX < 100 завершены (видимы)         │
│    xmax: 105,        // Все TX >= 105 ещё не начались           │
│    xip: [101, 103],  // Активные TX в момент snapshot           │
│  }                                                              │
└─────────────────────────────────────────────────────────────────┘
Правила видимости строки:
  1. xmin < snapshot. xmin И xmin завершён с COMMIT → видима
  2. xmin в xip (активная транзакция) → не видима
  3. xmax установлен И завершён → строка удалена, не видима
-- Посмотреть текущий snapshot:
SELECT pg_current_snapshot();
-- Результат: 100:105:101,103
-- Формат: xmin:xmax:xip_list
Когда создаётся snapshot:

  • READ COMMITTED: в начале каждого запроса
  • REPEATABLE READ / SERIALIZABLE: в начале транзакции
14.2. Что происходит при UPDATE в MVCC? (Пошаговый пример)
Ответ UPDATE в PostgreSQL = INSERT новой версии + DELETE старой!
-- Исходное состояние:
-- users: id=1, name='Alice', xmin=100, xmax=0

BEGIN;  -- TX 200
UPDATE users SET name = 'Alicia' WHERE id = 1;
COMMIT;
Что происходит пошагово:
┌─────────────────────────────────────────────────────────────────┐
│  ШАГ 1: До UPDATE                                               │
│  ┌──────────────────────────────────────────────────────────┐   │
│  │ ctid=(0,1) │ xmin=100 │ xmax=0 │ id=1 │ name='Alice'     │   │
│  └──────────────────────────────────────────────────────────┘   │
│                                                                 │
│  ШАГ 2: UPDATE выполняется (TX 200)                             │
│  ┌──────────────────────────────────────────────────────────┐   │
│  │ ctid=(0,1) │ xmin=100 │ xmax=200 │ id=1 │ name='Alice'   │ ← помечена как "мёртвая"
│  └──────────────────────────────────────────────────────────┘   │
│  ┌──────────────────────────────────────────────────────────┐   │
│  │ ctid=(0,2) │ xmin=200 │ xmax=0 │ id=1 │ name='Alicia'    │ ← новая версия
│  └──────────────────────────────────────────────────────────┘   │
│                                                                 │
│  ШАГ 3: После COMMIT                                            │
│  - Старая версия (0,1) "мертва" для всех новых транзакций       │
│  - Новая версия (0,2) видима для всех                           │
│  - VACUUM потом удалит (0,1)                                    │
└─────────────────────────────────────────────────────────────────┘
Ключевой момент: Старая версия НЕ удаляется сразу! Она нужна для:
  • Транзакций, которые начались ДО UPDATE (они видят старую версию)
  • Возможного ROLLBACK
14.3. Пример: две параллельные транзакции
Ответ
Время   │ TX 100 (READ COMMITTED)    │ TX 101 (REPEATABLE READ)
────────┼────────────────────────────┼───────────────────────────
 T1    │ BEGIN                      │
 T2    │                            │ BEGIN
 T3    │                            │ SELECT balance → 1000 ✓
 T4    │ UPDATE balance = 500       │
 T5    │ COMMIT                     │
 T6    │                            │ SELECT balance → ???
────────┴────────────────────────────┴───────────────────────────
Что увидит TX 101 в T6?
  • READ COMMITTED: 500 — новый snapshot на каждый SELECT
  • REPEATABLE READ: 1000 — snapshot создан в T2, видит "старый мир"
Как это работает под капотом:
Строка balance после T5:
┌─────────────────────────────────────────────────────────────┐
│ Версия 1: xmin=50, xmax=100, balance=1000  ← "мертва"       │
│ Версия 2: xmin=100, xmax=0, balance=500   ← "живая"         │
└─────────────────────────────────────────────────────────────┘

TX 101 (snapshot создан в T2, до TX 100):
- Видит версию 1 (xmin=50 < snapshot, xmax=100 ещё активна в момент snapshot)
- НЕ видит версию 2 (xmin=100, но 100 была активна в момент snapshot)
14.4. MVCC vs Блокирующий подход
Ответ

Аспект

MVCC (PostgreSQL)

Блокировки (старые системы)

SELECT ждёт UPDATE

Нет

Да, ждёт освобождения

UPDATE ждёт SELECT

Нет

Да, ждёт освобождения

Параллельность

Высокая

Низкая

Накладные расходы

Хранение версий, VACUUM

Управление блокировками

Deadlock при чтении

Невозможен

Возможен



Пример разницы:
┌─────────────────────────────────────────────────────────────────┐
│  Блокирующий подход:                                            │
│  TX1: UPDATE users SET name='A' WHERE id=1;  -- блокирует       │
│  TX2: SELECT * FROM users WHERE id=1;        -- ЖДЁТ! ⏳        │
│                                                                 │
│  MVCC:                                                          │
│  TX1: UPDATE users SET name='A' WHERE id=1;  -- создаёт версию  │
│  TX2: SELECT * FROM users WHERE id=1;        -- читает СТАРУЮ!  │
│                                               -- НЕ ждёт! ⚡     │
└─────────────────────────────────────────────────────────────────┘
🎯 Вывод: MVCC — причина, почему PostgreSQL отлично работает под нагрузкой. Читатели и писатели не мешают друг другу!
14.5. Проблемы и ограничения MVCC
Ответ 1. Table Bloat (раздувание таблицы)
-- Много UPDATE/DELETE = много мёртвых версий
-- Таблица 1GB данных может занимать 10GB на диске!

-- Диагностика:
SELECT
   relname,
   n_live_tup,
   n_dead_tup,
   ROUND(100.0 * n_dead_tup / NULLIF(n_live_tup + n_dead_tup, 0), 2) AS dead_ratio
FROM pg_stat_user_tables
WHERE n_dead_tup > 1000
ORDER BY n_dead_tup DESC;
2. Index Bloat
-- Индексы тоже раздуваются! Каждая версия строки = запись в индексе
-- (кроме HOT updates)

-- Оценка bloat через pgstattuple:
CREATE EXTENSION pgstattuple;
SELECT * FROM pgstattuple('users_pkey');
3. Долгие транзакции блокируют VACUUM
-- Даже ОДНА долгая транзакция не даёт очистить старые версии!
-- Все версии после её snapshot нужны для её видимости

-- Найти старые транзакции:
SELECT pid, age(backend_xid) AS xid_age,
      NOW() - xact_start AS duration,
      query
FROM pg_stat_activity
WHERE backend_xid IS NOT NULL
ORDER BY xid_age DESC;
4. Transaction ID Wraparound (см. вопрос 24)

5. Больше I/O при UPDATE
  • Каждый UPDATE = запись новой версии
  • В отличие от «in-place update» в других системах
14.6. Как диагностировать проблемы MVCC?
Ответ Чек-лист диагностики:
-- 1. Проверить мёртвые строки (нужен VACUUM?)
SELECT relname, n_dead_tup, last_autovacuum, last_vacuum
FROM pg_stat_user_tables
WHERE n_dead_tup > 10000
ORDER BY n_dead_tup DESC;

-- 2. Проверить bloat таблиц
SELECT
   schemaname || '.' || relname AS table,
   pg_size_pretty(pg_total_relation_size(relid)) AS total_size,
   n_live_tup,
   n_dead_tup
FROM pg_stat_user_tables
ORDER BY pg_total_relation_size(relid) DESC
LIMIT 10;

-- 3. Найти долгие транзакции (блокируют VACUUM)
SELECT pid,
      NOW() - xact_start AS duration,
      state,
      LEFT(query, 50) AS query
FROM pg_stat_activity
WHERE xact_start < NOW() - INTERVAL '5 minutes'
 AND state != 'idle';

-- 4. Проверить XID age (wraparound risk)
SELECT datname,
      age(datfrozenxid) AS xid_age,
      ROUND(100.0 * age(datfrozenxid) / 2147483647, 2) AS pct_to_wraparound
FROM pg_database
ORDER BY xid_age DESC;

-- 5. Проверить HOT ratio
SELECT relname,
      n_tup_upd,
      n_tup_hot_upd,
      CASE WHEN n_tup_upd > 0
           THEN ROUND(100.0 * n_tup_hot_upd / n_tup_upd, 2)
           ELSE 0 END AS hot_pct
FROM pg_stat_user_tables
WHERE n_tup_upd > 100
ORDER BY hot_pct;
Красные флаги:
🚨 n_dead_tup > 20% от n_live_tup — нужен VACUUM
🚨 last_autovacuum = NULL или > 1 день — autovacuum не работает
🚨 xid_age > 500 миллионов — риск wraparound
🚨 hot_pct < 50% — плохая стратегия индексов или fillfactor
14.7. Как починить bloat?
Ответ Вариант 1: VACUUM (без блокировки)
-- Обычный VACUUM — освобождает место для переиспользования
VACUUM users;

-- VACUUM FULL — сжимает таблицу, НО блокирует!
VACUUM FULL users;  -- ⚠️ ACCESS EXCLUSIVE LOCK
Вариант 2: pg_repack (без блокировки)
-- Устанавливаем расширение
CREATE EXTENSION pg_repack;

-- Перепаковываем таблицу онлайн
-- (создаёт копию, переключает атомарно)
pg_repack -t users mydb
Вариант 3: CLUSTER (с блокировкой)
-- Перестраивает таблицу по индексу
CLUSTER users USING users_pkey;  -- ⚠️ Блокирует
Профилактика:
-- 1. Настроить autovacuum агрессивнее для активных таблиц
ALTER TABLE hot_table SET (
   autovacuum_vacuum_scale_factor = 0.05,  -- 5% вместо 20%
   autovacuum_analyze_scale_factor = 0.02
);

-- 2. Увеличить fillfactor для таблиц с частыми UPDATE
ALTER TABLE users SET (fillfactor = 70);

-- 3. Мониторить и убивать долгие транзакции
SELECT pg_terminate_backend(pid)
FROM pg_stat_activity
WHERE xact_start < NOW() - INTERVAL '1 hour'
 AND state = 'idle in transaction';
15. Какие типы блокировок существуют?
Ответ

Уровень

Примеры

Row-level

SELECT FOR UPDATE, FOR SHARE

Table-level

LOCK TABLE, DDL операции

Advisory

pg_advisory_lock() — пользовательские

-- Row-level:
SELECT * FROM accounts WHERE id = 1 FOR UPDATE;

-- Table-level:
LOCK TABLE orders IN ACCESS EXCLUSIVE MODE;

-- Advisory:
SELECT pg_advisory_lock(12345);  -- ждёт
SELECT pg_try_advisory_lock(12345);  -- не ждёт
SELECT pg_advisory_unlock(12345);
16. Что такое SELECT FOR UPDATE?
Ответ Блокирует выбранные строки от изменения другими транзакциями до COMMIT/ROLLBACK.
SELECT * FROM accounts WHERE id = 1 FOR UPDATE;
-- Строка заблокирована

-- Варианты:
FOR UPDATE NOWAIT       -- ошибка если заблокировано
FOR UPDATE SKIP LOCKED  -- пропустить заблокированные
FOR SHARE               -- разрешить другие SELECT FOR SHARE
17. Что такое deadlock?
Ответ Deadlock («взаимоблокировка») — когда две транзакции ждут друг друга вечно.

Аналогия: Два человека в узком коридоре. Каждый ждёт, пока другой отойдёт. Никто не двигается → deadlock!
TX1: SELECT * FROM A FOR UPDATE;  -- блокирует A
TX2: SELECT * FROM B FOR UPDATE;  -- блокирует B
TX1: SELECT * FROM B FOR UPDATE;  -- ждёт TX2... 💤
TX2: SELECT * FROM A FOR UPDATE;  -- ждёт TX1... 💀 DEADLOCK!
PostgreSQL обнаруживает deadlock и убивает одну из транзакций!

🎯 Как предотвратить:

  1. Единый порядок блокировок — всегда сначала A, потом B
  2. Короткие транзакции — меньше времени держим блокировки
  3. SELECT FOR UPDATE NOWAIT — не ждать, сразу ошибка
  4. lock_timeout — ограничить время ожидания
18. Какие уровни блокировки таблицы существуют?
 Ответ
LOCK TABLE t IN ACCESS SHARE MODE;      -- SELECT
LOCK TABLE t IN ROW SHARE MODE;         -- SELECT FOR UPDATE/SHARE
LOCK TABLE t IN ROW EXCLUSIVE MODE;     -- UPDATE, DELETE, INSERT
LOCK TABLE t IN SHARE MODE;             -- CREATE INDEX CONCURRENTLY
LOCK TABLE t IN EXCLUSIVE MODE;         -- Блокирует большинство операций
LOCK TABLE t IN ACCESS EXCLUSIVE MODE;  -- Полная блокировка (DDL)
3. Архитектура PostgreSQL
Аналогия: Архитектура PostgreSQL — это как офисное здание. Есть этажи (базы данных), кабинеты (таблицы), документы в шкафах (строки). А ещё есть журнал безопасности (WAL), уборщик (VACUUM) и кэш на ресепшене (shared_buffers).
19. Как устроено хранение данных в PostgreSQL?
 Ответ

Иерархия хранения
Кластер ($PGDATA)
└── База данных (database)
    └── Схема (schema / namespace)
        └── Таблица (relation)
            └── Файлы на диске:
                ├── 16384          ← heap (основной файл с данными)
                ├── 16384_fsm      ← Free Space Map (карта свободного места)
                └── 16384_vm       ← Visibility Map (карта видимости строк)
Кластер — одна директория $PGDATA, один процесс postgres, несколько баз. Схема — namespace. По умолчанию public. Позволяет изолировать объекты: schema.table.
Страница (Page) — минимальная единица I/O
Всё чтение и запись — кратно 8 KB (изменяется только при компиляции). Один файл таблицы разбит на страницы, страницы читаются в shared_buffers.
┌────────────────────────────────────────────────┐  ← 8192 байта
│ PageHeaderData (24 байта)                      │
│   lsn      — позиция в WAL (crash recovery)    │
│   pd_lower — граница item pointers             │
│   pd_upper — начало данных строк               │
├────────────────────────────────────────────────┤
│ ItemId[1] ItemId[2] ItemId[3] ...  ← указатели │
│                                                │
│           (свободное место)                    │
│                                                │
│         ... Tuple 3 │ Tuple 2 │ Tuple 1        │  ← строки растут снизу
└────────────────────────────────────────────────┘
Tuple (строка) — системные поля заголовка
Каждая строка несёт ~23 байта служебного заголовка MVCC:

Поле

Что хранит

xmin

XID транзакции, создавшей строку

xmax

XID транзакции, удалившей строку (0 = строка жива)

ctid

Физический адрес: (номер_страницы, номер_в_странице)

t_infomask

Битовые флаги видимости (frozen, committed, aborted...)

-- Посмотреть системные поля напрямую:
SELECT ctid, xmin, xmax, id, name FROM users LIMIT 5;
-- ctid = (0,1) → страница 0, слот 1

-- Узнать OID → имя файла на диске:
SELECT relname, relfilenode
FROM pg_class
WHERE relname = 'users';
-- Файл: $PGDATA/base/<db_oid>/<relfilenode>
Вспомогательные файлы (forks)
Free Space Map (FSM) — карта свободного места по страницам. PostgreSQL использует её при INSERT, чтобы быстро найти страницу с местом (без полного сканирования файла).

Visibility Map (VM) — по одному биту на страницу:
  • all-visible = все строки на странице видны всем транзакциям → VACUUM пропускает страницу
  • all-frozen = все строки «заморожены» → VACUUM FREEZE пропускает страницу
-- Размеры: heap / индексы / TOAST / итого
SELECT
    relname,
    pg_size_pretty(pg_relation_size(oid))       AS heap,
    pg_size_pretty(pg_indexes_size(oid))        AS indexes,
    pg_size_pretty(pg_total_relation_size(oid)) AS total
FROM pg_class
WHERE relname = 'orders';
20. Что такое TOAST?
Ответ TOAST (The Oversized-Attribute Storage Technique) — механизм хранения больших значений (> 2KB):

Стратегия

Описание

PLAIN

Без сжатия, inline

EXTENDED

Сжатие + вынос в отдельную таблицу

EXTERNAL

Без сжатия, отдельная таблица

MAIN

Сжатие, вынос в крайнем случае

21. Что такое WAL?
Ответ WAL (Write-Ahead Logging) — журнал упреждающей записи.
Аналогия: Как чёрный ящик самолёта. Все действия сначала записываются в журнал. Если что-то пойдёт не так — можно восстановить.

Принцип: Сначала пишем в журнал, потом — в данные.
Зачем нужен WAL:
  • Гарантирует Durability (D в ACID) — данные не потеряются при сбое
  • Репликация — реплика получает WAL и «проигрывает» изменения
  • Point-in-Time Recovery — восстановление на любой момент времени
-- Текущая позиция WAL:
SELECT pg_current_wal_lsn();
22. Что такое checkpoint?
Ответ Checkpoint — момент синхронизации данных из памяти на диск.

Аналогия: Как автосохранение в игре. PostgreSQL работает с данными в памяти (быстро!), но периодически «сохраняется» на диск.
Что происходит при checkpoint:
  1. Все изменённые страницы (dirty pages) записываются на диск
  2. Старый WAL можно удалить/переиспользовать
  3. При crash recovery восстановление начнётся с последнего checkpoint
-- Ручной checkpoint:
CHECKPOINT;

-- Настройки:
SHOW checkpoint_timeout;  -- default: 5min (как часто)
SHOW max_wal_size;        -- default: 1GB (сколько WAL накопить)
23. Как работает autovacuum?
Ответ Autovacuum — фоновый процесс, удаляющий «мёртвые» строки.
Аналогия: Как уборщик в офисе. MVCC не удаляет строки сразу — помечает как «мёртвые». VACUUM приходит и убирает мусор.

Что делает VACUUM:

  • 🗑️ Освобождает место от мёртвых строк (для переиспользования)
  • 📊 Обновляет статистику для планировщика запросов
  • 🔄 Предотвращает transaction ID wraparound (критично!)
-- Статистика autovacuum:
SELECT relname, last_autovacuum, n_dead_tup
FROM pg_stat_user_tables
ORDER BY n_dead_tup DESC;
⚠️ Важно: Если VACUUM не работает — база "распухает" и замедляется!
24. Что такое transaction ID wraparound?
Ответ
Проблема: конечное пространство XID

PostgreSQL нумерует транзакции 32-bit целым (xid). Максимум — ~4 миллиарда, но "прошлое" и "будущее" делят пространство пополам: эффективный лимит ~2 миллиарда.
XID: 1 → 2 → 3 → ... → 2 147 483 647 → 2 147 483 648 → ... → 4 294 967 295 → 1 → 2 ...
                                         ↑
                         Здесь "прошлое" и "будущее" меняются местами!

Строка, созданная в xmin=1 000, после wraparound видит XID 1 000 как "будущее"
→ строка становится НЕВИДИМОЙ для всех запросов!
Итог: если не принимать меры — база перестаёт принимать записи и переходит в аварийный режим.

💀 Реальный случай: В 2019 году компания Sentry потеряла 4 часа из-за wraparound — PostgreSQL перешёл в аварийный режим и потребовал ручного VACUUM. Мораль: следи за age (datfrozenxid) и никогда не отключай autovacuum!
Решение: VACUUM FREEZE

VACUUM FREEZE — специальный режим VACUUM, который заменяет настоящий XID строки на специальный «замороженный» маркер FrozenTransactionId (XID = 2).
Замороженная строка считается видимой для любой транзакции — её XID больше не участвует в сравнениях, а значит wraparound ей не страшен.
ДО FREEZE:                        ПОСЛЕ FREEZE:
┌──────────────────────┐          ┌──────────────────────┐
│ xmin = 500 000       │  →→→→→   │ xmin = FrozenXID (2) │
│ xmax = 0             │          │ xmax = 0             │
│ data = "Иван"        │          │ data = "Иван"        │
└──────────────────────┘          └──────────────────────┘
  Возраст строки: 1.6 млрд XID      Возраст: 0 (навсегда молода!)
Как PostgreSQL решает, что морозить:

Параметр

Значение по умолчанию

Что означает

vacuum_freeze_min_age

50 млн

Строки старше → кандидаты на заморозку

vacuum_freeze_table_age

150 млн

Возраст таблицы → VACUUM просматривает всю таблицу

autovacuum_freeze_max_age

200 млн

Autovacuum запускается принудительно для защиты от wraparound


Когда возраст базы (age (datfrozenxid)) достигает ~2 млрд, PostgreSQL переходит в аварийный режим: отказывает всем DML до завершения VACUUM FREEZE.
-- Мониторинг возраста транзакций:
SELECT
    datname,
    age(datfrozenxid)                            AS db_age,
    2147483647 - age(datfrozenxid)               AS remaining_xids,
    round((age(datfrozenxid)::numeric / 2147483647) * 100, 1) AS pct_used
FROM pg_database
ORDER BY db_age DESC;

-- Возраст отдельных таблиц:
SELECT relname, age(relfrozenxid) AS table_age
FROM pg_class
WHERE relkind = 'r'
ORDER BY table_age DESC
LIMIT 10;

-- Принудительный freeze конкретной таблицы (без блокировки на чтение):
VACUUM FREEZE large_table;

-- VERBOSE — видно, сколько страниц заморожено:
VACUUM FREEZE VERBOSE large_table;
Когда запускать VACUUM FREEZE вручную:
  • age (datfrozenxid) > 500 млн — начинать беспокоиться
  • age (datfrozenxid) > 1 млрд — запустить VACUUM FREEZE в maintenance window
  • age (datfrozenxid) > 1.5 млрд — критически срочно!

VACUUM FREEZE vs обычный VACUUM: Обычный VACUUM только удаляет мёртвые строки. VACUUM FREEZE дополнительно проставляет frozen-маркер на старых живых строках — это дороже (больше страниц для чтения), но обязательно для долгоживущих баз.
4. VIEW и Materialized View
Аналогия: VIEW — это сохранённый запрос (как закладка в браузере). Materialized View — это скачанная копия страницы (быстро открывается, но может устареть).
26. Чем VIEW отличается от Materialized View?
Ответ

VIEW

Materialized View

Виртуальная таблица (запрос)

Физическая копия данных

Всегда актуальные данные

Данные на момент REFRESH

Выполняется при каждом обращении

Выполняется только при REFRESH

Нельзя индексировать

Можно индексировать

-- VIEW:
CREATE VIEW active_users AS
SELECT * FROM users WHERE status = 'active';

-- Materialized View:
CREATE MATERIALIZED VIEW sales_summary AS
SELECT date, SUM(amount) FROM orders GROUP BY date;

REFRESH MATERIALIZED VIEW sales_summary;
REFRESH MATERIALIZED VIEW CONCURRENTLY sales_summary;  -- без блокировки
27. Когда использовать Materialized View?
Ответ

  • Тяжёлые агрегации, которые редко меняются
  • Отчёты, дашборды
  • Данные для аналитики
  • Кэширование сложных JOIN
CREATE MATERIALIZED VIEW daily_stats AS
SELECT DATE_TRUNC('day', created_at) AS day,
      COUNT(*), SUM(amount)
FROM orders GROUP BY 1;

CREATE UNIQUE INDEX ON daily_stats(day);

-- Обновление по расписанию:
REFRESH MATERIALIZED VIEW CONCURRENTLY daily_stats;
5. Триггеры
Аналогия: Триггер — это датчик движения, который автоматически включает свет. Произошло событие (INSERT/UPDATE/DELETE) → сработала функция.
28. Что такое триггер?
Ответ Триггер — функция, автоматически выполняемая при определённых событиях на таблице.
CREATE OR REPLACE FUNCTION update_timestamp()
RETURNS TRIGGER AS $$
BEGIN
   NEW.updated_at = NOW();
   RETURN NEW;
END;
$$ LANGUAGE plpgsql;

CREATE TRIGGER tr_update_timestamp
   BEFORE UPDATE ON users
   FOR EACH ROW EXECUTE FUNCTION update_timestamp();
29. Какие типы триггеров существуют?
Ответ

По времени

По уровню

BEFORE

FOR EACH ROW

AFTER

FOR EACH STATEMENT

INSTEAD OF (для VIEW)

-- BEFORE ROW — можно изменить NEW
-- AFTER ROW — данные уже записаны
-- INSTEAD OF — заменяет операцию (для VIEW)
30. Как использовать триггер для аудита?
Ответ
CREATE TABLE audit_log (
   id BIGSERIAL PRIMARY KEY,
   table_name TEXT,
   record_id BIGINT,
   action TEXT,
   old_data JSONB,
   new_data JSONB,
   changed_at TIMESTAMPTZ DEFAULT NOW()
);

CREATE OR REPLACE FUNCTION audit_trigger()
RETURNS TRIGGER AS $$
BEGIN
   INSERT INTO audit_log (table_name, record_id, action, old_data, new_data)
   VALUES (
       TG_TABLE_NAME,
       COALESCE(NEW.id, OLD.id),
       TG_OP,
       CASE WHEN TG_OP != 'INSERT' THEN to_jsonb(OLD) END,
       CASE WHEN TG_OP != 'DELETE' THEN to_jsonb(NEW) END
   );
   RETURN COALESCE(NEW, OLD);
END;
$$ LANGUAGE plpgsql;

CREATE TRIGGER tr_audit_users
   AFTER INSERT OR UPDATE OR DELETE ON users
   FOR EACH ROW EXECUTE FUNCTION audit_trigger();
6. Партиционирование
Аналогия: Партиционирование — это как разделение документов по папкам-годам. Нужны документы за 2024? Открываем только папку «2024», остальные не трогаем.
31. Зачем нужно партиционирование?
Ответ Партиционирование — разбиение большой таблицы на физические части (партиции).

Когда необходимо:
  • Таблица > 100GB или > 100 млн записей
  • Данные имеют естественное разделение (по времени, региону)
  • Нужно быстро удалять старые данные (DROP PARTITION vs DELETE)
Преимущества:
  • Partition Pruning — запрос читает только нужные партиции
  • Параллельная обработка
  • Эффективное удаление: DROP PARTITION мгновенен
32. Какие типы партиционирования существуют?
Ответ

Тип

Когда

RANGE

Даты, числовые диапазоны

LIST

Категории, статусы, регионы

HASH

Равномерное распределение

-- RANGE:
CREATE TABLE orders (id SERIAL, order_date DATE, amount NUMERIC)
PARTITION BY RANGE (order_date);

CREATE TABLE orders_2024_q1 PARTITION OF orders
   FOR VALUES FROM ('2024-01-01') TO ('2024-04-01');

-- LIST:
CREATE TABLE orders PARTITION BY LIST (region);
CREATE TABLE orders_eu PARTITION OF orders FOR VALUES IN ('EU');

-- HASH:
CREATE TABLE orders PARTITION BY HASH (customer_id);
CREATE TABLE orders_p0 PARTITION OF orders
   FOR VALUES WITH (MODULUS 4, REMAINDER 0);
33. Что такое partition pruning?
Ответ Partition Pruning — оптимизация, при которой запрос читает только нужные партиции.
SELECT * FROM orders WHERE order_date = '2024-02-15';
-- Читает только orders_2024_q1, игнорирует остальные

EXPLAIN SELECT * FROM orders WHERE order_date BETWEEN '2024-01-01' AND '2024-03-31';
-- Покажет сканирование только релевантных партиций
7. Шардирование
Аналогия: Шардирование — это как распределение книг по библиотекам разных городов. Книги А-М хранятся в Москве, Н-Я — в Питере. Каждая библиотека независима, но вместе они составляют полный каталог.
34. Что такое шардирование и чем оно отличается от партиционирования?
Ответ Шардирование — горизонтальное разделение данных между несколькими независимыми БД (физическими серверами).

Ключевые отличия от партиционирования:

Параметр

Партиционирование

Шардирование

Где данные

Одна БД, разные таблицы/файлы

Разные БД на разных серверах

Транзакции

Работают между партициями

Не работают между шардами

Запросы

Прозрачны для приложения

Приложение выбирает шард

Масштабирование

Вертикальное (один сервер)

Горизонтальное (много серверов)

Сложность

Низкая

Высокая


Когда нужно шардирование:
  • Данных > 1TB и они не влезают на один сервер
  • Нагрузка > 100K запросов/сек и одна БД не справляется
  • Естественное разделение (по регионам, клиентам, временным периодам)
  • Нужна географическая близость данных к пользователям
Пример:
Партиционирование:
PostgreSQL (один сервер)
  └─ orders
      ├─ orders_2024_q1 (партиция)
      ├─ orders_2024_q2 (партиция)
      └─ orders_2024_q3 (партиция)

Шардирование:
PostgreSQL Server 1 (orders для клиентов 1-1000)
PostgreSQL Server 2 (orders для клиентов 1001-2000)
PostgreSQL Server 3 (orders для клиентов 2001-3000)
🎯 Правило: Сначала используй партиционирование (проще), затем вертикальное масштабирование (больше RAM/CPU), и только потом шардирование (самое сложное).
35. Какие стратегии шардирования существуют?
Ответ Стратегия шардирования — правило выбора шарда для конкретной записи.

Стратегия

Как работает

Плюсы

Минусы

Range-based

Диапазоны ключей (1−1000 → shard1, 1001−2000 → shard2)

Простота, легко добавлять данные

Неравномерная нагрузка (hotspot)

Hash-based

Hash (ключ) % N шардов

Равномерное распределение

Сложно добавлять шарды (resharding)

Geography-based

По региону/стране (EU → shard1, US → shard2)

Близость к пользователям, соблюдение законов (GDPR)

Неравномерная нагрузка

Entity-based

По типу сущности (клиент → shard по customer_id)

Все данные клиента на одном шарде

Один клиент может перегрузить шард

Directory-based

Таблица-справочник (lookup table)

Гибкость, легко перемещать данные

Дополнительный запрос, SPOF


Примеры:
# Range-based
def get_shard(user_id):
    if user_id <= 1000:
        return "shard1"
    elif user_id <= 2000:
        return "shard2"
    else:
        return "shard3"

# Hash-based
def get_shard(user_id):
    shard_num = hash(user_id) % 3
    return f"shard{shard_num + 1}"

# Geography-based
def get_shard(country):
    mapping = {"US": "shard_us", "EU": "shard_eu", "ASIA": "shard_asia"}
    return mapping.get(country, "shard_default")

# Directory-based
def get_shard(user_id):
    return db.query("SELECT shard_name FROM shard_directory WHERE user_id = ?", user_id)
Выбор ключа шардирования (shard key):
✅ Высокая кардинальность (много уникальных значений): user_id, order_id
✅ Равномерное распределение: hash(email)
❌ Низкая кардинальность: status (active/inactive), country (если мало стран)
❌ Монотонно растущий: created_at (все новые данные → один шард)

🎯 Совет: Для большинства случаев hash-based по user_id/customer_id — лучший выбор.
36. Какие проблемы создает шардирование и как их решать?
Ответ Проблемы шардирования:

1. Нет JOIN между шардами
-- ❌ Не работает, если users и orders на разных шардах:
SELECT u.name, COUNT(o.id)
FROM users u
JOIN orders o ON u.id = o.user_id
GROUP BY u.name;

-- ✅ Решение: денормализация или application-level JOIN
-- Храним user_name прямо в orders:
SELECT user_name, COUNT(id) FROM orders GROUP BY user_name;
2. Нет распределенных транзакций
-- ❌ Нельзя в одной транзакции:
BEGIN;
  UPDATE accounts SET balance = balance - 100 WHERE id = 1; -- shard1
  UPDATE accounts SET balance = balance + 100 WHERE id = 2000; -- shard2
COMMIT;

-- ✅ Решение: Saga pattern или 2PC (Two-Phase Commit)
-- Или храни связанные данные на одном шарде (colocation)
3. Глобальные ID перестают быть уникальными
-- ❌ AUTO_INCREMENT на каждом шарде даст дубликаты:
shard1: INSERT INTO users ... -- id=1
shard2: INSERT INTO users ... -- id=1 (конфликт!)

-- ✅ Решение: UUID, Snowflake ID, или диапазоны:
CREATE TABLE users (
    id BIGINT DEFAULT generate_snowflake_id(),  -- уникальный глобально
    ...
);
4. Resharding (перераспределение) дорогой
Добавили shard4 → нужно переместить 25% данных с shard1,2,3
На TB данных это может занять дни!

✅ Решение:
- Consistent Hashing (минимизирует перемещение)
- Virtual shards (логические шарды >> физических серверов)
- Заранее создавай больше шардов, чем серверов (1 сервер = 4 шарда)
5. Сложность мониторинга и бэкапов
# ❌ Теперь N серверов вместо одного:
pg_dump db1 > backup1.sql
pg_dump db2 > backup2.sql
pg_dump db3 > backup3.sql

# ✅ Решение: централизованный мониторинг (Prometheus + Grafana)
# и автоматизация бэкапов (pg_basebackup + wal-g)
Паттерны решения проблем:

Проблема

Решение

Пример

Нет JOIN

Денормализация, application-level JOIN

Дублируй user_name в orders

Нет транзакций

Saga pattern, eventual consistency

Каждый шард — отдельная транзакция + компенсации

Уникальность ID

UUID, Snowflake ID, диапазоны

id BIGINT DEFAULT gen_snowflake_id ()

Hotspot

Consistent hashing, лучший shard key

Hash (email) вместо range по id

Resharding

Virtual shards, consistent hashing

100 виртуальных шардов на 10 серверов


Инструменты для шардирования в PostgreSQL:
  • Citus (расширение) — прозрачное шардирование, распределенные запросы
  • pg_shard — автоматическое распределение данных
  • Vitess (от YouTube) — middleware для шардирования
  • Кастомное решение — приложение само выбирает шард
🎯 Золотое правило: Шардирование — это крайняя мера. Instagram с 1 млрд пользователей долго работал на одной БД с партиционированием и read replicas. Шардирование добавляет сложность — используй только когда действительно необходимо.
8. Репликация
Аналогия: Репликация — это как синхронизация iCloud. Изменения на одном устройстве (primary) автоматически появляются на других (replicas). Если primary сломается — replica возьмёт на себя нагрузку.
37. Какие типы репликации существуют?
Ответ

Тип

Описание

Streaming

Передача WAL в реальном времени

Logical

Репликация на уровне изменений данных

Synchronous

Ожидание подтверждения от реплики

Asynchronous

Без ожидания (возможна потеря)

38. Чем streaming отличается от logical репликации?
Ответ

Streaming

Logical

Байт-в-байт WAL

Логические изменения

Вся база

Выбранные таблицы

Одинаковая версия PG

Разные версии возможны

Read-only реплика

Можно писать в другие таблицы

-- Logical replication:
CREATE PUBLICATION all_tables FOR ALL TABLES;

CREATE SUBSCRIPTION my_sub
   CONNECTION 'host=primary dbname=mydb'
   PUBLICATION all_tables;
39. Что такое Hot Standby?
Ответ Hot Standby — streaming реплика, доступная для чтения:
  • hot_standby = on
  • Можно выполнять SELECT
  • Нельзя изменять данные
Используется для масштабирования чтения и отказоустойчивости.
40. Что такое replication slots?
Ответ Replication slots — механизм, гарантирующий сохранение WAL до подтверждения репликой.
-- Создать слот:
SELECT pg_create_physical_replication_slot('replica1');

-- Просмотр слотов:
SELECT * FROM pg_replication_slots;
Без слотов: реплика может отстать и потерять данные.
41. Что такое failover?
Ответ Failover — переключение на реплику при отказе primary.
Инструменты для автоматизации:
  • Patroni
  • repmgr
  • pg_auto_failover
9. Производительность
Аналогия: Настройка PostgreSQL — как тюнинг автомобиля. Можно ехать на заводских настройках, но под конкретную нагрузку лучше настроить память, кэш, и количество "потоков".
42. Какие параметры влияют на производительность?
Ответ

Параметр

Назначение

Рекомендация

shared_buffers

Кэш данных

25% RAM

work_mem

Память для сортировки, хеша

64−256MB

effective_cache_size

Оценка кэша ОС

50−75% RAM

maintenance_work_mem

Для VACUUM, CREATE INDEX

512MB-1GB

max_connections

Лимит соединений

Минимально необходимое

43. Что такое connection pooling?
Ответ Connection pooling — пул соединений для переиспользования.
Аналогия: Как служба такси. Вместо того чтобы каждый раз покупать новую машину (создавать соединение), берём из пула свободных.

Инструменты: PgBouncer, pgpool-II
Преимущества:
♻️ Переиспользование соединений (не создаём каждый раз)
⚡ Уменьшение overhead (соединение = ~2MB RAM)
🛡️ Защита от перегрузки max_connections
44. Что такое pg_stat_statements?
Ответ Расширение для анализа запросов:
CREATE EXTENSION pg_stat_statements;

SELECT query, calls, mean_exec_time, total_exec_time
FROM pg_stat_statements
ORDER BY total_exec_time DESC
LIMIT 10;

-- Сбросить статистику:
SELECT pg_stat_statements_reset();
45. Как найти блокирующие запросы?
Ответ
SELECT blocked.pid AS blocked_pid,
      blocking.pid AS blocking_pid,
      blocked.query AS blocked_query,
      blocking.query AS blocking_query
FROM pg_stat_activity blocked
JOIN pg_stat_activity blocking
   ON blocking.pid = ANY(pg_blocking_pids(blocked.pid))
WHERE blocked.wait_event_type = 'Lock';
46. Как мониторить соединения через pg_stat_activity?
Ответ
SELECT pid, usename, state, query,
      NOW() - query_start AS duration
FROM pg_stat_activity
WHERE datname = current_database();

-- Состояния:
-- active — выполняет запрос
-- idle — ожидает новый запрос
-- idle in transaction — В ТРАНЗАКЦИИ, но не выполняет (ОПАСНО!)

-- Убить проблемный процесс:
SELECT pg_terminate_backend(12345);  -- грубое завершение
SELECT pg_cancel_backend(12345);     -- мягкая отмена
10. Безопасность
Аналогия: Безопасность PostgreSQL — как система пропусков в офисе. Роли — это пропуска с разными уровнями доступа. RLS — охранник, который проверяет не только пропуск, но и «твой ли это документ».
47. Как работают роли и привилегии?
 Ответ
CREATE ROLE analyst LOGIN PASSWORD 'secret';
GRANT SELECT ON ALL TABLES IN SCHEMA public TO analyst;
GRANT USAGE ON SCHEMA reporting TO analyst;

-- Отозвать права:
REVOKE INSERT ON users FROM analyst;
48. Что такое Row Level Security (RLS)?
Ответ RLS (Row Level Security) — фильтрация строк на уровне БД.

Аналогия: Как персональный почтовый ящик. Все письма в одном хранилище, но каждый видит только свои.
ALTER TABLE orders ENABLE ROW LEVEL SECURITY;

CREATE POLICY tenant_isolation ON orders
   FOR ALL
   USING (tenant_id = current_setting('app.tenant_id')::INT);

-- Приложение устанавливает tenant_id:
SET app.tenant_id = 123;
SELECT * FROM orders;  -- автоматически только tenant_id = 123
🎯 Преимущество: Фильтрация на уровне БД — невозможно «забыть» WHERE в приложении!
49. Что такое SCRAM-SHA-256?
Ответ Современный метод аутентификации:

  • Пароль не передаётся в открытом виде
  • Защита от replay атак
  • Рекомендуется вместо md5
# pg_hba.conf
hostssl all all 0.0.0.0/0 scram-sha-256
11. Практические паттерны
Это самая полезная часть для production! Здесь собраны рецепты решения типовых задач: пагинация, soft delete, очереди, дубликаты.

🚀 Интересный факт: Slack обрабатывает миллиарды сообщений и использует PostgreSQL как основную БД. Они активно применяют паттерны из этого раздела: keyset pagination, partitioning по времени, и SKIP LOCKED для очередей. Если PostgreSQL справляется со Slack — справится и с твоим проектом!
50. Как реализовать keyset pagination?
Ответ Keyset pagination (курсорная пагинация) — пагинация по значению последней строки вместо OFFSET.
Почему OFFSET плох? OFFSET 1 000 000 — PostgreSQL прочитает миллион строк и выбросит их!
-- Первая страница:
SELECT id, name, created_at FROM products
ORDER BY created_at DESC, id DESC LIMIT 20;
-- Последняя строка: created_at='2024-01-15', id=12345

-- Следующая страница (передаём "курсор"):
SELECT id, name, created_at FROM products
WHERE (created_at, id) < ('2024-01-15', 12345)
ORDER BY created_at DESC, id DESC LIMIT 20;
Что здесь происходит:
  1. Запоминаем значения последней строки как «курсор»
  2. Следующий запрос начинаем с этого места
  3. Производительность O (LIMIT) — константная, независимо от страницы!
-- Индекс для поддержки:
CREATE INDEX ON products(created_at DESC, id DESC);
🎯 Золотое правило: OFFSET для маленьких таблиц (<10K), keyset для больших.
51. Как реализовать soft delete?
 Ответ
ALTER TABLE users ADD COLUMN deleted_at TIMESTAMPTZ;

-- "Удаление":
UPDATE users SET deleted_at = NOW() WHERE id = 123;

-- Выборка активных:
SELECT * FROM users WHERE deleted_at IS NULL;

-- Partial index для оптимизации:
CREATE INDEX idx_users_active ON users(email) WHERE deleted_at IS NULL;

-- Или RLS:
CREATE POLICY active_only ON users
   FOR SELECT USING (deleted_at IS NULL);
52. Как реализовать очередь задач на PostgreSQL?
 Ответ
CREATE TABLE job_queue (
   id BIGSERIAL PRIMARY KEY,
   payload JSONB NOT NULL,
   status TEXT DEFAULT 'pending',
   scheduled_at TIMESTAMPTZ DEFAULT NOW()
);

CREATE INDEX ON job_queue(scheduled_at) WHERE status = 'pending';

-- Взять задачу (атомарно):
WITH next_job AS (
   SELECT id FROM job_queue
   WHERE status = 'pending' AND scheduled_at <= NOW()
   ORDER BY scheduled_at LIMIT 1
   FOR UPDATE SKIP LOCKED  -- ключевой момент!
)
UPDATE job_queue
SET status = 'processing'
WHERE id = (SELECT id FROM next_job)
RETURNING *;
53. Как найти и удалить дубликаты?
 Ответ
-- Найти дубликаты по email:
SELECT email, COUNT(*), array_agg(id) AS ids
FROM users GROUP BY email HAVING COUNT(*) > 1;

-- Удалить дубликаты, оставив с минимальным id:
WITH duplicates AS (
   SELECT id, ROW_NUMBER() OVER (PARTITION BY email ORDER BY id) AS rn
   FROM users
)
DELETE FROM users WHERE id IN (SELECT id FROM duplicates WHERE rn > 1);

-- Предотвратить дубликаты:
CREATE UNIQUE INDEX ON users(email);
54. Как вести историю изменений (temporal tables)?
Ответ Temporal table (темпоральная таблица) — таблица, которая хранит всю историю измененийстрок, а не только актуальное состояние. Используется для аудита, «машины времени» и compliance.

Идея: вместо UPDATE (который уничтожает старое значение) — закрываем старую запись и вставляем новую.
Обычная таблица:          Temporal таблица:
┌────┬───────┬───────┐    ┌────┬───────┬───────────┬──────────────────────┬──────────────────────┐
│ id │ name  │ price │    │ id │ name  │ price     │ valid_from           │ valid_to             │
├────┼───────┼───────┤    ├────┼───────┼───────────┼──────────────────────┼──────────────────────┤
│  1 │ Apple │ 100   │    │  1 │ Apple │ 80        │ 2024-01-01 00:00:00  │ 2024-06-01 12:00:00  │
└────┴───────┴───────┘    │  1 │ Apple │ 100       │ 2024-06-01 12:00:00  │ infinity             │
 Цена обновлена — старая  └────┴───────┴───────────┴──────────────────────┴──────────────────────┘
 цена 80 потеряна!         Обе цены сохранены, можно запросить любой момент
-- Структура таблицы:
CREATE TABLE products (
    id         INT          NOT NULL,
    name       TEXT         NOT NULL,
    price      NUMERIC      NOT NULL,
    valid_from TIMESTAMPTZ  NOT NULL DEFAULT NOW(),
    valid_to   TIMESTAMPTZ  NOT NULL DEFAULT 'infinity',
    PRIMARY KEY (id, valid_from)  -- уникальность: одна запись на момент времени
);

-- Индекс для быстрых запросов "на момент времени":
CREATE INDEX ON products (id, valid_from, valid_to);

-- INSERT — как обычно:
INSERT INTO products (id, name, price) VALUES (1, 'Apple', 80);

-- UPDATE — закрыть старую запись + вставить новую:
WITH closed AS (
    UPDATE products
    SET valid_to = NOW()
    WHERE id = 1 AND valid_to = 'infinity'
    RETURNING id, name
)
INSERT INTO products (id, name, price, valid_from)
VALUES (1, 'Apple', 100, NOW());

-- DELETE — только закрыть запись (данные не удаляются!):
UPDATE products SET valid_to = NOW()
WHERE id = 1 AND valid_to = 'infinity';

-- Запрос текущих данных:
SELECT * FROM products WHERE valid_to = 'infinity';

-- Запрос на конкретный момент времени ("машина времени"):
SELECT * FROM products
WHERE id = 1
  AND valid_from <= '2024-03-15 10:00:00'
  AND valid_to   >  '2024-03-15 10:00:00';

-- Полная история конкретной записи:
SELECT * FROM products WHERE id = 1 ORDER BY valid_from;
VIEW для удобства:
-- Актуальное состояние через VIEW:
CREATE VIEW products_current AS
SELECT id, name, price FROM products WHERE valid_to = 'infinity';
Плюсы и минусы:

Плюсы

Минусы

Полная история изменений

Таблица растёт со временем

Запросы в любой момент прошлого

UPDATE заменяется двумя операциями

Аудит без триггеров

Нужен индекс по (id, valid_from, valid_to)

Без сложной инфраструктуры

Прикладная логика усложняется


Альтернативы: для автоматической истории можно использовать расширение temporal_tables или триггеры, которые сами перекладывают старые версии в history-таблицу.
55. Как сделать batch update большой таблицы?
 Ответ
-- Проблема: UPDATE миллионов строк блокирует таблицу

-- Решение: Batch update с LIMIT
DO $$
DECLARE
   batch_size INT := 10000;
   rows_affected INT;
BEGIN
   LOOP
       UPDATE orders SET status = 'archived'
       WHERE id IN (
           SELECT id FROM orders
           WHERE created_at < '2023-01-01' AND status != 'archived'
           LIMIT batch_size
           FOR UPDATE SKIP LOCKED
       );
       GET DIAGNOSTICS rows_affected = ROW_COUNT;
       EXIT WHEN rows_affected = 0;
       COMMIT;
       PERFORM pg_sleep(0.1);  -- пауза между batch
   END LOOP;
END $$;
12. Специальные возможности PostgreSQL
56. Как работать с JSONB?
 Ответ
-- Операторы:
-> : получить элемент как JSON
->> : получить элемент как текст
@> : содержит
? : есть ключ

-- Индекс:
CREATE INDEX ON docs USING gin(data);

-- Запросы:
SELECT * FROM docs WHERE data @> '{"status": "active"}';
SELECT * FROM docs WHERE data->>'name' = 'test';
57. Что такое LISTEN/NOTIFY?
Ответ Асинхронные уведомления:
-- Подписчик:
LISTEN new_orders;

-- Издатель:
NOTIFY new_orders, '{"order_id": 123}';

-- Через триггер:
CREATE FUNCTION notify_order() RETURNS TRIGGER AS $$
BEGIN
   PERFORM pg_notify('new_orders', row_to_json(NEW)::text);
   RETURN NEW;
END;
$$ LANGUAGE plpgsql;
58. Что такое Advisory Locks?
Ответ Пользовательские блокировки для координации на уровне приложения:
-- Блокировка по ключу:
SELECT pg_advisory_lock(12345);      -- ждёт
SELECT pg_try_advisory_lock(12345);  -- не ждёт, возвращает true/false
SELECT pg_advisory_unlock(12345);

-- Для job queue:
SELECT * FROM jobs WHERE pg_try_advisory_lock(id) LIMIT 1;
59. Что такое Foreign Data Wrapper?
Ответ Доступ к внешним источникам как к таблицам:
CREATE EXTENSION postgres_fdw;

CREATE SERVER remote FOREIGN DATA WRAPPER postgres_fdw
   OPTIONS (host 'db2', dbname 'analytics');

CREATE FOREIGN TABLE remote_orders (...) SERVER remote;

SELECT * FROM remote_orders WHERE ...;  -- прозрачный доступ
60. Что такое pg_cron?
Ответ Расширение для запуска SQL по расписанию внутри PostgreSQL:
CREATE EXTENSION pg_cron;

-- Очистка старых логов каждый день в 3:00:
SELECT cron.schedule('cleanup-logs', '0 3 * * *',
   $$DELETE FROM logs WHERE created_at < NOW() - INTERVAL '30 days'$$);

-- Обновление materialized view каждый час:
SELECT cron.schedule('refresh-mv', '0 * * * *',
   'REFRESH MATERIALIZED VIEW CONCURRENTLY sales_summary');

-- Управление:
SELECT * FROM cron.job;
SELECT cron.unschedule('cleanup-logs');
13. Антипаттерны
⚠️ Внимание! Это список «как НЕ надо делать». Каждый антипаттерн — реальная production-проблема, которая может положить базу
61. Долгие транзакции
Ответы
-- Плохо: транзакция открыта часами
BEGIN;
SELECT * FROM orders;
-- ... приложение думает 2 часа ...
COMMIT;

-- Проблемы:
-- - Блокирует VACUUM
-- - Удерживает transaction ID
-- - Держит блокировки

-- Решение:
SET idle_in_transaction_session_timeout = '5min';
62. Миграции с блокировкой
Ответ
-- Плохо: блокирует таблицу на всё время
CREATE INDEX idx_big ON huge_table(column);

-- Хорошо: не блокирует чтение и запись
CREATE INDEX CONCURRENTLY idx_big ON huge_table(column);

-- Плохо: ACCESS EXCLUSIVE lock
ALTER TABLE users ADD COLUMN age INT NOT NULL DEFAULT 0;

-- Хорошо: поэтапно
ALTER TABLE users ADD COLUMN age INT;
UPDATE users SET age = 0 WHERE age IS NULL;  -- batch
ALTER TABLE users ALTER COLUMN age SET NOT NULL;
63. Прямые DELETE миллионов строк
Ответ
-- Плохо: блокирует, создаёт много dead tuples
DELETE FROM logs WHERE created_at < '2023-01-01';

-- Хорошо: batch delete
DO $$
BEGIN
   LOOP
       DELETE FROM logs WHERE id IN (
           SELECT id FROM logs WHERE created_at < '2023-01-01' LIMIT 10000
       );
       EXIT WHEN NOT FOUND;
       COMMIT;
       PERFORM pg_sleep(0.1);
   END LOOP;
END $$;

-- Ещё лучше: партиционирование + DROP PARTITION
DROP TABLE logs_2022_01;  -- мгновенно!
14. Чеклист Senior

Тема

Ключевые навыки

Транзакции

ACID, уровни изоляции, Write Skew, SERIALIZABLE

MVCC

xmin/xmax, Visibility Map, HOT, системные колонки

Блокировки

FOR UPDATE, deadlock, advisory locks, predicate locks

Архитектура

WAL, MVCC, VACUUM, checkpoint, TOAST, shared_buffers

Партиционирование

RANGE, LIST, HASH, pruning, многоуровневое

Шардирование

Range/Hash/Geography-based, resharding, проблемы JOIN

Репликация

Streaming, logical, slots, failover, CDC

Производительность

Настройка параметров, профилирование, connection pooling

Безопасность

RLS, роли, аудит, SSL, SCRAM

Паттерны

Keyset pagination, soft delete, audit log, job queue

15. Чеклист перед деплоем SQL изменений
Новые запросы
  • EXPLAIN ANALYZE выполнен
  • Индексы есть для WHERE, JOIN, ORDER BY
  • Нет функций на индексированных колонках
  • Нет N+1 запросов
Миграции
  • CREATE INDEX CONCURRENTLY вместо CREATE INDEX
  • ALTER TABLE не блокирует надолго
  • Протестировано на копии продакшена
Производительность
  • Пагинация через keyset (не OFFSET на больших данных)
  • Batch операции для массовых изменений
  • Connection pooling настроен
Мониторинг

  • pg_stat_statements включён
  • Алерты на долгие запросы
  • Алерты на idle in transaction
  • VACUUM работает
Заключение
Поздравляю! 🎉 Ты прошёл полный курс SQL и PostgreSQL — от базового SELECT до архитектуры MVCC и production-паттернов.

Что ты теперь знаешь:
✅ Транзакции и ACID — как гарантировать целостность данных
✅ Уровни изоляции — компромисс между надёжностью и скоростью
✅ MVCC — почему PostgreSQL так хорошо работает параллельно
✅ Архитектура — WAL, VACUUM, checkpoint под капотом
✅ Партиционирование, шардирование и репликация — масштабирование на миллионы записей
✅ Production-паттерны — keyset pagination, soft delete, job queues
✅ Антипаттерны — чего избегать, чтобы не положить базу

Ключевые выводы:
ACID — не магия, а конкретные механизмы (WAL, MVCC, locks)
MVCC = Git для данных — версионирование вместо блокировок
VACUUM — не опционально — без него база "распухнет"
Keyset > OFFSET для больших таблиц
Короткие транзакции — залог здоровья БД
🚀 Что дальше?
  1. Практика 4 — реши задачи на транзакции, паттерны и мониторинг (Практика 4 (Транзакции, Паттерны, Мониторинг).md)
  2. Квизы — проверь свои знания через интерактивные вопросы (Квизы для самопроверки. md)
  3. Реальные схемы — изучи production-примеры БД для e-commerce, соцсетей, аналитики (Примеры реальных схем БД. md)
  4. Troubleshooting — научись диагностировать и решать типовые проблемы (Troubleshooting Guide. md)
  5. Глоссарий — закрепи терминологию и определения (Глоссарий терминов. md)
💡 Помни: Теория без практики = 0. Обязательно реши все задачи из Практики 4 и пройди квизы!
Шпаргалка: Ключевые команды
Код
-- Транзакции
BEGIN; ... COMMIT;
BEGIN ISOLATION LEVEL SERIALIZABLE;
SELECT ... FOR UPDATE SKIP LOCKED;

-- Мониторинг
SELECT * FROM pg_stat_activity;
SELECT * FROM pg_stat_user_tables;
SELECT * FROM pg_locks;

-- Блокировки
SELECT pg_blocking_pids(pid);
SELECT pg_cancel_backend(pid);
SELECT pg_terminate_backend(pid);

-- Статистика запросов
CREATE EXTENSION pg_stat_statements;
SELECT query, mean_exec_time FROM pg_stat_statements ORDER BY total_exec_time DESC;

-- VACUUM и статистика
VACUUM ANALYZE table_name;
SELECT relname, n_dead_tup, last_autovacuum FROM pg_stat_user_tables;