ARTICLE DETAIL

资讯详情

深耕郑州网站建设与运营推广的一线实战洞察。

Redis商户查询缓存实战:穿透、击穿、雪崩与一致性治理

Redis商户查询缓存实战:穿透、击穿、雪崩与一致性治理 “黑马点评”这个项目我前后刷了两遍第二遍专门盯住了“商户查询缓存”这一块才算是把 Redis 在企业级查询场景里到底怎么落地给嚼碎了。这个模块看起来就是“查一个商铺详情加个缓存”但里面塞了一堆实际开发中必然踩坑的东西缓存穿透、缓存击穿、缓存雪崩还有缓存和数据库的一致性。我把自己反复调试的过程整理成笔记这一篇重点讲商户查询缓存适合那些跟着教程敲完却不太理解为什么这么写的同学也适合准备面试前想搞懂 Redis 缓存治理细节的人。1. 商户查询缓存先搞清楚它到底在解决什么问题1.1 为什么要专门给商户查询加一层缓存黑马点评是一个模仿大众点评的实战项目商户查询是最核心也最高频的接口之一。你想想用户打开 App 第一件事就是看附近的店铺点进去看详情一个商户详情页可能在高峰期被请求成千上万次。如果每次请求都直接打到数据库哪怕 MySQL 能扛住几千 QPS也会被大量重复的读请求拖垮尤其是冷门商户和热门商户混在一起时数据库连接数很快就会被打满。缓存的作用就是把这些重复的查询结果“就近”存起来。第一次有人查某个商户时我们老老实实去数据库拿然后写入 Redis之后同样的查询直接走 Redis不再碰数据库。对于读多写少的场景这种“缓存数据库”的分层架构能扛住几十倍甚至上百倍的流量压力。商户查询恰好就是典型的读多写少业务——用户反复看商户信息却很少变。从数据特征上还能看到另一层意义。商户信息是结构化数据规模可控单条数据大小通常在几 KB 以内非常适合用 Redis 的 String 结构存储序列化后的 JSON。相比缓存用户会话、缓存验证码这类临时数据商户缓存的业务属性更强它需要解决的是“数据源压力”问题而不是单纯的“临时存储”问题。1.2 缓存选型为什么选了 Redis 而不是本地缓存做缓存方案时我第一反应是项目里已经引了 Spring 全家桶为什么不直接用本地缓存比如 Caffeine 或者简单的 ConcurrentHashMap当时我把两套方案都简单测了一下。本地缓存的优势是无网络开销速度更快实现也简单但目前提是缓存只为单个应用实例服务。黑马点评部署时通常不会单机跑Nginx 后面挂多个后端实例时用户请求会被负载均衡到任意一台机器。本地缓存会让每个实例各存一份数据出现“每台机器数据不一致”的问题而且当某个实例发生热点数据更新时其他实例难以及时感知只能等过期。另外本地缓存受堆内存限制商户数据一旦量大很容易挤占 JVM 空间。Redis 作为分布式缓存是多实例共享的所有后端实例读写同一份缓存数据不存在单机内存容量瓶颈也天然支持过期、持久化、集群等能力。虽然多了一次网络 IO但 Redis 本身处理速度极快在局域网环境下一次读取通常在毫秒级完全能接受。最终我选了 Redis更准确地说是 Spring Data Redis 中的 StringRedisTemplate配合 JSON 字符串存储。1.3 一条商户查询请求的完整流转路径把整体流程画在脑子里后面写代码才不会乱。标准的 Cache Aside 模式下的查询链路是这样的请求到达/shop/{id}接口先根据 key 查 Redis如果 Redis 有数据直接反序列化为 Shop 对象返回如果 Redis 没有数据查数据库数据库也没有返回错误数据库有则把数据写入 Redis设置过期时间再返回给前端。这个流程看着简单但它埋了很多细节key 的规则是什么用什么序列化方式过期时间设多长缓存没命中时数据库并发请求怎么控制。任何一个环节处理不好都会在真实场景里引发“缓存穿透”“缓存击穿”或者“缓存雪崩”。后面的章节就把这些细节逐个拆开讲。2. 设计与实操从零搭建一个可用的商户缓存查询接口2.1 准备环境Redis 依赖与配置黑马点评的初始工程里往往已经带了 Redis 依赖但自己从头建项目的话需要确认这几个东西。首先是 Maven 依赖dependency groupIdorg.springframework.boot/groupId artifactIdspring-boot-starter-data-redis/artifactId /dependency dependency groupIdorg.apache.commons/groupId artifactIdcommons-pool2/artifactId /dependency第二个依赖是连接池依赖Spring Boot 2.x 使用 Lettuce 作为默认客户端Lettuce 本身可以不用连接池但在高并发下建议配置连接池避免频繁创建连接。配置文件里基础项如下spring: redis: host: localhost port: 6379 lettuce: pool: max-active: 8 max-idle: 8 min-idle: 0这里要注意配置连接池后还需要引入 commons-pool2否则启动时不会报错但运行时拿不到连接池对象我踩过一次这个坑。另外生产环境一定要设置密码和指定 db不过学习阶段本机默认配置就够了。2.2 缓存 Key 的设计细节商户查询缓存的 key 到底长什么样教程里给的是cache:shop:1001这种形式。我第一次写的时候直接用了shop_1001功能没错后来才意识到命名空间的重要性。规范的 key 由三部分组成业务前缀、模块名、唯一标识。cache:shop:id这种冒号分隔的写法是 Redis 社区比较标准的做法一方面方便在 Redis Manager 里按前缀模糊搜索和删除另一方面也降低了多业务之间 key 冲突的概率。如果你还有其他模块比如优惠券、博客都可以沿用cache:coupon:、cache:blog:这样的前缀。还有一个小细节不同类型的查询要注意 key 的粒度。商户查询是按 id 查询key 就是cache:shop:1001但店铺列表查询往往附带地理位置、类型等条件这种复杂查询的 key 就得拼接查询参数比如cache:shop:type:1:region:xxx。设计 key 的时候尽量保持“越简单越好但不要牺牲可管理性”。2.3 商品商户信息序列化的坑黑马点评里的商户实体是Shop它对应的表叫tb_shop字段有 id、name、typeId、images、area、address、avgPrice、sold、comments、score、openHours、createTime、updateTime 等。Redis 里存的不是对象序列化后的二进制而是 JSON 字符串。为什么不用 JDK 序列化因为 JDK 序列化后的数据里有类路径信息和大量描述头可读性差、体积大而且 Redis 里如果存的是 Java 对象二进制格式其他语言或者直接用命令行看数据时会完全看不懂。用 JSON 的话数据清晰也方便后续做数据分析、日志排查。实际代码里我一开始用的是Jackson的ObjectMapper后来发现想省事的话直接使用StringRedisTemplate配合手动 JSON 转换即可。Spring Data Redis 默认的RedisTemplateObject,Object用的是JdkSerializationRedisSerializer存进去的数据是乱码不要直接拿它做业务缓存。推荐换成Configuration public class RedisConfig { Bean public RedisTemplateString, Object redisTemplate(RedisConnectionFactory factory) { RedisTemplateString, Object template new RedisTemplate(); template.setConnectionFactory(factory); // key 使用 String 序列化 template.setKeySerializer(RedisSerializer.string()); // value 使用 JSON 序列化 template.setValueSerializer(RedisSerializer.json()); // hash 结构也做同样配置 template.setHashKeySerializer(RedisSerializer.string()); template.setHashValueSerializer(RedisSerializer.json()); template.afterPropertiesSet(); return template; } }但这里又有一个新坑用RedisSerializer.json()反序列化时如果对象里有 LocalDateTime 这类时间字段Jackson 反序列化会报异常因为Shop的createTime和updateTime是LocalDateTime类型。教程里在application.yaml中增加了一个配置spring: jackson: date-format: yyyy-MM-dd HH:mm:ss time-zone: GMT8这个配置只对Date类型有效对LocalDateTime未必管用。稳妥的做法是给Shop中时间字段加上JsonFormat(pattern yyyy-MM-dd HH:mm:ss)注解或者用自定义ObjectMapper注册JavaTimeModule。StringRedisTemplate存取 JSON 字符串时建议自己控制序列化这样最稳private static final ObjectMapper MAPPER new ObjectMapper() .findAndRegisterModules() .disable(SerializationFeature.WRITE_DATES_AS_TIMESTAMPS);2.4 核心查询代码实现附带缓存空值逻辑查询商户接口的核心代码教程里其实给得挺简练但里面包含了一个处理缓存穿透的思想缓存空对象。先把基础版写出来后面再讲为什么需要处理空值。Service public class ShopServiceImpl extends ServiceImplShopMapper, Shop implements IShopService { Autowired private StringRedisTemplate stringRedisTemplate; Autowired private ObjectMapper objectMapper; Override public Result queryShopById(Long id) { String key cache:shop: id; // 1. 从 Redis 查询 String shopJson stringRedisTemplate.opsForValue().get(key); if (StrUtil.isNotBlank(shopJson)) { Shop shop objectMapper.readValue(shopJson, Shop.class); return Result.ok(shop); } // 2. 注意如果缓存中存的是空字符串说明之前查过数据库且不存在 if (shopJson ! null) { // 说明是故意缓存的空值直接返回不存在 return Result.fail(店铺不存在); } // 3. 查询数据库 Shop shop getById(id); if (shop null) { // 4. 缓存空值设置较短过期时间防止穿透 stringRedisTemplate.opsForValue().set(key, , 2L, TimeUnit.MINUTES); return Result.fail(店铺不存在); } // 5. 写入 Redis设置过期时间 stringRedisTemplate.opsForValue().set(key, objectMapper.writeValueAsString(shop), 30L, TimeUnit.MINUTES); return Result.ok(shop); } }这段代码里有几个关键点StrUtil.isNotBlank判断字符串不为空且非空串但空字符串也是“非空”的反面所以先用shopJson ! null判断是否命中缓存空值写入缓存时空值要设置一个远小于正常值的过期时间比如 2 分钟防止恶意查询不存在的 id 时把 Redis 塞满正常数据设置 30 分钟的 TTL这个值不是随便定的需要根据业务数据更新频率来调整。上面的代码为了展示清晰忽略了异常处理实操中一定要用 try-catch 包住 JSON 转换逻辑避免缓存数据损坏时把业务接口也拖挂。3. 缓存更新的姿势一致性问题的处理和取舍3.1 更新策略Cache Aside 模式拆解商户信息不是永远不变的运营可能会改店名、调整价格、修改营业时间。这就有个很现实的问题数据库更新后缓存里的旧数据怎么处理业界最常用的就是 Cache Aside 模式也叫旁路缓存操作顺序是读的时候先读缓存读不到再读库然后回填缓存写的时候先更新数据库再删除缓存。听起来很简单但每一步都有它存在的理由不能随意换顺序。这个模式最大的好处是实现简单且足够可靠只要数据库更新成功缓存删除虽然可能失败但最多只是让下一次查询拿到旧数据等 TTL 过期后最终一致。相比完全同步更新缓存的做法删除缓存而不是更新缓存更划算因为更新的成本高还要考虑并发写顺序问题而删除操作只需要一个DEL命令代价极低。在黑马点评项目里更新商户信息对应的是updateShop接口要走的就是“先更新数据库再删除缓存”这条路。我们业务中使用了 MyBatis Plus可以直接调用updateById然后执行delete操作删除 Redis key。3.2 先更新数据库还是先删缓存我在学习时一直在想为什么不先删缓存再更新数据库或者先更新缓存再更新数据库实际上这几种顺序都有问题。先删缓存、再更新数据库如果删完缓存之后、数据库还没更新完突然来一个读请求会查不到缓存然后去数据库读取旧数据再回填到缓存里。等数据库更新完成后缓存里还是旧数据而且 TTL 可能很长导致数据长期不一致。先更新缓存、再更新数据库如果数据库更新失败缓存里就是新数据但实际上数据库是旧数据数据不一致问题更严重而且很难排查。先更新数据库、再更新缓存数据库更新成功后在写缓存时会经历一个时间窗口此时并发读可能读到旧数据。虽然时间很短但严格来说也存在不一致问题不过它好过上面两种方案。先更新数据库、再删缓存是目前综合来看最稳妥的方式。即使删除缓存失败也可以通过 TTL 兜底而且配合下面的延迟双删能最大程度减少不一致窗口。3.3 延迟双删一个很土但有效的补丁既然“先更新数据库再删缓存”已经不错为什么还要延迟双删因为存在一个极端情况请求 A 更新数据库后准备删除缓存请求 B 在这之前读到了旧缓存然后 B 把旧数据回填到缓存里此时 A 再删缓存旧数据被删但如果 A 删除缓存在 B 回填之前B 则会把旧数据重新写回缓存造成缓存长期不一致。延迟双删的思路是第一次删除缓存后等待几百毫秒再次删除一次。目的是让那些在“删除后、回填前”的并发读请求完成回填第二次删除就能把可能被写入的旧缓存清掉。代码思路如下updateById(shop); stringRedisTemplate.delete(key); // 延迟 500ms 后再删一次 executor.schedule(() - stringRedisTemplate.delete(key), 500, TimeUnit.MILLISECONDS);这里有两个注意点。延迟时间的选择不是越长越好而是要大于“读数据库并回填缓存”的时间一般取 500ms 到 1s 之间压测时可以用实际耗时来调节。另一个是第二次删除必须保证执行成功生产环境可以扔到消息队列里异步重试或者使用可重试的脚本否则又等于没删。延迟双删不是银弹它只是把不一致窗口缩短到极低真正的最终一致还是要靠 TTL 兜底。3.4 过期时间与随机化最廉价的雪崩预防每次往 Redis 里写缓存时都需要设置过期时间。为什么不设置永久有效一来缓存数据必须与源数据最终一致二来防止 Redis 内存被无限制占用。我在写的时候一开始直接set(key, json, 30, TimeUnit.MINUTES)也没有多想。后来模拟大量缓存同时过期时才发现问题如果一大批商户缓存同时失效而这时正好有大量请求进来数据库会被瞬间压垮这就是缓存雪崩的其中一个诱因。解决办法很朴素给 TTL 加一个随机扰动。比如原本是 30 分钟实际设置为 30 分钟加上一个 0 到 300 秒的随机值。这样即使大量 key 是同一个时间创建的过期时间也会错开避免出现“整点集体失效”的场面。代码实现long ttl 30 * 60 ThreadLocalRandom.current().nextInt(300); stringRedisTemplate.opsForValue().set(key, json, ttl, TimeUnit.SECONDS);这个技巧非常廉价但很有效。对于商户类冷数据甚至可以把基础 TTL 提升到 1 小时甚至更长降低数据库压力同时靠业务更新时主动删缓存保证一致性。4. 线上最常见的三大缓存问题穿透、击穿、雪崩4.1 缓存穿透空值缓存与布隆过滤器缓存穿透是指查询一个不存在的数据。因为缓存里没有所以请求会一直打到数据库。如果攻击者或者恶意用户频繁用不存在的 id 调用接口数据库会被大量无效查询打崩。黑马点评里最传统的场景就是大量查询一个不存在的商户 id比如-1、99999999这种。我在课程练习里主要使用了缓存空值的方式解决当查询数据库发现结果为 null 时仍然把 null 值包装成空字符串写入 Redis并设置一个 2 分钟的过期时间。这样下次同样的查询直接命中缓存虽然拿到的不是真实数据但避免了对数据库的重复冲击。缓存空值的优点是实现简单完全不改变现有代码结构缺点也很明显如果恶意请求总是换不同的不存在 id空值缓存也挡不住而且会占用 Redis 空间。所以更严谨的方案是引入布隆过滤器把所有可能存在的商户 id 提前存到布隆过滤器里查询前先判断 id 是否存在如果不存在就直接返回失败连 Redis 都不需要查。布隆过滤器的原理可以理解为用一个很长的二进制位数组加多个哈希函数来标记数据是否存在。它能够比较快速地判断一个元素“一定不存在”或者“可能存在”代价是存在一定的误判率不过对于防止穿透来说是够用的。在实际项目中布隆过滤器适合用在数据量大、空查比例高的场景而黑马点评这种单表数据量小的情况下缓存空值已经够用了。4.2 缓存击穿互斥锁和逻辑过期方案对比缓存击穿和缓存穿透名字接近但意思完全不一样。击穿针对的是某个热点 key当这个 key 突然过期时正好有大量请求同时来查它所有请求都发现缓存没有了于是一起打到数据库。典型场景就是某个爆款店铺突然首页展示查询量瞬间飙升而它的缓存又刚好到期。黑马点评教程里给了两种方案互斥锁和逻辑过期。互斥锁的思路是第一个发现缓存未命中的请求先获取一个分布式锁然后去查数据库回填缓存其他请求发现拿不到锁就暂时等待等第一个请求回填完成后再去读缓存。代码上可以用 Redis 的SETNX命令实现锁注意设置过期时间防止死锁。这么说可能有点抽象实际代码类似String lockKey lock:shop: id; boolean locked tryLock(lockKey); if (!locked) { Thread.sleep(20); return queryShopById(id); // 递归重试 } try { // 再次检查缓存double check // 查询数据库回填缓存 } finally { unlock(lockKey); }互斥锁方案实现相对简单逻辑清晰而且能保证强一致性但缺点是为等待锁的请求增加了一定的延迟如果持有锁的线程执行较慢后续请求会被阻塞。逻辑过期方案正好相反。它不设置真实的 TTL 过期而是把过期时间作为一个逻辑字段存进 value 里。查询时如果发现逻辑时间已过期先尝试获取互斥锁拿到锁的线程单独去重建缓存其他线程则先返回旧数据。这个方案的特点是“短暂的数据不一致可以接受但绝不阻塞用户请求”更适合对性能要求极高、能容忍少许脏数据的场景。黑马点评明显推荐理解互斥锁逻辑过期更多是面试和扩展思路用。我自己实际模拟击穿场景时发现如果热点 key 的过期时间固定击穿的破坏力比想象中大几十个并发请求就足以让数据库 CPU 飙高。加了互斥锁后数据库压力明显下降。但要注意锁的粒度最好细到单个 key而不是全业务共用一把锁否则会出现一个商户慢查询拖慢所有商户查询的情况。4.3 缓存雪崩过期时间随机化与多级降级缓存雪崩是指大量 key 在同一时间过期或者 Redis 节点宕机导致所有请求直接打到数据库造成数据库不可用。这里要区分一下缓存雪崩和击穿的区别在于击穿是单个热点 key 失效雪崩是大面积缓存同时失效。在商户查询场景里如果所有商户缓存都设置了相同的 30 分钟过期那么每过一个 30 分钟周期缓存里的数据就会像约定好一样集体失效一次这时候压力峰值特别难看。我用 Jmeter 简单压过整点前后的请求错误率明显升高。缓解手段我在 3.4 节已经提过就是 TTL 随机化。此外还可以做多级缓存在 JVM 本地缓存一层 Caffeine设置非常短的过期时间比如 30 秒这样即使 Redis 中的 key 集体失效还有本地缓存挡一层。我后来在实际项目中加上了 Caffeine 做 L1 缓存Redis 做 L2 缓存效果相当明显数据库 QPS 压力进一步降低。另一个思路是限流和降级。当检测到数据库连接池使用率过高时直接对一些非核心查询返回默认兜底数据比如热门店铺的默认营业时间而不是让请求继续打到数据库。黑马点评课程里没有实现这么重但从雪崩应对的角度理解这个方向就够了。5. 实测踩坑记录这些问题不真跑一遍根本发现不了5.1 缓存穿透压测下数据库连接被打满我用 Jmeter 模拟了 1000 个并发请求随机传不存在的商户 id数据库连接池瞬间被打满控制台全是连接超时异常。后来加了空值缓存情况明显好转但还发现一个细节空值缓存写入时没有设置过期时间导致 Redis 里堆积了大量过期空值。这里要特别留意所有缓存空值都一定要尽可能短地设置 TTL否则 Redis 内存会涨得很快。另外一个坑来自 MyBatis Plus 的getById方法。如果数据库中确实不存在该记录它返回 null但如果表主键类型是雪花 ID而请求传入的 id 是普通数字MyBatis Plus 会自动做类型转换严重时还会抛异常。所以接口入参最好使用包装类型比如Long同时增加参数校验过滤掉明显不合法 id。5.2 并发查询时的缓存击穿现场我模拟热点商户缓存过期同时用 100 个线程并发请求同一个 id结果数据库在那一瞬间出现了几十条一模一样的 SQL。原因很简单所有线程都发现缓存不存在然后一起执行了select * from tb_shop where id ?。这说明缓存未命中后的回填操作必须加锁保护否则热点 key 就是数据库的定时炸弹。互斥锁实现中我也踩了坑第一次用setIfAbsent实现加锁后忘记设置过期时间。后来某个请求抛异常导致锁没有释放整个接口就一直卡住。正确做法是加锁时设置超时时间比如 3 秒避免死锁。还有一个细节释放锁时不能直接del key最好用 Lua 脚本先比较 value防止误删了别人的锁这就是网上常说的“锁的持有者校验”。5.3 序列化工具选择导致的兼容性坑课程刚开始时我用的RedisTemplate默认序列化器存 Redis 时 key 带了乱码前缀value 是二进制 JDK 序列化对象。后来从命令行看数据完全无法阅读重新启动项目后反序列化还报了类 cast 异常就是因为序列化方式不一致。后来换成了StringRedisTemplate加手动 JSON世界清净了。但要注意手动 JSON 时Shop对象里如果有LocalDateTime字段用默认 Jackson 会报InvalidDefinitionException需要在ObjectMapper上注册JavaTimeModule或者给字段加JsonFormat注解。时间字段序列化后的格式如果带T还要注意前端解析是否兼容。5.4 缓存清理工具 和后续扩展实操中经常需要手动清缓存来测试效果我用的是 RedisInsight直接在界面里按前缀cache:shop:*搜索比命令行方便很多。不过别在生产环境这么干KEYS命令在大量 key 时会阻塞 Redis应该用SCAN命令。项目里如果要开发缓存治理功能最好封装一个统一的缓存管理接口支持按前缀删除、按 key 删除同时预留 TTL 修改能力。如果你打算继续深挖这个项目我建议接着做两件事一是给商户查询加一个“附近商户”的 GEO 缓存用 Redis GEO 命令处理坐标数据二是把缓存代码抽成注解比如自定义Cacheable类似的逻辑统一管理 key 和过期时间。这样黑马点评的缓存模块就算真正吃透了。个人经验上学缓存最容易犯的毛病是只背概念不看实现。你把穿透、击穿、雪崩背得再熟不如真的开一个压测工具把并发打上去亲眼看看数据库发生了什么。黑马点评这个项目最大的好处就是给了你一个足够真实的业务场景商户查询缓存这段代码值得反复敲、反复折腾每次折腾都会对缓存一致性有更深的理解。
返回列表