HTTP устроен как «вопрос — ответ»: клиент спрашивает, сервер отвечает. Но у многих задач направление обратное: пришло сообщение в чат, изменился статус заказа, обновился курс — сервер должен сообщить клиенту. Обычный HTTP так не умеет, и за годы выработалось три рабочих способа: long polling, Server-Sent Events и WebSocket.
Long polling: растянутый вопрос
Простейшая идея: клиент спрашивает «есть новости?», а сервер не отвечает, пока новости не появятся (или не истечёт таймаут, скажем 30 секунд). Получив ответ, клиент немедленно спрашивает снова.
Плюсы: работает поверх обычного HTTP везде, где работает HTTP — любые прокси, балансировщики, файрволы. Минусы: постоянно висящие запросы занимают ресурсы, между ответом и новым запросом есть щель (событие подождёт), каждый цикл несёт полные HTTP-заголовки. Сегодня это запасной вариант — для сред, где ничего лучше не проходит.
На сервере запрос не блокируют, а откладывают ответ, пока не появится событие или не истечёт таймаут:
@GetMapping("/orders/{id}/updates")
DeferredResult<ResponseEntity<OrderStatus>> updates(@PathVariable long id) {
// второй аргумент — что вернуть по таймауту, это тело ответа, а не код статуса
var result = new DeferredResult<ResponseEntity<OrderStatus>>(
30_000L, ResponseEntity.noContent().build());
waiters.add(id, result);
result.onCompletion(() -> waiters.remove(id, result));
return result; // поток свободен, клиент ждёт
}
@app.get("/orders/{id}/updates")
async def updates(id: int):
try:
return await asyncio.wait_for(waiters.next(id), timeout=30) # ждём событие
except asyncio.TimeoutError:
return Response(status_code=204)
func updates(w http.ResponseWriter, r *http.Request) {
select {
case ev := <-waiters.Sub(orderID(r)): // пришло событие
json.NewEncoder(w).Encode(ev)
case <-time.After(30 * time.Second): // таймаут
w.WriteHeader(http.StatusNoContent)
}
}
app.get("/orders/:id/updates", (req, res) => {
const timer = setTimeout(() => res.sendStatus(204), 30_000); // таймаут
waiters.add(req.params.id, (ev) => { clearTimeout(timer); res.json(ev); });
});
Клиент, получив ответ, сразу спрашивает снова:
async function poll(id) {
const r = await fetch(`/orders/${id}/updates`); // висит, пока нет события
if (r.status === 200) render(await r.json());
poll(id); // без паузы — новый вопрос
}
Server-Sent Events: односторонний поток
SSE — часть веб-стандартов: клиент открывает обычное HTTP-соединение, сервер держит его и пишет события текстовым потоком text/event-stream:
data: {"orderId": 42, "status": "SHIPPED"}
data: {"orderId": 43, "status": "PAID"}
Ключевые свойства:
- однонаправленность — события идут только от сервера; запросы клиент шлёт обычным HTTP рядом;
- автопереподключение встроено: браузерный EventSource сам восстанавливает соединение и передаёт
Last-Event-ID, чтобы дочитать пропущенное; - это по-прежнему HTTP: сжатие, прокси, кэширование — всё стандартно. С авторизацией одна оговорка: браузерный
EventSourceне умеет отправлять заголовокAuthorization, поэтому подписку авторизуют cookie либо берут вместо него потоковыйfetch.
На сервере держим соединение и пишем в него события по мере появления:
@GetMapping("/orders/{id}/events")
SseEmitter events(@PathVariable long id) {
SseEmitter emitter = new SseEmitter(1_800_000L); // 30 минут
subscribers.add(id, emitter);
emitter.onCompletion(() -> subscribers.remove(id, emitter));
return emitter;
}
// при событии: emitter.send(SseEmitter.event().id(seq).data(status));
@app.get("/orders/{id}/events")
async def events(id: int):
async def stream():
async for s in subscribe(id):
yield f"id: {s.seq}\ndata: {s.json()}\n\n"
return StreamingResponse(stream(), media_type="text/event-stream")
func events(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "text/event-stream")
flusher := w.(http.Flusher)
for s := range subscribe(orderID(r)) {
fmt.Fprintf(w, "id: %d\ndata: %s\n\n", s.Seq, s.JSON())
flusher.Flush()
}
}
app.get("/orders/:id/events", (req, res) => {
res.set({ "Content-Type": "text/event-stream", "Cache-Control": "no-cache" });
const send = (s) => res.write(`id: ${s.seq}\ndata: ${JSON.stringify(s)}\n\n`);
subscribe(req.params.id, send);
req.on("close", () => unsubscribe(req.params.id, send));
});
Клиенту достаточно EventSource — переподключение и Last-Event-ID он берёт на себя:
const es = new EventSource(`/orders/${id}/events`);
es.onmessage = (e) => render(JSON.parse(e.data));
es.onerror = () => {/* браузер переподключится сам, дошлём с Last-Event-ID */};
SSE — недооценённый вариант: для «сервер уведомляет клиента» (статусы, ленты, прогресс операций, курсы) он закрывает задачу заметно дешевле WebSocket.
WebSocket: полный дуплекс
WebSocket начинается как HTTP-запрос с заголовком Upgrade: websocket, после рукопожатия соединение превращается в постоянный двунаправленный канал: обе стороны шлют сообщения когда угодно, без HTTP-заголовков на каждое.
Это единственный из трёх способов с настоящей связью в обе стороны и минимальными накладными расходами на сообщение — цена соответствующая:
- протокол другой: нужна поддержка на всём пути (прокси, балансировщики, таймауты простоя);
- переподключение, восстановление подписок и доставка пропущенного — ваша забота (потому поверх WebSocket обычно живёт протокол уровнем выше: STOMP, socket.io и подобные);
- тысячи постоянных соединений — отдельная статья нагрузки на сервер.
Минимальный обработчик: принять соединение, а входящее сообщение разослать остальным:
class ChatHandler extends TextWebSocketHandler {
private final Set<WebSocketSession> sessions = ConcurrentHashMap.newKeySet();
public void afterConnectionEstablished(WebSocketSession s) { sessions.add(s); }
public void afterConnectionClosed(WebSocketSession s, CloseStatus st) { sessions.remove(s); }
protected void handleTextMessage(WebSocketSession from, TextMessage msg) throws IOException {
for (WebSocketSession s : sessions) // разослать остальным
if (s.isOpen() && s != from) s.sendMessage(msg);
// ВАЖНО: WebSocketSession не рассчитана на отправку из нескольких потоков.
// Здесь сообщения от разных клиентов приходят параллельно и могут
// «перемешаться» в одном соединении. В рабочем коде каждую сессию
// оборачивают в ConcurrentWebSocketSessionDecorator.
}
}
@app.websocket("/ws/chat")
async def chat(ws: WebSocket):
await ws.accept(); clients.add(ws)
try:
while True:
msg = await ws.receive_text()
for c in clients:
if c is not ws: await c.send_text(msg) # разослать остальным
except WebSocketDisconnect:
clients.discard(ws)
func chat(w http.ResponseWriter, r *http.Request) {
conn, _ := upgrader.Upgrade(w, r, nil)
clients.Add(conn); defer clients.Remove(conn)
for {
_, msg, err := conn.ReadMessage()
if err != nil { return }
clients.Broadcast(conn, msg) // разослать остальным
}
}
const wss = new WebSocketServer({ path: "/ws/chat" });
wss.on("connection", (ws) => {
ws.on("message", (msg) => {
for (const c of wss.clients) // разослать остальным
if (c !== ws && c.readyState === 1) c.send(msg.toString());
});
});
Клиент шлёт и принимает в обе стороны; переподключение пишем сами:
let ws;
function connect() {
ws = new WebSocket(`wss://${location.host}/ws/chat`);
ws.onmessage = (e) => addMessage(JSON.parse(e.data));
// переподключение — на вас; пауза растёт и слегка разбрасывается,
// иначе после перезапуска сервера все клиенты придут одновременно
ws.onclose = () => setTimeout(connect, Math.min(30000, delay *= 2) * (0.5 + Math.random()));
}
connect();
sendBtn.onclick = () => ws.send(JSON.stringify({ text: input.value }));
Сравнение и выбор
| Long polling | SSE | WebSocket | |
|---|---|---|---|
| Направление | сервер → клиент | сервер → клиент | оба |
| Транспорт | обычный HTTP | обычный HTTP | свой протокол после Upgrade |
| Переподключение | само по природе | встроено (EventSource) | руками / библиотекой |
| Накладные расходы | высокие | низкие | минимальные |
| Когда брать | враждебная среда | уведомления, ленты, прогресс | чат, игры, совместное редактирование |
Правило выбора: нужен пуш от сервера — начните с SSE; нужен диалог (клиент часто пишет: чат, курсор в совместном документе, игры) — WebSocket; ничего не проходит — long polling.
Масштабирование: общая грабля
С постоянными соединениями ломается привычная модель «любой запрос — на любой экземпляр»: соединение живёт на конкретном сервере, а событие для клиента может родиться на другом. Стандартное решение — общая шина: экземпляры публикуют события в брокер или Redis Pub/Sub, и каждый доставляет их своим подключённым клиентам. Плюс придётся подружить балансировщик с длинными соединениями: таймауты простоя, при необходимости — привязка клиента к экземпляру.
И помните про устойчивость: любое постоянное соединение рвётся — вопрос не «если», а «когда»; переподключение и дочитывание пропущенного проектируются с первого дня.
Коротко
- Long polling — запрос, который ждёт события; работает везде, но дорог и с щелями между циклами — запасной вариант.
- SSE — односторонний поток событий поверх обычного HTTP со встроенным переподключением; лучший старт для «сервер уведомляет клиента».
- WebSocket — двунаправленный канал с минимальными накладными расходами; берут для диалоговых сценариев, платят своей обвязкой переподключений и доставки.
- Масштабирование постоянных соединений — через общую шину событий между экземплярами; разрывы и дочитывание — часть дизайна, не аварийный случай.
Что почитать дальше
- HTTP: как устроен протокол — фундамент, поверх которого всё это работает.
- Балансировка нагрузки — длинные соединения и балансировщик.
- Надёжность сети — таймауты, повторы и жизнь с разрывами.