在业务系统中,状态字段往往取值稀疏,例如订单的status只有pending、paid、shipped、done几种,而运营后台频繁统计pending订单量。如果直接对全表建普通索引或扫表计数,随着数据量膨胀响应越来越慢。通过部分索引思路,只索引感兴趣的状态行,能大幅缩减索引体积并加快统计。

为什么普通索引在状态统计上不够用
假设有一张订单表orders,包含id、user_id、status、created_at等字段,status用枚举值表示。当执行SELECT COUNT(*) FROM orders WHERE status = 'pending'时,如果在status上建了普通二级索引,MySQL确实会走索引,但索引里包含了所有状态的记录。对于取值高度重复的字段,B+树索引的选择性很低,优化器有时甚至认为扫表比走索引更划算。
更关键的是,普通索引把paid、shipped、done这些不需要统计的状态也一并维护,既浪费存储又在统计时增加了叶子节点扫描量。部分索引的核心思想是:既然只关心pending,那就只给pending建索引,其余行根本不进索引结构。
MySQL原生对部分索引的支持情况
很多PostgreSQL用户熟悉CREATE INDEX ... WHERE status = 'pending'这种带谓词的部分索引,但MySQL在八点零版本之前并没有该语法。MySQL八点零引入了不可见索引、函数索引,但仍不支持在CREATE INDEX里写WHERE条件。因此我们需要用生成列(generated column)来变相实现。
生成列是表定义中的一个虚拟列或存储列,其值由表达式计算而来。我们可以定义一个列is_pending,当status为pending时存1,否则存NULL,然后在这个列上建普通索引。由于NULL值不计入B+树索引,等效于只索引了pending行。
表结构改造示例
下面给出完整的建表与索引语句,使用存储生成列保证写入时计算一次,避免每次查询计算:
CREATE TABLE orders ( id BIGINT PRIMARY KEY AUTO_INCREMENT, user_id BIGINT NOT NULL, status VARCHAR(20) NOT NULL, created_at DATETIME NOT NULL, is_pending TINYINT AS (IF(status = 'pending', 1, NULL)) STORED, KEY idx_pending (is_pending) ) ENGINE=InnoDB;
上述DDL中,is_pending是STORED生成列,写入时持久化。当status不是pending时,它的值为NULL,InnoDB不会把NULL放进idx_pending索引,于是该索引物理上只保存pending订单。统计语句改写为:
SELECT COUNT(*) FROM orders WHERE is_pending = 1;
这条查询直接命中idx_pending,且索引树极小,统计速度比扫全表或普通status索引快一个数量级。在千万级数据、pending占比不到百分之一的场景下,耗时从两秒降到十毫秒内。
与覆盖索引方案的对比
另一种常见做法是建(status, id)联合索引,让统计走覆盖索引不回表。这种方式比扫表好,但索引仍包含所有状态。我们用一张表对比两者差异:
| 方案 | 索引体积 | 统计pending速度 | 写入开销 | 适用场景 |
|---|---|---|---|---|
| 普通status索引 | 大(全状态) | 中 | 低 | 多种状态都需查询 |
| 生成列部分索引 | 小(仅pending) | 极快 | 略高(算列) | 只频繁统计某状态 |
| 联合覆盖索引 | 大 | 较快 | 低 | 需按状态排序分页 |
从表中可见,若业务只盯着一个冷门状态做实时大盘,生成列部分索引最合适。如果还需按status翻页或统计多状态,联合索引更通用。生成列写入时多一次条件判断,但对现代CPU几乎无感。
注意事项与避坑
使用生成列部分索引时,不要对生成列建唯一索引,因为NULL不计入唯一约束,可能导致误用。另外,如果status字段会改值,比如pending转paid,更新行时is_pending会自动重算,索引自动维护,这一点比手动维护汇总表省心。
还需注意,MySQL优化器认生成列上的索引,但查询条件必须写is_pending = 1而非status = 'pending',否则不会走部分索引。可以在应用层封装统计函数,避免写错条件。
用事件调度定时修正的替代方案
若表已存在且数据量极大,线上加STORED生成列会锁表重算,可用pt-online-schema-change类工具无损变更。如果不想改表结构,也可以建一张pending_counter汇总表,由触发器或异步任务更新。但相比部分索引,汇总表有延迟且代码复杂。
下面给出一个触发器维护汇总表的粗略示例,仅作对比思路,不推荐高频写场景使用:
CREATE TABLE pending_counter (cnt BIGINT DEFAULT 0);
INSERT INTO pending_counter VALUES (0);
DELIMITER //
CREATE TRIGGER after_order_insert
AFTER INSERT ON orders
FOR EACH ROW
BEGIN
IF NEW.status = 'pending' THEN
UPDATE pending_counter SET cnt = cnt + 1;
END IF;
END;//
DELIMITER ;
触发器方案在超高并发插入时易成瓶颈,而部分索引跟随主表事务,一致性更好。综合来看,MySQL通过生成列实现部分索引,是加速状态字段统计查询的务实选择。