3. MVCC в PostgreSQL
Когда вы проектируете многопоточное приложение на Go и вам требуется защитить разделяемое состояние в оперативной памяти (например, кэш на базе map), первым интуитивным решением становится мьютекс с разделением прав чтения и записи: sync.RWMutex. Логика проста: сотни горутин могут параллельно читать карту (RLock()), но как только одной горутине требуется обновить ключ, она захватывает эксклюзивную блокировку (Lock()), и все читатели замирают в ожидании.
В мире высоконагруженных реляционных баз данных подобная блокировочная модель привела бы к катастрофической деградации пропускной способности. Если бы транзакция, обновляющая баланс пользователя в онлайн-банкинге или статус заказа в интернет-магазине, замораживала чтение этой записи для всех остальных клиентов, система не выдержала бы и сотни конкурентных запросов.
Чтобы обеспечить высочайший параллелизм, современные СУБД опираются на парадигму MVCC (Multi-Version Concurrency Control — управление конкурентным доступом с помощью многоверсионности). Золотое правило MVCC звучит так: Читатели никогда не блокируют писателей, а писатели никогда не блокируют читателей.
Мы уже разбирали теоретические основы многоверсионности в статье 7. MVCC. Multi Version Concurrency Control. Однако физическая реализация MVCC в ядре PostgreSQL кардинально отличается от подходов MySQL (InnoDB) или Oracle. Это фундаментальное архитектурное отличие напрямую влияет на то, как бэкенд-инженер должен писать транзакционный код в Go и проектировать схемы реляционных таблиц.
Философия PostgreSQL: Версии данных прямо в куче (In-Place Heap)
В большинстве классических реляционных СУБД (таких как MySQL с движком InnoDB или Oracle) при обновлении строки новая версия записывается непосредственно на место старой, а предыдущие значения столбцов вытесняются в специальный циклический журнал отката — Undo Log. Если параллельный запрос читает старый снимок данных, он на лету конструирует прежнюю версию строки, восстанавливая дельты изменений из Undo Log в оперативной памяти.
Архитекторы PostgreSQL избрали радикально иной, бескомпромиссно надежный путь: в PostgreSQL вообще нет Undo Logs.
Все версии одной и той же строки (прошлая, текущая закоммиченная и будущая, модифицируемая незавершенной транзакцией) физически сосуществуют бок о бок в одном и том же файле таблицы — в той самой дисковой Куче (Heap), устройство которой мы детально изучали в 2. Storage engine PostgreSQL.
Анатомия UPDATE: Почему обновление — это удаление плюс вставка
Поскольку старые версии строк не вытесняются в Undo Log, в движке PostgreSQL на физическом уровне не существует операции перезаписи данных (In-place UPDATE)!
Когда вы отправляете в базу запрос:
UPDATE users SET balance = 150 WHERE id = 1;
под капотом страничного хранилища разворачивается следующая цепочка низкоуровневых операций:
- Движок отыскивает на 8-килобайтной странице текущую видимую версию кортежа (
t_xmax = 0). - Вместо физической модификации байтов баланса, СУБД «мягко удаляет» старую версию: в ее системное поле
t_xmaxзаписывается ID текущей транзакции (допустим,105). - Рядом (на этой же странице, если хватает свободного места, либо на новой выделенной странице) создается совершенно новый физический кортеж со значением баланса
150. - У нового кортежа атрибут
t_xminустанавливается в105, а полеt_xmaxинициализируется нулем (0). - Старый кортеж получает физическую ссылку на новый через скрытое поле
t_ctid, образуя цепочку версий.
flowchart TD
classDef process fill:#1e293b,stroke:#38bdf8,stroke-width:1px,color:#f8fafc
classDef success fill:#064e3b,stroke:#34d399,stroke-width:1px,color:#f8fafc
classDef error fill:#7f1d1d,stroke:#f87171,stroke-width:1px,color:#f8fafc
classDef warning fill:#78350f,stroke:#fbbf24,stroke-width:1px,color:#f8fafc
classDef entry fill:#1e3a5f,stroke:#38bdf8,stroke-width:2px,color:#f8fafc
classDef aux fill:#334155,stroke:#64748b,stroke-width:1px,color:#f8fafc
classDef system fill:#312e81,stroke:#a78bfa,stroke-width:1px,color:#f8fafc
classDef data fill:#132d29,stroke:#2dd4bf,stroke-width:1px,color:#f8fafc
classDef network fill:#881337,stroke:#fb7185,stroke-width:1px,color:#f8fafc
classDef accent fill:#1e1b4b,stroke:#818cf8,stroke-width:1px,color:#f8fafc
subgraph Page ["8-килобайтная страница Heap"]
direction TB
V1["Версия 1 (Старая) <br> id=1, balance=100 <br> t_xmin=100, t_xmax=105"]
V2["Версия 2 (Новая) <br> id=1, balance=150 <br> t_xmin=105, t_xmax=0"]
V1 -.->|"t_ctid указывает на новую строку"| V2
end
TX105["Транзакция 105 <br> UPDATE users SET balance = 150 WHERE id = 1"] -->|"1. Записывает t_xmax = 105"| V1
TX105 -->|"2. Создает новый кортеж (t_xmin = 105)"| V2
Обе физические версии строки одновременно присутствуют на страницах накопителя. Любая параллельная транзакция, стартовавшая до фиксации (коммита) транзакции 105, будет прозрачно читать Версию 1. Все последующие транзакции, инициированные после успешного коммита, увидят Версию 2.
Снимки данных (Snapshots) и правила видимости
Каким образом исполнитель запросов (Executor) при выполнении SELECT безошибочно определяет, какая из версий кортежа является видимой для текущего клиента?
В момент начала выполнения SQL-команды (или в момент старта транзакции — в зависимости от выбранного уровня изоляции, подробно разобранного в 3. Read Committed, Repeatable Read, Serializable), PostgreSQL генерирует легковесный Снимок данных (Snapshot).
Важно подчеркнуть: создание снимка — это не физическое копирование данных! Снимок представляет собой компактную структуру в оперативной памяти (в рантайме ядра СУБД), описывающую текущее состояние глобального счетчика транзакций:
xminснимка: Наименьший идентификатор транзакции (xid), которая еще активна (не закоммичена и не откачена) на момент фиксации снимка. Все транзакции сxid < xminгарантированно завершены, и их результаты считаются видимыми в прошлом.xmaxснимка: Следующий еще не назначенный идентификатор транзакции (next_xid). Ни одна транзакция сxid >= xmaxеще не существовала в момент создания снимка, следовательно, ее изменения строго невидимы.xip_list(active transactions list): Срез идентификаторов всех транзакций, которые были активны (in-flight) ровно в момент снятия снапшота (междуxminиxmax).
flowchart LR
classDef process fill:#1e293b,stroke:#38bdf8,stroke-width:1px,color:#f8fafc
classDef success fill:#064e3b,stroke:#34d399,stroke-width:1px,color:#f8fafc
classDef error fill:#7f1d1d,stroke:#f87171,stroke-width:1px,color:#f8fafc
classDef warning fill:#78350f,stroke:#fbbf24,stroke-width:1px,color:#f8fafc
classDef entry fill:#1e3a5f,stroke:#38bdf8,stroke-width:2px,color:#f8fafc
classDef aux fill:#334155,stroke:#64748b,stroke-width:1px,color:#f8fafc
classDef system fill:#312e81,stroke:#a78bfa,stroke-width:1px,color:#f8fafc
classDef data fill:#132d29,stroke:#2dd4bf,stroke-width:1px,color:#f8fafc
classDef network fill:#881337,stroke:#fb7185,stroke-width:1px,color:#f8fafc
classDef accent fill:#1e1b4b,stroke:#818cf8,stroke-width:1px,color:#f8fafc
subgraph Snapshot ["Структура Snapshot в памяти"]
S_XMIN["xmin: 100 <br> (Все xid < 100 закоммичены)"]
S_XIP["xip_list: [102, 104] <br> (Активны прямо сейчас)"]
S_XMAX["xmax: 106 <br> (Будущие транзакции)"]
end
subgraph Evaluation ["Проверка кортежа при чтении"]
Tuple["Строка: t_xmin=102, t_xmax=0"]:::process
Check{"t_xmin в xip_list?"}:::warning
Verdict["Строка НЕВИДИМА <br> (Транзакция 102 еще не закоммичена)"]
end
Snapshot --> Evaluation
Tuple --> Check
Check -- "Да" --> Verdict
Когда исполнитель запроса сканирует 8-килобайтную страницу и инспектирует заголовок кортежа, он применяет математически строгие Правила видимости (Visibility Rules):
- Если
t_xminкортежа больше или равенxmaxснимка либо присутствует в списке активных транзакцийxip_list(транзакция-создатель еще не зафиксирована) — кортеж невидим. - Если создавшая транзакция
t_xminуспешно завершена (COMMITTED), но удалившая транзакцияt_xmaxактивна вxip_listили принадлежит будущему (>= xmax) — кортеж видим (удаление для нас еще не произошло). - Если
t_xminзафиксирован в прошлом, а полеt_xmaxравно0(или транзакция, удалившая строку, была отмененаABORTED) — кортеж видим.
Эта проверка выполняется со скоростью машинных инструкций в L1/L2-кэшах процессора над байтами заголовка строки, без системных вызовов к ядру ОС и без ожидания дисковых операций.
Mechanical Sympathy: Раздувание таблиц (Table Bloat)
Хранение всех версий данных непосредственно в Куче дает невероятную скорость чтения и изоляцию, но имеет оборотную сторону: раздувание таблиц и индексов (Table and Index Bloat).
Поскольку каждая операция UPDATE порождает новый физический кортеж, а DELETE лишь выставляет маркер t_xmax, таблица постоянно прирастает физическими страницами. Если ваша система интенсивно обновляет один и тот же набор из 10 000 строк (например, статусы очередей задач, остатки товаров или координаты курьеров), со временем таблица может разрастись до 10 гигабайт на диске, хотя полезных данных в ней по-прежнему содержится всего 10 000 записей!
Чем опасно раздувание?
- Резко возрастает дисковый ввод-вывод (I/O): чтобы прочитать 1 000 живых строк, базе приходится вычитывать с SSD сотни тысяч 8-килобайтных страниц с "мертвыми" кортежами.
- Катастрофически падает утилизация кэша
shared_buffers: ценная оперативная память забивается страницами с мертвыми версиями, вытесняя полезные горячие данные. - Растет задержка (Latency) клиентских запросов в Go-сервисах.
Для утилизации мертвых строк в рантайм PostgreSQL встроен компонент Autovacuum (фоновый сборщик мусора СУБД). Он обходит страницы, находит кортежи, чей t_xmax находится глубже горизонта видимости всех активных транзакций, и освобождает занимаемое ими пространство для новых записей (детально о его тонкой настройке — в 10. VACUUM, ANALYZE).
Оптимизация HOT (Heap-Only Tuples)
Чтобы снизить чудовищный оверхед при обновлении записей, в PostgreSQL реализован элегантный механизм HOT (Heap-Only Tuples — кортежи только в куче).
В стандартном сценарии при вставке нового кортежа СУБД обязана обновить все до единого индексы таблицы (B-Tree, Hash), вставив в них ссылки на новый CTID. Если у вас на таблице висит 6 вторичных индексов, один UPDATE вызывает лавину случайных записей в 6 индексных деревьев!
Когда срабатывает HOT?
HOT-оптимизация включается автоматически, если соблюдены два строгих условия:
- Запрос
UPDATEне модифицирует ни одной колонки, по которой построен хотя бы один индекс. - На текущей 8-килобайтной странице данных Heap имеется достаточно свободного места (
Free Space) для размещения новой версии строки.
flowchart LR
classDef process fill:#1e293b,stroke:#38bdf8,stroke-width:1px,color:#f8fafc
classDef success fill:#064e3b,stroke:#34d399,stroke-width:1px,color:#f8fafc
classDef error fill:#7f1d1d,stroke:#f87171,stroke-width:1px,color:#f8fafc
classDef warning fill:#78350f,stroke:#fbbf24,stroke-width:1px,color:#f8fafc
classDef entry fill:#1e3a5f,stroke:#38bdf8,stroke-width:2px,color:#f8fafc
classDef aux fill:#334155,stroke:#64748b,stroke-width:1px,color:#f8fafc
classDef system fill:#312e81,stroke:#a78bfa,stroke-width:1px,color:#f8fafc
classDef data fill:#132d29,stroke:#2dd4bf,stroke-width:1px,color:#f8fafc
classDef network fill:#881337,stroke:#fb7185,stroke-width:1px,color:#f8fafc
classDef accent fill:#1e1b4b,stroke:#818cf8,stroke-width:1px,color:#f8fafc
Index["B-Tree Index Entry <br> (Root -> Leaf: key=42)"] -->|"CTID (Block 1, Item 1)"| V1
subgraph Page1 ["Страница 8 KB (Block 1)"]
direction TB
V1["Старый кортеж (Item 1) <br> t_xmax=200, HOT-link -> Item 2"]
V2["Новый кортеж (Item 2) <br> t_xmin=200, t_xmax=0"]
V1 -.->|"Внутренний HOT-link"| V2
end
При HOT-обновлении база данных вообще не касается индексных структур!
В листовом узле B-Tree остается прежний указатель на исходную строку (Item 1). Когда поисковый запрос по индексу приходит на страницу данных, он считывает заголовок старой строки, мгновенно видит флаг HEAP_HOT_UPDATED и переходит по внутренней ссылке к актуальной версии (Item 2). Экономия на дисковом вводе-выводе и блокировках индексов достигает 80–90%!
Чтобы обеспечить постоянное наличие свободного места для HOT-обновлений, на часто модифицируемых таблицах настраивают параметр заполнения страниц: ALTER TABLE accounts SET (fillfactor = 70);. Это оставляет 30% каждой страницы про запас специально для HOT-цепочек.
Резюме для инженера
- Архитектура без Undo Log: Все версии строк хранятся непосредственно в страницах Heap, что исключает накладные расходы на поддержание отдельного журнала отката.
UPDATE— это дублирование: Каждое обновление физически создает новый кортеж и помечает старый черезt_xmax, неизбежно порождая раздувание (Bloat) при высокой частоте модификаций.- Снимки (Snapshots): Обеспечивают консистентное неблокирующее чтение через сравнение системных полей
t_xmin/t_xmaxсо списком активных транзакцийxip_list. - HOT Updates: Спасают производительность, исключая модификацию вторичных индексов при условии наличия свободного места на странице.
- Опасность долгих транзакций: Зависшие транзакции в Go блокируют очистку мертвых строк Autovacuum'ом и могут привести к неконтролируемому раздуванию базы или даже угрозе Transaction ID Wraparound.
Зная, как хранятся и версионируются данные на диске, мы можем перейти к следующему краеугольному камню производительности — поисковым деревьям и индексам. В следующей лекции: 4. Индексы в PostgreSQL.