一个建站系统刚上线时,文章表只有几十行,怎么查都快。写到五万篇,后台列表开始转圈;到十万篇,前台栏目页要三秒。不是服务器不行,是表设计和查询方式从一开始就没为增长做准备。
文章表不需要设计得很复杂,但几个关键决定必须在第一天做对:字段类型、索引组合、正文与列表分离、分页方式。
字段:列表要用的和详情要用的分开
文章表最常见的错误,是把正文、附件、自定义字段全塞进一张表,然后列表查询用 SELECT *。一页二十篇文章,每篇正文五千字,数据库要把几十兆内容读出来再丢掉,内存和 IO 都浪费。
建议把高频列表字段和低频大字段分开:主表放标题、slug、栏目、摘要、封面、状态、发布时间、字数;正文放独立的 content 表,用 article_id 关联。列表查询只查主表,详情页再取正文。
如果不想拆表,至少做到列表查询只 SELECT 需要的字段,永远不要 SELECT *。
字段类型:别用 TEXT 存所有东西
标题、slug、封面路径用 VARCHAR,长度按实际需要给,slug 建议 191 以内并加唯一索引。状态用 TINYINT,不要用字符串。发布时间用 DATETIME,不要用 INT 时间戳,后者在人工排查时非常难受。
正文用 MEDIUMTEXT 或 TEXT,但要注意 MySQL 的 TEXT 不能有默认值,也不能直接建普通索引。需要全文检索的,另建搜索表或用搜索引擎,不要在文章表上堆 FULLTEXT。
还有一个细节:site_id、category_id 用 INT UNSIGNED,和关联表保持一致,避免隐式类型转换导致索引失效。
索引:按查询顺序建,不要按字段重要性建
前台最常见的三个查询是:某站点已发布文章按时间倒序、某栏目已发布文章按时间倒序、按 slug 查单篇。对应的索引应该是:
-- 首页/搜索
KEY idx_site_status_time (site_id, status, published_at)
-- 栏目列表
KEY idx_cat_status_time (category_id, status, published_at)
-- 详情
UNIQUE KEY uk_site_slug (site_id, slug)
索引的顺序很重要。把 status 放在 published_at 前面,是因为 status 的选择性虽然低,但能先过滤掉草稿;如果只建 published_at,草稿多的时候会扫很多无效行。
不要给每个字段都建索引。索引会拖慢写入,也会占空间。文章表通常写少读多,三到五个索引足够。
慢查询的三个来源
第一,SELECT * 加大正文,前面说过。第二,COUNT(*) 做分页总数,十万篇时每次列表都全表统计,慢得明显。可以缓存总数,或者用“是否有下一页”的游标分页替代页码。第三,LIKE '%关键词%' 搜索,前置通配符无法走索引,数据量大时必然慢。搜索应该走独立的搜索表或搜索引擎。
后台的筛选条件更容易出问题:时间范围、栏目、状态、作者任意组合。不要为每种组合建索引,选两三个高频组合即可,其余接受稍慢。
分页:页码越深越慢
LIMIT 20 OFFSET 100000 会让数据库先跳过十万行。前台栏目页通常只有前几页有流量,问题不大;后台翻到很深的页码时,可以改成“上一页/下一页”的游标方式,用 WHERE published_at < 上一页最后一条 来定位。
如果产品坚持要页码,至少限制最大页数,并在总数上做缓存。
冷热数据与归档
三年前的文章仍然有搜索价值,但不需要和最新文章放在同一个热表里。可以按年份归档到历史表,前台详情仍可访问,列表默认只查近两年。归档前做好 URL 和 sitemap 的兼容,别让老文章变成 404。
归档还能让备份变小。十万篇文章的正文可能几十 GB,全量备份每天跑一次不现实,冷热分离后只备份热表,成本会低很多。
相关阅读:网站备份的三二一原则。
常见问题
文章表要不要存纯文本正文?
建议存。搜索、摘要、字数统计都需要纯文本,每次从 HTML 里现算很浪费。存一份 body_text,写入时生成即可。
slug 要不要全局唯一?
同一个站点内唯一即可。多站点共用一个库时,用 site_id + slug 的联合唯一索引。
十万篇一定要分表吗?
不一定。做好字段拆分、索引和分页,单表几十万篇也能跑。真正的瓶颈通常是大字段和无效查询,不是行数。
如果表已经膨胀,处理顺序可以看CMS 数据库优化,先定位慢查询再动手。