缓存是 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 的读流程分为四步:
- 查缓存,命中则直接返回
- 未命中,查数据库
- 将数据库结果回写缓存
- 返回结果
import redisimport 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"}回写缓存时必须设置过期时间(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 再删缓存,在极端时序下仍有不一致窗口:
线程 1 查到旧值后还没回写,线程 2 完成了更新和删缓存,线程 1 随后把旧值写回缓存。缓存里就留了旧值,直到过期前一直不一致。这个场景触发概率低,需要读请求的回写动作恰好夹在写请求的两步之间,但一旦触发,影响时间等于 TTL。下一节的延迟双删就是为这个场景设计的。
二、缓存穿透:查询不存在的数据
2.1 现象
缓存穿透是指查询一个数据库中根本不存在的数据。由于缓存中也没有这条数据,每次请求都会穿透到数据库,缓存层形同虚设。
典型场景:恶意攻击者用大量不存在的 ID 请求接口(如 user/-1、user/999999999),或者业务上查询了已被物理删除的记录。这些 ID 在缓存和数据库中都不存在,每次查询都打到数据库。
穿透的危险在于:请求永远 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布隆过滤器的误判率(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 的请求集中在同一时刻。
典型场景:电商首页推荐位商品、热门直播间信息、秒杀活动商品详情。这些 key 平时 QPS 极高,一旦过期,瞬间几万请求同时打到数据库。
3.2 根因
击穿的根因是热点 key 过期与高并发请求的时间窗口重叠。Redis 的惰性删除策略下,key 过期后不会主动通知客户端,而是在第一次访问时发现已过期。如果这一刻有大量并发请求,它们都会读到 miss,然后各自去查数据库。
3.3 解决方案一:互斥锁
互斥锁的核心思想是:缓存 miss 后,只允许一个请求去查数据库并回写缓存,其他请求等待。
import timeimport 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)互斥锁的关键细节有三个:
- 双重检查:拿到锁后先查一次缓存,因为等待期间前一个持有锁的请求可能已经回写了。不做这步会导致重复查 DB。
- 锁的过期时间:必须设置,防止持有锁的进程崩溃导致死锁。
PX 10000表示 10 秒后自动释放。 - 安全释放:释放锁时用 Lua 脚本判断值是否匹配,避免误删别人的锁。如果直接
DEL,可能删掉已过期后被另一个请求重新获取的锁。
互斥锁的代价是:等待锁的请求会串行化,响应时间变长。在极端高并发下,等待队列可能超时。
3.4 解决方案二:逻辑过期
逻辑过期的思路是:不给 key 设置物理过期时间(TTL),而是在 value 里存一个逻辑过期时间戳。请求读取后判断是否逻辑过期,过期则异步触发刷新,当前请求仍返回旧数据。
import jsonimport timeimport 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}singleflight 的局限是只在单进程内有效。分布式部署下,多个实例的相同请求仍会各自查一次 DB。跨进程的合并需要分布式锁或 Redis 侧的 FCALL(Redis 7.0 的 Functions 特性)。单进程方案在大多数场景已经够用,因为真正打到同一实例同一 key 的并发量,远小于全局总并发量。
四、缓存雪崩:大量 key 同时失效
4.1 现象
缓存雪崩是指大量 key 在同一时刻集中过期,或者 Redis 服务整体宕机,导致数据库瞬时承受巨大压力。
和击穿的区别:击穿是单个热点 key 失效,雪崩是大量 key 同时失效。击穿是点,雪崩是面。
典型场景:批量预热的缓存数据设置了相同的 TTL(比如都是 1 小时),1 小时后全部同时过期;或者 Redis 主节点宕机,全部缓存不可用。
4.2 根因
雪崩的根因分两类:
- key 集中过期:批量加载数据时设置了相同或接近的过期时间,到期后集中失效。
- 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 randomimport timefrom 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上面的本地缓存回写用了 lru_cache 的简化写法,生产环境建议用 cachetools.TTLCache 或 fastcache,它们支持 TTL 过期和主动淘汰。lru_cache 没有 TTL 概念,只能靠容量淘汰,不适合做带时效的本地缓存。
多级缓存要注意本地缓存的一致性问题:多个实例的本地缓存各自独立,更新时只删 Redis 缓存不会清本地缓存,可能读到本地旧值。解决方法是本地缓存设置很短的 TTL(如 10 秒),牺牲一点一致性换取 Redis 宕机时的兜底能力。
4.5 解决方案三:熔断与降级
当数据库已经被压垮,缓存层短期无法恢复时,需要熔断和降级保护系统不被拖死。
import timefrom 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 timeimport 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 延迟时间的取值
延迟时间是延迟双删的核心参数,取值取决于两个因素:
- 读请求的耗时:从查 DB 到回写缓存的完整时间,包括 DB 查询、网络往返、序列化。
- 主从复制延迟:如果读走从库,写走主库,要等主从同步完成后再删,否则删了从库还没同步,读请求又从从库读到旧值回写。
实际取值:单机场景下读请求耗时通常在 10 到 50 毫秒,延迟设 100 毫秒以上即可。主从架构下主从复制延迟通常在毫秒到秒级,延迟需要设到 500 毫秒到 1 秒。如果业务对一致性要求极高,延迟可以设到 1 到 2 秒,代价是这段时间内缓存可能不一致。
延迟双删无法保证 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 里预扣库存,扣减成功才创建订单,支付确认后再真正扣减数据库库存。如果超时未支付,预扣的库存回滚。
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_idif 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 0end
-- 记录预扣订单,用于回滚和幂等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_idif redis.call('EXISTS', dedup_key) == 1 then return 1end
local current = redis.call('GET', stock_key)if not current then return -2end
current = tonumber(current)if current < deduct_amount then return 0end
local remaining = redis.call('DECRBY', stock_key, deduct_amount)if remaining < 0 then redis.call('INCRBY', stock_key, deduct_amount) return 0end
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 False6.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 1ROLLBACK_SCRIPT = """local stock_key = KEYS[1]local rollback_amount = tonumber(ARGV[1])local order_id = ARGV[2]
local dedup_key = "dedup:" .. order_idif redis.call('EXISTS', dedup_key) == 0 then return 0end
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 == 16.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)库存预扣方案在 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 命令 | 复杂度 |
|---|---|---|
| 更新分数 | ZADD | O(log n) |
| 查 Top N | ZREVRANGE | O(log n + m) |
| 查个人排名 | ZREVRANK | O(log n) |
| 查个人分数 | ZSCORE | O(1) |
| 增量更新分数 | ZINCRBY | O(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 天后自动删除相同分数的排序问题: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=82345print(f'命中率: {hits/(hits+misses)*100:.2f}%')"# 命中率: 94.87%Redis 的 keyspace_hits 和 keyspace_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 USAGE 和 DEBUG 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:daily8.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
# 扫描热 keyredis-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>0> 到 item:1001<9>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 优先级 |
LRU 和 LFU 的选择:LRU 淘汰最久未访问的 key,适合访问有时间局部性的场景(最近访问的还会再访问)。LFU 淘汰访问频率最低的 key,适合有明显热点且冷数据多的场景。电商商品详情页、排行榜这类热点明确的场景用 LFU;通用缓存用 LRU。Redis 的 LRU 和 LFU 都是近似算法,基于 24 位 lru 字段做随机抽样淘汰,maxmemory-samples 控制抽样数(默认 5,调大更精确但更慢)。
8.5 运维速查表
| 问题 | 排查命令 | 关注点 |
|---|---|---|
| 缓存命中率低 | INFO stats 看 keyspace_hits/misses | 趋势下降需排查策略变更 |
| 大 key 阻塞 | --bigkeys 扫描 + MEMORY USAGE 确认 | DEL 大 key 用 UNLINK 替代 |
| 热 key 倾斜 | --hotkeys(需 LFU 策略) | 单 key QPS 超过阈值需拆分 |
| 内存打满 | INFO memory 看 used_memory vs maxmemory | 检查淘汰策略是否生效 |
| 延迟尖峰 | LATENCY HISTORY + latest_fork_usec | bigkey、fork、AOF fsync 都会记录 |
| 淘汰过快 | INFO stats 看 evicted_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 近似算法与策略选择
支持与分享
如果这篇文章对你有帮助,欢迎支持作者或分享给更多人
部分信息可能已经过时






