第06章 · 发帖功能与缓存(正式版)

2023-01-06T14:11:02+08:00 | 70分钟阅读 | 更新于 2023-01-06T14:11:02+08:00

@

写在最前面:这一版和普通讲义有什么不同

这是《内容社区》课程第 06 章。你现在已经做完前五章,会用 Gin 写接口、用 GORM 操作 MySQL、用 Redis 存点简单数据。但这一章会冒出一堆你大概率没系统学过的东西:MongoDB、缓存三大问题、TDD、雪花算法、OSS + CDN、Upsert。

这一版做了三件事:

  1. 每一个陌生名词当场补「补课胶囊」。TDD 是什么、NoSQL 是什么、缓存三大问题怎么解——不让你自己跑去搜。
  2. 每一步都能跑。代码标文件路径、给运行命令、给预期输出。
  3. 结尾有 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 节之后逐一展开。


二、本章知识点大纲

先把地图看一遍,知道这章要往哪走。

必会(学完必须能独立写出来)

  1. 用 TDD 节奏先写"新建帖子成功"的测试,再补实现让它变绿;再写一个集成测试验证"修改别人的帖子"被拦截。
  2. 给"按 ID 修改 / 删除"的接口加上 author_id 条件,堵住越权漏洞。
  3. 区分制作库与线上库,在 Service 层用两个 Repository 把发表动作拆成"写制作库 + 同步线上库”。
  4. 写出分页接口的三种形态(Offset/Limit、Page、Cursor),并给列表接口加"只缓存第一页"的缓存。
  5. 讲清并写出缓存三大问题的对策:穿透用空值 / 布隆过滤器,击穿用 singleflight / 永不过期,雪崩用随机过期 / 多级缓存 / 熔断。

理解(能讲清原理和取舍,不要求背代码)

  1. 双库设计是从"读者该看到什么"一步步推出来的,不是拍脑袋。
  2. 事务应该在"整合多个数据库操作"的那一层控制:同库不同表在 DAO 层,跨库在 Service 层用重试补偿。
  3. MongoDB 的文档模型、雪花算法的比特布局、OSS + CDN 的回源关系各自解决什么问题。
  4. 四种缓存模式(Cache-Aside / Read-Through / Write-Through / Write-Behind)的差异与选型。
  5. 缓存与数据库一致性的四种更新顺序,为什么标准是"先更新 DB,再删缓存"。

了解(知道有这回事,面试能说出名字和一句话定位)

  1. DDD 的实体(Entity)与值对象(Value Object),同一对象在不同领域角色不同。
  2. Upsert(Insert or Update)语义,以及为什么 MySQL 制作库不能用它。
  3. 缓存淘汰策略 LRU / LFU / FIFO,Redis 默认用近似 LRU。
  4. 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)是本章考点最密处,建议放慢速度。

发帖与缓存需求与 TDD存储选型缓存策略双库:制作/线上TDD 红绿重构越权校验 author_idMongoDB 存大文本雪花算法分布式 IDOSS + CDNCache-Aside 模式穿透/击穿/雪崩只缓存第一页
复习提示:怎么读这一章:正文每一节都按同一个节奏推进——先抛问题 → 给个直觉(类比或图)→ 最小可运行代码 → 逐行讲解 → 常见坑 → 小收口。遇到没见过的名词,跳过正文直接看对应「补课胶囊」也行。

三、正文

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 发表的三种流程情况

