Rust 如何参考 Erlang 做一个热更新服务:服务先跑起来,新规则后加载
如果聊“不停机维护”,Erlang 是绕不开的。
Erlang/OTP 最迷人的地方,不是语法,也不是性能,而是它的运行时模型:
进程持有状态,进程之间靠消息通信,代码升级时尽量不打断正在跑的系统。
Rust 没有 BEAM,也没有 Erlang 那种原生 code upgrade 机制。Rust 编译出来是 native binary,你不能指望它像 Erlang 一样在 VM 里自然保留新旧两份模块。
但 Erlang 真正值得借鉴的不是“运行时替换代码”这个表面动作,而是背后的结构:
- 状态归进程所有,不到处共享。
- 请求和升级都通过消息进入系统。
- 新逻辑发布后,先加载验证,再切换。
- 状态迁移是显式动作,不是隐式魔法。
把这套思想搬到 Rust 里,我觉得比较实际的方案是:
主服务用 Actor 模型长期运行,变化快的业务逻辑编译成 Wasm,后续发布新 Wasm 后,通过消息让 Actor 切换规则。
重点是“后续发布”。
一开始系统里不应该天然存在两个版本。真实业务一定是先上线一个可工作的版本,服务跑起来;后来业务规则变了,才发布一个新规则包,再让运行中的服务加载它。
真实流程应该长这样
我们要做的不是把 V1、V2 都写进同一个 main.rs,然后在代码里手动切换。
那不是热更新,那只是 if-else 换实现。
更真实的流程应该是:
第一次发布
├── 编译 rule-v1 -> risk_rule.wasm
├── Rust 主服务启动
└── 主服务加载当前规则 risk_rule.wasm
服务运行中
├── 请求持续进来
├── Actor 持有状态
└── 当前 Wasm 规则处理请求
后来业务要改规则
├── 编译 rule-v2 -> risk_rule_v2.wasm
├── 发布到 rules/ 或对象存储
├── 管理端发 Upgrade 消息
└── Actor 加载新 Wasm,验证成功后切换也就是说,热更新的关键不是“代码里提前准备好两个版本”,而是:
运行中的服务有一个稳定入口,可以在未来接收一个新规则产物。
这才像 Erlang 的味道:进程先活着,后面有新模块发布,再通过升级协议切过去。
Erlang 给我们的启发:升级也是一条消息
Erlang 里,进程有自己的 mailbox。外部不能直接摸它的状态,只能发消息。
Rust 里我们也这么做:业务请求是消息,升级也是消息。
HotService Actor
├── Call(request)
├── Upgrade(new_wasm_path)
└── Stop这样有个好处:升级不会从旁边突然插进来。
如果一个请求正在处理,它会先处理完;升级消息排在 mailbox 里,轮到它时再加载新模块。
所有变化都走同一个入口,系统就不会变成一堆锁和全局变量。
第一次发布:只有一个规则版本
先写第一版规则。
这个 crate 编译目标是 wasm32-unknown-unknown,导出一个朴素函数:输入用户 ID 和金额,返回决策码。
# rule/Cargo.toml
[package]
name = "risk-rule"
version = "0.1.0"
edition = "2024"
[lib]
crate-type = ["cdylib"]第一版逻辑很简单:金额大于 10000 就人工审核。
// rule/src/lib.rs
#[unsafe(no_mangle)]
pub extern "C" fn decide(_user_id: i64, amount: i64) -> i32 {
if amount > 10_000 {
1
} else {
0
}
}决策码约定如下:
| 返回值 | 含义 |
|---|---|
0 |
allow |
1 |
review |
2 |
allow-fast-lane |
这里故意不用 JSON,不用复杂 ABI。
热更新服务最怕一开始就把边界做复杂。真实业务后面可以引入 wit、Wasm Component Model 或序列化协议,但第一版最好先把链路跑通。
编译:
rustup target add wasm32-unknown-unknown
cargo build --release --target wasm32-unknown-unknown第一次发布时,只需要把产物放到当前规则位置:
rules/current/risk_rule.wasm主服务启动时只知道这个路径。
它不知道未来会不会有 v2,也不需要知道。
主服务:Actor 持有状态,WasmHandler 持有行为
主服务这边用 tokio 做 Actor,用 wasmtime 执行 Wasm。
# hot-service/Cargo.toml
[dependencies]
anyhow = "1.0"
tokio = { version = "1.48", features = ["full"] }
wasmtime = "38"先定义请求、响应和状态。
use anyhow::{anyhow, Result};
use std::path::{Path, PathBuf};
use tokio::sync::{mpsc, oneshot};
use wasmtime::{Engine, Instance, Module, Store, TypedFunc};
struct Request {
user_id: i64,
amount: i64,
}
struct Response {
decision: String,
rule_version: String,
}
struct State {
processed: u64,
schema_version: u32,
}这里的 State 留在主服务里,而不是放到 Wasm 里。
Wasm 只负责“行为”:这笔请求怎么判断。
主服务负责“状态”:处理过多少请求、当前状态 schema 是什么,真实业务里还可能有缓存、连接池、限流桶。
这就是从 Erlang 借来的核心思想:状态和代码分离。
WasmHandler:把当前 wasm 包装成业务规则
WasmHandler 负责加载一个 wasm 文件,找到里面导出的 decide 函数,然后把返回码翻译成业务响应。
struct WasmHandler {
version: String,
store: Store<()>,
decide: TypedFunc<(i64, i64), i32>,
}
impl WasmHandler {
fn load(engine: &Engine, path: impl AsRef<Path>) -> Result<Self> {
let path = path.as_ref();
let module = Module::from_file(engine, path)?;
let mut store = Store::new(engine, ());
let instance = Instance::new(&mut store, &module, &[])?;
let decide = instance.get_typed_func::<(i64, i64), i32>(&mut store, "decide")?;
let version = path
.file_stem()
.and_then(|name| name.to_str())
.unwrap_or("unknown")
.to_string();
Ok(Self {
version,
store,
decide,
})
}
fn handle(&mut self, state: &mut State, request: Request) -> Result<Response> {
state.processed += 1;
let code = self
.decide
.call(&mut self.store, (request.user_id, request.amount))?;
let decision = match code {
0 => "allow",
1 => "review",
2 => "allow-fast-lane",
other => return Err(anyhow!("unknown decision code: {other}")),
};
Ok(Response {
decision: decision.to_string(),
rule_version: self.version.clone(),
})
}
fn migrate(&mut self, state: &mut State) -> Result<()> {
if state.schema_version == 0 {
state.schema_version = 1;
}
Ok(())
}
}这个边界很干净:
- Wasm 不直接碰主服务状态。
- 主服务不信任 Wasm 返回值,会校验决策码。
- 新规则加载失败,不影响旧规则继续跑。
这就是 Wasm 比 Rust 动态库舒服的地方。Wasm 模块坏了,最多加载失败;不会轻易把主进程内存布局搞炸。
Actor 消息:请求、升级、停止
现在定义 Actor 的消息。
enum Command {
Call {
request: Request,
reply: oneshot::Sender<Result<Response>>,
},
Upgrade {
wasm_path: PathBuf,
reply: oneshot::Sender<Result<String>>,
},
Stop,
}这里我让 Upgrade 带一个回执。
真实业务里一定要知道升级成功还是失败,不能只是发一条消息然后祈祷。
HotService:Rust 版 gen_server
现在把 Actor 写出来。
struct HotService {
engine: Engine,
state: State,
handler: WasmHandler,
rx: mpsc::Receiver<Command>,
}
impl HotService {
async fn run(mut self) {
while let Some(command) = self.rx.recv().await {
match command {
Command::Call { request, reply } => {
let response = self.handler.handle(&mut self.state, request);
let _ = reply.send(response);
}
Command::Upgrade { wasm_path, reply } => {
let result = self.upgrade(wasm_path);
let _ = reply.send(result);
}
Command::Stop => break,
}
}
}
fn upgrade(&mut self, wasm_path: PathBuf) -> Result<String> {
let old_version = self.handler.version.clone();
let mut next = WasmHandler::load(&self.engine, wasm_path)?;
self.validate(&mut next)?;
next.migrate(&mut self.state)?;
let new_version = next.version.clone();
self.handler = next;
Ok(format!("upgrade {old_version} -> {new_version}"))
}
fn validate(&self, next: &mut WasmHandler) -> Result<()> {
let mut shadow_state = State {
processed: self.state.processed,
schema_version: self.state.schema_version,
};
let response = next.handle(
&mut shadow_state,
Request {
user_id: 1,
amount: 100,
},
)?;
if response.decision.is_empty() {
return Err(anyhow!("empty decision from new rule"));
}
Ok(())
}
}这段代码就是整篇文章的核心。
升级流程是:
- 收到
Upgrade消息。 - 加载新发布的 wasm 文件。
- 先用影子状态做一次验证。
- 对真实状态执行迁移。
- 迁移成功后切换 handler。
- 如果任何一步失败,旧 handler 继续工作。
这和 Erlang 的 code_change 很像,只是 Rust 不帮你自动做。你必须把升级过程写出来。
我个人反而喜欢这个显式性:线上系统最怕“看起来自动,其实不知道什么时候发生”。
Handle:外部只拿句柄,不碰内部状态
为了让外部调用更舒服,我们封装一个句柄。
#[derive(Clone)]
struct HotServiceHandle {
tx: mpsc::Sender<Command>,
}
impl HotServiceHandle {
async fn call(&self, request: Request) -> Result<Response> {
let (reply, rx) = oneshot::channel();
self.tx
.send(Command::Call { request, reply })
.await?;
rx.await?
}
async fn upgrade(&self, wasm_path: impl Into<PathBuf>) -> Result<String> {
let (reply, rx) = oneshot::channel();
self.tx
.send(Command::Upgrade {
wasm_path: wasm_path.into(),
reply,
})
.await?;
rx.await?
}
}调用方不知道里面现在跑的是哪个规则版本。
它只负责发请求、发升级命令。
这也很 Erlang:外部面对的是进程句柄,内部状态和升级策略由进程自己管理。
启动时只加载当前版本
主服务启动时,不应该知道未来版本。
它只加载“当前规则”。
async fn start_service(initial_rule: impl AsRef<Path>) -> Result<HotServiceHandle> {
let engine = Engine::default();
let initial = WasmHandler::load(&engine, initial_rule)?;
let (tx, rx) = mpsc::channel(64);
let service = HotService {
engine,
state: State {
processed: 0,
schema_version: 0,
},
handler: initial,
rx,
};
tokio::spawn(service.run());
Ok(HotServiceHandle { tx })
}然后 main 只做一件事:用当前规则把服务跑起来。
#[tokio::main]
async fn main() -> Result<()> {
let handle = start_service("rules/current/risk_rule.wasm").await?;
let response = handle
.call(Request {
user_id: 1,
amount: 6_000,
})
.await?;
println!("{}:{}", response.rule_version, response.decision);
tokio::signal::ctrl_c().await?;
Ok(())
}此时系统里没有 v2。
它就是一个正常启动的服务。
这点很重要:热更新能力是服务的基础设施,不是业务代码里预埋两个版本。
后来发布新规则:管理端触发升级
过了一段时间,业务说:审核阈值要从 10000 调到 5000,偶数用户走快速通道。
这时候才会出现第二版规则。
// rule/src/lib.rs,新发布版本
#[unsafe(no_mangle)]
pub extern "C" fn decide(user_id: i64, amount: i64) -> i32 {
if amount > 5_000 {
1
} else if user_id % 2 == 0 {
2
} else {
0
}
}编译后发布成一个新的不可变文件:
rules/releases/risk_rule_v2.wasm然后管理端调用升级接口。
真实项目里这通常是 HTTP API、后台任务、配置中心 watcher,或者部署系统发来的事件。示例里我们直接写成函数:
async fn admin_upgrade(handle: HotServiceHandle, path: PathBuf) -> Result<()> {
let message = handle.upgrade(path).await?;
println!("{message}");
Ok(())
}如果是 HTTP 接口,大概就是这个意思:
async fn upgrade_rule(
handle: HotServiceHandle,
wasm_path: PathBuf,
) -> Result<String> {
handle.upgrade(wasm_path).await
}关键不是 HTTP 框架怎么写,关键是它最后一定要变成一条 Upgrade 消息进入 Actor。
不要在 HTTP handler 里直接改全局变量。
不要多个线程同时抢着换规则。
升级路径只能有一条:发消息给 Actor。
串起来看:这才是完整热更新闭环
整个流程应该是这样:
T0:第一次发布
├── 构建 risk_rule.wasm
├── 启动 Rust 主服务
└── Actor 加载 rules/current/risk_rule.wasm
T1:服务运行中
├── 请求进入 mailbox
├── Actor 使用当前 WasmHandler 处理
└── State 一直留在 Actor 内部
T2:规则变更
├── 构建新的 wasm release 文件
├── 上传到 rules/releases/...
└── 管理端发 Upgrade(new_path)
T3:Actor 执行升级
├── 加载新 wasm
├── 验证新规则
├── migrate 当前 State
├── 切换 WasmHandler
└── 返回升级结果
T4:继续运行
├── 新请求走新规则
└── 主服务没有重启,状态没有丢这里没有“启动时就知道 v2”。
v2 是未来某次发布产生的外部产物。
主服务只是提前具备了加载新产物的能力。
这才是热更新服务应该有的样子。
这里和 Erlang 的差异
这个方案借鉴了 Erlang,但它不是 Erlang。
差异主要有三个:
| 点 | Erlang | Rust + Wasm |
|---|---|---|
| 运行时 | BEAM 原生支持代码加载 | 主服务自己实现升级流程 |
| 状态迁移 | code_change 回调 |
migrate 显式执行 |
| 新代码来源 | 新 beam 模块 | 新 wasm release 文件 |
Erlang 更自动,Rust 更显式。
我个人觉得,在很多业务系统里,Rust 这种显式反而更容易做审计和回滚。尤其是线上规则更新,能清楚看到“谁发布了哪个 wasm,hash 是多少,验证有没有通过,当前版本是什么”,比魔法更重要。
真实业务里还要补什么
上面的代码是主线,不是完整生产框架。
真上生产,我会补这几块:
- 版本元数据:不要只靠文件名,最好有 manifest,记录版本、hash、发布时间。
- 签名校验:加载 wasm 前校验来源,别让未知文件进入执行链路。
- 预热验证:升级前跑几组样例请求,确认新规则能正常返回。
- 回滚机制:保留上一个
WasmHandler,切换后错误率异常可以退回。 - 资源限制:限制 Wasm fuel、内存和执行时间,避免规则写死循环。
- 观测指标:当前规则版本、升级次数、失败次数、执行耗时都要打出来。
但主线不要变:
请求、升级、回滚都走同一个 mailbox。
这条线守住了,系统就还是一个可理解的 Actor,而不是一堆全局状态和临时开关。
结论
Rust 做热更新,最容易走偏的是一上来就研究动态库。
能不能做?能。
但 Rust 没有稳定 ABI,直接跨动态库传 Rust 类型并不舒服。你最后很可能还是要退回到 C ABI,手写边界,处理内存归属。
如果业务目标是“不停机更新规则”,我更愿意选 Wasm。
因为它天然像一个可替换模块:输入清楚,输出清楚,可以沙箱,可以验证,可以回滚。
Erlang 给我们的启发不是“Rust 也应该有一个 BEAM”,而是:把状态放在长期存活的进程里,把变化的行为做成未来可加载的模块,升级通过消息完成。
主服务像 Erlang 进程一样稳定运行。
Wasm 像 Erlang 新模块一样在未来发布后被加载进来。
升级不是旁路修改,而是一条明确的消息。
说实话,我觉得这才是 Rust 借鉴 Erlang 热更新机制时最舒服的落点。