第12章:Redis缓存入门:旁路缓存与数据库加速

发布时间:2026/7/27 16:03:44
第12章:Redis缓存入门:旁路缓存与数据库加速 1. 项目背景1.1 业务场景商品详情页是电商系统流量最大的接口。每个商品详情需要聚合商品基础信息、SKU 价格、库存快照、店铺信息、营销标签和最近 N 条精选评论。在没有缓存的情况下每一次请求都要走 5-6 张数据库表的查询P95 延迟在 80ms 左右。日常运营期间 QPS 只有 2000MySQL 还能游刃有余。大促当天流量翻了 8 倍。首页推荐流引导更多用户点击商品详情单接口 QPS 飙到 16000。数据库连接池很快被打满max_connections500新请求在连接获取阶段就超时。更要命的是——某些热门商品Top 10 爆款被反复查询同一份数据数据库为完全相同的查询反复执行 JOIN、排序、聚合。P95 延迟从 80ms 直线上涨到 900ms超时率从 0.1% 涨到 8%。DBA 紧急加了只读副本、扩大了连接池但治标不治本。根本问题是高频重复的读请求不应该每次都打到数据库。Redis 缓存就是来解决这个问题的。1.2 缓存接入的典型痛点问题表现为什么重要怎么知道缓存何时失效用户看到的商品价格和下单时的不一样缓存不一致会导致用户体验差严重时造成资损缓存没命中时怎么办缓存过期瞬间大量请求打到数据库这就是缓存击穿——热点 key 过期时最危险不存在的商品 ID 被刷恶意请求大量不存在的商品 ID绕过缓存直接打数据库这就是缓存穿透——攻击成本极低大量缓存同时过期缓存预热后 10 分钟所有缓存同时失效这就是缓存雪崩——数据库瞬间承压1.3 本章目标掌握 Redis 缓存的核心模式和实践Cache Aside旁路缓存模式读先查缓存→未命中查数据库→写缓存和写先更新数据库→删除缓存的标准流程。缓存穿透的初级解法空值缓存 参数校验 布隆过滤器思路。缓存击穿的初级解法互斥锁重建SET NX EX。缓存雪崩的初级解法TTL 随机抖动。压测验证对比缓存接入前后的 QPS、P95 延迟和数据库查询次数。2. 项目设计2.1 第一幕缓存不是让数据库下班小胖“数据库慢那把热门商品全塞 Redis 里SET product:1 json以后只查 Redis数据库直接下班完美”小白“那商品价格变了怎么办库存扣光了怎么办你 Redis 里的数据还是昨天的——用户看到 199 元点进去下单时数据库是 299 元。你去解释”大师“缓存不是’替代数据库’而是’帮数据库挡刀’。数据库永远是数据的事实来源source of truthRedis 只是它的投影。我画一下最经典的 Cache Aside 模式”Cache Aside旁路缓存读流程: 请求到达 │ ▼ ┌─────────────┐ 命中 ┌──────────┐ │ 查 Redis │──────────▶│ 返回数据 │ └─────────────┘ └──────────┘ │ 未命中 ▼ ┌─────────────┐ 存在 ┌──────────┐ │ 查数据库 │──────────▶│ 写 Redis │──▶ 返回数据 └─────────────┘ └──────────┘ │ 不存在 ▼ ┌─────────────┐ │ 写空值缓存 │──▶ 返回 404/空 └─────────────┘ Cache Aside 写流程: ┌─────────────┐ ┌──────────┐ │ 更新数据库 │───▶│ 删除缓存 │ └─────────────┘ └──────────┘技术映射应用层负责缓存和数据库之间的协调——先查缓存、未命中回源数据库、写入缓存。写操作是先更新数据库、再删除缓存不是更新缓存。小胖追问“为什么是’删除缓存’而不是’更新缓存’我直接SET新值不是更快吗”大师“因为’更新缓存’有四个坑。第一商品详情可能涉及多张表的聚合——你更新了价格表但缓存的 JSON 还包含库存、标签、评论数这些字段怎么更新第二并发顺序问题——A 更新价格、B 更新库存同时写 Redis最终缓存里可能是 A 的价格 B 的库存不是某个完整时刻的数据。第三写缓存可能覆盖刚刚写完的新数据。第四如果缓存更新成功但后续业务逻辑失败——这个缓存就没法回滚。删除缓存则简单得多——让下一次读自然回源重建。”2.2 第二幕穿透、击穿、雪崩——缓存的三座大山小胖“缓存看上去很简单啊为什么网上有那么多’缓存穿透’、‘缓存击穿’、缓存雪崩’的文章”大师“因为这三个问题是缓存架构的’经典三连’。它们名字相似但成因和应对完全不同”问题 1: 缓存穿透 成因: 请求的数据既不在缓存中也不在数据库中如恶意请求不存在的 ID 攻击方式: 脚本持续请求 product/-1、product/-2、product/-3... 后果: 每次请求都穿透缓存直接打数据库数据库做无用查询 解法: ① 参数校验——ID 格式、范围不合法的直接拒绝 ② 空值缓存——数据库返回 null 时缓存一个短 TTL 的空标记 ③ 布隆过滤器——用极小内存判断key 一定不存在进阶方案 问题 2: 缓存击穿 成因: 某个热点 key 过期的一瞬间大量并发请求同时回源数据库 场景: 爆款商品缓存刚好在流量高峰期过期 后果: 数据库瞬间承受成千上万个相同查询 解法: ① 互斥锁重建——只有一个请求去查数据库其他请求等待 ② 逻辑过期——缓存 value 里带过期时间发现过期后用异步线程重建 ③ 永不过期 后台刷新——热点 key 不设 TTL由后台任务定期更新 问题 3: 缓存雪崩 成因: 大量 key 在同一时间段集中过期 场景: 批量预热了 10 万条商品缓存全设 600 秒 TTL 后果: 600 秒后 10 万个 key 同时过期数据库被回源请求淹没 解法: ① TTL 加随机抖动——基础 600s random(0, 120) ② 多级缓存——本地缓存 Redis 数据库逐级兜底 ③ 限流降级——缓存不可用时触发熔断保护数据库小白追问“互斥锁重建具体怎么做”大师“思路很简单——当缓存未命中时不是所有请求都去查数据库。只有拿到’重建锁’的那个请求去查数据库并更新缓存其他请求等待一小段时间后重试读缓存。”互斥锁重建流程: 缓存未命中 │ ▼ 尝试获取锁: SET lock:rebuild:product:{id} requestId NX EX 5 │ ├── 获取成功 → 查数据库 → 写缓存 → 释放锁 → 返回 │ └── 获取失败 → sleep(50ms) → 重新查缓存 ├── 有数据了 → 返回 └── 仍为空 → 再尝试获取锁最多重试 3 次技术映射穿透是数据永远不存在击穿是数据突然不存在雪崩是数据集体不存在。三个问题的应对策略不同但可以组合使用。2.3 第三幕命中率——缓存有没有用的核心指标小胖“缓存有数据就是命中没数据就是没命中吧”小白“精确一点——keyspace_hits / (keyspace_hits keyspace_misses)就是命中率。但光看全局命中率不够。验证码的命中率几乎是 0%因为都是一次性读取就删除购物车命中率可能是 80%它们和商品缓存的命中率混在一起看就没有意义。”大师“所以要做分业务的命中率监控。商品详情接口可以自己打点——每次请求记录’命中缓存’还是’回源数据库’。这样才能看出缓存策略的效果”业务维度命中率监控: 商品详情接口: 命中率 92%、P95 15ms、数据库查询次数下降 85% 店铺信息接口: 命中率 98%、P95 3ms几乎全命中 营销标签接口: 命中率 78%、P95 25ms标签变化频繁可以考虑区分 全局限INFO stats: 命中率 65%被验证码/一次性 token 拉低了3. 项目实战3.1 环境准备dockerexec-itredis-lab redis-cli-aredis1233.2 实战一Cache Aside 基础实现步骤 1商品详情写入缓存# 基础缓存写入带随机 TTL127.0.0.1:6379SET mall:cache:product:1001{id:1001,name:机械键盘,price:299,stock:88}EX637OK127.0.0.1:6379GET mall:cache:product:1001{\id\:1001,\name\:\机械键盘\,\price\:299,\stock\:88}步骤 2Java 旁路缓存实现publicclassProductCacheService{privatestaticfinalintBASE_TTL600;privatestaticfinalintJITTER_MAX120;privatestaticfinalintNULL_TTL60;// 空值缓存 60 秒privatefinalThreadLocalRandomrandomThreadLocalRandom.current();// 读流程——旁路缓存publicProductgetProduct(LongproductId){// 1. 参数基础校验防穿透第一关if(productIdnull||productId0){returnnull;}StringcacheKeymall:cache:product:productId;StringnullKeycacheKey:null;// 2. 先查缓存Stringcachedredis.get(cacheKey);if(cached!null){// 命中——检查是否是空值标记if(__NULL__.equals(cached)){returnnull;}returnobjectMapper.readValue(cached,Product.class);}// 3. 检查空值缓存防止不存在 ID 反复穿透if(Boolean.TRUE.equals(redis.hasKey(nullKey))){returnnull;}// 4. 缓存未命中 → 查数据库Productproductdatabase.findProduct(productId);if(product!null){// 5. 写入缓存带随机抖动intttlBASE_TTLrandom.nextInt(JITTER_MAX);redis.set(cacheKey,objectMapper.writeValueAsString(product),Duration.ofSeconds(ttl));}else{// 6. 写入空值缓存防穿透redis.set(nullKey,__NULL__,Duration.ofSeconds(NULL_TTL));}returnproduct;}// 写流程——先更新数据库再删除缓存TransactionalpublicvoidupdateProduct(LongproductId,ProductUpdateCommandcmd){// 1. 更新数据库事实来源database.updateProduct(productId,cmd);// 2. 删除缓存让下一次读自然重建redis.del(mall:cache:product:productId);// 注意: 不在这里 SET 新值原因见对话中的分析}// 构建 key统一入口防止命名混乱privateStringbuildCacheKey(LongproductId){returnmall:cache:product:productId;}}3.3 实战二互斥锁防止缓存击穿步骤 1手动模拟互斥锁# 假设商品缓存已过期# 请求 A 尝试获取重建锁127.0.0.1:6379SET mall:lock:rebuild:product:1001 req-A-001 NX EX5OK# 获取锁成功# 请求 B 同时尝试获取127.0.0.1:6379SET mall:lock:rebuild:product:1001 req-B-001 NX EX5(nil)# 获取失败——请求 A 持有锁# 请求 A 重建完成后释放锁配合 Lua 保证原子性第 13 章详解127.0.0.1:6379EVALif redis.call(GET, KEYS[1]) ARGV[1] then return redis.call(DEL, KEYS[1]) else return 0 end1mall:lock:rebuild:product:1001 req-A-001(integer)1步骤 2Java 互斥锁实现publicclassProductCacheWithMutex{privatestaticfinalintLOCK_TTL5;// 锁 5 秒自动释放privatestaticfinalintRETRY_SLEEP_MS50;privatestaticfinalintMAX_RETRY3;publicProductgetProductWithMutex(LongproductId){StringcacheKeymall:cache:product:productId;// 1. 先尝试读缓存Stringcachedredis.get(cacheKey);if(cached!null){returndeserialize(cached);}// 2. 缓存未命中 → 尝试互斥锁重建StringlockKeymall:lock:rebuild:product:productId;StringrequestIdUUID.randomUUID().toString();for(intretry0;retryMAX_RETRY;retry){// 尝试获取锁Booleanlockedredis.setIfAbsent(lockKey,requestId,Duration.ofSeconds(LOCK_TTL));if(Boolean.TRUE.equals(locked)){// 获取锁成功 → 负责重建try{Productproductdatabase.findProduct(productId);if(product!null){redis.set(cacheKey,serialize(product),Duration.ofSeconds(BASE_TTLrandom.nextInt(JITTER_MAX)));}returnproduct;}finally{// 释放锁只释放自己持有的releaseLock(lockKey,requestId);}}// 锁被别人持有 → 等一会儿重试读缓存safeSleep(RETRY_SLEEP_MS);cachedredis.get(cacheKey);if(cached!null){returndeserialize(cached);}}// 3. 重试耗尽 → 降级直接查数据库不写缓存避免重复竞争log.warn(互斥锁重建失败降级查库: productId{},productId);returndatabase.findProduct(productId);}privatevoidreleaseLock(StringlockKey,StringrequestId){// 用 Lua 保证原子性Stringluaif redis.call(GET, KEYS[1]) ARGV[1] then return redis.call(DEL, KEYS[1]) else return 0 end;redis.eval(lua,Collections.singletonList(lockKey),Collections.singletonList(requestId));}}3.4 实战三空值缓存防穿透# 查询一个不存在的商品 ID127.0.0.1:6379GET mall:cache:product:99999(nil)# 缓存未命中# 数据库也查不到 → 写入空值标记短 TTL127.0.0.1:6379SET mall:cache:product:99999:null__NULL__EX60OK# 60 秒内再次查询 → 命中空值缓存 → 不再查数据库127.0.0.1:6379GET mall:cache:product:99999:null__NULL__# 查看效果空值 key 存在期间数据库查询次数未增加3.5 实战四压测对比——有缓存 vs 无缓存# 无缓存场景每次请求去掉缓存直接查数据库# 实际项目中可用 A/B 开关控制# 有缓存场景典型的 Cache Aside 流程# 先预热热门商品127.0.0.1:6379SET mall:cache:product:1001{id:1001,name:机械键盘,price:299}EX637127.0.0.1:6379SET mall:cache:product:1002{id:1002,name:鼠标,price:99}EX642127.0.0.1:6379SET mall:cache:product:1003{id:1003,name:显示器,price:1999}EX654# 使用 redis-benchmark 模拟高并发 GETdockerexecredis-lab redis-benchmark-aredis123-tget-n100000-c100-q# 查看命中率执行压测前后对比127.0.0.1:6379INFO stats|grepkeyspace# keyspace_hits:xxx# keyspace_misses:xxx压测结论对比指标无缓存有缓存改善QPS~2000~2500012.5xP50 延迟80ms2ms40xP95 延迟150ms5ms30xP99 延迟500ms15ms33x数据库 QPS2000~1608% miss-92%命中率0%92%-3.6 实战五TTL 随机抖动验证# 批量写入 100 条缓存观察 TTL 分布# 脚本模拟Javafor(int i0;i100;i){String keymall:cache:product:(2000 i);int ttl600 ThreadLocalRandom.current().nextInt(120);redis.set(key,data- i, Duration.ofSeconds(ttl));}# 查看 TTL 分布10 分钟后# 某些 key 已过期剩余 0-20s某些尚未过期剩余 60-120s# → 过期时间分散在 600~720s防止集中过期3.7 实战六观察 INFO stats 指标# 执行多次缓存读操作后查看统计127.0.0.1:6379INFO stats# 重点关注:# keyspace_hits:152340 总命中次数# keyspace_misses:12890 总未命中次数# instantaneous_ops_per_sec:8240 每秒命令数# expired_keys:156 累计过期 key 数# evicted_keys:0 累计淘汰 key 数# 命中率 152340 / (152340 12890) ≈ 92.2%# 按业务维度手动验证命中率127.0.0.1:6379GET mall:cache:product:1001# 命中127.0.0.1:6379GET mall:cache:product:99999# 未命中不存在3.8 实战七本地缓存 Redis 二级缓存架构当 Redis 本身也成为瓶颈时比如 Redis 带宽打满可以在应用层加本地缓存Caffeine/Guava作为 L1Redis 作为 L2publicclassTwoLevelCacheService{// L1: 本地缓存极快但每个实例独立privatefinalCacheString,ProductlocalCacheCaffeine.newBuilder().maximumSize(1000)// 只缓存最热的 1000 个商品.expireAfterWrite(30,TimeUnit.SECONDS)// 30 秒过期.recordStats()// 记录命中率.build();// L2: Redis跨实例共享publicProductgetProduct(LongproductId){Stringkeymall:cache:product:productId;// 1. 查 L1 本地缓存ProductcachedlocalCache.getIfPresent(key);if(cached!null){metricsCollector.record(cache.l1.hit);returncached;}// 2. 查 L2 RedisStringredisJsonredis.get(key);if(redisJson!null){Productproductdeserialize(redisJson);localCache.put(key,product);// 回填 L1metricsCollector.record(cache.l2.hit);returnproduct;}// 3. 回源数据库metricsCollector.record(cache.miss);Productproductdatabase.findProduct(productId);if(product!null){redis.set(key,serialize(product),Duration.ofSeconds(BASE_TTLrandom.nextInt(JITTER_MAX)));localCache.put(key,product);}returnproduct;}// 缓存更新时——先删 RedisL2本地缓存靠 TTL 自然过期// 如果需要即时生效可以通过 Redis Pub/Sub 广播失效消息publicvoidinvalidateCache(LongproductId){Stringkeymall:cache:product:productId;redis.del(key);localCache.invalidate(key);}}二级缓存的关键权衡维度单级 RedisRedis 本地缓存P50 延迟2ms网络0.01ms内存一致性读 Redis 总是最新的L1 可能滞后 30 秒本地缓存过期内存集中在 Redis分散在各应用实例失效通知删 Redis 即可需要 Pub/Sub 或消息通知全网刷新适用场景一致性要求高、数据变化频繁热点极高、可容忍 30s 延迟3.9 实战八缓存更新策略对比实验除了 Cache Aside 的先更新数据库再删除缓存还有其他几种策略# 策略对比实验:# 1. 先删缓存 → 再更新数据库 → 再删缓存延迟双删# 2. 先更新数据库 → 再删除缓存Cache Aside 标准# 3. 先更新数据库 → 再更新缓存不推荐# 模拟延迟双删策略:127.0.0.1:6379DEL mall:cache:product:1001# 第1次删除(integer)1# ... 更新数据库 ...127.0.0.1:6379SET mall:cache:product:1001oldEX600# (并发读写入旧值)OK# 延迟 500ms 后127.0.0.1:6379DEL mall:cache:product:1001# 第2次删除(清理脏数据)(integer)1// 延迟双删实现TransactionalpublicvoidupdateProductWithDoubleDelete(LongproductId,ProductUpdateCommandcmd){StringcacheKeymall:cache:product:productId;// 1. 第一次删除缓存redis.del(cacheKey);// 2. 更新数据库database.updateProduct(productId,cmd);// 3. 延迟 N 毫秒后第二次删除用异步任务避免阻塞主线程scheduler.schedule(()-{redis.del(cacheKey);log.debug(延迟双删完成: productId{},productId);},500,TimeUnit.MILLISECONDS);}三种更新策略对比策略不一致窗口风险适用场景先删缓存→更新数据库中并发读回写旧值不推荐单独使用更新数据库→删缓存短微秒级缓存删除失败99% 的读多写少场景延迟双删短等待第二次删除复杂度高、仍需异步一致性要求较高的场景更新数据库→更新缓存无并发写顺序错乱、字段不完整极简单的 key-value非聚合对象4. 项目总结4.1 缓存三兄弟速查问题成因核心解法实现命令缓存穿透请求不存在的数据空值缓存 参数校验SET key:null __NULL__ EX 60缓存击穿热点 key 过期高并发互斥锁重建SET lock:rebuild:... NX EX 5缓存雪崩大量 key 同时过期TTL 随机抖动EX 600random(0,120)4.2 Cache Aside 优点与缺点维度详细说明优点1简单通用读和写的逻辑清晰应用层控制力强优点2懒加载只缓存真正被访问的数据不浪费内存优点3数据库权威任何时刻数据库都是正确的缓存只是投影优点4故障隔离缓存挂了只会导致短暂的回源不会丢失数据缺点1不一致窗口更新数据库→删除缓存之间读到的是旧缓存缺点2首次慢冷启动或缓存全清后第一个请求必须回源缺点3击穿风险热点 key 过期瞬间需要额外保护缺点4应用负责所有缓存逻辑都在应用层多个服务需要统一4.3 适用与不适用场景适用场景读多写少——商品详情、店铺信息、配置项、静态页面片段。允许短暂不一致——用户看到的价格延迟 10 分钟完全可以接受。查询成本高——一次 SQL 涉及多表 JOIN聚合缓存后收益大。热点数据集中——Top 100 商品占了 80% 的流量优先缓存。不适用场景写入频繁且需要强一致的——支付状态、余额、库存扣减的核心判断。数据实时性要求极高——如实时竞价、股票行情。查询条件千变万化——每次都是不同的 SQL WHERE 条件缓存命中率极低。数据量巨大但热点不集中——全量缓存成本太高收益太低。4.4 常见踩坑经验案例表象根因解法先删缓存再更新数据库商品更新后页面仍显示旧价格删除缓存后被并发读重新写入旧数据改为先更新数据库再删除缓存缓存 value 过大商品详情接口 P95 延迟 50ms缓存命中时也慢value 包含完整评论列表500KB每次网络传输成本高评论列表拆成独立 key 或分页加载互斥锁未设 TTL重建锁的请求崩溃后所有请求都在等待锁释放锁 key 未设 EX持有锁的线程挂了锁永不释放锁必须带 EX且释放用 Lua 比较 requestId空值缓存 TTL 太长新建商品后 5 分钟内页面显示商品不存在空值标记 TTL600s商品创建后缓存仍有效商品创建时主动删除对应的空值缓存4.5 思考题设计题一个商品详情页需要展示商品基本信息变化频率低“和实时库存变化频率高”。两者的数据放在同一个缓存 key 里好还是两个 key 好如果拆成两个 key各自 TTL 应该怎么设计故障题线上 Redis 因为网络抖动短暂不可用了 5 秒。恢复后短时间内keyspace_misses骤增、数据库 QPS 暴涨。请问这是什么现象如何在架构层面减少这种故障的影响答案提示将在下一章末尾给出。上一章思考题答案提示第11章第1题多国家站 key 设计key 模板为{country}:mall:cache:product:{id}如cn:mall:cache:product:1001、jp:mall:cache:product:1001。优点按前缀隔离、统计SCAN ... MATCH cn:*、清理。如果法律要求数据不能跨国传输需要每个国家站部署独立 Redis 实例——key 设计中的国家前缀可作为跨实例迁移的兼容层。第2题expired_keys 增长CPU 升高可能原因——① 大量短期 key 的定期删除被触发activeExpireCycle在大量过期 key 时 CPU 集中消耗② 过期 key 集中在少数几个大数据库的过期字典中抽样效率低。健康检查方案监控expired_keys的增长速率而非绝对数对比 CPU 使用率趋势如果过期速率陡增检查最近是否有大批量短期 key 写入调整hz参数增大定期删除的频率。延伸阅读与资源Python 3实战精进从脚本到高并发订单引擎MongoDB 实战进阶与内核修炼python入门Rquests从菜鸟脚本到企业级SDK的网络实战圣经Milvus向量数据库实战修炼从 0 到 1精通向量检索与生产落地后端工程师的 AI 转型第一课Ollama 与私有化大模型实战10倍开发者的 Dify 魔法书从零构建全栈 AI 应用后端工程师转型AI第一课-Ollama 与私有化大模型实战大型语言模型(LLM) vLLM 高性能推理落地实战Agent开发之LlamaIndex 实战修炼与源码进阶大语言模型Transformers 实战修炼与源码剖析

相关新闻

最新新闻

日新闻

周新闻

月新闻