Архитектура

Построение реактивной системы уведомлений на Laravel и WebSocket: архитектура, очереди и масштабирование

Ruslan Ismailov Опубликовано 14 мин чтения
П

Введение: почему real-time уведомления требуют отдельной архитектуры

Уведомления в современных веб-приложениях делятся на несколько принципиально разных типов: in-app (счётчик колокольчика), email, push (браузерные и мобильные) и SMS. Каждый из них имеет разные требования к скорости доставки, надёжности и каналу передачи.

Ключевой вопрос: можно ли обойтись polling — периодическим запросом к серверу каждые N секунд? Технически да, но цена очевидна: нагрузка на базу данных растёт линейно от количества пользователей, задержка доставки неприемлема для чатов и алертов, а масштабирование превращается в боль. Именно поэтому real-time уведомления требуют отдельной архитектурной ветки: постоянного соединения (WebSocket или SSE) плюс асинхронного конвейера обработки на стороне сервера.

В этой статье мы разберём, как построить такую систему на базе Laravel, Redis и Laravel Reverb — от модели данных до горизонтального масштабирования в продакшене.

Обзор стека: как части складываются в систему

Стек состоит из нескольких уровней, каждый из которых решает свою задачу:

  • Laravel Notifications — высокоуровневый API для отправки уведомлений через разные каналы (mail, database, broadcast, Slack и т.д.).
  • Laravel Broadcasting — механизм публикации событий в WebSocket-каналы; работает поверх драйверов Pusher, Ably или собственного сервера.
  • Redis — двойная роль: очередь задач (driver redis) и транспорт Pub/Sub между приложением и WebSocket-сервером.
  • Laravel Reverb — нативный первопартийный WebSocket-сервер, представленный в Laravel 11; заменяет Soketi и сторонние решения.
  • Queue Workers — обрабатывают тяжёлые каналы (email, SMS) асинхронно, не блокируя HTTP-запрос.

Поток данных выглядит так: HTTP-запрос или событие домена → Laravel создаёт уведомление → пишет в БД и отправляет в очередь → воркер обрабатывает канал → Redis Pub/Sub → Reverb → WebSocket → браузер.

Проектирование модели уведомлений

Стандартная таблица notifications, которую создаёт Laravel, минималистична. Для production-системы её стоит расширить.

-- Миграция расширенной таблицы уведомлений
CREATE TABLE notifications (
    id          UUID PRIMARY KEY DEFAULT gen_random_uuid(),
    type        VARCHAR(255) NOT NULL,          -- класс уведомления
    notifiable_type VARCHAR(255) NOT NULL,
    notifiable_id   BIGINT UNSIGNED NOT NULL,
    data        JSON NOT NULL,
    status      ENUM('pending','sent','read','archived') DEFAULT 'pending',
    priority    TINYINT DEFAULT 0,              -- 0=normal, 1=high, 2=critical
    group_key   VARCHAR(128) NULL,              -- для группировки похожих
    channel     VARCHAR(64) NOT NULL DEFAULT 'database',
    read_at     TIMESTAMP NULL,
    expires_at  TIMESTAMP NULL,
    created_at  TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
    updated_at  TIMESTAMP DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
    INDEX idx_notifiable (notifiable_type, notifiable_id, status),
    INDEX idx_group (group_key),
    INDEX idx_priority (priority, created_at)
);

Поле group_key позволяет схлопывать однотипные уведомления: вместо «10 новых комментариев» пользователь видит одну карточку с числом. Поле priority управляет порядком обработки в очереди — критические уведомления попадают в отдельную высокоприоритетную очередь.

Модель в Laravel:

<?php

namespace App\Models;

use Illuminate\Database\Eloquent\Concerns\HasUuids;
use Illuminate\Notifications\DatabaseNotification;

class Notification extends DatabaseNotification
{
    use HasUuids;

    protected $casts = [
        'data'       => 'array',
        'read_at'    => 'datetime',
        'expires_at' => 'datetime',
    ];

    public function scopeUnread($query)
    {
        return $query->whereNull('read_at')->where('status', '!=', 'archived');
    }

    public function scopeHighPriority($query)
    {
        return $query->where('priority', '>=', 1)->orderByDesc('priority');
    }

