24. Long polling
Long Polling в Go: архитектура длинного опроса и выносливость рантайма
Исторический хак, ставший индустриальным стандартом
«Управление сложностью — вот сама суть программирования».
— Брайан Керниган
В проектировании распределенных систем существует жесткий закон реальности: самое изящное теоретическое решение не стоит ничего, если оно разбивается о суровую физику корпоративной инфраструктуры. В предыдущих статьях мы восхищались полнодуплексной мощью 22. WebSocket и потоковой простотой 23. Server Sent Events. Однако обе эти технологии требуют непрерывной поддержки постоянных долгоживущих соединений от всех промежуточных узлов сети.
В реальном мире Enterprise-бэкенда вы неизбежно столкнетесь с инфраструктурным адом: жесткими корпоративными прокси-серверами (Squid), древними аппаратными файрволами, агрессивными сотовыми операторами и системами глубокой инспекции пакетов (DPI). Они безжалостно обрывают любые подозрительные TCP-соединения, если по ним не прокачиваются байты в течение 10–30 секунд, или блокируют протоколы, отличные от классического HTTP GET/POST. В таких условиях WebSocket и SSE просто отказываются работать.
Чтобы обеспечить надежную доставку событий в реальном времени даже в самой враждебной сетевой среде, инженеры придумали Long Polling (Длинный опрос). Это не отдельный протокол, а остроумный архитектурный паттерн поверх стандартного, короткоживущего HTTP/1.1 (и HTTP/2). До появления стандартизированных сокетов на нем держался весь интерактивный интернет (ранние чаты Facebook, ВКонтакте), а сегодня Long Polling является признанным индустриальным стандартом для надежных межсерверных шлюзов и бот-платформ (самый яркий пример — Telegram Bot API).
1. Short Polling vs Long Polling
Чтобы оценить инженерную красоту Long Polling, полезно взглянуть на его наивного предшественника — Short Polling (Короткий опрос).
Представьте человека, ожидающего важную телеграмму на почте. В парадигме Short Polling клиент каждые две секунды стучит в окошко оператора:
«Письмо пришло? Нет. А сейчас? Нет. А сейчас? Нет. А сейчас? Да, вот оно!»
С точки зрения кремния процессора, сетевого стека и рантайма Go наивный Short Polling — это ресурсоемкая катастрофа:
- Трата CPU и аллокации памяти: Сервер непрерывно парсит заголовки HTTP-пакетов, создает тысячи экземпляров структуры
http.Request, выполняет маршрутизацию и выделяет память в куче (Heap). - Перегрузка хранилища (Database Churn): База данных бомбардируется сотнями тысяч пустых
SELECT-запросов в секунду, которые лишь подтверждают отсутствие новостей. - Сетевой мусор: Сеть забивается миллионами TCP-сегментов и TLS-хэндшейков ради передачи 99.9% пустых ответов
[]илиnull.
flowchart TD
subgraph SP ["Short Polling (Бессмысленная бомбардировка)"]
direction TB
C1["Клиент"] -->|"GET /updates (каждые 2 сек)"| S1["Go Сервер"]
S1 -->|"HTTP 200 OK: [] (Пусто)"| C1
C1 -->|"GET /updates"| S1
S1 -->|"HTTP 200 OK: [] (Пусто)"| C1
end
subgraph LP ["Long Polling (Элегантное зависание)"]
direction TB
C2["Клиент"] -->|"GET /poll (Ожидание до 50s)"| S2["Go Сервер"]
S2 -.->|"Данных нет: Горутина засыпает"| W["Ожидание в runtime.gopark"]
Ev["Событие: Новое сообщение"] ==>|"Пробуждение через канал"| W
W -->|"HTTP 200 OK: {data}"| C2
C2 -->|"Мгновенный новый GET /poll"| S2
end
style SP fill:#1e293b,stroke:#f43f5e,stroke-width:1px,color:#f8fafc
style LP fill:#1e293b,stroke:#10b981,stroke-width:2px,color:#f8fafc
Принцип работы Long Polling
В Long Polling клиент отправляет стандартный HTTP GET-запрос. Если у сервера прямо сейчас нет новых данных для клиента, сервер не спешит возвращать пустой ответ. Он «подвешивает» HTTP-запрос (удерживая TCP-сокет открытым) и переводит обработчик в состояние ожидания.
Как только в системе генерируется событие (пришло сообщение, изменился статус заказа), сервер мгновенно записывает ответ в сокет и корректно завершает HTTP-запрос статусом 200 OK. Клиент принимает данные, мгновенно запускает их обработку в UI и тут же инициирует следующий Long Polling запрос. Если же за отведенный таймаут (например, 50 секунд) ничего не произошло, сервер возвращает статус 204 No Content (или пустой 200 OK), и клиент немедленно переподключается.
2. Mechanical Sympathy: Цена спящей горутины
Что происходит внутри операционной системы и рантайма Go, когда 50 000 HTTP-соединений одновременно переходят в режим ожидания?
В асинхронных платформах вроде Node.js такой паттерн реализуется почти бесплатно за счет единого цикла событий (Event Loop): дескрипторы сокетов регистрируются в системном селекторе ядра (epoll в Linux), и процесс не расходует память на стек, пока не сработает коллбэк.
В Go принята иная парадигма — Goroutine-per-Connection. Для каждого входящего TCP-соединения net/http запускает отдельную изолированную горутину. Если 50 000 клиентов висят в ожидании Long Polling, рантайм Go держит в памяти 50 000 параллельных горутин.
Насколько это тяжело для сервера?
- Минимальный стек горутины: В Go начальный размер стека горутины составляет всего 2 КБ (в отличие от потоков ОС в Java/C++, где тред требует от 1 до 8 МБ).
- Парковка горутины (
runtime.gopark): Когда горутина блокируется на операции чтения из канала или ожиданииselect, рантайм Go переводит её в статус_Gwaitingи вызывает функциюgopark. Планировщик Go (Scheduler) мгновенно снимает её с потока ядра ОС (M). Спящая горутина потребляет ровно 0% процессорного времени (CPU). Поток ядра M свободен и выполняет полезную работу для других задач. - Накладные расходы на структуры соединения: Сама горутина занимает 2 КБ, но структуры
http.Server, буферы чтения/записиbufioи контекст запроса суммарно аллоцируют от 10 до 20 КБ на каждое открытое клиентское соединение. - Реальный расчет памяти:
$$50\,000 \times 20\text{ КБ} \approx 1\text{ ГБ RAM}$$
Для современного сервера 1 ГБ оперативной памяти под 50 000 одновременных пользователей реального времени — это пренебрежимо малая величина. Рантайм Go справляется с такой нагрузкой играючи.
3. Идиоматичный Go: Реализация Long Polling
Ключевое инженерное требование к Long Polling: HTTP-запрос никогда не должен висеть вечно.
Если ваш Go-код зависнет в ожидании события навсегда, внешняя инфраструктура разорвет соединение за вас. Обратный прокси (Nginx) или балансировщик нагрузки (AWS ALB, Cloudflare) имеют жесткие таймауты ожидания ответа от бэкенда (proxy_read_timeout, обычно 60 секунд). Если бэкенд промолчит 60 секунд, балансировщик сочтет его мертвым, аварийно оборвет TCP-канал и вернет клиенту ошибку 504 Gateway Timeout. Фронтенд воспримет это как критический сбой сети.
Идиоматичный Go-обработчик обязан превентивно завершить ожидание ДО срабатывания таймаута инфраструктуры (например, через 50 секунд при лимите Nginx в 60 секунд), вернуть аккуратный статус 204 No Content и мягко предложить клиенту открыть новый цикл опроса.
Реализация безопасного Long-Polling хэндлера:
package main
import (
"encoding/json"
"log"
"math/rand/v2"
"net/http"
"time"
)
// Event представляет сообщение для клиента
type Event struct {
ID int64 `json:"id"`
Payload string `json:"payload"`
CreatedAt int64 `json:"created_at"`
}
// PubSubBroker эмулирует интерфейс шины сообщений
type PubSubBroker interface {
Subscribe(userID string) <-chan Event
Unsubscribe(userID string, ch <-chan Event)
GetEventsAfter(userID string, lastID int64) []Event
}
func LongPollHandler(pubsub PubSubBroker) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
userID := r.PathValue("user_id")
if userID == "" {
http.Error(w, "missing user_id", http.StatusBadRequest)
return
}
// 1. Проверяем наличие пропущенных сообщений (курсор last_event_id)
// Если клиент отстал, мы отдаем данные немедленно без засыпания!
var lastEventID int64
if val := r.URL.Query().Get("last_event_id"); val != "" {
// В реальном коде: strconv.ParseInt
lastEventID = 100
}
missedEvents := pubsub.GetEventsAfter(userID, lastEventID)
if len(missedEvents) > 0 {
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(http.StatusOK)
json.NewEncoder(w).Encode(missedEvents)
return
}
// 2. Подписываемся на новые события пользователя в шине Pub/Sub
eventChan := pubsub.Subscribe(userID)
defer pubsub.Unsubscribe(userID, eventChan)
// 3. Устанавливаем таймер ДО таймаута Nginx (базовые 50s)
// КРИТИЧЕСКИ ВАЖНО: Добавляем Jitter (рандомизацию), чтобы избежать Thundering Herd!
jitterMs := rand.IntN(5000) // от 0 до 4999 мс
timeoutDuration := 50*time.Second + time.Duration(jitterMs)*time.Millisecond
timeout := time.NewTimer(timeoutDuration)
defer timeout.Stop()
// 4. Блокирующий select на каналах
select {
case event := <-eventChan:
// Сценарий А: Появились новые данные
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(http.StatusOK)
json.NewEncoder(w).Encode([]Event{event})
return
case <-timeout.C:
// Сценарий Б: Таймаут ожидания. Мягко просим переподключиться
w.WriteHeader(http.StatusNoContent) // 204 No Content
return
case <-r.Context().Done():
// Сценарий В: Клиент оборвал связь (закрыл вкладку, сбой Wi-Fi)
// Netpoller Go перехватывает TCP FIN/RST и отменяет контекст запроса
log.Printf("Клиент %s отключился: %v", userID, r.Context().Err())
return
}
}
}
4. Ловушки и корнер-кейсы (Gotchas)
Проблема «Стада бизонов» (Thundering Herd) и спасительный Jitter
Обратите внимание на вычисление интервала таймера с добавлением jitterMs:
timeoutDuration := 50*time.Second + time.Duration(rand.IntN(5000))*time.Millisecond
5. Окно потери данных (The Reconnect Window)
Самый коварный архитектурный дефект наивной реализации Long Polling кроется в сетевом зазоре между последовательными HTTP-запросами.
Посмотрим на хронологию жизненного цикла соединения:
- Сервер возвращает клиенту ответ (событие или таймаут 204).
- HTTP-пакет летит по сети клиенту ($\approx 50$ мс).
- Браузер парсит ответ и формирует новый HTTP-запрос ($\approx 10$ мс).
- Новый запрос летит по сети к серверу ($\approx 50$ мс).
Суммарно возникает «слепое окно» (The Reconnect Window) длительностью около 110 мс. В этот интервал клиент физически не подключен к серверу. Если в эту десятую долю секунды бизнес-система попытается отправить клиенту срочное уведомление, а подписка в бэкенде была эфемерной (fire-and-forget), сообщение растворится в воздухе.
sequenceDiagram
autonumber
actor Client as Клиент (Браузер)
participant Server as Go Сервер
participant Bus as Шина Событий (Redis / Kafka)
Client->>Server: GET /poll (Запрос 1)
Server-->>Client: 204 No Content (Таймаут 50с)
Note over Client,Server: СЛЕПОЕ ОКНО РЕКОННЕКТА (~110 мс)
Note over Client: Клиент не подключен к серверу!
Bus->>Server: Новое событие для юзера: "ID: 42"
Server--xServer: Горутина подписчика отсутствует! Сообщение потеряно!
Client->>Server: GET /poll (Запрос 2)
Note over Server: Сервер засыпает, клиент пропустил событие 42!
6. Long Polling в распределенной системе
В многонодовом кластере (Kubernetes) клиентские Long Polling запросы распределяются балансировщиком между множеством реплик приложения (подов).
Если HTTP-запрос от пользователя $A$ «завис» на Pod-1, а транзакция оплаты пользователя завершилась в воркере на Pod-2, возникает классическая задача межсервисной маршрутизации. Pod-2 ничего не знает о TCP-сокете на Pod-1.
Для координации используется распределенная шина сообщений:
- Redis Pub/Sub или NATS Core для мгновенной доставки событий между подами.
- Pod-2 после сохранения изменений в БД публикует команду:
PUBLISH user_123_events "payment_success". - Pod-1, подписанный на канал пользователя, принимает событие из Redis/NATS, отправляет его в локальный Go-канал
eventChan, горутина мгновенно просыпается и отдает клиентуHTTP 200 OK.
flowchart LR
Client["Клиент"] -->|"GET /poll (Ожидание)"| Pod1["Go Pod-1 (Держит сокет)"]
Worker["Фоновый воркер (Pod-2)"] -->|"1. PUBLISH user_events"| Bus[("Redis Pub/Sub / NATS")]
Bus -->|"2. Доставка события"| Pod1
Pod1 -->|"3. HTTP 200 OK"| Client
style Pod1 fill:#1e293b,stroke:#38bdf8,stroke-width:1px,color:#f8fafc
style Bus fill:#1e293b,stroke:#f59e0b,stroke-width:2px,color:#f8fafc
7. Итог
- Long Polling — самый надежный и инфраструктурно неприхотливый способ эмуляции Real-Time. Он гарантированно проходит сквозь любые прокси, VPN, сотовые вышки и строгие Enterprise-файрволы, поскольку выглядит как обычный короткий HTTP
GET. - Механика Go: Тысячи ожидающих запросов — это тысячи спящих горутин, выведенных планировщиком из очередей CPU (
runtime.gopark). Они расходуют лишь небольшое количество оперативной памяти (Heap), не нагружая процессор. - Управление таймаутами и Jitter: Бэкенд обязан прерывать опрос самостоятельно (статус 204), опережая таймаут Nginx/ALB. Использование случайного разброса (Jitter) предотвращает катастрофический резонанс нагрузки (Thundering Herd).
- Слепое окно и курсоры: Для предотвращения потери данных в зазоре реконнекта архитектура обязана опираться на монотонные курсоры (
last_event_id) и персистентное хранилище событий.
Мы завершили детальный разбор всех ключевых сетевых интерфейсов и протоколов (REST, gRPC, GraphQL, WebSocket, SSE, Long Polling). Теперь мы понимаем, как сервисы взаимодействуют с внешним миром. Но прежде чем этот многоликий поток внешнего трафика достигнет бизнес-логики сервисов, он обязан пройти через единый защитный барьер инфраструктуры. О том, как вынести сквозную авторизацию, лимиты трафика, SSL-терминацию и умный роутинг на периметр кластера, мы поговорим в следующей статье: 25. API Gateway.