☰
高并发Redis缓存实战:穿透击穿雪崩、分布式锁与集群部署
2026/10/8 2:16:21 网站建设 项目流程

简介:面向毕业设计及后端开发学习者,这份高并发缓存项目针对Redis在真实场景中的应用展开实战教学。项目从Redis基础数据结构入手,覆盖RDB与AOF持久化机制,重点演示发布订阅、事务和管道操作等高并发关键技术,并讲解集群搭建与读写分离策略。资源共79个文件,以72个Java源文件为核心,配合XML配置、Lua脚本、YAML设置和SQL初始化脚本,整体压缩包为93KB,便于快速查阅项目结构与核心代码。已有48人学习下载。借由完整项目实践,可掌握缓存系统从设计、编码到性能测试的全过程,理解高并发场景下如何设计缓存策略、提升数据访问速度并减轻数据库压力,为后续系统开发与架构设计积累实用经验。

1. 高并发场景下的缓存第一课:为什么这个项目值得你动手做一遍

做过秒杀、抢购或者任何大促接口的人,都经历过同一个噩梦:数据库连接池被打满,CPU 飙到 100%,日志里全是超时异常,用户一遍遍刷新页面,系统在崩溃边缘反复横跳。这时候最能救命的不是加机器,而是把热数据挡在数据库前面——用 Redis 做高并发缓存。这个标题里的项目,本质上就是带你走完这样一条路:从缓存设计、数据类型选型,到分布式锁、缓存一致性,再到集群和压测,把高并发场景下缓存那套完整打法串起来。适合正在做电商、直播、IM 类应用,或者准备面试高频缓存题的后端工程师。下面我把整个方案按一个可落地项目的脉络拆开讲,每一步都给你能直接抄的配置和代码。

2. 缓存三大痛点穿透、击穿、雪崩:先立住理论再动手

2.1 缓存穿透:查询一个不存在的数据,Redis 和数据库都在裸奔

先说穿透。这是最常见的坑:请求查一个 key,缓存里没有,数据库里也没有,但每个请求都实打实地打到了数据库。如果这是被恶意攻击,比如用不存在的用户 ID 循环刷接口,数据库瞬间就被打爆。我见过一个真实案例,某后台管理系统的列表接口被脚本遍历不存在的 ID,一天下午数据库 CPU 直接 100%,DBA 紧急 Kill 了所有慢查询才缓过来。

解决穿透的思路很简单:对不存在的数据也做缓存,设置较短的过期时间,比如 5 分钟。但要注意,如果空值缓存时间太长,数据真正写入后用户看到的还是空,所以时间要短。另一个方案是布隆过滤器,把所有可能存在的主键提前加载到过滤器里,请求来了先过过滤器,不存在直接返回。布隆过滤器的问题是误差率和维护成本,如果数据频繁增删,过滤器标记的删除操作比较麻烦,我一般在小项目里优先用空值缓存,只有确实被脚本刷了才上布隆过滤器。

// 用空值缓存解决穿透的代码片段 public String getProductById(String productId) { // 先查缓存 String value = redisTemplate.opsForValue().get("product:" + productId); if (value != null) { // 命中缓存,直接返回 return value; } // 缓存未命中,查数据库 Product product = productMapper.selectById(productId); if (product == null) { // 数据库也没有:写一个空值占位,过期时间设短一点,比如 5 分钟 redisTemplate.opsForValue().set("product:" + productId, "", 5, TimeUnit.MINUTES); return null; } // 数据库有:写缓存,过期时间设 1 小时 String json = JSON.toJSONString(product); redisTemplate.opsForValue().set("product:" + productId, json, 1, TimeUnit.HOURS); return json; }

这段代码的逻辑是:缓存层承担了绝大部分读请求,数据库只有在缓存确实没有且不是空值占位的情况下才会被访问。注意redisTemplate.opsForValue().set的四个参数:key、value、过期时间、时间单位。过期时间不能太长也不能太短,太短失去防穿透意义,太长会导致数据更新不及时。我习惯对空值用 5 分钟,对正常数据用 1 到 2 小时,具体看业务容忍度。还有一个小细节:写空值缓存时最好加一个前缀区分,比如empty:,排查问题时一眼就能看出这是占位还是真实数据。

2.2 缓存击穿:单个热点 key 过期瞬间,所有请求涌向数据库

