返回文章列表

高并发缓存架构

系统梳理高并发场景下的 Cache Aside、缓存穿透、击穿、雪崩、热点 Key、本地缓存、回源保护、缓存一致性与 Go 实战。

第 18 章:高并发缓存架构

1. 本章定位

前面的章节解决了 Redis 的数据结构、性能、高可用和分片问题。本章进一步回答一个工程问题:

当 Redis 被放在数据库前面承接高并发流量时,怎样避免缓存本身成为一致性漏洞、故障放大器或新的性能瓶颈?

缓存不是简单的 GETSET。一个完整的缓存架构至少要处理:

  • 缓存未命中时谁负责加载数据。
  • 大量请求同时未命中时怎样保护数据库。
  • 数据库更新后怎样处理旧缓存。
  • Redis 故障时是否允许回源。
  • 热点数据怎样分散。
  • 本地缓存与 Redis 怎样协同。
  • 缓存失效、消息丢失和主从切换会产生什么后果。

本章以 Redis Open Source 8.8 为当前技术基线,同时保留 Redis 6、7 中仍广泛使用的经典方案。Redis 8.8 已于 2026 年 5 月列入官方发布记录;本章涉及的 Cache Aside、TTL、空值缓存和请求合并并不依赖 Redis 8,而 Bloom Filter 在 Redis 8 中已经作为集成数据结构提供。(Redis)


2. 学习目标

完成本章后,应能够:

  1. 准确说明 Cache Aside、Read Through、Write Through、Write Behind 的读写路径。
  2. 区分缓存穿透、缓存击穿、缓存雪崩和热 Key。
  3. 使用空值缓存、Bloom Filter、随机 TTL、singleflight、互斥重建和逻辑过期保护数据库。
  4. 使用 Go 和 go-redis/v9 实现完整的 Cache Aside 读路径。
  5. 解释为什么常见写路径是“更新数据库后删除缓存”。
  6. 分析旧值回填、缓存删除失败、CDC 延迟和事件乱序造成的不一致。
  7. 设计本地缓存与 Redis 组成的两级缓存。
  8. 在 Redis 故障、集群切换和流量突增时设计限流、降级与回源保护。

2.1 本章边界与跳转

本章是高并发缓存架构的主章节。Key 命名、TTL 和随机过期的命令规则见[第 3 章](/blog/tech/Redis/03.Key 设计、TTL 与生命周期管理/);Bloom Filter 的结构语义见[第 10 章](/blog/tech/Redis/10.Redis 8 的扩展数据能力/);缓存击穿中使用的互斥重建只作为缓存保护手段,分布式锁、限流、幂等和延迟任务的完整并发控制见第 19 章;热 Key、故障回源和容量压测的观测闭环见第 20 章

3. 核心概念

3.1 缓存是派生数据,不是免费性能

在典型 Cache Aside 架构中,数据库是事实来源,缓存是可以重新生成的派生副本。

一次读取的平均成本可以粗略表示为:

[ T \approx H \times T_{cache} + (1-H) \times (T_{cache}+T_{db}+T_{fill}) ]

其中:

  • H 是命中率。
  • Tcache 是访问 Redis 的时间。
  • Tdb 是数据库查询时间。
  • Tfill 是序列化和回填缓存的时间。

因此,命中率下降不仅会增加平均延迟,还会把流量重新压回数据库。缓存架构的关键目标不是单纯提高命中率,而是:

  1. 控制未命中的并发数量。
  2. 限制回源数据库的最大压力。
  3. 控制缓存数据的陈旧时间。
  4. 保证缓存故障不会直接演化成数据库故障。

Redis 的 INFO stats 提供 keyspace_hitskeyspace_missesexpired_keysevicted_keys 等指标,但全局命中率不能代替按业务、Key 类型和请求结果分类的应用指标。(Redis)

3.2 四种常见缓存模式

模式读路径写路径优点主要代价适用场景
Cache Aside应用先查缓存,未命中再查数据库并回填应用写数据库,再更新或失效缓存灵活、容易渐进式接入一致性和回源保护由应用负责大多数读多写少业务
Read Through应用只访问缓存,由缓存层负责加载数据通常与 Write Through 配合业务代码简单需要支持加载器的缓存框架或中间层统一数据访问框架
Write Through写请求同步写缓存和数据库写入成功后才返回读缓存通常较新写延迟增加,仍有跨系统失败问题读多写少、允许同步写放大
Write Behind先写缓存,再异步写数据库缓存层批量或延迟落库写吞吐高、可合并写入数据丢失、乱序和恢复更复杂可重放的计数、日志或非关键聚合

Cache Aside 是 Redis 最常见的缓存使用方式:应用同时管理缓存和数据库,未命中时按需加载。其优势是只缓存真正访问的数据,但第一次访问存在回源成本,并且应用必须自行处理一致性。(Redis)

普通 Redis 命令本身不会自动访问外部数据库。因此,Read Through、Write Through 和 Write Behind 通常由客户端框架、代理层、数据访问层或专门的缓存产品实现,而不是单个 Redis 命令提供的隐式能力。

3.3 Cache Aside 的标准读写路径

读路径

1. 根据业务 ID 构造缓存 Key。
2. GET Redis。
3. 命中:反序列化并返回。
4. 未命中:查询数据库。
5. 数据存在:写入 Redis,并设置 TTL。
6. 数据不存在:返回不存在,必要时缓存空值。

写路径

1. 在数据库事务中更新数据。
2. 数据库提交成功。
3. 删除对应缓存。
4. 后续读请求未命中,并从数据库加载新值。

通常选择“删除缓存”而不是“直接更新缓存”,原因包括:

  • 一次数据库写入可能影响多个查询维度和多个缓存 Key。
  • 有些缓存值由关联表、聚合结果或权限信息计算得到。
  • 更新一个缓存而遗漏另一个缓存,会形成长期不一致。
  • 删除操作具有较好的幂等性,后续读请求可以自然重建。

但必须明确:

更新数据库和删除 Redis Key 是两个系统上的操作,默认不存在跨系统原子事务。

3.4 缓存穿透

缓存穿透是指请求的数据在缓存和数据库中都不存在,因此每次请求都会回源数据库。

典型来源:

  • 爬虫遍历随机 ID。
  • 攻击者构造不存在的商品、用户或订单号。
  • 参数校验缺失。
  • 已删除对象仍被高频访问。
  • 业务代码没有区分“缓存未命中”和“对象不存在”。

