☰
Redis高并发缓存实战:穿透、击穿、雪崩与一致性解决方案
2026/10/26 23:48:28 网站建设 项目流程

简介:面向毕业设计及高并发场景学习的基于Redis实战缓存项目,以真实业务为背景,完整演示缓存层设计与性能优化。项目内容从Redis五大常用数据类型入手,逐步延伸到RDB/AOF持久化策略、发布订阅、事务、管道操作及集群搭建,帮助学习者理解读写分离、负载均衡和数据一致性等关键问题。压缩包共79个文件,以72个Java源码文件为主体,辅以XML配置、Lua脚本、YAML配置和SQL脚本,体积仅93KB,代码结构精炼完整。目前已有48人学习浏览,适合具备一定Java基础、希望掌握高并发缓存落地方案的开发者。通过该项目可直接获得可运行的工程代码、配置与数据库脚本,便于从零搭建并验证基于Redis的缓存系统,为后续独立设计和优化缓存架构提供扎实参考。

1. 基于Redis实战的高并发缓存项目,到底解决了什么问题?

先看标题里三个关键词:Redis、高并发、缓存。很多团队遇到读多写少的接口,第一反应就是把数据丢进 Redis,但真要扛住几千甚至上万的峰值 QPS,光学会 get/set 远远不够。这个项目拆开来看,其实是在回答一个核心问题:在缓存穿透、缓存击穿、缓存雪崩、缓存一致性这些连环坑里,Redis 怎么通过代码和组织结构变成一个真正能上生产的缓存层,而不是一台永远在超时的内存服务器。这篇文章适合正在做后端、手里有一份类似源码包但不知道从哪看起的人,也适合那些缓存已经上线但每隔一段时间就要救火的团队。下面我会按项目拆解、本地跑通、坑点排查、集群进阶这个顺序,把一套常见的高并发缓存方案讲清楚。

2. 项目模块拆解:Redis 为什么能扛高并发,代码先看哪几个文件

2.1 缓存选型:Redis 的数据类型、线程模型和中间件定位

要判断这套缓存项目值不值得照着做,先得理解为什么选 Redis 而不是 PostgreSQL、MySQL 或其它 KV 存储。Redis 的核心优势不是快,而是它的线程 IO 模型和数据结构设计:命令执行由一个主线程串行处理,通过 IO 多路复用监听大量连接,这避开了多线程锁竞争,让单实例在普通机器上也能跑到 10 万级 QPS。相比数据库必须处理事务、MVCC、磁盘刷页这些重活,Redis 作为中间件只做内存读写,所以同样的流量打到应用层,Redis 可以当缓冲带,数据库只承受最终落盘的那部分。

再看数据类型。高并发缓存项目里,String 用来存用户会话、验证码、接口响应;Hash 用来存订单、商品这类对象,可以单独更新某个字段而不整存整取;List 做消息队列演进前的临时队列;ZSet 用来做排行榜和延迟队列;Set 做去重和抽奖。像标题里提到的 Redis 分布式锁,本质也是靠 String + 过期时间 + Lua 脚本实现的互斥机制。选型阶段不需要把每个命令背下来,但要知道框架里哪些地方用了哪些类型,读到代码时就不发怵。

项目的常见分层也值得先看清楚:入口是 Nginx 或网关,往下一层是应用服务,再往下是 Redis 缓存,最后一层是数据库。这个结构决定了每个接口的读路径是「先查缓存,再查库」,写路径是「先更库,后失效缓存」,也就是 Cache Aside 模式。项目中如果引入了消息队列做异步缓存更新,那 Redis 的位置就变成了数据总线的一部分,队列消费失败时的重试和幂等逻辑也会出现在代码里。阅读源码包时,先分辨这个项目用的是同步失效还是异步更新,这会直接影响你对整个架构的判断。

2.2 先看这几个文件:配置类、序列化器、缓存工具、启动类

一个合格的 Redis 高并发项目,目录结构通常不会太散。我一般先看 config 包,里面一定有 Redis 连接工厂和 RedisTemplate 配置。高并发场景下,连接工厂的配置决定了连接池上限、空闲超时、命令超时,这些是后期排查 Redis command timeout 的第一现场。然后看 common 或 core 包里的序列化器,很多项目默认用 JDK 序列化,key 会变成 \xac\xed 乱码,value 体积膨胀,反序列化时还会因为类版本不一致报错。项目如果改用了 StringRedisSerializer + GenericJackson2JsonRedisSerializer,说明作者踩过这个坑。