击穿和穿透的区别在于:穿透是查不存在的数据,击穿是查一个非常热的数据,但它在缓存中正好过期了。这个热 key 过期的瞬间,可能几千个并发请求同时发现缓存没有,全部冲到数据库。最典型的就是某商品做秒杀,商品详情就是这个热 key。

常规解法有两个。第一个是互斥锁:发现缓存没有时,先尝试获取分布式锁,只有拿到锁的线程去查数据库并回填缓存,其他线程等锁释放后再查缓存。第二个是逻辑过期:缓存里存的值带一个逻辑过期时间,比如 30 分钟,但缓存的物理过期时间设为永不过期。后台起一个定时任务,扫描发现逻辑过期后,拿锁去刷新缓存。第二个方案的巧妙之处在于,即使逻辑过期了,请求还是能从缓存拿到旧数据,不会被卡住,代价是数据可能短暂不一致。

// 用互斥锁解决击穿的代码片段 public String getProductDetail(String productId) { String key = "detail:" + productId; String value = redisTemplate.opsForValue().get(key); if (value != null) { return value; } // 缓存没有,尝试获取锁 String lockKey = "lock:detail:" + productId; boolean locked = redisTemplate.opsForValue() .setIfAbsent(lockKey, "1", 3, TimeUnit.SECONDS); if (locked) { // 拿到锁,查数据库回填缓存 try { String dbValue = queryDb(productId); redisTemplate.opsForValue().set(key, dbValue, 1, TimeUnit.HOURS); return dbValue; } finally { // 释放锁 redisTemplate.delete(lockKey); } } else { // 没拿到锁,等其他线程回填后重试 try { Thread.sleep(50); } catch (InterruptedException e) { Thread.currentThread().interrupt(); } return redisTemplate.opsForValue().get(key); } }

这段代码的关键在于setIfAbsent这个原子操作:只有当锁 key 不存在时才会设置成功,这就保证了同一时刻只有一个线程能拿到锁。锁的过期时间设 3 秒,这是一个需要根据业务调整的参数:如果查数据库很慢,3 秒可能不够,逻辑上应该配合看门狗机制做续期;如果太快,锁提前释放会导致多个线程同时查库。对于中小项目,3 到 5 秒一般够用。还有释放锁时的finally块:无论查询是否抛异常都要释放,否则一旦异常,锁会一直不释放。这套逻辑我已经在项目里用过很多次,翻车最多的就是忘记finally,结果接口大面积阻塞。

2.3 缓存雪崩:大量 key 同时失效,Redis 和数据库一起被冲垮

雪崩和击穿的区别是范围:击穿是单个 key,雪崩是一批 key 同时过期,或者 Redis 本身宕机。比如缓存预热时给所有商品设置了相同的过期时间,凌晨 2 点集体失效,恰好那时候有定时任务全量刷数据,数据库直接被打趴。解决办法最常用的是过期时间加随机值:在基础过期时间上加一个 1 到 5 分钟的随机数,让 key 的过期时间错开。还有一个方案是永不过期加后台更新,也就是上面说的逻辑过期,但这么做需要额外维护一个更新任务。

// 设置过期时间时加随机值,避免雪崩 public void cacheProductList(List<Product> products) { for (Product product : products) { String key = "product:" + product.getId(); String json = JSON.toJSONString(product); // 基础过期时间 2 小时,加 1 到 5 分钟随机值 int randomMinutes = ThreadLocalRandom.current().nextInt(1, 6); redisTemplate.opsForValue().set( key, json, 2 * 60 + randomMinutes, TimeUnit.MINUTES); } }

额外说一句,雪崩还有一个隐藏诱因是 Redis 服务本身挂掉,这属于高可用层面的问题,后面第五章我会专门讲集群部署的注意点。做缓存设计时,我习惯把穿透、击穿、雪崩放在一起考虑:数据层统一封装一层,把所有防御逻辑收拢到一个类里,而不是散落在各个业务 Service 中。这个封装层可以叫CacheGuard,内部实现空值缓存、互斥锁、过期时间随机化三个能力,业务代码只需要调用一个方法。这样做的收益是后续如果要用 Caffeine 做本地缓存,或者切换别的中间件,只需要改一个类。

3. Redis 数据类型与场景选型:从 String 到 ZSet 的高并发姿势

3.1 String 与 Hash:对象缓存和计数器场景的选型标准

