小程序访问慢?从TLS握手与Nginx优化入手定位SSL性能瓶颈
这是服务器运维系列第三十四篇。先说结论小程序web访问慢十次里有七八次问题不在业务代码而在网络链路和SSL/TLS握手。“东方仙盟”的小程序最近接到一堆打开卡顿的反馈开发同事盯着后端日志看了半天应用响应时间不过几十毫秒用户却要转圈好几秒。后来我把整个HTTPS链路拆开逐段测定位到TLS握手耗时接近0.7秒TTFB高达1.2秒大量时间耗在了证书交互和会话协商上。这篇文章把这次SSl优化的完整过程记录下来包含问题定位方法、TLS协议层优化项、Nginx配置实现和踩坑记录。如果你在给小程序Web入口做性能优化尤其是HTTPS链路上觉得无从下手这份实操记录可以直接给你参考。1. 访问慢的根因定位先拆时间再谈优化在动手调整配置之前先把“慢”拆开。我的习惯是把用户从点击到页面可交互的整个过程拆成几段来测DNS解析、TCP建连、TLS握手、首字节时间、资源下载、应用渲染。前四段属于服务器和网络侧后两段属于客户端侧。很多运维在优化时容易一头扎进Nginx配置里但如果不先搞清楚耗时分布很可能在一个无关痛痒的参数上浪费两小时。1.1 用curl建立基线数据用curl配合-w参数一行命令就能拿到关键耗时指标curl -o /dev/null -s -w DNS: %{time_namelookup}s\nTCP: %{time_connect}s\nTLS: %{time_appconnect}s\nTTFB: %{time_starttransfer}s\nTotal: %{time_total}s\n https://api.xxx.com/api/health这里time_appconnect就是TLS握手完成的时间它的数值如果明显大于time_connect说明SSL层是主要瓶颈。“东方仙盟”这次我测到的数据是DNS 0.02sTCP 0.03sTLS 0.68sTTFB 1.2s。TLS握手占了连接耗时的绝大部分问题出在协议层无疑。需要注意的是这个基线要选在业务低峰期测而且要连续多测几次取中位数。第一次请求往往包含DNS缓存未命中、TCP慢启动等因素数值会偏高我一般会连测5次去掉最大最小值再对比优化前后的趋势这样数据才可信。1.2 小程序环境的特殊放大效应小程序的WebView环境和普通浏览器差异很大。首先它对证书链完整性要求更高如果源站下发的证书链不完整客户端会尝试在线补链这部分耗时会被计入首屏等待。其次小程序网络库的会话缓存策略比较保守连接复用不如桌面浏览器积极所以服务端即使配了TLS会话缓存客户端也不一定每次都能命中。这就解释了为什么“浏览器打开正常小程序里却卡顿明显”。另外小程序容器自身初始化本来就有开销服务器端能省出来的每一毫秒对客户端来说都是宝贵的“余量”。所以这次优化的核心思路是先把服务器侧HTTPS链路的耗时压到最短再配合客户端的资源预加载和请求合并让用户感知到的卡顿明显下降。两种优化缺一不可单改服务器配置效果会打折扣。2. SSL优化核心策略让TLS握手更少、更短、更稳SSL优化的总原则可以概括成九个字少握手、短握手、稳握手。少握手靠会话复用短握手靠TLS1.3和证书链裁剪稳握手靠OCSP装订和合理的加密套件配置。下面逐个说清楚。2.1 升级TLS1.3并确认HTTP/2支持TLS1.3将握手从2个RTT压缩到1个RTT对于跨地域访问或者弱网环境收益非常可观。前提是Nginx和OpenSSL版本要够新。我建议Nginx至少1.17.0以上、OpenSSL 1.1.1以上。检查OpenSSL版本用openssl version检查Nginx编译参数用nginx -V。Nginx里对应配置ssl_protocols TLSv1.2 TLSv1.3; ssl_prefer_server_ciphers off; ssl_ciphers ECDHE-ECDSA-AES128-GCM-SHA256:ECDHE-RSA-AES256-GCM-SHA384:ECDHE-ECDSA-CHACHA20-POLY1305:ECDHE-RSA-CHACHA20-POLY1305:ECDHE-RSA-AES128-GCM-SHA256;配置ssl_protocols时不要把TLS1.0/1.1加回来不单是因为安全性差还会让某些客户端优先选择更低版本导致握手RTT变多。新版本的TLS1.2协议在安全性和性能上已经足够配合TLS1.3共存兼容性最好。需要注意的是ssl_prefer_server_ciphers这个指令只影响TLS1.2及以下TLS1.3的加密套件是协议内置的不受它控制所以这里把它关掉把算法选择权交给客户端反而能避免某些老客户端协商失败。HTTP/2和TLS1.3是“好搭档”。HTTP/2的多路复用能让多个资源请求共用一条连接页面首屏阶段的并发能力大幅提升。开启HTTP/2的写法根据Nginx版本有差异老版本在listen指令里加http2参数Nginx 1.25.1之后的写法改为单独的http2 on指令。写配置前先看版本避免用了新语法导致启动失败。2.2 会话复用压倒性的第一收益会话复用是最容易出效果、却最容易被忽略的一项。没有会话复用的时候每次新请求都要走一次完整握手有了会话复用客户端可以把之前协商好的会话参数直接恢复握手的RTT从两跳变成一跳甚至零跳。Nginx配置ssl_session_cache shared:SSL:10m; ssl_session_timeout 1d; ssl_session_tickets on; ssl_session_ticket_key /etc/nginx/ssl/ticket.key;关于10M缓存能存多少会话可以算一笔账每个TLS会话大约占100字节左右10MB理论上可以保存约十万条会话记录实际再打个折扣也足够覆盖日常在线用户量。shared关键字表示所有worker进程共享这段缓存如果不加shared每个worker各自维护一份独立的会话表命中率会明显下降。会话票据机制则适合负载均衡多实例场景。客户端拿着服务端签发的ticket下次握手时直接带过来服务端验证通过就能恢复会话。但这里有个关键坑如果前面挂了多个Nginx节点所有节点必须使用同一个ticket密钥文件否则A节点签发的ticket到B节点无法验证会话恢复直接失效。用openssl rand 80生成一个随机文件同步到所有节点配置ssl_session_ticket_key指向它即可。2.3 OCSP装订把证书校验时间从客户端移到服务端客户端在握手时除了验证证书链还可能向CA的OCSP服务器查询证书吊销状态。这一步如果由客户端自行发起在网络环境不稳定时可能等很久甚至超时。OCSP Stapling的思路是服务器定期自己去查询OCSP结果缓存下来在握手时直接把证书状态“装订”在响应里发给客户端客户端就无需再发起外部查询。Nginx配置ssl_stapling on; ssl_stapling_verify on; ssl_trusted_certificate /etc/nginx/ssl/chain.pem; resolver 10.0.0.2 valid300s; resolver_timeout 5s;这里的ssl_trusted_certificate必须指向包含中间证书的完整证书链Nginx才能验证OCSP响应是否合法。resolver地址填你自己VPC内的DNS或者云厂商提供的DNS均可以关键是Nginx要能正常访问到CA的OCSP服务器。配置完成后用openssl s_client验证如果响应里能看到OCSP Response字段说明装订生效。2.4 证书链瘦身少传一张证书就少一段耗时证书链太长握手阶段传输的数据包就多弱网下这个差距会被明显放大。我见过有的服务器把叶子证书、中间证书、根证书、过期交叉证书一股脑全下发客户端光解析证书就卡半天。先检查当前下发的证书链openssl s_client -connect api.xxx.com:443 -servername api.xxx.com -showcerts /dev/null 21 | grep -i s:正常情况下应该输出2到3张证书叶子证书和必要的中间证书。如果发现包含根证书或者多余的交叉证书就手动从CA站点下载正确的中间证书拼成chain.pem配置到ssl_certificate或者ssl_trusted_certificate。一个容易被忽略的点不要裁剪过度。如果只保留叶子证书客户端没有中间证书时还得通过AIACAI在线拉取反而更慢。正确的做法是去掉根证书保留叶子证书加中间证书链。2.5 加密套件取舍移动端性能优先加密套件列表直接决定TLS1.2握手时能协商出什么算法。在小程序这种大量移动端访问的场景里我把带有硬件加速友好的AES-GCM套件放在前面同时保留CHACHA20-POLY1305给那些没有AES硬件加速的低端安卓设备。ssl_ciphers ECDHE-ECDSA-AES128-GCM-SHA256:ECDHE-RSA-AES128-GCM-SHA256:ECDHE-ECDSA-AES256-GCM-SHA384:ECDHE-RSA-AES256-GCM-SHA384:ECDHE-ECDSA-CHACHA20-POLY1305:ECDHE-RSA-CHACHA20-POLY1305;很多老教程还会带上CBC系列套件比如AES128-SHA这一类。现在完全没必要保留这类套件不但性能差还容易受到 padding oracle 类攻击影响。另外如果服务器CPU支持AES-NI指令集AES-GCM的加解密速度非常快如果是不支持AES-NI的低配机器CHACHA20反而更有优势。所以我的做法不是盲目删掉CHACHA20而是把它放在列表中间靠后位置让服务端优先协商GCM同时给移动端设备留一条更优的退路。3. 服务端整体调优不能只优化SSL那一层SSL握手节省下来的时间如果被服务器慢响应、网络拥塞、后端慢查询抵消用户感知的提升仍然有限。所以这次我也把服务端链路整体过了一遍。3.1 静态资源缓存策略与压缩小程序页面里的图片、JS、CSS等静态资源如果每次请求都回源再快的TLS也扛不住。我建议给静态资源设一个足够长的缓存过期时间并用immutable标记让客户端在有效期内直接使用本地缓存不再发请求验证。location ~* \.(jpg|jpeg|png|gif|webp|svg|css|js|woff|woff2)$ { root /data/www/static; expires 30d; add_header Cache-Control public, max-age2592000, immutable; access_log off; gzip_static on; }gzip_static和gzip的区别在于前者直接读取预压缩好的.gz文件不会每次动态压缩消耗CPU。我建议在生产环境提前用gzip命令生成一份.gz版本配合gzip_static on在高并发场景下能省下不少CPU开销。动态压缩的gzip_comp_level设置在5左右是性能和压缩率的平衡点设到9对CPU的消耗翻倍压缩率却提升很小不划算。3.2 HTTP/2多路复用与连接保持HTTP/2的意义不只是减少握手次数而是在一条TCP连接上并行传输多个请求。以前浏览器对同一域名最多开6条连接HTTP/2时代一条连接就够。这样既减少了TCP慢启动的重复开销也降低了服务器维持大量连接的资源消耗。listen 443 ssl; http2 on;如果Nginx版本低于1.25.1需要写成listen 443 ssl http2;这里还有个和HTTP/2相关的小细节小程序端到Nginx之间保持长连接Nginx到后端之间也要记得开启keepalive。否则前端复用连接做得很漂亮Nginx却每次向后端新建TCP连接上游的握手开销又会成为新瓶颈。upstream配置里加上keepalive 32location里设置proxy_http_version 1.1和proxy_set_header Connection 让Nginx和后端也走连接复用。3.3 TCP与内核参数调优协议层优化之外TCP内核参数也影响明显。我这次调整了初始拥塞窗口相关的行为让慢启动阶段更快进入高吞吐状态net.ipv4.tcp_fastopen 3 net.ipv4.tcp_slow_start_after_idle 0 net.ipv4.tcp_mtu_probing 1 net.core.rmem_max 16777216 net.core.wmem_max 16777216 net.ipv4.tcp_rmem 4096 87380 16777216 net.ipv4.tcp_wmem 4096 65536 16777216 net.ipv4.tcp_syncookies 1 net.ipv4.tcp_tw_reuse 1tcp_slow_start_after_idle设为0意思是连接空闲后不重置拥塞窗口下次复用连接时能直接以之前学习到的窗口大小发送数据。对于小程序这种“高频短请求”模式收益很实在。tcp_fastopen可以配合TLS1.3实现0-RTT的体验但要注意它存在重放风险不是所有接口都适合。支付、扣款这类非幂等接口不建议开启fastopen只读查询类接口可以大胆开。mtu_probing解决的是PMTU黑洞问题。如果网络路径上某些设备丢弃大包而不通知发送方TCP会一直重传表现为连接偶发性卡死。打开探测后能自动发现可用MTU避免这类玄学超时。3.4 带宽与静态资源分发思路服务器带宽如果本身很小SSl优化做得再好也是杯水车薪。这次“东方仙盟”的服务器出口带宽只有几兆高峰期静态资源并发下载直接占满出口。我的处理方式是把静态资源全面交给CDN源站只保留API请求。操作上有两个注意点。第一CDN域名要添加到小程序后台的合法域名列表否则请求会被客户端直接拦截。第二回源策略要设置好静态资源回源频率越低越好让CDN节点缓存尽可能长的时间。API入口仍走源站但可以配合HTTPDNS或者云厂商的调度能力做就近接入。带宽被释放后源站的SSL握手和API响应都稳定了很多不再有“一个下载任务拖垮所有接口”的情况。4. 完整Nginx配置参考与验证方法把优化项落到实际的server配置块里下面这份是我这次调整后的版本可以直接参考但证书路径、域名、业务路径需要按自己的环境改。4.1 一份可直接参考的server配置server { listen 443 ssl; server_name api.xxx.com; http2 on; # nginx 1.25.1若版本较旧改为 listen 443 ssl http2; ssl_certificate /etc/nginx/ssl/api.xxx.com.pem; ssl_certificate_key /etc/nginx/ssl/api.xxx.com.key; ssl_trusted_certificate /etc/nginx/ssl/chain.pem; ssl_session_cache shared:SSL:10m; ssl_session_timeout 1d; ssl_session_tickets on; ssl_session_ticket_key /etc/nginx/ssl/ticket.key; ssl_protocols TLSv1.2 TLSv1.3; ssl_ciphers ECDHE-ECDSA-AES128-GCM-SHA256:ECDHE-RSA-AES128-GCM-SHA256:ECDHE-ECDSA-AES256-GCM-SHA384:ECDHE-RSA-AES256-GCM-SHA384:ECDHE-ECDSA-CHACHA20-POLY1305:ECDHE-RSA-CHACHA20-POLY1305; ssl_prefer_server_ciphers off; ssl_stapling on; ssl_stapling_verify on; resolver 10.0.0.2 valid300s; resolver_timeout 5s; location /api/ { proxy_pass http://backend_upstream; proxy_set_header Host $host; proxy_set_header X-Real-IP $remote_addr; proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; proxy_set_header X-Forwarded-Proto $scheme; proxy_http_version 1.1; proxy_set_header Connection ; proxy_connect_timeout 3s; proxy_read_timeout 5s; proxy_send_timeout 5s; proxy_buffering off; } location ~* \.(jpg|jpeg|png|gif|webp|svg|css|js|woff|woff2)$ { root /data/www/static; expires 30d; add_header Cache-Control public, max-age2592000, immutable; access_log off; gzip_static on; } location / { root /data/www/html; try_files $uri $uri/ /index.html; add_header Cache-Control no-cache, must-revalidate; } }关于这份配置有几点说明。proxy_set_header Connection 配合proxy_http_version 1.1是为了复用后端连接但前提是upstream里配置了keepalive否则这个设置意义不大。另外proxy_buffering off是我对API场景的具体选择响应体不大时让数据尽快返回给客户端减少首字节等待如果接口响应很大开着缓冲反而对后端压力小一些需要按接口特点自己权衡。4.2 使用curl和openssl验证优化效果先确认TLS协议版本和加密套件协商结果openssl s_client -connect api.xxx.com:443 -servername api.xxx.com /dev/null 2/dev/null | grep -E Protocol|Cipher|Verify|OCSP如果Protocol显示TLSv1.3Cipher是TLS_AES_256_GCM_SHA384或CHACHA20系列Verify return code为0OCSP Response有内容说明核心优化项全部生效。这条命令每次建立的是全新连接所以只能验证服务端配置不能体现会话复用效果。会话复用是否生效要看实际业务请求里的复用比例。我建议在Nginx日志格式里加上$ssl_session_reused字段log_format ssl_debug $remote_addr [$time_local] $request $ssl_protocol $ssl_cipher session_reused:$ssl_session_reused;优化前这个字段基本都是0优化后在业务流量正常时能看到大量1说明客户端和服务器之间的会话恢复在起作用。如果你想用curl模拟连续请求可以写个小循环对比第一次和第二次的time_appconnect差值第二次明显小于第一次就说明会话恢复成功。4.3 浏览器与小程序侧验证浏览器直接访问页面打开DevTools的Network面板看每个资源的Timing标签。重点关注Connection ID是否大量复用以及TLS握手耗时是否下降到几十毫秒级别。如果存在大量单独建立的连接说明HTTP/2和会话复用配置可能只对部分请求生效。小程序端的验证稍微麻烦一些。开发者工具可以调试但真机上的网络库行为和模拟器并不完全一致。更稳妥的方法是看小程序后台的性能监控页面首次渲染耗时、请求耗时分布。或者用代理工具抓包查看请求的TLS握手耗时和连接复用情况。不过要注意某些抓包工具会开启中间人代理此时看到的TLS耗时是“客户端到代理”的时间不代表真实源站性能只能用来判断请求次数和合并情况。5. 实测效果与收益量化优化做完之后我在同样的低峰时段重新测了一遍同样的接口数据变化很直观。5.1 优化前后指标对比指标优化前优化后提升TLS握手耗时0.68s0.28s59%TTFB1.2s0.44s63%会话复用比例基本为0约75%明显小程序首屏资源总耗时3.5s1.6s54%TLS握手从0.68秒降到0.28秒主要贡献来自TLS1.3减少RTT、证书链瘦身降低握手数据量、会话复用让大量请求直接跳过完整握手。TTFB下降更多则叠加了HTTP/2多路复用和TCP拥塞窗口调优的效果——原来请求在队列里排队等连接现在可以并发发送。有一点必须说明首屏资源总耗时包含小程序端基础库初始化、业务脚本执行和渲染的时间服务器侧优化的收益体现在网络等待被压缩后客户端整体耗时曲线会更平滑。不要指望只改服务器就能让首屏从5秒变1秒前端的资源体积和请求数量同样要管。5.2 用户的直接体感用户反馈最明显的变化是“转圈时间短了点进去很快就看到内容了”。小程序后台的“页面首次渲染平均耗时”从2.9秒左右降到1.7秒。开发同事也反馈接口报错的频率没有明显变化但页面加载成功率的体验好了很多。这类性能优化有个特点改动集中在基础设施层业务无感知但线上体验的提升是实打实的。所以我在优化完成后会保留基线数据每隔一段时间重新测一次确认效果没有随着流量模型变化而回落。6. 常见问题与排错记录这次优化过程不是一路顺畅的中间踩了几个坑单独列出来都是网上文档很少讲清楚的地方。6.1 会话复用没生效排查了半天发现是负载均衡多实例导致最初只在单台Nginx上配了会话缓存后来发现公司前面的负载均衡会把请求轮询到两台Nginx。A节点建立的会话到B节点无法恢复导致会话复用率几乎为0。解决办法就是前面提到的session_ticket_key做成共享密钥把同一个ticket.key文件同步到所有节点。如果用的是云负载均衡还要确认是否开启了会话保持如果开启可以降低节点间切换的频率但最根本的方案还是共享ticket密钥。6.2 OCSP装订不生效原因是resolver写错了有次配置了OCSP Stapling但验证时发现响应里没有OCSP字段。查看Nginx错误日志发现它一直在报解析失败。原因是resolver地址填了一个当时并不通的内网DNS。把resolver改成VPC内网DNS后OCSP装订立刻生效。另外要注意如果ssl_trusted_certificate配置的chain.pem里缺少中间证书Nginx无法验证OCSP响应来源可信同样会导致装订不生效所以这个配置不是随便填的。6.3 证书链裁剪过度把中间证书也裁了裁剪证书链的目的是去掉根证书和多余交叉证书但裁过头就麻烦。只保留叶子证书时客户端在验证证书链时需要通过AIACAI去拉取中间证书反而增加延迟甚至失败。正确做法是保留叶子证书加中间证书链只去掉根证书。判断方法很简单用openssl s_client -showcerts看实际下发的证书数量2到3张通常是合理的1张太少4张以上大概率有多余内容。6.4 TLS1.3开启后部分老客户端连不上TLS1.3推出后绝大多数现代客户端都支持但有些Android 4.x设备、老款iOS设备确实只支持到TLS1.2。我的做法是保留TLS1.2和TLS1.3共存ssl_protocols里两个都写上让客户端协商各自支持的最高版本。如果彻底关掉TLS1.2业务量小的时候看不出问题一旦用户设备分布里老机型占比高错误率就会上来。如果遇到兼容性投诉可以先观察错误日志里的SSL协议版本分布再做决定。6.5 HTTP/2与部分小程序基础库的兼容问题理论上HTTP/2完全没问题但我在实测中发现某些版本的小程序基础库在HTTP/2长连接下偶尔出现请求超时。排查后发现是Nginx keepalive_requests设置值过小导致的边缘情况——连接在多次复用后被服务端强制关闭客户端没有及时感知下一次请求正好挂在这条已失效的连接上。解决方法设置keepalive_requests为100到1000之间的合理值keepalive_timeout保持65秒左右即可。如果问题依旧可以在Nginx层根据User-Agent对小程序的旧基础库版本做HTTP/1.1降级但优先级很低一般用不上。6.6 HSTS配置谨慎开启HSTS可以让浏览器强行走HTTPS省去301跳转减少一次额外的往返延迟。这个头对性能是正收益但风险在于一旦开启浏览器会强制HTTPS访问证书异常时用户无法快速绕过。我建议第一次配置时先设置max-age300观察几天没有问题再调大到一个月、半年。如果证书更换流程不规范HSTS配置得越激进越容易给自己挖坑。6.7 别忘了后端也要配合SSL优化做得再好后端接口如果慢吞吞返回前端该卡还是卡。TTFB包含“Nginx到后端的耗时”加“后端处理耗时”两者都要管。这次优化过程中顺手修了几个后端问题一个接口里存在N1查询一个接口返回了多余的大字段JSON序列化时间占了响应时间的一半。只改Nginx配置不碰应用代码性能提升的天花板很快就能碰到。7. 回归与监控优化不是一次性的优化做完不等于结束。SSL/TLS相关的性能问题会被证书更换、Nginx版本升级、客户端环境升级等因素再次触发。我建议把以下监控项纳入日常巡检。7.1 在Nginx日志里留下SSL指标在日志格式中加入$ssl_protocol、$ssl_cipher、$ssl_session_reused三个字段定期检查协议分布和会话复用比例。如果某天发现session_reused比例突然下降可能是客户端升级了网络策略或者ticket密钥被重新生成。另外按天统计TLS版本分布可以提前发现兼容性风险比如某类旧设备占比异常上升。log_format ssl_metric $remote_addr [$time_local] $request $ssl_protocol $ssl_cipher session_reused:$ssl_session_reused upstream_time:$upstream_response_time;7.2 主动巡检脚本我配置了一个每天凌晨执行的巡检脚本对核心域名发起HTTPS请求记录TLS耗时、证书剩余有效期、HTTP状态码。任何一项异常都会触发告警。这比“等用户反馈”要主动得多。证书即将过期这类问题很多人都是等到浏览器报错才发现有了这个巡检基本不会出现。脚本逻辑不复杂curl取各阶段耗时openssl s_client解析证书有效期把结果写到日志文件再用阈值判断是否告警。建议同时记录DNS解析耗时如果DNS本身就慢再好的SSL优化也白搭。最后说个实际操作中最大的体会很多团队遇到小程序访问慢第一反应是加服务器、买带宽但我这次反而验证了协议层优化的收益被严重低估。一次正确的SSL/TLS配置调整带来的时延下降幅度甚至超过把带宽翻倍。配置这些花不了多少时间却能让用户在弱网环境下明显感觉“页面没那么卡了”。如果你也在为小程序Web入口慢而头疼先从TLS握手时间和会话复用率这两个指标看起大概率能找到突破口。