用 Rust 从零开发 MySQL Proxy

  • King
  • 发布于 11小时前
  • 阅读 38

十多年前用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: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 负责异步网络
  • 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 流量隧道

它还不会:

  • 拆 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 时代,最容易被低估的不是生成代码的能力,而是判断下一步该做什么的能力

你可以很快让模型给你生成一个代理骨架。 也可以让它帮你补命令行参数、补日志、补测试。 但如果你自己没想清楚“第一篇应该只做透明转发,不要过早碰协议”,那模型只会很高效地陪你一起走歪。

所以我想写这个系列,不只是为了教“怎么写”。 更是为了把这种判断过程也写出来:

为什么第一步不碰 SQL。 为什么现在先做通道。 为什么后面要先拆包,再讲握手,再讲认证。 为什么有些“看起来聪明”的优化,其实只是在提前制造故障。

这部分,在今天比代码本身还重要。


这一篇到这里,算是迈出了很像样的第一步

今天我们没有做任何华丽的事。

没有分析协议。 没有改写查询。 没有做读写分离。 没有做连接池。 没有做高可用。

我们只是写了一个最小可运行的 Rust 程序,让 MySQL 流量真的从我们这里穿过去。

但别低估这一步。

因为一旦你真的坐在客户端和 MySQL 之间,后面的世界才会真正打开。你终于有了一个可以继续演化的起点:

下一篇开始,我们会把这个“只会搬运字节的傻代理”升级成“开始看得懂 MySQL packet 的代理”。

到那时,我们才会真正第一次摸到 MySQL Proxy 的骨头。

而今天,先把路修通,就已经很不错了。

点赞 0
收藏 0
分享
本文参与登链社区写作激励计划 ,好文好收益,欢迎正在阅读的你也加入。

0 条评论

请先 登录 后评论
King
King
0x56af...a0dd
擅长Rust/Solidity/FunC/Move开发