索引优化
那次数据库崩溃
还记得我第一次实现的订单超时检查吗?
# 😱 问题代码
def check_order_timeout():
timeout_threshold = datetime.now() - timedelta(minutes=30)
pending_orders = Order.objects.filter(
status='pending_payment',
created_at__lt=timeout_threshold
)
for order in pending_orders:
cancel_order(order)2024 年 3 月的一天,订单量达到 80 万单。早上 10 点,运维群里突然炸锅:
“数据库 CPU 100%!” “所有查询都卡住了!” “用户无法下单!”
我慌了,登录数据库一看,那个定时任务的查询正在扫描 80 万条订单记录。
慢查询日志
-- 😱 慢查询:扫描了 80 万行
SELECT * FROM orders
WHERE status = 'pending_payment'
AND created_at < '2024-03-15 09:30:00';
-- 执行计划:
-- type: ALL(全表扫描)
-- rows: 800000
-- Extra: Using where
-- 执行时间:15.3 秒这个查询每分钟都在执行,数据库当然扛不住!
索引基础
什么是索引?
索引就像书的目录,可以快速找到需要的内容,而不必翻遍整本书。
-- 没有索引:全表扫描 😱
SELECT * FROM orders WHERE status = 'pending';
-- 扫描 100 万行,耗时 2 秒
-- 有索引:索引查找 ✅
SELECT * FROM orders WHERE status = 'pending';
-- 扫描 100 行,耗时 0.01 秒索引的类型
| 类型 | 说明 | 适用场景 |
|---|---|---|
| 主键索引 | 唯一标识记录 | 主键字段 |
| 唯一索引 | 值必须唯一 | 手机号、身份证 |
| 普通索引 | 最常用的索引 | 查询条件字段 |
| 联合索引 | 多个字段的组合 | 多条件查询 |
| 全文索引 | 文本搜索 | 搜索功能 |
联合索引设计
最关键的问题
-- 延时任务表
CREATE TABLE delay_tasks (
id BIGINT PRIMARY KEY,
task_id VARCHAR(100),
execute_at TIMESTAMP,
status VARCHAR(20),
...
);
-- 问题:如何创建索引?索引设计原则
原则 1:最左前缀原则
-- ✅ 好的联合索引
CREATE INDEX idx_execute_status ON delay_tasks (execute_at, status);
-- 为什么?
-- 因为我们的查询是:
SELECT * FROM delay_tasks
WHERE execute_at <= NOW()
AND status = 'pending';
-- 这个索引可以完美支持查询
-- ❌ 不好的联合索引
CREATE INDEX idx_status_execute ON delay_tasks (status, execute_at);
-- 为什么?
-- 虽然也能用,但不如上面的好原则 2:索引覆盖查询
-- ✅ 覆盖索引:不需要回表
CREATE INDEX idx_covering ON delay_tasks (execute_at, status, task_id);
-- 查询时:
SELECT task_id FROM delay_tasks
WHERE execute_at <= NOW()
AND status = 'pending';
-- 所有需要的字段都在索引中,不需要回表查询主键
-- ❌ 需要回表
SELECT * FROM delay_tasks
WHERE execute_at <= NOW()
AND status = 'pending';
-- 需要回表查询其他字段,性能较差原则 3:选择性和基数
-- 高选择性:status 有很多不同的值(pending, processing, completed, failed)
-- 适合建立索引
-- 低选择性:is_deleted 只有两个值(0, 1)
-- 单独索引效果不好,需要联合索引实战索引优化
场景 1:订单超时查询
-- 原始表结构
CREATE TABLE orders (
id BIGINT PRIMARY KEY,
user_id BIGINT,
product_id BIGINT,
status VARCHAR(50),
created_at TIMESTAMP,
...
);
-- 原始查询(慢)
SELECT * FROM orders
WHERE status = 'pending_payment'
AND created_at < DATE_SUB(NOW(), INTERVAL 30 MINUTE);
-- 😱 执行计划:全表扫描
-- rows: 800000
-- time: 15.3s
-- ✅ 优化 1:添加联合索引
CREATE INDEX idx_status_created ON orders (status, created_at);
-- ✅ 执行计划:索引查找
-- rows: 1200
-- time: 0.05s
-- ✅ 优化 2:添加覆盖索引(如果只需要 ID)
CREATE INDEX idx_covering ON orders (status, created_at, id);
SELECT id FROM orders
WHERE status = 'pending_payment'
AND created_at < DATE_SUB(NOW(), INTERVAL 30 MINUTE);
-- ✅ 执行计划:索引覆盖
-- rows: 1200
-- time: 0.02s(不需要回表)场景 2:延时任务查询
-- 延时任务表
CREATE TABLE delay_tasks (
id BIGINT PRIMARY KEY AUTO_INCREMENT,
task_id VARCHAR(100) UNIQUE,
task_type VARCHAR(50),
task_data TEXT,
execute_at TIMESTAMP,
status ENUM('pending', 'processing', 'completed', 'failed'),
retry_count INT,
created_at TIMESTAMP,
updated_at TIMESTAMP,
...
);
-- 核心查询
SELECT * FROM delay_tasks
WHERE execute_at <= NOW()
AND status = 'pending'
ORDER BY execute_at ASC
LIMIT 100;
-- ✅ 最佳索引设计
CREATE INDEX idx_execute_status ON delay_tasks (execute_at, status);
-- 额外的查询 1:按状态统计
SELECT status, COUNT(*) FROM delay_tasks GROUP BY status;
-- ✅ 添加状态索引
CREATE INDEX idx_status ON delay_tasks (status);
-- 额外的查询 2:监控任务积压
SELECT COUNT(*) FROM delay_tasks
WHERE created_at >= DATE_SUB(NOW(), INTERVAL 1 HOUR);
-- ✅ 添加创建时间索引
CREATE INDEX idx_created_at ON delay_tasks (created_at);索引维护
定期分析索引
-- 查看索引使用情况
SELECT
TABLE_NAME,
INDEX_NAME,
CARDINALITY,
SEQ_IN_INDEX
FROM information_schema.STATISTICS
WHERE TABLE_SCHEMA = 'your_database'
AND TABLE_NAME = 'delay_tasks'
ORDER BY TABLE_NAME, INDEX_NAME, SEQ_IN_INDEX;
-- 查看索引大小
SELECT
INDEX_NAME,
ROUND(STAT_VALUE * @@innodb_page_size / 1024 / 1024, 2) AS size_mb
FROM mysql.innodb_index_stats
WHERE DATABASE_NAME = 'your_database'
AND TABLE_NAME = 'delay_tasks'
AND STAT_NAME = 'size'
AND STAT_TYPE = 'Leaf-node-size'
ORDER BY size_mb DESC;删除无用索引
-- 查看未使用的索引
SELECT
object_schema,
object_name,
index_name
FROM performance_schema.table_io_waits_summary_by_index_usage
WHERE index_name IS NOT NULL
AND count_star = 0
AND object_schema = 'your_database'
ORDER BY object_schema, object_name;
-- ⚠️ 谨慎删除:先确认索引确实不需要
-- DROP INDEX idx_unused ON delay_tasks;重建碎片化索引
-- 查看表碎片
SELECT
TABLE_NAME,
ENGINE,
TABLE_ROWS,
DATA_LENGTH / 1024 / 1024 AS data_mb,
INDEX_LENGTH / 1024 / 1024 AS index_mb,
DATA_FREE / 1024 / 1024 AS data_free_mb,
ROUND(DATA_FREE / (DATA_LENGTH + INDEX_LENGTH) * 100, 2) AS fragmentation_pct
FROM information_schema.TABLES
WHERE TABLE_SCHEMA = 'your_database'
AND DATA_FREE > 0
ORDER BY fragmentation_pct DESC;
-- 优化表(重建索引)
OPTIMIZE TABLE delay_tasks;
-- 或者:ALTER TABLE
ALTER TABLE delay_tasks ENGINE=InnoDB;想一想
思考 1
如果查询条件中有 execute_at >= NOW() AND execute_at <= NOW() + INTERVAL 1 HOUR,如何设计索引?
参考答案
问题分析:
这是一个范围查询,查询未来 1 小时内将要执行的任务。
-- 查询语句
SELECT * FROM delay_tasks
WHERE execute_at >= NOW()
AND execute_at <= NOW() + INTERVAL 1 HOUR
AND status = 'pending'
ORDER BY execute_at ASC
LIMIT 100;索引设计:
-- ✅ 方案 1:execute_at + status(推荐)
CREATE INDEX idx_execute_status ON delay_tasks (execute_at, status);
-- 执行计划:
-- Using index idx_execute_status
-- rows: 50
-- Extra: Using where; Using filesort
-- ❌ 方案 2:status + execute_at(不推荐)
CREATE INDEX idx_status_execute ON delay_tasks (status, execute_at);
-- 执行计划:
-- Using index idx_status_execute
-- rows: 1000
-- Extra: Using where; Using filesort为什么方案 1 更好?
最左前缀原则:
方案 1:idx_execute_status (execute_at, status)
├─ 先按 execute_at 过滤(范围查询)
└─ 再按 status 过滤
✅ execute_at 的范围查询可以直接利用索引
方案 2:idx_status_execute (status, execute_at)
├─ 先按 status 过滤(等值查询)
└─ 再按 execute_at 过滤(范围查询)
⚠️ 虽然也能用,但不够高效最佳实践:
-- 对于范围查询,把范围字段放在索引前面
CREATE INDEX idx_range_field_first ON table_name (range_field, other_field);
-- 对于等值查询,把等值字段放在索引前面
CREATE INDEX idx_eq_field_first ON table_name (eq_field, other_field);