资讯详情

高并发缓存项目必知:穿透、击穿、雪崩与Spring Boot Redis实战

📅 2026/10/26 9:18:55 | 华诺云谱 👁 阅读
高并发缓存项目必知:穿透、击穿、雪崩与Spring Boot Redis实战
简介这是一份基于Redis实战的高并发缓存项目完整源码包面向毕业设计、后端开发与性能优化学习者重点解决高并发读写下数据库压力大、响应延迟高等问题。包内共79个文件主体为72个Java源码覆盖业务接口、缓存读取与更新逻辑实现2个XML配置和1个YAML配置用于服务与Redis连接设置1个SQL脚本提供初始化数据另有2个Lua脚本展示Redis原子脚本的编写。整个压缩包仅93KB代码结构紧凑便于快速导入IDE研读。目前已有48人浏览学习。学习这份资源可以掌握Redis核心数据结构选型、持久化策略、发布订阅、事务与管道使用以及集群部署和负载均衡思路并能借鉴一套可运行的缓存项目骨架实践从系统设计到性能测试的完整流程为实际项目中的缓存优化提供直接参考。1. 别急着部署这个高并发缓存项目到底在解决什么先泼一盆冷水很多打着“高并发缓存”名头的项目代码看下来只是把 Spring Cache 注解往方法上一贴压测一跑就暴露原形——QPS 没过 3000Redis 连接池先报错缓存一失效数据库瞬间被打穿。你拿到的这个.zip不管里面是完整服务还是教学骨架核心价值都不在“用了 Redis”而在三个技术点上缓存穿透的拦截、热点 key 击穿的重建、以及缓存与数据库的一致性处理。这三个点处理干净Redis 才能称作高并发架构里的缓存中间层否则它就是个慢一点的 HashMap。这篇笔记会从读多写少的业务选型讲起接着给出一套可以直接抄的缓存服务代码然后把线上最常见的一批翻车现场拆开讲。适合刚把 Spring Boot Redis 跑通、却不知道怎么撑住万级并发读的开发者也适合正在做缓存治理、想捋清边界的老手。2. 先把缓存设计想清楚粒度和一致性是架构的第一道分水岭2.1 业务场景怎么决定缓存粒度全量对象、字段级还是聚合视图做缓存之前先回答一个问题这条数据被读出来之后是整块用还是只取其中几个字段我见过太多人图省事把 MySQL 一行记录序列化成 JSON 整个塞进 String 类型美其名曰“对象缓存”。商品表一共 60 个字段前端详情页真正展示的只有 8 个字段其他 52 个字段跟着 JSON 一起序列化、反序列化、占内存。并发一高内存涨得飞快网络带宽也被无谓消耗。在你开始动手改代码前先把几条典型路径列出来详情页这类读多写少的场景按聚合根缓存比如订单主体携带商品快照、用户基础信息列表页这类查询条件复杂的场景缓存 key 要包含查询参数哈希value 只存 ID 列表字段级高频更新场景单独缓存热点字段其他字段留在数据库。常见做法是项目里维护一张缓存治理表记录 key 前缀、过期时间、更新触发方避免多个服务各写各的 key 导致互相覆盖。这个表不用做成系统Excel 就够用关键是定下规则。2.2 缓存与数据库的一致性问题先更新DB还是先删缓存不一致的根源在于两个存储没有事务。你永远没法保证 Redis 写成功、MySQL 写失败之后能自动回滚所以目标系统里做的都是“最终一致”而不是“强一致”。业界最流传的是 Cache Aside 模式读的时候先读缓存读不到就读数据库回填缓存写的时候先更新数据库再删除缓存。为什么删除而不是更新因为更新缓存无法处理并发写。两个请求同时改同一条数据后写入数据库的可能是 A先更新缓存的也可能是 A最终缓存里留下旧值或错值这个事故很难排查而且非常常见。删除缓存则是让下次读的时候把新值加载进来。这里有个小坑先删缓存再更新数据库会导致读请求在两步之间把旧值重新写进缓存正确顺序是“先更新 DB后删缓存”。这个顺序仍有一个理论上的弱一致窗口——更新 DB 成功、删除缓存失败的那一瞬间旧值还在缓存里。处理办法是引入一个重试队列或者订阅 MySQL binlog 做异步补偿不过在中小项目里最务实的做法是给删除操作加一个短超时重试。提示别在主流程里同步做 binlog 监听。把删缓存失败的 key 丢进本地内存队列由后台线程每分钟兜底重删一次实测够用。缓存治理的优先级是“别把缓存写坏”而不是“每毫秒都一致”。3. 高并发缓存三大灾难穿透、击穿、雪崩的拦截方案3.1 缓存穿透拦截布隆过滤器与空值缓存缓存穿透指的是请求查询一个数据库里根本不存在的数据缓存查不到数据库也查不到每次请求都直达数据库。攻击者只要拿一个不存在的用户 ID 轮询你的接口数据库压力立刻上来。最直接的办法是布隆过滤器在请求进入缓存之前先用过滤器判断 key 是否存在。布隆过滤器用位数组加多个哈希函数判断“不存在”是准确的判断“存在”有小概率误判这个特性恰好适合拦截非法 key。实际落地时布隆过滤器维护起来很麻烦元素新增好说删除做不到。如果你的业务有频繁下线、删除商品这类操作布隆过滤器会一直保留已删除的 key导致误判增加。因此我一般会在过滤器前再做一道空值缓存查询结果为空时往 Redis 里写一个短过期时间的空占位比如 30 秒。两招并用穿透概率基本可以忽略。需要注意的是空值缓存不能设太长过期时间否则用户新增数据后会有最长 N 秒看不到新内容。接口报错时期望打到 MQ 做数据更新提醒不要直接返回错误。3.2 热点 key 击穿互斥重建与逻辑过期缓存击穿指的是一个单独的 key 在失效瞬间并发请求全部打到数据库。典型场景就是一个爆款商品的详情页过期时间一到几千个请求同时穿透。最简单有效的做法是互斥重建拿到请求后发现缓存失效不立刻去查库而是先尝试获取分布式锁获取成功的请求承担查库和回填缓存的责任获取失败的请求短暂 sleep 后重新读缓存。这个方案能让数据库压力降几个数量级。互斥重建的代价是热点 key 失效的那一瞬部分请求会轻微延迟。更高级一点的做法是逻辑过期value 里存一个过期时间戳读的时候发现逻辑上已过期先返回旧值同时异步线程去更新缓存。这个方案体验好但实现上要小心“异步更新还没完成、又有请求读到过期数据”的重复重建问题需要在异步任务里加分布式锁来兜底。我自己的项目里QPS 要求不高时用互斥重建要求毫秒级响应时用逻辑过期加异步更新锁用 Redis 分布式锁统一封装。3.3 缓存雪崩过期时间打散与多级缓存雪崩和击穿的区别在于规模击穿是单个 key雪崩是一大批 key 在同一时间失效。比如系统初始化时把所有商品缓存设置为同一个过期时间零点一过全部过期数据库瞬间被淹没。打散过期时间的标准做法是在统一过期时间上再加一个随机偏移量比如 5 分钟加上 0 到 120 秒的随机值。这个做法简单但只能降低概率不能完全避免因为热点 key 往往集中在一批商品上。第二道防线是多级缓存本地缓存做一级Redis 做二级数据库做三级。本地缓存用进程内 map 或 Caffeine设置极短过期时间比如 30 秒。请求进来先查本地命中就直接返回不再进 Redis。这样即使 Redis 短暂不可用本地缓存还能扛住一部分流量。但多级缓存会带来一致性问题因为一级和二级缓存可能同时存在旧值。为此每次写操作要同时清理本地缓存和 Redis并且一级缓存的过期时间要尽量短它的定位只是“瞬间缓冲”不是可靠存储。Redis 本身也要做高可用k8s 环境里部署主从集群是更好选择业务侧再处理一下读路由不能把所有读压在单点上。3.4 Redis 线程 IO 模型为什么阻塞命令会把高并发打回原形Redis 6.x 之前核心命令执行是单线程的IO 复用解决的是读写等待问题命令处理仍然是串行。这就意味着一个慢命令会把后续所有命令堵住。在使用高并发缓存项目时最容易出问题的有三类KEYS *、大 key 的DEL、以及HGETALL/LRANGE一次性捞巨大集合。生产环境里禁用KEYS已经是教科书级忠告但真的出事时总有人用它查线上 key。正确做法是用SCAN分批遍历删除大 key 用UNLINKRedis 4.0 起它就是异步删除了。IO 模型这个点还决定了你设计缓存时长时要避开的另一个坑把 Redis 当成消息队列。它的流类型虽然能做简单队列但高并发场景下盲发BRPOPLPUSH加阻塞比专业 MQ 更容易把瓶颈引到 Redis 上。中间件各司其职Redis 做好缓存热数据这件事本身已经很有价值。4. 落地最小可运行 Redis 缓存服务Spring Boot 代码与序列化选择4.1 基础依赖与连接配置下面这套代码是 Spring Boot 项目里最基础也最稳固的基础设施配置。先引入依赖dependency groupIdorg.springframework.boot/groupId artifactIdspring-boot-starter-data-redis/artifactId /dependency然后创建一个RedisConfig设置 String 序列化器Configuration public class RedisConfig { Bean public RedisTemplateString, Object redisTemplate( RedisConnectionFactory factory) { RedisTemplateString, Object template new RedisTemplate(); template.setConnectionFactory(factory); // key、field 一律用 String 序列化防止 \xAC\xED 这类乱码 template.setKeySerializer(new StringRedisSerializer()); template.setHashKeySerializer(new StringRedisSerializer()); // value 用 JSON 序列化可读且语言无关 template.setValueSerializer(new GenericJackson2JsonRedisSerializer()); template.setHashValueSerializer(new GenericJackson2JsonRedisSerializer()); template.afterPropertiesSet(); return template; } }这段配置解决的是热词里反复提到的“redis序列化”问题。很多教程直接把默认的 JDK 序列化器留下导致存入 Redis 的 key 带有二进制前缀你用 Redis Desktop Manager 查看时看到的全是乱码。上面的配置把 key 固定为 Stringvalue 用 JSON可读性好跨语言也不会出问题。4.2 读多写少场景下自研缓存 Service防穿透、防击穿、随机过期时间接着写一个通用缓存服务Service public class CacheService { private final static String LOCK_PREFIX REBUILD_LOCK:; Autowired private RedisTemplateString, Object redisTemplate; Autowired private StringRedisTemplate stringRedisTemplate; /** * 查询缓存不存在则执行回源方法回源结果自动写回 * * param key 缓存 key * param expireSeconds 基础过期时间 * param loadFromSource 从数据库或远程 RPC 加载数据 */ public T T get( String key, long expireSeconds, SupplierT loadFromSource, TypeReferenceT typeRef) { Object cached redisTemplate.opsForValue().get(key); if (cached ! null) { return convert(cached, typeRef); } // 尝试获取重建锁防止热点 key 失效瞬间请求全部打到数据库 String lockKey LOCK_PREFIX key; String requestId UUID.randomUUID().toString(); boolean locked tryLock(lockKey, requestId, 5); if (!locked) { // 拿不到锁的请求先睡一觉再回来读缓存 try { Thread.sleep(20); } catch (InterruptedException ignored) {} cached redisTemplate.opsForValue().get(key); if (cached ! null) { return convert(cached, typeRef); } throw new RuntimeException(系统繁忙请稍候重试); } try { // 二次检查可能第一个拿到锁的线程已经重建完成 cached redisTemplate.opsForValue().get(key); if (cached ! null) { return convert(cached, typeRef); } // 回源查询数据库 T target loadFromSource.get(); if (target ! null) { // 加随机偏移避免批量 key 同时过期引发雪崩 long realExpire expireSeconds ThreadLocalRandom.current().nextLong(0, 120); redisTemplate.opsForValue().set(key, target, realExpire, TimeUnit.SECONDS); } else { // 空值缓存防穿透 redisTemplate.opsForValue().set(key, , 30, TimeUnit.SECONDS); } return target; } finally { unlock(lockKey, requestId); } } private boolean tryLock(String lockKey, String requestId, int seconds) { return stringRedisTemplate.opsForValue() .setIfAbsent(lockKey, requestId, seconds, TimeUnit.SECONDS); } private void unlock(String lockKey, String requestId) { // 通过 Lua 脚本保证“检查 删除”原子性避免误删他人锁 String lua if redis.call(get, KEYS[1]) ARGV[1] then return redis.call(del, KEYS[1]) else return 0 end; DefaultRedisScriptLong script new DefaultRedisScript(lua, Long.class); stringRedisTemplate.execute(script, List.of(lockKey), requestId); } SuppressWarnings(unchecked) private T T convert(Object data, TypeReferenceT typeRef) { if (data null) return null; if (data instanceof String s s.isEmpty()) return null; return (T) data; } }这里说明几个关键参数。tryLock中的setIfAbsent是 Redis 分布式锁的常规实现key 是待重建的缓存 key 加前缀value 为 UUID。这个锁只由回源线程持有非回源线程不会获取锁因此锁的粒度合理。时间为 5 秒表示回源操作最长阻塞 5 秒超时自动释放避免死锁。unlock用 Lua 脚本比较 value 再删除。为什么不用先get再del因为两步操作存在竞态线程 A 判断锁是自己的接下来还没删除时锁已自动过期线程 B 获取了同一个 key 的锁A 随后执行del就把 B 的锁误删了。Lua 脚本可以保证判断和删除是原子的这段代码值得直接抄。get方法里拿不到锁的请求会 sleep 20 毫秒后读缓存这是互斥重建的最后一次兜底。如果仍然没读到会返回异常提示。实战中这一步的语义应该细化成业务重试而不能让用户直接看到报错。4.3 写数据时删除缓存保证下一次读能加载到新值读服务写好后还需要一个配套的写侧操作public void invalidateCache(String key) { // 先更新数据库事务提交后再删缓存 redisTemplate.delete(key); }在业务代码里使用时建议将数据库更新和缓存删除放到同一个事务的afterCommit回调里这样不会因为事务回滚导致缓存先删掉而数据库没有更新。实际项目中顺序应该是更新 MySQL → 事务提交成功 → 删除 Redis key。如果事务还没提交就删除缓存此时其他线程读到的还是旧缓存等事务提交后才会读到新值白白多了一次缓存击穿别且整个窗口期的数据是旧值。5. 线上常见问题排查五条 Redis 缓存踩坑记录5.1 缓存总是莫名“短命”没过半小时就全部失效现象缓存明明设置了 3600 秒过期但观察 Redis 内存使用率每隔十几分钟就出现一批 key 消失命中率直线下降。原因没有设置maxmemory-policy或者配置了allkeys-lru。Redis 内存达到上限后即使你的 key 还没到期也会被 LRU 策略清理。全量删 内存淘汰产出了一批新 key相当于缓存雪崩提前来了。解决设置合理的淘汰策略volatile-lru只对设置过过期时间的 key 做 LRU 淘汰同时监控used_memory和evicted_keys。在redis.conf里加两行maxmemory 4gb maxmemory-policy volatile-lru这是缓存治理的第一步。还要留意 RDB 持久化配置save 900 1表示 900 秒内发生 1 次变更就触发快照如果生产环境写入量大后台 fork 容易导致瞬间卡顿按需调整持久化策略比默认配置更可靠。5.2 Redis Command Timed Out 刷屏连接池耗尽现象日志里大量io.lettuce.core.RedisCommandTimeoutException: Command timed out应用响应变慢Redis 本身 CPU 却不高。原因这是热词里很多人搜到的经典问题。用了 Lettuce 默认配置连接池太小超过并发值之后请求都在等连接。根本原因是 Lettuce 底层是 Netty默认每连接可以并发很多请求但一旦出现一个慢命令整个连接通道被堵住所有请求一起超时。解决换用连接池并在application.yml中调参spring: data: redis: timeout: 3s lettuce: pool: max-active: 64 max-idle: 16 min-idle: 8 max-wait: 500ms另一个更容易踩的点是 Redis 服务端配置timeout 0表示不主动断开空闲连接。如果之前有人设置过空闲超时 30 秒客户端复用连接时会拿到失效连接也会超时。5.3 主从架构下读到了旧缓存缓存一致被打破现象项目部署了 Redis 主从读写分离读请求从库走。更新商品后刷新页面偶尔还会展示几秒前的旧数据。原因主从复制是异步的。主库删除或更新了 key从库还没收到同步指令读请求已经从从库拿到了旧数据。这个场景在 MySQL 主从下也会发生但缓存场景的影响更明显——缓存命中率高旧数据被读到的概率也高。解决如果不接受瞬时旧数据就不要让缓存层做读写分离。Redis 缓存本身性能极高单实例 QPS 已经可以到 10 万级别绝大多数业务完全不需要靠读写分离来分担读压力。更稳妥的方案是读请求全部走主库或者只在从库上做统计报表类的非实时读取。如果确实需要读写分离补偿方案是在写操作完成后主动redis-cli -h slave等待WAIT 1。这个命令可以让主库等待从库确认复制完成但会带来额外延迟需要自己评估。5.4 用 DEL 删除大 key 导致 Redis 抖了一秒现象压测时发现每删除某个订单缓存Redis 操作耗时突然飙到几百毫秒甚至一秒伴随慢查询日志异常。原因误删了一个包含大量元素的 key比如用SADD存了一批用户 ID 的 Set或者用LPUSH存的列表有几十万条。DEL是同步操作Redis 单线程要花大量时间回收这个 key 的内存期间所有请求全部排队。解决Redis 4.0 以上用UNLINK key异步释放内存在redis-cli里执行UNLINK order:list:20250101同时排查代码里是否有类似redisTemplate.delete(key)直接调用Spring Data Redis 的delete默认执行的是 DEL。建议封装一个unlink方法或者对超大规模集合不要直接整体缓存改成 key 分片存储。5.5 连接不上 Redis Desktop Manager连接测试通过但查询报错现象本地 Redis Desktop Manager 能连上但是点击某个 key 时界面卡死或者提示WRONGTYPE Operation against a key holding the wrong kind of value。原因第一个可能是单条 value 过大比如存了 10MB 的字符串图形客户端一次性拉取把界面拖死。第二个原因很隐蔽项目里用了两种序列化器一部分 key 是 JSON另一部分是 JDK 序列化的二进制用 RDM 直接打开时会显示乱码导致你误判类型。解决不要把 RDM 当作线上监控工具线上用another redis desktop manager带过滤功能连接的时候限制扫描数量。检查 key 类型用命令而不是肉眼TYPE order:info:123 MEMORY USAGE order:info:123如果MEMORY USAGE返回的数字非常大考虑拆分 value 或压缩。这类排查类的问题我通常三分钟内就能定位前提是项目里最初就规范了 key 命名和序列化配置如果没规范先按上文 4.1 节的配置统一起来。6. 收尾动作把缓存命中率做成可验证的观测报告到这个阶段代码和坑都过了一遍最后不要靠感觉验证效果。我会在项目里做一个最简单的定时任务每 30 秒从 Redis 拉一次INFO stats统计命中率。redis-cli info stats | grep -E keyspace_hits|keyspace_misses再用一段脚本把它转成可读的百分比#!/bin/bash HITS$(redis-cli info stats | awk -F: /keyspace_hits/{gsub(/[^0-9]/,,$2); print $2}) MISSES$(redis-cli info stats | awk -F: /keyspace_misses/{gsub(/[^0-9]/,,$2); print $2}) if [ $((HITSMISSES)) -eq 0 ]; then echo no data; exit 0; fi echo hits$HITS misses$MISSES rate$(echo scale2; $HITS*100/($HITS$MISSES) | bc)%把这个脚本挂到 cron 里每天落一份日志观察趋势。命中率稳定在 90% 以上说明缓存设计基本合理如果某一天突然掉到 70% 以下就去查是不是新上线的报表功能把所有 key 都刷了一遍或者有人手工执行了FLUSHALL。这是复盘各类事故最重要的基础数据。我个人习惯在每次缓存改动后先做一轮 5 分钟压测同时记录 Redis 的instantaneous_ops_per_sec和rejected_connections总出现异常波动就要翻回去看 4.1 节的配置是不是被人偷偷改过。这套经验来自好几次线上翻车后的反思希望帮到你。本文还有配套的精品资源点击获取
📝

华诺云谱内容团队

资深建站顾问 · 行业研究员

10年+企业数字化服务经验,专注智能建站、SEO优化与品牌营销,持续输出建站技巧、行业洞察与营销干货,已帮助5000+企业实现数字化增长。

你可能需要的服务

订阅华诺云谱资讯周报

每周一封,精选建站技巧、SEO与营销干货,直达邮箱。已有 8,000+ 企业主订阅,助你少走弯路。

↑