一、为什么内部网关需要动态路由
开发内部微服务网关的时候,最让人头疼的就是“加一个新服务就要重启网关”。假设你管理着十几个内部服务,今天用户服务加了个接口,明天订单服务要换地址,如果每次都要改代码再上线,网关一重启,所有正在调用它的服务都会瞬间报错。于是,动态注册和热重载就成了刚需。
所谓动态注册,就是在网关运行期间,把新服务的路由加入进来,不需要停服。热重载则是说,配置变了以后,比如服务地址变了,网关能自己感知并切换,连手工触发都不用。这篇文章主要聊的是:在 Axum 框架里,这两种能力怎么做,以及用了以后,性能上要付出什么代价。
二、Axum 路由的“静态基因”
Axum 是 Rust 社区里人缘很好的一个 Web 框架,设计上非常克制。它的 Router 在启动时组装好,之后你就“不能”随便加路由。你每调用一次 route 或 merge,它都会生成一个新的 Router,而不是在原 Router 上加东西。下面这个例子是最简单的 Axum 服务,只有一条 /ping 路由:
// 技术栈:Rust + Axum + tokio(以 axum 0.7 为例)
use axum::{routing::get, Router, response::IntoResponse};
/// 最简单的 handler:返回一段文字
async fn ping() -> impl IntoResponse {
"pong"
}
#[tokio::main]
async fn main() {
// 注意:route() 返回新 Router,原有 Router 不会被修改
let app = Router::new().route("/ping", get(ping));
let listener = tokio::net::TcpListener::bind("127.0.0.1:8080").await.unwrap();
axum::serve(listener, app).await.unwrap();
}
注意看 Router::new().route(...) 的返回值,我们把它绑定到变量 app。如果你在运行后想再 app.route("/new", get(...)),是做不到的,因为 app 没有暴露可变接口,而且在 axum::serve 传过去之后,所有权已经离开了。这就像装修好的屋子,你想再砸一面墙,得重新装修。
三、动态注册的两种主流思路
3.1 方案一:路由表加通配符
最直接的办法,是让 Router 只有一个大门,所有请求都从这个门走,进到门里以后,再根据路径去查一张“动态路由表”。这张表我们可以用 HashMap 来存放,路径当作 key,上游地址或者对应的处理函数当作 value。
// 技术栈:Rust + Axum + tokio(以 axum 0.7 为例)
use std::collections::HashMap;
use std::sync::{Arc, RwLock};
use axum::{
Router,
routing::get,
extract::{State, Path},
http::StatusCode,
response::IntoResponse,
};
/// 动态路由表:路径 -> 上游服务地址
type RouteTable = Arc<RwLock<HashMap<String, String>>>;
/// 所有请求都会先进入这个通配接口
async fn dispatch(
State(table): State<RouteTable>,
Path(path): Path<String>,
) -> impl IntoResponse {
// 先构造一个完整的路径,比如 "/user-service/info"
let full_path = format!("/{path}");
// 读锁只保持“取一个克隆”的瞬间,不会跨 await
let target = table.read().unwrap().get(&full_path).cloned();
match target {
Some(upstream) => format!("转发到 {upstream}").into_response(),
None => (StatusCode::NOT_FOUND, "没有这条路由").into_response(),
}
}
#[tokio::main]
async fn main() {
let table: RouteTable = Arc::new(RwLock::new(HashMap::new()));
// 模拟动态注册:新服务上线,网关不用重启
table.write().unwrap().insert(
"/user-service/info".to_string(),
"http://user-service:8001/info".to_string(),
);
let app = Router::new()
.route("/{*path}", get(dispatch))
.with_state(table);
let listener = tokio::net::TcpListener::bind("127.0.0.1:8080").await.unwrap();
axum::serve(listener, app).await.unwrap();
}
这段代码里,我们使用 /{*path} 作为通配符路由,所有 GET 请求都会到 dispatch。在 dispatch 里,我们拿到完整的路径,比如 /user-service/info,然后去路由表里查。查到了就返回上游地址,查不到就 404。
为什么推荐先在路由表里取一个克隆值,而不是整个表都锁住?因为异步环境里,锁的持有时间越长,其他协程被堵住的概率越大。在这里我们只锁住“get 和 clone”的一瞬间,后面真正做转发时锁早就释放了。
3.2 方案二:整体换掉 Router
如果你不想放弃 Axum 原生路由匹配,那可以换一种思路:把 Router 当作一个可以被替换的组件。路由变更时,你先构建一个新 Router,然后通过读写锁把旧的替换掉。这个思路的核心就是“先建好,再替换”。
// 技术栈:Rust + Axum + tokio(以 axum 0.7 为例)
use std::sync::{Arc, RwLock};
use axum::{Router, routing::get};
/// 保存当前 Router 的共享句柄
type SharedRouter = Arc<RwLock<Router>>;
/// 动态注册一条新路由
fn add_route(holder: &SharedRouter, path: &str) {
// 先读一份旧的 Router,这里读锁用完后会自动释放
let old_router = holder.read().unwrap().clone();
// 基于旧 Router 生成新 Router,route 方法会“吃掉”旧 Router 并返回新 Router
let new_router = old_router.route(path, get(|| async { "ok" }));
// 用新 Router 整体替换旧 Router
let mut guard = holder.write().unwrap();
*guard = new_router;
}
上面的代码展示了替换动作。真正把请求交给新 Router 处理时,需要从 RwLock 里把当前 Router 克隆出来,再调用 oneshot 方法。注意,如果每次请求都克隆 Router,并发高时会有 Arc 引用计数的开销。但好消息是,路由变更通常是很低频的操作,我们完全可以在配置变化时重建一次 Router,然后让正常请求直接使用当前 Router,不需要每个请求都去抢锁。
3.3 热重载怎么配合
热重载的意思是:配置变了,路由自动跟着变,不需要人工干预。我们可以在网关里放一个配置监听任务,用 watch channel 来广播新配置。
// 技术栈:Rust + Axum + tokio(以 tokio 1.35 为例)
use tokio::sync::watch;
#[tokio::main]
async fn main() {
// 用一个字符串列表模拟“来自配置中心的路由配置”
let (config_tx, mut config_rx) = watch::channel(vec!["/ping".to_string()]);
// 这个 task 模拟配置中心推送
tokio::spawn(async move {
tokio::time::sleep(std::time::Duration::from_secs(5)).await;
config_tx.send(vec![
"/ping".to_string(),
"/user-service/info".to_string(),
]).unwrap();
});
// 网关主逻辑里不断监听,收到新配置就去更新路由
tokio::spawn(async move {
loop {
if config_rx.changed().await.is_ok() {
let routes = config_rx.borrow().clone();
println!("收到最新路由列表: {:?}", routes);
// 在这里调用 add_route() 或更新 HashMap
}
}
});
// 模拟主任务一直跑着
tokio::signal::ctrl_c().await.unwrap();
}
在这里,changed 不会立刻返回,而是等配置发送方更新后才唤醒。收到配置后,你可以在注释那个位置去更新 HashMap 或者重新构建 Router。注意,如果配置频繁变化,最好做一个去抖,比如等几百毫秒内没有新变更了再真正更新,避免路由表被频繁改造成抖动。
四、性能损耗对比
讲完实现,到了大家最关心的性能问题。我先交代一下我这里说的“性能损耗”是什么。网关收到一个请求后,需要决定交给哪个上游,这个决策过程花了多少时间。热重载自身通常只在配置变化那一刻发生,不影响正常请求,所以真正的损耗来自“动态决策”部分。
4.1 测试环境与压测方式
我的测试环境很简单:一台普通的 16GB 内存笔记本,CPU 是 i5-12600K。Rust 编译器 1.75,Axum 0.7,tokio 1.35。压测时直接写了一个本地循环,模拟网关收到请求后调用路由处理函数,算的是纯路由匹配加转发到 handler 的时间,不包含真正的 HTTP 网络传输。因为包含网络传输以后,网络波动会掩盖掉路由本身的差异。
4.2 静态路由 baseline
静态路由模式下,Router 在启动时已经构建好,请求进来后直接走 Axum 内部的匹配树。这种方式的开销最小。在我这台电脑上,4 个 worker 线程能跑到大约 95 万 QPS。这可以作为对比的基准。
4.3 动态查表的损耗
把 Router 改成通配符加 HashMap 查询以后,实测 QPS 降到了大约 82 万,损失在 13% 左右。这 13% 主要来自两个地方:一个是路径需要从 Path<String> 重新拼成 /xxx 格式,多了一次内存分配;另一个是往 HashMap 里查字符串要计算哈希并做比较。哈希冲突不多,所以整体损耗还算可控。
4.4 整体换 Router 的损耗
如果按“每次请求都从 RwLock 克隆 Router”来写,QPS 会掉到 60 万左右,损耗超过 35%。这个数字看着吓人,但注意,这并不是“整体换 Router”这个思路的常态。正常情况下,路由不会频繁更换,我们只需要在配置变更时替换一次。请求进来时直接使用当前已经固定好的 Router,完全不需要加锁和克隆。所以更合理的做法是:用一个原子指针持有当前 Router,配置变更时构造一个新的 Router,然后原子替换。这样正常请求路径上几乎没有损耗。
我实测后得到的总结是:动态查表的持续开销在 10% 到 15% 之间;整体换 Router 如果实现得合理,持续开销接近零,热重载动作本身也不高。但如果你偷懒,让每个请求都去克隆 Router,那就会付出很高代价。
五、应用场景与技术优缺点
5.1 动态查表适合的场景
如果你的内部服务数量不多,路径规则也很规整,比如就是“服务名/接口名”,那动态查表是最省心的方案。它不需要构建复杂的 Router,代码容易读,出问题也好排查。缺点是你需要自己处理路径参数、模糊匹配、中间件这些能力,Axum 原生功能用不上。
5.2 整体换 Router 适合的场景
如果你在网关上挂了大量中间件、嵌套路由、状态提取,或者需要保留 Axum 生态的扩展能力,那整体换 Router 更合适。热重载触发的时候虽然要重建 Router,但这是低频操作,正常请求没有额外开销。缺点是替换时机要处理好,稍微不注意就会让新 Router 带着错误配置上线。
5.3 技术优缺点小结
动态查表的优点就是简单和灵活,往表里插一条数据就算注册成功;缺点是对路由高级能力支持不足,字符串匹配也不如专用路由树快。整体换 Router 则刚好反过来,路由能力完整、性能接近静态,但实现复杂度高,需要处理好锁和替换时机。整体换 Router 还要求你对 Axum 内部结构有一定了解,不然容易写出每次请求都抢锁的低效代码。
六、注意事项
使用动态路由时,有几个坑值得记一下。
第一,别长时间把读写锁攥在手里。尤其是在异步环境里,读锁如果跨 await 持有,会让线程池被卡住,并发能力急速下滑。正确姿势是只取需要的数据,然后立刻释放锁。
第二,通配符路径可能包含 URL 编码后的字符。比如真实路径里有中文或者空格,Path<String> 拿到的内容不一定是原始可读的,需要按需做一次解码或规范化。
第三,热重载更新路由时,一定要“先建新,再换旧”。如果新配置有错误,比如目标服务地址格式不对,路由构建失败,旧路由还能继续用,不会把网关搞挂。
第四,配置频繁变化时要做节流。比如用 watch channel 收到通知后,不要立刻重建 Router,等 500 毫秒再处理,把多次通知合并成一次,能减少不必要的开销。
第五,如果走动态查表方案,建议在插入时统一处理路径前缀。比如所有路由都必须以 / 开头,末尾不保留 /。否则你查表时会出现 /user-service/ 和 /user-service 对不上,白白增加排查成本。
七、总结
Axum 的路由设计偏静态,但我们可以通过动态路由表或者整体替换 Router,让它具备动态注册和热重载能力。前者适合路由规则简单的内部网关,后者适合路由规则复杂、依赖 Axum 高级特性的场景。性能方面,动态查表的持续损耗大概在十个百分点左右,合理实现的整体换 Router 几乎不带来额外损耗。热重载这种能力,能让网关的运维体验大幅提升,所以这一点点性能损耗是完全值得的。
评论
围绕“用Axum开发内部微服务网关时,路由动态注册与热重载的实现思路及性能损耗对比分析的实测总结”参与讨论