标签 TCP 下的文章

本文为达坦科技DatenLord新系列文章【开源周报】的第8篇。

设立这一系列的初衷,是为了更透明地分享达坦科技开源项目的成长轨迹。在这里,我们不仅会同步项目近期的核心开发进展与技术突破,更将通过路线图为您揭示未来的演进方向。

📍 项目地址与参与

GitHub 仓库:

https://github.com/open-rdma/open-rdma-driver

我们诚挚邀请所有对高性能网络、Rust系统编程或RDMA技术感兴趣的朋友点击链接关注、支持我们的项目。开源的力量源于社区。您的每一次关注、讨论或代码贡献,都是项目前进的重要动力。期待与您携手,共建更完善的高性能基础设施生态。

01本周进展

本周核心目标:解决QP带外传输端口冲突问题,优化内存管理模块结构,提升测试基础设施

本周主要完成了QP带外传输的端口冲突修复、内存管理模块的重构以及测试框架的系统性完善,为RCCL集成和后续功能开发提供了更稳定的基础。

1. Send/Recv QP带外传输优化 (commits: 24d009d, c4839d5)

问题背景:

  • QP带外传输使用的TCP监听端口存在冲突问题
  • 原有设计中每个QP独立建立TCP连接,使用基于QPN哈希的端口号,容易产生端口冲突
  • 多设备场景(如仿真模式下的blue0/blue1)无法正确区分

核心改进:

端口冲突问题修复 (commit: 24d009d)

原有设计问题:

  • 每个QP尝试监听一个基于QPN哈希计算的端口(qpn\_to\_port函数)
  • 多个QPN可能哈希到同一端口,导致监听失败
  • TCP连接数过多,每个QP一个连接

新设计方案:

  • 统一端口监听:使用固定的 RECV\_WORKER\_PORT (60000) 端口
  • IP级别连接复用(IpTxTable):
  • 按目标IP地址管理TCP连接,而非按QPN
  • 同一IP的所有QP共享一个TCP连接
  • 在消息中携带QPN信息(RecvWrQpn结构体)
  • 接收端统一调度(RecvWorkers):
  • 根据消息中的QPN和源IP查找对应的本地QP
  • 统一管理所有QP的recv wr接收
  • 避免端口冲突:
  • 使用socket2库的bind功能绑定本地地址
  • 客户端连接时自动分配临时端口,避免冲突
  • 多设备支持:
  • 根据sysfs\_name(uverbs0/uverbs1)动态选择网卡(blue0/blue1)
  • 每个设备使用独立的IP地址

统计数据:

  • 11个文件改动
  • 新增325行,删除257行
  • recv\_chan.rs重构426行

RCCL场景适配和多线程安全修复 (commit: c4839d5)

针对RCCL场景的优化:

  • 多线程安全:将 Rc<RefCell<>> 改为 Arc<Mutex<>>,支持跨线程共享
  • 硬件模式支持:硬件模式也改用基于sysfs\_name的动态设备选择
  • 错误处理改进:使用更清晰的panic信息,便于问题定位
  • RCCL配置优化:
  • 添加 NCCL\_IB\_GID\_INDEX=3 配置
  • 修复 RecvWrQpn 序列化的buffer大小问题
  • 改进dest\_qp\_ip的处理逻辑

统计数据:

  • 8个文件改动
  • 新增66行,删除57行

2. 内存管理模块重构 (commit: 09b72ea)

重构背景:

  • mem模块的文件组织结构不够清晰
  • umem(user memory)处理逻辑分散
  • 缺少硬件环境和仿真环境的统一抽象

核心改进:

新增umem子模块

设计目标:为不同环境提供统一的用户内存处理接口

新增umem子模块(rust-driver/src/mem/umem/):

  • 提供硬件和仿真两种环境的统一抽象
  • host.rs - 真实硬件环境的内存处理(61行)
  • emulated.rs - RTL仿真器环境的内存处理(88行)
  • 支持DMA映射和页表管理

模块结构优化

  • 精简mem/mod.rs:删除134行冗余代码,将具体实现下沉到子模块
  • 重构页表管理:优化page/host.rs逻辑(161行改动),保留旧实现便于参考
  • 删除冗余模块:移除u\_dma\_buf.rs(119行),功能已由umem模块覆盖

统计数据:

  • 10个文件改动
  • 新增865行,删除319行
  • 主要新增:umem/emulated.rs (88行)、umem/host.rs (61行)
  • 主要删除:u\_dma\_buf.rs (119行)、mod.rs精简 (134行)

新增RCCL分析文档

同时新增了详细的RCCL GID选择和默认IP分析文档(508行),为RCCL集成提供参考。

效果:

  • 建立了清晰的硬件/仿真环境抽象
  • 统一了用户内存处理接口
  • 为后续GPU内存支持奠定基础
  • 提升了代码的可维护性

3. 测试基础设施完善 (commit: 26d6553)

改进背景:

  • 测试脚本缺少统一文档和入口
  • 调试辅助工具不足
  • 测试用例需要优化

核心改进:

新增调试库和文档

  • rdma\_debug调试库(77行):提供状态打印、数据校验等调试辅助功能
  • 完整测试文档(335行README.md):包含详细的脚本清单、使用说明和示例

统一测试入口

  • run\_all\_tests.sh(108行):一键运行所有测试,自动收集结果和生成报告

测试脚本和用例优化

  • 新增/改进测试脚本,删除过时脚本
  • 更新测试程序以适配新的WR逻辑和优化测试覆盖

统计数据:

  • 12个文件改动
  • 新增641行,删除103行
  • 核心新增:README.md (335行)、run\_all\_tests.sh (108行)、rdma\_debug.c (77行)

效果:

  • 测试流程更加标准化
  • 调试能力显著提升
  • 降低了测试使用门槛
  • 提高了问题定位效率

4. 其他改进

  • RCCL测试脚本修复 (commit: c9e3f90)
  • 为RCCL测试添加hack\_libc编译步骤
  • 更新测试文档
  • 工程维护 (commit: b6dfc59)
  • 更新.gitignore规则

02解决的关键问题

1. QP带外传输端口冲突问题

问题:QP使用TCP进行带外WR交换时,基于QPN哈希的端口分配机制导致端口冲突

根因:

  • 每个QP尝试监听独立端口,使用 qpn\_to\_port 哈希函数计算
  • 多个QPN可能哈希到同一端口,导致监听失败
  • 在RCCL等多QP场景下问题尤为明显

解决:

  • 改用统一的固定端口(60000)进行监听
  • 引入IpTxTable实现IP级别的连接复用,减少TCP连接数
  • 在消息中携带QPN信息,接收端根据QPN分发
  • 使用socket2库绑定本地地址,避免客户端端口冲突
  • 支持多设备场景(blue0/blue1)

状态:已完成,RCCL场景测试通过

2. 内存管理模块结构混乱

问题:umem处理逻辑分散,硬件和仿真环境的代码耦合

解决:

  • 新增umem子模块,提供HostUmemHandler和EmulatedUmemHandler
  • 删除冗余的u\_dma\_buf.rs模块
  • 重构page/host.rs,优化页表管理

状态:已完成

  1. 测试框架不完善

问题:测试脚本缺少文档,调试工具不足,测试流程不规范

