跳到正文
知识库 / Go 博客教程 / 第 9 课返回主站 ↗

Go 博客教程 · 第 9 课 / 10

第 9 课:Redis 缓存与限流

前 8 课,博客后端已经是 Gin + GORM + 四层架构。这一课解决读多写少的文章详情把 MySQL 打满的问题,顺带用 Redis 做限流、分布式锁和 JWT 黑名单。

但在写第一行 Redis 代码之前,先讨论一个更重要的问题:你到底该不该加这个缓存。

教程日期:实测环境:Go 1.25.0 · MySQL 8.4.5 · Redis← 上一课:第 8 课:GORM 与关联下一课:第 10 课:工程化上线 →

① 本课目标

学完能独立写出:

  • 带连接池 + 启动自检的 Redis 客户端封装
  • 正确处理 redis.Nil 的缓存读写层(go-redis 头号坑)
  • 完整的 Cache-Aside 文章详情缓存,同时防住穿透、击穿、雪崩
  • 基于 ZSet 的热榜 + 阅读量批量回写 MySQL
  • 固定窗口 / 滑动窗口两种限流的 Gin 中间件
  • 用 Lua 保证原子释放的分布式锁
  • 登出即失效的 JWT 黑名单(呼应第 6 课的伏笔)

更重要的是能回答:「这里该不该加缓存?加了以后我承担了什么新风险?」

② 前置检查

cd ~/go-blog
go run ./cmd/server &
curl -s http://localhost:8080/api/v1/posts/1 | head -c 120
kill %1

redis-cli ping
redis-cli INFO server | grep redis_version
PONG
redis_version:8.4.0
go get github.com/redis/go-redis/v9
go get golang.org/x/sync
go: added github.com/redis/go-redis/v9 v9.22.0
go: added golang.org/x/sync v0.22.0

这两个包不用钉版本,但你要知道为什么。 第 6 课踩过一次:go get golang.org/x/crypto 拉到 v0.56.0,它的 go.mod 声明 go 1.26.0,于是 Go 悄悄下载了一整套 1.26 工具链(几百 MB),所以第 6 课把它钉成了 @v0.55.0。 本课这两个包实测没有这个问题golang.org/x/sync 最新版 v0.22.0 声明 go 1.25.0go-redis/v9 v9.22.0 声明 go 1.24,都能被 Go 1.25.0 直接编译,go.mod 里也不会被塞进 toolchain 指令。

想确认任何一个 go get 会不会偷换工具链,加 GOTOOLCHAIN=local——它会让 Go 拒绝下载新工具链,直接把问题暴露出来:

GOTOOLCHAIN=local go get golang.org/x/sync    # ✅ 正常装上
GOTOOLCHAIN=local go get golang.org/x/crypto  # ❌ 立刻报错
go: golang.org/x/[email protected] requires go >= 1.26.0 (running go 1.25.0; GOTOOLCHAIN=local)

养成习惯:新加依赖时用 GOTOOLCHAIN=local 走一遍,比事后发现构建机上多了个工具链好得多。

易错点 0:import 路径别写错。 老教程写的是 github.com/go-redis/redis/v8(或更老的 gopkg.in/redis.v5)。这个库在 v9 时转交给 Redis 官方维护并改了路径:现在是 github.com/redis/go-redis/v9 —— redis 在前、go-redis 在后。 写错的表现是拉到一个不再更新的旧包,API 大面积对不上(v9 给所有命令加了 ctx 参数)。认准 /v9

③ 核心概念

3.1 先问:这个数据该不该缓存

缓存不是免费的性能。你用一致性换延迟,同时给系统加了一个新的故障点。

维度 该缓存 不该缓存
读写比 读远多于写(文章详情:读 1 万次写 1 次) 读写各半(购物车、草稿自动保存)
计算成本 JOIN 三张表 / 全文扫描 / 调外部 API 主键单表查询,已走索引,1ms 返回
一致性 能容忍几秒到几分钟的旧数据 必须强一致(余额、库存、权限判定)
数据体积 单条几 KB 单条几 MB(大 key 会阻塞 Redis)
命中率 热点集中,20% 数据占 80% 流量 每个用户看的都不一样,几乎不重复

一条止损规则:先看慢查询日志、先加索引。如果一条 SQL 加个索引就能从 800ms 降到 3ms,就别加缓存——你省下一次网络往返,换来一整套失效逻辑要维护。

本课选文章详情做主线:读多写少、有 JOIN(作者 + 标签)、能容忍旧数据(改了标题晚 10 分钟生效没人会死)。

用户权限本课不缓存——你不会希望一个被封禁的用户因为缓存还能发帖 10 分钟。

3.2 go-redis 的 API 风格

每个命令返回一个 *XxxCmd 对象,而不是直接返回值

cmd := rdb.Get(ctx, "key")   // *redis.StringCmd,还不是结果
val, err := cmd.Result()     // 取值 + 错误
val := cmd.Val()             // 只取值(错误被吞,慎用)
err := cmd.Err()             // 只取错误(适合 Set/Del)

日常写成一行:val, err := rdb.Get(ctx, "key").Result()

为什么这么设计?因为 Pipeline:先把一串命令排队,Exec 之后再从各自的 *XxxCmd 里取结果。限流会用到。

3.3 redis.Nil 不是错误 —— 本课头号坑

GET 一个不存在的 key 时,go-redis 返回哨兵错误 redis.Nil。实测它的文本是:

redis: nil

如果按平时习惯写:

// ❌ 未命中被当成故障上抛
val, err := rdb.Get(ctx, key).Result()
if err != nil {
    return nil, fmt.Errorf("查缓存失败: %w", err)
}

后果:任何一次缓存未命中都变成 500。而且上层看到的文本就是 redis: nil,你在日志里根本分不清是「数据不在缓存里」(完全正常)还是「Redis 连不上了」(真事故)。

// ✅ 未命中必须单独判断
val, err := rdb.Get(ctx, key).Result()
if err != nil {
    if errors.Is(err, redis.Nil) {
        return nil, ErrCacheMiss    // 正常,走回源
    }
    return nil, fmt.Errorf("查缓存失败: %w", err)   // 这才是故障
}

errors.Is 而不是 err == redis.Nil。两者在当前版本实测都是 true,但一旦中间某层用 %w 包装过,== 就失效了。

注意:只有 GET 类命令会返回 redis.NilDEL 删不存在的 key 返回 0,EXISTS 返回 0,都不报错。

④ 函数逐个精讲

4.1 NewRedis —— 连接池 + 启动自检

// internal/cache/redis.go
package cache

import (
    "context"
    "fmt"
    "time"

    "github.com/redis/go-redis/v9"
)

// NewRedis 建立客户端并立刻验证连通性。
// 为什么必须 Ping:redis.NewClient 是"懒"的,它只构造对象不建连接,
// 地址写错也不报错,要等第一个用户来了才炸。启动时 Ping,配置错就在启动阶段崩。
func NewRedis(addr, password string, db int) (*redis.Client, error) {
    rdb := redis.NewClient(&redis.Options{
        Addr:     addr,     // "127.0.0.1:6379"
        Password: password, // 本机开发为空
        DB:       db,       // 0~15,开发和测试用不同 DB 互不干扰

        PoolSize:     10, // 常驻连接数。博客量级 10 足够
        MinIdleConns: 2,  // 保底空闲连接,避免流量突增时现建连接(要一次 TCP 握手)

        DialTimeout:  3 * time.Second,
        ReadTimeout:  2 * time.Second, // Redis 正常 <1ms,2s 还不回说明它已经不健康了
        WriteTimeout: 2 * time.Second,
        PoolTimeout:  4 * time.Second, // 池满时等空闲连接最多等多久

        ConnMaxIdleTime: 5 * time.Minute, // 防止被中间 LB 悄悄断开后拿到坏连接
        MaxRetries:      2,               // ⚠️ -1 才是禁用重试,0 是"用默认值 3"
    })

    // 自检用独立短超时:不该被全局超时影响,也不该无限挂着
    ctx, cancel := context.WithTimeout(context.Background(), 3*time.Second)
    defer cancel()
    if err := rdb.Ping(ctx).Err(); err != nil {
        rdb.Close() // 失败要把已建的连接还回去,别泄漏
        return nil, fmt.Errorf("redis ping %s: %w", addr, err)
    }
    return rdb, nil
}

