十多年前用Erlang做了一个MySQL Proxy,当时是一个懂协议专家团队开发的项目;到了 vibe coding 时代,一个人也终于能认真把它做起来。
十多年前,这更像是一个团队项目;到了 vibe coding 时代,一个人也终于能认真把它做起来。
我第一次认真动手写 MySQL Proxy 的时候,有一种很熟悉的错觉:
“先把 TCP 监听起来,再顺手看一下 SQL,不就差不多了?”
结果现实很快教育了我。 你以为自己在写一个“会转发流量的小工具”,实际上你碰到的是一个状态化协议、一整套连接生命周期、认证流程、包边界、结果集格式,以及一堆你不小心碰一下就会把客户端弄断线的细节。
这也是为什么,放在很多年前,MySQL Proxy 这种题目听起来就不像是“一个人周末写着玩”的东西。
它更像一个需要专家团队认真拆解的问题:得有人懂协议,得有人懂连接管理,得有人处理认证和异常,得有人盯性能和线上稳定性。难点从来不只是代码量,而是你必须知道自己到底在碰什么。
但今天,事情确实有点不一样了。
不是因为 MySQL 协议变简单了,也不是因为中间件工程 suddenly 失去了门槛,而是因为我们已经进入了一个很有意思的阶段:
vibe coding 时代,复杂系统的原型搭建第一次对个人开发者真正友好了。

