2.3 结果落库:MySQL 表结构设计
场景:终端里的结果不算数据
爬虫能在终端打印标题正文之后,业务同学的第一反应是:然后呢。数据在终端里看看可以,上系统不行——他们要按站点筛选、按时间范围查、按关键词搜,这些能力都指向数据库。而且原始的 PageItem 结构体只够我们自己用,对外要有版本、有来源、有状态。
这一节把结果从内存搬到云数据库 MySQL 的过程讲清楚。表结构是后来整个平台对外服务的地基,改一次表动一层代码,所以在单机阶段多花一天设计是值得的。
方案:三张表撑起结果层
结果层我们只用三张表,各自职责清晰,避免一坨大宽表:
| 表名 | 职责 | 关键字段 | 索引 |
|---|---|---|---|
| page_item | 采集结果主表 | url_fp、site_id、title、body、pub_at | url_fp 唯一,site_id 加 pub_at |
| task_run | 任务执行记录 | task_id、site_id、status、started_at | site_id 加 started_at |
| content_fp | 内容指纹(变更检测) | url_fp、content_md5 | url_fp 唯一 |
page_item 是对外暴露的主表,所有看板和接口都读它。task_run 记录每次任务跑的结果,是 5.1 节监控告警的数据底座,也是判断重跑还是断点的依据。content_fp 是 2.2 节内容层去重的落地点。三张表之间没有外键约束——爬虫系统写入压力大,约束放业务层更灵活。
为什么不把 content_fp 合进 page_item?因为它的读写节奏完全不一样:page_item 每天被业务读几千次,content_fp 只有采集进程在写和比对。读写节奏不同的字段挤在一张表里,缓存的命中率会互相拖累。这个拆分在后来给 page_item 加缓存时体现出价值。
为什么不用一整张 JSON 大宽表?试过,两周后死了。业务侧要按 site_id 过滤加按 pub_at 排序,JSON 列里的字段走不了索引,查询性能必须靠规范化的列才能保证。这个教训在 1.4 节的技术地图里 MySQL 一行其实已经埋下伏笔:用云数据库不只是为了省运维,更是为了把索引和查询能力当作基础设施用。
配置与代码:建表与写入
主表的建表语句,看起来朴素但每个字段都有来由:
CREATE TABLE page_item (
id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT,
url_fp CHAR(32) NOT NULL,
site_id VARCHAR(32) NOT NULL,
url VARCHAR(512) NOT NULL,
title VARCHAR(256) NOT NULL DEFAULT '',
body MEDIUMTEXT,
pub_at DATETIME DEFAULT NULL,
crawled_at DATETIME NOT NULL,
updated DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP
ON UPDATE CURRENT_TIMESTAMP,
PRIMARY KEY (id),
UNIQUE KEY uk_fp (url_fp),
KEY idx_site_pub (site_id, pub_at)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
几个设计决定的理由:url_fp 用 CHAR(32) 存 MD5,比 VARCHAR 更省更稳;body 用 MEDIUMTEXT 而不是 TEXT 是因为遇到过几篇超长正文; CHARSET 必须是 utf8mb4,否则日文或 emoji 字符会插入失败。写入用批量 INSERT 加 ON DUPLICATE KEY UPDATE 实现幂等,这是 2.2 节内容指纹检测之后的落库动作:
func saveItems(ctx context.Context, db *sql.DB, items []PageItem) error {
query := `INSERT INTO page_item (url_fp, site_id, url, title, body, pub_at, crawled_at)
VALUES (?, ?, ?, ?, ?, ?, ?)
ON DUPLICATE KEY UPDATE title=VALUES(title), body=VALUES(body),
pub_at=VALUES(pub_at), crawled_at=VALUES(crawled_at)`
tx, err := db.BeginTx(ctx, nil)
if err != nil {
return err
}
for _, it := range items {
if _, err := tx.ExecContext(ctx, query, it.URLFp, it.SiteID,
it.URL, it.Title, it.Body, it.PubAt, time.Now()); err != nil {
tx.Rollback()
return err
}
}
return tx.Commit()
}
这版没做批量拼接 VALUES 的一次性写入,因为单机阶段每批二三十条,逐条在事务里执行足够快。真正上量之后的批量写入优化在 2.4 节之后的需求里才出现。
需要强调的是事务的使用边界。每批二三十条开一个事务是合理的,但如果批大小涨到几百条还这么写,事务持锁时间拉长,凌晨窗口可能撞上云数据库的连接数上限。我们的经验是批大小别超过一百,事务粒度控制在一秒内提交。云数据库 MySQL 默认参数对长事务不算友好,详情可以查 cloud.tencent.com/document/product/236 的连接与参数说明。
踩坑:表结构上的三个后悔
第一个后悔是 pub_at 存成了字符串。最早图省事直接存页面上的时间文本,结果格式五花八门,排序和范围查询全部失效。改回 DATETIME 并在解析层统一格式花了一个周末。时间字段的类型选择动手之前就要想清楚。
第二个后悔是没留软删除位。上线后业务发现某批数据有问题想要标记剔除,结果只能整段 DELETE,删掉的历史快照引用就断链了。后来加了 status 字段做软删除,看板加过滤条件。任何一个对外提供的数据表,都值得一开始就留状态位。
第三个后悔是索引加了太多。为了让看板每个查询都快,很激进地堆了四五个索引,写入速度肉眼可见下降,凌晨窗口的采集延迟从十分钟拖到半小时。后来按查询频率砍掉了一半索引,只保留 site 加时间和 pub_at 单字段。索引不是越多越好,要按真实查询频率来。
第四个后悔是 body 列直接存长正文。业务要全文搜索时,MySQL 的 LIKE 查询慢到不可用,最后还是把标题之外又冗余了一份精简摘要列用于搜索,正文只负责详情页展示。设计表结构时要想清楚每列的用途:展示用还是检索用,两者有时候必须分开存。
小结
这一节最后的建议是:表结构上线之后,任何 schema 变更都先在从库或者影子表上演练。我们吃过一次 ALTER TABLE 锁表的亏,加一列的字段变更在两千万行的表上跑了四十多分钟,白天看板直接卡死。从那以后大表变更一律走影子表加双写,这是血泪换来的流程。
结果落库的原则是表结构对齐业务查询而非对齐采集过程:采集端怎么爽都行,查询端要的索引和字段必须提前规划。三张表、幂等写入、utf8mb4、时间用类型而不是字符串——这五条记住,单机阶段的结果层就稳了。下一节把原始 HTML 也存起来,给数据留一份证据与重放能力,这是 2.4 节。想了解 MySQL 在即时通讯场景下的表设计思路,可以参考 go-im-guide。
落库时还有一个索引相关的实战教训。最初我们给 item 表按 site_id 单字段建索引,结果某天跑了一个按站点加时间范围的统计查询,整整扫了八百万行。后来改成 (site_id, created_at) 联合索引,同样的查询降到毫秒级。索引不是建了就有,得对准查询姿势。规矩是后面每加一个查询先看执行计划再跑——这个习惯在数据量上来之后每天都救我们一次。另外慢查询日志要长期开着,超过一秒的查询自动进监控盘,比事后排查省力。表结构演进上我们还立了一条规矩:任何新表上线必须附带预期数据量与主要查询方式说明,评审时对着索引设计逐条过。起初大家嫌流程啰嗦,三个月后新表再也没出现过线上紧急加索引的救火场景。规矩立起来之后最大的变化不是表变好了,而是建表之前的讨论变多了,很多索引问题在白板上就解决了。