易错点:MaxRetries: 0 不是「不重试」。 go-redis 约定 0 = 用默认值 3 次,-1 才是禁用。你以为关掉了,实际一个超时的 INCR 会被悄悄重发三遍——限流计数就莫名多加了。

易错点:*redis.Client 并发安全,全局共用一个。 它内部就是连接池。不要在 handler 里 NewClient——那等于每个请求建一个新池,几百个请求就把 Redis 的 maxclients 打爆,报 ERR max number of clients reached


4.2 key 设计:blog:v1:post:{id}

func postKey(id int64) string { return fmt.Sprintf("blog:v1:post:%d", id) }
作用 不这么做的后果
blog: 业务前缀 和同机其他服务撞 key(生产 Redis 常常多服务共用)
v1: 结构版本号 改了 Post 字段后老缓存反序列化出来是残缺数据。 升到 v2 就自然绕过全部旧 key,不用 FLUSHDB
post: 实体类型 blog:v1:1 三个月后你完全看不懂
{id} 标识

冒号是社区事实标准,很多可视化工具会按冒号折叠成树。

绝不要把不可控输入直接拼进 key

key := "blog:v1:search:" + c.Query("q")   // ❌ 用户传 100KB 的 q,你就存一个 100KB 的 key
                                          //    传一百万个不同的 q,内存就被撑爆(缓存污染)
sum := sha256.Sum256([]byte(q))           // ✅ 定长可控
key := "blog:v1:search:" + hex.EncodeToString(sum[:8])

序列化用 encoding/json 就够了:可读(redis-cli GET 直接看得懂,排查问题时极有用)、无依赖、性能不是瓶颈。等真测出序列化是热点了再换 msgpack(体积小 20~30%)或 protobuf(最快但要写 .proto)。别为省 0.1ms 提前付出可读性和构建复杂度。

一个关于 model.Status 的提醒。 第 4 课把它定义成了 defined type(type Status int8,常量 StatusDraft / StatusPublished / StatusOffline)。JSON 序列化时它就是个普通数字,类型信息全部丢失——实测:

Marshal   → {"id":1,"status":1,"published_at":"2026-09-06T10:00:00Z"}
Unmarshal → Status=1 (类型 model.Status), p.Status == model.StatusPublished → true

两个后果:① 缓存里存的是 "status":1,你必须记得 1 代表已发布,光看 Redis 内容是猜不出来的——所以 4.2 里那个 v1 版本号很重要,哪天你调整了枚举值(比如插入一个 StatusReviewing = 1),旧缓存会全部错位且不报任何错。改枚举值 = 必须升 key 版本号。② 反序列化回来仍然是 model.Status 类型,可以直接和常量比较,Go 侧的类型安全没丢。代码里一律写 model.StatusPublished,永远不要写裸的 1


4.3 PostCache.Get —— 正确处理 redis.Nil

// internal/cache/post_cache.go
package cache

import (
    "context"
    "encoding/json"
    "errors"
    "fmt"
    "math/rand/v2"
    "time"

    "github.com/redis/go-redis/v9"

    "blog/internal/model"
)

// 缓存层自己的哨兵错误:让 service 用 errors.Is 分辨两种情况。
// 不要把 redis.Nil 漏给 service —— service 不该知道底下是 Redis。
var (
    ErrCacheMiss = errors.New("cache miss") // 没有,去回源
    ErrCachedNil = errors.New("cached nil") // 明确记着"这条不存在",别回源
)

const nilPlaceholder = "__NIL__" // 正常 JSON 绝不可能等于它

type PostCache struct {
    rdb     *redis.Client
    baseTTL time.Duration // 基础存活时间
    jitter  time.Duration // 随机抖动上限,防雪崩
    nilTTL  time.Duration // 空值占位的存活时间,必须很短
}

func NewPostCache(rdb *redis.Client) *PostCache {
    return &PostCache{rdb: rdb, baseTTL: 10 * time.Minute, jitter: 2 * time.Minute, nilTTL: 60 * time.Second}
}

func postKey(id int64) string { return fmt.Sprintf("blog:v1:post:%d", id) }

// Get 读缓存。四种返回:
//   (post, nil)          命中
//   (nil, ErrCachedNil)  命中"不存在"占位,直接 404,不回源
//   (nil, ErrCacheMiss)  没命中,去 DB 回源
//   (nil, 其他 error)    Redis 真出问题了 → 调用方应降级查 DB,而不是返回 500
func (c *PostCache) Get(ctx context.Context, id int64) (*model.Post, error) {
    raw, err := c.rdb.Get(ctx, postKey(id)).Result()
    if err != nil {
        // ★ 全课最关键的三行:未命中必须和真故障分开
        if errors.Is(err, redis.Nil) {
            return nil, ErrCacheMiss
        }
        return nil, fmt.Errorf("cache get post %d: %w", id, err)
    }
    if raw == nilPlaceholder {
        return nil, ErrCachedNil
    }

    var p model.Post
    if err := json.Unmarshal([]byte(raw), &p); err != nil {
        // 反序列化失败 = 缓存里是脏数据(通常是改了结构体没升 key 版本)。
        // 删掉再当作未命中,让下次读把正确值写回来。
        // ⚠️ 千万不要 return error —— 一条脏缓存会让这个 id 永久 500
        _ = c.rdb.Del(ctx, postKey(id)).Err()
        return nil, ErrCacheMiss
    }
    return &p, nil
}

func (c *PostCache) Set(ctx context.Context, p *model.Post) error {
    b, err := json.Marshal(p)
    if err != nil {
        return fmt.Errorf("marshal post %d: %w", p.ID, err)
    }
    return c.rdb.Set(ctx, postKey(p.ID), b, c.ttl()).Err()
}

// SetNil 写"这条不存在"的短命占位,防穿透。
func (c *PostCache) SetNil(ctx context.Context, id int64) error {
    return c.rdb.Set(ctx, postKey(id), nilPlaceholder, c.nilTTL).Err()
}

func (c *PostCache) Del(ctx context.Context, id int64) error {
    return c.rdb.Del(ctx, postKey(id)).Err()
}

// ttl 返回 base + [0, jitter) 的随机时长,防雪崩。
// math/rand/v2 是 Go 1.22 新包:rand.N 是泛型的,而且不再需要手动 Seed。
func (c *PostCache) ttl() time.Duration {
    return c.baseTTL + time.Duration(rand.N(int64(c.jitter)))
}

TTL 永远要设。 Set(ctx, k, v, 0)0 表示永不过期——那是定时炸弹:任何一次你忘了删缓存的更新路径,脏数据就永远存在,直到有人 FLUSHDB。设了 TTL 最坏也只脏 10 分钟。TTL 是所有失效逻辑的兜底保险。


4.4 Cache-Aside 模式与写路径

Cache-Aside(旁路缓存):缓存不参与写,应用代码自己在读时填、写时删。

读路径                                  写路径
┌──────────┐                          ┌──────────┐
│ 请求到达  │                          │ 更新请求  │
└────┬─────┘                          └────┬─────┘
     ▼                                     ▼
┌──────────┐  命中  ┌────────────┐   ┌──────────────┐
│ 查 Redis  ├──────►│ 反序列化返回 │   │ 1. 写 MySQL   │
└────┬─────┘        └────────────┘   └────┬─────────┘
     │ 未命中(redis.Nil)                   ▼
     ▼                                ┌──────────────┐
┌──────────┐  没有  ┌──────────────┐  │ 2. 删 Redis   │
│ 查 MySQL  ├──────►│写空值占位(60s)│  └────┬─────────┘
└────┬─────┘        │  → 返回 404   │       ▼ (可选)
     │ 查到          └──────────────┘  ┌──────────────────┐
     ▼                                │ 3. 延迟 500ms 补删 │
┌──────────────────┐                  └──────────────────┘
│ 写回 Redis(带TTL) │
└────┬─────────────┘
     ▼
   返回结果

删缓存还是更缓存?主流选

  1. 并发写会互相覆盖。 A、B 同时改同一篇文章:

    A: 写DB(标题=甲) ─────────────► A: 写缓存(标题=甲)
    B:      写DB(标题=乙) ──► B: 写缓存(标题=乙)
    结果:DB 里是乙,缓存里是甲,一直错到 TTL 到期
    
    「写 DB」和「写缓存」之间没有原子性。改成删就没这问题——删除是幂等的,删两次和删一次结果一样。

  2. 写完不一定有人读。 每次写都更新缓存,等于为可能永远没人访问的数据付出序列化 + 网络往返。

  3. 更缓存要求写路径知道完整的缓存结构。 缓存的是「文章 + 作者名 + 标签列表」的聚合视图时,改一个标签名就得知道怎么重建整个视图。删不需要——回源逻辑只有一份,在读路径里。

