364 lines
12 KiB
Markdown
364 lines
12 KiB
Markdown
|
|
---
|
|||
|
|
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 共享
|