    public function markAsRead(): void
    {
        $this->update(['read_at' => now(), 'status' => 'read']);
    }
}

Laravel Broadcasting и каналы: private, presence, аутентификация

Broadcasting в Laravel строится на концепции каналов. Три типа:

  • Public — любой может подписаться; подходит для глобальных объявлений.
  • Private — требует аутентификации пользователя; стандарт для персональных уведомлений.
  • Presence — расширение private; сервер знает, кто сейчас онлайн; используется для «X пользователей читают».

Пример уведомления с поддержкой broadcast:

<?php

namespace App\Notifications;

use App\Models\Order;
use Illuminate\Bus\Queueable;
use Illuminate\Contracts\Queue\ShouldQueue;
use Illuminate\Notifications\Messages\BroadcastMessage;
use Illuminate\Notifications\Notification;

class OrderStatusChanged extends Notification implements ShouldQueue
{
    use Queueable;

    public function __construct(private Order $order) {}

    public function via(object $notifiable): array
    {
        return ['database', 'broadcast'];
    }

    public function toDatabase(object $notifiable): array
    {
        return [
            'order_id' => $this->order->id,
            'status'   => $this->order->status,
            'message'  => "Заказ #{$this->order->number} изменил статус на {$this->order->status}",
        ];
    }

    public function toBroadcast(object $notifiable): BroadcastMessage
    {
        return new BroadcastMessage([
            'id'      => $this->id,
            'type'    => 'order.status_changed',
            'payload' => $this->toDatabase($notifiable),
        ]);
    }

    public function broadcastOn(): array
    {
        return [new \Illuminate\Broadcasting\PrivateChannel(
            'users.' . $this->notifiable->id
        )];
    }

    // Кастомное имя события на клиенте
    public function broadcastAs(): string
    {
        return 'notification.new';
    }
}

Авторизация private-каналов настраивается в routes/channels.php:

<?php

use Illuminate\Support\Facades\Broadcast;

Broadcast::channel('users.{userId}', function ($user, $userId) {
    return (int) $user->id === (int) $userId;
});

// Presence-канал для команды
Broadcast::channel('team.{teamId}', function ($user, $teamId) {
    if ($user->teams->contains($teamId)) {
        return ['id' => $user->id, 'name' => $user->name];
    }
});

Redis как транспорт: Pub/Sub между приложением и WebSocket-сервером

Когда Laravel публикует событие в broadcast-канал, он сериализует его в JSON и публикует в Redis-канал через PUBLISH. WebSocket-сервер подписан на этот канал через SUBSCRIBE и пересылает сообщение всем подключённым клиентам в нужном канале.

Это классический паттерн Redis Pub/Sub, и он критически важен для масштабирования: несколько инстансов приложения могут публиковать сообщения независимо, а WebSocket-сервер выступает единой точкой доставки.

Настройка в config/broadcasting.php:

<?php

return [
    'default' => env('BROADCAST_DRIVER', 'reverb'),

    'connections' => [
        'reverb' => [
            'driver'  => 'reverb',
            'key'     => env('REVERB_APP_KEY'),
            'secret'  => env('REVERB_APP_SECRET'),
            'app_id'  => env('REVERB_APP_ID'),
            'options' => [
                'host'   => env('REVERB_HOST', '0.0.0.0'),
                'port'   => env('REVERB_PORT', 8080),
                'scheme' => env('REVERB_SCHEME', 'http'),
            ],
        ],
    ],
];

Для Redis как транспорта очередей в config/queue.php:

'redis' => [
    'driver'      => 'redis',
    'connection'  => 'default',
    'queue'       => env('REDIS_QUEUE', 'default'),
    'retry_after' => 90,
    'block_for'   => null,
    'after_commit' => true, // важно: отправляем после успешного коммита транзакции
],

Интеграция с Laravel Reverb: конфигурация и запуск

Laravel Reverb — нативный WebSocket-сервер, написанный на PHP поверх ReactPHP. Он появился в Laravel 11 и решает главную боль — зависимость от Pusher или необходимость поддерживать отдельный Node.js-сервис.

Установка:

composer require laravel/reverb
php artisan reverb:install