解决:

  • 新增335行完整的README.md文档
  • 实现rdma\_debug调试库(77行)
  • 提供run\_all\_tests.sh统一测试入口(108行)
  • 改进多个测试用例的实现

状态:已完成

03下周规划

短期任务(最高优先级)

完善QP带外传输并进行RCCL集成测试

  • 为recv\_chan重构添加详细注释和文档
  • 运行完整的send/recv测试套件,验证端口冲突修复的有效性
  • 在仿真模式和RCCL场景下进行压力测试和性能验证
  • 验证IP级别连接复用的稳定性和性能优势
  • 修复RCCL场景下的已知问题
  • 对比重构前后的TCP连接数和性能变化

DMA Buffer系统重构(重构计划优先级最高)

  • 核心问题:
  • mlock不能保证地址一定不变
  • 需要支持dma-buf机制
  • PAGE\_SIZE大小需要讨论(当前采用64k页面大小以支持GPU)
  • 具体任务:
  • 设计更可靠的内存固定机制
  • 调研dma-buf内核接口的实现细节
  • 评估可变页面大小的可行性
  • 预期效果:
  • 提升内存管理的可靠性
  • 为GPU内存注册奠定基础

中期任务

Driver基础模块重构(重构计划优先级最高)

  • ring模块持续完善:
  • 补充ProducerRing、ConsumerRing的文档和注释
  • 添加单元测试验证同步逻辑正确性
  • 优化性能和错误处理
  • mem模块持续重构:
  • virt\_to\_phy接口优化:区分CPU内存和GPU内存的地址转换,为dma-buf支持打下基础
  • 地址类型系统完善:完成已开始的地址类型区分工作,提升类型安全性
  • GPU内存支持准备:基于新的umem抽象设计GPU内存handler,实现ibv\_reg\_dmabuf\_mr verbs支持

仿真器稳定性提升

  • 解决高压稳定性问题(遗留):

ImmAssert failed in mkBsvTopWithoutHardIpInstance.topLevelDmaChannelMux
DataStream checkFullyPipeline Failed: delta=23

  • 在重构后重新验证问题是否仍然存在
  • 深入调试流水线控制逻辑

完善cocotb仿真器测试代码

  • 使用cocotb-pcie库实现更完善的硬件仿真
  • 将cocotb升级到2.0版本
  • 提升仿真器的稳定性和可靠性

长期任务(暂缓,等待硬件代码稳定)

Worker模块和生命周期管理优化(暂缓)

  • 说明:由于后续会逐步修改硬件代码,worker的交互逻辑和资源管理可能会变化
  • 当前策略:保持能用即可,暂不进行大规模重构
  • 待解决问题(记录备查):
  • worker之间的交互逻辑过于复杂
  • 多线程程序的错误处理困难
  • 存在大量轮询,可考虑改为async框架
  • 重传worker的定时器参数不合理(当前5天)
  • 资源manager需要实现drop避免手动释放
  • QP资源申请和释放流程需要优化
  • 解决QP地址冲突引入的hashmap需要析构

04本周总结

本周主要完成了Send/Recv功能修复、内存管理重构和测试基础设施完善三大任务:

成果:

  1. QP带外传输优化:解决了端口冲突问题,实现了IP级别的连接复用,支持RCCL等多QP场景(426行recv\_chan重构)
  2. 内存管理优化:新增umem子模块,建立了硬件/仿真环境的统一抽象,删除了119行冗余代码
  3. 测试框架完善:新增335行测试文档、108行统一测试脚本、77行调试库,大幅提升测试规范性
  4. 代码质量提升:共42个文件改动,新增1397行,删除538行,净增859行高质量代码

挑战:

  1. 端口冲突修复验证:recv\_chan的426行重构改变了TCP连接管理模式,需要充分测试确保未引入regression
  2. 功能与重构平衡:在推进新功能的同时,需要持续优化现有代码架构,特别是DMA buffer系统的重构
  3. GPU内存支持准备:需要在现有架构基础上设计可扩展的GPU内存管理方案

下周重点: 完善QP带外传输的测试和文档,在RCCL场景下进行充分验证;重点推进DMA Buffer系统重构和mem模块优化,为GPU内存支持打好基础。

达坦科技始终致力于打造高性能AI+Cloud基础设施平台,积极推动AI应用的落地。达坦科技通过软硬件深度融合的方式,提供AI推理引擎和高性能网络,为AI应用提供弹性、便利、经济的基础设施服务,以此满足不同行业客户对AI+Cloud的需求。

公众号​:达坦科技DatenLord

DatenLord官网

https://datenlord.github.io/zh-cn/

知乎账号:

https://www.zhihu.com/org/da-tan-ke-ji

B站​:

https://space.bilibili.com/2017027518

邮箱:info@datenlord.com

如果您有兴趣加入达坦科技Rust前沿技术交流群、硬件敏捷开发和验证方法学讨论群或AI Infra ​交流群,请添加小助手微信:DatenLord\_Tech

JuiceFS 企业版 5.3 近日发布,单文件系统支持超 5,000 亿文件,实现里程碑式突破。此次升级针对元数据多分区架构进行了多项关键优化,并首次引入 RDMA 技术,以提升分布式缓存效率;此外,5.3 版本还增强了可写镜像,为跨桶导入的对象提供数据缓存等多项功能,旨在支持高性能要求及多云应用场景。

JuiceFS 企业版专为高性能场景设计。自 2019 年起开始应用于机器学习领域,现已成为 AI 行业核心基础设施之一。商业客户涵盖大模型公司:MiniMax、智谱 AI、阶跃星辰;AI 基础设施及应用如 Fal.ai、HeyGen 等;自动驾驶领域的 Momenta、地平线等,以及众多应用 AI 技术的各行业领先科技企业。

01 单文件系统支持超 5,000 亿文件

多分区架构是 JuiceFS 应对千亿文件规模的关键技术之一,保证了系统的高扩展性和高并发处理能力。为了继续满足如自动驾驶场景业务增长的需求,5.3 版本对多分区架构进行了深入优化,将分区数量限制提高到 1,024 个,单文件系统能够存储和访问至少 5,000 亿个文件。(每个分区可存储 5 亿个文件,最大支持 20 亿)。

这一突破对系统性能、数据一致性、稳定性要求提出了几何级的难度,背后是一系列繁杂的底层优化与研发工作。

关键优化 1 - 分区间热点均衡:自动监测和热点迁移;提供手动运维工具

在分布式系统中,热点问题是常见的挑战,特别是当数据被分布到多个分区时,某些分区的负载可能比其他分区更高,这种不均衡会引发热点问题,影响系统的性能。

当分区数量达到数百时,热点问题变得更加普遍。尤其是在数据集较小、涉及的文件数量较多的情况下,读写热点问题会加剧,进一步增加延迟波动。

我们引入了自动化的热点迁移机制,将访问频繁的文件迁移到其他分区,从而分担负载并降低特定分区的压力。然而在实际环境中,我们发现仅依赖自动迁移并不能完全解决所有问题。特别是在某些特殊场景或极端情况下,自动迁移工具可能无法及时应对。因此,我们在自动监测和迁移的基础上,增加了手动运维工具,允许运维人员在遇到复杂场景时介入,进行人工分析并实施优化方案

关键优化 2 - 大规模迁移:提升迁移速度,少量多次并发迁移

