mobile wallpaper 1mobile wallpaper 2mobile wallpaper 3mobile wallpaper 4
6933 字
18 分钟
Redis 缓存三大问题与实战
2024-08-03

缓存是 Redis 最常见的用法,也是最容易出问题的地方。一次促销活动开始,商品详情页 QPS 从几百飙升到几万,缓存命中率从 95% 掉到 30%,数据库连接池瞬间打满,服务雪崩。排查下来,原因可能只是某个热点 key 刚好过期。

缓存穿透、缓存击穿、缓存雪崩这三个问题,本质都是缓存层没能挡住请求,让流量直接涌向后端存储。理解它们的区别和联系,才能在面对线上故障时快速定位。这篇讲怎么用好 Redis 做缓存,以及缓存出问题后怎么救场。

一、缓存策略:Cache-Aside 与读写顺序#

1.1 为什么要 Cache-Aside#

应用程序直接读写数据库,在高并发场景下会面临两个问题:数据库连接数有限,扛不住突发流量;相同查询重复执行,浪费计算资源。引入缓存层后,读请求优先命中内存,数据库压力骤降。

Cache-Aside(旁路缓存)是最通用的缓存读写模式。它的核心思想是:应用程序既管缓存又管数据库,缓存不主动同步数据,所有读写都由应用层发起。与 Write-Through(写穿)、Write-Behind(写回)等模式相比,Cache-Aside 不依赖缓存中间件的数据同步能力,实现简单,对任何缓存和数据库组合都适用。

1.2 读流程:先查缓存,miss 再查 DB#

Cache-Aside 的读流程分为四步:

  1. 查缓存,命中则直接返回
  2. 未命中,查数据库
  3. 将数据库结果回写缓存
  4. 返回结果
import redis
import json
r = redis.Redis(host='localhost', port=6379, db=0, decode_responses=True)
def get_user(user_id: int) -> dict | None:
"""Cache-Aside 读流程:先查缓存,miss 再查 DB"""
cache_key = f"user:{user_id}"
# 1. 查缓存
cached = r.get(cache_key)
if cached:
return json.loads(cached)
# 2. 未命中,查数据库
user = db_query_user(user_id) # 伪代码,实际为 ORM 查询
if user is None:
return None
# 3. 回写缓存,设置过期时间避免脏数据永久驻留
r.setex(cache_key, 3600, json.dumps(user))
# 4. 返回结果
return user
def db_query_user(user_id: int) -> dict | None:
"""模拟数据库查询"""
# 实际项目中是 SELECT * FROM users WHERE id = ?
return {"id": user_id, "name": "test", "email": "test@example.com"}
Note

回写缓存时必须设置过期时间(SETEX 而非 SET)。没有过期时间的缓存是脏数据的温床,数据库更新后缓存不刷新,读到的永远是旧值。过期时间是最终一致性的兜底手段。

1.3 写流程:先更新 DB,再删缓存#

写流程有两种常见做法:先更新数据库再删缓存(推荐),或者先删缓存再更新数据库。

def update_user(user_id: int, new_name: str) -> None:
"""Cache-Aside 写流程:更新 DB,删缓存"""
cache_key = f"user:{user_id}"
# 1. 更新数据库
db_update_user(user_id, new_name)
# 2. 删除缓存(不是更新缓存)
r.delete(cache_key)
def db_update_user(user_id: int, new_name: str) -> None:
"""模拟数据库更新"""
# UPDATE users SET name = ? WHERE id = ?
pass

为什么是删缓存而不是更新缓存?两个原因:

对比维度删缓存更新缓存
写复杂度一次 DEL,简单需重新查 DB 组装完整对象
并发安全删后下次读会回写新值并发更新可能写回旧值
内存占用下次读才重建,省内存写多读少时缓存长期占内存不命中

更新缓存的陷阱在于并发:线程 A 更新 name 为 “Alice”,线程 B 更新 name 为 “Bob”,如果线程 A 的缓存写回晚于线程 B,缓存里就留了 “Alice” 这个旧值。删缓存模式下,写操作只做删除,读操作负责重建,把并发冲突收敛到一次 DEL 上。

1.4 读写顺序的并发陷阱#

先更新 DB 再删缓存,在极端时序下仍有不一致窗口:

sequenceDiagram participant T1 as 线程 1(读) participant T2 as 线程 2(写) participant C as 缓存 participant DB as 数据库 T1->>DB: 查询(读到旧值 v1) T2->>DB: 更新为 v2 T2->>C: 删除缓存 T1->>C: 回写旧值 v1 Note over C: 缓存里是旧值 v1,脏数据

线程 1 查到旧值后还没回写,线程 2 完成了更新和删缓存,线程 1 随后把旧值写回缓存。缓存里就留了旧值,直到过期前一直不一致。这个场景触发概率低,需要读请求的回写动作恰好夹在写请求的两步之间,但一旦触发,影响时间等于 TTL。下一节的延迟双删就是为这个场景设计的。

二、缓存穿透:查询不存在的数据#

2.1 现象#

缓存穿透是指查询一个数据库中根本不存在的数据。由于缓存中也没有这条数据,每次请求都会穿透到数据库,缓存层形同虚设。

典型场景:恶意攻击者用大量不存在的 ID 请求接口(如 user/-1user/999999999),或者业务上查询了已被物理删除的记录。这些 ID 在缓存和数据库中都不存在,每次查询都打到数据库。

flowchart LR A["请求<br/>user_id = -1"] --> B{"查缓存"} B -->|"miss"| C{"查数据库"} C -->|"不存在"| D["返回 null"] D --> E["不回写缓存"] E --> A style B fill:#fff9c4,stroke:#f9a825 style C fill:#ffcdd2,stroke:#c62828

穿透的危险在于:请求永远 miss,缓存完全失效,数据库持续承受高压。与击穿和雪崩不同,穿透针对的是不存在的数据,缓存层根本无法拦住。

2.2 根因#

