前几天一个后端朋友跑来找我,说他们的订单库最近扛不住了:单台 MySQL 读写混跑,查询一多,CPU 直接飙到 80%,关键业务写操作被拖得特别慢。这个场景太典型了,读多写少、数据库连接动不动被慢查询占满。当时我建议他走读写分离,把查询流量从主库上拆出去。而实现手段,我推荐了 Mycat——这个系列前面已经聊过它的基本定位,今天这篇就接着往下走,用 Docker 把 Mycat 跑起来,配合 MySQL 主从复制,把读写分离完整落地。
这篇内容适合两类人:一类是后端开发,想在不改业务代码的情况下给数据库减负;另一类是初级 DBA,想弄明白 Mycat 到底怎么配置、流量怎么分流的。文章中我会把 MySQL 主从搭建、Mycat 三份核心配置文件、启动验证、常见坑全部过一遍,配置基本可以直接抄走用。先提醒一句,读写分离不是银弹,后面我会讲清楚它解决的问题和它解决不了的问题。
1. 先想清楚:为什么需要 Mycat 这种中间件来读写分离
1.1 单库瓶颈到底出在哪里
很多团队对数据库的第一反应是“加配置”“上 SSD”,但字节到硬件之后,真正的问题往往不在磁盘,而在并发。以电商订单库为例,一个下单操作可能只写几条记录,但商品详情、订单查询、用户历史订单这些接口,一次请求可能就要查几十上百条记录。读和写混在同一个实例上时,大量 select 会把 CPU、IO 和连接池打满,写请求就得排队。
这就是“读多写少”的典型场景。读写分离的核心思路说白了就一句话:把那些慢查询、查询量大的 select 全部引流到从库,主库只保留写操作和一小部分实时性要求极高的读操作。这样主库压力下降,从库专门扛读流量,整体吞吐能提高不少。
但这里有个前提必须说清楚:从库的数据是异步同步过来的,存在主从延迟。如果业务要求“写入后立刻读到自己写的数据”,读写分离就会出问题。所以做任何方案之前,先问自己一句:这个系统能容忍 1-3 秒的数据不一致吗?能容忍,才走读写分离这条路。
1.2 Mycat 的角色:应用无侵入的“数据库路由器”
实现读写分离,常见有两条路。一条是应用层搞多数据源,比如在代码里写两个数据源,然后通过 AOP 或注解给读操作指定从库;另一条是引入中间件,让应用只连一个地址,中间件自己完成 SQL 解析和路由。两条路我都走过,前者灵活但侵入性强,每个项目都要维护一套切库逻辑;后者对业务透明,改动最小。
Mycat 走的就是后者。它在 MySQL 前端伪装成一个数据库服务器,应用根本不知道背后连着两台甚至更多台 MySQL。Mycat 收到 SQL 后,解析出是 select 还是 insert/update/delete,再决定把这包 SQL 扔给哪个后端实例。从应用视角看,它只是换了一个“数据库地址”而已。
“数据库路由器”这个比喻我个人觉得很贴切。就像快递分拣中心,包裹(SQL)到了之后,分拣员看目的地址(SQL 类型),放到不同线路的车上,送到对应片区(主库或从库)。快递员(应用)不需要关心线路怎么走,他只需要把包裹交给分拣中心。
1.3 为什么直接用 Docker 部署
我同事第一次部署 Mycat 是在物理机上跑的,结果踩了一下午环境坑:JDK 版本不匹配、启动脚本路径不对、配置文件少拷贝了一个、端口被别的 Java 进程占掉……Mycat 本质是一个 Java 进程,对运行环境有一定要求。用 Docker 之后,镜像里把 JDK 和运行环境全部打包好了,拉下来直接启动,省掉一大半环境问题。
另外,Mycat 在测试环境和生产环境往往需要不同配置,容器化之后配置目录独立挂载,改配置、重启容器就能完成环境切换。集群扩展时,多启几个容器也能快速横向拉起。
当然,容器化不是没代价。Mycat 本身不存数据,但它依赖配置文件和后端连接,所以配置目录必须挂载到宿主机,镜像升级时不要覆盖配置;容器重启策略也要想好,别让容器一崩就“消失”了。下面这张表可以看下容器部署和传统部署的差别:
| 对比项 | 物理机/虚拟机部署 | Docker 部署 |
|---|---|---|
| 环境一致性 | 依赖基础环境,易受 JDK 版本干扰 | 镜像内容自带环境,拉起一致 |
| 配置管理 | 散落在安装目录,迁移易遗漏 | 独立挂载,可备份可复用 |
| 多实例扩展 | 端口、目录冲突风险高 | 容器隔离,扩展方便 |
| 快速恢复 | 需要重新部署一次 | 一条命令重启 |
2. 地基先行:用 Docker 搭好 MySQL 主从复制
2.1 主从复制的核心链路
Mycat 只负责路由,真正的数据同步还得靠 MySQL 自己的主从复制。先花 30 秒把原理理清楚,后面排查问题会轻松很多。
主库上所有数据变更都会按顺序写进 binlog。从库启动两个线程:I/O 线程负责连上主库,把 binlog 拉到本地存成 relay log;SQL 线程负责把 relay log 里的日志逐条回放,做到和主库一样的数据变更。说白了,主从复制就是“日志传输 + 日志回放”。
要注意的点是:这个同步是异步的,从库追日志需要时间。正常情况下延迟很小,几十毫秒;但主库有大事务或者从库配置较弱时,延迟会被拉大。后面接 Mycat 时,读请求是按批打到从库的,如果从库明显落后,读出来的数据就是旧数据,这一点业务方必须有心理预期。
2.2 启动两个 MySQL 容器
假设宿主机已经装好 Docker,我们先创建目录,放主从各自的自定义配置文件。
mkdir -p /data/mysql-master/conf /data/mysql-master/data mkdir -p /data/mysql-slave/conf /data/mysql-slave/data主库配置文件/data/mysql-master/conf/my.cnf:
[mysqld] server-id=1 log-bin=mysql-bin binlog_format=ROW从库配置文件/data/mysql-slave/conf/my.cnf:
[mysqld] server-id=2 relay-log=mysql-relay-bin read_only=1server-id必须唯一,等于是实例在集群里的身份证。主库开启log-bin才可能产生 binlog;从库设置read_only,防止业务误连从库写数据。binlog_format 我用 ROW,它比 STATEMENT 在同步准确性上更好,虽然日志体积会大一些,但对一致性友好。
然后启动两个实例,端口故意错开:
docker run -d --name mysql-master \ -p 3306:3306 \ -e MYSQL_ROOT_PASSWORD=root123 \ -v /data/mysql-master/conf:/etc/mysql/conf.d \ -v /data/mysql-master/data:/var/lib/mysql \ mysql:8.0 docker run -d --name mysql-slave \ -p 3307:3306 \ -e MYSQL_ROOT_PASSWORD=root123 \ -v /data/mysql-slave/conf:/etc/mysql/conf.d \ -v /data/mysql-slave/data:/var/lib/mysql \ mysql:8.0启动后分别确认能登录:
docker exec -it mysql-master mysql -uroot -proot123 docker exec -it mysql-slave mysql -uroot -proot123两个容器之间能互相访问吗?这个先别急着测。目前它们都在同一个默认 bridge 网络里,容器名不一定能直接解析。后面 Mycat 容器要访问这两个 MySQL,我建议给 Mycat 用宿主机 IP 去连,省去网络解析的麻烦。如果跨服务器部署,就用对应服务器的 IP。
2.3 初始化主从同步
在主库执行,创建同步专用账号:
CREATE USER 'repl'@'%' IDENTIFIED BY 'repl123'; GRANT REPLICATION SLAVE ON *.* TO 'repl'@'%'; FLUSH PRIVILEGES;这个账号权限只需要REPLICATION SLAVE,不需要给库表读写权限,权限最小化。
然后查看主库当前 binlog 位点:
SHOW MASTER STATUS;记录下File和Position,比如mysql-bin.000001和157。接着到从库执行:
CHANGE MASTER TO MASTER_HOST='192.168.1.10', MASTER_PORT=3306, MASTER_USER='repl', MASTER_PASSWORD='repl123', MASTER_LOG_FILE='mysql-bin.000001', MASTER_LOG_POS=157; START SLAVE;这里的MASTER_HOST要根据实际环境写,容器内可以用宿主 IP。执行完检查状态:
SHOW SLAVE STATUS\G;重点关注这几个字段:
Slave_IO_Running: Yes,表示能连上主库并拉日志;Slave_SQL_Running: Yes,表示 relay log 回放正常;Seconds_Behind_Master: 0,表示当前没有延迟。
最后验证一下同步是否真的通了。主库建库建表、插入数据:
CREATE DATABASE db_test; USE db_test; CREATE TABLE t_user (id INT PRIMARY KEY AUTO_INCREMENT, name VARCHAR(50)); INSERT INTO t_user(name) VALUES ('zhangsan'),('lisi');到从库执行SHOW DATABASES;和SELECT * FROM db_test.t_user;。能看到db_test和两条数据,说明主从链路没问题。
2.4 这个阶段最容易翻车的三个点
第一,从库配置了server-id=2,但auto.cnf里的server-uuid如果跟主库一样,复制会直接报错。这种情况常见于用docker cp或者直接复制了主库的数据目录。解决方法很简单,删掉从库数据目录下的auto.cnf再重启容器。
第二,MySQL 8 默认的认证插件是caching_sha2_password,老版本 MySQL 客户端和很多中间件解析不了。尽管 Mycat 这一步还没上,我建议在主从搭建时就把账号认证方式统一一下。后续 Mycat 要连 MySQL 时,用下面这种写法创建账号:
CREATE USER 'mycat'@'%' IDENTIFIED WITH mysql_native_password BY 'mycat123'; GRANT ALL PRIVILEGES ON db_test.* TO 'mycat'@'%'; FLUSH PRIVILEGES;第三,忘记放行防火墙端口。两台机器部署时,从库要能访问主库的 3306,后面 Mycat 机器要能访问 3306、3307。安全组和防火墙规则提前确认。
3. Mycat 配置拆解:三份 XML 是全部核心
3.1 先建立逻辑库和逻辑表的概念
Mycat 配置里经常出现“逻辑库”“逻辑表”这个词。所谓逻辑,就是 Mycat 虚拟出来的库表,应用只会看到这一层。比如你定义了一个逻辑库叫TESTDB,里面的逻辑表叫t_user,但背后 Mycat 真正访问的其实是你 MySQL 里的物理库db_test、物理表t_user。
可以理解成前台点菜:客户(应用)对着菜单(逻辑库表)点菜,后厨(MySQL)真正做菜,菜单上写的是“鱼香肉丝”,后厨做的可能用的是猪肉而不是鱼肉。只要菜品符合预期,客户不需要知道后厨细节。
Mycat 的核心配置就是三份 XML:server.xml定义账号和全局参数,schema.xml定义逻辑库表和后端数据源的关系,rule.xml定义分片规则。读写分离场景下,前两份是绝对主角。
3.2 server.xml:登录账号和全局参数
先看一份最小可用的server.xml:
<?xml version="1.0" encoding="UTF-8"?> <!DOCTYPE mycat:server SYSTEM "server.dtd"> <mycat:server xmlns:mycat="http://org.opencloudb/"> <system> <property name="defaultSqlParser">druidparser</property> </system> <user name="mycat"> <property name="password">mycat123</property> <property name="schemas">TESTDB</property> </user> <user name="manager"> <property name="password">manager123</property> </user> </mycat:server>第一个user是给应用连接 Mycat 用的,连接后只能看到TESTDB这个逻辑库。第二个user是管理端口用的,登录 Mycat 的 9066 管理端口做运维操作,比如查看数据源状态、在线调整配置。
这里有个细节容易被忽略:Mycat 的前端连接 8066 和管理连接 9066 是同一个进程的两个端口,配置里的用户体系是独立的。生产环境里管理账号要单独设置,不要共用业务账号。
3.3 schema.xml:读写分离的真正战场
schema.xml是 Mycat 的灵魂。读写分离能不能生效,主要看dataHost里的writeHost和readHost配得对不对。下面这份配置可以直接用于我们的主从环境:
<?xml version="1.0" encoding="UTF-8"?> <!DOCTYPE mycat:schema SYSTEM "schema.dtd"> <mycat:schema xmlns:mycat="http://org.opencloudb/"> <schema name="TESTDB" checkSQLschema="true" sqlMaxLimit="100"> <table name="t_user" primaryKey="id" dataNode="dn1"/> </schema> <dataNode name="dn1" dataHost="localhost1" database="db_test"/> <dataHost name="localhost1" maxCon="1000" minCon="10" balance="1" writeType="0" dbType="mysql" dbDriver="native" switchType="1"> <heartbeat>select user()</heartbeat> <writeHost host="master" url="192.168.1.10:3306" user="mycat" password="mycat123"> <readHost host="slave" url="192.168.1.11:3307" user="mycat" password="mycat123" weight="1"/> </writeHost> </dataHost> </mycat:schema>逐个解释关键属性:
schema标签定义了逻辑库TESTDB,table标签把逻辑表t_user映射到数据节点dn1。dataNode标签说明这个逻辑节点对应的是dataHost上的物理库db_test。如果后续有多个表,可以继续在schema里加table标签。
dataHost里balance是最关键的参数。balance="0"表示不开启读写分离,所有请求走写节点;balance="1"表示读请求全部走从库节点;balance="2"和"3"主要用在多分片或多主多从场景。读写分离最常用balance="1"。
writeHost和readHost是嵌套关系,readHost必须写在writeHost内部。Mycat 的读写分离不是平行的两台机器,而是“写主机下挂读机器”的树形结构。如果读机器和写机器配置在同一层,Mycat 会认为它们是两个数据源,和预期就偏离了。
heartbeat是心跳检测语句,Mycat 用它定期探测后端 MySQL 是否存活。探活失败后,如果switchType="1",Mycat 会自动把写流量切换到备用节点。心跳语句不复杂,select user()足够用。
3.4 rule.xml 和分片的关系
如果你只做读写分离,不需要配置分片规则。但 Mycat 启动时依然会读取rule.xml,所以我建议保留一份默认配置,不要删除。里面定义的分片算法、分片函数,在schema.xml里没有表指定分片规则时基本不生效,可以不用深究。
如果硬要精简,可以把rule.xml换成最简形式,只保留空的<mycat:rule>标签结构。我这里直接沿用默认配置,更稳妥。
4. Docker 运行 Mycat 全程实操
4.1 准备镜像和配置目录
我用的镜像是基于 Mycat 1.6.7.6 版本打的容器镜像,镜像里默认路径为/usr/local/mycat/conf。不同镜像的目录可能会变,启动之前可以先执行docker run --rm 镜像名 ls /usr/local/mycat看一下结构,确认 conf 目录位置。
提前把三份配置文件放到宿主机/data/mycat/conf:
mkdir -p /data/mycat/conf # 将 server.xml、schema.xml、rule.xml 放入 /data/mycat/conf然后启动 Mycat:
docker run -d --name mycat \ -p 8066:8066 \ -p 9066:9066 \ -v /data/mycat/conf:/usr/local/mycat/conf \ mycat-image:1.6-7.6注意端口映射:8066 是应用连接端口,9066 是管理端口。两个端口都要映射出来。
启动之后看日志确认状态:
docker logs -f mycat出现类似startup success或Server startup successfully的日志,说明 Mycat 已经起来了。如果日志里报读取配置文件失败,基本是挂载路径没对或者 XML 语法有问题。
4.2 用客户端走一遍读写链路
用 MySQL 客户端连接 Mycat 的前端端口:
mysql -u mycat -pmycat123 -h 127.0.0.1 -P 8066登录后执行:
SHOW DATABASES; USE TESTDB; SELECT * FROM t_user;能正常执行,说明 Mycat 和后端 MySQL 的连接也通了。此时你可能会发现,查出来的数据全靠主从复制同步到从库。这个没有任何问题,因为 Mycat 已经替应用做好了路由,应用只需要关心TESTDB这个逻辑库。
4.3 验证读写分离是否真正生效的三种方法
很多人的测试停在了“能连上、能查到数据”,但这不够。我们要确认 select 是不是真的走了从库。
方法一:反复执行服务器变量查询
在 Mycat 前端连接里执行:
SELECT @@server_id;如果每次返回 2,说明读请求全部路由到了从库(server-id=2);如果偶尔返回 1,说明有部分读请求还留在主库。在balance="1"配置下,稳定返回 2 是符合预期的。
方法二:开启从库的 general_log 看真实流量
最可靠的方法是直接看后端 MySQL 收到的 SQL。在主库执行:
SET GLOBAL general_log = 'ON'; SET GLOBAL log_output = 'TABLE';然后在 Mycat 上执行几条 select。再到主库查:
SELECT * FROM mysql.general_log WHERE argument LIKE 'SELECT%' ORDER BY event_time DESC LIMIT 5;这个方法能精确看到哪些 SQL 打到了哪个实例。实际操作时,我一般是把主库和从库的 general_log 都打开,对比同一批 select 分别出现在哪个库的日志里。
方法三:通过管理端口查看连接分布
连接 Mycat 的 9066 管理端口:
mysql -u manager -pmanager123 -h 127.0.0.1 -P 9066执行:
SHOW @@DATASOURCE;输出里能看到每个dataHost的 writeHost 和 readHost 各自有多少连接。如果只看到 writeHost 有连接,readHost 一直是 0,多半是配置没对或者根本没触发读请求。
4.4 验证时顺手做的数据一致性测试
验证读写分离的同时,我建议顺手测一下主从同步的正常性。在 Mycat 上执行:
INSERT INTO t_user(name) VALUES ('wangwu'); SELECT * FROM t_user;接着到从库查:
SELECT * FROM db_test.t_user;如果能查到wangwu,说明写 MySQL 主库后,同步到从库的链路也正常。不要只验证select走了从库,要验证写入后的数据最终能流到从库,这样才算一条完整的闭环。
5. 踩坑记录和问题速查
5.1 连不上 8066 端口
第一个常见现象是mysql -h 127.0.0.1 -P 8066报连接拒绝。我遇到的情况有一半是 Mycat 容器根本没好,另一半是端口映射没配。
先看docker ps,确认容器在跑;再看docker logs mycat,确认没有报异常。如果容器正常但宿主机端口不通,检查防火墙和 SELinux。容器内部端口 8066 是否监听,可以用docker exec mycat netstat -tlnp | grep 8066确认。如果容器内部没问题但宿主机访问不到,考虑端口映射是不是被其他进程占了。
5.2 数据全部打在主库上
这种情况最打击信心:配置看着没问题,但读请求全都走了主库。
按优先级排查:
- 看
schema.xml里dataHost的balance是不是0,如果是,改1重启; - 检查
<table>标签是否真的把逻辑表映射到了对应dataNode,如果表没有指定,Mycat 会走默认路由; - 确认
readHost的账号密码对不对,如果从库连接失败,Mycat 可能直接把流量降级到 writeHost; - 最后,检查是不是事务内读。Mycat 对事务内 SQL 有特殊处理——事务期间的所有 SQL 都会发给 writeHost,保证一致性。业务如果使用了
BEGIN之后查数据,这些 select 走主库是正常现象,不算 bug。
一般事务内读会有大量 select 走主库,这个可以通过代码层面优化,减少长事务。
5.3 MySQL 8 认证插件不兼容
这个问题在 5.0 和 8.0 混用时特别典型。Mycat 自带的后端驱动可能不支持 MySQL 8 的caching_sha2_password认证,表现为日志里报Unknown authentication plugin。
解决办法不是升级驱动,而是在 MySQL 里给连接账号改认证方式:
ALTER USER 'mycat'@'%' IDENTIFIED WITH mysql_native_password BY 'mycat123';改完不用重启 mycat,只要后端连接池重建连接就能生效。重建连接最快的方式是重启 mycat 容器。
5.4 读写分离期间必须盯的业务细节
主从延迟是最容易忽略的。balance="1"把所有 select 都推到从库后,一旦从库延迟超过几秒,可能会出现这种场景:用户下单成功后查订单详情,列表里看不到刚下的单,然后工单就来了。
经验做法是写一个延迟监控脚本,定时从从库执行SHOW SLAVE STATUS,把Seconds_Behind_Master打到监控系统,超过阈值直接告警。同时,业务侧尽量做兜底:关键数据写入后立刻读用缓存或者强制走主库,普通列表查询可以容忍延迟。
还有连接池的问题。Mycat 本身有连接池,应用也有连接池。如果应用连接池里的老连接一直没释放,Mycat 又发生过后端切换,可能会让请求打到旧节点。遇到诡异的数据“看不新鲜”,可以先考虑刷新连接池试试。
下面整理了一张速查表,方便你后面直接对着排查:
| 现象 | 可能原因 | 排查命令/动作 |
|---|---|---|
| 8066 端口无法连接 | 容器未启动、端口映射错误、防火墙 | docker logs mycat、netstat -tlnp |
| 只有主库有流量 | balance=0、readHost 配错 | 查看 schema.xml、SHOW @@DATASOURCE |
| 报认证失败 | MySQL8 认证插件不兼容 | ALTER USER ... mysql_native_password |
| 数据查不到最新的 | 主从延迟 | SHOW SLAVE STATUS看Seconds_Behind_Master |
| 从库能连但读写未分离 | 事务内读、长连接已建立 | 检查应用是否BEGIN后再 select |
| Mycat 启动失败 | XML配置错误、挂载路径错误 | 看容器日志,确认 conf 目录挂载位置 |
最后分享个人习惯
这套东西我前前后后搭过不少次,现在每次做读写分离方案,都会固定做三件事:一是部署前先单独验证主从同步,确保数据能同步才开始接 Mycat,否则后面排查问题会多一重变量;二是验证阶段一定开 general_log,看实际SQL打到了哪个实例,而不是只看SELECT @@server_id——后者能说明大致方向,前者才是铁证;三是给主从延迟设监控和告警,这是读写分离上线后最容易出事的点,延迟一大,业务反馈立刻就来。
如果你接下来要在这个环境上继续扩展,可以往两个方向走:一个是在 Mycat 上做水平分片,把单表数据拆到多个 MySQL 实例;另一个是给 writeHost 加一个备用主节点,配合switchType做自动故障切换。从读写分离到分片,到高可用,这些东西都是一层一层叠上去的,每一层先跑稳,再往上加,比一次性搞一个大而全的方案要可靠得多。