编程闭关教程 · 系列 08–20 · 编制日期 2026-10-14

Redis 专题:把它用成"数据加速层",而不是又一个数据库

你点名 Redis 常用,本模块把它独立成一套:从本机 Docker 起一个带密码的实例,到 go-redis v9 完整接入工作台,再到缓存三防(击穿/穿透/雪崩)、限流、Stream 队列、备份恢复与 1GB 低配服务器调优。全程最小必要理论 + 照做步骤 + 完整可复制代码 + 常见报错表,不跳出本教程。
本模块承诺Redis 在你的系统里只有一个身份——缓存与队列(数据加速层),不是业务主库。学完本模块:工作台接 Redis 缓存、跑通一次备份/恢复演练、能独立写出防击穿/防穿透/防雪崩的 Cache Aside。
8.10.2
锁定 Redis 稳定版(2026-09-17)
5+2
核心数据结构 + Streams + Pub/Sub
三防
击穿 / 穿透 / 雪崩 全套 Go 实现
1 份
RDB+AOF 备份恢复方案(含 cron)
1 周
学时(数据库四件套 ③)
版本唯一事实来源见 09 环境与版本清单;Redis 定位口径与 08 §8.1 一致(缓存与队列,非业务主库)。
08 总纲 09 环境·版本 10 Go 内核 11 工作台 12 Vue3 13 Flutter 14 Python 15 C/C++ 16 MySQL 17 PostgreSQL 18 Redis 19 SQLite3 20 部署
本页目录
  1. 01 版本与许可现状(8.10.2 锁定)
  2. 02 安装:本机 Docker 与 Linux 服务器两条路
  3. 03 核心概念(最小必要理论)
  4. 04 命令速查表
  5. 05 Go 集成:go-redis v9 完整示例
  6. 06 缓存模式实战:Cache Aside + 三防 + 限流
  7. 07 Stream 做异步消息队列
  8. 08 与工作台集成(不新增 API 端点)
  9. 09 备份、恢复与 1GB 服务器调优
  10. 10 常见报错表(12 条)
  11. 11 验收清单与来源
01

版本与许可现状(8.10.2 锁定)

本模块所有命令、配置、代码围绕 Redis 8.10.2(2026-09-17 安全发布线)编写;Go 客户端锁定 go-redis v9.23.0。这是全系列版本纪律的执行结果(08 §7),不是让你再做选择。

Redis Server

8.10.2
2026-09-17 安全发布。本教程本机用 Docker 镜像 redis:8.10(跟随 8.10.x 补丁线),服务器用发行版包或官方构建。升级三条件见 08 §7,不主动追新。

Go 客户端 go-redis

v9.23.0
2026-10-05 发布,v9 线 API。go get github.com/redis/go-redis/v9@v9.23.0。注意导入路径带 /v9。

1.1 许可现状(知道即可,不影响本教程)

Redis 自 8.0 起采用 RSALv2 / SSPLv1 / AGPLv3 三许可任选的商业源码模式(不再是 OSI 认证的开源协议)。社区已分叉出 Valkey 9.0.6(Linux 基金会主导,活跃)。对你的影响:

一句话口径:许可变了是"大厂商业博弈",不是"你不能用了"。缓存/队列这个用途,Redis 官方版与 Valkey 二选一都行,本教程锁 Redis 8.10.2 是因为它是 versions.md 里的事实。
02

安装:本机 Docker 与 Linux 服务器两条路

本机(Windows + Docker Desktop 4.94)用容器,干净可删;1GB Linux 服务器不装 Docker(内存不够,versions.md 明确),直接用发行版包跑 systemd。

2.1 路线 A:本机 Docker Compose(推荐开发用)

在你喜欢的项目目录建 redis-docker/docker-compose.yml,内容如下(密码、端口、卷全部写死,照抄即可):

# docker-compose.yml —— 本机开发用 Redis 8.10,带密码 + 持久化卷
services:
  redis:
    image: redis:8.10
    container_name: redis810
    restart: unless-stopped
    ports:
      - "6379:6379"
    command: ["redis-server",
              "--requirepass", "dev_redis_pwd_2026",
              "--appendonly", "yes",
              "--appendfsync", "everysec",
              "--maxmemory", "512mb",
              "--maxmemory-policy", "allkeys-lru"]
    volumes:
      - redis_data:/data

volumes:
  redis_data:

启动与验证:

# 在 docker-compose.yml 所在目录
docker compose up -d

# 进入容器用 redis-cli 验证(密码见上方 requirepass)
docker exec -it redis810 redis-cli -a dev_redis_pwd_2026

# 容器内逐条敲,预期结果写在注释里
127.0.0.1:6379> PING
PONG
127.0.0.1:6379> SET hello world
OK
127.0.0.1:6379> GET hello
"world"
127.0.0.1:6379> INFO server
# 看 redis_version:8.10.x、os、process_id 等,确认版本与运行状态
127.0.0.1:6379> EXIT

本机验证通过标志:PING 返回 PONG、INFO 里 redis_version:8.10 开头、persistence 段 aof_enabled:1。三条都对上,本机环境就绪。

2.2 路线 B:1GB Linux 服务器(不装 Docker)

以 Debian 13 / Ubuntu 26.04 为例。优先用发行版自带包(省心、自动 systemd);想要精确 8.10.2 再走官方源码构建。

B1 · 发行版包(首选)

