学习目标
- 掌握发帖功能的需求分析方法:从用例、状态图、流程图出发设计接口,理解"制作库/线上库"双库设计的原因
- 熟练运用 TDD(测试驱动开发)流程,能从单元测试或集成测试出发迭代出可工作的实现
- 理解 DDD 中的实体、值对象、领域服务概念,知道事务应该在哪一层控制
- 能用 MongoDB 实现非关系型存储方案,掌握雪花算法生成分布式 ID
- 理解 OSS + CDN 的应用场景,能结合 GORM 与 S3 设计混合存储方案
- 掌握分页接口设计(Offset/Limit、Page、Cursor 三种形态)与缓存策略
- 熟悉缓存模式(Cache-Aside、Read-Through、Write-Through、Write-Behind),理解缓存穿透/击穿/雪崩的成因与对策
- 能在发帖业务中应用业务预加载、Publish 提前预热缓存等高级缓存方案
前置知识(如果下面任意一点生疏,先回看对应章):
- 第02章 GORM + MySQL:会建表、写 DAO、用事务。
- 第03章 JWT:知道
ctx.Get("claims")怎么拿到登录用户。 - 第04章 面向接口 / Repository 分层 / 单元测试 Table-Driven:本章大量复用。
- 基本 Redis 概念(string、TTL、删除 key)。
- 一点分布式常识:知道"跨库事务"做不到。
本章你会动手做的事:
- 用 TDD 先写"新建帖子成功"的测试,再补实现让它变绿。
- 给"修改别人的帖子"场景加
author_id条件,写个集成测试验证攻击请求被拦。 - 给"创作者列表"接口加第一页缓存,并验证编辑文章后会清掉该缓存。
一、发帖功能:从需求到实现
1.1 需求分析
1.1.1 用例分析
发帖功能对创作者来说是增删改查,对读者来说只有查。本文先从创作者视角切入。
1.1.2 关键问题:内容对读者的可见性
创作者修改时,读者能不能看到?显然:只有发表后才对读者可见。修改期间读者应看到上一个发表版本。
这就引出了帖子状态:未发表 / 已发表 / 仅自己可见(撤回)。
1.1.3 状态图
stateDiagram-v2
[*] --> 未发表
未发表 --> 已发表: 发表
已发表 --> 仅自己可见: 撤回
已发表 --> 未发表: 修改
仅自己可见 --> 未发表: 修改注意:一般不要把零值做成有意义的值,所以状态常量定义时让"未发表"是非 0 值:
// domain/article.go
package domain
const (
ArticleStatusUnknown uint8 = 0 // 未知,零值无意义
ArticleStatusUnpublished uint8 = 1 // 未发表
ArticleStatusPublished uint8 = 2 // 已发表
ArticleStatusPrivate uint8 = 3 // 仅自己可见
)
1.1.4 制作库与线上库:双库设计
| 库 | 用途 | 谁写 | 谁读 |
|---|---|---|---|
| 制作库 | 创作者编辑中的内容 | 创作者 | 创作者 |
| 线上库 | 读者能看到的内容 | 系统(发表时同步) | 读者 |
为什么分两个库? 因为创作者和读者的需求不同:
- 创作者要频繁修改但不想让读者看到中间状态
- 读者要稳定的、已发表的内容
如果用单库,就必须每次修改都通过状态字段过滤,逻辑复杂且易错。
flowchart LR
subgraph 创作者
M[制作库
编辑中]
E[编辑接口]
end
subgraph 读者
O[线上库
已发表]
R[阅读接口]
end
E --> M
P[发表] -->|同步| O
O --> R1.1.5 删除:真删除 vs 假删除
- 假删除(设置为"仅自己可见"):满足大部分"对外不可见"的需求
- 真删除:满足"彻底删除"的需求
本课程只实现假删除(撤回),不实现真删除。
1.2 编辑接口:TDD 实战
1.2.1 TDD 简介
TDD(测试驱动开发,Test-Driven Development):先写测试,再写实现。
核心循环:
- 根据需求理解,初步定义接口(不要怕定义得不合适)
- 根据接口定义测试框架(参考测试模板)
- 执行核心循环:
- 增加测试用例
- 提供/修改实现
- 执行测试用例
TDD 的好处:理清思路、查漏补缺、方便重构、测试完善。
TDD 和 DDD 没什么关系。DDD 适合战略规划,TDD 适合落地具体功能。
flowchart TD
A[定义接口] --> B[定义测试框架]
B --> C{核心循环}
C --> D[增加测试用例]
D --> E[提供/修改实现]
E --> F[运行测试]
F --> C1.2.2 第一步:定义接口
前端只需要两个字段:标题、内容。
// web/article.go
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) // 发表
}
1.2.3 第二步:定义测试用例结构体
// web/article_test.go
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 web.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 := web.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 web.Result[int64]
err := json.Unmarshal(recorder.Body.Bytes(), &res)
require.NoError(t, err)
assert.Equal(t, tc.wantRes, res)
})
}
}
1.2.4 第三步:第一个测试用例:新建帖子
{
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: web.Result[int64]{Data: 1},
},
1.2.5 提供实现
// web/article.go
func (h *ArticleHandler) Edit(ctx *gin.Context) {
// 步骤 1:绑定请求体
var req Article
if err := ctx.Bind(&req); err != nil {
return
}
// 步骤 2:从登录态拿创作者 ID
uid, _ := ctx.Get("claims")
claims := uid.(*jwt.Claims)
// 步骤 3:调用 Service 保存
id, err := h.svc.Save(ctx, domain.Article{
Id: req.Id,
Title: req.Title,
Content: req.Content,
AuthorId: claims.Uid,
})
if err != nil {
ctx.JSON(http.StatusOK, web.Result{Code: 5, Msg: "系统错误"})
return
}
ctx.JSON(http.StatusOK, web.Result{Data: id})
}
1.2.6 DDD:实体与值对象
- 实体(Entity):独立存在的对象,有自己的 ID
- 值对象(Value Object):依附于实体,作为属性出现
关键洞察:同一个对象在不同领域中角色不同。一个人:
- 在用户领域中是实体(独立存在)
- 在帖子领域中是值对象(只是某些帖子的"创作者"属性)
所以在帖子领域里,“用户"退化为 AuthorId。
1.7 第二个测试用例:修改帖子
{
name: "修改帖子成功",
before: func(t *testing.T) {
// 在数据库中插入一条已有数据
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,
},
1.8 第三个测试用例:修改别人的帖子(安全!)
这是一个容易被忽略但非常重要的问题。如果文章 ID 从前端接收,攻击者可以传别人的 ID!
实现:在更新条件里带上 author_id
// dao/article.go
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 = ?") 而不是先查后判? 节省一次数据库查询。
缺陷:无法区分"ID 不存在"和"不是作者”。但都是异常情况,不需要仔细区分。
⚠️ 新手必踩的坑:凡是"按 ID 改/删"的接口,都必须带 owner 条件。只传
id做更新等于把别人的数据敞开门——攻击者遍历 ID 就能改全网帖子。所有类似场景(订单、评论、资料)都要排查,可以用 Gin middleware 提供统一校验。
升职加薪提醒:某司早期订单没校验订单号和创建者,导致订单库被爬虫全部爬完。所有类似场景都要排查,可以考虑用 Gin middleware 提供统一校验。
1.9 发表接口:单元测试 TDD
1.9.1 接口定义
func (h *ArticleHandler) Publish(ctx *gin.Context) {
// 步骤 1:绑定请求体
var req Article
if err := ctx.Bind(&req); err != nil {
return
}
// 步骤 2:从登录态拿创作者 ID
uid, _ := ctx.Get("claims")
claims := uid.(*jwt.Claims)
// 步骤 3:调用 Service 发表
id, err := h.svc.Publish(ctx, domain.Article{
Id: req.Id,
Title: req.Title,
Content: req.Content,
AuthorId: claims.Uid,
})
if err != nil {
ctx.JSON(http.StatusOK, web.Result{Code: 5, Msg: "系统错误"})
return
}
ctx.JSON(http.StatusOK, web.Result{Data: id})
}
1.9.2 单元测试用例
{
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: web.Result[int64]{Data: 1},
},
1.10 Service 层:同步数据到线上库
发表的核心:先保存到制作库,再同步到线上库。
1.10.1 职责划分:在哪一层同步?
| 层 | 单体/微服务 | 优点 | 缺点 |
|---|---|---|---|
| Web/聚合服务 | 微服务 | 创作者和读者各自有服务,自然分离 | 跨服务调用 |
| Service | 单体/微服务 | 调用不同 repo 保存 | 部分失败难处理 |
| Repository | 单体 | 可以用本地事务 | repository 强依赖具体 DAO |
1.10.2 Service 层分发:用两个 Repository
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[创作者可重试
读者看到旧版/无]1.10.3 部分失败问题
为什么不用事务?
- Service 层不知道 repository 用什么存储(关系型?NoSQL?)
- 制作库和线上库可能是两个不同的数据库,跨库事务做不到
怎么处理部分失败?
- 创作者能看到失败,可以重试
- 读者要么看不到帖子,要么看到上次版本
- 可以引入重试机制,但建议先上线观察,再决定是否需要
1.11 Repository 层同步数据
如果制作库和线上库是同库不同表,可以在 Repository 层用事务:
type articleRepository struct {
dao dao.ArticleDAO
}
func (r *articleRepository) Sync(ctx context.Context, art domain.Article) error {
return r.dao.Sync(ctx, art)
}
// dao 层用 GORM 闭包事务
func (d *ArticleDAO) Sync(ctx context.Context, art Article) 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{...}),
}).Create(&PublishedArticle{...}).Error
})
}
1.11.1 DAO 层同步数据
如果制作库和线上库是同库不同表,最简单的做法是在 DAO 层用 GORM 闭包事务:
func (d *ArticleDAO) Sync(ctx context.Context, art Article) error {
return d.db.Transaction(func(tx *gorm.DB) error {
// 事务里同时操作两张表
if err := tx.Model(&Article{}).Where("id = ?", art.Id).
Updates(...).Error; err != nil {
return err
}
return tx.Clauses(clause.OnConflict{...}).
Create(&PublishedArticle{...}).Error
})
}
1.12 Upsert 语义
线上库可能已发表过(更新),也可能没发表过(插入),所以用 INSERT OR UPDATE(Upsert):
// GORM Upsert
db.Clauses(clause.OnConflict{
Columns: []clause.Column{{Name: "id"}}, // 冲突判定列
DoUpdates: clause.Assignments(map[string]any{...}), // 冲突时更新
}).Create(&art)
1.13 为什么制作库不用 Upsert?
因为制作库的更新需要校验 author_id(只有作者能改自己的),而 MySQL 不支持 ON DUPLICATE UPDATE WHERE author_id=123 这种写法。
1.14 谁来控制事务?
| 层 | 何时用 |
|---|---|
| Service | 控制分布式事务,不控制数据库事务 |
| Repository | 可以控制数据库本地事务,但理论上不知道底层用 GORM |
| DAO | 最常在这里控制事务 |
核心原则:在哪一层整合多个数据库操作,就在哪一层控制事务。
flowchart TD
Q{在哪一层整合多个 DB 操作?}
Q -->|同库不同表| D[DAO/Repository 层本地事务]
Q -->|跨库/跨服务| S[Service 层用重试补偿]二、发帖功能增强
2.1 维护状态
2.1.1 状态流转
制作库:未发表 → 已发表 → 仅自己可见 线上库:已发表 → 仅自己可见
本课程不实现"删除"状态,只提供"撤回"(仅自己可见)。
2.1.2 状态设置的时机
- 保存时:设置为
ArticleStatusUnpublished- 新建-保存:直接未发表
- 修改-保存:依旧未发表
- 发表-修改-保存:从已发表变回未发表
- 发表时:制作库和线上库都改为
ArticleStatusPublished - 撤回时:通过新接口
/articles/withdraw设置为ArticleStatusPrivate
2.1.3 撤回接口
func (h *ArticleHandler) Withdraw(ctx *gin.Context) {
// 接收 id,校验登录态
// 调用 svc.Withdraw
}
// dao 通过事务同时更新两张表的状态
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
})
}
2.1.4 存储状态映射
直接在 DAO 里用 domain 层的 uint8 常量值是可以接受的,但不够优雅。
- 领域状态:domain 中的状态最完整、最真实
- 存储状态:数据库中的状态可能从领域状态衍生,也可能有辅助状态(如"软删除标记")
实践中,状态映射一直是难点。一种做法是在 DAO 层做映射表。
2.2 MongoDB:用 NoSQL 存储大文本
2.2.1 何时用 MongoDB
关系型数据库不好用时的备选。MongoDB 的优点:
- 灵活的文档模型:不需要预先定义表结构
- 易于横向扩展:通过分片应对流量和数据增长
适合场景:动态模型、巨量数据不想管分库分表。
2.2.2 MongoDB 基本概念
| MongoDB | MySQL 对应 |
|---|---|
| Database | Database |
| Collection | Table |
| Document | Row |
flowchart LR
DB[(Database)] --> C1[Collection 文章]
DB --> C2[Collection 用户]
C1 --> D1[Document]
C1 --> D2[Document]2.2.3 MongoDB 基本操作
// 初始化客户端
client, _ := mongo.Connect(context.TODO(),
options.Client().ApplyURI("mongodb://localhost:27017"))
db := client.Database("webook")
col := db.Collection("articles")
// 插入
res, _ := col.InsertOne(ctx, Article{Title: "x", Content: "y"})
id := res.InsertedID // 是字符串,不是数字!
// 查找(用结构体构造查询条件)
var art Article
err := col.FindOne(ctx, Article{Id: 1}).Decode(&art)
if errors.Is(err, mongo.ErrNoDocuments) {
// 没找到
}
// 更新
col.UpdateOne(ctx, bson.M{"_id": 1}, bson.M{"$set": bson.M{"title": "new"}})
// 删除
col.DeleteOne(ctx, bson.M{"_id": 1})
2.2.4 BSON 与 E/D/M/A
BSON 类似 JSON 的序列化协议。EDMA 是 BSON 的四种类型:
| 类型 | 本质 | 说明 |
|---|---|---|
| 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} |
2.2.5 复杂查询:OR/AND/IN
// OR
col.Find(ctx, bson.D{{Key: "$or", Value: bson.A{
bson.D{{Key: "x", Value: 1}},
bson.D{{Key: "y", Value: 2}},
}})
// IN
col.Find(ctx, bson.D{{Key: "x", Value: bson.D{{Key: "$in", Value: bson.A{1,2,3}}}}})
可读性很差,但记住:bson.E 基本都放在 bson.D 里。
2.2.6 MongoDB 索引设计: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 换顺序有时性能更好。
2.3 MongoDB 重构:主键问题
MongoDB 没有 MySQL 那种自增主键,_id 是 12 字节字节切片,不能直接当 int64 用。
方案对比:
- ID 改 string:影响整个系统
- ID 生成策略(雪花算法):改动最小
- 定义新接口
MongoArticleDAO:放弃 DAO 一致性 - GUID:字符串 ID
本课程选雪花算法:改动最小,最有教学价值。
2.4 雪花算法
原理:
| 比特位 | 1 | 41 | 10 | 12 |
|---|---|---|---|---|
| 含义 | 保留 | 时间戳 | Worker ID | 自增序号 |
- 1 比特保留位(不画也行)
- 41 比特时间戳(毫秒级,可用 69 年)
- 10 比特机器位(最多 1024 个节点)
- 12 比特序号(每毫秒可生成 4096 个 ID)
// 用 bwmarrin/snowflake 库
import "github.com/bwmarrin/snowflake"
node, _ := snowflake.NewNode(1) // worker ID = 1
id := node.Generate().Int64() // 生成 int64 ID
flowchart LR
T[41bit 时间戳
毫秒] --> ID[64bit 雪花 ID]
W[10bit 机器ID] --> ID
S[12bit 序号
每毫秒4096] --> ID⚠️ 新手必踩的坑:雪花算法依赖"机器时钟"。如果服务器时钟回拨(NTP 校准、虚拟机迁移),可能生成重复 ID。生产上要给 worker ID 做配置/分配,并对时钟回拨做保护(等待或报错)。
2.5 MongoDB DAO 实现
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},
bson.M{"$set": bson.M{
"title": art.Title,
"content": art.Content,
"utime": time.Now().UnixMilli(),
}})
return err
}
2.6 MongoDB 发表接口
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
}
2.7 OSS + CDN:用对象存储存大文本
2.7.1 何时用 OSS
OSS(Object Storage Service)适合存储大文本、文件、多媒体、图片。小文本直接存数据库没问题。
帖子其实不太适合 OSS,类似图片视频类生产平台才适合。
2.7.2 OSS 与 CDN
- OSS:对象存储,存储原始数据
- CDN:内容分发网络,分布全球的缓存节点,提高访问速度和稳定性
常见组合:OSS 作为 CDN 的回源站点。
flowchart LR
U[用户] --> CDN[CDN 边缘节点
缓存]
CDN -->|未命中回源| OSS[OSS 对象存储]2.7.3 OSS 与 S3 API
各大云厂商都有 OSS 服务,API 之间有差异。好在绝大多数都兼容 S3 API(Amazon 推出的 Simple Storage Service)。S3 是 RESTful 风格,比较优雅。
类比短信:短信就没有这种统一 API。
2.7.4 S3 API 入门
//go get github.com/aws/aws-sdk-go-v2/config
//go get github.com/aws/aws-sdk-go-v2/service/s3
cfg, _ := config.LoadDefaultConfig(ctx,
config.WithRegion("us-east-1"),
config.WithCredentialsProvider(credentials.NewStaticCredentialsProvider(
"accessKey", "secretKey", "")),
)
client := s3.NewFromConfig(cfg, func(o *s3.Options) {
o.BaseEndpoint = aws.String("https://cos.ap-shanghai.myqcloud.com")
})
// 上传文件
_, 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 防乱码
})
2.7.5 设计决策:只把 Content 存到 OSS
读者可能希望看到某创作者的文章列表,所以只在 OSS 存 Content,其他字段(标题、作者、状态等)存数据库,以参与复杂查询。
// dao.articleOSSDAO.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)
if err := tx.Clauses(clause.OnConflict{...}).
Create(&PublishedArticle{...}).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
})
}
2.7.6 SyncStatus 的麻烦
设置为"仅自己可见"时,要把数据从 OSS 删除。问题:OSS + CDN 时,OSS 删除了,CDN 里可能还有缓存。
2.7.7 OSS + CDN 的安全隐患
如果用户知道 CDN 的 URL 拼接方式,可以绕过登录机制直接从 CDN 访问文章内容。这是一个有漏洞的做法。
⚠️ 新手必踩的坑:CDN 上的内容默认"谁都能拿"。OSS+CDN 方案把文章正文挂到公开 CDN URL 后,登录态形同虚设——只要猜到 URL 就能读。要么文章内容不涉及敏感信息,要么用私有化签名 URL(带过期签名)才能访问。
2.8 升职加薪
2.8.1 在公司引入 OSS + CDN
如果你还在自己部署文件服务器、前端静态资源还访问后端获取,应该尝试 OSS + CDN。OSS + CDN 性价比高,能解决:
- 大量小文件
- 大文件
- 缓存与性能
- 可用性
2.8.2 在公司推行 MongoDB
适合场景:
- 动态模型(无法预先定义表结构,或表结构经常变)
- 灵活扩展(巨量数据,不想管分库分表)
要谨慎考虑场景,不能为了刷 KPI 而刷 KPI。
三、查询接口与缓存
3.1 查询接口分类
- 创作者的查询接口
- 列表接口(分页):在创作中心看自己发表的所有文章
- 详情接口:进入编辑时加载已有内容
- 读者的查询接口
- 搜索接口:接入搜索模块后再设计
- 推荐接口:根据用户喜好推荐
- 阅读文章接口:查看文章全部内容
本节先实现创作者列表、创作者详情、读者阅读三个接口。
3.2 分页接口
核心目标:避免一次操作太多数据引发性能问题。
3.2.1 三种分页形态
| 形态 | 示例 | 说明 |
|---|---|---|
| Offset + Limit | Offset=200, Limit=100 | 前端计算偏移 |
| Page | Page=3(每页 100 条) | 后端计算偏移 |
| Cursor | Cursor=eyJvZmZzZXQiOjIwMH0= | 后端返回游标,前端透传 |
Cursor 灵活性最强,用于复杂系统。一般推荐用第一种。
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.MustGet("claims").(*jwt.Claims).Uid
// 步骤 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.3 缓存模式(补充知识)
| 模式 | 读流程 | 写流程 | 特点 |
|---|---|---|---|
| Cache-Aside | 先查缓存,未命中查 DB 后回写 | 更新 DB,删除缓存 | 最常用,应用层负责 |
| Read-Through | 应用只查缓存,缓存自己去查 DB | - | 缓存层负责回源 |
| Write-Through | - | 应用写缓存,缓存同步写 DB | 强一致,性能差 |
| Write-Behind | - | 应用写缓存,缓存异步写 DB | 高性能,弱一致 |
最常用的是 Cache-Aside,本课程的实现都是基于这种模式。
flowchart TD
Q[读请求] --> C{缓存命中?}
C -- 是 --> R[返回]
C -- 否 --> D[查 DB]
D --> W[回写缓存]
W --> R
U[写请求] --> DB[更新 DB]
DB --> DEL[删除缓存]3.4 缓存三大问题(补充知识)
flowchart TD
P[缓存问题] --> P1[穿透: 查不存在的 key]
P --> P2[击穿: 热 key 突然过期]
P --> P3[雪崩: 大量 key 同时过期]
P1 --> S1[缓存空值 / 布隆过滤器]
P2 --> S2[互斥锁 / 永不过期]
P3 --> S3[过期加随机值 / 多级缓存 / 熔断]3.4.1 缓存穿透
问题:查询一个不存在的 key,缓存和 DB 都没有,每次请求都打到 DB。
解决方案:
- 缓存空值:DB 没查到也写一个空值到缓存(带短 TTL)
- 布隆过滤器:在缓存前加布隆过滤器,过滤掉一定不存在的 key
3.4.2 缓存击穿
问题:一个热 key 突然过期,瞬间大量请求打到 DB。
解决方案:
- 互斥锁:缓存未命中时只让一个请求查 DB,其他等待
- 永不过期:热 key 不设过期时间,靠后台异步更新
3.4.3 缓存雪崩
问题:大量 key 同时过期,DB 瞬间压力暴增。
解决方案:
- 过期时间加随机值:避免同时失效
- 多级缓存:本地缓存 + Redis
- 熔断降级:DB 压力大时直接返回降级数据
⚠️ 新手必踩的坑:Cache-Aside 的"先更新 DB 再删缓存"有极小窗口不一致。正常流程没问题,但极端并发下可能读到旧值。工程上通常容忍(最终一致),若要求强一致可加"延迟双删"或走 Write-Through。不要为小概率过度设计。
3.5 列表接口的缓存:只缓存第一页
为什么不缓存所有页? 因为筛选条件、排序条件、偏移量、每页大小任意一个变化,缓存就难用了。
为什么只缓存第一页? 因为大多数用户只看第一页,很少往后翻。
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,节省内存。
3.6 写操作清理缓存
新写文章、编辑文章、发表文章时都要清除相关缓存:
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 抽象的作用——在写入时统一清理缓存。
3.7 创作者详情接口的缓存:业务预加载
实践上:针对创作者的缓存性价比低(只有创作者自己用得上)。但这个场景适合演示一种新奇的方案——业务相关的缓存预加载。
思路:拿到列表后,创作者有更大概率访问第一条数据。所以列表返回时,异步把第一条数据预加载到缓存。
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.8 改进:不缓存大文档
不是所有内容都值得缓存。很长的文章就别缓存了,浪费内存效果有限。
注意:不是所有情况下都不缓存大对象,而是要权衡性能与内存开销。
3.9 读者阅读接口:Publish 提前预热缓存
奇妙的缓存方案:在 Publish 接口调用时,直接把数据放进缓存。
理由:新帖子发布后,立刻就会被人访问。
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.10 缓存过期时间策略
理论上,缓存过期时间应保证接下来对该资源的访问都命中缓存。但过期时间长短有权衡:
- 长:命中率高,但数据一致性差
- 短:一致性更好,但命中率低
面试案例:
- 业务相关的缓存预加载:过期时间要短
- Publish 提前设置:可根据创作者是否大 V 决定,大 V 用更长的过期时间
3.11 缓存淘汰策略(补充知识)
当缓存内存不够时,需要淘汰一些数据腾空间。
| 策略 | 全称 | 思路 |
|---|---|---|
| LRU | Least Recently Used | 淘汰最近最少使用 |
| LFU | Least Frequently Used | 淘汰最不频繁使用 |
面试亮点策略:
- 优先淘汰普通创作者的数据,留下大 V 的数据。LFU 能达到近似效果,LRU 效果差一些
- 优先淘汰大对象:一次性释放很多内存
- 优先淘汰小对象(如果大对象算起来很慢,小对象算起来很快,就没必要留着小对象)
3.12 读者阅读接口的 Author 组装
// 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。
四、工程实践要点
4.1 安全
- 修改/删除接口必须校验"是不是作者本人",把
author_id加到 WHERE 条件 - OSS + CDN 时注意绕过登录访问内容的风险
4.2 缓存
- 缓存只放在高频读场景
- 写操作要清理相关缓存
- 缓存出错也查 DB(业务可用优先)
- 列表只缓存第一页
- 大文档可以不缓存
- 利用业务规律做缓存预加载
4.3 事务
- 同库不同表:可以在 DAO/Repository 层用事务
- 跨库(制作库 + 线上库分离):不能用事务,用重试补偿
- Service 层不应控制数据库事务(它不知道底层用什么存储)
4.4 存储选型
| 场景 | 推荐 |
|---|---|
| 结构化数据 + 复杂查询 | MySQL |
| 大文本 + 灵活模型 | MongoDB |
| 大文件 / 多媒体 | OSS |
| 高并发读 | Redis 缓存 |
4.5 TDD
- 先写测试再写实现
- 测试框架可以参考模板
- 集成测试用例之间不能依赖
- 没测到的代码必须 review
4.6 索引设计:ESR 原则
E(Equal)→ S(Sort)→ R(Range)的相对顺序。MySQL 和 MongoDB 都适用。
五、面试要点
5.1 TDD
- 什么是 TDD? 测试先行
- TDD 和 DDD 是什么关系? 没什么关系。DDD 适合战略规划,TDD 适合落地具体功能
- TDD 有什么好处? 理清思路、查漏补缺、方便重构、测试完善
- 你是如何实施 TDD 的? 具体步骤:定义接口 → 定义测试 → 核心循环(加用例/实现/运行)
5.2 MongoDB
- 有没有用过 MongoDB?用来解决什么问题?
- 为什么用 MongoDB?用 MySQL 行不行?
- 解释集合和文档的关系?
- 如何设计索引?ESR 原则
- MongoDB 怎么生成 ID?雪花算法
- 是否支持事务?是否支持跨文档事务?是否支持 ACID?
5.3 缓存(适合面试的方案)
缓存过期时间设置:
- 业务相关的缓存预加载:过期时间要短
- Publish 提前设置:大 V 用更长过期时间
缓存淘汰策略:
- 优先淘汰普通创作者,留下大 V
- 优先淘汰大对象
- 或优先淘汰计算成本小的小对象
5.4 制作库/线上库
- 为什么分两个库?创作者和读者需求不同
- 谁负责同步数据?取决于是否需要本地事务
- 部分失败怎么办?引入重试,或让创作者重试
自测题与动手练习
自测题(合上书能答出来,才算懂):
- 为什么发帖要分"制作库"和"线上库"两个库?只用一个库靠状态字段过滤会有什么问题?
- TDD 的核心循环是什么?TDD 和 DDD 到底有没有关系?
- “修改别人的帖子"漏洞是怎么产生的?为什么修复时要
WHERE id=? AND author_id=?而不是先查后判? - 制作库和线上库"同库不同表"与"跨库"时,事务分别该在哪一层控制?跨库为什么不能用事务?
- 缓存三大问题(穿透 / 击穿 / 雪崩)各是什么、分别怎么解?为什么列表缓存"只缓存第一页”?
动手练习(建议真做一遍):
- TDD 跑绿:从
TestArticleHandler_Edit的"新建帖子成功"用例出发,先写空Save让测试编译失败,再补实现直到变绿;再加"修改别人的帖子"集成测试验证被拦。 - 缓存第一页:给创作者列表接口加上
GetFirstPage/SetFirstPage/DelFirstPage,写测试确认:列表走缓存命中、编辑文章后缓存被清、缓存出错时仍查 DB。 - 缓存三大问题对照表:挑一个你自己的接口,分别写出它会遭遇穿透/击穿/雪崩的触发方式,并各给一种对策(空值/互斥锁/随机过期)。
本章小结
第6章围绕"内容生产与查询"展开,从发帖到缓存,覆盖了一个典型内容系统的核心设计与实现。
关键回顾:
- 制作库/线上库双库设计:满足"创作者修改时读者看到上一个版本"的需求。状态流转:未发表 → 已发表 → 仅自己可见
- TDD 实战:定义接口 → 定义测试 → 核心循环。可以基于单元测试或集成测试出发
- DDD 实体/值对象:同一对象在不同领域角色不同,用户在帖子领域是值对象(AuthorId)
- 安全校验:修改接口必须带
author_id条件,防止攻击者改别人的内容 - 事务控制层:同库不同表在 DAO/Repository 层,跨库在 Service 层用重试补偿
- MongoDB:用于动态模型和大规模数据,主键用雪花算法,索引用 ESR 原则
- OSS + CDN:适合大文件多媒体,注意 CDN 绕过登录的安全问题
- 缓存模式:最常用 Cache-Aside;列表只缓存第一页;大文档可不缓存;Publish 时预热缓存;业务规律预加载
- 缓存三大问题:穿透(空值/布隆)、击穿(互斥锁/永不过期)、雪崩(随机过期/多级缓存/熔断)
- 缓存淘汰:LRU/LFU 是基础,亮点策略是按创作者权重 / 按对象大小淘汰
掌握这些内容,你已经具备了从 0 到 1 设计一个内容生产系统的能力,包括需求分析、存储设计、TDD 实现、缓存优化等核心工程化技能。