news 2026/10/2 3:07:48

黑马点评商品类型Redis缓存实战:从Key设计到穿透击穿防御

作者头像

张小明

前端开发工程师

1.2k 24
文章封面图
黑马点评商品类型Redis缓存实战:从Key设计到穿透击穿防御

最近在整理黑马点评项目的课后练习,其中一道题是给商品类型列表加上Redis缓存。这道题看起来很小,真做起来却能带出一串问题:缓存key怎么设计、商品类型用哪种数据结构、缓存穿透要不要防、RedisTemplate序列化为什么全是乱码、连接池超时怎么排查……今天把整个实践过程复盘一遍,希望对正在做黑马点评或者类似点评、电商项目的朋友有点帮助。

黑马点评里的商品类型(shop_type)本质上就是首页那些美食、酒店、景点、休闲娱乐的分类标签。这种数据的特点是量级小、极少更新、但读取频繁,属于典型的“读多写少”基础数据。课后练习要求在不改动前端接口的前提下,把原来的数据库查询优化成Redis缓存查询。做完这个练习,等于把Redis最核心的缓存应用场景完整走了一遍,比单纯背面试题要实在得多。

1. 项目背景与需求拆解

1.1 商品类型数据长什么样

黑马点评项目中,商品类型表存在shop_type,字段大概包括id、name、sort、icon等。首页导航栏会一次性展示所有类型,所以Controller层的接口逻辑很简单:查询全部类型,按sort排序返回。但问题就在这里,首页是所有用户进应用时第一个看到的页面,流量非常大,每次进来都查一次MySQL,数据库连接池和磁盘IO很容易被打满,接口延迟也会上升。

课后练习的原始代码大致是这样的:

@GetMapping("/list") public Result queryTypeList() { List<ShopType> typeList = shopTypeService .query().orderByAsc("sort").list(); return Result.ok(typeList); }

这段代码在数据量小、并发低的时候没什么问题。可一旦访问量上来,每一次请求都会触发一次数据库查询,如果Redis里已经有了缓存,情况就完全不同了。练习的核心目标,就是在Service层或者Controller层引入Redis,让绝大部分请求直接命中内存。

1.2 缓存要解决的两个核心问题

用缓存主要解决性能问题和压力问题。性能方面,Redis读内存数据通常不到一毫秒,而MySQL走一次完整查询加上网络开销最少也要几毫秒。商品类型列表的数据量不大,SQL本身很快,可当调用量放大到每秒几百上千次,数据库的连接、线程调度、SQL解析这些开销就会被无限放大,接口RT也会不稳定。

压力方面更直观。假设这个接口一天被调用100万次,没有缓存时数据库一天要执行100万次同样SQL,有了缓存后可能只执行几次。数据库只需要扛住冷启动和后台更新那一点流量,剩余时间都在空闲状态,这对整个系统的稳定性意义很大。黑马点评虽然是个教学项目,但这个优化思路和真实互联网公司的做法是一样的。

为什么选择Redis而不是本地Map缓存?因为黑马点评是分布式项目,后续会部署多个后端实例。如果每个实例都用自己的本地缓存,实例之间数据不一致,一个实例更新了,另一个实例还在用旧数据。Redis是独立中间件,所有实例共享同一份缓存,数据一致性天然有保证。而且Redis本身支持过期策略、持久化和多种数据结构,后续做缓存穿透、缓存击穿、分布式锁方案时都是基于Redis,所以这个课后练习的技术选型非常标准。

2. 技术选型与方案设计

2.1 用String还是List存商品类型

商品类型是一个列表,很多同学第一反应是把整个List序列化成JSON字符串,用Redis的String类型存。这样做确实最简单:一个key对应一个值,读取后反序列化就行。我在第一版实现里也尝试过,后来还是改成了Redis的List类型,把每条类型单独序列化成JSON,然后从右边push进列表。

为什么用List?首先语义更清晰,列表数据天然适合List结构;其次如果需要单独修改某条类型,List可以针对某个下标操作,而用String整存整改太重;第三个原因是黑马点评后续课程会讲更多Redis数据结构,用List也算提前实战。

但用List也有一个麻烦:缓存空结果的时候不好处理。空列表没法往List里push任何元素。所以如果主要目的是防穿透,String存整个JSON数组其实是更简洁的方案。我的最终做法是折中:正常数据类型用List,出现空结果时用一个单独的String类型key存空字符串占位。这里不唯一,面试时能说清楚取舍逻辑就行。