新手容易犯的一个错误是全用 String 存对象。比如把整个用户对象序列化成 JSON 放进一个 key,读的时候反序列化出来,看起来简单,但有两个问题:一是大 key,一个 JSON 可能有几 KB,一次 GET 还好,如果同一个 value 被频繁读取,网络带宽就被消耗了;二是更新局部字段很别扭,必须整个读出、改完、再整个写回,在高并发下容易产生并发覆盖。改用 Hash 类型会好一些:一个 Hash 对应一个对象,每个属性对应一个 field,更新某个字段只需要HSet一个 field,同时还能针对单个字段做过期。

# 用 Hash 存用户信息,field 级操作示例 HSET user:1001 name "zhangsan" age 25 city "beijing" HGET user:1001 name # 只更新 age 字段,不需要读写整个对象 HSET user:1001 age 26 # 设置整个 Hash 的过期时间 EXPIRE user:1001 3600

对于计数场景,比如商品库存、点赞数、在线人数,直接用 String 的INCR/DECR原子操作。这里有一个容易忽略的点:INCR是原子操作,但如果你想先GET拿到值再SET新值,那就不是原子的,高并发下一定会丢数据。所以只要有计数需求,优先用INCRBY而不是自研"读取-修改-写回"。

用 Hash 的另一个好处是内存占用。Redis 对小 Hash 做了编码优化,默认ziplist编码,在 field 数量和 value 长度较小时,内存利用率远高于同量的 String key。具体阈值由hash-max-ziplist-entries和hash-max-ziplist-value两个参数控制,默认 128 和 64。如果 field 数量超过阈值,会自动转为hashtable编码,内存开销变大。所以设计时如果预估有大量 field,要提前想好是不是真的适合用 Hash。

3.2 List 与 ZSet:最新列表和排行榜场景的高并发读法

List 类型天然适合做最新消息列表,比如用户的时间线、订单流水。用LPUSH往头部插入新数据,LRANGE分页读取,配合LTRIM裁剪长度,可以控制列表不会无限增长。这里有一个常见误区:把LTRIM只看作清理手段,其实它可以用来保证列表只保留最近 N 条数据,在高并发写入场景下非常实用。

ZSet 则适合排行榜和延迟队列。每个元素带一个 score,Redis 内部按 score 排序,查询 Top N 只需要ZREVRANGE。举个例子,直播间礼物排行榜:

# 某用户贡献了 100 个金币 ZINCRBY rank:live:1001 100 "user_888" # 获取 top 10 ZREVRANGE rank:live:1001 0 9 WITHSCORES # 获取某用户排在第几名 ZRANK rank:live:1001 "user_888"

这些操作都是 O(log N) 级别的,在高并发下表现依然稳定。延迟队列的玩法是:score 存任务要执行的时间戳,然后用ZRANGEBYSCORE取出当前时间之前到期的任务,处理完删除。我在实际项目里用 ZSet 做过异步任务调度,比定时轮询数据库高效得多。

ZSet 有一个注意点:单个 ZSet 的元素数量不宜过大,超过百万级别后ZRANGE的耗时虽然还是可接受,但每个元素占用的内存不可忽视。如果排行榜数量级特别大,比如全网用户,通常的做法是分桶:按用户 ID 取模拆成多个 ZSet,查询时合并结果。这个方案会增加代码复杂度,但对于支撑千万级用户的排行榜是必走的一条路。

3.3 大 key 与热 key:高并发下最容易被忽视的性能杀手

前面数据类型选型做完,紧接着就是大 key 和热 key 的治理。大 key 指的是 value 很大的 key,比如一个 Hash 里有几十万字段,或者一个字符串存了几 MB 的数据。大 key 的问题在于:网络传输慢、阻塞 Redis 单线程、内存倾斜导致集群节点负载不均。热 key 则是被高频访问的 key,比如某明星的微博详情,瞬间几十万 QPS 打在一个 key 上,单个 Redis 实例的 CPU 先扛不住。

解决热 key 的常见思路是加本地缓存层——把 Caffeine 放在应用里,Redis 作为二级缓存,请求到达时先查本地缓存。这样做的收益是单机 Redis 的压力大幅下降,代价是本地缓存存在一致性问题:你想主动失效某个 key 比较麻烦,因为它在每台应用服务器上都有副本。我见过一个项目用 Redis Pub/Sub 广播失效请求,每次更新时发一条消息,各节点收到后删除本地 key,算是兼顾了性能与一致性。

