个人主页北极的代码欢迎来访作者简介java后端学习者❄️个人专栏苍穹外卖日记SSM框架深入JavaWeb✨命运的结局尽可永在不屈的挑战却不可须臾或缺前言我们前面已经学习完了黑马点评的第一个业务功能也就是根据短信验证登陆的业务功能接下来我们继续学习最近忙着复习学校的课程导致进度不是很快还有就是算法题也挺头疼的其次就是八股文可能顾虑太多了想同时进行但还是没找到平衡点再试试一星期吧实在不行就舍弃一部分。今天要学习的是商户查询缓存的业务功能。摘要本文介绍了商户查询缓存功能的实现原理与应用场景。首先阐述了缓存的基本概念通过做饭和冰箱的类比形象说明了缓存的作用。文章详细分析了缓存的优势提升性能、减轻数据库压力和挑战数据一致性、缓存穿透等并比较了本地缓存与Redis缓存的区别。在技术实现部分提供了完整的代码示例展示如何为商铺信息和类型添加Redis缓存包括查询逻辑和更新策略。重点讲解了三种缓存更新策略主动更新、TTL过期、双写模式推荐采用先更新数据库再删除缓存的最佳实践以避免数据不一致问题。最后总结了不同缓存策略的适用场景强调主动更新策略在保证数据一致性方面的优势。商户查询缓存功能什么是缓存简单说缓存就是临时存储数据的地方目的是为了以后能更快地获取它。想象一下没有缓存就像每次做饭都要从种菜、养猪开始效率极低。有缓存就像把做好的饭菜放在冰箱里饿了直接拿出来热一下就能吃又快又方便。在计算机世界里CPU有缓存浏览器有缓存而黑马点评项目用的就是Redis缓存用来存储像商铺信息、用户会话这类数据。缓存的核心原理快慢分层计算机存储是一个金字塔结构越往上越快但容量越小、成本越高。L1/L2/L3 缓存速度极快纳秒级集成在CPU内部。内存 (如 Redis)速度快微秒级容量较大。硬盘 (如 MySQL)速度慢毫秒级容量巨大。缓存的目标就是把最常用的数据从慢速的硬盘MySQL搬到快速的内存Redis中从而加速访问。使用缓存的好处1. 性能飙升用户体验好读内存通常只需几十微秒读硬盘通常需要几毫秒甚至几十毫秒差距内存比硬盘快100-1000倍。用户打开页面从等2秒变成瞬间打开。2. 减轻数据库压力系统更稳数据库的连接数是有限的比如200个。如果没有缓存所有请求都冲击数据库很容易把数据库压垮。有了缓存后80%以上的读请求都被缓存拦截数据库的压力骤降系统更稳定。3. 应对高并发支撑大流量像双11这样的场景每秒百万级请求数据库根本无法承受。必须依靠缓存集群来扛住绝大部分的读请求。4. 提高扩展性当流量增长时可以通过增加缓存节点如Redis集群来水平扩展读能力成本相对较低。使用缓存的坏处与挑战1. 数据一致性问题最核心的挑战本章讲解缓存是数据的“副本”当数据库中的数据发生变化时如何保证缓存中的数据也同步更新如果不更新用户看到的是脏数据例如你修改了商铺信息但页面还是旧的。解决方案需要复杂的缓存更新策略如更新数据库后立即删除或更新缓存。2. 缓存穿透恶意查询一个缓存和数据库中都不存在的数据例如查询ID为-1的商品。后果每次请求都会穿透缓存直接打到数据库可能压垮数据库。解决方案缓存空对象、使用布隆过滤器。3. 缓存雪崩大量缓存同时失效导致所有请求瞬间全部打到数据库。常见原因缓存服务器宕机或大量key设置了相同的过期时间。解决方案给过期时间增加随机值、部署高可用集群。4. 缓存击穿某个热点数据如爆款商品的缓存恰好失效此时大量并发请求同时打到数据库去查询这个数据。后果数据库瞬间压力巨大。解决方案使用互斥锁只让一个线程去查数据库其他线程等待。5. 增加系统复杂度和维护成本需要额外的缓存系统如Redis服务器。代码逻辑变复杂读数据要先读缓存没命中再读数据库然后写缓存写数据要考虑更新缓存。需要考虑缓存淘汰策略内存满了怎么办如LRU。剩下的几个问题我们下一节再详细讲解。在黑马点评项目中的应用场景无缓存 (查MySQL)有缓存 (查Redis)提升查询商铺信息慢耗时高数据库压力大快毫秒级响应数据库无压力性能提升10倍登录状态刷新每次请求都查数据库验证Session基于Token (JWT或存Redis)无需查库水平扩展能力强秒杀/优惠券无法应对高并发利用Redis原子操作和Lua脚本轻松应对高并发本地缓存(JVM层面缓存)定义: 缓存数据存储在同一个应用程序的内存中。也就是跟写的Java代码在同一个Java虚拟机(JVM)里。代表技术:Caffeine(黑马点评中用的就是它)、Guava Cache、Ehcache。特点:极快: 不需要网络传输,直接从内存读,微秒级。强耦合: 每个微服务/应用实例都有自己的缓存副本。有上限: 受限于JVM堆内存大小,不能存太多。使用场景:黑马点评中查询商铺类型列表(这种数据很少变)。热点参数、计数器、临时状态。2. 分布式缓存(进程外缓存)定义: 缓存数据存储在独立的缓存服务器上,应用程序通过网络请求访问它。代表技术:Redis(黑马点评中的核心)、Memcached。特点:共享性: 多个应用实例(比如10个订单服务)可以共享同一份缓存数据。大容量: 独立于应用内存,可以存几十GB甚至更多。网络开销: 需要网络IO,比本地缓存慢一点(毫秒级),但远快于数据库。使用场景:黑马点评中的商铺详情、优惠券库存、用户登录状态(Session共享)、短信验证码。3. 客户端缓存定义: 缓存数据存储在调用方的机器上。代表技术: 浏览器缓存(HTTP Cache)、客户端App本地存储。特点: 最接近用户,延迟最低,但数据安全性差,不可控。使用场景: 网页静态资源(图片、CSS、JS)、App首页数据。按计算机体系结构分类(宏观视角)如果是在面试或学习计算机基础,这个分类也很重要。层级介质速度容量典型例子CPU缓存SRAM(静态随机存取存储器)纳秒级KB-MBL1、L2、L3 Cache操作系统缓存内存微秒级内存大小Page Cache(页缓存)数据库缓存内存微秒级配置决定MySQL Buffer Pool应用缓存内存(进程内/进程外)微秒-毫秒可配置Caffeine、Redis网络缓存硬盘/内存毫秒级很大CDN、HTTP缓存、代理缓存本地缓存 vs Redis缓存核心区别这是你在黑马点评中需要重点区分的,我帮你总结了一个表格对比维度本地缓存 (Caffeine)分布式缓存 (Redis)数据存储位置应用进程的内存中独立的Redis服务器内存中数据一致性弱。实例A改了数据,实例B可能还是旧数据强。所有实例读同一个Redis,数据一致性能非常高(无网络IO,纳秒/微秒级)高(有网络IO,毫秒级)容量限制小(受限于JVM堆内存,几GB)大(独立服务器,几十GB甚至TB)生命周期随应用进程生灭独立于应用,重启应用缓存还在典型用途少量、极少变化、机器级数据大量、共享、需要持久化/集群的数据黑马点评中的实战分类在这个项目中,你会看到两种缓存同时使用,各有分工本地缓存(Caffeine) - 店铺类型查询为什么因为店铺类型(比如“美食”、“电影”)很少变化,而且数据量极小。用本地缓存最快,不用每次查Redis增加网络开销。代码位置ShopTypeController-queryTypeList()。Redis缓存 - 几乎所有其他业务商铺详情需要共享,且数据量大。登录状态微服务架构下,需要共享Token/Session。优惠券秒杀需要原子操作和计数。用户签到利用Redis的Bitmap数据结构。总结一句话本地缓存是你的私人笔记本,自己用最快,但别人看不到你写的。Redis缓存是办公室的共享白板,大家都看得到、写得上,但走过去写需要时间。浏览器缓存是你家冰箱里的食物,只有你自己吃。完整的技术栈分层从代码到硬件应该是这样分层的层面技术作用类比应用层你的代码Controller, Service业务逻辑饭店的厨师Web容器层Tomcat接收HTTP请求管理线程池调用你的代码饭店的前台传菜员JVM层JVM内存运行Java代码管理堆内存厨房的操作台本地缓存CaffeineJVM堆内存中的一块区域灶台上的调料架分布式缓存Redis独立进程通过网络访问中央仓库数据库MySQL硬盘持久化存储冰箱冷库关于tomcat的一些了解Tomcat 在缓存架构中的角色Tomcat 本身不是缓存但它和缓存有密切关系1. Tomcat 管理着本地缓存的生存环境java // 这段代码运行在 Tomcat 内的 JVM 中 Configuration public class CacheConfig { Bean public CacheString, Object localCache() { // 这个缓存存在 Tomcat 的 JVM 堆内存里 return Caffeine.newBuilder() .maximumSize(10000) .expireAfterWrite(10, TimeUnit.MINUTES) .build(); } }2. Tomcat 的线程池影响缓存效率Tomcat 默认有 200 个线程处理请求。如果缓存命中率高这些线程很快就能返回结果Tomcat 就能处理更多并发。3. 多 Tomcat 实例下的缓存问题在生产环境中你通常会有多个 Tomcat 实例集群text负载均衡器 (Nginx) ↓ ┌─────────┼─────────┐ ↓ ↓ ↓ Tomcat1 Tomcat2 Tomcat3 (本地缓存A) (本地缓存B) (本地缓存C) ↓ ↓ ↓ └─────────┼─────────┘ ↓ Redis (共享缓存)问题来了本地缓存CaffeineTomcat1 改了数据Tomcat2 的本地缓存还是旧的 → 数据不一致Redis所有 Tomcat 共享数据一致这就是为什么黑马点评里店铺类型很少变→ 用本地缓存店铺详情可能变→ 用 RedisTomcat 本身自带的缓存Tomcat 作为 Web 服务器也提供了一些缓存机制1. 静态资源缓存xml!-- Tomcat 的 context.xml -- Resources PreResources classNameorg.apache.catalina.webresources.Cache cacheMaxSize10000 !-- 缓存静态资源 -- / /Resources2. Session 缓存存储用户会话java// Tomcat 默认把 Session 存在内存中 HttpSession session request.getSession(); session.setAttribute(user, user); // 存在 Tomcat 内存里问题多 Tomcat 实例下Session 不共享用户第一次请求打到 Tomcat1第二次打到 Tomcat2 就丢了。解决方案用 Redis 存 Session黑马点评用的 JWT Token 方案浏览器缓存 vs Tomcat/JVM缓存 vs Redis缓存现在来完整对比这三者对比维度浏览器缓存本地缓存(Caffeine)Redis缓存存储位置用户硬盘/内存Tomcat的JVM堆内存独立Redis服务器谁管理浏览器自动JS手动你的Java代码你的Java代码Redis访问速度极快本地读无网络极快微秒级无网络快毫秒级有网络容量限制有限几MB到几百MB小JVM堆内存限制大独立服务器内存数据共享只有当前用户能看到当前Tomcat实例的所有用户所有Tomcat实例的所有用户生命周期手动清除或自动过期随Tomcat重启清空独立于应用重启适用场景静态资源、用户偏好、离线数据应用级热点数据如店铺类型共享业务数据如库存、登录态项目添加Redis缓存模型如图具体的添加商户缓存的实现流程代码实现Service public class ShopServiceImpl extends ServiceImplShopMapper, Shop implements IShopService { Resource private StringRedisTemplate stringRedisTemplate; /** * 根据id查询商铺信息 * param id 商铺id * return */ public Result queryById(Long id) { String key RedisConstants.CACHE_SHOP_KEY id; //从Redis中查询商品缓存信息 String shopJson stringRedisTemplate.opsForValue().get(key); //判断缓存是否存在 if(StrUtil.isNotBlank(shopJson)){ //存在直接返回 //将json转为对象 Shop shop JSONUtil.toBean(shopJson, Shop.class); } //不存在查询数据库 Shop shop getById(id); if (shop null){ return Result.fail(商铺信息不存在);} //写入缓存 stringRedisTemplate.opsForValue().set(key,JSONUtil.toJsonStr(shop)); return Result.ok(shop); } }这里主要是Service层的代码实现因为是在这里实现主要的业务操作然后这里需要注意的是我们从Redis中查到的是json格式的要转成对象有的对这些不是很了解或者说忘记了我们在这里提及一下为什么要转成对象核心原因JSON是死的对象是活的java // JSON字符串死的 - 只是一堆文本 String shopJson {\id\:1,\name\:\海底捞\,\price\:100}; // Java对象活的 - 有行为、有类型 Shop shop new Shop(); shop.setId(1); shop.setName(海底捞); shop.calculateDiscount(); // 对象可以有方法具体对比维度JSON字符串Java对象类型安全❌ 编译期不检查字段名✅ 字段类型编译期确定方法调用❌ 不能调用业务方法✅ 可以调用shop.getPrice()IDE支持❌ 无代码提示✅ 有代码补全、重构支持性能⚠️ 每次需要重新解析✅ 直接访问内存字段可读性❌ 大段字符串难以调试✅ 对象结构清晰给店铺类型查询业务添加缓存代码实现Service public class ShopTypeServiceImpl extends ServiceImplShopTypeMapper, ShopType implements IShopTypeService { Resource private StringRedisTemplate stringRedisTemplate; /** * 获取所有商铺类型 * return */ public ListShopType queryTypeListWithCache() { ListString shopTypesJsonList stringRedisTemplate.opsForList().range(cache:shop:types, 0, -1); // 判断缓存中是否有数据 if (shopTypesJsonList ! null !shopTypesJsonList.isEmpty()) { // 缓存中有数据 return shopTypesJsonList.stream().map(shopTypesJson - JSONUtil.toBean(shopTypesJson, ShopType.class)) .collect(Collectors.toList()); } // 缓存中没有数据 // 查询数据库 ListShopType shopTypes query().orderByAsc(sort).list(); // 将数据写入缓存 stringRedisTemplate.opsForList().leftPushAll(cache:shop:types, shopTypes.stream().map(shopType - JSONUtil.toJsonStr(shopType)).collect(Collectors.toList())); return shopTypes; } }这里我们使用的是List集合来接受redis数据也可以用StringRedis中存储的样子redis# 在Redis中List就像一个数组 key: cache:shop:types value: [商品1, 商品2, 商品3, 商品4, 商品5] 索引0 索引1 索引2 索引3 索引4 索引-5 索引-4 索引-3 索引-2 索引-1String存储JSON// 方案1用 String 存 JSON 数组推荐 String key cache:shop:types; String shopTypesJson stringRedisTemplate.opsForValue().get(key); if (StrUtil.isNotBlank(shopTypesJson)) { return JSONUtil.toList(shopTypesJson, ShopType.class); } // 写入时 stringRedisTemplate.opsForValue().set(key, JSONUtil.toJsonStr(shopTypes), 30, TimeUnit.MINUTES);缓存更新策略为什么需要缓存更新策略核心问题数据不一致缓存是数据库的副本当数据库数据变化时缓存如果不更新就会出现数据不一致。java // 场景管理员修改了店铺类型 // 数据库id1 的 name 从 美食 改为 餐饮 // 问题缓存中还是旧数据 缓存{id:1,name:美食} // 脏数据 数据库{id:1,name:餐饮} // 最新数据 // 用户查询时从缓存读到的是美食错误真实案例影响场景问题后果商品价格修改缓存还是旧价格用户下单价格错误库存扣减缓存库存未更新超卖问题用户信息修改缓存是旧信息显示错误信息店铺类型改名缓存是旧名称前端显示混乱二、三种缓存更新策略1. 主动更新Cache Aside Pattern- 最常用核心思想由业务代码主动控制缓存的更新java Service public class ShopTypeService { Autowired private StringRedisTemplate redisTemplate; // 查询先查缓存再查数据库 public ListShopType queryTypeList() { String key cache:shop:types; String json redisTemplate.opsForValue().get(key); if (StrUtil.isNotBlank(json)) { return JSONUtil.toList(json, ShopType.class); } // 查数据库 ListShopType list query().orderByAsc(sort).list(); // 写缓存 redisTemplate.opsForValue().set(key, JSONUtil.toJsonStr(list), 30, TimeUnit.MINUTES); return list; } // 更新先更新数据库再删除缓存 Transactional public void updateType(ShopType shopType) { // 1. 更新数据库 updateById(shopType); // 2. 删除缓存而不是更新缓存 String key cache:shop:types; redisTemplate.delete(key); } }流程图text更新操作 用户修改数据 → 更新数据库 → 删除缓存 ↓ 下次查询 → 缓存未命中 → 查数据库 → 写缓存得到最新数据 查询操作 用户查询 → 查缓存 → 命中返回 ↓ 未命中 查数据库 → 写缓存 → 返回2. 被动更新TTL过期核心思想设置过期时间自动失效java // 写入缓存时设置过期时间 redisTemplate.opsForValue().set(key, json, 30, TimeUnit.MINUTES); // 30分钟后自动过期 // 下次查询会重新加载最新数据优点简单无需手动删除缺点30分钟内数据可能不一致3. 双写模式Write Through核心思想更新数据库的同时也更新缓存java Transactional public void updateType(ShopType shopType) { // 1. 更新数据库 updateById(shopType); // 2. 更新缓存而不是删除 String key cache:shop:types; ListShopType newList query().orderByAsc(sort).list(); redisTemplate.opsForValue().set(key, JSONUtil.toJsonStr(newList), 30, TimeUnit.MINUTES); }缺点性能较差每次更新都要查数据库重建缓存三、主动更新的最佳实践黑马点评中的标准写法java Service public class ShopServiceImpl extends ServiceImplShopMapper, Shop implements IShopService { Autowired private StringRedisTemplate redisTemplate; // 查询单个店铺带缓存 Override public Result queryById(Long id) { String key CACHE_SHOP_KEY id; // 1. 从缓存查询 String shopJson redisTemplate.opsForValue().get(key); // 2. 缓存命中 if (StrUtil.isNotBlank(shopJson)) { Shop shop JSONUtil.toBean(shopJson, Shop.class); return Result.ok(shop); } // 3. 缓存未命中查数据库 Shop shop getById(id); if (shop null) { return Result.fail(店铺不存在); } // 4. 写入缓存设置过期时间 redisTemplate.opsForValue().set(key, JSONUtil.toJsonStr(shop), CACHE_SHOP_TTL, TimeUnit.MINUTES); return Result.ok(shop); } // 更新店铺先更新数据库再删除缓存 Override Transactional public Result update(Shop shop) { Long id shop.getId(); if (id null) { return Result.fail(店铺ID不能为空); } // 1. 更新数据库 updateById(shop); // 2. 删除缓存 String key CACHE_SHOP_KEY id; redisTemplate.delete(key); return Result.ok(); } }为什么是先更新数据库再删除缓存对比维度先删缓存再更新DB先更新DB再删缓存并发问题会导致缓存脏数据只会短暂不一致脏数据持续时间可能很长直到过期或再次删除不存在脏数据不一致窗口从删缓存到更新DB完成从更新DB到删缓存风险等级 高 低java // 方案A先删缓存再更新数据库有问题 public void updateA(Shop shop) { redisTemplate.delete(key); // 1. 先删缓存 updateById(shop); // 2. 再更新数据库 } // 问题删缓存后、更新数据库前有查询请求 // 查询发现缓存为空 → 查数据库旧数据→ 写缓存脏数据 // 结果缓存又变成旧数据了 // 方案B先更新数据库再删缓存推荐 public void updateB(Shop shop) { updateById(shop); // 1. 先更新数据库 redisTemplate.delete(key); // 2. 再删缓存 } // 优点即使查询请求进来读到的也是旧缓存还没删 // 等更新完成后删除缓存下次查询会加载新数据 // 短暂不一致可以接受四、缓存更新策略对比策略优点缺点适用场景主动删除实时性高数据一致性好需要写代码有短暂不一致窗口大部分业务TTL过期简单无需维护实时性差窗口期内数据不一致不重要的数据双写更新缓存始终最新性能差事务复杂读写比例接近1:1总结问题答案推荐顺序先更新数据库再删除缓存为什么不推荐先删缓存并发会导致缓存脏数据不一致窗口多长从更新DB到删缓存毫秒级如何保证最终一致设置过期时间兜底删除失败怎么办重试 消息队列一句话记住先更新数据库再删除缓存即使有短暂不一致也不会产生脏数据而先删缓存再更新数据库在并发下会产生难以消除的脏数据。