MySQL索引优化实战二:分页、关联查询与Count优化深度解析

一、分页查询优化:告别"深度翻页"的性能噩梦

1.1 为什么深度分页这么慢?

复制代码
-- 常见分页写法:查询第10001-10010条记录
SELECT * FROM employees LIMIT 10000, 10;

问题分析

这个看似简单的查询,实际上MySQL需要:

  1. 读取前10010条记录

  2. 抛弃前10000条记录

  3. 返回最后的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

适用条件

  1. 主键自增且连续

  2. 查询结果按主键排序

  3. 表中没有删除记录(确保主键连续性)

局限性

复制代码
-- 如果删除了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

优化原理

  1. 子查询:只查主键id,走覆盖索引(Using index)

  2. 主查询:通过主键快速定位记录

  3. 性能提升:减少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

算法流程

  1. 选择小表t2作为驱动表,全表扫描100行

  2. 对每一行,取出关联字段a的值

  3. 通过索引idx_a在t1表中查找匹配记录

  4. 合并结果返回

扫描行数计算

  • 驱动表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)

算法流程

  1. 将驱动表t2的所有数据(100行)读入join_buffer

  2. 扫描被驱动表t1(10000行)

  3. 对t1的每一行,与join_buffer中的所有行比较

  4. 返回满足条件的记录

内存判断次数

  • t1行数 × t2行数 = 10000 × 100 = 100万次

join_buffer分段处理

如果join_buffer太小(默认256K),放不下驱动表的所有数据:

  1. 第一次:放800行t2数据,与t1全表比较

  2. 清空buffer,放剩余200行t2数据

  3. 再次与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使用注意事项

  1. 只适用于INNER JOIN

  2. LEFT/RIGHT JOIN已经指定了驱动顺序

  3. 谨慎使用,优化器通常比人更聪明

  4. 适用于明确知道小表的情况

优化方案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(*)的特殊优化

重要结论

  1. count(*)是例外,MySQL专门优化,不取值,按行累加

  2. 不需要count(1)count(列名)替代count(*)

  3. 从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 优化建议

  1. 小表驱动大表:根据数据集大小选择IN或EXISTS

  2. 索引是关键:确保关联字段有索引

  3. 可以相互转化:IN和EXISTS经常可以相互改写

  4. 实际测试验证:不同数据分布可能影响优化器选择


五、阿里巴巴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状态位
);

优化建议

  1. 无负数用UNSIGNED,容量扩大一倍

  2. 小范围用TINYINT/SMALLINT,节省空间

  3. 实数计算用DECIMAL,避免精度丢失

  4. 自增ID用INTBIGINT

时间类型选择
复制代码
-- 根据需求选择
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)
);

优化建议

  1. 长度变化大用VARCHAR

  2. 短且定长用CHAR

  3. BLOB/TEXT单独存表,避免影响主表性能

  4. 避免在索引列使用长字符串


六、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 分页查询优化要点

  1. 深度分页是性能杀手,尽量避免

  2. 连续主键 场景可用WHERE id > ? LIMIT ?优化

  3. 非主键排序使用子查询先查ID,再关联

  4. 业务层面限制最大查询页数

7.2 关联查询优化要点

  1. 关联字段必须有索引,使用NLJ算法

  2. 小表驱动大表,优化器通常会自动选择

  3. 理解NLJ和BNL算法的区别与适用场景

  4. 谨慎使用STRAIGHT_JOIN,信任优化器

7.3 Count查询优化要点

  1. 直接用count(*),MySQL已做专门优化

  2. 频繁查询的count考虑缓存或计数表

  3. 区分精确计数估算值的使用场景

7.4 数据类型选择要点

  1. 够用就好,选择最小的合适类型

  2. 避免NULL,尽量设为NOT NULL

  3. 字符串长度要合理,避免过度预留

  4. 时间类型根据精度和范围选择

7.5 写给Java开发者

  1. 不要过度优化,先满足业务需求

  2. 监控慢查询,针对性优化

  3. 理解业务数据特征,设计合适的索引

相关推荐
qq_192779879 小时前
高级爬虫技巧:处理JavaScript渲染(Selenium)
jvm·数据库·python
u01092727110 小时前
使用Plotly创建交互式图表
jvm·数据库·python
爱学习的阿磊10 小时前
Python GUI开发:Tkinter入门教程
jvm·数据库·python
tudficdew10 小时前
实战:用Python分析某电商销售数据
jvm·数据库·python
Fleshy数模11 小时前
CentOS7 安装配置 MySQL5.7 完整教程(本地虚拟机学习版)
linux·mysql·centos
sjjhd65211 小时前
Python日志记录(Logging)最佳实践
jvm·数据库·python
Configure-Handler11 小时前
buildroot System configuration
java·服务器·数据库
2301_8213696111 小时前
用Python生成艺术:分形与算法绘图
jvm·数据库·python
az44yao12 小时前
mysql 创建事件 每天17点执行一个存储过程
mysql
电商API_1800790524712 小时前
第三方淘宝商品详情 API 全维度调用指南:从技术对接到生产落地
java·大数据·前端·数据库·人工智能·网络爬虫