Skip to content
清晨的一缕阳光
返回

索引设计与最佳实践

引言

索引设计是数据库性能优化的核心环节。一个好的索引设计可以将查询速度提升数十倍甚至上百倍,而糟糕的索引设计不仅浪费存储空间,还会拖慢写入性能。

本章将深入讲解:

核心概念

索引设计的目标

索引设计的核心目标是在查询性能写入性能之间找到最佳平衡点:

graph LR
    subgraph 查询性能
        A[减少 IO 次数]
        B[避免全表扫描]
        C[加速排序分组]
    end
    
    subgraph 写入性能
        D[减少索引维护]
        E[降低锁竞争]
        F[减少存储空间]
    end
    
    A & B & C --> G[最佳平衡点]
    D & E & F --> G
    
    style G fill:#90EE90

索引的代价

每个索引都有成本,设计前需要权衡:

成本类型说明影响程度
存储空间每个索引占用磁盘空间⭐⭐⭐
写入性能INSERT/UPDATE/DELETE 需维护索引⭐⭐⭐⭐
优化器开销查询优化器需选择最优索引⭐⭐
内存占用索引缓存占用 buffer pool⭐⭐⭐

经验法则:

索引设计原则

1. 选择高选择性列

选择性(Selectivity) = 不同值的数量 / 总行数

选择性越高,索引效果越好:

-- 表结构
CREATE TABLE users (
    id BIGINT PRIMARY KEY,
    phone VARCHAR(11) UNIQUE,      -- 选择性 ≈ 1(最优)
    name VARCHAR(50),               -- 选择性中等
    gender TINYINT,                 -- 选择性低(只有 2 个值)
    status TINYINT DEFAULT 1        -- 选择性低(大部分为 1)
);

-- ✅ 适合建索引
CREATE INDEX idx_phone ON users(phone);

-- ⚠️ 谨慎建索引(区分度低)
CREATE INDEX idx_gender ON users(gender);

-- ❌ 不建议建索引
CREATE INDEX idx_status ON users(status);

检查选择性:

-- 查看列的选择性
SELECT 
    COUNT(DISTINCT phone) / COUNT(*) AS phone_selectivity,
    COUNT(DISTINCT gender) / COUNT(*) AS gender_selectivity
FROM users;

-- 结果示例:
-- phone_selectivity: 1.0000  (100% 不同)
-- gender_selectivity: 0.0002  (仅 2 个值)

2. 利用最左前缀原则设计联合索引

联合索引遵循最左前缀原则,设计时需考虑查询模式:

-- 常见查询场景
SELECT * FROM orders WHERE user_id = 100;                      -- 场景 1
SELECT * FROM orders WHERE user_id = 100 AND status = 1;       -- 场景 2
SELECT * FROM orders WHERE user_id = 100 AND create_time > '2024-01-01';  -- 场景 3

错误设计:

-- ❌ 索引列顺序不合理
CREATE INDEX idx_status_user ON orders(status, user_id);
-- 场景 1 无法使用该索引(跳过了 status 列)

正确设计:

-- ✅ 将高频查询列放在前面
CREATE INDEX idx_user_status_time ON orders(user_id, status, create_time);
-- 场景 1、2、3 都可以使用该索引

设计技巧:

  1. 等值查询列优先于范围查询列
  2. 高频查询列优先于低频查询列
  3. 选择性高的列优先于选择性低的列

3. 优先使用覆盖索引

覆盖索引是指查询所需的所有列都在索引中,无需回表:

-- 表结构
CREATE TABLE products (
    id BIGINT PRIMARY KEY,
    name VARCHAR(100),
    category_id INT,
    price DECIMAL(10,2),
    stock INT,
    INDEX idx_category_price (category_id, price)
);

-- ❌ 需要回表(查询了索引外的列)
SELECT id, name, price FROM products WHERE category_id = 10;

-- ✅ 覆盖索引(所有查询列都在索引中)
SELECT category_id, price FROM products WHERE category_id = 10;

-- ✅ 覆盖索引(包含主键)
SELECT id, category_id, price FROM products WHERE category_id = 10;

验证覆盖索引:

EXPLAIN SELECT id, category_id, price FROM products WHERE category_id = 10;
-- Extra 列显示:Using index(表示使用了覆盖索引)

4. 避免索引失效场景

常见导致索引失效的操作:

-- 1. 在索引列上使用函数
SELECT * FROM users WHERE YEAR(create_time) = 2024;  -- ❌ 索引失效
SELECT * FROM users WHERE create_time >= '2024-01-01';  -- ✅ 索引有效

-- 2. 隐式类型转换
SELECT * FROM users WHERE phone = 13800138000;  -- ❌ phone 是字符串,数字会触发转换
SELECT * FROM users WHERE phone = '13800138000';  -- ✅ 索引有效