Ключевые переменные в .env:

BROADCAST_DRIVER=reverb
REVERB_APP_ID=my-app
REVERB_APP_KEY=my-key
REVERB_APP_SECRET=my-secret
REVERB_HOST=0.0.0.0
REVERB_PORT=8080
REVERB_SCHEME=http

# Для горизонтального масштабирования
REVERB_SCALING_ENABLED=true
REVERB_SCALING_CHANNEL=reverb

Запуск в продакшене через Supervisor:

[program:reverb]
command=php /var/www/app/artisan reverb:start --host=0.0.0.0 --port=8080
autostart=true
autorestart=true
stopasgroup=true
killasgroup=true
user=www-data
redirect_stderr=true
stdout_logfile=/var/log/supervisor/reverb.log

При горизонтальном масштабировании Reverb использует Redis для синхронизации состояния между несколькими инстансами сервера. Это включается через scaling в config/reverb.php:

'scaling' => [
    'enabled' => env('REVERB_SCALING_ENABLED', false),
    'driver'  => 'redis',
    'redis'   => [
        'channel' => env('REVERB_SCALING_CHANNEL', 'reverb'),
    ],
],

Асинхронная отправка: очереди, батчинг, тяжёлые каналы

Email и SMS — дорогостоящие операции: обращение к внешнему API, возможные таймауты, rate limits. Отправлять их синхронно в HTTP-запросе — антипаттерн. Laravel решает это через интерфейс ShouldQueue.

Стратегия разделения очередей по приоритетам:

// Запуск воркеров для разных очередей
// Высокоприоритетные (in-app, broadcast) — быстрые воркеры
php artisan queue:work redis --queue=notifications-critical,notifications-high,default

// Медленные каналы (email, SMS) — отдельный пул
php artisan queue:work redis --queue=notifications-email,notifications-sms --timeout=60

Батчинг уведомлений через Bus::batch() позволяет отслеживать групповую отправку:

<?php

use Illuminate\Bus\Batch;
use Illuminate\Support\Facades\Bus;
use Illuminate\Support\Facades\Notification;

$users = User::whereIn('id', $targetIds)->cursor();

$jobs = collect();
foreach ($users as $user) {
    $jobs->push(new SendNotificationJob($user, new CampaignNotification($campaign)));
}

Bus::batch($jobs)
    ->then(function (Batch $batch) use ($campaign) {
        $campaign->markAsDelivered();
    })
    ->catch(function (Batch $batch, \Throwable $e) {
        Log::error('Batch notification failed', ['error' => $e->getMessage()]);
    })
    ->onQueue('notifications-email')
    ->dispatch();

Масштабирование: воркеры, несколько инстансов, stateless-подход

При росте нагрузки система должна масштабироваться горизонтально. Несколько принципов:

  • Stateless приложение: Laravel-приложение не хранит состояние WebSocket-соединений — это задача Reverb. Приложение только публикует события в Redis.
  • Несколько Reverb-инстансов: при включённом scaling через Redis все инстансы синхронизируют состояние каналов. Балансировщик (Nginx/HAProxy) распределяет WebSocket-соединения — здесь не нужны sticky sessions, так как Reverb сам синхронизируется через Redis Pub/Sub.
  • Масштабирование воркеров: добавляйте воркеры горизонтально. Используйте --max-jobs и --max-time для предотвращения утечек памяти.

Пример Nginx-конфигурации для WebSocket-проксирования:

upstream reverb_servers {
    # stateless — без sticky sessions благодаря Redis scaling
    least_conn;
    server reverb-1:8080;
    server reverb-2:8080;
    server reverb-3:8080;
}

server {
    listen 443 ssl;
    server_name ws.example.com;

    location / {
        proxy_pass         http://reverb_servers;
        proxy_http_version 1.1;
        proxy_set_header   Upgrade $http_upgrade;
        proxy_set_header   Connection "upgrade";
        proxy_set_header   Host $host;
        proxy_read_timeout 3600s;
        proxy_send_timeout 3600s;
    }
}

Для контейнерного окружения (Kubernetes / Docker Swarm) горизонтальное масштабирование через Microservices-подход выглядит естественно: Reverb-поды масштабируются независимо от PHP-FPM-подов, а Redis выступает общей шиной.

