Библиотека на домашнем сервере редко упирается в процессор: книг много, но читателей — несколько человек. Тем не менее до недавнего времени каждый HTTP-запрос к Flibusta-at-home открывал соединение с Postgres и выполнял как минимум два запроса — даже если отвечать было нечем. Отдача обложки, которая уже лежит в кэше и на которую браузер получит «304 Not Modified»; сохранение позиции чтения при прокрутке; отказ в доступе неавторизованному клиенту; скачивание книги, уже распакованной в /cache/local. Всё это стоило соединения с базой.

Эта статья — о том, что с этим сделано, и о необязательном компоненте Redis, который появился в конфигурации.

Откуда бралась нагрузка

Причина была структурной. Сессии PHP хранились в таблице Postgres, а session_start() вызывается в самом начале каждой точки входа — до того, как станет понятно, нужна ли вообще база. Значит, на каждый запрос приходились:

  • открытие TCP-соединения с Postgres;
  • SELECT данных сессии;
  • UPSERT сессии при завершении запроса (даже если в сессии ничего не изменилось — обработчик не умел «только обновить отметку времени»);
  • раз в сотню запросов — сборка мусора по всей таблице сессий.

Сверху накладывались запросы самой страницы. Показательный пример — страница со списком книг: для каждой из десяти карточек отдельным запросом выяснялось, лежит ли эта книга в избранном у читателя. Десять почти одинаковых запросов на одну страницу.

Отдельная история — сохранение позиции чтения. Браузер отправлял позицию через 66 миллисекунд после остановки прокрутки, то есть при неторопливом чтении — по несколько запросов в секунду, и каждый из них был полноценным запуском PHP с подключением к базе. При pm.max_children = 10 несколько одновременно читающих людей могли занять весь пул воркеров.

Что изменилось

Три независимые вещи. Первые две работают всегда, третья — по желанию.

Ленивое подключение к Postgres. Соединение открывается при первом обращении к базе, а не при старте обработки запроса. Запрос, которому база не нужна, не подключается к ней вовсе. Сам по себе, пока сессии лежат в Postgres, этот пункт ничего не даёт (сессия и есть то самое первое обращение) — он нужен, чтобы заработали остальные.

Индекс архивов в файле. Соответствие «идентификатор книги → zip-архив» раньше жило только в таблице book_zip и запрашивалось на каждое открытие книги, на каждую загрузку и на каждую обложку. Данных там немного — одна строка на архив — и меняются они только при сканировании ZIP, импорте дампа или добавлении книги вручную. Поэтому индекс дополнительно выгружается в генерируемый PHP-файл /cache/zip_index.php, который OPcache держит в разделяемой памяти: поиск архива стал обычным двоичным поиском в массиве, без обращения к базе. Таблица book_zip осталась и служит запасным вариантом. Подробнее — в статье об архивах.

Кэш на Redis — необязательный. Если переменная FLIBUSTA_REDIS_HOST не задана, всё работает как раньше: сессии в Postgres, кэш выключен. Если задана — в Redis переезжают сессии и появляется кэш описаний книг, настроек читателя, избранного и проверенных паролей OPDS.

Ещё одно изменение видно только в браузере: позиция чтения теперь отправляется не чаще раза в секунду, повторная отправка того же значения пропускается, а при уходе со страницы или сворачивании вкладки последнее значение досылается через navigator.sendBeacon. Место, на котором вы остановились, при этом не теряется — досыл срабатывает раньше, чем браузер закроет страницу.

Что даёт Redis

Сессия — это и есть то, чем библиотека проверяет, кто вы: идентификатор пользователя, имя, признак администратора. Пока она в Postgres, никакая страница не может обойтись без базы. Когда она в Redis, целые классы запросов перестают обращаться к Postgres вообще:

Запрос С Postgres-сессиями С Redis
Обложка из кэша, ответ «304» подключение + 2 запроса без обращений к базе
Скачивание книги из /cache/local подключение + 3 запроса без обращений к базе
Сохранение позиции чтения подключение + 3 запроса подключение + 1 запрос
Страница списка книг (10 карточек) подключение + около 15 запросов подключение + 2 запроса
Та же книга, скачанная читалкой по OPDS подключение + 6 запросов и проверка пароля bcrypt без обращений к базе

