Senior: питання на співбесіді з теми «HTTP і продуктивність»
Питання з реальних співбесід з відповідями: Laravel і PHP, бази даних, JavaScript і фронтенд, Git, Docker, API, безпека й архітектура. Тими самими темами, що й тести.
4 питання
Проблема втраченого оновлення. Два менеджери відкрили одне замовлення. Перший змінив адресу доставки й зберіг. Другий, дивлячись на стару версію, змінив коментар і зберіг - і перезаписав адресу першого старим значенням. Ніхто не отримав помилки.
Оптимістичне блокування через умовні запити:
1. Сервер віддає версію ресурсу в ETag:
GET /api/orders/42
HTTP/1.1 200 OK
ETag: "v17"
2. Клієнт оновлює з умовою «лише якщо версія досі та сама»:
PATCH /api/orders/42
If-Match: "v17"
{"comment": "Подзвонити перед доставкою"}
3. Сервер перевіряє:
- версія збігається - оновлює, повертає новий
ETag: "v18"; - версія змінилася -
412 Precondition Failed, нічого не змінює. Клієнт перечитує ресурс, показує користувачу зміни й пропонує злити їх.
Якщо клієнт не надіслав If-Match для ресурсу, де конфлікти критичні, сервер може вимагати його: 428 Precondition Required (RFC 6585).
Реалізація в Laravel:
public function update(UpdateOrderRequest $request, Order $order): JsonResponse
{
$expected = trim($request->header('If-Match', ''), '"');
$updated = Order::whereKey($order->id)
->where('version', (int) ltrim($expected, 'v'))
->update([...$request->validated(), 'version' => DB::raw('version + 1')]);
abort_if($updated === 0, 412, 'Ресурс змінено іншим користувачем');
return OrderResource::make($order->fresh())
->response()
->header('ETag', '"v'.$order->fresh()->version.'"');
}
Ключове - перевірка й оновлення одним атомарним UPDATE ... WHERE version = ?. Варіант «прочитати версію, порівняти в PHP, потім записати» має ту саму гонитву, від якої ми захищаємося.
Джерело версії: окрема колонка version (лічильник), updated_at з мікросекундами або хеш вмісту. Лічильник найнадійніший: updated_at може збігтися при двох змінах в одну секунду.
Чим це краще за песимістичне блокування (заблокувати запис на час редагування): не потрібно тримати блокування між HTTP-запитами, немає «завислих» блокувань, коли користувач закрив вкладку. Конфлікти рідкісні - і обробляються лише тоді, коли справді стаються.
Слабкі ETag (W/"v17") для If-Match не підходять - порівняння має бути сильним.
Пакетний ендпойнт виконує багато однотипних операцій одним запитом: імпорт тисячі товарів, позначення 200 повідомлень прочитаними, оновлення цін.
Навіщо, якщо є HTTP/2: мультиплексування знімає витрати на з'єднання, але кожен окремий запит - це окремі автентифікація, валідація, транзакція, запис у журнал, ліміт частоти. Для тисяч операцій пакет у рази ефективніший і для клієнта, і для сервера.
Проєктування:
POST /api/products:batchCreate
{"items": [{"sku": "A-1", "name": "..."}, {"sku": "A-2", "name": "..."}]}
Головне рішення - атомарність:
1. Усе або нічого - одна транзакція; при будь-якій помилці нічого не застосовується:
HTTP/1.1 422 Unprocessable Content
{"errors": {"items.17.sku": ["Такий SKU вже існує"]}}
Просто для клієнта, але одна погана позиція блокує всі інші.
2. Часткове виконання - кожна позиція окремо, у відповіді результат для кожної:
HTTP/1.1 200 OK
{
"results": [
{"index": 0, "status": 201, "id": 501},
{"index": 1, "status": 422, "errors": {"sku": ["Такий SKU вже існує"]}}
]
}
Деякі API використовують 207 Multi-Status, але більшість обирає 200 з детальним тілом. Клієнт мусить перевіряти кожен результат - звичайна перевірка статусу відповіді нічого не скаже.
Яку модель обрано - має бути явно в документації і, бажано, в самому API (параметр atomic=true).
Обмеження й безпека:
- максимальний розмір пакета (100-1000 позицій) - перевищення дає 413/422, а не тайм-аут;
- ліміт частоти рахує позиції, а не запити - інакше пакети стають обходом ліміту;
- авторизація кожної позиції - пакет не повинен дозволяти змінити чужі записи серед своїх;
- ідемпотентність (
Idempotency-Key) - повтор пакета після обриву з'єднання не повинен створити дублікати.
Великі пакети - асинхронно: імпорт десятків тисяч позицій приймається як 202 Accepted з ресурсом статусу, обробляється чергою частинами (у Laravel - Bus::batch() з джобами).
Ефективність на сервері: масова вставка (insert() чи upsert() по частинах) замість створення моделей по одній - але тоді не спрацюють події й спостерігачі моделей, і це треба враховувати.
Докладніше в документації: Google AIP-233: пакетне створення
Звичайна відповідь Model::all()->toJson() будує весь JSON у пам'яті перед відправкою. Для експорту на сотні тисяч записів це гігабайти пам'яті й хвилини тиші, поки клієнт чекає першого байта.
Потокова відповідь відправляє дані частинами, щойно вони готові.
1. Потоковий JSON у Laravel:
return response()->streamJson([
'orders' => Order::query()->with('items')->lazyById(500),
]);
streamJson серіалізує й відправляє елементи по одному, а lazyById читає з бази порціями - пам'ять не росте з кількістю записів. Клієнт отримує звичайний валідний JSON.
2. NDJSON (JSON Lines) - один JSON-об'єкт на рядок:
{"id":1,"total":"150.00"}
{"id":2,"total":"80.50"}
return response()->stream(function () {
foreach (Order::query()->lazyById(500) as $order) {
echo json_encode(OrderResource::make($order)->resolve()), "\n";
flush();
}
}, 200, ['Content-Type' => 'application/x-ndjson']);
Перевага - клієнт може обробляти записи, не дочекавшись кінця: читати рядок, розбирати, обробляти. Звичайний JSON-масив неможливо розібрати стандартним JSON.parse до кінця відповіді.
3. Server-Sent Events - для подій, що з'являються з часом (прогрес, сповіщення, відповідь LLM по токенах):
return response()->eventStream(function () {
foreach ($this->generateAnswer() as $chunk) {
yield new StreamedEvent(event: 'chunk', data: $chunk);
}
});
Що враховувати в продакшені:
- буферизація по дорозі: Nginx (
proxy_buffering), PHP output buffering, стиснення, CDN можуть накопичувати дані й віддати все наприкінці. Для потокових маршрутів -X-Accel-Buffering: noчи окреме налаштування; - помилка посередині потоку: статус 200 уже відправлено. Клієнт має розпізнати обірвану відповідь (невалідний JSON, відсутній завершальний маркер), а сервер - логувати;
- воркер зайнятий весь час відправки - для PHP-FPM довгі потоки дорогі; тривалі експорти краще генерувати чергою у файл і віддавати посилання;
- тайм-аути проксі мають покривати тривалість передачі;
Content-Lengthневідомий - використовується chunked-передача (HTTP/1.1) чи кадри HTTP/2.
Альтернатива для великих експортів: асинхронна генерація файлу (CSV, NDJSON у сховищі S3/R2) і тимчасове підписане посилання на завантаження - сервер API не тримає з'єднання хвилинами.
Keyset-пагінація (курсорна, «seek method») продовжує вибірку з місця, де закінчилась попередня сторінка, умовою за ключем сортування, а не пропуском N рядків:
-- перша сторінка
SELECT id, title, published_at FROM posts
ORDER BY published_at DESC, id DESC
LIMIT 20;
-- наступна: після останнього елемента (published_at = '2026-09-30 10:00', id = 1057)
SELECT id, title, published_at FROM posts
WHERE (published_at, id) < ('2026-09-30 10:00', 1057)
ORDER BY published_at DESC, id DESC
LIMIT 20;
Ключові деталі:
- унікальність порядку: сортування лише за
published_atнеоднозначне - у кількох постів однаковий час, і на межі сторінок записи загубляться чи задвояться. До ключа додають унікальну колонку (id) як «розв'язувач»; - індекс під сортування:
(published_at DESC, id DESC)- тоді кожна сторінка - короткий пошук в індексі незалежно від глибини; - порівняння кортежів
(a, b) < (x, y)підтримують PostgreSQL і MySQL, але оптимізатор MySQL не завжди використовує для нього індекс - інколи надійніше розгорнута умоваa < x OR (a = x AND b < y); - NULL у ключі сортування ламає порівняння - такі колонки або виключають, або обробляють окремо;
- курсор непрозорий для клієнта: значення кодують (base64 JSON) і, бажано, підписують, щоб клієнт не підставляв довільні значення. Laravel
cursorPaginate()робить це сам.
Чому total дорогий. COUNT(*) з тими самими фільтрами - окремий запит, що проходить усі відповідні рядки. На таблиці з мільйонами записів і складними фільтрами він може коштувати більше, ніж сама сторінка, - і виконується на кожну сторінку. У PostgreSQL через MVCC навіть COUNT(*) без умов читає всю таблицю чи індекс.
Що робити замість точного total:
- не показувати - лише «далі» (
has_more), як у стрічках і більшості великих API; - рахувати до межі:
SELECT COUNT(*) FROM (SELECT 1 ... LIMIT 10001)і показувати «10 000+»; - приблизна кількість зі статистики бази (
reltuplesу PostgreSQL,EXPLAIN) - для оцінок «близько 2,3 млн»; - кешувати підрахунок на хвилини, якщо точність до запису не потрібна.
Обмеження keyset: немає переходу на довільну сторінку і зручного «сторінка 5 з 120». Для адмінок це інколи прийнятний компроміс з simplePaginate, для API з великими обсягами - стандарт.
Докладніше в документації: No Offset: пагінація без зміщення