穿透的根因有两个:查询条件本身不合法(ID 为负数、格式错误),或者数据已被物理删除但请求仍在过来。第一种可以通过参数校验拦在入口,第二种需要缓存层配合处理。

2.3 解决方案一:缓存空值#

最简单的做法是把数据库查不到的结果也缓存起来,设置一个较短的过期时间。

def get_user_safe(user_id: int) -> dict | None:
"""缓存空值,防止穿透"""
cache_key = f"user:{user_id}"
cached = r.get(cache_key)
if cached is not None:
if cached == "NULL":
# 命中空值缓存,直接返回 null
return None
return json.loads(cached)
# 查数据库
user = db_query_user(user_id)
if user is None:
# 数据库不存在,缓存空值,过期时间短(5 分钟)
r.setex(cache_key, 300, "NULL")
return None
r.setex(cache_key, 3600, json.dumps(user))
return user

缓存空值的好处是简单直接,改造成本低。坏处是:如果被攻击的 ID 空间很大(比如一千万个不同 ID),缓存会被大量空值占满,内存浪费严重。而且空值缓存的 TTL 较短,过期后又会重复打到数据库。

2.4 解决方案二:布隆过滤器#

布隆过滤器(Bloom Filter)是一种概率型数据结构,能用极小的内存判断一个元素是否存在于集合中。它的特点是:可能误判(说存在实际不存在),但不会漏判(说不存在就一定不存在)。

Redis 4.0 之后可通过 RedisBloom 模块使用布隆过滤器。它的原理是:用 k 个独立的哈希函数将元素映射到位数组的 k 个位置,置为 1。查询时检查这 k 个位置是否全为 1,全为 1 则可能存在,有 0 则一定不存在。

def init_bloom_filter():
"""初始化布隆过滤器,加载所有合法 user_id"""
# RedisBloom 模块提供 BF.ADD / BF.EXISTS 命令
# 容量 100 万,误判率 0.1%
r.execute_command('BF.RESERVE', 'bloom:user_ids', 0.001, 1000000)
# 启动时加载所有 user_id
all_ids = db_get_all_user_ids() # SELECT id FROM users
for uid in all_ids:
r.execute_command('BF.ADD', 'bloom:user_ids', str(uid))
def get_user_with_bloom(user_id: int) -> dict | None:
"""布隆过滤器前置拦截,防止穿透"""
# 先过布隆过滤器
exists = r.execute_command('BF.EXISTS', 'bloom:user_ids', str(user_id))
if exists == 0:
# 布隆过滤器说不存在,一定不存在,直接返回
return None
# 布隆过滤器说可能存在,继续查缓存和 DB
cache_key = f"user:{user_id}"
cached = r.get(cache_key)
if cached:
return json.loads(cached)
user = db_query_user(user_id)
if user:
r.setex(cache_key, 3600, json.dumps(user))
return user
Tip

布隆过滤器的误判率(false positive rate)和内存占用是此消彼长的。误判率越低,需要的位数组越大。按位数组公式 m = -n·ln(p)/(ln2)²,100 万元素、0.1% 误判率约需 1438 万 bit,即约 1.71 MB 内存,远小于缓存一百万个空值。但误判率不能设到 0,那是位数组要无限大。

布隆过滤器的局限在于:只能添加不能删除。如果某个 user_id 被物理删除,布隆过滤器里仍有它的位标记,后续查询仍会误判为可能存在。RedisBloom 提供了布谷鸟过滤器(Cuckoo Filter,CF.* 命令族),基于布谷鸟哈希和指纹(fingerprint)存储,通过 CF.DEL 移除指纹支持删除,内存开销比布隆过滤器略高。注意它是布谷鸟过滤器,不是计数布隆过滤器(Counting Bloom Filter)。计数布隆过滤器是把位数组每个比特扩展成计数器来支持删除,RedisBloom 并未实现这种数据结构。

2.5 两种方案对比#

对比维度缓存空值布隆过滤器
实现复杂度低,改几行代码中,需引入 RedisBloom 模块
内存占用高(每个不存在 ID 一条空值)低(固定大小的位数组)
适用场景不存在 ID 少且固定不存在 ID 多或 ID 空间大
新增数据天然支持需手动 BF.ADD 同步
删除数据TTL 过期自动清除普通布隆过滤器不支持删除

生产环境的常见组合是:布隆过滤器做前置拦截,缓存空值兜底处理布隆过滤器的误判。两层防护下,穿透流量基本能拦住。

三、缓存击穿:热点 key 过期#

3.1 现象#

缓存击穿是指某个热点 key 在过期的瞬间,大量并发请求同时未命中缓存,全部涌向数据库。

和穿透的区别:穿透是查不存在的数据,击穿是查存在的数据,但缓存恰好失效了。穿透是大量不同 key 的请求,击穿是大量相同 key 的请求集中在同一时刻。

flowchart TB A["热点 key<br/>item:1001 过期"] --> B{"并发请求<br/>1 万 QPS"} B --> C["全部 miss"] C --> D["全部查数据库"] D --> E["数据库连接打满"] E --> F["服务超时"] style C fill:#fff9c4,stroke:#f9a825 style D fill:#ffcdd2,stroke:#c62828 style F fill:#ffcdd2,stroke:#c62828

典型场景:电商首页推荐位商品、热门直播间信息、秒杀活动商品详情。这些 key 平时 QPS 极高,一旦过期,瞬间几万请求同时打到数据库。

3.2 根因#

击穿的根因是热点 key 过期与高并发请求的时间窗口重叠。Redis 的惰性删除策略下,key 过期后不会主动通知客户端,而是在第一次访问时发现已过期。如果这一刻有大量并发请求,它们都会读到 miss,然后各自去查数据库。

3.3 解决方案一:互斥锁#

互斥锁的核心思想是:缓存 miss 后,只允许一个请求去查数据库并回写缓存,其他请求等待。

