秒杀系统架构设计04:缓存架构
秒杀缓存架构击穿、穿透、雪崩三大战役本文是「10Wqps 秒杀架构」系列的第四篇聚焦秒杀场景下最核心的性能优化手段——缓存。我们将逐一攻克缓存的三大经典问题击穿、穿透、雪崩。一、开篇秒杀的库存查询是典型的读多写少场景。以上一篇中的数字为例200 万用户抢 10 万件商品最终只有 10 万人能成功预扣库存。这意味着有 190 万次请求在查询库存后得到已抢完的结果——这些全是读操作。如果这 190 万次读全部落在 MySQL 上以单库 2000 qps 的写入能力来算仅是读请求就足以让数据库崩溃。何况 MySQL 的连接数是稀缺资源通常配置在 200-500 之间根本不足以支撑秒杀级别的并发。缓存Redis是唯一的解单机 Redis 可以支撑 5W-10W qps 的读操作集群部署能轻松达到数十万级别。但引入缓存后三个经典问题随之而来——缓存击穿、缓存穿透、缓存雪崩。这三个问题如果处理不好轻则性能劣化重则缓存形同虚设、数据库被打垮。二、问题拆解2.1 为什么不能只用数据库数十万秒杀请求 ↓ 直接查 MySQL ↓ 连接池耗尽 (max 500 connections) ↓ 请求排队 → 超时 → 服务假死 → 雪崩MySQL 的连接模型是一个连接一个线程连接数不能无限制扩大。秒杀瞬间的数万并发请求如果全部打到 MySQL连接池会在几十毫秒内耗尽后续请求全部排队等待连接最终超时。即使加只读实例读扩展也有上限。2.2 引入缓存后的基本流程请求查商品信息 ↓ 先查 Redis 缓存 ├── 命中 → 返回参与秒杀 └── 未命中 → 查 MySQL → 写入 Redis → 返回这个看似简单的流程在秒杀的高并发环境下会暴露出三个致命问题。三、核心方案3.1 缓存击穿热点 Key 失效的瞬间洪流问题描述某个热点 Key如秒杀商品的库存信息在 Redis 中设置了过期时间。如果在 Key 刚好失效的瞬间大量并发请求同时涌入这些请求会发现缓存中没有数据然后全部穿透到 MySQL 查询同一条记录。场景商品 A 的 Redis 缓存在 T0 时刻过期 T0 1ms: 请求 1 → Redis 查不到 → 请求 2 → Redis 查不到 → ... T0 5ms: 1000 个请求同时查询 MySQL 的同一条记录 T0 20ms: MySQL 连接数飙升CPU 100%解决方案分布式锁 双重检查publicProductgetProduct(StringproductId){// 1. 先查缓存Productproductredis.get(product:productId);if(product!null){returnproduct;}// 2. 获取分布式锁只让一个请求查 DBStringlockKeylock:product:productId;RLocklockredissonClient.getLock(lockKey);try{lock.lock(5,TimeUnit.SECONDS);// 3. 双重检查获取锁后再次查缓存可能前一个持有者已写入productredis.get(product:productId);if(product!null){returnproduct;}// 4. 查数据库productmysql.query(SELECT * FROM product WHERE id ?,productId);if(product!null){redis.set(product:productId,product,30,TimeUnit.MINUTES);}returnproduct;}finally{lock.unlock();}}关键点只让一个请求查 DB分布式锁保证同一时刻只有一个线程查询数据库双重检查获取锁后再次查缓存避免在等待锁期间缓存已被前一个线程写入缓存预热是最佳实践在秒杀活动开始前将热点商品信息全部预加载到 Redis从根源上消除击穿为什么本地锁不够本地锁synchronized/ReentrantLock只能锁住当前 JVM 内的线程。在微服务架构下秒杀服务部署了 N 个实例每个实例的本地锁互不影响。如果总库存是 100实例 A 用本地锁扣到 99实例 B 也用自己的本地锁扣到 99——两个实例同时扣减成功最终库存变成 99 而不是 98出现了超卖。分布式锁必须用 Redisson 而不是简单 setnx expire原因在于setnx 和 expire 是两个独立命令不具备原子性——setnx 成功但 expire 失败会导致死锁即使使用SET key value NX EX timeout原子命令还需要处理锁续期问题——业务执行时间可能超过过期时间Redisson 的 WatchDog 机制会在持有锁期间每隔 10 秒自动续期续到 30 秒确保业务执行完成前锁不会意外释放。具体实现将在 库存扣减与数据一致性篇 详述。3.2 缓存穿透查询不存在的数据问题描述攻击者或 bug不断查询一个数据库中也不存在的商品 ID。因为这个 ID 在 Redis 和 MySQL 中都不存在每次查询都会穿透缓存直接访问 MySQL。当大量此类请求涌入时MySQL 同样会被打垮。攻击者: GET /seckill/product?id-99999 → Redis 无此 key → MySQL 也无此记录 → 返回 null → 缓存未写入因为结果是 null → 下次请求再次穿透解决方案一缓存空值publicProductgetProduct(StringproductId){Productproductredis.get(product:productId);if(product!null){returnproduct;}// 检查是否已缓存不存在标记StringnullMarkerredis.get(product:null:productId);if(NULL.equals(nullMarker)){returnnull;}productmysql.query(SELECT * FROM product WHERE id ?,productId);if(product!null){redis.set(product:productId,product,30,TimeUnit.MINUTES);}else{// 缓存 null 值设置较短的过期时间5 分钟redis.set(product:null:productId,NULL,5,TimeUnit.MINUTES);}returnproduct;}缓存 null 值是一种轻量方案但有两个局限一是占用额外的缓存空间如果攻击者用大量不存在的 ID 轰炸二是过期时间不好把握。解决方案二布隆过滤器 — 穿透问题的重型武器布隆过滤器Bloom Filter是一种空间效率极高的概率性数据结构用于判断一个元素是否可能存在于集合中。它的关键特性是如果布隆过滤器说不存在那一定不存在100% 确定如果布隆过滤器说可能存在那可能真的存在也可能不存在有一定的误判率通常在 1-3%// 初始化布隆过滤器在服务启动时BloomFilterStringbloomFilterBloomFilter.create(Funnels.stringFunnel(Charset.defaultCharset()),1000000,// 预期元素数量0.01// 误判率 1%);// 将所有已存在的商品 ID 加入布隆过滤器ListStringproductIdsmysql.queryAllProductIds();for(Stringid:productIds){bloomFilter.put(id);}// 查询时先过布隆过滤器publicProductgetProduct(StringproductId){// 布隆过滤器说不存在 → 直接返回不查缓存也不查 DBif(!bloomFilter.mightContain(productId)){returnnull;}// 可能存在 → 走正常的缓存 → DB 流程returngetProductFromCacheOrDb(productId);}布隆过滤器的局限性在于数据一致性。当新增商品或删除商品时需要同步更新布隆过滤器。如果缓存中的数据发生了变化而布隆过滤器没有同步就会产生误判。因此布隆过滤器适用于数据变更频率很低的场景对于变更频繁的数据更推荐缓存 null 值的方案。两种方案对比方案内存占用实现复杂度适用场景缓存 null 值低仅不存在的 ID低一般场景布隆过滤器极低1M 元素约 1MB中数据变更少、ID 空间大的场景前端校验无低作为补充手段拦截明显的非法 ID3.3 缓存雪崩大规模缓存同时失效问题描述有两种情况会触发缓存雪崩大量 Key 在同一时间过期比如为所有商品缓存设置了相同的过期时间00:00:00零点一到全部失效。Redis 集群宕机整个缓存层不可用所有请求直接打到 MySQL。这两种情况的后果是一样的MySQL 瞬间承受全量请求崩溃然后即使重启数据库也会被新的流量再次打挂——形成一个启动即死的恶性循环。场景模拟正常情况: 5000 qps → Redis 扛 4000 qps → MySQL 扛 1000 qps ✓ 雪崩发生: 5000 qps → Redis 不可用 → MySQL 扛 5000 qps ✗ → 崩溃 重启 DB: 5000 qps → Redis 仍不可用 → MySQL 刚启动就被打挂 ✗解决方案多层防御体系第一层过期时间随机化// 所有缓存的过期时间加上随机偏移intbaseExpire30;// 基础过期 30 分钟intrandomOffsetThreadLocalRandom.current().nextInt(1,6);// 随机 1-5 分钟redis.set(key,value,baseExpirerandomOffset,TimeUnit.MINUTES);通过将过期时间分散在 30-35 分钟之间避免大量 Key 在同一时刻同时过期。第二层多级缓存请求 → Caffeine 本地缓存一级微秒级延迟 → 未命中 → Redis 集群二级毫秒级延迟 → 未命中 → MySQL三级作为兜底每级缓存的过期时间不同进一步分散失效风险。第三层热点数据永不过期对秒杀活动中的核心数据如商品信息、活动配置设置永不过期通过主动更新而非被动过期来刷新缓存。如果必须设置过期时间通过定时任务在过期前主动刷新。第四层熔断降级兜底当 Redis 不可用时触发熔断——暂停对 Redis 的调用直接走降级逻辑返回默认值或提示系统繁忙保护 MySQL 不被冲垮。熔断器的使用将在 高可用篇 详述。第五层Redis 集群高可用Redis Cluster (3 主 6 从 3 哨兵) ├── Master-1 (分片 0-5460) │ ├── Slave-1 (同机房) │ └── Slave-2 (跨机房) ├── Master-2 (分片 5461-10922) │ └── ... └── Master-3 (分片 10923-16383) └── ...3 主 6 从 哨兵模式单个节点故障时自动切换确保缓存层本身的可用性。3.4 三大问题的对比总结问题触发条件核心危害首选方案兜底方案缓存击穿热点 Key 过期 高并发大量并发查同一条 DB 记录分布式锁 缓存预热双重检查缓存穿透查询不存在的数据大量无效查询穿透到 DB布隆过滤器缓存 null 值 前端校验缓存雪崩大量 Key 同时过期 / Redis 宕机DB 瞬间承受全量压力过期时间随机化 多级缓存熔断降级四、边界与异常4.1 缓存预热失败预热脚本可能在执行中出错如 Redis 连接超时导致部分商品未加载到缓存。应对预热脚本增加重试逻辑3 次失败后告警业务代码中仍然保留缓存未命中查 DB的逻辑作为兜底。4.2 布隆过滤器误判由于布隆过滤器的概率特性可能存在说不存在但实际存在的漏判。应对布隆过滤器只用于快速拦截肯定不存在的数据对可能存在的结果后端仍需查询缓存/DB 确认。4.3 Redis 主从切换时的数据不一致在主从切换的瞬间通常 300ms可能有少量写入丢失。对于秒杀场景这意味着少量库存预扣记录丢失。应对方案在 库存扣减篇 详细讨论核心是通过定时对账修复不一致。4.4 多级缓存的更新传播延迟Caffeine 本地缓存更新后其他实例的 Caffeine 可能还在使用旧值。对于库存这类强一致性数据本地缓存不应缓存库存值而应该只缓存相对稳定的商品元数据。五、总结缓存是秒杀系统的性能基石Redis 的单机 10W qps 能力使得在读多写少的秒杀场景下可以将绝大部分请求拦截在数据库之前。击穿靠锁热点 Key 失效瞬间用分布式锁确保只有一个请求穿透到 DB。穿透靠过滤器布隆过滤器在缓存之前再加一道防线拦截不存在的数据查询。雪崩靠分散过期时间随机化 多级缓存 熔断降级构建多层防御体系。分布式锁必须选 Redissonsetnx expire 非原子 → 死锁风险本地锁跨实例无效 → 超卖。Redisson 的 WatchDog 自动续期解决了业务超时导致锁意外释放的问题。

相关新闻

最新新闻

日新闻

周新闻

月新闻