前言
当我们刚入职一家公司,刚接手一个老项目,在项目初期的架构中往往没有完善的架构设计和数据库设计,不足以支撑上亿级别的数据查询,更甚者可能几百万的数据量就已经可以令系统卡死,使用户体验极差。这样的场景在如今的程序员面试中也常常被问及,今天来探讨一下我的优化思路和方案。
以我们最熟悉的Mysql数据库为例
排查原因
SQL
SELECT * FROM orders
WHERE status = 'completed'
ORDER BY create_time DESC
LIMIT 1000000, 20;
上面是我们平时开发项目中最常用也是最经典的分页查询语句,我们执行以下数据库自带的EXPLAIN。
SQL
EXPLAIN SELECT * FROM orders
WHERE status = 'completed'
ORDER BY create_time DESC
LIMIT 1000000, 20;
-- 结果显示:
-- rows: 1000020 -- 扫描了100万+行
从结果上来看,已经很明显指出查询缓慢原因:Mysql需要扫描前1000020行数据才能返回结果,而随着数据偏移量增大,响应时间会呈线性增长,从我实际接触到的经验出发,1亿条商品数据表第10000页的查询会耗时28秒,这是很难被用户接受的体验。
核心优化方案
游标分页(Cursor-based Pagination)
基于主键的游标分页
我们都知道,Mysql对于主键的查询和回表查询是非常快的,利于这一点我们从客户端和数据库两方面进行优化。
Java
@Service
public class OrderService {
@Autowired
private OrderMapper orderMapper;
/**
* 游标分页查询
* @param lastId 上一页最后一条记录的ID
* @param pageSize 页面大小
* @return 订单列表
*/
public List<Order> getOrdersByCursor(Long lastId, int pageSize) {
if (Objects.isNull(lastId)) {
// 第一页
return orderMapper.selectFirstPage(pageSize);
} else {
// 后续页:基于上一页最后ID查询
return orderMapper.selectNextPage(lastId, pageSize);
}
}
}
SQL
-- 第一页
SELECT * FROM orders
WHERE status = 'completed'
ORDER BY id DESC
LIMIT #{pageSize};
-- 后续页
SELECT * FROM orders
WHERE status = 'completed' AND id < #{lastId}
ORDER BY id DESC
LIMIT #{pageSize};
游标分页的优势:
时间复杂度O(1),性能稳定;
无需跳过大量数据;
响应时间一般能稳定控制在50ms以内;
游标分页适用场景:Feed流(瀑布式加载)、商品列表、消息列表等无限滚动场景
无需跳转到指定页码的场景
复合索引游标分页
对于有复杂条件查询的场景,使用这种SQL查询方式更佳。
SQL
SELECT * FROM orders
WHERE status = 'completed'
AND (create_time, id) < ('2024-01-01 10:00:00', 1000) ORDER BY create_time DESC, id
DESC LIMIT 20;
-- 需要创建复合索引
CREATE INDEX idx_status_create_time_id
ON orders(status, create_time DESC, id DESC);
覆盖索引优化法
当业务必须支持跳转到指定页码时(如后台管理系统),使用覆盖索引:
SQL
-- 步骤1:先通过覆盖索引获取主键
SELECT id FROM orders
WHERE status = 'completed'
ORDER BY create_time DESC LIMIT 1000000, 20;
-- 步骤2:通过主键回表查询详细信息
SELECT * FROM orders WHERE id IN (/* 上面查询的结果 */);
Java
/** 客户端实现方法 */
public List<Order> getPageWithCoveringIndex(int pageNum, int pageSize) {
int offset = (pageNum - 1) * pageSize;
// 第一步:获取主键列表(走覆盖索引)
List<Long> ids = orderMapper.selectIdsOnly(offset, pageSize);
if (Objects.isNull(ids) || ids.isEmpty()) {
return Collections.emptyList();
}
// 第二步:回表查询详细信息
return orderMapper.selectByIds(ids);
}
索引设计:
SQL
-- 覆盖索引:包含WHERE条件 + ORDER BY字段 + 主键
CREATE INDEX idx_covering ON orders(status, create_time DESC, id);
覆盖索引设计相比传统分页查询性能提升:
- 从28秒 -> 200ms(实测数据)
- 减少磁盘I/O,避免回表开销
延迟关联(Deferred Join) - 高级优化
SQL
-- 延迟关联写法
SELECT o.* FROM orders o
INNER JOIN (
SELECT id FROM orders
WHERE status = 'completed'
ORDER BY create_time DESC LIMIT 1000000, 20
) AS tmp ON o.id = tmp.id
ORDER BY o.create_time DESC;
延迟关联原理:
- 子查询只扫描索引,不回表
- 外层查询只处理20条记录的回表操作
- 避免了对100万+条记录的回表操作
数据归档与分区表
冷热数据分离
SQL
-- 将冷数据归档到历史表
CREATE TABLE orders_history LIKE orders;
-- 归档一年前的数据
INSERT INTO orders_history;
SELECT * FROM orders WHERE create_time < DATA_SUB(now(), INTERVAL 1 YEAR);
DELETE FROM orders WHERE create_time < DATA_SUB(now(), INTERVAL 1 YEAR);
冷热数据分离效果:
- 主表数据量从亿级别减少到千万级别
- 分页查询性能可以提升10倍有余
- 用户通常只查询最近数据
Mysql分区
SQL
-- 按时间范围分区
CREATE TABLE orders_partitioned (
id BIGINT PRIMARY KEY,
user_id BIGINT NOT NULL,
status VARCHAR(32),
create_time DATETIME NOT NULL,
amount DECIMAL(10,2)
) PARTITION BY RANGE (YEAR(create_time)) (
PARTITION p2023 VALUES LESS THAN (2024),
PARTITION p2024 VALUES LESS THAN (2025),
PARTITION p2025 VALUES LESS THAN (2026),
PARTITION p_current VALUES LESS THAN MAXVALUE
);
-- 查询时自动路由到对应分区
SELECT * FROM orders_partitioned
WHERE status = 'completed'
AND create_time >= '2024-01-01'
ORDER BY create_time DESC
LIMIT 1000000,20;
缓存预加载策略
思路是将一些热门数据预先缓存到本地或者缓存服务中,从而提升性能。
Java
/** 后台异步预加载 */
@Component
public class PaginationCachePreloader {
@Scheduled(fixedRate = 300000) // 每5分钟
public void preloadPopularPages() { // 预加载热门用户的前100页数据
List<Long> hotUserIds = getUserHotList();
for (Long userId : hotUserIds) {
for (int page = 1; page <= 100; page++) {
CompletableFuture.runAsync(() -> {
List<Order> orders = orderService.getOrdersByCursor( getLastIdForPage(userId, page), 20 );
cacheService.set( "user_orders:" + userId + ":page:" + page, orders, Duration.ofHours(1) );
});
}
}
}
}
数据表设计索引优化
索引设计原则
SQL
-- 错误的索引设计
CREATE INDEX idx_status ON orders(status); --单列索引
CREATE INDEX idx_create_time ON orders(create_time); --单例索引
-- 正确的复合索引设计
CREATE INDEX idx_optimal ON orders(status, create_time DESC, id);
索引设计黄金法则
- WHERE条件字段放在最前面
- ORDER BY字段紧随其后
- SELECT字段如果能被索引覆盖则更好
- 主键通常最为最后字段
索引选择性分析
SQL
-- 分析字段选择性
SELECT
COUNT(DISTINCT status) / COUNT(*) as status_selectivity,
COUNT(DISTINCT create_time) / COUNT(*) as time_selectivity
FROM orders;
-- 高选择性字段应该放在复合索引前面
-- 如果status只有3个值(低选择性),考虑调整索引顺序
不同场景的方案选择
| 场景 | 推荐方案 | 理由 |
|---|---|---|
| Web/App应用(Feed流、商品列表) | 游标分页 | 性能最优,用户体验好 |
| 管理后台/报表系统(需跳页) | 覆盖索引 + 延迟关联 | 兼容传统页码需求 |
| 历史数据查询 | 数据归档 + 分区表 | 根本性解决数据量问题 |
| 高频访问页面 | 缓存预加载 | 提供极致用户体验 |
实战性能对比
以下是根据我历年工作经验的总结得到的数据(仅供参考):
| 方案 | 100万偏移量 | 1000万偏移量 | 实现复杂度 |
|---|---|---|---|
| 传统LIMIT | 2.8秒 | 28秒 | 低 |
| 游标分页 | 45ms | 48ms | 中 |
| 覆盖索引 | 200ms | 220ms | 中 |
| 延迟关联 | 180ms | 210ms | 高 |
| 数据归档后 | 50ms | 55ms | 高 |
总结
对于上亿及大数据量的分页查询优化,没有银弹,需要根据实际具体场景选择合适的方案,通过组合使用以上提到的方案,可以将原本需要几十秒甚至几分钟的查询优化到百毫秒以内,显著提升系统性能和用户体验。
如果您觉得这篇技术分享对您有用,请点赞收藏关注博主,不定时分享技术。愿您每天开心,工作顺利,家庭幸福。