把"保存"和"发表"组合起来,创作者实际会走出三种流程:

  1. 新建后直接发表:写完点发表——先插入制作库,再同步到线上库。
  2. 写了一半保存,之后第一次发表:草稿已在制作库,发表时更新它并首次同步到线上库。
  3. 修改已发表的文章后再次发表:线上库已有旧版本,发表时用新版本覆盖(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
  1. 红(Red):先写一个目前会失败的测试(甚至编译都过不了,因为函数还没写)。
  2. 绿(Green):用最省事的方式让测试通过,先别管代码好不好看。
  3. 重构(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-11ctx.Bind(&req)自动按 Content-Type 解析 JSON;解析失败直接返回
14ctx.GetInt64("user_id")登录态由第 03 章的 JWT 中间件塞进 context,这里取出来
18AuthorId: 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 改 / 删"的接口,都必须带 owner 条件。只传 id 做更新等于把别人的数据敞开门——攻击者遍历 ID 就能改全网帖子。所有类似场景(订单、评论、资料)都要排查,可以用 Gin middleware 提供统一校验。
复习提示:升职加薪提醒:某司早期订单没校验订单号和创建者,导致订单库被爬虫全部爬完。所有"按 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 部分失败问题

为什么不用事务一把梭?

  1. Service 层不知道 repository 用什么存储(关系型?NoSQL?)。
  2. 制作库和线上库可能是两个不同的数据库,跨库事务做不到(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? 三个硬伤:

  1. 行溢出(Row Overflow):MySQL 一行有大小上限(约 65535 字节),超长的 text 会被存到"溢出页",读它要多一次磁盘寻址,慢。
  2. 索引效率低:长文本字段很难建高效索引;若正文参与查询(如按内容搜),MySQL 的全文索引能力弱、且会把整行拖慢。
  3. 灵活度差:文章正文之外可能还有"摘要、标签、大纲"等结构,关系型表要加列才能存;而文档型数据库加字段零成本,不用 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 做参照系来理解——两者核心概念几乎一一对应,只是换了名字:

MongoDBMySQL 对应
DatabaseDatabase
CollectionTable
DocumentRow
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}
DE 的切片(有序)bson.D{{Key: "x", Value: 1}}
MMap(无序)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 相对顺序不变即可。

复习提示:MySQL 索引设计也可以参考 ESR 规则。但实践中 R 和 S 换顺序有时性能更好,别当死教条。

小收口

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 位):

比特位1411012
含义保留时间戳(毫秒)Worker ID(机器号)自增序号
容量无可用约 69 年最多 1024 个节点每毫秒 4096 个 ID
flowchart LR
    T[41bit 时间戳
毫秒] --> ID[64bit 雪花 ID] W[10bit 机器ID] --> ID S[12bit 序号
每毫秒4096] --> ID
复习提示:⚠️ 新手必踩的坑:雪花算法依赖"机器时钟"。如果服务器时钟回拨(NTP 校准、虚拟机迁移),可能生成重复 ID。生产上要给 worker 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 的代码,就能对接多家云。

复习提示:类比短信:短信就没有这种统一 API,每家厂商 SDK 都不一样;而 OSS 因为 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 访问文章内容。这是一个有漏洞的做法。

复习提示:⚠️ 新手必踩的坑:CDN 上的内容默认"谁都能拿"。OSS + CDN 把文章正文挂到公开 CDN URL 后,登录态形同虚设——只要猜到 URL 就能读。要么文章内容不涉及敏感信息,要么用私有化签名 URL(带过期签名的临时链接)才能访问。

小收口

存储选型三件套讲完了:MySQL(结构化)、MongoDB(大文本 / 灵活)、OSS(文件 / 多媒体)。写链路到此完备。接下来进入本章核心考点——读链路与缓存。


3.19 查询接口分类

写链路打通后,轮到读链路。查询接口先按"谁在查"分家:

  • 创作者的查询接口:列表(分页,创作中心看自己的全部文章)、详情(进入编辑时加载已有内容)。
  • 读者的查询接口:搜索(接搜索模块后设计)、推荐(按喜好推荐)、阅读(查看文章全文)。

本节先实现创作者列表、创作者详情、读者阅读三个接口。


3.20 分页接口(三种形态)

核心目标:避免一次拉太多数据引发性能问题。分页参数怎么从前端传到后端?业界三种主流形态:

形态示例说明
Offset + LimitOffset=200, Limit=100前端计算偏移
PagePage=3(每页 100 条)后端计算偏移
CursorCursor=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 就被打挂。

解决方案:

  1. 缓存空值:DB 没查到也写一个空值到缓存(带短 TTL),下次同样的请求直接命中缓存,不再打 DB。
  2. 布隆过滤器:在缓存前加一层布隆过滤器,过滤掉"一定不存在"的 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 被这一波峰值压垮。

解决方案:

  1. 互斥锁:缓存未命中时只让一个请求查 DB,其他请求等待它回填后再读。
  2. 永不过期:热 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 瞬间压力暴增被打挂。

解决方案:

  1. 过期时间加随机值:避免同时失效。
  2. 多级缓存:本地缓存(如 bigcache)+ Redis,Redis 挂了本地还能顶。
  3. 熔断降级: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 再删缓存——不是零风险,而是四种顺序里风险最小的。

再进一步,为什么是"删缓存"而不是"更新缓存"?两个原因:

  1. 并发写时更新缓存会乱序:两个写请求的缓存更新顺序无法保证与 DB 一致;删缓存则天然规避(下次读统一回源重建)。
  2. 懒加载思想:删掉之后,只有真正有人读才回源重建;没人读的数据不用白白算一遍缓存值。

要求更高时的增强是延迟双删:更新 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
}
复习提示:这就是 Repository 抽象的作用——在写入时统一清理缓存。业务层(Service)完全不用关心"该清哪个缓存",Repository 把它封装掉了。