主要解决方法如下。

1. 参数校验和访问控制

在访问缓存前过滤非法 ID、越权请求和明显异常参数。这是成本最低的一层。

2. 空值缓存

数据库确认不存在后,在 Redis 中写入一个特殊标记:

cache:user:999999 -> {"_cache_null":true}
TTL = 20~60 秒

空值 TTL 应明显短于正常数据 TTL,否则新创建的数据可能在一段时间内仍被空值遮挡。

3. Bloom Filter

先判断 ID 是否可能存在:

Bloom 判断一定不存在 -> 直接返回
Bloom 判断可能存在   -> 再查缓存和数据库

Bloom Filter 使用位数组和多个哈希函数,以较小内存换取概率性判断。它可能产生假阳性,即判断“可能存在”但实际不存在;在正确维护且没有漏加元素的前提下,不会把已经加入的元素判断为不存在。Redis 的 BF.RESERVE 可以按预期容量和假阳性率预留空间。(Redis)

Bloom Filter 不能代替空值缓存:

  • Bloom Filter 适合过滤海量随机不存在请求。
  • 空值缓存适合吸收实际不存在但被重复访问的少量热点 Key。
  • Bloom Filter 本身也需要和数据库的新增、删除流程保持同步。
  • 标准 Bloom Filter 不适合直接删除元素;需要删除能力时可考虑 Cuckoo Filter。

3.5 缓存击穿

缓存击穿是指某个高频热点 Key 失效后,大量并发请求同时回源数据库。

例如一个明星商品每秒有 20,000 次读取,缓存刚好在某一时刻过期。即使数据库单次查询只需 5 毫秒,也可能在一个很短的时间窗口内积累大量并发查询。

解决方法一:singleflight 请求合并

对于相同 Key,一个进程内只允许一个 goroutine 执行数据库加载,其他请求等待并共享结果。

golang.org/x/sync/singleflightGroup.DoDoChan 可以保证同一个 Key 同一时刻只有一个函数执行,其余调用者复用结果。(Go Packages)

限制是:

  • 只在单个 Go 进程内生效。
  • 部署 100 个实例时,最坏仍可能有 100 次数据库查询。
  • 首个执行者很慢时,等待者会同时受影响。
  • 错误结果也可能被一批等待者共享。
  • 必须处理调用方取消和共享加载上下文之间的关系。

解决方法二:分布式互斥重建

多个实例竞争一个 Redis 锁,只有持锁者查询数据库并重建缓存。

优点是跨实例抑制回源;缺点是引入锁超时、续期、错误释放和主从切换问题。安全解锁和 Fencing Token 将在第 19 章展开。

解决方法三:逻辑过期

缓存值中保存逻辑失效时间:

{
  "data": {"id": 42, "name": "phone"},
  "expire_at": 1782000000000
}

Redis Key 的物理 TTL 设置得较长,甚至由主动失效控制。读取到逻辑过期的数据时:

  1. 当前请求仍返回旧值。
  2. 一个后台任务尝试重建。
  3. 其他请求继续读取旧值。
  4. 新值写入后恢复正常。

这是典型的 Stale While Revalidate 思路。

优点是热点 Key 不会突然消失;代价是业务必须接受一段时间的旧数据,并处理后台任务失败、并发重建和永久陈旧问题。

3.6 缓存雪崩

缓存雪崩是指大量 Key 同时失效,或者整个缓存服务不可用,导致大范围请求同时回源。

它与击穿的区别是:

  • 击穿通常集中在一个或少量热点 Key。
  • 雪崩影响大量 Key、整个业务域或整个 Redis 集群。

常见原因:

  • 批量预热时给所有 Key 设置完全相同的 TTL。
  • Redis 集群故障或网络隔离。
  • 发布新版本后 Key 前缀整体变化。
  • 淘汰策略导致大量常用数据被驱逐。
  • 数据迁移或扩容后新节点为空。
  • 客户端超时配置不合理,形成重试风暴。

保护措施应组合使用:

  1. 在基础 TTL 上增加随机抖动。
  2. 热点数据分批预热。
  3. 回源数据库设置并发上限。
  4. 对非核心请求进行限流和快速失败。
  5. 准备本地缓存、默认值或静态快照。
  6. 对 Redis 错误采用有界重试,禁止无限重试。
  7. 确保数据库拥有一定的降级容量,但不要假设其能承受全部缓存流量。

3.7 热 Key

热 Key是访问流量明显集中于少量 Key。即使缓存一直命中,也可能产生:

  • 单个 Redis 分片网络带宽过高。
  • Redis 主线程处理该 Key 的命令过多。
  • 单个客户端连接池拥塞。
  • 热 Key 对应的大 Value 占用大量出站带宽。
  • Cluster 中出现热 Slot。

常用方案:

本地缓存

将最热、可短暂陈旧的数据放入应用进程内,读取不再经过网络。

读副本

对允许旧读的数据使用副本分散读取,但必须评估复制延迟和故障切换后的客户端行为。

Key 拆分

将只读热点值复制成多个 Key:

product:42:copy:0
product:42:copy:1
...
product:42:copy:15

客户端按随机数或请求哈希选择副本。

这种方式会增加:

  • 内存占用。
  • 更新和失效成本。
  • 多副本不一致窗口。
  • 监控与排障复杂度。

它适合低频更新、读流量极高的数据,不适合高频修改的计数器或库存。

3.8 本地缓存与 Redis 两级缓存

典型结构为:

请求

L1:进程内缓存
  ↓ 未命中
L2:Redis
  ↓ 未命中
数据库

优点

  • L1 读取不经过网络,延迟最低。
  • 可以显著降低 Redis 热 Key 压力。
  • Redis 短时故障时,可以继续提供有限的旧数据。

问题

  • 每个实例都有独立副本。
  • 实例越多,重复内存越多。
  • 数据更新后必须失效所有实例的 L1。
  • 新实例会形成冷启动流量。
  • 失效消息丢失后可能长期返回旧值。

Redis 从 6.0 开始提供 CLIENT TRACKING,可以记录客户端读取过的 Key,并在 Key 被修改时发送失效通知。RESP3 可以在同一连接接收通知,RESP2 可重定向到其他连接。该机制能辅助客户端缓存,但应用仍需处理断线、重连、失效风暴和客户端库支持边界。(Redis)

