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

心智难民

心智难民

心智,按照牛津词典的定义,是获取和运用知识的能力。 互联网是一场技术革命,给每个人提供了机会。社会是由阶层组成的,每一场技术革命都促使了不同阶层的重新洗牌,或者说阶层分化。网络世界的阶层分化是什么样的呢?大概可以分为两个大的阶层:一类是接受高质量信息的精英阶层,另外一类是消费网络上的垃圾信息、接受劣质信息的乌合之众。 当然,这里说的“免费”是打引号的。因为它不仅不免费,而且一点也不便宜。 人们喜欢免费的东西。但是世界上除了阳光和空气,没什么是真正免费的东西,只是支付的方式不一样——有的直接用钱付,有的间接用钱付;有些用生活质量付,有些用人生的潜力和机会付。 You must pay for everything in this world, one way or another. Nothing is free. 你终究会以不同的方式付费,天下没有免费的午餐。 如果一个人只接受网上“免费”的信息,就像是只吃劣质食品一样,结果就是精神世界的劣质化。因为接受信息质量的差异,

By 王圆圆
Crazy World

Crazy World

by Jeff Daniels 译文 我看见一个年轻女孩笑了, 因为他刚说的话。 我看着他坠入她那双美丽的眼睛里, 脸红的像玫瑰。 我看见一位老人在走路, 妻子陪在他身旁。 我看着他俯身握住她的手, 天啊,我竟然哭了。 这疯狂的世界越来越疯狂, 我有什么资格评判呢? 但值得庆幸的是, 在这个充满仇恨的世界里, 还有人在用心相爱着。 我看见狗摇着尾巴, 看见孩子在奔跑。 我也曾在无数个日落里, 对着夕阳唱着歌。 我看见有人为别人扶着门, 看见陌生人握手寒暄。 我看见她和那个曾经错过的旧情人拥吻, 时间比计划中的更长了一些。 这个疯狂的世界继续疯狂着, 但我能说什么? 好在这个充满恨的世界里, 还有人在用心相爱着。 我看见祈祷被回应, 看见了六月里的新娘。 我骄傲地说,我当时见到了银河, 对着月光下的人们闪烁。 我看见送出的一打玫瑰, 见过她满心的欢喜藏不住, 我见过的已经足够, 让我明白我所知道的, 也坚信我依然相信的。 这疯狂的世界越来越疯狂, 我能说什么? 但值得庆幸的是, 在这个充满仇恨的世界里, 还有人相爱着。 原文 I’ve seen a

By 王圆圆
人是能被改变的吗?

人是能被改变的吗?

想改变别人基本上是在浪费时间。这个话题听起来简单,但仔细想想,我们生活中有太多时候都在做这种徒劳的事。 生活中的人大概可以分成三类: 喜欢的人 - 这些人即使有缺点你也能接受。你们相处舒服,他们做什么你都能理解,就算偶尔看不惯,也不会想着要去改造他们。 无所谓的人 - 占了我们生活中的大多数。同事、路人、网上的陌生人,他们怎么生活、怎么思考,其实跟你一点关系都没有。 讨厌的人 - 那些让你感到不舒服的人。可能是价值观完全相反,可能是行为方式你无法忍受。 既然人际关系本来就是这样,为什么还要费劲去改变谁呢?尤其是那些无所谓的人和讨厌的人,你花时间去说服他们、纠正他们,最后累的是自己。有这个功夫,不如多看两本书,学点新东西,改变一下自己。 美国人教小孩一个词:Walk Away。意思就是遇到麻烦的人、不讲理的人,转身走就完了,不用纠缠。 这听起来好像是逃避,但其实是一种很成熟的处理方式。你不是害怕对方,而是知道跟这种人浪费时间没有意义。 有个作家Charles Portis说过一句话挺有意思的:"

By 王圆圆
留守的代价

留守的代价

我有一个90后的朋友,她的故事让我久久无法平静。 她13岁那年,初中还没读完就辍学了,跟着同乡去了南方打工。六年后,在家人的安排下,她嫁给了邻村一个老实人家的儿子。没有恋爱,没有了解,只有两个家庭觉得"差不多,能过"的判断。 婚后他们一起在宁波工作,陆续有了两个女儿。按理说,一家四口,日子虽苦但也算完整。但我们那个地方,重男轻女的观念像一只看不见的手,推着她生下了第三个孩子——终于是个儿子。 三个孩子陆续到了上学的年龄,他们却一直在外打工。孩子成了留守儿童,跟着爷爷奶奶在老家,一年见父母一两次。视频通话里,孩子越来越沉默,成绩越来越差,老师反映性格也出现了问题。 她做了一个决定:回家照顾孩子。 他继续在外地送快递。从此,这个家庭被一分为二——一边是她独自面对三个问题儿童的混乱和辛苦,一边是他在城市里每天十几个小时的奔波劳累。 本来就没什么感情基础的两个人,在这种分离中,最后那点维系也消磨殆尽了。 最近两年,他给家里的生活费越来越少。后来她才知道,他在外面有了别人,赚的钱不多,都花在了新欢身上。

By 王圆圆