-- 3. LIKE 以通配符开头
SELECT * FROM users WHERE name LIKE '%张%';  -- ❌ 索引失效
SELECT * FROM users WHERE name LIKE '张%';   -- ✅ 索引有效(最左匹配)

-- 4. 使用 OR 连接非索引列
SELECT * FROM orders WHERE user_id = 100 OR status = 1;  -- ❌ status 无索引,全表扫描
SELECT * FROM orders WHERE user_id = 100 OR order_id = 200;  -- ✅ 两个列都有索引

-- 5. 不等于操作
SELECT * FROM users WHERE age != 18;  -- ⚠️ 可能索引失效(取决于数据分布)
SELECT * FROM users WHERE age > 18;   -- ✅ 索引有效

高级索引优化技术

索引下推(Index Condition Pushdown)

索引下推是 MySQL 5.6 引入的优化技术,将过滤条件下推到存储引擎层:

-- 表结构
CREATE TABLE users (
    id BIGINT PRIMARY KEY,
    name VARCHAR(50),
    age INT,
    city VARCHAR(50),
    INDEX idx_name_city (name, city)
);

-- 查询
SELECT * FROM users WHERE name LIKE '张%' AND city = '北京';

无索引下推(MySQL 5.6 之前):

  1. 通过索引定位 name LIKE '张%' 的记录
  2. 回表查询完整行数据
  3. 在 Server 层过滤 city = '北京'

有索引下推(MySQL 5.6+):

  1. 通过索引定位 name LIKE '张%' 的记录
  2. 在索引层直接过滤 city = '北京'(无需回表)
  3. 只回表符合条件的记录

验证索引下推:

EXPLAIN SELECT * FROM users WHERE name LIKE '张%' AND city = '北京';
-- Extra 列显示:Using index condition

前缀索引

对于长字符串列,可以只索引前 N 个字符:

-- 原列:VARCHAR(200)
CREATE INDEX idx_email ON users(email);  -- 索引较大

-- 前缀索引:只索引前 20 个字符
CREATE INDEX idx_email_prefix ON users(email(20));  -- 索引更小

选择合适的前缀长度:

-- 计算不同前缀长度的选择性
SELECT 
    COUNT(DISTINCT LEFT(email, 10)) / COUNT(*) AS prefix_10,
    COUNT(DISTINCT LEFT(email, 20)) / COUNT(*) AS prefix_20,
    COUNT(DISTINCT LEFT(email, 30)) / COUNT(*) AS prefix_30,
    COUNT(DISTINCT email) / COUNT(*) AS full_column
FROM users;

-- 结果示例:
-- prefix_10: 0.85  (85% 选择性)
-- prefix_20: 0.92  (92% 选择性)
-- prefix_30: 0.95  (95% 选择性)
-- full_column: 0.98 (98% 选择性)

-- 选择 prefix_20,在索引大小和选择性之间取得平衡
CREATE INDEX idx_email_prefix ON users(email(20));

注意事项:

函数索引(Generated Column + 索引)

MySQL 5.7+ 支持通过生成列实现函数索引:

-- 场景:需要按年份查询
CREATE TABLE orders (
    id BIGINT PRIMARY KEY,
    create_time DATETIME,
    -- 定义生成列
    create_year INT AS (YEAR(create_time)) STORED,
    -- 为生成列创建索引
    INDEX idx_create_year (create_year)
);

-- 查询(使用函数索引)
SELECT * FROM orders WHERE create_year = 2024;  -- ✅ 索引有效

-- 对比:直接对函数查询
SELECT * FROM orders WHERE YEAR(create_time) = 2024;  -- ❌ 索引失效

覆盖索引优化排序

利用索引的有序性避免 filesort:

-- 表结构
CREATE TABLE articles (
    id BIGINT PRIMARY KEY,
    category_id INT,
    create_time DATETIME,
    INDEX idx_category_time (category_id, create_time)
);

-- ✅ 利用索引排序(无需 filesort)
SELECT * FROM articles 
WHERE category_id = 10 
ORDER BY create_time DESC 
LIMIT 10;

-- ❌ 无法利用索引排序
SELECT * FROM articles 
WHERE category_id = 10 
ORDER BY create_time DESC, id ASC  -- 排序方向不一致
LIMIT 10;

索引设计实战

电商订单表索引设计

业务场景:

表结构:

CREATE TABLE orders (
    id BIGINT PRIMARY KEY AUTO_INCREMENT,
    order_no VARCHAR(32) NOT NULL,      -- 订单号
    user_id BIGINT NOT NULL,             -- 用户 ID
    status TINYINT NOT NULL DEFAULT 1,   -- 订单状态
    amount DECIMAL(10,2) NOT NULL,       -- 订单金额
    create_time DATETIME NOT NULL,       -- 创建时间
    pay_time DATETIME,                   -- 支付时间
    INDEX idx_user_id (user_id),         -- 用户查询
    UNIQUE INDEX idx_order_no (order_no) -- 订单号唯一索引
);

