发帖功能与缓存

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

@

学习目标

  1. 掌握发帖功能的需求分析方法:从用例、状态图、流程图出发设计接口,理解"制作库/线上库"双库设计的原因
  2. 熟练运用 TDD(测试驱动开发)流程,能从单元测试或集成测试出发迭代出可工作的实现
  3. 理解 DDD 中的实体、值对象、领域服务概念,知道事务应该在哪一层控制
  4. 能用 MongoDB 实现非关系型存储方案,掌握雪花算法生成分布式 ID
  5. 理解 OSS + CDN 的应用场景,能结合 GORM 与 S3 设计混合存储方案
  6. 掌握分页接口设计(Offset/Limit、Page、Cursor 三种形态)与缓存策略
  7. 熟悉缓存模式(Cache-Aside、Read-Through、Write-Through、Write-Behind),理解缓存穿透/击穿/雪崩的成因与对策
  8. 能在发帖业务中应用业务预加载、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 --> R

1.1.5 删除:真删除 vs 假删除

  • 假删除(设置为"仅自己可见"):满足大部分"对外不可见"的需求
  • 真删除:满足"彻底删除"的需求

本课程只实现假删除(撤回),不实现真删除。

1.2 编辑接口:TDD 实战

1.2.1 TDD 简介

TDD(测试驱动开发,Test-Driven Development):先写测试,再写实现。

核心循环:

  1. 根据需求理解,初步定义接口(不要怕定义得不合适)
  2. 根据接口定义测试框架(参考测试模板)
  3. 执行核心循环:
    • 增加测试用例
    • 提供/修改实现
    • 执行测试用例

TDD 的好处:理清思路、查漏补缺、方便重构、测试完善。

TDD 和 DDD 没什么关系。DDD 适合战略规划,TDD 适合落地具体功能。

flowchart TD
    A[定义接口] --> B[定义测试框架]
    B --> C{核心循环}
    C --> D[增加测试用例]
    D --> E[提供/修改实现]
    E --> F[运行测试]
    F --> C

1.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 部分失败问题

为什么不用事务?

  1. Service 层不知道 repository 用什么存储(关系型?NoSQL?)
  2. 制作库和线上库可能是两个不同的数据库,跨库事务做不到

怎么处理部分失败?

  • 创作者能看到失败,可以重试
  • 读者要么看不到帖子,要么看到上次版本
  • 可以引入重试机制,但建议先上线观察,再决定是否需要

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 基本概念

MongoDBMySQL 对应
DatabaseDatabase
CollectionTable
DocumentRow
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}
DE 的切片bson.D{{Key: "x", Value: 1}}
MMapbson.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 雪花算法

原理:

比特位1411012
含义保留时间戳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 + LimitOffset=200, Limit=100前端计算偏移
PagePage=3(每页 100 条)后端计算偏移
CursorCursor=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。

解决方案

  1. 缓存空值:DB 没查到也写一个空值到缓存(带短 TTL)
  2. 布隆过滤器:在缓存前加布隆过滤器,过滤掉一定不存在的 key

3.4.2 缓存击穿

问题:一个热 key 突然过期,瞬间大量请求打到 DB。

解决方案

  1. 互斥锁:缓存未命中时只让一个请求查 DB,其他等待
  2. 永不过期:热 key 不设过期时间,靠后台异步更新

3.4.3 缓存雪崩

问题:大量 key 同时过期,DB 瞬间压力暴增。

解决方案

  1. 过期时间加随机值:避免同时失效
  2. 多级缓存:本地缓存 + Redis
  3. 熔断降级: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 缓存淘汰策略(补充知识)

当缓存内存不够时,需要淘汰一些数据腾空间。

策略全称思路
LRULeast Recently Used淘汰最近最少使用
LFULeast 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 制作库/线上库

  • 为什么分两个库?创作者和读者需求不同
  • 谁负责同步数据?取决于是否需要本地事务
  • 部分失败怎么办?引入重试,或让创作者重试

自测题与动手练习

自测题(合上书能答出来,才算懂)

  1. 为什么发帖要分"制作库"和"线上库"两个库?只用一个库靠状态字段过滤会有什么问题?
  2. TDD 的核心循环是什么?TDD 和 DDD 到底有没有关系?
  3. “修改别人的帖子"漏洞是怎么产生的?为什么修复时要 WHERE id=? AND author_id=? 而不是先查后判?
  4. 制作库和线上库"同库不同表"与"跨库"时,事务分别该在哪一层控制?跨库为什么不能用事务?
  5. 缓存三大问题(穿透 / 击穿 / 雪崩)各是什么、分别怎么解?为什么列表缓存"只缓存第一页”?

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

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

本章小结

第6章围绕"内容生产与查询"展开,从发帖到缓存,覆盖了一个典型内容系统的核心设计与实现。

关键回顾:

  1. 制作库/线上库双库设计:满足"创作者修改时读者看到上一个版本"的需求。状态流转:未发表 → 已发表 → 仅自己可见
  2. TDD 实战:定义接口 → 定义测试 → 核心循环。可以基于单元测试或集成测试出发
  3. DDD 实体/值对象:同一对象在不同领域角色不同,用户在帖子领域是值对象(AuthorId)
  4. 安全校验:修改接口必须带 author_id 条件,防止攻击者改别人的内容
  5. 事务控制层:同库不同表在 DAO/Repository 层,跨库在 Service 层用重试补偿
  6. MongoDB:用于动态模型和大规模数据,主键用雪花算法,索引用 ESR 原则
  7. OSS + CDN:适合大文件多媒体,注意 CDN 绕过登录的安全问题
  8. 缓存模式:最常用 Cache-Aside;列表只缓存第一页;大文档可不缓存;Publish 时预热缓存;业务规律预加载
  9. 缓存三大问题:穿透(空值/布隆)、击穿(互斥锁/永不过期)、雪崩(随机过期/多级缓存/熔断)
  10. 缓存淘汰:LRU/LFU 是基础,亮点策略是按创作者权重 / 按对象大小淘汰

掌握这些内容,你已经具备了从 0 到 1 设计一个内容生产系统的能力,包括需求分析、存储设计、TDD 实现、缓存优化等核心工程化技能。

About Me

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

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

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

目标

学AI,加油!加油!