Rust FD泄漏问题的排查与解决

Rust FD泄漏问题的排查与解决
Photo by Arian Darvishi / Unsplash

问题背景

在生产环境中运行的 Rust Web 服务出现了文件描述符(FD)持续增长的现象。服务使用 actix-web 框架,配合 SQLite 数据库(通过 sqlx)、Redis 缓存、以及 Tantivy 全文检索引擎。

初始监控数据显示:

  • 同一个数据库文件 data.db 被打开了多次(FD: 9, 10, 49, 52, 61)
  • 文件描述符总数在运行一段时间后持续增长
  • 服务内存占用从 71.81 MB 增长到 305.30 MB

排查过程

1. 文件描述符分析

通过 Linux 系统工具进行详细排查:

# 查看进程打开的文件描述符
ls -l /proc/<PID>/fd

# 持续监控 FD 数量变化
watch -n 5 'ls -l /proc/<PID>/fd | wc -l'

# 详细查看 FD 类型分布
lsof -p <PID>

发现进程中存在:

  • 多个数据库文件句柄
  • 大量 eventpoll、eventfd(异步 I/O 相关)
  • Socket 连接(Redis、HTTP)

2. 代码层面审查

重点检查了可能导致 FD 泄漏的常见模式:

✅ 已排除的风险点:

  • 未使用 into_raw_fd/from_raw_fd 等底层 API
  • 未使用 mem::forget 阻止资源释放
  • 没有频繁创建不可回收的客户端对象
  • HTTP 客户端(reqwest::Client)在 AppState 中单例复用
  • Redis 连接惰性初始化后复用

⚠️ 发现的潜在问题:

  1. SQLite WAL 模式未优化
    • 默认配置下 WAL 文件可能持续增长
    • 未配置连接空闲超时

Tantivy IndexReader 频繁创建

// 每次搜索都创建新 Reader
pub fn search(&self, query: &str) -> Result<Vec<Item>> {
    let reader = self.index.reader()?;  // ❌ 频繁创建
    // ...
}

SQLite 连接池配置不当

// 原配置
SqlitePoolOptions::new()
    .max_connections(5)  // 连接数过多
    .connect(...)

优化方案

1. 限制 SQLite 连接池并设置超时

let db_pool = SqlitePoolOptions::new()
    .max_connections(3)  // 从 5 降低到 3
    .idle_timeout(Duration::from_secs(30))  // 空闲连接 30 秒后释放
    .connect(&database_url)
    .await?;

优化原理:

  • 减少同时打开的数据库文件句柄数量
  • 空闲连接自动回收,避免长期占用 FD

2. 配置 SQLite WAL 模式与 Checkpoint

// 启动时执行一次
sqlx::query("PRAGMA journal_mode=WAL;")
    .execute(&db_pool)
    .await?;

sqlx::query("PRAGMA wal_checkpoint(TRUNCATE);")
    .execute(&db_pool)
    .await?;

关于 WAL 相关文件:

  • data.db-wal(Write-Ahead Log):事务日志文件,记录未提交到主数据库的写操作
  • data.db-shm(Shared Memory):共享内存索引文件,协调多个进程/连接对 WAL 的访问

这两个文件是 SQLite WAL 模式的标配,不是"泄漏",而是正常机制。通过定期执行 wal_checkpoint(TRUNCATE) 可以:

  • 将 WAL 中的数据刷入主数据库
  • 截断 WAL 文件,释放磁盘空间
  • 减少相关文件句柄的长期占用

3. 缓存 Tantivy IndexReader

pub struct Searcher {
    index: Index,
    reader: IndexReader,  // ✅ 缓存 Reader
    query_parser: QueryParser,
}

impl Searcher {
    pub fn new(index_path: &Path) -> Result<Self> {
        let index = Index::open_in_dir(index_path)?;
        let reader = index.reader()?;  // 启动时创建一次
        let query_parser = /* ... */;
        
        Ok(Self {
            index,
            reader,
            query_parser,
        })
    }
    
    pub fn search(&self, query: &str) -> Result<Vec<Item>> {
        let searcher = self.reader.searcher();  // ✅ 复用 Reader
        // ...
    }
    
    // 索引更新后调用
    pub fn reload_reader(&mut self) -> Result<()> {
        self.reader.reload()?;
        Ok(())
    }
}

优化原理:

  • IndexReader 在创建时会打开索引的 segment 文件
  • 每次请求都创建新 Reader 会导致 FD 频繁打开/关闭
  • 缓存 Reader 后,FD 数量稳定在索引分段数量上

优化效果

优化前

  • FD 数量:52+ 并持续增长
  • 数据库文件被打开 5 次
  • 内存占用:71.81 MB → 305.30 MB(持续增长)

优化后

  • FD 数量:46 并保持稳定
  • 数据库相关文件:4 个(主库 + WAL + SHM + 备用连接)
  • 内存占用稳定,无明显增长趋势
  • Socket 连接数稳定在合理范围

监控建议

生产环境监控脚本

#!/bin/bash
# monitor_fd.sh

APP_NAME="your-service"
THRESHOLD=800  # 根据 ulimit -n 设置阈值(建议 60-80%)

while true; do
    PID=$(pgrep -f "$APP_NAME" | head -1)
    if [ -n "$PID" ]; then
        FD_COUNT=$(ls -l /proc/$PID/fd 2>/dev/null | wc -l)
        echo "$(date '+%Y-%m-%d %H:%M:%S') - PID: $PID, FD Count: $FD_COUNT"
        
        if [ "$FD_COUNT" -gt "$THRESHOLD" ]; then
            echo "⚠️ WARNING: FD count exceeds threshold!"
            # 可以触发告警或详细日志
            lsof -p $PID > "/tmp/fd_detail_$(date +%s).log"
        fi
    fi
    sleep 300  # 每 5 分钟检查一次