Последняя строка заслуживает пояснения. Читалки по OPDS не держат сессию и присылают имя и пароль в каждом запросе, а проверка пароля — это bcrypt, который намеренно медленный. Теперь удачная проверка запоминается на пять минут, так что листание каталога и скачивание книг не превращается в непрерывный пересчёт хэшей. Неудачные попытки не кэшируются никогда — счётчики блокировки видят каждую из них.

Что и насколько долго кэшируется

Ключ Что внутри Живёт Сбрасывается
sess:* сессии и индексы для списка активных сессий 4 часа, из доверенной сети — 30 дней при выходе, смене роли, удалении сессии администратором
auth:* проверенные пары «имя + пароль» для HTTP Basic 5 минут при смене пароля, роли, удалении пользователя
book:<поколение>:* описания книг, авторы, жанры, серии, отзывы 7 дней сменой «поколения библиотеки»
user:<id>:prefs настройки читателя и скрытые жанры сутки при сохранении настроек
user:<id>:fav идентификаторы избранного сутки при изменении избранного
lib:gen, cache:secret счётчик поколения и ключ для подписи бессрочно никогда

«Поколение библиотеки» — это способ не искать отдельные ключи при обновлении каталога. Номер поколения входит в имя каждого ключа с данными книг; импорт дампа, сканирование ZIP и добавление книги вручную увеличивают счётчик, и все старые ключи разом становятся недостижимы, а потом исчезают сами по истечении срока. Поэтому кэш никогда не покажет описание книги из предыдущего дампа.

Обратите внимание, чего в таблице нет: позиций чтения. Они пишутся прямо в Postgres. Redis в этой конфигурации запускается без сохранения на диск — это кэш, который не жалко потерять, — а место, на котором читатель остановился в середине книги, терять как раз жалко.

Как включить

Нужен работающий Redis, доступный контейнеру библиотеки по общей сети Docker. Минимальный сервис:

services:
  redis:
    image: redis:alpine
    command: >
      redis-server --save "" --appendonly no
      --maxmemory 128mb --maxmemory-policy volatile-lru
    restart: unless-stopped
    networks:
      - flibusta_net

И переменные в environment контейнера библиотеки (полный список — в справочнике по переменным):

      - FLIBUSTA_REDIS_HOST=redis
      - FLIBUSTA_REDIS_PORT=6379
      - FLIBUSTA_REDIS_DB=0
      - "FLIBUSTA_REDIS_PREFIX=flibusta:"

Кавычки в последней строке не для красоты. Значение префикса кончается двоеточием, а двоеточие в конце строки YAML считает признаком ключа — без кавычек элемент списка превращается во вложенное отображение, и compose отказывается запускать стек с сообщением вроде services.flibusta-fpm.environment.[16]: unexpected type map[string]interface {}. Номер в квадратных скобках — это порядковый номер строки в environment, считая с нуля и не считая закомментированных; по нему и находится виновник.

Разберём параметры запуска Redis, они здесь не случайны.

--save "" --appendonly no выключают сохранение на диск. Данные в этом Redis одноразовые: потеря означает, что читатели войдут заново, а кэш наполнится при следующих обращениях.

--maxmemory 128mb обязателен. Без лимита Redis растёт, пока его не убьёт ядро по нехватке памяти. Для домашней библиотеки 128 МБ — с большим запасом: сессия занимает 1–2 КБ, описание книги — от одного до двадцати килобайт (основной объём там — аннотация).

--maxmemory-policy volatile-lru — вытеснять при нехватке памяти только ключи со сроком жизни, начиная с дольше всего не использованных. Срок жизни есть у всего, кроме счётчика поколения и ключа подписи, и это сделано намеренно: если сбросить счётчик поколения, он снова совпадёт со старыми ключами кэша, и библиотека начнёт показывать данные из прошлого дампа. Сессии обновляются при каждом обращении, поэтому под давлением памяти первыми вытесняются давно не открывавшиеся книги, а не живые сессии; вытесненная сессия ведёт себя как истёкшая по времени.