先更 DB 还是先删缓存?推荐先更 DB

顺序 中间失败会怎样
先更 DB,再删缓存 删失败 → 缓存是旧的,但 TTL 到期就自愈,窗口有界
先删缓存,再更 DB ❌ 删完、DB 还没更新完的这段时间,任何读请求都会把旧值重新加载进缓存,这条旧值要活满一整个 TTL

先更 DB 也有一个很窄的漏洞:

t0: 读请求 R 未命中,查 DB 读到旧值 V1
t1: 写请求 W 把 DB 更新为 V2
t2: W 删缓存(此时缓存本来就空,删了个寂寞)
t3: R 才把它 t0 读到的 V1 写进缓存   ← 脏数据诞生

要求 R 的「读 DB → 写缓存」耗时长于 W 的整个流程,概率很低但不是零。延迟双删就是给这个窗口打的补丁:

// internal/service/post_service.go

func (s *PostService) Update(ctx context.Context, p *model.Post) error {
    // 1. 先更 DB。DB 是唯一真相源
    if err := s.repo.Update(ctx, p); err != nil {
        return err
    }

    // 2. 立刻删缓存。用 context.WithoutCancel(Go 1.21+):
    //    客户端此时断开会取消 ctx,那就变成"DB 改了缓存没删" → 脏数据。
    //    删缓存必须跑完,不能被请求生命周期拖累
    if err := s.cache.Del(context.WithoutCancel(ctx), p.ID); err != nil {
        // 删失败不阻塞主流程:DB 已经对了,最多脏到 TTL 到期。记日志让人查得到
        slog.Warn("删缓存失败,将由 TTL 兜底", "post", p.ID, "err", err)
    }

    // 3. 延迟补删:清掉可能被并发读请求写回去的旧值。
    //    延迟多久?略大于"一次读请求查 DB + 写缓存"的耗时,一般 300ms~1s
    time.AfterFunc(500*time.Millisecond, func() {
        ctx, cancel := context.WithTimeout(context.Background(), 3*time.Second)
        defer cancel()
        _ = s.cache.Del(ctx, p.ID)
    })
    return nil
}

易错点:time.AfterFunc 里绝不能用原来那个 ctx 500ms 后 HTTP 请求早结束了,ctx 已被 Gin 取消,删除命令直接返回 context canceled,双删完全失效——而且不报错,你根本不知道它没生效。必须新起一个带独立超时的 context。

另外,进程在这 500ms 里重启,补删就丢了。生产上更稳的做法是把补删丢进消息队列。本课用 AfterFunc 是为了让你看清机制。

延迟双删只降低概率,不能消除。 要强一致就别用缓存,或者上 binlog 订阅(canal)那一套。


4.5 三大经典问题

三个词长得像,讲的是三件完全不同的事:

问题 一句话现象 触发条件 打到 DB 的量 主要方案
穿透 根本不存在的数据,缓存永远不命中 恶意遍历 id=999999;业务上大量无效 ID 每一次请求 缓存空值(短 TTL)/ 布隆过滤器 / 参数校验
击穿 一个热点 key 过期瞬间大量并发同时回源 热点数据 TTL 到期 瞬时高并发全打同一条 SQL singleflight / 互斥锁 / 逻辑过期
雪崩 大批 key 同时过期,或 Redis 整个挂了 批量预热设了相同 TTL;Redis 宕机 全站流量瞬间全压到 DB TTL 随机抖动 / 多级缓存 / 降级

记忆法:穿透是「没有这个数据」,击穿是「一个点破了」,雪崩是「一片全塌了」

穿透:攻击者遍历 id=999999

场景很具体:有人跑 for i in $(seq 1 10000000); do curl /api/v1/posts/$i; done。你库里只有 1000 篇文章,于是 999 万次请求全部「未命中 → 查 MySQL → 没有 → 404」。缓存一次都没起作用,MySQL 独自承受全部流量。

穿透这一节的核心,是把三个长得很像的「没有」分清楚。混淆它们就写不出正确的防穿透代码:

语义 谁产生的 用什么表示 该怎么办
缓存里没有 Redis(GET 未命中) redis.Nil → 包装成 cache.ErrCacheMiss 回源查 DB。这是正常路径,不是错误
数据库里没有 repository(第 4 课定义,在 model 包) model.ErrNotFound 写空值占位 + 返回 404。这才是要防的穿透
缓存里记着「数据库没有」 我们自己写的占位符 cache.ErrCachedNil 直接返回 404,不回源。防穿透生效的那一刻

把第一种当成第二种 → 缓存一失效就返回 404,用户看到文章凭空消失。 把第二种当成第一种 → 空值占位永远写不进去,防穿透形同虚设。 注意 model.ErrNotFoundmodel 包不在 repository——它是领域概念(「这个东西不存在」),不是存储细节,所以 service 层 errors.Is 它不算越层依赖。

  • 方案零(最该先做):参数合法性校验。 id <= 0 直接拒绝,连 Redis 都不查。零成本,别忘了。
  • 方案一:缓存空值(本课采用)。查不到就写一个短命占位。优点是五行代码立刻见效;代价是占内存,所以 TTL 必须很短(本课 60s)且要和正常 TTL 分开配置。注意:新建文章后要主动删掉对应 id 的占位,否则新文章 60 秒内查不到。
  • 方案二:布隆过滤器。 启动时把所有存在的 ID 灌进去,请求先问「可能存在吗」,答「绝对不存在」就直接 404。优点是内存极省(一百万 ID 约 1MB)、能挡无限量无效 ID;代价是有假阳性(会漏一点到 DB,可接受)、标准布隆不支持删除、要额外引依赖(RedisBloom 或 bits-and-blooms/bloom)。

什么时候升级到布隆:当空值占位的 key 数量超过真实数据数量时。博客这个体量,缓存空值足够。

击穿:singleflight

一篇被首页推荐的文章 QPS 5000,缓存 TTL 到了:

        TTL 到期
           │
5000 并发 ─┼─► 5000 次全部未命中 ─► 5000 条一模一样的 SQL 同时打到 MySQL
           │                          │
           ▼                          ▼
                             连接池耗尽 → 后续请求全超时 → 服务雪崩

这 5000 次查询结果完全一样,为什么要查 5000 次?让第一个去查,其余 4999 个等着共享结果。这正是 golang.org/x/sync/singleflight(Go 官方扩展库)干的事:

v, err, shared := g.Do("post:7", func() (any, error) {
    return queryDB(7)   // 同一 key 同一时刻只有一个 goroutine 跑到这里
})

实测(100 goroutine 同时请求同一篇文章):

✅ singleflight: 100 并发 → repo 实际被调用 1 次

三个返回值:v any(需类型断言)、err errorshared bool(结果是否被共享,实测 50 并发时 50 个全是 true,包括真正执行的那个,可以拿来打点观测击穿有多严重)。

三个必须知道的坑(均已实测):

  1. 错误会被共享——一次失败,所有等待者拿到同一个错误:

    一次失败 → 10 个 caller 全部拿到同一个错误
    
    所以 fn 里不要做重试,重试放在 Do 外面。

  2. 第一个调用者的 ctx 取消会连累所有人(fn 闭包捕获的是第一个调用者的 ctx):

    第一个 caller 取消后: err1=context canceled err2=context canceled (第二个 caller 无辜受害)
    
    这可接受:受害者只是拿到一个错误,重试即可,不会有数据问题。彻底解决要用 DoChan + select先别做,是过度设计

  3. 只在单进程内生效。 部署 4 个实例就是 4 次回源,不是 1 次——这通常完全够了(4 条 SQL vs 5000 条)。

其他两种方案(了解即可)互斥锁(只放一个 goroutine 回源,其他人 sleep 后重试读缓存,比 singleflight 笨但零依赖);逻辑过期(key 永不物理过期,value 里存 expire_at,发现逻辑过期就返回旧值 + 异步刷新,永远不会有请求打到 DB,代价是会返回旧数据)。

雪崩:TTL 抖动

服务重启后你预热了 1000 篇热门文章,全部 Set(..., 10*time.Minute)。10 分钟后这 1000 个 key 在同一秒集体过期,MySQL 瞬间收到 1000 条并发查询。

修法极简单,就是 4.3 里那个 ttl()