Надёжность: повторные попытки, dead letter queue, мониторинг

Надёжная система уведомлений должна переживать сбои внешних сервисов. Несколько практик:

Повторные попытки с экспоненциальной задержкой

<?php

namespace App\Notifications;

use Illuminate\Notifications\Notification;

class EmailAlertNotification extends Notification
{
    public int $tries = 5;
    public int $maxExceptions = 3;

    public function backoff(): array
    {
        // 1 мин → 5 мин → 15 мин → 30 мин → 60 мин
        return [60, 300, 900, 1800, 3600];
    }

    public function retryUntil(): \DateTime
    {
        return now()->addHours(6);
    }
}

Dead Letter Queue

Сообщения, исчерпавшие все попытки, попадают в таблицу failed_jobs. Настройте мониторинг этой таблицы:

// В AppServiceProvider или отдельном мониторинговом сервисе
Schedule::command('queue:failed-jobs-check')
    ->everyFiveMinutes()
    ->withoutOverlapping();

// Кастомная команда
public function handle(): void
{
    $failedCount = DB::table('failed_jobs')
        ->where('failed_at', '>=', now()->subHour())
        ->count();

    if ($failedCount > 10) {
        // Алерт в Slack/PagerDuty
        Notification::route('slack', config('alerts.slack_webhook'))
            ->notify(new QueueHealthAlert($failedCount));
    }
}

Мониторинг с Laravel Horizon

Laravel Horizon — обязательный инструмент для production-систем с Redis-очередями. Он даёт визуальный дашборд, метрики throughput, ошибки и время обработки.

// config/horizon.php — разделение воркеров по очередям
'environments' => [
    'production' => [
        'supervisor-notifications-realtime' => [
            'connection' => 'redis',
            'queue'      => ['notifications-critical', 'notifications-high'],
            'balance'    => 'auto',
            'processes'  => 8,
            'tries'      => 3,
        ],
        'supervisor-notifications-async' => [
            'connection' => 'redis',
            'queue'      => ['notifications-email', 'notifications-sms'],
            'balance'    => 'simple',
            'processes'  => 4,
            'tries'      => 5,
            'timeout'    => 120,
        ],
    ],
],

Заключение: чек-лист production-ready системы уведомлений

Прежде чем выпускать систему в продакшен, пройдитесь по этому списку:

  1. Модель данных: расширена полями status, priority, group_key, expires_at; индексы созданы.
  2. Каналы аутентифицированы: private-каналы защищены через Broadcast::channel().
  3. Broadcast реализован: уведомление реализует toBroadcast() и broadcastOn().
  4. Все каналы асинхронны: уведомление реализует ShouldQueue.
  5. Очереди разделены по приоритетам: realtime-очередь не блокируется тяжёлыми email-задачами.
  6. Laravel Reverb настроен с Redis-scaling для горизонтального масштабирования.
  7. Nginx/балансировщик правильно проксирует WebSocket-соединения (заголовки Upgrade).
  8. Повторные попытки и backoff настроены для каждого канала.
  9. Dead letter queue мониторится автоматически с алертами.
  10. Laravel Horizon запущен под Supervisor с раздельными supervisor-группами.
  11. Нагрузочное тестирование проведено: WebSocket-сервер держит целевое число одновременных соединений.
  12. Политика истечения срока уведомлений реализована: старые записи архивируются или удаляются.

Реактивная система уведомлений — это не одна фича, а полноценная подсистема со своей архитектурой, отказоустойчивостью и операционными требованиями. Инвестиции в правильный фундамент окупаются, когда нагрузка вырастает в 10 раз.

Используя Laravel, Redis и Reverb совместно, вы получаете production-ready стек, который покрывает большинство сценариев от небольшого SaaS до highload-платформы. Ключ — правильное разделение ответственности: приложение публикует, Redis транспортирует, Reverb доставляет.

Технологии

Теги

Руслан Исмаилов

Senior Web / Backend разработчик. Senior web/backend разработчик с 9-летним опытом. Стек: PHP, Laravel, PostgreSQL, Redis, Docker, Kubernetes, REST, микросервисы, CI/CD. Подробнее обо мне →