// 用 Caffeine 做本地缓存,Redis 做二级缓存的代码结构 private final Cache<String, String> localCache = Caffeine.newBuilder() .maximumSize(10_000) .expireAfterWrite(30, TimeUnit.SECONDS) .build(); public String getWithLocalCache(String key) { String localValue = localCache.getIfPresent(key); if (localValue != null) { return localValue; } String remoteValue = redisTemplate.opsForValue().get(key); if (remoteValue != null) { localCache.put(key, remoteValue); } return remoteValue; }

这个代码的逻辑不复杂:本地缓存 30 秒过期,Redis 兜底。参数上,maximumSize决定本地缓存能放多少 key,太少命中率低,太多本地内存不够;expireAfterWrite决定本地缓存最长存活时间,时间越长一致性越差,越短 Redis 压力越大。我习惯把这两个参数放到配置中心动态调整,线上出问题时可以不用发版就调。另外热 key 的识别可以做日志分析,Redis 的hotkeys参数开启后会输出访问频率最高的 key,但只能提供采样数据,精度不是很高,生产环境还是推荐靠 APM 工具抓接口慢调用反推。

4. 分布式锁与缓存一致性:高并发写入场景的硬骨头

4.1 Redis 分布式锁的正确写法:setnx 不是只调一个命令就完事

说起分布式锁,很多人第一反应是SETNX,但只调一个命令十有八九翻车。因为如果拿到锁的线程在执行过程中挂了,锁永远不会释放,其他线程全部阻塞。正确写法是用SET key value NX EX seconds一个命令完成加锁和设置过期时间,保证原子性,释放锁时要先比对 value 再删除,防止误删别人的锁。

// 正确使用分布式锁:加锁 + 过期 + 唯一标识 + 安全释放 public boolean acquireLock(String lockKey, String requestId, int expireSeconds) { // 原子操作:只有 key 不存在时才设置成功 return redisTemplate.opsForValue() .setIfAbsent(lockKey, requestId, expireSeconds, TimeUnit.SECONDS); } public boolean releaseLock(String lockKey, String requestId) { String script = """ if redis.call("get", KEYS[1]) == ARGV[1] then return redis.call("del", KEYS[1]) else return 0 end """; Long result = redisTemplate.execute( new DefaultRedisScript<>(script, Long.class), Collections.singletonList(lockKey), requestId); return result != null && result > 0; }

加锁的setIfAbsent参数分别是 key、线程唯一标识 requestId、过期时间、时间单位。requestId 是一个关键参数,比如用 UUID 生成,释放锁时 Lua 脚本会比较当前值和 requestId 是否一致,一致才删除。为什么要这么做?考虑一种情况:线程 A 加锁后执行时间超过过期时间,锁自动释放了;线程 B 拿到锁开始执行;这时 A 执行完了调用DEL,如果直接删,就会把 B 的锁删掉,两个线程同时进入临界区。有了 requestId 比对,A 删不掉 B 的锁,规避了这个问题。

还有一个经常被问的参数:过期时间设多少合适?设太短,业务还没执行完锁就没了;设太长,A 挂了之后要等很久其他线程才能拿到锁。常见的做法是设置一个估算执行时间的两倍,同时配合额外的续期机制——比如用子线程定时EXPIRE续期,直到业务执行完主动释放。这个续期机制在 Redisson 里叫看门狗,如果你不想引入额外依赖,也可以自己写一个简单的续期,但一定要记得在finally里停掉续期线程。

// 带续期逻辑的分布式锁使用模板 public void executeWithLock(String lockKey, Runnable task) throws InterruptedException { String requestId = UUID.randomUUID().toString(); boolean locked = acquireLock(lockKey, requestId, 10); if (!locked) { throw new RuntimeException("获取锁失败,请重试"); } // 续期线程:每 5 秒把锁的过期时间重置为 10 秒 ScheduledExecutorService renewThread = Executors.newSingleThreadScheduledExecutor(); renewThread.scheduleAtFixedRate(() -> { redisTemplate.expire(lockKey, 10, TimeUnit.SECONDS); }, 5, 5, TimeUnit.SECONDS); try { task.run(); } finally { renewThread.shutdown(); releaseLock(lockKey, requestId); } }