import time
import uuid
def get_hot_item(item_id: int) -> dict | None:
"""互斥锁防止击穿"""
cache_key = f"item:{item_id}"
lock_key = f"lock:item:{item_id}"
# 1. 查缓存
cached = r.get(cache_key)
if cached:
return json.loads(cached)
# 2. 缓存 miss,尝试加互斥锁
lock_value = str(uuid.uuid4())
# SET NX PX:仅当 key 不存在时设置,并设置过期时间
acquired = r.set(lock_key, lock_value, nx=True, px=10000)
if acquired:
try:
# 双重检查:拿到锁后再查一次缓存
cached = r.get(cache_key)
if cached:
return json.loads(cached)
# 查数据库
item = db_query_item(item_id)
if item:
r.setex(cache_key, 3600, json.dumps(item))
return item
finally:
# 释放锁:用 Lua 保证判等与删除的原子性
release_script = """
if redis.call('get', KEYS[1]) == ARGV[1] then
return redis.call('del', KEYS[1])
else
return 0
end
"""
r.eval(release_script, 1, lock_key, lock_value)
else:
# 未拿到锁,短暂等待后重试读缓存
time.sleep(0.05)
return get_hot_item(item_id)

互斥锁的关键细节有三个:

  1. 双重检查:拿到锁后先查一次缓存,因为等待期间前一个持有锁的请求可能已经回写了。不做这步会导致重复查 DB。
  2. 锁的过期时间:必须设置,防止持有锁的进程崩溃导致死锁。PX 10000 表示 10 秒后自动释放。
  3. 安全释放:释放锁时用 Lua 脚本判断值是否匹配,避免误删别人的锁。如果直接 DEL,可能删掉已过期后被另一个请求重新获取的锁。

互斥锁的代价是:等待锁的请求会串行化,响应时间变长。在极端高并发下,等待队列可能超时。

3.4 解决方案二:逻辑过期#

逻辑过期的思路是:不给 key 设置物理过期时间(TTL),而是在 value 里存一个逻辑过期时间戳。请求读取后判断是否逻辑过期,过期则异步触发刷新,当前请求仍返回旧数据。

import json
import time
import threading
def get_item_logical(item_id: int) -> dict | None:
"""逻辑过期:永远返回数据,异步刷新"""
cache_key = f"item:logic:{item_id}"
cached = r.get(cache_key)
if not cached:
# 冷启动,需要预热
return None
data = json.loads(cached)
now = int(time.time())
if data["expire_at"] > now:
# 未过期,直接返回
return data["value"]
# 逻辑过期,尝试获取刷新锁
lock_key = f"lock:refresh:{item_id}"
acquired = r.set(lock_key, "1", nx=True, px=10000)
if acquired:
# 异步刷新,当前请求先返回旧值
def refresh():
try:
item = db_query_item(item_id)
new_data = {
"value": item,
"expire_at": int(time.time()) + 3600
}
r.set(cache_key, json.dumps(new_data))
finally:
r.delete(lock_key)
threading.Thread(target=refresh, daemon=True).start()
# 无论是否触发刷新,都返回旧数据
return data["value"]

逻辑过期的好处是:所有请求都能立即拿到数据,不存在等待锁的问题,不会压垮数据库。代价是:在刷新窗口内,部分请求读到的是旧数据,属于最终一致性的妥协。另外 key 永远不过期,如果业务下线,需要手动清理。

3.5 两种方案对比#

对比维度互斥锁逻辑过期
一致性强,拿到锁的请求查 DB 后返回新值弱,刷新窗口内返回旧值
响应时间等待锁的请求变慢所有请求都快
实现复杂度中,需处理双重检查和锁释放中,需异步刷新和预热
数据库压力低(只有一个请求查 DB)低(异步单请求刷新)
适用场景对一致性敏感对延迟敏感,容忍短暂不一致

3.6 singleflight 模式#

Go 语言标准库提供了 singleflight 包,天然实现了互斥锁的语义:相同 key 的并发调用只执行一次,结果共享给所有调用方。这比手动加锁更简洁。

package main
import (
"context"
"encoding/json"
"fmt"
"time"
"github.com/redis/go-redis/v9"
"golang.org/x/sync/singleflight"
)
var (
g singleflight.Group
ctx = context.Background()
)
func GetItem(rdb *redis.Client, itemID int) (map[string]any, error) {
cacheKey := fmt.Sprintf("item:%d", itemID)
// 1. 查缓存
cached, err := rdb.Get(ctx, cacheKey).Result()
if err == nil {
var item map[string]any
json.Unmarshal([]byte(cached), &item)
return item, nil
}
// 2. 缓存 miss,用 singleflight 合并并发请求
val, err, _ := g.Do(cacheKey, func() (any, error) {
// 相同 key 的并发调用只执行一次这里的逻辑
// 其他调用方阻塞等待结果
item := dbQueryItem(itemID) // 伪代码
if item != nil {
data, _ := json.Marshal(item)
rdb.Set(ctx, cacheKey, data, time.Hour)
}
return item, nil
})
if err != nil {
return nil, err
}
return val.(map[string]any), nil
}
Tip

singleflight 的局限是只在单进程内有效。分布式部署下,多个实例的相同请求仍会各自查一次 DB。跨进程的合并需要分布式锁或 Redis 侧的 FCALL(Redis 7.0 的 Functions 特性)。单进程方案在大多数场景已经够用,因为真正打到同一实例同一 key 的并发量,远小于全局总并发量。

四、缓存雪崩:大量 key 同时失效#

4.1 现象#

缓存雪崩是指大量 key 在同一时刻集中过期,或者 Redis 服务整体宕机,导致数据库瞬时承受巨大压力。

和击穿的区别:击穿是单个热点 key 失效,雪崩是大量 key 同时失效。击穿是点,雪崩是面。

flowchart TB A["0 点批量过期<br/>5 万个 key"] --> B["缓存命中率骤降"] B --> C["请求涌向数据库"] C --> D["数据库 CPU 打满"] D --> E["查询超时"] E --> F["服务线程阻塞"] F --> G["整个服务雪崩"] style A fill:#fff9c4,stroke:#f9a825 style D fill:#ffcdd2,stroke:#c62828 style G fill:#ffcdd2,stroke:#c62828

