Бенчмарки кэша PostgreSQL: 3.1.0
В этих измерениях сравниваются pg_local_cache RESP MGET, Valkey и подготовленный SQL на двух VM в частной сети. Все результаты получены на финальной сборке 3.1.0 c431bcc. Для матриц чтения и тестов записи приведены медианы повторных запусков; оба закреплённых однопарных случая Valkey io-threads=1 имеют по два повтора, остальные закреплённые случаи — по пять. Смешанные тесты, 120-секундные проверки устаревания и часовой soak — одиночные запуски. Эти прогоны не обещают производительность production.
Результаты финальной сборки
- На одно закреплённое ядро лучшая измеренная конфигурация Valkey на одном ядре — io-threads=1 (180k чтений/с против 210k у pg_local_cache), на двух ядрах — io-threads=4 (266k против 337k). На четырёх ядрах достигнуто ограниченное клиентом плато около 361k против 357k; pg_local_cache использовал 5,24 против 7,95 vCPU сервера. Valkey с io-threads=1 расходовал меньше всего CPU сервера на запрос. Подготовленный SQL сильно отставал в тестах одного ключа.
- При Valkey io-threads=8 наблюдаемые различия медианной пропускной способности MGET 16/64 составили примерно от −2,15 % до +1,26 %; диапазоны не пересекались ни в одном из четырёх сравнений на 64 ключах. Эти выборки не доказывают статистическую эквивалентность. Широкие MGET ограничивались клиентом и сетью. В одной строке p99 pg_local_cache выше для случайных ключей, MGET 64 и 256 клиентов (43,52 против 40,37 мс).
- Накладные расходы относительно транзакций обычной таблицы составили 3,6–8,2% для UPDATE и 3,7–5,5% для INSERT. Обычный UPDATE с последующим Valkey DEL уступал по UPDATE 28,9–63,7%.
- Во время записи проверки не нашли устаревших записей pg_local_cache. Через 120 секунд у Valkey cache-aside осталось 2 устаревшие записи при Uniform и 1 при Zipf. Часовой soak завершил 252 млн MGET и 72 млн UPDATE; рост RssAnon worker не превысил 44 кБ.
Чтение на закреплённую пару vCPU сервера
Клиент Go/pgx использовал 256 клиентов и один ключ на MGET. Каждый случай выполнялся 20 секунд. Два однопарных случая Valkey io-threads=1 повторялись дважды; остальные закреплённые случаи — пять раз. PostgreSQL и Valkey закреплялись за N парами vCPU (2N vCPU). Valkey проверялся с io-threads=2N и io-threads=1; подготовленный SQL использовал те же ключи.
| Пары vCPU | Ключи | pg_local_cache запросов/с (p99 мс, vCPU) | Valkey io-threads=2N (запросов/с, p99 мс, vCPU) | Valkey io-threads=1 (запросов/с, p99 мс, vCPU) | Подготовленный SQL (запросов/с, p99 мс, vCPU) |
|---|---|---|---|---|---|
| 1 | Горячие | 209,696 (2.46, 1.99) | 91,478 (3.24, 2.00) | 180,141 (2.59, 0.85) | 23,200 (24.90, 2.00) |
| 1 | 100k случайных | 195,950 (2.65, 2.00) | 88,672 (3.38, 2.00) | 175,853 (2.65, 0.92) | 22,433 (25.95, 2.00) |
| 2 | Горячие | 336,691 (2.02, 3.78) | 266,340 (1.69, 4.00) | 162,230 (2.85, 0.66) | 46,386 (16.38, 4.00) |
| 2 | 100k случайных | 326,125 (1.98, 3.83) | 267,826 (1.72, 4.00) | 160,220 (2.92, 0.69) | 45,211 (17.56, 4.00) |
| 4 | Горячие | 360,968 (2.33, 5.24) | 357,161 (2.20, 7.95) | 160,633 (2.85, 0.66) | 88,604 (6.75, 8.00) |
| 4 | 100k случайных | 353,941 (2.33, 5.27) | 351,344 (2.20, 7.95) | 159,414 (2.92, 0.68) | 86,790 (6.88, 8.00) |
На одном ядре лучшей конфигурацией Valkey был один поток. На двух ядрах лучшим был io-threads=4; p99 Valkey оказался ниже при меньшей пропускной способности. На четырёх ядрах pg_local_cache и io-threads=8 показали почти одинаковую клиент-ограниченную пропускную способность; серверный CPU pg_local_cache был ниже.
Raw runs: Valkey io-threads=2N, Valkey io-threads=1.
Чтение при доступных 16 vCPU
Эти прогоны не закрепляли CPU и повторялись три раза. В сценариях с ограничением клиента узел нагрузки использовал около 7–13 ядер CPU. При Valkey io-threads=8 наблюдаемые различия медианной пропускной способности MGET 16/64 составили примерно от −2,15 % до +1,26 %; диапазоны не пересекались ни в одном из четырёх сравнений на 64 ключах. Эти выборки не доказывают статистическую эквивалентность. При io-threads=1 случайный MGET 16 на 256 клиентах показал 116 130 против 97 489 запросов/с (на 19,1 % больше у pg_local_cache). Широкие MGET ограничивались клиентом и сетью; для io-threads=1 нет результата на 64 ключах.
| Ключи | Ключей MGET | Клиенты | pg_local_cache (запросов/с, p99 мс, vCPU) | Valkey io-threads=8 (запросов/с, p99 мс, vCPU) | Valkey io-threads=1 (запросов/с, p99 мс, vCPU) | Подготовленный SQL (запросов/с, p99 мс, vCPU) |
|---|---|---|---|---|---|---|
| Горячие | 1 | 64 | 217,724 (0.70, 2.89) | 206,634 (0.70, 7.85) | 167,929 (0.71, 0.66) | 132,433 (0.96, 11.32) |
| Горячие | 1 | 256 | 372,683 (2.39, 4.21) | 365,947 (2.33, 7.92) | 161,455 (2.92, 0.68) | 173,571 (4.26, 13.14) |
| Горячие | 16 | 64 | 82,858 (3.38, 3.13) | 83,098 (3.38, 6.41) | 79,179 (3.38, 0.69) | 73,512 (2.05, 8.78) |
| Горячие | 16 | 256 | 122,496 (9.83, 4.07) | 122,817 (9.83, 7.42) | 111,790 (8.06, 0.83) | 100,966 (6.36, 11.49) |
| Горячие | 64 | 64 | 30,241 (8.78, 3.40) | 30,845 (9.04, 3.57) | — | 27,790 (6.88, 9.05) |
| Горячие | 64 | 256 | 39,531 (43.52, 4.26) | 39,995 (47.71, 5.66) | — | 34,519 (30.67, 12.23) |
| 100k случайных | 1 | 64 | 215,310 (0.70, 2.89) | 205,081 (0.70, 7.85) | 162,778 (0.73, 0.67) | 131,563 (0.97, 11.34) |
| 100k случайных | 1 | 256 | 366,860 (2.39, 4.31) | 360,894 (2.33, 7.92) | 158,444 (2.98, 0.68) | 169,884 (4.26, 13.19) |
| 100k случайных | 16 | 64 | 79,505 (3.51, 3.45) | 79,556 (3.44, 6.68) | 74,270 (3.38, 0.79) | 69,262 (2.13, 9.12) |
| 100k случайных | 16 | 256 | 116,130 (10.35, 4.42) | 114,683 (9.83, 7.53) | 97,489 (7.67, 0.95) | 91,572 (7.01, 11.96) |
| 100k случайных | 64 | 64 | 29,000 (8.78, 3.81) | 29,464 (9.04, 4.61) | — | 26,169 (7.14, 9.39) |
| 100k случайных | 64 | 256 | 37,356 (43.52, 4.93) | 38,175 (40.37, 7.58) | — | 32,756 (31.20, 13.03) |
Для случайных ключей, MGET 64 и 256 клиентов p99 pg_local_cache составил 43,52 мс против 40,37 мс у Valkey. Подготовленный SQL имел значительно меньшую пропускную способность в тестах одного ключа; результаты на пакетах зависят от размера и метрики задержки.
Raw runs: Valkey io-threads=8, Valkey io-threads=1.
Накладные расходы записи
Три 15-секундных повтора сравнивают транзакции обычной таблицы, транзакции через pg_local_cache и обычные транзакции с последующим Valkey DEL. Проценты рассчитаны относительно соответствующего случая обычной таблицы.
| Операция | synchronous_commit | Клиенты | Обычная таблица tx/s (DB µs/tx) | pg_local_cache tx/s (DB µs/tx, изменение) | Обычная таблица + Valkey DEL tx/s (DB µs/tx, изменение) |
|---|---|---|---|---|---|
| Update | on | 32 | 46,747 (109) | 45,065 (119, −3.6%) | 33,234 (204, −28.9%) |
| Update | on | 64 | 63,479 (108) | 61,027 (121, −3.9%) | 37,659 (238, −40.7%) |
| Update | off | 32 | 119,156 (75) | 110,987 (87, −6.9%) | 53,374 (158, −55.2%) |
| Update | off | 64 | 148,358 (73) | 136,194 (83, −8.2%) | 53,853 (189, −63.7%) |
| Insert | on | 32 | 48,831 (97) | 47,020 (105, −3.7%) | 34,863 (184, −28.6%) |
| Insert | on | 64 | 65,688 (97) | 63,011 (108, −4.1%) | 39,642 (218, −39.7%) |
| Insert | off | 32 | 123,679 (63) | 117,868 (72, −4.7%) | 58,168 (135, −53.0%) |
| Insert | off | 64 | 138,454 (68) | 130,805 (77, −5.5%) | 58,361 (165, −57.8%) |
Для UPDATE pg_local_cache уступал обычной записи на 3,6–8,2%; для INSERT накладные расходы составили 3,7–5,5%. Обычный UPDATE плюс Valkey DEL был ниже по пропускной способности на 28,9–63,7%; для INSERT разница составила 28,6–57,8%.
Raw runs: write overhead.
Чтение во время записи
Каждый случай выполнялся один раз в течение 30 секунд: 64 читателя запрашивали по одному ключу из 60k строк; writers использовали synchronous_commit=off. Valkey работал в режиме cache-aside (запись SQL, затем DEL) с io-threads=8. Устаревшие записи — результат финальной проверки sentinel; n/a означает, что кэшируемых записей для проверки нет.
| Стек | Распределение ключей | Writer | Чтений/с | p99 чтения, мс | Доля попаданий | Записей/с | Устаревшие записи |
|---|---|---|---|---|---|---|---|
| pg_local_cache | Uniform | Нет | 214,508 | 0.71 | 1.000 | 0 | 0 |
| pg_local_cache | Uniform | Update 10,000/s | 195,384 | 0.86 | 0.948 | 10,000 | 0 |
| pg_local_cache | Uniform | Update 30,000/s | 161,919 | 1.04 | 0.837 | 29,999 | 0 |
| pg_local_cache | Uniform | Update unlimited | 82,640 | 1.69 | 0.432 | 111,013 | 0 |
| pg_local_cache | Zipf | Нет | 216,297 | 0.70 | 1.000 | 0 | 0 |
| pg_local_cache | Zipf | Update 10,000/s | 198,454 | 0.86 | 0.964 | 10,000 | 0 |
| pg_local_cache | Zipf | Update 30,000/s | 168,875 | 1.01 | 0.923 | 30,000 | 0 |
| pg_local_cache | Zipf | Update unlimited | 96,297 | 1.46 | 0.834 | 113,505 | 0 |
| Valkey cache-aside | Uniform | Нет | 223,537 | 0.61 | 1.000 | 0 | 0 |
| Valkey cache-aside | Uniform | Update 10,000/s | 156,983 | 1.92 | 0.936 | 10,000 | 0 |
| Valkey cache-aside | Uniform | Update 30,000/s | 42,294 | 7.14 | 0.577 | 29,998 | 0 |
| Valkey cache-aside | Uniform | Update unlimited | 29,692 | 8.78 | 0.444 | 38,481 | 1 |
| Valkey cache-aside | Zipf | Нет | 222,575 | 0.63 | 0.999 | 0 | 0 |
| Valkey cache-aside | Zipf | Update 10,000/s | 168,911 | 1.65 | 0.960 | 10,000 | 0 |
| Valkey cache-aside | Zipf | Update 30,000/s | 75,642 | 4.78 | 0.887 | 29,999 | 0 |
| Valkey cache-aside | Zipf | Update unlimited | 48,993 | 6.88 | 0.852 | 40,735 | 0 |
| Prepared SQL | Uniform | Нет | 129,714 | 0.97 | — | 0 | n/a |
| Prepared SQL | Uniform | Update 10,000/s | 121,567 | 1.13 | — | 10,000 | n/a |
| Prepared SQL | Uniform | Update 30,000/s | 106,949 | 1.33 | — | 30,000 | n/a |
| Prepared SQL | Uniform | Update unlimited | 77,574 | 2.02 | — | 93,955 | n/a |
| Prepared SQL | Zipf | Нет | 129,937 | 0.97 | — | 0 | n/a |
| Prepared SQL | Zipf | Update 10,000/s | 122,182 | 1.13 | — | 10,000 | n/a |
| Prepared SQL | Zipf | Update 30,000/s | 107,852 | 1.33 | — | 29,999 | n/a |
| Prepared SQL | Zipf | Update unlimited | 78,601 | 2.08 | — | 95,199 | n/a |
| pg_local_cache | Uniform | Insert unlimited | 117,531 | 1.20 | 1.000 | 114,066 | 0 |
| Valkey cache-aside | Uniform | Insert unlimited | 70,302 | 4.92 | 1.000 | 46,622 | 0 |
| Prepared SQL | Uniform | Insert unlimited | 84,294 | 1.98 | — | 95,094 | n/a |
Во всех перечисленных смешанных тестах pg_local_cache завершил работу без устаревших записей. Только в одном случае UPDATE у Valkey осталась одна устаревшая sentinel-запись: Uniform-ключи при неограниченной скорости обновления. При 30k UPDATE/s и Uniform чтение составило 161 919/s у pg_local_cache, 42 294/s у Valkey cache-aside и 106 949/s у подготовленного SQL.
Raw runs: mixed reads and writes.
Проверки устаревших записей за 120 секунд
Каждая проверка запускалась один раз: 64 читателя и 64 неограниченных UPDATE writer, затем полная проверка устаревших записей. Valkey использовал io-threads=8.
| Стек | Распределение ключей | Чтений/с | p99 чтения, мс | Записей/с | Устаревшие записи | Примечание |
|---|---|---|---|---|---|---|
| pg_local_cache | Uniform | 82,565 | 1.687552 | 108,580 | 0 | — |
| pg_local_cache | Zipf | 0 | n/a | 101,797 | 0 | См. примечание ниже. |
| Valkey cache-aside | Uniform | 30,210 | 8.781824 | 36,508 | 2 | — |
| Valkey cache-aside | Zipf | 28,568 | 8.781824 | 35,789 | 1 | — |
Читатель pg_local_cache Zipf не прошёл стартовую проверку эквивалентности при параллельной записи (проблема harness), поэтому показал ноль чтений. Проверка устаревших записей всё равно выполнилась и нашла ноль.
Raw runs: 120-second stale probes.
Часовой soak-тест
Один запуск: MGET 16 по 100k случайных ключей, 64 клиента и 20 000 UPDATE/s. RssAnon RESP worker измерялся раз в минуту.
- Чтение: 251 987 814 MGET за 3 600 секунд (69 997/s), p99 3,57 мс.
- Запись: 71 999 993 UPDATE (20 000/s), p99 1,13 мс, ошибок нет.
- RssAnon worker оставался стабильным: максимальный рост на worker — 44 кБ за 60 измерений.
Raw runs: one-hour soak.
Стенд и методика
По данным lscpu, каждая VM имела 16 vCPU: 2 сокета × 4 ядра на сокет × 2 потока на ядро (топология VM), 62 ГБ RAM и Debian 13 с ядром 6.12.111+deb13-amd64. На VM базы работали PostgreSQL 16.15 (max_connections=1000, shared_buffers=1048576) и pg_local_cache 3.1.0 c431bcc (включён, memory_budget_mb=1024, workers=8). Таблица содержала 100 000 JSON-строк в среднем по 166 байт. Valkey 8.1.1 использовал io-threads=8 в тестах записи, смешанных тестах и проверках устаревания. От VM нагрузки к VM базы iperf3 показал 10,4 Gbit/s, ping — RTT min/avg/max 0,090/0,143/0,871 мс. Клиент Go/pgx работал на второй VM. Чтение запускалось на 20 секунд; запись — 15 секунд, смешанные тесты — 30 секунд, проверки устаревания — 120 секунд, soak — 3 600 секунд.
Закреплённая матрица использовала 256 клиентов; каждый случай повторялся пять раз, кроме двух однопарных случаев Valkey io-threads=1 — у них было по два повтора. Полная матрица и тесты записи имели по три повтора; смешанные тесты, проверки устаревания и soak запускались по одному разу. IRQ сетевой карты не закреплялись. Все таблицы относятся к финальной сборке c431bcc. Пропускная способность указана в запросах/с, если не указаны транзакции/с; задержка — p99, CPU — серверные vCPU.
Окружение и исходные JSONL: окружение, данные стенда, закреплённое чтение, закреплённое чтение Valkey io-threads=1, чтение на всех ядрах, все ядра Valkey io-threads=1, накладные расходы записи, чтение во время записи, проверки 120 секунд и часовой soak.
Повторный запуск через bench/
Нужны Python 3, SSH-алиасы для VM базы и нагрузки. На VM базы нужны PostgreSQL 16, Valkey, mpstat, sar, ip и systemd; на VM нагрузки — скомпилированный клиент Go в /root/bench-client. Подробности: bench/README.md и клиент Go/pgx.
Стандартная матрица чтения: 20 секунд, три повтора, 1/16/64 ключа, 16/64/256 клиентов, горячий набор и 100k ключей:
python3 bench/run_bench.py reads.jsonl
Пример закрепления одной пары vCPU; замените 0-1 на CPU ID логических потоков этой пары vCPU:
PGLC_SERVER_CPUS=0-1 REPEATS=5 BATCHES=1 CLIENTS=256 KEY_SPACES=0,100000 python3 bench/run_bench.py pinned.jsonl
Скрипт закрепляет PostgreSQL и Valkey через AllowedCPUs и снимает закрепление после запуска. Он записывает серверный CPU, задержку, сетевой RX/TX и CPU клиента. Перед запуском настройте io-threads Valkey согласно нужной строке.
Запуск теста записи и 30-секундной смешанной нагрузки:
REPEATS=3 WRITE_SECONDS=15 WRITE_CLIENTS=32,64 python3 bench/write_bench.py writes.jsonl
REPEATS=1 MIXED_SECONDS=30 MIXED_RATES=none,10000,30000,unlimited MIXED_KEY_DISTS=uniform,zipf python3 bench/write_bench.py mixed.jsonl
Скрипты дописывают JSONL и сохраняют завершённые прогоны при ошибке. Тест записи измеряет CPU базы на транзакцию; смешанный тест также записывает p99, попадания/промахи, скорость записи и финальную проверку контрольных записей.
Исторические результаты 2.x на MacBook
Старые JSON — исторические измерения на Apple M3 Max с PostgreSQL 16; стенд и схема клиента отличаются от двух VM выше. Они включают SQL mget API из 2.x, удалённый в 3.0.0. Описание старого API: документация 2.0.4. В актуальной версии чтение из кэша выполняется через RESP MGET.
Обновлено .