Головна / Статті

Проєктування системи бекенду для команд, що використовують React: GraphQL, кешування, черги та розгортання

П’ять поширених помилок бекенду, яких припускаються команди React, орієнтовані на фронтенд, від вибору між REST та GraphQL до кешування, черг повідомлень, журналів та процесу розгортання, з рішеннями щодо проектування системи.

Видавець: REACTAPP.TOP14 хв читання3258 слів

Джерела: 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 та JavaScript, які демонструють справжню глибину знань: Дізнайтеся про більш сильні та деталізовані відповіді на поширені запитання під час співбесід з React та JavaScript — від Virtual DOM до проектування систем — які свідчать про вищий рівень інженерних навичок.
  • REST проти GraphQL: Справжні компроміси, які лежать в основі кожної архітектури: Пояснюється, які конкретні проблеми вирішують REST та GraphQL, які у них внутрішні механізми, а також які приховані компроміси потрібно врахувати перед вибором одного з них для вашого API.
  • Основи кешування в Redis: шаблони, підводні камені та питання для співбесід: Дізнайтеся, як працює кешування в Redis у додатках на Node.js, починаючи від механізмів cache-aside та TTL, закінчуючи захистом від надмірного навантаження, політиками видалення даних та поширеними питаннями під час співбесід.
  • Проектування бекенд-систем з урахуванням вузьких місць: від скорочувачів URL до електронної комерції: Підхід до проектування бекендів на Node.js, зосереджений на вимогах: коли використовувати балансувальники навантаження, Redis, репліки, черги та обмеження швидкості, та яку вартість має кожен з цих елементів.
  • Beyond CRUD: Десять архітектурних звичок, які забезпечують підтримуваність додатків MERN: Дізнайтеся про звички на рівні системи, які допомагають підтримувати додаток MERN у хорошому стані під час його розвитку: власність на дані, контракти API, похідний стан, код у шарах, оптимізовані дані та послідовні повідомлення про помилки.
  • Сім запитань, які потрібно вирішити перед написанням першої рядки функції: Чек-лист перед реалізацією, який охоплює справжню проблему користувача, гарантії завершення, власність на правила, наявні дані, можливості повторних спроб та конкурентність, можливості спостереження та безпеку розгортання.
  • Таблиці вихідної та вхідної пошти: проектування Webhooks, які виживають після збоїв: Дізнайтеся, як транзакційна вихідна пошта, ідемпотентна вхідна пошта, механізми відкладення з урахуванням стану та черги для некоректних повідомлень забезпечують надійну доставку Webhooks у AWS, Azure та GCP.
  • GraphQL-запит, який виснажив банк даних: Один вкладений GraphQL-документ знищив продакшн-базу даних. Чому не спрацювали обмеження на кількість запитів, таймаути HTTP та DataLoader, та які чотири заходи нарешті стримали ситуацію.