Go WebSocket即時聊天實戰:從零構建可擴展訊息伺服器的5個關鍵架構
技术架构
Go WebSocket即時聊天:為什麼2026年你還在用輪詢
還在用HTTP輪詢實現即時訊息?每秒發100次請求,伺服器扛不住,訊息延遲3-5秒?WebSocket是全雙工即時通訊的標準協定,Go語言的goroutine模型天然適合高併發WebSocket服務。2026年,gorilla/websocket和nhooyr.io/websocket兩大庫已經成熟,配合Redis Pub/Sub和Kafka,可以輕鬆構建支援10萬+線上連線的即時聊天系統。
本文將從5個關鍵架構出發,帶你完成連線管理→房間模型→訊息廣播→持久化儲存→水平擴展的全鏈路實戰。
核心概念
| 概念 | 說明 |
|---|---|
| WebSocket | 全雙工持久連線協定,RFC 6455 |
| Hub | 連線管理中心,維護所有客戶端 |
| Room | 聊天房間,訊息廣播的隔離單元 |
| Client | WebSocket客戶端連線封裝 |
| Pub/Sub | 發布訂閱模式,用於跨程序訊息分發 |
| Backpressure | 背壓控制,防止慢客戶端拖垮服務 |
| Heartbeat | 心跳偵測,及時發現斷開連線 |
| Message Queue | 訊息佇列,保證訊息可靠投遞 |
問題分析:WebSocket聊天的5大挑戰
- 連線管理:10萬+連線的記憶體和goroutine開銷
- 訊息廣播:一條訊息需要推送給房間內所有線上使用者
- 慢客戶端:某個客戶端消費慢導致服務端記憶體溢位
- 水平擴展:單機無法承載,需要多實例協同
- 訊息持久化:離線使用者上線後需要取得歷史訊息
分步實操:5個WebSocket聊天核心架構
架構1:Hub-Client連線管理模型
// internal/chat/client.go
package chat
import (
"encoding/json"
"log"
"time"
"github.com/gorilla/websocket"
)
const (
writeWait = 10 * time.Second
pongWait = 60 * time.Second
pingPeriod = (pongWait * 9) / 10
maxMessageSize = 512
)
type Client struct {
hub *Hub
conn *websocket.Conn
send chan []byte
userID string
username string
rooms map[string]*Room
}
func NewClient(hub *Hub, conn *websocket.Conn, userID, username string) *Client {
return &Client{
hub: hub,
conn: conn,
send: make(chan []byte, 256),
userID: userID,
username: username,
rooms: make(map[string]*Room),
}
}
func (c *Client) ReadPump() {
defer func() {
c.hub.unregister <- c
c.conn.Close()
}()
c.conn.SetReadLimit(maxMessageSize)
c.conn.SetReadDeadline(time.Now().Add(pongWait))
c.conn.SetPongHandler(func(string) error {
c.conn.SetReadDeadline(time.Now().Add(pongWait))
return nil
})
for {
_, message, err := c.conn.ReadMessage()
if err != nil {
if websocket.IsUnexpectedCloseError(err, websocket.CloseGoingAway, websocket.CloseAbnormalClosure) {
log.Printf("read error: %v", err)
}
break
}
var msg Message
if err := json.Unmarshal(message, &msg); err != nil {
log.Printf("invalid message: %v", err)
continue
}
msg.Sender = c.userID
msg.Timestamp = time.Now().UnixMilli()
c.hub.HandleMessage(c, &msg)
}
}
func (c *Client) WritePump() {
ticker := time.NewTicker(pingPeriod)
defer func() {
ticker.Stop()
c.conn.Close()
}()
for {
select {
case message, ok := <-c.send:
c.conn.SetWriteDeadline(time.Now().Add(writeWait))
if !ok {
c.conn.WriteMessage(websocket.CloseMessage, []byte{})
return
}
w, err := c.conn.NextWriter(websocket.TextMessage)
if err != nil {
return
}
w.Write(message)
n := len(c.send)
for i := 0; i < n; i++ {
w.Write([]byte{'\n'})
w.Write(<-c.send)
}
if err := w.Close(); err != nil {
return
}
case <-ticker.C:
c.conn.SetWriteDeadline(time.Now().Add(writeWait))
if err := c.conn.WriteMessage(websocket.PingMessage, nil); err != nil {
return
}
}
}
}
// internal/chat/hub.go
package chat
import (
"log"
"sync"
)
type Hub struct {
clients map[*Client]bool
rooms map[string]*Room
register chan *Client
unregister chan *Client
mu sync.RWMutex
}
func NewHub() *Hub {
return &Hub{
clients: make(map[*Client]bool),
rooms: make(map[string]*Room),
register: make(chan *Client),
unregister: make(chan *Client),
}
}
func (h *Hub) Run() {
for {
select {
case client := <-h.register:
h.mu.Lock()
h.clients[client] = true
h.mu.Unlock()
log.Printf("client connected: %s (total: %d)", client.userID, len(h.clients))
case client := <-h.unregister:
h.mu.Lock()
if _, ok := h.clients[client]; ok {
delete(h.clients, client)
close(client.send)
for _, room := range client.rooms {
room.Leave(client)
}
}
h.mu.Unlock()
log.Printf("client disconnected: %s (total: %d)", client.userID, len(h.clients))
}
}
}
func (h *Hub) GetOrCreateRoom(roomID string) *Room {
h.mu.Lock()
defer h.mu.Unlock()
if room, ok := h.rooms[roomID]; ok {
return room
}
room := NewRoom(roomID)
h.rooms[roomID] = room
go room.Run()
log.Printf("room created: %s", roomID)
return room
}
func (h *Hub) HandleMessage(client *Client, msg *Message) {
switch msg.Type {
case "join":
room := h.GetOrCreateRoom(msg.RoomID)
room.Join(client)
case "leave":
if room, ok := h.rooms[msg.RoomID]; ok {
room.Leave(client)
}
case "chat":
if room, ok := h.rooms[msg.RoomID]; ok {
room.Broadcast(msg)
}
}
}
架構2:Room聊天房間與訊息廣播
// internal/chat/room.go
package chat
import (
"encoding/json"
"log"
"sync"
)
type Room struct {
ID string
clients map[*Client]bool
forward chan *Message
join chan *Client
leave chan *Client
mu sync.RWMutex
}
type Message struct {
Type string `json:"type"`
RoomID string `json:"roomId"`
Sender string `json:"sender"`
Content string `json:"content"`
Timestamp int64 `json:"timestamp"`
}
func NewRoom(id string) *Room {
return &Room{
ID: id,
clients: make(map[*Client]bool),
forward: make(chan *Message, 100),
join: make(chan *Client),
leave: make(chan *Client),
}
}
func (r *Room) Run() {
for {
select {
case client := <-r.join:
r.mu.Lock()
r.clients[client] = true
r.mu.Unlock()
r.broadcastSystemMsg(client.username + " joined the room")
case client := <-r.leave:
r.mu.Lock()
if _, ok := r.clients[client]; ok {
delete(r.clients, client)
}
r.mu.Unlock()
r.broadcastSystemMsg(client.username + " left the room")
case msg := <-r.forward:
r.mu.RLock()
data, _ := json.Marshal(msg)
for client := range r.clients {
select {
case client.send <- data:
default:
r.mu.RUnlock()
r.mu.Lock()
delete(r.clients, client)
close(client.send)
r.mu.Unlock()
r.mu.RLock()
}
}
r.mu.RUnlock()
}
}
}
func (r *Room) Join(client *Client) {
r.join <- client
client.rooms[r.ID] = r
}
func (r *Room) Leave(client *Client) {
r.leave <- client
delete(client.rooms, r.ID)
}
func (r *Room) Broadcast(msg *Message) {
r.forward <- msg
}
func (r *Room) broadcastSystemMsg(content string) {
msg := &Message{
Type: "system",
RoomID: r.ID,
Content: content,
Timestamp: time.Now().UnixMilli(),
}
r.forward <- msg
}
架構3:HTTP升級與WebSocket服務端
// internal/chat/server.go
package chat
import (
"log"
"net/http"
"github.com/gorilla/websocket"
)
var upgrader = websocket.Upgrader{
ReadBufferSize: 1024,
WriteBufferSize: 1024,
CheckOrigin: func(r *http.Request) bool {
return true
},
}
type Server struct {
hub *Hub
}
func NewServer(hub *Hub) *Server {
return &Server{hub: hub}
}
func (s *Server) ServeWS(w http.ResponseWriter, r *http.Request) {
userID := r.URL.Query().Get("userId")
username := r.URL.Query().Get("username")
if userID == "" || username == "" {
http.Error(w, "userId and username required", http.StatusBadRequest)
return
}
conn, err := upgrader.Upgrade(w, r, nil)
if err != nil {
log.Printf("upgrade error: %v", err)
return
}
client := NewClient(s.hub, conn, userID, username)
s.hub.register <- client
go client.WritePump()
go client.ReadPump()
}
架構4:Redis Pub/Sub跨實例訊息分發
// internal/chat/redis_pubsub.go
package chat
import (
"context"
"encoding/json"
"log"
"github.com/redis/go-redis/v9"
)
type RedisBroker struct {
client *redis.Client
hub *Hub
channel string
}
func NewRedisBroker(addr, channel string, hub *Hub) *RedisBroker {
rdb := redis.NewClient(&redis.Options{
Addr: addr,
PoolSize: 10,
})
return &RedisBroker{
client: rdb,
hub: hub,
channel: channel,
}
}
func (rb *RedisBroker) Start(ctx context.Context) error {
sub := rb.client.Subscribe(ctx, rb.channel)
_, err := sub.Receive(ctx)
if err != nil {
return err
}
ch := sub.Channel()
go func() {
for msg := range ch {
var m Message
if err := json.Unmarshal([]byte(msg.Payload), &m); err != nil {
log.Printf("redis message parse error: %v", err)
continue
}
if room, ok := rb.hub.rooms[m.RoomID]; ok {
room.forward <- &m
}
}
}()
return nil
}
func (rb *RedisBroker) Publish(ctx context.Context, msg *Message) error {
data, err := json.Marshal(msg)
if err != nil {
return err
}
return rb.client.Publish(ctx, rb.channel, data).Err()
}
架構5:訊息持久化與歷史查詢
// internal/chat/repository.go
package chat
import (
"context"
"database/sql"
"fmt"
_ "github.com/lib/pq"
)
type MessageRepository struct {
db *sql.DB
}
func NewMessageRepository(databaseURL string) (*MessageRepository, error) {
db, err := sql.Open("postgres", databaseURL)
if err != nil {
return nil, fmt.Errorf("failed to open database: %w", err)
}
return &MessageRepository{db: db}, nil
}
func (r *MessageRepository) Save(ctx context.Context, msg *Message) error {
_, err := r.db.ExecContext(ctx,
`INSERT INTO chat_messages (room_id, sender, content, type, timestamp)
VALUES ($1, $2, $3, $4, $5)`,
msg.RoomID, msg.Sender, msg.Content, msg.Type, msg.Timestamp,
)
return err
}
func (r *MessageRepository) GetHistory(ctx context.Context, roomID string, limit, offset int) ([]*Message, error) {
rows, err := r.db.QueryContext(ctx,
`SELECT room_id, sender, content, type, timestamp
FROM chat_messages
WHERE room_id = $1
ORDER BY timestamp DESC
LIMIT $2 OFFSET $3`,
roomID, limit, offset,
)
if err != nil {
return nil, err
}
defer rows.Close()
var messages []*Message
for rows.Next() {
msg := &Message{}
if err := rows.Scan(&msg.RoomID, &msg.Sender, &msg.Content, &msg.Type, &msg.Timestamp); err != nil {
return nil, err
}
messages = append(messages, msg)
}
return messages, nil
}
避坑指南
坑1:send channel緩衝區溢位
// ❌ 錯誤:無緩衝channel,慢客戶端阻塞廣播
send chan []byte
// ✅ 正確:帶緩衝channel + 溢位踢出
send chan []byte // buffered channel with 256 capacity
select {
case client.send <- data:
default:
delete(r.clients, client)
close(client.send)
}
坑2:缺少心跳偵測
// ❌ 錯誤:不設定ping/pong,無法偵測斷開連線
c.conn.SetReadDeadline(time.Time{})
// ✅ 正確:設定ping/pong心跳
c.conn.SetReadDeadline(time.Now().Add(pongWait))
c.conn.SetPongHandler(func(string) error {
c.conn.SetReadDeadline(time.Now().Add(pongWait))
return nil
})
坑3:併發寫入WebSocket
// ❌ 錯誤:多個goroutine同時寫入
go func() { c.conn.WriteMessage(1, data) }()
go func() { c.conn.WriteMessage(1, data2) }()
// ✅ 正確:透過channel串行化寫入
c.send <- data
坑4:未處理Close訊息
// ❌ 錯誤:忽略關閉幀
_, message, err := c.conn.ReadMessage()
// ✅ 正確:區分正常關閉和異常
if err != nil {
if websocket.IsUnexpectedCloseError(err, websocket.CloseGoingAway) {
log.Printf("abnormal close: %v", err)
}
break
}
坑5:Redis Pub/Sub訊息遺失
// ❌ 錯誤:先廣播再持久化,Redis斷連時訊息遺失
room.Broadcast(msg)
redisBroker.Publish(ctx, msg)
// ✅ 正確:先持久化再廣播
msgRepo.Save(ctx, msg)
room.Broadcast(msg)
redisBroker.Publish(ctx, msg)
報錯排查
| 序號 | 報錯資訊 | 原因 | 解決方法 |
|---|---|---|---|
| 1 | websocket: close sent |
連線已關閉 | 檢查客戶端是否斷開 |
| 2 | write: broken pipe |
寫入已關閉連線 | WritePump中處理close channel |
| 3 | read: connection reset |
客戶端異常斷開 | ReadPump中defer清理 |
| 4 | upgrade: Origin check failed |
CORS檢查失敗 | 設定upgrader.CheckOrigin |
| 5 | concurrent write to websocket |
併發寫入 | 使用channel串行化 |
| 6 | channel full |
send緩衝區滿 | 增大緩衝或踢出慢客戶端 |
| 7 | redis: connection refused |
Redis未啟動 | 檢查Redis連線設定 |
| 8 | OOM killed |
記憶體溢位 | 限制單機連線數和訊息緩衝 |
| 9 | too many open files |
檔案描述符耗盡 | 調大ulimit -n |
| 10 | ping timeout |
心跳逾時 | 調整pongWait時間 |
進階最佳化
- 連線限流:使用令牌桶限制單IP連線速率,防止惡意連線攻擊
- 訊息壓縮:使用permessage-deflate擴充套件壓縮WebSocket幀,減少頻寬
- 分片廣播:大房間按連線分片廣播,減少單goroutine壓力
- 訊息ACK:實現訊息確認機制,保證訊息至少投遞一次
- 連線遷移:使用Redis記錄連線對映,支援服務端重啟後客戶端重連
對比分析
| 維度 | gorilla/websocket | nhooyr.io/websocket | gobwas/ws |
|---|---|---|---|
| API易用性 | ⭐⭐⭐⭐ | ⭐⭐⭐⭐⭐ | ⭐⭐⭐ |
| 效能 | ⭐⭐⭐⭐ | ⭐⭐⭐⭐ | ⭐⭐⭐⭐⭐ |
| 記憶體佔用 | ⭐⭐⭐ | ⭐⭐⭐⭐ | ⭐⭐⭐⭐⭐ |
| Context支援 | ⭐⭐ | ⭐⭐⭐⭐⭐ | ⭐⭐⭐ |
| 壓縮支援 | ⭐⭐⭐⭐ | ⭐⭐⭐⭐⭐ | ⭐⭐⭐⭐ |
| 社群活躍度 | ⭐⭐⭐⭐⭐ | ⭐⭐⭐⭐ | ⭐⭐⭐ |
| 文件品質 | ⭐⭐⭐⭐ | ⭐⭐⭐⭐⭐ | ⭐⭐⭐ |
總結:Go的goroutine模型是構建高併發WebSocket服務的天然優勢。Hub-Client連線管理→Room訊息廣播→Redis Pub/Sub跨實例→訊息持久化→水平擴展五位一體,讓你從零構建支援10萬+線上的即時聊天系統。核心要點:串行化寫入、心跳偵測、背壓控制、先持久化再廣播。
線上工具推薦
- JSON格式化:/zh-TW/json/format
- Hash計算:/zh-TW/encode/hash
- cURL轉程式碼:/zh-TW/dev/curl-to-code
本站提供瀏覽器本地工具,免註冊即可試用 →
#Go WebSocket#实时聊天#gorilla/websocket#Go实时通信#2026#技术架构