return c.baseTTL + time.Duration(rand.N(int64(c.jitter)))   // 10min + [0,2min)

实测:

$ redis-cli TTL blog:v1:post:1
608

608 秒 = 10 分 8 秒,落在 600~720 区间。多跑几次会得到不同值——抖动生效了。抖动幅度取 base 的 10%~30%:太小起不到摊平作用,太大会让有效期不可预测。

另一种雪崩:Redis 整个挂了。 抖动救不了。这时需要的是降级——Get 返回非 redis.Nil 的错误时不要 500,记 warn 日志然后直接查 DB。这就是 4.3 里要把 ErrCacheMiss 和真故障分成两种错误的原因:缓存挂了应该是性能问题,不该是可用性问题。


4.6 PostService.GetByID —— 本课高光函数

三种防护全在这一个函数里:

// internal/service/post_service.go
type PostService struct {
    repo  repository.PostRepository // 第 4 课的接口,service 不知道底下是 GORM
    cache *cache.PostCache
    sf    singleflight.Group // 零值可用。⚠️ 它含锁,PostService 必须用指针传递
}

// GetByID 全站 QPS 最高的路径,三种防护都在这里:
//   防穿透 —— 查不到时写空值占位
//   防击穿 —— singleflight 让并发回源收敛成一次
//   防雪崩 —— TTL 带随机抖动(在 cache.Set 里)
func (s *PostService) GetByID(ctx context.Context, id int64) (*model.Post, error) {
    // ---- 1. 参数校验:零成本挡掉一批垃圾请求,连 Redis 都不查 ----
    if id <= 0 {
        return nil, model.ErrNotFound
    }

    // ---- 2. 查缓存 ----
    p, err := s.cache.Get(ctx, id)
    switch {
    case err == nil:
        return p, nil // 命中。99% 的请求走这条路

    case errors.Is(err, cache.ErrCachedNil):
        // 命中"不存在"占位:刚才有人查过,DB 里确实没有。
        // 直接 404 不回源 —— 这就是防穿透生效的地方
        return nil, model.ErrNotFound

    case errors.Is(err, cache.ErrCacheMiss):
        // 正常未命中,继续往下回源

    default:
        // ★ Redis 真出问题了(超时/内存满/被 kill)。
        // 关键决策:降级,不是报错。缓存挂了服务还得能用,只是慢一点
        slog.Warn("缓存降级", "post", id, "err", err)
    }

    // ---- 3. singleflight 收敛回源 ----
    // key 用业务语义即可,singleflight 是进程内的,不需要 blog:v1: 前缀
    v, err, _ := s.sf.Do(fmt.Sprintf("post:%d", id), func() (any, error) {
        // ⚠️ 这个闭包在高并发下只会被执行一次,其余调用者阻塞等结果

        post, err := s.repo.GetByID(ctx, id)
        if err != nil {
            if errors.Is(err, model.ErrNotFound) {
                // 防穿透:DB 说没有,把这个"没有"缓存 60 秒。
                // WithoutCancel:即使调用方 ctx 已取消,这个占位也值得写进去
                if e := s.cache.SetNil(context.WithoutCancel(ctx), id); e != nil {
                    slog.Warn("写空值占位失败", "post", id, "err", e)
                }
                return nil, model.ErrNotFound
            }
            return nil, fmt.Errorf("查文章 %d: %w", id, err)
        }

        // 回填缓存。写失败不影响本次返回 —— 数据已拿到,大不了下次再回源
        if e := s.cache.Set(context.WithoutCancel(ctx), post); e != nil {
            slog.Warn("回填缓存失败", "post", id, "err", e)
        }
        return post, nil
    })
    if err != nil {
        return nil, err // 注意:同一批等待者共享这同一个 error
    }

    // singleflight 返回 any 必须断言。用不带 ok 的形式:
    // 这里断言失败说明代码写错了,让它 panic 比返回静默的 nil 更容易在测试阶段暴露
    return v.(*model.Post), nil
}

易错点:PostService 必须用指针传递。 singleflight.Group 内含 sync.Mutex。handler 收值类型的话,每次复制结构体就复制了一份锁,go vet 直接报:

./post_service.go:12:6: assignment copies lock value: contains sync.Mutex

看到 copies lock value 就是这个。含锁的结构体一律用指针。

易错点:context.WithoutCancel 没有 deadline。 它保留 ctx 的 value(比如 request_id)但去掉取消信号,同时也丢掉了 deadline(实测 Deadline() 返回 ok=false)。写缓存有 WriteTimeout: 2s 兜底还好,长操作记得再套一层 WithTimeout


4.7 热榜 ZSet 与阅读量回写

为什么不用 ORDER BY view_count

问题不在慢,而在它和阅读量的写入互相拖累

  1. 每次阅读都要 UPDATE posts SET view_count = view_count + 1 WHERE id = ? —— 行锁 + 写盘 + 刷 binlog。QPS 5000 就是 5000 次并发更新同一行,全部串行排队等行锁。
  2. 你想给 view_count 加索引来加速排序,但这列每秒被更新几千次,索引维护开销比排序省下的还多
  3. 排序结果每秒都在变,缓存也没法缓。

Redis 的 ZSet 天生干这个:ZINCRBY 是 O(log N) 原子递增,ZREVRANGE 取 TopN 是 O(log N + M)。

// internal/cache/post_cache.go
const hotKey = "blog:v1:hot:posts"

func viewKey(id int64) string { return fmt.Sprintf("blog:v1:views:%d", id) }

// IncrView 记一次阅读。Pipeline 把三条命令打成一个网络往返。
// 完全不碰 MySQL —— 一次阅读的成本从"一条 UPDATE 加行锁"降到"一次 Redis 往返"。
func (c *PostCache) IncrView(ctx context.Context, id int64) error {
    pipe := c.rdb.Pipeline()
    pipe.Incr(ctx, viewKey(id))                             // 待回写 MySQL 的增量
    pipe.ZIncrBy(ctx, hotKey, 1, strconv.FormatInt(id, 10)) // 热榜累加
    pipe.Expire(ctx, hotKey, 7*24*time.Hour)                // 防止冷门文章永远赖在榜上
    _, err := pipe.Exec(ctx)
    return err
}

// TopN 取热榜前 N。返回 []redis.Z,Z 是 { Score float64; Member interface{} }。
// ⚠️ Member 的动态类型是 string(实测确认),不是 int64 ——
//    Redis 里一切都是字符串,写进去 int64 取出来也是 string。
// ⚠️ 区间是闭区间:要前 10 个写 0, 9。写 0, 10 会拿到 11 条。
func (c *PostCache) TopN(ctx context.Context, n int64) ([]redis.Z, error) {
    return c.rdb.ZRevRangeWithScores(ctx, hotKey, 0, n-1).Result()
}

ZIncrBy 的签名容易记错,实测确认是 增量在前、成员在后

ZIncrBy(ctx context.Context, key string, increment float64, member string) *redis.FloatCmd

实测输出:

✅ 热榜 TopN 返回 []redis.Z: {member=2(string) score=12} {member=1(string) score=5} {member=3(string) score=1}

批量回写:SCAN + GETDEL + 事务

// internal/job/flush_views.go