sudo apt update
sudo apt install -y redis-server
sudo systemctl enable --now redis-server
redis-cli -a 本机密码 PING      # 返回 PONG 即活

Debian 13 / Ubuntu 26.04 仓库自带的是 Valkey 9 或 Redis 8.x 系列,命令与 go-redis 完全兼容;版本号若不是 8.10.2 属正常——服务器跑缓存,补丁级差异无关紧要。若必须锁 8.10.2,走 B2。

B2 · 官方源码构建(要精确版本时)

sudo apt install -y build-essential tcl
cd /tmp
curl -LO https://download.redis.io/releases/redis-8.10.2.tar.gz
tar xzf redis-8.10.2.tar.gz
cd redis-8.10.2
make -j$(nproc) BUILD_TLS=yes
sudo make install
# 把 utils/redis_init_script 改成 systemd unit(模板见模块 20),
# 配置文件放 /etc/redis/redis.conf,数据目录 /var/lib/redis

1GB 服务器铁律:不装 Docker(versions.md:Ubuntu Server 官方要求 1.5GB 内存起)。Redis 本体驻留内存控制在 256MB 内,剩下给 Go 单二进制 + SQLite + 系统。调优参数见 §9.2。

2.3 本机无 Docker 时的退路

Windows 本机也可以直接装 Redis:用 WSL2 里 apt install redis-server,或下载 Memurai/Redis Windows 移植版。但本教程默认 Docker 路线——删容器即清理,不污染系统。

03

核心概念(最小必要理论)

只讲"写代码时必须知道"的部分。教科书里那些持久化引擎细节、集群 gossip 协议,小项目用不到,先不展开。

3.1 五种核心数据结构 + Streams + Pub/Sub

结构像什么典型用途(你的工作台会用到的)
String一个 key → 一个字符串(可塞 JSON)缓存对象、token 会话、计数器(INCR)、限流计数。最常用,80% 场景用它。
Hash一个 key → 一张小哈希表(field→value)缓存"一个对象的多个字段",如用户信息 user:1001:{name,email}。比 String 存 JSON 更省、可单字段改。
List双向链表(队列/栈)简单最新消息流、轻量队列(LPUSH+BRPOP)。复杂队列用 Streams。
Set无序去重集合标签、共同好友、去重(如已处理的消息 ID)。
ZSet带分数的有序集合排行榜、按时间窗口滑动限流、延迟队列。score 排序是灵魂。
Streams追加日志流 + 消费组本模块重点:异步任务队列、事件日志。消费组 + ACK 保证不丢消息。
Pub/Sub发布/订阅广播实时通知、多实例间事件广播。发出去就完了,不持久化;要可靠就用 Streams。

3.2 过期时间与键空间

3.3 持久化:RDB 与 AOF 怎么选(选型表)

方案是什么优点 / 缺点你的场景怎么选
RDB
快照
定时把全库内存二进制 dump 成一个文件(dump.rdb)文件小、恢复快;但两次快照之间宕机会丢数据纯缓存场景:足够。丢了就回源查库,无所谓。
AOF
追加日志
把每条写命令追加到 appendonly.aof,重启重放数据更全(最多丢 1 秒);文件大、恢复慢、有 rewrite 开销队列(Streams)场景:开 everysec,防任务丢。
混合RDB 做基础 + AOF 做增量(默认开启)兼顾恢复速度与数据安全,代价略增 CPU/IO工作台这种"缓存+队列混合":默认开 AOF everysec 即可,RDB 由 AOF 自动触发。

一句话选型:只当缓存 → 关 AOF 也行(RDB 定时快照兜底);当队列 → AOF everysec 必开。工作台两者都有,统一开 AOF everysec,别纠结。

3.4 内存淘汰策略(maxmemory-policy 六种,一句话记住)

策略一句话含义
noeviction默认值:内存满了直接拒绝写入报错(OOM),不删任何 key。纯缓存别用它。
allkeys-lru所有 key 里删最久没被访问的。缓存场景首选。
allkeys-lfu所有 key 里删访问次数最少的。热点明显时比 LRU 好。
volatile-lru只在"设了 TTL 的 key"里删最久未用的。
volatile-lfu只在设了 TTL 的 key 里删访问最少的。
volatile-ttl优先删 TTL 最短(最快过期)的 key。

你的 1GB 服务器统一用 allkeys-lru(§9.2)。它的含义是:内存到顶了,Redis 自己挑最冷的 key 踢掉,业务无感。

3.5 单线程模型与"为什么快"(面试+排障都用得上)

3.6 集群 / 哨兵(一句话,小项目别碰)

哨兵(Sentinel)= 主从自动故障转移;Cluster = 数据分片多主。你的工作台就一个单实例:挂了就挂了,缓存本来就能重建(从 SQLite/MySQL 回源),队列丢了就重放。什么时候上哨兵?等到你多实例、多可用区、缓存宕机直接丢营收那天再说——不是现在。

04

命令速查表

按数据结构组织。左边是命令,右边是你什么时候用。redis-cli 里照敲即可(带密码先 redis-cli -a 你的密码)。

4.1 String(最常用)

命令作用 / 示例
SET k v [EX 秒] [NX|XX]写字符串;EX 60 顺手设 60 秒过期;NX 不存在才写(可做分布式锁)。
GET k读;key 不存在返回 nil。
INCR k / DECR k原子自增/自减。限流计数、验证码次数全靠它。
SETEX k 60 v写 + 60 秒过期一步到位。
MGET k1 k2 / MSET k1 v1 k2 v2批量读/写,省网络往返。