2.2 缓存key的命名规范

Redis里的缓存key设计不好,后面会很难维护。我在这道练习里用的key是cache:shop:type:list。这个命名可以拆开看:cache是缓存模块前缀,shop:type是业务实体,list是操作类型。用冒号分层最大的好处是,在任何Redis可视化工具里都能按前缀一眼找到同类的key,也方便用命令批量删除或按模式扫描。

我在实际开发中还见过更完整的命名方式,比如带项目名或环境名:mall:prod:shop:type:list。虽然黑马点评只有一个项目,但养成带业务上下文的习惯对以后工作很有帮助。特别要注意的是,key不要用随机后缀,否则无法复用;过期时间不要写死,要设计成常量,方便统一调整。

2.3 过期时间和缓存预热策略

商品类型数据更新的频率不高,但也不是完全不变。后台管理员可能会新增一个“剧本杀”分类,或者调整分类排序。如果缓存永不过期,这些变更用户永远看不到。如果过期时间太短,缓存又失去了意义。我给这个key设置的TTL是30分钟,同时加了0到60秒的随机偏移。

这个随机偏移很关键。假设整个列表key在某个整点过期,缓存重建一瞬间所有请求同时落到数据库,可能造成缓存雪崩。虽然只有一个key,但不代表不会出现瞬间高流量。加了偏移之后,重建缓存的时间被打散,数据库压力小很多。

缓存预热是我后来自己加的。商品类型列表属于启动后必然会被读取的基础数据,与其等第一个用户来触发缓存重建,不如项目启动后主动加载一次。黑马点评的课后练习没有强制要求预热,但加上去很简单,我可以写一个CommandLineRunner,在Spring Boot启动完成后查一次数据库并写入Redis,这样用户从第一秒开始访问就是全缓存状态,体检更好。

3. 核心代码实现:一步步加上缓存

3.1 基础版本:先缓存后数据库

我最初完成的代码逻辑很直白,就是“缓存命中直接返回,未命中查数据库并回填缓存”。这个版本必须用StringRedisTemplate,而不是RedisTemplate,因为StringRedisTemplate默认key和value都用String序列化,Redis里存的就是正常JSON字符串,出了问题能直接看出对错。核心代码如下:

