WSS 与 TLS 终结:证书、性能与会话复用

WSS 不是新协议,它就是"先在这条 TCP 上做完 TLS 握手,再在加密通道里做一次标准的 WebSocket 升级"。所以 TLS 的全部坑(证书链不全、会话不复用、握手 CPU)都会原样搬到长连接上;又因为连接活得久,握手开销被摊得极薄,而重连风暴时的握手洪峰会成倍放大——这是长连接场景与普通 HTTP 最不一样的地方。

WSS 握手时序

客户端                                        服务端
  |--- TCP 三次握手 ------------------------->|
  |--- ClientHello(SNI、ALPN=http/1.1)----->|
  |<-- ServerHello / Certificate / Finished --|   证书链 + 密钥交换(TLS 1.3 为 1-RTT)
  |--- GET /ws/  Upgrade: websocket --------->|   加密通道内的 HTTP/1.1 升级请求
  |<-- 101 Switching Protocols ---------------|   此后所有帧都在 TLS 记录层内传输

三个必须记住的点:证书校验发生在 WebSocket 升级之前,证书错了根本走不到 101;ALPN 必须协商到 http/1.1,因为 WebSocket 升级本质是 HTTP/1.1 语义(RFC 8441 的 HTTP/2 版本尚未普及,浏览器会在同域上另开一条 ALPN 为 http/1.1 的连接来做 WS);心跳帧也走 TLS,即每 30s 一次小记录层开销,这是长连接唯一稳定的加密成本。

证书:最难排的一类问题

# 1) 证书链顺序必须是 叶子 → 中间证书
openssl s_client -connect ws.example.com:443 -servername ws.example.com -showcerts </dev/null 2>/dev/null | grep '^ *[0-9] s:'
# 输出:0 s:CN = ws.example.com    /    1 s:C = US, O = Let's Encrypt, CN = R10

# 2) 验证链完整性与 ALPN 协商结果(若为 h2,部分客户端升级失败)
openssl s_client -connect ws.example.com:443 -servername ws.example.com -alpn http/1.1 </dev/null 2>/dev/null | grep -E 'Verify return code|ALPN protocol'
# 输出:Verify return code: 0 (ok)   /   ALPN protocol: http/1.1

# 3) 会话复用是否生效(TLS 1.3 下 -reconnect 恒显示 Reused,要测得指定 -tls1_2)
openssl s_client -connect ws.example.com:443 -servername ws.example.com -tls1_2 -reconnect </dev/null 2>/dev/null | grep -ci Reused

生产上必须下发 fullchain(叶子 + 全部中间证书),只配叶子的后果是:桌面浏览器(自带中间证书缓存)正常,而 Android、Java、部分嵌入式客户端直接握手失败——这类"只有一部分客户端连不上"的问题几乎都是证书链不全。

在哪终结 TLS

三种位置各有取舍:云 SLB / CDN 终结证书集中管理、业务进程零握手 CPU,代价是内网明文;nginx 终结可做 OCSP stapling、复用与按域名分流,代价是 nginx 扛下全部握手 CPU,扩容瓶颈前移;Go 进程直接终结少一跳、端到端加密、CPU 分散到各业务实例,代价是每实例都要证书热加载与会话票据密钥同步。固定口径:公网入口终结、内网明文 + 网络策略隔离是绝大多数 IM 系统的选择,跨公网的内网链路(多机房)用 mTLS(双向证书认证)或 IPsec 补上。

nginx 配置:终结 + 复用

# 证书必须写全链文件;密钥单独放,权限 600
ssl_certificate     /etc/nginx/ssl/ws.example.com/fullchain.pem;
ssl_certificate_key /etc/nginx/ssl/ws.example.com/privkey.pem;

ssl_protocols TLSv1.2 TLSv1.3;
ssl_ciphers   ECDHE-ECDSA-AES128-GCM-SHA256:ECDHE-RSA-AES128-GCM-SHA256:ECDHE-ECDSA-CHACHA20-POLY1305;
ssl_prefer_server_ciphers off;        # TLS 1.3 由客户端选套件

# 会话复用:1 MB ≈ 4000 个会话,50 MB ≈ 20 万,够 10 万连接重连时命中
ssl_session_cache   shared:SSL:50m;
ssl_session_timeout 1d;
ssl_session_tickets on;

# OCSP stapling 省掉客户端自查吊销状态的一次往返;buffer 默认 16k,小消息降到 4k
ssl_stapling on;
ssl_stapling_verify on;
resolver 223.5.5.5 8.8.8.8 valid=300s;
ssl_buffer_size 4k;

