缓存策略——库存变了,缓存怎么处理 / Cache Strategies and Inventory Consistency
📅 创建时间:2026-05-08 🏷️ 标签:#缓存策略 #Cache-Aside #Write-Through #延时双删 #多级缓存 #数据一致性 📚 前置知识:[[00-backend-overview]] [[01-redis-deep]](Redis 三问) [[09-mysql-optimization]](MySQL 优化) 📚 相关知识:[[/03-web/06-databases-and-data-access/05-redis]](Redis 基础)
场景:用户看到的库存和实际库存不一致了
┌─────────────────────────────────────────────────────────────┐
│ │
│ 运营发现:商品 A 显示库存 100,但实际只有 10 了。 │
│ │
│ 排查过程: │
│ 管理员手动调整了库存:MySQL: 100 → 10 │
│ 但 Redis 缓存还是:100 │
│ │
│ 期间有 90 个用户下单成功,但仓库实际只有 10 件货。 │
│ │
│ 用户投诉:我们买到了,但你们说没货了。 │
│ │
└─────────────────────────────────────────────────────────────┘这一章,我们理解缓存和数据库之间的数据一致性。
第1节:四种经典缓存模式
模式一:Cache-Aside(旁路缓存,最常用)
┌─────────────────────────────────────────────────────────────┐
│ Cache-Aside(旁路缓存) │
├─────────────────────────────────────────────────────────────┤
│ │
│ 读操作: │
│ ┌─────────────────────────────────────────────────────┐ │
│ │ 1. 查缓存 │ │
│ │ cache = redis.get(key) │ │
│ │ if cache: return cache ✅ 命中,返回 │ │
│ │ │ │
│ │ 2. 缓存 miss,查数据库 │ │
│ │ db = mysql.query(key) │ │
│ │ │ │
│ │ 3. 写入缓存 │ │
│ │ redis.setex(key, ttl, db) │ │
│ │ return db │ │
│ └─────────────────────────────────────────────────────┘ │
│ │
│ 写操作: │
│ ┌─────────────────────────────────────────────────────┐ │
│ │ 1. 写数据库 │ │
│ │ mysql.update(key, value) │ │
│ │ │ │
│ │ 2. 删除缓存(注意是删除,不是更新!) │ │
│ │ redis.del(key) │ │
│ └─────────────────────────────────────────────────────┘ │
│ │
│ 为什么写操作是"删除缓存"而不是"更新缓存"? │
│ 如果更新缓存时失败了 → 缓存和数据库不一致 │
│ 如果删除缓存时失败了 → 下次读会从数据库加载最新数据 │
│ │
│ 适用场景:读多写少的业务(90% 互联网场景) │
│ │
└─────────────────────────────────────────────────────────────┘模式二:Read-Through(读穿透)
python
# Cache-Aside:应用自己负责读写缓存和数据库
# Read-Through:缓存层自动帮你加载
# 用户代码:只查缓存
cache.get(key)
# 缓存层自动完成:
# if cache miss: → 从数据库加载 → 存入缓存 → 返回模式三:Write-Through(写穿透)
python
# 写操作:同时写缓存和数据库
def update(key, value):
# 缓存和数据库同时更新
mysql.update(key, value)
redis.set(key, value)
# 要么都成功,要么都失败(需要事务或补偿)模式四:Write-Behind(写回)
python
# 写操作:只写缓存,异步写数据库
def update(key, value):
# 立即写缓存
redis.set(key, value)
# 异步写数据库(通过消息队列)
mq.send("update_db", {"key": key, "value": value})
# 后台消费者处理消息队列,定期同步到数据库四种模式对比
┌─────────────────────────────────────────────────────────────┐
│ 四种缓存模式对比 │
├─────────────────────────────────────────────────────────────┤
│ │
│ │ 模式 │ 读一致性 │ 写一致性 │ 复杂度 │ 适用场景 │
│ ├─────────────┼──────────┼──────────┼────────┼──────────┤
│ │ Cache-Aside│ 较高 │ 中 │ 低 │ 读多写少 │
│ ├─────────────┼──────────┼──────────┼────────┼──────────┤
│ │ Read-Through│ 高 │ 高 │ 中 │ 读多写少 │
│ ├─────────────┼──────────┼──────────┼────────┼──────────┤
│ │ Write-Through│ 高 │ 高 │ 高 │ 写多读少 │
│ ├─────────────┼──────────┼──────────┼────────┼──────────┤
│ │ Write-Behind │ 低 │ 低 │ 高 │ 极高写入 │
│ │
│ 大部分互联网场景:Cache-Aside(旁路缓存) │
│ │
└─────────────────────────────────────────────────────────────┘第2节:缓存一致性的核心问题——双写问题
场景:缓存和数据库同时写,谁先谁后
┌─────────────────────────────────────────────────────────────┐
│ │
│ 方案 A:先更新数据库,再更新缓存 │
│ │
│ T1: 更新 MySQL stock = 10 │
│ T2: 更新 Redis stock = 10 ← 此时缓存是最新的 │
│ T3: 读请求:Redis = 10 ✅ │
│ │
│ 问题:T1 成功后、T2 执行前,系统崩溃了 │
│ → MySQL = 10,Redis = 旧值 │
│ │
│ 方案 B:先更新缓存,再更新数据库 │
│ │
│ T1: 更新 Redis stock = 10 │
│ T2: 读请求:Redis = 10 ✅ │
│ T3: 更新 MySQL stock = 10 ← 此时缓存是最新的 │
│ │
│ 问题:T1 成功后、T3 执行前,系统崩溃了 │
│ → MySQL = 旧值,Redis = 10 │
│ → 下次读还是走缓存,数据不一致 │
│ │
│ 结论:无论谁先谁后,都无法保证强一致性 │
│ │
└─────────────────────────────────────────────────────────────┘解决方案:延时双删
python
def update_stock(product_id, new_stock):
# 1. 先删缓存
redis.del(f"product:{product_id}")
# 2. 更新数据库
mysql.execute("UPDATE products SET stock = %s WHERE id = %s",
new_stock, product_id)
# 3. 延时再删一次(关键!)
# 等待一小段时间,让可能正在读取旧缓存的请求完成
# 然后再删一次缓存
import time
time.sleep(0.5) # 500ms,等待读请求完成
redis.del(f"product:{product_id}")为什么延时双删有效
┌─────────────────────────────────────────────────────────────┐
│ 延时双删的原理 │
├─────────────────────────────────────────────────────────────┤
│ │
│ 时间线: │
│ │
│ T1: DELETE cache ← 删旧缓存 │
│ T2: UPDATE mysql ← 更新数据库 │
│ T3: 线程 A 读请求:cache miss → 读 MySQL(旧值)→ 写入旧缓存 │
│ T4: DELETE cache ← 再删一次(删掉线程 A 的旧缓存) │
│ T5: 线程 B 读请求:cache miss → 读 MySQL(新值)→ 写入新缓存 │
│ │
│ 结果:缓存里是最新的值 ✅ │
│ │
│ sleep 时间怎么定? │
│ 经验值:0.5-1 秒(读请求的平均耗时) │
│ 更精确:监控读请求的 P99 耗时,设为 2 倍 │
│ │
└─────────────────────────────────────────────────────────────┘第3节:多级缓存——本地缓存 + Redis
问题:Redis 虽然快,但还是要走网络
┌─────────────────────────────────────────────────────────────┐
│ │
│ 场景:热点数据(如商品详情) │
│ │
│ L1(本地缓存):进程内存,0 延迟 │
│ L2(Redis):网络调用,0.5-1ms 延迟 │
│ L3(MySQL):磁盘 IO,5-10ms 延迟 │
│ │
│ 优化: │
│ L1 命中 → 直接返回(不查 Redis) │
│ L1 未命中 → 查 Redis → 查 MySQL │
│ │
└─────────────────────────────────────────────────────────────┘多级缓存实现
python
import time
import threading
from functools import lru_cache
# Python 本地缓存:Caffeine(Java)/ Guava(Java)
# Python 可以用 dict + TTL 或 lru_cache
class MultiLevelCache:
def __init__(self, redis_client, mysql_client):
self.redis = redis_client
self.db = mysql_client
# L1:本地缓存(dict + 过期时间)
self.l1_cache = {}
self.l1_lock = threading.Lock()
self.l1_ttl = 5 # 本地缓存 5 秒
def get(self, product_id):
key = f"product:{product_id}"
# L1:本地缓存
with self.l1_lock:
if key in self.l1_cache:
entry = self.l1_cache[key]
if time.time() - entry["ts"] < self.l1_ttl:
return entry["data"] # L1 命中
del self.l1_cache[key]
# L2:Redis
val = self.redis.get(key)
if val:
data = json.loads(val)
# 回填 L1
with self.l1_lock:
self.l1_cache[key] = {"data": data, "ts": time.time()}
return data
# L3:MySQL
data = self.db.query("SELECT * FROM products WHERE id = %s", product_id)
if data:
self.redis.setex(key, 3600, json.dumps(data))
with self.l1_lock:
self.l1_cache[key] = {"data": data, "ts": time.time()}
return data
def invalidate(self, product_id):
key = f"product:{product_id}"
self.redis.del(key)
with self.l1_lock:
if key in self.l1_cache:
del self.l1_cache[key]本地缓存的问题:数据更新后怎么通知
┌─────────────────────────────────────────────────────────────┐
│ 本地缓存的数据同步问题 │
├─────────────────────────────────────────────────────────────┤
│ │
│ 问题:多实例部署时,实例 A 更新了数据 │
│ 实例 B 的本地缓存还是旧数据 │
│ │
│ 解决: │
│ 1. L1 缓存 TTL 设短(5-10 秒) │
│ → 旧数据最多存 10 秒 │
│ │
│ 2. 发布-订阅:数据变更时发布消息 │
│ 实例 A 更新数据 → 通知 Redis PubSub → 实例 B 清理本地缓存 │
│ │
│ 3. 主动查询:后台定期刷新 │
│ 定时任务对比 L1 和 Redis,不一致时清理 │
│ │
│ 最佳实践: │
│ 本地缓存只存"允许短暂不一致"的数据 │
│ 库存等强一致性数据:只用 Redis,不加 L1 │
│ │
└─────────────────────────────────────────────────────────────┘第4节:缓存预热——系统启动后缓存是空的
场景:秒杀系统重启后,所有请求同时打数据库
┌─────────────────────────────────────────────────────────────┐
│ │
│ 凌晨 3 点,系统发布重启。 │
│ │
│ 凌晨 3:01,监控报警:MySQL CPU 100% │
│ │
│ 原因:Redis 缓存是空的,所有请求穿透到 MySQL │
│ 10000 QPS × MySQL = 10000 QPS → 爆炸 │
│ │
│ 解决:缓存预热——系统启动后提前加载数据到缓存 │
│ │
└─────────────────────────────────────────────────────────────┘预热方案
python
def warmup_cache():
"""系统启动时执行"""
print("开始缓存预热...")
# 1. 预热热点数据(TOP 1000 商品)
hot_products = db.query("""
SELECT * FROM products
WHERE status = 'active'
ORDER BY view_count DESC
LIMIT 1000
""")
for p in hot_products:
redis.setex(f"product:{p['id']}", 86400, json.dumps(p))
# 2. 预热用户数据(最近活跃的用户)
active_users = db.query("""
SELECT * FROM users
WHERE last_login > DATE_SUB(NOW(), INTERVAL 7 DAY)
LIMIT 10000
""")
for u in active_users:
redis.setex(f"user:{u['id']}", 3600, json.dumps(u))
# 3. 预热配置数据
configs = db.query("SELECT * FROM configs")
for c in configs:
redis.setex(f"config:{c['key']}", 86400, c['value'])
print(f"预热完成:{len(hot_products)} 商品,{len(active_users)} 用户")升华:缓存一致性的 trade-off
┌─────────────────────────────────────────────────────────────┐
│ 缓存一致性的四个层次 │
├─────────────────────────────────────────────────────────────┤
│ │
│ 强一致性(CP): │
│ → 不用缓存,所有请求直接查数据库 │
│ → 性能差,但数据一定正确 │
│ │
│ 最终一致性(AP): │
│ → Cache-Aside + 延时双删 │
│ → 有短暂不一致窗口(通常 <1 秒),但性能好 │
│ │
│ 弱一致性: │
│ → 本地缓存 + 短 TTL │
│ → 不一致窗口可能更长,但用户体验好 │
│ │
│ 业务兜底: │
│ → 库存超卖:下单时再检查一次库存 │
│ → 最终以数据库为准 │
│ │
│ 一句话总结: │
│ 强一致性代价高,最终一致性性价比好。 │
│ 关键数据的兜底检查永远不能省。 │
│ │
└─────────────────────────────────────────────────────────────┘"AI 可查 vs 必须理解"清单
AI 可查:
✅ 各语言的多级缓存实现(Caffeine / Guava / Python dict)
✅ Redis PubSub 的具体 API
✅ 缓存预热脚本的具体写法
必须理解:
🔴 Cache-Aside 的读写流程(为什么读是"命中返回+miss查库+写回",写是"先库后删")
🔴 为什么不能"更新缓存"而要"删除缓存"
🔴 延时双删的 sleep 时间怎么定
🔴 多级缓存中 L1 本地缓存的数据同步问题学习状态:🟡 开始学习