4、TLS记录层优化:记录大小与MTU的关系、记录分片与重组、动态记录大小调整算法
说到TLS优化,很多人第一反应就是握手延迟、证书链、密码套件这些。但我个人觉得,真正容易被忽视的,反而是最底层的记录层(Record Layer)。
你想想看,TLS所有的应用数据,最终都要封装成记录(Record)才能往下走。记录怎么切、切多大、什么时候切,直接决定了网络传输的效率。我在项目中遇到过好几次,明明握手优化得很好了,但吞吐就是上不去,最后发现是记录层在“拖后腿”。
4.1 记录大小与MTU的关系
先看一个最基础的问题:TLS记录最大能有多大?
标准规定,TLS记录的最大载荷是2^14字节,也就是16KB。但实际传输中,你很少真的发16KB一条的记录。为什么?因为下层还有MTU(最大传输单元)的限制。
以太网的MTU通常是1500字节。去掉IP头(20字节)和TCP头(20字节),留给TLS的可用空间大约是1460字节。如果TLS记录超过这个值,IP层就会分片。
所以,一个常见的优化思路是:让TLS记录大小适配路径MTU(Path MTU,简称PMTU)。
具体来说,TLS记录的大小应该满足:
TLS记录大小 ≤ PMTU - IP头 - TCP头 - (可选的TCP选项)
假设PMTU是1500,TCP选项用了12字节(时间戳+窗口缩放),那么:
TLS记录大小 ≤ 1500 - 20 - 20 - 12 = 1448字节
这1448字节里,还要包含TLS记录头(5字节)和可能的MAC/填充。所以实际应用数据载荷会更小一些。
| 场景 | PMTU | IP头 | TCP头 | TCP选项 | TLS记录上限 |
|---|---|---|---|---|---|
| 以太网 | 1500 | 20 | 20 | 12 | 1448 |
| PPPoE | 1492 | 20 | 20 | 12 | 1440 |
| VPN隧道 | 1400 | 20 | 20 | 12 | 1348 |
| Jumbo Frame | 9000 | 20 | 20 | 12 | 8948 |
ip route get查看,或者用tracepath工具。如果探测不到(比如中间设备屏蔽了ICMP),保守起见按1400字节算。
4.2 记录分片与重组
当应用层下发的数据超过TLS记录上限时,TLS协议栈会自动做分片(Fragmentation)。
分片的逻辑其实不复杂:
- 应用层调用
SSL_write(),传入一大块数据(比如32KB)。 - TLS协议栈检查当前记录大小上限(受PMTU和密码套件影响)。
- 如果数据超过上限,就切成多个TLS记录,每个记录独立加密、独立添加MAC。
- 每个记录作为一个独立的TCP段发送出去。
接收端呢?每个TLS记录独立解密、独立校验。校验通过后,把载荷按顺序拼接回完整的应用数据,再交给上层。
这里有个容易被忽略的点:分片粒度。分得太细,记录头开销占比大,加密次数多,性能下降。分得太粗,又容易触发IP分片。
我记得有一次,一个同事把TLS记录大小设成了16KB,想着“标准最大就是16KB,肯定没问题”。结果在WiFi环境下,丢包率从0.1%飙升到3%。原因就是WiFi的MTU通常只有1500,16KB的记录被IP层切成了11个分片,任何一个分片丢失都要重传整个IP数据报。
4.3 动态记录大小调整算法
固定记录大小虽然简单,但不够灵活。网络环境是动态的——PMTU可能变化(比如路由切换),拥塞窗口可能波动,不同链路的延迟也不同。
所以,现代TLS实现(如OpenSSL 1.1.1+、BoringSSL)都引入了动态记录大小调整机制。
说白了,就是根据当前的网络状况,自动调整TLS记录的大小。核心算法大致是这样的:
初始阶段:
记录大小 = 保守值(如512字节)
目的:避免在拥塞窗口未打开时发送大记录
正常阶段:
如果连续N个记录都成功确认(ACK):
记录大小 += 增长步长(如256字节)
如果发生重传或超时:
记录大小 = 保守值(如512字节)
记录大小上限 = min(PMTU - 开销, 16KB)
退出阶段(连接关闭前):
记录大小 = 保守值
目的:确保最后一个记录能完整送达
这个算法和TCP的拥塞控制有点像,都是“加法增大,乘法减小”的思路。但TLS记录层更保守一些,因为TLS记录的重传代价比TCP段更高——每个记录都要重新加密。
我画了一张图,帮你理清整个流程:
这个算法有几个关键参数需要调优:
- 初始记录大小:我建议512字节。太小了浪费头开销,太大了在慢启动阶段容易丢包。
- 增长步长:256字节是个不错的起点。网络好时增长快,网络差时增长慢。
- 确认阈值N:一般设为3-5。太敏感容易误判,太迟钝又跟不上网络变化。
- 重传回退:一旦发生重传,直接回退到初始值。这是最保守的策略,但也是最安全的。
SSL_CTX_set_record_split_threshold()和SSL_set_record_split()来控制记录分片行为。BoringSSL则提供了SSL_set_record_size_hint()接口。不过要注意,这些API在不同版本中行为略有差异,建议先做兼容性测试。
4.4 实际调优中的几个坑
最后,分享几个我在实际调优中踩过的坑:
- 坑一:PMTU探测不可靠。有些网络设备会丢弃ICMP“需要分片”消息,导致PMTU黑洞。这时候TLS记录层会一直发大记录,然后一直丢包。解决方案是启用TCP的PLPMTUD(Packetization Layer Path MTU Discovery),或者干脆手动设置一个保守值。
- 坑二:动态调整和TCP Offload打架。有些网卡支持TSO(TCP Segmentation Offload),会把大TCP段在硬件层面分片。如果TLS记录层也在做分片,两者叠加反而降低效率。我建议在启用TSO时,把TLS记录大小设大一些(如8KB),让网卡去处理分片。
- 坑三:记录大小和延迟的权衡。小记录延迟低,但吞吐差。大记录吞吐高,但延迟大。对于实时性要求高的场景(如WebRTC、在线游戏),我建议固定使用小记录(如256字节),牺牲吞吐换延迟。