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

Rust 所有权模型与 蓝绿部署 的思考

最近同时在干两件看起来毫不相关的事:下班啃 Rust,上班给公司一个老服务从滚动发布改成蓝绿部署。本来以为一个语言特性、一个发布策略,八竿子打不着,结果越做越觉得它们在讲同一件事:一个资源同一时刻只能有一个所有者,交接必须显式,不能靠猜。

先说 Rust 那边

Rust 里每个值都有明确的所有者,赋值默认是 move 而不是 copy,旧的变量移交之后就失效了:

let blue = String::from("v1.0");
let green = blue;          // 所有权在这里移交
println!("{}", blue);      // 编译直接报错,borrow of moved value

写代码的时候跟借用检查器搏斗是真的烦,到处都在跟它讲道理。但架不住它防的坑全是经典款:use after free、double free、数据竞争,随便拎一个出来都是线上事故素材库里的老演员。它的思路很朴素,与其在运行时炸,不如在编译期就给你一个大红字。

蓝绿部署翻车现场

然后我就栽在了一个“经典款”上。

这个老服务有本地内存缓存,存用户会话。蓝绿切换,流量从蓝实例切到绿实例,看起来一切正常。结果过了一会儿发现还是有零星请求打到蓝上——注册中心的心跳有间隔,老实例的下线通知有延迟。于是就出现了两个“所有者”同时认为会话归自己管的场面:绿的更新了会话,蓝的拿着旧数据回写,脏数据就这么进去了。

排查到半夜,说白了就是一次 move 之后仍然使用了旧变量,而且这次没有任何编译器在旁边拦你。后来翻资料才发现这其实是蓝绿部署的文档里明确提过的经典问题,可能是我瞎没看吧...

把切换变成一次显式的 move

两边连解决思路都长得一样:Rust 是让移交变成显式动作,move 完旧引用全部失效;部署上也一样,切流量必须是一个明确的所有权移交,而不是“大概切了吧”。落到实操就是三步:

  1. 旧实例先把自己从注册中心摘除,然后等一段时间,要覆盖心跳间隔加上负载均衡的刷新周期;
  2. 处理完在途请求,期间不再接收新流量;
  3. 最后才真正停掉进程。

这其实就是给“蓝”这个变量补了一个显式的生命周期,跑完就 drop,不许悬在那儿。

我们注册中心心跳是 5 秒一次,所以 k8s 里是这么配的,sleep 的值按你自己的情况来,别照抄:

lifecycle:
  preStop:
    exec:
      command: ["/bin/sh", "-c", "sleep 15"]
terminationGracePeriodSeconds: 60

preStop 里的 sleep 给摘除通知留时间,terminationGracePeriodSeconds 保证 drain 有足够的预算。改完之后又灰度发了一轮,不出问题的话就没有问题了,那之后脏数据的问题再没复现过。

数据库才是真正的共享可变状态

蓝绿的文档里一般还有一句轻描淡写的话:新旧版本要能同时跑。加个字段还好,真到了改字段类型的时候就露馅了。你等于是在借用检查器明令禁止的场景下硬来:旧版本要求 schema 是旧形状,新版本要求是新形状,还都想要写权限,教科书级别的数据竞争。只不过这次编译器帮不了你,因为数据库是跨进程的共享可变状态,谁也拿不走它的所有权。

所以实践里的妥协是把 schema 变更拆成多步:先加新列、双写、回填、切读、最后删旧列,保证每一次发布的版本都能在同一个 schema 上跑。繁琐吗?繁琐。但借用检查器平时也烦,事后你还是会谢谢它。

写在最后

当然 Rust 也不是灵丹妙药,它保证的是单进程内的内存安全,跨进程、跨实例的状态它一概管不着,这些边界上的坑还是得靠部署策略、靠流程去兜底。但学 Rust 这段时间最大的收获倒不是语法,而是养成了一种条件反射:看到“两个东西同时管一份状态”就浑身难受。

蓝绿部署搞顺了之后回头看,所有权模型讲的根本不只是内存,它说的是所有共享状态的管理方式。编译器能帮你一次的地方就让它帮一次,帮不了的地方,你就得自己当自己的编译器。

评论

还没有评论。

发表评论

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

未在播放