Redis缓存店铺查询:读多写少场景下的设计与实践

发布时间:2026/10/1 3:29:51
Redis缓存店铺查询:读多写少场景下的设计与实践 店铺项目做到第二天终于轮到性能优化里最经典也最实用的一环把店铺查询信息加进Redis缓存。“黑马店铺”这个项目本身就是读多写少的典型——用户进首页看店铺列表、点店铺详情、搜索店铺几乎全是查询操作。如果每次查询都直接打到MySQL并发稍微上来一点数据库连接池就会被打满接口的响应时间也会肉眼可见地变差。这天的实操核心就一句话在店铺查询接口里接入Redis让热门店铺的查询直接命中缓存数据库只负责兜底。这篇文章我会直接把当天做的设计、代码、验证过程以及后来踩过的坑都写出来。适合正在做电商、店铺、商品这类读多写少业务的后端开发也适合刚学完Spring Boot和Redis基础、想看看真实业务里缓存怎么落地的同学。整个改造不复杂但你如果能理解每一步为什么这么做后面自己写缓存逻辑就不会再稀里糊涂了。1. 店铺查询缓存的设计思路为什么选Redis而不是别的1.1 读多写少的业务模型决定了缓存方案店铺数据有几个特点变化频率低、查询频率高、单条数据量小。用户打开App看到的一家店铺它的名称、评分、营业时间、地址这些信息可能在一天之内都不会变但同一个店铺详情页一天会被成千上万人反复打开。这种情况就是教科书里说的“读多写少”也是缓存最擅长解决的场景。MySQL单机在普通配置下大概能扛到每秒几千次查询听起来不少但一旦遇到秒杀、活动页、热点店铺集中曝光一个详情接口的QPS就可能冲到几万。数据库连接的创建和销毁、磁盘IO、SQL解析这些开销在高峰期都会被无限放大。Redis就不同数据在内存里单线程模型配合IO多路复用单机读QPS可以到十万以上而且响应时间稳定在毫秒级。用个生活化的类比MySQL是水库Redis是每家每户楼顶的水塔用户用水先取水塔里的存量水塔空了再去水库抽水水库的压力自然就小了。但缓存不是万能的。它解决的是“高频读”的问题不是“海量存储”的问题。店铺总数可能上百万条不可能全塞进Redis也没必要。我们只缓存那些真正会被反复查的店铺其他冷门店铺走数据库就行。所以设计缓存的第一步不是写代码而是想清楚要缓存哪些数据、用什么维度组织、数据失效了怎么办。1.2 Key和Value怎么设计从Redis数据类型说起Redis有String、Hash、List、Set、ZSet等数据类型店铺查询这种场景大多数人第一反应是String存JSON也有人觉得Hash更省内存。这两种我都试过这里直接说结论单体业务里用String存JSON是最省心、最通用的方案。Key的格式一定要设计清楚。我习惯用“业务模块前缀:业务ID”的结构比如店铺详情就用cache:shop:1其中1就是店铺的ID。这么做的好处有几个一是所有店铺缓存都在同一个命名空间下不会跟其他业务混在一起二是在可视化工具里一眼就能看到这个key属于哪个业务三是后面做批量删除、按前缀扫描、清理缓存都很方便。最忌讳的是直接拿id当key比如1、2到时候跟其他模块的缓存撞了都不知道怎么回事。Value选择上我做了个简单的对比方案优点缺点适合场景String JSON可读性好序列化可控可视化工具里一眼看懂通用性最强JSON解析有轻微CPU开销字段冗余大多数业务缓存尤其是对象型数据Hash 多字段内存占用相对小支持修改单个字段代码复杂嵌套对象处理麻烦可视化不直观需要频繁改个别字段的对象比如计数器String JDK序列化代码最简单默认配置就能跑存进去是一堆二进制乱码占空间不可读基本不推荐生产环境用店铺详情是一个完整的对象查询时整条返回很少需要只改某个字段。用String存JSON反序列化直接得到对象逻辑最简单。后面要加字段、嵌套对象、改结构JSON都不会让你头疼。1.3 Cache Aside模式读缓存、回填、删除的闭环店铺查询用的缓存模式业界叫Cache Aside也叫旁路缓存。套路非常固定读请求先查Redis命中就直接返回没命中就去查MySQL查到后把结果写入Redis并设置过期时间再返回给前端。写请求先更新MySQL然后删除Redis里对应的缓存而不是去更新缓存。为什么写的时候是“删缓存”而不是“更新缓存”举个我实际遇到的例子。假设店铺的评分从4.5改到了4.8我更新完数据库之后顺手把Redis里的店铺JSON也改成4.8。看着没问题但并发场景下会有个大坑线程A更新数据库为4.8线程B读到旧值4.5B比A更晚把4.5写进Redis然后A再更新缓存为4.8覆盖了B的操作最终缓存里是对的但如果顺序反过来A先更新缓存4.8B后写4.5那缓存里就成了旧值而且这个旧值可能要在TTL过期后才会被纠正。删除缓存就不一样了我不碰旧值只删掉它下次读请求自然会把最新值从数据库回填到缓存里。这是一种“惰性”思维也是我后来做各种缓存方案时最常用的一条准则拿不准的时候优先删不要改。这一天的业务操作里读链路是核心店铺查询先走缓存未命中再回源数据库然后把查询结果回填到Redis。理解了这个闭环后面看代码就会觉得每一步都顺理成章。2. 缓存落地前的准备工作环境、依赖与序列化选型2.1 Redis安装与Spring Boot依赖配置动手写代码之前先确认Redis本身是可用的。我自己在Windows上开发时直接下载Windows版Redis压缩包解压后双击redis-server.exe就能跑起来默认端口6379。macOS用户用Homebrew装brew install redis然后brew services start redis。Linux服务器上也可以用包管理器装比如Ubuntu的apt install redis-server。需要说明的是Windows版的Redis一般是Redis Labs维护的社区移植版生产环境还是建议用Linux但本地开发调试完全够用。项目里接入Redis很简单Spring Boot项目加上一个starter即可dependency groupIdorg.springframework.boot/groupId artifactIdspring-boot-starter-data-redis/artifactId /dependency我遇到过一个版本坑Spring Boot 2.x用的是spring.redis.*配置项Spring Boot 3.x改成了spring.data.redis.*。网上很多教程还是老写法照抄到新项目里会发现配置根本不起作用。当前项目用的配置长这样spring: data: redis: host: 127.0.0.1 port: 6379 database: 0 lettuce: pool: max-active: 8 max-idle: 8 min-idle: 0 timeout: 5sdatabase这个参数值得多说一句。Redis默认有16个逻辑库编号0到15。我习惯把业务数据放0库临时数据或锁放1库测试数据放2库。不是说必须这样分但环境多了之后分开能省很多排查问题的时间。实际上很多公司会用不同端口甚至不同实例来隔离本地开发用database分词也够用。Lettuce是Spring Boot默认的Redis客户端池化参数按默认就能用不用刻意调。如果追求极致性能再研究连接池平时手动调整反而容易把并发调崩。2.2 序列化方案怎么选StringRedisTemplate与JSON这是新手最容易踩坑的地方。默认注入的RedisTemplate使用的序列化器是JdkSerializationRedisSerializer它会把对象序列化成以\xAC\xED开头的二进制字节。直接用默认配置存一条数据再用可视化工具打开Redis看到的是一堆乱码根本不知道里面存的是什么而且占用的空间也比原始数据大好几倍。我在项目里更习惯用StringRedisTemplate。它内部使用String序列化只会把字符串原样存进去。存储的时候我把店铺对象手动转成JSON字符串读取的时候再把JSON字符串反序列化成对象。这样存进Redis里的数据是明文JSON在Redis Desktop Manager这类可视化工具里直接能看到内容哪怕线上出了问题用redis-cli查也一目了然排查效率不知道高了多少。序列化时用Jackson的ObjectMapper就行Spring Boot自带的。我封装了一个简单的写法private static final ObjectMapper MAPPER new ObjectMapper(); private Shop toShop(String json) { try { return MAPPER.readValue(json, Shop.class); } catch (JsonProcessingException e) { throw new RuntimeException(店铺JSON反序列化失败, e); } } private String toJson(Shop shop) { try { return MAPPER.writeValueAsString(shop); } catch (JsonProcessingException e) { throw new RuntimeException(店铺对象序列化失败, e); } }如果用自定义RedisTemplate也可以设置GenericJackson2JsonRedisSerializer让它自动序列化对象但这样JSON里会多一个class字段用来存储类的全限定名。好处是可以自动反序列化坏处是类重命名或移动包之后旧缓存会全部反序列化失败而且数据可读性变差。所以我个人始终推荐String 手动JSON简单直接可控性最强。2.3 TTL设计缓存过期时间不是随便填的说到回填缓存一定要带上过期时间。很多新手写缓存时不设置TTL结果缓存里存了一条跟数据库永远不一致的数据直到手动删缓存或者重启才恢复。我见过生产事故就是这种原因造成的——运营改了价用户端三天没刷新投诉都堆成山了。TTL给多少要看业务容忍度。店铺基础信息变化不频繁给30分钟到1小时都合理如果运营经常改活动信息就缩短到5到10分钟。黑马店铺这个场景我给的是30分钟。但还有一个要注意的点所有店铺的缓存如果定在整点或同一时刻过期很可能造成缓存雪崩——大量key同时失效一瞬间所有请求都穿透到MySQL。解决办法很简单过期时间加一个随机偏移量long ttl 30 * 60 new Random().nextInt(300); // 30分钟加0到5分钟的随机值这招成本极低但效果非常明显。后来我做的所有缓存设计基本都会遵守“TTL必须是基础值随机扰动”这条规则。3. 店铺查询信息写入Redis的完整实现3.1 Service层改造查询店铺先查缓存这里是当天实操的核心代码。原来的查询逻辑很简单直接调getById查数据库现在要改成查询时先走Redis。我用的类名是ShopServiceImpl继承MyBatis Plus的ServiceImpl目标是重写店铺查询方法把Redis缓存合并进查询链路。Service public class ShopServiceImpl extends ServiceImplShopMapper, Shop implements IShopService { Autowired private StringRedisTemplate stringRedisTemplate; private static final String CACHE_SHOP_KEY cache:shop:; private static final ObjectMapper MAPPER new ObjectMapper(); Override public Shop queryShopById(Long id) { // 1. 拼接Redis Key String key CACHE_SHOP_KEY id; // 2. 优先查询Redis缓存 String shopJson stringRedisTemplate.opsForValue().get(key); if (StrUtil.isNotBlank(shopJson)) { Shop shop toShop(shopJson); log.debug(店铺查询命中缓存, id {}, id); return shop; } // 3. 缓存未命中查询MySQL数据库 Shop shop getById(id); if (shop null) { // 数据库也没查到返回空 return null; } // 4. 数据库查询结果回填Redis设置过期时间 String json toJson(shop); stringRedisTemplate.opsForValue().set(key, json, Duration.ofMinutes(30)); // 5. 返回查询结果 return shop; } }步骤很容易理解先查Redis命中就返回没命中才查数据库查到之后顺手把结果写进Redis下次同样的查询直接命中。这里的核心是最后那个set(key, json, Duration.ofMinutes(30))它把店铺查询信息真正地“添加”到了Redis里这一步缺了整个缓存链路就不闭环。写这段代码时有几个细节我特别提醒一下。一是StrUtil.isNotBlank判断空字符串因为后面做缓存穿透时我会往Redis里存空字符串空字符串不能算命中但也不能再去查库这是个边界情况。二是反序列化失败时不要直接吞异常要抛出来否则缓存里存的是脏数据接口返回的也是null排查问题会非常痛苦。三是日志里把“命中缓存”和“走数据库”区分开本地调试时一眼就能看出缓存到底生效没有。3.2 Controller与调用链一次请求怎么走通Controller倒是没太多变化保持轻量RestController RequestMapping(/shop) public class ShopController { Autowired private IShopService shopService; GetMapping(/{id}) public Result queryShopById(PathVariable(id) Long id) { Shop shop shopService.queryShopById(id); return Result.ok(shop); } }重点在于理解一次请求走通的路径。用户请求/shop/1进入Controller调用Service的queryShopById(1)。第一次请求时Redis里没有cache:shop:1这个key查询逻辑落到MySQLgetById(1)从数据库捞出店铺数据序列化成JSON写入RedisTTL设置为30分钟然后把数据返回给前端。前端拿到数据渲染页面。第二次用户再请求同样的接口Redis里已经有了cache:shop:1直接反序列化返回MySQL全程不参与。从调用方的感受来说第一次请求可能耗时30毫秒第二次开始就是2到3毫秒。这个差别在单次请求里不算夸张但放大到每天上百万次查询数据库减少的负载就是数量级的差距。我实操时会在Service里分别打印“查缓存耗时”和“查数据库耗时”对比结果非常直观数据库查询普遍在20毫秒以上缓存查询基本在1毫秒以内。3.3 用可视化工具验证缓存是否生效代码写完不能光看控制台日志我习惯用可视化客户端确认缓存里的真实数据。macOS上我用的是Another Redis Desktop ManagerWindows上Redis Desktop Manager也很常见这俩都是免费的图形化Redis客户端。操作路径是打开客户端新建连接填host为127.0.0.1、端口为6379如果设过密码就在auth里填密码点连接。连接成功后默认进到database 0左侧会列出所有key。我刷新几遍页面之后在key列表里能看到一条名为cache:shop:1的记录双击打开value里面是店铺对象的JSON字符串包含店铺名、评分、地址这些字段右下角还能看到剩余TTL在倒计时。这说明缓存回填成功了。我每次做完缓存功能都会用客户端手动做三个验证动作一是在没访问过接口前确认key不存在二是访问接口后确认key创建出来了内容正确TTL符合预期三是在客户端里手动删掉这个key再访问接口确认数据库查询日志重新出现说明缓存重建逻辑可靠。这套验证流程能覆盖大部分缓存读写问题养成习惯之后线上出问题也能少很多被动。4. 缓存上线后踩过的坑穿透、击穿、雪崩与一致性4.1 缓存穿透不存在的店铺ID也会打爆数据库刚把缓存加上线我就发现一个新问题如果有人恶意或者不小心请求了一个不存在的店铺ID比如/shop/999999Redis里没有这个key数据库里也没有这条店铺每次请求都会穿透Redis直接打到MySQL。如果这种请求量再大一点Redis在缓存层面就完全失去了拦截作用数据库一样会被打爆。这就是缓存穿透。解决思路很朴素把查不到的“空结果”也缓存起来。我在查询到null的时候往Redis里写一个空字符串并且把过期时间设置得很短比如5分钟。下次再查这个不存在的IDRedis命中的是空字符串直接返回null数据库完全不用参与。代码就是在shop null分支里补一行if (shop null) { stringRedisTemplate.opsForValue().set(key, , Duration.ofMinutes(5)); return null; }这里有个细节上面查询缓存时用的是StrUtil.isNotBlank(shopJson)而不是StringUtils.isNotEmpty就是因为空字符串也是有效缓存不能当作未命中。如果你用isNotEmpty判断空字符串会被当成不存在还是会穿透到数据库。更高级的方案是布隆过滤器把所有存在的店铺ID都预先加载到布隆过滤器里查询前先判断ID是否存在不存在直接返回。但这个方案有实现成本、有误判率店铺这种数据量在百万级以下的时候空值缓存完全够用。我个人的建议是先做空值缓存扛不住再说布隆过滤器别一开始就上复杂方案。4.2 缓存击穿与雪崩热点Key的几种死法缓存击穿和缓存雪崩经常被混在一起说其实是两个问题。缓存击穿针对的是单个热点Key。比如一家头部店铺的详情页缓存Key是cache:shop:888平时几万QPS全靠Redis扛。结果某一个瞬间这个Key的TTL刚好到期了数据在Redis里被删除下一个瞬间大量请求同时进来发现缓存没命中全部冲到MySQL。MySQL瞬间被打满这就是击穿。本质上就是“某个Key过期时正好赶上超大流量”。解决击穿最常用的是互斥锁也叫Setnx锁。查询时没命中缓存先尝试获取一把锁获取成功才去查数据库获取失败的请求说明有人正在重建缓存可以让它短暂等待后重试查缓存。代码大致长这样// 尝试获取互斥锁key为 lock:shop:888超时10秒 Boolean locked stringRedisTemplate.opsForValue() .setIfAbsent(lock:shop: id, 1, Duration.ofSeconds(10)); if (Boolean.TRUE.equals(locked)) { try { // 查数据库、回填缓存 } finally { // 释放锁 stringRedisTemplate.delete(lock:shop: id); } } else { // 休眠50毫秒后重查缓存 Thread.sleep(50); return queryShopById(id); }缓存雪崩则是大量Key在同一时间过期。前面提到每个店铺过期时间都设置成30分钟如果这些店铺是同一天同一批创建并首次被查询的那它们的过期时间就会整齐地落在同一个时间点导致大量Key同时失效数据库瞬时承受巨大压力。解决办法我在2.3里已经说过了给TTL加随机偏移量让过期时间错开。这两件事一个靠锁一个靠随机原理都很简单但缺一个都可能在生产环境翻车。4.3 店铺信息更新后先改库还是先删缓存跟缓存相关的另一大类问题是数据一致性说白了就是数据库里的店铺信息和缓存里的店铺信息对不上。黑马店铺项目里有修改店铺信息的接口改动之后缓存必须同步处理。业界标准做法是“先更新数据库再删除缓存”。按这个顺序处理后即使在更新数据库之后、删除缓存之前的窗口里出现读请求最多也就是读到一次旧缓存影响范围极小。反过来如果先删缓存再更新数据库问题就大了线程A删掉缓存线程B马上来读发现缓存为空就查数据库查到的是旧数据并写回缓存然后线程A才把数据库更新成新值。最终缓存里存了旧数据而且大概率要等TTL过期才能恢复这就是我在1.3里提到的坑。我在这天项目里顺手写了一个删除缓存的更新逻辑Override public boolean updateShop(Shop shop) { // 1. 先更新数据库 boolean updated super.updateById(shop); // 2. 再删缓存 if (updated) { stringRedisTemplate.delete(CACHE_SHOP_KEY shop.getId()); } return updated; }删除缓存这个操作失败怎么办代码层面通常是先更库、后删缓存但删缓存也可能因为网络异常失败。为了尽可能保证最终一致可以在删除失败时做重试或者配合延迟双删——就是删除缓存后等待几百毫秒再删一次。延迟双删能规避并发窗口里旧数据回填的问题。不过黑马店铺这个项目的数据一致性容忍度没那么高先更库再删缓存已经足够。真追求极端一致性就得引入消息队列或者Binlog订阅来做缓存最终一致性了那是后话。4.4 排障利器几个看缓存现场的小技巧缓存类问题最难的不是写代码而是线上出了状况时怎么快速定位。这里分享几个我常用的排查动作。第一用redis-cli直接查Key。redis-cli进入命令行后TTL cache:shop:1可以看剩余过期时间GET cache:shop:1可以看缓存的原始值EXISTS cache:shop:1判断Key是否存在。如果发现缓存里存的JSON跟数据库不一致基本可以断定是更新链路出了问题。第二用MONITOR命令看实时读写。这个命令会打印Redis接收到的所有命令本地调试或者压测时能很清楚地看到请求到底是不是从缓存命中的。但它很消耗性能生产环境千万不能长时间开。第三给缓存Key加业务前缀的最大好处是可以用SCAN命令做模糊匹配。比如我想清理所有店铺缓存SCAN 0 MATCH cache:shop:* COUNT 1000就能分批扫出所有店铺Key再批量删除。注意不能用KEYS cache:shop:*生产环境数据量大时KEYS会阻塞Redis。第四Redis Desktop Manager这类可视化工具不只是用来“看”数据的。它支持直接在界面上删Key、改TTL、执行命令。每次上线前我都会用它把相关缓存清一遍避免开发环境的历史缓存干扰测试结果。团队协作时这个操作习惯能省掉很多“我改了代码怎么没生效”的沟通成本。另外还有一个小窍门上线前最好把Java对象字段修改跟缓存格式联动检查一遍。如果Shop类加了新字段旧缓存反序列化时Jackson默认不会报错但新字段会读不到如果删了字段旧缓存里多余字段也会被忽略。这些都还好最怕的是把字段类型改了比如从int改成String旧缓存反序列化可能会直接抛异常。遇到这种情况提前清一下对应缓存或者做一个版本号前缀比如cache:shop:v2:1能更从容地完成升级。做缓存这个功能你会发现前期写代码很快真正花时间的都是这些边界情况和排障手段。黑马店铺二刷到第二天我最大的收获不是我写出了那段查缓存的代码而是把“缓存命中率”“TTL设计”“删除缓存策略”这些概念真正落到了实际业务里。这套思路后面还能继续扩展比如店铺列表页用Redis存List、店铺搜索热词用ZSet、详情页用逻辑过期方案来解决热点数据缓存重建问题。每一条路都是从这个最简单的“单店铺查询写Redis”开始长出来的。最后再分享一个个人体会别急着给所有数据都上缓存。缓存是给高频、只读、可容忍短暂不一致的数据准备的如果一条数据一天都查不了几次或者修改极其频繁加缓存反而增加复杂度和出问题概率。先把业务模型想清楚再去动Redis才是正确的顺序。

关于本文作者

来自尧图内容编辑团队

尧图内容编辑团队 内容团队

尧图内容编辑团队

本文由尧图网络内容编辑团队执笔。团队由资深项目经理、前端工程师与设计师组成,所有内容均来自亲手交付的真实项目,先讲清问题、再给出可落地的解法。尧图深耕北京网站建设十年,服务过京华建材集团、智造科技等各行业客户,把一线经验沉淀为可复用的行业观察。

  • 十年建站经验,覆盖建材、制造、服务、文创等
  • 项目经理把关选题与事实准确性
  • 工程师与设计师联合撰写专业细节
  • 统一编辑规范,保证文风与排版一致
  • 每月复盘转化数据,迭代选题方向

延伸阅读

相关资讯与近期热门内容

深度阅读推荐

建站决策前值得细读的三篇

网站改版的5个关键决策
2024-08-12

网站改版的5个关键决策

什么时候该改版、改到什么程度、如何避免流量掉光,京华建材集团改版复盘给出答案。

获取专属建站方案

看完文章,把您的行业与预算告诉我们,免费获取一份量身定制的官网建设方案与报价。

立即免费咨询