1. 先搞清楚这条链路:socket异常为什么总从 com.github.tobato.fastdfs 冒出来
1.1 FastDFS的通信模型与Tobato客户端的调用方式
FastDFS本身是一个用C语言实现的分布式文件系统,核心就两个角色:Tracker Server和Storage Server。Tracker负责调度,Storage负责真正存文件。Tobato这套组件就是把服务端协议封装成了Spring环境下可以方便使用的Java API,核心类是FastFileStorageClient。
一个正常的上传动作,从客户端视角拆开是这样的:
- 客户端从配置的
tracker-list里取一个地址,跟Tracker建立TCP连接,Tracker默认端口是22122; - 客户端向Tracker发起“找storage”的请求,Tracker根据group、负载、剩余空间等信息,返回一个可用的storage节点信息,里面至少包含IP和端口;
- 客户端再跟这个storage节点建立TCP连接,storage默认端口一般是23000;
- 客户端在storage连接上完成写文件、写meta等交互。
所以你在日志里看到一个socket异常时,先别急着怀疑代码。这个异常可能出现在第1步(连Tracker失败)、第2步(请求Tracker后等待响应超时)、第3步(连storage失败)或者第4步(跟storage交互过程中出错)。位置不同,排查方向完全不同。
很多人习惯只看异常文本,比如看到SocketTimeoutException就把超时调大,看到Connection refused就去重启服务,这其实容易把问题带偏。正确做法是先看堆栈走到哪一层:FastFileStorageClient只是入口,真正干活的是TrackerClient和SocketConnection。堆栈如果停留在TrackerClient的getStoreStorage,说明问题出在tracker这侧;如果已经进入storage的上传流程,问题出在storage这侧。这一步判断对了,后面90%的排查思路基本就明确了。
1.2 后台日志里的socket异常常见长相
我按平时排障的经验,把常见异常文本和对应的出错阶段整理成了下面这张表。你翻日志的时候可以直接对照,先做一个粗分类:
| 异常文本/典型堆栈 | 出错阶段 | 比较常见的原因 |
|---|---|---|
java.net.ConnectException: Connection refused | TCP握手阶段被拒绝 | 服务端端口没监听、进程没起来、防火墙主动RST |
java.net.SocketTimeoutException: connect timed out | TCP握手阶段超时 | SYN包发出去了没人响应,大概率是防火墙丢包或路由不通 |
java.net.SocketTimeoutException: Read timed out | 已建立连接,等待响应超时 | 对端处理太慢、so-timeout设太短、链路拥塞 |
java.net.SocketException: Connection reset | 已建立连接,读写过程中被重置 | 对端主动关闭连接、中间设备回收空闲连接、Nginx/LVS空闲超时 |
java.io.EOFException | 读取响应时提前读到EOF | 响应数据被截断、连接被对端关闭后客户端继续读、协议版本不匹配 |
java.net.NoRouteToHostException: No route to host | 路由层找不到路径 | 路由表问题、目标IP不回包 |
从com.github.tobato.fastdfs包抛出来的异常,外层常会包一层FdfsException,看到Caused by里的原生socket异常才能定性。另外有一点容易忽略:Connection reset和Connection refused是两回事。前者是连接曾经建立过、后来被对端或中间设备强制断掉;后者是压根没建立成功。二者的排查重点完全不同,别混在一起调参数。
2. 排查路径:从配置、网络到服务端,一层一层剥
2.1 基础配置核对:connect-timeout与so-timeout
Tobato的配置前缀是fdfs,最常用的两个超时参数是connect-timeout和so-timeout,单位都是毫秒。很多第一次用的人把单位当成秒,随手写个600,结果连接稍微慢一点就报connect timed out。Spring Boot环境下的典型配置长这样:
fdfs: connect-timeout: 3000 so-timeout: 5000 tracker-list: - 192.168.1.100:22122这里有几个容易踩的点。
第一,tracker-list必须是Tracker的地址,不是storage的地址。把storage的IP写进去,客户端会拿它当tracker去问“有没有可用storage”,自然得不到合法响应,最后报错的样子往往也是各种IO异常。这种配置错误比较隐蔽,因为端口可能通着,但业务就是一直失败。
第二,tracker-list里尽量写IP,别写hostname。Java底层要经过一次DNS解析,容器环境里/etc/hosts、DNS配置如果比较乱,解析慢或者解析到错误IP都会导致连接异常。我见过一个案例,写的是内网域名,客户端时不时UnknownHostException,但重试一下又好了,最后把配置改成IP后彻底稳定。
第三,so-timeout影响的是建立连接之后的读写。上传的文件越大,单次socket读写不一定会变慢,但如果storage处理事务需要较长时间,建议把so-timeout放宽到5000甚至更长,防止storage还没处理完,客户端这边就先等得不耐烦。反过来,如果设置得过大,线程会在socket读取上挂很久,业务线程被大量占用,看起来像“假死”。
注意:
connect-timeout是毫秒,不是秒。600毫秒的连接超时在跨机房、有安全设备做检测的场景下非常容易触发,生产环境建议2000到5000毫秒起步。
2.2 端口连通性与防火墙、安全组排查
FastDFS默认端口是tracker 22122、storage 23000。实际端口以服务端配置为准,但绝大多数默认安装就是这两个。在应用服务器上执行:
telnet 192.168.1.100 22122 telnet 192.168.1.101 23000如果tracker端口能通、storage端口不通,十有八九是安全组或iptables只放行了22122。云上环境尤其常见:刚部署时只加了tracker端口,上传一直失败,排查半天才发现23000被漏了。放行端口即可解决,但要注意安全,只对受信网段开放,别把整个公网都能访问的规则加上去。
如果两个端口都不通,优先查客户端到tracker的网络路径。ping看链路通不通,route -n看路由表,再用tcpdump抓包定位丢包点:
tcpdump -i eth0 host 192.168.1.100 and port 22122抓包结果里如果只有SYN没有SYN-ACK,说明中间设备把包丢了,去查防火墙、安全组、路由ACL;如果回的是RST,说明服务端没有监听这个端口,或者监听的不是当前IP。还有一种情况是服务端进程起来了但监听在127.0.0.1上,外部IP自然连不上,用ss -lntp在服务端确认一下监听地址即可。
2.3 tracker返回了不可达的storage地址
这是个非常隐蔽的坑:客户端从tracker拿到的storage地址,是tracker根据storage节点自己上报的地址返回的。如果部署时storage上报的是内网IP,而应用服务器和那个内网网段不可达,就会反复出现连接storage失败。
判断方法也简单:在应用服务器上用监控命令查一下tracker认为的storage状态和地址。FastDFS自带fdfs_monitor工具:
fdfs_monitor /etc/fdfs/client.conf输出里能看到storage的IP、端口以及在线状态。如果发现返回的IP和应用服务器根本不在一个网段,问题基本就定位了。解决方案有两种:一是调整网络让应用和storage互通;二是修改storage的配置,让上报地址变成应用服务器实际能访问的地址。这类问题调客户端参数是没有用的,别白费力气。
3. 连接池与运行期状态:容易被忽略的socket异常制造机
3.1 连接池参数与高并发下的连接耗尽
Tobato客户端内部用连接池缓存到tracker、storage的长连接。相关参数以fdfs.pool.*为前缀,常见配置类似这样:
fdfs: pool: max-total: 200 max-idle: 100 min-idle: 10 max-wait-millis: 3000 test-on-borrow: false test-while-idle: true time-between-eviction-runs-millis: 30000 num-tests-per-eviction-run: 20这里最关键的两个参数是max-total和max-wait-millis。并发上传很高时,如果池里连接不够,新的请求要么借不到连接,要么在等连接时超时,最终抛出来的异常往往看起来就是socket相关。我遇到过压测时日志里全是连接池借钱方向的异常,把max-total调大后立刻缓解。
估算max-total时别拍脑袋。先看峰值并发:假设同时上传的线程有100个,max-total至少大于100,最好留30%到50%的冗余。max-wait-millis别设成0,否则获取不到连接直接抛异常;也别设太大,否则业务线程大量堆积在等待连接上,服务整体吞吐下降。
如果应用启动后第一次上传就失败、之后又正常,通常不是池容量问题,而是初始化时机和连接预热问题。可以在项目启动完成后主动上传一个极小文件,把连接“热”起来,很多偶发的首次连接异常都能避开。
3.2 空闲连接被链路设备回收,导致Connection reset
这条是我最想强调的排查思路。连接在池里放的时间长了,中间如果经过LVS、F5、云上NAT网关这类设备,设备对空闲TCP连接一般都有超时时间,比如60秒或300秒。超时后设备直接把这个连接清掉,不通知两端。客户端在池里并不知道,下次拿到这个连接去写数据,对端回一个RST,日志里就是Connection reset或者Broken pipe。
这种异常的典型规律是:只在“一段时间没有流量之后的第一个请求”出现。比如每天凌晨跑批量任务,任务前的几个小时都没有上传请求,第一个请求大概率失败,重试又成功。白天请求密集,反而很难触发。
解决办法有几个方向:
- 开启
test-while-idle,让连接池的eviction线程定期对空闲连接做检测; - 把
time-between-eviction-runs-millis设得比链路设备空闲超时时间小,比如链路设备90秒清连接,就设成30000毫秒; - 如果链路太黑盒,干脆
test-on-borrow: true,每次借出前都验证一遍;代价是每个请求多一次交互,性能有一定损失; - 还有一种兜底做法:写一个定时任务,每隔一段时间主动借一个连接做小文件上传或协议探测,让连接一直保持活跃。
我复盘的很多生产问题最后都落在这个原因上。表面上看起来是“socket异常”,实际是连接池和网络设备的空闲回收策略博弈,跟FastDFS本身没有半毛钱关系。
3.3 版本兼容与服务端异常退出带来的隐性影响
如果排查完配置、网络、连接池都正常,就要怀疑版本兼容性了。FastDFS服务端5.x、6.x在不同的版本对协议细节有调整,而Tobato客户端1.26.x、1.27.x也有各自适配的边界。大部分核心接口是兼容的,但遇到奇怪的EOFException时,还是要确认服务端和客户端的版本组合是否在社区推荐的范围内。
另外,storage或tracker所在进程如果因为内存不足、磁盘满被系统杀掉,客户端重连时就会表现为refused或RST。这时候先去服务端看进程状态和日志,别在客户端反复调参数。tracker和storage的日志默认在各自base_path下的logs目录里,tracker日志叫trackerd.log,storage日志叫storaged.log。服务端日志里有大段的磁盘、心跳、同步错误记录时,优先解决服务端问题,socket异常自然就消失了。
4. 一次生产环境的排查复盘与最终解决步骤
4.1 现场过程:从日志栈到telnet再到服务端日志
分享一个我实际处理过的案例。某系统用com.github.tobato.fastdfs上传图片,后台日志每天凌晨批量任务时间段会报一批SocketTimeoutException: Read timed out,堆栈指向com.github.tobato.fastdfs.proto.conn.SocketConnection。业务侧重试后又能成功,白天基本正常。一开始怀疑是so-timeout太小,从1500毫秒调到3000毫秒,失败时间点往后推了,但没有根治。
后来的排查顺序是:
- 看失败时刻的并发情况,发现该批量任务会开20个线程同时传文件;
- telnet测试tracker和storage端口都通,排除防火墙;
- 在应用服务器抓包,发现很多连接在写文件前已经空闲了几分钟;
- 检查网络链路,负载均衡设备空闲超时是90秒,而连接池没有主动回收空闲连接;
- 把
time-between-eviction-runs-millis调成10000,开启test-while-idle,再把空闲超过60秒的连接淘汰,问题消失。
这个案例典型就典型在“只有特定时间点失败”“白天单次请求多所以不太触发空闲回收”,很难联想到是连接已经死了。所以看到间歇性socket异常时,一定要把“空闲连接失效”这个维度加进怀疑清单,不能只盯着超时配置。
4.2 配置修正与验证方法
以一个相对稳健的最终配置为例,可以作为模板参考:
fdfs: connect-timeout: 3000 so-timeout: 8000 tracker-list: - 192.168.1.100:22122 - 192.168.1.101:22122 pool: max-total: 200 max-idle: 80 min-idle: 5 max-wait-millis: 3000 time-between-eviction-runs-millis: 10000 min-evictable-idle-time-millis: 60000 test-while-idle: true这里so-timeout: 8000是为大文件传输留的余量,min-evictable-idle-time-millis: 60000确保空闲超过60秒的连接会被淘汰,time-between-eviction-runs-millis: 10000表示每10秒检查一次。整体思路就是让连接池里的连接保持新鲜,避免用到已经被链路设备回收的“僵尸连接”。
验证方式不要只测一遍,最好是连续上传100个小文件,停5分钟,再传100个。如果中间停顿时长已经超过了网络设备的空闲超时时间,而后一批请求不再出现reset或read timeout,基本就算稳了。有条件的话,在服务器上执行tcpdump观察连接是否一直处于活跃复用状态,能更直观地佐证。
4.3 日常健康检查与重试机制的补充
长期稳定运行不能只靠一次调参。我的习惯是给上传服务加两个东西:
一是探活任务。定时上传一个极小的文本文件到FastDFS,上传成功说明链路、服务端、连接池都正常,失败则触发告警。探活频率建议低于网络链路空闲超时时间,比如每30秒一次,这样还能顺带把空闲连接维持在活跃状态,一举两得。
二是业务层的重试机制。上传入口统一封装,捕获FdfsException后做一次重试,重试前先做一次连接池清理或者直接换下一个tracker。这样即使偶发问题没有被完全消灭,业务侧也能自动消化,用户无感知。但重试要防止雪崩:重试次数控制在1到2次,并且记录重试日志,方便后续继续分析根因。
最后再提醒一句,日志里出现socket异常时先别急着改代码,把异常出现的时间、频率、并发量、网络设备空闲超时、连接池状态这几个信息对齐,往往比盲目调参有效得多。FastDFS本身是稳定的,Tobato客户端也是稳定的,大多数异常其实是部署架构和连接生命周期管理的问题。把这层想透了,以后再遇到类似问题,基本可以在半小时内给出方向。