小收口

写时清缓存,保证读者最终看到新内容。但还有一类"预测式"优化——提前把用户大概率要看的数据塞进缓存。


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 支持
LRULeast Recently Used淘汰最近最少使用✅(近似 LRU)
LFULeast Frequently Used淘汰最不频繁使用✅(Redis 4.0+)
FIFOFirst 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
}
复习提示:在 Repository 层操作 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 的危害有四条:

  1. 阻塞:Redis 处理命令是单线程的,读一个 10MB 的 key、删一个百万元素的 List,耗时几十上百毫秒,期间所有请求排队;
  2. 内存倾斜:分片集群下,大 key 所在的分片内存和带宽远超兄弟节点,容量与 QPS 都打不平;
  3. 网络放大:一次 LRANGE 0 -1 拉回几百 MB,网卡和序列化 CPU 一起被打爆;
  4. 删除卡顿: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。

复习提示:工程提示:10KB / 5000 个元素这些阈值不是圣旨,真正该盯的是单个 key 的操作耗时有没有污染 P99。要离线做精确分析,可以拿 RDB 文件用 rdb-tools 类工具看内存分布,比 --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 节)。


六、自测题与动手练习

自测题(合上书能答出来,才算懂;答案在第七节面试问答里对应题号):

  1. 为什么发帖要分"制作库"和"线上库"两个库?只用一个库靠状态字段过滤会有什么问题?
  2. TDD 的核心循环是什么?TDD 和 DDD 到底有没有关系?
  3. “修改别人的帖子"漏洞是怎么产生的?为什么修复时要 WHERE id=? AND author_id=? 而不是先查后判?
  4. 制作库和线上库"同库不同表"与"跨库"时,事务分别该在哪一层控制?跨库为什么不能用事务?
  5. 缓存三大问题(穿透 / 击穿 / 雪崩)各是什么、分别怎么解?为什么列表缓存"只缓存第一页”?
  6. 缓存与数据库一致性有哪四种更新顺序?为什么 Cache-Aside 标准是"先更新 DB,再删缓存"?
  7. 为什么大文本不存 MySQL,而用 MongoDB?MongoDB 的 _id 和 MySQL 自增 ID 有什么本质区别?
  8. 什么是 Upsert?为什么线上库能用 Upsert,而制作库不能?

动手练习(建议真做一遍):

  1. TDD 跑绿:从 TestArticleHandler_Edit 的"新建帖子成功"用例出发,先写空 Save 让测试编译失败(红),再补实现直到变绿;再加"修改别人的帖子"集成测试验证被拦。
  2. 缓存第一页:给创作者列表接口加上 GetFirstPage / SetFirstPage / DelFirstPage,写测试确认:列表走缓存命中、编辑文章后缓存被清、缓存出错时仍查 DB。
  3. 缓存三大问题对照表:挑一个你自己的接口,分别写出它会遭遇穿透 / 击穿 / 雪崩的触发方式,并各给一种对策(空值 / singleflight / 随机过期)。

七、模拟面试问答

这一节模拟真实面试场景。每题按基础 / 进阶 / 追问三档组织,格式为:一句话结论 → 展开 → 面试官想听的关键词 → 常见失分点。

面试官
【基础】 为什么发帖功能要分制作库和线上库?如果只用一个库,靠状态字段过滤行不行?
候选人

一句话结论:分库是为了把"创作者编辑中"和"读者可见"从物理上隔离,保证读者永远只看到已发表版本;单库靠状态过滤既复杂又容易把草稿漏给读者。

展开:创作者要频繁改但不想让读者看中间态,读者要稳定的已发表内容,两者需求天然冲突。分库后制作库只服务创作者、线上库只服务读者,发表时由系统同步一次。单库方案每次读都要用状态字段过滤"该不该对读者可见",逻辑嵌套深、易错,且一不留神就把未发表草稿暴露出去。

面试官想听的关键词:双库隔离、可见性、发表即同步、单库过滤复杂度、草稿泄露风险。

常见失分点:只说"为了性能"——分库主要不是为了性能,是为了正确隔离读写视角;或说不清"单库到底会出什么具体 bug"。

面试官
【基础】 什么是 TDD?它的核心循环和好处是什么?
候选人

一句话结论:TDD(测试驱动开发)是"先写测试、再写实现"的开发节奏,核心循环是红 - 绿 - 重构。