面对热点过高的分区,早期的迁移操作比较简单,但随着系统规模扩大,迁移效率逐渐降低。为此,我们引入了“少量多次并发迁移”的策略,将高访问量的目录分解成多个小块,并行迁移到多个负载较低的分区,从而迅速分散热点,恢复业务的正常访问体验。

关键优化 3 - 强化可靠性自检:自动修复与清理迁移中间态文件

在大规模集群中,分布式事务的失败概率显著上升,特别是在大量迁移过程中。为应对这一问题,我们增强了可靠性检测机制,增加了后台周期性的检查功能,定期扫描跨分区文件的状态,特别关注中间状态问题,并自动进行修复和清理

此前,系统曾遇到过中间状态数据残留的问题,虽然短期内未影响系统运行,但随着时间推移,这些残留数据可能导致错误。通过增强的自检机制,我们确保了后台能够定期扫描并及时处理中间状态问题,从而提升了系统的稳定性和可靠性。

除了上述三项关键优化外,我们还在控制台进行了多项改进,以更好地适应更多分区的管理需求。我们优化了并发处理、运维操作和查询展示,提升了整体性能和用户体验。特别是,在 UI 设计方面,我们做了优化,以便更好地展示大规模分区环境下的系统状态。

千亿文件性能压测:稳定性与资源利用良好

我们在谷歌云上使用自定义的 mdtest 测试工具进行了大规模测试,部署了 60 个节点,每个节点的内存超过 1 TB。在软件配置方面,我们将分区数增加至 1,024 个。部署方式与之前类似,为了降低内存消耗,我们选择仅部署一个服务进程,另两个作为冷备。

  • 测试持续时间:大约 20 小时
  • 写入的文件总数:约 4,000 亿个文件
  • 每秒写入速度:500 万个文件
  • 内存占用:约 35% 到 40%
  • 硬盘使用: 40% 到 50%,主要用于元数据的持久化,使用情况良好

根据我们的经验,如果采用一个服务进程、一个热备进程和一个冷备进程的配置,内存占用会增加 20% 到 30%。

由于云端资源有限,本次测试只写到 4,000 亿文件。在压测过程中,系统表现稳定,且硬件资源尚有富余。后续,我们会继续尝试更大规模的测试。

02 首次支持 RDMA:带宽上限提升,CPU 占用降低

在此次新版本中首次支持了 RDMA(Remote Direct Memory Access)技术,它的基本原理架构如下图所示。RDMA 通过允许直接访问远程节点的内存,绕过操作系统的网络协议栈,显著提高了数据传输效率。

RDMA 的主要优点包括:

  1. 低延迟:通过直接从内存到内存的传输,绕过操作系统的网络协议层,减少 CPU 的中断和上下文切换,从而降低延迟。
  2. 高吞吐量:RDMA 通过硬件直接传输数据,能够更好地发挥网卡(NIC)的带宽。
  3. 减少 CPU 占用:在 RDMA 中,数据的拷贝几乎全部由网卡完成,CPU 仅用于处理控制消息。这样,网卡负责硬件传输,释放了 CPU 的资源。

在 JuiceFS 中,客户端与元数据服务之间的网络请求消息都较小,现有的 TCP 配置已能满足需求。而在分布式缓存中,客户端与缓存节点之间传输的是文件数据,使用 RDMA 可以有效提升传输效率,降低 CPU 消耗。

我们使用了 160 Gbps 网卡进行 1MB 随机读测试,比较了 5.1、 5.2(使用 TCP 网络) 和 5.3 版本(RDMA),并观察了 CPU 占用情况。测试表明,RDMA 有效降低了 CPU 占用。在 5.2 版本中,CPU 占用了近 50%;而在 5.3 版本中,通过 RDMA 优化,CPU 占用降至约 1/3。客户端和缓存节点的 CPU 占用分别降至 8 核和 5 核,带宽达到了 20 GiB/s

在以往的测试中,我们发现 TCP 在 200G 网卡下虽然稳定运行,但要完全拉满带宽仍有困难,通常只能达到 85-90% 的带宽利用率。对于需要更高带宽(如 400G 网卡)的客户,TCP 无法满足需求,而 RDMA 能够更容易地发挥硬件带宽上限,提供更优的传输效率

如果用户的硬件支持 RDMA 且存在高带宽需求(如网卡大于 100G),同时希望降低 CPU 占用,那么 RDMA 是值得尝试的技术。目前,我们的 RDMA 功能处于公测阶段,尚未在生产环境中广泛部署。

03 可写镜像增强

最初,镜像集群主要用于企业产品中的只读镜像。随着用户提出在镜像中写入临时文件(如训练数据)等需求,我们为此提供了可写镜像功能。

镜像客户端在实现时采用了读写分离机制。客户端在读取数据时优先从镜像集群获取,以降低延迟;而写入数据时,仍然需要写入源集群,以确保数据一致性。通过元数据版本号的记录与对比,我们确保了镜像客户端和源集群客户端看到的数据保持强一致性。

为了提升可用性,我们在 5.3 版本引入了回退机制,即当镜像不可用时,客户端的读请求能自动回退到源集群,从而保证业务连续性,避免镜像集群故障导致的业务中断。我们还优化了多镜像环境的部署。原先,镜像端需要部署两个热备节点以确保高可用性。现在,通过改进的回退功能,部署一个镜像节点也能实现类似的效果,确保业务连续性并降低成本,尤其适用于需要多个镜像的用户。

通过这一改进,我们不仅降低了硬件成本,还在高可用性和低成本之间找到了平衡。对于那些在多个地点部署镜像的用户,减少元数据副本的同时进一步降低了总体成本。

04 简化运维管理,提升灵活性:为导入对象提供跨桶数据缓存

在 JuiceFS 中,用户可以使用 import 命令将对象存储中的现有文件导入并统一管理。这对于已经存储大量数据(如几十 PB)的用户来说十分便捷。但在之前版本中,这一功能仅支持为同一数据桶中的对象提供缓存,意味着导入的对象必须与现有文件系统数据处于同一个桶内。这一限制在实际使用中带来了一定局限性。

在 5.3 版本中,我们对该功能进行了改进。现在,用户可以为任何导入的对象提供缓存能力,无论这些对象是否来自同一数据桶。这样,用户可以更加灵活地管理不同数据桶中的对象,避免了对数据桶的严格限制,从而提升了数据管理的自由度。

此外,以前如果用户将数据分布在多个桶中,想要为这些桶中的数据提供缓存能力,需要为每个桶新建一个文件系统。而在 5.3 版本中,用户只需创建一个文件系统(volume),便可统一管理多个桶的数据,并为所有桶提供缓存能力。

05 其他重要优化

Trace 功能

我们新增了 trace 功能,这是 Go 语言本身提供的一个特性。通过这个功能,资深用户可以进行追踪和性能分析,获得更多信息,帮助我们快速定位问题。

回收站恢复

在之前的版本中,特别是在多分区的情况下,有时回收站记录的路径不完整,导致恢复时出现异常,未能恢复到预期位置。为了解决这个问题,在 5.3 版本中,在删除文件时,我们会记录文件的原始路径,确保恢复时能够提供更可靠的恢复能力。

Python SDK 改进

在前几个版本中,我们发布了 Python SDK,它提供了基础的读写功能,方便 Python 用户与我们的系统对接。在 5.3 版本中,我们不仅加强了基础读写功能,还增加了对运维子命令的支持。例如,用户可以直接通过 SDK 调用 juicefs info 或 warmup 等命令,而不需要依赖外部系统命令。这不仅简化了编码工作,并且避免了频繁调用外部命令时可能产生的性能瓶颈。

