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

阅读更多

为了平凡的生活

为了平凡的生活

为了翻拣垃圾的孩子,和他们的梦想。 为了那些枯萎的树木, 为了那些如纸牌般倒塌的危楼。 为了那些安眠药, 和那些漫长而失眠的夜晚。 为了那些被困在原地的人, 为了改变那些腐朽而停滞的头脑。 为了那些被加速的人生, 为了恐慌,焦虑,和没有尽头的比较。 为了那些被迫接受的规则, 为了那些被强加的指令, 为了那些看不见的手, 我们成长, 成功, 不断向前。 加速学习, 加速工作, 加速赚钱, 加速消费, 加速体验, 加速衰老。 好像停下来, 就会落在后面。 我想慢下来。 想要一份普通的工作, 不用害怕失去。 想让孩子好好长大, 让老人慢慢变老。 想在傍晚回家, 吃一顿晚饭。 想让夜晚只是夜晚, 而不是焦虑、失眠和安眠药。 想让树长在那里, 而不是倒在推土机经过的地方。 想让房子能够站得住, 住进去,可以安心睡觉。 想让一个人活着, 不需要不停证明自己活得有价值。 我们曾经以为, 生活应该越来越精彩。 后来发现, 真正难得的是它能够保持正常。 没有突然坍塌的房子, 没有突然失去的工作, 没有突然降临的疾病,