一、分页查询优化:告别"深度翻页"的性能噩梦
1.1 为什么深度分页这么慢?
-- 常见分页写法:查询第10001-10010条记录
SELECT * FROM employees LIMIT 10000, 10;
问题分析 :
这个看似简单的查询,实际上MySQL需要:
-
读取前10010条记录
-
抛弃前10000条记录
-
返回最后的10条记录
性能损耗:
-
扫描行数:
offset + limit条记录 -
数据量越大,性能越差,特别是
offset值很大时
1.2 优化方案一:基于连续主键的优化
-- 原始查询(效率低)
SELECT * FROM employees LIMIT 90000, 5;
-- 优化后(效率高)
SELECT * FROM employees WHERE id > 90000 LIMIT 5;
执行计划对比:
-- 原始查询:全表扫描
EXPLAIN SELECT * FROM employees LIMIT 90000, 5;
-- type: ALL, rows: 100183
-- 优化查询:索引扫描
EXPLAIN SELECT * FROM employees WHERE id > 90000 LIMIT 5;
-- type: range, key: PRIMARY, rows: 19250
适用条件:
-
主键自增且连续
-
查询结果按主键排序
-
表中没有删除记录(确保主键连续性)
局限性:
-- 如果删除了id=90001的记录
DELETE FROM employees WHERE id = 90001;
-- 原始查询结果
SELECT * FROM employees LIMIT 90000, 5;
-- 返回:90002, 90003, 90004, 90005, 90006
-- 优化查询结果
SELECT * FROM employees WHERE id > 90000 LIMIT 5;
-- 返回:90001(已删除), 90002, 90003, 90004, 90005
-- 结果不一致!
1.3 优化方案二:基于非主键字段排序的分页优化
-- 原始查询:按name排序分页
SELECT * FROM employees ORDER BY name LIMIT 90000, 5;
-- Extra: Using filesort(文件排序)
问题分析 :
优化器认为扫描整个索引的成本(索引扫描 + 回表)比全表扫描更高,所以放弃使用索引。
优化方案:先查主键,再关联查询
-- 优化后的查询
SELECT e.*
FROM employees e
INNER JOIN (
SELECT id
FROM employees
ORDER BY name
LIMIT 90000, 5
) ed ON e.id = ed.id;
执行计划对比:
-- 原始查询:全表扫描 + 文件排序
EXPLAIN SELECT * FROM employees ORDER BY name LIMIT 90000, 5;
-- type: ALL, Extra: Using filesort
-- 优化查询:索引排序 + 主键关联
EXPLAIN SELECT e.* FROM employees e
INNER JOIN (SELECT id FROM employees ORDER BY name LIMIT 90000,5) ed
ON e.id = ed.id;
-- 执行计划:
-- id=1: <derived2> (子查询结果) type: ALL
-- id=1: e (主查询) type: eq_ref, key: PRIMARY
-- id=2: employees (子查询) type: index, key: idx_name_age_position, Extra: Using index
优化原理:
-
子查询:只查主键id,走覆盖索引(Using index)
-
主查询:通过主键快速定位记录
-
性能提升:减少IO操作,避免文件排序
1.4 分页优化总结
| 场景 | 优化方案 | 适用条件 | 注意事项 |
|---|---|---|---|
| 主键连续排序 | WHERE id > ? LIMIT ? |
主键自增连续、无删除 | 删除数据会导致结果不一致 |
| 非主键排序 | 子查询查ID + 关联查询 | 任何排序字段 | 确保子查询走覆盖索引 |
| 超大分页 | 业务上限制最大页数 | 所有场景 | 用户体验与性能的平衡 |
Java代码中的实践:
// 1. 限制最大分页深度
public PageResult<User> queryUsers(PageRequest request) {
if (request.getPageNo() > 100) {
throw new BusinessException("最多查询前100页数据");
}
// ... 查询逻辑
}
// 2. 使用游标分页(适用于无限滚动)
public List<User> queryUsersByCursor(Long lastId, Integer pageSize) {
String sql = "SELECT * FROM users WHERE id > ? ORDER BY id LIMIT ?";
return jdbcTemplate.query(sql, new Object[]{lastId, pageSize}, USER_MAPPER);
}
// 3. 缓存热门页数据
@Cacheable(value = "userPage", key = "#pageNo")
public PageResult<User> getCachedUsers(Integer pageNo) {
return userMapper.selectByPage(pageNo, 20);
}
二、Join关联查询优化:NLJ vs BNL算法
2.1 测试环境准备
-- 创建测试表
CREATE TABLE `t1` (
`id` int(11) NOT NULL AUTO_INCREMENT,
`a` int(11) DEFAULT NULL,
`b` int(11) DEFAULT NULL,
PRIMARY KEY (`id`),
KEY `idx_a` (`a`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8;
CREATE TABLE t2 LIKE t1;
-- 插入测试数据
-- t1: 10000条记录
-- t2: 100条记录
2.2 嵌套循环连接(Nested-Loop Join, NLJ)
-- 关联字段有索引
EXPLAIN SELECT * FROM t1 INNER JOIN t2 ON t1.a = t2.a;
执行计划:
id | select_type | table | type | key | key_len | ref | rows ---+-------------+-------+------+--------+---------+-------------+------ 1 | SIMPLE | t2 | ALL | NULL | NULL | NULL | 100 1 | SIMPLE | t1 | ref | idx_a | 5 | test.t2.a | 1
算法流程:
-
选择小表t2作为驱动表,全表扫描100行
-
对每一行,取出关联字段a的值
-
通过索引
idx_a在t1表中查找匹配记录 -
合并结果返回
扫描行数计算:
-
驱动表t2:100行
-
被驱动表t1:100次索引查找(每次1行)= 100行
-
总计:200行
特点:
-
Extra中没有
Using join buffer -
关联字段必须有索引
-
适合小表驱动大表
2.3 基于块的嵌套循环连接(Block Nested-Loop Join, BNL)
-- 关联字段无索引
EXPLAIN SELECT * FROM t1 INNER JOIN t2 ON t1.b = t2.b;
执行计划:
id | select_type | table | type | key | key_len | ref | rows | Extra ---+-------------+-------+------+-----+---------+-----+-------+----------------------------- 1 | SIMPLE | t2 | ALL | NULL| NULL | NULL| 100 | NULL 1 | SIMPLE | t1 | ALL | NULL| NULL | NULL| 10337 | Using where; Using join buffer (Block Nested Loop)
算法流程:
-
将驱动表t2的所有数据(100行)读入
join_buffer -
扫描被驱动表t1(10000行)
-
对t1的每一行,与
join_buffer中的所有行比较 -
返回满足条件的记录
内存判断次数:
- t1行数 × t2行数 = 10000 × 100 = 100万次
join_buffer分段处理 :
如果join_buffer太小(默认256K),放不下驱动表的所有数据:
-
第一次:放800行t2数据,与t1全表比较
-
清空buffer,放剩余200行t2数据
-
再次与t1全表比较(多扫一次t1)
2.4 NLJ vs BNL 算法对比
| 对比项 | NLJ算法 | BNL算法 |
|---|---|---|
| 出现条件 | 关联字段有索引 | 关联字段无索引 |
| 扫描方式 | 索引查找 | 全表扫描 |
| 内存使用 | 少 | 使用join_buffer |
| 判断次数 | 驱动表行数 | 驱动表行数 × 被驱动表行数 |
| Extra标识 | 无特殊标识 | Using join buffer (Block Nested Loop) |
为什么关联字段无索引时使用BNL?
如果用NLJ算法:100 × 10000 = 100万次磁盘扫描
如果用BNL算法:100 + 10000 = 10100次磁盘扫描 + 100万次内存判断
内存操作远快于磁盘IO,所以BNL更优。
2.5 关联查询优化实践
优化方案1:关联字段加索引
-- 为关联字段添加索引
ALTER TABLE t1 ADD INDEX idx_b(b);
ALTER TABLE t2 ADD INDEX idx_b(b);
-- 再次执行,会使用NLJ算法
EXPLAIN SELECT * FROM t1 INNER JOIN t2 ON t1.b = t2.b;
优化方案2:使用straight_join强制驱动顺序
-- 明确指定t2为驱动表(t2是小表)
SELECT * FROM t2 STRAIGHT_JOIN t1 ON t2.a = t1.a;
straight_join使用注意事项:
-
只适用于INNER JOIN
-
LEFT/RIGHT JOIN已经指定了驱动顺序
-
谨慎使用,优化器通常比人更聪明
-
适用于明确知道小表的情况
优化方案3:小表驱动大表
-- 业务逻辑:先过滤,再判断大小
SELECT *
FROM (
SELECT * FROM t1 WHERE create_time > '2024-01-01' -- 过滤后可能只有100行
) AS small_table
INNER JOIN t2 ON small_table.a = t2.a; -- t2有10000行
什么是"小表"?
不是绝对的数据量小,而是过滤后参与join的数据量小。
三、Count查询优化:你真的了解count吗?
3.1 四种count方式对比
-- 关闭查询缓存,查看真实性能
SET GLOBAL query_cache_size=0;
SET GLOBAL query_cache_type=0;
-- 对比四种count方式
EXPLAIN SELECT COUNT(1) FROM employees; -- 使用索引
EXPLAIN SELECT COUNT(id) FROM employees; -- 使用索引
EXPLAIN SELECT COUNT(name) FROM employees; -- 使用索引
EXPLAIN SELECT COUNT(*) FROM employees; -- 使用索引
执行计划 :四个SQL的执行计划完全一样,都使用了idx_name_age_position索引。
3.2 Count性能排序
场景1:字段有索引
-- 性能排序
count(*) ≈ count(1) > count(字段) > count(主键 id)
-- 原因:
-- count(字段):走二级索引,统计非NULL值
-- count(主键 id):走主键索引,数据量更大
场景2:字段无索引
-- 性能排序
count(*) ≈ count(1) > count(主键 id) > count(字段)
-- 原因:
-- count(字段):无索引,全表扫描
-- count(主键 id):主键索引扫描
3.3 Count(*)的特殊优化
重要结论:
-
count(*)是例外,MySQL专门优化,不取值,按行累加 -
不需要 用
count(1)或count(列名)替代count(*) -
从MySQL 5.7开始,优化器会自动选择最优索引
3.4 Count优化方案
方案1:使用MyISAM引擎(特定场景)
-- MyISAM会存储总行数
SELECT COUNT(*) FROM test_myisam;
-- Extra: Select tables optimized away
方案2:使用SHOW TABLE STATUS(估算值)
SHOW TABLE STATUS LIKE 'employees';
-- Rows: 99806(估算值,误差约±40%)
方案3:Redis计数(实时性要求高)
// 插入时增加计数
@Transactional
public void addUser(User user) {
userMapper.insert(user);
redisTemplate.opsForValue().increment("user:count", 1);
}
// 查询时直接读取
public Long getUserCount() {
String count = redisTemplate.opsForValue().get("user:count");
return count != null ? Long.parseLong(count) : 0;
}
方案4:计数表(精确计数)
-- 创建计数表
CREATE TABLE table_count (
table_name VARCHAR(64) PRIMARY KEY,
count BIGINT NOT NULL DEFAULT 0
);
-- 使用事务保证一致性
BEGIN;
INSERT INTO users(name, age) VALUES('张三', 25);
UPDATE table_count SET count = count + 1 WHERE table_name = 'users';
COMMIT;
四、IN和EXISTS优化:小表驱动大表原则
4.1 IN查询
-- 当B表数据集小时,IN优于EXISTS
SELECT * FROM A WHERE id IN (SELECT id FROM B);
-- 等价于:
FOR (id IN B) {
SELECT * FROM A WHERE A.id = B.id;
}
4.2 EXISTS查询
-- 当A表数据集小时,EXISTS优于IN
SELECT * FROM A WHERE EXISTS (SELECT 1 FROM B WHERE B.id = A.id);
-- 等价于:
FOR (row IN A) {
SELECT 1 FROM B WHERE B.id = A.id;
}
4.3 优化建议
-
小表驱动大表:根据数据集大小选择IN或EXISTS
-
索引是关键:确保关联字段有索引
-
可以相互转化:IN和EXISTS经常可以相互改写
-
实际测试验证:不同数据分布可能影响优化器选择
五、阿里巴巴MySQL规范解读(补充)
5.1 数据类型选择原则
数值类型优化
-- ❌ 不推荐:显示宽度
CREATE TABLE user (
id INT(10) UNSIGNED -- 这个10只影响显示
);
-- ✅ 推荐:简洁写法
CREATE TABLE user (
id INT UNSIGNED AUTO_INCREMENT,
age TINYINT UNSIGNED, -- 0-255范围,1字节
status TINYINT(1) -- 0/1状态位
);
优化建议:
-
无负数用
UNSIGNED,容量扩大一倍 -
小范围用
TINYINT/SMALLINT,节省空间 -
实数计算用
DECIMAL,避免精度丢失 -
自增ID用
INT或BIGINT
时间类型选择
-- 根据需求选择
CREATE TABLE orders (
id BIGINT PRIMARY KEY,
create_date DATE, -- 只需要日期
create_time TIME, -- 只需要时间
create_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, -- 自动更新时间
update_at DATETIME -- 大范围时间存储
);
TIMESTAMP vs DATETIME:
-
TIMESTAMP:4字节,2038年上限,时区相关 -
DATETIME:8字节,无上限,时区无关 -
阿里推荐
DATETIME(不考虑空间和2038问题)
字符串类型优化
-- CHAR vs VARCHAR
CREATE TABLE user (
name VARCHAR(64), -- 变长,节省空间
gender CHAR(1), -- 定长,M/F/U
email VARCHAR(255) -- 邮箱通常不会超过255
);
-- 大文本处理
CREATE TABLE article (
id BIGINT PRIMARY KEY,
title VARCHAR(200),
content TEXT, -- 大文本单独存储
summary VARCHAR(500)
);
优化建议:
-
长度变化大用
VARCHAR -
短且定长用
CHAR -
BLOB/TEXT单独存表,避免影响主表性能 -
避免在索引列使用长字符串
六、Java开发者实战建议
6.1 分页查询封装
@Component
public class PageHelper {
/**
* 优化分页查询(避免深度翻页)
*/
public <T> PageResult<T> optimizePageQuery(
PageRequest request,
Function<Long, List<T>> queryByCursor) {
// 限制最大页码
if (request.getPageNo() > MAX_PAGE) {
throw new BusinessException("超出最大查询页数");
}
// 深度分页使用游标方式
if (request.getPageNo() > CURSOR_THRESHOLD) {
Long lastId = getLastId(request.getPageNo() - 1, request.getPageSize());
List<T> data = queryByCursor.apply(lastId);
return new PageResult<>(data, request.getPageSize(), request.getPageNo());
}
// 浅分页使用传统方式
return traditionalPageQuery(request);
}
}
6.2 Join查询优化实践
@Repository
public class UserRepository {
@Autowired
private JdbcTemplate jdbcTemplate;
/**
* 优化关联查询:确保关联字段有索引
*/
public List<UserDTO> findUsersWithDepartment() {
String sql = """
SELECT u.*, d.name as dept_name
FROM users u
INNER JOIN departments d ON u.dept_id = d.id -- dept_id必须有索引
WHERE u.status = 1
ORDER BY u.create_time DESC
LIMIT 100
""";
return jdbcTemplate.query(sql, USER_DTO_MAPPER);
}
/**
* 使用EXISTS优化IN查询
*/
public List<User> findActiveUsers(List<Long> deptIds) {
if (deptIds.size() > 100) {
// 大列表使用EXISTS
String sql = """
SELECT * FROM users u
WHERE EXISTS (
SELECT 1 FROM departments d
WHERE d.id = u.dept_id
AND d.status = 'ACTIVE'
)
""";
return jdbcTemplate.query(sql, USER_MAPPER);
} else {
// 小列表使用IN
String sql = "SELECT * FROM users WHERE dept_id IN (?)";
return jdbcTemplate.query(sql, USER_MAPPER, deptIds.toArray());
}
}
}
6.3 Count查询缓存策略
@Service
public class UserService {
@Autowired
private RedisTemplate<String, String> redisTemplate;
private static final String USER_COUNT_KEY = "stats:user:count";
private static final long CACHE_EXPIRE_HOURS = 1;
/**
* 带缓存的count查询
*/
@Cacheable(value = "userCount", key = "'total'")
public Long getUserCount() {
// 1. 尝试从缓存获取
String cachedCount = redisTemplate.opsForValue().get(USER_COUNT_KEY);
if (cachedCount != null) {
return Long.parseLong(cachedCount);
}
// 2. 查询数据库
Long count = userMapper.countUsers();
// 3. 写入缓存(带过期时间)
redisTemplate.opsForValue().set(
USER_COUNT_KEY,
count.toString(),
CACHE_EXPIRE_HOURS,
TimeUnit.HOURS
);
return count;
}
/**
* 更新用户时的计数维护
*/
@Transactional
public void addUser(User user) {
userMapper.insert(user);
// 删除缓存,下次查询重新加载
redisTemplate.delete(USER_COUNT_KEY);
// 异步更新统计表
taskExecutor.execute(() -> {
statsMapper.incrementUserCount();
});
}
}
七、总结与最佳实践
7.1 分页查询优化要点
-
深度分页是性能杀手,尽量避免
-
连续主键 场景可用
WHERE id > ? LIMIT ?优化 -
非主键排序使用子查询先查ID,再关联
-
业务层面限制最大查询页数
7.2 关联查询优化要点
-
关联字段必须有索引,使用NLJ算法
-
小表驱动大表,优化器通常会自动选择
-
理解NLJ和BNL算法的区别与适用场景
-
谨慎使用
STRAIGHT_JOIN,信任优化器
7.3 Count查询优化要点
-
直接用
count(*),MySQL已做专门优化 -
频繁查询的count考虑缓存或计数表
-
区分精确计数 和估算值的使用场景
7.4 数据类型选择要点
-
够用就好,选择最小的合适类型
-
避免NULL,尽量设为NOT NULL
-
字符串长度要合理,避免过度预留
-
时间类型根据精度和范围选择
7.5 写给Java开发者
-
不要过度优化,先满足业务需求
-
监控慢查询,针对性优化
-
理解业务数据特征,设计合适的索引