Код-ревью: сервис перед масштабированием в 4 инстанса
Вопрос
Сервис работал на одном инстансе, теперь его запускают в 4 репликах за балансировщиком. Найди всё, что сломается или будет работать неправильно.
const sessions = new Map();
const rateLimits = {};
let orderCounter = 1000;
app.post('/login', async (req, res) => {
const user = await auth(req.body);
const sid = crypto.randomUUID();
sessions.set(sid, user);
res.cookie('sid', sid).json({ ok: true });
});
app.post('/orders', async (req, res) => {
const ip = req.ip;
rateLimits[ip] = (rateLimits[ip] || 0) + 1;
if (rateLimits[ip] > 100) return res.status(429).end();
const id = orderCounter++;
await db.orders.insert({ id, user: sessions.get(req.cookies.sid) });
res.json({ id });
});
app.post('/avatar', upload.single('file'), (req, res) => {
fs.renameSync(req.file.path, './uploads/' + req.file.originalname);
res.json({ url: '/uploads/' + req.file.originalname });
});
setInterval(sendDailyReport, 24 * 60 * 60 * 1000);
Коротко
Что должен найти ревьюер
- Ошибкастрока 1Сессии в памяти процесса: запрос, попавший на другую реплику, не найдёт сессию. Нужен общий стор (Redis) или stateless-токен.
- Ошибкастрока 2Лимит считается в каждой реплике отдельно: фактический лимит 4 × 100, плюс счётчик никогда не сбрасывается и растёт в памяти бесконечно.
- Ошибкастрока 3Счётчик id в памяти: четыре реплики выдадут одинаковые номера заказов, после рестарта нумерация начнётся заново. Нужна последовательность БД или UUID.
- Ошибкастрока 13
req.ipза балансировщиком это адрес балансировщика, если не настроенtrust proxy: все клиенты попадут в один лимит. - Ошибкастрока 17
sessions.getможет вернуть undefined: заказ создаётся без пользователя вместо ответа 401. - Ошибкастрока 22Файл сохраняется на локальный диск реплики: с других реплик он недоступен и пропадёт при пересоздании контейнера. Нужно объектное хранилище (S3).
- Безопасностьстрока 22
originalnameот клиента в пути: path traversal и перезапись чужих файлов. Имя генерировать на сервере. - Производительностьстрока 22
renameSyncблокирует event loop в обработчике запроса. - Ошибкастрока 26Фоновая задача в каждой реплике: отчёт уйдёт 4 раза. Нужен отдельный воркер/cron с одной копией или распределённая блокировка; к тому же
setIntervalсбивается при каждом рестарте.
Разбор
Главный принцип горизонтального масштабирования: реплики не хранят состояние. Всё, что должно быть общим, уезжает во внешние системы: сессии и лимиты в Redis, счётчики в БД (sequence, identity) или генерируемые id (UUIDv7), файлы в S3-совместимое хранилище, расписание в отдельный процесс (cron-джоба в Kubernetes, BullMQ repeatable jobs) с гарантией одной копии.
Отдельно стоит сказать про trust proxy: за балансировщиком реальный адрес клиента приходит в X-Forwarded-For, и Express начинает ему доверять только при явной настройке (и только для известных прокси, иначе клиент подделает заголовок).
Хорошая проверка перед масштабированием: «что будет, если запрос попадёт на любую реплику, а любую реплику убьют в любой момент?»
Ответьте сами, а не прочитайте
В тренажёре вопрос попадёт в повторения и вернётся, когда начнёт забываться. Плюс билеты с зачётом: 20 вопросов, не больше 2 ошибок.
Термины по теме
Ещё по теме «Кластер, воркеры, масштабирование»
- Консистентное хеширование с виртуальными узлами
- Балансировка least connections
- cluster внутри Kubernetes
- Память у воркеров cluster
- cluster или worker_threads
- setInterval-крон при нескольких экземплярах
- Флаг --max-old-space-size
- Как Node.js использует несколько ядер