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