23.1 Три слоя блокировок
У PostgreSQL не один механизм блокировок, а три, под разные задачи и разное время жизни.
| Слой | Что защищает | Время жизни | Видно в |
|---|---|---|---|
| Тяжёлые (heavyweight) | таблицы, строки, объекты | до конца транзакции | pg_locks |
| Лёгкие (LWLock) | структуры в shared memory | на время операции | wait_event |
| Spinlock | пара полей, счётчик | десятки тактов | нигде |
Тяжёлые мы уже знаем: они про логику данных, держатся долго, у них есть очередь и детектор deadlock. Лёгкие и spinlock - про целостность внутренних структур самого сервера: буферный кеш, буферы WAL, таблицы хешей в памяти. Они короткие и не имеют отношения к твоим транзакциям как к данным - только к тому, чтобы два бэкенда не испортили общую структуру, трогая её одновременно.
23.2 Лёгкие блокировки (LWLock)
LWLock охраняет конкретную структуру в разделяемой памяти на время, пока с ней работают. Пример: чтобы прочитать страницу из буферного кеша, бэкенд берёт LWLock на нужный буфер, копирует, отпускает. Чтобы дописать запись в буфер WAL - берёт LWLock на вставку в WAL.
У LWLock два режима: разделяемый (несколько читателей) и исключительный (один писатель), как у тяжёлых SHARE/EXCLUSIVE. Но есть три принципиальных отличия от тяжёлых:
- они держатся не до конца транзакции, а до конца короткой операции - микросекунды;
- у них нет обнаружения взаимоблокировок: код ядра берёт LWLock в строго фиксированном порядке, поэтому цикл невозможен по построению;
- их нет в
pg_locks- там только тяжёлые. LWLock проявляется как ожидание сwait_event_type = 'LWLock'.
Имена LWLock говорят, за что идёт борьба: WALInsert - вставка в
WAL, BufferContent - содержимое буфера, LockManager - сама
таблица тяжёлых блокировок. Если под нагрузкой бэкенды массово
ждут WALInsert, узкое место - запись журнала, а не диск с данными.
23.3 Spinlock: самый дешёвый замок
Иногда нужно защитить буквально пару инструкций - например, увеличить счётчик в общей структуре. Заводить ради этого LWLock дорого: сама постановка в очередь LWLock тяжелее, чем защищаемая работа.
Для таких случаев есть spinlock. Он не усыпляет процесс: если замок занят, бэкенд крутится в коротком цикле (busy-wait), снова и снова проверяя, не освободился ли он. Это разумно, только если замок держат считанные такты - дольше крутиться было бы расточительно.
Spinlock нигде не видно: у него нет ни имени, ни записи, ни
wait_event. Это сознательно - любой учёт стоил бы больше самой
защищаемой операции. Для тебя как пользователя spinlock - деталь
реализации; знать о нём полезно лишь чтобы понимать, что не каждая
синхронизация внутри сервера отражается в статистике.
23.4 Wait events: на чём стоит бэкенд
Раз LWLock и ожидания тяжёлых блокировок не сводятся в одну
таблицу, нужен общий язык «на чём именно сейчас ждёт процесс». Это
и есть wait events. Каждый бэкенд в pg_stat_activity показывает
две колонки:
SELECT pid, state, wait_event_type, wait_event, query
FROM pg_stat_activity
WHERE state != 'idle';
wait_event_type - категория ожидания, wait_event - конкретное
событие. Основные категории:
| Тип | Что значит |
|---|---|
Lock | ждёт тяжёлую блокировку (та самая очередь) |
LWLock | ждёт лёгкую блокировку структуры в памяти |
IO | ждёт диск (чтение/запись страницы, WAL) |
Client | ждёт данные от клиента (часто idle) |
IPC | ждёт другой процесс сервера |
Timeout | спит по таймеру |
Если wait_event_type пустой, процесс не ждёт - он работает на
процессоре. Это тоже информация: упёрлись в CPU, а не в блокировки
или диск.
23.5 Семплирование ожиданий
Один снимок pg_stat_activity показывает ожидания в момент
запроса - этого мало. Узкое место ищут семплированием: снимают
wait_event много раз подряд и смотрят, какое событие встречается
чаще всего. Это профилировщик для бедных, встроенный в сервер.
-- грубое семплирование: что чаще всего под нагрузкой
SELECT wait_event_type, wait_event, count(*)
FROM pg_stat_activity
WHERE state = 'active'
GROUP BY 1, 2
ORDER BY count(*) DESC;
Прогнав такой запрос десятки раз во время нагрузки, получаешь
распределение: если 80% выборок показывают Lock, проблема в
конкуренции за строки; если IO:DataFileRead - не хватает кеша и
бьём по диску; если событий нет вовсе - всё упёрлось в процессор.
В продакшене это семплирование автоматизируют расширениями, но
принцип ровно тот же.
23.6 Предикатные блокировки: замок, который не блокирует
Последний вид - предикатные блокировки, и они самые контринтуитивные. Их использует уровень изоляции Serializable в реализации SSI (Serializable Snapshot Isolation). Слово «блокировка» здесь почти обманывает: предикатная блокировка никого не заставляет ждать.
Идея такая. На Serializable PostgreSQL должен гарантировать, что результат параллельных транзакций совпадёт с каким-нибудь последовательным их выполнением. Для этого ему мало версий строк - нужно знать, какие данные транзакция читала, чтобы поймать ситуацию, когда другая транзакция изменила ровно то, на чём первая строила решение.
Поэтому при чтении на Serializable транзакция оставляет
предикатную блокировку (SIReadLock) на прочитанных строках,
страницах или таблице - метку «я это читала». Эти метки никого не
тормозят. Но если другая транзакция пишет туда, куда стоит чужой
SIReadLock, и вместе они образуют опасный шаблон зависимостей,
одна из них при коммите получит ошибку сериализации (SQLSTATE
40001). Проверять SSI помогает lightweight-predicate-locks.
23.6.1 Копнуть глубже: SIReadLock в pg_locks и его огрубление
В отличие от LWLock, предикатные блокировки видны в pg_locks -
с режимом SIReadLock:
BEGIN ISOLATION LEVEL SERIALIZABLE;
SELECT count(*) FROM flights WHERE departure = 'SVO';
SELECT locktype, relation::regclass, mode
FROM pg_locks WHERE mode = 'SIReadLock';
Гранулярность у них переменная: сначала PostgreSQL метит отдельные
кортежи, но если их слишком много, огрубляет до страницы, а затем
до всей таблицы - чтобы не утонуть в учёте. Огрубление повышает
шанс ложного срабатывания (ошибка сериализации там, где реального
конфликта не было), зато ограничивает память. Это компромисс между
точностью и стоимостью, типичный для Serializable: на нём
приложение обязано быть готовым повторять транзакцию при 40001,
как при deadlock.
Уроки в sandbox
lab-23.1. Прочитать wait events и предикатные блокировки
Сначала заставим бэкенд ждать тяжёлую блокировку и поймаем его
wait_event. Потом откроем транзакцию на Serializable и найдём
её SIReadLock. Перед проверкой предскажи wait_event_type
ждущего и наличие строк SIReadLock.
Сессия A:
BEGIN; SELECT * FROM bookings WHERE book_ref='000001' FOR UPDATE;- держит строку.Сессия B:
BEGIN; SELECT * FROM bookings WHERE book_ref='000001' FOR UPDATE;- виснет в очереди за A.Сессия C:
SELECT pid, wait_event_type, wait_event FROM pg_stat_activity WHERE wait_event_type='Lock';. Предскажи и проверь: B стоит с типомLock.Заверши A (
COMMIT), убедись, что B разблокировалась, и закрой B.Сессия A:
BEGIN ISOLATION LEVEL SERIALIZABLE; SELECT count(*) FROM flights WHERE departure='SVO';- оставь транзакцию открытой.Сессия C:
SELECT count(*) FROM pg_locks WHERE mode='SIReadLock';. Предскажи и проверь: счётчик больше нуля - это следы предикатных блокировок чтения.
sandbox с автопроверкой - открыть в песочнице
Резюме
- У PostgreSQL три слоя блокировок: тяжёлые (данные, до конца транзакции, в pg_locks), лёгкие LWLock (структуры памяти, на время операции) и spinlock (пара полей, десятки тактов).
- LWLock защищает буферы, WAL, таблицы хешей. У него два режима, нет детектора deadlock (берётся в фиксированном порядке) и нет места в pg_locks - только wait_event.
- Spinlock крутится в busy-wait вместо сна и оправдан, лишь когда замок держат считанные такты. Он нигде не учитывается - учёт стоил бы дороже самой операции.
- wait_event_type/wait_event в pg_stat_activity - общий язык ожиданий: Lock, LWLock, IO, Client, IPC, Timeout. Пустой тип значит работу на CPU.
- Узкое место ищут семплированием wait_event: снять много раз и посмотреть, что чаще. Lock - конкуренция, IO - мало кеша, ничего - упёрлись в процессор.
- Предикатные блокировки (SIReadLock) нужны Serializable/SSI и не блокируют: это метки «я это читала» для поиска опасных зависимостей при коммите.
- SIReadLock виден в pg_locks и огрубляется кортеж → страница → таблица ради экономии памяти; цена огрубления - ложные ошибки сериализации 40001, которые надо ретраить.
Контрольные вопросы
Почему у лёгких блокировок нет обнаружения взаимоблокировок, а у тяжёлых есть?
Показать ответ
Потому что код ядра берёт LWLock-и в строго фиксированном глобальном порядке. Если все участники всегда захватывают замки в одном порядке, цикл в графе ожидания невозможен по построению - значит, и искать его не нужно. Тяжёлые блокировки берутся по логике данных (какие строки тронул запрос), порядок предсказать нельзя, поэтому там нужен детектор циклов. LWLock жертвует гибким порядком ради скорости и отсутствия проверок.
Бэкенд в `pg_stat_activity` показывает `wait_event_type` пустым. Что это значит?
Показать ответ
Что процесс ни на что не ждёт - он прямо сейчас выполняется на процессоре. Это не ошибка и не «зависание»: пустой тип ожидания означает работу, а не простой. При диагностике производительности это важный сигнал: если под нагрузкой большинство активных бэкендов с пустым wait_event, упёрлись в CPU, а не в блокировки или диск, и тюнить надо запросы/индексы, а не конкуренцию.
Почему spinlock крутится вхолостую вместо того, чтобы заснуть и освободить процессор?
Показать ответ
Потому что spinlock держат считанные такты. Усыпить процесс и потом разбудить - это переключение контекста, которое стоит на порядки дороже, чем защищаемая операция (увеличить счётчик). Выгоднее несколько раз проверить замок в коротком цикле и тут же его взять, чем спать. Этот приём оправдан только при микроскопическом времени удержания; для долгих ожиданий используют LWLock со сном и очередью.
В каком смысле предикатная блокировка - блокировка, если она никого не останавливает?
Показать ответ
В смысле учёта, а не ожидания.
SIReadLock- это метка «транзакция прочитала вот эти данные», которую Serializable оставляет, чтобы потом обнаружить опасный шаблон: кто-то записал туда, на что чужая транзакция опиралась при чтении. Никто не ждёт из-за этой метки. Конфликт всплывает только при коммите - в виде ошибки сериализации 40001, после которой транзакцию нужно повторить. То есть это механизм обнаружения, а не взаимного исключения.Зачем PostgreSQL огрубляет предикатные блокировки до страницы и таблицы?
Показать ответ
Чтобы ограничить память. Если метить каждый прочитанный кортеж, на большом чтении число
SIReadLockстанет огромным. Поэтому при превышении порога PostgreSQL заменяет множество кортежных меток одной меткой на страницу, а затем на всю таблицу. Цена - снижение точности: грубая метка ловит больше «подозрительных» пересечений, чем реально опасно, и иногда даёт ложную ошибку сериализации. Это сознательный компромисс между расходом памяти и числом ложных 40001.