WebSocket

和HTTP长连接的对比

协议层面

  • HTTP 长连接(Keep-Alive)
    • 仍然是 HTTP 协议(通常是 HTTP/1.1)。
    • 客户端和服务器之间的 TCP 连接可以被复用发送多个 HTTP 请求/响应,但每次仍然是“请求-响应”模型。
    • 每个请求/响应之间是独立的,不能实时推送数据。
  • WebSocket
    • 是一种独立的协议,从 HTTP 升级(Upgrade)而来(通过 HTTP 发起握手,但之后切换为 WebSocket 协议)。
    • 建立后,连接变成全双工通信,支持服务器主动推送数据给客户端。
    • 通信是基于帧的格式,比 HTTP 的开销小。

通信模式

  • HTTP 长连接
    • 客户端发送请求,服务器响应,仍是单向请求响应模型
    • 想要获得新数据,客户端需要轮询(定时发送请求)。
  • WebSocket
    • 双向通信,一旦连接建立,客户端和服务器可以随时互相发送数据。
    • 实现实时性需求(如聊天、游戏、股票推送)更高效。

性能与开销

  • HTTP 长连接
    • 每次请求仍需要携带完整的 HTTP 头部信息,有较高的冗余。
    • 适合请求频率低、实时性要求不高的场景。
  • WebSocket
    • 建立后帧格式非常小,通信效率更高。
    • 更适合频繁、实时通信的场景。

为什么需要Upgrade

参考 https://stackoverflow.com/questions/19568432/why-websocket-needs-an-opening-handshake-using-http-why-cant-it-be-an-independ

目前已经存在一整套庞大的 HTTP 和 HTTPS 基础设施(包括代理、防火墙、缓存及其他中间组件)。为了提高被广泛采用的可能性,WebSocket 协议被设计成可以在现有基础上进行调整和扩展,而不必为了支持一种新协议而从头构建一个专用端口的全新体系。

同样重要的是,即使 WebSocket 协议去掉了与 HTTP 兼容的握手,它仍然需要一个几乎同样复杂的握手过程,以满足现代 Web 的安全需求 —— 比如确保浏览器和服务器能够验证彼此的身份,以及安全地支持 CORS(跨域资源共享)。即便是“原始”的 Flash Socket,在建立实际连接之前,也会先通过向服务器发送安全策略请求来完成握手过程。

握手过程

  1. 客户端发起一个HTTP GET 请求,带上Upgrade 头部请求升级协议。
  2. 服务端识别请求,返回 101 Switching Protocols 响应,表示接受升级。
  3. 双方随后使用 WebSocket 协议通信(在同一个 TCP 连接上)。

客户端请求示例

GET /ws HTTP/1.1
Host: example.com
Upgrade: websocket
Connection: Upgrade
Sec-WebSocket-Key: x3JJHMbDL1EzLkh9GBhXDw==
Sec-WebSocket-Version: 13
Origin: http://example.com
Header 名称说明
Upgrade: websocket请求升级到 WebSocket 协议
Connection: Upgrade告诉服务器连接应该升级
Sec-WebSocket-Key客户端随机生成的 Base64 编码字符串,用于服务器响应时做签名(防伪)
Sec-WebSocket-VersionWebSocket 协议版本,当前为 13(标准)
Origin(可选)表示请求来源,用于安全校验,防止跨站攻击(CSRF)

服务端响应示例

HTTP/1.1 101 Switching Protocols
Upgrade: websocket
Connection: Upgrade
Sec-WebSocket-Accept: HSmrc0sMlYUkAGmm5OPpG2HaGWk=
Header 名称说明
101 Switching Protocols状态码:协议切换成功(来自 RFC 7231)
Upgrade: websocket确认已升级协议
Connection: Upgrade表示这是一个协议升级响应
Sec-WebSocket-Accept服务端对客户端 Sec-WebSocket-Key 做哈希 + Base64 后返回,防止伪造连接

Sec-WebSocket-Key

服务器必须向客户端证明它收到了客户端的 WebSocket 握手,以防服务器接受那些并非 WebSocket 的连接。这可以防止攻击者使用 XMLHttpRequest 或表单提交等方式伪装请求 WebSocket 服务器,从而欺骗服务器。

服务器会从客户端请求中的 Sec-WebSocket-Key 中获取值(即 base64 编码的字符串,去除前后空白),然后将其与一个固定的 GUID "258EAFA5-E914-47DA-95CA-C5AB0DC85B11" 拼接,并进行哈希处理。

这个 GUID 是一个全球唯一标识符,选择它是因为它极不可能被那些不了解 WebSocket 协议的网络端点使用,因此起到了标识“你懂 WebSocket”的作用。

还有一层作用是防止浏览器端通过 XMLHttpRequest.setRequestHeader 伪造 WebSocket 请求,因为 WebSocket 相关头(如 Sec-WebSocket-Key、Upgrade)是被浏览器安全策略禁止手动设置的

简单测试

