1. 问题背景与现象描述
最近在生产环境遇到一个典型的分布式系统问题:Spark作业在向ClickHouse写入数据时,频繁出现Stage重试和Task失败的情况。最初我们以为是常规的网络抖动或资源不足问题,但深入排查后发现,这实际上是由DNS解析引发的竞态条件导致的连锁反应。
具体现象表现为:
- Spark作业运行过程中,特定Stage会突然出现大量Task失败
- 失败Task会被自动重试,部分重试成功,部分继续失败
- 查看日志发现大量"Connection refused"和"UnknownHostException"
- 问题呈现明显的间歇性特征,同一作业在不同时段运行成功率差异很大
需要模型API调用? 免费领10W Token,多模型网关一键接入 Claude、DeepSeek 等主流模型。
2. 问题排查过程全记录
2.1 初步排查方向
我们首先按照常规分布式系统问题排查路径进行检查:
-
资源检查:
- 确认Spark executor和ClickHouse服务器资源使用率正常
- 网络带宽和连接数未达到上限
- 系统负载在合理范围内
-
基础环境检查:
- 各节点时钟同步正常(NTP服务正常)
- 磁盘空间充足
- 系统日志无OOM或关键错误
-
配置检查:
- Spark和ClickHouse的连接参数配置正确
- 连接池大小设置合理
- 超时参数符合业务需求
2.2 深入日志分析
当常规检查无果后,我们开始深入分析各组件日志:
Spark Executor日志中发现关键线索:
code复制WARN [task-result-getter-2] org.apache.spark.scheduler.TaskSetManager:
Lost task 35.0 in stage 3.0 (TID 107, executor 5, hostname worker-5):
java.net.ConnectException: Connection refused (Connection refused)
at java.net.PlainSocketImpl.socketConnect(Native Method)
...
Caused by: java.net.UnknownHostException: ch-server-3.prod.internal:
Temporary failure in name resolution
ClickHouse服务端日志:
发现部分连接请求的客户端IP地址不一致,同一个主机名在不同时刻解析出不同IP。
2.3 DNS问题定位
通过以上日志,我们将问题定位到DNS解析环节:
-
在Spark executor上手动执行nslookup:
code复制$ nslookup ch-server-3.prod.internal Server: 10.0.0.2 Address: 10.0.0.2#53 Non-authoritative answer: Name: ch-server-3.prod.internal Address: 10.1.3.15 Name: ch-server-3.prod.internal Address: 10.1.3.16 -
发现关键问题:
- ClickHouse集群采用主备架构
- 同一个主机名对应多个IP(主备节点)
- DNS服务器配置了轮询策略
- Spark的DNS缓存机制与连接池产生冲突
3. 竞态条件原理分析
3.1 问题发生的时间线
让我们还原问题发生的完整过程:
-
初始阶段:
- Spark executor初始化连接池
- 解析ch-server-3.prod.internal得到IP1(10.1.3.15)
- 建立到IP1的连接并放入连接池
-
DNS变更时刻:
- ClickHouse主节点(IP1)故障
- DNS记录更新,移除IP1,保留IP2(10.1.3.16)
- 但Spark executor仍缓存旧DNS记录
-
问题爆发阶段:
- Task从连接池获取"有效"连接(实际指向已下线的IP1)
- 连接失败触发重试机制
- 新Task重新解析DNS可能得到IP2(工作正常)
- 导致部分成功部分失败的混乱状态
3.2 根本原因总结
这个问题本质上是分布式系统中典型的"缓存一致性问题",具体表现为:
-
DNS缓存与连接池的交互问题:
- JVM默认缓存DNS解析结果(根据security.properties设置)
- 连接池不知道底层IP已失效
- 两者缺乏协同机制
-
重试机制的副作用:
- Spark的自动重试掩盖了根本问题
- 重试过程中可能获得新的DNS解析结果
- 造成"时好时坏"的假象
-
多组件协作的边界条件:
- DNS轮询 + 主备切换 + 连接池 + 重试机制
- 每个组件单独工作正常
- 组合后产生意料之外的交互问题
4. 解决方案设计与实施
4.1 短期应急方案
我们首先实施了以下应急措施:
-
强制DNS缓存刷新:
java复制// 在Spark作业中设置JVM参数 java.security.Security.setProperty("networkaddress.cache.ttl", "60"); java.security.Security.setProperty("networkaddress.cache.negative.ttl", "10"); -
连接池优化:
scala复制// 配置连接池验证机制 .option("connectionTestQuery", "SELECT 1") .option("testOnBorrow", "true") .option("testWhileIdle", "true") -
重试策略调整:
scala复制// 降低重试频率但增加单次重试间隔 spark.conf.set("spark.task.maxFailures", "3") spark.conf.set("spark.speculation.interval", "5s")
4.2 长期架构解决方案
在应急方案稳定后,我们实施了更彻底的解决方案:
-
DNS解析策略调整:
- 为每个ClickHouse节点分配固定主机名
- 禁用DNS轮询,采用显式的主备配置
- 实现基于健康检查的DNS自动更新
-
连接管理优化:
scala复制// 使用带故障转移的JDBC URL val url = "jdbc:clickhouse://ch-server-1:8123,ch-server-2:8123/default" // 配置连接失效检测 .option("socketTimeout", "30000") .option("keepAlive", "true") -
Spark配置优化:
bash复制# 在spark-defaults.conf中添加 spark.executor.extraJavaOptions=-Dsun.net.inetaddr.ttl=60 spark.driver.extraJavaOptions=-Dsun.net.inetaddr.ttl=60 -
监控体系增强:
- 实现DNS解析结果监控
- 建立连接健康度指标
- 设置基于历史数据的预测性告警
5. 验证与效果评估
5.1 测试方案设计
我们设计了以下验证方案:
-
模拟DNS变更:
- 编写脚本动态修改DNS记录
- 模拟主节点故障场景
-
压力测试:
bash复制# 使用spark-submit提交测试作业 spark-submit --class com.example.ClickHouseStressTest \ --num-executors 20 \ --executor-cores 4 \ --executor-memory 8g \ clickhouse-test.jar -
监控指标:
- Stage失败率
- Task重试次数
- 连接建立耗时
- DNS查询频率
5.2 实施效果
优化前后的关键指标对比:
| 指标 | 优化前 | 优化后 |
|---|---|---|
| Stage失败率 | 23% | 0.1% |
| 平均Task重试次数 | 2.8 | 0.2 |
| 99分位连接耗时(ms) | 450 | 120 |
| DNS查询频率(次/min) | 150 | 30 |
6. 经验总结与最佳实践
通过这次问题的排查和解决,我们总结了以下分布式系统交互设计的经验:
-
DNS使用规范:
- 避免对关键服务使用DNS轮询
- 明确区分主备节点的主机名
- 合理设置TTL值(建议30-60秒)
-
连接池配置要点:
java复制// 推荐配置模板 HikariConfig config = new HikariConfig(); config.setConnectionTestQuery("SELECT 1"); config.setValidationTimeout(1000); config.setMaxLifetime(600000); // 10分钟 config.setIdleTimeout(300000); // 5分钟 -
Spark作业优化建议:
- 在executor启动参数中明确设置DNS缓存时间
- 对关键数据源实现自定义的重试策略
- 考虑使用带故障转移的连接字符串
-
监控体系建设:
- 实现DNS解析结果与连接目标的比对监控
- 建立连接生命周期追踪机制
- 对跨组件交互设计专门的健康检查
这次问题的解决过程再次证明,在分布式系统中,各组件的独立正确性不能保证系统整体的可靠性。特别是在涉及网络基础设施(如DNS)、资源管理(如连接池)和容错机制(如重试策略)等多个层面的交互时,必须考虑它们组合后产生的边缘效应。