@Override public List<ShopType> queryList() { String cacheKey = "cache:shop:type:list"; // 1. 从Redis读取整个列表 List<String> cachedList = stringRedisTemplate .opsForList() .range(cacheKey, 0, -1); // 2. 缓存命中,JSON转对象 if (cachedList != null && !cachedList.isEmpty()) { return cachedList.stream() .map(item -> JSONUtil.toBean(item, ShopType.class)) .collect(Collectors.toList()); } // 3. 未命中,查数据库 List<ShopType> list = shopTypeService.list(); if (list == null || list.isEmpty()) { return list; } // 4. 写入缓存 for (ShopType item : list) { stringRedisTemplate.opsForList() .rightPush(cacheKey, JSONUtil.toJsonStr(item)); } // 5. 设置过期时间,带随机偏移 stringRedisTemplate.expire(cacheKey, 1800 + RandomUtil.randomInt(60), TimeUnit.SECONDS); return list; }

这段代码里,rightPush表示从列表右边推入元素,读取时用range(key, 0, -1)一次取出全部元素,顺序正好是插入顺序。写入完成后设置过期时间是必须的,否则缓存会一直存在,后续数据更新无法生效。

这个版本看起来能用,但还比较脆弱,至少有三个隐患:缓存穿透、缓存击穿和缓存与数据库的一致性问题。接下来我一步步把这些问题补上。

3.2 缓存穿透:空对象占位方案

缓存穿透指的是请求的数据在数据库里根本不存在,导致缓存里也永远不会命中,请求每次都穿透到数据库。商品类型列表虽然是查询全部,正常情况不会出现不存在的数据,但接口如果被人恶意调用,或者后续改成按类型id查询,缓存穿透就一定需要防御。

我在练习中采用缓存空对象的方式:查数据库结果为空时,往Redis中写入空串占位,并设置极短的过期时间。这样下一次同样的请求就会命中这个空缓存,直接返回空结果,不再打到数据库。关键代码是:

List<ShopType> list = shopTypeService.list(); if (list == null || list.isEmpty()) { // 缓存空对象,防止穿透,60秒后再次尝试查询数据库 stringRedisTemplate.opsForValue() .set(cacheKey + ":empty", "", 60, TimeUnit.SECONDS); return list; }

既然列表为空,用List没法推入元素,所以我单独为“空”场景建了一个key。不过更简洁的方案是干脆不用List,整个把序列化后的JSON数组用String存,空列表就存[],读取时反序列化仍然是空列表,不需要额外key。两种方式都可以,我最终保留了List方案,只是为了练习List结构。

另一种防穿透思路是布隆过滤器,利用位图结构快速判断某个key是否存在于集合中,如果过滤器说不存在,那一定不存在,直接拦截。布隆过滤器适合数据量大、key长期变化快的场景,在商品类型这个场景里显得有些重,但面试时可以提一下,说明你知道这个方案,并且懂它和缓存空对象的取舍。

3.3 缓存击穿:互斥锁重建缓存

缓存击穿针对的是单个热点key在过期瞬间被高并发访问。商品类型列表恰好就是首页的热点key,如果它刚好在流量顶峰时过期,几十个请求同时发现缓存miss,就会同时去查数据库,数据库瞬间可能被打出慢查询或者连接超时。

解决策略是缓存重建互斥。核心思想:缓存没有时,最先到达的线程获取一把分布式锁,拿到锁后查数据库、写缓存、释放锁;其他线程拿不到锁,就短暂休眠后重新读取缓存,直到第一个线程把缓存写好。我用Redis的setnx简单模拟了一把锁,代码逻辑:

// 获取锁 String lockKey = "lock:shop:type:list"; Boolean locked = stringRedisTemplate.opsForValue() .setIfAbsent(lockKey, "1", 30, TimeUnit.SECONDS); if (Boolean.TRUE.equals(locked)) { try { // 查到数据库并写入缓存 List<ShopType> list = shopTypeService.list(); for (ShopType item : list) { stringRedisTemplate.opsForList() .rightPush(cacheKey, JSONUtil.toJsonStr(item)); } stringRedisTemplate.expire(cacheKey, 1800 + RandomUtil.randomInt(60), TimeUnit.SECONDS); } finally { // 释放锁 stringRedisTemplate.delete(lockKey); } return list; } // 没有获取到锁,休眠后重试 Thread.sleep(50); return queryList();

这里有几个细节:setIfAbsent一定要带超时时间,否则线程异常崩溃时锁不会自动释放,其他线程会永久等待。释放锁时最好先判断持有者是不是自己,更严谨的做法是用Lua脚本保证判断和删除原子性。黑马点评课程后面讲分布式锁时会展开,这个练习可以先从setnx版本入门。

商品类型查询本身非常快,所以我也不敢说这里必须加锁。但作为一个课后练习,把击穿问题考虑进去,可以让你对Redis的理解从“存取数据”上升到“保证缓存系统稳定”的层面。

3.4 RedisTemplate序列化配置

有段时间很多同学跟我反馈,用RedisTemplate存了一个对象,Redis里看到的是一堆\xAC\xED开头的乱码,数据点开也看不懂。这是因为RedisTemplate默认使用JDK序列化,Java对象被序列化成了二进制字节,不仅肉眼不可读,其他语言也读不了。

所以我在练习中直接使用StringRedisTemplate,手动把商品类型转成JSON字符串。这样Redis里看到的就是正常字符串,排查问题非常方便。如果项目里其他地方确实需要RedisTemplate存对象,必须自定义序列化器。我整理过一份常见配置:

@Configuration public class RedisConfig { @Bean public RedisTemplate<String, Object> redisTemplate(RedisConnectionFactory factory) { RedisTemplate<String, Object> template = new RedisTemplate<>(); template.setConnectionFactory(factory); StringRedisSerializer stringSerializer = new StringRedisSerializer(); // key 使用 String 序列化 template.setKeySerializer(stringSerializer); template.setHashKeySerializer(stringSerializer); Jackson2JsonRedisSerializer<Object> jacksonSerializer = new Jackson2JsonRedisSerializer<>(Object.class); // value 使用 Jackson 序列化 template.setValueSerializer(jacksonSerializer); template.setHashValueSerializer(jacksonSerializer); template.afterPropertiesSet(); return template; } }

注意一点:如果你的对象里包含LocalDateTime这类Java时间类型,Jackson序列化器需要额外配置JavaTimeModule,否则会直接抛异常。商品类型实体没有时间字段,但以后做用户缓存、订单缓存时会遇到。

4. 常见问题与排查技巧实录

4.1 缓存和数据库一致性

商品类型虽然是读多写少,但后台有新增分类或修改排序的需求。如果没有做一致性处理,用户看到的分类列表会一直停留在旧缓存里,直到缓存过期。我的做法是:在所有修改商品类型的接口里,事务提交后删除对应的缓存key。因为列表缓存是整个key,删除比更新简单,下次读请求发现没有缓存,就会重新查数据库并回填。

这里有一个经典的顺序问题:先更新数据库,再删除缓存,而不是先删缓存再更新数据库。如果先删缓存,在更新数据库的间隙,另一个线程会拿着旧数据重建缓存,导致旧值被塞回Redis。而先更新数据库再删缓存,即便删除稍有延迟,最多是短暂读到旧数据,数据库最终会覆盖。万一删除失败,还有30分钟过期作为最终兜底。商品类型这类低更新频率的数据,使用“更新数据库-删除缓存-TTL”三件套已经足够。

4.2 缓存雪崩和热Key排查

缓存雪崩最常见的原因是大量key在同一时间过期,或者Redis实例整体不可用。黑马点评商品类型只有一个列表key,不存在大量key同时过期的问题,但我依然给TTL加了随机偏移。真正容易翻车的是Redis重启后的缓存预热:如果Redis重启完成,但应用里的缓存没有先加载,第一批用户请求会同时发现miss并同时打数据库,效果和雪崩一样。所以我把预热逻辑做成项目启动后主动执行,相当于把冷启动提前了。

排查热Key可以用Redis自带的redis-cli --hotkeys命令,它会扫描并输出访问频率最高的key。也可以开启monitor命令观察实时命令,不过生产环境慎用。练习阶段最方便的是用可视化工具看key的访问次数和过期时间,我推荐Another Redis Desktop Manager,免费、跨平台,能够直接查看列表里的元素内容。

本地开发连不上Redis多半不是代码问题,而是Redis配置了bind 127.0.0.1和protected-mode yes,只允许本机连接。我的做法是本地练习把bind注释掉,设置一个简单密码,然后重启Redis服务。Windows用户有个大坑:老版本Redis启动后默认没有密码,防火墙也可能拦截6379端口,连接超时不要先怀疑代码,先用redis-cli ping确认服务通不通。

4.3 连接超时与环境配置

有一段时间我的接口偶尔报command timed out; nested exception is io.lettuce.core.RedisCommandTimeoutException。排查过程很典型:Redis进程正常,socket也能通,为什么还会超时?最后定位到两个原因:一是音频项目远程调试时网络延迟波动大,二是连接池太小,在高并发下连接被耗尽,请求只能等。

用Lettuce作为客户端时,Spring Boot默认没有启用连接池,需要在配置里显式打开。我用的配置如下:

spring: data: redis: timeout: 3000ms lettuce: pool: max-active: 16 max-idle: 8 min-idle: 2

注意版本差异:Spring Boot 2.x使用的配置前缀是spring.redis,Spring Boot 3.x则是spring.data.redis。我一开始就用错了前缀,配置没生效,排查了很久。这个问题也值得记住,因为它是Redis练习中最容易忽略的环境配置坑。

4.4 Redis安装与可视化小技巧

课后练习阶段,Redis环境搭得顺利就能省下大量时间。macOS用户直接用Homebrew安装:

brew install redis brew services start redis

Windows用户除了下载Windows版本,我更推荐用Docker统一环境:

docker run -d --name redis \ -p 6379:6379 \ redis:7

这样安装的Redis干净、版本一致,删除容器也不会留下残留。Docker跑Redis主从也是黑马点评后续课程要用到的,提前熟悉容器命令对后面学习没有任何坏处。可视化工具方面,Redis官方推出的Redis Insight也很不错,能查看key、分析内存、跑命令行,适合练习阶段观察缓存变化。

5. 课后练习的扩展与思考

5.1 从缓存练习到缓存设计

商品类型缓存只是一个小切口,但可以往外延伸出很多值得思考的点。比如我给Service加了一个开关,上线前可以通过配置中心动态关闭缓存操作,方便故障时快速降级。又比如我在缓存操作前后埋点统计命中次数和未命中次数,通过日志打印出缓存命中率。这个命中率能直观反映缓存设计是否合理,商品类型这种几乎是100%命中,而一些设计不合理的缓存可能命中率只有30%,那就需要考虑Key设计是否有问题,是否把不该缓存的数据也缓存了。

也可以给缓存监控单独做一个封装,统一记录Redis操作耗时和异常,这样未来接入Prometheus监控时就顺理成章。练习虽小,思维可以拉得很大。面试时提到这些点,比单纯说“我用Redis加了个缓存”要有说服力得多。

5.2 用数据说话:改造前后对比

练习做完后,我顺手用压测工具对接口做了一组对比。改造前,首页类型接口在本地环境平均响应时间大概是30毫秒,数据库查询次数和请求次数完全一致,一个请求就是一次查询。加了Redis缓存后,第一次冷启动还是30毫秒左右,后续请求稳定在1到3毫秒之间,数据库查询次数几乎降到0。我用100个线程循环压测了1000次,改造前数据库SQL执行了1000多次,改造后只有第一轮压测时执行了1次,其余999次全部命中缓存。

这组数据让我自己对缓存的价值有了更直观的体会。在做项目总结或者准备面试时,能随手给出“响应时间从30ms降到2ms,数据库查询从1000次降到1次”这样的对比,比任何概念解释都更能说明问题。不要只满足于把代码跑通,多问自己一句“改造前后差距有多大”,收获会完全不同。

这段练习做到最后,我的体会是:缓存不是一个set、get就完事的功能,key设计、数据结构选型、过期策略、穿透和击穿防御、一致性保障,每一步都有值得深挖的细节。黑马点评商品类型缓存作为课后练习,正好把这些点串了起来。如果你也在做这个练习,建议不要把代码写完就结束,试着把上面的问题全部过一遍,再随手改一改参数看看效果,收获会比只看官方笔记大得多。

版权声明: 本文来自互联网用户投稿,该文观点仅代表作者本人,不代表本站立场。本站仅提供信息存储空间服务,不拥有所有权,不承担相关法律责任。如若内容造成侵权/违法违规/事实不符,请联系邮箱:809451989@qq.com进行投诉反馈,一经查实,立即删除!
网站建设 2026/10/2 3:06:13

opencode:终端开源AI编程代理的安装配置与实战指南

如果你最近刷到了大量“opencode”相关内容&#xff0c;正在纠结它到底是什么、值不值得换掉手头的Codex或Claude Code&#xff0c;那我可以直接告诉你结论&#xff1a;opencode是一个跑在终端里的开源AI编程代理&#xff0c;它的核心定位不是做一个“IDE插件”&#xff0c;而是…

作者头像 李华
网站建设 2026/10/2 3:06:13

容器化数据库与GORM实践:从Docker部署到Go数据访问层调优

最近把一套内部系统的数据库全部容器化&#xff0c;顺手把Golang这边的数据访问层从裸SQL迁到了GORM。折腾下来的感受是&#xff1a;容器化数据库和ORM这俩东西单独用都不算难&#xff0c;难的是两套体系交界处的细节——容器网络、连接池、时区、字符集、类型转换&#xff0c;…

作者头像 李华
网站建设 2026/10/2 3:04:51

跨平台开发必读:用.gitattributes彻底解决Git行尾符问题

我们组上周刚结束一场莫名其妙的代码审查&#xff0c;原因是某个同事在Windows上提交了一版配置类文件&#xff0c;结果Linux服务器上的CI构建直接报错&#xff0c;排查了半天&#xff0c;最后发现罪魁祸首就是行尾符——CRLF和LF的经典跨平台冲突。这不是个例&#xff0c;几乎…

作者头像 李华
网站建设 2026/10/2 3:03:36

SAP QM质量管理核心流程:从主数据到检验批的完整事务码指南

做了十多年SAP&#xff0c;QM这块我接触的项目不算少&#xff0c;但像标题里这种“QS41→QS51→CT04→CL02→QS31→QS21→CL24N→QP01→MM02→QA01→CO01/MIGO→QA32(QE02,QA11)”一长串事务码排出来的流程&#xff0c;还是经常能吓到新人。别慌&#xff0c;这一串看起来吓人&a…

作者头像 李华