1. 为什么“分页多数据源”在MyBatis-Plus项目里总像两块拼不上的积木我第一次在某电商中台项目里接到“订单查分页、用户查分页、库存也得查分页但三张表分别在MySQL主库、PostgreSQL用户中心、Oracle仓储系统”的需求时下意识点了点MyBatis-Plus的PageHelper.startPage()——结果连编译都过不了。不是报错是根本找不到PageHelper这个类。后来才明白MyBatis-Plus自带的Page对象和分页插件压根没设计成能跨数据源自动路由的结构。它默认只认一个SqlSessionFactory而多数据源意味着至少两个SqlSessionFactory实例甚至更多。这背后不是配置漏了几个属性那么简单。它是MyBatis-Plus底层执行链路的一次结构性冲突分页插件如PaginationInnerInterceptor依赖Executor执行前的SQL重写而多数据源切换比如基于AbstractRoutingDataSource发生在DataSource层面比Executor更早。也就是说分页逻辑还没来得及介入数据源已经选错了或者干脆没选对。你看到的“分页失效”本质是分页拦截器压根没被触发你看到的“count查询走错库”是因为count语句和list语句用了不同数据源而MyBatis-Plus默认把它们当成了同一上下文。更隐蔽的问题藏在事务里。某次上线后凌晨三点告警用户中心分页接口响应时间突增300%DBA反馈PostgreSQL慢查询日志里全是SELECT COUNT(*) FROM user_info WHERE ...但user_info表明明只有8万行。排查发现事务方法上加了Transactional而该事务管理器绑定的是MySQL主库的数据源。结果所有count查询全被强制路由到MySQL——可user_info根本不在MySQL里于是MySQL返回空结果MyBatis-Plus误判为“查不到count”又反复重试形成雪崩。所以“MyBatis-Plus分页多数据源”从来就不是“配个yml再加个注解”就能跑通的功能组合。它是一道典型的“框架能力边界题”你要么绕开框架的默认路径自己接管分页执行流程要么在框架允许的扩展点上做足够深的定制。而绝大多数人卡住的地方不是不会写代码是没看清这两条路各自要付出什么代价。提示别急着抄GitHub上那些“5行代码搞定多数据源分页”的Demo。它们90%以上只处理了“非事务场景下的简单路由”一旦加上Transactional、动态数据源切换、或读写分离立刻原形毕露。真正的生产级方案必须同时回答三个问题分页count怎么路由分页list怎么路由两者如何保证数据源一致性2. 分页拦截器的“双刃剑”为什么默认PaginationInnerInterceptor在多数据源下会失效MyBatis-Plus 3.4.0之后官方推荐用PaginationInnerInterceptor替代老版PaginationInterceptor理由是“更轻量、更符合MyBatis插件规范”。这话没错但恰恰是这个“更轻量”让它在多数据源场景下成了摆设。我们先看它的核心注册逻辑Bean public MybatisPlusInterceptor mybatisPlusInterceptor() { MybatisPlusInterceptor interceptor new MybatisPlusInterceptor(); interceptor.addInnerInterceptor(new PaginationInnerInterceptor(DbType.MYSQL)); return interceptor; }注意new PaginationInnerInterceptor(DbType.MYSQL)这行。DbType参数不是用来“识别当前SQL该发给哪个库”的而是用来决定SQL重写规则的语法模板。比如MySQL用LIMIT ?, ?Oracle用ROWNUM嵌套PostgreSQL用LIMIT OFFSET。但这个DbType一旦写死就全局生效——无论你当前执行的是MySQL的订单查询还是PostgreSQL的用户查询分页插件都只会按MySQL语法去改写SQL。更关键的是PaginationInnerInterceptor的intercept方法签名是Override public Object intercept(Invocation invocation) throws Throwable { // ... MappedStatement ms (MappedStatement) args[0]; Object parameter args[1]; RowBounds rowBounds (RowBounds) args[2]; // ... }它从Invocation里拿到的是MappedStatement而MappedStatement是在SqlSessionFactory构建时就固化下来的它只知道自己属于哪个Configuration却不知道自己该跑在哪个DataSource上。换句话说分页插件看到的永远是“静态的SQL定义”而不是“动态的执行上下文”。我做过一个实验在同一个Mapper接口里用DS(pg)切到PostgreSQL数据源然后调用一个带Page参数的方法。断点打在PaginationInnerInterceptor.intercept里发现ms.getBoundSql().getSql()输出的是SELECT * FROM user_info WHERE status ?后面根本没有LIMIT和OFFSET因为插件压根没触发SQL重写。为什么因为PaginationInnerInterceptor内部有个判断if (boundSql null || boundSql.getSql() null) { return invocation.proceed(); } // 后续才是分页重写逻辑而多数据源环境下boundSql为空的情况极多——尤其当你用DS注解切换数据源时MyBatis的Executor会重新解析MappedStatement但PaginationInnerInterceptor的执行时机可能早于这个解析完成点。所以指望PaginationInnerInterceptor自动适配多数据源就像指望交通信号灯自动识别每辆车的目的地并调整红绿灯时长——它没那个上下文信息。你必须主动把“当前数据源类型”这个信息以某种方式注入到分页逻辑里。注意网上流传的“在PaginationInnerInterceptor构造时传入DynamicRoutingDataSource”是典型误区。DynamicRoutingDataSource是DataSource实现类而分页插件工作在Executor层两者生命周期和作用域完全不重叠。强行注入只会导致NullPointerException或ClassCastException。3. 真实可用的三套落地方案从“能跑”到“稳跑”再到“高可用”面对分页与多数据源的结构性矛盾我团队在三年内迭代出三套方案分别对应不同阶段的项目需求。没有银弹只有取舍。下面直接说清每套方案的适用边界、核心代码、以及我们踩过的最痛的坑。3.1 方案一手动分页适合MVP验证期/低QPS后台这是最朴素、最可控的方式放弃MyBatis-Plus的自动分页所有分页逻辑由业务代码显式控制。核心就两步先查总数再查列表。// UserServiceImpl.java DS(pg) // 明确指定数据源 public IPageUser pageUsers(PageUser page, QueryWrapperUser wrapper) { // 1. 手动查count确保和list走同一数据源 Long total userMapper.selectCount(wrapper); // 2. 手动查list带limit/offset ListUser records userMapper.selectListWithPage( wrapper, (int) (page.getCurrent() - 1) * (int) page.getSize(), (int) page.getSize() ); return new Page(page.getCurrent(), page.getSize(), total).setRecords(records); }对应的Mapper XML!-- UserMapper.xml -- select idselectListWithPage resultTypeUser SELECT * FROM user_info where if testew ! null and ew.sqlSegment ! null ${ew.sqlSegment} /if /where LIMIT #{offset}, #{limit} /select优势逻辑完全透明无框架黑盒调试成本最低。致命缺陷count和list两次查询之间存在数据变更窗口。比如用户A在count后、list前注册他既不会被计入总数也不会出现在列表里造成“漏查”。QPS超过500后count查询本身就会成为数据库瓶颈。实战心得我们曾在一个CRM后台用此方案初期很爽但当客户导入10万联系人时SELECT COUNT(*) FROM contact耗时从20ms飙升到1.2s。最后被迫加缓存Cacheable(value userCount, key #wrapper)但缓存key生成必须排除orderBy等非count相关字段否则缓存命中率极低。3.2 方案二自定义分页插件适合中大型项目/需事务一致性这是真正解决“count与list数据源一致”问题的正解。核心思想是让分页插件自己感知当前数据源并动态选择Dbtype。我们继承PaginationInnerInterceptor重写willDoQuery方法public class DynamicPaginationInnerInterceptor extends PaginationInnerInterceptor { Override public void beforeQuery(Executor executor, MappedStatement ms, Object parameter, RowBounds rowBounds, ResultHandler resultHandler, BoundSql boundSql) throws SQLException { // 1. 从ThreadLocal获取当前数据源名 String dsKey DynamicDataSourceContextHolder.peek(); // 2. 根据dsKey映射DbType DbType dbType resolveDbType(dsKey); // 3. 临时覆盖分页插件的DbType this.dbType dbType; super.beforeQuery(executor, ms, parameter, rowBounds, resultHandler, boundSql); } private DbType resolveDbType(String dsKey) { switch (dsKey) { case mysql: return DbType.MYSQL; case pg: return DbType.POSTGRE_SQL; case oracle: return DbType.ORACLE; default: return DbType.MYSQL; // fallback } } }关键在于DynamicDataSourceContextHolder.peek()——它必须在DS注解生效后、SQL执行前被调用。这就要求你的多数据源切换逻辑比如AbstractRoutingDataSource必须在TransactionSynchronizationManager之前完成。我们用了一个DataSourceAspectAspect Component public class DataSourceAspect { Around(annotation(ds)) public Object around(ProceedingJoinPoint point, DS ds) throws Throwable { String dsKey ds.value(); DynamicDataSourceContextHolder.push(dsKey); // 切换 try { return point.proceed(); } finally { DynamicDataSourceContextHolder.poll(); // 清理 } } }优势完美复用MyBatis-Plus分页语法count和list自动走同一数据源事务内100%一致。陷阱DynamicDataSourceContextHolder必须是InheritableThreadLocal否则线程池如Async里会丢失数据源上下文。我们曾因此在线程池任务里查出MySQL的订单数据却试图用PostgreSQL语法分页直接报SQL语法错误。3.3 方案三分库分表中间件集成适合高并发/海量数据场景当单库分页性能触及天花板比如count超2s就得跳出“在应用层做分页”的思维。我们接入ShardingSphere-JDBC后分页逻辑完全下沉到中间件层。配置application.ymlspring: shardingsphere: props: sql-show: true rules: - !SHARDING tables: t_order: actual-data-nodes: ds_${0..1}.t_order_${0..3} table-strategy: standard: sharding-column: order_id sharding-algorithm-name: t-order-table-inline database-strategy: standard: sharding-column: user_id sharding-algorithm-name: t-order-ds-inline sharding-algorithms: t-order-table-inline: type: INLINE props: algorithm-expression: t_order_${order_id % 4} t-order-ds-inline: type: INLINE props: algorithm-expression: ds_${user_id % 2}此时MyBatis-Plus的Page对象可以直接使用DS(sharding) // 指向ShardingSphere数据源 public IPageOrder pageOrders(PageOrder page, QueryWrapperOrder wrapper) { return orderMapper.selectPage(page, wrapper); }ShardingSphere会自动将SELECT COUNT(*)路由到所有真实分片再聚合结果SELECT * LIMIT则根据分片键计算出目标分片避免全表扫描。优势分页性能与数据量解耦千万级数据count仍稳定在200ms内。代价引入新中间件运维复杂度上升ORDER BY必须包含分片键否则无法下推GROUP BY分页需额外配置sql-parser。踩坑实录某次升级ShardingSphere 5.3.0后Page对象的total字段始终为0。排查发现是新版默认关闭了count聚合需显式开启props: sql-show: true, check-table-metadata-enabled: true。文档里藏得很深靠翻GitHub issue才找到答案。4. 数据源一致性校验那个让90%团队栽跟头的“隐性事务陷阱”你以为配好DS和分页插件就万事大吉错。最大的雷往往埋在事务里。我见过太多团队本地测试一切正常一上预发环境就出现“分页数对不上”的诡异问题。根源只有一个Spring事务管理器绑定的数据源和DS注解指定的数据源根本不是同一个。举个真实案例某支付对账服务需要从MySQL查交易流水分页再从Oracle查商户结算单分页最后比对差异。开发写了Service public class ReconciliationService { Transactional // 默认事务管理器绑定MySQL public void reconcile() { PageTrade tradePage tradeMapper.selectPage(new Page(1, 10), null); PageSettlement settlePage settlementMapper.selectPage(new Page(1, 10), null); // ... 比对逻辑 } }tradeMapper用DS(mysql)settlementMapper用DS(oracle)。表面看没问题但Transactional默认使用PlatformTransactionManager而该Bean默认指向第一个配置的数据源通常是MySQL。结果settlementMapper的count查询被事务强制路由到MySQL——而MySQL里根本没有settlement表整个事务回滚日志里只有一行Table mysql.settlement doesnt exist根本看不出是分页惹的祸。解决方案不是禁用事务那会丢数据一致性而是为每个数据源配专属事务管理器Configuration public class TransactionConfig { Bean(mysqlTransactionManager) Primary // 默认事务管理器 public DataSourceTransactionManager mysqlTxManager(Qualifier(mysqlDataSource) DataSource dataSource) { return new DataSourceTransactionManager(dataSource); } Bean(oracleTransactionManager) public DataSourceTransactionManager oracleTxManager(Qualifier(oracleDataSource) DataSource dataSource) { return new DataSourceTransactionManager(dataSource); } }然后在Mapper方法上显式指定Mapper public interface SettlementMapper { Transactional(transactionManager oracleTransactionManager) IPageSettlement selectPage(PageSettlement page, QueryWrapperSettlement wrapper); }但这又引出新问题如果一个业务方法里要操作多个数据源且必须保证原子性比如MySQL扣款Oracle记账怎么办答案是放弃本地事务改用Saga模式或TCC。我们最终选择了Seata AT模式但代价是所有SQL必须兼容AT的undo_log机制——这意味着INSERT ... SELECT、UPDATE ... LIMIT等非标准SQL全部禁用。关键检查清单上线前必做在Transactional方法内用System.out.println(TransactionSynchronizationManager.getCurrentTransactionName())确认事务名用Druid监控面板查看每个SQL实际执行的JDBC URL而非DS注解名对每个分页接口手动构造Page对象传入current1, size1观察count和list是否都命中预期库压测时开启show-sql抓包验证SELECT COUNT(*)和SELECT * LIMIT是否发往同一IP端口。5. 性能压测实录从200QPS到5000QPS的分页优化全路径理论说完来点硬核数据。我们在某物流调度系统做了完整压测目标是支撑5000QPS的运单分页查询平均数据量单库2000万行3个分库。原始方案方案一手动分页在200QPS就崩溃最终通过四层优化达成目标。5.1 第一层SQL层优化提升37% QPS原始count语句SELECT COUNT(*) FROM waybill WHERE status IN (PENDING, ASSIGNED) AND create_time 2023-01-01问题status和create_time未联合索引COUNT(*)全表扫描。优化后-- 创建联合索引 CREATE INDEX idx_status_ctime ON waybill(status, create_time);效果单库count从1.8s降至320msQPS从200升至274。注意不要迷信COUNT(1)比COUNT(*)快。MySQL 8.0对两者优化已无差别真正影响性能的是索引覆盖程度。EXPLAIN显示typerange且ExtraUsing index才算达标。5.2 第二层分页算法替换提升120% QPS原始LIMIT OFFSET分页SELECT * FROM waybill WHERE status PENDING ORDER BY id DESC LIMIT 10000, 20当OFFSET超过10万性能断崖下跌。改为游标分页Cursor-based Pagination// 前一页最后一条记录的id public IPageWaybill pageByCursor(PageWaybill page, Long lastId) { QueryWrapperWaybill wrapper new QueryWrapper(); wrapper.gt(id, lastId) // 游标条件 .eq(status, PENDING) .orderByDesc(id); return waybillMapper.selectPage(page, wrapper); }效果OFFSET100000时查询耗时从4.2s降至86msQPS从274升至603。5.3 第三层分库分表异步count提升280% QPS即使游标分页count仍是瓶颈。我们采用“异步预热缓存兜底”用户进入分页页码时前端异步请求/api/waybill/count?statusPENDING结果存入RedisTTL30sselectPage方法优先读缓存缓存未命中则走ShardingSphere的分布式count后台定时任务每5分钟刷新各状态的count缓存。Cacheable(value waybillCount, key #status, unless #result null) public Long getCountByStatus(String status) { return waybillMapper.selectCount(new QueryWrapperWaybill().eq(status, status)); }效果count平均耗时从320ms降至3ms缓存命中率92%QPS从603升至2290。5.4 第四层读写分离连接池调优提升118% QPS最后瓶颈在连接数。Druid监控显示ActiveCount峰值达200远超MySQL默认max_connections151。写库保持minIdle10, maxActive50读库minIdle20, maxActive200因分页全是读关键参数removeAbandonedOnBorrowtrue, removeAbandonedTimeout60防连接泄漏效果连接等待时间从120ms降至8msQPS从2290升至5000P99延迟稳定在180ms。最后一句真心话别迷信“一次优化解决所有问题”。我们花了6周时间每天压测、分析、调优、再压测。真正的高并发分页是SQL、框架、中间件、基础设施四层协同的结果。任何想靠改一个配置就扛住5000QPS的想法都是对生产环境的不尊重。
阅读完成 · 觉得有帮助?