典型场景:批量预热的缓存数据设置了相同的 TTL(比如都是 1 小时),1 小时后全部同时过期;或者 Redis 主节点宕机,全部缓存不可用。

4.2 根因#

雪崩的根因分两类:

  1. key 集中过期:批量加载数据时设置了相同或接近的过期时间,到期后集中失效。
  2. Redis 宕机:主节点故障,缓存层整体不可用,所有请求涌向数据库。

4.3 解决方案一:随机过期时间#

针对集中过期问题,最简单的做法是给 TTL 加上随机偏移,让过期时间分散开。

import random
def cache_item_with_jitter(item_id: int, value: dict) -> None:
"""设置随机过期时间,避免雪崩"""
cache_key = f"item:{item_id}"
base_ttl = 3600 # 基础 TTL:1 小时
jitter = random.randint(0, 600) # 随机偏移:0 到 10 分钟
ttl = base_ttl + jitter
r.setex(cache_key, ttl, json.dumps(value))
def batch_warmup(item_ids: list[int]) -> None:
"""批量预热缓存"""
for item_id in item_ids:
item = db_query_item(item_id)
if item:
# 每个 key 的 TTL 都不同,过期时间分散
cache_item_with_jitter(item_id, item)

随机偏移让原本集中在同一秒过期的 key 分散到 10 分钟内陆续过期,数据库压力被摊平。偏移范围取决于 key 数量:5 万个 key 分散到 10 分钟,每秒约 83 个过期,数据库可以承受。

4.4 解决方案二:多级缓存#

多级缓存是在 Redis 之外再加一层本地缓存(如内存中的 LRU 缓存),即使 Redis 不可用,本地缓存仍能挡住部分请求。

import random
import time
from functools import lru_cache
# 本地缓存(L1):进程内,毫秒级访问
@lru_cache(maxsize=10000)
def get_item_local(item_id: int) -> tuple[dict | None, float]:
"""本地缓存,返回 (value, expire_time)"""
return None, 0 # 实际由下面的函数填充
def get_item_multilevel(item_id: int) -> dict | None:
"""多级缓存:本地缓存 -> Redis -> 数据库"""
cache_key = f"item:{item_id}"
now = time.time()
# L1:本地缓存
local_val, local_expire = get_item_local(item_id)
if local_val is not None and local_expire > now:
return local_val
# L2:Redis
cached = r.get(cache_key)
if cached:
item = json.loads(cached)
# 回写 L1,本地 TTL 短于 Redis TTL
get_item_local.cache_clear() # lru_cache 的简化回写
return item
# L3:数据库
item = db_query_item(item_id)
if item:
r.setex(cache_key, 3600 + random.randint(0, 600), json.dumps(item))
return item
Note

上面的本地缓存回写用了 lru_cache 的简化写法,生产环境建议用 cachetools.TTLCachefastcache,它们支持 TTL 过期和主动淘汰。lru_cache 没有 TTL 概念,只能靠容量淘汰,不适合做带时效的本地缓存。

多级缓存要注意本地缓存的一致性问题:多个实例的本地缓存各自独立,更新时只删 Redis 缓存不会清本地缓存,可能读到本地旧值。解决方法是本地缓存设置很短的 TTL(如 10 秒),牺牲一点一致性换取 Redis 宕机时的兜底能力。

4.5 解决方案三:熔断与降级#

当数据库已经被压垮,缓存层短期无法恢复时,需要熔断和降级保护系统不被拖死。

import time
from collections import deque
class CircuitBreaker:
"""简单的熔断器:连续失败超阈值则熔断"""
def __init__(self, threshold: int = 10, recovery_time: float = 60):
self.threshold = threshold
self.recovery_time = recovery_time
self.failures = deque(maxlen=100)
self.opened_at = 0
def is_open(self) -> bool:
"""是否处于熔断状态"""
if self.opened_at == 0:
return False
# 超过恢复时间,进入半开状态
if time.time() - self.opened_at > self.recovery_time:
self.opened_at = 0
self.failures.clear()
return False
return True
def record_failure(self):
self.failures.append(time.time())
if len(self.failures) >= self.threshold:
self.opened_at = time.time()
breaker = CircuitBreaker(threshold=20, recovery_time=60)
def get_item_with_breaker(item_id: int) -> dict | None:
"""带熔断的查询:数据库挂了就降级"""
if breaker.is_open():
# 熔断中,返回降级数据
return get_fallback_item(item_id)
try:
return get_item_multilevel(item_id)
except Exception:
breaker.record_failure()
return get_fallback_item(item_id)
def get_fallback_item(item_id: int) -> dict | None:
"""降级:返回默认值或空"""
return {"id": item_id, "name": "商品信息暂不可用"}

熔断和降级是兜底手段,不是常规方案。它们在缓存雪崩已经发生后保护系统,而不是预防雪崩。组合策略是:随机过期时间预防集中失效,多级缓存应对 Redis 宕机,熔断降级做最后防线。

五、缓存与数据库一致性:延迟双删#

5.1 为什么需要延迟双删#

回到第一节提到的并发陷阱:先更新 DB 再删缓存,如果读请求的回写夹在写请求的两步之间,缓存会留下旧值。延迟双删就是为这个问题设计的。

延迟双删的流程是:先删缓存,更新 DB,延迟一段时间后再删一次缓存。

import time
import threading
def update_user_double_delete(user_id: int, new_name: str) -> None:
"""延迟双删:先删 -> 更新 DB -> 延迟再删"""
cache_key = f"user:{user_id}"
# 1. 第一次删除缓存
r.delete(cache_key)
# 2. 更新数据库
db_update_user(user_id, new_name)
# 3. 延迟第二次删除(异步,不阻塞主流程)
delay_ms = 500 # 延迟 500ms
def second_delete():
time.sleep(delay_ms / 1000)
r.delete(cache_key)
threading.Thread(target=second_delete, daemon=True).start()

