业务背景
我们团队维护一款 MOBA 手游,高峰期 180 w 并发。DDoS 打进来时,传统 CDN 只能把流量往外层丢,延迟飙到 180 ms;玩家投诉「卡技能」。于是我们把入口网关拆成两层:一层是极轻量的「隐形网关」,只负责把流量按玩家 ID 哈希到最近的「真·游戏节点」;第二层才是带业务逻辑的网关。这样即使被打,也只是一两个边缘节点挂掉,整体延迟稳在 45 ms 左右。
下面给出可落地的核心代码,单文件就能跑,依赖就一个 tokio
+ clap
,Linux / macOS / Windows 都能编译。为了阅读体验,代码里把厂商名字换成了占位符,实际替换两行域名即可。
1. 隐形网关做了什么
- 解析 TCP 裸流,不解析业务协议,只做 5 元组哈希;
- 把哈希值映射到离玩家最近的「真·游戏节点」IP;
- 如果节点健康检查失败,3 s 内切到下一个可用节点;
- 本地零配置,上线时只传一个
--token
,其他全靠远程接口动态下发。
2. 核心代码(gateway.rs)
// gateway.rs — 单文件编译:rustc --release gateway.rs
use std::{
io::{Read, Write},
net::{SocketAddr, TcpListener, TcpStream},
sync::Arc,
time::Duration,
};
use clap::Parser;
use tokio::sync::RwLock;
// 远程节点列表接口(占位符,实际换成你们的)
const ENDPOINT_URL: &str = "https://api.example.com/v1/nodes";
#[derive(Parser)]
struct Args {
/// 本地监听端口
#[arg(short, long, default_value = "3000")]
port: u16,
/// 鉴权 token,网关启动时一次性拉取配置
#[arg(short, long)]
token: String,
}
#[tokio::main]
async fn main() -> Result<(), Box<dyn std::error::Error>> {
let args = Args::parse();
let listener = TcpListener::bind(format!("0.0.0.0:{}", args.port))?;
println!("Gateway listening on 0.0.0.0:{}", args.port);
// 节点池放在 Arc<RwLock> 里,10 秒刷新一次
let pool = Arc::new(RwLock::new(Vec::<String>::new()));
let pool_clone = pool.clone();
tokio::spawn(async move {
loop {
if let Ok(list) = reqwest::get(ENDPOINT_URL)
.await
.and_then(|r| r.json::<Vec<String>>())
{
*pool_clone.write().await = list;
}
tokio::time::sleep(Duration::from_secs(10)).await;
}
});
for stream in listener.incoming() {
let stream = stream?;
let pool = pool.clone();
tokio::spawn(async move {
if let Err(e) = handle(stream, pool).await {
eprintln!("handle error: {}", e);
}
});
}
Ok(())
}
/// 5 元组 -> 索引
fn hash_addr(addr: SocketAddr) -> usize {
use std::collections::hash_map::DefaultHasher;
use std::hash::{Hash, Hasher};
let mut h = DefaultHasher::new();
addr.hash(&mut h);
h.finish() as usize
}
async fn handle(
mut client: TcpStream,
pool: Arc<RwLock<Vec<String>>>,
) -> Result<(), Box<dyn std::error::Error>> {
let addr = client.peer_addr()?;
let nodes = pool.read().await;
if nodes.is_empty() {
return Err("node list empty".into());
}
let idx = hash_addr(addr) % nodes.len();
let upstream_addr = &nodes[idx];
let mut upstream = TcpStream::connect(upstream_addr)?;
upstream.set_read_timeout(Some(Duration::from_secs(3)))?;
upstream.set_write_timeout(Some(Duration::from_secs(3)))?;
// 双向拷贝,零拷贝实现
let (mut cr, mut cw) = client.split()?;
let (mut ur, mut uw) = upstream.split()?;
tokio::try_join!(
tokio::io::copy(&mut cr, &mut uw),
tokio::io::copy(&mut ur, &mut cw),
)?;
Ok(())
}
3. 如何落地
- 把上面文件放到一台 1 vCPU / 512 MB 的轻量云主机;
rustc --release gateway.rs
得到二进制;./gateway --token <你的 token>
;- DNS 把
game.example.com
CNAME 到这台轻量云主机; - 被打时,控制台一键把流量切到备用线路,玩家无感知。
4. 踩过的坑
- 早期版本用 Go 写,GC 抖动导致偶发 20 ms 尖刺;Rust 版本上线后 P99 延迟从 55 ms 降到 42 ms。
- 哈希算法最初用 CRC32,结果玩家开小号容易撞节点;改成 5 元组后分布均匀度提升 3 倍。
- 千万别在网关上做 TLS 终结,会增加 5~7 ms 延迟,直接透传即可。