高并发缓存架构
系统梳理高并发场景下的 Cache Aside、缓存穿透、击穿、雪崩、热点 Key、本地缓存、回源保护、缓存一致性与 Go 实战。
第 18 章:高并发缓存架构
1. 本章定位
前面的章节解决了 Redis 的数据结构、性能、高可用和分片问题。本章进一步回答一个工程问题:
当 Redis 被放在数据库前面承接高并发流量时,怎样避免缓存本身成为一致性漏洞、故障放大器或新的性能瓶颈?
缓存不是简单的 GET 和 SET。一个完整的缓存架构至少要处理:
- 缓存未命中时谁负责加载数据。
- 大量请求同时未命中时怎样保护数据库。
- 数据库更新后怎样处理旧缓存。
- 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. 学习目标
完成本章后,应能够:
- 准确说明 Cache Aside、Read Through、Write Through、Write Behind 的读写路径。
- 区分缓存穿透、缓存击穿、缓存雪崩和热 Key。
- 使用空值缓存、Bloom Filter、随机 TTL、
singleflight、互斥重建和逻辑过期保护数据库。 - 使用 Go 和
go-redis/v9实现完整的 Cache Aside 读路径。 - 解释为什么常见写路径是“更新数据库后删除缓存”。
- 分析旧值回填、缓存删除失败、CDC 延迟和事件乱序造成的不一致。
- 设计本地缓存与 Redis 组成的两级缓存。
- 在 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是序列化和回填缓存的时间。
因此,命中率下降不仅会增加平均延迟,还会把流量重新压回数据库。缓存架构的关键目标不是单纯提高命中率,而是:
- 控制未命中的并发数量。
- 限制回源数据库的最大压力。
- 控制缓存数据的陈旧时间。
- 保证缓存故障不会直接演化成数据库故障。
Redis 的 INFO stats 提供 keyspace_hits、keyspace_misses、expired_keys 和 evicted_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/singleflight 的 Group.Do 和 DoChan 可以保证同一个 Key 同一时刻只有一个函数执行,其余调用者复用结果。(Go Packages)
限制是:
- 只在单个 Go 进程内生效。
- 部署 100 个实例时,最坏仍可能有 100 次数据库查询。
- 首个执行者很慢时,等待者会同时受影响。
- 错误结果也可能被一批等待者共享。
- 必须处理调用方取消和共享加载上下文之间的关系。
解决方法二:分布式互斥重建
多个实例竞争一个 Redis 锁,只有持锁者查询数据库并重建缓存。
优点是跨实例抑制回源;缺点是引入锁超时、续期、错误释放和主从切换问题。安全解锁和 Fencing Token 将在第 19 章展开。
解决方法三:逻辑过期
缓存值中保存逻辑失效时间:
{
"data": {"id": 42, "name": "phone"},
"expire_at": 1782000000000
}
Redis Key 的物理 TTL 设置得较长,甚至由主动失效控制。读取到逻辑过期的数据时:
- 当前请求仍返回旧值。
- 一个后台任务尝试重建。
- 其他请求继续读取旧值。
- 新值写入后恢复正常。
这是典型的 Stale While Revalidate 思路。
优点是热点 Key 不会突然消失;代价是业务必须接受一段时间的旧数据,并处理后台任务失败、并发重建和永久陈旧问题。
3.6 缓存雪崩
缓存雪崩是指大量 Key 同时失效,或者整个缓存服务不可用,导致大范围请求同时回源。
它与击穿的区别是:
- 击穿通常集中在一个或少量热点 Key。
- 雪崩影响大量 Key、整个业务域或整个 Redis 集群。
常见原因:
- 批量预热时给所有 Key 设置完全相同的 TTL。
- Redis 集群故障或网络隔离。
- 发布新版本后 Key 前缀整体变化。
- 淘汰策略导致大量常用数据被驱逐。
- 数据迁移或扩容后新节点为空。
- 客户端超时配置不合理,形成重试风暴。
保护措施应组合使用:
- 在基础 TTL 上增加随机抖动。
- 热点数据分批预热。
- 回源数据库设置并发上限。
- 对非核心请求进行限流和快速失败。
- 准备本地缓存、默认值或静态快照。
- 对 Redis 错误采用有界重试,禁止无限重试。
- 确保数据库拥有一定的降级容量,但不要假设其能承受全部缓存流量。
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 回填到缓存
即使顺序是“更新数据库后删除缓存”,仍可能因为读请求跨越写事务而发生旧值回填。
常见改进包括:
- 缓存值携带数据库版本号。
- 使用 Lua 只允许更高版本覆盖缓存。
- 更新事件再次删除缓存。
- 对单 Key 的读写进行串行化。
- 缩短 TTL,将错误限制在可接受窗口。
- 对强一致请求直接读取主数据库。
延迟双删
典型流程:
删除缓存
更新数据库
等待一段时间
再次删除缓存
第二次删除试图清理并发读回填的旧值。
它只能降低概率,不能提供严格保证,因为:
- 延迟时间很难准确选择。
- 慢查询可能超过等待时间。
- 第二次删除任务也可能失败。
- 会增加缓存写流量。
- 实例暂停、网络抖动可能改变原有时序。
生产环境不应把“睡眠几百毫秒后再删一次”描述成严格一致性方案。
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、预计算表 |
| 随机不存在 ID | Bloom + 空值缓存 | 数据集合无法维护 | 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 客户端能否并发复用?
- 是否应该重试
GET和SET? - 一个等待者取消时是否应终止共享加载?
常见错误回答
把所有错误都当作缓存未命中,或者使用无 deadline 的 context.Background() 执行数据库查询。
评分点
- 初级: 能处理
redis.Nil。 - 中级: 能处理超时、降级和幂等。
- 高级: 能讨论连接池等待、重试风暴、共享上下文和部分成功。
11. 一分钟面试回答
高并发场景下最常用的是 Cache Aside:读请求先查 Redis,未命中再查数据库并回填;写请求一般先更新数据库,再删除缓存。缓存穿透是查询不存在的数据,可以用参数校验、短 TTL 空值缓存和 Bloom Filter;缓存击穿是热点 Key 失效后并发回源,可以用 singleflight、分布式互斥重建或逻辑过期;缓存雪崩是大量 Key 同时失效或 Redis 整体不可用,需要随机 TTL、分批预热、限流、熔断和数据库回源保护。更新数据库后删除缓存只是最终一致方案,仍需处理删除失败和旧值晚到回填,可以通过 Outbox、CDC、版本号和二次失效收敛。本地缓存加 Redis 可以降低热 Key 压力,但必须处理多实例失效、消息丢失和最大陈旧时间。最重要的是,缓存故障时不能无限制回源,否则缓存会从性能组件变成数据库故障放大器。
12. 本章总结
- 缓存是数据库的派生副本,必须明确事实来源和最大陈旧时间。
- Cache Aside 易于落地,但一致性、回源保护和异常恢复均由应用负责。
- 穿透、击穿和雪崩的影响范围不同,不能使用单一方案治理。
singleflight只解决单进程内相同 Key 的重复加载。- 逻辑过期以旧值换取稳定延迟,适用于允许短暂陈旧的热点数据。
- “先更新数据库,再删除缓存”是合理默认值,但不是严格一致性保证。
- 延迟双删只能降低概率;Outbox、CDC 和版本控制更适合系统化治理。
- L1 能降低 Redis 压力,但会增加多副本一致性和内存成本。
- Redis 故障时必须限制数据库回源并发,不能简单无限 Fail Open。
- 缓存架构的最终评价指标是业务正确性、尾延迟和故障隔离能力,而不仅是命中率。
13. 自测清单
- 你能否画出 Cache Aside 的完整读写时序?
- 为什么空值缓存的 TTL 通常短于正常数据 TTL?
- Bloom Filter 发生假阳性后,系统会执行什么路径?
- 为什么
singleflight不能限制整个集群的数据库回源量? - 逻辑过期怎样处理后台刷新长期失败?
- “更新数据库后删除缓存”仍可能出现哪两类不一致?
- 延迟双删为什么不能提供严格一致性?
- 本地缓存订阅失效消息时,断线期间的数据怎样处理?
- Redis 故障后,怎样限制直接查询数据库的请求数量?
- CDC 直接更新缓存时,怎样防止旧事件覆盖新值?
14. 官方资料
Redis 官方资料
- Redis Open Source 发布记录与 Redis 8.8 版本信息。(Redis)
- Redis Cache Aside 使用场景说明。(Redis)
- Redis 客户端缓存介绍。(Redis)
CLIENT TRACKING命令文档。(Redis)- Bloom Filter 数据类型与
BF.RESERVE。(Redis) SET、EXPIRE和UNLINK命令文档。(Redis)- Redis Key 淘汰和缓存命中指标。(Redis)
- go-redis 连接、错误处理和生产超时指南。(Redis)
- Redis Pub/Sub 投递语义与 Keyspace Notification 边界。(Redis)
Go 官方补充资料
golang.org/x/sync/singleflight调用合并语义。(Go Packages)