Не публикуйте порт Redis наружу. По умолчанию он без пароля, а доступ к нему — это доступ ко всем сессиям библиотеки. Если Redis всё же доступен за пределами доверенной сети, задайте пароль и укажите его через FLIBUSTA_REDIS_PASSWORD_FILE.

Если Redis недоступен

Как только FLIBUSTA_REDIS_HOST задан, Redis становится обязательным компонентом наравне с Postgres: при его недоступности библиотека отвечает кодом 503 и не пускает никого.

Это сделано намеренно. Молчаливый откат на сессии в Postgres выглядел бы хуже, чем честная ошибка: часть сессий оказалась бы в одном хранилище, часть в другом, читатели разлогинивались бы без видимой причины, а список активных сессий в административном разделе показывал бы неправду. Кэш (в отличие от сессий) при ошибке Redis просто считается пустым — промах кэша безобиден.

Практический вывод: если вы включили Redis, добавьте его контейнер в тот же restart: unless-stopped, что и остальные, и не выключайте «чтобы освободить память».

Переход на работающей библиотеке

Обновление образа и включение Redis — два независимых шага, и каждый можно откатить. Изменений схемы базы данных в этом обновлении нет.

Шаг 1: обновить образ, ничего не меняя в конфигурации. Библиотека продолжит работать на сессиях в Postgres и никого не разлогинит, но уже перестанет ходить в базу там, где это не нужно. При старте контейнер построит /cache/zip_index.php из существующей таблицы book_zip — отдельно запускать сканирование не требуется.

Шаг 2: добавить Redis. Поднимите сервис, задайте FLIBUSTA_REDIS_HOST, пересоздайте контейнер. Все читатели будут разлогинены один раз — но не все заметят: из доверенной сети вход и так не нужен, а у кого стоит галочка «Запомнить меня», тот войдёт автоматически (эти токены лежат в Postgres и обновление их не трогает).

Шаг 3: один раз прибрать за старым хранилищем. После переезда таблица php_sessions больше не используется и не очищается. Оставшиеся в ней строки можно удалить:

docker exec -u www-data flibusta-fpm sh -c '. /tools/dbinit.sh; \
  $SQL_CMD -c "TRUNCATE php_sessions"'

Откат — вернуть прежний тег образа или просто убрать FLIBUSTA_REDIS_HOST. Миграций нет, поэтому больше ничего делать не нужно; цена отката — ещё один разлогин.

Как убедиться, что всё работает

Есть диагностическая переменная FLIBUSTA_DEBUG_DB. Со значением true она добавляет в лог контейнера по строке на каждый запрос:

dbstats /fb2.php?id=123 connected=0 statements=0
dbstats /book/view/123 connected=1 statements=4

connected — открывалось ли соединение с Postgres, statements — сколько запросов выполнено. Скачивание книги, которая уже лежит в /cache/local, при включённом Redis должно давать нули. Страница книги — единицу и несколько запросов: позиция чтения всегда читается из базы, а не из кэша.

docker logs -f flibusta-fpm | grep dbstats

После проверки переменную нужно убрать — она пишет строку на каждый запрос, включая каждую картинку.

Ещё пара проверок, не требующих ничего специального:

  • индекс архивов существует и не пуст: docker exec flibusta-fpm head -3 /cache/zip_index.php;
  • в административном разделе «Пользователи → Сессии» видны сессии (значит, список успешно читается из нового хранилища);
  • смена роли пользователя в разделе «Пользователи» немедленно выкидывает его из библиотеки на всех устройствах.

Что осталось за кадром

Кэш не ускоряет то, что и так упирается в диск и процессор: импорт дампа Флибусты, сканирование архивов, конвертацию fb2 в epub или kfx, распаковку вложенных zip. Это разовые операции, и они как были, так и остались самыми долгими вещами в библиотеке.

Не изменился и полнотекстовый поиск: он выполняется в Postgres по индексам pg_trgm, и кэшировать его результаты бессмысленно — запросы почти никогда не повторяются дословно.