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 | 各实例会话票据密钥不同 | 共享票据密钥,或统一在一层终结 |
| 升级握手 400 | ALPN 协商到了 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,多实例务必共享会话票据密钥。