展开:红阶段写一个会失败的测试,绿阶段用最省事的方式让测试通过,重构阶段在测试兜底下放手改整洁代码。四大好处:站在使用者视角理清接口、测试用例表充当需求清单查漏补缺、有测试兜底才敢重构、功能迭代完测试自然齐备。TDD 和 DDD 没关系——一个是工程节奏,一个是设计思想。

面试官想听的关键词:红绿重构、先写测试、理清思路、查漏补缺、方便重构、与 DDD 无关。

常见失分点:把 TDD 说成"写完代码再补测试"(那是测试后置,不是 TDD);或把 TDD 和 DDD 绑在一起讲。

面试官
【基础】 为什么"按 ID 修改 / 删除"的接口必须带 owner 条件(author_id)?
候选人

一句话结论:不带 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 这个现成武器。

面试官
【进阶】 缓存与数据库一致性,为什么 Cache-Aside 标准是"先更新 DB,再删缓存"?为什么不"更新缓存"而是"删缓存"?
候选人

一句话结论:四种更新顺序里"先更 DB 再删缓存"风险最小;选"删"而非"更",是为了规避并发写乱序并符合懒加载。

展开:先更缓存再更 DB 会留脏数据(最差);先更 DB 再更缓存在并发写下可能留旧值;先删缓存再更 DB 有窗口期读旧值回写;先更 DB 再删缓存只在"读比写慢"的罕见时序下出问题,概率极低。用"删"而不是"更新"的原因:并发写时更新缓存顺序无法保证与 DB 一致,删缓存则下次读统一回源重建;且懒加载避免为无人读的数据白算缓存值。增强可加延迟双删。

面试官想听的关键词:先更 DB 再删缓存、四种顺序对比、并发写乱序、懒加载、延迟双删。

常见失分点:答不出"为什么不更新缓存而删缓存"这个追问;或把"删缓存"说成"缓存一定和 DB 强一致"(其实仍有极小窗口)。

面试官
【进阶】 事务应该在哪一层控制?跨库为什么不能用事务?
候选人

一句话结论:事务必须跟着"整合多个 DB 操作"的那一层走——同库不同表在 DAO 层,跨库 / 跨服务在 Service 层用重试补偿。

展开:同库不同表时,DAO 层用 GORM 闭包事务一把梭最干净;Repository 层不该直接持有 gorm.DB,否则强依赖具体存储、违背面向接口。跨库时,Service 层不知道底层用的是什么存储,且 MySQL 不支持跨库分布式事务,所以只能接受"部分失败靠重试补偿"——创作者看到失败可重试,读者顶多看到旧版或看不到,不会读到半篇。

面试官想听的关键词:整合层控制事务、DAO 本地事务、跨库无分布式事务、重试补偿、部分失败。

常见失分点:说"在 Service 层开数据库事务"——Service 不该知道底层是 GORM 还是别的;或认为跨库事务"加个注解就行"。

面试官
【进阶】 为什么大文本用 MongoDB 而不是 MySQL?MongoDB 的 _id 和自增 ID 有什么不同?
候选人

一句话结论:MySQL 行有大小上限、长字段索引弱、加列成本高,不适合大文本;MongoDB 文档模型无模式、原生分片、适合大文本。二者主键方案本质不同:自增 ID 依赖单点,雪花算法分布式。

展开:MySQL 一行约 65535 字节上限,超长 text 走溢出页、多一次磁盘寻址;长文本字段难建高效索引;加列要 ALTER TABLE。MongoDB 存的是文档,字段随意增删,且分片自动扩容。主键上,MySQL 自增依赖单点数据库发号、分布式下做不到、还会泄露规模;MongoDB 默认 _id 是 12 字节对象 ID,本课程用雪花算法生成 int64,把时间戳 + 机器号 + 序号编进 ID,每台机器独立发号。

面试官想听的关键词:行溢出、索引效率、无模式、分片、雪花算法、分布式 ID。

常见失分点:把"大文本"说成"MySQL 完全不能存"(能存,只是代价高);或答不出雪花算法为什么不用自增。

面试官
【进阶】 什么是 Upsert?为什么线上库能用、制作库不能?
候选人

一句话结论: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 淘汰最久未用、LFU 淘汰最少使用、FIFO 淘汰最早进入;Redis 默认是近似 LRU,4.0+ 支持 LFU;更聪明的是按业务权重淘汰(优先留大 V、优先淘汰大对象)。

