写在最前面:这一版和普通讲义有什么不同
这是《内容社区》课程第 06 章。你现在已经做完前五章,会用 Gin 写接口、用 GORM 操作 MySQL、用 Redis 存点简单数据。但这一章会冒出一堆你大概率没系统学过的东西:MongoDB、缓存三大问题、TDD、雪花算法、OSS + CDN、Upsert。
这一版做了三件事:
- 每一个陌生名词当场补「补课胶囊」。TDD 是什么、NoSQL 是什么、缓存三大问题怎么解——不让你自己跑去搜。
- 每一步都能跑。代码标文件路径、给运行命令、给预期输出。
- 结尾有 15 道面试问答,按"基础 → 进阶 → 追问"排列,每题告诉你一句话结论、怎么展开、面试官想听什么词、新手最容易答错在哪。
如果你想要"高密度速查版",可以看原版第 06 章 ;两版知识点完全一致,只是讲法不同。
一、本章导读
上一章我们解决了"你是谁"的问题——SSO 与微信扫码登录让带着可信登录态的用户进入系统,这为一切需要区分身份的业务铺平了道路。本章把镜头转向内容社区最核心的业务:发帖。
这一章的体量是全书截至目前最大的一章,因为它同时塞进了三件大事:
- 写链路:怎么把"创作者编辑中"和"读者能看到"的内容从物理上分开(双库设计),并用 TDD 一步步落地编辑、发表、撤回接口。
- 存储选型:把视野从 MySQL 扩出去,认识 MongoDB 存大文本、雪花算法生成分布式 ID、OSS + CDN 存图片和大文件。
- 读链路与缓存:这是全书第一次正面迎战缓存——Cache-Aside 模式、穿透 / 击穿 / 雪崩三大问题、缓存与数据库一致性的取舍,都会在一个真实的发帖业务里展开。
学完这一章,你就能独立走完"需求分析 → 存储选型 → TDD 实现 → 缓存优化"的完整链路。本章特别长,建议分两次学:第一次啃透发帖功能与存储选型(3.1 ~ 3.18),第二次专攻缓存三大问题(3.19 ~ 3.25)。
开读前先看一眼全局:本章分两条主线——先把"发帖"的读写链路走通(写入 MySQL、读时靠缓存加速),再回头看读多写少的场景下缓存一定会遇到的三大问题。图里三块都齐了:正文 3.13.18 在讲上面两条链路,3.193.25 在讲下面三个对策。
flowchart TB
subgraph W["写链路:发帖"]
A["用户提交帖子"] --> B["Handler
参数校验"] --> C["Service
业务校验"] --> D["GORM
写 MySQL"]
end
subgraph RD["读链路:看帖"]
E["用户浏览帖子"] --> F{"缓存命中?"}
F -->|"命中"| G["直接返回"]
F -->|"未命中"| H["查 MySQL
回填缓存"]
end
subgraph C3["读多写少必然遇到的缓存三大问题"]
P["穿透:查不存在的数据"] --> S["布隆过滤器 / 缓存空值"]
Q["击穿:热 key 过期瞬间"] --> T["singleflight 合并回源"]
U["雪崩:大批 key 同时失效"] --> V["过期时间加随机抖动"]
end
D -.->|"写后删缓存"| F这张图怎么读:上块"写链路"里,“用户提交帖子"依次流经 Handler(参数校验)→ Service(业务校验)→ GORM 写 MySQL;中块"读链路"里,“用户浏览帖子"先到菱形"缓存命中?“分叉——命中走"直接返回”,未命中才"查 MySQL、回填缓存”。连接两块的虚线"写后删缓存"是全图枢纽:写链路落库成功后删掉缓存,读链路下次未命中就会回源到新数据。最下块是读多写少场景迟早要撞上的三大问题(穿透/击穿/雪崩)与各自对策,正文 3.19 节之后逐一展开。
二、本章知识点大纲
先把地图看一遍,知道这章要往哪走。
必会(学完必须能独立写出来)
- 用 TDD 节奏先写"新建帖子成功"的测试,再补实现让它变绿;再写一个集成测试验证"修改别人的帖子"被拦截。
- 给"按 ID 修改 / 删除"的接口加上
author_id条件,堵住越权漏洞。 - 区分制作库与线上库,在 Service 层用两个 Repository 把发表动作拆成"写制作库 + 同步线上库”。
- 写出分页接口的三种形态(Offset/Limit、Page、Cursor),并给列表接口加"只缓存第一页"的缓存。
- 讲清并写出缓存三大问题的对策:穿透用空值 / 布隆过滤器,击穿用 singleflight / 永不过期,雪崩用随机过期 / 多级缓存 / 熔断。
理解(能讲清原理和取舍,不要求背代码)
- 双库设计是从"读者该看到什么"一步步推出来的,不是拍脑袋。
- 事务应该在"整合多个数据库操作"的那一层控制:同库不同表在 DAO 层,跨库在 Service 层用重试补偿。
- MongoDB 的文档模型、雪花算法的比特布局、OSS + CDN 的回源关系各自解决什么问题。
- 四种缓存模式(Cache-Aside / Read-Through / Write-Through / Write-Behind)的差异与选型。
- 缓存与数据库一致性的四种更新顺序,为什么标准是"先更新 DB,再删缓存"。
了解(知道有这回事,面试能说出名字和一句话定位)
- DDD 的实体(Entity)与值对象(Value Object),同一对象在不同领域角色不同。
- Upsert(Insert or Update)语义,以及为什么 MySQL 制作库不能用它。
- 缓存淘汰策略 LRU / LFU / FIFO,Redis 默认用近似 LRU。
- OSS 多云存储的动机(厂商锁定 / 成本 / 容灾)与 Go 接口设计思路。
前置依赖与补课清单
| 需要你已掌握 | 在哪章学过 | 本章会不会补 |
|---|---|---|
| Gin 写接口、GORM 建表 / 事务 | 第 02 章 | ❌ 不补 |
JWT 登录态 c.GetInt64("user_id") | 第 03 章 | ❌ 不补 |
| 面向接口 / Repository 分层 / Table-Driven 测试 | 第 04 章 | 略加回顾 |
| Redis 的 string、TTL、删除 key | 第 03 章 | 略加回顾 |
| TDD(测试驱动开发) | 从未系统讲 | ✅ 补(3.2 节) |
| MongoDB / NoSQL 是什么 | 从未讲 | ✅ 补(3.12 节) |
| 为什么大文本不存 MySQL | 从未讲 | ✅ 补(3.12 节) |
| 缓存三大问题 / 缓存模式 | 从未讲 | ✅ 补(3.21 / 3.22 节) |
| 缓存淘汰策略 | 从未讲 | ✅ 补(3.25 节) |
| 雪花算法 | 从未讲 | ✅ 补(3.15 节) |
| OSS + CDN | 从未讲 | ✅ 补(3.18 节) |
| 什么是 Upsert | 从未讲 | ✅ 补(3.10 节) |
不需要你提前懂的:分布式事务、消息队列、搜索引擎。本章只讲单机 / 单库能落地的部分,跨服务的事后面章节再展开。
预计时长与学习成果
- 预计时长:4 小时(精读 + 跟着敲);只求主干 2 小时可过一遍。
- 学完你能跑出来的东西(验收标准,做到才算过关):
一个能"新建 → 修改 → 发表 → 撤回"帖子的 Go 服务,制作库与线上库用 GORM 管理;给创作者列表接口加上第一页缓存,且编辑文章后该缓存被清掉;
go test ./...全绿。
flowchart LR
A["3.1 需求分析
双库设计"] --> B["3.2-3.7 TDD
编辑/发表接口"]
B --> C["3.8-3.10 同步
事务分层"]
C --> D["3.11-3.18 增强
状态/Mongo/雪花/OSS"]
D --> E["3.19-3.22 缓存模式
三大问题"]
E --> F["3.23-3.25 列表缓存
清缓存/预加载"]
F --> G["第四节 跟着写"]
G --> H["第七节 面试问答"]这张图怎么读:这是本章的学习路线图,箭头即先后顺序:从"3.1 需求分析(双库设计)“出发,先由"3.2-3.7 TDD"把编辑/发表接口跑红再变绿,接着"3.8-3.10"解决同步与事务分层,“3.11-3.18"补状态维护、MongoDB、雪花算法与 OSS 选型;然后进入重头戏"3.19-3.22 缓存模式与三大问题”,由"3.23-3.25 列表缓存"收尾,最终通向"第四节 跟着写"与"第七节 面试问答”。缓存三块(E→F)是本章考点最密处,建议放慢速度。
三、正文
3.1 需求分析:双库是怎么推出来的
先抛问题
我们要做发帖功能。最朴素的需求是"用户能写文章、能看文章"。但一动手就冒出几个绕不开的问题:
- 创作者正在修改一篇文章时,读者看到的应该是哪一版?是改到一半的草稿,还是上一个发表版本?
- 作者点了"撤回",这篇文章对读者应该立刻消失吗?
- 同一篇已发表的文章被再次编辑、再次发表,线上库该怎样更新?
不先想清楚这些问题,写出来的代码会在"草稿外泄"“撤回不生效"“覆盖错版本"之间反复横跳。
给个直觉:便利店的"后厨"和"橱窗”
把内容社区想象成一家便利店:
- 后厨(制作库):厨师在里面切菜、试味、改配方,顾客看不见这里面乱成什么样。
- 橱窗(线上库):只摆已经做好的、可以卖的成品,顾客看到的只有这一层。
厨师炒好一道菜,才把它从后厨端到橱窗。中间改配方、试做失败的版本,永远留在后厨。制作库与线上库分离,本质就是把"编辑中的内容"和"读者可见的内容"从物理上分开——这样读者永远只看到"已发表版本”。
3.1.1 用例与可见性
从角色看需求:
- 对创作者:增删改查(自己能看到草稿、已发表、撤回的内容)。
- 对读者:只有查(并且只查得到"已发表"的内容)。
关键问题就一句:创作者修改时,读者该看到什么? 答案显然——只有发表后才对读者可见,修改期间读者应看到上一个发表版本。
这就引出了帖子状态:
| 状态 | 含义 | 谁可见 |
|---|---|---|
| 未发表 | 草稿,编辑中 | 仅创作者 |
| 已发表 | 对外公开 | 所有人 |
| 仅自己可见(撤回) | 撤回,不再公开 | 仅创作者 |
3.1.2 帖子状态:零值陷阱
状态用什么类型存?一个 uint8 就够。但有个坑必须先讲——一般不要把零值做成有意义的值。
所谓零值,就是 Go 里变量只声明、未赋值时自动获得的默认值(数字是 0、字符串是空串、布尔是 false)。一个刚 new 出来的结构体,状态字段必然是 0。如果 0 恰好等于"未发表",那"还没来得及赋值"和"真的是未发表"就再也区分不开了——这会引发"状态莫名变成未发表"的灵异 bug。
所以状态常量定义时让"未发表"是非 0 值:
// domain/article.go
package domain
const (
ArticleStatusUnknown uint8 = 0 // 未知,零值无意义
ArticleStatusUnpublished uint8 = 1 // 未发表
ArticleStatusPublished uint8 = 2 // 已发表
ArticleStatusPrivate uint8 = 3 // 仅自己可见
)
口诀:零值留给"未知",有意义的业务状态从 1 开始编号。
3.1.3 制作库与线上库:双库设计
顺着"可见性"这条线往下想,最直接的解法就是把"编辑中"和"读者可见"各放一个库。下面这张表对比两个库的用途与读写方,请特别留意"谁写"这一列的变化:线上库不再由创作者直接写,而是由系统在发表时同步写入——这正是"读者永远看到已发表版本"的保证来源。
| 库 | 用途 | 谁写 | 谁读 |
|---|---|---|---|
| 制作库 | 创作者编辑中的内容 | 创作者 | 创作者 |
| 线上库 | 读者能看到的内容 | 系统(发表时同步) | 读者 |
stateDiagram-v2
[*] --> 未发表
未发表 --> 已发表: 发表
已发表 --> 仅自己可见: 撤回
已发表 --> 未发表: 修改
仅自己可见 --> 未发表: 修改这张图怎么读:这是帖子在制作库里的状态机:初始处于"未发表",执行"发表"流转到"已发表"(此后读者可见);“已发表"可经"撤回"进入"仅自己可见”,也可经"修改"退回"未发表"——即 3.11 节口诀"保存永远打回未发表";“仅自己可见"同样只能经"修改"回到"未发表”。关键分支在"修改":无论当前是已发表还是已撤回,一次保存都会回到"未发表",只有再次"发表"才重新对读者可见。
flowchart LR
subgraph 创作者
M[制作库
编辑中]
E[编辑接口]
end
subgraph 读者
O[线上库
已发表]
R[阅读接口]
end
E --> M
P[发表] -->|同步| O
O --> R为什么分两个库? 因为创作者和读者的需求不同:创作者要频繁修改但不想让读者看到中间状态;读者要稳定的、已发表的内容。如果用单库,就必须每次修改都通过状态字段过滤"该不该对读者可见",逻辑复杂且易错(一不小心就把草稿漏给读者)。
3.1.4 删除:真删除 vs 假删除
- 假删除(设置为"仅自己可见"):满足大部分"对外不可见"的需求。
- 真删除:满足"彻底抹掉"的需求(涉及 GDPR、合规等场景)。
本课程只实现假删除(撤回),不实现真删除——真删除在内容社区里极其罕见,且一旦误删难以恢复。
3.1.5 发表的三种流程情况
把"保存"和"发表"组合起来,创作者实际会走出三种流程:
- 新建后直接发表:写完点发表——先插入制作库,再同步到线上库。
- 写了一半保存,之后第一次发表:草稿已在制作库,发表时更新它并首次同步到线上库。
- 修改已发表的文章后再次发表:线上库已有旧版本,发表时用新版本覆盖(Upsert 语义,详见 3.10 节)。
三种情况最终都收敛到同一个核心动作:先保存到制作库,再同步到线上库。这就是后面 Publish 接口的设计依据。
常见坑:把"保存"和"发表"混为一谈
新手常写一个 Save 既写制作库又偷偷改成已发表状态。后果:用户在编辑页每敲一次自动保存,文章就对外公开了。保存永远是"未发表",只有明确的"发表"动作才改状态——这条边界在 3.11 节"维护状态"里还会重申。
小收口
双库不是凭空来的,是从"读者该看到什么"一步步推出来的。它奠定了本章所有接口的骨架:编辑只碰制作库,发表才触碰线上库。下一节我们用 TDD 把这个骨架落地。
3.2 补课胶囊:什么是 TDD(红 - 绿 - 重构)
一句话:TDD(Test-Driven Development,测试驱动开发)就是先写测试,再写实现——让测试反过来逼出接口和实现。
类比:像先画好"验收标准"再去盖房子。不是房子盖完才验收,而是验收标准(测试)一开始就钉在那儿,盖一点、验一点,盖歪了立刻知道。
最小示例:
// 先写测试:期望 Save 被调用、返回 id=1 func TestSave(t *testing.T) { // 此时 Save 还不存在,这行编译不过 —— 这是"红" id, _ := svc.Save(ctx, art) if id != 1 { t.Fatal("期望返回 1") } // 实现不对 —— 还是"红" } // 补实现让测试变绿后,再回头把代码改漂亮 —— 这是"重构"
TDD 的核心循环(红 - 绿 - 重构):
flowchart TD
R[红: 写一个会失败的测试] --> G[绿: 最小实现让它通过]
G --> Ref[重构: 在不改测试前提下改漂亮代码]
Ref --> R- 红(Red):先写一个目前会失败的测试(甚至编译都过不了,因为函数还没写)。
- 绿(Green):用最省事的方式让测试通过,先别管代码好不好看。
- 重构(Refactor):测试已经兜底,放心把代码改整洁,改完测试还是绿的。
为什么先写测试? 四点好处各有来路:
- 理清思路:写测试时你必须站在"使用者"视角消费自己的接口,接口该怎么设计才顺手,写测试时最清楚。
- 查漏补缺:测试用例表本身就是一张需求清单,遗漏的场景会在表里现形。
- 方便重构:有测试兜底,你才敢放开手脚改代码——改坏了测试会立刻变红。
- 测试完善:一个功能迭代下来,配套的测试自然就齐了,不用事后再补。
重要澄清:TDD 和 DDD(领域驱动设计)没什么关系。DDD 是战略规划(划分子域、建领域模型),TDD 是落地具体功能的工程节奏。别把两者绑在一起记。
3.3 编辑接口:TDD 实战
需求捋清了,开始写代码。这一节我们走 TDD 节奏:先定义接口,再搭测试,再补实现。
3.3.1 第一步:定义接口
前端只需要两个字段:标题、内容。我们先把 Web 层的请求结构体和路由定下来:
// web/article.go
package web
type Article struct {
Id int64 `json:"id"` // 新建时前端不传,后端生成后返回
Title string `json:"title"` // 标题
Content string `json:"content"` // 内容
}
type ArticleHandler struct {
svc service.ArticleService // 注入业务服务,不直接碰数据库
}
func (h *ArticleHandler) RegisterRoutes(server *gin.Engine) {
g := server.Group("/articles")
g.POST("/edit", h.Edit) // 新建或修改
g.POST("/publish", h.Publish) // 发表
}
小收口:Web 层只负责"收请求、取登录态、调 Service",不掺任何业务逻辑——这是第 04 章分层原则的延续。
3.3.2 第二步:搭测试骨架(Table-Driven)
这是第 04 章介绍过的表驱动测试标准结构:先定义用例表(每个用例自带 mock 函数、请求体、期望结果),再统一遍历执行。眼下用例表先留空,后面往里逐个填。
// web/article_test.go
package web
func TestArticleHandler_Edit(t *testing.T) {
// 步骤 1:定义用例表(mock / reqBody / want...)
testCases := []struct {
name string
mock func(ctrl *gomock.Controller) service.ArticleService
reqBody string
wantCode int
wantRes Result[int64]
}{
// 用例稍后填
}
// 步骤 2:遍历用例
for _, tc := range testCases {
t.Run(tc.name, func(t *testing.T) {
ctrl := gomock.NewController(t)
defer ctrl.Finish()
svc := tc.mock(ctrl)
h := NewArticleHandler(svc)
server := gin.Default()
h.RegisterRoutes(server)
req := httptest.NewRequest(http.MethodPost, "/articles/edit",
strings.NewReader(tc.reqBody))
req.Header.Set("Content-Type", "application/json")
recorder := httptest.NewRecorder()
server.ServeHTTP(recorder, req)
assert.Equal(t, tc.wantCode, recorder.Code)
var res Result[int64]
err := json.Unmarshal(recorder.Body.Bytes(), &res)
require.NoError(t, err)
assert.Equal(t, tc.wantRes, res)
})
}
}
3.3.3 第三步:第一个用例"新建帖子成功"
往用例表里填第一个用例:不带 id、新建一篇帖子。mock 部分预先声明"Save 应该被调用一次、参数长这样、返回 id=1"——如果实现没有按预期调用 Save,或参数对不上,这条测试就会变红。
{
name: "新建帖子成功",
mock: func(ctrl *gomock.Controller) service.ArticleService {
svc := svcmocks.NewMockArticleService(ctrl)
// 期望 Save 被调用,返回 id=1
svc.EXPECT().Save(gomock.Any(), domain.Article{
Title: "我的标题",
Content: "我的内容",
}).Return(int64(1), nil)
return svc
},
reqBody: `{"title":"我的标题","content":"我的内容"}`,
wantCode: 200,
wantRes: Result[int64]{Data: 1},
},
3.3.4 提供实现让测试变绿
测试就位,现在补实现让它变绿。Edit 处理器只做三件事:绑定请求、从登录态取出创作者 ID、调用 Service 保存。
// web/article.go
package web
func (h *ArticleHandler) Edit(ctx *gin.Context) {
// 步骤 1:绑定请求体(JSON → 结构体)
var req Article
if err := ctx.Bind(&req); err != nil {
return
}
// 步骤 2:从登录态拿创作者 ID(第 03 章的 JWT 中间件写入的 user_id)
uid := ctx.GetInt64("user_id")
// 步骤 3:调用 Service 保存(业务逻辑在 Service 层)
id, err := h.svc.Save(ctx, domain.Article{
Id: req.Id,
Title: req.Title,
Content: req.Content,
AuthorId: uid, // 关键:作者来自登录态,前端说了不算
})
if err != nil {
ctx.JSON(http.StatusOK, web.Result{Code: 5, Msg: "系统错误"})
return
}
ctx.JSON(http.StatusOK, web.Result{Data: id})
}
| 行 | 代码 | 为什么这么写 |
|---|---|---|
| 9-11 | ctx.Bind(&req) | 自动按 Content-Type 解析 JSON;解析失败直接返回 |
| 14 | ctx.GetInt64("user_id") | 登录态由第 03 章的 JWT 中间件塞进 context,这里取出来 |
| 18 | AuthorId: uid | 作者 ID 必须来自服务端登录态,绝不信任前端传的 |
3.3.5 补课胶囊:DDD 的实体与值对象
实现里反复出现的 domain.Article,正好借机认识 DDD(领域驱动设计)中两个最基础的概念。区分它们只看一条:有没有属于自己的身份标识。
- 实体(Entity):独立存在的对象,有自己的 ID(如一篇文章、一个用户)。
- 值对象(Value Object):依附于实体,作为属性出现,没有独立身份(如地址、金额)。
关键洞察:同一个对象在不同领域里角色不同。一个"人":
- 在用户领域中是实体(独立存在,有自己的用户 ID)。
- 在帖子领域中是值对象(只是某些帖子的"创作者"属性)。
所以在帖子领域里,“用户"退化为 AuthorId 这个整数——它不再是一个完整的用户对象,只是帖子上贴的一个"谁写的"标签。
常见坑:把 AuthorId 让前端传
如果 AuthorId 直接来自请求体 req.AuthorId,攻击者可以伪造任意作者 ID 发帖。永远记住:身份标识从登录态取,前端传来的身份一律视为不可信。
小收口
我们用 TDD 走完了"定义接口 → 搭测试 → 填用例 → 补实现"的循环,第一个用例变绿。接下来要回答一个接口设计上的关键问题:同一个 /edit 怎么区分"新建"和"修改”?
3.4 新建还是更新:共用接口怎么区分
编辑接口(/edit)同时承担"新建"和"修改"两种语义,靠什么区分?看请求里有没有 id:
- 没带 id:新建,DAO 执行 Insert。
- 带了 id:修改,DAO 执行 Update。
这也解释了 Save 方法的签名为什么是 (int64, error) 而不是只返回 error:新建时前端根本不知道 id,必须由后端生成后返回,前端下次保存时才能把它带上。如果你最初把签名设计成只返回 error,等发现"前端要拿 id 区分新建和更新"时,就得回头改签名、改调用方、改测试——这正是 TDD 迭代中很典型的"接口被需求逼着演化"。
小收口
区分逻辑清楚了:有 id 走 Update,无 id 走 Insert。下一个用例,我们就来验证"修改"这条路径——并且要顺手堵住一个致命的安全漏洞。
3.5 第二个测试用例:修改帖子(集成测试)
新建的用例走通了,第二个用例轮到"修改"。它与第一个用例形态不同,是集成测试风格:before 钩子先往数据库插入一条旧数据,请求发出后由 after 钩子验证字段确实被改了、且不该改的字段(如 author_id)纹丝不动。
注意:集成测试用例用到的表结构体,相比 3.3.2 的 mock 版多了
before、after两个字段。需要在使用前扩展定义——可以给同一个用例结构体补上before func(t *testing.T)、after func(t *testing.T)两个字段(mock 用例把这两个字段留空即可),或单开一个集成用例结构体。
{
name: "修改帖子成功",
before: func(t *testing.T) {
// 在数据库中插入一条已有数据(id=1,作者是 123)
db.Exec("INSERT INTO articles (id, title, content, author_id) VALUES (?, ?, ?, ?)",
1, "旧标题", "旧内容", 123)
},
after: func(t *testing.T) {
// 验证字段都被修改了
// 验证不该修改的字段(如 author_id)没变
var a Article
db.First(&a, 1)
assert.Equal(t, "新标题", a.Title)
assert.Equal(t, int64(123), a.AuthorId) // 作者不该变
},
reqBody: `{"id":1,"title":"新标题","content":"新内容"}`,
wantCode: 200,
},
为什么用集成测试而不是纯 mock? 因为"修改"要验证的是数据库真实落地的数据,mock 只能验证"方法被调用了",验证不了"字段确实改对、没改歪"。这类"端到端看结果"的场景,集成测试比单元测试更靠谱。
小收口
修改路径也通了。但上面这个用例有个隐患没覆盖——如果攻击者传的是别人的帖子 id 呢?这就是下一个、也是最重要的测试用例。
3.6 第三个测试用例:修改别人的帖子(安全!)
这是一个容易被忽略但非常重要的问题:文章 ID 从前端接收,攻击者可以传别人的 ID 去调修改接口,从而改全网帖子。
修复办法是在更新条件里带上 author_id——只有"自己的"帖子才改得动:
// dao/article.go
package dao
func (d *ArticleDAO) UpdateById(ctx context.Context, art Article) error {
// 步骤 1:带 author_id 条件,只改"自己的"帖子
res := d.db.WithContext(ctx).Model(&Article{}).
Where("id = ? AND author_id = ?", art.Id, art.AuthorId). // 关键!
Updates(map[string]any{
"title": art.Title,
"content": art.Content,
"utime": time.Now().UnixMilli(),
})
if res.Error != nil {
return res.Error
}
// 步骤 2:没命中行 = ID 不存在 或 不是作者
if res.RowsAffected == 0 {
return errors.New("更新失败")
}
return nil
}
为什么用 Where("id = ? AND author_id = ?") 而不是先查后判? 节省一次数据库查询——先 SELECT 再 UPDATE 是两趟,合一趟更省。
缺陷:无法区分"ID 不存在"和"不是作者"。但都是异常情况,统一报"更新失败"即可,无需细究。
id 做更新等于把别人的数据敞开门——攻击者遍历 ID 就能改全网帖子。所有类似场景(订单、评论、资料)都要排查,可以用 Gin middleware 提供统一校验。小收口
安全底线钉死了:任何"按 ID 操作"的写接口都带 author_id。现在 编辑 接口完整了,接下来写它的兄弟——发表接口。
3.7 发表接口:单元测试 TDD
3.7.1 接口定义
Publish 的 Web 层代码与 Edit 几乎一模一样——同样是绑定请求、取登录态、调 Service 三步,只是换成了 svc.Publish。这种暂时的重复并非坏事:两个接口未来会走向分化(比如发表前可能要做审核),等需求真正分化时再抽象不迟。
// web/article.go
func (h *ArticleHandler) Publish(ctx *gin.Context) {
// 步骤 1:绑定请求体
var req Article
if err := ctx.Bind(&req); err != nil {
return
}
// 步骤 2:从登录态拿创作者 ID(第 03 章的 JWT 中间件写入的 user_id)
uid := ctx.GetInt64("user_id")
// 步骤 3:调用 Service 发表
id, err := h.svc.Publish(ctx, domain.Article{
Id: req.Id,
Title: req.Title,
Content: req.Content,
AuthorId: uid,
})
if err != nil {
ctx.JSON(http.StatusOK, web.Result{Code: 5, Msg: "系统错误"})
return
}
ctx.JSON(http.StatusOK, web.Result{Data: id})
}
3.7.2 单元测试用例
写法与 3.3.3 如出一辙,只是把期望调用的方法换成了 Publish:
{
name: "新建发表成功",
mock: func(ctrl *gomock.Controller) service.ArticleService {
svc := svcmocks.NewMockArticleService(ctrl)
svc.EXPECT().Publish(gomock.Any(), domain.Article{
Title: "标题",
Content: "内容",
}).Return(int64(1), nil)
return svc
},
reqBody: `{"title":"标题","content":"内容"}`,
wantCode: 200,
wantRes: Result[int64]{Data: 1},
},
3.7.3 防御性测试用例
理论上 Publish 的单元测试还差三个用例:Bind 返回错误、找不到 user、Publish 返回错误。但你会发现一个尴尬的事实:这些分支从 Publish 的代码里根本"看不出来"(Bind 失败由 gin 框架处理,找不到 user 由 JWT 中间件拦截)——这些逻辑压根不归 Web 层管。
那为什么还要写?这叫防御性测试:担心将来有人在这个位置引入额外逻辑(比如 Publish 前加个校验),现在就提供用例把当前行为"固化"下来,将来谁改坏了测试立刻变红。说实话,大多数防御性测试都是杞人忧天,但在关键链路上花几分钟固化行为,性价比是正的。
小收口
Web 层两个接口都齐了。但真正的设计难题藏在 Service 层:发表要同时写制作库和线上库,这个"同步"动作放哪一层? 这正是下一节的话题。
3.8 Service 层:同步数据到线上库
Web 层只是"收请求、调 Service",真正的同步逻辑在 Service 层。发表的核心就一句话:先保存到制作库,再同步到线上库。
3.8.1 在哪一层同步?
把三个候选层放在一起对比,“单体/微服务"一列指该方案适用的架构形态。看完这张表可以带走一个规律:层级越低、离数据库越近,越容易用上本地事务;层级越高,越贴合微服务的服务边界,但一致性只能靠补偿手段维护。
| 层 | 单体/微服务 | 优点 | 缺点 |
|---|---|---|---|
| Web/聚合服务 | 微服务 | 创作者和读者各自有服务,自然分离 | 跨服务调用 |
| Service | 单体/微服务 | 调用不同 repo 保存 | 部分失败难处理 |
| Repository | 单体 | 可以用本地事务 | repository 强依赖具体 DAO |
3.8.2 Service 层分发:用两个 Repository
本课程的选择是在 Service 层分发:注入两个 Repository,一个管制作库、一个管线上库,Publish 依次调用它们。
// service/article.go
type articleService struct {
authorRepo repository.ArticleAuthorRepository // 操作制作库
readerRepo repository.ArticleReaderRepository // 操作线上库
}
func (s *articleService) Publish(ctx context.Context, art domain.Article) (int64, error) {
// 步骤 1:保存到制作库(状态设为已发表)
art.Status = domain.ArticleStatusPublished
id, err := s.authorRepo.Save(ctx, art)
if err != nil {
return 0, err
}
art.Id = id
// 步骤 2:同步到线上库
err = s.readerRepo.Save(ctx, art)
if err != nil {
// 部分失败:制作库已写,线上库失败
// 创作者会看到发表失败,可以重试
// 读者要么看不到,要么看到上一次发表的版本
return 0, err
}
return id, nil
}
flowchart TD
P[Publish] --> A[写制作库]
A -->|成功| R[写线上库]
R -->|成功| OK[发表完成]
R -->|失败: 部分失败| F[创作者可重试
读者看到旧版/无]3.8.3 部分失败问题
为什么不用事务一把梭?
- Service 层不知道 repository 用什么存储(关系型?NoSQL?)。
- 制作库和线上库可能是两个不同的数据库,跨库事务做不到(MySQL 不支持跨库的分布式事务)。
怎么处理部分失败?
- 创作者能看到失败,可以重试。
- 读者要么看不到帖子,要么看到上次版本——不会读到"半篇”。
- 可以引入重试机制,但建议先上线观察,再决定是否需要。
小收口
Service 层用两个 Repository 完成了"写制作库 + 同步线上库",并坦然接受了"部分失败靠重试"的现实。那如果制作库和线上库其实是同一个 MySQL 里的两张表呢?事务就能用了——下一节看。
3.9 Repository / DAO 层同步数据
如果制作库和线上库是同库不同表,就可以在 DAO 层用 GORM 闭包事务一把梭:
// dao/article.go
func (d *ArticleDAO) Sync(ctx context.Context, art Article) error {
// 用 GORM 的闭包事务:函数返回 nil 才提交,返回 error 自动回滚
return d.db.WithContext(ctx).Transaction(func(tx *gorm.DB) error {
// 步骤 1:制作库更新状态为已发表
if err := tx.Model(&Article{}).
Where("id = ?", art.Id).
Updates(map[string]any{"status": art.Status}).Error; err != nil {
return err
}
// 步骤 2:线上库 Upsert(存在则更新,不存在则插入)
return tx.Clauses(clause.OnConflict{
Columns: []clause.Column{{Name: "id"}},
DoUpdates: clause.Assignments(map[string]any{
"title": art.Title,
"content": art.Content,
"status": art.Status,
}),
}).Create(&PublishedArticle{
Id: art.Id,
Title: art.Title,
Content: art.Content,
Status: art.Status,
}).Error
})
}
为什么不把事务开在 Repository 层? 如果 repository 层直接持有 gorm.DB,会强依赖"本该只有 DAO 才依赖的东西":
- 违背面向接口原则:repository 理论上只该认识 DAO 接口,不该知道底层是 GORM 还是别的。
- 跨层依赖:将来把 gorm.DB 换成别的实现,repository 也得跟着改。
对比下来结论很清晰:需要本地事务时,在 DAO 层控制比在 repository 层更干净——DAO 本来就知道自己用的是关系型数据库,让它管事务天经地义;repository 保持"只面向 DAO 接口"的纯洁性。
小收口
同库不同表 → DAO 层事务;跨库 / 跨服务 → Service 层重试补偿。事务跟着"整合多个 DB 操作"的那一层走。3.10 节把 Upsert 和这个原则收个尾。
3.10 Upsert 语义与"谁来控制事务"
3.10.1 补课胶囊:什么是 Upsert
一句话:Upsert = “Update + Insert”,存在就更新,不存在就插入,一条语句搞定。
类比:像便利店的"会员卡"——卡号已存在就更新积分,卡号不存在就现场办一张。不用先翻本子查"有没有这张卡"再决定"改还是建"。
最小示例(GORM):
// 冲突判定列是 id;冲突时就更新后面的字段,否则插入 db.Clauses(clause.OnConflict{ Columns: []clause.Column{{Name: "id"}}, // 冲突判定列 DoUpdates: clause.Assignments(map[string]any{...}), // 冲突时更新这些列 }).Create(&art)
线上库为什么需要 Upsert?因为发表的那一刻,我们无法预知这篇文章此前是否发表过:可能已发表过(该走更新),也可能从未发表(该走插入)。与其先查一次再分支判断,不如让数据库用一条语句同时覆盖两种情况。
3.10.2 为什么制作库不用 Upsert?
因为制作库的更新需要校验 author_id(只有作者能改自己的),而 MySQL 不支持 ON DUPLICATE KEY UPDATE WHERE author_id=123 这种带条件的写法。所以制作库走"先按 id+author_id 查,命中才更新"的普通路径(见 3.6 节),只有线上库能用 Upsert 一把梭。
3.10.3 事务控制决策表
把 3.8 与 3.9 的讨论收拢成一张表。判据只有一条——事务必须跟着"整合多个数据库操作"的那一层走:
| 层 | 何时用 |
|---|---|
| Service | 控制分布式事务,不控制数据库事务 |
| Repository | 可以控制数据库本地事务,但理论上不知道底层用 GORM |
| DAO | 最常在这里控制事务 |
flowchart TD
Q{在哪一层整合多个 DB 操作?}
Q -->|同库不同表| D[DAO/Repository 层本地事务]
Q -->|跨库/跨服务| S[Service 层用重试补偿]小收口
写链路的骨架全部立起来了:双库设计、TDD 接口、越权防护、事务分层、Upsert。但文章功能还"能用不好用"——状态怎么维护、超大正文往哪存、主键怎么生成。下一节(PART2)我们补齐这些短板,并把存储选型视野从 MySQL 扩到 MongoDB 与 OSS。
3.11 维护状态
骨架有了,但"状态"这个字段到底什么时候设、设成什么,得说清楚,否则会出现"明明撤回了,读者还能看见"的 bug。
3.11.1 状态流转
制作库:未发表 → 已发表 → 仅自己可见(撤回) 线上库:已发表 → 仅自己可见(撤回)
注意:撤回是双向的——制作库和线上库都要把状态改成"仅自己可见",读者才会真正看不见。
3.11.2 状态设置的时机
- 保存时:设置为
ArticleStatusUnpublished- 新建-保存:直接未发表
- 修改-保存:依旧未发表(即使之前发表过,一保存就回退到未发表)
- 发表-修改-保存:从已发表变回未发表
- 发表时:制作库和线上库都改为
ArticleStatusPublished - 撤回时:通过新接口
/articles/withdraw设置为ArticleStatusPrivate
口诀:保存永远把状态打回"未发表",只有"发表"这个明确动作才点亮"已发表"。这样用户每次自动保存,文章都不会意外公开。
3.11.3 撤回接口
// web/article.go
func (h *ArticleHandler) Withdraw(ctx *gin.Context) {
// 接收 id,校验登录态(同样要带 author_id)
// 调用 svc.Withdraw
}
// dao 通过事务同时更新两张表的状态(都带 author_id 条件)
func (d *ArticleDAO) SyncStatus(ctx context.Context, id, authorId int64, status uint8) error {
// 步骤 1:制作库 + 线上库同时更新状态(带 author_id 防越权)
return d.db.Transaction(func(tx *gorm.DB) error {
if err := tx.Model(&Article{}).
Where("id = ? AND author_id = ?", id, authorId).
Updates(map[string]any{"status": status}).Error; err != nil {
return err
}
return tx.Model(&PublishedArticle{}).
Where("id = ?", id).
Updates(map[string]any{"status": status}).Error
})
}
3.11.4 存储状态映射
直接在 DAO 里用 domain 层的 uint8 常量值是可以接受的,但不够优雅。
- 领域状态:domain 中的状态最完整、最真实(未发表 / 已发表 / 仅自己可见)。
- 存储状态:数据库中的状态可能从领域状态衍生,也可能有辅助状态(如"软删除标记")。
实践中状态映射一直是难点,一种做法是在 DAO 层做映射表,把领域语义翻译成存储字段。本课用"两张表都存同一个 status 字段"的最简做法,足够讲清思路。
小收口
状态机的边界钉死了。接下来进入存储选型——先认识一个全新的数据库:MongoDB。
3.12 补课胶囊:MongoDB / NoSQL 是什么(含"为什么大文本不存 MySQL")
一句话:NoSQL 是"不仅仅是 SQL"的非关系型数据库家族;MongoDB 是其中最流行的一种,它存的是文档(类似 JSON),而不是 SQL 的"行"。
类比:MySQL 像规整的 Excel 表格——每一行结构必须一致、列是预先定好的。MongoDB 像一摞便利贴——每张便签上写什么字段都行,不必统一格式。
最小示例:
// 一条 MongoDB 文档就是一个结构体,字段可随意增删 type Article struct { Id int64 `bson:"_id"` Title string `bson:"title"` Content string `bson:"content"` // 几万字的长文也能直接塞进去 }
为什么大文本不存 MySQL? 三个硬伤:
- 行溢出(Row Overflow):MySQL 一行有大小上限(约 65535 字节),超长的
text会被存到"溢出页",读它要多一次磁盘寻址,慢。 - 索引效率低:长文本字段很难建高效索引;若正文参与查询(如按内容搜),MySQL 的全文索引能力弱、且会把整行拖慢。
- 灵活度差:文章正文之外可能还有"摘要、标签、大纲"等结构,关系型表要加列才能存;而文档型数据库加字段零成本,不用
ALTER TABLE。
什么时候选 MongoDB?
- 数据模式经常变(动态模型)。
- 数据量巨大、不想自己管分库分表(MongoDB 原生支持分片)。
- 要存大段文本、嵌套结构(如文章的富文本、评论树)。
| 维度 | MySQL(关系型) | MongoDB(文档型) |
|---|---|---|
| 存储单位 | 行(Row),结构固定 | 文档(Document),结构自由 |
| Schema | 建表时定好列 | 无模式,随时加字段 |
| 扩展开销 | 分库分表很重 | 分片自动搞定 |
| 适合 | 强一致、结构化、多表关联 | 大文本、灵活模型、海量数据 |
小收口
大文本、灵活结构 → 交给 MongoDB。接下来我们真正动手用 MongoDB,先建立它的基本概念。
3.13 MongoDB 实战
3.13.1 何时用 MongoDB(三点特性)
MongoDB 的三个基本特性:
- 面向集合存储:一个集合(Collection)类似 MySQL 的表,里面存很多文档(Document)。
- 模式自由:无需预先定义文档格式,可随时调整字段。
- 支持分片:MongoDB 自动解决分片的扩容问题,加实例即可应对流量和数据增长。
我们最看重两个优点:灵活的文档模型(不用预先定义表结构)+ 易于横向扩展(分片应对增长)。
3.13.2 基本概念(用 MySQL 做对照)
MongoDB 的概念体系可以用 MySQL 做参照系来理解——两者核心概念几乎一一对应,只是换了名字:
| MongoDB | MySQL 对应 |
|---|---|
| Database | Database |
| Collection | Table |
| Document | Row |
flowchart LR
DB[(Database)] --> C1[Collection 文章]
DB --> C2[Collection 用户]
C1 --> D1[Document]
C1 --> D2[Document]3.13.3 基本操作
// dao/article_mongo.go
package dao
import (
"context"
"errors"
"go.mongodb.org/mongo-driver/bson"
"go.mongodb.org/mongo-driver/mongo"
"go.mongodb.org/mongo-driver/mongo/options"
)
func basicOps(ctx context.Context) {
// 步骤 1:初始化客户端(连本机 27017)
client, _ := mongo.Connect(context.TODO(),
options.Client().ApplyURI("mongodb://localhost:27017"))
db := client.Database("webook")
col := db.Collection("articles")
// 步骤 2:插入(未显式指定 _id,驱动会自动生成 12 字节的 ObjectID)
res, _ := col.InsertOne(ctx, Article{Title: "x", Content: "y"})
id := res.InsertedID // 类型是 interface{},自动生成时底层值是 primitive.ObjectID
_ = id
// 步骤 3:查找(用结构体构造条件)
var art Article
err := col.FindOne(ctx, Article{Id: 1}).Decode(&art)
if errors.Is(err, mongo.ErrNoDocuments) {
// 没找到
}
// 步骤 4:更新 / 删除
_, _ = col.UpdateOne(ctx, bson.M{"_id": 1}, bson.M{"$set": bson.M{"title": "new"}})
_, _ = col.DeleteOne(ctx, bson.M{"_id": 1})
}
col.FindOne(ctx, Article{Id: 1}) 里,结构体中值为零的字段(空字符串、0、false)默认也会成为查询条件(只有 nil 指针/接口/切片/map 默认被省略)——这既是方便之处也是坑:Article{Id: 1} 会连带把 title:""、content:"" 也拼进 filter,把查询约束得过紧,查出来的结果反而比预期少、甚至查不到。想省略零值字段要给模型加 bson:",omitempty" 标签;对"零值本身有意义"的字段,老老实实用 bson.M 显式写条件。3.13.4 BSON 与 E/D/M/A
BSON 类似 JSON 的二进制序列化协议。bson.E/D/M/A 是它的四种构造类型:
| 类型 | 本质 | 说明 |
|---|---|---|
| E | 键值对 | bson.E{Key: "x", Value: 1} |
| D | E 的切片(有序) | bson.D{{Key: "x", Value: 1}} |
| M | Map(无序) | bson.M{"x": 1},key 必须是 string |
| A | 切片 | bson.A{1, 2, 3} |
3.13.5 复杂查询:OR / AND / IN
// OR:x=1 或 y=2
col.Find(ctx, bson.D{{Key: "$or", Value: bson.A{
bson.D{{Key: "x", Value: 1}},
bson.D{{Key: "y", Value: 2}},
}}})
// IN:x 属于 {1,2,3}
col.Find(ctx, bson.D{{Key: "x", Value: bson.D{{Key: "$in", Value: bson.A{1, 2, 3}}}}})
可读性很差,但记住:bson.E 基本都放在 bson.D 里(D 保证顺序,适合做查询条件)。
只查询特定字段(Projection):类似 SQL 的 SELECT title, content,避免把整个大文档都拉回来:
// 只取 title 和 content(注意传入的是 bson.D)
col.Find(ctx, bson.M{"author_id": 123},
options.Find().SetProjection(bson.D{
{Key: "title", Value: 1},
{Key: "content", Value: 1},
}))
3.13.6 索引设计:ESR 原则
E(Equal)→ S(Sort)→ R(Range) 的相对顺序。
举例:查询 author_id=123 一周内(ctime)发布、按 read_cnt 排序的帖子,索引顺序是:author_id, read_cnt, ctime。
可扩展为 ER、EESR、ESSR、ESRR,只要 ESR 相对顺序不变即可。
小收口
MongoDB 的基本概念和操作齐了。但前面代码里 _id 是字符串,而我们的系统主键一直是 int64——这引出主键重构问题。
3.14 MongoDB 重构:主键问题
MongoDB 没有 MySQL 那种自增主键,_id 是 12 字节的字节切片,不能直接当 int64 用。主键方案得重新设计,可选四条路:
| 方案 | 代价 |
|---|---|
| ID 改 string | 影响整个系统(所有外键、接口都要改) |
| ID 生成策略(雪花算法) | 改动最小 |
定义新接口 MongoArticleDAO | 放弃 DAO 一致性 |
| GUID | 字符串 ID,同方案一 |
本课程选雪花算法:改动最小,最有教学价值。
确定方案后,重构的方法也是 TDD 式的:把 GORM 版本的集成测试复制一份,改成基于 MongoDB 的测试(插入、查询细节换成 MongoDB 写法),再让实现去把新测试跑绿。配合依赖注入,测试里可传入不同 DAO 实现构造出同一个 Handler——从 Handler 层开始整链路测试。已有的 GORM 测试原样保留,两套实现各有测试保护,重构才有安全网。
小收口
主键要换成分布式 ID。为什么不用 MySQL 自增?这就引出一个全新概念:雪花算法。
3.15 补课胶囊:雪花算法(分布式 ID)
一句话:雪花算法(Snowflake)是 Twitter 开源的分布式 ID 生成方案——把"时间戳 + 机器编号 + 序号"打包进一个 64 位整数,让集群中每台机器不依赖任何中心节点,就能独立生成全局唯一且大体按时间递增的 ID。
类比:快递单号 = 发货日期 + 分拣中心编号 + 当天流水号。两家分拣中心(不同机器)即使同时打包,因为"分拣中心编号"不同,单号也绝不重复。
最小示例:
import "github.com/bwmarrin/snowflake" node, _ := snowflake.NewNode(1) // worker ID = 1(这台机器的编号) id := node.Generate().Int64() // 生成 int64 ID,直接能当 MySQL 主键
为什么不用自增 ID? 因为自增 ID 要求有一个中心节点统一发号(数据库自己干),在分布式 / 多库分表场景下做不到;而且自增 ID 会泄露业务规模(用户 id=1000 说明你只有约 1000 个用户)。雪花算法把"谁生成的、什么时候生成的"编进 ID 本身,每台机器自己算,天然适合分布式。
它的比特布局(64 位):
| 比特位 | 1 | 41 | 10 | 12 |
|---|---|---|---|---|
| 含义 | 保留 | 时间戳(毫秒) | Worker ID(机器号) | 自增序号 |
| 容量 | 无 | 可用约 69 年 | 最多 1024 个节点 | 每毫秒 4096 个 ID |
flowchart LR
T[41bit 时间戳
毫秒] --> ID[64bit 雪花 ID]
W[10bit 机器ID] --> ID
S[12bit 序号
每毫秒4096] --> ID小收口
有了雪花算法,MongoDB 版 DAO 的主键问题迎刃而解。接下来写真正的 MongoDB DAO。
3.16 MongoDB DAO 实现
ID 方案定了,MongoDB 版 DAO 就水到渠成。下面两个方法对应"新建"和"修改":Insert 里先用雪花算法生成 ID 再插入;Update 的过滤条件里带着 author_id——和 GORM 版防越权的思路一脉相承。
// dao/article_mongo.go
type ArticleMongoDAO struct {
col *mongo.Collection
node *snowflake.Node
}
func (d *ArticleMongoDAO) Insert(ctx context.Context, art Article) (int64, error) {
// 步骤 1:雪花算法生成 ID
art.Id = d.node.Generate().Int64()
art.Ctime = time.Now().UnixMilli()
art.Utime = time.Now().UnixMilli()
// 步骤 2:插入
_, err := d.col.InsertOne(ctx, art)
if err != nil {
return 0, err
}
return art.Id, nil
}
func (d *ArticleMongoDAO) Update(ctx context.Context, art Article) error {
// 步骤 1:显式指定要更新的字段,避免零值覆盖
_, err := d.col.UpdateOne(ctx,
bson.M{"_id": art.Id, "author_id": art.AuthorId}, // 带 author_id 防越权
bson.M{"$set": bson.M{
"title": art.Title,
"content": art.Content,
"utime": time.Now().UnixMilli(),
}})
return err
}
更新时显式指定字段,是为了避免零值覆盖($set 里没写的字段不会被碰)。另一种偷懒做法是在模型字段上加 bson:",omitempty" 标签,让零值字段序列化时被忽略——代码更省,但"哪些字段会被更新"变隐晦;团队新人多的话,显式写出来更稳。
小收口
MongoDB 版的增改有了。发表时线上库也要用 MongoDB 落地,且同样要 Upsert——下一节看。
3.17 MongoDB 发表接口(Upsert)
发表在 MongoDB 侧同样要实现 Upsert 语义,靠的是 UpdateOne 搭配两个更新操作符:
$set:无论插入还是更新都会写入的字段。$setOnInsert:只在首次插入时生效——正好用来放"只该写一次"的创建时间。
// dao/article_mongo.go
func (d *ArticleMongoDAO) Sync(ctx context.Context, art Article) error {
// 没有 MongoDB 事务(本场景不需要)
// Upsert 语义:$set 用于更新,$setOnInsert 用于插入时引入额外字段
filter := bson.M{"_id": art.Id}
update := bson.M{
"$set": bson.M{
"title": art.Title,
"content": art.Content,
"status": art.Status,
"utime": time.Now().UnixMilli(),
},
"$setOnInsert": bson.M{
"ctime": time.Now().UnixMilli(), // 只在第一次插入时写
},
}
// 步骤 1:Upsert(存在则更新,不存在则插入)
_, err := d.col.UpdateOne(ctx, filter, update, options.Update().SetUpsert(true))
return err
}
小收口
MongoDB 全链路打通。还有一类数据——图片、视频、超大正文——既不合适塞 MySQL 也不合适全塞 MongoDB,它走另一条路:OSS + CDN。
3.18 补课胶囊:OSS + CDN 是什么
一句话:OSS(对象存储)是用来存大文件 / 大文本的服务(像无限容量的网盘);CDN(内容分发网络)是把内容缓存到全球边缘节点、让用户就近访问的加速网络。两者常配合:OSS 当源站,CDN 做加速。
类比:OSS 是"中央仓库",所有货都放这儿;CDN 是"小区门口的便利店",从中央仓库进货后就近卖给居民,居民不用每次都跑中央仓库。
最小示例:
// 把一段 HTML 正文上传到 OSS(兼容 S3 API) _, err := client.PutObject(ctx, &s3.PutObjectInput{ Bucket: aws.String("webook"), Key: aws.String(fmt.Sprintf("article/%d.html", id)), Body: strings.NewReader(content), })
3.18.1 何时用 OSS
OSS 适合存储大文本、文件、多媒体、图片。小文本直接存数据库没问题——把每行几百字节的小数据扔进对象存储反而小题大做。帖子正文这种可能几万字的"大文本",以及图片视频,才是 OSS 的舞台。
OSS 三个基本概念:
| 概念 | 含义 | 白话 |
|---|---|---|
| Bucket(桶) | 逻辑上的分组 | 一个业务一个桶:文章一个桶、用户头像另一个桶 |
| Object(对象) | 你存储的东西 | 桶里的每一个文件 |
| ACL(访问控制列表) | 权限控制方案 | 一张表写满"谁能操作哪个资源、能怎么操作" |
权限控制可以做在 Bucket 上(整桶统一管理),也可以做在单个 Object 上(精确到文件)。OSS 服务商还提供冷数据存储(访问频率低的数据放到更便宜的介质),进一步降成本。
3.18.2 OSS 与 CDN
- OSS:对象存储,存原始数据(源站)。
- CDN:内容分发网络,分布全球的缓存节点,提高访问速度和稳定性。
常见组合:OSS 作为 CDN 的回源站点——用户先打 CDN,CDN 没缓存才回源到 OSS 取。
flowchart LR
U[用户] --> CDN[CDN 边缘节点
缓存]
CDN -->|未命中回源| OSS[OSS 对象存储]3.18.3 OSS 与 S3 API
各大云厂商都有 OSS 服务,API 之间有差异。好在绝大多数都兼容 S3 API(Amazon 推出的 Simple Storage Service,RESTful 风格)。所以写一套兼容 S3 的代码,就能对接多家云。
一段可运行的 S3 上传示例(endpoint 以腾讯云 COS 为例):
//go get github.com/aws/aws-sdk-go-v2/config
//go get github.com/aws/aws-sdk-go-v2/service/s3
import (
"context"
"fmt"
"strings"
"github.com/aws/aws-sdk-go-v2/aws"
"github.com/aws/aws-sdk-go-v2/config"
"github.com/aws/aws-sdk-go-v2/credentials"
"github.com/aws/aws-sdk-go-v2/service/s3"
)
// uploadArticleHTML 把文章 HTML 正文上传到 OSS(兼容 S3 API,这里以腾讯云 COS 为例)
func uploadArticleHTML(ctx context.Context, id int64, content string) error {
// 步骤 1:加载配置(区域 + 静态凭证)
cfg, err := config.LoadDefaultConfig(ctx,
config.WithRegion("us-east-1"),
config.WithCredentialsProvider(credentials.NewStaticCredentialsProvider(
"accessKey", "secretKey", "")),
)
if err != nil {
return fmt.Errorf("加载配置: %w", err)
}
// 步骤 2:指定兼容 S3 的 endpoint(这里是腾讯云 COS)
client := s3.NewFromConfig(cfg, func(o *s3.Options) {
o.BaseEndpoint = aws.String("https://cos.ap-shanghai.myqcloud.com")
})
// 步骤 3:上传 HTML 正文(注意 charset 防乱码)
_, err = client.PutObject(ctx, &s3.PutObjectInput{
Bucket: aws.String("webook"),
Key: aws.String(fmt.Sprintf("article/%d.html", id)),
Body: strings.NewReader(content),
ContentType: aws.String("text/html;charset=utf-8"), // 少写 charset 会乱码
})
return err
}
3.18.4 设计决策:只把 Content 存到 OSS
读者可能希望看到某创作者的文章列表,所以只在 OSS 存 Content,其他字段(标题、作者、状态等)存数据库,以参与复杂查询(排序、过滤、分页)。
// dao/article_oss.go
type ArticleDAOS3 struct {
db *gorm.DB
s3 *s3.Client
}
func (d *ArticleDAOS3) Sync(ctx context.Context, art Article) error {
// 步骤 1:制作库(不含 Content,Content 在 OSS)
return d.db.Transaction(func(tx *gorm.DB) error {
if err := tx.Model(&Article{}).Where("id = ?", art.Id).
Updates(map[string]any{"title": art.Title, "status": art.Status}).Error; err != nil {
return err
}
// 步骤 2:线上库(不含 Content)。字段名以你自己的 PublishedArticle 结构为准
if err := tx.Clauses(clause.OnConflict{
Columns: []clause.Column{{Name: "id"}},
DoUpdates: clause.Assignments(map[string]any{
"title": art.Title,
"status": art.Status,
"utime": art.Utime,
}),
}).Create(&PublishedArticle{
Id: art.Id,
Title: art.Title,
Status: art.Status,
}).Error; err != nil {
return err
}
// 步骤 3:Content 上传到 OSS
_, err := d.s3.PutObject(ctx, &s3.PutObjectInput{
Bucket: aws.String("webook"),
Key: aws.String(fmt.Sprintf("article/%d.html", art.Id)),
Body: strings.NewReader(art.Content),
})
return err
})
}
3.18.5 安全隐患
如果用户知道 CDN 的 URL 拼接方式,可以绕过登录机制直接从 CDN 访问文章内容。这是一个有漏洞的做法。
小收口
存储选型三件套讲完了:MySQL(结构化)、MongoDB(大文本 / 灵活)、OSS(文件 / 多媒体)。写链路到此完备。接下来进入本章核心考点——读链路与缓存。
3.19 查询接口分类
写链路打通后,轮到读链路。查询接口先按"谁在查"分家:
- 创作者的查询接口:列表(分页,创作中心看自己的全部文章)、详情(进入编辑时加载已有内容)。
- 读者的查询接口:搜索(接搜索模块后设计)、推荐(按喜好推荐)、阅读(查看文章全文)。
本节先实现创作者列表、创作者详情、读者阅读三个接口。
3.20 分页接口(三种形态)
核心目标:避免一次拉太多数据引发性能问题。分页参数怎么从前端传到后端?业界三种主流形态:
| 形态 | 示例 | 说明 |
|---|---|---|
| Offset + Limit | Offset=200, Limit=100 | 前端计算偏移 |
| Page | Page=3(每页 100 条) | 后端计算偏移 |
| Cursor | Cursor=eyJvZmZzZXQiOjIwMH0= | 后端返回游标,前端透传 |
三者的表达能力递进:Offset + Limit 最直白;Page 只是把"第几页"换算成偏移的活儿挪到后端;Cursor 把"翻到哪儿了"编码成不透明令牌(示例那串 Base64 解开就是 {"offset":200}),能表达更复杂的续读语义。Cursor 灵活性最强,用于复杂系统。一般推荐用第一种。
// web/article.go
type ListReq struct {
Offset int `form:"offset"`
Limit int `form:"limit"`
}
func (h *ArticleHandler) List(ctx *gin.Context) {
// 步骤 1:绑定分页参数
var req ListReq
if err := ctx.Bind(&req); err != nil {
return
}
// 步骤 2:限制最大每页数量,防一次性拉爆
if req.Limit > 100 {
req.Limit = 100
}
uid := ctx.GetInt64("user_id") // 第 03 章 JWT 中间件写入的登录态
// 步骤 3:查列表
arts, err := h.svc.List(ctx, uid, req.Offset, req.Limit)
if err != nil {
ctx.JSON(http.StatusOK, web.Result{Code: 5, Msg: "系统错误"})
return
}
ctx.JSON(http.StatusOK, web.Result{Data: arts})
}
小收口:分页是缓存的前提——“只缓存第一页"这类优化,正是因为分页参数千变万化才需要谨慎对待。
3.21 补课胶囊:缓存模式(四种)
一句话:缓存模式解决两件事——“缓存没命中时谁去查数据库"和"写入时谁去同步数据库”。
类比:缓存像前台小哥。Cache-Aside 是"你自己去后台拿货、自己记台账”;Read/Write-Through 是"前台小哥替你去后台拿、替你记";Write-Behind 是"你只告诉前台,前台慢慢异步写后台"。
| 模式 | 读流程 | 写流程 | 特点 |
|---|---|---|---|
| Cache-Aside | 先查缓存,未命中查 DB 后回写 | 更新 DB,删除缓存 | 最常用,应用层负责 |
| Read-Through | 应用只查缓存,缓存自己去查 DB | — | 缓存层负责回源 |
| Write-Through | — | 应用写缓存,缓存同步写 DB | 强一致,性能差 |
| Write-Behind | — | 应用写缓存,缓存异步写 DB | 高性能,弱一致 |
最常用的是 Cache-Aside——原因很简单:它把"回源"和"清缓存"的逻辑都留在应用层,缓存组件本身保持简单(就是个 kv 存储),出问题也好排查。本课程的实现全部基于 Cache-Aside。
flowchart TD
Q[读请求] --> C{缓存命中?}
C -- 是 --> R[返回]
C -- 否 --> D[查 DB]
D --> W[回写缓存]
W --> R
U[写请求] --> DB[更新 DB]
DB --> DEL[删除缓存]小收口
记住 Cache-Aside 的口诀:读时"缓存没中就查库并回填",写时"先更库、再删缓存"。下一节我们攻本章真正的硬骨头:缓存三大问题。
3.22 缓存三大问题(核心考点,必配图)
这是后端基本功,面试高频。先上一张总览图,再逐个拆。
flowchart TD
P[缓存问题] --> P1[穿透: 查不存在的 key]
P --> P2[击穿: 热 key 突然过期]
P --> P3[雪崩: 大量 key 同时过期]
P1 --> S1[缓存空值 / 布隆过滤器]
P2 --> S2[互斥锁 / 永不过期]
P3 --> S3[随机过期 / 多级缓存 / 熔断]这张图怎么读:根节点"缓存问题"分出三个分支:P1 穿透是"查不存在的 key"、P2 击穿是"热 key 突然过期"、P3 雪崩是"大量 key 同时过期",三者的共同后果都是请求绕过缓存直接压到 DB;每个问题再各自指向对策——穿透接 S1(缓存空值 / 布隆过滤器)、击穿接 S2(互斥锁 / 永不过期,Go 里对应 singleflight)、雪崩接 S3(随机过期 / 多级缓存 / 熔断)。按"问题 → 解法"一对一看图,下面 3.22.1 ~ 3.22.3 三小节正好按 P1 → P2 → P3 的顺序展开。
3.22.1 缓存穿透
问题:查询一个不存在的 key,缓存和 DB 都没有,每次请求都打到 DB。攻击者只要用一堆随机不存在的 id 狂刷,DB 就被打挂。
解决方案:
- 缓存空值:DB 没查到也写一个空值到缓存(带短 TTL),下次同样的请求直接命中缓存,不再打 DB。
- 布隆过滤器:在缓存前加一层布隆过滤器,过滤掉"一定不存在"的 key。
| 解法 | 优点 | 缺点 |
|---|---|---|
| 缓存空值 | 实现极简单,几行代码 | 空值占缓存内存;攻击者每次换随机 key,照样往缓存里写;DB 后来新增了这条数据,要等空值过期才可见 |
| 布隆过滤器 | 内存效率极高,随机 key 也拦得住 | 要维护"已存在 key"集合(新增数据得同步写进去);有假阳性(无害);实现复杂 |
实践建议:数据量不大、key 空间有限(如按 id 查帖子),缓存空值就够;key 空间巨大且攻击面广(如按用户名 / 手机号查),上布隆过滤器。
缓存空值的写法:
// repository/cached_article.go
// 步骤 1:DB 没查到,也写一个"空值"进缓存(短 TTL,比如 60 秒)
art, err := r.dao.Get(ctx, id)
if errors.Is(err, ErrRecordNotFound) {
// 用一个特殊标记占位,下次同样的请求直接命中缓存,不再打 DB
_ = r.cache.Set(ctx, domain.Article{Id: id, Empty: true}, time.Minute)
return domain.Article{}, ErrRecordNotFound
}
3.22.2 缓存击穿
问题:一个热 key(比如首页爆款文章)突然过期,瞬间大量请求同时发现缓存没了,全部打到 DB,DB 被这一波峰值压垮。
解决方案:
- 互斥锁:缓存未命中时只让一个请求查 DB,其他请求等待它回填后再读。
- 永不过期:热 key 不设过期时间,靠后台异步更新(数据可能陈旧,但绝不被击穿)。
| 解法 | 优点 | 缺点 |
|---|---|---|
| 互斥锁 | 只保护出问题的热 key,改动小;数据保持新鲜 | 其他请求要排队;引入并发复杂度 |
| 永不过期 | 请求永远命中缓存,性能最好 | 数据可能陈旧;要写后台刷新逻辑;只适合极少数热 key |
Go 生态的标准武器:singleflight。标准库扩展包 golang.org/x/sync/singleflight 专门解决"瞬间大量相同请求,只放一个去回源":
// repository/cached_article.go
import "golang.org/x/sync/singleflight"
var g singleflight.Group
func (r *CachedArticleRepository) Get(ctx context.Context, id int64) (domain.Article, error) {
// 步骤 1:同一个 key 的并发请求,只有一个真正执行函数,其余等待并共享结果
v, err, _ := g.Do(fmt.Sprintf("article:%d", id), func() (any, error) {
return r.loadFromDB(ctx, id) // 回源 + 回写缓存只发生一次
})
if err != nil {
return domain.Article{}, err
}
return v.(domain.Article), nil
}
它和"互斥锁"思路相同,但粒度是 key 级别(不同 key 互不阻塞),而且结果自动共享给所有等待者,不用自己写等待逻辑。面试提到它,就是"用 Go 真做过缓存击穿防护"的实证。
3.22.3 缓存雪崩
问题:大量 key 同时过期,或 Redis 实例整体宕机,DB 瞬间压力暴增被打挂。
解决方案:
- 过期时间加随机值:避免同时失效。
- 多级缓存:本地缓存(如 bigcache)+ Redis,Redis 挂了本地还能顶。
- 熔断降级:DB 压力大时直接返回降级数据,宁可拒部分请求也不打挂 DB。
| 成因 | 解法 |
|---|---|
| 大量 key 同时过期 | 过期时间加随机值(如基础 30 分钟 + 随机 0~5 分钟),把失效时间打散 |
| Redis 实例故障 | 多级缓存(本地缓存兜一层)、Redis 高可用(哨兵 / 集群)、熔断降级 |
三种解法各管一段:随机过期是"事前预防"(让雪崩难以形成);多级缓存是"事中缓冲"(Redis 挂了本地还能顶);熔断降级是"事后保命"(宁可拒部分请求,也不把 DB 打挂导致全站不可用)。
3.22.4 缓存与数据库一致性
讲完三大问题,再展开一个高频面试考点:写操作来临时,缓存和数据库到底按什么顺序更新? 3.21 节 Cache-Aside 的写流程是"更新 DB,删除缓存",为什么偏偏是这个顺序?四种组合逐个看:
| 顺序 | 问题 |
|---|---|
| 先更新缓存,再更新 DB | 缓存写成功、DB 写失败:缓存里是脏数据,且直到过期前一直被读到。最差 |
| 先更新 DB,再更新缓存 | 并发写时,两个请求的"更新 DB"和"更新缓存"可能交错,缓存里留下旧值 |
| 先删缓存,再更新 DB | 删完缓存、DB 还没更新完的窗口里,读请求未命中 → 查 DB(查到旧值)→ 回写缓存 → 缓存从此是旧值,直到过期 |
| 先更新 DB,再删缓存 | 理论上有窗口(读恰好在 DB 更新后、缓存删除前查到旧值并回写),但要求"读比写还慢"的罕见时序,概率极低 |
所以 Cache-Aside 的标准答案是先更新 DB 再删缓存——不是零风险,而是四种顺序里风险最小的。
再进一步,为什么是"删缓存"而不是"更新缓存"?两个原因:
- 并发写时更新缓存会乱序:两个写请求的缓存更新顺序无法保证与 DB 一致;删缓存则天然规避(下次读统一回源重建)。
- 懒加载思想:删掉之后,只有真正有人读才回源重建;没人读的数据不用白白算一遍缓存值。
要求更高时的增强是延迟双删:更新 DB 前删一次缓存,更新完 DB 后延迟几百毫秒再删一次,把窗口期混进来的旧值也清掉:
// repository/cached_article.go
// 步骤 1:先删缓存
_ = r.cache.Del(ctx, id)
// 步骤 2:更新 DB
if err := r.dao.Update(ctx, art); err != nil {
return err
}
// 步骤 3:延迟再删一次,清掉窗口期被回写的旧值
time.AfterFunc(500*time.Millisecond, func() {
_ = r.cache.Del(context.Background(), id)
})
小收口
三大问题 + 一致性是本章最重的考点。口诀:穿透用空值 / 布隆、击穿用 singleflight / 永不过期、雪崩用随机过期 / 多级 / 熔断;写时"先更库、再删缓存",要更强就"延迟双删"。
3.23 列表接口的缓存:只缓存第一页
为什么不缓存所有页? 因为筛选条件、排序条件、偏移量、每页大小任意一个变化,缓存就难用了——缓存 key combinatorially 爆炸。
为什么只缓存第一页? 因为大多数用户只看第一页,很少往后翻。性价比最高。
// repository/cached_article.go
func (r *CachedArticleRepository) List(ctx context.Context, authorId int64,
offset, limit int) ([]domain.Article, error) {
// 步骤 1:只有偏移量为 0 且每页是 100 时才走缓存
if offset == 0 && limit == 100 {
arts, err := r.cache.GetFirstPage(ctx, authorId)
if err == nil {
return arts, nil
}
// 降级:缓存出错也查 DB
}
// 步骤 2:查 DB
arts, err := r.dao.List(ctx, authorId, offset, limit)
if err != nil {
return nil, err
}
// 步骤 3:列表页只需要摘要,不缓存 Content
res := slice.Map[entity.Article, domain.Article](arts, func(art entity.Article) domain.Article {
return art.ToDomain()
})
// 步骤 4:只缓存第一页
if offset == 0 && limit == 100 {
_ = r.cache.SetFirstPage(ctx, authorId, res)
}
return res, nil
}
注意:列表页不需要缓存 Content,节省内存。
offset == 0 && limit == 100 的判定故意写得很严格(两个条件都精确相等才走缓存),先保证正确性。这个判定完全可以优化——比如放宽成 limit <= 100 都走缓存,命中场景更多。但要注意:放宽条件的代价是缓存 key 的语义变模糊(不同 limit 是共用一个 key,还是按 limit 分别缓存?),动手改之前先想清楚这个问题。小收口
列表缓存策略定了:只缓存第一页、不缓存 Content、缓存出错降级查 DB。但光缓存不清理会"读到旧数据"——下一步解决清缓存。
3.24 写操作清理缓存
新写文章、编辑文章、发表文章时都要清除相关缓存,否则读者看到的是旧列表。
// repository/cached_article.go
func (r *CachedArticleRepository) Save(ctx context.Context, art domain.Article) (int64, error) {
// 步骤 1:写制作库
id, err := r.authorDAO.Save(ctx, art)
if err != nil {
return 0, err
}
// 步骤 2:清除第一页缓存(因为列表内容变了)
_ = r.cache.DelFirstPage(ctx, art.AuthorId)
return id, nil
}
小收口
写时清缓存,保证读者最终看到新内容。但还有一类"预测式"优化——提前把用户大概率要看的数据塞进缓存。
3.25 预加载、预热、淘汰与 Author 组装
3.25.1 业务预加载
实践上:针对创作者的缓存性价比低(只有创作者自己用得上)。但这个场景适合演示一种新奇方案——业务相关的缓存预加载。
思路:拿到列表后,创作者有更大概率访问第一条数据。所以列表返回时,异步把第一条数据预加载到缓存。
// repository/cached_article.go
func (r *CachedArticleRepository) List(ctx context.Context, authorId int64,
offset, limit int) ([]domain.Article, error) {
// 步骤 1:查 DB
arts, err := r.dao.List(ctx, authorId, offset, limit)
if err != nil {
return nil, err
}
// 步骤 2:业务预加载——把第一条数据放进缓存,过期时间很短
// 让调用者决定是否异步,而不是偷偷异步
if len(arts) > 0 {
_ = r.cache.Set(ctx, arts[0]) // 短 TTL,如 1 分钟
}
return arts, nil
}
关键点:
- 调用者决定是否异步(可读性、可维护性更强)。
- 过期时间短(预测失败时内存开销小)。
3.25.2 读者阅读接口:Publish 提前预热缓存
奇妙的缓存方案:在 Publish 接口调用时,直接把数据放进缓存。
理由:新帖子发布后,立刻就会被人访问。
// repository/cached_article_reader.go
func (r *CachedArticleReaderRepository) Save(ctx context.Context, art domain.Article) error {
// 步骤 1:写线上库
err := r.dao.Upsert(ctx, art)
if err != nil {
return err
}
// 步骤 2:提前设置缓存(发布即预热)
// 失败不用关心,可能是超时或偶发性能抖动
_ = r.cache.Set(ctx, art)
return nil
}
所有有制作库 / 线上库概念的场景都适合这种方案——发表时顺手把线上库内容预热进缓存,读者一来直接命中。
3.25.3 缓存过期时间策略
理论上,缓存过期时间应保证接下来对该资源的访问都命中缓存。但过期时间长短有权衡:
- 长:命中率高,但数据一致性差。
- 短:一致性更好,但命中率低。
面试案例:
- 业务相关的缓存预加载:过期时间要短(预测可能错)。
- Publish 提前设置:可根据创作者是否大 V 决定,大 V 用更长的过期时间(被访问频率高)。
3.25.4 补课胶囊:缓存淘汰策略(LRU / LFU / FIFO)
一句话:当缓存内存不够时,需要淘汰一些数据腾空间。淘汰策略决定"先扔谁"。
类比:冰箱塞满了,要扔菜——可以扔"最久没动过的"(LRU)、扔"最少被吃的"(LFU)、或扔"最早放进去的"(FIFO)。
最小示例:
// Redis 的 maxmemory-policy 可配置淘汰策略 // 例如 allkeys-lru:内存满时淘汰最久未使用的 key
| 策略 | 全称 | 思路 | Redis 支持 |
|---|---|---|---|
| LRU | Least Recently Used | 淘汰最近最少使用 | ✅(近似 LRU) |
| LFU | Least Frequently Used | 淘汰最不频繁使用 | ✅(Redis 4.0+) |
| FIFO | First In First Out | 淘汰最早进入的 | ❌ |
重点:Redis 默认不是严格 LRU,而是近似 LRU(随机采样若干个 key 挑最久未用的淘汰),因为严格 LRU 要维护全局链表、开销大。Redis 4.0+ 还提供了 LFU,更适合"热点数据常驻"的场景。
面试亮点策略(在基础 LRU/LFU 之上做业务加权):
- 优先淘汰普通创作者的数据,留下大 V 的数据:LFU 能达到近似效果(大 V 被访问频率高),LRU 效果差一些。
- 优先淘汰大对象:一次性释放很多内存。
- 优先淘汰小对象(如果大对象算起来很慢、小对象算起来很快,就没必要留着小对象)。
3.25.5 读者阅读接口的 Author 组装
读文章时,往往还要把作者信息(昵称等)一起返回。这步组装放在 Repository 层最合适——它本就负责"把原始数据拼成领域对象"。
// repository/article_reader.go
type CachedArticleReaderRepository struct {
dao dao.ArticleReaderDAO
cache cache.ArticleCache
userRepo repository.UserRepository // 用于组装 Author
}
func (r *CachedArticleReaderRepository) Get(ctx context.Context, id int64) (domain.Article, error) {
// 步骤 1:先查缓存
art, err := r.cache.Get(ctx, id)
if err == nil {
// 缓存命中:组装作者信息(也走缓存)
u, _ := r.userRepo.FindById(ctx, art.AuthorId)
art.Author = domain.Author{Id: u.Id, Nickname: u.Nickname}
return art, nil
}
// 步骤 2:查 DB
art, err = r.dao.Get(ctx, id)
if err != nil {
return domain.Article{}, err
}
// 步骤 3:回写缓存 + 组装 Author
_ = r.cache.Set(ctx, art)
u, _ := r.userRepo.FindById(ctx, art.AuthorId)
art.Author = domain.Author{Id: u.Id, Nickname: u.Nickname}
return art, nil
}
UserRepository,保持了 Repository 的语义(在 Repository 层完成 Author 的组装)。微服务架构下,可以用 user 服务实现 UserRepository。小收口
读链路与缓存全部讲完:分页 → 缓存模式 → 三大问题 → 只缓存第一页 → 写时清缓存 → 预加载 / 预热 / 淘汰 / Author 组装。下面进入动手环节,把这一章真正跑起来。
3.26 补充考点:大 key——识别、危害与拆分
先抛问题。3.23 节我们说"列表只缓存第一页",3.25 节做了预热——但还有一个隐形的雷没拆:如果某个 key 本身就特别大呢?比如一个千万粉丝的大 V,粉丝列表用一个 Redis List 存一千万个 userId,这个 key 会发生什么?
给直觉。这就是大 key 问题。行业里粗略的判定线:String 类型的 value 大于 10KB、集合类型(List / Hash / Set / ZSet)元素个数超过几千,就要警惕。大 key 的危害有四条:
- 阻塞:Redis 处理命令是单线程的,读一个 10MB 的 key、删一个百万元素的 List,耗时几十上百毫秒,期间所有请求排队;
- 内存倾斜:分片集群下,大 key 所在的分片内存和带宽远超兄弟节点,容量与 QPS 都打不平;
- 网络放大:一次
LRANGE 0 -1拉回几百 MB,网卡和序列化 CPU 一起被打爆; - 删除卡顿:
DEL大 key 是同步的,线上直接引发抖动——要用UNLINK后台异步删,或SCAN分批删。
怎么治。核心思路一句话:化整为零,按访问模式拆。
// 拆法一:List 分段——粉丝列表拆成固定大小的段
const segSize = 10000
// 按"第几页"定位段:第 0~9999 个粉丝在 slice0,第 10000~19999 个在 slice1
key := fmt.Sprintf("follower:%d:slice%d", starUID, page*pageSize/segSize)
// 写入:新粉丝 LPUSH 到最新段;段满开新段
// 读取:只回查用户点开的那个段,LRANGE 范围小、响应快
// 拆法二:Hash 分片——按用户 id 取模定位,写入分散、读取 O(1)
slice := uid % 16
key = fmt.Sprintf("like_users:%d:slice%d", contentId, slice)
rdb.HSet(ctx, key, strconv.FormatInt(uid, 10), 1)
再加两条策略组合拳:
- 只缓存前 N 页:数据显示粉丝列表 90%+ 的浏览集中在前 5 页,后面的页根本没人翻——只缓存前 5 页,深翻页直接回源 DB(慢,但没人看)。这是 3.23 节"只缓存第一页"思想从"帖子列表"推广到"关系列表";
- 过期时间错峰:大 key 的过期和重建要错开,避免一批大 key 同时失效——呼应 3.22 节雪崩的随机过期对策。
常见坑:大 key 往往是"养"出来的——上线时粉丝 100 个没事,两年后大 V 涨到千万粉才爆。所以 redis-cli --bigkeys 的定期巡检要进监控例行(第 08 章的可观测性思维在这里复用),别等报警了才知道家里藏着几个巨型 key。
--bigkeys 的在线采样更细——两者搭配进例行巡检。小收口:大 key 的套路 = 识别(阈值 + 巡检)→ 治理(分段 / 分片 / 只缓存前 N 页)→ 兜底(异步删除)。第 07 章讲"点赞用户列表"时,会用这套手段做一次完整的演进实战,那里能看到 V1 → V3 一步步踩过来。
四、跟着写:分步实现
本章动手主线分 7 步。每一步结束时项目都能编译、能跑、能验证。我们基于前五章已有的 GORM + Redis + JWT 脚手架,只新增发帖相关代码。
步骤 1 · 定义领域模型与状态常量
新建 domain/article.go,把状态、实体定义好。
// domain/article.go
package domain
const (
ArticleStatusUnknown uint8 = 0 // 未知,零值无意义
ArticleStatusUnpublished uint8 = 1 // 未发表
ArticleStatusPublished uint8 = 2 // 已发表
ArticleStatusPrivate uint8 = 3 // 仅自己可见
)
type Article struct {
Id int64 `json:"id"`
Title string `json:"title"`
Content string `json:"content"`
AuthorId int64 `json:"-"` // 不暴露给前端
Status uint8 `json:"status"`
Author Author `json:"author"` // 组装时填充
Empty bool `json:"-"` // 缓存空值占位标记:true 表示"DB 中不存在"(见 3.22.1)
}
type Author struct {
Id int64 `json:"id"`
Nickname string `json:"nickname"`
}
验证:go build ./domain/... 编译通过,无报错。
步骤 2 · 定义 Service 与双 Repository 接口
// service/article.go
package service
type ArticleService interface {
Save(ctx context.Context, art Article) (int64, error)
Publish(ctx context.Context, art Article) (int64, error)
}
type ArticleAuthorRepository interface {
Save(ctx context.Context, art Article) (int64, error)
}
type ArticleReaderRepository interface {
Save(ctx context.Context, art Article) error
}
步骤 3 · 用 TDD 写编辑接口测试
在 web/article_test.go 里照 3.3 节搭好 Table-Driven 骨架,填"新建帖子成功"用例,先跑测试确认它红(编译失败,因为 Save 还没实现)。
go test ./web/ -run TestArticleHandler_Edit
预期输出:编译错误,提示 Save 未定义——这是"红"阶段,符合预期。
步骤 4 · 补实现让测试变绿
实现 3.3.4 的 Edit 处理器和 articleService.Save(写制作库),再跑测试。
go test ./web/ -run TestArticleHandler_Edit
预期输出:ok github.com/yourname/webook/web X.XXXs,测试变绿。
步骤 5 · 加"修改别人的帖子"集成测试
照 3.6 节写集成测试:插入一条 author_id=123 的旧数据,用另一个作者的登录态去改,断言返回"更新失败"。
go test ./web/ -run TestArticleHandler_Edit/修改别人的帖子
预期输出:测试通过(攻击请求被 WHERE id=? AND author_id=? 拦截,RowsAffected==0)。
步骤 6 · 给列表接口加第一页缓存
照 3.23 节实现 CachedArticleRepository.List,新增 GetFirstPage/SetFirstPage/DelFirstPage 三个缓存方法(用 Redis 存 authorId 到列表的映射)。写测试确认:
- 第一次查走 DB,第二次命中缓存(Redis 里能看到 key)。
- 调用
Save后第一页缓存被清。 - 缓存出错时仍查 DB(降级)。
go test ./repository/ -run TestCachedArticleRepository
预期输出:三组断言全过。
步骤 7 · 补发表 / 撤回接口并跑全量测试
照 3.7 / 3.11 节实现 Publish 与 Withdraw,articleService.Publish 同时写两个 Repository。最后跑全量:
go test ./...
预期输出:ok 覆盖所有包,全绿。
本章完成后的项目结构(节选)
webook/
├── domain/article.go # 实体 + 状态常量
├── service/article.go # ArticleService + 双 Repository 接口
├── service/article_impl.go # Publish 同步双库
├── dao/article.go # GORM DAO(带 author_id 条件)
├── repository/
│ ├── article.go # 基础 Repository
│ └── cached_article.go # Cache-Aside 包装
└── web/article.go # Edit/Publish/Withdraw/List 处理器
验证清单(可勾选)
-
go test ./...全绿 - 新建帖子:返回后端生成的 id
- 用他人登录态改他人帖子:被拦截
- 创作者列表第一页命中缓存;编辑后缓存被清
- 缓存出错时接口仍返回正确数据(降级成功)
- 发表后线上库出现已发表版本;撤回后读者不可见
五、工程实践要点
生产环境里,本章这些设计有几点必须记住,顺序不分先后,但每一条都来自真实事故。
5.1 安全
- 修改 / 删除接口必须校验"是不是作者本人",把
author_id加到 WHERE 条件(3.6 节)。只传id做更新等于把别人的数据敞开门。 - OSS + CDN 时注意绕过登录访问内容的风险(3.18.5 节)。公开 CDN URL 等于登录态形同虚设,敏感内容必须走私有签名 URL。
5.2 缓存
- 缓存只放在高频读场景,别什么都缓存。
- 写操作要清理相关缓存(3.24 节)——否则读者读到旧数据。
- 缓存出错也查 DB(业务可用优先),别因为 Redis 抖一下就让接口挂掉。
- 列表只缓存第一页(3.23 节),且列表页不缓存 Content 节省内存。
- 大文档可以不缓存(3.25 节),权衡性能与内存开销。
- 利用业务规律做缓存预加载 / 预热(3.25.1 / 3.25.2 节)。
5.3 事务
- 同库不同表:可以在 DAO / Repository 层用事务(GORM 闭包事务)。
- 跨库(制作库 + 线上库分离):不能用事务,用重试补偿。
- Service 层不应控制数据库事务(它不知道底层用什么存储,见 3.8 / 3.10 节)。
5.4 存储选型
| 场景 | 推荐 |
|---|---|
| 结构化数据 + 复杂查询 | MySQL |
| 大文本 + 灵活模型 | MongoDB |
| 大文件 / 多媒体 | OSS |
| 高并发读 | Redis 缓存 |
5.5 TDD
- 先写测试再写实现(红 - 绿 - 重构)。
- 测试框架可以参考模板(Table-Driven)。
- 集成测试用例之间不能依赖(每个用例自带 before/after)。
- 没测到的代码必须 review。
5.6 索引设计:ESR 原则
E(Equal)→ S(Sort)→ R(Range)的相对顺序。MySQL 和 MongoDB 都适用(3.13.6 节)。
六、自测题与动手练习
自测题(合上书能答出来,才算懂;答案在第七节面试问答里对应题号):
- 为什么发帖要分"制作库"和"线上库"两个库?只用一个库靠状态字段过滤会有什么问题?
- TDD 的核心循环是什么?TDD 和 DDD 到底有没有关系?
- “修改别人的帖子"漏洞是怎么产生的?为什么修复时要
WHERE id=? AND author_id=?而不是先查后判? - 制作库和线上库"同库不同表"与"跨库"时,事务分别该在哪一层控制?跨库为什么不能用事务?
- 缓存三大问题(穿透 / 击穿 / 雪崩)各是什么、分别怎么解?为什么列表缓存"只缓存第一页”?
- 缓存与数据库一致性有哪四种更新顺序?为什么 Cache-Aside 标准是"先更新 DB,再删缓存"?
- 为什么大文本不存 MySQL,而用 MongoDB?MongoDB 的
_id和 MySQL 自增 ID 有什么本质区别? - 什么是 Upsert?为什么线上库能用 Upsert,而制作库不能?
动手练习(建议真做一遍):
- TDD 跑绿:从
TestArticleHandler_Edit的"新建帖子成功"用例出发,先写空Save让测试编译失败(红),再补实现直到变绿;再加"修改别人的帖子"集成测试验证被拦。 - 缓存第一页:给创作者列表接口加上
GetFirstPage / SetFirstPage / DelFirstPage,写测试确认:列表走缓存命中、编辑文章后缓存被清、缓存出错时仍查 DB。 - 缓存三大问题对照表:挑一个你自己的接口,分别写出它会遭遇穿透 / 击穿 / 雪崩的触发方式,并各给一种对策(空值 / singleflight / 随机过期)。
七、模拟面试问答
这一节模拟真实面试场景。每题按基础 / 进阶 / 追问三档组织,格式为:一句话结论 → 展开 → 面试官想听的关键词 → 常见失分点。
一句话结论:分库是为了把"创作者编辑中"和"读者可见"从物理上隔离,保证读者永远只看到已发表版本;单库靠状态过滤既复杂又容易把草稿漏给读者。
展开:创作者要频繁改但不想让读者看中间态,读者要稳定的已发表内容,两者需求天然冲突。分库后制作库只服务创作者、线上库只服务读者,发表时由系统同步一次。单库方案每次读都要用状态字段过滤"该不该对读者可见",逻辑嵌套深、易错,且一不留神就把未发表草稿暴露出去。
面试官想听的关键词:双库隔离、可见性、发表即同步、单库过滤复杂度、草稿泄露风险。
常见失分点:只说"为了性能"——分库主要不是为了性能,是为了正确隔离读写视角;或说不清"单库到底会出什么具体 bug"。
一句话结论:TDD(测试驱动开发)是"先写测试、再写实现"的开发节奏,核心循环是红 - 绿 - 重构。
展开:红阶段写一个会失败的测试,绿阶段用最省事的方式让测试通过,重构阶段在测试兜底下放手改整洁代码。四大好处:站在使用者视角理清接口、测试用例表充当需求清单查漏补缺、有测试兜底才敢重构、功能迭代完测试自然齐备。TDD 和 DDD 没关系——一个是工程节奏,一个是设计思想。
面试官想听的关键词:红绿重构、先写测试、理清思路、查漏补缺、方便重构、与 DDD 无关。
常见失分点:把 TDD 说成"写完代码再补测试"(那是测试后置,不是 TDD);或把 TDD 和 DDD 绑在一起讲。
一句话结论:不带 owner 条件的按 ID 写接口等于把全库数据敞开门——攻击者遍历 ID 就能改 / 删别人的资源。
展开:当 ID 从前端接收时,攻击者完全可以传别人的 ID。正确做法是在 UPDATE / DELETE 的 WHERE 里同时带 id 和 author_id,只改"自己的";没命中行就返回失败,把"ID 不存在"和"不是作者"统一归为异常。比"先查后判"省一次查询。所有类似场景(订单、评论、资料)都要排查,可用 Gin middleware 统一校验。
面试官想听的关键词:越权、WHERE id+author_id、RowsAffected==0、省一次查询、统一 middleware。
常见失分点:说"先查出来比对作者再改更安全"——既多一次查询,又没解释清楚为什么 WHERE 条件一步到位更好。
一句话结论:穿透(查不存在的 key 打 DB)、击穿(热 key 过期瞬间压垮 DB)、雪崩(大量 key 同时过期 / Redis 宕机);分别用空值 / 布隆、singleflight / 永不过期、随机过期 / 多级缓存 / 熔断解决。
展开:穿透是 key 根本不存在,每次都穿透到 DB,对策是缓存空值或布隆过滤器前置;击穿是单个热 key 过期引发并发回源,对策是 singleflight 让同一 key 只回源一次或热 key 永不过期;雪崩是大量 key 同时失效或 Redis 挂,对策是过期加随机值打散、本地 + Redis 多级缓存、DB 快扛不住时熔断降级。三种解法分别管"事前预防 / 事中缓冲 / 事后保命"。
面试官想听的关键词:穿透 / 击穿 / 雪崩、空值 + 布隆、singleflight、随机过期、多级缓存、熔断降级。
常见失分点:把击穿和雪崩混为一谈(击穿是单 key,雪崩是批量);或不知道 Go 有 singleflight 这个现成武器。
一句话结论:四种更新顺序里"先更 DB 再删缓存"风险最小;选"删"而非"更",是为了规避并发写乱序并符合懒加载。
展开:先更缓存再更 DB 会留脏数据(最差);先更 DB 再更缓存在并发写下可能留旧值;先删缓存再更 DB 有窗口期读旧值回写;先更 DB 再删缓存只在"读比写慢"的罕见时序下出问题,概率极低。用"删"而不是"更新"的原因:并发写时更新缓存顺序无法保证与 DB 一致,删缓存则下次读统一回源重建;且懒加载避免为无人读的数据白算缓存值。增强可加延迟双删。
面试官想听的关键词:先更 DB 再删缓存、四种顺序对比、并发写乱序、懒加载、延迟双删。
常见失分点:答不出"为什么不更新缓存而删缓存"这个追问;或把"删缓存"说成"缓存一定和 DB 强一致"(其实仍有极小窗口)。
一句话结论:事务必须跟着"整合多个 DB 操作"的那一层走——同库不同表在 DAO 层,跨库 / 跨服务在 Service 层用重试补偿。
展开:同库不同表时,DAO 层用 GORM 闭包事务一把梭最干净;Repository 层不该直接持有 gorm.DB,否则强依赖具体存储、违背面向接口。跨库时,Service 层不知道底层用的是什么存储,且 MySQL 不支持跨库分布式事务,所以只能接受"部分失败靠重试补偿"——创作者看到失败可重试,读者顶多看到旧版或看不到,不会读到半篇。
面试官想听的关键词:整合层控制事务、DAO 本地事务、跨库无分布式事务、重试补偿、部分失败。
常见失分点:说"在 Service 层开数据库事务"——Service 不该知道底层是 GORM 还是别的;或认为跨库事务"加个注解就行"。
_id 和自增 ID 有什么不同?一句话结论:MySQL 行有大小上限、长字段索引弱、加列成本高,不适合大文本;MongoDB 文档模型无模式、原生分片、适合大文本。二者主键方案本质不同:自增 ID 依赖单点,雪花算法分布式。
展开:MySQL 一行约 65535 字节上限,超长 text 走溢出页、多一次磁盘寻址;长文本字段难建高效索引;加列要 ALTER TABLE。MongoDB 存的是文档,字段随意增删,且分片自动扩容。主键上,MySQL 自增依赖单点数据库发号、分布式下做不到、还会泄露规模;MongoDB 默认 _id 是 12 字节对象 ID,本课程用雪花算法生成 int64,把时间戳 + 机器号 + 序号编进 ID,每台机器独立发号。
面试官想听的关键词:行溢出、索引效率、无模式、分片、雪花算法、分布式 ID。
常见失分点:把"大文本"说成"MySQL 完全不能存"(能存,只是代价高);或答不出雪花算法为什么不用自增。
一句话结论:Upsert = 存在就更新、不存在就插入;线上库发表时无法预知文章是否发表过,正好用 Upsert 一把梭;制作库因需校验 author_id 而 MySQL 不支持带条件的 Upsert,只能走普通更新。
展开:GORM 用 clause.OnConflict{Columns, DoUpdates} 实现。线上库在发表瞬间不知道这篇文章此前发过没有,Upsert 用一条语句同时覆盖"更新旧版"和"插入新版"。制作库更新时必须带 author_id 条件防越权,而 MySQL 的 ON DUPLICATE KEY UPDATE 不支持 WHERE author_id=123,所以制作库只能走"先按 id+author_id 查,命中才更新"的路径。
面试官想听的关键词:OnConflict、存在即更新、author_id 校验、MySQL 不支持条件 Upsert。
常见失分点:说"制作库也能用 Upsert"——忽略越权校验的需求;或答不出为什么需要 Upsert 而不是先查再分支。
一句话结论:LRU 淘汰最久未用、LFU 淘汰最少使用、FIFO 淘汰最早进入;Redis 默认是近似 LRU,4.0+ 支持 LFU;更聪明的是按业务权重淘汰(优先留大 V、优先淘汰大对象)。
展开:严格 LRU 要维护全局链表开销大,Redis 用随机采样近似 LRU 平衡性能。LFU 记录访问频率,更适合"热点常驻"。面试亮点在于业务加权:优先淘汰普通创作者的数据、留下大 V(LFU 近似可达),或优先淘汰大对象一次性释放内存,或优先淘汰计算成本低的小对象。这些是在基础策略之上叠加业务语义。
面试官想听的关键词:近似 LRU、LFU、业务加权淘汰、大 V 优先、大对象优先。
常见失分点:说"Redis 用严格 LRU"(实际是近似);或只知道三种基础策略,答不出业务加权的亮点思路。
一句话结论:OSS 是对象存储(无限容量网盘),CDN 是就近加速的缓存网络,常配合(OSS 回源、CDN 加速);大文件 / 大文本走它是因为数据库不适合存这类数据;隐患是公开 CDN URL 可绕过登录。
展开:OSS 用 Bucket / Object / ACL 组织数据,适合大文本、多媒体;CDN 把内容缓存到边缘节点提升访问速度。小文本直接存数据库没问题,但几万字正文、图片视频该走 OSS。多数云兼容 S3 API,迁移成本低。隐患:如果把文章正文挂到公开 CDN URL,攻击者猜到拼接规则就能绕过登录直接读,敏感内容必须走私有签名 URL(带过期签名)。
面试官想听的关键词:对象存储、CDN 回源、S3 API 兼容、绕过登录、私有签名 URL。
常见失分点:把 OSS 和 CDN 混为一谈(一个是源站,一个是加速层);或忽略"公开 CDN 可绕过登录"这个安全点。
limit <= 100 都走缓存,会有什么坑?一句话结论:只缓存第一页是因为绝大多数用户只看第一页、且分页参数组合太多缓存难复用;放宽判定会让缓存 key 语义变模糊,可能命中错误数据。
展开:筛选、排序、偏移、每页大小任一变化都会让缓存失效,全量缓存 key 会爆炸。第一页命中率最高、性价比最好。若放宽成 limit <= 100 都走缓存,必须想清楚:不同 limit 是共用一个 key 还是分别缓存?共用会把"看 10 条"和"看 100 条"的结果混为一谈返回错误数据;分别缓存又会膨胀 key 数量。放宽前先定清楚 key 语义。
面试官想听的关键词:命中率、缓存 key 语义、分页参数组合、正确性优先于命中率。
常见失分点:说"所有页都缓存更好"(key 爆炸、复用率低);或放宽条件时答不出"key 语义模糊"这个具体坑。
一句话结论:核心是面向接口编程——定义一套贴合业务而非贴合 S3 的统一存储接口(Put/Get/Delete/Exists),每家云一个实现,把 endpoint / 鉴权 / 错误码差异封装在实现内部。
展开:要点有四:接口贴合业务需要(存 / 取文章)而非 S3 方法(PutObject/HeadObject);只抽象用到的能力,别覆盖对象存储全部 API;把差异藏进实现(如把厂商"不存在"错误翻译成统一 ErrNotFound);路由决策外置到配置。形态递进:上层抽象 + 多实现(能换)→ 按区域 / 类型路由(会分)→ 故障自动切换(RouterStorage 组合主备,主云非"不存在"类失败时读备云,且自身也实现同一接口,业务无感知)。
面试官想听的关键词:面向接口、贴合业务不贴合 S3、ErrNotFound 统一、RouterStorage、主备切换。
常见失分点:把接口定义成"S3 的影子"(PutObject/HeadObject 原样搬),换云时照样改业务;或忽视"哪个 key 在哪朵云"的路由元数据该放哪。
一句话结论:单个 key 的 value 特别大就叫大 key(经验阈值:String 超 10KB、集合类型元素上万),危害四大件——阻塞单线程、内存倾斜、网络放大、删除卡顿。
展开:Redis 处理命令是单线程的,读一个 10MB 的 key 期间所有请求排队,这是第一危害;分片集群下大 key 所在分片内存带宽远超兄弟节点,打不平;LRANGE 0 -1 拉全量打爆网卡;DEL 大 key 是同步的,线上要用 UNLINK 异步删或 SCAN 分批删。
面试官想听的关键词:10KB/万级阈值、单线程阻塞、内存倾斜、网络放大、UNLINK 异步删。
常见失分点:只背定义讲不出"阻塞单线程"这个第一危害;不知道 DEL 大 key 本身也是事故源。
一句话结论:治法 = 分段/分片拆开 + 按访问模式只缓存热点区间 + 异步删除兜底;“只缓存前 N 页"成立是因为数据显示 90%+ 的浏览集中在前几页、深翻页根本没人看——用产品行为裁剪数据范围,慢的那部分就让它慢在没人看的地方。
展开:List 按"第几页"分段、Hash 按用户 id 取模分片,写入分散、读取范围可控;过期时间错峰,避免一批大 key 同时失效引发雪崩。深翻页直接回源 DB——回源慢但频率低,整体 QPS 曲线是平的。
面试官想听的关键词:分段、分片、只缓存前 N 页、过期错峰、按访问模式拆。
常见失分点:只会"加内存加机器”,说不出"裁剪数据范围"这个产品视角;或者忘了拆完还要处理过期与删除。
一句话结论:redis-cli --bigkeys 定期巡检 + RDB 离线分析(rdb-tools 类工具看内存分布)进监控例行;大 key 是数据量随业务增长"养"出来的——上线时 100 个粉丝没事,两年后千万粉才爆,所以巡检必须常态化,而不是出事才查。
展开:这暴露的是"容量规划要按终局数据量做"的教训:设计缓存结构时问一句"这个 key 三年后多大"。配合第 08 章的可观测性思维,把"最大 key 大小、大 key 数量"做成监控指标,趋势一抬头就报警。
面试官想听的关键词:–bigkeys、RDB 离线分析、常态化巡检、按终局容量设计。
常见失分点:答"上线前测一次就行",忽略数据增长的时间维度;说不出巡检之外的具体分析手段。
八、本章小结
学完这一章,下面这些应该能从脑子里调出来。建议逐条打勾,打不上勾的回去看对应小节。
可勾选清单(面试前 5 分钟扫一遍)
- 双库设计:制作库服务创作者、线上库服务读者,发表即同步;状态流转 未发表 → 已发表 → 仅自己可见(撤回)
- 零值陷阱:状态常量从 1 开始编号,零值留给"未知",避免"未赋值"和"未发表"分不清
- TDD 红 - 绿 - 重构:先写失败测试 → 最小实现变绿 → 重构;TDD 与 DDD 无关
- 新建 vs 更新:同一
/edit靠"有没有 id"区分;Save返回(int64, error)是因为新建时 id 由后端生成 - 越权防护:所有"按 ID 改 / 删"接口必须
WHERE id=? AND author_id=?,绝不先查后判 - 事务分层:同库不同表在 DAO 层事务;跨库在 Service 层重试补偿;事务跟着"整合多个 DB 操作"的层走
- Upsert:线上库用(GORM
clause.OnConflict),制作库因要校验 author_id 而不能用 - MongoDB / NoSQL:文档模型、无模式、原生分片,适合大文本 / 灵活结构;用 MySQL 做对照理解
- 雪花算法:64 位 = 时间戳 + 机器号 + 序号,分布式独立发号;依赖机器时钟,注意回拨
- OSS + CDN:对象存储当源站、CDN 做加速;公开 CDN URL 可绕过登录,敏感内容走签名 URL
- 分页三形态:Offset+Limit / Page / Cursor,一般推荐第一种,记得限制最大每页
- 缓存四大模式:最常用 Cache-Aside(读时回填、写时先更库再删缓存)
- 缓存三大问题:穿透(空值 / 布隆)、击穿(singleflight / 永不过期)、雪崩(随机过期 / 多级 / 熔断)
- 一致性:先更 DB 再删缓存风险最小;增强可用延迟双删
- 列表缓存:只缓存第一页、不缓存 Content、写时清缓存、缓存出错降级查 DB
- 淘汰策略:LRU / LFU / FIFO,Redis 用近似 LRU;亮点业务加权(留大 V / 淘汰大对象)
下一章我们将进入「点赞与消息队列」,把发帖产出的内容接到点赞、评论、Feed 流,并第一次引入 Kafka 这样的消息队列来解耦写链路。带着本章"双库 + 缓存三大问题"的底子进入第 07 章,你会看到缓存思路如何延续到更重的读场景。