Redis 操作与缓存策略

Redis 操作与缓存策略

核心概念

Redis 是高性能内存键值存储,在 Web 架构中承担缓存、分布式锁、限流、消息队列等多重角色。Go 生态中主流的 Redis 客户端是 github.com/redis/go-redis/v9

go-redis 基本用法

go 复制代码
import "github.com/redis/go-redis/v9"

rdb := redis.NewClient(&redis.Options{
    Addr:     "localhost:6379",
    Password: "",
    DB:       0,
    PoolSize: 20,
})

// 所有操作都接受 context.Context
ctx := context.Background()
rdb.Set(ctx, "key", "value", 10*time.Minute)
val, err := rdb.Get(ctx, "key").Result()

常用数据结构

Redis 类型 Go 方法 典型场景
String Set/Get/Incr 缓存、计数器、分布式锁
Hash HSet/HGet/HGetAll 对象缓存
List LPush/RPop 消息队列
Set SAdd/SMembers 标签、去重
ZSet ZAdd/ZRange 排行榜

三大经典缓存模式

1. Cache-Aside(旁路缓存)

最常用的缓存模式:读时先查缓存,未命中则查 DB 并回写缓存。

markdown 复制代码
读请求 → 查缓存 → 命中?→ 返回
                   ↓ 未命中
              查数据库 → 写缓存 → 返回
go 复制代码
func GetUser(rdb *redis.Client, db *sql.DB, id int64) (*User, error) {
    ctx := context.Background()
    key := fmt.Sprintf("user:%d", id)

    // 1. 查缓存
    if data, err := rdb.Get(ctx, key).Bytes(); err == nil {
        var user User
        json.Unmarshal(data, &user)
        return &user, nil // 缓存命中
    }

    // 2. 缓存未命中,查数据库
    user, err := getUserFromDB(db, id)
    if err != nil {
        return nil, err
    }

    // 3. 回写缓存(设置过期时间防永久脏数据)
    if data, _ := json.Marshal(user); data != nil {
        rdb.Set(ctx, key, data, 5*time.Minute)
    }
    return user, nil
}

写操作策略:先更新 DB,再删除缓存(而非更新缓存)。

go 复制代码
func UpdateUser(rdb *redis.Client, db *sql.DB, user *User) error {
    // 1. 先更新数据库
    if err := updateUserInDB(db, user); err != nil {
        return err
    }
    // 2. 再删除缓存(下次读时会重新加载)
    rdb.Del(ctx, fmt.Sprintf("user:%d", user.ID))
    return nil
}

为什么是"删除"而不是"更新"缓存?

  • 避免并发写导致缓存与 DB 不一致
  • 避免频繁更新但很少读取的缓存浪费资源
  • 删除是幂等操作,更安全

2. Write-Through(直写)

写请求同时写缓存和 DB,由缓存层代理 DB 操作。应用程序只面对缓存,缓存层负责同步到 DB。一致性最强但写入延迟较高。

3. Write-Behind(异步写回)

写请求只写缓存,异步批量刷新到 DB。写入性能最高,但可能丢数据。

三大缓存问题与对策

缓存穿透

现象:查询一个根本不存在的数据,缓存永远不命中,每次都打到 DB。恶意攻击常见手段。

对策一:缓存空值

go 复制代码
user, err := getUserFromDB(db, id)
if err == sql.ErrNoRows {
    // 缓存空值,短过期时间
    rdb.Set(ctx, key, "", 30*time.Second)
    return nil, err
}

对策二:布隆过滤器------在缓存前加一层布隆过滤器,不存在的 key 直接拦截。

缓存击穿

现象:一个热点 key 过期瞬间,大量并发请求同时打到 DB。

对策:singleflight 或互斥锁------只让一个请求查 DB,其他请求等待。

go 复制代码
import "golang.org/x/sync/singleflight"

var sf singleflight.Group

func GetUserWithSF(rdb *redis.Client, db *sql.DB, id int64) (*User, error) {
    key := fmt.Sprintf("user:%d", id)

    // 先查缓存
    if data, _ := rdb.Get(ctx, key).Bytes(); data != nil {
        return decodeUser(data)
    }

    // singleflight: 相同 key 的并发请求只执行一次
    val, err, _ := sf.Do(key, func() (interface{}, error) {
        user, err := getUserFromDB(db, id)
        if err == nil {
            rdb.Set(ctx, key, encodeUser(user), 5*time.Minute)
        }
        return user, err
    })
    return val.(*User), err
}

缓存雪崩

现象:大量缓存 key 同时过期,DB 瞬间压力暴增。

