Раздел 37 · Redis с нуля

Сессии и кэш-паттерны

junior~25 мин

без входаВойди, чтобы отмечать прогресс, вести заметки и решать задачи в редакторе. войти

В прошлом уроке мы закрыли тему рейтингов. Последний паттерн этого блока самый будничный: сессии и кэш. Почти у каждого сайта есть вход по паролю и страницы, которые долго собирать. Кэш мы уже строили в уроке про cache-aside, а сегодня соберём то, что вокруг него: где хранить сессию, как её продлевать, как выйти на всех устройствах и как одним движением сбросить весь кэш, связанный с книгой.

Номерок в гардеробе

В театре ты сдаёшь пальто и получаешь номерок. На номерке ничего нет, кроме числа: ни твоего имени, ни описания пальто. Всё это знает гардероб. Если номерок потерять, пальто по нему уже не получить, а если пальто провисело до закрытия театра, его уносят на склад.

Сессия на сайте устроена так же. После входа сервер создаёт запись «это пользователь 7, вошёл тогда-то» и отдаёт браузеру только идентификатор, случайную строку в cookie. С каждым запросом браузер присылает идентификатор, а сервер по нему находит запись.

Почему записи держат в Redis, а не в памяти процесса Node.js? Потому что процессов несколько. Запрос может попасть в любую копию сервиса, и все они должны видеть одну сессию. А в базе их держать дорого: сессию читают на каждом запросе, и это ровно та нагрузка, которую Redis снимает с базы.

Сессия в хеше

Сессию удобно хранить в хеше: поля читаются и меняются по одному. Ключ session:<идентификатор>, срок 30 минут. И сразу кладём идентификатор в множество сессий пользователя, оно понадобится для выхода:

127.0.0.1:6379> HSET session:a1b2c3 user_id 7 name ivan created_at 1791500000
(integer) 3
127.0.0.1:6379> EXPIRE session:a1b2c3 1800
(integer) 1
127.0.0.1:6379> SADD user:7:sessions a1b2c3
(integer) 1
127.0.0.1:6379> HGETALL session:a1b2c3
1) "user_id"
2) "7"
3) "name"
4) "ivan"
5) "created_at"
6) "1791500000"

Идентификатор в жизни длинный и случайный, например UUID из crypto.randomUUID(). Короткий a1b2c3 здесь только для читаемости. Если идентификатор можно угадать, чужую сессию можно украсть.

Если сессии нет или она истекла, HGETALL отвечает пустым массивом:

127.0.0.1:6379> HGETALL session:nope
(empty array)

Скользящий срок

Как долго жить сессии? Если дать ей ровно 30 минут с момента входа, пользователь, который активно покупает книги, вылетит посреди оформления заказа. Нужен скользящий срок: 30 минут без запросов. Каждый запрос продлевает его заново.

В Redis это просто EXPIRE на каждом запросе. Счётчик снова становится полным:

127.0.0.1:6379> EXPIRE session:a1b2c3 1800
(integer) 1
127.0.0.1:6379> TTL session:a1b2c3
(integer) 1800

Если сессия лежит строкой, например в JSON, чтение и продление делаются одной командой GETEX. Она возвращает значение и заодно ставит новый срок:

127.0.0.1:6379> SET session:s9 "{\"user_id\":7}" EX 60
OK
127.0.0.1:6379> GETEX session:s9 EX 1800
"{\"user_id\":7}"
127.0.0.1:6379> TTL session:s9
(integer) 1800

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

Сессии в Express

Соберём всё в middleware на Express и node-redis. Он на каждом запросе находит сессию по cookie, проверяет неделю и продлевает срок:

import express from 'express';
import { createClient } from 'redis';
import { randomUUID } from 'node:crypto';

const redis = await createClient({ url: 'redis://localhost:6379' }).connect();

const SESSION_TTL = 1800; // 30 минут без запросов, и сессия истекает
const MAX_AGE = 7 * 24 * 3600; // и не дольше недели с момента входа

const app = express();

// Каждый запрос: найти сессию по cookie и продлить её срок.
app.use(async (req, res, next) => {
  const sid = req.headers.cookie?.match(/(?:^|;\s*)sid=([\w-]+)/)?.[1];
  if (sid) {
    const key = `session:${sid}`;
    const session = await redis.hGetAll(key);
    const age = Date.now() / 1000 - Number(session.created_at);
    if (session.user_id && age < MAX_AGE) {
      await redis.expire(key, SESSION_TTL);
      req.session = { id: sid, ...session };
    } else if (session.user_id) {
      await redis.del(key); // неделя прошла: пусть входит заново
    }
  }
  next();
});

app.post('/login', async (req, res) => {
  const userId = String(req.query.user); // в жизни здесь проверка пароля
  const sid = randomUUID();
  await redis
    .multi()
    .hSet(`session:${sid}`, { user_id: userId, created_at: Math.floor(Date.now() / 1000) })
    .expire(`session:${sid}`, SESSION_TTL)
    .sAdd(`user:${userId}:sessions`, sid)
    .exec();
  res.cookie('sid', sid, { httpOnly: true, sameSite: 'lax' }).send('ok');
});

app.get('/me', (req, res) => {
  res.send(req.session ? `user ${req.session.user_id}` : 'guest');
});

Вход создаёт сессию одной транзакцией MULTI из урока про транзакции: хеш, срок и запись в множество появляются вместе. Флаг httpOnly не даёт скриптам страницы прочитать cookie, так идентификатор сложнее украсть.

Писать такое руками необязательно. Пакет express-session вместе с connect-redis делает то же самое и умеет больше: подписывает cookie, продлевает срок, хранит сессию в JSON. Свой вариант полезен, чтобы понимать, что происходит внутри.

урок из курса

Урок из курса «Redis с нуля». Курс покупается один раз и остаётся навсегда: 500 ₽.

Оплата пока не подключена: премиум и курсы открываются промокодом.