返回文章列表

Rust 如何参考 Erlang 做一个热更新服务:服务先跑起来,新规则后加载

723·6 分钟阅读
Rust热更新Erlang服务治理

如果聊“不停机维护”,Erlang 是绕不开的。

Erlang/OTP 最迷人的地方,不是语法,也不是性能,而是它的运行时模型:

进程持有状态,进程之间靠消息通信,代码升级时尽量不打断正在跑的系统。

Rust 没有 BEAM,也没有 Erlang 那种原生 code upgrade 机制。Rust 编译出来是 native binary,你不能指望它像 Erlang 一样在 VM 里自然保留新旧两份模块。

但 Erlang 真正值得借鉴的不是“运行时替换代码”这个表面动作,而是背后的结构:

  1. 状态归进程所有,不到处共享。
  2. 请求和升级都通过消息进入系统。
  3. 新逻辑发布后,先加载验证,再切换。
  4. 状态迁移是显式动作,不是隐式魔法。

把这套思想搬到 Rust 里,我觉得比较实际的方案是:

主服务用 Actor 模型长期运行,变化快的业务逻辑编译成 Wasm,后续发布新 Wasm 后,通过消息让 Actor 切换规则。

重点是“后续发布”。

一开始系统里不应该天然存在两个版本。真实业务一定是先上线一个可工作的版本,服务跑起来;后来业务规则变了,才发布一个新规则包,再让运行中的服务加载它。


真实流程应该长这样

我们要做的不是把 V1V2 都写进同一个 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(())
    }
}

这段代码就是整篇文章的核心。

升级流程是:

  1. 收到 Upgrade 消息。
  2. 加载新发布的 wasm 文件。
  3. 先用影子状态做一次验证。
  4. 对真实状态执行迁移。
  5. 迁移成功后切换 handler。
  6. 如果任何一步失败,旧 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 是多少,验证有没有通过,当前版本是什么”,比魔法更重要。


真实业务里还要补什么

上面的代码是主线,不是完整生产框架。

真上生产,我会补这几块:

  1. 版本元数据:不要只靠文件名,最好有 manifest,记录版本、hash、发布时间。
  2. 签名校验:加载 wasm 前校验来源,别让未知文件进入执行链路。
  3. 预热验证:升级前跑几组样例请求,确认新规则能正常返回。
  4. 回滚机制:保留上一个 WasmHandler,切换后错误率异常可以退回。
  5. 资源限制:限制 Wasm fuel、内存和执行时间,避免规则写死循环。
  6. 观测指标:当前规则版本、升级次数、失败次数、执行耗时都要打出来。

但主线不要变:

请求、升级、回滚都走同一个 mailbox。

这条线守住了,系统就还是一个可理解的 Actor,而不是一堆全局状态和临时开关。


结论

Rust 做热更新,最容易走偏的是一上来就研究动态库。

能不能做?能。

但 Rust 没有稳定 ABI,直接跨动态库传 Rust 类型并不舒服。你最后很可能还是要退回到 C ABI,手写边界,处理内存归属。

如果业务目标是“不停机更新规则”,我更愿意选 Wasm。

因为它天然像一个可替换模块:输入清楚,输出清楚,可以沙箱,可以验证,可以回滚。

Erlang 给我们的启发不是“Rust 也应该有一个 BEAM”,而是:把状态放在长期存活的进程里,把变化的行为做成未来可加载的模块,升级通过消息完成。

主服务像 Erlang 进程一样稳定运行。

Wasm 像 Erlang 新模块一样在未来发布后被加载进来。

升级不是旁路修改,而是一条明确的消息。

说实话,我觉得这才是 Rust 借鉴 Erlang 热更新机制时最舒服的落点。