使用Go作为后端,需要先安装依赖 go get [github.com/gorilla/websocket](http://github.com/gorilla/websocket)

package main
 
import (
	"fmt"
	"math/rand"
	"net/http"
	"time"
 
	"github.com/gorilla/websocket"
)
 
var upgrader = websocket.Upgrader{
	CheckOrigin: func(r *http.Request) bool {
		return true
	},
}
 
func serverPing(conn *websocket.Conn) {
	go func() {
		for {
			err := conn.WriteMessage(websocket.PingMessage, []byte("ping"))
			if err != nil {
				fmt.Println("Ping failed:", err)
				return
			}
			fmt.Println("Ping sent")
			time.Sleep(10 * time.Second)
		}
	}()
}
 
func getSessionID(r *http.Request) string {
	sessionID := r.URL.Query().Get("session_id")
	if sessionID == "" {
		rand.Seed(time.Now().UnixNano())
		sessionID = fmt.Sprintf("sess-%d", rand.Intn(1000000))
	}
	return sessionID
}
 
func handleWebSocket(w http.ResponseWriter, r *http.Request) {
	conn, err := upgrader.Upgrade(w, r, nil)
	if err != nil {
		fmt.Println("Upgrade failed:", err)
		return
	}
	defer conn.Close()
 
	fmt.Println("Connected to client")
 
	sessionID := getSessionID(r)
	fmt.Printf("Session ID: %s\n", sessionID)
 
	serverPing(conn)
 
	for {
		messageType, message, err := conn.ReadMessage()
		if err != nil {
			fmt.Println("Read failed:", err)
			break
		}
		fmt.Printf("Received: %s\n", message)
 
		err = conn.WriteMessage(messageType, []byte("Echo: "+string(message)))
		if err != nil {
			fmt.Println("Send failed:", err)
			break
		}
	}
}
 
func main() {
	http.HandleFunc("/", handleWebSocket)
	fmt.Println("WebSocket server started on ws://localhost:3000")
	http.ListenAndServe(":3000", nil)
}

然后简单的前端页面:

<!DOCTYPE html>
<html lang="en">
<head>
    <meta charset="UTF-8">
    <title>WebSocket Tester</title>
    <style>
        body { font-family: sans-serif; margin: 2rem; }
        #log { border: 1px solid #ccc; padding: 10px; height: 200px; overflow-y: scroll; background: #f9f9f9; }
        input, button { padding: 5px; font-size: 16px; }
    </style>
</head>
<body>
<h2>WebSocket Client → ws://localhost:3000</h2>
 
<input type="text" id="messageInput" placeholder="Enter message" />
<button onclick="sendMessage()">Send</button>
 
<h3>Log</h3>
<div id="log"></div>
 
<script>
    const log = (msg) => {
        const el = document.createElement('div');
        el.textContent = msg;
        document.getElementById('log').appendChild(el);
    };
 
    function getSessionID() {
        return localStorage.getItem("session_id");
    }
 
    function setSessionID(id) {
        localStorage.setItem("session_id", id);
    }
 
    let socket;
 
    function connect() {
        const sessionID = getSessionID();
        const wsURL = sessionID
            ? `ws://localhost:3000/?session_id=${sessionID}`
            : "ws://localhost:3000/";
 
        socket = new WebSocket(wsURL);
 
        socket.onopen = () => log("✅ Connected to WebSocket server");
        socket.onmessage = (event) => {
            const msg = event.data;
            if (msg.startsWith("New Session ID:")) {
                const newID = msg.split(":")[1].trim();
                setSessionID(newID);
                log("💾 Stored Session ID: " + newID);
            }
            log("📨 Received: " + msg);
        };
        socket.onerror = (e) => log("❌ Error: " + (e.message || "Unknown error"));
        socket.onclose = () => {
            log("🔌 Connection closed, retrying in 3s...");
            setTimeout(connect, 3000);
        };
    }
 
    connect();
 
    function sendMessage() {
        const input = document.getElementById("messageInput");
        const msg = input.value.trim();
        if (!msg) {
            log("⚠️ Please enter a message before sending.");
            return;
        }
        if (socket && socket.readyState === WebSocket.OPEN) {
            socket.send(msg);
            log("📤 Sent: " + msg);
        } else {
            log("⚠️ Cannot send, socket not open");
        }
        input.value = "";
    }
</script>
</body>
</html>

这里为了测试nginx,观察proxy超时的影响,需要加上以下参数:

        proxy_set_header X-Real-IP $remote_addr;
        proxy_set_header Upgrade $http_upgrade;
        proxy_set_header Connection "Upgrade";
        proxy_read_timeout 10s;

注意 proxy_read_timeout 是 Nginx 等待“后端服务器”发送数据给客户端的最大时间。

测试结果表现proxy的timeout设置会影响连接的断开,适当的PING和超时设置都很重要。

商业转载请联系站长获得授权,非商业转载请注明本文出处及文章链接,您可以自由地在任何媒体以任何形式复制和分发作品,也可以修改和创作,但是分发衍生作品时必须采用相同的许可协议。

本文采用CC BY-NC-SA 4.0 - 非商业性使用 - 相同方式共享 4.0 国际进行许可。