4.2 Hash

命令作用 / 示例
HSET user:1 name "Tom" age 30写多个字段。
HGET user:1 name读单个字段。
HGETALL user:1读全部字段(小 hash 用;大 hash 别全拉)。
HINCRBY user:1 age 1字段级原子自增。
HDEL user:1 age删字段。

4.3 List / Set / ZSet

结构命令作用
ListLPUSH q a b从左边压入(队头)。
BRPOP q 5从右边弹出,阻塞最多 5 秒(简单队列)。
LRANGE q 0 9取前 10 个,不弹出。
SetSADD tag:go 1001 1002加成员。
SISMEMBER tag:go 1001判断是否在集合里(去重/白名单)。
SINTER s1 s2交集(共同好友)。
ZSetZADD rank 95 "Tom" 88 "Jim"按 score 加成员。
ZREVRANGE rank 0 9 WITHSCORES取前 10 名(排行榜)。
ZADD rate:uid1 1697000000 "ev"滑动窗口限流:score 存时间戳。

4.4 Streams(队列核心,§7 展开)

命令作用
XADD mystream * task "send_mail" to "a@b.c"追加一条消息(* 让 Redis 自动生成 ID)。
XGROUP CREATE mystream g1 $建消费组 g1($ = 从最新消息开始)。
XREADGROUP GROUP g1 c1 COUNT 10 BLOCK 5000 STREAMS mystream >组内消费者 c1 拉取未读消息,阻塞 5 秒。
XACK mystream g1 <id>处理完确认。没 ACK 的会进 PEL 待重投。

4.5 通用:过期 / 删除 / 扫描

命令作用
EXPIRE k 60给已存在的 key 设 60 秒 TTL。
TTL k查剩余秒数;-1=永不过期,-2=key 不存在。
PERSIST k去掉过期时间(变永久 key,慎用)。
DEL k1 k2删 key。大 key 用 UNLINK k(异步删,不阻塞)。
SCAN 0 MATCH ws:task:* COUNT 100增量游标扫键。第一次返回游标,用返回值继续扫直到 0。替代 KEYS *。
05

Go 集成:go-redis v9 完整示例

从建 go.mod 到连接、五种结构操作、Pipeline/事务、发布订阅,全部给全可运行代码。客户端版本锁定 v9.23.0。

5.1 初始化项目与依赖

mkdir redisdemo && cd redisdemo
go mod init redisdemo
go get github.com/redis/go-redis/v9@v9.23.0
go get golang.org/x/sync/singleflight@latest   # §6 防击穿用,先装上

go.mod 关键行(锁好后提交入库):

module redisdemo

go 1.27

require (
    github.com/redis/go-redis/v9 v9.23.0
    golang.org/x/sync v0.x.x   # singleflight,go get 后自动写入实际版本
)

5.2 连接(含密码、连接池参数)

// main.go —— 连接 + 最常用操作一览
package main