展开:严格 LRU 要维护全局链表开销大,Redis 用随机采样近似 LRU 平衡性能。LFU 记录访问频率,更适合"热点常驻"。面试亮点在于业务加权:优先淘汰普通创作者的数据、留下大 V(LFU 近似可达),或优先淘汰大对象一次性释放内存,或优先淘汰计算成本低的小对象。这些是在基础策略之上叠加业务语义。

面试官想听的关键词:近似 LRU、LFU、业务加权淘汰、大 V 优先、大对象优先。

常见失分点:说"Redis 用严格 LRU"(实际是近似);或只知道三种基础策略,答不出业务加权的亮点思路。

面试官
【追问】 什么是 OSS + CDN?为什么图片 / 大文本走这条路?有什么安全隐患?
候选人

一句话结论: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 语义模糊"这个具体坑。

面试官
【追问】 如果公司要同时用两家云的对象存储(多云),Go 侧应该怎么设计?接口定义要注意什么?
候选人

一句话结论:核心是面向接口编程——定义一套贴合业务而非贴合 S3 的统一存储接口(Put/Get/Delete/Exists),每家云一个实现,把 endpoint / 鉴权 / 错误码差异封装在实现内部。

展开:要点有四:接口贴合业务需要(存 / 取文章)而非 S3 方法(PutObject/HeadObject);只抽象用到的能力,别覆盖对象存储全部 API;把差异藏进实现(如把厂商"不存在"错误翻译成统一 ErrNotFound);路由决策外置到配置。形态递进:上层抽象 + 多实现(能换)→ 按区域 / 类型路由(会分)→ 故障自动切换(RouterStorage 组合主备,主云非"不存在"类失败时读备云,且自身也实现同一接口,业务无感知)。

面试官想听的关键词:面向接口、贴合业务不贴合 S3、ErrNotFound 统一、RouterStorage、主备切换。

常见失分点:把接口定义成"S3 的影子"(PutObject/HeadObject 原样搬),换云时照样改业务;或忽视"哪个 key 在哪朵云"的路由元数据该放哪。

面试官
【基础】 什么是大 key?它有哪些危害?
候选人

一句话结论:单个 key 的 value 特别大就叫大 key(经验阈值:String 超 10KB、集合类型元素上万),危害四大件——阻塞单线程、内存倾斜、网络放大、删除卡顿。

展开:Redis 处理命令是单线程的,读一个 10MB 的 key 期间所有请求排队,这是第一危害;分片集群下大 key 所在分片内存带宽远超兄弟节点,打不平;LRANGE 0 -1 拉全量打爆网卡;DEL 大 key 是同步的,线上要用 UNLINK 异步删或 SCAN 分批删。

面试官想听的关键词:10KB/万级阈值、单线程阻塞、内存倾斜、网络放大、UNLINK 异步删。

常见失分点:只背定义讲不出"阻塞单线程"这个第一危害;不知道 DEL 大 key 本身也是事故源。

面试官
【进阶】 大 key 怎么治理?“只缓存前 N 页"为什么成立?
候选人

一句话结论:治法 = 分段/分片拆开 + 按访问模式只缓存热点区间 + 异步删除兜底;“只缓存前 N 页"成立是因为数据显示 90%+ 的浏览集中在前几页、深翻页根本没人看——用产品行为裁剪数据范围,慢的那部分就让它慢在没人看的地方。

展开:List 按"第几页"分段、Hash 按用户 id 取模分片,写入分散、读取范围可控;过期时间错峰,避免一批大 key 同时失效引发雪崩。深翻页直接回源 DB——回源慢但频率低,整体 QPS 曲线是平的。

面试官想听的关键词:分段、分片、只缓存前 N 页、过期错峰、按访问模式拆。

常见失分点:只会"加内存加机器”,说不出"裁剪数据范围"这个产品视角;或者忘了拆完还要处理过期与删除。

面试官
【追问】 线上怎么提前发现大 key?大 key 是怎么"养"出来的?
候选人

一句话结论: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 章,你会看到缓存思路如何延续到更重的读场景。

复习提示:给下一章留个伏笔:本章我们让 Publish 接口"同步"地把数据写进线上库。当点赞、阅读计数、Feed 推送都挂到发表动作后面时,同步写会让发表越来越慢。下一章引入消息队列后,“发表"只负责发一条消息就返回,后续的阅读计数、Feed 分发交给队列异步做——这正是削峰解耦的经典手法。
About Me

没什么想介绍的,一个很大众的码农…

喜欢代码,车,马,真的是 🐎

讨厌别人让我给自己的代码写注释 最厌烦别人的程序没有写注释

目标

学AI,加油!加油!