在分布式系统架构中,分库分表是解决MySQL单表千万级以上数据量性能瓶颈的核心方案。但绝大多数开发者在落地分库分表后,都会遇到分页查询的致命问题:要么分页结果数据错乱、重复丢失,要么深分页接口直接超时、应用OOM,甚至引发数据库集群雪崩。
本文将从底层原理出发,彻底讲透分库分表分页查询的核心难题,拆解所有主流方案的实现逻辑、优缺点与适用场景,同时梳理99%开发者踩过的坑与最优避坑方案,兼顾深度与可读性,让你既能夯实底层基础,又能直接解决线上实际问题。
一、先搞懂:为什么分库分表后,分页查询会成为难题?
1.1 单库单表分页的底层逻辑
MySQL单库单表的分页查询,标准语法为SELECT * FROM t_order ORDER BY id LIMIT offset, size;,其底层执行逻辑为:
- 扫描满足条件的前
offset + size行数据; - 丢弃前
offset行,返回剩余的size行结果。 该逻辑在单表中可以保证结果的准确性,因为所有数据都在同一个物理表中,排序是全局有序的。即使是深分页(offset过大),最多也只是单表扫描性能下降,不会出现结果错乱的问题。
1.2 分库分表的核心分片模型
本文仅讨论对分页影响最大的水平分片(垂直分片不改变单表数据结构,分页逻辑与单库一致),主流分片规则分为两类:
- 哈希分片:按分片键的哈希值取模,将数据均匀分散到多个分片表/库中,比如
t_order_${order_id % 2},数据分布均匀,无热点问题,但无法预判数据所在的分片范围。 - 范围分片:按分片键的数值范围划分分片,比如按id范围,分片1存1-10000,分片2存10001-20000,或按时间按月分片,可精准定位数据所在分片,范围查询性能优异,但易出现数据热点问题。
1.3 分库分表分页的核心矛盾
分库分表后,数据分散在多个独立的物理分片上,每个分片仅存储全局数据的一部分,没有任何一个分片持有全局有序的完整数据集,这就是分页查询的核心矛盾。
这里用一个极简的示例,让你一眼看懂常规分页方案的错误: 假设我们有2个分片表,采用哈希分片,分片键为id,分片规则为id % 2:
- t_order_0(分片0)存储id为偶数的数据:2、4、6、8、10、12
- t_order_1(分片1)存储id为奇数的数据:1、3、5、7、9、11
业务需求:按id升序分页,取第2页,每页2条数据,即offset=2,size=2,正确结果应为3、4。
如果直接在每个分片执行单表分页SQL:
SELECT * FROM t_order_0 ORDER BY id LIMIT 2,2;
SELECT * FROM t_order_1 ORDER BY id LIMIT 2,2;
执行结果:
- 分片0返回:6、8
- 分片1返回:5、7 将两个分片的结果合并排序后,取前2条得到5、6,与正确结果3、4完全不符,出现了严重的数据错乱。
这就是绝大多数开发者落地分库分表时踩的第一个坑:直接把单表分页逻辑照搬到分库分表中,导致分页结果完全错误。
二、分库分表分页的主流方案全拆解:底层逻辑、代码实现与适用场景
本章节所有示例均采用当前最新稳定版组件:
- Spring Boot 3.3.5
- ShardingSphere-JDBC 5.5.0(分布式数据治理中间件)
- MyBatis-Plus 3.5.9
- MySQL 8.4
- Maven项目管理
基础环境配置
maven核心依赖,可直接编译运行:
<?xml version="1.0" encoding="UTF-8"?>
<project xmlns="http://maven.apache.org/POM/4.0.0" xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 https://maven.apache.org/xsd/maven-4.0.0.xsd">
<modelVersion>4.0.0</modelVersion>
<parent>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-parent</artifactId>
<version>3.3.5</version>
<relativePath/>
</parent>
<groupId>com.example</groupId>
<artifactId>sharding-page-demo</artifactId>
<version>0.0.1-SNAPSHOT</version>
<name>sharding-page-demo</name>
<description>分库分表分页查询示例</description>
<properties>
<java.version>17</java.version>
<shardingsphere.version>5.5.0</shardingsphere.version>
<mybatis-plus.version>3.5.9</mybatis-plus.version>
</properties>
<dependencies>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-web</artifactId>
</dependency>
<dependency>
<groupId>org.apache.shardingsphere</groupId>
<artifactId>shardingsphere-jdbc-core-spring-boot-starter</artifactId>
<version>${shardingsphere.version}</version>
</dependency>
<dependency>
<groupId>com.baomidou</groupId>
<artifactId>mybatis-plus-spring-boot3-starter</artifactId>
<version>${mybatis-plus.version}</version>
</dependency>
<dependency>
<groupId>com.mysql</groupId>
<artifactId>mysql-connector-j</artifactId>
<scope>runtime</scope>
</dependency>
<dependency>
<groupId>org.projectlombok</groupId>
<artifactId>lombok</artifactId>
<optional>true</optional>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-test</artifactId>
<scope>test</scope>
</dependency>
</dependencies>
<build>
<plugins>
<plugin>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-maven-plugin</artifactId>
<configuration>
<excludes>
<exclude>
<groupId>org.projectlombok</groupId>
<artifactId>lombok</artifactId>
</exclude>
</excludes>
</configuration>
</plugin>
</plugins>
</build>
</project>
MySQL表结构语句:
CREATE TABLE IF NOT EXISTS `t_order_0` (
`order_id` BIGINT NOT NULL COMMENT '订单ID',
`user_id` BIGINT NOT NULL COMMENT '用户ID',
`order_amount` DECIMAL(10,2) NOT NULL COMMENT '订单金额',
`create_time` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间',
PRIMARY KEY (`order_id`),
KEY `idx_user_id` (`user_id`),
KEY `idx_create_time_order_id` (`create_time`,`order_id`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci COMMENT='订单表0';
CREATE TABLE IF NOT EXISTS `t_order_1` (
`order_id` BIGINT NOT NULL COMMENT '订单ID',
`user_id` BIGINT NOT NULL COMMENT '用户ID',
`order_amount` DECIMAL(10,2) NOT NULL COMMENT '订单金额',
`create_time` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间',
PRIMARY KEY (`order_id`),
KEY `idx_user_id` (`user_id`),
KEY `idx_create_time_order_id` (`create_time`,`order_id`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci COMMENT='订单表1';
ShardingSphere基础配置(application.yml),哈希分片规则:
spring:
shardingsphere:
datasource:
names: ds0
ds0:
type: com.zaxxer.hikari.HikariDataSource
driver-class-name: com.mysql.cj.jdbc.Driver
jdbc-url: jdbc:mysql://127.0.0.1:3306/sharding_demo?useUnicode=true&characterEncoding=utf8&useSSL=false&serverTimezone=Asia/Shanghai&allowPublicKeyRetrieval=true
username: root
password: root
rules:
sharding:
tables:
t_order:
actual-data-nodes: ds0.t_order_${0..1}
table-strategy:
standard:
sharding-column: order_id
sharding-algorithm-name: order-inline
sharding-algorithms:
order-inline:
type: INLINE
props:
algorithm-expression: t_order_${order_id % 2}
props:
sql-show: true
实体类Order:
package com.example.shardingpagedemo.entity;
import com.baomidou.mybatisplus.annotation.IdType;
import com.baomidou.mybatisplus.annotation.TableId;
import com.baomidou.mybatisplus.annotation.TableName;
import lombok.Data;
import java.math.BigDecimal;
import java.time.LocalDateTime;
@Data
@TableName("t_order")
public class Order {
@TableId(type = IdType.ASSIGN_ID)
private Long orderId;
private Long userId;
private BigDecimal orderAmount;
private LocalDateTime createTime;
}
Mapper接口OrderMapper:
package com.example.shardingpagedemo.mapper;
import com.baomidou.mybatisplus.core.mapper.BaseMapper;
import com.example.shardingpagedemo.entity.Order;
import org.apache.ibatis.annotations.Mapper;
@Mapper
public interface OrderMapper extends BaseMapper<Order> {
}
MyBatis-Plus分页配置类:
package com.example.shardingpagedemo.config;
import com.baomidou.mybatisplus.annotation.DbType;
import com.baomidou.mybatisplus.extension.plugins.MybatisPlusInterceptor;
import com.baomidou.mybatisplus.extension.plugins.inner.PaginationInnerInterceptor;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
@Configuration
public class MybatisPlusConfig {
@Bean
public MybatisPlusInterceptor mybatisPlusInterceptor() {
MybatisPlusInterceptor interceptor = new MybatisPlusInterceptor();
interceptor.addInnerInterceptor(new PaginationInnerInterceptor(DbType.MYSQL));
return interceptor;
}
}
2.1 方案一:全局视野法(二次排序法)
2.1.1 底层逻辑
要保证分库分表分页结果的全局准确性,必须让每个分片都返回从第0行到offset+size行的全量数据,在应用层/中间件层完成全局排序后,再截取offset到offset+size的目标数据。
回到前文的示例,offset=2,size=2,正确的执行逻辑为:
- 每个分片执行
SELECT * FROM t_order_${0..1} ORDER BY id LIMIT 0, 4;(offset+size=4) - 分片0返回:2、4、6、8;分片1返回:1、3、5、7
- 应用层将两个分片的结果合并,按id全局排序得到:1、2、3、4、5、6、7、8
- 截取offset=2开始的2条数据,得到正确结果:3、4
ShardingSphere-JDBC已经内置了该方案的完整实现,开发者无需手动编写合并排序逻辑,只需正常编写分页SQL,中间件会自动完成SQL改写、结果合并排序。
2.1.2 底层优化:流式归并排序
很多开发者担心全量数据加载到内存会导致OOM,ShardingSphere采用了流式归并排序优化该问题:
- 每个分片的查询结果都是按排序字段有序的;
- 采用优先级队列(PriorityQueue)实现多路归并排序,无需将所有数据加载到内存,仅需保存每个分片的当前游标;
- 跳过前offset条数据时,仅移动游标,不加载全量数据到内存,极大降低了内存占用。
2.1.3 代码实现
Service层分页实现:
package com.example.shardingpagedemo.service.impl;
import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
import com.baomidou.mybatisplus.core.metadata.IPage;
import com.baomidou.mybatisplus.extension.plugins.pagination.Page;
import com.baomidou.mybatisplus.extension.service.impl.ServiceImpl;
import com.example.shardingpagedemo.entity.Order;
import com.example.shardingpagedemo.mapper.OrderMapper;
import com.example.shardingpagedemo.service.OrderService;
import org.springframework.stereotype.Service;
@Service
public class OrderServiceImpl extends ServiceImpl<OrderMapper, Order> implements OrderService {
@Override
public IPage<Order> pageList(int pageNum, int pageSize) {
Page<Order> page = new Page<>(pageNum, pageSize);
LambdaQueryWrapper<Order> wrapper = new LambdaQueryWrapper<Order>()
.orderByAsc(Order::getOrderId);
return this.page(page, wrapper);
}
}
Controller层接口:
package com.example.shardingpagedemo.controller;
import com.baomidou.mybatisplus.core.metadata.IPage;
import com.example.shardingpagedemo.entity.Order;
import com.example.shardingpagedemo.service.OrderService;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RequestParam;
import org.springframework.web.bind.annotation.RestController;
@RestController
@RequestMapping("/order")
public class OrderController {
@Autowired
private OrderService orderService;
@GetMapping("/page")
public IPage<Order> pageList(@RequestParam int pageNum, @RequestParam int pageSize) {
return orderService.pageList(pageNum, pageSize);
}
}
当调用/order/page?pageNum=2&pageSize=2时,ShardingSphere会自动改写SQL,在每个分片执行全量查询,完成归并排序后返回正确结果。
2.1.4 优缺点与适用场景
| 优点 | 缺点 |
| 100%保证分页结果准确性 | offset越大,每个分片需要扫描的数据量越大,数据库IO压力呈线性增长 |
| 实现简单,无需修改业务代码,ShardingSphere内置支持 | 深分页场景下,数据传输量极大,网络IO与应用排序压力剧增,易引发超时与OOM |
| 支持任意跳页,满足后台管理系统的分页需求 | 分片数量越多,性能下降越明显 |
适用场景:后台管理系统的分页查询,且严格限制最大翻页数(建议不超过100页,offset不超过10000);小数据量、少分片的分库分表场景;无法使用其他优化方案的兜底场景。绝对禁止场景:无限制的深分页查询,C端用户的无限滚动列表。
2.2 方案二:游标分页法(滚动分页、禁止跳页法)
这是分库分表场景下,解决深分页问题的生产级最优方案,也是业内C端场景的通用标准方案。
2.2.1 底层逻辑
摒弃传统的offset行号定位方式,采用基于值的游标定位:
- 每次分页查询,返回结果的最后一条数据的排序字段值作为下一次查询的游标;
- 下一次查询时,通过
WHERE 排序字段 > 上一次的游标值过滤数据,再执行LIMIT size; - 每个分片仅需扫描符合条件的size条数据,无需扫描前offset行,无论翻多少页,性能始终稳定。
该方案的核心前提:排序字段必须是全局唯一、单调递增/递减的,保证排序的确定性,避免出现数据重复或丢失。推荐使用主键ID、创建时间+全局唯一ID作为排序字段,禁止仅用非唯一的创建时间排序。
回到前文的示例,采用游标分页的执行逻辑:
- 第一页查询,size=2,执行SQL:
SELECT * FROM t_order ORDER BY id ASC LIMIT 2; - 返回结果:1、2,最后一条数据的id=2,作为下一页的游标;
- 第二页查询,游标id=2,执行SQL:
SELECT * FROM t_order WHERE id > 2 ORDER BY id ASC LIMIT 2; - ShardingSphere在每个分片执行对应SQL,分片0返回:4、6;分片1返回:3、5;
- 合并排序后得到:3、4、5、6,取前2条,得到正确结果3、4。
可以看到,每个分片仅需扫描2条数据,无论翻到第100页还是第1000页,每个分片的扫描行数始终是size条,性能完全稳定,无任何深分页问题。
2.2.2 代码实现
Service层游标分页实现:
package com.example.shardingpagedemo.service.impl;
import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
import com.baomidou.mybatisplus.extension.service.impl.ServiceImpl;
import com.example.shardingpagedemo.entity.Order;
import com.example.shardingpagedemo.mapper.OrderMapper;
import com.example.shardingpagedemo.service.OrderService;
import org.springframework.stereotype.Service;
import java.util.List;
@Service
public class OrderServiceImpl extends ServiceImpl<OrderMapper, Order> implements OrderService {
@Override
public List<Order> cursorPage(Long lastId, int pageSize) {
LambdaQueryWrapper<Order> wrapper = new LambdaQueryWrapper<Order>()
.gt(lastId != null, Order::getOrderId, lastId)
.orderByAsc(Order::getOrderId)
.last("LIMIT " + pageSize);
return this.list(wrapper);
}
}
Controller层接口:
@GetMapping("/cursorPage")
public List<Order> cursorPage(@RequestParam(required = false) Long lastId, @RequestParam int pageSize) {
return orderService.cursorPage(lastId, pageSize);
}
首次调用/order/cursorPage?pageSize=2,lastId不传,返回第一页数据;第二次调用/order/cursorPage?lastId=2&pageSize=2,返回第二页正确数据,性能稳定。
2.2.3 优缺点与适用场景
| 优点 | 缺点 |
| 性能极致稳定,无论翻多少页,每个分片仅扫描size条数据,无深分页问题 | 不支持跳页,仅支持上一页/下一页的滚动查询 |
| 100%保证分页结果准确性 | 排序字段必须全局唯一、单调,无法支持复杂的多字段非唯一排序 |
| 实现简单,无额外组件依赖,业务改造成本极低 | 无法直接获取总条数与总页数,需额外查询count |
| 分片数量对性能影响极小,支持大规模分片集群 |
适用场景:C端APP、小程序的无限滚动列表(如订单列表、商品列表、信息流);大数据量、深分页的无跳页查询场景;大规模分片集群的分页查询。不适用场景:后台管理系统需要任意跳页的分页查询。
2.3 方案三:分片键精准路由法
这是分库分表分页查询的性能天花板方案,没有之一。
2.3.1 底层逻辑
分库分表的核心是分片键,若查询条件中携带了完整的分片键,ShardingSphere可直接通过分片算法计算出数据所在的唯一分片,将查询请求精准路由到该分片,后续的分页查询逻辑与单库单表完全一致,无任何额外开销,性能与单表分页完全相同。
比如,我们将订单表的分片键改为user_id,分片规则为user_id % 2,当用户查询自己的订单列表时,必然会携带user_id查询条件,ShardingSphere可直接路由到对应的分片表,无需全分片扫描,分页查询完全无性能问题。
2.3.2 代码实现
修改ShardingSphere分片规则,分片键改为user_id:
spring:
shardingsphere:
rules:
sharding:
tables:
t_order:
actual-data-nodes: ds0.t_order_${0..1}
table-strategy:
standard:
sharding-column: user_id
sharding-algorithm-name: order-inline
sharding-algorithms:
order-inline:
type: INLINE
props:
algorithm-expression: t_order_${user_id % 2}
Service层带分片键的分页实现:
@Override
public IPage<Order> pageListByUserId(Long userId, int pageNum, int pageSize) {
Page<Order> page = new Page<>(pageNum, pageSize);
LambdaQueryWrapper<Order> wrapper = new LambdaQueryWrapper<Order>()
.eq(Order::getUserId, userId)
.orderByDesc(Order::getCreateTime)
.orderByDesc(Order::getOrderId);
return this.page(page, wrapper);
}
当调用pageListByUserId(1001, 100, 10)时,ShardingSphere会通过1001 % 2 = 1计算出分片为t_order_1,直接路由到该分片执行分页SQL,与单库单表分页完全一致,即使offset=10000,也仅在单个分片扫描,性能无任何问题。
2.3.3 优缺点与适用场景
| 优点 | 缺点 |
| 性能天花板,与单库单表分页完全一致,无任何额外开销 | 必须携带完整的分片键查询条件,否则无法使用 |
| 100%保证数据准确性,支持任意跳页与深分页 | 分片键的选择必须与高频查询场景完全匹配 |
| 实现简单,无业务改造成本 |
适用场景:C端用户查询自己的私有数据(如个人订单、个人收藏、个人账单);所有能携带分片键的分页查询场景,优先使用该方案。不适用场景:无法携带分片键的全量数据分页查询(如后台管理系统的全平台订单查询)。
2.4 方案四:ES二级索引法(全局索引法)
该方案是解决大数据量、支持跳页、深分页场景的唯一成熟生产级方案,兼顾了跳页能力与深分页性能。
2.4.1 底层逻辑
分库分表分页的核心矛盾是没有全局有序的数据集,该方案通过引入全局索引组件解决该问题:
- 通过Binlog同步组件(Canal/Maxwell)将分库分表中的分页查询关键字段(排序字段、查询条件、主键ID、分片键)实时同步到Elasticsearch中;
- 分页查询时,先在ES中执行分页查询,获取符合条件的主键ID列表与分片键;
- 通过主键ID+分片键,精准路由到对应分片,批量查询完整数据;
- 组装数据后返回给前端。
该方案的核心优势:ES天生支持分布式分页,深分页性能优异,同时支持复杂的条件查询与跳页,完美解决了分库分表分页的核心矛盾。
架构图如下:
graph LR
A[业务应用] --> B[Elasticsearch全局索引]
A --> C[ShardingSphere-JDBC]
C --> D[分库分表集群]
E[Binlog同步组件Canal] --> D
E --> B
2.4.2 核心实现代码
ES索引设计,仅存储分页查询所需的关键字段,避免数据冗余:
{
"mappings": {
"properties": {
"order_id": { "type": "long" },
"user_id": { "type": "long" },
"order_amount": { "type": "double" },
"create_time": { "type": "date", "format": "yyyy-MM-dd HH:mm:ss" }
}
}
}
Service层ES分页实现:
@Override
public IPage<Order> esPage(int pageNum, int pageSize) {
int offset = (pageNum - 1) * pageSize;
// 1. 在ES中执行分页查询,获取order_id列表
SearchRequest searchRequest = new SearchRequest("order_index");
SearchSourceBuilder sourceBuilder = new SearchSourceBuilder()
.query(QueryBuilders.matchAllQuery())
.sort("create_time", SortOrder.DESC)
.sort("order_id", SortOrder.DESC)
.from(offset)
.size(pageSize)
.fetchSource(new String[]{"order_id", "user_id"}, null);
searchRequest.source(sourceBuilder);
SearchResponse response;
try {
response = restHighLevelClient.search(searchRequest, RequestOptions.DEFAULT);
} catch (IOException e) {
throw new RuntimeException("ES查询失败", e);
}
// 解析ES返回的order_id列表
List<Long> orderIdList = Arrays.stream(response.getHits().getHits())
.map(hit -> Long.parseLong(hit.getId()))
.toList();
long total = response.getHits().getTotalHits().value;
if (orderIdList.isEmpty()) {
return new Page<>(pageNum, pageSize, total);
}
// 2. 批量查询分库分表中的完整数据,带order_id分片键可精准路由
List<Order> orderList = this.listByIds(orderIdList);
// 3. 按ES返回的顺序排序,避免in查询导致的顺序错乱
Map<Long, Order> orderMap = orderList.stream()
.collect(Collectors.toMap(Order::getOrderId, o -> o));
List<Order> sortedList = orderIdList.stream()
.map(orderMap::get)
.filter(Objects::nonNull)
.toList();
// 4. 组装分页结果
Page<Order> page = new Page<>(pageNum, pageSize, total);
page.setRecords(sortedList);
return page;
}
2.4.3 优缺点与适用场景
| 优点 | 缺点 |
| 支持任意跳页与深分页,性能稳定,无offset限制 | 引入额外组件,架构复杂度提升,运维成本增加 |
| 支持复杂的多条件查询、模糊查询、范围查询,远超MySQL的查询能力 | 存在数据一致性延迟,binlog同步有毫秒级延迟,无法满足强一致性场景 |
| 仅需在ES中存储关键字段,存储成本低,查询性能优异 | 需保证ES与MySQL的数据一致性,异常场景需处理数据修复 |
适用场景:后台管理系统的全平台大数据量分页查询,需要跳页且有深分页需求;复杂多条件的分页查询场景,MySQL索引无法满足需求;分库分表集群规模大,无法使用全局视野法的场景。不适用场景:强一致性要求的实时数据查询,小数据量简单分页场景。
2.5 方案五:范围分片优化法
该方案是针对范围分片场景的专属优化方案,可大幅降低分页查询的分片扫描数量。
2.5.1 底层逻辑
若采用范围分片(如按id范围、时间范围分片),数据的分片范围是可预判的,分页查询时可先通过计算定位到offset所在的分片范围,仅需扫描相关分片,无需全分片扫描,大幅提升性能。
比如,按id范围分为3个分片:
- 分片0:id 1-10000
- 分片1:id 10001-20000
- 分片2:id 20001-30000
当需要查询offset=15000,size=10时,可直接定位到分片1和分片2,分片0无需扫描,减少了1/3的数据库压力与数据传输量。
2.5.2 优缺点与适用场景
| 优点 | 缺点 |
| 大幅减少扫描的分片数量,提升分页查询性能 | 易出现数据热点问题,最新数据集中在单个分片 |
| 范围查询性能优异,适合按时间维度的报表分页 | 分片扩容难度大,需调整范围规则 |
| 支持跳页与深分页,数据准确性100% | 仅适用于范围分片场景,无法用于哈希分片 |
适用场景:按时间范围分片的日志、报表、账单数据的分页查询;数据冷热分离的场景,冷数据分片无需扫描;范围查询为主的业务场景。不适用场景:哈希分片场景,高并发写入易出现热点的业务场景。
三、生产级避坑指南:99%开发者踩过的分页陷阱
3.1 排序字段非全局唯一导致的数据丢失/重复
坑点描述:很多开发者仅用create_time作为排序字段,同一毫秒内会产生多条数据,跨分片时,合并排序的顺序无法保证确定性,导致分页时出现数据重复或丢失。错误示例:SELECT * FROM t_order ORDER BY create_time DESC LIMIT offset, size;解决方案:排序字段必须包含全局唯一字段,保证排序的确定性,正确示例:SELECT * FROM t_order ORDER BY create_time DESC, order_id DESC LIMIT offset, size;,同时必须为排序字段建立联合索引idx_create_time_order_id (create_time, order_id)。
3.2 深分页使用全局视野法导致的OOM与数据库雪崩
坑点描述:无限制的深分页查询,比如offset=100万,size=10,10个分片的场景下,每个分片需要扫描1000010条数据,总扫描行数超过1亿,直接导致数据库CPU打满,应用OOM,甚至集群雪崩。解决方案:严格限制最大翻页数,后台管理系统最多允许翻100页,offset不超过10000;超过限制的查询,强制要求用户添加查询条件缩小数据范围;C端场景必须使用游标分页,禁止使用offset分页。
3.3 分片键选择错误导致的分页性能灾难
坑点描述:分片键选择与业务高频查询场景不匹配,比如分片键选了order_id,但90%的查询都是按user_id分页,无法精准路由,每次查询都全分片扫描,性能极差。解决方案:分片键的选择必须遵循高频查询优先原则,优先选择业务中最常用的查询维度作为分片键,比如用户系统选user_id,商户系统选merchant_id。
3.4 跨分片JOIN分页查询的结果错乱
坑点描述:分库分表后,直接使用JOIN关联多个分表进行分页查询,比如订单表关联订单明细表,跨分片JOIN无法保证关联结果的准确性,分页结果完全错乱。解决方案:禁止跨分片JOIN分页查询,采用字段冗余,将常用的关联字段冗余到主表中,避免JOIN;采用应用层组装,先分页查询主表数据,再通过主表ID批量查询明细表数据,在应用层组装结果。
3.5 COUNT查询的性能灾难
坑点描述:分页查询通常需要返回总条数,分库分表后,SELECT COUNT(*) FROM t_order需要全分片扫描,每个分片执行全表count,大数据量下性能极差,甚至超时。解决方案:C端无限滚动场景,无需返回总条数,直接去掉count查询;后台管理系统场景,采用Redis定时统计总条数,或使用MySQL的信息表估算行数,禁止实时全分片count;有条件的count查询,优先使用ES方案,ES的count性能远超MySQL。
四、全场景方案选型决策指南
4.1 分页方案决策流程图
4.2 全场景选型矩阵
| 业务场景 | 最优方案 | 可用方案 | 禁止方案 |
| C端用户个人数据查询(带分片键) | 精准路由法 | 游标分页法 | 全局视野法深分页 |
| C端无限滚动列表(无跳页) | 游标分页法 | ES二级索引法 | offset深分页 |
| 后台管理系统分页(≤100页) | 全局视野法 | ES二级索引法 | 无限制深分页 |
| 后台管理系统大数据量深分页 | ES二级索引法 | 范围分片优化法 | 全局视野法 |
| 按时间范围的报表分页 | 范围分片优化法 | ES二级索引法 | 哈希分片全局视野法 |
| 复杂多条件模糊查询分页 | ES二级索引法 | 精准路由法 | 全局视野法 |
| 小表字典数据分页 | 广播表法 | 单表分页 | 分库分表 |
五、性能优化终极最佳实践
- 优先带分片键查询:这是性能最高的方案,没有之一,所有分页查询优先考虑能否携带分片键。
- C端场景100%使用游标分页:摒弃offset分页,从根源上解决深分页问题,这是业内C端场景的通用标准。
- 严格限制翻页深度:后台管理系统必须限制最大翻页数,禁止无限制的深分页查询,超过限制必须强制添加查询条件。
- 排序字段必须建联合索引:排序字段必须包含全局唯一字段,且建立对应的联合索引,避免全表扫描。
- 禁止SELECT:分页查询仅返回业务所需的字段,减少数据传输量与内存占用,尤其是全局视野法场景。
- 复杂查询优先使用ES:多条件、模糊查询、大数据量分页,优先使用ES二级索引方案,避免MySQL的性能瓶颈。
- 避免跨分片JOIN:分库分表后禁止跨分片JOIN分页,采用字段冗余或应用层组装,保证结果准确性与性能。
分库分表下的分页查询,本质上是分布式系统中CAP理论的体现:数据分片带来了扩展性与性能提升,必然会牺牲全局数据视图的便利性。没有万能的最优方案,只有最适合业务场景的方案。