第二次删除的作用:清掉在更新 DB 期间被读请求回写的旧值。延迟时间必须大于一次读请求从开始到回写完成的时间,否则第二次删完又会被旧值回写覆盖。

5.2 延迟时间的取值#

延迟时间是延迟双删的核心参数,取值取决于两个因素:

  1. 读请求的耗时:从查 DB 到回写缓存的完整时间,包括 DB 查询、网络往返、序列化。
  2. 主从复制延迟:如果读走从库,写走主库,要等主从同步完成后再删,否则删了从库还没同步,读请求又从从库读到旧值回写。
sequenceDiagram participant W as 写线程 participant R as 读线程 participant C as 缓存 participant MDB as 主库 participant SDB as 从库 W->>C: 1. 删除缓存 W->>MDB: 2. 更新主库 R->>SDB: 查询(读到旧值) R->>C: 回写旧值 Note over MDB,SDB: 主从复制中... MDB->>SDB: 同步完成 Note over W: 等待延迟时间 W->>C: 3. 第二次删除(清掉旧值)

实际取值:单机场景下读请求耗时通常在 10 到 50 毫秒,延迟设 100 毫秒以上即可。主从架构下主从复制延迟通常在毫秒到秒级,延迟需要设到 500 毫秒到 1 秒。如果业务对一致性要求极高,延迟可以设到 1 到 2 秒,代价是这段时间内缓存可能不一致。

Warning

延迟双删无法保证 100% 一致性。如果第二次删除失败(网络抖动、Redis 重启),缓存仍会残留旧值直到 TTL 过期。生产环境可以加重试机制:第二次删除失败则记录日志,后续异步补偿删除。但不要为了强一致而无限重试,那会把系统复杂度推到不可控。

5.3 延迟双删 vs 其他一致性方案#

方案一致性强度复杂度适用场景
先更新 DB 再删缓存弱(极端时序下有窗口)大多数缓存场景
延迟双删中(覆盖回写窗口)写多读多,容忍短暂不一致
订阅 binlog 删缓存中(异步准实时)已有 Canal/Debezium 基础设施
分布式事务(2PC)极高金融等强一致场景,通常不用于缓存

订阅 binlog 的方案是:用 Canal 监听 MySQL 的 binlog,捕获到数据变更后删除对应缓存。它的好处是写流程完全不感知缓存,解耦干净,且 binlog 顺序保证删缓存操作有序。坏处是引入额外组件,运维成本上升,且 binlog 消费是异步的,仍有延迟窗口。

六、实战:库存预扣(Lua 原子操作)#

6.1 为什么用 Redis 做库存#

秒杀场景下,库存扣减是核心操作。如果每次扣减都走数据库,一条 UPDATE stock SET count = count - 1 WHERE item_id = ? AND count > 0 在几万 QPS 下会让数据库行锁成为瓶颈。用 Redis 做库存预扣,利用其单线程原子性,能扛住高并发。

库存预扣的流程是:下单时先在 Redis 里预扣库存,扣减成功才创建订单,支付确认后再真正扣减数据库库存。如果超时未支付,预扣的库存回滚。

flowchart LR A["下单请求"] --> B["Redis 预扣库存"] B -->|"扣减成功"| C["创建订单<br/>状态:待支付"] B -->|"库存不足"| D["返回:已售罄"] C --> E{"支付结果"} E -->|"支付成功"| F["DB 扣减库存<br/>订单状态:已支付"] E -->|"超时未支付"| G["Redis 回滚库存<br/>订单状态:已取消"] style B fill:#bbdefb,stroke:#1565c0 style F fill:#c8e6c9,stroke:#2e7d32 style G fill:#fff9c4,stroke:#f9a825

6.2 为什么需要 Lua 脚本#

库存扣减需要两步原子操作:先查库存是否足够,再扣减。如果用两条 Redis 命令分开执行:

# 错误写法:非原子操作
stock = int(r.get("stock:1001") or 0)
if stock > 0:
r.decr("stock:1001")

在并发下,线程 A 查到 stock=1,线程 B 也查到 stock=1,两者都执行 DECR,库存变成 -1,超卖了。问题的根因是查和扣之间有时间窗口,其他请求能插入。

Lua 脚本在 Redis 中以原子方式执行,执行期间不会被其他命令打断。把查和扣放在一个 Lua 脚本里,就保证了原子性。

6.3 Lua 脚本实现库存预扣#

库存预扣的 Lua 脚本需要处理三种情况:库存充足则扣减并返回成功,库存不足返回失败,库存为空(key 不存在)需要初始化。

-- stock_deduct.lua
-- KEYS[1] = 库存 key,如 stock:1001
-- ARGV[1] = 扣减数量
-- ARGV[2] = 订单 ID(用于幂等)
-- 返回:1 成功,0 库存不足,-2 key 不存在
local stock_key = KEYS[1]
local deduct_amount = tonumber(ARGV[1])
local order_id = ARGV[2]
-- 检查是否已预扣过该订单(幂等性)
local dedup_key = "dedup:" .. order_id
if redis.call('EXISTS', dedup_key) == 1 then
return 1 -- 已预扣过,直接返回成功
end
local current = redis.call('GET', stock_key)
if not current then
return -2 -- 库存 key 不存在,需要初始化
end
current = tonumber(current)
if current < deduct_amount then
return 0 -- 库存不足
end
-- 原子扣减
local remaining = redis.call('DECRBY', stock_key, deduct_amount)
if remaining < 0 then
-- 扣减后为负,回滚并返回失败(并发竞态兜底)
redis.call('INCRBY', stock_key, deduct_amount)
return 0
end
-- 记录预扣订单,用于回滚和幂等
redis.call('SET', dedup_key, stock_key)
redis.call('EXPIRE', dedup_key, 1800) -- 30 分钟过期
return 1
# 加载并执行 Lua 脚本
DEDUCT_SCRIPT = """
local stock_key = KEYS[1]
local deduct_amount = tonumber(ARGV[1])
local order_id = ARGV[2]
local dedup_key = "dedup:" .. order_id
if redis.call('EXISTS', dedup_key) == 1 then
return 1
end
local current = redis.call('GET', stock_key)
if not current then
return -2
end
current = tonumber(current)
if current < deduct_amount then
return 0
end
local remaining = redis.call('DECRBY', stock_key, deduct_amount)
if remaining < 0 then
redis.call('INCRBY', stock_key, deduct_amount)
return 0
end
redis.call('SET', dedup_key, stock_key)
redis.call('EXPIRE', dedup_key, 1800)
return 1
"""
# 用 EVALSHA 执行,比 EVAL 省带宽(只传 SHA 不传脚本内容)
script_sha = r.script_load(DEDUCT_SCRIPT)
def deduct_stock(item_id: int, order_id: str, amount: int = 1) -> bool:
"""预扣库存"""
stock_key = f"stock:{item_id}"
result = r.evalsha(script_sha, 1, stock_key, str(amount), order_id)
if result == 1:
return True # 预扣成功
elif result == 0:
return False # 库存不足
elif result == -2:
# key 不存在,需要初始化库存
return False # 交由初始化流程处理
return False

