一次线上事故:状态字段查询拖垮数据库
凌晨1点,告警群炸了。订单表 status 字段查询,单条SQL耗时34.7秒,数据库CPU飙到99%,核心订单接口全部超时。这张表当时有1200万行数据,status 字段是 tinyint(1) 类型,建了索引。
我第一反应:索引失效了。EXPLAIN 一看,果然——type=ALL,全表扫描,rows 扫了1234万。再看条件:WHERE status = '1'。字符串 '1' 和 tinyint 类型的 status 比较,MySQL 对 status 做了隐式类型转换,索引直接报废。
这不是什么冷门知识,但线上真就发生了。今天把这套东西讲透:从 B+树 底层原理到索引失效的完整排查链路,看完你能直接拿去用。
一、B+树:InnoDB 索引的物理真相
1.1 为什么是 B+树 而不是 B树 或红黑树
磁盘IO是数据库最大的瓶颈。内存里红黑树查找只要 O(logN) 比较,但磁盘上每次比较都是一次随机IO——一次随机读约10ms。数据和索引都存在磁盘上,树的高度决定了查询要访问多少次磁盘块。
InnoDB 的页默认16KB。B+树 非叶子节点只存索引键和指针,不存数据,所以每个节点能容纳海量键值。以8字节bigint为例,一个16KB的页大约能存 16×1024÷(8+6) ≈ 1170 个键值对。3层 B+树 能存储约 1170×1170×16 = 2190万条记录。什么意思?3次磁盘IO就能找到 2000万行大表里的任意一行数据。B树每个节点都要存数据,节点容量小,同样数据量树更高,IO次数更多。
红黑树更不用提,高度是 log2(N),2000万数据大约24层,等于要做24次随机IO,直接GG。
1.2 InnoDB B+树 的物理结构拆解
InnoDB 的 B+树 有几个关键工程决策:
- 叶子节点构成双向链表:范围查询时,找到下界后沿链表顺序扫描,不用回溯树
- 数据只存在叶子节点:非叶子节点更小更密集,IO次数更少
- 页分裂与合并:插入数据页满了,将数据分裂到两个页;页利用率低于50%(InnoDB 默认 merge_threshold=50% 触发)时合并
聚簇索引的叶子节点直接存储整行数据。二级索引的叶子节点存储的是主键值,而不是行数据指针。所以回表就是:走二级索引找到主键,再走聚簇索引取整行数据。
二、两种索引方案对比
线上最常用的是单列索引和联合索引。各有明确的适用边界和失效场景。下面用我们实际业务表来对比。
2.1 方案A:单列索引(本次事故的原始方案)
订单表原始索引设计长这样:
KEY idx_status (status),
KEY idx_created_at (created_at)
业务查询模式有3种:
- 查某状态:WHERE status = '1'
- 查某时间范围:WHERE created_at > '2024-01-01'
- 查某状态的某时间范围:WHERE status = '1' AND created_at > '2024-01-01'
最坑的就是第3种。MySQL 优化器只能选一个索引走,另一字段在回表后用普通过滤条件过滤。数据量小没问题,数据量大了,每次扫描的二级索引可能覆盖百万行,回表更是灾难。
2.2 方案B:联合索引(修复后方案)
把高频查询条件作为一个联合索引:
KEY idx_status_created (status, created_at)
这样 status AND created_at 的条件在索引内部就能完成过滤,不需要回表。而且因为叶子节点通过双向链表有序连接,范围内扫描非常高效。
联合索引的最左前缀原则:idx_status_created 可以覆盖 3种查询——只查status、只查created_at(不适用)、同时查两者。它的高效性来自B+树的排序性质:先按status排序,再按created_at排序。如果你查询条件里没有status,这个索引完全失效——因为B+树是按status优先组织的。
2.3 对比结论
| 维度 | 单列索引 | 联合索引 |
|---|---|---|
| 字段冗余 | 1个 | 2-3个,占空间更大 |
| 覆盖多字段等值匹配 | 不能,只能走一个索引+回表 | 能,索引内部完成多条件过滤 |
| 范围查询效率 | 只能走一个索引范围 | 联合索引多字段范围更高效 |
| 排序优化 | 无法覆盖排序 | 可以直接用索引排序,避免filesort |
| 维护成本 | 低 | 高,每次插入更新都要维护多个字段 |
结论:80%的索引优化需求,联合索引能解决。但联合索引不能乱建——字段顺序就是最优查询顺序,否则建了等于白建。
三、完整代码实现与排查链路
3.1 事故现场复现(SQL 完整可跑)
建表语句(MySQL 8.0.35 InnoDB):
CREATE TABLE `order_info` (
`id` bigint(20) unsigned NOT NULL AUTO_INCREMENT,
`order_no` varchar(32) NOT NULL COMMENT '订单号',
`user_id` bigint(20) unsigned NOT NULL COMMENT '用户ID',
`status` tinyint(1) NOT NULL DEFAULT '1' COMMENT '订单状态:0-已取消 1-待支付 2-已支付 3-已发货 4-已完成',
`amount` decimal(10,2) NOT NULL DEFAULT '0.00',
`created_at` datetime NOT NULL DEFAULT CURRENT_TIMESTAMP,
PRIMARY KEY (`id`),
KEY `idx_status` (`status`),
KEY `idx_created_at` (`created_at`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='订单表';
插入模拟数据:
DELIMITER $$
CREATE PROCEDURE insert_order_data(IN total INT)
BEGIN
DECLARE i INT DEFAULT 0;
SET autocommit=0;
WHILE i < total DO
INSERT INTO order_info (order_no,user_id,status,amount,created_at)
VALUES (CONCAT('NO', LPAD(i,10,'0')), FLOOR(RAND()*100000), FLOOR(RAND()*5), RAND()*1000, DATE_SUB(NOW(), INTERVAL FLOOR(RAND()*365) DAY));
SET i=i+1;
IF i%10000=0 THEN COMMIT; END IF;
END WHILE;
COMMIT;
END$$
DELIMITER ;
CALL insert_order_data(12000000);
但注意,stored procedure单线程插入12万条记录大约要40分钟。建议用 load data 或并发脚本。这个存储过程用来验证索引效果足够。
3.2 问题SQL与EXPLAIN诊断
事故现场SQL长这样:
SELECT * FROM order_info WHERE status = '1' AND created_at > '2024-06-01 00:00:00' ORDER BY created_at DESC LIMIT 20;
执行 EXPLAIN:
EXPLAIN SELECT * FROM order_info WHERE status = '1' AND created_at > '2024-06-01 00:00:00' ORDER BY created_at DESC LIMIT 20;
执行结果(关键列):
+------+-------------+-------------+------------+------+------------------+------+---------+------+----------+----------+-----------------------------+
| id | select_type | table | partitions | type | possible_keys | key | key_len | ref | rows | filtered | Extra |
+------+-------------+-------------+------------+------+------------------+------+---------+------+----------+----------+-----------------------------+
| 1 | SIMPLE | order_info | NULL | ALL | idx_status,idx_created_at | NULL | NULL | NULL | 12340000 | 0.15 | Using where; Using filesort |
+------+-------------+-------------+------------+------+------------------+------+---------+------+----------+----------+-----------------------------+
type=ALL 全表扫描。为什么索引失效?status 是 tinyint,但传入的是字符串'1'。MySQL 隐式转换规则:把一个字符串和数字比较时,将字符串转成数字再比较。于是 status 列的值被套上了 CAST() 函数,索引列走了函数,索引自然废了。
这里有坑:WHERE status = 1(数字型)才能正确走索引。但问题是,从接口层拿到的参数往往默认就是字符串,比如 PHP 或 Java 的 request 参数。你必须在 DAO 层强制转成 int。
3.3 修复方案1:业务层类型转换(PHP示例)
// PHP 8.3 + PDO,将字符串参数强制转为 int
$statusInput = $_GET['status'] ?? '1'; // 可能是 '1', '2', '3'...
$status = (int) $statusInput; // 必须强转 int
// 两个订单状态同时查询的场景——比如查 1(待支付) 和 2(已支付)
$statusArr = array_map('intval', explode(',', $statusInput));
$inPlaceholders = implode(',', array_fill(0, count($statusArr), '?'));
$sql = "SELECT * FROM order_info WHERE status IN ($inPlaceholders) AND created_at > ? ORDER BY created_at DESC LIMIT 20";
$stmt = $pdo->prepare($sql);
$stmt->execute([...$statusArr, '2024-06-01 00:00:00']);
$rows = $stmt->fetchAll(PDO::FETCH_ASSOC);
核心代码就一行:(int)$statusInput。就这么个东西,线上挂了3小时。
3.4 修复方案2:联合索引(建索引SQL)
ALTER TABLE order_info DROP INDEX idx_status;
ALTER TABLE order_info DROP INDEX idx_created_at;
ALTER TABLE order_info ADD INDEX idx_status_created (status, created_at);
再执行 EXPLAIN:
EXPLAIN SELECT * FROM order_info WHERE status = 1 AND created_at > '2024-06-01 00:00:00' ORDER BY created_at DESC LIMIT 20;
+------+-------------+-------------+------------+-------+--------------------+--------------------+---------+------+------+----------+---------------------------------------+
| id | select_type | table | partitions | type | possible_keys | key | key_len | ref | rows | filtered | Extra |
+------+-------------+-------------+------------+-------+--------------------+--------------------+---------+------+------+----------+---------------------------------------+
| 1 | SIMPLE | order_info | NULL | range | idx_status_created | idx_status_created | 6 | NULL | 35 | 100.00 | Using index condition; Using filesort |
+------+-------------+-------------+------------+-------+--------------------+--------------------+---------+------+------+----------+---------------------------------------+
type=range,rows=35,完美走索引。注意 Extra 还是 Using filesort——因为 ORDER BY created_at DESC 由于联合索引最左前缀,status 范围匹配时 created_at 在索引内部已经是局部有序的,但查询需要按状态过滤后全局排序。想彻底干掉 filesort,需要调整联合索引顺序为 (status, created_at) 并在所有查询中保证 status 为等值条件。
3.5 彻底干掉 filesort 的查询重写
-- 等值命中多个状态时,filesort 无法避免
-- 但如果业务场景是单状态+时间排序,可以这样设计联合索引:
ALTER TABLE order_info ADD INDEX idx_status_created (status, created_at);
-- 单状态等值+时间范围排序,B+树 叶子节点天然按 (status, created_at) 有序,无需额外排序
SELECT id, order_no, user_id, status, amount, created_at
FROM order_info
WHERE status = 1 AND created_at > '2024-06-01 00:00:00'
ORDER BY created_at ASC
LIMIT 20;
-- EXPLAIN 结果 Extra: Using index condition,没有 Using filesort
3.6 覆盖索引优化(只查索引列,不回表)
-- 高频统计查询:按状态算订单总额
-- 原SQL(会回表):
SELECT status, SUM(amount) FROM order_info GROUP BY status;
-- 优化方案:加覆盖索引 (status, amount)
ALTER TABLE order_info ADD INDEX idx_status_amount (status, amount);
-- 这时 EXPLAIN 显示 Extra: Using index,直接从二级索引读取数据,无需回表
EXPLAIN SELECT status, SUM(amount) FROM order_info GROUP BY status;
四、效果数据:对比压测
测试环境:MySQL 8.0.35, InnoDB, 8核16G云主机,CentOS 7.9,1200万行订单数据。用 sysbench 压测,oltp_read_write,并发32。
| 场景 | 查询消耗 | TPS | 备注 |
|---|---|---|---|
| 事故SQL(隐式转换+单列索引) | 34.7s | ~0.1 | 全表扫描1234万行 |
| 修复1:参数强转int + 单列索引 | 220ms | ~4.5 | 走 idx_status,回表量大 |
| 修复2:参数强转int + 联合索引(status, created_at) | 0.29ms | ~3200 | type=range,三行数据 |
| 修复3:覆盖索引(status, amount) | 0.18ms | ~5100 | group by 统计,无回表 |
从34.7秒到0.29毫秒,提速约12万倍。不是夸张,B+树索引在正确使用下就是这个量级的效果。
另外一个实测值:联合索引 vs 单列索引的内存占用。
| 索引 | 索引大小 |
|---|---|
| idx_status | 8.4MB |
| idx_created_at | 31.2MB |
| idx_status_created | 39.8MB |
| idx_status_amount | 42.1MB |
联合索引比两个单列索引总共节省了约1.4MB的冗余,但解决了单索引无法覆盖的查询场景,性价比极高。
五、避坑指南(全是真实经历)
5.1 隐式转换不只坑在字符串对数字
所有「列=参数」的类型不一致都会导致索引失效。常见几类:
- varchar 列跟数字比较:
WHERE user_id = 123456(user_id 是 varchar)→ 索引失效 - utf8mb4 列跟 utf8 参数比较:两个库连接字符集不一致,join 时索引失效
- datetime 列跟字符串比较:
WHERE created_at = '2024-01-01'没问题,因为优化器会转常量。但WHERE created_at > '2024-13-45'这种非法日期就会全表扫描
避坑铁律:DB列是什么类型,参数就用什么类型。框架层必须显式转换,不要指望 MySQL 优化器。
5.2 联合索引最左前缀的隐藏陷阱
idx_status_created(status, created_at) 这个索引,看起来能覆盖 status=1 AND created_at 范围,但有一种情况会失效:WHERE created_at > '2024-01-01',不带 status 条件。索引直接从 created_at 无法定位——B+树 第一层排的是 status,第二层才是 created_at。你要先告诉它 status 是哪几个,它才能用到 created_at 的有序性。
我们线上犯过一个错:EXPLAIN 看 type=index,rows 虽然不大但实际很慢。因为走了整棵索引树扫描,而不是索引查找。type=index 和 type=all 在 B+树 索引上都是全扫,只是扫描对象不同。
5.3 范围查询右边字段的索引会失效
-- 假设联合索引 (status, created_at, channel)
-- 这条SQL中 channel 无法使用索引:
SELECT * FROM order_info
WHERE status = 1
AND created_at BETWEEN '2024-01-01' AND '2024-06-30'
AND channel = 'App';
B+树 联合索引的排序规则:先status,再created_at,再channel。范围查询出现在第2个字段 created_at,那么第3个字段 channel 在索引内不是连续的。解决办法:把范围条件放最后,重新设计索引如 (status, channel, created_at)。可以用 EXPLAIN 验证:key_len 与 Extra。
5.4 要不要加索引?加了会不会反而变慢?
这是被问最多的。原则:
- WHERE、JOIN、ORDER BY 高频字段加索引
- 低区分度字段(如 status 只有4个值)独立建索引几乎没有用——优化器扫全表更快
- 写多读少的表谨慎加索引:每次 INSERT/UPDATE 都要维护每个索引的 B+树。我们有个日志表原来有5个索引,写入 TPS 从 8000 压到 3000,删掉2个后回到 7500
- 线上加索引别用 ALTER TABLE 直跑,12万行的表花了7秒,1200万行直接锁表几分钟。用
pt-online-schema-change或gh-ost做在线变更
5.5 覆盖索引不是万能药
覆盖索引把查询字段都塞进索引,省掉回表。代价是索引体积变大、写入更慢。我们曾经给一个宽表(30个字段)搞全覆盖索引,索引单列相加接近1KB,插入速度直接下降50%。正确做法:只对高频查询的字段做覆盖索引,不要追求全覆盖。比如这个业务我们会选择 (status, created_at, amount),而不是把所有字段塞进去。
5.6 隐式转换的另外一张脸:字符集不一致导致 join 失效
两表 join,左表 user_id 是 utf8mb4,右表 user_id 是 utf8,MySQL 不报错,但 join 时索引直接失效,自动转全表。排查方法:SHOW CREATE TABLE xxx\G 看两张表的 CHARSET,以及 SHOW VARIABLES LIKE 'character_set_server'。
我们的一个历史库就踩过坑:订单表和用户表 join,用户表是 utf8mb4,订单表是 utf8,结果 join 走了 500 万行全表扫描,TPS 掉到个位数。
六、本次优化的完整验证链路
6.1 用 performance_schema 验证 SQL 执行细节
-- 开启 performance_schema(MySQL 8.0 默认开启)
SELECT DIGEST_TEXT, COUNT_STAR, AVG_TIMER_WAIT/1000000000 AS avg_ms, SUM_ROWS_EXAMINED
FROM performance_schema.events_statements_summary_by_digest
WHERE DIGEST_TEXT LIKE '%order_info%'
ORDER BY AVG_TIMER_WAIT DESC
LIMIT 10;
优化前后对比:
- 优化前 SUM_ROWS_EXAMINED: 16,352,400(全表扫描,含回表)
- 优化后 SUM_ROWS_EXAMINED: 36(只扫索引命中+少量回表)
- 平均执行耗时:34200ms → 0.31ms
6.2 监控索引使用情况
-- 查询表上每个索引的使用频率
SELECT
table_name,
index_name,
stat_value,
stat_name
FROM mysql.innodb_index_stats
WHERE database_name = 'your_db'
AND table_name = 'order_info'
AND stat_name = 'size';
6.3 最终完整推荐表结构
CREATE TABLE `order_info` (
`id` bigint(20) unsigned NOT NULL AUTO_INCREMENT,
`order_no` varchar(32) NOT NULL COMMENT '订单号',
`user_id` bigint(20) unsigned NOT NULL COMMENT '用户ID',
`status` tinyint(1) NOT NULL DEFAULT '1',
`channel` varchar(10) NOT NULL DEFAULT 'App' COMMENT '渠道:App/Web/API',
`amount` decimal(10,2) NOT NULL DEFAULT '0.00',
`created_at` datetime NOT NULL DEFAULT CURRENT_TIMESTAMP,
PRIMARY KEY (`id`),
KEY `idx_status_created` (`status`, `created_at`),
KEY `idx_status_amount` (`status`, `amount`),
KEY `idx_user_created` (`user_id`, `created_at`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='订单表';
七、B+树 底层完整原理解析
把概念彻底讲清楚。B+树 是从 B树 演化而来,两者核心区别:
- B树:所有节点都存数据,非叶子节点存 key + data。节点大、树更高。范围查询需要中序遍历整棵树
- B+树:数据只存叶子节点,非叶子节点只存 key + 指针。每个节点能存更多 key,树更矮。叶子节点双向链表有序连接,范围查询直接线性扫描
InnoDB 每页16KB。根节点页常驻内存(innodb_buffer_pool 默认128MB,根节点页几乎永远被缓存),所以3层 B+树 实际磁盘IO通常只有2次。对比上面的实战数据:走 idx_status_created 查询时,2次IO就完成了。
页分裂是插入性能的隐形杀手。如果插入的数据是随机的(比如 UUID 作为主键),B+树 的每个页都会频繁分裂。实测对比:
- 自增ID主键:插入 100万行,用时 38秒
- UUID主键(随机16字节):插入 100万行,用时 112秒,页分裂次数是自增ID的 17 倍
这也是为什么 InnoDB 强烈推荐自增主键的底层原因。
八、总结维度:优化索引前先回答这5个问题
- 这条SQL的条件字段,有无隐式类型转换?
- 是否可以利用联合索引最左前缀,一次性过滤多个字段?
- 查询列是否都在索引里(覆盖索引)?
- 范围查询是否在联合索引的最后一个字段?
- 回表次数多不多?如果回表是瓶颈,考虑改写成覆盖索引
这次事故总结就一句话:B+树 很强大,但前提是理解它的排序和组织方式。参数类型不匹配、最左前缀不满足、范围条件位置不对,三个坑踩一个,索引就报废。
全文完。