Rust 所有权模型与 连接池 的思考
最近在用 Rust 重写一个老项目,里面有个场景是要频繁读写数据库,自然就绕不开连接池这个东西。用的时候突然意识到,连接池这个概念在 Rust 里面变得特别有意思,跟我在 Go 或者 Java 里用连接池的心路历程完全不一样,索性写篇文章记录一下。
先说说所有权模型这件事
Rust 的所有权模型,说白了就三条规则:每个值都有一个所有者,同一时刻只能有一个所有者,所有者离开作用域值就被释放。听起来很简单,但真写起来你会发现到处都是编译器在教你做人。
我一开始的写法是这样的,拿到一个连接,用完扔回池子里:
let conn = pool.get().unwrap();
let rows = conn.query("SELECT * FROM users", &[])?;
// conn 在这里自动"还回去"了
注意这里没有任何手动归还的操作。原因是 pool.get() 返回的对象实现了 RAII,离开作用域的时候 Drop 会被自动调用,连接就回到池子里了。这就是所有权模型在干活的直观体现:借用检查器保证你在使用期间别人拿不走这个连接,作用域结束保证了连接一定会被归还。没有忘记 close,没有连接泄漏,编译期就把这些坑填了。
C++ 也有 RAII,那 Rust 特别在哪
有人可能会说,C++ 的智能指针不也是这么回事吗。确实,思路是同源的,但体验差别很大。
C++ 里你照样可以把指针到处拷贝,运行时才发现连接被两个线程同时用了,或者池子被搬空了。而 Rust 里这些统统在编译期拦下来。我在写代码的时候遇到过一个真实的问题:我想把一个连接的引用同时传给两个闭包,编译器直接报错。一开始还挺烦的,但后来仔细想想,如果编译器放行了,这俩闭包要是跑在 tokio 的不同 task 里,那连接的状态就是灾难现场。
烦归烦,但它确实在替我兜底。
连接池的借用语义
真正让我觉得舒服的是连接池的 API 设计。以 deadpool 为例:
let conn = pool.get().await?;
// 使用期间独占借用
let result = conn.simple_query("SELECT 1").await?;
drop(conn); // 不想等到作用域结束,可以提前显式还回去
get() 返回的是一个包了一层的对象,本质上可以理解成“带归还义务的借用”。这个对象是独占的,你想共享给并发任务?编译器不让。想忘了归还?Drop 帮你还。想在归还之后还偷偷留着指针用?借用检查器第一个不答应。
对比一下我在 Go 里用 database/sql 的体验:连接归还需要你调用 defer rows.Close(),忘写了就是泄漏,而且这种泄漏往往要等到生产环境连接数打满才暴露。Rust 这边这类问题根本活不过 cargo check。
一些不爽的地方
当然也不是全是好处。所有权的约束在某些场景下确实碍事。比如你只是想“读一下”连接的状态,因为 get() 返回的是拥有所有权的对象而不是引用,跨函数传递的时候签名写起来就比较啰嗦。异步场景下更明显,生命周期参数和 async 块搅在一起,报错信息能把人看懵。
还有一点,连接池本身是个共享资源,内部必须用锁或者无锁结构来管理。这意味着池子的实现者要非常小心,Arc + Mutex 的组合如果用得不好,高并发下锁竞争会很明显。作为使用者虽然感知不到,但排查性能问题时偶尔还是会撞上。
小结
写到这里我的感受是:连接池这个在别的语言里纯靠“约定和纪律”维持的东西,在 Rust 里被所有权模型变成了一种结构性保证。借用了就必须还,还了就不能再用,这些不是文档里的注意事项,而是编译器的硬性要求。
代价是学习曲线和和编译器搏斗的时间。但就连接池这个具体场景来说,我觉得这笔交易是划算的。
评论
还没有评论。
发表评论
提交后评论将经过自动审核,审核通过后公开展示。