三種機制的本質差異

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 沒這個問題——連線建好後持續用同一條。


延伸閱讀