// FlushViews 把 Redis 累积的阅读增量批量刷进 MySQL,由每分钟一次的定时任务调用。
func FlushViews(ctx context.Context, rdb *redis.Client, db *gorm.DB) (int, error) {
    // ---- 1. 扫出待回写的 key ----
    // ⚠️ 用 SCAN 不要用 KEYS。Redis 是单线程的,KEYS 会阻塞整个实例直到遍历完 ——
    //    在生产库上执行 KEYS * 是把线上打挂的标准姿势。SCAN 是游标式分批的
    var keys []string
    var cursor uint64
    for {
        batch, next, err := rdb.Scan(ctx, cursor, "blog:v1:views:*", 200).Result()
        if err != nil {
            return 0, fmt.Errorf("scan view keys: %w", err)
        }
        keys = append(keys, batch...)
        cursor = next
        if cursor == 0 { // 游标回到 0 表示遍历完一圈
            break
        }
    }

    // ---- 2. 取值并原子清零 ----
    type delta struct{ id, n int64 }
    var deltas []delta
    for _, k := range keys {
        // GETDEL 是"取出并删除"的原子命令(Redis 6.2+)。
        // ⚠️ 为什么不用 GET + DEL 两条?两条之间可能有新的 INCR 进来,
        //    你 DEL 掉的是"已读到的 + 刚新增的",那次新增就永久丢了
        raw, err := rdb.GetDel(ctx, k).Result()
        if errors.Is(err, redis.Nil) {
            continue // 已被上一轮取走,正常
        }
        if err != nil {
            return 0, fmt.Errorf("getdel %s: %w", k, err)
        }
        n, err := strconv.ParseInt(raw, 10, 64)
        if err != nil || n == 0 {
            continue
        }
        id, err := strconv.ParseInt(k[strings.LastIndex(k, ":")+1:], 10, 64)
        if err != nil {
            continue
        }
        deltas = append(deltas, delta{id, n})
    }
    if len(deltas) == 0 {
        return 0, nil
    }

    // ---- 3. 一个事务里批量更新 ----
    // 用 view_count + ? 而不是 = ?:即使有别的路径也在改,增量也不会被覆盖丢失
    err := db.WithContext(ctx).Transaction(func(tx *gorm.DB) error {
        for _, d := range deltas {
            if err := tx.Model(&model.Post{}).Where("id = ?", d.id).
                UpdateColumn("view_count", gorm.Expr("view_count + ?", d.n)).Error; err != nil {
                return err
            }
        }
        return nil
    })
    if err != nil {
        return 0, fmt.Errorf("flush views: %w", err)
    }
    return len(deltas), nil
}

实测(本机 blog_dev 真实执行):

回写 3 条增量 → MySQL 现状: [id=1 "Hello Go" view_count=13] [id=2 "database/sql 入门" view_count=34] [id=3 "这是一篇草稿" view_count=1]

诚实说明代价:进程在 GETDEL 之后、事务提交之前崩了,那一批阅读量就丢了。阅读量丢几个没人在意,所以这个取舍划算。换成「账户余额」,这套方案一秒都不能用。

Pipeline 还是 TxPipeline TxPipeline 把命令包在 MULTI/EXEC 里,保证中间不插入别人的命令,但 Redis 事务不支持回滚。实测 Pipeline 中一条命令报错时:

Exec err=ERR value is not an integer or out of range; 后续命令仍执行 after="written"

Exec 返回第一个错误,但其余命令依然执行了。所以要「要么全做要么全不做」,Redis 事务给不了你,得用 Lua(见 4.9)。日常批量发命令用 Pipeline 就行,它只是省网络往返。


4.8 限流中间件

// internal/middleware/ratelimit.go

// RateLimit 固定窗口限流:每个 (路由, IP) 在 window 内最多 limit 次。
func RateLimit(rdb *redis.Client, limit int64, window time.Duration) gin.HandlerFunc {
    return func(c *gin.Context) {
        ctx := c.Request.Context()
        // ⚠️ 用 c.FullPath() 拿路由模板 "/api/v1/posts/:id" 而不是 c.Request.URL.Path,
        //    否则每个不同的 id 都会产生一个独立的限流 key,限流形同虚设
        key := fmt.Sprintf("blog:v1:rl:%s:%s", c.FullPath(), c.ClientIP())

        // ⚠️ INCR 和 EXPIRE 必须打包成 Pipeline。分两次发送时,
        //    进程在两次之间崩了,这个 key 就永远没有 TTL,该 IP 被永久封禁
        //    —— 这是真实发生过的线上事故
        pipe := rdb.Pipeline()
        incr := pipe.Incr(ctx, key)
        pipe.Expire(ctx, key, window)
        if _, err := pipe.Exec(ctx); err != nil {
            // ★ 限流器自己挂了该放行还是拒绝?
            // 本课选 fail-open:限流是保护措施,不该因为它坏了就让全站不可用。
            // 反过来,如果限流是安全边界(防暴力破解登录),就该 fail-closed 直接拒绝
            slog.Warn("限流器不可用,放行", "err", err, "key", key)
            c.Next()
            return
        }

        n := incr.Val() // Pipeline Exec 之后才能取值
        c.Header("X-RateLimit-Limit", strconv.FormatInt(limit, 10))
        c.Header("X-RateLimit-Remaining", strconv.FormatInt(max(limit-n, 0), 10))

        if n > limit {
            ttl, _ := rdb.TTL(ctx, key).Result()
            c.Header("Retry-After", strconv.Itoa(int(ttl.Seconds())+1))
            // ⚠️ 用 AbortWithStatusJSON。只写 c.JSON 不 Abort 的话,业务 handler 照样执行
            // ⚠️ 这里不能 import handler 去用 APIError —— 会 import cycle,见下方易错点。
            // 中间件自己拼,但字段名必须和 APIError{Code,Message,Field} 一致
            c.AbortWithStatusJSON(http.StatusTooManyRequests, gin.H{
                "code": "RATE_LIMITED", "message": "请求太频繁,请稍后再试",
            })
            return
        }
        c.Next()
    }
}

实测(limit=5,连打 20 次):

✅ 限流中间件: 20 次请求 → map[200:5 429:15]
✅ 第 20 次响应头: X-RateLimit-Remaining=0 Retry-After=11

易错点:为什么中间件要自己拼 JSON,而不是复用 APIError

第 2 课定稿了对外错误契约 APIError{Code, Message, Field},第 4 课把它和 httpStatusFor(err) 一起放进了 internal/handler/errors.go。那这里为什么不直接 handler.APIError{Code: "RATE_LIMITED"},反而要手拼一个 gin.H

因为那样整个项目编译不过。 真实报错:

package blog/internal/handler
    imports blog/internal/middleware from errors.go
    imports blog/internal/handler from ratelimit.go: import cycle not allowed

拆开看这个环是怎么成的:

第 5 课已经有的边:  handler ──────► middleware
                    (errors.go 里 writeErrorFrom 要调 middleware.RequestIDFrom
                     把 request_id 写进错误响应)

你现在想加的边:    middleware ──────► handler
                    (ratelimit.go 想用 handler.APIError)

合起来:            handler ⇄ middleware        ← 环

根因是 Go 的语言级硬约束:包依赖必须是有向无环图(DAG)。 Go 没有 C 的前向声明、没有 Java 那种「同包不同文件随便互相引用」的宽松度,也没有任何逃生舱——不存在什么编译选项能让循环 import 通过。这不是编译器保守,是 Go 刻意的设计:它保证了任何一个包都能被独立编译和理解,也保证了初始化顺序(init() 和包级变量)永远是确定的。

值得停下来想一下的是:这恰好是第 4 课「依赖方向」那一节在编译期被强制执行。 当时我们约定 handler → service → repository 单向依赖,那还只是一条纪律,靠人自觉。而这里 Go 直接告诉你:箭头画歪了,不让编译。架构约束从「文档里的规矩」变成了「编译器的红线」——这是 Go 包机制最被低估的价值。

三种解法及取舍:

解法 做法 代价 本课选择
① 中间件自己拼 gin.H{"code":..., "message":...},只保证字段名APIError 一致 响应格式在两处重复,改契约要改两个地方 ✅ 第 6 课的 401 响应已经这么做了,保持一致
② 共享类型下沉 APIError 挪到一个不依赖任何人的叶子包(如 internal/apierr),handler 和 middleware 都 import 它 更干净,但要改第 2/4/5/6 课已定稿的代码 ❌ 波及面太大
③ 依赖倒置 middleware 定义一个 ErrorWriter 接口,由 handler 实现并注入 最灵活,但为一个结构体引入一层抽象,过度设计

选 ① 的代价要说清楚{"code","message"} 这个形状现在有两份定义(handler.APIError 一份、中间件手拼一份)。哪天要给契约加字段,两处都得改,而且编译器不会提醒你漏了哪个。项目再大一点就该上 ②。

一个实用的排查手法go build 报 import cycle 时,报错信息里那几行 imports X from Y.go 已经把完整的环和触发它的文件都列出来了,从下往上读就是环的路径。不用去猜,也不用装工具。

易错点:上面每次都 EXPIRE 会滑动 TTL。 持续发请求的客户端会让 TTL 永远刷新,窗口永远不结束。想要「严格每 10 秒重置」应该只在 incr.Val() == 1 时设置过期——但那又回到「两条命令不原子」的问题(INCR 成功、EXPIRE 前崩溃 = 永久封禁)。两害相权,本课选每次刷新:最坏是被限的客户端等久一点,而不是永久封死。要两全其美就用 Lua 一次搞定。

固定窗口的临界问题

