Files

6.0 KiB
Raw Permalink Blame History

id, title, type, domain, stack, tags, level, prerequisites, est_hours, related, week, order
id title type domain stack tags level prerequisites est_hours related week order
backend.redis.cache-patterns 缓存模式:旁路缓存、穿透 / 击穿 / 雪崩、一致性 lesson backend
redis
旁路缓存
缓存穿透
缓存击穿
缓存雪崩
一致性
singleflight
intermediate
backend.redis.data-structures
3
backend.redis.lock-ratelimit
backend.redis.interview
3
6
2

缓存模式:旁路缓存、穿透 / 击穿 / 雪崩、一致性

解释说明

旁路缓存(Cache Aside)——最常用:

  • 读:先读缓存,命中直接返回;未命中读数据库,写入缓存(带过期时间)再返回。
  • 写:先更新数据库,再删除缓存(不是更新缓存)。下一次读自然会加载新值。

为什么删除而不是更新缓存:并发写时两个请求更新缓存的顺序可能和更新数据库的顺序不一致,导致缓存里留下旧值;删除更简单,也避免为不常读的数据计算缓存。

三个经典问题

问题 现象 解决
穿透 查询不存在的数据,每次都打到数据库(常见于恶意请求) 缓存空值(短 TTL);参数校验;布隆过滤器
击穿 一个热点 key 过期瞬间,大量请求同时查库 同一个 key 只让一个请求回源(singleflight / 互斥锁);热点 key 逻辑过期
雪崩 大量 key 同时过期或 Redis 宕机,请求全部压到数据库 过期时间加随机值;Redis 高可用;限流降级

一致性:“先更新库再删缓存”仍有极小概率不一致(读请求在删除前读到旧值并在删除后写回)。常见补充:延迟双删(更新后过一会儿再删一次)、订阅 binlog 异步删除缓存、给缓存设较短 TTL 兜底。缓存只能做到最终一致,强一致需求(如余额)不要读缓存。

案例

商品详情页 QPS 高,数据库扛不住:

  • 详情缓存 10 分钟 + 0~60 秒随机值,防止同时过期。
  • 不存在的商品 ID 缓存空值 1 分钟,防穿透。
  • 热门商品过期时用 singleflight 合并回源请求,防击穿。
  • 后台修改商品后删除缓存。

代码示例

旁路缓存 + 空值缓存 + singleflight(golang.org/x/sync/singleflight 合并同一进程内的并发回源):

package main

import (
	"context"
	"encoding/json"
	"errors"
	"fmt"
	"math/rand/v2"
	"os"
	"sync"
	"sync/atomic"
	"time"

	"github.com/redis/go-redis/v9"
	"golang.org/x/sync/singleflight"
)

type Product struct {
	ID    int64  `json:"id"`
	Name  string `json:"name"`
	Price int64  `json:"price"`
}

var ErrNotFound = errors.New("商品不存在")

const nullValue = "null" // 空值标记,防穿透

type ProductCache struct {
	rdb   *redis.Client
	group singleflight.Group
	load  func(ctx context.Context, id int64) (*Product, error) // 查数据库
}

func (c *ProductCache) Get(ctx context.Context, id int64) (*Product, error) {
	key := fmt.Sprintf("product:%d", id)
	s, err := c.rdb.Get(ctx, key).Result()
	if err == nil {
		if s == nullValue {
			return nil, ErrNotFound
		}
		var p Product
		if err := json.Unmarshal([]byte(s), &p); err == nil {
			return &p, nil
		}
	} else if !errors.Is(err, redis.Nil) {
		// Redis 出错时降级直接查库(也可以选择返回错误,看业务)
		return c.load(ctx, id)
	}
	// 未命中:同一个 key 的并发请求只有一个去查库
	v, err, _ := c.group.Do(key, func() (any, error) {
		p, err := c.load(ctx, id)
		if errors.Is(err, ErrNotFound) {
			c.rdb.Set(ctx, key, nullValue, time.Minute)
			return nil, err
		}
		if err != nil {
			return nil, err
		}
		b, _ := json.Marshal(p)
		ttl := 10*time.Minute + time.Duration(rand.IntN(60))*time.Second // 随机过期,防雪崩
		c.rdb.Set(ctx, key, b, ttl)
		return p, nil
	})
	if err != nil {
		return nil, err
	}
	return v.(*Product), nil
}

// Update:先更新数据库,再删除缓存
func (c *ProductCache) Update(ctx context.Context, p *Product, save func(*Product) error) error {
	if err := save(p); err != nil {
		return err
	}
	return c.rdb.Del(ctx, fmt.Sprintf("product:%d", p.ID)).Err()
}

func main() {
	url := os.Getenv("REDIS_URL")
	if url == "" {
		fmt.Println("未设置 REDIS_URL,跳过")
		return
	}
	opt, _ := redis.ParseURL(url)
	rdb := redis.NewClient(opt)
	defer rdb.Close()
	ctx := context.Background()
	rdb.Del(ctx, "product:1", "product:404")

	var dbHits atomic.Int32
	c := &ProductCache{rdb: rdb, load: func(ctx context.Context, id int64) (*Product, error) {
		dbHits.Add(1)
		time.Sleep(50 * time.Millisecond) // 模拟慢查询
		if id == 404 {
			return nil, ErrNotFound
		}
		return &Product{ID: id, Name: "键盘", Price: 19900}, nil
	}}

	var wg sync.WaitGroup
	for range 100 { // 100 个并发请求同一个未缓存的商品
		wg.Add(1)
		go func() { defer wg.Done(); _, _ = c.Get(ctx, 1) }()
	}
	wg.Wait()
	fmt.Println("100 个并发请求,查库次数:", dbHits.Load()) // 1

	_, err := c.Get(ctx, 404)
	_, err2 := c.Get(ctx, 404)
	fmt.Println(err, err2, "查库次数:", dbHits.Load()) // 第二次命中空值缓存,仍是 2
	rdb.Del(ctx, "product:1", "product:404")
}

易错点

  • 先删缓存再更新数据库:删除后、更新前有读请求进来,会把旧值重新写进缓存,不一致窗口更大。
  • 更新数据库后更新缓存:并发写时可能写入旧值;且很多数据写多读少,白白计算。
  • 空值缓存 TTL 太长:数据后来被创建了,用户仍然看到“不存在”。
  • singleflight 只在单个进程内生效:多实例部署时每个实例仍会各回源一次,一般可以接受;需要全局只回源一次时用分布式锁。
  • 缓存序列化格式变了:新版本读不了旧缓存,key 带版本号(如 product:v2:1)。
  • 所有接口都上缓存:缓存带来一致性问题,只给读多写少、能接受短暂旧数据的接口加。

面试常问

见 Redis 面试题:穿透 / 击穿 / 雪崩、缓存和数据库一致性、为什么删缓存而不是更新缓存。