这段代码里,续期线程每 5 秒执行一次EXPIRE,把锁续到 10 秒。这样即使业务执行超过 10 秒,锁也不会丢失;最坏的情况是业务卡死,续期线程还在跑,锁一直不释放,所以必须和请求超时机制配合使用。真正在生产环境,我建议直接使用 Redisson 现成的RLock,它把加锁、续期、释放全部封装好了,还支持可重入。除非你的项目禁止引入新依赖,否则自己写的锁很容易忽略边角情况,出了问题还不好甩锅给别人。

4.2 缓存一致性:先删缓存还是先更新数据库

缓存一致性问题最常见于读多写少的场景:数据库更新了,缓存还是旧值,用户看到的和实际不一致。业界方案有好几种,但各有代价。最简单的是 Cache Aside Pattern,也就是旁路缓存模式,核心思路是:更新时先操作数据库,再删除缓存。这里有一个关键问题:为什么不先更新缓存而是删除?因为更新缓存是值替换,如果两个线程并发写,后写的覆盖先写的,可能导致数据库和缓存不一致;而删除缓存,让下次读请求重新加载数据库,相当于用一次缓存 miss 换一致性。

高并发下 Cache Aside 也有一个经典竞争问题:线程 A 读缓存没命中,准备查数据库;线程 B 更新了数据库,删除了缓存;线程 A 查库得到的是旧值,写回缓存,结果缓存里又变成旧数据了。解决思路是延迟双删:更新数据库后,先删缓存,等 500 毫秒再删一次,中间那段窗口期让旧值短暂存在,但最终一致。另一种更强的方案是订阅数据库 binlog——Canal 监听 MySQL 的变更日志,异步删除缓存,应用层完全不感知缓存更新。这是业界处理缓存和数据库一致性的标准姿势之一,能承接大部分极端并发场景,代价是需要额外部署 Canal 组件和维护 binlog 消费端。

// 延迟双删的模板代码 public void updateProduct(Product product) { // 1. 更新数据库 productMapper.updateById(product); // 2. 删除缓存 String key = "product:" + product.getId(); redisTemplate.delete(key); // 3. 延时 500ms 后再删一次,处理并发读导致的旧值回写 scheduledExecutorService.schedule(() -> { redisTemplate.delete(key); }, 500, TimeUnit.MILLISECONDS); }

延迟双删的 500 毫秒是一个经验值,在不同项目里可能需要调。如果业务链路特别长,或者有跨机房延迟,500 毫秒不够,可以适当加大,但要注意这个窗口期内数据仍然是不一致的。还有一种情况是更新频率极高,每秒钟几十次更新,这时候删缓存反而会让缓存命中率暴跌,数据库压力剧增。碰到这种情况,可以考虑给缓存设置一个很短的过期时间,比如 1 秒,并允许短暂不一致。生产环境没有银弹,都是根据业务容忍度选方案。

4.3 避免缓存穿透的另一种思路:布隆过滤器的工程实现

前面讲了空值缓存,现在补上布隆过滤器的工程做法。布隆过滤器的原理是用一个位数组和多个哈希函数,判断"一定不存在"和"可能存在",代价是误判率和内存的平衡。误判率越低,需要的位数组越大。工程上我见过三种实现方式:Google Guava 单机版,适合数据量不大;Redisson 分布式版,适合 Redis 集群;自定义实现,适合对误判率有特殊要求。

// Redisson 布隆过滤器使用示例 RBloomFilter<String> bloomFilter = redissonClient.getBloomFilter("productIds"); // 初始化:预期数据量 100 万,误判率 0.01 bloomFilter.tryInit(1_000_000L, 0.01); // 预热时把所有商品 ID 塞进去 for (Long id : productIds) { bloomFilter.add(id.toString()); } // 查询时先判断 if (!bloomFilter.contains(productId)) { // 一定不存在,直接返回 return null; }

布隆过滤器的两个初始化参数要理解清楚:预期数据量决定位数组大小,误判率决定哈希函数个数。如果实际数据远超预期,误判率会急剧上升,过滤器形同虚设。所以数据量估算宁可偏大,也不能偏小。布隆过滤器还有一个让人头疼的问题:它不支持删除操作,因为一个位可能被多个元素映射,删掉一个会影响其他元素的判断。如果业务中主键会大量删除,需要配合定期重建过滤器或者使用计数布隆过滤器。这个方案更适合新增远多于删除的场景,比如商品上新、用户注册,如果你的系统频繁删数据,空值缓存是更省事的选择。

