同一台 4 核云服务器,TLS 完整握手从 1.4k 干到 4.1k QPS:RSA/ECDSA、会话票证共享、OCSP Stapling 三组交叉实测
测试台子先摆清楚
4 vCPU / 8G 云主机,Ice Lake 系,基频 2.5GHz,nginx 1.24.0 + OpenSSL 3.0.13,systemd 下 4 个 worker,listen 443 ssl reuseport;。客户端是同可用区另一台机器,走内网,RTT 0.2ms,彻底排除网络变量。
三个自变量:证书算法(RSA2048 / ECDSA P-256)、会话票证是否跨 worker 共享、OCSP Stapling 开关。因变量只有一个:完整握手 QPS(非 keep-alive,每次连接都跑满握手)。
测法别用 wrk,wrk 默认吃 keep-alive,你测的是吞吐不是握手。用 ab 不带 -k 或者 openssl s_time 强制新建连接:
ab -n 20000 -c 100 https://a.example.com/ # 不开 keep-alive = 完整握手
openssl s_time -connect 10.0.0.5:443 -www / -new -time 10 -nbio
第一组:证书算法值多少钱
先说结论:网上流传的“换 ECDSA 证书握手快 5 倍”是拿 openssl speed rsa2048 跟 openssl speed ecdsap256 对比得出的,那个数字跟真实握手没多大关系。先看真实数据:
openssl speed -multi 1 -seconds 3 rsa2048
openssl speed -multi 1 -seconds 3 ecdsap256
# 典型 2.5GHz Ice Lake:
# rsa 2048 sign: ~1200 ops/s (单核)
# ecdsa 256 sign: ~33000 ops/s (单核)
单核签名差 27 倍,但落到 nginx 上,RSA2048 完整握手实测 1460 QPS,ECDSA P-256 是 2380 QPS,只有 1.6 倍。原因很直白:一次完整握手除了服务端签名,还要做 ECDHE 密钥协商、对称套件初始化、TLS record 组包,RSA 签名只是其中最重的一块,不是全部。
RSA2048 的服务端签名开销大概是 0.8ms CPU,ECDHE P-256 的密钥对生成大约 0.04ms,剩下的全被 TLS 栈本身吃掉。想让 RSA 那 0.8ms 真正变成瓶颈,你得先把其它环节压到极致。
再看 CPU 侧证据,别只看 QPS:
perf stat -e cycles,instructions,branch-misses -p $(pgrep -f 'nginx: worker process' | head -1) sleep 10
RSA2048 每握手的 instructions 大概是 ECDSA 的 3.5 倍,cycles 差更多,因为大数模幂对分支预测极不友好。这就是 ECDSA 在云服务器上真正的优势——不是绝对速度,是单核 CPU 时间占用少,同样 4 核能塞进更多连接。
兼容性上得留一手,nginx 1.11.0 之后可以在同一个 server 块里挂两张证书:
ssl_certificate /etc/nginx/ssl/fullchain.rsa.pem;
ssl_certificate_key /etc/nginx/ssl/privkey.rsa.pem;
ssl_certificate /etc/nginx/ssl/fullchain.ecdsa.pem;
ssl_certificate_key /etc/nginx/ssl/privkey.ecdsa.pem;
nginx 会按客户端 cipher 能力自动挑。老 Android 4.x、Java 6 那些只认 RSA 的客户端不会 502,现代客户端走 ECDSA。别嫌麻烦,这是唯一不牺牲兼容性的做法。
第二组:会话票证不共享,才是真正的黑洞
这组是我认为最值得写下来的部分。默认配置下,nginx 每个 worker 各自生成一份 session ticket key,随便找个机器验一下就知道多离谱:
# 第一次握手,把 ticket 存到本地
openssl s_client -connect 10.0.0.5:443 -servername a.example.com -tls1_3 \
-sess_out /tmp/sess.pem /dev/null 2>&1
# 用同一张 ticket 恢复 20 次,数 Reused
ok=0
for i in $(seq 1 20); do
openssl s_client -connect 10.0.0.5:443 -servername a.example.com -tls1_3 \
-sess_in /tmp/sess.pem /dev/null | grep -q 'Reused, TLSv1.3' && ok=$((ok+1))
done
echo "reuse: $ok/20"
4 个 worker 的情况下,这个数字长期在 11%~26% 之间跳。因为 ticket 是 worker A 签的,连接落到 worker B,B 解不开,只能退回完整握手。客户端那边看到的是“有时候快有时候慢”,服务端看到的是一半流量在白白做 ECDHE + 签名。
注意 -reconnect 这个参数在 TLS 1.3 下不能用来验证复用,它走的是 TLS 1.2 时代的老逻辑,测出来全是假阳性。必须用 -sess_out / -sess_in。
修复只要一行,但坑在后面:
umask 077
openssl rand 80 > /etc/nginx/ssl/ticket.key.202503
ssl_session_cache shared:SSL:20m;
ssl_session_timeout 1d;
ssl_session_tickets on;
ssl_session_ticket_key /etc/nginx/ssl/ticket.key.202503;
80 字节是给 AES-256 用的(32B HMAC key + 48B AES key),48 字节的旧格式只能配 AES-128。配完之后同一段脚本的复用率直接到 94%。
这里有个反直觉的设定:ssl_session_tickets off 会直接干掉 TLS 1.3 的会话恢复能力。TLS 1.3 取消了 session ID 恢复机制,PSK 全靠 ticket 承载。ssl_session_cache shared 只对 TLS 1.2 的 session ID 恢复有效。你在网上抄到的那份"加固配置"把 tickets 关掉,TLS 1.3 客户端就永远在跑完整握手,而且 nginx 不会给你任何警告。
轮换的时候要保留旧 key,新版放最上面:
ssl_session_ticket_key /etc/nginx/ssl/ticket.key.202503; # 新,用于加密
ssl_session_ticket_key /etc/nginx/ssl/ticket.key.202502; # 旧,仅用于解密
nginx 用第一个 key 加密,全部 key 参与解密。你把旧 key 一删,所有持有老 ticket 的长连接客户端——尤其是移动端 APP 的后台连接——下次直接用不了,退化成完整握手风暴。这一刀切下去,P99 能飙起来。
再叠一层 reuseport 的干扰:开了 listen 443 ssl reuseport; 之后,内核按四元组哈希把连接分到各 worker 的 accept 队列,同一个客户端的连续连接会跳到不同 worker。这在 ticket key 不共享的时候就是灾难放大器,在共享之后反而无所谓了。
第三组:OCSP Stapling,一个正在退场的优化
先说结论:如果你用的是 Let's Encrypt 证书,这一整节可以跳过——LE 已经在 2025 年 5 月下线了 OCSP responder,ssl_stapling on; 现在是个纯粹的空配置,nginx 抓不到响应,静默跳过,握手包里不带 staple。
如果你的 CA 还提供 OCSP,先确认配置真的生效了:
echo | openssl s_client -connect 10.0.0.5:443 -status 2>/dev/null \
| sed -n '/OCSP response:/,/---/p'
看不到 OCSP Response Status: successful (0x0) 就是没 staple。最常见的三个原因:resolver 没配、ssl_trusted_certificate 指向的是 chain 而不是 root+intermediate 的完整链、CA 的 OCSP 地址从你的机房不可达。
ssl_stapling on;
ssl_stapling_verify on;
ssl_trusted_certificate /etc/nginx/ssl/ca-chain.pem; # 必须是 root + intermediate
resolver 100.100.2.136 100.100.2.138 valid=300s; # 云内 DNS,别用 8.8.8.8
resolver_timeout 3s;
实测影响:开启 stapling 后握手包的 ServerHello + Certificate + CertificateStatus 会多出 1.5~2KB,意味着多一个 TCP 段。在内网 RTT 0.2ms 的环境下,完整握手 QPS 掉 3% 左右;但跨公网 RTT 20ms 的环境下,因为有 stapling 客户端省掉了一次去 CA 的 OCSP 查询(那次查询动辄 100~300ms),首字节时间反而改善 40% 以上。
所以我的判断是:stapling 是给公网用的,不是给内网 QPS 用的。你要是内部服务调内部服务,关掉它,少 2KB 少一次组包。
还有个隐性成本:nginx 拉 OCSP 响应是异步的,但如果 CA 的 responder 抖动,worker 会积压一批待填充的 stapling 状态。用 ssl_stapling_responder 指定一个就近的 responder,比让 nginx 自己解析证书里的 URL 靠谱得多。
把三组变量收束到一份配置里
ssl_protocols TLSv1.2 TLSv1.3;
ssl_prefer_server_ciphers off;
ssl_ciphers ECDHE-ECDSA-AES128-GCM-SHA256:ECDHE-ECDSA-CHACHA20-POLY1305:ECDHE-RSA-AES128-GCM-SHA256;
ssl_session_cache shared:SSL:20m; # 20MB ≈ 8 万会话
ssl_session_timeout 1d;
ssl_session_tickets on; # 别关!TLS1.3 靠它
ssl_session_ticket_key /etc/nginx/ssl/ticket.key.202503;
ssl_stapling off; # LE 证书,开了也没用
ssl_early_data off; # 0-RTT 有重放风险,除非你做了 anti-replay
三组叠加之后,这台 4 核机器的实测从 1460 QPS 拉到 4120 QPS,2.8 倍。拆开看贡献:ECDSA 证书约 +63%,ticket 共享约 +180%,两者叠加还有互相放大效应——因为会话恢复路径本身就跳过了 ECDHE 和签名,ECDSA 省下的那部分在有 94% 复用率的时候,边际收益反而变小了。
云服务器侧:CPU steal 会把你的压测数据全部带偏
上面所有数字,前提是你的 vCPU 是实打实能用的。共享型实例上做 TLS 握手压测,第一件事是看 steal:
vmstat 1 5
# st 列只要非 0 且持续 > 3,后面所有 QPS 数字都别信
握手是短时高 CPU 密集任务,对调度延迟极其敏感。steal 5% 的机器,P99 握手延迟能劣化 3 倍以上,因为每次被抢走时间片都落在最贵的 RSA 模幂或者 ECDHE 点乘上。
另外确认 CPU 是否带 AES-NI 和 AVX-512:
grep -o -m1 'aes\|avx512' /proc/cpuinfo | sort -u
openssl speed -evp aes-128-gcm # 有 AES-NI 的话单核 5GB/s 以上
没有 AES-NI 的实例跑 TLS 1.3,AES-GCM 全走软件实现,握手之后的数据面会成为新瓶颈,这时候 CHACHA20-POLY1305 反而是更优解,因为它不依赖硬件加速。
多核扩展性也要自己测一遍,别信“核数翻倍 QPS 翻倍”:
openssl speed -multi 1 -seconds 3 ecdsap256
openssl speed -multi 4 -seconds 3 ecdsap256
4 核跑到 3.1~3.4 倍是正常的,掉到 2.5 倍以下说明你在共享核或者 NUMA 跨节点了。做 TLS 终结的机器,选独享型、高主频的实例比堆核数划算得多——我在轻云互联上跑这套压测的时候,同样标称 4C 的机器,握手 QPS 的顶比某些共享型实例高出近一倍,核心差异就在 steal time 和主频上,跟带宽规格一点关系都没有。
一句话收尾
证书算法那 1.6 倍是明面上的,会话票证不共享那 11% 的复用率是暗地里的,而 ssl_session_tickets off 这种藏在“安全加固模板”里的定时炸弹,能让你在 TLS 1.3 上白烧一整年的 CPU。压测之前先把这三件事查一遍,比换任何硬件都值。