批量测试的数据库索引设计方法详解(AI 大模型评测平台的高并发查询优化)

围着查询路径设计索引,比对着字段猛加索引有效得多。用户提交一个 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 全表扫,强制 hint USE 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 才真正解决问题。如果重来一次,我会按下面的顺序重新设计:

  1. 先梳理全部查询路径,按 WHERE 条件分组,找出高频场景;
  2. 针对每个高频场景设计复合索引,让索引列顺序对齐查询列顺序;
  3. 覆盖索引兜底,把 SELECT 的列全部收进索引避免回表;
  4. 数据量大到单表查询变慢时再按月分区,配合索引裁剪。

这个顺序能省下大半返工时间。

常见问题(FAQ)

Q1:为什么不用 UUID 做主键?

UUID 16 字节,B+ 树分裂严重,主键索引空间翻倍。BIGINT AUTO_INCREMENT 是雪糕型顺序写,对 InnoDB 最友好。

Q2:复合索引列顺序怎么定?

高频查询的 WHERE 条件放最左,ORDER BY 字段放最后(避免 filesort)。

Q3:表数据量大了就一定要分区吗?

1000 万行考虑分区,> 1 亿行强烈建议。同时要按时间分(按业务维度分不利于历史清理)。

版权声明:本文内容由互联网用户自发贡献,该文观点仅代表作者本人。本站仅提供信息存储空间服务,不拥有所有权,不承担相关法律责任。如发现本站有涉嫌抄袭侵权/违法违规的内容, 请发送邮件至 qiqicto@qq.com 举报,一经查实,本站将立刻删除。
赞 (0)
其AI的头像其AI普通用户

相关推荐

返回顶部