也可以使用 Pub/Sub 广播 L1 失效消息,但 Redis Pub/Sub 是至多一次投递;订阅端断线期间的消息不会重发,因此不能把它当作可靠的一致性日志。(Redis)

生产方案通常还需要:

  • L1 设置较短 TTL。
  • 失效消息携带数据版本。
  • 重连后清空相关 L1,或执行版本校验。
  • 关键数据不依赖 L1 提供强一致读。
  • 限制每个实例的 L1 内存和最大对象大小。

3.9 缓存与数据库一致性

方案一:先删除缓存,再更新数据库

可能出现以下时序:

写请求 W:删除缓存
读请求 R:缓存未命中
读请求 R:从数据库读取旧值 V1
读请求 R:把 V1 写入缓存
写请求 W:数据库更新为 V2

最终数据库是 V2,缓存却是 V1,旧值可能持续到 TTL 到期。

方案二:先更新数据库,再删除缓存

常见时序为:

写请求 W:数据库更新为 V2
写请求 W:删除缓存
读请求 R:缓存未命中
读请求 R:读取数据库 V2
读请求 R:回填 V2

该顺序通常更合理,但仍有两个问题。

问题一:缓存删除失败

数据库已经是 V2,缓存中的 V1 未被删除。

治理方式包括:

  • 短 TTL 作为最终收敛机制。
  • 删除失败进入可靠重试队列。
  • 事务 Outbox。
  • Binlog/CDC 异步失效。
  • 对高一致性数据绕过缓存。

问题二:旧值晚到回填

R:缓存未命中
R:读取数据库旧值 V1
W:数据库更新为 V2
W:删除缓存
R:把稍早读取的 V1 回填到缓存

即使顺序是“更新数据库后删除缓存”,仍可能因为读请求跨越写事务而发生旧值回填。

常见改进包括:

  1. 缓存值携带数据库版本号。
  2. 使用 Lua 只允许更高版本覆盖缓存。
  3. 更新事件再次删除缓存。
  4. 对单 Key 的读写进行串行化。
  5. 缩短 TTL,将错误限制在可接受窗口。
  6. 对强一致请求直接读取主数据库。

延迟双删

典型流程:

删除缓存
更新数据库
等待一段时间
再次删除缓存

第二次删除试图清理并发读回填的旧值。

它只能降低概率,不能提供严格保证,因为:

  • 延迟时间很难准确选择。
  • 慢查询可能超过等待时间。
  • 第二次删除任务也可能失败。
  • 会增加缓存写流量。
  • 实例暂停、网络抖动可能改变原有时序。

生产环境不应把“睡眠几百毫秒后再删一次”描述成严格一致性方案。

Binlog/CDC 异步失效

数据库提交后,由 CDC 消费者根据变更日志删除或更新缓存。

优点:

  • 所有数据库写入方可以统一触发失效。
  • 应用不需要直接执行跨系统双写。
  • 消费失败可以重试和重放。

需要处理:

  • 消费延迟。
  • 重复事件。
  • 分区内外的顺序。
  • 消费位点持久化。
  • Schema 变更。
  • 数据库版本和缓存版本比较。
  • 全量恢复与增量追平。

仅发送“删除 Key”时,乱序通常主要造成额外未命中;若 CDC 直接把值写入缓存,旧事件晚到就可能覆盖新值,因此应携带单调递增版本并拒绝版本倒退。


4. 命令与 Go 使用方法

4.1 必要的 redis-cli 命令

普通缓存与 TTL

SET cache:user:42 '{"id":42,"name":"Alice","version":7}' EX 300
GET cache:user:42
TTL cache:user:42

SET 可以原子地同时设置值和过期时间。覆盖已有 Key 时需要注意 TTL 是否被重置;普通 SET 默认会清除旧 TTL,除非显式使用 KEEPTTL。(Redis)

缓存失效

DEL cache:user:42
UNLINK cache:user:42

UNLINK 会先从 Key 空间移除 Key,再由后台线程回收对象内存。对于较大的缓存对象,它可以降低同步释放造成的阻塞;每个 Key 的摘除为 O(1),实际内存释放工作在后台完成。(Redis)

空值缓存

SET cache:user:999999 '{"_cache_null":true}' EX 30

必须使用业务不可能产生的专用编码,不能用空字符串混淆“对象不存在”和“对象字段为空”。

Bloom Filter

Redis 8 中可以直接使用集成的 Bloom 命令;Redis 6、7 环境需要确认是否部署了 RedisBloom 或 Redis Stack。(Redis)

BF.RESERVE valid:user:id 0.001 1000000
BF.ADD valid:user:id 42
BF.EXISTS valid:user:id 42
BF.MEXISTS valid:user:id 42 43 999999

这里:

  • 0.001 表示目标假阳性率为 0.1%。
  • 1000000 是预计容量。
  • 容量估计过小会导致创建额外子过滤器,增加内存和查询 CPU 成本。(Redis)

4.2 Go:Cache Aside、空值缓存与 singleflight

下面的示例实现:

  • 正常 Cache Aside。
  • 正确区分 redis.Nil 和 Redis 故障。
  • 空值缓存。
  • TTL 抖动。
  • 进程内 singleflight 防击穿。
  • singleflight 内二次检查缓存。
  • 调用方取消。
  • 数据库更新后失效缓存。
package cache

