Библиотека на домашнем сервере редко упирается в процессор: книг много, но
читателей — несколько человек. Тем не менее до недавнего времени каждый
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, и кэшировать его результаты бессмысленно — запросы почти никогда
не повторяются дословно.