Windows 客户端

我们在之前版本中推出了 Windows 客户端 Beta 版本,并已获得不少用户反馈。经过改进,当前版本在挂载的可靠性、性能以及与 Linux 系统的兼容性上都有了显著提升。未来,我们计划进一步完善 Windows 客户端,为依赖 Windows 的用户提供更接近 Linux 的体验。

06 小结

相较于昂贵的专用硬件,JuiceFS 通过灵活地利用云上或客户现有的存储资源,帮助用户在应对数据增长时平衡性能与成本。在 5.3 版本中,通过优化元数据分区架构,单文件系统可支持超过 5,000 亿个文件。首次引入的 RDMA 技术显著提升了分布式缓存带宽和数据访问效率,减少了 CPU 占用,进一步优化了系统性能。此外,我们还优化了可写镜像、缓存等多项功能,提升了大规模集群的性能和运维效率,优化用户体验。

云服务用户现已可以直接在线体验 JuiceFS 企业版 5.3,私有部署用户可通过官方渠道获得升级支持。我们将继续专注于高性能存储解决方案,和企业一起应对数据量的持续增长所带来的挑战。

如果你在存储架构设计、成本控制或性能优化中遇到过问题,或有相关实践心得,欢迎在评论区留言。

今天来讲一讲TCP 的 TIME_WAIT 的问题。这个问题尽人皆知,不过,这次遇到的是不太一样的场景,前两天也解决了,正好写篇文章,顺便把 TIME_WAIT 的那些事都说一说。对了,这个场景,跟我开源的探活小工具 EaseProbe 有关,我先说说这个场景里的问题,然后,顺着这个场景跟大家好好说一下这个事。

目录

问题背景

先说一下背景,EaseProbe 是一个轻量独立的用来探活服务健康状况的小工具,支持http/tcp/shell/ssh/tls/host以及各种中间件的探活,然后,直接发送通知到主流的IM上,如:Slack/Telegram/Discrod/Email/Team,包括国内的企业微信/钉钉/飞书, 非常好用,用过的人都说好 😏。

这个探活工具在每次探活的时候,必须要从头开始建立整个网络链接,也就是说,需要从头开始进行DNS查询,建立TCP链接,然后进行通信,再关闭链接。这里,我们不会设置 TCP 的 KeepAlive 重用链接,因为探活工具除了要探活所远端的服务,还要探活整个网络的情况,所以,每次探活都需要从新来过,这样才能捕捉得到整个链路的情况。

但是,这样不断的新建链接和关闭链接,根据TCP的状态机,我们知道这会导致在探测端这边出现的 TIME_WAIT 的 TCP 链接,根据 TCP 协议的定义,这个 TIME_WAIT 需要等待 2倍的MSL 时间,TCP 链接都会被系统回收,在回收之前,这个链接会占用系统的资源,主要是两个资源,一个是文件描述符,这个还好,可以调整,另一个则是端口号,这个是没法调整的,因为作为发起请求的client来说,在对同一个IP上理论上你只有64K的端口号号可用(实际上系统默认只有近30K,从32,768 到 60,999 一共 60999+1-32768=28,232,你可以通过 sysctl net.ipv4.ip_local_port_range 查看  ),如果 TIME_WAIT 过多,会导致TCP无法建立链接,还会因为资源消耗太多导致整个程序甚至整个系统异常。

试想,如果我们以 10秒为周期探测10K的结点,如果TIME_WAIT的超时时间是120秒,那么在第60秒后,等着超时的 TIME_WAIT 我们就有可能把某个IP的端口基本用完了,就算还行,系统也有些问题。(注意:我们不仅仅只是TCP,还有HTTP协议,所以,大家不要觉得TCP的四元组只要目标地址不一样就好了,一方面,我们探的是域名,需要访问DNS服务,所以,DNS服务一般是一台服务器,还有,因为HTTPS一般是探API,而且会有网关代理API,所以链接会到同一个网关上。另外就算还可以建出站连接,但是本地程序会因为端口耗尽无法bind了。所以,现实情况并不会像理论情况那样只要四元组不冲突,端口就不会耗尽)

为什么要 TIME_WAIT

那么,为什么TCP在 TIME_WAIT 上要等待一个2MSL的时间?

以前写过篇比较宏观的《TCP的那些事》(上篇下篇),这个访问在“上篇”里讲过,这里再说一次,TCP 断链接的时候,会有下面这个来来回回的过程。

我们来看主动断链接的最后一个状态 TIME_WAIT 后就不需要等待对端回 ack了,而是进入了超时状态。这主要是因为,在网络上,如果要知道我们发出的数据被对方收到了,那我们就需要对方发来一个确认的Ack信息,那问题来了,对方怎么知道自己发出去的ack,被收到了?难道还要再ack一下,这样ack来ack回的,那什么谁也不要玩了……是的,这就是比较著名的【两将军问题】——两个将军需要在一个不稳定的信道上达成对敌攻击时间的协商,A向B派出信鸽,我们明早8点进攻,A怎么知道B收到了信?那需要B向A派出信鸽,ack说我收到了,明早8点开干。但是,B怎么知道A会收到自己的确认信?是不是还要A再确认一下?这样无穷无尽的确认导致这个问题是没有完美解的(我们在《分布式事务》一文中说过这个问题,这里不再重述)

所以,我们只能等一个我们认为最大小时来解决两件个问题:

1) 为了 防止来自一个连接的延迟段被依赖于相同四元组(源地址、源端口、目标地址、目标端口)的稍后连接接受(被接受后,就会被马上断掉,TCP状态机紊乱)。虽然,可以通过指定 TCP 的 sequence number 一定范围内才能被接受。但这也只是让问题发生的概率低了一些,对于一个吞吐量大的的应用来说,依然能够出现问题,尤其是在具有大接收窗口的快速连接上。RFC 1337详细解释了当 TIME-WAIT状态不足时会发生什么。TIME-WAIT以下是如果不缩短状态可以避免的示例:

由于缩短的 TIME-WAIT 状态,后续的 TCP 段已在不相关的连接中被接受(来源

 

2)另一个目的是确保远端已经关闭了连接。当最后一个ACK​​ 丢失时,对端保持该LAST-ACK状态。在没有TIME-WAIT状态的情况下,可以重新打开连接,而远程端仍然认为先前的连接有效。当它收到一个SYN段(并且序列号匹配)时,它将以RST应答,因为它不期望这样的段。新连接将因错误而中止:

 

