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 用途都应定义故障行为:

  • 普通缓存:记录指标并回源数据库。
  • 限流:根据风险决定失败开放还是失败关闭。
  • 会话:通常无法绕过,应返回明确服务错误。
  • 分布式锁:获取状态不明确时不能继续执行临界区。
  • 计数与排行榜:决定允许短暂丢失还是写入持久队列。

不要在请求路径无限重试。重试必须有超时、次数上限和抖动。

监控指标

至少关注:

  • 命令延迟和错误率。
  • 连接池使用量、等待次数和超时。
  • 缓存命中率、回源量和负缓存命中。
  • 键数量、内存、淘汰量和过期量。
  • 热点键和大键。
  • 锁获取失败、持有时间和续期失败。
  • 限流允许与拒绝数量。

命中率需要按业务和键类型拆分,单一全局命中率很难指导优化。

生产检查清单

  1. 所有临时键是否设置 TTL,并加入必要抖动。
  2. 键名是否有命名空间且不包含敏感数据。
  3. 缓存故障时是否有明确降级策略。
  4. 热点键是否防止穿透、击穿和同时过期。
  5. Pipeline 是否仅用于减少往返,而没有被误认为事务。
  6. 分布式锁是否使用唯一令牌和 Lua 安全释放。
  7. 关键业务是否有数据库约束或 Fencing Token 作为最终保护。
  8. 限流键、窗口和失败策略是否符合业务风险。
  9. 是否监控连接池、命中率、延迟、内存和淘汰。

Redis 的正确用法来自明确语义:缓存允许多大不一致、锁保护什么资源、限流在哪个维度生效、故障时是否允许继续。先定义这些边界,再选择数据结构和命令。