done

关键指标

# 查看进程 FD 限制
ulimit -n

# 实时监控 FD 变化
watch -n 5 'ls -l /proc/<PID>/fd | wc -l'

# 按类型统计 FD
lsof -p <PID> | awk '{print $5}' | sort | uniq -c

# 查看 TCP 连接状态
ss -anpt | grep <PID>

经验总结

文件描述符泄漏的常见原因

  1. 数据库连接池配置不当
    • 连接数设置过大
    • 缺少空闲超时机制
    • 未正确关闭连接
  2. 文件系统资源频繁创建
    • 日志文件轮转不当
    • 临时文件未清理
    • 索引/缓存文件重复打开
  3. 网络连接管理问题
    • HTTP 客户端重复创建
    • WebSocket 连接未正确关闭
    • 长连接无超时设置
  4. 异步资源生命周期管理
    • Tokio/async-std 任务未正确清理
    • Channel 未关闭
    • Stream 未消费完毕

最佳实践

  1. 资源池化:数据库连接、HTTP 客户端等复用
  2. 设置超时:idle_timeout、connect_timeout、request_timeout
  3. 及时释放:明确资源生命周期,避免跨作用域持有
  4. 监控告警:生产环境持续监控 FD 数量
  5. 压力测试:上线前进行长时间并发测试,观察资源使用趋势

Rust 特定建议

  • 利用 RAII(Resource Acquisition Is Initialization)自动管理资源
  • 避免使用 mem::forget 和底层 FD 操作
  • 使用 Drop trait 确保资源清理
  • 异步代码注意 Future 的生命周期
  • 善用 Arc<Mutex<T>> 等模式共享资源

总结

文件描述符泄漏是生产环境中常见但容易被忽视的问题。通过系统化的排查方法、合理的资源配置、以及持续的监控,可以有效避免和解决此类问题。本次优化不仅解决了 FD 泄漏问题,还提升了服务的整体性能和稳定性。

对于 Rust 开发者来说,虽然语言本身的内存安全保证减少了很多低级错误,但在系统资源管理层面仍需要深入理解底层机制,合理配置第三方库,才能构建真正可靠的生产级服务。

Read more

間

春节回家,我又见到了我干爹家的三儿子。 他生下来就带着残疾,不能说话,手脚不协调,走路一瘸一拐,嘴角总是挂着口水。小时候干爹干娘怕别人欺负他,教他见人就笑。所以这么多年,不管走到哪,他都是笑着的。 左脚脚尖点地,左手弯着伸不直,走路习惯性靠在路的最右边,紧贴着路沿。我有时候担心他会踩进沟里,想想又觉得,也许他自己知道,这样不容易被人撞到。 那天下午我一个人在村东边路上走,他跟了上来。脸上沾着灰,鼻子里有一团鼻垢,我下意识想帮他弄掉,他偏过头,自己扣了下来,然后转过脸,把手里点着的烟举了举,冲我笑。 他的手指黄黄的,染得很深。后来我知道,小时候有人逗他,教他抽烟,就这么上了瘾,又没有能力自己戒。烟瘾越来越大,有烟就一口气抽完,多的时候一天三包。这两年逢年过节,大家口袋里都装着烟,见面互让,他也学会了凑过去。村里谁家办红白喜事,他都去帮着搬凳子搬椅子,人家给他几根烟,他就高兴。我那半包苏烟,后来进了他的口袋。

折叠时间

折叠时间

上次坐地铁的时候,我盯着手机看了一眼时间:20:37。等反应过来抬起头,已经是20:52了。十五分钟,就这么没了。 但1月牙疼去看牙医,在椅子上躺着等医生准备器械,那三分钟感觉比一个小时还长。 同样是时间,为什么有时候像沙子一样从指缝溜走,有时候又像琥珀一样凝固住每一秒? 不同的星球,不同的时钟 物理学告诉我们,引力会让时间变慢。在靠近黑洞的地方过一小时,地球上可能已经过了好几年。就像不同重量的球压在一张网上,越重的球把网面压得越深,时间在那里流逝得就越慢。 这个画面一直让我着迷。 后来我想,其实我们每个人的内心世界也像是不同的星球。有些事情对你来说很重要,它就像一颗大质量的星球,把你的时间网压出很深的凹陷。你围绕着它打转,时间在那里变得又浓又稠。 恋爱的时候,一天能想对方好几百次。每一次心跳都被放大,每一个眼神都值得回味。楼下等她的那段时间好像特别"漫长"。 但也有些日子,你就是在重复。起床、上班、吃饭、睡觉。一天天像复制粘贴一样过去了,回头看,好像什么都没留下。 大象和蚂蚁的一秒钟

思考

思考

在你阅读这篇文章之前,先问自己一个问题:你上一次真正深度思考是什么时候? 我所说的"深度思考",是指遇到一个具体而困难的问题,然后花费好几天时间专注于解决它的那种状态。 你的答案是什么? * a) 经常如此 * b) 从来没有 * c) 介于两者之间 如果你的答案是 (a) 或 (b),这篇文章可能不适合你。但如果像我一样,你的答案是 (c),那么这篇文章或许能引起你的共鸣,至少让你知道,你并不孤单。 首先声明:这篇文章没有答案,甚至没有建议。它只是我最近几个月内心感受的一次宣泄。 建造者与思考者 我相信我的性格建立在两个主要特质之上: 1. 建造者(渴望创造、交付和务实) 2. 思考者(需要深度、持久的智力挑战) 建造者这一面很容易理解,它追求速度和实用性。这是我渴望将"想法"转化为"现实&