外观
文章模块面经文档
项目:黑马点评(类 Yelp 本地生活服务平台) 技术栈:Spring Boot · MySQL · Redis · MyBatis-Plus
1. 模块概览
文章模块的核心设计亮点:将文章表拆分为信息表和内容表,通过解耦两类访问特性完全不同的字段,加速多种业务场景下的查询,同时降低单表的并发压力。
这是一个典型的数据库垂直分表设计,面试时常被追问「为什么要这么设计」「如果不拆表会怎样」「MySQL 里大字段对性能有什么影响」。
2. 表结构设计
2.1 原始单表设计(问题版)
sql
CREATE TABLE article (
article_id BIGINT PRIMARY KEY, -- 雪花算法
user_id BIGINT, -- 作者ID
title VARCHAR(128), -- 文章标题
cover_image VARCHAR(256), -- 封面图地址
status TINYINT, -- 状态(0草稿/1发布/2下架)
create_time DATETIME, -- 发布时间
update_time DATETIME, -- 更新时间
-- ⚠️ 问题字段
content MEDIUMTEXT, -- 文章正文(可达几MB)
image_count INT, -- 图片数量
image_paths TEXT -- 所有图片路径(JSON)
);单表的问题:文章正文(content)、图片路径(image_paths)属于大字段(可达几百KB乃至数MB),而标题、封面、状态等元信息字段只有几十字节。绝大多数业务场景只需要元信息,却不得不把大字段一起加载进内存,造成大量 IO 浪费。
2.2 垂直分表后的设计(本项目)
sql
-- 文章信息表:轻量,适合列表查询、状态查询
CREATE TABLE article (
article_id BIGINT PRIMARY KEY,
user_id BIGINT,
title VARCHAR(128),
cover_image VARCHAR(256),
status TINYINT,
create_time DATETIME,
update_time DATETIME
);
-- 文章内容表:大字段,只在需要时按需加载
CREATE TABLE article_content (
article_id BIGINT PRIMARY KEY, -- 与 article 表 1:1 关联
image_count INT,
content MEDIUMTEXT, -- 正文内容
image_paths TEXT -- 图片路径(JSON)
);3. 架构图:不同场景的查询路径
用户请求
│
┌──────────────┼──────────────┐
│ │ │
首页/推荐列表 用户主页列表 文章详情页
│ │ │
只查 article 只查 article 先查 article
表(轻量) 表(轻量) 再查 article_content
│ │ │
返回标题/封面 返回标题/封面 拼装完整 ArticleVO
/发布时间 /发布时间 返回给客户端核心收益:列表类查询(并发量最大)只扫描 article 小表,完全不碰大字段;只有用户点进详情页时,才额外查一次 article_content,按需加载。
4. 技术原理深挖
4.1 MySQL 中大字段为什么影响性能?
InnoDB 的行存储格式:InnoDB 默认使用 COMPACT 或 DYNAMIC 行格式。对于 TEXT、MEDIUMTEXT 等大字段:
- 若字段长度小于等于 768 字节,数据存储在行内(In-Page)
- 若超过 768 字节,溢出部分存储在独立的「溢出页」(Off-Page),行内只保留 20 字节的指针
性能影响链路:
查询包含大字段的行
│
▼
InnoDB 读取数据页(16KB 一个页)
│
▼
大字段触发额外的「溢出页」随机 IO
│
▼
Buffer Pool 被大量大字段占满
├── 挤占其他热点数据页的缓存空间
└── 缓存命中率下降 → 更多磁盘 IO垂直分表后,article 表的每行只有约 100 字节,一个 16KB 的数据页可以存放约 160 行,Buffer Pool 利用率大幅提升,列表查询的缓存命中率显著提高。
4.2 索引效率的提升
MySQL 的二级索引叶子节点存储的是「索引值 + 主键」,回表时需要根据主键到聚簇索引读取完整行数据。
-- 场景:按用户ID查询其发布的所有文章列表
SELECT article_id, title, cover_image, create_time
FROM article
WHERE user_id = ? AND status = 1
ORDER BY create_time DESC;若存在 (user_id, status, create_time) 联合索引,且表中不含大字段:
- 每个索引页能存储更多索引记录(行短则数据页内可放更多行)
- 回表读取主表行时,每行小,IO 代价低
- 整个查询可能只需几次 IO
若大字段和元信息在同一表中,回表代价翻倍甚至更高。
4.3 为什么用 1:1 关联而不是两个独立表?
article_content 的主键与 article 主键相同(都是 article_id),这是严格的 1:1 关系。设计原则:
- 生命周期一致:文章删除时内容也应删除
- 主键相同:JOIN 查询代价最低(等值连接走主键索引,O(log n))
- 没有外键约束(性能考量):由应用层保证一致性
java
// 查询文章详情:先查信息表,再按需查内容表
Article article = articleService.getById(articleId);
ArticleContent content = articleContentService.getById(articleId); // 同一主键
ArticleVO vo = new ArticleVO();
BeanUtils.copyProperties(article, vo);
BeanUtils.copyProperties(content, vo);
return vo;4.4 MySQL B+ 树与大字段的关系
B+ 树索引的效率依赖于树的高度,高度取决于每个节点能存放多少个 key,而节点大小固定(InnoDB 默认 16KB)。行越短,每页能存的行越多,树越矮,IO 次数越少:
单表(含大字段):平均行长 5000 字节
每页约 3 行 → B+ 树较高 → 查询需更多层次的 IO
分表后(article 表):平均行长 100 字节
每页约 160 行 → B+ 树更矮 → 查询 IO 次数减少5. 各业务场景的查询分析
5.1 首页推荐 / 列表展示(最高频)
sql
-- 只查轻量信息表,不碰 content
SELECT article_id, title, cover_image, create_time
FROM article
WHERE status = 1
ORDER BY create_time DESC
LIMIT 20;特点:高并发(每个用户刷新首页都会触发),但数据量小(只需元信息),通过 Redis 缓存热点列表进一步减少 DB 压力。
5.2 用户主页(中频)
sql
-- 查某作者的所有已发布文章
SELECT article_id, title, cover_image, create_time
FROM article
WHERE user_id = ? AND status = 1
ORDER BY create_time DESC;(user_id, status) 联合索引覆盖此查询,只访问 article 小表。
5.3 文章详情页(低频但数据量大)
java
// 两次查询,按需组装
Article article = articleService.getById(articleId); // 查 article 表
ArticleContent content = articleContentService.getById(articleId); // 查 content 表详情页访问频率远低于列表页,且大字段只在用户主动点进时才加载,不影响列表查询性能。
6. 方案对比
6.1 垂直分表 vs 单表
| 维度 | 单表 | 垂直分表(本项目) |
|---|---|---|
| 列表查询性能 | 低(扫描含大字段的宽表) | 高(只扫小表) |
| Buffer Pool 利用率 | 低(大字段占用缓存) | 高(小表行短,缓存更多) |
| 详情页查询 | 一次查询 | 两次查询(JOIN 或分两步) |
| 开发复杂度 | 低 | 中(需维护两表一致性) |
| 扩展性 | 差(大表难以分库分表) | 好(大小表可独立扩展) |
6.2 垂直分表 vs 水平分表
| 维度 | 垂直分表 | 水平分表 |
|---|---|---|
| 解决的问题 | 字段过多/大字段性能 | 数据量过大(亿级以上) |
| 表数量 | 固定(按字段类型分) | 动态增长(按数据量分) |
| 查询变化 | 多表 JOIN | 路由到指定分片查询 |
| 适用场景 | 宽表优化 | 海量数据存储 |
本项目数据量有限,用垂直分表即可;若文章量达到亿级,还需在此基础上加水平分表(如按 user_id 分片)。
7. 索引设计
sql
-- article 表索引
CREATE INDEX idx_user_status ON article(user_id, status); -- 用户主页查询
CREATE INDEX idx_status_time ON article(status, create_time DESC); -- 首页列表查询
-- article_content 表
-- 主键即 article_id,1:1 关联,无需额外索引联合索引字段顺序原则(最左前缀):
(user_id, status):先按 user_id 过滤缩小范围,再按 status 筛选,符合查询条件的顺序(status, create_time):先过滤 status=1 的已发布文章,再按时间排序
8. 极端场景与容灾
场景一:文章表数据量激增,查询变慢
垂直分表后 article 表仍会随文章数量增长:
- 短期:加索引、优化查询、Redis 缓存热点数据
- 中期:按
user_id水平分表,将不同作者的文章分散到不同分片 - 长期:冷热数据分离(近 6 个月的文章在热库,更早的归档到冷库),配合推荐算法只推新文章,冷数据基本无流量
场景二:article 和 article_content 数据不一致
不使用外键约束(性能原因),由应用层保证:发布文章时,在 @Transactional 注解下同时写两张表,任一失败则全部回滚:
java
@Transactional
public void publishArticle(ArticleVO vo) {
Article article = new Article();
BeanUtils.copyProperties(vo, article);
articleService.save(article); // 写 article 表
ArticleContent content = new ArticleContent();
BeanUtils.copyProperties(vo, content);
content.setArticleId(article.getArticleId());
articleContentService.save(content); // 写 article_content 表
// 任一失败,事务回滚,两表保持一致
}场景三:content 字段内容超大(如几十 MB 的长文)
MEDIUMTEXT 最大支持 16MB,足够大多数场景。若确实有极大内容(如视频脚本、长篇小说),可考虑:
- 将内容存储到对象存储(OSS/S3),数据库只存 URL
- 对内容进行分段存储(将一篇文章拆为多段落存储)
9. 面试题清单
9.1 基础实现层
| 题目 | 考察点 |
|---|---|
| 你的文章表是怎么设计的?为什么要拆成两张表? | 垂直分表动机 |
| 信息表和内容表是什么关系?怎么关联的? | 1:1 关联设计 |
| 文章详情查询需要查两次 DB,比单表多一次,这不是性能更差吗? | 权衡思维:低频牺牲换高频收益 |
| 发布文章时如何保证两张表的数据一致性? | 事务 |
9.2 原理深挖层
| 题目 | 考察点 |
|---|---|
| MySQL 中大字段(TEXT)对性能有什么影响? | InnoDB 行存储,溢出页,Buffer Pool |
| 为什么表中行越短,查询越快? | B+树高度,数据页利用率 |
| 垂直分表和水平分表有什么区别,分别解决什么问题? | 分表策略理解 |
联合索引 (user_id, status) 和 (status, user_id) 有什么区别? | 最左前缀原则,区分度 |
9.3 方案对比层
| 题目 | 考察点 |
|---|---|
| 如果不做垂直分表,有没有其他办法优化大字段的影响? | 覆盖索引、懒加载等 |
| 文章数据量很大时,除了垂直分表还需要什么优化? | 水平分表,冷热分离,归档 |
| 为什么不用外键约束来保证两表一致性? | 外键对性能的影响,应用层事务替代 |
9.4 极端场景层
| 题目 | 考察点 |
|---|---|
| article 写入成功但 article_content 写入失败,怎么处理? | 事务回滚 |
| 文章内容特别长(几十 MB),该怎么存储? | 对象存储,内容分段 |
| 文章表有 1 亿条数据了,查询变慢了,怎么优化? | 水平分表,冷热分离,读写分离 |
10. 一句话总结(面试开场白模板)
文章表采用垂直分表设计,将文章元信息(标题、封面、状态、发布时间)和文章内容(正文 TEXT、图片路径)拆分成两张表,通过相同的
article_id做 1:1 关联。这么设计的核心原因是:首页列表、用户主页等高并发场景只需要元信息,不应该因为大字段拖累查询效率。MySQL 中 TEXT 类型大字段会触发溢出页的额外 IO,还会占用大量 Buffer Pool 缓存空间,挤压其他热点数据;垂直分表后 article 信息表行长约 100 字节,单个数据页可存 160 行,缓存命中率显著提升。代价是详情页需要两次查询,但详情页访问频率远低于列表页,这是合理的权衡。