6.4 库存回滚#

订单超时未支付时,需要把预扣的库存加回去。回滚也要保证原子性和幂等性。

-- stock_rollback.lua
-- KEYS[1] = 库存 key
-- ARGV[1] = 回滚数量
-- ARGV[2] = 订单 ID
local stock_key = KEYS[1]
local rollback_amount = tonumber(ARGV[1])
local order_id = ARGV[2]
local dedup_key = "dedup:" .. order_id
-- 检查是否曾预扣过
if redis.call('EXISTS', dedup_key) == 0 then
return 0 -- 没有预扣记录,无需回滚
end
-- 删除预扣记录(防止重复回滚)
redis.call('DEL', dedup_key)
-- 回滚库存
redis.call('INCRBY', stock_key, rollback_amount)
return 1
ROLLBACK_SCRIPT = """
local stock_key = KEYS[1]
local rollback_amount = tonumber(ARGV[1])
local order_id = ARGV[2]
local dedup_key = "dedup:" .. order_id
if redis.call('EXISTS', dedup_key) == 0 then
return 0
end
redis.call('DEL', dedup_key)
redis.call('INCRBY', stock_key, rollback_amount)
return 1
"""
rollback_sha = r.script_load(ROLLBACK_SCRIPT)
def rollback_stock(item_id: int, order_id: str, amount: int = 1) -> bool:
"""回滚预扣库存"""
stock_key = f"stock:{item_id}"
result = r.evalsha(rollback_sha, 1, stock_key, str(amount), order_id)
return result == 1

6.5 完整的订单流程#

把预扣、回滚、DB 扣减串起来:

def place_order(user_id: int, item_id: int) -> dict:
"""下单流程:预扣库存 -> 创建订单"""
order_id = generate_order_id() # 伪代码
# 1. Redis 预扣库存
if not deduct_stock(item_id, order_id):
return {"success": False, "msg": "库存不足或已售罄"}
# 2. 创建订单(状态:待支付)
order = create_order(order_id, user_id, item_id, status="pending")
if not order:
# 订单创建失败,回滚库存
rollback_stock(item_id, order_id)
return {"success": False, "msg": "订单创建失败"}
# 3. 设置超时任务(30 分钟后自动取消)
schedule_order_timeout(order_id, 1800) # 伪代码
return {"success": True, "order_id": order_id}
def confirm_payment(order_id: str) -> dict:
"""支付确认:DB 扣减库存,订单状态改为已支付"""
order = get_order(order_id) # 伪代码
# DB 真正扣减库存
affected = db_execute(
"UPDATE stock SET count = count - 1 WHERE item_id = ? AND count > 0",
order["item_id"]
)
if affected == 0:
# DB 库存不足(Redis 预扣和 DB 不一致)
rollback_stock(order["item_id"], order_id)
return {"success": False, "msg": "库存不足"}
update_order_status(order_id, "paid")
return {"success": True}
def handle_timeout(order_id: str) -> None:
"""订单超时:回滚 Redis 库存,取消订单"""
order = get_order(order_id)
if order["status"] != "pending":
return # 已支付或已取消,不处理
rollback_stock(order["item_id"], order_id)
update_order_status(order_id, "cancelled")

6.6 库存预热与一致性#

Redis 库存和 DB 库存需要保持一致。活动开始前从 DB 加载初始库存到 Redis,活动结束后用 Redis 的剩余库存校准 DB。

def warmup_stock(item_id: int) -> None:
"""预热库存:从 DB 读取并写入 Redis"""
db_stock = db_query_stock(item_id) # SELECT count FROM stock WHERE item_id = ?
stock_key = f"stock:{item_id}"
# 用 SET 覆盖,确保是最新值
r.set(stock_key, db_stock)
def sync_stock_to_db(item_id: int) -> None:
"""活动结束后:将 Redis 库存同步回 DB"""
stock_key = f"stock:{item_id}"
redis_stock = int(r.get(stock_key) or 0)
db_execute(
"UPDATE stock SET count = ? WHERE item_id = ?",
redis_stock, item_id
)
# 同步后可删除 Redis 缓存
r.delete(stock_key)
Warning

库存预扣方案在 Redis 宕机时会丢失预扣数据。如果 Redis 没有开启 AOF 或 AOF 还没刷盘,宕机瞬间预扣的库存会丢失,已创建的订单对应的库存没了,超卖。生产环境必须开启 AOF(appendfsync everysec),并且对已创建未支付的订单做兜底校验:支付前再查一次 DB 库存是否足够。

七、实战:排行榜(ZSet)#

7.1 为什么用 ZSet 做排行榜#

排行榜是 Redis Sorted Set(ZSet)的经典应用。ZSet 的每个元素关联一个分值(score),内部用跳表和哈希表实现,支持 O(log n) 的插入、删除和范围查询。

