数据库方案
最简单的方案
在上一章,我分析了各种技术方案。现在,让我从最简单的开始。
先用数据库实现,看看会发生什么。
数据库设计
表结构
我需要两张表:
数据设计要点
- 核心是在
articles里保存业务事实,而不是把规则散落在应用逻辑里。- 索引服务于高频查询,重点是缩小扫描范围,而不是堆更多字段。
- 关键字段包括
id、title、content、author_id、like_count、created_at、updated_at、article_id,它们决定后续查询和管理能力。
设计思路:
article_likes 表:
- 记录谁给哪篇文章点过赞
- UNIQUE KEY 保证一个用户对一篇文章只能点赞一次
- 用于查询"用户是否点赞"
articles.like_count 字段:
- 冗余字段,存储点赞总数
- 避免每次都 COUNT 查询
- 用于快速显示点赞数
为什么要有冗余字段?
没有冗余字段的方案:
数据设计要点
- 查询目标是快速定位状态、任务或资源,避免在关键路径上做大范围扫描。
问题:
- COUNT 查询慢(需要扫描索引)
- 列表页显示 20 篇文章,需要 20 次 COUNT
- 数据库压力大
有冗余字段的方案:
数据设计要点
- 查询目标是快速定位状态、任务或资源,避免在关键路径上做大范围扫描。
好处:
- 查询快,直接读取字段
- 不需要 COUNT
- 数据库压力小
代价:
- 需要维护冗余字段的一致性
- 点赞/取消点赞时需要更新
我选择了空间换时间。
点赞功能实现
点赞接口
取消点赞接口
查询文章详情
事务问题
场景:并发点赞
如果有两个用户同时对一篇文章点赞,会发生什么?
初始状态:article.id = 1, like_count = 100
用户 A 点赞:
1. INSERT INTO article_likes (article_id, user_id) VALUES (1, 1001)
2. UPDATE articles SET like_count = like_count + 1 WHERE id = 1
用户 B 点赞(同时):
1. INSERT INTO article_likes (article_id, user_id) VALUES (1, 1002)
2. UPDATE articles SET like_count = like_count + 1 WHERE id = 1
期望结果:like_count = 102
实际结果:like_count = 102 ✅
看起来没问题?
但如果使用事务呢?
使用事务
事务的好处:
- 保证两个操作要么都成功,要么都失败
- 避免数据不一致
事务的代价:
- 锁定数据库资源
- 降低并发性能
测试验证
单元测试
性能测试
我用 Apache Benchmark 测试了一下:
验证要点
- 命令只用于验证系统状态,读者不需要记具体参数。
看起来还不错?
但是,这只是单机测试。如果用户量增长呢?
初步成果
上线一周后,数据如下:
文章数:1,500 篇
用户数:8,000 人
点赞总数:35,000 次
日均点赞:约 5,000 次
系统运行正常,没有出现明显问题。
我看了看数据库监控:
MySQL 状态:
- CPU:15-20%
- 连接数:30/200
- QPS:约 500
- 慢查询:0 条
article_likes 表:
- 记录数:35,000 条
- 索引大小:约 5MB
- 查询时间:< 1ms
一切看起来都很美好。
潜在问题
虽然目前运行正常,但我开始担心一些问题:
问题 1:数据一致性
解决方案:使用事务。
问题 2:并发更新
这是经典的”丢失更新”问题。
问题 3:性能瓶颈
当前:日均 5,000 次点赞,数据库 QPS 500
增长预测:
- 用户增长 10 倍 → 日均 50,000 次点赞
- 用户增长 100 倍 → 日均 500,000 次点赞
- 峰值可能达到 10 倍
数据库能承受吗?
我需要更深入地思考。
课后练习
练习 1
为什么需要在 article_likes 表上创建 (article_id, user_id) 的联合唯一索引?
原因 1:保证数据唯一性
联合唯一索引确保一个用户对一篇文章只能有一条点赞记录:
数据设计要点
- 关键字段包括
INSERT,它们决定后续查询和管理能力。
原因 2:提升查询性能
查询”用户是否点赞”时,可以利用索引快速定位:
数据设计要点
- 查询目标是快速定位状态、任务或资源,避免在关键路径上做大范围扫描。
原因 3:避免回表查询
联合索引包含了查询所需的所有字段,不需要回表:
联合索引结构:(article_id, user_id) → 主键 ID
查询:SELECT id FROM article_likes WHERE article_id = 1 AND user_id = 100
执行:直接在索引中查找,不需要回表练习 2
如何解决并发更新导致的”丢失更新”问题?
方案一:使用乐观锁
数据设计要点
- 这是一次表结构演进:随着业务能力增加,把新状态、新时间点或新归属关系补进数据模型。
方案二:使用数据库原子操作
数据设计要点
- 这里关注数据模型和约束关系,不需要记住具体语法。
这种方式不需要读取原值,直接在数据库层面完成计算。
方案三:使用 SELECT FOR UPDATE
数据设计要点
- 查询目标是快速定位状态、任务或资源,避免在关键路径上做大范围扫描。
方案四:使用 Redis 计数器
推荐方案:
对于点赞计数,推荐使用 方案二(原子操作) 或 方案四(Redis):
- 方案二简单高效,利用数据库原子性
- 方案四性能最好,适合高并发场景
练习 3
设计一个接口,返回文章列表及每篇文章的点赞数和当前用户是否点赞。
要求:优化查询性能,避免 N+1 查询问题。
问题分析:
N+1 查询问题:
优化方案一:批量查询
数据设计要点
- 查询目标是快速定位状态、任务或资源,避免在关键路径上做大范围扫描。
优化方案二:LEFT JOIN
数据设计要点
- 查询目标是快速定位状态、任务或资源,避免在关键路径上做大范围扫描。
优化方案三:使用冗余字段 + 批量查询是否点赞
数据设计要点
- 查询目标是快速定位状态、任务或资源,避免在关键路径上做大范围扫描。
性能对比:
| 方案 | 查询次数 | 复杂度 |
|---|---|---|
| N+1 查询 | 1 + 2N | 简单 |
| 批量查询 | 3 | 中等 |
| LEFT JOIN | 1 | 复杂 |
| 冗余字段 + 批量 | 2 | 简单 |
推荐方案:使用 冗余字段 + 批量查询是否点赞,简单高效。
练习 4
如何处理”点赞失败但用户已经点击”的情况?
问题场景:
- 用户点击点赞按钮
- 前端立即显示点赞成功(乐观更新)
- 后端返回失败(已点赞、网络错误等)
- 前端状态与后端不一致
解决方案:
方案一:悲观更新(先请求,后更新 UI)
优点:数据准确 缺点:用户体验稍差(等待响应)
方案二:乐观更新 + 回滚
优点:用户体验好 缺点:实现复杂,需要处理回滚
方案三:乐观更新 + 定期同步
优点:用户体验最好 缺点:可能有短暂的不一致
推荐方案:
根据业务场景选择:
- 严格要求一致:使用方案一(悲观更新)
- 普通点赞功能:使用方案二(乐观更新 + 回滚)
- 社交应用:使用方案三(乐观更新 + 定期同步)
练习 5
设计数据库表结构,支持”查看用户点赞过的所有文章”功能。
需求分析:
- 查询用户点赞过的所有文章
- 支持分页
- 按点赞时间倒序排列
表结构设计:
数据设计要点
- 核心是在
article_likes里保存业务事实,而不是把规则散落在应用逻辑里。- 索引服务于高频查询,重点是缩小扫描范围,而不是堆更多字段。
- 关键字段包括
id、article_id、user_id、created_at,它们决定后续查询和管理能力。
查询 SQL:
数据设计要点
- 查询目标是快速定位状态、任务或资源,避免在关键路径上做大范围扫描。
索引分析:
索引 idx_user_created (user_id, created_at DESC)
查询:
WHERE user_id = ?
ORDER BY created_at DESC
执行计划:
- 使用索引 idx_user_created
- 索引扫描
- 避免文件排序(Using filesort)优化建议:
- 覆盖索引优化(如果只需要文章 ID):
数据设计要点
- 查询目标是快速定位状态、任务或资源,避免在关键路径上做大范围扫描。
- 延迟关联(分页优化):
数据设计要点
- 查询目标是快速定位状态、任务或资源,避免在关键路径上做大范围扫描。
- 缓存优化:
思考题
-
如果用户在一篇文章上反复点赞、取消点赞,会对数据库产生什么影响?如何优化?
-
如何实现”点赞动画”效果?前后端如何配合?
-
如果需要统计”用户获得的点赞总数”(即用户所有文章的点赞数之和),如何设计?
💡 提示:这些问题没有标准答案,建议结合实际情况深入思考。
