第一次接触MyBatis的RowBounds分页时,我和大多数开发者一样眼前一亮——这简直是分页查询的终极解决方案!不需要在SQL里写丑陋的limit和offset,只需要在Java代码里优雅地new一个RowBounds对象,就能实现分页效果。但直到线上服务突然崩溃,监控面板显示JVM内存爆满,我才意识到自己掉进了一个精心设计的性能陷阱。
让我们还原一个典型场景:假设你要开发一个用户管理系统,需要展示第2页的用户数据,每页10条。使用RowBounds的代码看起来如此简洁:
java复制List<User> users = userMapper.selectAllUsers(new RowBounds(10, 10));
表面上看,这像是在告诉数据库:"从第10条记录开始,给我10条数据"。但残酷的事实是:MyBatis会先执行SELECT * FROM users,把百万级数据全部加载到内存,然后在内存中做切片操作。这就好比你想吃一碗米饭,服务员却把整个电饭煲端到你面前让你自己盛——不仅浪费资源,还可能把你撑死。
我曾在实际项目中见过这样的惨案:一个简单的订单查询接口,因为使用了RowBounds分页,在促销期间查询三个月内的订单数据时,直接导致应用服务器OOM崩溃。事后分析堆转储文件,发现一个查询就加载了2GB的订单数据到内存。
打开RowBounds的源码,真相就摆在眼前:
java复制public class RowBounds {
public static final int NO_ROW_LIMIT = Integer.MAX_VALUE;
//...
}
默认的limit值竟然是Integer的最大值!这已经暗示了它的设计初衷就不是为大数据量分页服务的。真正的分页魔法发生在DefaultResultSetHandler类中,关键逻辑在handleRowValuesForSimpleResultMap方法:
java复制private void handleRowValuesForSimpleResultMap(ResultSetWrapper rsw,
ResultMap resultMap, ResultHandler<?> resultHandler,
RowBounds rowBounds, ResultMapping parentMapping) throws SQLException {
skipRows(rsw.getResultSet(), rowBounds); // 先跳过offset行
while (shouldProcessMoreRows(resultContext, rowBounds) &&
rsw.getResultSet().next()) {
// 只处理limit指定的行数
}
}
这个实现有个致命缺陷:虽然最终返回给用户的只有limit条数据,但JDBC驱动早已把所有结果集都加载到内存了。我曾经用JProfiler做过测试,查询一个10万行的表,使用RowBounds分页时内存占用是直接SQL分页的100倍以上。
为了更直观地展示差异,我分别在MySQL环境下执行了两种分页方式:
物理分页SQL:
sql复制SELECT * FROM large_table LIMIT 100000, 10;
执行计划显示:Using where; Using index
RowBounds等效查询:
sql复制SELECT * FROM large_table;
执行计划显示:全表扫描
实测数据量在百万级时,物理分页的响应时间是20ms左右,而RowBounds方式需要超过5秒,并且内存占用飙升到1GB以上。更可怕的是,当并发量上来后,这种内存分页会迅速吃光JVM堆空间。
去年双十一大促期间,我们的订单查询接口突然接连崩溃。查看错误日志,清一色的java.lang.OutOfMemoryError: Java heap space。问题就出在下面这段看似无害的代码:
java复制public List<Order> queryOrders(Date startDate, Date endDate, RowBounds rowBounds) {
return orderMapper.selectByDateRange(startDate, endDate, rowBounds);
}
当日期范围跨度较大时,这个查询会返回几十万条订单记录。虽然前端只需要显示10条,但RowBounds已经默默加载了全部数据。最终我们的解决方案是重写为:
java复制public List<Order> queryOrders(Date startDate, Date endDate, int page, int size) {
int offset = (page - 1) * size;
return orderMapper.selectByDateRangeWithLimit(startDate, endDate, offset, size);
}
对应的Mapper文件改为:
xml复制<select id="selectByDateRangeWithLimit" resultType="Order">
SELECT * FROM orders
WHERE create_time BETWEEN #{startDate} AND #{endDate}
LIMIT #{offset}, #{size}
</select>
RowBounds的另一个隐蔽问题是连接查询时的笛卡尔积爆炸。比如这样的查询:
java复制List<User> users = userMapper.selectUsersWithOrders(new RowBounds(0, 10));
对应的SQL可能是:
sql复制SELECT u.*, o.* FROM users u LEFT JOIN orders o ON u.id = o.user_id
即使用户表只有10条记录,如果每个用户有100个订单,内存中就会产生1000条组合记录。我曾优化过一个这样的案例,改为物理分页后查询速度从8秒降到0.1秒。
经过多次踩坑,我总结出RowBounds仅适用于以下场景:
对于可能产生大量数据的查询,我强烈推荐以下方案:
方案一:经典LIMIT分页
xml复制<select id="selectWithLimit" resultType="User">
SELECT * FROM users
WHERE status = 1
LIMIT #{offset}, #{limit}
</select>
方案二:游标分页(适合深度分页)
java复制public interface UserMapper {
@Select("SELECT * FROM users WHERE id > #{lastId} ORDER BY id LIMIT #{limit}")
List<User> selectAfterId(@Param("lastId") long lastId, @Param("limit") int limit);
}
方案三:MyBatis-Plus分页插件
java复制Page<User> page = new Page<>(1, 10);
userMapper.selectPage(page, Wrappers.<User>query().eq("status", 1));
在最近的一个电商项目中,我们把所有RowBounds分页改为游标分页后,分页查询的GC时间减少了80%,P99响应时间从1200ms降到了150ms左右。特别是在处理用户行为日志这种可能上百万的数据时,效果尤为明显。
当数据量真的很大时(比如亿级数据),即使使用LIMIT也会遇到性能问题。这时可以考虑以下优化手段:
索引覆盖优化:
sql复制-- 普通分页
SELECT * FROM huge_table LIMIT 1000000, 10;
-- 优化版(先通过覆盖索引定位)
SELECT t.* FROM huge_table t
JOIN (SELECT id FROM huge_table ORDER BY create_time LIMIT 1000000, 10) tmp
ON t.id = tmp.id;
延迟关联技巧:
xml复制<select id="selectWithDelayJoin" resultType="Order">
SELECT o.* FROM orders o
JOIN (SELECT id FROM orders WHERE user_id = #{userId} LIMIT #{offset}, #{limit}) tmp
ON o.id = tmp.id
</select>
为了避免团队其他成员误用RowBounds,我们在项目中建立了防护措施:
这些措施实施后,我们再也没有因为分页问题导致线上事故。现在回想起来,RowBounds就像一把没有护手的利剑——看似锋利,但稍有不慎就会伤到自己。理解其底层原理后,我们才能更好地决定何时使用它,何时选择更安全的替代方案。