对策:过期时间加随机抖动

go 复制代码
ttl := 5*time.Minute + time.Duration(rand.Intn(60))*time.Second
rdb.Set(ctx, key, data, ttl)

完整练习代码

以下代码使用 Go 内置的 map + sync.RWMutex 模拟 Redis 行为,无需安装 Redis 服务器即可运行,完整演示 Cache-Aside、缓存穿透/击穿/雪崩对策。

go 复制代码
// redis_cache_strategy.go
package main

import (
	"encoding/json"
	"errors"
	"fmt"
	"math/rand"
	"sync"
	"time"
)

// ============================================
// 模拟 Redis 客户端(内存实现,替代真实 go-redis)
// ============================================

// MockRedis 内存模拟 Redis 客户端
type MockRedis struct {
	mu    sync.RWMutex
	store map[string]mockEntry
}

type mockEntry struct {
	value     []byte
	expiresAt time.Time
}

func NewMockRedis() *MockRedis {
	rdb := &MockRedis{store: make(map[string]mockEntry)}
	// 后台清理过期 key
	go func() {
		for range time.Tick(time.Second) {
			rdb.cleanup()
		}
	}()
	return rdb
}

func (r *MockRedis) Set(key string, val interface{}, ttl time.Duration) {
	data, _ := json.Marshal(val)
	r.mu.Lock()
	r.store[key] = mockEntry{
		value:     data,
		expiresAt: time.Now().Add(ttl),
	}
	r.mu.Unlock()
}

func (r *MockRedis) Get(key string) ([]byte, bool) {
	r.mu.RLock()
	entry, ok := r.store[key]
	r.mu.RUnlock()
	if !ok || time.Now().After(entry.expiresAt) {
		return nil, false
	}
	return entry.value, true
}

func (r *MockRedis) Del(key string) {
	r.mu.Lock()
	delete(r.store, key)
	r.mu.Unlock()
}

func (r *MockRedis) cleanup() {
	r.mu.Lock()
	for k, v := range r.store {
		if time.Now().After(v.expiresAt) {
			delete(r.store, k)
		}
	}
	r.mu.Unlock()
}

// ============================================
// 模拟数据库
// ============================================

type User struct {
	ID   int    `json:"id"`
	Name string `json:"name"`
	Age  int    `json:"age"`
}

// MockDB 模拟数据库
type MockDB struct {
	users map[int]*User
	// 查询计数器(用于验证缓存效果)
	QueryCount int
}

func NewMockDB() *MockDB {
	return &MockDB{
		users: map[int]*User{
			1: {ID: 1, Name: "张三", Age: 28},
			2: {ID: 2, Name: "李四", Age: 32},
			3: {ID: 3, Name: "王五", Age: 25},
		},
	}
}

func (db *MockDB) FindUser(id int) (*User, error) {
	db.QueryCount++
	time.Sleep(10 * time.Millisecond) // 模拟 DB 查询延迟

	user, ok := db.users[id]
	if !ok {
		return nil, errors.New("user not found")
	}
	// 返回副本避免外部修改
	copy := *user
	return &copy, nil
}

// ============================================
// Cache-Aside 模式实现
// ============================================

// CacheAsideService 旁路缓存服务
type CacheAsideService struct {
	rdb *MockRedis
	db  *MockDB
}

func NewCacheAsideService(rdb *MockRedis, db *MockDB) *CacheAsideService {
	return &CacheAsideService{rdb: rdb, db: db}
}

// GetUser 基础 Cache-Aside
func (s *CacheAsideService) GetUser(id int) (*User, error) {
	key := fmt.Sprintf("user:%d", id)

	// 1. 查缓存
	if data, ok := s.rdb.Get(key); ok {
		var user User
		if json.Unmarshal(data, &user) == nil {
			return &user, nil
		}
	}

	// 2. 查 DB
	user, err := s.db.FindUser(id)
	if err != nil {
		// 缓存穿透对策:缓存空值
		if err.Error() == "user not found" {
			s.rdb.Set(key, nil, 30*time.Second)
		}
		return nil, err
	}

	// 3. 回写缓存(雪崩对策:TTL 加随机抖动)
	ttl := 5*time.Minute + time.Duration(rand.Intn(60))*time.Second
	s.rdb.Set(key, user, ttl)
	return user, nil
}

// UpdateUser 更新用户(先更新 DB,再删除缓存)
func (s *CacheAsideService) UpdateUser(user *User) error {
	key := fmt.Sprintf("user:%d", user.ID)

	// 1. 先更新 DB
	s.db.users[user.ID] = user

	// 2. 再删除缓存
	s.rdb.Del(key)
	return nil
}