如果远端因为最后一个 ACK​​ 丢失而停留在 LAST-ACK 状态,则打开具有相同四元组的新连接将不起作用 (来源

TIME_WAIT 的这个超时时间的值如下所示:

  • 在 macOS 上是15秒, sysctl net.inet.tcp | grep net.inet.tcp.msl
  • 在 Linux 上是 60秒 cat /proc/sys/net/ipv4/tcp_fin_timeout

解决方案

要解决这个问题,网上一般会有下面这些解法

  • 把这个超时间调小一些,这样就可以把TCP 的端口号回收的快一些。但是也不能太小,如果流量很大的话,TIME_WAIT一样会被耗尽。
  • 设置上 tcp_tw_reuse 。RFC 1323提出了一组 TCP 扩展来提高高带宽路径的性能。除其他外,它定义了一个新的 TCP 选项,带有两个四字节时间戳字段。第一个是发送选项的 TCP 时间戳的当前值,而第二个是从远程主机接收到的最新时间戳。如果新时间戳严格大于为前一个连接记录的最新时间戳。Linux 将重用该状态下的现有 TIME_WAIT 连接用于出站的链接。也就是说,这个参数对于入站连接是没有任何用图的。
  • 设置上 tcp_tw_recycle 。 这个参数同样依赖于时间戳选项,但会影响进站和出站链接。这个参数会影响NAT环境,也就是一个公司里的所有员工用一个IP地址访问外网的情况。在这种情况下,时间戳条件将禁止在这个公网IP后面的所有设备在一分钟内连接,因为它们不共享相同的时间戳时钟。毫无疑问,禁用此选项要好得多,因为它会导致 难以检测诊断问题。(注:从 Linux 4.10 (commit 95a22caee396 ) 开始,Linux 将为每个连接随机化时间戳偏移量,从而使该选项完全失效,无论有无NAT。它已从 Linux 4.12中完全删除)

对于服务器来说,上述的三个访问都不能解决服务器的 TIME_WAIT 过多的问题,真正解决问题的就是——不作死就不会死,也就是说,服务器不要主动断链接,而设置上KeepAlive后,让客户端主动断链接,这样服务端只会有CLOSE_WAIT

但是对于用于建立出站连接的探活的 EaseProbe来说,设置上 tcp_tw_reuse 就可以重用 TIME_WAIT 了,但是这依然无法解决 TIME_WAIT 过多的问题。

然后,过了几天后,我忽然想起来以前在《UNIX 网络编程》上有看到过一个Socket的参数,叫 <code>SO_LINGER,我的编程生涯中从来没有使用过这个设置,这个参数主要是为了延尽关闭来用的,也就是说你应用调用 close()函数时,如果还有数据没有发送完成,则需要等一个延时时间来让数据发完,但是,如果你把延时设置为 0  时,Socket就丢弃数据,并向对方发送一个 RST 来终止连接,因为走的是 RST 包,所以就不会有 TIME_WAIT 了。

这个东西在服务器端永远不要设置,不然,你的客户端就总是看到 TCP 链接错误 “connnection reset by peer”,但是这个参数对于 EaseProbe 的客户来说,简直是太完美了,当EaseProbe 探测完后,直接 reset connection, 即不会有功能上的问题,也不会影响服务器,更不会有烦人的 TIME_WAIT 问题。

Go 实际操作

在 Golang的标准库代码里,net.TCPConn 有个方法 SetLinger()可以完成这个事,使用起来也比较简单:

conn, _ := net.DialTimeout("tcp", t.Host, t.Timeout())

if tcpCon, ok := conn.(*net.TCPConn); ok {
    tcpCon.SetLinger(0)
}

你需要把一个 net.Conn  转型成 net.TCPConn,然后就可以调用方法了。

但是对于Golang 的标准库中的 HTTP 对象来说,就有点麻烦了,Golang的 http 库把底层的这边连接对象全都包装成私有变量了,你在外面根本获取不到。这篇《How to Set Go net/http Socket Options – setsockopt() example 》中给出了下面的方法:

dialer := &net.Dialer{
    Control: func(network, address string, conn syscall.RawConn) error {
        var operr error
        if err := conn.Control(func(fd uintptr) {
            operr = syscall.SetsockoptInt(int(fd), unix.SOL_SOCKET, unix.TCP_QUICKACK, 1)
        }); err != nil {
            return err
        }
        return operr
    },
}

client := &http.Client{
    Transport: &http.Transport{
        DialContext: dialer.DialContext,
    },
}

上面这个方法非常的低层,需要直接使用setsocketopt这样的系统调用,我其实,还是想使用 TCPConn.SetLinger(0) 来完成这个事,即然都被封装好了,最好还是别破坏封闭性碰底层的东西。

经过Golang http包的源码阅读和摸索,我使用了下面的方法:

client := &http.Client{
    Timeout: h.Timeout(),
    Transport: &http.Transport{
      TLSClientConfig:   tls,
      DisableKeepAlives: true,
      DialContext: func(ctx context.Context, network, addr string) (net.Conn, error) {
        d := net.Dialer{Timeout: h.Timeout()}
        conn, err := d.DialContext(ctx, network, addr)
        if err != nil {
          return nil, err
        }
        tcpConn, ok := conn.(*net.TCPConn)
        if ok {
          tcpConn.SetLinger(0)
          return tcpConn, nil
        }
        return conn, nil
      },
    },
  }

然后,我找来了全球 T0p 100W的域名,然后在AWS上开了一台服务器,用脚本生成了 TOP 10K 和 20K 的网站来以5s, 10s, 30s, 60s的间隔进行探活,搞到Cloudflare 的 1.1.1.1 DNS 时不时就把我拉黑,最后的测试结果也非常不错,根本 没有 TIME_WAIT 的链接,相关的测试方法、测试数据和测试报告可以参看:Benchmark Report

总结

下面是几点总结

  • TIME_WAIT 是一个TCP 协议完整性的手段,虽然会有一定的副作用,但是这个设计是非常关键的,最好不要妥协掉。
  • 永远不要使用  tcp_tw_recycle ,这个参数是个巨龙,破坏力极大。
  • 服务器端永远不要使用  SO_LINGER(0),而且使用 tcp_tw_reuse 对服务端意义不大,因为它只对出站流量有用。
  • 在服务端上最好不要主动断链接,设置好KeepAlive,重用链接,让客户端主动断链接。
  • 在客户端上可以使用 tcp_tw_reuse  和 SO_LINGER(0)

最后强烈推荐阅读这篇文章 – Coping with the TCP TIME-WAIT state on busy Linux servers

今天来讲一讲TCP 的 TIME_WAIT 的问题。这个问题尽人皆知,不过,这次遇到的是不太一样的场景,前两天也解决了,正好写篇文章,顺便把 TIME_WAIT 的那些事都说一说。对了,这个场景,跟我开源的探活小工具 EaseProbe 有关,我先说说这个场景里的问题,然后,顺着这个场景跟大家好好说一下这个事。

目录

问题背景

先说一下背景,EaseProbe 是一个轻量独立的用来探活服务健康状况的小工具,支持http/tcp/shell/ssh/tls/host以及各种中间件的探活,然后,直接发送通知到主流的IM上,如:Slack/Telegram/Discrod/Email/Team,包括国内的企业微信/钉钉/飞书, 非常好用,用过的人都说好 😏。

这个探活工具在每次探活的时候,必须要从头开始建立整个网络链接,也就是说,需要从头开始进行DNS查询,建立TCP链接,然后进行通信,再关闭链接。这里,我们不会设置 TCP 的 KeepAlive 重用链接,因为探活工具除了要探活所远端的服务,还要探活整个网络的情况,所以,每次探活都需要从新来过,这样才能捕捉得到整个链路的情况。

但是,这样不断的新建链接和关闭链接,根据TCP的状态机,我们知道这会导致在探测端这边出现的 TIME_WAIT 的 TCP 链接,根据 TCP 协议的定义,这个 TIME_WAIT 需要等待 2倍的MSL 时间,TCP 链接都会被系统回收,在回收之前,这个链接会占用系统的资源,主要是两个资源,一个是文件描述符,这个还好,可以调整,另一个则是端口号,这个是没法调整的,因为作为发起请求的client来说,在对同一个IP上理论上你只有64K的端口号号可用(实际上系统默认只有近30K,从32,768 到 60,999 一共 60999+1-32768=28,232,你可以通过 sysctl net.ipv4.ip_local_port_range 查看  ),如果 TIME_WAIT 过多,会导致TCP无法建立链接,还会因为资源消耗太多导致整个程序甚至整个系统异常。

试想,如果我们以 10秒为周期探测10K的结点,如果TIME_WAIT的超时时间是120秒,那么在第60秒后,等着超时的 TIME_WAIT 我们就有可能把某个IP的端口基本用完了,就算还行,系统也有些问题。(注意:我们不仅仅只是TCP,还有HTTP协议,所以,大家不要觉得TCP的四元组只要目标地址不一样就好了,一方面,我们探的是域名,需要访问DNS服务,所以,DNS服务一般是一台服务器,还有,因为HTTPS一般是探API,而且会有网关代理API,所以链接会到同一个网关上。另外就算还可以建出站连接,但是本地程序会因为端口耗尽无法bind了。所以,现实情况并不会像理论情况那样只要四元组不冲突,端口就不会耗尽)

为什么要 TIME_WAIT

那么,为什么TCP在 TIME_WAIT 上要等待一个2MSL的时间?

以前写过篇比较宏观的《TCP的那些事》(上篇下篇),这个访问在“上篇”里讲过,这里再说一次,TCP 断链接的时候,会有下面这个来来回回的过程。

我们来看主动断链接的最后一个状态 TIME_WAIT 后就不需要等待对端回 ack了,而是进入了超时状态。这主要是因为,在网络上,如果要知道我们发出的数据被对方收到了,那我们就需要对方发来一个确认的Ack信息,那问题来了,对方怎么知道自己发出去的ack,被收到了?难道还要再ack一下,这样ack来ack回的,那什么谁也不要玩了……是的,这就是比较著名的【两将军问题】——两个将军需要在一个不稳定的信道上达成对敌攻击时间的协商,A向B派出信鸽,我们明早8点进攻,A怎么知道B收到了信?那需要B向A派出信鸽,ack说我收到了,明早8点开干。但是,B怎么知道A会收到自己的确认信?是不是还要A再确认一下?这样无穷无尽的确认导致这个问题是没有完美解的(我们在《分布式事务》一文中说过这个问题,这里不再重述)

所以,我们只能等一个我们认为最大小时来解决两件个问题:

1) 为了 防止来自一个连接的延迟段被依赖于相同四元组(源地址、源端口、目标地址、目标端口)的稍后连接接受(被接受后,就会被马上断掉,TCP状态机紊乱)。虽然,可以通过指定 TCP 的 sequence number 一定范围内才能被接受。但这也只是让问题发生的概率低了一些,对于一个吞吐量大的的应用来说,依然能够出现问题,尤其是在具有大接收窗口的快速连接上。RFC 1337详细解释了当 TIME-WAIT状态不足时会发生什么。TIME-WAIT以下是如果不缩短状态可以避免的示例:

由于缩短的 TIME-WAIT 状态,后续的 TCP 段已在不相关的连接中被接受(来源

 

2)另一个目的是确保远端已经关闭了连接。当最后一个ACK​​ 丢失时,对端保持该LAST-ACK状态。在没有TIME-WAIT状态的情况下,可以重新打开连接,而远程端仍然认为先前的连接有效。当它收到一个SYN段(并且序列号匹配)时,它将以RST应答,因为它不期望这样的段。新连接将因错误而中止:

 

如果远端因为最后一个 ACK​​ 丢失而停留在 LAST-ACK 状态,则打开具有相同四元组的新连接将不起作用 (来源

TIME_WAIT 的这个超时时间的值如下所示:

  • 在 macOS 上是15秒, sysctl net.inet.tcp | grep net.inet.tcp.msl
  • 在 Linux 上是 60秒 cat /proc/sys/net/ipv4/tcp_fin_timeout

解决方案

要解决这个问题,网上一般会有下面这些解法

  • 把这个超时间调小一些,这样就可以把TCP 的端口号回收的快一些。但是也不能太小,如果流量很大的话,TIME_WAIT一样会被耗尽。
  • 设置上 tcp_tw_reuse 。RFC 1323提出了一组 TCP 扩展来提高高带宽路径的性能。除其他外,它定义了一个新的 TCP 选项,带有两个四字节时间戳字段。第一个是发送选项的 TCP 时间戳的当前值,而第二个是从远程主机接收到的最新时间戳。如果新时间戳严格大于为前一个连接记录的最新时间戳。Linux 将重用该状态下的现有 TIME_WAIT 连接用于出站的链接。也就是说,这个参数对于入站连接是没有任何用图的。
  • 设置上 tcp_tw_recycle 。 这个参数同样依赖于时间戳选项,但会影响进站和出站链接。这个参数会影响NAT环境,也就是一个公司里的所有员工用一个IP地址访问外网的情况。在这种情况下,时间戳条件将禁止在这个公网IP后面的所有设备在一分钟内连接,因为它们不共享相同的时间戳时钟。毫无疑问,禁用此选项要好得多,因为它会导致 难以检测诊断问题。(注:从 Linux 4.10 (commit 95a22caee396 ) 开始,Linux 将为每个连接随机化时间戳偏移量,从而使该选项完全失效,无论有无NAT。它已从 Linux 4.12中完全删除)

对于服务器来说,上述的三个访问都不能解决服务器的 TIME_WAIT 过多的问题,真正解决问题的就是——不作死就不会死,也就是说,服务器不要主动断链接,而设置上KeepAlive后,让客户端主动断链接,这样服务端只会有CLOSE_WAIT

但是对于用于建立出站连接的探活的 EaseProbe来说,设置上 tcp_tw_reuse 就可以重用 TIME_WAIT 了,但是这依然无法解决 TIME_WAIT 过多的问题。

然后,过了几天后,我忽然想起来以前在《UNIX 网络编程》上有看到过一个Socket的参数,叫 <code>SO_LINGER,我的编程生涯中从来没有使用过这个设置,这个参数主要是为了延尽关闭来用的,也就是说你应用调用 close()函数时,如果还有数据没有发送完成,则需要等一个延时时间来让数据发完,但是,如果你把延时设置为 0  时,Socket就丢弃数据,并向对方发送一个 RST 来终止连接,因为走的是 RST 包,所以就不会有 TIME_WAIT 了。

这个东西在服务器端永远不要设置,不然,你的客户端就总是看到 TCP 链接错误 “connnection reset by peer”,但是这个参数对于 EaseProbe 的客户来说,简直是太完美了,当EaseProbe 探测完后,直接 reset connection, 即不会有功能上的问题,也不会影响服务器,更不会有烦人的 TIME_WAIT 问题。

Go 实际操作

在 Golang的标准库代码里,net.TCPConn 有个方法 SetLinger()可以完成这个事,使用起来也比较简单:

conn, _ := net.DialTimeout("tcp", t.Host, t.Timeout())

if tcpCon, ok := conn.(*net.TCPConn); ok {
    tcpCon.SetLinger(0)
}

你需要把一个 net.Conn  转型成 net.TCPConn,然后就可以调用方法了。

但是对于Golang 的标准库中的 HTTP 对象来说,就有点麻烦了,Golang的 http 库把底层的这边连接对象全都包装成私有变量了,你在外面根本获取不到。这篇《How to Set Go net/http Socket Options – setsockopt() example 》中给出了下面的方法:

dialer := &net.Dialer{
    Control: func(network, address string, conn syscall.RawConn) error {
        var operr error
        if err := conn.Control(func(fd uintptr) {
            operr = syscall.SetsockoptInt(int(fd), unix.SOL_SOCKET, unix.TCP_QUICKACK, 1)
        }); err != nil {
            return err
        }
        return operr
    },
}

client := &http.Client{
    Transport: &http.Transport{
        DialContext: dialer.DialContext,
    },
}

上面这个方法非常的低层,需要直接使用setsocketopt这样的系统调用,我其实,还是想使用 TCPConn.SetLinger(0) 来完成这个事,即然都被封装好了,最好还是别破坏封闭性碰底层的东西。

经过Golang http包的源码阅读和摸索,我使用了下面的方法:

client := &http.Client{
    Timeout: h.Timeout(),
    Transport: &http.Transport{
      TLSClientConfig:   tls,
      DisableKeepAlives: true,
      DialContext: func(ctx context.Context, network, addr string) (net.Conn, error) {
        d := net.Dialer{Timeout: h.Timeout()}
        conn, err := d.DialContext(ctx, network, addr)
        if err != nil {
          return nil, err
        }
        tcpConn, ok := conn.(*net.TCPConn)
        if ok {
          tcpConn.SetLinger(0)
          return tcpConn, nil
        }
        return conn, nil
      },
    },
  }

然后,我找来了全球 T0p 100W的域名,然后在AWS上开了一台服务器,用脚本生成了 TOP 10K 和 20K 的网站来以5s, 10s, 30s, 60s的间隔进行探活,搞到Cloudflare 的 1.1.1.1 DNS 时不时就把我拉黑,最后的测试结果也非常不错,根本 没有 TIME_WAIT 的链接,相关的测试方法、测试数据和测试报告可以参看:Benchmark Report

总结

下面是几点总结

  • TIME_WAIT 是一个TCP 协议完整性的手段,虽然会有一定的副作用,但是这个设计是非常关键的,最好不要妥协掉。
  • 永远不要使用  tcp_tw_recycle ,这个参数是个巨龙,破坏力极大。
  • 服务器端永远不要使用  SO_LINGER(0),而且使用 tcp_tw_reuse 对服务端意义不大,因为它只对出站流量有用。
  • 在服务端上最好不要主动断链接,设置好KeepAlive,重用链接,让客户端主动断链接。
  • 在客户端上可以使用 tcp_tw_reuse  和 SO_LINGER(0)

最后强烈推荐阅读这篇文章 – Coping with the TCP TIME-WAIT state on busy Linux servers

今天来讲一讲TCP 的 TIME_WAIT 的问题。这个问题尽人皆知,不过,这次遇到的是不太一样的场景,前两天也解决了,正好写篇文章,顺便把 TIME_WAIT 的那些事都说一说。对了,这个场景,跟我开源的探活小工具 EaseProbe 有关,我先说说这个场景里的问题,然后,顺着这个场景跟大家好好说一下这个事。

目录

问题背景

先说一下背景,EaseProbe 是一个轻量独立的用来探活服务健康状况的小工具,支持http/tcp/shell/ssh/tls/host以及各种中间件的探活,然后,直接发送通知到主流的IM上,如:Slack/Telegram/Discrod/Email/Team,包括国内的企业微信/钉钉/飞书, 非常好用,用过的人都说好 😏。

这个探活工具在每次探活的时候,必须要从头开始建立整个网络链接,也就是说,需要从头开始进行DNS查询,建立TCP链接,然后进行通信,再关闭链接。这里,我们不会设置 TCP 的 KeepAlive 重用链接,因为探活工具除了要探活所远端的服务,还要探活整个网络的情况,所以,每次探活都需要从新来过,这样才能捕捉得到整个链路的情况。

但是,这样不断的新建链接和关闭链接,根据TCP的状态机,我们知道这会导致在探测端这边出现的 TIME_WAIT 的 TCP 链接,根据 TCP 协议的定义,这个 TIME_WAIT 需要等待 2倍的MSL 时间,TCP 链接都会被系统回收,在回收之前,这个链接会占用系统的资源,主要是两个资源,一个是文件描述符,这个还好,可以调整,另一个则是端口号,这个是没法调整的,因为作为发起请求的client来说,在对同一个IP上理论上你只有64K的端口号号可用(实际上系统默认只有近30K,从32,768 到 60,999 一共 60999+1-32768=28,232,你可以通过 sysctl net.ipv4.ip_local_port_range 查看  ),如果 TIME_WAIT 过多,会导致TCP无法建立链接,还会因为资源消耗太多导致整个程序甚至整个系统异常。

试想,如果我们以 10秒为周期探测10K的结点,如果TIME_WAIT的超时时间是120秒,那么在第60秒后,等着超时的 TIME_WAIT 我们就有可能把某个IP的端口基本用完了,就算还行,系统也有些问题。(注意:我们不仅仅只是TCP,还有HTTP协议,所以,大家不要觉得TCP的四元组只要目标地址不一样就好了,一方面,我们探的是域名,需要访问DNS服务,所以,DNS服务一般是一台服务器,还有,因为HTTPS一般是探API,而且会有网关代理API,所以链接会到同一个网关上。另外就算还可以建出站连接,但是本地程序会因为端口耗尽无法bind了。所以,现实情况并不会像理论情况那样只要四元组不冲突,端口就不会耗尽)

为什么要 TIME_WAIT

那么,为什么TCP在 TIME_WAIT 上要等待一个2MSL的时间?

以前写过篇比较宏观的《TCP的那些事》(上篇下篇),这个访问在“上篇”里讲过,这里再说一次,TCP 断链接的时候,会有下面这个来来回回的过程。

我们来看主动断链接的最后一个状态 TIME_WAIT 后就不需要等待对端回 ack了,而是进入了超时状态。这主要是因为,在网络上,如果要知道我们发出的数据被对方收到了,那我们就需要对方发来一个确认的Ack信息,那问题来了,对方怎么知道自己发出去的ack,被收到了?难道还要再ack一下,这样ack来ack回的,那什么谁也不要玩了……是的,这就是比较著名的【两将军问题】——两个将军需要在一个不稳定的信道上达成对敌攻击时间的协商,A向B派出信鸽,我们明早8点进攻,A怎么知道B收到了信?那需要B向A派出信鸽,ack说我收到了,明早8点开干。但是,B怎么知道A会收到自己的确认信?是不是还要A再确认一下?这样无穷无尽的确认导致这个问题是没有完美解的(我们在《分布式事务》一文中说过这个问题,这里不再重述)

所以,我们只能等一个我们认为最大小时来解决两件个问题:

1) 为了 防止来自一个连接的延迟段被依赖于相同四元组(源地址、源端口、目标地址、目标端口)的稍后连接接受(被接受后,就会被马上断掉,TCP状态机紊乱)。虽然,可以通过指定 TCP 的 sequence number 一定范围内才能被接受。但这也只是让问题发生的概率低了一些,对于一个吞吐量大的的应用来说,依然能够出现问题,尤其是在具有大接收窗口的快速连接上。RFC 1337详细解释了当 TIME-WAIT状态不足时会发生什么。TIME-WAIT以下是如果不缩短状态可以避免的示例:

由于缩短的 TIME-WAIT 状态,后续的 TCP 段已在不相关的连接中被接受(来源

 

2)另一个目的是确保远端已经关闭了连接。当最后一个ACK​​ 丢失时,对端保持该LAST-ACK状态。在没有TIME-WAIT状态的情况下,可以重新打开连接,而远程端仍然认为先前的连接有效。当它收到一个SYN段(并且序列号匹配)时,它将以RST应答,因为它不期望这样的段。新连接将因错误而中止:

 

如果远端因为最后一个 ACK​​ 丢失而停留在 LAST-ACK 状态,则打开具有相同四元组的新连接将不起作用 (来源

TIME_WAIT 的这个超时时间的值如下所示:

  • 在 macOS 上是15秒, sysctl net.inet.tcp | grep net.inet.tcp.msl
  • 在 Linux 上是 60秒 cat /proc/sys/net/ipv4/tcp_fin_timeout

解决方案

要解决这个问题,网上一般会有下面这些解法

  • 把这个超时间调小一些,这样就可以把TCP 的端口号回收的快一些。但是也不能太小,如果流量很大的话,TIME_WAIT一样会被耗尽。
  • 设置上 tcp_tw_reuse 。RFC 1323提出了一组 TCP 扩展来提高高带宽路径的性能。除其他外,它定义了一个新的 TCP 选项,带有两个四字节时间戳字段。第一个是发送选项的 TCP 时间戳的当前值,而第二个是从远程主机接收到的最新时间戳。如果新时间戳严格大于为前一个连接记录的最新时间戳。Linux 将重用该状态下的现有 TIME_WAIT 连接用于出站的链接。也就是说,这个参数对于入站连接是没有任何用图的。
  • 设置上 tcp_tw_recycle 。 这个参数同样依赖于时间戳选项,但会影响进站和出站链接。这个参数会影响NAT环境,也就是一个公司里的所有员工用一个IP地址访问外网的情况。在这种情况下,时间戳条件将禁止在这个公网IP后面的所有设备在一分钟内连接,因为它们不共享相同的时间戳时钟。毫无疑问,禁用此选项要好得多,因为它会导致 难以检测诊断问题。(注:从 Linux 4.10 (commit 95a22caee396 ) 开始,Linux 将为每个连接随机化时间戳偏移量,从而使该选项完全失效,无论有无NAT。它已从 Linux 4.12中完全删除)

对于服务器来说,上述的三个访问都不能解决服务器的 TIME_WAIT 过多的问题,真正解决问题的就是——不作死就不会死,也就是说,服务器不要主动断链接,而设置上KeepAlive后,让客户端主动断链接,这样服务端只会有CLOSE_WAIT

但是对于用于建立出站连接的探活的 EaseProbe来说,设置上 tcp_tw_reuse 就可以重用 TIME_WAIT 了,但是这依然无法解决 TIME_WAIT 过多的问题。

然后,过了几天后,我忽然想起来以前在《UNIX 网络编程》上有看到过一个Socket的参数,叫 <code>SO_LINGER,我的编程生涯中从来没有使用过这个设置,这个参数主要是为了延尽关闭来用的,也就是说你应用调用 close()函数时,如果还有数据没有发送完成,则需要等一个延时时间来让数据发完,但是,如果你把延时设置为 0  时,Socket就丢弃数据,并向对方发送一个 RST 来终止连接,因为走的是 RST 包,所以就不会有 TIME_WAIT 了。

这个东西在服务器端永远不要设置,不然,你的客户端就总是看到 TCP 链接错误 “connnection reset by peer”,但是这个参数对于 EaseProbe 的客户来说,简直是太完美了,当EaseProbe 探测完后,直接 reset connection, 即不会有功能上的问题,也不会影响服务器,更不会有烦人的 TIME_WAIT 问题。

Go 实际操作

在 Golang的标准库代码里,net.TCPConn 有个方法 SetLinger()可以完成这个事,使用起来也比较简单:

conn, _ := net.DialTimeout("tcp", t.Host, t.Timeout())

if tcpCon, ok := conn.(*net.TCPConn); ok {
    tcpCon.SetLinger(0)
}

你需要把一个 net.Conn  转型成 net.TCPConn,然后就可以调用方法了。

但是对于Golang 的标准库中的 HTTP 对象来说,就有点麻烦了,Golang的 http 库把底层的这边连接对象全都包装成私有变量了,你在外面根本获取不到。这篇《How to Set Go net/http Socket Options – setsockopt() example 》中给出了下面的方法:

dialer := &net.Dialer{
    Control: func(network, address string, conn syscall.RawConn) error {
        var operr error
        if err := conn.Control(func(fd uintptr) {
            operr = syscall.SetsockoptInt(int(fd), unix.SOL_SOCKET, unix.TCP_QUICKACK, 1)
        }); err != nil {
            return err
        }
        return operr
    },
}

client := &http.Client{
    Transport: &http.Transport{
        DialContext: dialer.DialContext,
    },
}

上面这个方法非常的低层,需要直接使用setsocketopt这样的系统调用,我其实,还是想使用 TCPConn.SetLinger(0) 来完成这个事,即然都被封装好了,最好还是别破坏封闭性碰底层的东西。

经过Golang http包的源码阅读和摸索,我使用了下面的方法:

client := &http.Client{
    Timeout: h.Timeout(),
    Transport: &http.Transport{
      TLSClientConfig:   tls,
      DisableKeepAlives: true,
      DialContext: func(ctx context.Context, network, addr string) (net.Conn, error) {
        d := net.Dialer{Timeout: h.Timeout()}
        conn, err := d.DialContext(ctx, network, addr)
        if err != nil {
          return nil, err
        }
        tcpConn, ok := conn.(*net.TCPConn)
        if ok {
          tcpConn.SetLinger(0)
          return tcpConn, nil
        }
        return conn, nil
      },
    },
  }

然后,我找来了全球 T0p 100W的域名,然后在AWS上开了一台服务器,用脚本生成了 TOP 10K 和 20K 的网站来以5s, 10s, 30s, 60s的间隔进行探活,搞到Cloudflare 的 1.1.1.1 DNS 时不时就把我拉黑,最后的测试结果也非常不错,根本 没有 TIME_WAIT 的链接,相关的测试方法、测试数据和测试报告可以参看:Benchmark Report

总结

下面是几点总结

  • TIME_WAIT 是一个TCP 协议完整性的手段,虽然会有一定的副作用,但是这个设计是非常关键的,最好不要妥协掉。
  • 永远不要使用  tcp_tw_recycle ,这个参数是个巨龙,破坏力极大。
  • 服务器端永远不要使用  SO_LINGER(0),而且使用 tcp_tw_reuse 对服务端意义不大,因为它只对出站流量有用。
  • 在服务端上最好不要主动断链接,设置好KeepAlive,重用链接,让客户端主动断链接。
  • 在客户端上可以使用 tcp_tw_reuse  和 SO_LINGER(0)

最后强烈推荐阅读这篇文章 – Coping with the TCP TIME-WAIT state on busy Linux servers