第一版把用户、批次、任务、子任务这些核心数据全丢进 MySQL 8.0,当时对表设计的全部要求就是”能存就行”。等跑到几十万条子任务记录、慢查询开始冒头,我才意识到表设计不是写完建表语句就完事。平台每天要跑上千个评测批次,每个批次拆成 5 个模型 × 几百条用例的子任务,读写比例大约 9:1,查询热点集中在”按批次看进度”和”按状态捞任务”两条路径上。所以在选型和表结构上,我始终以”读得快、扩展省心、团队能长期维护”为准绳。下面从选型、表结构、索引、演进四个维度,把踩过的坑和留下的方案讲清楚。
一、为什么选 MySQL 而不是 PostgreSQL
选型的时候,我们认真对比过 PostgreSQL。团队里有人安利 PG 的 JSONB 和窗口函数,也有人担心 PG 生态。我把两边列在同一张表里逐项打分,最后仍然选了 MySQL 8.0,不是因为它各方面都领先,而是它在评测平台这个场景里”够用且顺手”。
| 维度 | MySQL 8.0 | PostgreSQL 15 |
|---|---|---|
| 读性能 | 优 | 良 |
| 写性能 | 优 | 良 |
| JSON 支持 | 良 | 优 |
| 全文检索 | 良(需 ES 配合) | 优(内置) |
| 运维生态 | 成熟 | 成熟 |
| 云服务 | RDS 完善 | RDS 完善 |
| 团队熟悉度 | 高 | 中 |
从表里能看出,MySQL 的优势集中在读多写少的经典 Web 场景,而这恰恰是评测平台的形态。评测数据是”写入一次、读很多次”,子任务跑完后基本不再变化,很少做复杂关联和统计;全文检索我们也明确走 Elasticsearch,不让数据库背这个担子。反过来说,如果平台将来要做复杂的指标聚合、大量 JSON 条件查询或者向量检索,PG 会更合适,但那是另一条产品线的事。
平台选 MySQL 8.0 的原因很具体:
- 团队 MySQL 经验丰富,PostgreSQL 学习成本高;
- 读多写少场景 MySQL 性能足够;
- 全文检索走 Elasticsearch(专业工具);
- JSON 字段少(仅变量定义等少量元数据)。
第 1 条其实是决定性因素。我们团队全员写过多年 MySQL,出了问题能立刻定位;换 PG 意味着运维、排查、云数据库调优全要重新攒经验,对一个小团队来说,这笔隐形成本比想象中大。后三条是技术理由,第 1 条是现实理由,两者缺一不可。
其实我们也短暂试跑过 PostgreSQL。那位安利 PG 的同事把 JSONB 用在提示词变量存储上,GIN 索引查询确实快,可问题出在别的环节:监控看板、备份恢复、云上参数模板全得换一套,连慢查询分析都要重新搭。折腾了两周,收益只落在一张表上,代价却摊到整个平台,最后我们把这张表也迁回 MySQL,结论就再也没有动摇过。
二、数据库整体设计原则
建表之前,我先定了一套全局规矩,避免每个人按自己喜好建表,导致三个月后没人敢改库。规矩不多,但每一条后面都有实际事故支撑。
定规矩这件事,起因很现实。上线前有一次排查慢查询,发现同一张用户表在两个库里字段命名不一样,一个叫 createdat,一个叫 createtime,联调脚本被迫写两份映射。从那天起,我把”没有规矩的表结构迟早变成所有人的负担”写进了团队 wiki 第一页。
1. 范式与反范式平衡
教科书会劝你”尽量三范式”,但纯范式在评测场景里会引入大量关联查询。我采用的原则是:核心业务表严格三范式,反范式只用在查询热点上。
-- 范式:批次、任务、子任务三表分离
-- 反范式:子任务表冗余 batch_id 字段
ALTER TABLE eval_subtask ADD COLUMN batch_id BIGINT NOT NULL;
CREATE INDEX idx_subtask_batch ON eval_subtask(batch_id);
这段 SQL 解决的是”子任务表频繁按批次查询”的问题。子任务本身挂在 task 上,但运营后台常直接按 batch 拉全量子任务,少了 batch_id 就得先查 task 再 join 一次,多一跳就慢一截。冗余这个字段后,查询直接命中索引,代价只是写入时多存一个值。
理由:子任务经常按 batch 查,关联反而慢。实际效果是批次详情页从 300ms 降到 30ms 以内,而这个改动只动了一张表,风险可控。反范式要克制,我会在每周的表结构 review 里检查是不是又有人往业务表里塞冗余字段。
2. 命名规范
命名看似琐碎,实际是后期维护的地基。我们统一了这套约定:
- 表名:snake_case,复数(
eval_sessions) - 字段名:snake_case
- 主键:
id - 外键:
{table_singular}_id(user_id) - 时间:
created_at/updated_at(DATETIME 类型) - 软删:
is_deleted(TINYINT) - 状态:
status(VARCHAR,避免枚举变化频繁改表)
规则本身不复杂,难的是执行。我在 CI 里加了一个简单脚本扫描建表语句,不符合约定的直接让构建失败,这比口头要求有效得多。后来新同事入职,看一遍规范加一个例子就能上手,不再出现同一张表三种命名的乱象。
3. 字符集
字符集这条是我吃过亏后定的。老项目用的还是 3 字节的 utf8,用户昵称带 emoji 直接报错,晚上十点被叫起来改库的经历记忆犹新。
CREATE TABLE user (
id BIGINT NOT NULL AUTO_INCREMENT,
nickname VARCHAR(128) CHARACTER SET utf8mb4,
PRIMARY KEY (id)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
建表时统一 utf8mb4 + utf8mb4_unicode_ci,一劳永逸。注意连接串和 JDBC 驱动参数也要配套,光改表不改驱动,照样存不进四字节字符。排序规则选 unicode_ci,是因为评测平台的模型名、用户名里会出现各种语言的字符,这个排序在跨语言场景下更稳。
三、核心表设计
评测平台的四张核心表,我按”用户 → 批次 → 任务 → 子任务”的层级拆,每一层职责单一,改动时互不牵连,这是后面所有索引和分区的根基。
1. 用户表
用户表承担登录、鉴权、配额管理三重职责。budget_daily 和 budget_monthly 是后来补的,因为评测要调模型 API,每个用户每天能花多少钱必须可控,否则一个测试脚本就能把预算烧光。
CREATE TABLE user (
id BIGINT NOT NULL AUTO_INCREMENT,
username VARCHAR(64) NOT NULL UNIQUE,
password_hash VARCHAR(128) NOT NULL,
email VARCHAR(128),
phone VARCHAR(32),
nickname VARCHAR(64),
avatar VARCHAR(256),
user_role VARCHAR(32) NOT NULL DEFAULT 'user', -- user/admin/superAdmin
budget_daily DECIMAL(10,4) DEFAULT 50.0000,
budget_monthly DECIMAL(10,4) DEFAULT 1000.0000,
status VARCHAR(16) NOT NULL DEFAULT 'active',
last_login_at DATETIME,
created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
updated_at DATETIME ON UPDATE CURRENT_TIMESTAMP,
is_deleted TINYINT NOT NULL DEFAULT 0,
PRIMARY KEY (id),
INDEX idx_role_status (user_role, status),
INDEX idx_created (created_at)
) ENGINE=InnoDB;
user_role 用 VARCHAR 而不用 ENUM,是吸取了”加枚举值要 ALTER TABLE 锁表”的教训;status 同理,先预留字符串空间,后面加状态不用改表。索引 idx_role_status 服务管理员后台按角色筛选,idx_created 服务列表排序。这张表几乎是最常见的后台用户模型,但把配额和角色一起设计进去,是评测业务特有的点。
2. 评测批次表
一个批次是一次”拿 N 个模型跑同一批用例”的完整动作,所以它要记录总量、完成量、失败量这几个计数器,让前端直接展示进度,不必每次去聚合子任务表。
CREATE TABLE eval_batch (
id BIGINT NOT NULL AUTO_INCREMENT,
user_id BIGINT NOT NULL,
name VARCHAR(128),
description TEXT,
total_count INT NOT NULL DEFAULT 0,
done_count INT NOT NULL DEFAULT 0,
fail_count INT NOT NULL DEFAULT 0,
status VARCHAR(16) NOT NULL DEFAULT 'pending',
-- pending / running / done / partial_error / cancelled
started_at DATETIME,
finished_at DATETIME,
created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
updated_at DATETIME ON UPDATE CURRENT_TIMESTAMP,
PRIMARY KEY (id),
INDEX idx_user_created (user_id, created_at DESC),
INDEX idx_status (status, created_at DESC)
) ENGINE=InnoDB;
done_count、fail_count 是典型的计数冗余,牺牲一点写入一致性换查询速度。进度更新我们用乐观锁比较当前值,防止并发加一丢数据。索引设计上,idx_user_created 支撑”我的批次列表”,idx_status 支撑运营后台按状态筛选,这两条查询是批次表 95% 以上的访问路径。
这里有个容易翻车的点:计数器的更新必须在一个事务里和子任务状态变更一起提交,否则前端进度条会跳来跳去。我们第一版图省事,让进度接口现场 count,单批次几百条子任务还能扛,跑到上千条就慢到告警,才老老实实加了这几个冗余计数。
3. 任务表
任务 = 批次 × 模型。一个批次选了 5 个模型,就产生 5 条任务记录,各自记录该模型在整批用例上的完成情况。
CREATE TABLE eval_task (
id BIGINT NOT NULL AUTO_INCREMENT,
batch_id BIGINT NOT NULL,
model_code VARCHAR(64) NOT NULL,
prompt_id BIGINT,
prompt_snapshot TEXT,
total_count INT NOT NULL DEFAULT 0,
done_count INT NOT NULL DEFAULT 0,
fail_count INT NOT NULL DEFAULT 0,
status VARCHAR(16),
created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
PRIMARY KEY (id),
INDEX idx_batch (batch_id, model_code),
INDEX idx_status (batch_id, status)
) ENGINE=InnoDB;
prompt_snapshot 把评测用的提示词快照存下来,这是给”结果可追溯”兜底——提示词后续可能改版,但历史评测结果必须能还原当时用的什么。model_code 不建外键,模型配置表的变化不回溯历史任务。索引 idx_batch 让”批量查询某模型的进度”直接走索引。
4. 子任务表(最关键)
子任务表是数据量增长幅度很大的一张,单条用例跑一个模型就是一行,百万行级别来得比想象中快。所以从设计第一版起,我就按”必然要分区”的思路建表。
CREATE TABLE eval_subtask (
id BIGINT NOT NULL AUTO_INCREMENT,
batch_id BIGINT NOT NULL,
task_id BIGINT NOT NULL,
case_id BIGINT,
model_code VARCHAR(64) NOT NULL,
input_text TEXT,
expected_text TEXT,
output_text LONGTEXT,
input_tokens INT,
output_tokens INT,
fee DECIMAL(10,6),
duration_ms INT,
score DECIMAL(4,2),
status VARCHAR(16) NOT NULL DEFAULT 'pending',
-- pending / running / success / fail
error_msg VARCHAR(512),
first_error VARCHAR(512), -- 首次错误永久保留
retry_count INT NOT NULL DEFAULT 0,
started_at DATETIME,
finished_at DATETIME,
created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
updated_at DATETIME ON UPDATE CURRENT_TIMESTAMP,
PRIMARY KEY (id, created_at), -- 分区表必须包含分区键
INDEX idx_task_status (task_id, status, id),
INDEX idx_batch_status (batch_id, status, updated_at),
INDEX idx_status_created (status, 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
);
这张表有三个关键点:一是 output_text 用 LONGTEXT,模型输出偶尔超长,不能截断;二是 first_error 永久保留首次错误,重试多次后仍能还原最初失败原因;三是按 created_at 月分区,分区键必须进主键,所以主键写成 (id, created_at)。按月分区后,过期分区的数据直接 DROP 掉,清理成本极低,也避免单分区索引膨胀。索引按”任务状态””批次状态”两条查询路径设计,idx_task_status 支撑任务内部翻页,idx_batch_status 支撑批次维度捞子任务。
四、JSON 字段的使用
MySQL 8.0 的原生 JSON 类型很方便,但我在项目里给它划了一条红线:只放”不查询”的配置类数据。模型参数、变量定义这些字段的结构随厂商频繁变化,做成独立列会被 ALTER TABLE 拖死,放进 JSON 就灵活多了。
-- 模型参数(变化频繁、不查)
parameters JSON
-- 变量定义(结构化但不查)
variables JSON
-- 用户偏好(不查)
preferences JSON
Java 侧配合 MyBatis Flex 的 JacksonTypeHandler,实体里直接映射成 Map,读写无感。
// MyBatis Flex JSON 类型处理器
@TableField(value = "parameters", typeHandler = JacksonTypeHandler.class)
private Map<String, Object> parameters;
踩过的坑是有人想用 WHERE parameters->>'$.model' = 'gpt-4o' 查记录,结果全表扫,几百万行直接卡死。所以我把规矩写进文档:JSON 里一旦出现”要当查询条件”的字段,马上拆成独立列加索引,别贪图一时的灵活。
JSON 和用 TEXT 存 JSON 字符串,看起来只差一个类型声明,实际差别不小。JSON 类型入库时会校验语法,还能用 ->、->> 这类操作符直接取字段,MySQL 8.0 甚至支持部分更新,改一个 key 不用整条重写。我们早期一张配置表用 TEXT 存,手工补数据时漏了一个逗号,整条解析失败,排查了半天才发现是格式写坏了;换成 JSON 类型后,这种低级问题在入库前就被挡下来。
不要把”需要查询的字段”放进 JSON,性能差。
五、软删与硬删
删除策略我们分了两种:默认软删,注销用户这种合规场景才硬删。这两条路的取舍,直接影响后面每个查询的写法。
软删(默认)
ALTER TABLE eval_batch ADD COLUMN is_deleted TINYINT NOT NULL DEFAULT 0;
ALTER TABLE eval_batch ADD INDEX idx_user_deleted (user_id, is_deleted);
软删的代价是每条查询都要带 is_deleted = 0,漏一次就会把已删数据查出来。我的做法是把过滤逻辑收敛到 DAO 基类里,业务层无感,同时靠 review 和测试兜底。
业务查询强制带 is_deleted = 0。
硬删(用户注销)
硬删只发生在用户注销,且要在应用层把关联数据一并处理,不能只删主表留一堆孤儿数据。
DELETE FROM user WHERE id = ?;
-- 关联表通过外键 CASCADE 自动删
-- 或异步任务硬删
之所以留一条硬删路径而不是全程软删,是因为涉及合规。用户要求注销账号时,隐私条例要求数据不能再保留,软删只是逻辑上隐藏,后台导出的备份里仍能翻出来。硬删前我们会先归档必要的数据(比如对账单),再删主表,并用一个异步任务兜底清理缓存和搜索索引,避免删完库、ES 里还残留着用户名。
六、字段类型选择
字段类型我在第一版就吃过亏(金额用 FLOAT 丢精度、时间用 TIMESTAMP 遇到 2038 边界),后来整理成一张对照表贴在 wiki 里,建表一律按这张表来。
这张表不是一次写出来的,是几回线上事故喂出来的。最早金额用 FLOAT,用户对账时小数点后几位对不上;时间用 TIMESTAMP,同事半开玩笑说 2038 年系统会集体罢工,虽然是玩笑,但凌晨补数据时被时区问题坑过不止一次。后来我干脆把常用类型和理由列成一张对照表,review 建表语句时逐行核对,踩过的坑才算真正关进笼子里。
| 字段 | 类型 | 备注 |
|---|---|---|
| 主键 | BIGINT | 雪花 ID 或自增 |
| 状态 | VARCHAR(16) | 不用 ENUM(变化频繁) |
| 短文本 | VARCHAR(N) | N 实际最长值 |
| 长文本 | TEXT | > 5000 字 |
| 超长文本 | LONGTEXT | > 50000 字 |
| 金额 | DECIMAL(10,4) | 不用 FLOAT |
| 时间 | DATETIME | 不用 TIMESTAMP(2038 问题) |
| 布尔 | TINYINT(1) | 不用 BIT |
| JSON | JSON | MySQL 5.7+ 原生 |
核心结论一句话:金额用 DECIMAL,时间用 DATETIME,状态用 VARCHAR,布尔用 TINYINT(1)。这几条定下来,90% 的类型坑就绕开了。剩下 10% 靠 review 把关,比如有人把 ip 存成 INT,我一般会追问一句为什么。
七、表关系与外键
评测平台几乎不用外键,这是我做过取舍之后的结果。外键在写入时多一次约束检查,高并发下成本被放大,而且删除要级联,容易把一次小删除拖成全表锁。
-- 不加外键
CREATE TABLE eval_task (
batch_id BIGINT NOT NULL -- 没有 FOREIGN KEY
);
一致性交给应用层保证,删除批次时在一个事务里先删子任务再删批次,失败整体回滚。
@Transactional
public void deleteBatch(long batchId) {
evalTaskDao.deleteByBatch(batchId);
evalBatchDao.deleteById(batchId);
// 失败回滚
}
代价是少了数据库层的保护,团队必须自觉写对删除顺序,漏一步就会出现孤儿数据。我有一次漏删 task 层,导致批次删了任务还在,清数据花了半天。从那以后,所有删除方法都配了单元测试。
八、数据迁移与版本管理
schema 变更我们用 Flyway 管理,每个变更一个文件,按版本号递增,跑过一次就永久记录,杜绝”手工改库、改完不知道改了啥”的情况。
db/migration/
├── V1__init.sql
├── V2__add_user_role.sql
├── V3__add_budget.sql
└── V4__partition_subtask.sql
每次 schema 变更一个文件,不可修改,永久记录。新环境从零初始化也是 Flyway 从 V1 跑到最新,效果等于把库结构当成代码管理,回滚、追溯、评审都顺了。要提醒的是,Flyway 的文件一旦发布就别改,改了校验和就对不上,报错比悄悄改坏数据好得多。
九、冷热数据分离
数据量上去之后,历史数据全堆在 MySQL 里,查询越拖越慢,备份也越来越重。我把数据按温度分层:
-- 3 个月内数据查 MySQL
-- 3-12 个月数据查 ClickHouse
-- 12 个月以上数据归档到 OSS(按需加载)
MySQL 只留热数据,分析走 ClickHouse,历史归档进 OSS。定时任务每月 1 号凌晨跑归档:
@Scheduled(cron = "0 0 4 1 * ?") // 每月 1 号凌晨 4 点
public void archiveOldData() {
LocalDateTime cutoff = LocalDateTime.now().minusMonths(12);
archiveService.toOSS("eval_subtask", cutoff);
analyticsService.toClickHouse("eval_subtask", cutoff);
}
做这一步后,主库单表体积明显下降,慢查询也随之减少。踩过的坑是归档任务和在线写入冲突过,后来把归档改成按 id 段批量扫描、限制每批大小,才把对线上的影响降到可以忽略。
十、监控与运维
数据库不监控等于盲开。我把监控分成三层:慢 SQL、表大小、索引使用率,每层一个抓手,缺一个都容易出盲区。
1. 慢 SQL 监控
# MySQL slow_query_log
slow_query_log = 1
long_query_time = 1
log_output = TABLE
Druid 拦截器 + Prometheus 上报。慢查询一旦超过阈值就告警,研发能第一时间拿到 SQL 去优化,而不是等用户反馈”页面很慢”。
阈值定成 1 秒是我们试出来的。定得太松,每天告警几十条,大家看多了就麻木,告警沦为摆设;定得太紧,一条条全是正常的分页查询,反而淹没真问题。现在这个阈值配合按表维度聚合,每次告警都能直接定位到具体 SQL 和来源接口。
2. 表大小监控
SELECT
table_name,
ROUND(data_length / 1024 / 1024, 2) AS data_mb,
ROUND(index_length / 1024 / 1024, 2) AS index_mb
FROM information_schema.tables
WHERE table_schema = 'eval'
ORDER BY data_length DESC;
每月跑一次,看哪张表在涨,提前规划分区或归档,别等磁盘告警了才动手。
3. 索引使用率
用 pt-index-usage 定期分析,长期不用的索引直接删,减少写入开销。索引不是越多越好,这个道理谁都知道,但真删的时候都舍不得,所以我定了个规则:连续 3 个月零使用的索引就删。
十一、踩过的坑
这些坑基本都是线上踩出来的,写下来免得再摔一次:
- JSON 字段查询慢:
WHERE parameters->>'$.model' = 'gpt-4o'走全表扫。需要查询的字段拆出来。 - DATETIME vs TIMESTAMP:TIMESTAMP 最大 2038,年限长的项目必须 DATETIME。
- DECIMAL 精度:DECIMAL(10,4) 写错成 DECIMAL(10,2) 丢精度。
- ENUM 改值:加枚举值要 ALTER TABLE,锁表。改用 VARCHAR。
- utf8 不是 utf8mb4:老 MySQL 默认 utf8 只支持 3 字节,emoji 存不了。
- 自增 ID 耗尽:BIGINT 上限 9.2e18,平台用分布式 ID 更安全。
- 分区表主键:分区键必须是主键的一部分,否则报错。
- 大表 ALTER TABLE:加列锁表几小时。用 pt-online-schema-change。
每条背后都对应一次线上事故或一次加班。比如 DECIMAL 精度那条,评测费用差了几分钱,用户对账对不上,排查半天才发现是字段精度定义错了。这些教训比任何教程都来得深刻。
十二、数据库演进时间线
数据库设计不是一次到位的,我们的演进路径可以看作一条时间线:
| 版本 | 变化 | 原因 |
|---|---|---|
| v1.0 | 单库单表 | MVP |
| v1.5 | 拆 user / eval 分库 | 单库压力大 |
| v2.0 | 子任务按月分区 | 数据量爆发 |
| v2.5 | ClickHouse 分流分析 | 复杂查询影响主库 |
| v3.0 | 多租户加 tenant_id | 企业客户 |
每一步都是被真实压力推着走的:先跑通 MVP,再在数据量上来时分库、分区;分析查询拖垮主库就分流;来了企业客户就加租户维度。如果一开始就按最终形态设计,表结构反而要推翻三遍。到这里,评测平台从选型到演进的数据链路就完整了。
常见问题(FAQ)
Q1:什么时候考虑分库分表?
单表 > 5000 万行 或 单库 QPS > 1 万。平台还没到。
Q2:JSON 字段什么场景用?
结构变化频繁、不需要查询的元数据。其它场景不要用。
Q3:分库分表用什么方案?
Sharding-JDBC(应用层)或 MyCat(代理层)。平台用前者。