// ============================================
// 缓存击穿对策:singleflight
// ============================================

type singleflightCall struct {
	wg  sync.WaitGroup
	val interface{}
	err error
}

// SFGroup 极简 singleflight 实现
type SFGroup struct {
	mu     sync.Mutex
	calls  map[string]*singleflightCall
}

func NewSFGroup() *SFGroup {
	return &SFGroup{calls: make(map[string]*singleflightCall)}
}

func (g *SFGroup) Do(key string, fn func() (interface{}, error)) (interface{}, error) {
	g.mu.Lock()
	if call, ok := g.calls[key]; ok {
		g.mu.Unlock()
		call.wg.Wait() // 等待第一个请求完成
		return call.val, call.err
	}

	call := &singleflightCall{}
	call.wg.Add(1)
	g.calls[key] = call
	g.mu.Unlock()

	// 执行实际查询
	call.val, call.err = fn()
	call.wg.Done()

	g.mu.Lock()
	delete(g.calls, key)
	g.mu.Unlock()

	return call.val, call.err
}

// AntiBreakdownService 防缓存击穿服务
type AntiBreakdownService struct {
	rdb *MockRedis
	db  *MockDB
	sf  *SFGroup
}

func NewAntiBreakdownService(rdb *MockRedis, db *MockDB) *AntiBreakdownService {
	return &AntiBreakdownService{rdb: rdb, db: db, sf: NewSFGroup()}
}

func (s *AntiBreakdownService) GetUser(id int) (*User, error) {
	key := fmt.Sprintf("user:%d", id)

	// 1. 先查缓存
	if data, ok := s.rdb.Get(key); ok {
		var user User
		if json.Unmarshal(data, &user) == nil {
			return &user, nil
		}
	}

	// 2. 缓存未命中,用 singleflight 合并并发请求
	val, err := s.sf.Do(key, func() (interface{}, error) {
		// 双重检查:可能在等待期间其他请求已经写入了缓存
		if data, ok := s.rdb.Get(key); ok {
			var user User
			if json.Unmarshal(data, &user) == nil {
				return &user, nil
			}
		}

		user, err := s.db.FindUser(id)
		if err != nil {
			return nil, err
		}
		s.rdb.Set(key, user, 5*time.Minute)
		return user, nil
	})
	if err != nil {
		return nil, err
	}
	return val.(*User), nil
}

// ============================================
// 主函数:演示所有缓存策略
// ============================================