排行榜的核心操作是:更新分数、查 Top N、查个人排名。这些操作用 ZSet 的命令都能高效完成。

操作ZSet 命令复杂度
更新分数ZADDO(log n)
查 Top NZREVRANGEO(log n + m)
查个人排名ZREVRANKO(log n)
查个人分数ZSCOREO(1)
增量更新分数ZINCRBYO(log n)

7.2 实时积分排行榜#

def update_score(user_id: int, score_delta: int) -> float:
"""更新积分:ZINCRBY 原子增减"""
leaderboard_key = "leaderboard:daily"
# ZINCRBY 原子性地增加分数,返回更新后的总分
new_score = r.zincrby(leaderboard_key, score_delta, str(user_id))
return new_score
def get_top_n(n: int = 10) -> list[dict]:
"""获取 Top N:ZREVRANGE 降序取前 N"""
leaderboard_key = "leaderboard:daily"
# ZREVRANGE key 0 n-1 WITHSCORES:分数从高到低
results = r.zrevrange(leaderboard_key, 0, n - 1, withscores=True)
return [
{"user_id": int(member), "score": int(score)}
for member, score in results
]
def get_user_rank(user_id: int) -> dict:
"""查个人排名和分数"""
leaderboard_key = "leaderboard:daily"
# ZREVRANK 返回降序排名(0 开始),ZSCORE 返回分数
rank = r.zrevrank(leaderboard_key, str(user_id))
score = r.zscore(leaderboard_key, str(user_id))
if rank is None:
return {"rank": -1, "score": 0}
return {"rank": rank + 1, "score": int(score)}

7.3 分页排行榜#

排行榜数据量大时需要分页。ZSet 的 ZREVRANGE 支持按索引范围取,天然支持分页。

def get_leaderboard_page(page: int, page_size: int = 20) -> dict:
"""分页获取排行榜"""
leaderboard_key = "leaderboard:daily"
start = (page - 1) * page_size
end = start + page_size - 1
# ZREVRANGE 按分数降序取指定范围
results = r.zrevrange(leaderboard_key, start, end, withscores=True)
total = r.zcard(leaderboard_key) # 总人数
return {
"page": page,
"page_size": page_size,
"total": total,
"items": [
{"rank": start + i + 1, "user_id": int(member), "score": int(score)}
for i, (member, score) in enumerate(results)
]
}

7.4 围绕分数的处理#

排行榜常有一些细节需求:相同分数按时间排序、查看自己前后的名次、排行榜过期清理。

import time
def get_neighbors(user_id: int, count: int = 2) -> list[dict]:
"""查看自己前后 count 名"""
leaderboard_key = "leaderboard:daily"
rank = r.zrevrank(leaderboard_key, str(user_id))
if rank is None:
return []
# 取排名前后各 count 名
start = max(0, rank - count)
end = rank + count
results = r.zrevrange(leaderboard_key, start, end, withscores=True)
return [
{"rank": start + i + 1, "user_id": int(member), "score": int(score)}
for i, (member, score) in enumerate(results)
]
def cleanup_daily_leaderboard() -> None:
"""每日排行榜清理:用新 key 替换旧 key"""
today = time.strftime("%Y%m%d")
yesterday = (time.time() - 86400)
yesterday_str = time.strftime("%Y%m%d", time.localtime(yesterday))
old_key = f"leaderboard:{yesterday_str}"
# 用 RENAME 原子替换,或直接删除历史数据
r.delete(old_key)
# 当天排行榜设置过期时间,自动清理
today_key = f"leaderboard:{today}"
r.expire(today_key, 86400 * 7) # 7 天后自动删除
Tip

相同分数的排序问题:ZSet 默认对相同分数的元素按成员字典序排列。如果需要按达到分数的时间排序(先达到的排前面),可以把时间戳编码进 score。例如 score = 主分数 * 1000000 + (MAX_TIMESTAMP - 当前时间戳),这样分数相同先达到的 score 更大,排名更靠前。

八、运维与监控#

8.1 缓存命中率监控#

缓存命中率是衡量缓存效果的核心指标。命中率低说明缓存没起作用,请求大量打到数据库。计算公式:命中率 = keyspace_hits / (keyspace_hits + keyspace_misses)。

# 查看 Redis 命中统计
redis-cli INFO stats | grep -E "keyspace_hits|keyspace_misses"
# keyspace_hits:1523456
# keyspace_misses:82345
# 计算命中率
python3 -c "
hits=1523456; misses=82345
print(f'命中率: {hits/(hits+misses)*100:.2f}%')
"
# 命中率: 94.87%
Note

Redis 的 keyspace_hitskeyspace_misses 是全局累计值,重启后清零。计算某段时间的命中率需要取两个时间点的差值。Prometheus + redis_exporter 会自动做这个差值计算,直接暴露 hit_rate 指标。

命中率没有绝对标准,取决于业务场景。读多写少的缓存场景,命中率低于 90% 就要排查原因;冷数据多或访问分散的场景,60% 到 80% 也可能正常。关键是看趋势:命中率突然下降,往往对应某个缓存策略的调整或流量模式的变化。

8.2 大 key 扫描#

大 key(bigkey)是 Redis 性能杀手。一个包含几十万元素的 ZSet 或 List,在 DEL 或持久化时会阻塞主线程。Redis 自带 --bigkeys 选项可以扫描。

# 扫描 bigkey,-i 0.1 表示每 100 个 key 休眠 0.1 秒,降低对线上影响
redis-cli --bigkeys -i 0.1
# 示例输出
# -------- summary -------
# Biggest string found so far: 'session:abc123' with 5.2 MB
# Biggest list found so far: 'queue:tasks' with 128456 entries
# Biggest hash found so far: 'user:profile:123' with 523 fields
# Biggest zset found so far: 'rank:daily' with 234567 members

