MySQL索引设计最佳实践
索引设计原则
合理的索引设计是数据库性能优化的基础。在设计索引时,需要综合考虑查询性能、存储空间、写入性能等多个方面。
选择合适的字段创建索引
1. 频繁作为查询条件的字段
分析业务中的高频查询场景,为WHERE子句中经常出现的字段建立索引:
-- 如果经常按用户ID查询订单
SELECT * FROM orders WHERE user_id = ?;
-- 应该创建索引
CREATE INDEX idx_user_id ON orders(user_id);
-- 如果经常按时间范围查询
SELECT * FROM logs WHERE create_time BETWEEN ? AND ?;
-- 应该创建索引
CREATE INDEX idx_create_time ON logs(create_time);
2. 频繁用于JOIN连接的字段
关联查询中的连接字段应该建立索引:
-- 订单表和用户表经常关联查询
SELECT o.*, u.username
FROM orders o
JOIN users u ON o.user_id = u.user_id;
-- 应该在orders表的user_id上创建索引
CREATE INDEX idx_user_id ON orders(user_id);
3. 频繁用于排序和分组的字段
-- 如果经常按创建时间排序
SELECT * FROM articles ORDER BY publish_time DESC LIMIT 20;
-- 创建索引可以避免文件排序
CREATE INDEX idx_publish_time ON articles(publish_time);
-- 如果经常按类别分组统计
SELECT category, COUNT(*) FROM products GROUP BY category;
-- 创建索引可以优化分组操作
CREATE INDEX idx_category ON products(category);
4. 区分度高的字段
字段的区分度(选择性)是指不重复值的数量占总行数的比例:
-- 计算字段区分度
SELECT COUNT(DISTINCT column_name) / COUNT(*) AS selectivity
FROM table_name;
一般来说:
- 选择性 > 0.8: 适合建索引
- 选择性 0.5-0.8: 根据情况决定
- 选择性 < 0.5: 通常不适合建索引
但也有例外: 如果数据分布极度倾斜,即使区分度低也可能需要索引。
示例:
-- 订单状态字段,虽然区分度低
-- 但如果95%的订单是已完成,只有5%是待处理
-- 查询待处理订单时索引仍然有效
SELECT * FROM orders WHERE status = 'PENDING';
避免在频繁更新的字段上建索引
索引虽然能加速查询,但会降低写入性能:
-- 不推荐:在频繁更新的字段上建索引
CREATE TABLE user_sessions (
session_id VARCHAR(64) PRIMARY KEY,
user_id BIGINT NOT NULL,
last_active TIMESTAMP NOT NULL, -- 每次用户操作都会更新
KEY idx_last_active (last_active) -- 索引维护成本高
);
-- 如果确实需要查询,可以考虑定期归档+查询归档表
限制每张表的索引数量
建议单张表索引不超过5个,原因包括:
- 占用存储空间: 每个索引都是一棵B+树,占用磁盘空间
- 降低写入性能: 每次INSERT/UPDATE/DELETE都要维护所有索引
- 增加优化器负担: 索引越多,优化器选择合适索引的时间越长
优先考虑联合索引
联合索引相比多个单列索引有明显优势:
空间效率:
-- 方案1: 创建两个单列索引(占用空间大)
CREATE INDEX idx_user ON orders(user_id);
CREATE INDEX idx_status ON orders(status);
-- 方案2: 创建一个联合索引(占用空间小)
CREATE INDEX idx_user_status ON orders(user_id, status);
查询效率:
-- 联合索引可以覆盖多种查询场景
SELECT * FROM orders WHERE user_id = 10086; -- 使用联合索引
SELECT * FROM orders WHERE user_id = 10086 AND status = 1; -- 使用联合索引
-- 还可以实现覆盖索引
SELECT user_id, status FROM orders WHERE user_id = 10086; -- 无需回表
联合索引字段顺序原则:
- 区分度高的字段放在前面
- 查询频率高的字段放在前面
- 考虑覆盖索引,将SELECT字段包含进来
避免冗余索引
冗余索引会浪费存储空间并降低写入性能:
-- 冗余示例
CREATE INDEX idx_user_status ON orders(user_id, status);
CREATE INDEX idx_user ON orders(user_id); -- 冗余!被上面的索引覆盖
-- 正确做法:删除冗余索引
DROP INDEX idx_user ON orders;
如何识别冗余索引:
- 如果索引idx_a(a, b)存在,索引idx_a_only(a)就是冗余的
- 使用工具检测:pt-duplicate-key-checker
前缀索引的使用
前缀索引是针对较长的字符串字段,只对前N个字符建立索引。
基本用法
-- 对URL字段的前50个字符创建索引
CREATE TABLE web_pages (
page_id BIGINT PRIMARY KEY,
url VARCHAR(500) NOT NULL,
title VARCHAR(200),
KEY idx_url_prefix (url(50))
) ENGINE=InnoDB;
-- 对邮箱地址的前20个字符创建索引
CREATE INDEX idx_email ON users(email(20));
前缀长度的选择
选择合适的前缀长度需要在空间效率和查询效率之间平衡:
-- 计算不同前缀长度的选择性
SELECT
COUNT(DISTINCT LEFT(email, 5)) / COUNT(*) AS prefix_5,
COUNT(DISTINCT LEFT(email, 10)) / COUNT(*) AS prefix_10,
COUNT(DISTINCT LEFT(email, 15)) / COUNT(*) AS prefix_15,
COUNT(DISTINCT LEFT(email, 20)) / COUNT(*) AS prefix_20,
COUNT(DISTINCT email) / COUNT(*) AS full_column
FROM users;
选择原则:
选择能达到接近完整字段选择性的最小前缀长度。
例如结果是:
- prefix_5: 0.45
- prefix_10: 0.78
- prefix_15: 0.95
- prefix_20: 0.98
- full_column: 0.98
可以选择15作为前缀长度,因为选择性已经接近完整字段。
前缀索引的限制
1. 无法用于覆盖索引
-- 即使有前缀索引,以下查询仍需回表
SELECT email FROM users WHERE email = 'user@example.com';
2. 无法用于排序
-- 无法利用前缀索引优化排序
SELECT * FROM users ORDER BY email;
3. 精确匹配能力下降
如果前缀长度选择不当,可能导致大量重复值:
-- 如果很多用户的邮箱都是user001@..., user002@...
-- 前缀索引(email(7))会有大量重复
-- 导致回表次数增加
适用场景
前缀索引适用于:
- URL、文件路径等较长字段
- 邮箱地址
- 长文本描述字段
不适用于:
- 身份证号(前几位区分度很低)
- 手机号(前几位区分度很低)
- 经常需要精确匹配的字段
InnoDB页分裂与页合并
什么是页分裂
InnoDB以页(默认16KB)为单位存储数据。当向一个已满的页插入数据时,会触发页分裂。
页分裂过程:
触发条件:
- 当前页已满(接近16KB)
- 需要插入的数据在页中间(非末尾)
页分裂的危害:
- 性能下降: 涉及大量数据移动和I/O操作
- 空间浪费: 新分裂的页通常有较多空闲空间
- 碎片增加: 导致磁盘碎片化
什么是页合并
当删除数据后,相邻的两个页可以合并成一个页,这个过程叫页合并。
触发条件:
- 页的填充度低于阈值(默认50%)
- 相邻页可以合并
页合并的影响:
- 减少磁盘碎片
- 但合并操作本身也有性能开销
如何避免页分裂
1. 使用自增主键
-- 推荐:使用自增主键
CREATE TABLE orders (
order_id BIGINT PRIMARY KEY AUTO_INCREMENT,
order_no VARCHAR(32) NOT NULL,
...
) ENGINE=InnoDB;
-- 不推荐:使用UUID作为主键
CREATE TABLE orders (
order_id CHAR(36) PRIMARY KEY DEFAULT (UUID()), -- 无序,导致页分裂
...
) ENGINE=InnoDB;
使用自增主键,数据总是插入到页的末尾,避免页分裂。
2. 使用有序的业务主键
如果必须使用业务字段作为主键,选择有序的字段:
-- 如果使用时间戳作为主键,相对有序
CREATE TABLE logs (
log_id BIGINT PRIMARY KEY, -- 时间戳,相对有序
...
);
3. 批量插入代替逐条插入
-- 不推荐:逐条插入
INSERT INTO orders VALUES (...);
INSERT INTO orders VALUES (...);
INSERT INTO orders VALUES (...);
-- 推荐:批量插入
INSERT INTO orders VALUES (...), (...), (...);
4. 使用逻辑删除代替物理删除
-- 不推荐:物理删除(可能导致页合并)
DELETE FROM users WHERE user_id = 12345;
-- 推荐:逻辑删除
UPDATE users SET is_deleted = 1 WHERE user_id = 12345;
监控页分裂
可以通过InnoDB状态查看页分裂情况:
SHOW ENGINE INNODB STATUS;
关注以下指标:
- Merges: 页合并次数
- Splits: 页分裂次数
索引设计实战案例
案例1: 电商订单表索引设计
需求分析:
-- 高频查询场景
-- 1. 查询用户的订单列表
SELECT * FROM orders WHERE user_id = ? ORDER BY create_time DESC LIMIT 20;
-- 2. 查询用户的待支付订单
SELECT * FROM orders WHERE user_id = ? AND status = 'PENDING_PAY';
-- 3. 查询某个时间段的订单
SELECT * FROM orders WHERE create_time BETWEEN ? AND ?;
-- 4. 统计每个状态的订单数量
SELECT status, COUNT(*) FROM orders GROUP BY status;
索引设计:
CREATE TABLE orders (
order_id BIGINT PRIMARY KEY AUTO_INCREMENT,
order_no VARCHAR(32) NOT NULL,
user_id BIGINT NOT NULL,
status VARCHAR(20) NOT NULL,
total_amount DECIMAL(10,2) NOT NULL,
create_time DATETIME NOT NULL,
update_time DATETIME NOT NULL,
-- 唯一索引:保证订单号唯一
UNIQUE KEY uk_order_no (order_no),
-- 联合索引:覆盖场景1和2
KEY idx_user_time (user_id, create_time),
-- 普通索引:覆盖场景3和4
KEY idx_create_time (create_time),
KEY idx_status (status)
) ENGINE=InnoDB;
案例2: 社交应用用户表索引设计
需求分析:
-- 1. 手机号登录
SELECT * FROM users WHERE phone = ?;
-- 2. 邮箱登录
SELECT * FROM users WHERE email = ?;
-- 3. 用户名搜索
SELECT user_id, username, avatar FROM users WHERE username LIKE '张%';
-- 4. 查询活跃用户
SELECT * FROM users WHERE last_login_time > ? ORDER BY last_login_time DESC;
索引设计:
CREATE TABLE users (
user_id BIGINT PRIMARY KEY AUTO_INCREMENT,
username VARCHAR(50) NOT NULL,
phone VARCHAR(20) NOT NULL,
email VARCHAR(100),
avatar VARCHAR(200),
last_login_time DATETIME,
-- 唯一索引:保证手机号和邮箱唯一,同时加速查询
UNIQUE KEY uk_phone (phone),
UNIQUE KEY uk_email (email),
-- 普通索引:支持用户名前缀搜索
KEY idx_username (username),
-- 普通索引:支持活跃用户查询和排序
KEY idx_last_login (last_login_time)
) ENGINE=InnoDB;
案例3: 日志表索引设计
特殊考虑:
- 日志表写入量大,读取相对较少
- 通常按时间查询
- 不适合建立过多索引
索引设计:
CREATE TABLE access_logs (
log_id BIGINT PRIMARY KEY AUTO_INCREMENT,
user_id BIGINT,
request_path VARCHAR(500),
request_method VARCHAR(10),
response_time INT,
create_time DATETIME NOT NULL,
-- 只建立必要的索引
KEY idx_user_time (user_id, create_time),
KEY idx_create_time (create_time)
-- request_path不建索引(查询频率低,字段过长)
) ENGINE=InnoDB;
-- 配合分区表使用
ALTER TABLE access_logs PARTITION BY RANGE (YEAR(create_time)) (
PARTITION p2023 VALUES LESS THAN (2024),
PARTITION p2024 VALUES LESS THAN (2025),
PARTITION p2025 VALUES LESS THAN (2026)
);
索引维护与优化
定期分析和优化表
-- 分析表,更新索引统计信息
ANALYZE TABLE orders;
-- 优化表,整理碎片
OPTIMIZE TABLE orders;
删除长期未使用的索引
MySQL 5.7+可以查询未使用的索引:
SELECT * FROM sys.schema_unused_indexes;
谨慎删除索引的步骤:
- 先设置索引为不可见(MySQL 8.0+)
- 观察一段时间业务是否正常
- 确认无影响后再删除
-- 设置索引不可见
ALTER TABLE orders ALTER INDEX idx_old_column INVISIBLE;
-- 观察一段时间后,确认可以删除
DROP INDEX idx_old_column ON orders;
监控索引使用情况
-- 查看索引统计信息
SHOW INDEX FROM orders;
-- 查看表的索引使用情况
SELECT * FROM sys.schema_index_statistics
WHERE table_schema = 'your_database'
AND table_name = 'orders';
索引设计最佳实践总结
- 选择性原则: 为查询频繁、区分度高的字段建索引
- 最少原则: 控制索引数量,避免过度索引
- 联合原则: 优先使用联合索引代替多个单列索引
- 覆盖原则: 设计联合索引时考虑索引覆盖,避免回表
- 有序原则: 主键使用自增字段,避免页分裂
- 长度原则: 对于长字段使用前缀索引
- 维护原则: 定期分析和优化,删除无用索引
通过遵循这些原则,可以设计出高效的索引体系,显著提升数据库性能。