简介面向毕业设计及后端开发学习者这份高并发缓存项目针对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(ListProduct 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 则适合排行榜和延迟队列。每个元素带一个 scoreRedis 内部按 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 CacheString, 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 布隆过滤器使用示例 RBloomFilterString 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 yescluster-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 提升到了 2000TP99 从 800ms 降到了 20ms这是整个项目最有说服力的产出。监控方面我会额外强调一个参数Redis 的INFO commandstats。这个命令能看到所有命令的调用次数和耗时是定位慢查询的第一手数据。建议把它输出到监控系统里重点观察DEL、KEYS、LRANGE这类命令的耗时。KEYS命令在高并发生产环境必须禁掉它在数据量大时会阻塞 Redis 单线程数十秒正确替代品是SCAN命令。这个坑是很多新手从本地开发环境上到生产环境时最容易踩的一定提前全局搜索代码里有没有KEYS *。关于后续的进阶方向我会建议把项目里的缓存层升级为多级缓存架构本地 Caffeine 为一级Redis 为二级数据库为三级同时引入 Redisson 的看门狗机制做自动续期并把锁的粒度从整个方法锁细化到具体 key 粒度。还有一个很值得做的方向是把缓存穿透防御做成可观测的在CacheGuard层埋点统计空值缓存命中次数、互斥锁等待次数、缓存回填耗时等指标接入监控后线上任何异常都能快速判断是缓存问题还是数据库问题。最后说一个我的习惯每次新项目上线前我会主动把 Redis 进程 kill 掉看服务会变成什么样。如果只是变慢但没挂说明缓存方案是合格的如果直接崩溃那说明缺少降级逻辑。这个做法我在团队里推了很多年每次都能提前发现几个没人注意到的故障点。上面这些踩坑经历和工程细节都是我一遍一遍翻车换来的希望帮到你。本文还有配套的精品资源点击获取