常见查询及索引优化:

-- 查询 1:用户订单列表(按时间倒序)
SELECT * FROM orders 
WHERE user_id = 100 
ORDER BY create_time DESC 
LIMIT 20;
-- ✅ 现有索引 idx_user_id 可用,但需要 filesort
-- 优化:创建联合索引
CREATE INDEX idx_user_time ON orders(user_id, create_time DESC);

-- 查询 2:用户待支付订单
SELECT * FROM orders 
WHERE user_id = 100 AND status = 1;
-- ✅ 优化索引
CREATE INDEX idx_user_status ON orders(user_id, status);

-- 查询 3:用户订单统计
SELECT status, COUNT(*), SUM(amount) 
FROM orders 
WHERE user_id = 100 
GROUP BY status;
-- ✅ 覆盖索引优化
CREATE INDEX idx_user_status_amount ON orders(user_id, status, amount);

-- 查询 4:按时间范围查询订单
SELECT * FROM orders 
WHERE create_time >= '2024-01-01' AND create_time < '2024-02-01';
-- ⚠️ 需要单独的时间索引
CREATE INDEX idx_create_time ON orders(create_time);

最终索引方案:

-- 主键索引
PRIMARY KEY (id)

-- 唯一索引
UNIQUE INDEX idx_order_no (order_no)

-- 联合索引(核心查询)
INDEX idx_user_time_status (user_id, create_time DESC, status)

-- 单列索引(辅助查询)
INDEX idx_create_time (create_time)

用户表索引设计

业务场景:

CREATE TABLE users (
    id BIGINT PRIMARY KEY,
    phone VARCHAR(11) NOT NULL,
    username VARCHAR(50),
    email VARCHAR(100),
    city VARCHAR(50),
    status TINYINT DEFAULT 1,
    create_time DATETIME
);

-- 索引设计
UNIQUE INDEX idx_phone (phone);              -- 手机号唯一,登录用
INDEX idx_username (username);               -- 用户名搜索
INDEX idx_city_status (city, status);        -- 城市筛选(联合状态)

注意事项

1. 避免过度索引

-- ❌ 错误示例:为每个列都创建索引
CREATE INDEX idx_name ON users(name);
CREATE INDEX idx_age ON users(age);
CREATE INDEX idx_city ON users(city);
CREATE INDEX idx_status ON users(status);
-- 问题:写入性能严重下降,优化器选择困难

-- ✅ 正确做法:根据实际查询设计
-- 分析慢查询日志,只为高频查询创建索引

2. 定期清理无用索引

-- 查看索引使用情况
SELECT 
    table_schema,
    table_name,
    index_name,
    rows_selected,
    rows_inserted,
    rows_updated,
    rows_deleted
FROM performance_schema.table_io_waits_summary_by_index_usage
WHERE table_schema = 'your_database'
ORDER BY rows_selected DESC;

-- 删除长期未使用的索引
ALTER TABLE users DROP INDEX idx_unused;

3. 大表索引变更策略

对于大表(>100 万行),添加索引需谨慎:

-- ❌ 直接添加索引(可能锁表)
ALTER TABLE large_table ADD INDEX idx_new (column);

-- ✅ 低风险方案
-- 方案 1:使用 pt-online-schema-change(Percona 工具)
pt-online-schema-change --add-index "idx_new(column)" D=database,t=large_table

-- 方案 2:MySQL 8.0+ 使用 INPLACE 算法
ALTER TABLE large_table ADD INDEX idx_new (column), ALGORITHM=INPLACE, LOCK=NONE;

-- 方案 3:业务低峰期执行
-- 在凌晨等低峰时段执行索引变更

4. 监控索引健康度

-- 查看索引碎片率
SELECT 
    table_name,
    index_name,
    ROUND((data_length + index_length) / 1024 / 1024, 2) AS total_mb,
    ROUND(data_free / 1024 / 1024, 2) AS free_mb,
    ROUND(data_free / (data_length + index_length) * 100, 2) AS fragmentation_pct
FROM information_schema.tables
WHERE table_schema = 'your_database'
ORDER BY fragmentation_pct DESC;

-- 优化高碎片表(谨慎使用)
OPTIMIZE TABLE large_table;

总结

核心要点

  1. 选择性原则:优先为高选择性列创建索引
  2. 最左前缀:联合索引设计需考虑查询顺序
  3. 覆盖索引:尽可能让查询命中覆盖索引
  4. 避免失效:注意函数、类型转换等导致索引失效的场景
  5. 平衡取舍:在查询性能和写入性能之间找到平衡点

索引设计检查清单

下一步

掌握索引设计后,下一章我们将学习:

参考资料


分享这篇文章到:

上一篇文章
索引失效场景分析
下一篇文章
Go Channel 底层原理