Проєктування системи бекенду для команд, що використовують React: GraphQL, кешування, черги та розгортання
П’ять поширених помилок бекенду, яких припускаються команди React, орієнтовані на фронтенд, від вибору між REST та GraphQL до кешування, черг повідомлень, журналів та процесу розгортання, з рішеннями щодо проектування системи.
Джерела: 5 Deep Technical Backend Mistakes React Devs Make in Production (And How to Fix Them)
Як розробники React, ви, ймовірно, добре вмієте створювати елегантні та адаптивні інтерфейси. Ви розумієте конкурентне відображення, серверні компоненти та складні шаблони керування станом. Але коли розмова змінюється на бекенд-системи, багато інженерів, які зосереджені на фронтенді, все ще працюють на рівні хобі-проєктів. Базові проміжки Express, прямі запити до MongoDB та платформи розгортання, які абстрагують інфраструктуру, є загальноприйнятими стандартами. Усе працює без проблем на localhost, і стейджинг також виглядає нормально. Але як тільки з’являється справжній трафік у продакшені, слабкі місця швидко проявляються.
Бекенд — це не просто сервіс, який надсилає JSON до вашого фронтенду. Це система, відповідальна за керування конкурентністю, відновлення після збоїв, затримками та масштабуванням. У цій статті розглядаються п’ять поширених помилок у бекенді, які часто з’являються у проектах на React після їх випуску в продакшн, а також архітектурні зміни, необхідні для їх виправлення.
Помилка 1: покладання на середовище Express без розуміння REST, GraphQL та gRPC
Знайома конфігурація бекенду для розробників React — це додаток Express, об’єднаний за допомогою низки викликів app.use(). Ви налаштовуєте cors, body-parser, morgan, додаєте кілька обробників маршрутів та повертаєте JSON. Це здається зручним, тому що відображає ті самі шаблони JavaScript, які ви вже використовуєте на фронтенді. Проблема полягає у тому, що цей підхід ігнорує більш фундаментальне питання: яка саме парадигма API насправді підходить для обробки даних, які надаються?
Використання багатьох проміжних компонентів без попереднього аналізу структури вашого контракту даних зазвичай призводить до створення жорстких кінцевих точок, які надсилають занадто великі JSON-дані до мобільних клієнтів. У результаті щось на кшталт /api/user/123 повертає запис користувача разом із його замовленнями, адресами, уподобаннями та історією діяльності — усе це лише тому, що один екран колись потребував повної інформації. Відтоді кожен користувач цієї кінцевої точки оплачує ціну цього єдиного випадку використання.
Рішення: свідомий вибір парадигми API
Критично важливо розуміти переваги та недоліки REST, GraphQL та gRPC та свідомо обирати один з них для кожного конкретного випадку використання.
REST є простим у використанні та добре підходить для кешування, але схильний до надмірного отримання даних. Те, що повертає кінцева точка, і є тим, що отримує ваш компонент React, навіть якщо насправді потрібні лише кілька полів. При повільних мобільних з’єднаннях цей додатковий обсяг даних призводить до повільної відрисовки та може відштовхнути користувачів.
GraphQL усуває проблему надмірного отримання даних, дозволяючи клієнту вказати саме ті поля, які він потребує. Однак це супроводжується проблемою з боку сервера, відомою як проблема N+1 запитів. Припустимо, резолвер отримує список користувачів, а потім відправляє окремий запит для кожного користувача, щоб отримати його замовлення; один вхідний запит перетворюється на сотні поїздок до бази даних. Без засобів на кшталт DataLoader чи групування запитів на рівні полів GraphQL-сервер не витримає реального обсягу трафіку.
gRPC ґрунтується на Protocol Buffers, а не на JSON, що забезпечує бінарні дані, які в десять разів менші та значно швидше піддаються обробці. Він не призначений для API, які працюють у браузерах, адже браузери не можуть безпосередньо обробляти HTTP/2 trailer без проксі-сервера, проте він ідеально підходить для внутрішнього обміну даними між сервісами. Коли шлюз Node.js потрібно взаємодіяти, скажімо, з аналітичним сервісом на Python чи сервісом автентифікації на Go, gRPC з protobuf значно перевершує REST через JSON за ефективністю у внутрішній мережі.
Вибір REST чи GraphQL залежно від сценарію використання
Для API, яке призначене для зовнішніх користувачів та живить фронтенд на React, зазвичай найкраще підходить змішана стратегія. Використовуйте REST для простих операцій CRUD, де важлива кешування. Звертайтеся до GraphQL, коли вимоги до даних є глибоко вкладеними та складними, але поєднуйте його з DataLoader, щоб запити до бази даних оброблялися пакетно та без дублікатів. Залиште gRPC для внутрішнього обміну даними між сервісами, які знаходяться за вашим гейтвеєм.
Нижче наведено приклад резолвера GraphQL, який обробляє запити пакетно за допомогою DataLoader:
// userLoader.js
const DataLoader = require('dataloader');
const batchUsers = async (ids) => {
// Single query for all IDs
const users = await db.user.findMany({
where: { id: { in: ids } }
});
// Return in the same order as the keys
const userMap = new Map(users.map(u => [u.id, u]));
return ids.map(id => userMap.get(id) || null);
};
const userLoader = new DataLoader(batchUsers);
// Resolver
const resolvers = {
Order: {
user: (parent) => userLoader.load(parent.userId),
}
};
Якщо пропустити цей лоадер, обробка сотні замовлень означатиме виконання сотні окремих запитів SELECT. Якщо ж його додати, усі вони об’єднаються в один запит SELECT ... WHERE id IN (...). Саме це створює різницю між відповіддю за 50 мс та запитом, який тайм-аутує через три секунди.
Помилка 2: безпосередній звернення до бази даних при кожному зчитуванні
Коли кожне оновлення сторінки викликає новий запит до бази даних, це насправді не архітектура, а просто канал передачі даних. Бази даних на кшталт MongoDB та PostgreSQL є швидкими, але не необмежено. Під конкурентним навантаженням пули з’єднань спорожнюються, запити накопичуються у черзі, і час відповіді, який раніше становив 20 мілісекунд, може збільшитися до 5 секунд.
У розробників React часто трапляється, що вони ставляться до бази даних так, ніби це просто ще один об’єкт JavaScript у пам’яті. Запит до Mongoose чи виклик Prisma потрапляє безпосередньо до обробника маршруту, результат повертається, і на цьому все закінчується. Це працює добре, поки продукт не починає набирати справжніх користувачів. У цей момент використання CPU базою даних досягає максимуму, графік затримок вашого API починає нагадувати крутий схил, а користувачі дивляться на індикатори завантаження, які ніколи не завершуються.
Рішення: шар кешування Redis, який ви розумієте
Відповіддю є додавання шару кешування та реальне розуміння того, як він працює, а не просте його встановлення без роздумів. Redis — це не просто „швидка база даних“; уявіть його як буфер, який розташований стратегічно між вашим додатком та справжнім сховищем даних, яке їх зберігає.
Почніть із шаблону Cache-Aside. Коли надходить запит, спочатку знайдіть його в Redis. Якщо значення існує та ще не закінчило термін дії, негайно поверніть його, не звертаючись до бази даних. Якщо його немає, це означає пропуск з кешу: зробіть запит до основної бази даних, збережіть результат у Redis із параметром часу дії та потім поверніть його. Кожен наступний запит на ці самі дані буде оброблений з пам’яті протягом менше мілісекунди.
Якщо застосувати цей шаблон правильно, він може зменшити навантаження на базу даних на 90 відсотків у сценаріях, де переважають операції читання. Однак це вимагає дисципліни: ви повинні анулювати чи оновлювати записи в кеші щоразу, коли змінюється відповідна інформація, інакше ваші користувачі будуть бачити застарілі дані.
Ось як цей шаблон виглядає у коді:
// cache.js
const Redis = require('ioredis');
const redis = new Redis(process.env.REDIS_URL);
async function getCachedOrFetch(key, fetchFn, ttlSeconds = 300) {
// 1. Check cache
const cached = await redis.get(key);
if (cached) {
return JSON.parse(cached);
}
// 2. Cache miss: fetch from database
const data = await fetchFn();
// 3. Store in Redis with TTL
await redis.setex(key, ttlSeconds, JSON.stringify(data));
return data;
}
// Route handler
app.get('/api/products/:id', async (req, res) => {
const { id } = req.params;
const product = await getCachedOrFetch(
`product:${id}`,
() => db.product.findById(id), // Only runs on cache miss
600 // 10 minutes
);
if (!product) return res.status(404).json({ error: 'Not found' });
res.json(product);
});
Ось крок скасування, який виконується щоразу, коли оновлюється запис продукту:
async function updateProduct(id, updates) {
const updated = await db.product.update(id, updates);
await redis.del(`product:${id}`); // Invalidate
return updated;
}
Існує ще одне рішення щодо моделювання, яке варто приймати усвідомлено: потрібно знати, коли PostgreSQL є кращим вибором порівняно з MongoDB. Якщо ваш фронтенд на React потребує відображення панелей керування, які містять об’єднання даних, агрегації та аналіз за часовими рядами, належним чином індексована система PostgreSQL завжди буде кращою за MongoDB. MongoDB є гарним вибором для даних у форматі документів, які не мають багатьох зв’язків. PostgreSQL є кращим вибором, коли у ваших даних є чітка структура, а запити залежать від JOIN.
Помилка 3: створення синхронних монолітів
Уявіть, що користувач завантажує фото високої роздільної здатності через ваш додаток на React. Ваш сервер Express бере файл, змінює його розмір у п’яти різних форматах, стискає кожну версію, завантажує їх усі у S3, оновлює запис у базі даних, і лише після того, як усе це завершиться, надсилає відповідь 200 OK. Користувач змушений чекати, поки буде показуватися індикатор завантаження, протягом дванадцяти секунд. А якщо на етапі зміни розміру виникне проблема, вся заявка зазнає невдачі, і йому доводиться завантажувати файл знову.
Це приклад синхронного моноліту в дії: потік заявок залишається заблокованим, поки не буде виконано кожен крок роботи. Коли обсяг трафіку зростає, у вашому сервері закінчуються вільні потоки, черга очікуючих відповідей збільшується, і весь додаток починає працювати повільно.
Рішення: робота на основі подій із використанням черг повідомлень
Тут вам потрібна архітектура, заснована на подіях та повідомленнях. Немає жодних причин, чому фронтенд має чекати на роботу, яка не обов’язково має виконатися перед надсиланням відповіді.
Як тільки з’являється зображення, ваш бекенд має записати необроблений файл у тимчасове сховище, додати повідомлення до черги та негайно надіслати відповідь з кодом 202 Accepted разом із ідентифікатором завдання. Фронтенд отримує цей код 202 миттєво, а потім або періодично перевіряє статус, або підписується через WebSocket, щоб дізнатися, коли завдання буде завершене. Окремо спеціалізований сервіс-робітник бере повідомлення з черги, виконує основну роботу та оновлює базу даних після її завершення.
Якщо працівник зупиняється під час виконання завдання, черга сама спробує його виконати знову. Якщо черга починає накопичуватися, достатньо просто додати більше працівників, незалежно від серверів API. Результат: фронтенд залишається швидким, а бекенд — стійким під тиском.
Ось той самий процес, реалізований з використанням BullMQ та Redis:
// api.js — The HTTP layer
const { Queue } = require('bullmq');
const imageQueue = new Queue('image-processing', { connection: redis });
app.post('/api/upload', upload.single('image'), async (req, res) => {
const job = await imageQueue.add('process-image', {
filePath: req.file.path,
userId: req.user.id,
}, {
attempts: 3,
backoff: { type: 'exponential', delay: 2000 }
});
// Return immediately. Work happens elsewhere.
res.status(202).json({ jobId: job.id, status: 'processing' });
});
// worker.js - The background processor
const { Worker } = require('bullmq');
const sharp = require('sharp');
const imageWorker = new Worker('image-processing', async (job) => {
const { filePath, userId } = job.data;
// Heavy work happens here, not in the API thread
const sizes = [1200, 800, 400, 200];
const uploads = sizes.map(async (size) => {
const buffer = await sharp(filePath)
.resize(size)
.jpeg({ quality: 85 })
.toBuffer();
return s3.upload({
Bucket: 'my-bucket',
Key: `users/${userId}/image-${size}.jpg`,
Body: buffer,
}).promise();
});
await Promise.all(uploads);
await db.user.update(userId, { imageProcessed: true });
// Notify frontend via WebSocket or push notification
await notifyUser(userId, { type: 'IMAGE_READY' });
}, { connection: redis, concurrency: 5 });
Єдиною функцією шару API є обробка HTTP-запитів; єдиною функцією робочого шару — використання процесорних ресурсів. Вони масштабуються окремо. Десять тисяч завантажень можуть означати необхідність запуску десяти інстанцій API разом із п’ятдесятьма робочими процесами. Саме так виглядає справжня архітектура.
Помилка 4: припущення, що бекенд — це просто ще JavaScript
Коли ваша React-додаток видає помилку 500, природною реакцією є її перехоплення, відображення повідомлення та передача проблеми тому, хто відповідає за бекенд. Але у більшості реальних систем фронтенд та бекенд не чітко розділені за мовою програмування. Шлюз API, з яким ви спілкуєтесь, може працювати на Node.js, тоді як основна бізнес-логіка знаходиться у Java-сервісі, автентифікація виконується на Go, а двигун рекомендацій написаний на Python.
Якщо ви не можете розібрати стек-трейс у Java чи зрозуміти ситуацію паніки в Go, то фактично ви відлагоджуєте проблеми, закривши одне око. Ви будете годинами чекати, поки хтось інший скаже вам, що справжньою причиною був вичерпаний пул з’єднань до бази даних, що ви могли б самі виявити за кілька хвилин, просто прочитавши журнали подій.
Рішення: читання журналів подій у різних мовах
Навчіться читати журнали подій від систем, написаних мовами, якими ви не обов’язково володієте. Вам не потрібна вільна влада в синтаксисі Java чи Go; вам потрібно вміти розпізнавати їхні типові ознаки збоїв.
Слід стеку в Java розповідає про проблему знизу догори; справжня причина зазвичай знаходиться ближче до верху у вигляді таких помилок, як NullPointerException, ConnectionPoolTimeoutException чи HeapSpaceError. Якщо з’являється повідомлення Caused by: java.sql.SQLException: Connection pool exhausted, це означає, що база даних перевантажена одночасними запитами. Рішення зовсім не полягає у шарі Java — його потрібно шукати у налаштуваннях пулу з’єднань або у оптимізації базових запитів.
Паніка в Go зазвичай є більш прямою. Вона вказує точну горутину, файл та рядок, де виникла проблема. Повідомлення на кшталт panic: runtime error: invalid memory address or nil pointer dereference означає, що якась структура була використана до того, як її було ініціалізовано.
Коли ви намагаєтесь відстежити проблему, починаючи з фронтенду, ось на що слід звернути увагу:
- Виснаження з’єднань бази даних: шукайте у журналах пошуку записи зі словами
timeout,pool,connection refusedабоtoo many clients. Це зазвичай вказує на необхідність кращого управління пулом з’єднань на бекенді або додавання реплік для читання. - Виснаження пам’яті: шукайте у журналах контейнера записи зі словами
HeapSpace,OOMабоKilled. Рішення зазвичай полягають у оптимізації запитів, додаванні функції сторінкування або підвищенні лімітів пам’яті контейнера. - Помилки серіалізації: стежте за повідомленнями на кшталт
JSON parse errorабоcannot serialize. Це майже завжди означає, що формат даних, які надсилає фронтенд, більше не відповідає тому, що очікує бекенд.
Якщо ваша організація використовує централізовану платформу для обробки журналів подій, таку як Datadog, Splunk чи стек ELK, приділіть час тому, щоб навчитися правильно її використовувати. Порівняйте час створення запису про помилку на фронтенді з записами журналів на бекенді, зробленими приблизно у той самий момент, та відстежуйте ID запиту під час його переміщення між сервісами. Інженер фронтенду, який може відстежити окремий запит у всьому стеку, — це саме той, хто зможе впровадити справжнє рішення проблеми, а не просто подати заявку на її вирішення.
Помилка 5: розгортання через те, що все працювало добре локально
Платформи на кшталт Heroku та Vercel роками приховували від розробників проблеми, пов’язані з інфраструктурою. Ви завантажували свій код, і він просто запускався. Це чудово для створення прототипів та опанування основ, але це створює справжню прогалину у розумінні того, як насправді функціонують продакшн-системи. Як тільки щось зламувалося, у вас не було жодних уявлень про операційну систему, шар мережевого зв’язку чи те, як організовується робота контейнерів, а відтворення помилки локально було марним, оскільки ваша локальна конфігурація зовсім не нагадувала продакшн-середовище.
Рішення: Docker, Kubernetes та основи CI/CD
Вкладіть час у вивчення Docker, Kubernetes та CI/CD — не на рівні професійного інженера інфраструктури, але достатньо, щоб мислити як архітектор. Ви повинні знати, що насправді відбувається з вашим кодом відразу після його завантаження.
Цінність Docker полягає у відтворюваності: Dockerfile точно вказує, яку ОС, залежності та середовище виконання потрібні вашому додатку для правильної роботи. Використання багатостадійної компіляції забезпечує легку та більш безпечну кінцеву продуктивну образ, оскільки воно розділяє інструменти, необхідні для створення додатку, від тих, що потрібні для його фактичного виконання.
Нижче наведено приклад Dockerfile багатостадійного типу для продакшн-середовища з використанням Node.js на бекенді:
# Stage 1: Build
FROM node:20-alpine AS builder
WORKDIR /app
COPY package*.json ./
RUN npm ci --only=production && npm cache clean --force
COPY . .
RUN npm run build
# Stage 2: Production
FROM node:20-alpine
WORKDIR /app
ENV NODE_ENV=production
# Create non-root user for security
RUN addgroup -g 1001 -S nodejs && adduser -S nodejs -u 1001
USER nodejs
# Copy only necessary files from builder
COPY --from=builder --chown=nodejs:nodejs /app/dist ./dist
COPY --from=builder --chown=nodejs:nodejs /app/node_modules ./node_modules
COPY --from=builder --chown=nodejs:nodejs /app/package.json ./
EXPOSE 3000
CMD ["node", "dist/main.js"]
Отримана зображення залишається меншою за 150 МБ, оскільки вона не включає компілятори TypeScript, інструменти для збірки та карти джерела. Вона працює під користувачем, який не є root, і містить лише те, що дійсно необхідне для її виконання.
Kubernetes бере на себе керування цими контейнерами у масштабі. Ресурс Deployment визначає, скільки копій вашого API має працювати одночасно. Service забезпечує розподіл трафіку між цими копіями. HorizontalPodAutoscaler автоматично додає поди, коли використання CPU досягає приблизно 70 відсотків, та скорочує їх кількість, коли попит знижується.
Ось як виглядає ця конфігурація на практиці:
apiVersion: apps/v1
kind: Deployment
metadata:
name: api-backend
spec:
replicas: 3
selector:
matchLabels:
app: api-backend
template:
metadata:
labels:
app: api-backend
spec:
containers:
- name: api
image: my-registry/api:latest
ports:
- containerPort: 3000
env:
- name: DATABASE_URL
valueFrom:
secretKeyRef:
name: db-secret
key: url
resources:
requests:
memory: "256Mi"
cpu: "250m"
limits:
memory: "512Mi"
cpu: "500m"
---
apiVersion: autoscaling/v2
kind: HorizontalPodAutoscaler
metadata:
name: api-backend-hpa
spec:
scaleTargetRef:
apiVersion: apps/v1
kind: Deployment
name: api-backend
minReplicas: 3
maxReplicas: 20
metrics:
- type: Resource
resource:
name: cpu
target:
type: Utilization
averageUtilization: 70
Коли обсяг трафіку зростає, Kubernetes автоматично створює більше подів; коли він зменшується, він їх знову знищує. Ваш API не ламається під навантаженням — він масштабується, щоб його витримати.
Мета CI/CD-пайплайну — переконатися, що те, що ви перевірили перед злиттям, саме те, що буде працювати в продакшені. Добре створений пайплайн виконує автоматизовані тести на рівні одиниць та інтеграції, проводить сканування на наявність загроз безпеці, а лише після цього створює образ контейнера, і все це відбувається ще до того, як щось потрапить до реального трафіку. Невдача на будь-якому з цих етапів негайно зупиняє розгортання — саме цей механізм запобігає тому, щоб пошкоджені зміни потрапили до справжніх користувачів.
Мислення про проектування системи, а не про синтаксис
Переход від розробника фронтенду до інженера full-stack — це не просто опанування нової синтаксису та заміна React на Node.js. Це розуміння того, як дані насправді передаються в системі, як вони кешуються, як обробляються асинхронно, а також як вони розгортаються та масштабуються.
Бекенд — це не якась незрозуміла служба, яка просто повертає JSON. Це розподілена система, сповнена обмежень, можливих збоїв та факторів, які впливають на продуктивність. Як тільки ви зрозумієте парадигми API, стратегії кешування, черги повідомлень, методи відлагодження в різних мовах та оркестрацію контейнерів, ви перестанете створювати демонстраційні проекти та почнете розробляти системи, здатні витримувати реальних користувачів, реальний трафік та реальні збої.
Пов’язана література
- Десять прихованих проблем компонентів React, які уповільнюють сучасні додатки: Дізнайтеся про десять поширених помилок у компонентах React — від проблем із семантичним HTML до відсутності мемоізації — та про способи їх виправлення, щоб додатки залишалися швидкими, доступними та без помилок у 2026 році.
- Десять архітектурних звичок, які забезпечують підтримку кодових баз фронтенду протягом багатьох років: Описано структурні практики, такі як оптимізація для можливості видалення, чіткий потік даних та ізоляція бізнес-логіки, які допомагають зберігати кодові бази придатними до підтримки протягом багатьох років змін.