三種機制的本質差異
HTTP(傳統 Request-Response):client 問,server 答,連線結束。
Long Polling:client 送 request,server 不立刻回答,等有新資料再回傳。client 收到後立刻再送下一個 request。假裝是 push,其實還是輪詢。
SSE(Server-Sent Events):建立一條單向的長連線,server 可以隨時向 client 推送訊息。client 不能反向推。HTTP/1.1 就支援。
WebSocket:建立雙向的長連線。client 和 server 都可以隨時推訊息。需要 protocol upgrade(ws://)。
怎麼選
server 推 → client(單向)? → SSE
client 也需要推 → server(雙向)? → WebSocket
推送頻率低(每分鐘幾次)? → Long Polling 或 SSE 都 OK
需要支援很舊的瀏覽器 / 代理? → Long Polling(最相容)
聊天室 / 協作編輯 / 遊戲? → WebSocket
訂單狀態、報表進度、通知? → SSE 夠了
SSE 被低估:大部分「即時通知」場景(訂單狀態、進度條、server 推播)都是單向的——server 推,client 聽。SSE 比 WebSocket 簡單太多,HTTP/2 下還能 multiplexing,不需要引入 WebSocket 的複雜性。
SSE 實作
// Express SSE endpoint
router.get('/events/order-status/:orderId', authenticate, async (req, res) => {
const { orderId } = req.params;
// SSE 必要的 headers
res.setHeader('Content-Type', 'text/event-stream');
res.setHeader('Cache-Control', 'no-cache');
res.setHeader('Connection', 'keep-alive');
res.setHeader('X-Accel-Buffering', 'no'); // 關掉 Nginx buffering
res.flushHeaders();
// 送初始狀態
const order = await orderService.findById(orderId);
res.write(`data: ${JSON.stringify({ status: order.status })}\n\n`);
// 訂閱狀態變更
const unsubscribe = eventBus.subscribe(`order:${orderId}:status`, (data) => {
res.write(`data: ${JSON.stringify(data)}\n\n`);
if (data.status === 'completed' || data.status === 'failed') {
res.end();
unsubscribe();
}
});
// client 斷線時清理
req.on('close', () => {
unsubscribe();
});
});SSE 的格式:
data: {"status":"processing"}\n\n ← 一般資料
event: order-update\ndata: {...}\n\n ← 帶 event type
id: 123\ndata: {...}\n\n ← 帶 ID(client 可用 Last-Event-ID 斷線重連)
: heartbeat\n\n ← comment(保持連線活著)
Heartbeat(防止 proxy / load balancer 切掉閒置連線):
const heartbeat = setInterval(() => {
res.write(': heartbeat\n\n');
}, 30000);
req.on('close', () => {
clearInterval(heartbeat);
unsubscribe();
});WebSocket 實作
import { WebSocketServer } from 'ws';
import http from 'http';
const server = http.createServer(app);
const wss = new WebSocketServer({ server });
// 管理所有連線(用 Map 方便查找)
const connections = new Map<string, Set<WebSocket>>();
wss.on('connection', (ws, req) => {
// 從 upgrade request 拿 auth token
const token = new URL(req.url!, `http://localhost`).searchParams.get('token');
const user = verifyToken(token);
if (!user) {
ws.close(4001, 'Unauthorized');
return;
}
// 記錄用戶的連線(一個用戶可能多個 tab)
if (!connections.has(user.id)) connections.set(user.id, new Set());
connections.get(user.id)!.add(ws);
ws.on('message', (data) => {
const message = JSON.parse(data.toString());
handleMessage(ws, user, message);
});
ws.on('close', () => {
connections.get(user.id)?.delete(ws);
});
ws.on('error', (error) => {
logger.error('WebSocket error', { userId: user.id, error: error.message });
});
// Ping-Pong 保持連線
const pingInterval = setInterval(() => {
if (ws.readyState === ws.OPEN) ws.ping();
}, 30000);
ws.on('close', () => clearInterval(pingInterval));
});
// 推送給特定用戶的所有連線
function pushToUser(userId: string, data: unknown) {
const userConnections = connections.get(userId);
if (!userConnections) return;
const payload = JSON.stringify(data);
for (const ws of userConnections) {
if (ws.readyState === ws.OPEN) {
ws.send(payload);
}
}
}多實例 / 水平擴展問題
單機時,一個 WebSocket 連線在一個 server 上——推送很簡單。多實例後:
用戶 A 連到 Pod 1
用戶 B 連到 Pod 2
Pod 2 想推訊息給用戶 A → 但 A 的連線在 Pod 1 上
解法:Redis Pub/Sub 當訊息 bus
import { Redis } from 'ioredis';
const pub = new Redis(process.env.REDIS_URL);
const sub = new Redis(process.env.REDIS_URL);
// 訂閱頻道
sub.subscribe('ws:broadcast');
sub.on('message', (channel, message) => {
const { userId, data } = JSON.parse(message);
// 把訊息推給本機連到這個 pod 的用戶
pushToUser(userId, data);
});
// 推送時不管用戶在哪個 pod,發到 Redis
async function broadcastToUser(userId: string, data: unknown) {
await pub.publish('ws:broadcast', JSON.stringify({ userId, data }));
}每個 pod 都訂閱同一個 Redis channel,收到訊息後只推給「連在自己這個 pod」的用戶。
房間 / 頻道設計(聊天室場景)
// 用 Map<roomId, Set<WebSocket>> 管理房間成員
const rooms = new Map<string, Set<WebSocket>>();
function joinRoom(roomId: string, ws: WebSocket) {
if (!rooms.has(roomId)) rooms.set(roomId, new Set());
rooms.get(roomId)!.add(ws);
}
function leaveRoom(roomId: string, ws: WebSocket) {
rooms.get(roomId)?.delete(ws);
}
function broadcastToRoom(roomId: string, data: unknown, exclude?: WebSocket) {
const members = rooms.get(roomId);
if (!members) return;
const payload = JSON.stringify(data);
for (const ws of members) {
if (ws !== exclude && ws.readyState === ws.OPEN) {
ws.send(payload);
}
}
}
// 訊息處理
function handleMessage(ws: WebSocket, user: AuthUser, message: ClientMessage) {
switch (message.type) {
case 'join-room':
joinRoom(message.roomId, ws);
break;
case 'send-message':
broadcastToRoom(message.roomId, {
type: 'new-message',
from: user.name,
content: message.content,
timestamp: Date.now(),
}, ws); // exclude 自己(不需要回傳給自己)
break;
}
}Nginx 的 WebSocket 設定
WebSocket 需要 HTTP Upgrade,Nginx 預設不支援:
location /ws {
proxy_pass http://backend;
proxy_http_version 1.1;
proxy_set_header Upgrade $http_upgrade;
proxy_set_header Connection "upgrade";
proxy_set_header Host $host;
# WebSocket 連線不能太快 timeout
proxy_read_timeout 3600s;
proxy_send_timeout 3600s;
}Long Polling(簡單 fallback)
// 最簡單的 long polling:等到有新資料或 timeout
router.get('/notifications/poll', authenticate, async (req, res) => {
const since = parseInt(req.query.since as string) || Date.now();
const timeout = 30000; // 最多等 30 秒
const pollInterval = 500;
const deadline = Date.now() + timeout;
while (Date.now() < deadline) {
const notifications = await notificationService.getAfter(req.user.id, since);
if (notifications.length > 0) {
return res.json({ notifications, timestamp: Date.now() });
}
await sleep(pollInterval);
}
// 30 秒沒資料,讓 client 重連
res.json({ notifications: [], timestamp: Date.now() });
});Long polling 的問題:每次都要新 HTTP connection,overhead 大;server 要同時維持很多 hanging request。SSE 沒這個問題——連線建好後持續用同一條。
