围着查询路径设计索引,比对着字段猛加索引有效得多。用户提交一个 200 条的批量任务,前端每翻一页就等 5 秒,慢 SQL 日志里全是 WHERE batch_id = ? 的全表扫描。最开始我以为是 SQL 写得差,逐条 EXPLAIN 之后才发现问题出在索引——单表日增百万行,查询模式又固定,缺的是一套围着查询路径设计的索引。评测平台日增百万行 eval_subtask,没有合适索引的话一条 SELECT ... WHERE batch_id = ? 走全表扫描,5 秒都查不完。平台在 4 张核心表上做了精细索引设计,把批量任务的查询稳定在 50ms 以内。这篇把整个设计思路完整摊开:哪些查询是热点、每张表建了什么索引、为什么用复合索引而不是单列索引、什么时候该上覆盖索引和分区。
一、核心查询场景
不优化索引前,先要识别”哪些查询是热点”。很多团队上来就对着字段一顿加索引,索引建了一堆,慢查询依旧。我们反过来做:先翻一周的慢日志和业务埋点,把查询按频率和耗时排序,再逐条设计索引。下面是平台最核心的 6 个查询:
| 查询 | 频率 | 表 | SQL 形态 |
|---|---|---|---|
| 批次进度 | 每秒数次 | eval_batch | WHERE id = ? |
| 任务列表 | 每秒数次 | eval_task | WHERE batch_id = ? |
| 子任务分页 | 用户每翻页 | eval_subtask | WHERE task_id = ? AND status = ? LIMIT 20 |
| 用户批次列表 | 用户每开页 | eval_batch | WHERE user_id = ? ORDER BY created_at DESC LIMIT 20 |
| 失败任务筛选 | 重试时 | eval_subtask | WHERE batch_id = ? AND status = 'fail' |
| 模型价格查询 | 每次调用 | model_price | WHERE model_code = ? |
围绕这 6 个查询设计索引。
这张表可以当成索引设计的验收清单:后面的每一节索引都能在这张表里找到出处,任何一条查询都能用 EXPLAIN 走索引,就算过关。
二、批次表 eval_batch
批次表是入口表,数据量相对小,索引策略也最直观,只解决两类问题:用户维度倒序列表、状态维度筛选。我们早期给 user_id、created_at 各建了一个单列索引,结果”我的批次列表”卡在 filesort 上,加了 created_at 进复合索引才解决:
CREATE TABLE eval_batch (
id BIGINT PRIMARY KEY,
user_id BIGINT NOT NULL,
name VARCHAR(128),
status VARCHAR(16) NOT NULL,
total_count INT,
done_count INT,
fail_count INT,
created_at DATETIME NOT NULL,
-- 复合索引:用户批次列表
INDEX idx_user_created (user_id, created_at DESC),
-- 单一索引:按状态筛选(如查"运行中"的所有批次)
INDEX idx_status (status, created_at DESC)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
idx_user_created 让”我的批次列表(按时间倒序)”走索引覆盖,避免 filesort。
建完这版索引,批次列表从 1.2 秒降到 30ms 以内。注意 created_at DESC 是 MySQL 8 才支持的降序索引,如果还跑 5.7,这里会退化成反向扫描,选型时要提前确认版本。
三、任务表 eval_task
任务表挂在批次下面,一个批次通常 10 到 50 个任务,每个任务对应一个模型。这里的第一版只建了 idx_batch (batch_id),结果”批次下指定模型的任务”每次都要回表,才补上了 model_code 组成复合索引:
CREATE TABLE eval_task (
id BIGINT PRIMARY KEY,
batch_id BIGINT NOT NULL,
model_code VARCHAR(64) NOT NULL,
status VARCHAR(16),
total_count INT,
done_count INT,
fail_count INT,
created_at DATETIME,
-- 复合索引:批次下任务列表
INDEX idx_batch (batch_id, model_code),
-- 状态统计
INDEX idx_batch_status (batch_id, status)
);
idx_batch 同时支持”批次下所有任务”和”批次下指定模型任务”。
到这里批次和任务两级都稳了,但真正的硬骨头在子任务表,数据量最大、查询组合最多,单独一节说。
四、子任务表 eval_subtask(最关键)
子任务表最大(百万级/天),索引设计最讲究。
这张表的写入量全平台最高,也是容易翻车的一张。我们的做法是”先穷举查询,再定索引”:每个索引都对应一个明确的查询用途,不养闲索引,注释里写清楚给谁用:
CREATE TABLE eval_subtask (
id BIGINT PRIMARY KEY,
task_id BIGINT NOT NULL,
batch_id BIGINT NOT NULL, -- 反范式存 batch_id,避免 JOIN
model_code VARCHAR(64) NOT NULL,
case_id BIGINT,
status VARCHAR(16) NOT NULL,
error_msg VARCHAR(512),
input_tokens INT,
output_tokens INT,
fee DECIMAL(10,6),
duration_ms INT,
retry_count INT DEFAULT 0,
created_at DATETIME NOT NULL,
updated_at DATETIME,
-- 1) 任务维度的子任务分页
INDEX idx_task_status (task_id, status, id),
-- 2) 批次维度的失败任务筛选
INDEX idx_batch_status (batch_id, status, updated_at),
-- 3) 状态机扫表(消费者拉 pending 任务)
INDEX idx_status_created (status, created_at),
-- 4) 用户/批次复合(按模型统计)
INDEX idx_batch_model (batch_id, model_code)
) ENGINE=InnoDB;
关键索引说明
四个索引各管一摊,实测耗时全部在百毫秒内:
| 索引 | 适用查询 | 性能 |
|---|---|---|
idx_task_status |
任务下子任务分页 + 状态筛选 | < 10ms |
idx_batch_status |
批次下筛选失败/重试任务 | < 50ms |
idx_status_created |
消费者扫 pending 任务 | < 100ms |
idx_batch_model |
批次按模型统计成本/成功率 | < 80ms |
这里想重点说 idx_status_created:它专门给异步消费者扫 pending 用。之前用 WHERE status = 'pending' LIMIT 100,没有索引时一次全表扫 800ms,三个消费者一起跑直接把 IO 打满。加上这个索引后扫表稳定在百毫秒内,消费者拉取不再互相拖累。
复合索引的最左前缀原则
idx_task_status (task_id, status, id):
WHERE task_id = ?✅ 用索引WHERE task_id = ? AND status = ?✅ 用索引WHERE task_id = ? AND status = ? ORDER BY id✅ 用索引且免排序WHERE status = ?❌ 不走索引(最左前缀缺失)
最左前缀是复合索引最容易踩的坑,团队里新手写过单独 WHERE status = ? 的查询,直接全表扫。后来我们在规范里写明:复合索引列的顺序必须按”等值条件 + 排序字段”排,缺了最左列就等于白建。
五、为什么建复合索引而不是多个单列索引
反例:每个字段单独建索引。
INDEX idx_task (task_id),
INDEX idx_status (status),
INDEX idx_created (created_at)
这种”看似周到”的设计在生产上会出问题:
- 优化器可能选错索引(基数低的列导致扫全表);
- 每次 INSERT 要维护 3 个 B+ 树,写入性能下降 30%;
- 多个单列索引不能合并,复杂查询反而更慢。
复合索引按”查询频率从高到低”组织列顺序,命中一条索引走完整个查询路径。
这段是用血的教训换来的。v1.1 版本为了”每个查询都有索引”,一口气建了 12 个单列索引,结果批量导入 10 万条数据的时间直接翻倍——写入要同步维护 12 棵 B+ 树。后来全部收敛成复合索引,写入性能恢复,查询也没变慢。
六、覆盖索引(Covering Index)
高频统计查询用覆盖索引减少回表:
-- 查"批次下每个模型的子任务数"
SELECT model_code, COUNT(*)
FROM eval_subtask
WHERE batch_id = ?
GROUP BY model_code;
普通索引 idx_batch_model 走完需要回表(查 count),改成覆盖索引:
INDEX idx_batch_model_cover (batch_id, model_code, id)
SELECT COUNT(id) 走索引就能拿 count,零回表。
这个统计在批次详情页每次打开都要跑,原来回表 40ms,改成覆盖索引后 8ms。覆盖索引的代价只是多占一点索引空间,换回来的查询速度很值,值得优先用在”高频统计”这类查询上。
七、分区表
子任务表 6 个月 1 亿行,单表查询慢。平台用按月分区:
CREATE TABLE eval_subtask (
id BIGINT NOT NULL,
...
created_at DATETIME NOT NULL,
PRIMARY KEY (id, created_at)
) ENGINE=InnoDB
PARTITION BY RANGE (YEAR(created_at) * 100 + MONTH(created_at)) (
PARTITION p202606 VALUES LESS THAN (202607),
PARTITION p202607 VALUES LESS THAN (202608),
PARTITION p202608 VALUES LESS THAN (202609),
PARTITION pmax VALUES LESS THAN MAXVALUE
);
好处:
- 查”8 月的子任务”只扫
p202608一个分区; - 删 6 个月前数据
DROP PARTITION p202602秒级完成; - 索引也按分区建,体积更小。
分区是在数据量真的上来之后才做的。我们当时在”归档到 ClickHouse”和”按月分区”之间对比过,最终先选分区,因为它改造成本低、对现有查询透明。注意分区键必须包含在主键里,所以主键从 id 改成了 (id, created_at),这一步最容易漏。
八、慢 SQL 监控
平台用 Druid + Prometheus 监控所有慢 SQL:
@Configuration
public class DruidConfig {
@Bean
public DruidInterceptor slowSqlInterceptor() {
return new DruidInterceptor() {
@Override
public void afterService(Statement st) {
long cost = System.currentTimeMillis() - startTime;
if (cost > 200) {
metrics.counter("sql.slow",
"table", extractTable(st.getSql())
).increment();
}
}
};
}
}
Grafana 看板显示 Top 10 慢 SQL,自动告警。
监控是索引优化的闭环。没有监控的时候,索引失效要等用户投诉才知道;上了这个埋点之后,任何超过 200ms 的 SQL 都会自动进看板,我们每周按 Top 10 逐条处理,大部分都是新查询没吃到索引。
九、踩过的坑
- 索引失效 1:函数操作:
WHERE DATE(created_at) = '2026-08-20'不走索引,改成范围WHERE created_at >= '2026-08-20' AND created_at < '2026-08-21'。 - 索引失效 2:隐式转换:
batch_id是 BIGINT,但 SQL 写WHERE batch_id = '123'(字符串)能走但性能差,类型一致才对。 - 索引失效 3:OR 连接:
WHERE task_id = ? OR model_code = ?不走复合索引,拆成 UNION。 - 过度索引:早期给 12 个查询路径各建一个索引,写入性能下降 50%。删到 6 个核心索引后写入性能恢复。
- 索引选错:某次查
WHERE batch_id = ? AND status = ?走idx_status_created全表扫,强制 hintUSE INDEX(idx_batch_status)解决,但更优是调整索引顺序。
这五条几乎是新手团队的必经之路。函数操作和隐式转换适合通过 code review 和 SQL 规范从源头拦截,OR 连接和索引选错要靠对优化器行为的理解。我们的经验是:索引设计不是一锤子买卖,每次迭代都要跟着查询演进。
十、平台索引演进时间线
| 时间 | 优化 | 效果 |
|---|---|---|
| v1.0 | 仅有主键索引 | 子任务分页 5s+ |
| v1.1 | 加 6 个单列索引 | 写入下降 30% |
| v1.2 | 改复合索引 + 覆盖索引 | 查询 50ms,写入恢复 |
| v1.3 | 按月分区 + 索引裁剪 | 6 月数据查询 100ms |
| v2.0 | 归档到 ClickHouse | MySQL 只保留 3 个月 |
这条时间线基本还原了平台索引从粗放到精细的全过程。回头看,v1.0 到 v1.1 的教训是”有索引不等于有对的索引”,v1.2 才真正解决问题。如果重来一次,我会按下面的顺序重新设计:
- 先梳理全部查询路径,按 WHERE 条件分组,找出高频场景;
- 针对每个高频场景设计复合索引,让索引列顺序对齐查询列顺序;
- 覆盖索引兜底,把 SELECT 的列全部收进索引避免回表;
- 数据量大到单表查询变慢时再按月分区,配合索引裁剪。
这个顺序能省下大半返工时间。
常见问题(FAQ)
Q1:为什么不用 UUID 做主键?
UUID 16 字节,B+ 树分裂严重,主键索引空间翻倍。BIGINT AUTO_INCREMENT 是雪糕型顺序写,对 InnoDB 最友好。
Q2:复合索引列顺序怎么定?
高频查询的 WHERE 条件放最左,ORDER BY 字段放最后(避免 filesort)。
Q3:表数据量大了就一定要分区吗?
1000 万行考虑分区,> 1 亿行强烈建议。同时要按时间分(按业务维度分不利于历史清理)。