以前,一个人做这种题,最容易被消耗掉的不是理解力,而是体力。参数解析要自己铺,日志框架要自己搭,测试脚手架要自己写,状态机雏形要自己抠,网络样板代码要自己磨。你明明知道大的方向,但在真正碰到“协议核心”之前,往往已经先死在无穷无尽的边角活里。
现在不同了。
样板代码、参数解析、基础测试、日志埋点、模块骨架,这些东西已经比以前便宜太多。大模型不会替你理解协议,也不会替你承担事故,但它确实把“从 0 到 1 先站起来”这件事的成本压低了很多。于是我们终于有机会把更多精力留给真正关键的问题:
MySQL Proxy 里,哪些部分可以先透明转发,哪些部分必须真正理解协议?
所以这个系列,我想认真做一件很具体的事:
用 Rust,从零把一个 MySQL Proxy 一步一步搭出来。
不是写一篇“AI 一键生成代理”的爽文。 也不是吹一个“看完就能做生产级中间件”的故事。 而是老老实实地,从第一步开始,把一个原本很像团队项目的题目,拆成今天一个人也能持续推进的工程。
而第一步,恰恰要反直觉一点:
别急着解析 SQL。先把流量送过去。
这是我现在越来越相信的一件事: 写代理,第一天最重要的能力不是“理解协议”,而是“知道什么时候不该乱理解协议”。
MySQL 不像一个你随便 read() 几下就能糊弄过去的文本协议。它的数据传输是按 packet 组织的,每个包有自己的头部、长度和序号。连接刚建立时,客户端和服务端先完成握手、能力协商、可选 TLS、认证;这些结束以后,才真正进入后面的命令阶段。
也就是说,在你还没有把这些状态边界摸清楚之前,最稳妥的做法,不是“看起来很专业地解析几段字节”,而是先老老实实做一个透明通道。
听起来有点怂。
但工程上,这种怂通常是对的。
因为很多人第一步就死在“手太快”。还没搞清 packet 边界,就想顺手打印一下 SQL;还没搞清认证流程,就想偷偷改两位 capability;还没搞清连接阶段的状态切换,就开始尝试拦截握手包。最后的结果往往不是“离成功更近了一步”,而是“客户端直接连不上了”。
所以今天这一篇,我们就做一件朴素但非常重要的事:
写一个最小可运行的 MySQL Proxy。
它不懂 SQL。 它不懂结果集。 它也不懂 prepared statement。
它只是把客户端和真正的 MySQL 后端接起来,然后安安静静地把字节流双向转发。
这听起来像一个小学生水平的代理。 但别小看它。很多中间件项目,真正没做出来的原因,不是后面的功能太复杂,而是第一步根本没把链路稳稳打通。
目标非常具体:
127.0.0.1:3307127.0.0.1:3306就这么简单。
如果最后你能通过这个 Rust 程序成功登录 MySQL,然后执行一句最朴素的:
SELECT 1;
那第一篇的任务就算完成了。
别嫌它基础。 这一步一旦打通,后面所有“开始理解 MySQL”的工作,才有了真正的落脚点。
我很喜欢 Rust 写这种东西,不是因为“它性能高”这么一句空话,而是因为它很适合把这种网络中间件写得收敛。
这类程序最怕的不是一开始不会写,而是越写越散: 连接处理散,日志散,错误处理散,状态管理散,最后整个工程像一个用异步和 if-else 临时缝起来的帐篷。
Rust 的好处在于,它天然鼓励你把连接生命周期、字节流边界、错误路径和并发模型想清楚。你写的时候可能会觉得它有点较真,但等你开始做协议解析、状态流转和连接上下文管理时,你会发现这种较真特别值钱。
今天这一篇还很简单,感受可能没那么强。
但等到后面我们真的开始拆包、处理握手、识别 COM_QUERY 和 prepared statement 时,这种“先把边界立住”的好处会越来越明显。
Cargo.toml 先放最小依赖:
[package]
name = "mini-mysql-proxy"
version = "0.1.0"
edition = "2024"
[dependencies]
anyhow = "1"
clap = { version = "4", features = ["derive"] }
tokio = { version = "1", features = ["rt-multi-thread", "macros", "net", "io-util"] }
tracing = "0.1"
tracing-subscriber = { version = "0.3", features = ["fmt", "env-filter"] }
这里没有什么炫技成分,基本就是一个朴素组合:
tokio 负责异步网络clap 负责把监听地址、上游地址做成命令行参数tracing 负责给每条连接挂日志上下文anyhow 先把错误处理压平,别第一篇就把自己淹死在错误类型里你会发现,这也是 vibe coding 时代一个很现实的变化:以前这种骨架活很消耗意志力,现在它已经不再是最大的阻力。真正决定项目能不能继续往前走的,是你后面怎么拆协议,而不是这里多写了二十行样板代码。
src/main.rs:
use anyhow::Result;
use clap::Parser;
use tokio::io::copy_bidirectional;
use tokio::net::{TcpListener, TcpStream};
use tracing::{error, info, info_span, Instrument};
#[derive(Debug, Parser, Clone)]
struct Args {
/// 代理监听地址,例如 127.0.0.1:3307
#[arg(long, default_value = "127.0.0.1:3307")]
listen: String,
/// 后端 MySQL 地址,例如 127.0.0.1:3306
#[arg(long, default_value = "127.0.0.1:3306")]
upstream: String,
}
#[tokio::main]
async fn main() -> Result<()> {
tracing_subscriber::fmt()
.with_env_filter("info")
.with_target(false)
.init();
let args = Args::parse();
let listener = TcpListener::bind(&args.listen).await?;
info!(listen = %args.listen, upstream = %args.upstream, "proxy started");
loop {
let (mut client, client_addr) = listener.accept().await?;
let upstream = args.upstream.clone();
let span = info_span!("mysql_conn", %client_addr, %upstream);
tokio::spawn(
async move {
let mut server = match TcpStream::connect(&upstream).await {
Ok(stream) => stream,
Err(err) => {
error!(error = ?err, "connect upstream failed");
return;
}
};
match copy_bidirectional(&mut client, &mut server).await {
Ok((client_to_server, server_to_client)) => {
info!(
client_to_server,
server_to_client,
"connection closed"
);
}
Err(err) => {
error!(error = ?err, "proxy io failed");
}
}
}
.instrument(span),
);
}
}
整段代码看下来,最核心的其实只有一句:
copy_bidirectional(&mut client, &mut server).await
它的意思非常直白:
左边来的字节送去右边,右边来的字节送回左边。
今天我们不试图理解中间在发生什么。 只确保这条路是通的。
这就是我说的“完全不耍小聪明”。
如果你以前写过这类东西,可能会下意识冒出一个念头:
“既然都已经在中间了,要不顺便把前几个字节读出来打印一下?”
千万别。
这是第一篇里最值得压住手痒的一刻。
因为一旦你开始“先读一点,再转发”,你就不再只是一个透明代理了。你开始对上层语义和底层缓冲负责。你得考虑半包、粘包、顺序、重放、缓冲区残留、两端节奏不一致这些问题。你本来只是修一条路,结果下一秒就把自己变成了交通调度中心。
很多“第一版就写崩”的代理,都是这么崩的。
今天最聪明的做法,恰恰是别显得太聪明。
这段代码里,我特意用了这个写法:
tokio::spawn(
async move {
// ...
}
.instrument(span),
);
而不是写成:
let _guard = span.enter();
// 然后跨很多 await
原因很简单:
在 async 代码里,很多人会把同步场景里好用的日志上下文写法直接照搬过来,结果把 enter() 返回的 guard 一路带过 .await。表面上没报错,实际上日志上下文已经开始串线,最后你看到的 trace 可能像灵异事件一样,明明是 A 连接的日志,怎么突然跑到了 B 连接的故事里。
这种 bug 特别烦。 因为它不是“程序一运行就炸”,而是“程序好像还能跑,但日志越来越不可信”。
而中间件最怕什么? 最怕你出了问题以后,连日志都不能信。
所以从第一篇开始,我就希望这类东西先摆正。代码不一定复杂,但连接的上下文要干净。
先启动你的代理:
cargo run -- --listen 127.0.0.1:3307 --upstream 127.0.0.1:3306
然后用 MySQL 客户端去连代理,而不是直连后端:
mysql -h 127.0.0.1 -P 3307 -u root -p
如果能正常输入密码,进到 MySQL shell,先别激动,来一句最无聊但最重要的测试:
SELECT 1;
只要它正常返回,第一篇的目标就达到了。
代理日志大概会长这样:
INFO proxy started listen=127.0.0.1:3307 upstream=127.0.0.1:3306
INFO mysql_conn{client_addr=127.0.0.1:53421 upstream=127.0.0.1:3306}: connection closed client_to_server=187 server_to_client=524
看到这行日志的时候,我一般会很开心。
不是因为它炫。 而是因为它说明了一件非常实际的事:你的 Rust 程序已经坐在 MySQL 客户端和 MySQL 服务端之间,而且没有把事情搞砸。
这一步说起来像开胃菜,但它其实是整个系列的地基。
先把边界讲清楚,不然很多人会误会自己已经“写出了 MySQL Proxy”。
没有。 你现在写出来的,更准确地说,是一个MySQL 流量隧道。
它还不会:
COM_QUERY换句话说,它现在完全不懂 MySQL,只是恰好能运送 MySQL 流量。
但这没有任何问题。 因为这正是第一篇该有的样子。
第一篇最怕的不是“功能少”,而是“做了半懂不懂的功能”。 你要的是一个边界清楚的起点,而不是一个充满误解的雏形。
我越来越不喜欢那种上来就教人“解析 SQL”的 MySQL Proxy 教程。
不是说 SQL 不重要,而是它根本不是第一层问题。 你连连接阶段、字节流边界、基础转发和连接生命周期都还没捋顺,就去盯 SQL,这跟还没学会下车就开始改引擎差不多。
真正靠谱的顺序应该是:
第一步,先做透明通道。
第二步,学会看 packet。
第三步,搞明白握手和认证。
第四步,才开始看 COM_QUERY 和结果集。
后面再慢慢谈 prepared statement、路由、审计、观测、工程化。
也就是说,先会搬,再会看,最后才会改。
这个顺序听起来不性感,但非常符合真实工程。
写到这里,其实这个系列的主线已经出来了。
很多人会觉得,既然现在大模型能写代码,那这种教程的意义是不是变小了? 我反而觉得恰恰相反。
因为在 vibe coding 时代,最容易被低估的不是生成代码的能力,而是判断下一步该做什么的能力。
你可以很快让模型给你生成一个代理骨架。 也可以让它帮你补命令行参数、补日志、补测试。 但如果你自己没想清楚“第一篇应该只做透明转发,不要过早碰协议”,那模型只会很高效地陪你一起走歪。
所以我想写这个系列,不只是为了教“怎么写”。 更是为了把这种判断过程也写出来:
为什么第一步不碰 SQL。 为什么现在先做通道。 为什么后面要先拆包,再讲握手,再讲认证。 为什么有些“看起来聪明”的优化,其实只是在提前制造故障。
这部分,在今天比代码本身还重要。
今天我们没有做任何华丽的事。
没有分析协议。 没有改写查询。 没有做读写分离。 没有做连接池。 没有做高可用。
我们只是写了一个最小可运行的 Rust 程序,让 MySQL 流量真的从我们这里穿过去。
但别低估这一步。
因为一旦你真的坐在客户端和 MySQL 之间,后面的世界才会真正打开。你终于有了一个可以继续演化的起点:
下一篇开始,我们会把这个“只会搬运字节的傻代理”升级成“开始看得懂 MySQL packet 的代理”。
到那时,我们才会真正第一次摸到 MySQL Proxy 的骨头。
而今天,先把路修通,就已经很不错了。
如果觉得我的文章对您有用,请随意打赏。你的支持将鼓励我继续创作!