在排查HTTPS接口慢问题时,很多人会把Nginx日志里的$request_time当成完整请求耗时,但TLS握手发生在HTTP请求读取之前,默认的access_log并不会单独记录这一段。要想知道客户端完成TLS握手究竟花了多少毫秒,需要让Nginx显式输出$ssl_handshake_time。这个变量从1.19.4版本开始提供,单位是秒,并且带毫秒精度,可以直接放进自定义日志格式里。围绕这个字段做分析,可以定位不少用其他指标看不出来的性能问题。

这里需要先理清一个边界:$ssl_handshake_time统计的是TLS层的握手耗时,不包括TCP三次握手、TLS会话恢复后的0-RTT数据发送,也不包括证书链在客户端本地的验证时间。它记录的是Nginx作为服务端视角下,从开始处理TLS握手到握手完成的时间。对于keep-alive连接上的后续请求,由于连接已建立,$ssl_handshake_time通常只在首次请求上出现,复用连接上的请求可能为空。理解这一点是后面分析日志的前提。
一、为什么不能把request_time当成SSL握手耗时
$request_time从Nginx读取客户端第一个HTTP请求字节开始,到响应发送完成写入日志为止。这个时间确实包含请求解析、上游响应、磁盘IO等环节,但TLS握手发生在这之前。也就是说,一个HTTPS请求的总墙钟时间可以粗略看作TCP建立、TLS握手、HTTP请求处理三部分之和,而$request_time只覆盖第三部分。如果TLS握手因为客户端网络质量差或密钥交换算法慢而达到500毫秒,即使后端处理只有20毫秒,$request_time仍然只显示20毫秒左右,看不出问题。
更隐蔽的是,Nginx在同一个keep-alive连接上处理多个请求时,只有第一个请求会产生TLS握手,后续请求不再出现$ssl_handshake_time。如果日志系统把所有请求混在一起直接求平均,握手耗时的样本量会被大量复用请求稀释,计算出的平均值往往过于乐观。因此,分析SSL握手耗时需要先把首次握手请求和连接复用请求区分开,至少单独观察$ssl_handshake_time非空的记录。
另一个容易混淆的指标是$upstream_response_time,它反映的是Nginx与后端应用之间的响应时间,与客户端到Nginx的TLS握手没有直接关系。把这两个指标放在一张日志里,能帮助判断慢的到底是入口TLS握手,还是后端业务响应。
二、用log_format把SSL握手耗时写入日志
在http块中定义日志格式,把与TLS相关的变量集中输出。$ssl_protocol表示协商出的协议版本,$ssl_cipher表示选中的加密套件,$ssl_session_reused用来判断是否复用了SSL会话,$ssl_handshake_time就是本次握手耗时。下面是一个JSON格式示例,方便后续用jq等工具处理。
http {
log_format tls_json escape=json
'{"remote_addr":"$remote_addr","request":"$request","status":$status,'
'"ssl_protocol":"$ssl_protocol","ssl_cipher":"$ssl_cipher",'
'"ssl_session_reused":"$ssl_session_reused",'
'"ssl_handshake_time":"$ssl_handshake_time",'
'"request_time":"$request_time",'
'"upstream_response_time":"$upstream_response_time"}';
access_log /var/log/nginx/tls_access.log tls_json buffer=32k flush=5s;
}
这里把$ssl_handshake_time保留为字符串,是因为在纯HTTP的server块里这个变量为空;如果直接按JSON数字输出,一旦遇到空值就会生成不合法JSON。后面分析时再用tonumber转换即可。对于只提供HTTPS访问的站点,也可以直接去掉时间字段的引号,让日志体积更小、排序更符合数值直觉。
需要特别留意的是,$ssl_handshake_time只有在Nginx 1.19.4或更高版本中才可用。如果还在使用旧版本,只能通过符号表、OpenSSL回调或升级Nginx来获得类似能力。对于大多数维护中的系统来说,升级到一个较新的主版本是成本最低的方案,因为变量本身就是编译进模块的,不需要额外编译选项。
三、从日志里统计握手耗时分布
有了JSON格式日志后,可以用jq把非空的握手耗时取出来,并转换成数值后交给sort和awk计算P50、P95、P99。下面这条命令示例假设日志文件为/var/log/nginx/tls_access.log,会输出毫秒级的百分位分布。
cat /var/log/nginx/tls_access.log \
| jq -r 'select(.ssl_handshake_time != null and .ssl_handshake_time != "") | .ssl_handshake_time | tonumber' \
| sort -n \
| awk 'BEGIN{count=0} {arr[count++]=$1} END{
p50=int(arr[int(count*0.50)]*1000);
p95=int(arr[int(count*0.95)]*1000);
p99=int(arr[int(count*0.99)]*1000);
printf "样本数=%d P50=%dms P95=%dms P99=%dms\n", count, p50, p95, p99
}'
命令开头用了反斜杠换行,方便在shell中复制执行。统计时要注意,如果你的日志流量很大,直接cat整个文件会让内存压力变大,可以先按小时切分日志,或者使用jq的流式处理。对于一次性排查,取最近一段时间的样本已经足够发现问题。
除了总体百分位,还应该按照ssl_session_reused字段分组。会话复用成功时,握手通常只需一次RTT甚至更短;会话未复用时,完整握手需要证书传输、非对称密钥交换,耗时明显更高。如果发现P95很高但P50很低,多半是少数远端客户端网络抖动,或者某些旧客户端只支持TLS1.2且没有会话复用。此时再用ssl_protocol和ssl_cipher分组,能进一步分辨出是协议版本差异还是加密套件性能差异。
例如,ECDHE_ECDSA套件在较老的CPU上做椭圆曲线计算会慢于RSA证书交换,而TLS1.3通常比TLS1.2少一个RTT。通过日志聚合出不同套件的平均握手时间,可以量化升级证书或关闭低版本协议能带来的收益。
四、基于日志结果做针对性优化
如果统计结果显示会话复用率偏低,优先在Nginx中扩大SSL会话缓存并延长过期时间。共享内存的大小决定了能缓存多少会话;当在线连接数较大时,10m可能不够,建议根据活跃连接数调整到20m或更大。会话票据也要保持开启,便于客户端在重新连接时使用。
http {
ssl_session_cache shared:SSL:20m;
ssl_session_timeout 1d;
ssl_session_tickets on;
ssl_protocols TLSv1.2 TLSv1.3;
}
如果日志显示大量TLS1.2客户端仍然采用了昂贵的RSA密钥交换,而服务器端证书是RSA类型,可以评估增加ECDSA证书。双证书部署可以让支持ECDSA的客户端走更轻量的密钥交换,降低服务端CPU消耗和握手延迟。但要注意,ECDSA证书需要CA支持,且部分老旧客户端可能不兼容。
对于证书链过长导致的握手传输量增加,应检查服务器是否发送了不必要的中间证书。Nginx在配置ssl_certificate时,证书文件里最好只保留服务器证书和必要的中间证书,不要把根证书也拼进去。根证书通常已内置在客户端信任库中,多传反而浪费带宽。
另外,开启OCSP stapling可以在TLS握手中附带证书状态响应,省去客户端自己查询OCSP服务器的往返时间。但这个优化更多影响客户端侧感知,服务器端$ssl_handshake_time不一定能完全体现。如果日志里握手耗时已经很低,但用户仍反馈慢,可能还需要结合$request_time和TCP层抓包来排查。