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缓存策略全景。你看一眼,基本就全明白了。

Redis缓存策略全景图 Redis缓存 数据结构选择 String:单库存 Hash:多字段 List:流水日志 三大杀手应对方案 穿透:布隆过滤器 + 缓存空对象 击穿:互斥锁 + 逻辑过期 雪崩:TTL随机化 + 多级缓存 TTL策略:动态调整 + 配置中心

这张图把本章的核心内容串起来了。你写代码的时候,可以对照着看,不容易漏掉关键点。

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