Go 与 Redis:从给慢接口加缓存开始
以"给变慢的详情页接口加缓存"为主线学 go-redis/v9:连接与 redis.Nil、Cache-Aside、按问题选数据结构、管道、分布式锁与限流、Scan 与生产习惯,附坑点、自检与练习。
真实场景:详情页接口越来越慢
你的服务上线三个月,监控显示商品详情页 P99 从 80ms 涨到 1.2s,数据库 CPU 飙高。看慢查询日志:同一个”查商品”的 SQL 每秒被重复执行几百次——大部分请求读的都是同一批热点商品。经典的解法:在应用与数据库之间加一层 Redis 缓存,热点数据放内存里,数据库只处理缓存没有的请求。
本篇就沿着”加缓存”这件事,把 go-redis/v9 的核心能力全部过一遍。本机实验需要一个 Redis:docker run -d -p 6379:6379 redis:7。
动手第一步:连上并跑通读写
go get github.com/redis/go-redis/v9package main
import (
"context"
"fmt"
"log"
"time"
"github.com/redis/go-redis/v9"
)
func main() {
rdb := redis.NewClient(&redis.Options{
Addr: "localhost:6379",
Password: "", // 没设密码就留空
DB: 0,
ReadTimeout: 3 * time.Second, // 生产必设:Redis 卡住时不拖死调用方
WriteTimeout: 3 * time.Second,
})
ctx := context.Background()
// Ping 是真正的连通性验证(NewClient 本身不建连接)
if err := rdb.Ping(ctx).Err(); err != nil {
log.Fatal("连接 Redis 失败:", err)
}
// 写入:第三个参数是过期时间,0 表示永不过期
if err := rdb.Set(ctx, "name", "小明", 0).Err(); err != nil {
log.Fatal(err)
}
val, err := rdb.Get(ctx, "name").Result()
if err != nil {
log.Fatal(err)
}
fmt.Println("name =", val) // name = 小明
}三个立刻要养成的习惯都埋在上面了:所有命令都吃 context(超时与取消能传导下去);Ping 验证连通(NewClient 只是建配置);读写超时显式设置(默认值为 3s,但显式写出让团队约定可见)。
动手第二步:Cache-Aside 缓存函数
缓存的标准模式叫 Cache-Aside:先查缓存,未命中查数据库并回填:
func GetUser(rdb *redis.Client, id string) (*User, error) {
ctx := context.Background()
cacheKey := "user:" + id
// 1. 先查缓存
val, err := rdb.Get(ctx, cacheKey).Result()
if err == nil {
var user User
if err := json.Unmarshal([]byte(val), &user); err == nil {
return &user, nil
}
} else if err != redis.Nil {
// redis.Nil = 键不存在,走未命中;其他错误才是真故障,记日志但别让缓存故障拖垮接口
log.Printf("cache get: %v", err)
}
// 2. 未命中,查数据库
user, err := db.GetUserByID(id)
if err != nil {
return nil, err
}
// 3. 回填缓存。TTL 加随机量,避免大批键同一时刻过期造成缓存雪崩
data, _ := json.Marshal(user)
ttl := 5*time.Minute + time.Duration(rand.Intn(60))*time.Second
rdb.Set(ctx, cacheKey, data, ttl)
return user, nil
}这段代码里最重要的细节是 err == redis.Nil 的分支:
讲为什么:redis.Nil 为什么不是错误
键不存在时,Get 返回的是哨兵值 redis.Nil 而非普通 error。因为”没查到”在缓存场景里是每日亿万次的正常状态,和”Redis 挂了”必须区别对待:
val, err := rdb.Get(ctx, "key").Result()
switch {
case err == redis.Nil:
// 键不存在 -> 未命中,去查数据库
case err != nil:
// 真正的错误(超时、断连)-> 记日志、降级
default:
// 命中
}如果直接 if err != nil { return err },第一个未命中的请求就会把整个接口打成 500——这是 Redis 新手最常见的事故。
动手第三步:按问题选数据结构
五大数据结构不是并列的知识点,而是五个问题的答案:
问题一:统计今天有多少人访问? 自增计数器,字符串结构:
rdb.Incr(ctx, "page_views") // 原子自增
count, _ := rdb.Get(ctx, "page_views").Int64() // 取回为数字问题二:对象有很多字段,只想改其中之一? 哈希,类似 Go 的 map:
rdb.HSet(ctx, "user:1001", "name", "小明", "age", 25)
name, _ := rdb.HGet(ctx, "user:1001", "name").Result()
fields, _ := rdb.HGetAll(ctx, "user:1001").Result() // map[string]string
rdb.HDel(ctx, "user:1001", "age")问题三:排行榜? 有序集合,每个成员带分数、按分数排序:
rdb.ZAdd(ctx, "leaderboard", redis.Z{Score: 95, Member: "小明"})
rdb.ZAdd(ctx, "leaderboard", redis.Z{Score: 87, Member: "小红"})
top3, _ := rdb.ZRevRangeWithScores(ctx, "leaderboard", 0, 2).Result()
for i, z := range top3 {
fmt.Printf("第%d名: %s (%.0f分)\n", i+1, z.Member, z.Score)
}
rank, _ := rdb.ZRevRank(ctx, "leaderboard", "小明").Result() // 从 0 开始
rdb.ZIncrBy(ctx, "leaderboard", 5, "小红") // 加分问题四:标签去重、找共同好友? 集合,天然不重复,自带交并差:
rdb.SAdd(ctx, "tags:article:1", "Go", "Redis")
common, _ := rdb.SInter(ctx, "tags:article:1", "tags:article:2").Result() // 交集
all, _ := rdb.SUnion(ctx, "tags:article:1", "tags:article:2").Result() // 并集
diff, _ := rdb.SDiff(ctx, "tags:article:1", "tags:article:2").Result() // 差集问题五:轻量任务队列? 列表,两端推弹:
rdb.RPush(ctx, "tasks", "任务1", "任务2") // 右端入队
task, _ := rdb.LPop(ctx, "tasks").Result() // 左端出队
n, _ := rdb.LLen(ctx, "tasks").Result() // 队列长度正式的高吞吐队列见 消息队列;列表队列适合量不大、不想多维护一个中间件的场合。
动手第四步:批量化与原子性
省网络往返用管道。每条命令一次网络往返,一万次 Set 就是一万次 RTT;管道把命令攒起来一次发送:
pipe := rdb.Pipeline()
setCmd := pipe.Set(ctx, "key1", "value1", 0)
getCmd := pipe.Get(ctx, "key2")
incrCmd := pipe.Incr(ctx, "counter")
pipe.Exec(ctx) // 一次性发送
fmt.Println(setCmd.Val(), getCmd.Val(), incrCmd.Val()) // 各自取结果“判断再写入”必须下沉到服务端。分布式锁是标准例子:不能 Get 看一眼再 Set——两步之间别人可能抢先。用 SetNX(不存在才写)加锁,且必须写入随机令牌;释放时用 Lua 脚本”校验令牌再删除”,两步在 Redis 服务端原子完成:
func AcquireLock(rdb *redis.Client, lockKey, token string, ttl time.Duration) (bool, error) {
return rdb.SetNX(ctx, lockKey, token, ttl).Result()
}
var releaseScript = redis.NewScript(`
if redis.call('GET', KEYS[1]) == ARGV[1] then
return redis.call('DEL', KEYS[1])
end
return 0
`)
func ReleaseLock(rdb *redis.Client, lockKey, token string) error {
return releaseScript.Run(ctx, rdb, []string{lockKey}, token).Err()
}
// 使用:token 必须是随机串
token := "lock-" + uuid.NewString()
if locked, _ := AcquireLock(rdb, "lock:order_123", token, 10*time.Second); locked {
defer ReleaseLock(rdb, "lock:order_123", token)
processOrder()
}为什么令牌是必需的:如果只是 Del,你的业务超时、锁过期被 B 抢走后,你回来一删,删掉的是 B 的锁。随机令牌让”释放别人的锁”变成安全失败。生产级锁还有”业务没跑完锁先过期”(看门狗续期)与主从切换丢锁两个坑,强正确性要求高时评估 Redlock 或改用 etcd/ZooKeeper。
限流是同一思想的另一应用,先看滑动窗口的管道版:
func IsRateLimited(rdb *redis.Client, userID string, limit int, window time.Duration) bool {
key := "rate:" + userID
now := time.Now().UnixNano()
pipe := rdb.Pipeline()
pipe.ZRemRangeByScore(ctx, key, "0", fmt.Sprintf("%d", now-window.Nanoseconds())) // 清窗口外
pipe.ZAdd(ctx, key, redis.Z{Score: float64(now), Member: now}) // 记本次
countCmd := pipe.ZCard(ctx, key) // 数窗口内
pipe.Expire(ctx, key, window)
pipe.Exec(ctx)
return countCmd.Val() > int64(limit)
}更简洁的固定窗口用 Lua:INCR 与 EXPIRE 的组合在服务端原子执行,没有”自增了但还没设过期”的中间态:
var rateLimitScript = redis.NewScript(`
local current = redis.call('INCR', KEYS[1])
if current == 1 then
redis.call('EXPIRE', KEYS[1], ARGV[1])
end
if current > tonumber(ARGV[2]) then
return 0
end
return 1
`)
result, _ := rateLimitScript.Run(ctx, rdb, []string{"rate:user:123"}, "60", "100").Int64()
if result == 0 {
fmt.Println("请求过于频繁")
}需要多键乐观锁事务时用 Watch(键在事务前被改则事务失败):
err := rdb.Watch(ctx, func(tx *redis.Tx) error {
val, err := tx.Get(ctx, "counter").Int64()
if err != nil && err != redis.Nil {
return err
}
_, err = tx.TxPipelined(ctx, func(pipe redis.Pipeliner) error {
pipe.Set(ctx, "counter", val+1, 0)
return nil
})
return err
}, "counter")生产习惯四条
一,别用 Keys,用 Scan。 rdb.Keys(ctx, "user:*") 一次扫描全库,数据量大时阻塞整个 Redis。Scan 游标分批迭代,每次只取一小批:
var cursor uint64
for {
var keys []string
keys, cursor, _ = rdb.Scan(ctx, cursor, "user:*", 100).Result()
// 处理 keys ...
if cursor == 0 {
break
}
}二,控制大键。 单键塞进几 MB 的列表或集合,操作它会阻塞单线程的 Redis。大集合拆小键、分页取。
三,序列化选型想清楚。 Redis 只存字符串:复杂对象 JSON 起步,性能敏感时换 msgpack;能存哈希字段的对象优先哈希,省掉整对象反序列化。
四,按容量选客户端形态。 单机用 NewClient;Redis Cluster 用 NewClusterClient(&redis.ClusterOptions{Addrs: [...]}),接口几乎不变。Redis 还能当简单消息通道(Subscribe/Publish),但投递不保证可靠,正经异步任务请用消息队列。
坑点与自检
坑 1:未命中当故障。 redis.Nil 直接 return err,缓存一冷全部请求报错——见前文三分支写法。
坑 2:TTL 全场统一。 大批键同时过期,数据库瞬间被击穿。TTL 加随机抖动;热点 key 用 SetNX 做互斥重建。
坑 3:锁只 SetNX 不带令牌。 过期误删别人的锁,事故复现极难排查。
坑 4:Keys 上生产。 代码评审时见到 Keys( 直接打回,换成 Scan 循环。
自检——能不看文档回答这些吗:
Get的 err 有哪三种情况?各自怎么处理?- Cache-Aside 三步是什么?TTL 为什么要加随机量?
- 释放锁为什么必须用 Lua 而不能先 Get 再 Del?
- Pipeline 和 Lua 各解决什么问题?什么场景必须用后者?
练习
- 给练习项目里的任一数据库查询套上 Cache-Aside 函数,用
redis-cli MONITOR观察:首次请求、命中请求、TTL 过期后请求分别产生哪些命令。 - 用哈希实现一个”用户资料”读写 API,再故意并发更新同一用户的两个字段,思考哈希是否比”整对象 JSON 覆盖写”更安全,结论写进注释。
- 把固定窗口 Lua 限流脚本跑起来,用 20 个并发 goroutine 打一个 limit=10 的接口,统计通过/被拒数量是否符合预期,并解释窗口边界的突刺问题。