限流规则: 每 60 秒 100 次

      窗口A结束 │ 窗口B开始
   [ 窗口A 0~60s ]│[ 窗口B 60~120s ]
                 │
   59.9s 打 100 次┤ 60.1s 又打 100 次
                 └──► 这 0.2 秒内实际通过了 200 次

边界处实际通过量可以达到限额的 2 倍。 防刷场景通常可接受,但如果下游只能扛 100 QPS,这个毛刺会打挂它。

滑动窗口:ZSet

把每次请求的时间戳作为 score 存进 ZSet,每次先删窗口外的旧记录,再数窗口内还剩多少条:

// internal/middleware/ratelimit.go
func allowSliding(ctx context.Context, rdb *redis.Client, key string, limit int64, window time.Duration) (bool, error) {
    now := time.Now()
    minScore := strconv.FormatInt(now.Add(-window).UnixMilli(), 10) // 窗口左边界

    pipe := rdb.Pipeline()
    pipe.ZRemRangeByScore(ctx, key, "0", minScore) // 1. 清窗口外的,不清 ZSet 会无限增长
    pipe.ZAdd(ctx, key, redis.Z{                   // 2. 记本次请求
        Score: float64(now.UnixMilli()),
        // ⚠️ member 必须唯一,否则同一毫秒的两个请求会被 ZAdd 去重成一条
        Member: fmt.Sprintf("%d-%d", now.UnixNano(), rand.Int64()),
    })
    card := pipe.ZCard(ctx, key)  // 3. 数窗口内有多少条
    pipe.Expire(ctx, key, window) // 4. 兜底:之后没人访问就自己消失

    if _, err := pipe.Exec(ctx); err != nil {
        return true, err // fail-open
    }
    return card.Val() <= limit, nil
}

实测同样 limit=5、20 次请求:滑动窗口限流 limit=5: 20 次拦掉 15 次

固定窗口 滑动窗口 ZSet
精度 边界有 2 倍毛刺 精确
内存 每 key 一个整数(几十字节) 每次请求一条记录(limit 越大越占)
命令数 2 条 4 条
适用 防刷、防爬虫,绝大多数场景 下游硬性容量限制、计费接口

先用固定窗口,确实观测到边界毛刺造成问题再换。

按 IP 还是按用户? 未登录接口(注册/登录/搜索)按 IP —— 注意 c.ClientIP() 依赖 X-Forwarded-For,必须先用 router.SetTrustedProxies() 配好可信代理,否则客户端伪造这个头就能绕过。已登录接口按 user_id(从第 6 课的 JWT claims 拿),换 IP 也照样被限。最好两个都加:IP 层挡扫描,用户层挡滥用。


4.9 分布式锁:SetNX + Lua 原子释放

场景:4.7 的 FlushViews。你部署了 3 个实例,每分钟都跑一次——三个实例同时 GETDEL 同一批 key,会重复回写或互相把对方读到的数据删掉。

// internal/cache/lock.go
var ErrLockNotHeld = errors.New("锁不是自己持有的(可能已超时被别人抢走)")

type Lock struct {
    rdb   *redis.Client
    key   string
    value string // ★ 持有者唯一标识,是安全释放的关键
}

// Acquire 尝试获取锁,返回 (锁, 是否拿到, 错误)。
func Acquire(ctx context.Context, rdb *redis.Client, key string, ttl time.Duration) (*Lock, bool, error) {
    // value 必须全局唯一。生产上更常用 UUID
    value := fmt.Sprintf("%d-%d", time.Now().UnixNano(), rand.Int64())

    // SET key value NX PX ttl —— go-redis 里就是 SetNX,返回 (bool, error)。
    // NX = 只在 key 不存在时设置。这一条命令天然原子,不会有竞态。
    // ★ TTL 必须设!没 TTL 的锁,持有者崩溃后就永远锁死了 —— 死锁
    ok, err := rdb.SetNX(ctx, key, value, ttl).Result()
    if err != nil {
        return nil, false, fmt.Errorf("acquire lock %s: %w", key, err)
    }
    if !ok {
        return nil, false, nil // 没抢到不是错误,是正常的竞争结果
    }
    return &Lock{rdb: rdb, key: key, value: value}, true, nil
}

// unlockScript:校验 + 删除必须原子。
// Redis 单线程执行 Lua,期间不会插入别的命令,所以"比较后删除"是原子的。
var unlockScript = redis.NewScript(`
if redis.call("GET", KEYS[1]) == ARGV[1] then
    return redis.call("DEL", KEYS[1])
else
    return 0
end
`)

func (l *Lock) Unlock(ctx context.Context) error {
    // Script.Run 签名: Run(ctx, 客户端, keys []string, args ...any) *redis.Cmd
    // ⚠️ keys 是切片、args 是可变参数,顺序别搞反。
    // Run 内部先试 EVALSHA(只发哈希省带宽),失败自动回退到 EVAL 发全文
    res, err := unlockScript.Run(ctx, l.rdb, []string{l.key}, l.value).Int64()
    if err != nil {
        return fmt.Errorf("unlock %s: %w", l.key, err)
    }
    if res == 0 {
        // 锁已经不是自己的了 = 业务执行时间超过 TTL,锁被别人抢走。
        // 这是严重信号:刚才可能有两个实例在同时跑临界区。必须报出来
        return ErrLockNotHeld
    }
    return nil
}

为什么释放必须校验 value —— 「误删别人的锁」

实例 A: Acquire(ttl=30s) 成功,开始跑 FlushViews
        │  ← 因为一次 GC / 网络抖动 / 慢 SQL,任务跑了 35 秒
    30s ┤ 锁自动过期,Redis 里的 key 没了
        ├──► 实例 B: Acquire 成功(key 不存在了),开始跑 FlushViews
    35s ┤ 实例 A 终于跑完,执行 DEL lock_key
        │        ↓ 把 B 的锁删掉了!
        ├──► 实例 C: Acquire 成功,也开始跑
        └──► 现在 B 和 C 在同时跑临界区,锁完全失效

Unlock 里如果是裸 DEL,这个事故一定会发生。加上 value 校验后,A 的 GET 拿到的是 B 的 value,对不上就不会删。

为什么校验和删除必须原子

// ❌ 看起来对,实际有竞态
val, _ := rdb.Get(ctx, key).Result()
if val == l.value {
    // ← 就在这一行,锁过期了,B 抢到锁并写了自己的 value
    rdb.Del(ctx, key)  // 删的是 B 的锁
}

「读」和「删」之间有时间窗口。Lua 在 Redis 里单线程、不可打断,中间插不进别的命令。这就是必须用 Lua 的原因。

实测:

✅ SetNX 锁: 第一次 ok=true 第二次 ok=false
✅ 别人的锁 Unlock → lock not held (ErrLockNotHeld=true)
✅ 自己的锁 Unlock 成功, key 剩余 0 个

用法:

lock, ok, err := cache.Acquire(ctx, rdb, "blog:v1:lock:flush-views", 60*time.Second)
if err != nil { slog.Error("获取锁失败", "err", err); return }
if !ok { slog.Debug("锁被别的实例持有,本轮跳过"); return } // 不是错误,是正常的
defer func() {
    if err := lock.Unlock(ctx); err != nil {
        slog.Error("释放锁异常,可能发生过并发执行", "err", err) // 这条值得告警
    }
}()
n, err := FlushViews(ctx, rdb, db)

诚实说明这把锁的边界

这是简化版,只在单点 Redis 上正确。 三个缺陷:

  1. 没有续期(watchdog)。 TTL 到了业务还没跑完,锁就没了。缓解:TTL 设成「业务最长耗时的 3 倍」。成熟方案(redsync、Java 的 Redisson)会起后台 goroutine 定期 PEXPIRE 续期。
  2. 主从切换会丢锁。 Redis 主从复制是异步的:A 在 master 拿到锁,master 还没同步就挂了,slave 升主后这把锁不存在,B 也能拿到。
  3. Redlock 有争议。 Redis 作者提出的 Redlock(向 N 个独立节点申请,过半即成功)被分布式系统专家 Martin Kleppmann 公开质疑过,核心争论是它依赖各节点时钟不会大幅漂移。至今没有共识。

结论:这把锁适合「重复执行只是浪费资源、不会导致数据错误」的场景(定时任务去重、缓存预热)。如果重复执行会扣两次钱,别用 Redis 锁——用 etcd / ZooKeeper(有 lease 和真正的一致性保证),或者更好的办法是把业务做成幂等的,让重复执行本身无害。


