Files
cs-note/hhs/Redis/17-Go项目集成Redis.md
T
2026-05-28 12:41:37 +08:00

12 KiB
Raw Blame History

tags, create time
tags create time
Redis
Go
go-redis
缓存
后端
2026-05-28 10:55

Go 项目集成 Redis

概述

Go 生态中最主流的 Redis 客户端是 go-redis(github.com/redis/go-redis/v9),它支持 Redis 6.0+ 的全部命令、Cluster、Sentinel、Pub/Sub、Lua 脚本、Pipeline 等能力,社区活跃度和文档质量均优于早期的 redigo。本文以 go-redis 为主线,覆盖从初始化到生产实践的完整链路。

[!QUESTION] go-redis vs redigo,怎么选?

维度 go-redis redigo
API 风格 命令式,返回值类型明确 Do() 返回 interface{},需手动断言
连接池 内置,参数可调 需手动封装 Pool
Cluster / Sentinel 原生支持 需自行实现
维护状态 持续更新(v9) 基本停更

结论:新项目直接用 go-redis,除非有历史包袱。

一、客户端初始化与连接池

1.1 基础连接

import (
    "context"
    "errors"
    "fmt"
    "os"
    "time"

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

// 单机模式
rdb := redis.NewClient(&redis.Options{
    Addr:     "localhost:6379",
    Password: os.Getenv("REDIS_PASSWORD"), // 生产环境从环境变量读取
    DB:       0,
})

1.2 连接池参数调优

go-redis 内置连接池,不需要再手动包一层。关键参数如下:

rdb := redis.NewClient(&redis.Options{
    Addr:         "localhost:6379",
    PoolSize:     100,              // 连接池最大连接数,默认 10*runtime.GOMAXPROCS
    MinIdleConns: 10,               // 最小空闲连接,避免冷启动时频繁创建连接
    DialTimeout:  5 * time.Second,  // 建立 TCP 连接的超时
    ReadTimeout:  3 * time.Second,  // 读超时(含从池中获取连接的等待时间)
    WriteTimeout: 3 * time.Second,  // 写超时
})

[!TIP] PoolSize 和 MinIdleConns 的关系

  • PoolSize 是硬上限——同时最多有这么多 TCP 连接到 Redis
  • MinIdleConns 是保底——即使没有请求,也会保持这么多空闲连接
  • 连接不够时,请求会阻塞等待(由 ReadTimeout / WriteTimeout 控制超时)
  • go-redis v9 没有 MaxIdleConns 和 PoolTimeout,空闲连接回收由内部 connMaxIdleTime(默认 5 分钟)管理

[!TIP] 如何确定 PoolSize?

  • 公式参考:PoolSize ≈ 预估并发数 × 单次 Redis 操作耗时(s)(Little's Law:任意时刻同时占用连接的期望数)
  • 例如并发 1000,每次操作 10ms → 1000 × 0.01 = 10,再留 2~3 倍余量 → PoolSize = 30
  • 监控 redis.PoolStats() 中的 Hits、Misses、Timeouts 来动态调整

1.3 Sentinel 模式

rdb := redis.NewFailoverClient(&redis.FailoverOptions{
    MasterName:    "mymaster",
    SentinelAddrs: []string{"sentinel-1:26379", "sentinel-2:26379", "sentinel-3:26379"},
    Password:      os.Getenv("REDIS_PASSWORD"),
    SentinelPassword: os.Getenv("SENTINEL_PASSWORD"),
    PoolSize:      100,
    MinIdleConns:  10,
})

1.4 Cluster 模式

rdb := redis.NewClusterClient(&redis.ClusterOptions{
    Addrs: []string{
        "node-1:6379", "node-2:6379", "node-3:6379",
        "node-4:6379", "node-5:6379", "node-6:6379",
    },
    Password:     os.Getenv("REDIS_PASSWORD"),
    PoolSize:     100,
    MinIdleConns: 10,
    ReadOnly:     true,  // 从节点可读,分担读压力
    RouteByLatency: true, // 自动路由到延迟最低的节点
})
flowchart TD
    A["Go Application"] --> B{"连接模式?"}
    B -->|"单机/开发"| C["redis.NewClient"]
    B -->|"主从+自动故障转移"| D["redis.NewFailoverClient"]
    B -->|"水平扩展/分片"| E["redis.NewClusterClient"]
    C --> F["redis.Options"]
    D --> G["redis.FailoverOptions"]
    E --> H["redis.ClusterOptions"]
    F --> I["调优连接池参数"]
    G --> I
    H --> I

二、基础操作示例

go-redis 的命令命名与 Redis 命令一一对应,遵循 rdb.Xxx(ctx, args...) 的统一模式。

2.1 String

ctx := context.Background()

// SET key value EX 60
err := rdb.Set(ctx, "user:1001:name", "张三", 60*time.Second).Err()

// GET key
name, err := rdb.Get(ctx, "user:1001:name").Result()
// 不存在时返回 redis.Nil 错误,而非空字符串
if errors.Is(err, redis.Nil) {
    fmt.Println("key 不存在,回源 DB")
}

[!WARNING] 区分 "key 不存在" 和 "其他错误" go-redis 用 redis.Nil 表示 key 不存在,不要用 err != nil 一概而论——这会把"不存在"误判为"出错"。

2.2 Hash

// HSET user:1001 name "张三" age 25
rdb.HSet(ctx, "user:1001", map[string]interface{}{
    "name": "张三",
    "age":  25,
})

// HGETALL user:1001
user, err := rdb.HGetAll(ctx, "user:1001").Result()

2.3 Sorted Set

// ZADD leaderboard 95.5 "player:A"
rdb.ZAdd(ctx, "leaderboard", redis.Z{
    Score:  95.5,
    Member: "player:A",
})

// ZREVRANGE leaderboard 0 9 WITHSCORES(取 Top 10)
top10, _ := rdb.ZRevRangeWithScores(ctx, "leaderboard", 0, 9).Result()

三、Pipeline 批量操作

逐条发送命令,每条都要等一次 RTT。Pipeline 把多条命令打包成一次请求发送,响应也一次性读回,大幅减少网络往返。

// 逐条写入 1000 个 key:1000 次 RTT ❌
// Pipeline 写入 1000 个 key:1 次 RTT  ✅

pipe := rdb.Pipeline()
for i := 0; i < 1000; i++ {
    pipe.Set(ctx, fmt.Sprintf("key:%d", i), i, 0)
}
cmds, err := pipe.Exec(ctx) // 一次发送,批量执行

[!QUESTION] Pipeline 和 Lua 脚本的区别?

  • Pipeline:多条命令打包发送,但不保证原子性——其他客户端的命令可能穿插在中间
  • Lua 脚本:在 Redis 服务端原子执行,适合需要"要么全成功,要么全失败"的场景(如分布式锁解锁)
  • 如果只是"批量读/写",Pipeline 性能更优;如果需要原子语义,用 Lua

四、分布式锁

4.1 加锁:SET NX EX

// SET lock:order:1001 $uuid NX EX 10
ok, err := rdb.SetNX(ctx, "lock:order:1001", uuid, 10*time.Second).Result()
if !ok {
    // 获取锁失败,其他协程/进程已持有
    return fmt.Errorf("锁已被持有")
}
defer unlock(ctx, rdb, "lock:order:1001", uuid) // 确保释放

4.2 解锁:Lua 原子判断 + 删除

解锁必须先判断锁是否是自己加的,再删除——这两步必须原子执行,否则会误删别人的锁。

const unlockScript = `
if redis.call("GET", KEYS[1]) == ARGV[1] then
    return redis.call("DEL", KEYS[1])
else
    return 0
end`

func unlock(ctx context.Context, rdb *redis.Client, key, uuid string) error {
    result, err := rdb.Eval(ctx, unlockScript, []string{key}, uuid).Int64()
    if err != nil {
        return err
    }
    if result == 0 {
        return fmt.Errorf("锁已过期或不属于当前持有者")
    }
    return nil
}
sequenceDiagram
    participant G1 as "Goroutine A"
    participant R as "Redis"
    participant G2 as "Goroutine B"
    G1->>R: "SET lock:order NX EX 10 uuid-a"
    R-->>G1: "OK"
    G2->>R: "SET lock:order NX EX 10 uuid-b"
    R-->>G2: "nil, 失败"
    Note over G2: "等待或重试"
    G1->>R: "Eval Lua: GET==uuid-a ? DEL : 0"
    R-->>G1: "1, 解锁成功"
    G2->>R: "SET lock:order NX EX 10 uuid-b"
    R-->>G2: "OK"

[!WARNING] Redlock 争议 Martin Kleppmann 在 How to do distributed locking 中指出单节点 SET NX EX 在主从切换时可能不安全。如果你的业务对锁的安全性要求极高,建议:

  1. 使用 Redlock 多节点方案(go-redis 的 redsync 库)
  2. 或引入 fencing token + 下游服务校验

五、缓存模式实战:Cache-Aside + GORM

5.1 Cache-Aside 流程

flowchart TD
    subgraph "读流程"
        R1["查询缓存"] --> R2{"命中?"}
        R2 -->|"是"| R3["返回缓存数据"]
        R2 -->|"否"| R4["查询 DB"]
        R4 --> R5["回填缓存(SET EX)"]
        R5 --> R3
    end
    subgraph "写流程"
        W1["更新 DB"] --> W2["删除缓存 Key"]
    end

5.2 实现示例

// 读:先缓存,miss 回源 DB
func GetUser(ctx context.Context, rdb *redis.Client, db *gorm.DB, id uint) (*User, error) {
    key := fmt.Sprintf("user:%d", id)

    // 1. 查缓存
    cached, err := rdb.Get(ctx, key).Bytes()
    if err == nil {
        var u User
        json.Unmarshal(cached, &u)
        return &u, nil
    }
    if !errors.Is(err, redis.Nil) {
        return nil, err // Redis 自身出错,降级到 DB
    }

    // 2. 回源 DB
    var user User
    if err := db.First(&user, id).Error; err != nil {
        return nil, err
    }

    // 3. 回填缓存(TTL 随机抖动防雪崩)
    ttl := 30*time.Minute + time.Duration(rand.Intn(300))*time.Second
    data, _ := json.Marshal(user)
    rdb.Set(ctx, key, data, ttl)

    return &user, nil
}

5.3 与 GORM 钩子联动

在写操作后自动删缓存,通过 GORM 的 AfterUpdate / AfterDelete 钩子实现。先用类型安全的 context key 注入 Redis 客户端:

// context key 用自定义类型避免碰撞
type ctxKey struct{}

func WithRedis(ctx context.Context, rdb *redis.Client) context.Context {
    return context.WithValue(ctx, ctxKey{}, rdb)
}

func RedisFromCtx(ctx context.Context) *redis.Client {
    return ctx.Value(ctxKey{}).(*redis.Client)
}

// 中间件注入:将 rdb 放入 Gin 的 context,GORM 通过 db.WithContext 传递
func RedisMiddleware(rdb *redis.Client) gin.HandlerFunc {
    return func(c *gin.Context) {
        c.Set("db", db.WithContext(WithRedis(c.Request.Context(), rdb)))
        c.Next()
    }
}
// 钩子中安全地取出 rdb
func (u *User) AfterUpdate(tx *gorm.DB) error {
    rdb := RedisFromCtx(tx.Statement.Context)
    key := fmt.Sprintf("user:%d", u.ID)
    return rdb.Del(tx.Statement.Context, key).Err()
}

[!WARNING] 别用裸字符串做 context key context.Value("rdb") 这种写法在多包协作时极易碰撞,且缺乏编译期类型检查。始终用自定义 struct 类型作为 key。

[!TIP] 延迟双删 在高并发写场景下,"先更新 DB,再删缓存"仍可能出现短暂不一致。延迟双删的做法是:第一次删缓存 → 更新 DB → sleep 500ms → 第二次删缓存。第二次删除清理了在 sleep 期间被其他请求回填的旧值。

六、Go 项目中 Redis 的典型架构位置

flowchart LR
    Client["客户端请求"] --> Gin["Gin Router"]
    Gin --> Handler["Handler 层"]
    Handler --> Svc["Service 层"]
    Svc --> Cache{"缓存层"}
    Cache -->|"命中"| R["Redis"]
    Cache -->|"未命中"| Repo["Repository 层"]
    Repo --> DB["MySQL / PostgreSQL"]
    DB -->|"回填"| R
    subgraph "Session 共享"
        Gin -->|"gin-contrib/sessions"| R
    end

Redis 在 Go 项目中通常承担三个角色:

  1. 数据缓存:Cache-Aside 模式,减轻数据库压力
  2. Session 存储:gin-contrib/sessions/redis 实现多实例共享会话
  3. 分布式协调:分布式锁、限流器、排行榜等

关联笔记