--bigkeys 用基于游标的 SCAN 遍历整个 keyspace,访问每一个 key,输出首行明确写 Scanning the entire keyspace,结尾报告 Sampled N keys in the keyspace(N 等于实际 key 总数)。这里的 Sampled 指对每个 key 统计其内存占用,不是对 key 做抽样跳过,不会漏掉大 key。-i 0.1 只是每 100 次 SCAN 休眠 0.1 秒以降低对线上影响,不影响扫描完整性。找到疑似大 key 后用 MEMORY USAGEDEBUG OBJECT 进一步确认。

# 查看单个 key 的内存占用
redis-cli MEMORY USAGE rank:daily
# (integer) 15892384 # 约 15 MB
# 查看元素数量
redis-cli ZCARD rank:daily
# (integer) 234567
# 大 key 用 UNLINK 异步删除,避免阻塞
redis-cli UNLINK rank:daily

8.3 热 key 排查#

热 key 是 QPS 远高于平均值的 key。单个 key 几万 QPS 会让所在节点的 CPU 成为瓶颈,集群模式下还会导致数据倾斜。Redis 6.0 引入了 --hotkeys 选项,依赖 maxmemory-policy 配置为 LFU 才能统计。

# 前提:必须配置 allkeys-lfu 或 volatile-lfu 策略
redis-cli CONFIG SET maxmemory-policy allkeys-lfu
# 扫描热 key
redis-cli --hotkeys
# 示例输出
# -------- sampling hotkeys --------
# -------- summary -------
# Sampled N keys in the keyspace!
# hot key found with counter: 6 keyname: "item:1001"
# hot key found with counter: 4 keyname: "user:session:xxx"

热 key 的处理方式取决于业务:

处理方式做法适用场景
本地缓存多级缓存,L1 挡住部分请求读多写少,容忍短暂不一致
拆分 key把一个 key 拆成多个副本(item:1001<0> 到 item:1001<9>可接受最终一致
集群迁移把热 key 迁移到负载低的节点集群模式,节点间可迁移
限流对热 key 的访问限流无法拆分的场景

8.4 maxmemory-policy 淘汰策略#

当 Redis 内存超过 maxmemory 限制时,会根据 maxmemory-policy 淘汰 key。缓存场景必须配置淘汰策略,否则写满内存后写入会失败。

# 查看当前淘汰策略
redis-cli CONFIG GET maxmemory-policy
# 1) "maxmemory-policy"
# 2) "noeviction"
# 设置为 allkeys-lru(缓存场景最常用)
redis-cli CONFIG SET maxmemory-policy allkeys-lru
# 设置内存上限
redis-cli CONFIG SET maxmemory 4gb

各策略的对比:

策略淘汰范围适用场景
noeviction不淘汰,写入报错持久化存储,不可丢数据
allkeys-lru所有 key 中淘汰最久未访问缓存,访问有时间局部性
allkeys-lfu所有 key 中淘汰最少访问缓存,访问有频率差异
volatile-lru设了 TTL 的 key 中淘汰最久未访问混合场景(部分数据持久化)
volatile-lfu设了 TTL 的 key 中淘汰最少访问同上
volatile-ttl设了 TTL 的 key 中淘汰快过期的业务明确 TTL 优先级
Tip

LRU 和 LFU 的选择:LRU 淘汰最久未访问的 key,适合访问有时间局部性的场景(最近访问的还会再访问)。LFU 淘汰访问频率最低的 key,适合有明显热点且冷数据多的场景。电商商品详情页、排行榜这类热点明确的场景用 LFU;通用缓存用 LRU。Redis 的 LRU 和 LFU 都是近似算法,基于 24 位 lru 字段做随机抽样淘汰,maxmemory-samples 控制抽样数(默认 5,调大更精确但更慢)。

8.5 运维速查表#

问题排查命令关注点
缓存命中率低INFO statskeyspace_hits/misses趋势下降需排查策略变更
大 key 阻塞--bigkeys 扫描 + MEMORY USAGE 确认DEL 大 key 用 UNLINK 替代
热 key 倾斜--hotkeys(需 LFU 策略)单 key QPS 超过阈值需拆分
内存打满INFO memoryused_memory vs maxmemory检查淘汰策略是否生效
延迟尖峰LATENCY HISTORY + latest_fork_usecbigkey、fork、AOF fsync 都会记录
淘汰过快INFO statsevicted_keys 增长速率内存不足或 TTL 太短

九、三大问题对比与选型#

问题本质触发条件核心解法
穿透查不存在的数据恶意请求或数据已删缓存空值 + 布隆过滤器
击穿热点 key 过期高并发访问的 key 失效互斥锁 + 逻辑过期
雪崩大量 key 同时失效TTL 集中或 Redis 宕机随机 TTL + 多级缓存 + 熔断

三个问题的解法有交叉:互斥锁既能防击穿也能防穿透(限制查 DB 的并发),多级缓存既能防雪崩也能兜底 Redis 宕机。实际选型不是二选一,而是分层组合。入口用参数校验挡掉非法请求,布隆过滤器挡掉不存在的 ID,空值缓存兜底误判,互斥锁保护热点 key,随机 TTL 分散过期,多级缓存应对 Redis 不可用,熔断降级做最后防线。每一层都不完美,但组合起来能覆盖绝大多数故障场景。

参考资料#

  • Redis 缓存最佳实践 - 官方文档,介绍服务端辅助的客户端缓存机制
  • 布隆过滤器原理 - 维基百科,概率数据结构的数学原理
  • RedisBloom 模块 - Redis 官方布隆过滤器模块文档,含 BF.RESERVE 等命令
  • singleflight 包 - Go 标准库,合并并发重复调用
  • Redis Lua 脚本 - 官方文档,EVAL/EVALSHA 与脚本原子性
  • Redis 内存淘汰策略 - 官方文档,LRU/LFU 近似算法与策略选择

支持与分享

如果这篇文章对你有帮助,欢迎支持作者或分享给更多人

Redis 缓存三大问题与实战
https://blog.souloss.cn/posts/middleware/cache/redis-cache-problems-and-practice/
作者
Souloss
发布于
2024-08-03
许可协议
CC BY-NC-SA 4.0

部分信息可能已经过时