4.10 JWT 黑名单(呼应第 6 课)

第 6 课留的问题:JWT 是无状态的,签发出去就没法撤回。用户点「退出登录」,那个 token 在过期前依然完全有效;token 被盗了你除了等它过期什么也做不了。

Redis 的标准答案:维护「已作废 token」名单,TTL 正好设成 token 的剩余有效期——token 自然过期的那一刻,黑名单记录也自动消失,不占额外内存。

前提:第 6 课签发时要在 claims 里带 jti(JWT ID)。当时没加现在补上。

// internal/cache/blacklist.go
func revokeKey(jti string) string { return "blog:v1:jwt:revoked:" + jti }

// Revoke 拉黑一个 token。exp 是它自己的过期时间。
func Revoke(ctx context.Context, rdb *redis.Client, jti string, exp time.Time) error {
    // ★ TTL = 剩余有效期。设固定值的话:设长了浪费内存,设短了有安全漏洞
    //   (记录没了但 token 还有效)。设成剩余有效期,不多不少
    ttl := time.Until(exp)
    if ttl <= 0 {
        return nil // 本来就过期了,不用记
    }
    return rdb.Set(ctx, revokeKey(jti), 1, ttl).Err() // value 存什么无所谓,存 1 最省
}

func IsRevoked(ctx context.Context, rdb *redis.Client, jti string) (bool, error) {
    // 用 EXISTS 不用 GET:只关心存不存在,不返回 value 省带宽;
    // 而且 EXISTS 对不存在的 key 返回 0 而不是 redis.Nil,少一层错误判断
    n, err := rdb.Exists(ctx, revokeKey(jti)).Result()
    if err != nil {
        return false, fmt.Errorf("check jwt blacklist: %w", err)
    }
    return n > 0, nil
}

改造第 6 课的鉴权中间件,验签通过后多查一次 Redis:

// internal/middleware/auth.go(在第 6 课基础上增补)
claims, err := parseToken(c, secret) // 第 6 课写的:取 header、验签、查过期
if err != nil { /* 401 */ }

// ★ 新增:签名有效不代表没被撤销
revoked, err := cache.IsRevoked(c.Request.Context(), rdb, claims.ID)
if err != nil {
    // ★ 这里的降级决策和限流相反:必须 fail-closed。
    // 查不到黑名单就放行,等于给攻击者一条"打挂 Redis 即可绕过登出"的路。
    // 认证相关的降级永远选拒绝
    slog.Error("黑名单查询失败,拒绝请求", "err", err)
    c.AbortWithStatusJSON(http.StatusServiceUnavailable, gin.H{"code": "UNAVAILABLE", "message": "服务暂时不可用"})
    return
}
if revoked {
    c.AbortWithStatusJSON(http.StatusUnauthorized, gin.H{"code": "TOKEN_REVOKED", "message": "登录已失效,请重新登录"})
    return
}

实测:JWT 黑名单: revoked=true other=false ttl=2h0m0s

一个诚实的取舍:加了黑名单之后,JWT 就不再是无状态的了——每次鉴权都要查一次 Redis,抵消掉了 JWT 相对 session 的主要优势。

那还用 JWT 吗?用。查 Redis 是一次 EXISTS(亚毫秒,且大概率不命中,走的是最快路径),而 session 每次都要读出完整会话数据;而且 JWT 的 claims 已经带了 user_id、角色,不用额外查库。

替代方案:把 access token 缩短到 15 分钟、用 refresh token 换新,登出时只作废 refresh token(数量少得多),access token 最多再活 15 分钟。这是 OAuth2 的标准做法,平衡更好但实现更复杂。

⑤ 跑起来验证

先打开 GORM 的 SQL 日志(第 8 课学过),这样能直接看到有没有查数据库:

gorm.Open(mysql.Open(dsn), &gorm.Config{Logger: logger.Default.LogMode(logger.Info)})

5.1 缓存真的生效了吗

redis-cli DEL blog:v1:post:1
curl -s http://localhost:8080/api/v1/posts/1 > /dev/null   # 第一次
curl -s http://localhost:8080/api/v1/posts/1 > /dev/null   # 第二次

服务端日志只应出现一次 SELECT

[0.812ms] [rows:1] SELECT * FROM `posts` WHERE `posts`.`id` = 1 AND `posts`.`deleted_at` IS NULL
                    ← 第二次请求这里什么都没有,说明命中了缓存

第二次也打 SQL 的话,检查三处:cache.Set 的错误是不是被忽略了、key 两次算出来是不是一样、errors.Is(err, redis.Nil) 那个分支是不是漏写了。

5.2 直接看 Redis 里存了什么

redis-cli KEYS 'blog:v1:*'      # ⚠️ 仅限本地开发,线上用 SCAN
redis-cli TTL  blog:v1:post:1   # 应在 600~720 之间,多跑几次会看到不同的值
redis-cli GET  blog:v1:post:1
redis-cli ZREVRANGE blog:v1:hot:posts 0 9 WITHSCORES

实测输出:

blog:v1:post:1
blog:v1:hot:posts
blog:v1:views:1
blog:v1:jwt:revoked:abc-123
blog:v1:rl:/api/v1/posts/:id:127.0.0.1

608
{"id":1,"title":"Hello Go","content":"正文","view_count":3,"created_at":"2026-09-06T09:33:16-07:00"}

2
12
1
5
3
1

用 JSON 序列化的好处就在这里:GET 出来肉眼可读。

5.3 防穿透

redis-cli DEL blog:v1:post:999999
curl -s -o /dev/null -w "%{http_code}\n" http://localhost:8080/api/v1/posts/999999   # → 404
redis-cli GET blog:v1:post:999999   # → "__NIL__"
redis-cli TTL blog:v1:post:999999   # → 60  (不是 600!)

再打几次这个 404,SQL 日志里不应再出现新的 SELECT

5.4 防击穿

redis-cli DEL blog:v1:post:1
seq 1 100 | xargs -P 100 -I{} curl -s -o /dev/null http://localhost:8080/api/v1/posts/1

数一下 SQL 日志里 WHERE id = 1 出现几次:没有 singleflight 时是几十次,有的话应该是 1~2 次(可能 2 次,因为 100 个请求不是严格同时到达)。单元测试里更好观测,实测 100 并发 → repo 实际被调用 1 次

5.5 限流

for i in $(seq 1 20); do curl -s -o /dev/null -w "%{http_code} " http://localhost:8080/api/v1/posts/1; done; echo
curl -s -D - -o /dev/null http://localhost:8080/api/v1/posts/1 | grep -iE 'ratelimit|retry-after'
200 200 200 200 200 429 429 429 429 429 429 429 429 429 429 429 429 429 429 429
X-RateLimit-Limit: 5
X-RateLimit-Remaining: 0
Retry-After: 11

等窗口过去 sleep 11 再试应该恢复 200。

5.6 分布式锁(两个终端)

# 终端 1
redis-cli SET blog:v1:lock:test mytoken NX PX 30000        # → OK
# 终端 2:抢不到
redis-cli SET blog:v1:lock:test othertoken NX PX 30000     # → (nil)
# 终端 2:用错误 value 释放,被拒绝
redis-cli EVAL 'if redis.call("GET",KEYS[1])==ARGV[1] then return redis.call("DEL",KEYS[1]) else return 0 end' 1 blog:v1:lock:test othertoken   # → 0
# 终端 1:用正确 value 释放
redis-cli EVAL 'if redis.call("GET",KEYS[1])==ARGV[1] then return redis.call("DEL",KEYS[1]) else return 0 end' 1 blog:v1:lock:test mytoken      # → 1

EVAL 语法是 EVAL 脚本 numkeys key1... arg1...,中间那个 1 是「接下来有几个参数属于 KEYS」。写错这个数字是新手用 EVAL 最常见的错误。

5.7 JWT 黑名单

TOKEN=$(curl -s -X POST http://localhost:8080/api/v1/auth/login -H 'Content-Type: application/json' \
  -d '{"username":"alice","password":"secret123"}' | jq -r .token)

curl -s -o /dev/null -w "%{http_code}\n" -H "Authorization: Bearer $TOKEN" http://localhost:8080/api/v1/me   # → 200
curl -s -X POST -H "Authorization: Bearer $TOKEN" http://localhost:8080/api/v1/auth/logout
curl -s -o /dev/null -w "%{http_code}\n" -H "Authorization: Bearer $TOKEN" http://localhost:8080/api/v1/me   # → 401

