Go Redis 实战:缓存、分布式锁与限流
Redis 常用于缓存、计数器、短期状态、分布式协调和限流。它速度很快,但错误的过期策略、无限键空间或不安全的锁实现同样会导致生产事故。
本文使用 github.com/redis/go-redis/v9。
安装与连接
go get github.com/redis/go-redis/v9
import (
"context"
"fmt"
"time"
"github.com/redis/go-redis/v9"
)
func OpenRedis(ctx context.Context, address, password string, database int) (*redis.Client, error) {
client := redis.NewClient(&redis.Options{
Addr: address,
Password: password,
DB: database,
PoolSize: 20,
MinIdleConns: 5,
ConnMaxIdleTime: 5 * time.Minute,
DialTimeout: 3 * time.Second,
ReadTimeout: 2 * time.Second,
WriteTimeout: 2 * time.Second,
})
pingCtx, cancel := context.WithTimeout(ctx, 3*time.Second)
defer cancel()
if err := client.Ping(pingCtx).Err(); err != nil {
client.Close()
return nil, fmt.Errorf("ping Redis: %w", err)
}
return client, nil
}
连接池大小应结合服务副本数和 Redis 最大客户端数设置。不要因为 Redis 快就配置无限并发。
生产环境应启用认证、网络隔离和 TLS,并通过 Secret 注入凭据。
设计可维护的键
键名应包含业务命名空间、环境和稳定标识:
func userKey(id int64) string {
return fmt.Sprintf("app:user:%d", id)
}
建议:
- 使用冒号分隔层级,例如
app:session:<id>。 - 不把邮箱、令牌等敏感信息直接放进键名。
- 预估键数量和生命周期,避免没有 TTL 的临时键。
- Cluster 场景需要多键原子操作时,使用一致的 Hash Tag,例如
order:{123}:items。
Cache-Aside 模式
读取时先查缓存,未命中再查数据库并回填:
type UserCache struct {
redis *redis.Client
store UserStore
ttl time.Duration
}
func (c *UserCache) Find(ctx context.Context, id int64) (User, error) {
key := userKey(id)
raw, err := c.redis.Get(ctx, key).Bytes()
switch {
case err == nil:
var user User
if err := json.Unmarshal(raw, &user); err == nil {
return user, nil
}
_ = c.redis.Del(ctx, key).Err()
case errors.Is(err, redis.Nil):
// cache miss
default:
// 根据业务策略记录错误后降级到数据库
}
user, err := c.store.Find(ctx, id)
if err != nil {
return User{}, err
}
encoded, err := json.Marshal(user)
if err != nil {
return User{}, fmt.Errorf("encode user cache: %w", err)
}
_ = c.redis.Set(ctx, key, encoded, c.expiration()).Err()
return user, nil
}
func (c *UserCache) expiration() time.Duration {
jitter := time.Duration(rand.Int64N(int64(c.ttl / 5)))
return c.ttl + jitter
}
示例中的 rand 来自 Go 1.22 及以上版本的 math/rand/v2。过期时间加入随机抖动,可以降低大量键同时过期造成的流量尖峰。实际代码还应确保基础 TTL 大于零,并在配置加载阶段完成校验。
缓存失败是否影响主请求取决于业务:用户资料读取通常可以降级到数据库;登录会话或幂等键可能不能忽略 Redis 故障。
更新数据时删除还是写缓存
常见策略是先提交数据库事务,再删除缓存:
func (s *UserService) Update(ctx context.Context, user User) error {
if err := s.store.Update(ctx, user); err != nil {
return err
}
if err := s.redis.Del(ctx, userKey(user.ID)).Err(); err != nil {
s.logger.WarnContext(ctx, "delete user cache", "error", err, "user_id", user.ID)
}
return nil
}
删除缓存让后续请求从数据库加载最新值。数据库提交前删除缓存可能出现旧值再次回填。
严格一致性场景不能只依赖简单 Cache-Aside,需要事件通知、版本号、Outbox 或其他一致性方案。
防止缓存穿透
不存在的 ID 被反复请求时,每次都会访问数据库。可以短期缓存“未找到”状态:
const notFoundValue = `{"not_found":true}`
if errors.Is(err, ErrUserNotFound) {
_ = client.Set(ctx, key, notFoundValue, 30*time.Second).Err()
}
负缓存 TTL 应较短,避免资源创建后长时间不可见。还可以在入口校验 ID、使用 Bloom Filter 或限制恶意请求。
防止缓存击穿
热点键失效时,可使用 singleflight 合并同一进程中的重复加载:
type Loader struct {
group singleflight.Group
}
func (l *Loader) Do(key string, load func() (User, error)) (User, error) {
value, err, _ := l.group.Do(key, func() (any, error) {
return load()
})
if err != nil {
return User{}, err
}
return value.(User), nil
}
多实例服务仍可能各自加载一次。特别热点的数据可以使用逻辑过期、后台刷新或分布式协调,但要权衡复杂度和一致性。
Pipeline:减少网络往返
多个彼此独立的命令可以使用 Pipeline:
pipe := client.Pipeline()
nameCmd := pipe.Get(ctx, "profile:1:name")
scoreCmd := pipe.Get(ctx, "profile:1:score")
_, err := pipe.Exec(ctx)
if err != nil && !errors.Is(err, redis.Nil) {
return err
}
name, nameErr := nameCmd.Result()
score, scoreErr := scoreCmd.Int64()
Pipeline 只是批量发送,不保证原子性。需要原子执行多个命令时使用事务、Lua 脚本或单个原子命令。
分布式锁的最低安全要求
使用 SET key value NX PX 获取锁,其中 value 必须是每次请求唯一的随机令牌:
type Lock struct {
client *redis.Client
key string
token string
}
func Acquire(ctx context.Context, client *redis.Client, key string, ttl time.Duration) (*Lock, error) {
token := randomToken()
acquired, err := client.SetNX(ctx, key, token, ttl).Result()
if err != nil {
return nil, fmt.Errorf("acquire lock: %w", err)
}
if !acquired {
return nil, ErrLockBusy
}
return &Lock{client: client, key: key, token: token}, nil
}
释放锁必须比较令牌并删除,不能直接 DEL,否则过期后获得锁的新客户端可能被旧客户端误删:
var releaseScript = redis.NewScript(`
if redis.call("GET", KEYS[1]) == ARGV[1] then
return redis.call("DEL", KEYS[1])
end
return 0
`)
func (l *Lock) Release(ctx context.Context) error {
_, err := releaseScript.Run(ctx, l.client, []string{l.key}, l.token).Result()
if err != nil && !errors.Is(err, redis.Nil) {
return fmt.Errorf("release lock: %w", err)
}
return nil
}
TTL 必须覆盖正常任务时间。任务可能超过 TTL 时需要安全续期,续期同样要校验令牌。
Redis 锁不能自动保证数据库操作严格互斥:网络暂停、主从切换和锁过期都可能出现多个持有者。关键资源应配合数据库唯一约束、版本号或 Fencing Token。资金和库存等高风险场景不能只依赖简单 Redis 锁。
使用 Lua 实现固定窗口限流
var limitScript = redis.NewScript(`
local current = redis.call("INCR", KEYS[1])
if current == 1 then
redis.call("PEXPIRE", KEYS[1], ARGV[1])
end
if current > tonumber(ARGV[2]) then
return 0
end
return 1
`)
func Allow(ctx context.Context, client *redis.Client, key string, window time.Duration, limit int64) (bool, error) {
result, err := limitScript.Run(
ctx,
client,
[]string{"rate:" + key},
window.Milliseconds(),
limit,
).Int64()
if err != nil {
return false, fmt.Errorf("run rate limiter: %w", err)
}
return result == 1, nil
}
Lua 脚本让计数和首次设置过期时间在 Redis 中原子执行。固定窗口边界可能出现瞬时双倍流量,更平滑的策略可使用滑动窗口或令牌桶。
限流键应包含租户、用户或 API Key 等业务维度,并设置 TTL,避免键无限增长。
Pub/Sub 与可靠消息的区别
Redis Pub/Sub 不持久化消息,订阅者离线时会丢失数据,适合在线通知和可丢失事件。
需要消费确认、重放和消费组时,应使用 Redis Streams 或专用消息队列。不要把 Pub/Sub 当作可靠任务队列。
故障策略
每种 Redis 用途都应定义故障行为:
- 普通缓存:记录指标并回源数据库。
- 限流:根据风险决定失败开放还是失败关闭。
- 会话:通常无法绕过,应返回明确服务错误。
- 分布式锁:获取状态不明确时不能继续执行临界区。
- 计数与排行榜:决定允许短暂丢失还是写入持久队列。
不要在请求路径无限重试。重试必须有超时、次数上限和抖动。
监控指标
至少关注:
- 命令延迟和错误率。
- 连接池使用量、等待次数和超时。
- 缓存命中率、回源量和负缓存命中。
- 键数量、内存、淘汰量和过期量。
- 热点键和大键。
- 锁获取失败、持有时间和续期失败。
- 限流允许与拒绝数量。
命中率需要按业务和键类型拆分,单一全局命中率很难指导优化。
生产检查清单
- 所有临时键是否设置 TTL,并加入必要抖动。
- 键名是否有命名空间且不包含敏感数据。
- 缓存故障时是否有明确降级策略。
- 热点键是否防止穿透、击穿和同时过期。
- Pipeline 是否仅用于减少往返,而没有被误认为事务。
- 分布式锁是否使用唯一令牌和 Lua 安全释放。
- 关键业务是否有数据库约束或 Fencing Token 作为最终保护。
- 限流键、窗口和失败策略是否符合业务风险。
- 是否监控连接池、命中率、延迟、内存和淘汰。
Redis 的正确用法来自明确语义:缓存允许多大不一致、锁保护什么资源、限流在哪个维度生效、故障时是否允许继续。先定义这些边界,再选择数据结构和命令。