import (
	"context"
	"crypto/rand"
	"encoding/json"
	"errors"
	"fmt"
	"math/big"
	"time"

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

var ErrNotFound = errors.New("user not found")

const nullMarker = `{"_cache_null":true}`

type User struct {
	ID      int64  `json:"id"`
	Name    string `json:"name"`
	Version int64  `json:"version"`
}

type UserStore interface {
	FindByID(ctx context.Context, id int64) (User, error)
	Update(ctx context.Context, user User) error
}

type cacheState uint8

const (
	cacheMiss cacheState = iota
	cacheValue
	cacheNegative
)

type UserCache struct {
	rdb   *redis.Client
	store UserStore
	group singleflight.Group

	valueTTL     time.Duration
	nullTTL      time.Duration
	ttlJitter    time.Duration
	redisTimeout time.Duration
	loadTimeout  time.Duration
}

func userKey(id int64) string {
	return fmt.Sprintf("cache:user:%d", id)
}

func (c *UserCache) GetUser(
	ctx context.Context,
	id int64,
) (User, error) {
	if id <= 0 {
		return User{}, ErrNotFound
	}

	key := userKey(id)

	// 第一次读取缓存。
	if user, state, err := c.readCache(ctx, key); err == nil {
		switch state {
		case cacheValue:
			return user, nil
		case cacheNegative:
			return User{}, ErrNotFound
		}
	}
	// Redis 故障时降级查询数据库,但生产中必须记录指标。

	resultCh := c.group.DoChan(key, func() (any, error) {
		// Go 1.21+。共享加载不由某个等待者的取消直接终止,
		// 但后面仍通过 loadTimeout 设置明确上限。
		base := context.WithoutCancel(ctx)

		// 二次检查:进入 singleflight 前后可能已有其他请求回填。
		if user, state, err := c.readCache(base, key); err == nil {
			switch state {
			case cacheValue:
				return user, nil
			case cacheNegative:
				return User{}, ErrNotFound
			}
		}

		loadCtx, cancel := context.WithTimeout(
			base,
			c.loadTimeout,
		)
		defer cancel()

		user, err := c.store.FindByID(loadCtx, id)
		if errors.Is(err, ErrNotFound) {
			// 不存在结果使用较短 TTL。
			_ = c.writeCache(
				loadCtx,
				key,
				[]byte(nullMarker),
				c.nullTTL,
			)
			return User{}, ErrNotFound
		}
		if err != nil {
			return User{}, fmt.Errorf(
				"load user %d: %w",
				id,
				err,
			)
		}

		payload, err := json.Marshal(user)
		if err != nil {
			return User{}, fmt.Errorf(
				"marshal user %d: %w",
				id,
				err,
			)
		}

		// 缓存回填失败不改变数据库查询结果。
		// 生产中应记录 cache_fill_error。
		_ = c.writeCache(loadCtx, key, payload, c.valueTTL)
		return user, nil
	})

	select {
	case <-ctx.Done():
		// 当前调用方停止等待,但共享加载可能继续到 loadTimeout。
		return User{}, ctx.Err()

	case result := <-resultCh:
		if result.Err != nil {
			return User{}, result.Err
		}

		user, ok := result.Val.(User)
		if !ok {
			return User{}, errors.New(
				"unexpected singleflight result type",
			)
		}
		return user, nil
	}
}

func (c *UserCache) readCache(
	parent context.Context,
	key string,
) (User, cacheState, error) {
	ctx, cancel := context.WithTimeout(
		parent,
		c.redisTimeout,
	)
	defer cancel()

	payload, err := c.rdb.Get(ctx, key).Bytes()
	if errors.Is(err, redis.Nil) {
		return User{}, cacheMiss, nil
	}
	if err != nil {
		return User{}, cacheMiss, fmt.Errorf(
			"redis get %s: %w",
			key,
			err,
		)
	}

	if string(payload) == nullMarker {
		return User{}, cacheNegative, nil
	}

	var user User
	if err := json.Unmarshal(payload, &user); err != nil {
		// 损坏的缓存值不能持续污染命中路径。
		_ = c.rdb.Unlink(ctx, key).Err()
		return User{}, cacheMiss, fmt.Errorf(
			"decode cache %s: %w",
			key,
			err,
		)
	}

	return user, cacheValue, nil
}

func (c *UserCache) writeCache(
	parent context.Context,
	key string,
	payload []byte,
	baseTTL time.Duration,
) error {
	ctx, cancel := context.WithTimeout(
		parent,
		c.redisTimeout,
	)
	defer cancel()

	ttl := ttlWithJitter(baseTTL, c.ttlJitter)
	return c.rdb.Set(ctx, key, payload, ttl).Err()
}

func ttlWithJitter(
	base time.Duration,
	spread time.Duration,
) time.Duration {
	if spread <= 0 {
		return base
	}

	n, err := rand.Int(
		rand.Reader,
		big.NewInt(int64(spread)+1),
	)
	if err != nil {
		return base
	}

	return base + time.Duration(n.Int64())
}

func (c *UserCache) UpdateUser(
	ctx context.Context,
	user User,
) error {
	dbCtx, cancelDB := context.WithTimeout(
		ctx,
		c.loadTimeout,
	)
	defer cancelDB()

	if err := c.store.Update(dbCtx, user); err != nil {
		return err
	}

	cacheCtx, cancelCache := context.WithTimeout(
		context.WithoutCancel(ctx),
		c.redisTimeout,
	)
	defer cancelCache()

	if err := c.rdb.Unlink(
		cacheCtx,
		userKey(user.ID),
	).Err(); err != nil {
		// 数据库已经提交。调用方不能把这个错误解释成
		// “整个写操作没有发生”并盲目重试。
		return fmt.Errorf(
			"database committed but cache invalidation failed: %w",
			err,
		)
	}

	return nil
}

4.3 代码关键点

redis.Nil 不是真正的 Redis 故障

redis.Nil 表示 Key 不存在,应进入回源流程;网络错误、超时和服务端错误则应记录并按照降级策略处理。官方 go-redis 文档也明确区分了 redis.Nil、超时、网络错误和 Redis 响应错误。(Redis)

缓存异常时是否回源必须有限制

示例选择降级查询数据库,但生产系统还应增加:

  • 数据库回源信号量。
  • 按接口限流。
  • 熔断。
  • 最大排队时间。
  • 非核心请求快速失败。
  • Redis 故障期间的重试抑制。

否则“缓存失败就查数据库”会直接造成雪崩。

singleflight 内必须二次检查

第一次发现未命中到进入共享加载之间,另一个请求可能已经完成回填。二次检查可以避免不必要的数据库查询。

共享加载不能无界运行

使用 DoChan 可以让当前请求通过 select 响应取消,但底层共享加载不会自动因为某个等待者取消而停止,因此必须设置独立的 loadTimeout

并发安全性

  • redis.Client 表示一个连接池,可被多个 goroutine 并发使用。(GitHub)
  • singleflight.Group 的零值可以直接使用。
  • 示例的 TTL 随机数生成没有共享的可变 rand.Rand
  • 不要在多个 goroutine 间共享同一个未加保护的 Pipeline;go-redis 的 Pipeline 本身不是并发安全的。(GitHub)

数据库提交后的失效失败是“部分成功”

UpdateUser 返回失效错误时,数据库已经成功更新。调用方不能直接把整个请求重新执行一遍,否则非幂等数据库操作可能重复发生。

更完整的方案是:

数据库事务:
  1. 更新业务表。
  2. 写入 Outbox 失效事件。
  3. 一起提交。

异步消费者:
  1. 读取 Outbox。
  2. 删除缓存。
  3. 成功后确认事件。

5. 典型业务场景

场景适用方式不适用情况数据量要求一致性要求性能风险可替代方案
商品详情Cache Aside、随机 TTL、热点预热价格必须毫秒级强一致单对象应较小通常允许秒级旧值大促热 Key、集中失效搜索索引、读库
用户资料Cache Aside、写后失效权限变更立即生效百万到亿级 Key 均可分片普通字段可最终一致空值穿透、旧值回填直接读数据库、版本读
配置和字典L1 + Redis、推送失效不能接受任何旧配置热数据集应能装入 L1通常要求快速收敛失效消息丢失配置中心、版本轮询
报表和聚合结果逻辑过期、异步刷新必须实时精确单结果不宜过大可接受较长旧值窗口重建成本高、任务堆积OLAP、预计算表
随机不存在 IDBloom + 空值缓存数据集合无法维护Bloom 容量需提前估算假阳性可接受过滤器漏加导致误拒绝参数校验、限流
库存展示短 TTL、版本号最终扣减只依赖缓存缓存展示值可较小展示可旧,扣减不可旧超卖、旧库存回填数据库锁、库存服务
账户余额通常不作为权威数据缓存所有资金写入与结算与缓存容量无关强一致或可审计旧值导致错误决策账本数据库、只缓存摘要

缓存是否适用,首先取决于业务能否定义明确的“最大陈旧时间”。无法定义陈旧边界的数据,不应仅靠 TTL 和失效通知保证正确性。


6. 底层实现

6.1 Redis 查找与网络成本

对于 String Key,Redis GET 的命令复杂度为 O(1)。但一次缓存读取的总成本还包括:

  • Key 编码和哈希。
  • 网络往返。
  • RESP 编解码。
  • Value 复制。
  • JSON 或其他格式反序列化。
  • 连接池等待。
  • Cluster 路由和可能的重定向。

因此,Redis 命令本身是 O(1),并不代表读取一个 5 MB JSON 对象也是低成本操作。GET 的网络和序列化成本近似与 Value 大小成正比。(Redis)

6.2 TTL 的实现影响

带 TTL 的 Key 除了存储值,还需要存储过期信息。Redis 通过访问时检查和后台主动过期共同清理 Key。

TTL 的作用是限制陈旧数据和内存占用,但它不是精确到时触发的业务调度器。大量 Key 在相同时间达到 TTL,仍可能在业务层造成集中未命中。

6.3 singleflight 的结构与成本

可以把 singleflight.Group 理解为一个受锁保护的“正在执行调用表”:

cache key -> 当前加载任务 + 等待者

对于一个 Key:

  • 第一个请求创建任务。
  • 后续请求增加等待关系。
  • 任务完成后广播结果并删除表项。

时间复杂度通常是哈希表平均 O(1),空间成本约为:

[ O(\text{正在加载的不同 Key 数量}+\text{等待者数量}) ]

若大量不同 Key 同时未命中,singleflight 不能减少总数据库查询数,只能合并相同 Key。因此仍需全局回源并发限制。

6.4 Bloom Filter 算法

Bloom Filter 使用:

  • 长度为 m 的位数组。
  • k 个哈希映射。
  • n 个预计元素。

每次加入元素时设置 k 个位置;查询时检查这些位置是否都为 1。

在目标假阳性率为 p 时,近似需要:

[ m \approx -\frac{n\ln p}{(\ln 2)^2} ]

官方 BF.RESERVE 文档给出的例子中,1% 假阳性率约需要每个元素 9.585 bit,0.1% 约需要 14.378 bit。(Redis)

空间成本远低于保存所有 ID 的普通 Set,但代价是:

  • 不能返回原始元素。
  • 存在假阳性。
  • 初始容量估计错误会影响性能。
  • 删除和数据同步更复杂。

6.5 逻辑过期算法

逻辑过期的正常读取仍是 O(1):

GET -> 反序列化 -> 比较 expire_at

当逻辑过期时,额外执行一次互斥竞争或异步任务投递。

其本质是将:

“缓存未命中导致请求等待”

转换为:

“请求读取旧值,后台刷新”

因此它不是免费提高可用性,而是明确用一致性换取延迟稳定性。

6.6 本地缓存的空间成本

假设:

  • 每个实例保存 S 字节热点数据。
  • 应用有 M 个实例。

总重复内存约为:

[ M \times S ]

L1 缓存减少 Redis 网络流量,却会把一份共享数据复制到所有实例。实例扩容还会同时带来冷启动和内存增长。

6.7 版本差异

  • Redis 6.0+:支持 CLIENT TRACKING 服务端辅助客户端缓存。(Redis)
  • Redis 7.x:Cache Aside、TTL、空值缓存等经典方案没有本质改变。
  • Redis 8.0+:Bloom、Cuckoo 等概率数据结构集成到 Redis Open Source 二进制发行版;Redis 6、7 通常需要单独模块或 Redis Stack。(Redis)
  • Redis 8.6+:增加 LRM 等淘汰策略,但跨版本系统不能假设 Redis 6、7 也支持这些策略。(Redis)

7. 高性能、高并发、高可用分析

7.1 高性能

CPU

主要 CPU 消耗可能来自:

  • JSON 序列化和反序列化。
  • 大 Value 复制。
  • Bloom Filter 多次哈希。
  • 压缩和解压缩。
  • 热 Key 高频命令。
  • 大量过期或淘汰处理。

小对象并不一定适合逐个请求 Redis。批量场景应考虑 MGET 或 Pipeline,但必须限制批次大小,避免单批响应过大。

内存

应同时考虑:

  • Key、Value 和 Redis 对象开销。
  • TTL 元数据。
  • 空值缓存数量。
  • Bloom Filter 容量。
  • L1 重复内存。
  • 主从复制和持久化缓冲区。
  • 内存碎片。

网络

Redis 命中仍然需要网络往返。热点小对象适合 L1;大对象应拆分、压缩或重新设计数据模型,而不是仅增加 Redis 机器。

磁盘

以 Redis 为纯缓存时,可以根据恢复方式评估是否需要持久化。但数据库回源能力不足时,完全无持久化的 Redis 重启可能造成严重冷缓存雪崩。

命令与批处理

  • 单 Key GET 为 O(1),但网络 RTT 仍存在。
  • Pipeline 减少 RTT,不改变命令总工作量。
  • 大批量操作会扩大单次延迟和响应缓冲区。
  • Cluster 多 Key 操作必须考虑 Slot 分布。

7.2 高并发

高并发缓存架构要控制三种竞争:

同 Key 竞争

使用:

  • singleflight
  • 分布式互斥重建。
  • 逻辑过期。
  • 主动预热。

不同 Key 同时未命中

singleflight 无法解决。需要:

  • 数据库查询信号量。
  • 全局回源 QPS 限制。
  • 请求优先级。
  • 熔断和负载丢弃。
  • 批量加载。

重试竞争

缓存超时后,所有客户端同时重试可能形成二次流量峰值。应使用:

  • 最大重试次数。
  • 指数退避。
  • 随机抖动。
  • 总请求 deadline。
  • 只对幂等操作重试。
  • 熔断状态下禁止无意义重试。

7.3 高可用

缓存高可用不等于业务高可用。

Redis 故障后,业务可能有三种策略:

策略行为风险
Fail Open绕过缓存,直接查数据库数据库被压垮
Fail Closed缓存失败即返回错误可用性下降
Graceful Degradation返回 L1 旧值、默认值或简化结果数据陈旧或功能受限

合理方案通常按接口分级:

  • 登录鉴权等关键接口可能快速失败。
  • 商品详情可返回有限旧值。
  • 推荐和统计接口可返回空结果。
  • 资金和库存写入不能因缓存故障绕过权威校验。

主从切换后,缓存中的近期写入或失效操作可能丢失;客户端还可能经历连接重建和超时。因此应设置明确的连接、读写和命令超时,并监控连接池等待、重试次数和降级回源量。go-redis 官方生产指南同样建议为连接和命令配置有界超时。(Redis)


8. 常见错误与生产事故

事故一:批量 Key 在整点同时过期

  • 现象: 每天 10:00 数据库 CPU 突然升至 100%,Redis 命中率快速下降。
  • 根因: 夜间预热给全部商品设置了完全相同的 8 小时 TTL。
  • 排查方法: 对比 keyspace_misses、数据库 QPS、缓存写入时间和 Key 的 TTL 分布。
  • 修复方案: 基础 TTL 增加随机抖动,预热任务分批执行,并限制回源并发。
  • 如何预防: 上线前检查 TTL 分布,压测批量过期场景。

事故二:明星商品过期击穿数据库

  • 现象: Redis 本身正常,但某个商品接口超时,数据库出现大量相同 SQL。
  • 根因: 热点 Key 到期后,数百个应用实例同时回源。
  • 排查方法: 按业务 ID 聚合缓存未命中和 SQL 参数。
  • 修复方案: 进程内 singleflight、跨实例互斥重建或逻辑过期。
  • 如何预防: 识别 Top Key,提前续期或预热,并为热点加载设置独立监控。

事故三:随机 ID 攻击造成缓存穿透

  • 现象: Redis 命中率下降,数据库收到大量不存在 ID 查询。
  • 根因: 没有参数校验、空值缓存和速率限制。
  • 排查方法: 统计不存在结果比例、ID 分布、来源 IP 和用户身份。
  • 修复方案: 参数校验、鉴权、限流、Bloom Filter 和短 TTL 空值缓存。
  • 如何预防: 将“不存在查询率”作为安全和容量指标。

事故四:数据库已更新,但缓存删除超时

  • 现象: 部分用户持续看到旧资料。
  • 根因: 数据库提交成功后 Redis 网络超时,应用只记录日志,没有重试机制。
  • 排查方法: 对比数据库版本、缓存版本和失效错误日志。
  • 修复方案: Outbox/CDC 异步失效、失败重试、缓存携带版本、缩短 TTL。
  • 如何预防: 明确监控“数据库成功但缓存失效失败”的部分成功状态。

事故五:误以为 singleflight 能覆盖所有实例

  • 现象: 应用扩容后,数据库回源量随实例数线性增长。
  • 根因: singleflight 只能合并单进程内请求。
  • 排查方法: 对比每个实例的加载次数和数据库同 Key 查询总量。
  • 修复方案: 增加分布式互斥、逻辑过期或统一缓存加载服务。
  • 如何预防: 架构评审中明确并发控制的作用域。

事故六:L1 失效消息丢失

  • 现象: 只有部分实例持续返回旧数据,Redis 中已经是新值。
  • 根因: 实例与 Pub/Sub 断线期间错过失效事件,L1 又没有最大 TTL。
  • 排查方法: 对比不同实例响应、L1 版本、订阅重连日志和 Redis 值。
  • 修复方案: L1 设置有限 TTL,重连后清理缓存,事件携带版本。
  • 如何预防: 不把 Pub/Sub 当作可靠日志,定期校验或使用可重放事件流。

9. 方案选型与权衡

方案一致性抗击穿能力读延迟实现复杂度主要风险
Cache Aside + TTL最终一致热 Key 失效、删除失败
空值缓存最终一致防穿透新数据被短暂遮挡
Bloom Filter依赖同步质量防大规模穿透很低假阳性、漏加、容量估计
进程内 singleflight不改变一致性单实例较强命中低,等待时增加多实例仍回源
分布式互斥重建不改变一致性跨实例较强等待锁时增加中高锁失效、超时、故障切换
逻辑过期主动接受旧值很强稳定中高数据长期陈旧、后台任务失败
L1 + Redis最终一致降低 Redis 热点最低多实例失效和内存重复
CDC 异步失效最终一致、可重放间接保护延迟、乱序、消费积压
绕过缓存直读数据库取决于数据库较高数据库容量不足

不存在统一最佳方案。一个常见组合是:

普通数据:
Cache Aside + 正常 TTL + 随机抖动

不存在数据:
参数校验 + 空值缓存

海量 ID:
Bloom Filter + 空值缓存

热点数据:
singleflight + 逻辑过期或互斥重建

超高频只读热点:
L1 + Redis + 版本化失效

多写入方:
数据库 Outbox/CDC + 幂等失效

10. 高频面试题

问题 1:什么是 Cache Aside?

推荐回答

Cache Aside 是由应用同时管理缓存和数据库的模式。读请求先查缓存,未命中时查数据库并回填;写请求通常先更新数据库,再删除缓存。它实现简单、适合读多写少业务,但缓存和数据库之间默认不是强一致的。

面试官追问

  • 为什么写入后通常删除缓存,而不是更新缓存?
  • Redis 故障后是否应直接查询数据库?
  • 缓存回填失败是否要让本次请求失败?

常见错误回答

“Redis 会自动从数据库加载数据”,或者声称 Cache Aside 可以保证缓存与数据库强一致。

评分点

  • 初级: 能说出标准读流程。
  • 中级: 能说明写后失效和异常降级。
  • 高级: 能分析删除失败、旧值回填和回源保护。

问题 2:Read Through、Write Through 和 Write Behind 有什么区别?

推荐回答

Read Through 由缓存层在未命中时加载数据;Write Through 同步写缓存和数据库;Write Behind 先写缓存,再异步写数据库。前两者简化应用但增加中间层能力要求,Write Behind 吞吐高,但数据丢失、乱序和恢复更复杂。

面试官追问

  • 普通 Redis 命令能否自动实现 Read Through?
  • Write Behind 的缓存是否暂时成为事实来源?
  • 哪类数据适合异步落库?

常见错误回答

把 Write Through 说成“先写 Redis 就返回”,这实际上更接近 Write Behind。

评分点

  • 初级: 区分同步和异步。
  • 中级: 能说明延迟和一致性差异。
  • 高级: 能讨论持久日志、重放和失败恢复。

问题 3:缓存穿透、击穿和雪崩有什么区别?

推荐回答

穿透是请求的数据在缓存和数据库都不存在;击穿是少量热点 Key 失效后大量请求回源;雪崩是大量 Key 同时失效或缓存整体不可用,导致大范围回源。

面试官追问

  • 热 Key 一直命中,还算不算击穿?
  • 空值缓存能解决雪崩吗?
  • Redis 集群宕机属于哪一类?

常见错误回答

把三者都解释为“缓存失效导致数据库压力大”,没有说明影响范围和数据是否存在。

评分点

  • 初级: 定义正确。
  • 中级: 能分别提出治理方案。
  • 高级: 能给出容量保护和故障演练方案。

问题 4:空值缓存和 Bloom Filter 如何选择?

推荐回答

空值缓存适合吸收少量重复的不存在查询,实现简单但会占用 Key,并可能短暂遮挡新数据。Bloom Filter 适合在大规模 ID 空间中过滤随机不存在请求,内存效率高,但存在假阳性,还要维护和数据库的同步。

面试官追问

  • Bloom Filter 能否直接删除元素?
  • Bloom 判断可能存在后还要不要查数据库?
  • Bloom 漏加元素会发生什么?

常见错误回答

声称 Bloom Filter 判断“存在”就代表数据库中一定存在。

评分点

  • 初级: 知道假阳性。
  • 中级: 能说明空值 TTL 和新增同步。
  • 高级: 能讨论容量、假阳性率和 Cuckoo Filter。

问题 5:singleflight 能完全解决缓存击穿吗?

推荐回答

不能。它只能在单个 Go 进程内合并相同 Key 的并发调用。多实例部署时,每个实例仍可能各自回源;不同 Key 同时未命中也无法被合并。

面试官追问

  • 首个请求超时后其他等待者怎么办?
  • 为什么要在 singleflight 内二次检查缓存?
  • 怎样实现跨实例请求合并?

常见错误回答

认为 singleflight 是分布式锁,或者认为它可以限制数据库总并发。

评分点

  • 初级: 知道相同 Key 合并。
  • 中级: 知道进程作用域和超时问题。
  • 高级: 能结合分布式互斥、逻辑过期和全局信号量。

问题 6:为什么通常先更新数据库,再删除缓存?

推荐回答

先删缓存再更新数据库时,并发读可能读取数据库旧值并回填,随后数据库才更新,最终形成旧缓存。先更新数据库再删除缓存通常能缩短这一窗口,但缓存删除失败和旧读晚到回填仍然存在,所以它只是较合理的默认顺序,不是严格一致保证。

面试官追问

  • 删除缓存失败怎么办?
  • 数据库事务提交成功后应用崩溃怎么办?
  • 能否使用分布式事务?

常见错误回答

“先更新数据库再删除缓存就绝对一致。”

评分点

  • 初级: 知道常见顺序。
  • 中级: 能解释删除失败。
  • 高级: 能分析旧值晚到,并提出 Outbox、CDC 和版本控制。

问题 7:什么是旧值回填问题?

推荐回答

一个读请求在数据库更新前读取了旧值,但在写请求删除缓存之后才把旧值写回 Redis,导致缓存重新出现旧数据。解决方法包括版本化缓存、二次失效、串行化关键读写、缩短 TTL,或对强一致请求绕过缓存。

面试官追问

  • 只靠延迟双删能否完全解决?
  • 版本号放在哪里?
  • Redis Cluster 中怎样原子比较版本?

常见错误回答

认为数据库更新完成后,所有仍在执行的读请求都会自动读取新值。

评分点

  • 初级: 能描述并发时序。
  • 中级: 能提出二次失效。
  • 高级: 能设计版本单调写入和 Lua 校验。

问题 8:怎样评价延迟双删?

推荐回答

延迟双删通过第二次删除清理并发读回填的旧值,可以降低不一致概率,但延迟值无法精确确定,任务可能失败,也不能覆盖所有线程暂停和网络异常,因此不能提供严格一致性。

面试官追问

  • 延迟应该设多长?
  • 第二次删除如何可靠执行?
  • 与 CDC 相比有什么差异?

常见错误回答

“延迟双删可以保证缓存和数据库一致。”

评分点

  • 初级: 知道两次删除。
  • 中级: 能指出时间窗口问题。
  • 高级: 能提出可靠任务、版本控制和 CDC 替代方案。

问题 9:逻辑过期适用于什么场景?

推荐回答

逻辑过期适合访问量极高、重建成本较大并且允许短暂旧值的数据。Key 不立即物理消失,过期后继续返回旧值,同时由一个任务刷新,因此能避免大量请求阻塞和回源。

面试官追问

  • 后台刷新一直失败怎么办?
  • 怎样保证只有一个刷新任务?
  • 能否用于库存扣减?

常见错误回答

认为逻辑过期只是“把 TTL 写进 JSON”,却不考虑异步重建、最大陈旧时间和任务失败。

评分点

  • 初级: 理解返回旧值。
  • 中级: 能说明互斥刷新。
  • 高级: 能设计最大陈旧阈值、熔断和刷新任务监控。

问题 10:如何处理热 Key?

推荐回答

先确认热点来自命令频率、Value 大小还是 Cluster 热 Slot。常用方案包括进程内 L1、读副本、请求合并、拆分只读 Key、缩小 Value,以及业务侧限流。Key 拆分会增加多副本更新和失效成本,不是所有场景都适合。

面试官追问

  • 热 Key 拆分后怎样更新?
  • 本地缓存怎样失效?
  • 读副本会带来什么一致性问题?

常见错误回答

只回答“增加 Redis 节点”,没有考虑热 Key 仍可能集中在单个 Slot。

评分点

  • 初级: 知道热点和普通高流量不同。
  • 中级: 能提出 L1 和副本。
  • 高级: 能从网络、Slot、对象大小和一致性综合分析。

问题 11:Redis 故障时如何防止数据库被压垮?

推荐回答

不能无限制地把所有请求直接回源。需要为数据库设置独立并发上限和 QPS 限制,结合熔断、请求优先级、L1 旧值、默认结果和快速失败。恢复后还要限制缓存预热速度,防止再次冲击数据库。

面试官追问

  • 哪些接口可以 Fail Open?
  • 怎样估算数据库回源容量?
  • Redis 恢复后为什么还可能发生雪崩?

常见错误回答

“Redis 挂了就直接访问数据库,系统仍然可用。”

评分点

  • 初级: 知道需要降级。
  • 中级: 能提出限流和熔断。
  • 高级: 能设计分级降级、容量预算和恢复预热。

问题 12:Go 实现缓存时有哪些关键错误处理?

推荐回答

要区分 redis.Nil 和 Redis 故障;所有 Redis 和数据库操作都应有超时;缓存错误可按业务降级,但必须限制回源;singleflight 要处理调用方取消和共享加载超时;缓存写失败通常不应覆盖本次数据库读取结果;数据库已提交但缓存失效失败属于部分成功。

面试官追问

  • go-redis 客户端能否并发复用?
  • 是否应该重试 GETSET
  • 一个等待者取消时是否应终止共享加载?

常见错误回答

把所有错误都当作缓存未命中,或者使用无 deadline 的 context.Background() 执行数据库查询。

评分点

  • 初级: 能处理 redis.Nil
  • 中级: 能处理超时、降级和幂等。
  • 高级: 能讨论连接池等待、重试风暴、共享上下文和部分成功。

11. 一分钟面试回答

高并发场景下最常用的是 Cache Aside:读请求先查 Redis,未命中再查数据库并回填;写请求一般先更新数据库,再删除缓存。缓存穿透是查询不存在的数据,可以用参数校验、短 TTL 空值缓存和 Bloom Filter;缓存击穿是热点 Key 失效后并发回源,可以用 singleflight、分布式互斥重建或逻辑过期;缓存雪崩是大量 Key 同时失效或 Redis 整体不可用,需要随机 TTL、分批预热、限流、熔断和数据库回源保护。更新数据库后删除缓存只是最终一致方案,仍需处理删除失败和旧值晚到回填,可以通过 Outbox、CDC、版本号和二次失效收敛。本地缓存加 Redis 可以降低热 Key 压力,但必须处理多实例失效、消息丢失和最大陈旧时间。最重要的是,缓存故障时不能无限制回源,否则缓存会从性能组件变成数据库故障放大器。


12. 本章总结

  1. 缓存是数据库的派生副本,必须明确事实来源和最大陈旧时间。
  2. Cache Aside 易于落地,但一致性、回源保护和异常恢复均由应用负责。
  3. 穿透、击穿和雪崩的影响范围不同,不能使用单一方案治理。
  4. singleflight 只解决单进程内相同 Key 的重复加载。
  5. 逻辑过期以旧值换取稳定延迟,适用于允许短暂陈旧的热点数据。
  6. “先更新数据库,再删除缓存”是合理默认值,但不是严格一致性保证。
  7. 延迟双删只能降低概率;Outbox、CDC 和版本控制更适合系统化治理。
  8. L1 能降低 Redis 压力,但会增加多副本一致性和内存成本。
  9. Redis 故障时必须限制数据库回源并发,不能简单无限 Fail Open。
  10. 缓存架构的最终评价指标是业务正确性、尾延迟和故障隔离能力,而不仅是命中率。

13. 自测清单

  1. 你能否画出 Cache Aside 的完整读写时序?
  2. 为什么空值缓存的 TTL 通常短于正常数据 TTL?
  3. Bloom Filter 发生假阳性后,系统会执行什么路径?
  4. 为什么 singleflight 不能限制整个集群的数据库回源量?
  5. 逻辑过期怎样处理后台刷新长期失败?
  6. “更新数据库后删除缓存”仍可能出现哪两类不一致?
  7. 延迟双删为什么不能提供严格一致性?
  8. 本地缓存订阅失效消息时,断线期间的数据怎样处理?
  9. Redis 故障后,怎样限制直接查询数据库的请求数量?
  10. CDC 直接更新缓存时,怎样防止旧事件覆盖新值?

14. 官方资料

Redis 官方资料

  • Redis Open Source 发布记录与 Redis 8.8 版本信息。(Redis)
  • Redis Cache Aside 使用场景说明。(Redis)
  • Redis 客户端缓存介绍。(Redis)
  • CLIENT TRACKING 命令文档。(Redis)
  • Bloom Filter 数据类型与 BF.RESERVE。(Redis)
  • SETEXPIREUNLINK 命令文档。(Redis)
  • Redis Key 淘汰和缓存命中指标。(Redis)
  • go-redis 连接、错误处理和生产超时指南。(Redis)
  • Redis Pub/Sub 投递语义与 Keyspace Notification 边界。(Redis)

Go 官方补充资料

  • golang.org/x/sync/singleflight 调用合并语义。(Go Packages)