5. 高并发缓存部署与避坑:集群配置、持久化和告警参数

5.1 Redis 集群部署选型:主从复制、哨兵还是 Cluster

单机 Redis 和项目实战的差距,最早体现在可用性上。一个 Redis 实例挂了,缓存全部 miss,数据库直接背锅。最常见的部署形态是主从复制加哨兵:主节点提供读写,从节点只读备份,哨兵监控主节点的状态,主节点宕机后自动将从节点提升为新主节点。这套方案的好处是运维简单,坏处是只支持单主多从,写入能力受限。如果业务写入请求本身不多,只是读需求大,加从节点就够用。

如果写入量也是 TPS 万级甚至更高,就需要 Redis Cluster。Cluster 在 3.0 后正式推出,通过一致性哈希把数据分散到 16384 个槽位,理论上支持横向扩展。搭建 Cluster 时最重要的参数是cluster-enabled yes和cluster-require-full-coverage。后者是个关键参数:默认是 yes,意思是只要超过半数槽位不可用就拒绝服务。在大流量场景下,如果某个节点网络抖动导致部分槽位不可访问,整个集群会直接停止响应,所以生产环境我一般都改成 no,让可用的部分继续提供服务。

# docker 启动 redis 节点示例 docker run -d --name redis-6380 -p 6380:6379 redis:7.0 \ --cluster-enabled yes \ --cluster-config-file nodes-6380.conf \ --cluster-node-timeout 5000 \ --appendonly yes

cluster-node-timeout决定节点间心跳超时时间,设太短会把稍微慢一点的节点误判为宕机,触发频繁的故障迁移;设太长则网络分区时恢复太慢。5 秒是一个比较稳的默认值。另外appendonly yes是开启 AOF 持久化,这是保证故障恢复数据不丢的关键。如果你的业务允许丢失秒级数据,可以选 RDB;但如果对一致性要求高,AOF 至少配置 everysec 每秒写一次。

5.2 内存淘汰策略:不是所有数据都该永不过期

Redis 默认的过期策略是惰性删除加定期删除,但真正撑不住的是写入速度远大于过期速度的情况:内存被占满,新写不进数据。这时就需要配置 maxmemory 和 maxmemory-policy。这是高并发缓存项目里必调的两个参数,我见过太多新手上线不配置,结果 Redis 到了内存上限后开始报 OOM 错误,写入全部失败。

# redis.conf 内存淘汰策略配置 maxmemory 4gb # allkeys-lru:从所有 key 中按 LRU 淘汰 # volatile-lru:只从设置过期时间的 key 中按 LRU 淘汰 # noeviction:默认策略,内存满了直接拒绝写入 maxmemory-policy allkeys-lru

如果是纯缓存服务,所有数据都能接受被淘汰,用allkeys-lru最合适。如果有些 key 不能随便丢,比如计数器的中间态,那就要用volatile-lru并给所有 key 设置过期时间。注意volatile-lru有一个风险:如果一个设置了过期时间的 key 很多,但长期不被访问,它还是会被淘汰,因为 LRU 按访问时间算;如果有一个 key 没设置过期时间,它就永远不会被淘汰,直到内存不足。noeviction是默认策略,也是隐藏雷区:内存满了之后写命令直接报错,流量一旦上来,缓存写入密集的任务先死。我吃过一次亏:某定时任务往 Redis 里写中间结果,没注意内存策略,某天 redis-cli 里全是OOM command not allowed when used memory,一查才发现是默认策略。所以每上线一个服务,第一件事就是把 maxmemory 配好,永不落空。

5.3 高并发场景的常见问题排查(避坑记录)

这一节我整理几条自己实际踩过的坑,按现象、原因、解决三步讲清楚,希望你不用再走一遍。

坑一:Redis 连接超时报错,服务启动后频繁抛 Connection Refused 或者 Command timed out现象:应用日志里高频出现Redis command timed out; nested exception is io.lettuce.core.RedisCommandTimeoutException。 原因:最常见的两种情况,一是连接池太小,高并发下请求在池里排队,超过 Lettuce 默认的 60 秒超时;二是 Redis 服务端慢查询太多,单线程处理不过来。 解决:把 Lettuce 连接池调大,MaxTotal从默认 8 调到 50 到 100,同时开慢查询日志查耗时命令。另外,检查是不是把大 key 放在热路径上,比如LRANGE一个大 List 的全部内容,这类命令最拖慢 Redis。

