十多年前,这更像是一个团队项目;到了 vibe coding 时代,一个人也终于能认真把它做起来。
我第一次动手写 MySQL Proxy 时,产生了一种很熟悉的错觉:“先把 TCP 监听搞定,再顺手解析一下 SQL,不就差不多了?”
现实很快教育了我。
你以为自己在写一个“会转发流量的小工具”,实际上你面对的是一个状态化协议、一整套连接生命周期、复杂的认证流程、严格的包边界定义、各种结果集格式,以及一堆稍不注意就会让客户端断连的细微之处。
这也解释了为什么在很多年前,开发一个 MySQL Proxy 听起来就不像是一个“周末写着玩”的个人项目。
它更像一个需要专家团队拆解的系统性问题:需要有人懂协议,有人擅长连接管理,有人能处理认证和异常,还要有人盯着性能和线上稳定性。难点从来不只是代码量,而在于你必须清楚自己到底在触碰什么。
但现在,情况确实有些不同了。
不是因为 MySQL 协议变简单了,也不是中间件工程的门槛消失了,而是因为我们进入了一个新的阶段:在 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:3307。
- 接收 MySQL 客户端的连接。
- 再去连接真正的 MySQL 服务器,例如
127.0.0.1:3306。
- 把客户端和服务端两边的数据原样互相转发。
就这么简单。
如果最后你能通过这个 Rust 程序成功登录 MySQL,然后执行一句最基础的 SELECT 1;,那第一篇的任务就算圆满完成了。
别嫌它基础。这一步一旦打通,后面所有“开始理解 MySQL 协议”的工作,才有了真正的落脚点和试验田。
用 Rust 来写这件事,刚刚好
我喜欢用 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 负责异步网络 IO。
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。
它的意思非常直白:左边(客户端)来的字节原样送到右边(服务端),右边来的字节原样送回左边。
今天,我们不试图理解中间流动的字节是什么含义,只确保这条传输通路是畅通无阻的。这就是我所说的“完全不耍小聪明”。
这里真正有价值的,不是转发,而是克制
如果你有网络编程经验,可能会下意识冒出一个念头:“既然流量都从我这过了,要不顺便把前几个字节读出来打印看看?”
千万别。
这是第一篇里最值得克制手痒的一刻。因为一旦你开始“先读一点,再转发”,你就不再是一个透明代理了。你开始需要对上层协议语义和底层缓冲区管理负责。你得考虑半包、粘包、顺序、重放、缓冲区残留、两端读写节奏不一致等一系列复杂问题。你本来只是想修一条路,结果下一秒就把自己变成了繁忙的交通调度中心。
很多“第一版就写崩”的代理项目,都是这么开始的。今天最聪明的做法,恰恰是别显得“太聪明”。
还有一个隐蔽的坑:异步日志上下文
在这段代码里,特意使用了 .instrument(span) 的写法:
tokio::spawn(
async move {
// ...
}
.instrument(span),
);
而不是写成:
let _guard = span.enter();
// 然后执行一堆 await 操作
原因很简单:在异步代码中,很多人会把同步场景下好用的日志上下文写法直接照搬,结果把 enter() 返回的 guard 一路带过多个 .await 点。表面上不会报错,但实际上日志上下文已经开始串线。最终你看到的追踪日志可能像灵异事件:明明是 A 连接的日志,却混入了 B 连接的信息。
这种 Bug 特别烦人,因为它不是“程序一运行就崩溃”,而是“程序好像还能跑,但日志越来越不可信”。而对于中间件来说,最可怕的是什么?就是出了问题时,连最基本的日志都不可信。
所以,从第一篇开始,我们就希望把这些基础设定摆正。代码可以不复杂,但每条连接的上下文必须保持干净、独立。
把它跑起来
-
启动你的代理:
cargo run -- --listen 127.0.0.1:3307 --upstream 127.0.0.1:3306
-
用 MySQL 客户端连接代理(注意端口是代理监听的 3307,而非 MySQL 本身的 3306):
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 Proxy”。
并没有。 目前写出来的,更准确地说,是一个 MySQL 流量隧道。
它现在还不会:
- 拆解 MySQL packet。
- 识别包头中的长度和序号。
- 分辨当前连接是处于握手阶段还是命令阶段。
- 识别
COM_QUERY 命令。
- 理解服务端返回的是 OK、ERR 还是结果集。
- 处理 prepared statement。
- 进行 SQL 日志、审计、改写或路由。
换句话说,它现在完全不懂 MySQL 协议,只是恰好能传输 MySQL 协议流量。
但这完全没有问题,因为这正是第一篇该有的样子。第一篇最怕的不是“功能少”,而是“做了半懂不懂、边界模糊的功能”。我们要的是一个边界清晰、稳固可靠的起点,而不是一个充满隐藏假设和误解的雏形。
为什么第一篇不碰协议,反而是正确路线
我越来越不喜欢那种一上来就教人“解析 SQL”的 MySQL Proxy 教程。
不是说 SQL 不重要,而是它根本不属于第一层要解决的问题。你连连接阶段、字节流边界、基础转发和连接生命周期都还没理顺,就去盯着 SQL 解析,这就像还没学会平稳驾驶就开始改装发动机。
真正靠谱的推进顺序应该是:
- 先做透明通道,打通链路。
- 学会识别和解析 packet。
- 搞明白握手和认证流程。
- 才开始看
COM_QUERY 和结果集格式。
- 后面再慢慢涉及 prepared statement、路由、审计、可观测性、工程化封装。
简单来说,先会搬(转发),再会看(解析),最后才会改(干预)。 这个顺序听起来不够“性感”,但非常符合真实的工程演进路径。
vibe coding 时代,真正值钱的还是判断力
写到这里,这个系列的主线已经清晰了。你可能会想,既然现在大模型能辅助写代码,这种教程的意义是不是变小了?
我反而觉得恰恰相反。因为在 vibe coding 时代,最容易被低估的不是生成代码的能力,而是 判断下一步该做什么、以及如何正确拆解问题的能力。
你可以让 AI 很快生成一个代理骨架,也可以让它帮你补全命令行参数、日志或测试。但如果你自己没有想清楚“第一篇应该只做透明转发,不要过早触碰协议解析”,那么 AI 只会高效地陪你一起走上弯路。
所以我想写这个系列,不只是为了教“怎么写代码”,更是为了把这种工程判断和拆解过程也呈现出来:为什么第一步不碰 SQL?为什么现在要先做通道?为什么后续要先拆包,再讲握手和认证?为什么有些“看起来聪明”的过早优化,其实是在为未来制造故障?
这部分思考,在当今时代,有时比代码本身更为重要。
这一篇到这里,算是迈出了很像样的第一步
今天我们没有做任何华丽的事情。没有分析协议,没有改写查询,没有实现读写分离、连接池或高可用。
我们只是编写了一个最小可运行的 Rust 程序,让 MySQL 协议流量真的从我们这里穿行而过。
但请不要低估这一步。因为只有当你真正“坐在”客户端和 MySQL 服务器之间,后面的世界才会真正向你打开。你终于有了一个可以持续观察、实验和演进的坚实起点。
下一篇开始,我们将把这个“只会搬运字节的简单代理”,升级为一个“开始能看得懂 MySQL packet 的智能代理”。到那时,我们才会第一次真正触碰到 MySQL Proxy 的核心骨架。
而今天,先把这条数据传输的道路稳稳修通,就已经是非常不错的开始了。如果你对这类从零开始的 开源实战 感兴趣,欢迎在 云栈社区 交流讨论,共同成长。