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) 联合索引,同样的查询降到毫秒级。索引不是建了就有,得对准查询姿势。规矩是后面每加一个查询先看执行计划再跑——这个习惯在数据量上来之后每天都救我们一次。另外慢查询日志要长期开着,超过一秒的查询自动进监控盘,比事后排查省力。表结构演进上我们还立了一条规矩:任何新表上线必须附带预期数据量与主要查询方式说明,评审时对着索引设计逐条过。起初大家嫌流程啰嗦,三个月后新表再也没出现过线上紧急加索引的救火场景。规矩立起来之后最大的变化不是表变好了,而是建表之前的讨论变多了,很多索引问题在白板上就解决了。

延伸阅读