多阶段生成的 AI 文章系统,article 表的核心矛盾是正文内容大、生成状态多变、查询维度分散。我们的做法是主内容与状态分离:title、content 这类大字段与生成元数据分开存放,status 用 tinyint 表达发布生命周期,stage 记录生成进行到哪一步;索引只围绕真实查询路径建,列表页命中联合索引,标题搜索走 ngram 全文索引,大字段绝不进普通 B+ 树索引。
一、先想清楚这张表要回答哪些查询
设计表结构之前,先把查询场景列出来,字段和索引才有依据。文章创作器里 article 表要支撑四类查询:
- 用户后台列表页:按当前用户分页取文章,附带状态过滤;
- 管理端全量列表:按状态、时间段筛选,可能按标题模糊搜索;
- 详情页:主键精确取单条,包含完整正文;
- 统计场景:某用户的文章数、某状态下有多少条。
普通博客的 article 表只有前三类,且以读为主;创作器项目多了”生成进行中”这个中间态,列表页要频繁刷新”生成中”的记录,写入频率高得多。这个差异直接决定字段和索引的选择。
| 场景 | 查询频率 | 核心过滤字段 | 索引需求 |
|---|---|---|---|
| 用户列表 | 高 | userid + status + createtime | 联合索引 |
| 管理端筛选 | 中 | status + create_time | 联合索引 |
| 标题搜索 | 低 | title | ngram 全文索引 |
| 详情 | 高 | id | 主键 |
二、字段怎么划分
2.1 基础信息与内容字段
id 用 BIGINT 自增,title 定 VARCHAR(255),content 用 LONGTEXT。创作器生成的文章普遍几千字,加上 Markdown 源码,普通 TEXT 的 64KB 上限偶尔会被逼近,LONGTEXT 更稳。summary 字段放 AI 生成的摘要,控制在 500 字内,列表卡片展示用,避免详情页式大字段读入。
2.2 状态字段:status 与 stage 分开
这是与普通博客最大的不同。普通博客一个 status 就够,创作器里文章生成需要几十秒,用户要看到”正在选题、正在写大纲、正在生成正文”的实时进度,又要区分草稿、已发布、已下线这类生命周期状态。拆成两个字段:
statustinyint:文章生命周期,1 草稿、2 已发布、3 已下线;stagetinyint:生成阶段,0 待生成、1 选题、2 大纲、3 初稿、4 润色、5 完成。
stage 由流式回调逐阶段推进,前端轮询或接收推送后更新进度条。
2.3 生成元数据与审计字段
提示词、模型名、token 消耗这些”一次生成一个批次”的信息,放在独立的 generate_record 表里,article 表只保留 last_generate_id 引用最近一次生成批次。一篇文章可能重试生成多次,元数据是一对多关系,塞进 article 表会造成字段冗余,还会让 article 表更新频率翻倍。
审计字段交给 MyBatis-Plus 自动填充:createtime、updatetime 由 MetaObjectHandler 统一赋值,deleted 做逻辑删除。
三、索引设计的三个决策
3.1 联合索引覆盖列表页
列表页 SQL 形如 WHERE user_id = ? AND status = ? ORDER BY create_time DESC。单独给 userid 建索引,status 过滤、createtime 排序都还要回表。建 idx_user_status_time (user_id, status, create_time),把过滤和排序字段一次覆盖,避免 filesort。管理端全量列表走 idx_status_time (status, create_time)。
3.2 标题搜索用 ngram 全文索引
后台按标题关键词搜索,LIKE '%关键词%' 在数据量上去后全表扫描,用 MySQL 全文索引替代。中文必须显式指定 ngram 解析器,否则默认按空格分词,中文搜不到:
ALTER TABLE article ADD FULLTEXT INDEX ft_title (title) WITH PARSER ngram;
ngramtokensize 保持默认 2,查询用 MATCH(title) AGAINST('AI 创作' IN BOOLEAN MODE)。ngram 精度比 Elasticsearch 弱,但后台搜索量很小,为它引入 ES 不划算。
3.3 大字段不进普通索引
content 是 LONGTEXT,普通索引建上去既浪费空间也拖慢写入。全文索引只在 title 上建,需要全文搜正文时再在 content 上单独建 FULLTEXT 索引。
四、完整 DDL
CREATE TABLE `article` (
`id` BIGINT NOT NULL AUTO_INCREMENT COMMENT '主键',
`user_id` BIGINT NOT NULL COMMENT '作者ID',
`title` VARCHAR(255) NOT NULL COMMENT '标题',
`summary` VARCHAR(500) DEFAULT '' COMMENT 'AI 生成摘要',
`content` LONGTEXT COMMENT 'Markdown 正文',
`status` TINYINT NOT NULL DEFAULT 1 COMMENT '1草稿 2已发布 3已下线',
`stage` TINYINT NOT NULL DEFAULT 0 COMMENT '0待生成 1选题 2大纲 3初稿 4润色 5完成',
`last_generate_id` BIGINT DEFAULT NULL COMMENT '最近生成批次ID',
`view_count` INT NOT NULL DEFAULT 0 COMMENT '阅读量',
`create_time` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
`update_time` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
`deleted` TINYINT NOT NULL DEFAULT 0 COMMENT '逻辑删除',
PRIMARY KEY (`id`),
KEY `idx_user_status_time` (`user_id`, `status`, `create_time`),
KEY `idx_status_time` (`status`, `create_time`),
KEY `idx_last_generate_id` (`last_generate_id`),
FULLTEXT INDEX `ft_title` (`title`) WITH PARSER ngram
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='文章表';
五、写入侧的两个坑
生成过程中 stage 频繁更新,加上流式回调,article 表的 update 集中在生成窗口期。我们把 stage 推进与正文落库拆成两个写路径:阶段推进只 update stage 单字段,正文生成完再一次性 update content 并置 stage=5,避免每次回调都带 content 大字段做 update,减少行锁持有时间和 redo 量。
单表月增量超过 5 万、总量接近百万时,正文和元数据可拆开:content 挪到对象存储或独立内容表,article 只留标题和索引字段,列表查询不再触碰大字段。
常见问题(FAQ)
Q1:article 表的 content 字段用 TEXT 还是 LONGTEXT?
长文章用 LONGTEXT 更稳,TEXT 上限 64KB 在万字 Markdown 面前不够用。
Q2:标题搜索为什么不用 LIKE 而是全文索引?
数据量上去后 LIKE 模糊匹配全表扫描,ngram 全文索引走倒排表更快。
Q3:status 和 stage 为什么不合并成一个字段?
一个管发布生命周期,一个管生成进度,合并会让状态判断复杂化。