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. 理解业务数据特征,设计合适的索引

相关推荐
TDengine (老段)3 小时前
TDengine Python 连接器进阶指南
大数据·数据库·python·物联网·时序数据库·tdengine·涛思数据
赵渝强老师3 小时前
【赵渝强老师】OceanBase的配置文件与配置项
数据库·oceanbase
玖日大大3 小时前
OceanBase SeekDB:AI 原生数据库的技术革命与实践指南
数据库·人工智能·oceanbase
高溪流5 小时前
3.数据库表的基本操作
数据库·mysql
alonewolf_995 小时前
深入剖析MySQL锁机制与MVCC原理:高并发场景下的数据库核心优化
数据库·mysql
一 乐5 小时前
绿色农产品销售|基于springboot + vue绿色农产品销售系统(源码+数据库+文档)
java·前端·数据库·vue.js·spring boot·后端·宠物
黄宝康5 小时前
sqlyog密钥亲测有效
mysql
Codeking__5 小时前
Redis初识——什么是Redis
数据库·redis·mybatis
YIN_尹5 小时前
【MySQL】数据类型(上)
android·mysql·adb