Real-time уведомления
Что такое real-time
Real-time — сервер сам присылает данные, когда что-то изменилось, без опроса.
Три способа:
- Polling — клиент спрашивает каждые N секунд.
- SSE — сервер держит однонаправленный поток.
- WebSocket — двунаправленное соединение.
Сравнение
| Способ | Направление | Нагрузка | Когда |
|---|---|---|---|
| Polling | Запрос-ответ | Высокая | Редко меняется |
| Long polling | Запрос-ответ | Средняя | Простая реализация |
| SSE | Сервер → клиент | Низкая | Уведомления, стрим |
| WebSocket | Оба | Низкая | Чат, игры, курсоры |
Правило:
- Уведомления — SSE.
- Чат / интерактив — WebSocket.
- AI-стрим — SSE.
Архитектура real-time
Laravel бизнес-событие
↓
Redis PUBLISH
↓
Node.js подписан
↓
Socket.IO / SSE
↓
Клиенты (браузер, мобильный)
Разделение:
- Laravel — источник событий.
- Redis — шина.
- Node.js — доставка клиентам.
Redis Pub/Sub
Laravel публикует:
use Illuminate\Support\Facades\Redis;
public function notify(int $userId, array $payload): void
{
Redis::publish('app:events', json_encode([
'channel' => "user:{$userId}",
'event' => 'notification',
'data' => $payload,
]));
}
Node.js подписан:
import { Redis } from 'ioredis';
const pub = new Redis(process.env.REDIS_URL);
const sub = new Redis(process.env.REDIS_URL);
sub.subscribe('app:events');
sub.on('message', (channel, message) => {
const { channel: userChannel, event, data } = JSON.parse(message);
io.to(userChannel).emit(event, data);
});
Что даёт: Laravel не знает про WebSocket, Node.js не знает про бизнес-логику.
Socket.IO сервер
import { Server } from 'socket.io';
import { createAdapter } from '@socket.io/redis-adapter';
const io = new Server(httpServer, {
cors: { origin: process.env.FRONTEND_URL, credentials: true },
adapter: createAdapter(pub, sub),
});
io.use(async (socket, next) => {
const token = socket.handshake.auth.token;
try {
const user = await verifyToken(token);
socket.data.user = user;
next();
} catch (err) {
next(new Error('Unauthorized'));
}
});
io.on('connection', (socket) => {
const user = socket.data.user;
socket.join(`user:${user.id}`);
if (user.isAdmin) socket.join('admins');
socket.on('disconnect', () => {
// auto leave rooms
});
});
Аутентификация
Три подхода:
1. Sanctum-токен в handshake:
io.use(async (socket, next) => {
const token = socket.handshake.auth.token;
const user = await verifySanctumToken(token);
socket.data.user = user;
next();
});
2. JWT:
import jwt from 'jsonwebtoken';
io.use((socket, next) => {
const { token } = socket.handshake.auth;
try {
const payload = jwt.verify(token, process.env.JWT_SECRET);
socket.data.user = payload;
next();
} catch {
next(new Error('Invalid token'));
}
});
3. Проверка в Redis:
io.use(async (socket, next) => {
const token = socket.handshake.auth.token;
const session = await redis.get(`session:${token}`);
if (!session) return next(new Error('Unauthorized'));
socket.data.user = JSON.parse(session);
next();
});
Правило: первое сообщение — только после аутентификации.
Rooms
Что даёт: группировка соединений.
// Подписать клиента на его комнату
socket.join(`user:${user.id}`);
// Отправить в комнату
io.to(`user:${user.id}`).emit('notification', data);
// Всем в комнате, кроме отправителя
socket.to(`room:${roomId}`).emit('message', data);
// Всем
io.emit('broadcast', data);
Использование:
user:{id}— личные уведомления.order:{id}— статус заказа.chat:{id}— сообщения чата.admins— для админки.
Presence (онлайн-статус)
Простой вариант:
const onlineUsers = new Map<number, Set<string>>();
io.on('connection', (socket) => {
const userId = socket.data.user.id;
if (!onlineUsers.has(userId)) onlineUsers.set(userId, new Set());
onlineUsers.get(userId).add(socket.id);
socket.broadcast.emit('user:online', userId);
socket.on('disconnect', () => {
onlineUsers.get(userId).delete(socket.id);
if (onlineUsers.get(userId).size === 0) {
onlineUsers.delete(userId);
socket.broadcast.emit('user:offline', userId);
}
});
});
С Redis (несколько инстансов):
await redis.sadd(`online:${userId}`, socket.id);
await redis.expire(`online:${userId}`, 300);
Масштабирование
Проблема: 2+ инстанса Node.js — клиент на инстансе A, событие на инстансе B.
Решение 1 — Redis adapter:
import { createAdapter } from '@socket.io/redis-adapter';
io.adapter(createAdapter(pubClient, subClient));
Что даёт: io.to(room).emit() работает между инстансами.
Решение 2 — sticky sessions:
upstream nodejs {
ip_hash;
server 127.0.0.1:3001;
server 127.0.0.1:3002;
}
Правило: для WebSocket — Redis adapter + sticky.
SSE — Server-Sent Events
Проще, чем WebSocket:
app.get('/notifications', async (req, res) => {
res.setHeader('Content-Type', 'text/event-stream');
res.setHeader('Cache-Control', 'no-cache');
res.setHeader('Connection', 'keep-alive');
const userId = req.user.id;
const channel = `user:${userId}`;
const sub = redis.duplicate();
await sub.subscribe(channel);
sub.on('message', (_, message) => {
res.write(`data: ${message}\n\n`);
});
req.on('close', () => {
sub.unsubscribe(channel);
sub.quit();
});
});
Клиент:
const es = new EventSource('/notifications');
es.onmessage = (e) => {
const data = JSON.parse(e.data);
console.log(data);
};
Что даёт: однонаправленный поток, авто-переподключение.
Когда SSE лучше WS:
- Только сервер → клиент.
- Простая реализация.
- Работает через HTTP/2.
Laravel Broadcasting
Альтернатива своей реализации:
composer require pusher/pusher-php-server
// config/broadcasting.php
'default' => env('BROADCAST_DRIVER', 'redis'),
'connections' => [
'redis' => [
'driver' => 'redis',
'connection' => 'default',
],
],
Событие:
class OrderShipped implements ShouldBroadcast
{
use Dispatchable, InteractsWithSockets, SerializesModels;
public function __construct(public Order $order) {}
public function broadcastOn(): array
{
return [
new PrivateChannel('user.' . $this->order->user_id),
];
}
public function broadcastAs(): string
{
return 'order.shipped';
}
}
Клиент (Laravel Echo):
import Echo from 'laravel-echo';
window.Echo = new Echo({
broadcaster: 'socket.io',
host: 'wss://api.example.com',
authEndpoint: '/broadcasting/auth',
});
Echo.private(`user.${userId}`)
.listen('.order.shipped', (e) => console.log(e));
Реализация с laravel-echo-server
Проще, чем свой Socket.IO:
npm install -g laravel-echo-server
laravel-echo-server init
laravel-echo-server start
Что даёт: готовый сервер для Broadcasting.
Когда использовать: нужны стандартные broadcast-события.
Когда свой Socket.IO:
- Сложная логика комнат.
- Presence.
- Кастомные события.
Порядок и доставка
Проблемы:
- Событие пропало — клиент не получил.
- Дубликат — пришло дважды.
- Порядок нарушен — важные события перепутались.
Решение:
- Event ID — каждое событие с UUID.
- Клиент хранит последний — при reconnect запрашивает с
after_id. - Идемпотентный обработчик — повторная обработка безопасна.
io.to(user).emit('event', {
id: uuid(),
type: 'order.shipped',
data: { orderId: 123 },
});
Reconnect:
socket.on('connect', async () => {
const lastId = localStorage.getItem('lastEventId');
const events = await fetch(`/api/events?after=${lastId}`);
events.forEach(processEvent);
});
Heartbeat
Проблема: прокси рвут idle-соединения через 60 сек.
Решение:
// Server
const interval = setInterval(() => {
io.emit('ping', Date.now());
}, 25000);
io.on('connection', () => {
clearInterval(interval);
});
// Client
socket.on('ping', () => socket.emit('pong'));
Что даёт: соединение активно.
Мониторинг
Что смотреть:
- Connected sockets — сколько онлайн.
- Rooms count — сколько комнат.
- Messages/sec — сколько событий.
- Redis lag — задержка pub/sub.
- Reconnects — частота переподключений.
Grafana dashboard:
- Prometheus exporter для Socket.IO.
- Метрики из Redis.
Практика: уведомления о заказах
1. Laravel при смене статуса:
class OrderStatusChanged
{
public function __construct(public Order $order) {}
public function handle(): void
{
Redis::publish('app:events', json_encode([
'channel' => "user:{$this->order->user_id}",
'event' => 'order.status',
'data' => [
'orderId' => $this->order->id,
'status' => $this->order->status,
'updatedAt' => $this->order->updated_at->toIso8601String(),
],
]));
}
}
2. Node.js:
sub.on('message', (_, message) => {
const { channel, event, data } = JSON.parse(message);
io.to(channel).emit(event, data);
});
3. Фронт:
socket.on('order.status', (data) => {
updateOrderInUI(data);
});
Частые ошибки
1. WebSocket без авторизации:
Любой может подписаться на user:5
Решение: middleware в io.use.
2. Один инстанс без Redis adapter:
События теряются при масштабировании
Решение: createAdapter.
3. Нет heartbeat:
Соединения рвутся через прокси
Решение: ping каждые 25 сек.
4. Нет reconnection:
После обрыва клиент не переподключается
Решение: Socket.IO сам, для SSE — свой.
5. Событие без ID:
При reconnect теряются события
Решение: событие с UUID + запрос пропущенных.
6. Все комнаты в одном процессе:
Память кончается
Решение: Redis для presence.
Итог
- Real-time — через SSE или WebSocket.
- Уведомления — SSE, чат — WebSocket.
- Redis — шина между Laravel и Node.js.
- Socket.IO rooms — группировка.
- Redis adapter — масштабирование.
- Heartbeat — против прокси.
- Event ID — против потерь.
- Laravel Broadcasting — готовое решение.
- Presence — через Redis.
Дальше — мониторинг и алерты.