⑥ TODO 练习

练习 1:给标签列表加缓存(★☆☆)

// internal/cache/tag_cache.go
// TODO(练习1): 实现标签列表缓存
//   1. key 用 "blog:v1:tags:all"(整个列表存一个 key,不要每标签一个)
//   2. TTL 30 分钟 + 抖动
//   3. 新建/删除标签时 Del 这个 key
//   4. redis.Nil 必须正确处理
func (c *TagCache) GetAll(ctx context.Context) ([]model.Tag, error) { panic("TODO") }

验收curl /api/v1/tags 打两次,SQL 日志只出现一次 SELECT * FROM tagsredis-cli TTL blog:v1:tags:all 返回 1800~1900;新建标签后立刻再查,新标签能出现。

练习 2:缓存命中率统计(★★☆)

你现在无法回答「我的缓存到底有没有用」。

// internal/cache/metrics.go
// TODO(练习2): 用 HINCRBY 记录命中/未命中
//   1. key 用 "blog:v1:stats:cache:{YYYY-MM-DD}",field 用 "hit"/"miss"/"error"
//   2. 给 key 设 7 天 TTL,别让统计数据无限堆积
//   3. 加 GET /admin/cache/stats 返回今日命中率
//   4. 打点本身失败了只记日志,绝不影响主流程返回
func (c *PostCache) recordHit(ctx context.Context)  { /* TODO */ }
func (c *PostCache) recordMiss(ctx context.Context) { /* TODO */ }

验收:打 100 次同一篇文章后 redis-cli HGETALL blog:v1:stats:cache:2026-09-06 显示 hit≈99 miss=1/admin/cache/stats 返回 {"hit_rate":0.99,...}把 Redis 停掉(本机才能这么干:redis-cli SHUTDOWN NOSAVE)后服务仍返回 200 走 DB 降级,而不是 500。

练习 3:按路由配置的限流(★★☆)

type LimitRule struct {
    Limit  int64
    Window time.Duration
    By     string // "ip" | "user"
}

// TODO(练习3): 实现按路由配置的限流
//   1. POST /api/v1/auth/login: 5 次/分钟,按 IP,fail-closed(防暴力破解)
//   2. POST/PUT /api/v1/posts:  30 次/分钟,按 user
//   3. 读接口:                   300 次/分钟,按 IP,fail-open
//   4. 没配规则的路由不限流
func RateLimitByRule(rdb *redis.Client, rules map[string]LimitRule) gin.HandlerFunc { panic("TODO") }

验收:连续用错密码登录 6 次,第 6 次返回 429 而不是 401;同一用户在两个 IP 发文章共享同一份 30 次/分钟额度;停掉 Redis 后登录接口返回 503、文章读接口仍返回 200。

练习 4:热榜时间衰减(★★★)

现在 score 只增不减,三年前的爆款会永远霸占第一。

// internal/job/decay_hot.go
// TODO(练习4): 实现热榜时间衰减
// 方案 A(简单): 每小时把所有 score 乘 0.95(ZRANGE 取全部 + Pipeline 批量 ZADD)
// 方案 B(推荐): 分时间桶 —— key 用 "blog:v1:hot:posts:{YYYYMMDDHH}",
//              查 TopN 时 ZUNIONSTORE 合并最近 24 个桶,越近权重越高
//   1. 至少实现一个方案
//   2. 衰减任务必须用 4.9 的分布式锁保护,多实例部署时只有一个在跑
//   3. score 低于 0.5 的成员用 ZREMRANGEBYSCORE 清掉,别让 ZSet 无限增长
func DecayHotRank(ctx context.Context, rdb *redis.Client) error { panic("TODO") }

验收:手动刷某篇到 score=100,跑一次后 ZSCORE 返回 95;造一个 score=0.3 的成员,跑完后它消失;同时起两个进程跑,ZSCORE 只被衰减一次(是 95 不是 90.25)

练习 5:缓存降级开关(★★★)

线上出事故时你需要一个能立刻关掉缓存的开关,而不是改代码重新发布。

// internal/cache/switch.go
// TODO(练习5): 运行时可切换的缓存开关
//   1. 状态存在 Redis 的 "blog:v1:switch:cache"(值 "on"/"off")
//   2. 用 sync/atomic 在内存里缓存状态,后台每 5 秒刷新一次
//      —— 绝不能每个请求都去 Redis 查开关,那本身就是一次额外往返
//   3. off 时 GetByID 直接走 DB,完全跳过缓存读写
//   4. 提供 POST /admin/cache/switch 切换(需管理员权限)
//   5. Redis 查不到开关时默认 "on"(缺省应该是正常状态)
type Switch struct{ /* TODO */ }

验收redis-cli SET blog:v1:switch:cache off 后 5 秒内所有请求都打 SQL 日志;切回 on 后 5 秒恢复命中;go test -race 并发读开关无竞态报告;停掉 Redis 后开关按内存里最后的值继续工作,不 panic。

⑦ 自检清单

判断与基础

  • 我能说出三个「不该加缓存」的具体场景,并解释为什么
  • 我会先看慢查询日志、先加索引,再考虑缓存
  • import 路径是 github.com/redis/go-redis/v9,不是 go-redis/redis
  • *redis.Client 全局共用一个,不在 handler 里 NewClient
  • 启动时 Ping 自检;连接池四个超时(Dial/Read/Write/Pool)都设了
  • 我知道 MaxRetries: 0 是「用默认值 3」,-1 才是禁用

redis.Nil

  • 我知道 Get 未命中返回的 redis.Nil 不是错误,用 errors.Is 判断
  • 缓存层把「未命中」和「Redis 故障」包成两种不同的哨兵错误
  • 我能分清 redis.Nil(缓存没有,回源)/ model.ErrNotFound(数据库没有,写空值占位)/ cache.ErrCachedNil(缓存记着没有,直接 404)
  • 我知道 model.ErrNotFoundmodel 包不在 repository
  • Redis 故障时服务降级查 DB,而不是返回 500

Cache-Aside

  • key 有业务前缀 + 版本号;改结构体或改 model.Status 枚举值都要升版本号
  • 不可控输入不直接拼进 key(先 hash 再截断)
  • 每个 key 都设了 TTL,没有 Set(..., 0)
  • 更新时是删缓存不是更缓存,我能说出至少两个理由
  • 顺序是先更 DB 再删缓存,我能说出反过来的具体后果
  • 延迟双删的 AfterFunc 里用的是新 context,不是请求的 ctx

三大问题

  • 我能一句话说清穿透 / 击穿 / 雪崩的区别
  • 穿透:缓存空值 TTL 很短(60s),新建数据时要删占位
  • 击穿:singleflight,我知道错误共享和 ctx 传染两个坑
  • 雪崩:TTL 加 10%~30% 抖动;Redis 整挂要靠降级不是抖动
  • singleflight.Group 含锁,service 是指针传递(go vet 不报 copies lock value

ZSet、限流、锁

  • 我知道为什么不用 ORDER BY view_count(行锁 + 索引维护成本)
  • ZRevRangeWithScores闭区间,前 10 个写 0, 9redis.Z.Memberstring
  • 遍历用 SCAN 不用 KEYS;取增量用 GETDEL 不用 GET+DEL
  • INCREXPIRE 打包成 Pipeline(分开发送可能永久封禁某个 IP)
  • 限流用 AbortWithStatusJSON,key 里用 c.FullPath() 而不是实际路径
  • 中间件的错误响应自己拼且字段名对齐 APIError{Code,Message,Field};我知道 middleware 不能 import handler(import cycle)
  • 我知道限流该 fail-open、认证该 fail-closed,并能说出原因
  • SetNX 一定带 TTL;锁 value 唯一;释放用 Lua 原子完成「比较 + 删除」
  • 我能讲清「误删别人的锁」的完整时间线
  • 我知道这把锁没有续期、主从切换会丢锁,只适合「重复执行无害」的场景

JWT 黑名单

  • TTL 设为 token 的剩余有效期,不是固定值;用 EXISTS 不用 GET
  • 鉴权 fail-closed(Redis 挂了返回 503,不放行)
  • 我知道加了黑名单后 JWT 就不再无状态了,并理解这个取舍

下一课:第 10 课是收官课——配置管理、log/slog 结构化日志、优雅关闭、go test + httptest、并发基础、构建部署。我们会把这 10 课的所有组件在一个 main() 里装配起来,然后打成一个能扔到服务器上跑的二进制。