Cache-aside без гонки: кэш строк внутри PostgreSQL
Когда чтения по первичному ключу становятся частыми, обычно рядом с PostgreSQL
разворачивают Redis или Valkey и используют cache-aside: сначала читают кэш,
при промахе читают PostgreSQL и заполняют кэш, а после каждой записи удаляют
ключ. Всё работает, пока чтение и запись не пересекаются. Читатель, не нашедший
ключ, может получить строку прямо перед коммитом писателя; DEL писателя
приходит первым, заполнение читателя — следом, и старая строка остаётся в кэше,
пока её что-нибудь не удалит. Ранее мы разбирали эту гонку позднего заполнения.
pg_local_cache переносит кэш внутрь PostgreSQL, поэтому кэш и записи, за которыми он должен следить, работают в одной транзакционной системе.
Как обеспечивается корректность
- Строки подключённой таблицы хранятся в ограниченной общей памяти,
индексируются первичным ключом и выдаются фоновыми процессами по RESP2.
Читать их можно любым Redis-клиентом через
MGET; ключ имеет видCRUD:<db>.<schema>.<table>:<json primary key>. - Триггеры строк публикуют маркер инвалидации для каждого записанного ключа до
того, как транзакция станет видимой. Изменения всей таблицы, например
TRUNCATE, публикуют маркер для таблицы целиком. - При промахе читатель сначала получает токен чтения. Результат попадёт в кэш, только если между этим моментом и заполнением не было маркера для ключа, таблицы или всего кэша. Заполнение, пересёкшееся с записью, отбрасывается.
- Параллельные промахи по одному ключу объединяются: строку загружает один читатель, остальные используют его результат.
- Источником истины остаётся PostgreSQL. Кэш временный, его можно удалить в любой момент.
Затраты и преимущества
Эти цифры получены на версии 3.1.0 на двух виртуальных машинах с 16 vCPU (Xeon Gold 6354, 10 GbE), PostgreSQL 16 и клиентом Go/pgx. На странице бенчмарков приведены все таблицы, исходные файлы JSONL и шаги для воспроизведения.
Чтения при записи. 64 читателя, один ключ на чтение, равномерное
распределение по 60k строк, 30,000 UPDATE/с:
| Стек | Чтений/с | Доля попаданий | Устаревшие записи |
|---|---|---|---|
| pg_local_cache | 161,919 | 0.837 | 0 |
| Valkey cache-aside | 42,294 | 0.577 | 0 |
| Подготовленный SQL | 106,949 | — | — |
При неограниченном числе писателей cache-aside на Valkey завершился с
устаревшими записями (1 в смешанном прогоне; 2 и 1 в 120-секундных проверках).
Во всех прогонах pg_local_cache завершал работу без них, в том числе после
часовой проверки с 252M MGET и 72M UPDATE.
Накладные расходы на запись. Относительно обычной записи в таблицу
пропускная способность UPDATE через pg_local_cache была ниже на 3.6–8.2%, а
для UPDATE с последующим DEL в Valkey — на 28.9–63.7%.
Пропускная способность чтения. При ограничении сервера одной парой vCPU pg_local_cache обслуживал 210k чтений/с против 180k у однопоточного Valkey; при двух парах — 337k против 266k у Valkey с потоками ввода-вывода. При четырёх парах обе системы достигали плато около 360k из-за ограничения со стороны клиента; pg_local_cache использовал 5.2 vCPU сервера, Valkey — 8.0. Однопоточный Valkey расходовал меньше всего CPU на запрос. В тестах с одним ключом подготовленный SQL сильно отставал.
Когда это неподходящий инструмент
- Это не замена Redis: нет TTL, pub/sub и структур данных, только чтение целых строк по первичному ключу.
- Горячий набор данных должен помещаться в настроенную память; размер строки ограничен 64 KiB JSON по RESP.
- Поддерживается одна база данных на одном доступном для записи основном сервере.
- Требуются
shared_preload_librariesи перезапуск.
Попробуйте
pg_local_cache поддерживает PostgreSQL 14–18 и распространяется по лицензии MIT. Установите его из релизов GitHub (deb и rpm), PGXN или репозиториев Pigsty, затем выполните краткое руководство.