Привет! В этой статье Антон Колесов, технический руководитель группы по эксплуатации платформ и системных решений, расскажет, как команда разработчиков Nexign настраивала альтернативное решение для кэша одной из наших высоконагруженных систем и анализировала возможность использования PostgreSQL в качестве одного из вариантов.
Хотя в таких вопросах нет универсального решения, наш опыт может быть полезен экспертам, которые готовы рассмотреть альтернативу общеизвестным key-value хранилищам (MongoDB, Redis и так далее) в виде популярной RDBMS.
Поиск оптимальной замены
В рамках инициативы по сокращению совокупной стоимости владения мы стремились найти альтернативное решение для кэширующего слоя вместо Couchbase 6 EE. Он содержал 1 Тб предподготовленных структур документов из RDBMS и оперативные сессионные данные. Вся информация хранилась в виде трех отдельных кластеров в геораспределенных дата-центрах. Кластеры работали в режиме Active-Passive с двусторонней XDCR-репликацией.
Мы искали альтернативное решение, которое отвечало бы ряду требований:
представляло собой документоориентированную базу с откликом менее 50 мс (это очень лайтовые требования);
поддерживало QL-интерфейс, поскольку в некоторых сценариях мы использовали язык запросов N1QL;
имело возможность персистентного хранения (учитывая объемы данных).
Решение должно было поддержать работу с кэшем, которая включала преимущественно операции с документами по ключу, а также поиск документа по его отдельным атрибутам или их сочетанию с помощью N1QL. Документы содержали JSON-объекты, которые можно было разделить на три типа:
100+ млн документов пользовательских профилей (по 3–5 Кб);
3,5+ млн документов активных пользовательских сессий (1 Кб);
2+ тыс. документов предрасчитанных ролевых иерархий (до 10 Мб).
На замену кэширующего слоя мы рассмотрели несколько кандидатов:
![](https://habrastorage.org/getpro/habr/upload_files/af2/32e/07c/af232e07c54f82a148a56cc2f7c70110.png)
Критерии отбора включали не только основные тактико-технические характеристики, но и особенности лицензирования, стоимость, архитектурное видение технологического стека компании и клиента. В качестве альтернативы кэширующему слою Couchbase 6 EE рассматривалась и его функционально подрезанная редакция Community, однако в версии 7 условия лицензии изменились, и в проде CE 7 использовать ее не получилось бы. В финальный шорт-лист среди прочего вошел PostgreSQL.
Подготовка к использованию PostgreSQL в качестве кэша
PostgreSQL «из коробки» обладал необходимыми нам базовыми качествами:
персистентность;
нативный гибкий интерфейс запросов (даже избыточно);
тип данных JSONB c возможностью индексирования;
изоляция и блокировки;
репликация и организация отказоустойчивости.
Тем не менее, по сравнению с заменяемым Couchbase, в PostgreSQL отсутствовали механизмы шардирования, времени жизни (TTL) и интерфейс управления кластером.
Несмотря на эти ограничения, мы решили проверить, возможно ли использовать PostgreSQL в качестве кэша. Для этого мы собрали прототип-коннектор к БД, выступающей в качестве кэша, немного затюнили саму базу и запустили тестовый JMeter-сценарий. Комплексный сценарий суммарно дает 25000 RPS на чтение и 7000 на запись, моделируя усредненную нагрузку прода. В качестве БД мы использовали собственную сборку PosgreSQL — Nexign Nord.
Коннектор написали на Spring Boot 2.7.6
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-jdbc</artifactId>
</dependency>
<dependency>F
<groupId>org.postgresql</groupId>
<artifactId>postgresql</artifactId>
</dependency>
В нем реализованы API для выполнения CRUD-операций с хранимыми документами.
Также хотелось бы отдельно сказать про недостающий в PostgreSQL механизм TTL. Вариантов его реализации не очень много: очевидный — через регулярное удаление записей с выборкой «старше, чем», и более сложный — с разбиением данных на партиции, по которым периодически работает truncate. Первый вариант прост в реализации, поскольку позволяет использовать и системный cron с вызовом pgSQL, и подход с триггерами, и расширения-планировщики (наш вариант, так как в Nexign Nord есть аналог DBMS Scheduler от Oracle). Однако следует учитывать и обратную сторону простой реализации: потенциальный блоатинг таблиц и индексов и необходимость следить за работой заданий очистки.
Более сложный вариант с разбиением данных на партиции будет тяжелее в плане реализации универсальной механики, поскольку ее нужно сначала придумать. Однако здесь не будет проблем с «дышашими» таблицами и индексами, поскольку партиции очищаются целиком.
Изменение базовых настроек кластера БД
В первом подходе тестирования мы подняли базу на физическом инстансе 32 CPU / 750 Гб RAM / 2 Тб SSD. Основные стартовые параметры конфигурации БД приведены ниже:
max_connections = 500
shared_buffers = 512GB
checkpoint_timeout = 2min
maintenance_work_mem = 1024MB
autovacuum_work_mem = 256MB
effective_io_concurrency = 10
pg_prewarm.autoprewarm = true
fsync = on
synchronous_commit = off
full_page_writes = on
wal_buffers = -1
max_wal_size = 16GB
min_wal_size = 2048MB
shared_preload_libraries = 'pg_prewarm'
max_locks_per_transaction = 256
effective_cache_size = 700GB
В первом приближении мы ограничились оптимизацией записи за счет отключения ожиданий подтверждения от механизма WAL (synchronous_commit = off), а для чтения задали объем буферного кэша из расчета, что туда должны уместиться все данные (shared_buffers = 512000MB из доступных на хосте 755 Гб).
Конечно, отключение синхронного коммита влияет на гарантию сохранности данных, но в сочетании с двухминутными чек-поинтами это приемлемый размен на получаемый прирост скорости. Также данные можно выборочно актуализировать после поднятия, что позволяет и вовсе пренебречь негативной стороной сделки.
Также мы посмотрели, что скажет pg_test_fsync по производительности методов синхронизации на нашем окружении, и по результатам дополнительно выставили wal_sync_method = open_datasync.
После этого мы наполнили данными базу — залили документы по трем таблицам с полями key, value и TTL. Мы также построили индексы по ключу и набору полей JSONB, запустили prewarm, чтобы поднять данные с диска в память. Кроме того, мы выставили чуть более агрессивные настройки необходимости запускать автоматическую очистку для таблиц (autovacuum_vacuum_scale_factor = 0.02).
![Вариант кластера с тремя простыми таблицами в одной базе Вариант кластера с тремя простыми таблицами в одной базе](https://habrastorage.org/getpro/habr/upload_files/854/6c0/6b4/8546c06b434ba8d9feb328cc025b5a3d.jpg)
Первые результаты
Несмотря на некоторые отклонения, PostgreSQL неплохо держал целевые тайминги по запросам. Более детальный анализ отклонений времени выполнения запросов по графикам отдельных сценариев показал их системность. Основной причиной были регулярные просадки по запросам получения больших документов. Задержки по этим потокам также негативно сказывались и на других параллельных потоках, занятых работой с мелкими документами.
Оптимизация структуры хранения документов
Во втором подходе мы решили оптимизировать структуру хранения документов путем изолирования разных типов данных в отдельных базах исходного кластера:
Первая БД была выделена под профили, то есть содержала 100 млн небольших документов. Основной профиль нагрузки БД — относительно интенсивное чтение/запись по ключу (15000/1500 RPS), а также индексный доступ по JSONB на чтение (3000 RPS).
Вторая база хранила 3,5 млн сессионных документов с активными операциями их чтения/записи (5000/5000 RPS), а также с удалением по исчерпании 10-минутного TTL.
Третья БД состояла из больших документов, по которым нет значимых рейтов чтения/записи (100/1 RPS), но присутствуют регулярные touch-обращения, обновляющие поле TTL (2000 RPS).
Чтобы упростить работу по обслуживанию кэша (vacuum), мы решили добавить коробочное партицирование (разделением по ключу):
по 100 для профилей (по 1 млн в партиции);
по 10 для сессионных и больших документов.
Сами настройки кластера БД не изменялись.
![Вариант кластера с штатным партицированием таблиц в отдельных базах Вариант кластера с штатным партицированием таблиц в отдельных базах](https://habrastorage.org/getpro/habr/upload_files/64a/691/e51/64a691e51dacc714633efa446a52f556.jpg)
Результаты второй итерации
Тестирование показало, что мы движемся в правильном направлении: было исключено перекрестное влияние от работы с большими документами, и общий график времени выполнения запросов стал более стабильным. Частота возникновения просадок значительно снизилась, но все еще явно читался их определенный паттерн.
В ходе разбора выяснилось, что проблема связана с запросами получения профиля по значениям полей JSON-документа. Здесь оказало влияние разделение таблицы на партиции по ключу документа. Дело в том, что на каждую партицию создается отдельный индекс, а не общий на таблицу. В результате индексный поиск по значению отдельного поля документа стал дороже — ему приходилось сканировать индексы всех партиций, что не всегда отрабатывало быстро.
Мы учли эти выводы и в следующем подходе попробовали использовать клиентское секционирование и собственную реализацию «индексов» для поисков не по ключу. Это должно было ускорить работу, причем не только за счет ускорения самого поиска, но и благодаря разгрузке базы от задач вычисления партиций.
Изменение логики партицирования и базового индексирования
В третьем подходе мы переложили логику партицирования и базового индексирования на сторону клиента. Также произвели небольшие изменения в самой БД:
Партицированные таблицы были заменены на эквивалентное количество обычных таблиц с номером в постфиксе. Поля остались без изменений и по-прежнему включали ключ, значение и TTL.
Для реализации индексирования документов по JSON-атрибутам без создания и перебора нескольких индексов (по одному для каждой таблицы-партиции) были созданы отдельные таблицы, первичным ключом в которых является индексируемое значение (оно всегда уникальное), а значением — идентификатор документа. Таблицы индексов состоят из двух полей и даже без партицирования занимают мало места. После получения идентификатора документа вычисляется партиция, а после с помощью простого запроса по ключу запрашивается искомый документ.
Мы также доработали клиент:
Шардирование в клиенте было реализовано через добавление в его логику обвязки работы с хэшами ключей для определения партиции (с помощью ring buffer). Это стандартный подход, который часто используется для равномерного распределения данных на сегменты. Он позволяет получить хэш ключа, взять остаток от деления и по нему получить номер партиции для документа.
Индексация полей документов была реализована с помощью дополнительного хопа. Мы использовали значение поля для поиска в созданной дополнительной таблице, там по индексу получали значение основного ключа документа и с этим ключом шли в нужную партицию основной таблицы.
Управление шардированием также позволило положить логически связанные данные в одну партицию. Для этого мы применяли комплексные ключи, состоящие из двух частей. Первая часть определяла целевую партицию, а вторая служила лежащим рядом индексированным полем в базе.
Такой подход удобен для таблицы с сессиями, поскольку он позволяет получить как конкретную сессию пользователя, так и все сессии без хождения по разным партициям. Поэтому составным ключом в этом примере будет сочетание идентификатора пользователя и идентификатора сессии. В этом случае для определения целевой партиции используется только идентификатор пользователя, а вторая часть представлена в отдельном индексированном поле этой партиции.
![Итоговый вариант кластера с кастомным партицированием таблиц в отдельных базах Итоговый вариант кластера с кастомным партицированием таблиц в отдельных базах](https://habrastorage.org/getpro/habr/upload_files/aa6/f15/573/aa6f155739e3233fae2ceeac43384e6e.jpg)
Результаты третьей итерации
Мы решили проблему с поиском по полям документа в партицированных по ключам таблицах. При этом сохранились все основные преимущества предыдущего «штатного» варианта, включая меньший размер таблиц и индексов, улучшение эффективности их обслуживания (одна таблица на 300 Гб и столько же индексов, как правило, работают хуже 100 таблиц по 3 Гб и упрощенный пробег по меньшим индексам партиций).
Итак, подошел ли нам PostgreSQL?
После прочтения данной статьи может возникнуть резонный вопрос: зачем было тестировать PostgreSQL в качестве кэша, если нативные индексы по JSONB и партицирование в рассматриваемой конфигурации не используются, а данные без синхронного коммита можно потерять? Другими словами, исходные сильные стороны PostgreSQL так и остались не у дел. Действительно, конкретно в этом проекте мы не пошли дальше прототипа, но это не отменяет перспективности решения. Наш опыт доказывает, что правильно подготовленный PostgreSQL по производительности хорош не только в качестве базы, но и как персистентный кэш.
В подтверждение этого заключения приведу статистику контрольного прогона (в конфигурации последнего подхода с клиентским шардированием) в разбивке по сценариям:
![](https://habrastorage.org/getpro/habr/upload_files/480/43a/2e4/48043a2e40b2784fcfc964696c430d36.png)
users_get — получение профиля пользователя по ключу
users_rw — изменение профиля пользователя по ключу
users_query — получение профиля пользователя по атрибуту в документе
shared_read — получение большого документа с иерархией прав по ключу
shared_touch — обновление значение TTL документа по ключу
get_sessions — получение сессии пользователя по ключу
put_sessions — добавление документа
Из результатов прогона видно, что значимых отклонений в скорости работы запросов не наблюдается на всей протяженности параллельной работы сценариев, а 90-й персентиль по всем сценариям (кроме shared_read) умещается в 1 мс.
Среднее значение в данном случае отображается некорректно из-за измерений логов до миллисекунд, например, 900 микросекунд в логах будут зафиксированы как 0 миллисекунд, что смазывает картину при вычислении значения среднего значения.
Для сравнения ниже следуют показатели работы того же сценария, но против Redis. Он был собран в кластерном режиме на аналогичном инстансе и запущен в конфигурации из семи равных шардов на разных портах. В конфигурации из важного только отключение механизмов RDB / AOF (save "" / appendonly no, т.е. в режиме без персиста):
![](https://habrastorage.org/getpro/habr/upload_files/333/78e/251/33378e251849e5c481a58aec56a8ae41.png)
Аналогичный коннектор под Redis собирался на том же Spring Boot:
<dependency>
<groupId>redis.clients</groupId>
<artifactId>jedis</artifactId>
<version>4.3.1</version>
</dependency>
Безусловно, влияние на результат оказывают используемые библиотеки и код. В нашем случае проводились не синтетические тесты, а испытания на вполне реальных данных. Кроме того, использовались подходы к реализации как прототипов, так и нагрузочных сценариев.
Результаты получились сопоставимые, поэтому PostgreSQL as a cache может быть реализован благодаря следующим характеристикам:
Высокая общая производительность для большого пласта задач даже с просто отключенным synchronous_commit и прогретыми pg_prewarm данными.
Возможность затюнить гораздо менее требовательное к ресурсам решение (относительно того же Redis) за счет гибкого подхода к стратегии использования памяти. Здесь можно проверить, что мы хотим положить в память, прогревая только нужные таблицы, и проанализировать реакцию на разное соотношение выделяемой памяти под shared_buffer и общего объема памяти на хосте. Например, мы заметили, что за счет появившейся у ОС возможности маневра при подрезании объема буферной памяти в конфигурации PostgreSQL до 64 Гб некоторые сценарии работали даже лучше, чем в конфигурации с полной загрузкой данных в shared_buffer.
В некоторых ситуациях SQL позволяет сработать проще (сделать небольшие предварительные манипуляции с данными), но это индивидуально, и не стоит этим злоупотреблять.
У каждого инструмента своя задача, но в некоторых случаях использование Postgres, в качестве кэша может сыграть на руку. Таким образом можно будет избежать лишней сущности в технологическом стеке.
Также стоит отдельно отметить ситуации без опасения потери данных. В этом случае скорость можно вплотную приблизить к чистокровным in-memory кэшам за счет отключения fsync и synchronous_commit, а также помещению pg_data в tmpfs. В сессии стресс-тестирования использовали рейты в 2 раза выше необходимых для задачи, по факту мы так и не уперлись в лимиты и получили схожие показатели. Это говорит о наличии серьезного запаса на вырост.
Результаты кейса подтвердили, что PostgreSQL может стать быстрой заменой тому же Couchbase EE в качестве высокопроизводительного персистентного кэша. Однако в этом случае нужно подумать над некоторыми доработками:
Реализация шардирования (eсть примеры очень интересных решений в случае его необходимости);
Механизм честного TTL и аналог двусторонней репликации XDCR, которых однозначно не хватает в PostgreSQL (мы проводили тестирование на коробочной стриминговой конфигурации A-P реплики, и по наблюдениям отставание не превышало 200 Мб).