import (
    "context"
    "fmt"
    "time"

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

var rdb *redis.Client

func initRedis() {
    rdb = redis.NewClient(&redis.Options{
        Addr:     "localhost:6379", // 服务器换成 服务器IP:6379
        Password: "dev_redis_pwd_2026", // 对应 docker-compose 里的 requirepass
        DB:       0,              // 默认 0 号库;小项目别分库,用 key 前缀隔离
        PoolSize: 10,             // 连接池大小,1GB 小机器 10 足够
        MinIdleConns: 2,
        DialTimeout:  2 * time.Second,  // 连不上快速失败,别傻等
        ReadTimeout:  500 * time.Millisecond,
        WriteTimeout: 500 * time.Millisecond,
    })
}

func main() {
    initRedis()
    ctx := context.Background()

    // 连通性验证
    if err := rdb.Ping(ctx).Err(); err != nil {
        panic(err)
    }
    fmt.Println("connected to redis")

    demoString(ctx)
    demoHash(ctx)
    demoList(ctx)
    demoExpire(ctx)
}

func demoString(ctx context.Context) {
    // SET + 60 秒过期
    err := rdb.Set(ctx, "ws:hello", "world", 60*time.Second).Err()
    must(err)

    val, err := rdb.Get(ctx, "ws:hello").Result()
    must(err)
    fmt.Println("GET ws:hello =", val)

    // 计数器(原子)
    n, _ := rdb.Incr(ctx, "ws:cnt:visit").Result()
    fmt.Println("visit count =", n)
}

func demoHash(ctx context.Context) {
    must(rdb.HSet(ctx, "ws:user:1001", map[string]any{
        "name": "Tom", "email": "tom@example.com",
    }).Err())

    name, _ := rdb.HGet(ctx, "ws:user:1001", "name").Result()
    fmt.Println("user name =", name)

    all, _ := rdb.HGetAll(ctx, "ws:user:1001").Result()
    fmt.Println("user all =", all)
}

func demoList(ctx context.Context) {
    must(rdb.LPush(ctx, "ws:msgs", "m1", "m2").Err())
    // 阻塞弹出,3 秒超时(简单队列)
    res, err := rdb.BRPop(ctx, 3*time.Second, "ws:msgs").Result()
    if err == redis.Nil {
        fmt.Println("queue empty, timeout")
    } else {
        must(err)
        fmt.Println("pop =", res[1])
    }
}

func demoExpire(ctx context.Context) {
    must(rdb.Set(ctx, "ws:temp", "data", 0).Err()) // 0 = 先不设过期
    must(rdb.Expire(ctx, "ws:temp", 10*time.Second).Err())
    ttl, _ := rdb.TTL(ctx, "ws:temp").Result()
    fmt.Println("ttl =", ttl) // 约 10s
    must(rdb.Del(ctx, "ws:temp").Err())
}

func must(err error) {
    if err != nil && err != redis.Nil {
        panic(err)
    }
}

运行:go run .。看到 connected to redis 和几行输出,连接与基础操作就通了。注意 redis.Nil 是"key 不存在"的正常返回,不是错误——代码里要单独判(见 demoList)。

5.3 Pipeline 与事务(TxPipeline)

多个命令打包一次网络往返 = Pipeline。要原子性(中间不能被插命令)= TxPipeline(MULTI/EXEC)。

func demoPipeline(ctx context.Context) {
    pipe := rdb.Pipeline()
    pipe.Set(ctx, "k1", "v1", 10*time.Second)
    pipe.Set(ctx, "k2", "v2", 10*time.Second)
    g := pipe.Get(ctx, "k1")
    cmds, err := pipe.Exec(ctx) // 一次往返发完
    must(err)
    fmt.Println("pipeline cmds =", len(cmds), "k1 =", g.Val())
}

func demoTxPipeline(ctx context.Context) {
    // 原子:DECR 余额 + 加流水,要么都成要么都不成
    tx := rdb.TxPipeline()
    tx.DecrBy(ctx, "acct:balance", 100)
    tx.LPush(ctx, "acct:log", "pay 100")
    _, err := tx.Exec(ctx)
    must(err) // 若期间 key 被别人改导致 WATCH 冲突,会返回 EXECABORT
    fmt.Println("tx done")
}

5.4 发布订阅(Pub/Sub)简介

func demoPubSub(ctx context.Context) {
    sub := rdb.Subscribe(ctx, "ws:events")
    defer sub.Close()

    // 另一个 goroutine 发消息
    go func() {
        time.Sleep(200 * time.Millisecond)
        rdb.Publish(ctx, "ws:events", "task:created:1001")
    }()

    msg, err := sub.ReceiveMessage(ctx)
    must(err)
    fmt.Println("recv =", msg.Payload)
}

Pub/Sub vs Streams:Pub/Sub 是"广播即焚"——订阅者掉线期间的消息直接丢。要可靠、要回溯、要消费组,用 §7 的 Streams。你的工作台异步队列用 Streams,Pub/Sub 只做"前端实时通知"这类可丢场景。

06

缓存模式实战:Cache Aside + 三防 + 限流

这是 Redis 最值钱的部分。Cache Aside(旁路缓存)是默认模式,三防(击穿/穿透/雪崩)是它的三个加固点,限流是计数结构的招牌用法。全部给完整 Go 代码,可直接当 snippets 沉淀(08 §5.3)。

6.1 Cache Aside 标准写法

// cache.go —— 标准旁路缓存:读先查缓存,miss 再回源写回;写先更库再删缓存
package main

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

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

type User struct {
    ID   int    `json:"id"`
    Name string `json:"name"`
}

// 模拟"数据库"(工作台里这里换成 GORM 查 SQLite/MySQL)
func queryUserFromDB(ctx context.Context, id int) (*User, error) {
    return &User{ID: id, Name: "Tom"}, nil
}

func GetUser(ctx context.Context, rdb *redis.Client, id int) (*User, error) {
    key := fmt.Sprintf("ws:user:%d", id)

    // 1) 先查缓存
    raw, err := rdb.Get(ctx, key).Result()
    if err == nil {
        var u User
        if json.Unmarshal([]byte(raw), &u) == nil {
            return &u, nil // 缓存命中
        }
    } else if err != redis.Nil {
        return nil, err // 真错误,别回源,直接返回(或降级)
    }

    // 2) miss → 回源查库
    u, err := queryUserFromDB(ctx, id)
    if err != nil {
        return nil, err
    }

    // 3) 写回缓存(带过期,§6.4 加随机防雪崩)
    data, _ := json.Marshal(u)
    ttl := 5*time.Minute + jitter()
    _ = rdb.Set(ctx, key, data, ttl).Err()
    return u, nil
}

// UpdateUser:写路径——先写库,再删缓存(不是改缓存!)
func UpdateUser(ctx context.Context, rdb *redis.Client, u *User) error {
    if err := saveUserToDB(ctx, u); err != nil {
        return err
    }
    // 删而不是改:避免并发下新旧值竞争;下次读自然重建
    return rdb.Del(ctx, fmt.Sprintf("ws:user:%d", u.ID)).Err()
}

func saveUserToDB(ctx context.Context, u *User) error { return nil }
func jitter() time.Duration { return time.Duration(rand.Intn(60)) * time.Second }

为什么写路径是"删缓存"而不是"改缓存":并发写时,"先改缓存再更库"或"先更库再改缓存"都可能留下脏数据;而"更库 + 删缓存"把数据一致性收敛到下一次读重建,是业界默认。

6.2 防击穿(singleflight 合并并发回源)

击穿:某个热点 key 过期瞬间,成千上万请求同时 miss、同时回源打挂数据库。解法:同一时刻对同一个 key 只放一个请求去回源,其他等待结果。Go 标准做法 singleflight。

import "golang.org/x/sync/singleflight"

var sf singleflight.Group

func GetUserProtected(ctx context.Context, rdb *redis.Client, id int) (*User, error) {
    key := fmt.Sprintf("ws:user:%d", id)

    // 1) 先查缓存(同 6.1)
    if raw, err := rdb.Get(ctx, key).Result(); err == nil {
        var u User
        if json.Unmarshal([]byte(raw), &u) == nil {
            return &u, nil
        }
    }

    // 2) miss 时用 singleflight 合并:同一 key 同一时刻只跑一次 fn
    v, err, _ := sf.Do(key, func() (any, error) {
        // 进函数后再查一次缓存(双重检查):可能已被同组先到的人填上
        if raw, err := rdb.Get(ctx, key).Result(); err == nil {
            var u User
            if json.Unmarshal([]byte(raw), &u) == nil {
                return &u, nil
            }
        }
        return queryUserFromDB(ctx, id) // 真正回源,全组共享这次结果
    })
    if err != nil {
        return nil, err
    }
    u := v.(*User)

    // 3) 写回缓存
    data, _ := json.Marshal(u)
    _ = rdb.Set(ctx, key, data, 5*time.Minute+jitter()).Err()
    return u, nil
}

没有 singleflight 的等价手工方案:用一个 per-key 的互斥锁(如 SETNX lock:key 1 EX 5),拿到锁的才回源,没拿到的自旋等一小会再读缓存。生产优先 singleflight,一行库搞定。

6.3 防穿透(空值缓存)

穿透:查根本不存在的数据(如 id=-1、恶意爬虫),缓存永远 miss,每次都打数据库。解法:把"查无此数据"也缓存成一个空标记,短 TTL。

var errNotFound = errors.New("not found")

func GetUserWithNullCache(ctx context.Context, rdb *redis.Client, id int) (*User, error) {
    key := fmt.Sprintf("ws:user:%d", id)

    if raw, err := rdb.Get(ctx, key).Result(); err == nil {
        if raw == "__NULL__" {
            return nil, errNotFound // 缓存里明确"不存在",直接挡掉
        }
        var u User
        if json.Unmarshal([]byte(raw), &u) == nil {
            return &u, nil
        }
    }

    u, err := queryUserFromDB(ctx, id)
    if errors.Is(err, errNotFound) {
        // 空值也缓存,但 TTL 要短(30~60s),防真数据来了迟迟看不到
        _ = rdb.Set(ctx, key, "__NULL__", 30*time.Second).Err()
        return nil, errNotFound
    }
    if err != nil {
        return nil, err
    }

    data, _ := json.Marshal(u)
    _ = rdb.Set(ctx, key, data, 5*time.Minute+jitter()).Err()
    return u, nil
}

穿透 vs 击穿:击穿是"热点 key 过期"导致的并发回源(key 存在过);穿透是"查不存在的 key"(缓存压根没有)。空值缓存 + 布隆过滤器(超大规模才上)是穿透的两道墙。

6.4 防雪崩(过期时间加随机)

雪崩:大批 key 同一时刻集体过期,瞬间全部回源。解法:在基础 TTL 上加一个随机抖动。6.1 里的 jitter() 就是干这个的:

import "math/rand"

// 每个 key 的过期 = 基础 5 分钟 + 0~60 秒随机,把"同时过期"摊平
func jitter() time.Duration {
    return time.Duration(rand.Intn(60)) * time.Second
}
// 写缓存统一用:rdb.Set(ctx, key, data, 5*time.Minute + jitter())

6.5 限流(滑动窗口,ZSet 实现)

需求:每个用户 60 秒内最多 100 次请求。用 ZSet 把每次请求的时间戳存进去,只留窗口内的。

import "time"

// AllowRate 滑动窗口限流:uid 在 window 内最多 max 次
func AllowRate(ctx context.Context, rdb *redis.Client, uid string, max int64, window time.Duration) (bool, error) {
    now := time.Now().UnixMilli()
    key := fmt.Sprintf("ws:rate:%s", uid)
    winStart := now - window.Milliseconds()

    // 用 Pipeline 原子化:① 清窗口外 ② 数窗口内 ③ 没超就加入本次
       pipe := rdb.TxPipeline()
    pipe.ZRemRangeByScore(ctx, key, "0", fmt.Sprintf("%d", winStart))
    cnt := pipe.ZCard(ctx, key)
    pipe.ZAdd(ctx, key, redis.Z{Score: float64(now), Member: now})
    pipe.Expire(ctx, key, window)
    if _, err := pipe.Exec(ctx); err != nil {
        return false, err
    }
    return cnt.Val() <= max, nil
}

更简单的固定窗口(够本教程用):INCR 一个 key 并 EXPIRE 60,超过阈值就拒绝。滑动窗口更准,这里给的是后者;固定窗口代码三行,照名字就能写。

07

Stream 做异步消息队列

阶段三会用 Redis Streams 做消息队列(08 §2 阶段三路线)。这里给最小可用模型 + go-redis 代码:生产者 XADD,消费者组 XREADGROUP,处理完 XACK。

7.1 三个必须先懂的概念

7.2 go-redis 生产者 + 消费者完整代码

// stream_demo.go —— 一个文件内同时演示生产与消费
package main

import (
    "context"
    "fmt"
    "log"
    "time"

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

const streamName = "ws:queue:mail"

func ensureGroup(ctx context.Context, rdb *redis.Client) {
    // 创建消费组;MKSTREAM 表示流不存在就建。BUSYGROUP 错误忽略(已存在)
    if err := rdb.XGroupCreateMkStream(ctx, streamName, "mailers", "$").Err(); err != nil &&
        err.Error() != "BUSYGROUP Consumer Group name already exists" {
        log.Fatal(err)
    }
}

func producer(ctx context.Context, rdb *redis.Client) {
    for i := 0; i < 3; i++ {
        id, err := rdb.XAdd(ctx, &redis.XAddArgs{
            Stream: streamName,
            Values: map[string]any{
                "to":      fmt.Sprintf("user%d@example.com", i),
                "subject": "welcome",
            },
        }).Result()
        if err != nil {
            log.Fatal(err)
        }
        fmt.Println("produced:", id)
    }
}

func consumer(ctx context.Context, rdb *redis.Client, name string) {
    for {
        // 阻塞 5 秒读新消息(> 表示从未投递过的)
        res, err := rdb.XReadGroup(ctx, &redis.XReadGroupArgs{
            Group:    "mailers",
            Consumer: name,
            Streams:  []string{streamName, ">"},
            Count:    10,
            Block:    5 * time.Second,
        }).Result()
        if err != nil {
            if err == redis.Nil {
                continue // 阻塞超时,没消息,继续等
            }
            log.Println("read err:", err)
            time.Sleep(time.Second)
            continue
        }
        for _, stream := range res {
            for _, msg := range stream.Messages {
                fmt.Printf("[%s] got id=%v to=%v\n", name, msg.ID, msg.Values["to"])
                // ---- 这里做真实工作:发邮件 ----
                if err := sendMail(msg.Values); err != nil {
                    log.Println("process err, keep in PEL:", err)
                    continue // 不 ACK,稍后重投
                }
                // 成功了才 ACK
                if err := rdb.XAck(ctx, streamName, "mailers", msg.ID).Err(); err != nil {
                    log.Println("ack err:", err)
                }
            }
        }
    }
}

func sendMail(vals map[string]any) error {
    fmt.Println("  >> sending mail to", vals["to"])
    return nil
}

func runStreamDemo() {
    ctx := context.Background()
    rdb := newDemoClient()
    ensureGroup(ctx, rdb)

    go consumer(ctx, rdb, "c1")
    go consumer(ctx, rdb, "c2") // 两个消费者分摊,演示负载均衡
    time.Sleep(300 * time.Millisecond)
    producer(ctx, rdb)
    time.Sleep(2 * time.Second)
}

跑起来你会看到:3 条消息被 c1/c2 分摊消费(组内广播变单播),每条处理完 XACK。故意在 sendMail 里 return error,那条消息就不 ACK——重启消费者后用 XReadGroup 从 PEL 重读即可重投。这就是工作台"异步发邮件/异步生成报表"的骨架。

取舍:你的量级(个人工作台)用 Redis Streams 足够。将来日消息量到百万级、要跨机房、要严格顺序,再评估专门 MQ(如 Kafka)——本阶段不需要。

08

与工作台集成(不新增 API 端点)

按 workbench-contract.md:工作台是 Go(Gin+GORM) + Vue3,端点表固定(/healthz、/api/v1/auth/login、/api/v1/tasks CRUD)。Redis 是加速层,接在现有端点内部,不新增任何 API 路径。下面三处接入位置,给片段。

8.1 接入位置一:会话 / token 缓存

现状(契约):JWT 无状态,每次请求都验签。可把"已签发的活跃 token ID"缓存进 Redis,支持主动登出(拉黑)与快速失效。接入点:JWT 认证 middleware 内部,路由表不变。

// internal/middleware/auth.go 内:验签通过后多查一次黑名单缓存
func AuthMiddleware(rdb *redis.Client) gin.HandlerFunc {
    return func(c *gin.Context) {
        token := extractBearer(c)
        claims, err := jwt.Parse(token) // 沿用契约 jwt/v5
        if err != nil { c.AbortWithStatusJSON(401, ...); return }

        jti := claims.ID
        // 若 jti 在黑名单缓存里 = 已登出
        if n, _ := rdb.Exists(c, "ws:jwt:bl:"+jti).Val(); n > 0 {
            c.AbortWithStatusJSON(401, gin.H{"code": 401, "msg": "logged out"})
            return
        }
        c.Set("userID", claims.Subject)
        c.Next()
    }
}
// 登出(复用现有 logout 逻辑):rdb.Set(ctx, "ws:jwt:bl:"+jti, "1", 剩余有效期)
// 注意:这是在现有 handler 里加一行,不是新端点。

8.2 接入位置二:任务列表缓存

现状(契约):GET /api/v1/tasks 每次查 GORM。任务列表读多写少,最适合 Cache Aside(§6.1)。接入点:task handler 内部,响应结构不变。

// internal/handler/task.go 内:ListTasks 加缓存旁路
func (h *TaskHandler) ListTasks(c *gin.Context) {
    uid := c.GetUint("userID")
    key := fmt.Sprintf("ws:task:list:uid:%d", uid)

    // 1) 先读缓存
    if raw, err := h.rdb.Get(c, key).Result(); err == nil {
        var tasks []Task
        json.Unmarshal([]byte(raw), &tasks)
        c.JSON(200, gin.H{"code": 0, "data": tasks})
        return
    }

    // 2) miss 回源 GORM(契约里的 db)
    var tasks []Task
    h.db.Where("uid = ?", uid).Find(&tasks)

    // 3) 写回缓存,TTL 5 分钟 + 随机抖动
    if data, err := json.Marshal(tasks); err == nil {
        h.rdb.Set(c, key, data, 5*time.Minute+jitter())
    }
    c.JSON(200, gin.H{"code": 0, "data": tasks})
}

// 写路径(Create/Update/Delete task)末尾统一加一行:删缓存
// h.rdb.Del(c, fmt.Sprintf("ws:task:list:uid:%d", uid))

契约遵守:对外仍是 GET /api/v1/tasks,请求/响应字段(code/data/msg、Task 的 title/note/done)一个没变。Redis 只在 handler 内部多了一层旁路。

8.3 接入位置三:Stream 做异步队列

把"新建任务后发通知邮件"这类副作用从请求链路里摘出去:handler 只往 Stream 投一条消息就返回,后台消费者(独立 goroutine 或独立二进制)慢慢发邮件。接入点:POST /api/v1/tasks 内部投递,不新增端点。

// CreateTask 成功后:不直接发邮件,改投队列
func (h *TaskHandler) CreateTask(c *gin.Context) {
    var in struct{ Title, Note string; Done bool }
    c.BindJSON(&in)
    task := saveTask(in) // GORM,契约原有逻辑

    // 异步通知:投 Stream,立即返回,不等邮件发完
    h.rdb.XAdd(c, &redis.XAddArgs{
        Stream: "ws:queue:notify",
        Values: map[string]any{"task_id": task.ID, "title": task.Title},
    })

    c.JSON(200, gin.H{"code": 0, "data": task})
}
// 消费者进程(§7.2 的 consumer)在服务器后台常驻,消费 ws:queue:notify 发邮件。
// 主请求延迟从"发邮件 500ms"降到"XAdd 0.5ms"。

8.4 配置项(走契约的环境变量)

沿用契约:配置走环境变量(godotenv 加载)。在 internal/config/config.go 加两个字段,不引入新配置体系:

REDIS_ADDR=localhost:6379
REDIS_PASSWORD=dev_redis_pwd_2026
# 服务器上:REDIS_ADDR=127.0.0.1:6379(Redis 只绑本机,不暴露公网)
09

备份、恢复与 1GB 服务器调优

Redis 是加速层,备份的优先级低于 SQLite/MySQL 主库。但 Stream 队列里可能有未消费任务,所以给一份最小可用方案:RDB 定时快照 + 必要时 AOF。

9.1 RDB 快照备份与恢复

BGSAVE vs SAVE

RDB 文件位置(默认):dir 配置指向的目录下的 dump.rdb(Docker 卷里即 /data/dump.rdb)。

cron 定时备份脚本(照抄,放到服务器 /usr/local/bin/redis-backup.sh)

#!/bin/bash
# /usr/local/bin/redis-backup.sh —— 每天凌晨备份 RDB,留 7 天
set -euo pipefail

REDIS_CLI="redis-cli -a dev_redis_pwd_2026"
BACKUP_DIR=/var/backups/redis
mkdir -p "$BACKUP_DIR"
STAMP=$(date +%Y%m%d-%H%M)

# 1) 触发后台快照
$REDIS_CLI BGSAVE

# 2) 等快照完成(通过 LASTSAVE 时间戳变化判断)
LAST_BEFORE=$($REDIS_CLI LASTSAVE)
while true; do
    sleep 2
    LAST_NOW=$($REDIS_CLI LASTSAVE)
    [ "$LAST_NOW" != "$LAST_BEFORE" ] && break
done

# 3) 拷贝快照出来(RDB 在 /var/lib/redis/dump.rdb,按你的 dir 改)
cp /var/lib/redis/dump.rdb "$BACKUP_DIR/dump-$STAMP.rdb"

# 4) 只留最近 7 份
ls -1t "$BACKUP_DIR"/dump-*.rdb | tail -n +8 | xargs -r rm -f

echo "backup done: dump-$STAMP.rdb"
# 加执行权限 + 挂 cron(每天 3:17 跑,避开整点)
sudo chmod +x /usr/local/bin/redis-backup.sh
sudo crontab -e
# 写入这一行:
17 3 * * * /usr/local/bin/redis-backup.sh >> /var/log/redis-backup.log 2>&1

恢复流程

  1. 停 Redis:sudo systemctl stop redis-server。
  2. 把要恢复的快照拷回数据目录:cp /var/backups/redis/dump-YYYYMMDD-HHMM.rdb /var/lib/redis/dump.rdb,chown redis:redis。
  3. 启动:sudo systemctl start redis-server,redis-cli INFO keyspace 确认 key 回来了。

9.2 AOF 开启、rewrite 与恢复

# redis.conf 关键行(队列场景开 everysec)
appendonly yes
appendfilename "appendonly.aof"
appendfsync everysec        # 每秒刷盘,宕机最多丢 1 秒
auto-aof-rewrite-percentage 100
auto-aof-rewrite-min-size 64mb  # AOF 涨到 64MB 且翻倍就自动 rewrite 瘦身

手动 rewrite:redis-cli BGREWRITEAOF(后台重写,压缩历史命令)。AOF 损坏时:用 redis-check-aof --fix appendonly.aof 修复截断尾部再启动。恢复顺序:有 AOF 先加载 AOF,没有才加载 RDB。

9.3 1GB 低配服务器调优(照抄 redis.conf 片段)

# ===== 1GB 服务器 Redis 调优基线 =====
bind 127.0.0.1            # 只绑本机!公网暴露 = 被扫到挖矿钱包
protected-mode yes
port 6379
requirepass dev_redis_pwd_2026   # 换强密码

maxmemory 256mb           # Redis 最多吃 256MB,剩下留给 Go+SQLite+系统
maxmemory-policy allkeys-lru   # 满了自动踢最冷的 key(§3.4)

# 持久化按角色选:纯缓存可 no;有队列必须 everysec(下面是队列场景)
appendonly yes
appendfsync everysec

# 危险命令改名/禁用(一句话:别让 KEYS/CONFIG/FLUSH 在公网能跑)
rename-command FLUSHDB ""
rename-command FLUSHALL ""
rename-command CONFIG "RedisCfg_2026"

9.4 什么时候用 Redis(引用 08 §8.1)

口径与 08 §8.1 完全一致:Redis = 缓存与队列(数据加速层),不是业务主库。你的业务数据真相永远在 SQLite(工作台默认)或 MySQL/PG 里。Redis 里的东西随时可以丢、可以重建——凡是"丢了不能重建"的数据,就不该只放 Redis。把这条写进你的 redis-playbook.md。
10

常见报错表(12 条)

照报错原文查"原因 → 解决"。这是闭关期最常翻的一节。

报错原文原因解决
dial tcp localhost:6379: connect: connection refusedRedis 没起来 / 端口不对本机:docker compose up -d;服务器:systemctl status redis-server;确认端口与密码一致。
NOAUTH Authentication required.连了带密码的实例但没发 AUTHredis-cli 加 -a 密码;go-redis 配置里 Password 字段填对。
WRONGTYPE Operation against a key holding the wrong kind of valuekey 类型和命令不匹配(对 String 用 HSET)TYPE k 看真实类型;key 前缀要带业务语义,别混用同名 key。
OOM command not allowed when used memory > 'maxmemory'到了 maxmemory 上限且策略是 noeviction设 maxmemory-policy allkeys-lru;或调大 maxmemory;清理大 key。
MISCONF Redis is configured to save RDB snapshots...BGSAVE 写盘失败(目录没权限/磁盘满)查磁盘 df -h、数据目录权限;临时 CONFIG SET stop-writes-on-bgsave-error no 但要根治磁盘。
READONLY You can't write against a read only replica.连到了只读从节点却在写写操作打主节点;确认 go-redis 的 Addr 指向主。
EXECABORT Transaction discarded because of previous errors.TxPipeline 里某条命令入队就失败(如类型错)EXEC 前看每步 Err;WATCH 期间 key 被改也会触发,加重试。
context deadline exceeded(go-redis 超时)读/写超时太短或 Redis 被慢命令阻塞调 ReadTimeout;查慢日志 SLOWLOG GET;别在单线程里跑 KEYS/大 HGETALL。
ERR max number of clients reached连接数打满(连接池泄漏/没 Close)客户端复用单例 client 别重复建;CONFIG SET maxclients 20000;排查 goroutine 泄漏。
Bad file format reading the append only file / AOF 损坏异常断电导致 AOF 尾部截断redis-check-aof --fix appendonly.aof 修复后重启;先备份再 fix。
WRONGPASS invalid username-password pair密码错核对 requirepass 与客户端 Password;注意配置文件里不要有多余空格/注释粘连。
LOADING Redis is loading the dataset in memory刚启动还在加载 RDB/AOF,暂不服务正常现象,等加载完;加载太久说明数据太大,1GB 机器别让缓存超出 maxmemory。
11

验收清单与来源

11.1 本模块验收(打勾才算过)

验收项通过标准
本机 docker compose 起 redis:8.10,redis-cli PING 返回 PONG,INFO 显示 8.10.x§2.1 三条全过
go-redis v9.23.0 连上,跑通 String/Hash/List/过期四个 demo§5.2 输出正确
Cache Aside 接进工作台 GET /api/v1/tasks,二次请求命中缓存(看日志/延迟)§8.2 接入点生效,端点不变
完成一次备份/恢复演练:BGSAVE → cp dump.rdb → 改数据 → 停服 → 拷回 → 起服 → 数据还原§9.1 全流程走通
Stream 队列:投 3 条消息,两个消费者分摊消费并 XACK§7.2 跑通
能默写并讲清:缓存三防分别防什么、Cache Aside 写路径为什么是删缓存§6 口头/白纸过一遍

11.2 与其他模块的关系

来源表

Redis 8.10.2 发布(2026-09-17 安全发布)与 8.0 起 RSALv2/SSPLv1/AGPLv3 三许可、Valkey 分叉https://github.com/redis/redis/releases
go-redis v9.23.0 发布(v9 线 API,导入路径 /v9)https://github.com/redis/go-redis/releases
Redis 命令、持久化(RDB/AOF)、内存淘汰、Streams 消费组/PEL、SLOWLOG 等官方语义https://redis.io/docs/latest/
Docker 镜像 redis:8.10 标签与官方 compose 用法https://hub.docker.com/_/redis
本模块版本锁定与 1GB 不装 Docker 的口径来源:全系列版本清单https://github.com/docker/desktop-release-notes
Redis 定位(缓存与队列,非业务主库)引用自 08 §8.1file:///D:/02-Docs/金融知识/08-闭关总纲.html
工作台端点/模型/技术栈契约(本模块不新增端点)workbench-contract.md(系列内部规范)

(编者整理:Cache Aside、三防、限流、Stream 队列的 Go 代码为本教程基于 go-redis v9 API 的演示实现,无外部独立来源;错误表为常见运维场景归纳。)