# 把复用情况写进日志,便于统计
log_format ws '$remote_addr $status $ssl_protocol $ssl_cipher $ssl_session_reused';
access_log /var/log/nginx/ws.log ws;
awk '{print $5}' /var/log/nginx/ws.log | sort | uniq -c | sort -rn   # r=复用成功,.=完整握手,稳定后 >80% 才正常

性能与容量估算

握手成本是"每连接一次"的固定开销,先看量级(实测依赖 CPU 与 OpenSSL 版本):

操作单核量级说明
ECDSA P-256 签名数万次/秒证书密钥用 ECDSA 明显比 RSA 划算(RSA-2048 只有千次/秒级)
完整握手(TLS 1.2)单核约 1000~3000 次/秒RSA 取低值、ECDSA 取高值
会话复用(ticket)单核约 2 万次/秒以上省掉签名与密钥交换

把它代进"重连风暴"(4 核):

完整握手:4 × 2000 = 8000 次/秒  → 10 万 ÷ 8000 ≈ 12.5 秒才能建完
复用会话:4 × 20000 = 80000 次/秒 → 10 万 ÷ 80000 ≈ 1.3 秒建完

内存同样要单算:TLS 会话状态每连接约 10~40 KB(读写缓冲 + 会话对象),100000 × 30 KB ≈ 3 GB;把 ssl_buffer_size 从 16k 降到 4k 还能再省数百 MB。

所以长连接做重连风暴预案,靠的不是堆 CPU,而是会话复用 + 客户端重连抖动(jitter):复用把单核能力提高一个数量级,抖动把"同时"摊成几十秒的斜坡。多实例终结 TLS 时还有一条硬约束:会话票据密钥必须共享,否则客户端每次重连落到不同实例,复用全部落空,CPU 尖峰照旧。

Go 侧直接终结 TLS

cfg := &tls.Config{
    MinVersion: tls.VersionTLS12,
    NextProtos: []string{"http/1.1"},   // WS 升级只能走 HTTP/1.1,别在这里协商 h2
    // 多实例部署必须共享票据密钥,否则重连落到别的实例、复用全部落空
    // cfg.SetSessionTicketKeys(keys)   // keys 为 [][32]byte
}
srv := &http.Server{Addr: ":8443", TLSConfig: cfg,
    ReadHeaderTimeout: 5 * time.Second}   // 只限制握手期,不影响升级后的长连接
log.Fatal(srv.ListenAndServeTLS("/etc/ssl/ws/fullchain.pem", "/etc/ssl/ws/privkey.pem"))
if st := r.TLS; st != nil {   // 顺手打印握手细节,复用率与协议版本一目了然
    log.Printf("tls=%s cipher=%s resumed=%v alpn=%q", tls.VersionName(st.Version),
        tls.CipherSuiteName(st.CipherSuite), st.DidResume, st.NegotiatedProtocol)
}
// 输出:tls=TLS 1.3 cipher=TLS_AES_128_GCM_SHA256 resumed=true alpn="http/1.1"

证书热加载用 GetCertificate 从内存/文件读取,避免到期换证时重启进程;ReadHeaderTimeout 与升级后的读超时是两码事,后者属于应用层心跳逻辑,别指望 http.Server 的超时字段去管长连接。

常见坑

现象原因处理
浏览器正常、Android/Java 握手失败只下发了叶子证书配置 fullchain,顺序叶子 → 中间证书
换证后仍报证书过期nginx 没 reload / Go 进程持有旧证书nginx -s reload;Go 用 GetCertificate 热加载
重连时 CPU 尖峰ssl_session_cache 默认 off,每次完整握手shared:SSL:50m + ssl_session_timeout 1d
多实例复用率接近 0各实例会话票据密钥不同共享票据密钥,或统一在一层终结
升级握手 400ALPN 协商到了 h2允许 http/1.1,WS 与 h2 接口分端口
certificate is not yet valid服务器时钟偏移保证 NTP 同步,校验证书生效时间
小消息延迟偏高ssl_buffer_size 默认 16k 攒包按业务消息大小选 4k
0-RTT 数据被重放early data 不保证幂等关闭 0-RTT,或只承载幂等查询

小结:WSS = TLS 握手 + HTTP/1.1 升级,证书用全链、ALPN 必须落到 http/1.1;性能上把手数量级记牢(完整握手单核约 10003000 次/秒,会话复用可达 2 万次/秒以上)并据此设计重连风暴预案(复用 + 抖动),内存上按每连接 2030 KB 计入预算——十万连接约 3 GB,多实例务必共享会话票据密钥。

笔记加载中…