小Cの已经记不起来的博客

Rust 所有权模型与 配置中心 的思考

最近在给一个 Rust 写的内部小服务接配置中心,本来以为就是启动的时候拉个配置的事儿,结果产品说改了配置要立刻生效,不许重启。行吧,那就做热更新。然后就掉坑里了:一个全局配置变量,想改,编译器不让;想读,也一堆讲究。折腾了两天回头一看,配置中心这套东西跟 Rust 的所有权模型居然是能一一对上的,索性记录一下。

先说踩的坑

最开始的写法很朴素,全局放一个 static,配 lazy_static 加 Mutex:

static CONFIG: Lazy<Mutex<AppConfig>> = Lazy::new(|| {
    Mutex::new(fetch_from_nacos())
});

读的时候 CONFIG.lock().unwrap(),收到推送的时候也是 lock 然后整个覆盖掉。本地跑没什么问题,一压测就露馅了:配置读取在请求路径上,锁竞争一上来 P99 直接起飞。而且 std::sync::Mutex 的守卫哪天手滑跨着 .await 持有,在 tokio 里就是一场事故,这种坑不炸则已,一炸就是线上。

换成 RwLock 好一点,读多写少嘛,但也就好一点,写者还是得等所有读者撒手。到这里问题的本质其实已经露出来了:一份共享可变的数据,运行期没有编译器帮你看,「同一时刻要么一个写者、要么多个读者」这条铁律全靠锁在运行时 enforcement,错了就是数据竞争或者死锁。

快照替换,而不是原地修改

后来换了 arc-swap:

use arc_swap::ArcSwap;

static CONFIG: ArcSwap<AppConfig> = ArcSwap::from_pointee(fetch_from_nacos());

// 读,几乎无锁开销,拿到的是当前版本的快照
let cfg = CONFIG.load();

// 配置中心推送过来的时候
CONFIG.store(Arc::new(new_config));

关键变化在于:更新不再是「把老配置打开改两个字段」,而是直接扔一版新的上去,老配置等还在读它的人用完(Arc 引用计数归零)自然释放。读者永远面对一个不可变的完整快照,不存在读到改一半的配置。

写完我愣了一下,这不就是配置中心本来的工作方式吗。etcd 每次改动 revision 递增,watch 推的是新版本,客户端各读各的,从头到尾没有任何人在「原地修改配置」。整块替换加指针切换,和发布新版本加推送订阅,是同一个模型在两个层面的实现。Rust 只是把这个模型用类型明晃晃摆在了你面前,不摆出来就编译不过。

到处 clone 的配置,其实已经不归你管了

热更新上线之后又出了幺蛾子:明明改了配置中心,有个模块死活不生效。排查半天,发现是那个模块初始化的时候把配置里的值拷了一份存进自己的结构体:

impl RetryPolicy {
    fn new() -> Self {
        Self {
            max_retries: CONFIG.load().max_retries,
        }
    }
}

构造那一刻,max_retries 这个 u32 的所有权(好吧,是副本)就归 RetryPolicy 了,之后配置中心改成天荒地老也跟它没关系。这在 Rust 里是被反复强调的常识:Copy 也好 clone 也好,拷出来就是两份数据,从此互不相干,编译器甚至会在你 move 之后禁止再碰原来的变量,生怕你搞混。

可业务代码里没有任何东西提醒你——你手里那份「配置」其实只是某个时刻的快照。Rust 用类型系统在编译期逼你承认这件事,普通代码里它就静悄悄变成一个线上 bug。所以现在组里的约定是:要么用的时候就地 load,用完就丢,别存;要么变量名直接带上 snapshot,明着告诉后人这是过期自负的快照,别骗自己说这是「活配置」。

lease 到期自动消失,这不就是 Drop 吗

顺手提一个服务发现的细节。etcd 注册的时候给 key 绑个 lease,session 心跳一断,key 自动删除,下游 watch 到 delete 事件就把节点摘了。第一次觉得这机制眼熟,后来想明白了:这不就是 Drop。资源生命周期绑在持有者身上,持有者没了资源自己清理,不用谁记得去收尾。

对比一下反例就更清楚了:服务下线靠人工调注销接口,忘了就留一地僵尸节点,跟手动 free 忘了就是内存泄漏一个道理。etcd 把清理绑进协议,Rust 把清理绑进类型系统,做的事是同一件——默认「人是会忘的」。

碎碎念

说到底,所有权模型回答的就三个问题:这份数据归谁、此刻谁能碰它、它什么时候死。拿这三个问题去审一遍配置系统,一半的配置事故都能提前现形:归谁——权威是配置中心还是服务本地那份副本;谁能碰——热更新改的是共享的这版,还是散落各处的拷贝;什么时候死——本地缓存跟着什么东西过期。

这么一想,写 Rust 最大的收获可能不是没有空指针,而是脑子里多了个随时审计数据流向的参照系。至于 load 出来的守卫拿太久会让旧版本一直释放不掉这件事,文档里提了一嘴我还没细究,等哪天真踩了坑,再来写下一篇吧。

评论

还没有评论。

发表评论

提交后评论将经过自动审核,审核通过后公开展示。

未在播放