1. 分库场景下 MyBatis 与 MyCat 的协作边界
分库这件事,很多团队第一次做的时候都会把注意力全放在“怎么把数据切开”上,结果真正上线之后才发现,最麻烦的不是切分规则本身,而是应用层和中间件之间的职责怎么划分。MyBatis 作为 ORM 框架,负责的是 SQL 映射、参数绑定、结果集封装;MyCat 作为数据库中间件,负责的是 SQL 解析、路由计算、结果归并。两者配合的时候,如果边界没划清楚,就会出现“应用层以为中间件会处理,中间件以为应用层会规避”的尴尬局面。
我见过不少项目在整合初期直接把原来的单库 MyBatis 配置原封不动指向 MyCat,然后发现分页查询结果不对、批量插入报错、自增主键拿不到。这些问题的根源,基本都出在没有理解 MyCat 对 SQL 的解析能力和限制上。MyCat 并不是一个透明的代理,它对 SQL 有明确的语法要求,尤其是分片键相关的查询、跨库 JOIN、子查询、UNION 这些操作,都有各自的处理逻辑和性能代价。
所以这一章我想先把协作边界讲清楚。MyBatis 侧需要做的是:确保生成的 SQL 对 MyCat 友好,比如分页语句的写法、批量操作的拆分方式、主键生成策略的选择。MyCat 侧需要做的是:配置好 schema、rule、dataNode 这些核心文件,让路由规则和实际数据分布一致。两边任何一边偷懒,最后都会在联调阶段加倍还回来。
1.1 为什么不是“配好数据源就完事”
很多人觉得整合就是改个 JDBC URL,把原来连 MySQL 的地址换成 MyCat 的地址就行。这个想法在只做读写分离的时候勉强成立,但一旦涉及分库分表,就完全不够了。原因在于 MyCat 需要知道你的分片规则,而分片规则又和你的业务查询模式强相关。比如你按用户 ID 取模分片,那么所有不带用户 ID 的查询都会变成全库扫描,MyCat 会把 SQL 发到所有分片节点再归并结果。这种查询在单库时代可能只要 50ms,到了分库环境可能变成 500ms 甚至更久。
MyBatis 这边如果还在用SELECT * FROM order WHERE status = 1这种没有分片键的语句,那 MyCat 就只能广播。广播本身不是错误,但你要清楚它的代价。我一般建议在整合初期就把所有 Mapper 里的 SQL 过一遍,标出哪些是分片键查询、哪些是维度查询、哪些是统计查询,然后针对不同类型决定是走 MyCat 还是走其他路径。
1.2 MyCat 对 MyBatis 生成 SQL 的隐性约束
MyBatis 的动态 SQL 很灵活,<if>、<choose>、<foreach>这些标签能拼出各种形态的语句。但 MyCat 的 SQL 解析器不是万能的,它对某些语法结构的支持有限。比如多层嵌套的子查询、复杂的窗口函数、某些类型的 JOIN,MyCat 可能无法正确路由,或者直接报解析错误。
我在实际项目里遇到过最典型的一个坑是INSERT INTO ... SELECT ...这种语句。MyBatis 里用<selectKey>配合批量插入的时候,生成的 SQL 在单库没问题,但 MyCat 对这类语句的路由逻辑和普通 INSERT 不一样,导致数据被写到了错误的分片。后来改成逐条插入或者用 MyBatis 的ExecutorType.BATCH才解决。这个后面会详细讲。
还有一个容易被忽略的点是大小写。MyCat 的 schema 配置里表名和字段名的大小写敏感性和 MySQL 本身可能不一致,如果 MyBatis 的映射文件里写的是小写,而 MyCat 配置里是大写,路由就可能匹配不上。这种问题在联调时往往表现为“表不存在”或者“字段不存在”,但实际数据库里明明有。
2. MyCat 核心配置文件的落地细节
MyCat 的配置体系围绕三个核心文件展开:server.xml、schema.xml、rule.xml。这三个文件的关系可以这样理解:server.xml定义的是“谁能连进来”,schema.xml定义的是“数据怎么分布”,rule.xml定义的是“按什么规则分”。三者必须严格对应,任何一处不一致都会导致路由失败。
2.1 server.xml 里那些容易配错的参数
server.xml里最关键的几个配置项是user、schema、readOnly和privileges。user标签里的name和password是应用连接 MyCat 时用的凭据,不是连接后端 MySQL 的凭据。后端 MySQL 的连接信息是在schema.xml的dataNode里配的。这个区分很重要,因为很多新手会把两套凭据搞混。
schema标签里的name属性要和schema.xml里的schema名称一致,否则 MyCat 启动时会报找不到 schema。readOnly属性如果设成 true,那么所有写操作都会被拒绝,这个在只读从库场景下有用,但在分库写入场景下必须设成 false。
还有一个参数是defaultAccount,它决定了没有显式指定账户时的默认行为。我一般建议显式配置,不要依赖默认值,因为默认值在不同版本里可能有差异。
<user name="app_user" defaultAccount="true"> <property name="password">app_password</property> <property name="schemas">order_db</property> <property name="readOnly">false</property> </user>上面这段配置的意思是:应用用app_user和app_password连接 MyCat,可以访问order_db这个逻辑库,并且允许写操作。注意这里的order_db是逻辑库名,不是物理库名。
2.2 schema.xml 中 dataNode 与 dataHost 的映射逻辑
schema.xml是 MyCat 配置里最核心也最容易出错的部分。它的结构是:schema包含多个table,每个table指定dataNode,每个dataNode又关联dataHost和具体的物理库。这个层级关系必须清晰。
<schema name="order_db" checkSQLschema="false" sqlMaxLimit="100"> <table name="t_order" dataNode="dn_order_0,dn_order_1" rule="mod-long" /> </schema> <dataNode name="dn_order_0" dataHost="host_master" database="order_0" /> <dataNode name="dn_order_1" dataHost="host_master" database="order_1" /> <dataHost name="host_master" maxCon="100" minCon="10" balance="0" writeType="0" dbType="mysql" dbDriver="native"> <heartbeat>select user()</heartbeat> <writeHost host="hostM1" url="127.0.0.1:3306" user="root" password="root"> </writeHost> </dataHost>这里有几个细节值得展开。checkSQLschema如果设成 true,MyCat 会把 SQL 里的 schema 前缀去掉再路由,这个在跨 schema 查询时有用,但一般建议设成 false,避免意外行为。sqlMaxLimit是默认返回行数限制,如果不设,MyCat 默认返回 100 条,这个在分页查询时特别容易踩坑。
dataHost里的balance参数决定读操作的负载均衡策略,writeType决定写操作的路由策略。在分库场景下,如果每个分片都是独立的主库,balance设成 0 就行,因为不需要读写分离。heartbeat语句用来检测后端 MySQL 是否存活,一般用select user()或者select 1都可以。
2.3 rule.xml 分片算法的选择与参数计算
rule.xml定义分片算法。MyCat 内置了十几种算法,常用的有mod-long(取模)、hash-int(哈希取整)、range-long(范围分片)、sharding-by-date(按日期分片)等。选择哪种算法,取决于你的业务查询模式和数据增长趋势。
取模算法最简单,数据分布也最均匀,但扩容时需要迁移大量数据。范围分片扩容方便,但容易出现热点。按日期分片适合时间序列数据,但需要定期维护分片。
<tableRule name="mod-long"> <rule> <columns>user_id</columns> <algorithm>mod-long</algorithm> </rule> </tableRule> <function name="mod-long" class="io.mycat.route.function.PartitionByMod"> <property name="count">2</property> </function>上面这个配置的意思是:按user_id对 2 取模,余数 0 的落到第一个 dataNode,余数 1 的落到第二个。count的值必须和dataNode的数量一致,否则会出现路由到不存在的节点。
这里有个经验:count最好设成 2 的幂次,比如 2、4、8、16。因为取模运算在 2 的幂次下可以用位运算优化,性能更好。而且后续如果要做一致性哈希扩容,2 的幂次也更容易处理。
3. MyBatis 侧的适配改造与 SQL 写法调整
MyBatis 这边要做的改造,核心思路是“让生成的 SQL 对 MyCat 友好”。具体来说,就是避免那些 MyCat 不支持或者支持不好的语法,同时利用 MyBatis 的特性来配合分片规则。
3.1 数据源配置从单库切换到 MyCat
数据源切换本身很简单,就是把 JDBC URL 从jdbc:mysql://127.0.0.1:3306/order_db改成jdbc:mysql://127.0.0.1:8066/order_db。8066 是 MyCat 的默认服务端口,9066 是管理端口。但这里有几个细节要注意。
第一,驱动类还是 MySQL 的驱动,因为 MyCat 兼容 MySQL 协议。第二,连接池的参数可能需要调整,因为 MyCat 本身也有连接池,两层连接池叠加可能导致连接数膨胀。我一般建议应用侧的连接池最大连接数设成 MyCat 侧maxCon的 60% 到 70%,留出余量。
<bean id="dataSource" class="com.zaxxer.hikari.HikariDataSource"> <property name="driverClassName" value="com.mysql.cj.jdbc.Driver" /> <property name="jdbcUrl" value="jdbc:mysql://127.0.0.1:8066/order_db?useUnicode=true&characterEncoding=utf8&useSSL=false" /> <property name="username" value="app_user" /> <property name="password" value="app_password" /> <property name="maximumPoolSize" value="60" /> <property name="minimumIdle" value="10" /> <property name="connectionTimeout" value="30000" /> </bean>注意 URL 里的useSSL=false,因为 MyCat 默认不支持 SSL 连接,如果开着 SSL 会连接失败。characterEncoding=utf8也要加上,避免中文乱码。
3.2 分页查询在 MyCat 下的正确写法
分页是分库场景下最容易出问题的地方。单库时代,MyBatis 的LIMIT offset, size直接发给 MySQL 就行。但在 MyCat 下,如果查询不带分片键,MyCat 需要把 SQL 发到所有分片,每个分片返回offset + size条,然后在 MyCat 层做归并排序,最后取offset到offset + size之间的数据。这个代价随分片数量线性增长。
更麻烦的是,如果排序字段不是分片键,MyCat 的归并排序可能和 MySQL 的排序结果不一致,导致分页数据重复或丢失。我遇到过最典型的情况是:按创建时间倒序分页,第一页和第二页有重复数据。原因是 MyCat 从各个分片取数据后做堆排序,但堆排序不是稳定排序,相同创建时间的记录顺序不确定。
解决办法有两个:一是排序字段加上唯一键作为第二排序条件,比如ORDER BY create_time DESC, id DESC,这样排序结果就稳定了。二是尽量让分页查询带上分片键,这样 MyCat 可以直接路由到单个分片,分页逻辑就和单库一样了。
<select id="selectByPage" resultMap="BaseResultMap"> SELECT * FROM t_order WHERE user_id = #{userId} ORDER BY create_time DESC, id DESC LIMIT #{offset}, #{size} </select>上面这个查询带了user_id,MyCat 可以直接路由到对应分片,分页效率最高。如果业务上确实无法带分片键,那就只能接受全库扫描的代价,同时用sqlMaxLimit限制最大返回行数,避免内存溢出。
3.3 批量插入与自增主键的处理策略
批量插入在分库场景下需要特别注意。MyBatis 的<foreach>批量插入生成的 SQL 是INSERT INTO t_order (col1, col2) VALUES (v1, v2), (v3, v4)。MyCat 对这条 SQL 的处理方式是:根据每一行的分片键值,把数据拆分到不同的分片。如果所有行的分片键值都落在同一个分片,那就直接发过去;如果落在不同分片,MyCat 会拆成多条 INSERT 分别发送。
这个行为本身没问题,但性能上不如应用层自己拆分。因为 MyCat 的拆分是在 SQL 解析阶段做的,有一定的 CPU 开销。如果批量插入的数据量很大,比如几千条,建议在应用层按分片键分组,然后分组批量插入。
自增主键是另一个坑。单库时代,MyBatis 可以用useGeneratedKeys="true"和keyProperty拿到自增 ID。但在 MyCat 下,如果分片规则是按自增 ID 取模,那么 MyCat 需要自己生成全局唯一 ID,而不是依赖后端 MySQL 的自增。MyCat 支持多种全局序列生成方式,包括本地文件、数据库、时间戳等。
<system> <property name="sequnceHandlerType">2</property> </system>sequnceHandlerType设成 2 表示使用数据库方式生成全局序列。这种方式需要在 MyCat 的配置库里建一张MYCAT_SEQUENCE表,然后为每个需要全局序列的表插入一行配置。这种方式的好处是 ID 连续,缺点是每次生成都要查数据库,性能有瓶颈。如果追求性能,可以用时间戳方式,但 ID 会很长,而且不是严格递增。
我一般建议:如果业务对 ID 连续性没要求,用时间戳方式最省事;如果要求连续,用数据库方式,但要加缓存,比如一次取 1000 个 ID 缓存在 MyCat 内存里,用完再取。
4. 联调阶段的高频故障与排查链路
整合完成之后,真正的挑战才开始。联调阶段遇到的问题往往不是配置写错了,而是配置之间的相互作用导致的。这一章我按故障现象来组织,每个现象给出排查链路和修复方案。
4.1 查询结果为空但数据库明明有数据
这个现象最让人抓狂。应用日志显示 SQL 执行成功,返回 0 条,但你去后端 MySQL 里查,数据明明在。这种情况十有八九是路由问题。
排查链路是这样的:第一步,去 MyCat 的日志里找这条 SQL 的路由记录。MyCat 的日志一般在logs/mycat.log,里面会记录 SQL 被路由到了哪些 dataNode。如果日志显示路由到了dn_order_0,但数据实际在dn_order_1,那就是分片规则配错了。
第二步,检查rule.xml里的分片算法和schema.xml里的 dataNode 数量是否一致。比如count设成 2,但 dataNode 只配了 1 个,那余数 1 的数据就无处可去。
第三步,检查分片键的值。如果分片键是字符串,取模算法会先转成哈希值再取模,这个哈希值和 Java 的hashCode()可能不一样。MyCat 用的是自己的哈希算法,所以同样的字符串在应用层算出来的分片和 MyCat 算出来的可能不同。这种情况建议在应用层用 MyCat 提供的分片算法库来预计算分片,或者直接用数值型分片键。
4.2 批量插入报“无法路由”错误
这个错误信息通常是can't find datanode for sharding column。原因是批量插入的某一行数据,其分片键值为 null 或者不在分片规则覆盖的范围内。
比如按user_id取模分片,但某一行的user_id是 null,MyCat 就无法计算路由。解决办法是在应用层做校验,确保分片键不为空。或者在 MyCat 配置里设置默认分片节点,让 null 值落到默认节点。
<table name="t_order" dataNode="dn_order_0,dn_order_1" rule="mod-long" ruleRequired="true" />ruleRequired="true"表示必须匹配分片规则,如果匹配不上就报错。设成 false 的话,匹配不上会落到第一个 dataNode。我一般建议设成 true,尽早暴露问题,而不是让数据悄悄落到错误的分片。
4.3 跨库 JOIN 导致的性能骤降
单库时代,JOIN 是数据库的强项。但在分库环境下,如果两张表的分片规则不一致,JOIN 就无法在单个分片内完成,MyCat 需要把数据拉到中间层做归并 JOIN。这个代价非常大,尤其是大表 JOIN。
我遇到过一个案例:订单表和用户表,订单按user_id分片,用户表按id分片。查询“某个用户的订单及用户信息”时,MyCat 需要先从订单分片拿到订单,再从用户分片拿到用户,然后在中间层做 JOIN。数据量小的时候没问题,数据量一大,内存直接爆掉。
解决办法有两个:一是让关联表使用相同的分片规则,这样 JOIN 可以在分片内完成。二是把 JOIN 拆成两次查询,在应用层做组装。第二种方式虽然多了一次网络往返,但内存可控,而且可以利用缓存。
// 先查订单 List<Order> orders = orderMapper.selectByUserId(userId); // 再查用户 User user = userMapper.selectById(userId); // 应用层组装 for (Order order : orders) { order.setUser(user); }这种方式在 MyBatis 里可以用<association>或者手动组装。手动组装更灵活,也更容易控制查询次数。
4.4 事务提交后数据不一致
分库环境下的事务是个大话题。MyCat 支持弱分布式事务,但默认不是强一致的。如果应用层用@Transactional注解,MyBatis 会把事务交给 MyCat 管理,MyCat 再协调各个分片的事务。这个过程中,如果某个分片提交失败,其他分片可能已经提交了,导致数据不一致。
我一般建议:如果业务能接受最终一致,就用 MyCat 的弱事务,配合补偿机制。如果必须强一致,那就避免跨分片事务,把相关数据设计在同一个分片内。比如订单和订单明细,都用order_id分片,这样它们永远在同一个分片,事务就是本地事务。
还有一个细节是 MyBatis 的SqlSession在 MyCat 下的行为。如果用了ExecutorType.BATCH,批量操作的提交时机和单条操作不一样,可能导致部分数据已经提交但部分还在缓冲区。这种情况建议在批量操作后显式调用flushStatements(),确保所有语句都发送到 MyCat。
5. 分片键选择与扩容预案的实战考量
分片键选得好不好,直接决定了分库方案能不能长期用下去。选得不好,后期扩容就是噩梦。这一章聊聊分片键的选择逻辑和扩容预案。
5.1 什么样的字段适合做分片键
分片键的第一个要求是“高频查询条件”。如果大部分查询都带这个字段,那分片键选它就对了。比如订单系统,大部分查询都是“查某个用户的订单”,那user_id就是天然的分片键。
第二个要求是“分布均匀”。如果分片键的值分布不均匀,比如按性别分片,那数据就会倾斜。取模算法对分布均匀性要求很高,哈希算法稍微好一点,但也不能太偏。
第三个要求是“不可变”。分片键一旦确定,就不应该再修改。因为修改分片键意味着数据要从一个分片迁移到另一个分片,这个代价很大。所以像status这种会变的字段,不适合做分片键。
第四个要求是“尽量是数值型”。数值型的分片键在取模和比较时性能更好,而且不容易出现哈希不一致的问题。字符串分片键虽然也能用,但要多一层哈希转换,而且哈希算法在不同版本间可能有差异。
5.2 取模分片的扩容困境与一致性哈希的取舍
取模分片最大的问题是扩容。假设现在 2 个分片,按user_id % 2分。要扩到 4 个分片,变成user_id % 4,那么原来余数 0 的数据里,有一半要迁到新的分片 2,原来余数 1 的数据里,有一半要迁到新的分片 3。迁移量是总数据量的一半。
一致性哈希可以解决这个问题。一致性哈希把分片节点映射到一个哈希环上,数据按哈希值落到环上顺时针最近的节点。扩容时只影响相邻节点的数据,迁移量是总数据量的 1/N。但一致性哈希的实现比取模复杂,而且 MyCat 内置的一致性哈希算法在数据量小的时候可能分布不均匀。
我的建议是:如果数据量不大,或者扩容频率很低,用取模就行,简单可靠。如果数据量很大,而且预期会频繁扩容,那就用一致性哈希,或者用 MyCat 的range-long范围分片,提前规划好分片范围。
5.3 双写方案与数据迁移的过渡策略
扩容的时候,最稳妥的方案是双写。具体做法是:新老两套分片规则同时运行,应用层写数据的时候同时写老分片和新分片,读数据的时候先读老分片,如果读不到再读新分片。等数据迁移完成后,再切换到只读新分片。
这个方案的关键是“数据迁移”和“双写”的配合。数据迁移一般用工具做,比如用 MyCat 的迁移工具,或者自己写脚本。迁移过程中,双写保证新数据不丢,迁移完成后,校验新老数据一致,然后切换。
双写的代价是写性能下降,因为要写两次。而且双写期间的数据一致性需要额外保证,比如用消息队列做异步补偿。如果业务能接受短暂的停机,那停机迁移更简单,但停机时间取决于数据量,大表可能要好几个小时。
我个人的经验是:如果数据量在千万级以下,停机迁移最省事。如果上亿,那就老老实实做双写和渐进迁移。没有银弹,只有取舍。
6. 监控与日常运维中值得关注的指标
分库方案上线之后,运维的复杂度比单库高很多。这一章聊聊日常需要关注的指标和排查工具。
6.1 MyCat 自身的关键指标
MyCat 的管理端口 9066 提供了很多监控命令。常用的有show @@datanode查看各分片节点的状态,show @@connection查看当前连接数,show @@sql查看 SQL 执行统计。
mysql -h127.0.0.1 -P9066 -uadmin -padmin show @@datanode; show @@connection; show @@sql;show @@datanode会显示每个 dataNode 的读写次数、活跃连接数、心跳状态。如果某个节点的活跃连接数一直很高,说明这个分片可能有热点。show @@sql会显示 SQL 的执行次数、平均耗时、最大耗时,可以用来发现慢查询。
我一般会把这些指标接入监控系统,设置告警阈值。比如某个分片的连接数超过 80% 就告警,SQL 平均耗时超过 100ms 就告警。
6.2 慢查询的定位与优化路径
MyCat 的慢查询日志在logs/mycat.log里,可以通过slowSqlTime参数设置阈值。定位到慢查询之后,优化路径和单库类似:加索引、改 SQL、调整分片规则。
但分库环境下的索引有个特殊之处:索引是建在每个分片上的,不是全局的。所以如果查询不带分片键,即使每个分片都有索引,MyCat 还是要广播到所有分片,总耗时是各分片耗时的叠加。这种情况下,优化索引的效果有限,关键还是要让查询带上分片键。
还有一个容易忽略的点是 MyCat 的连接池。如果 MyCat 到后端 MySQL 的连接数不够,SQL 就会排队等待。这个等待时间会计入 SQL 总耗时,但看后端 MySQL 的慢查询日志却看不到。所以排查慢查询时,要同时看 MyCat 的日志和后端 MySQL 的日志。
6.3 数据倾斜的发现与再平衡
数据倾斜是指某些分片的数据量远大于其他分片。取模算法在分片键分布均匀时不会倾斜,但如果分片键本身分布不均,比如按地区分片,一线城市的用户量远大于三线城市,就会倾斜。
发现倾斜的方法很简单:定期统计各分片的行数和磁盘占用。如果某个分片的行数是其他分片的几倍,那就是倾斜了。再平衡的方法有两种:一是调整分片规则,把热点数据单独分片;二是把热点数据迁移到独立的分片节点,用更强的硬件。
我遇到过一个案例:按user_id取模分片,但有一个大客户,它的user_id恰好落在某个分片,导致这个分片的数据量是其他分片的 10 倍。解决办法是把大客户的数据单独放到一个分片,其他用户继续取模分片。这种“热点隔离”的思路在分库场景下很实用。
7. 一些踩坑之后的个人体会
整合 MyBatis 和 MyCat 做分库,技术上的难点其实不多,真正难的是“想清楚”。想清楚分片键选什么、想清楚哪些查询会广播、想清楚扩容怎么办、想清楚事务一致性要求到什么程度。这些问题在写代码之前就要有答案,而不是等到上线之后才发现。
我自己的习惯是:在动手配置之前,先画一张表,列出所有核心业务查询,标出每个查询的 WHERE 条件、排序字段、返回行数。然后根据这张表来决定分片键和分片规则。这张表后来也成了联调时的检查清单,每跑通一个查询就打个勾。
还有一个体会是:不要迷信“一步到位”。分库方案可以分阶段做,先做读写分离,再做分库,最后做分表。每个阶段解决一个问题,风险可控。一次性把所有东西都上了,出了问题很难定位是哪个环节的错。
最后说一个具体的技巧:在 MyBatis 的 Mapper 里,给所有分片键查询加一个命名规范,比如方法名以ByShardingKey结尾。这样在代码审查的时候,一眼就能看出哪些查询是走分片的,哪些是广播的。广播查询要特别标注,说明为什么不能带分片键,以及预期的性能代价。这个习惯坚持下来,能避免很多无意识的广播查询。