接下来看 cache 工具类,里面一般会封装几个方法:带过期时间的 get/set、防穿透的空值缓存、防击穿的互斥重建、批量删除。这些方法名里通常带 cacheAside、preventPenetrate、mutexLoad 之类的词。再往下是 service 层和 controller 层,看缓存和业务是揉在一起,还是通过 AOP 注解解耦。实践下来,用注解声明式缓存(类似 @Cacheable 的思路)只适合低频内部接口,高并发流量下一定要在 service 代码里手动控制缓存粒度,否则注解的拦截器会引入额外的反射和代理开销,也会让超时和降级逻辑变得很难写。

还需要关注 key 的命名规范。好的项目会在常量类里统一定义 key 前缀,比如 user:info:{id}、order:detail:{orderId},而不是散落在业务代码里拼字符串。这个设计不光为了可读性,更是为了上线后的治理:当你想在 another redis desktop manager 里按前缀批量清理测试 key,或者在大促前统计热点 key 分布时,统一前缀能省非常多的时间。如果看到项目里对同样业务同时存在 user:1 和 user:{id} 两种命名,基本可以判断这是重构到一半的代码,参考时需要谨慎。

2.3 核心读写流程:缓存失效时间、回填策略和热点数据边界

把整个流程画清楚之前,先看一段典型的缓存读取逻辑,这是几乎所有高并发缓存项目的主要骨架:

public UserInfo getUserById(Long userId) { String cacheKey = RedisKey.USER_INFO + userId; // 1. 先从缓存查 String cacheValue = redisTemplate.opsForValue().get(cacheKey); if (StringUtils.isNotBlank(cacheValue)) { // 2. 命中直接返回 return JSON.parseObject(cacheValue, UserInfo.class); } // 3. 未命中查数据库 UserInfo user = userMapper.selectById(userId); if (user != null) { // 4. 回填缓存,TTL 加随机偏移 long ttl = 30 * 60 * 1000L + ThreadLocalRandom.current().nextLong(0, 5 * 60 * 1000L); redisTemplate.opsForValue().set(cacheKey, JSON.toJSONString(user), ttl, TimeUnit.MILLISECONDS); } else { // 5. 空值也缓存,防止穿透 redisTemplate.opsForValue().set(cacheKey, "NULL", 120, TimeUnit.SECONDS); } return user; }

这段代码的逻辑说明很直白:第一步先查缓存,命中就返回;没命中查库,查到后回填并设置随机过期时长;查不到也缓存空值。这里的两个参数,一个是 TTL 基础值 30 分钟,它决定了用户数据在缓存里存活的上界,调得越长数据库压力越小,但数据变更后生效越慢。另一个是随机偏移 0~5 分钟,作用就是避免同一批用户数据在同一秒集体过期,这是防缓存雪崩在代码层最基本的动作。

读完这段代码,你会发现高并发缓存项目的核心其实不复杂,复杂的是边界条件。比如查库耗时超过 200ms 时,互斥重建要不要开;批量查询接口里,管道能不能合并请求;缓存更新时,到底先更库还是先删缓存。这些在项目源码里通常通过配置开关暴露出来,比如 cache.enable-mutex、cache.mutex-expire-ms。读者的任务不是把每个开关都打开,而是先按默认配置跑通,再用压测数据决定要不要动。

3. 跑通最小闭环:本地装 Redis、配内存策略、把缓存访问层跑起来

3.1 安装与基础配置:把 Redis 服务拉起来并确认内存策略

拿到项目包后,第一步一定是先把 Redis 跑起来。生产环境用 Linux 或容器,本地开发可以用 Docker 装一个轻量实例。我常用的启动命令是:

docker run -d \ --name redis-cache \ -p 6379:6379 \ -v /data/redis:/data \ redis:7-alpine \ redis-server \ --appendonly yes \ --maxmemory 1gb \ --maxmemory-policy allkeys-lru

这里几个关键参数需要说明。--appendonly yes打开 AOF 持久化,保证重启后缓存数据不丢太多,但要注意 AOF 重写会 fork 子进程,在内存大的实例上可能造成秒级阻塞,所以本地验证没问题,线上要根据数据量评估。--maxmemory 1gb给 Redis 设置内存上限,超过之后的淘汰策略由--maxmemory-policy决定。allkeys-lru表示对所有 key 按最近最少使用算法淘汰,适合纯缓存场景;如果你的 Redis 还存着分布式锁、计数器这类不能丢的数据,那就得用volatile-lru,让它只淘汰带过期时间的 key。

启动后不要急着跑项目,先用redis-cli ping确认能连通,再用redis-cli info memory看一下 used_memory 和 maxmemory 的初始值。如果项目配置文件里的密码、数据库编号和本地实例不一致,通常会报错ERR Client sent AUTH, but no password is set或NOAUTH Authentication required,这时候只需要把本地 Redis 的 requirepass 关掉,或者在项目 application.yml 里把密码字段留空。还有一种情况是项目里指定了 Redis 6.0 以上的 ACL 用户,本地老版本不认识,直接换镜像版本到 7.x 是最快的解法。

3.2 实现缓存访问层:RedisTemplate 配置、序列化和超时参数

本地服务起来后,接着看项目的缓存访问层。一个最常见的改进点是把默认的 JdkSerializationRedisSerializer 换成 String + JSON。下面的配置类是我在多个高并发项目里沿用的版本:

@Configuration public class RedisConfig { @Bean public RedisTemplate<String, Object> redisTemplate(RedisConnectionFactory factory) { RedisTemplate<String, Object> template = new RedisTemplate<>(); template.setConnectionFactory(factory); StringRedisSerializer keySerializer = new StringRedisSerializer(); GenericJackson2JsonRedisSerializer valueSerializer = new GenericJackson2JsonRedisSerializer(); template.setKeySerializer(keySerializer); template.setHashKeySerializer(keySerializer); template.setValueSerializer(valueSerializer); template.setHashValueSerializer(valueSerializer); template.afterPropertiesSet(); return template; } @Bean public RedisCacheManager cacheManager(RedisConnectionFactory factory) { RedisCacheConfiguration config = RedisCacheConfiguration.defaultCacheConfig() .entryTtl(Duration.ofMinutes(15)) .disableCachingNullValues() .serializeKeysWith(RedisSerializationContext.SerializationPair .fromSerializer(new StringRedisSerializer())) .serializeValuesWith(RedisSerializationContext.SerializationPair .fromSerializer(new GenericJackson2JsonRedisSerializer())); return RedisCacheManager.builder(factory) .cacheDefaults(config) .transactionAware() .build(); } }

这个配置类的逻辑说明:key 统一用 String 序列化,这样 Redis Desktop Manager 或另一款开源客户端 another redis desktop manager 里看到的是可读的业务 key;value 用 JSON 序列化,解决跨语言读取问题和默认 JDK 序列化的体积膨胀。entryTtl设置了全局默认 15 分钟过期,如果项目里每个业务的过期时间差异很大,建议不要依赖全局配置,而是像 2.3 节那样在工具方法里手动传 TTL。

连接池参数是另一个关键。spring.redis.lettuce.pool.max-active控制最大连接数,默认一般 8,对于高并发项目太小,我一般从 64 起步;max-idle决定空闲连接保留数,太小会导致频繁创建连接;timeout默认 1~3 秒,如果接口本身要求低延迟,可以适当缩短到 500ms,但要注意缩短 timeout 只影响等待连接的时间,不会让 Redis 命令本身变快。

3.3 发布一个可压测的缓存接口:用 redis-benchmark 和 INFO 验证效果

配置完成后,先在项目里找一个带缓存的查询接口,比如根据 ID 查商品详情。把数据库连接指向本地测试库,Redis 指向刚启动的实例,启动项目后用 curl 连续打几次接口,观察第一次调用后的响应时间明显下降,说明缓存回填生效。

接着用 Redis 自带的压测命令验证单机 Redis 的 QPS 上限,脚本如下:

redis-benchmark -h 127.0.0.1 -p 6379 -t set,get -n 100000 -c 50 -P 16 -q

参数解释:-n 100000表示总共发送 10 万条请求,-c 50表示同时 50 个并发连接,-P 16表示每个连接管道内一次发 16 条命令,用来模拟批处理场景。执行后你会看到类似GET: 120000.00 requests per second的输出。如果这个数字低于 5 万,先检查是不是跑在虚拟机或 Docker Desktop 里,网络层转发会吃掉很多性能。注意-P值不要过高,管道的意义是减少 RTT,但在应用层代码里如果对 pipeline 不熟悉,容易在批量操作时把超时时间放大,反而拖垮接口。

最后用redis-cli info stats看两个指标:keyspace_hits和keyspace_misses。命中率 = hits / (hits + misses)。高并发缓存项目里,读接口命中率低于 80% 通常说明缓存粒度有问题或过期时间太短。如果发现 misses 很高,回到 2.3 节的代码里看是不是查了不存在的数据,空值缓存有没有加,这才是验证缓存方案是否有效的第一道验收线。

4. 高并发缓存避坑与排查:穿透、击穿、雪崩、一致性和超时处理

4.1 缓存穿透:空值缓存和布隆过滤器,选哪个先想清楚

先看一个典型的故障现场:某天凌晨监控告警,数据库 CPU 直接飙到 90%,请求量没涨,但数据库的连接数全满了。查日志发现所有上游服务都在查一个并不存在的用户 ID 下的订单列表,Redis 里查不到,数据库里也没有,于是一万次请求全部穿透到 MySQL。

缓存穿透的现象就是:请求的 key 在缓存和数据库里都不存在,导致每次请求都打到存储层。常见原因有外部恶意遍历 ID、前端传参校验不严、以及业务上删除数据后没有同步清理缓存。解决思路有两种。第一种是在 2.3 节里出现的空值缓存:查询结果为 null 时,往 Redis 写一个带短过期时间的空值标记,比如 60 秒,后续请求直接命中空值,不再打到数据库。这个方法实现简单,缺点是每个穿透 key 都会在 Redis 里占一个小空间,攻击者如果用随机 UUID 不停构造 key,Redis 里会堆积大量无意义空 key。

第二种是布隆过滤器。项目启动时把全量业务 ID 加载到布隆过滤器里,查询前先判断 ID 是否存在,不存在直接把请求挡在业务层之外。这个方案的关键参数是误判率 fpp,默认建议设置 0.01,数据量 1000 万时大约需要 120MB 左右的空间。误判率越低越占内存,但可以做到 100% 拦截不存在的 key。我见过很多项目把布隆过滤器做成每次请求都查,却忽略了它本身也存在极少量误判,所以业务对「数据不存在」的断言要能容忍被过滤掉一小部分有效请求。

4.2 缓存击穿与雪崩:互斥重建、随机过期时间和热点 Key 隔离

缓存击穿和缓存雪崩经常被搞混,但实际场景完全不同。击穿是单个热点 key 过期的瞬间,大量请求同时发现缓存没值,一起打到数据库;雪崩则是大量 key 在同一时间段集体失效,比如活动商品全部在凌晨 0 点过期,数据库瞬间被压垮。

应对击穿,常见做法是让重建缓存这件事只有一个线程做,其他线程等缓存重建完成后直接读已有值。用 Redis 分布式锁实现互斥重建是标准解法:

public String loadUserWithLock(Long userId) { String cacheKey = RedisKey.USER_INFO + userId; String cacheValue = redisTemplate.opsForValue().get(cacheKey); if (StringUtils.isNotBlank(cacheValue)) { return cacheValue; } String lockKey = RedisKey.LOCK_PREFIX + userId; String requestId = UUID.randomUUID().toString(); // 尝试加锁,10 秒自动过期 Boolean locked = redisTemplate.opsForValue() .setIfAbsent(lockKey, requestId, 10, TimeUnit.SECONDS); if (Boolean.TRUE.equals(locked)) { try { // 二次检查,防止上一个线程刚重建完 cacheValue = redisTemplate.opsForValue().get(cacheKey); if (StringUtils.isNotBlank(cacheValue)) { return cacheValue; } UserInfo user = userMapper.selectById(userId); String value = user == null ? "NULL" : JSON.toJSONString(user); redisTemplate.opsForValue() .set(cacheKey, value, 30, TimeUnit.MINUTES); return value; } finally { // 释放锁时校验持有者,避免误删别人的锁 String script = "if redis.call('get', KEYS[1]) == ARGV[1] then return redis.call('del', KEYS[1]) else return 0 end"; redisTemplate.execute(new DefaultRedisScript<>(script, Long.class), List.of(lockKey), requestId); } } else { // 没有拿到锁,短暂等待后重试 Thread.sleep(50); cacheValue = redisTemplate.opsForValue().get(cacheKey); return cacheValue; } }

这段代码的逻辑说明:setIfAbsent设置分布式锁,value 用requestId作为持有者标识,配合 Lua 脚本在释放锁时做比对,避免线程 A 把线程 B 刚建的锁删掉。10 秒是锁的过期时间,必须大于一次数据库查询加缓存回填的总耗时,否则锁提前释放会让并发风险重新出现。没有拿到锁的线程Thread.sleep(50)后直接查缓存,如果缓存还没建好会再次进入未命中逻辑,但此时拿到锁的概率已经很低,不会形成新的冲击。

应对雪崩的核心参数是过期时间的随机化。不要让所有 key 用同一套 TTL,在基础 TTL 上加 5%~10% 的抖动,比如 30 分钟变成 30~33 分钟随机。还有一个进阶技巧是逻辑过期:缓存里存一个比实际 TTL 更短的时间戳,请求发现逻辑过期后,当前线程直接返回旧值,另开一个后台线程去刷新缓存。这个方案能让用户体验完全无损,代价是业务要容忍最多一个刷新周期内的数据延迟。

4.3 缓存一致性:延迟双删、binlog 订阅的边界和取舍

高并发缓存项目走到后期,一定会遇到脏数据问题:数据库里的商品价格已经改成 99,但缓存里还是 89,用户在下单页看到的价格和结算页不一致。这个现象的背景是读写并发。一个请求先更新数据库为 99,另一个请求同时在读库并把旧值 88 回填缓存,后更新数据库的请求如果没删缓存,缓存永远停留在旧值。

解决这个问题的第一版方案是 Cache Aside 的「更新数据库后删除缓存」。但删除缓存的时机不对也会出事。比较流行的是延迟双删:先删除缓存,再更新数据库,最后延迟一段时间再次删除缓存。第二次删除的作用是清掉在第一次删除和数据库更新之间被回填进去的旧缓存。延迟时间怎么设?我一般取一次完整缓存重建时间的最坏值,通常 500ms 到 1s。设太短等于没删,太长会出现一段窗口期的数据不一致。

这个方案不是银弹。如果更新数据库和删除缓存之间存在极低概率的故障,比如第二次删除时 Redis 抖动导致命令失败,数据还是会不一致。妥协的办法是引入重试机制,把删除失败的 key 丢进消息队列异步补偿。更进一步,成熟项目会通过订阅 MySQL binlog 来实现缓存失效,中间件解析数据库变更后主动删除对应缓存,和业务代码彻底解耦。这里要提醒的是,binlog 订阅带来的实时性通常在百毫秒级,如果你面对的是类似秒杀扣减库存这种强一致场景,缓存本身就不应该承载库存余额这类数据,把它放回数据库更合适。

一句话总结这段经验:高并发缓存的一致性是最终一致,不是强一致。业务接受多少秒的延迟,决定了你在延迟双删、异步修复还是 binlog 订阅里选哪个方案。千万别在上线前临时决定用哪种策略,线上缓存失效的代价远比多写一行代码大。

4.4 高并发排查:Redis command timed out、连接池耗尽和序列化膨胀

缓存项目最常见的线上报警是命令超时,日志里一般会出现 Redis command timed out 或 nested exception is io.lettuce.core.RedisCommandTimeoutException。这个现象的直观感受是接口响应从 10ms 变成 3 秒,然后服务调用方开始重试,把 Redis 打得更慢,形成雪崩。

先检查 Redis 侧。用redis-cli --bigkeys扫大 key,一个几十 KB 甚至几 MB 的 String 或 Hash,在请求高峰期会让单条命令执行时间变长,阻塞后续所有命令。用redis-cli slowlog get 20查看最近慢查询,如果发现有KEYS *或HGETALL大 Hash 操作,直接定位到代码里改掉。然后是持久化阻塞,AOF rewrite 或 RDB bgsave 在内存占用高的实例上会 fork 子进程,期间可能阻塞几百毫秒,可以在日志里看fork time in microseconds。解决思路是把maxmemory调低、把auto-aof-rewrite-percentage调大,让重写不要那么频繁。

再看客户端侧。Lettuce 连接池默认 max-active 只有 8,如果接口把 Redis 操作放在循环里,比如一次请求循环查 100 个用户信息,8 个连接很快全部被占用,后面的请求开始排队。我的调整建议是 max-active 和业务预估并发量挂钩,单个实例压到 5000 QPS 时 max-active 可以按 100~200 配置,max-idle 设为和 max-active 一样,避免频繁建立新连接。同时要检查代码里有没有opsForValue().get一个接一个串行调用,改成redisTemplate.executePipelined做管道批量读取,延迟能下降一个数量级。

排查序列化问题也是高并发项目里躲不开的一课。很多 Spring Boot 项目默认使用 JdkSerializationRedisSerializer,缓存 key 看起来是一串\xac\xed\x00\x05t\x00\x15这样的乱码,value 也多了很多类型描述头,既占内存又难以跨语言访问。更麻烦的是,JDK 序列化后的对象一旦改了类名或字段名,反序列化直接报ClassCastException或java.lang.reflect.InvocationTargetException。这就是项目的代码结构里之所以要把序列化器单独抽出来的原因。解决方法是回到 3.2 节的配置,key 用 String,value 用 JSON,同时对旧数据进行一次迁移:读取旧 key 后删除,用新序列化方式回写,而不是直接改配置后立刻重启。

5. 从单机到 Redis Cluster:迁移前先做这三件事,再做缓存治理

单机 Redis 能扛的 QPS 不是上限,内存才是。当缓存数据超过单机内存的合理水位(通常 70%~80%),或者你需要容灾能力时,迁移到 Redis Cluster 是常见做法。但集群不是简单换一个配置就行,迁移前我一般先做三件验证的事。

第一件,规范所有 key 里是否包含跨 slot 操作的风险。Redis Cluster 把 key 按哈希槽分布在 16384 个槽里,不同 key 可能在不同节点,直接对一个业务的数据做批量删除或事务会失败。控制方法是用 hashtag 约定,比如{order:10001}:detail和{order:10001}:items都会路由到同一节点,可以安全地用 pipeline 批量操作。迁移前用一个脚本把线上所有 key 按前缀分组统计,看有没有跨槽操作的需求。

第二件,确认客户端侧的集群模式配置。Jedis、Lettuce、Redisson 都有对应的集群连接工厂,迁移后如果还是用单机的spring.redis.host配置,客户端不会处理MOVED重定向,大量请求会把某个节点打满。正确的做法是配置spring.redis.cluster.nodes指向多个节点,让客户端启动时去拉取集群拓扑。

第三件,验证故障转移的容错。Redis Cluster 的每个主节点需要至少一个从节点,否则主节点宕机后整个槽位不可用。迁移后在测试环境直接kill -9一个主节点,观察 Sentinel 或 Cluster 的自动故障切换时间,正常情况下几秒内集群能恢复。同时要确认应用层是否有重试机制,因为槽位重定向期间客户端会收到异常,没有重试的调用方会直接报错。

迁移完成后,缓存治理要跟上。我的做法是在监控面板里长期盯三个指标:Redis 命中率是否稳定在 90% 以上;每个节点的used_memory是否均衡,防止哈希倾斜;以及 key 的过期分布,避免大促后出现整点过期。有一年活动上线,我给一批运营配置的优惠券缓存都设成了相同的半小时有效期,零点刚过数据库直接被集中打到 100% CPU,那次之后我把「所有批量 key 必须加过期抖动」写进了项目代码规范,连测试环境都不放过。希望这篇文章帮你把高并发缓存这条路的坑提前看到,至少在上线前能少睡几个安稳觉。

本文还有配套的精品资源,点击获取

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询