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

364 lines
12 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
---
tags: [Redis, Go, go-redis, 缓存, 后端]
create time: 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 基础连接
```go
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 内置连接池,**不需要**再手动包一层。关键参数如下:
```go
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 模式
```go
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 模式
```go
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, // 自动路由到延迟最低的节点
})
```
```mermaid
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
```go
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
```go
// 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
```go
// 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** 把多条命令打包成一次请求发送,响应也一次性读回,大幅减少网络往返。
```go
// 逐条写入 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
```go
// 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 原子判断 + 删除
解锁必须**先判断锁是否是自己加的**,再删除——这两步必须原子执行,否则会误删别人的锁。
```go
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
}
```
```mermaid
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](https://martin.kleppmann.com/2016/02/08/how-to-do-distributed-locking.html) 中指出单节点 `SET NX EX` 在主从切换时可能不安全。如果你的业务对锁的安全性要求极高,建议:
> 1. 使用 Redlock 多节点方案(go-redis 的 `redsync` 库)
> 2. 或引入 fencing token + 下游服务校验
## 五、缓存模式实战:Cache-Aside + GORM
### 5.1 Cache-Aside 流程
```mermaid
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 实现示例
```go
// 读:先缓存,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 客户端:
```go
// 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()
}
}
```
```go
// 钩子中安全地取出 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 的典型架构位置
```mermaid
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. **分布式协调**:分布式锁、限流器、排行榜等
## 关联笔记
- [[hhs/Redis/README]] — Redis 知识索引入口
- [[hhs/Redis/10-缓存架构模式]] — Cache-Aside / 穿透 / 击穿 / 雪崩完整方案
- [[hhs/Redis/09-高级特性]] — Lua 脚本、Pipeline、Pub/Sub 详解
- [[hhs/Redis/11-运维与性能调优]] — 连接池监控、慢查询、大 Key 治理
- [[hhs/GORM/09-钩子函数]] — GORM 生命周期与缓存同步模式
- [[hhs/EXAM/Week05]] — Docker Compose 中 Redis 容器管理与 Session 共享