第一次处理千万级数据表的分页查询时,我盯着那个执行时间超过30秒的SQL语句陷入了沉思。当数据量突破亿级门槛后,传统的LIMIT offset, size分页方式开始暴露出致命缺陷——每次查询都需要完整扫描前offset条记录,随着页码增加性能呈指数级下降。这就是让无数开发者头疼的"深分页"问题。
在电商订单系统、物联网传感器数据、金融交易记录等场景中,这种性能瓶颈尤为明显。我曾见过一个分页查询第1000页(每页20条)的请求,数据库需要先读取19980条无用记录才能返回目标数据,不仅消耗大量I/O资源,还可能导致整个数据库连接池被占满。
游标分页通过记录最后一条数据的定位标识(通常是自增ID或时间戳)来实现分页。以下是一个典型实现:
sql复制-- 第一页查询
SELECT * FROM orders
WHERE user_id = 100
ORDER BY id DESC
LIMIT 20;
-- 后续页查询(假设上一页最后一条记录的id是12345)
SELECT * FROM orders
WHERE user_id = 100 AND id < 12345
ORDER BY id DESC
LIMIT 20;
优势分析:
适用场景:
对于复杂查询场景,可以先将主键分页后再关联获取完整数据:
sql复制SELECT t1.* FROM orders t1
JOIN (
SELECT id FROM orders
WHERE user_id = 100
ORDER BY create_time DESC
LIMIT 10000, 20
) t2 ON t1.id = t2.id;
性能对比:
| 方案 | 执行时间(100万数据) | 执行时间(1亿数据) |
|---|---|---|
| 传统分页 | 1200ms | 超时(>30s) |
| 延迟关联 | 80ms | 350ms |
在分库分表场景中,常见的两种方案:
全局排序法:
分片查询+内存排序:
java复制// 伪代码示例
List<Order> pagingSharding(ShardingKey key, int pageSize, String cursor) {
List<Order> results = shards.parallelStream()
.map(shard -> shard.queryAfterCursor(key, cursor, pageSize))
.flatMap(List::stream)
.sorted(comparing(Order::getCreateTime).reversed())
.limit(pageSize)
.collect(toList());
return fetchFullData(results.stream().map(Order::getId).collect(toList()));
}
推荐配置方案:
sql复制-- 创建优化索引
ALTER TABLE orders ADD INDEX idx_user_time (user_id, create_time DESC);
-- 优化后的查询(结合游标和延迟关联)
SELECT o.* FROM orders o
JOIN (
SELECT id FROM orders
WHERE user_id = 100
AND create_time < '2023-01-01 00:00:00' -- 游标条件
ORDER BY create_time DESC
LIMIT 20
) tmp ON o.id = tmp.id;
性能测试数据:
| 数据量 | 传统分页 | 游标分页 | 延迟关联+游标 |
|---|---|---|---|
| 100万 | 1200ms | 50ms | 35ms |
| 5000万 | 超时 | 55ms | 40ms |
| 1亿 | 超时 | 60ms | 45ms |
对于搜索场景,推荐使用search_after机制:
json复制{
"size": 20,
"query": {"match": {"user_id": 100}},
"sort": [
{"create_time": "desc"},
{"_id": "asc"}
],
"search_after": [1654041600000, "abc123"]
}
注意事项:
对于必须支持页码跳转的场景,可以采用以下优化组合:
sql复制SELECT COUNT(1) FROM orders USE INDEX(idx_user) WHERE user_id = 100;
java复制// 预计算各页的起始ID范围
Map<Integer, Long[]> pageMap = calculatePageRanges(userId, pageSize);
public List<Order> getPage(int pageNo) {
Long[] range = pageMap.get(pageNo);
return queryBetweenIds(range[0], range[1]);
}
处理新增数据时的分页漂移问题:
sql复制-- 使用固定时间锚点
SELECT * FROM orders
WHERE user_id = 100
AND create_time <= '2023-01-01 00:00:00' -- 查询时固定时间点
ORDER BY create_time DESC
LIMIT 20 OFFSET 40;
分页查询的索引必须包含:
复合索引示例:
sql复制-- 良好示例
ALTER TABLE orders ADD INDEX idx_paging (user_id, status, create_time DESC, id);
-- 反模式示例(无法优化排序)
ALTER TABLE orders ADD INDEX idx_bad (user_id, create_time, status);
关键MySQL参数调整:
ini复制# InnoDB缓冲池大小(建议物理内存的50-70%)
innodb_buffer_pool_size = 12G
# 排序缓冲区大小
sort_buffer_size = 4M
read_rnd_buffer_size = 4M
# 连接线程缓存
thread_cache_size = 16
多级缓存设计方案:
java复制public List<Order> getOrdersWithCache(long userId, String cursor, int size) {
String cacheKey = buildCacheKey(userId, cursor);
List<Order> cached = localCache.get(cacheKey);
if (cached != null) return cached;
cached = redis.get(cacheKey);
if (cached != null) {
localCache.put(cacheKey, cached);
return cached;
}
cached = db.queryOrders(userId, cursor, size);
redis.setex(cacheKey, 300, cached); // 5分钟过期
localCache.put(cacheKey, cached);
return cached;
}
使用EXPLAIN分析执行计划
检查是否出现以下危险信号:
慢查询日志分析要点:
sql复制# 开启慢查询日志
slow_query_log = 1
slow_query_log_file = /var/log/mysql/mysql-slow.log
long_query_time = 1
log_queries_not_using_indexes = 1
案例1:错误使用OR条件
sql复制-- 错误写法(导致索引失效)
SELECT * FROM orders
WHERE user_id = 100 OR status = 'PAID'
ORDER BY create_time DESC
LIMIT 20;
-- 优化方案
SELECT * FROM orders
WHERE user_id = 100
UNION ALL
SELECT * FROM orders
WHERE status = 'PAID' AND user_id != 100
ORDER BY create_time DESC
LIMIT 20;
案例2:错误使用函数转换
sql复制-- 错误写法(索引失效)
SELECT * FROM orders
WHERE DATE(create_time) = '2023-01-01'
ORDER BY id DESC
LIMIT 20;
-- 优化方案
SELECT * FROM orders
WHERE create_time >= '2023-01-01 00:00:00'
AND create_time < '2023-01-02 00:00:00'
ORDER BY id DESC
LIMIT 20;
根据业务场景选择合适方案的决策流程:
是否需要随机跳页?
数据是否频繁变更?
是否分布式环境?
是否需要精确总数?
在实际项目中,我们最终采用的混合方案:对于用户中心的订单列表使用游标分页,后台管理系统采用延迟关联+缓存分页结果,搜索场景使用Elasticsearch的search_after。这套组合使我们的分页查询性能提升了40倍,从原来的平均2秒降到50毫秒以内。