func main() {
	rdb := NewMockRedis()
	db := NewMockDB()

	// ============================================
	// 1. Cache-Aside 基本演示
	// ============================================
	fmt.Println("=== Cache-Aside 模式 ===")
	svc := NewCacheAsideService(rdb, db)

	// 第一次查询(缓存未命中,查 DB)
	user, _ := svc.GetUser(1)
	fmt.Printf("  首次查询 ID=1: %s, DB查询次数: %d\n", user.Name, db.QueryCount)

	// 第二次查询(缓存命中,不查 DB)
	user, _ = svc.GetUser(1)
	fmt.Printf("  二次查询 ID=1: %s, DB查询次数: %d (应不变)\n", user.Name, db.QueryCount)

	// 更新后缓存失效
	svc.UpdateUser(&User{ID: 1, Name: "张三丰", Age: 30})
	user, _ = svc.GetUser(1)
	fmt.Printf("  更新后查询 ID=1: %s, DB查询次数: %d (应+1)\n", user.Name, db.QueryCount)

	// ============================================
	// 2. 缓存穿透演示
	// ============================================
	fmt.Println("\n=== 缓存穿透对策 ===")
	queryCountBefore := db.QueryCount
	// 查不存在的用户
	_, err := svc.GetUser(999)
	fmt.Printf("  查询不存在的 ID=999: %v\n", err)

	// 第二次查同一个不存在的用户(应命中空值缓存)
	_, _ = svc.GetUser(999)
	fmt.Printf("  二次查询 ID=999: DB查询次数增加 %d (应为1,空值缓存生效)\n", db.QueryCount-queryCountBefore)

	// ============================================
	// 3. 缓存击穿对策(singleflight)
	// ============================================
	fmt.Println("\n=== 缓存击穿对策(singleflight) ===")
	rdb2 := NewMockRedis()
	db2 := NewMockDB()
	db2.users[10] = &User{ID: 10, Name: "热点用户", Age: 20}
	antiSvc := NewAntiBreakdownService(rdb2, db2)

	// 手动清除缓存模拟热点 key 过期
	rdb2.Del("user:10")

	// 50 个 goroutine 同时请求同一个 key
	var wg sync.WaitGroup
	startCount := db2.QueryCount
	for i := 0; i < 50; i++ {
		wg.Add(1)
		go func() {
			defer wg.Done()
			antiSvc.GetUser(10)
		}()
	}
	wg.Wait()
	fmt.Printf("  50个并发请求同一key: DB查询次数 %d (应为1,singleflight合并)\n", db2.QueryCount-startCount)

	// ============================================
	// 4. 缓存雪崩对策(TTL 随机抖动)
	// ============================================
	fmt.Println("\n=== 缓存雪崩对策(TTL 随机抖动) ===")
	rdb3 := NewMockRedis()
	db3 := NewMockDB()
	svc3 := NewCacheAsideService(rdb3, db3)

	// 批量加载缓存,每个 key 的 TTL 有随机偏移
	for i := 1; i <= 5; i++ {
		svc3.GetUser(i)
	}
	rdb3.mu.RLock()
	fmt.Println("  各 key 过期时间(应有差异):")
	now := time.Now()
	for k, v := range rdb3.store {
		fmt.Printf("    %s: TTL=%v\n", k, v.expiresAt.Sub(now).Round(time.Second))
	}
	rdb3.mu.RUnlock()

	// ============================================
	// 5. 真实 go-redis 代码参考(不可运行,仅参考)
	// ============================================
	fmt.Println("\n=== 真实 go-redis 代码参考 ===")
	fmt.Println(`
	// 真实 Redis 连接
	rdb := redis.NewClient(&redis.Options{
		Addr:     "localhost:6379",
		Password: "",
		DB:       0,
		PoolSize: 20,
	})

	// String 操作
	rdb.Set(ctx, "key", "value", 10*time.Minute)
	val, err := rdb.Get(ctx, "key").Result()

	// Hash 操作
	rdb.HSet(ctx, "user:1", "name", "张三", "age", 28)
	name, _ := rdb.HGet(ctx, "user:1", "name").Result()

	// 分布式锁(SET NX EX)
	ok, err := rdb.SetNX(ctx, "lock:order:123", "token", 10*time.Second).Result()
	if ok {
		defer rdb.Del(ctx, "lock:order:123")
		// 执行业务逻辑...
	}

	// 计数器(INCR)
	rdb.Incr(ctx, "page:views:home")

	// ZSet 排行榜
	rdb.ZAdd(ctx, "ranking", redis.Z{Score: 100, Member: "张三"})
	rdb.ZRevRangeWithScores(ctx, "ranking", 0, 9)
	`)
}

运行方式

bash 复制代码
mkdir -p demo5 && cd demo5
go mod init demo5
go run main.go  # 无需外部依赖,纯标准库

要点总结

  1. go-redis v9 所有操作接受 context.Context ,支持超时和取消;连接池大小建议设置为 CPU核数 * 10
  2. Cache-Aside 是最常用缓存模式:读时先缓存后 DB,写时先 DB 后删缓存;核心原则是"删除缓存"而非"更新缓存"
  3. 缓存穿透:查不存在的数据 → 缓存空值(短 TTL)或布隆过滤器
  4. 缓存击穿 :热点 key 过期 → singleflight 合并并发请求,只查一次 DB
  5. 缓存雪崩 :大量 key 同时过期 → TTL 加随机抖动(base + rand(0, jitter)
  6. 分布式锁用 SET key value NX EX seconds:原子性的"不存在才设置 + 过期";释放锁要用 Lua 脚本校验 token 防误删
  7. 生产环境 Redis key 命名规范业务:实体:ID(如 user:1001),冒号分隔层级,便于管理和监控
相关推荐
newerp1 小时前
GORM ORM 基础
后端·程序员·go
小岛前端1 小时前
AI Skills 已经封神,但新的问题却越来越严重!
前端·后端·github
拖孩1 小时前
一个人 + AI 做的小程序,上线 15 天赚了 10 块 5
前端·后端·微信小程序
newerp1 小时前
CRUD 操作与预处理语句
后端·程序员·go
wei_shuo2 小时前
KES 数据同步与ETL实战:数据集成、转换与实时同步方案
后端
乒乓狂魔14786739970002 小时前
LangGraph:用一张图,编排一群 AI 助手
后端
Gopher_HBo2 小时前
请求绑定 binding(binding/ 包)
后端
Cache技术分享2 小时前
504. Java 反射 - 创建一个简单的依赖注入框架
前端·后端
Java内核笔记2 小时前
Spring Boot 4 虚拟线程源码剖析:Tomcat 线程池是怎么被换掉的
spring boot·后端