4、Redis缓存策略设计:库存热点数据缓存、缓存穿透/击穿/雪崩的应对方案、Redis数据结构选择(String/Hash/List)、TTL策略
库存系统,说白了就是跟“高并发”死磕。
我做过好几个库存类的项目,每次上线前最担心的不是业务逻辑写错,而是——Redis扛不扛得住?缓存会不会崩?
这一章,我就把我在实战中踩过的坑、用过的方案,掰开了讲给你听。
4.1 库存热点数据缓存:为什么必须用Redis?
先问个问题:库存数据直接查MySQL行不行?
行,但仅限于日活几百的系统。一旦秒杀、抢购场景出现,MySQL的行锁会直接把你拖死。我见过一个项目,双十一当天MySQL的CPU直接飙到100%,原因就是库存查询全部走数据库。
所以,库存热点数据必须放Redis。原因有三:
- 速度:Redis是内存操作,单机QPS能到10万+,MySQL也就几千。
- 原子性:Redis的DECR、INCR命令天然支持原子扣减,不用加锁。
- 简单:库存操作无非就是“查、扣、还”,Redis的数据结构刚好够用。
核心原则:库存数据在Redis里是“权威数据”,MySQL只做最终落盘和审计。千万别搞反了。
4.2 缓存穿透、击穿、雪崩:三个“杀手”的应对方案
这三个词你肯定听过。但真正在项目里遇到时,很多人还是会手忙脚乱。我一个个说。
4.2.1 缓存穿透
什么是穿透?就是请求查了一个根本不存在的数据。比如用户查一个不存在的商品ID,Redis里没有,MySQL里也没有。每次请求都穿透到数据库,DB压力直接爆炸。
我曾经在一个老项目里见过,有人写了个脚本疯狂请求不存在的商品ID,MySQL连接池瞬间被打满。嗯,这就是典型的缓存穿透攻击。
解决方案:
- 布隆过滤器:把所有合法商品ID提前加载到布隆过滤器里。请求来了先过过滤器,不存在直接返回。我习惯用Redisson的RBloomFilter,开箱即用。
- 缓存空对象:如果查不到数据,也缓存一个空值(比如"null"),TTL设短一点,比如30秒。这样同样的请求不会打到DB。
我的建议:两种方案结合用。布隆过滤器挡掉大部分非法请求,缓存空对象兜底。别只依赖一种。
4.2.2 缓存击穿
击穿和穿透不一样。击穿是指一个热点key突然过期,大量请求同时打到DB上。比如某个爆款商品的库存key刚好在秒杀期间过期了,那瞬间的流量能把DB打挂。
我记得有一次线上事故,就是因为我们把某个热门商品的缓存TTL设成了固定值,结果整点秒杀时key刚好过期,MySQL直接挂了5分钟。
解决方案:
- 互斥锁(Mutex):当key过期时,只让一个线程去查DB并重建缓存,其他线程等待。我常用SETNX实现分布式锁。
- 逻辑过期:不给key设物理TTL,而是在value里存一个过期时间字段。每次读取时判断是否逻辑过期,如果过期则异步更新缓存。这样不会出现“同时大量请求打到DB”的情况。
注意:互斥锁方案会降低并发性能,适合写多读少的场景。逻辑过期方案适合读多写少的场景。你自己权衡。
4.2.3 缓存雪崩
雪崩比击穿更可怕。击穿是一个key,雪崩是一大片key同时过期。比如你给所有商品库存设了相同的TTL(比如1小时),那每到整点,所有key一起过期,DB瞬间被海量请求淹没。
解决方案:
- TTL随机化:给每个key的TTL加一个随机偏移量,比如基础值3600秒,再随机加0~600秒。这样过期时间就分散了。
- 多级缓存:Redis做一级缓存,本地内存(比如Caffeine)做二级缓存。即使Redis挂了,本地缓存还能扛一会儿。
- 限流降级:在Redis和DB之间加一层限流,比如Sentinel或Guava RateLimiter。流量太大时直接返回“系统繁忙”。
一句话总结:穿透靠过滤,击穿靠锁,雪崩靠随机。别搞混了。
4.3 Redis数据结构选择:String、Hash、List怎么选?
库存数据用什么数据结构?很多人上来就用String。但说实话,String不是万能的。
| 数据结构 | 适用场景 | 优点 | 缺点 |
|---|---|---|---|
| String | 单个商品的库存量 | 简单、支持INCR/DECR原子操作 | 无法存储多个字段,比如库存+锁定库存+已售 |
| Hash | 一个商品多个属性(库存、锁定、已售) | 可以同时管理多个字段,节省内存 | 操作稍复杂,需要HINCRBY |
| List | 库存扣减的流水记录、队列 | 支持LPUSH/RPOP,天然适合做队列 | 不适合直接做库存扣减,因为不是原子操作 |
我个人习惯:
- 如果只是“扣库存、还库存”,用String就够了。key是商品ID,value是库存数量。
- 如果需要记录“总库存、锁定库存、可用库存”三个字段,用Hash。比如:
HSET stock:1001 total 100 locked 20 available 80。 - List我一般不用来做库存扣减,而是用来做库存变更日志。比如每次扣减都LPUSH一条记录,方便后续对账。
避坑指南:我曾经用String存库存,后来业务要求加“锁定库存”字段,结果不得不把String改成Hash,迁移数据折腾了一整晚。所以一开始就设计好,别偷懒。
4.4 TTL策略:过期时间怎么设?
TTL设太短,频繁查DB;设太长,数据不一致。这是个平衡问题。
我总结了几条经验:
- 热点商品:TTL设短一点,比如30秒到1分钟。因为热点商品库存变化快,需要及时同步。
- 普通商品:TTL设长一点,比如5分钟到10分钟。库存变化不频繁,没必要频繁刷新。
- 秒杀/抢购期间:干脆不设TTL,或者设一个很长的TTL(比如24小时)。等秒杀结束后再统一清理。
- 空值缓存:TTL一定要短,30秒以内。否则会导致“数据已经存在了,但缓存还是空”的问题。
核心思路:TTL不是固定的,要根据业务动态调整。我习惯在配置中心(比如Nacos)里维护一套TTL规则,随时可以改,不用重启服务。
4.5 知识体系图:Redis缓存策略全景
下面这张图,是我自己总结的Redis缓存策略全景。你看一眼,基本就全明白了。
这张图把本章的核心内容串起来了。你写代码的时候,可以对照着看,不容易漏掉关键点。
4.6 实战代码片段:库存扣减(带缓存策略)
最后,给一段我常用的库存扣减代码。你拿去改改就能用。
import redis
import time
r = redis.Redis(host='localhost', port=6379, db=0)
def deduct_stock(product_id, quantity):
# 1. 布隆过滤器拦截(假设已初始化)
if not bloom_filter.contains(product_id):
return False, "商品不存在"
# 2. 尝试获取分布式锁(防止击穿)
lock_key = f"lock:stock:{product_id}"
if r.setnx(lock_key, "1"):
r.expire(lock_key, 3) # 锁超时3秒
try:
# 3. 检查缓存是否存在
stock_key = f"stock:{product_id}"
if not r.exists(stock_key):
# 缓存不存在,从DB加载(防止穿透)
db_stock = load_from_db(product_id)
if db_stock is None:
# 缓存空对象,TTL 30秒
r.setex(stock_key, 30, "null")
return False, "商品不存在"
else:
r.setex(stock_key, 60, db_stock)
# 4. 原子扣减
current = r.decr(stock_key, quantity)
if current < 0:
# 库存不足,回滚
r.incr(stock_key, quantity)
return False, "库存不足"
# 5. 异步写DB(略)
return True, "扣减成功"
finally:
r.delete(lock_key)
else:
# 没拿到锁,等待重试
time.sleep(0.1)
return deduct_stock(product_id, quantity)
注意:上面的代码用了递归重试,生产环境建议用循环+最大重试次数,防止死循环。
好了,这一章的内容就这些。缓存策略这东西,光看理论没用,你得真去写、真去压测,才能体会到每个细节的重要性。我当年也是踩了无数坑才总结出这些经验的。
公众号:蓝海资料掘金营,微信deep3321