Архитектура очередей: как мы убрали "тормоза" из HTTP-запросов и спасли нервы пользователей
Пользователи не любят ждать. Если кнопка "Отправить" не реагирует три секунды — они уже в Twitter пишут что ваш сайт сломан. Один из самых мощных инструментов для улучшения perceived performance — асинхронная обработка через очереди.
Что уходит в очередь
В HTTP-запросе должно происходить только то, что нужно пользователю для немедленного ответа.
Что НЕ нужно пользователю немедленно:
Отправка email
Генерация PDF
Пересчёт статистики
Синхронизация с внешними системами
Изменение размеров изображений
Push-уведомления
Webhook-уведомления партнёров
Всё это — в очередь. HTTP отвечает за 50ms. Фоновый воркер делает остальное.
Базовая архитектура очередей на Redis + CI4
<?php
namespace App\Libraries\Queue;
class Queue
{
private \Redis $redis;
private string $prefix = 'queue:';
public function push(string $queueName, BaseJob $job, int $delay = 0): string
{
$payload = [
'id' => $id = uniqid('job_', true),
'class' => get_class($job),
'data' => serialize($job),
'attempt' => 0,
'created_at' => time(),
];
if ($delay > 0) {
$this->redis->zAdd(
$this->prefix . 'delayed:' . $queueName,
time() + $delay,
json_encode($payload)
);
} else {
$this->redis->lPush($this->prefix . $queueName, json_encode($payload));
}
return $id;
}
public function pop(string $queueName, int $timeout = 5): ?array
{
$this->promoteDelayedJobs($queueName);
$result = $this->redis->brPop($this->prefix . $queueName, $timeout);
return $result ? json_decode($result[1], true) : null;
}
private function promoteDelayedJobs(string $queueName): void
{
$delayedKey = $this->prefix . 'delayed:' . $queueName;
$jobs = $this->redis->zRangeByScore($delayedKey, '-inf', time());
foreach ($jobs as $job) {
$this->redis->multi();
$this->redis->zRem($delayedKey, $job);
$this->redis->lPush($this->prefix . $queueName, $job);
$this->redis->exec();
}
}
}
Worker — CI4 CLI команда
<?php
namespace App\Commands;
use App\Libraries\Queue\Queue;
class QueueWorker extends BaseCommand
{
protected $name = 'queue:work';
protected $description = 'Process jobs from the queue';
private bool $shouldStop = false;
public function run(array $params): void
{
$queueName = $params[0] ?? 'default';
$maxJobs = (int) ($this->getOption('max-jobs') ?? 0);
$processed = 0;
if (function_exists('pcntl_signal')) {
pcntl_signal(SIGTERM, fn() => $this->shouldStop = true);
pcntl_signal(SIGINT, fn() => $this->shouldStop = true);
}
$queue = new Queue();
while (!$this->shouldStop) {
if (function_exists('pcntl_signal_dispatch')) {
pcntl_signal_dispatch();
}
$payload = $queue->pop($queueName);
if (!$payload) continue;
$this->processJob($queue, $queueName, $payload);
$processed++;
if ($maxJobs > 0 && $processed >= $maxJobs) break;
}
}
private function processJob(Queue $queue, string $queueName, array $payload): void
{
try {
$job = unserialize($payload['data']);
$job->setAttempt($payload['attempt'] + 1);
set_time_limit($job->getTimeout());
$job->handle();
} catch (\Throwable $e) {
$payload['attempt']++;
if ($payload['attempt'] < $job->getMaxAttempts()) {
$delay = pow(2, $payload['attempt']) * 10;
$queue->push($queueName, $job, $delay);
} else {
$job->failed($e);
$queue->bury($queueName, $payload);
}
}
}
}
Реальный пример: отправка email
<?php
namespace App\Jobs;
use App\Libraries\Queue\BaseJob;
class SendWelcomeEmailJob extends BaseJob
{
protected int $maxAttempts = 5;
protected int $timeout = 30;
public function __construct(
private readonly int $userId,
private readonly string $email,
private readonly string $name
) {}
public function handle(): void
{
service('email')->send(
to: $this->email,
subject: 'Добро пожаловать!',
view: 'emails/welcome',
data: ['name' => $this->name]
);
model('EmailLogModel')->insert([
'user_id' => $this->userId,
'type' => 'welcome',
'sent_at' => date('Y-m-d H:i:s'),
'status' => 'sent',
]);
}
}
Использование в Controller:
// Вместо: $emailService->send(...); // ждём 500ms
// Делаем:
$queue = new Queue();
$queue->push('emails', new SendWelcomeEmailJob($user->id, $user->email, $user->name));
// HTTP response за 15ms вместо 515ms
return $this->response->setJSON(['status' => 'registered', 'message' => 'Check your email']);
Результаты
Endpoint | До | После |
|---|---|---|
POST /register | 580ms | 45ms |
POST /upload-photo | 2300ms | 120ms |
POST /generate-report | 8000ms | 80ms (async) |
POST /checkout | 1200ms | 340ms |
Bounce rate на страницах с "тяжёлыми" действиями упал на 34%. Конверсия регистрации выросла на 12%. Просто потому что форма теперь отвечает мгновенно.
Пользователи не знают о ваших очередях. Они просто чувствуют что сайт быстрый. Это и есть цель. ⚡
Максим — продуктовый DevOps с горящими глазами и умеренно сгоревшими нервами. Пишу про реальный highload, реальные ошибки и реальные решения. Больше таких историй — на ithub.uno, там собираются те, кто делает, а не только говорит.
Recommended Comments