坑二:缓存和数据库数据不一致,但刷新缓存后就好了现象:用户看到的价格、库存是旧的,运营后台改完前端没变。 原因:Cache Aside 模式下先更新数据库再删缓存,但并发读线程可能把旧数据写回缓存。 解决:使用前面写的延迟双删,或者引入 Canal 订阅 binlog 做异步一致性。先定位是删除缓存失败还是回写旧值,最简单的验证方法是在缓存服务里加一个删除日志,对比业务更新日志和删除日志的时间线。

坑三:Redis 集群流量倾斜,部分节点 CPU 打满现象:集群中各节点 CPU 使用率不均衡,某个节点涨到 90% 以上,其他节点 20% 左右。 原因:热 key 全部命中同一个哈希槽位,或者使用 Hash 标签时把大量数据归到了同一个节点。 解决:给热 key 加随机后缀分布到不同节点,比如product:1001:0到product:1001:9再加一层映射表,读取时随机选一个。从根本上看,需要评估业务里是不是有天然的集中访问模式。

坑四:AOF 文件持续增大,重启 Redis 后恢复时间很长现象:AOF 文件几个 GB,每次重启都等很久,期间服务不可用。 原因:AOF 没有触发重写,或者auto-aof-rewrite-percentage设置过大。AOF 重写的触发条件是文件大小超过上一次重写时的百分比,默认 100%,如果业务写入量大,触发不频繁。 解决:把auto-aof-rewrite-percentage调小,比如 50%,或者用BGREWRITEAOF手动触发,并配合定时任务在低峰期重写。

坑五:主从切换后数据丢失现象:哨兵把从节点提升为主节点后,部分数据查不到了。 原因:从节点在故障发生前没有完全同步主节点最新数据,或者使用的是异步复制,复制积压缓冲区不足导致部分命令没有传到从节点。 解决:启用 WAIT 指令等待从节点确认,或者在网络抖动场景下调整min-replicas-to-write参数,让主节点在从节点不足时拒绝写入,这是用可用性换一致性。

6. 高并发缓存项目验证与进阶:从压测到监控的一个具体技巧

把整个项目做完之后,剩下的问题是:怎么证明你的缓存方案真的有效?光靠"感觉快了很多"不行,需要一个可量化的验证方法。我的建议是压测,工具用 wrk 或 JMeter 都行,压测模板按三个阶段来:先压数据库直连的接口,记录 QPS 和 TP99;再压加缓存的接口,对比同样的并发下各项指标;最后模拟缓存失效场景,比如把 Redis 里的 key 全部清掉,观察数据库连接数和接口耗时曲线。通过这三组数据,你能明确说出"缓存让接口 QPS 从 200 提升到了 2000,TP99 从 800ms 降到了 20ms",这是整个项目最有说服力的产出。

监控方面,我会额外强调一个参数:Redis 的INFO commandstats。这个命令能看到所有命令的调用次数和耗时,是定位慢查询的第一手数据。建议把它输出到监控系统里,重点观察DEL、KEYS、LRANGE这类命令的耗时。KEYS命令在高并发生产环境必须禁掉,它在数据量大时会阻塞 Redis 单线程数十秒,正确替代品是SCAN命令。这个坑是很多新手从本地开发环境上到生产环境时最容易踩的,一定提前全局搜索代码里有没有KEYS *。

关于后续的进阶方向,我会建议把项目里的缓存层升级为多级缓存架构:本地 Caffeine 为一级,Redis 为二级,数据库为三级,同时引入 Redisson 的看门狗机制做自动续期,并把锁的粒度从整个方法锁细化到具体 key 粒度。还有一个很值得做的方向是把缓存穿透防御做成可观测的:在CacheGuard层埋点,统计空值缓存命中次数、互斥锁等待次数、缓存回填耗时等指标,接入监控后,线上任何异常都能快速判断是缓存问题还是数据库问题。

最后说一个我的习惯:每次新项目上线前,我会主动把 Redis 进程 kill 掉,看服务会变成什么样。如果只是变慢但没挂,说明缓存方案是合格的;如果直接崩溃,那说明缺少降级逻辑。这个做法我在团队里推了很多年,每次都能提前发现几个没人注意到的故障点。上面这些踩坑经历和工程细节,都是我一遍一遍翻车换来的,希望帮到你。

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

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

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

立即咨询