news 2026/10/16 3:31:27

三级缓存与热点Key分片:高并发缓存稳定架构实战

作者头像

张小明

前端开发工程师

1.2k 24
文章封面图
三级缓存与热点Key分片:高并发缓存稳定架构实战

凌晨两点,大促流量开始往上冲。监控大屏上的QPS曲线几乎是垂直拉升,从日常的2万一路朝着200万的目标奔去。就在这时,Redis集群告警响了:某个商品的详情Key,访问量一分钟内突破了800万次,直接把对应分片的CPU打到了100%,请求开始排队,P99延迟从2毫秒一路飙到了3秒。这不是某本书里的虚构案例,而是做高并发系统的人迟早要面对的修罗场:热点Key打崩单分片。你之前做的那套“RestController先去查Redis,查不到再查MySQL”的简单缓存方案,在这个量级面前连一秒钟都撑不住。

这篇内容,我按工业级实战的标准,把整个“三级缓存+热点Key分片”方案从头拆到尾:每一级缓存到底放在哪、承担什么责任、为什么非这么分不可;热点Key怎么识别、怎么分片、分片副本数量怎么算;Redis Cluster集群的完整配置、连接池参数、部署步骤;还有能直接抄到项目里的Java落地代码和排坑经验。适合正在做亿级流量架构设计、或者已经被线上热点Key问题折磨过的后端开发者,照着这套思路,至少能让你的缓存系统在多波流量冲击下稳得住。如果你是刚接触高并发的小白,先把“三级缓存”和“热点Key”这两个概念吃透,再看后面的代码和配置,会顺畅很多。

1. 亿级流量下,单独一个Redis为什么会被打崩

1.1 缓存不是随便加一层就行,先看清压力到底在哪

很多人对缓存的理解是“数据库扛不住,加个Redis挡一下”。真到了亿级流量场景,这个说法是站不住脚的。流量不是均匀地撒在所有Key上,而是极端地聚在少数几个Key上。我见过一个真实案例:某电商大促,一个商品详情Key占了整个Redis集群读流量的68%。也就是200万QPS里,将近140万QPS全部打在一个Key上。

Redis是单线程模型,CPU密集操作和网络IO都在同一个线程里处理。虽然Redis本身性能很高,单分片GET能达到10万QPS左右,但这是理想情况。真实环境里,Key对应的Value越大、网络带宽越紧张、客户端连接越多,单分片实际吞吐就断崖式下滑。当140万QPS集中在一个Key上,不崩才怪。

所以核心矛盾不是“加不加缓存”,而是“缓存本身能不能撑住热点流量的单项压力”。一个大Key的访问量超过了单个Redis分片的能力上限,无论你的Redis集群有多少个节点,流量都只会往一个分片上怼,其他分片闲着看你笑话。

1.2 IOPS与响应时间的账,算清楚才知道每一层值多少

我们可以用一个简单的数学模型,看清不同存储层的能力差距。MySQL单库单表简单主键查询,QPS大概在5000到1万,平均耗时5到10毫秒;Redis单分片非热点Key查询,QPS能到5万到10万,平均耗时1到3毫秒;本地内存缓存(比如Caffeine)查询,QPS轻松破百万,平均耗时0.05到0.5毫秒。

如果200万QPS全部落到MySQL,需要至少200个数据库实例,这是天方夜谭。如果全部落到Redis,需要至少20个分片,而且热点Key还会让负载失衡。但如果我们先走本地缓存,命中率假设有70%,那么打到Redis的流量只剩60万,打到MySQL的只剩十几万。这个数字一下子就变得可控了。

每一级缓存都相当于一个“流量漏斗”,把上一层的绝大部分请求消化在更靠近业务代码的地方。这也是三级缓存架构最核心的数学逻辑。多花几十毫秒做一次回源不可怕,可怕的是让所有请求都去回源。

1.3 单Key访问量和分片能力的差距,靠什么来弥补

假设一个商品详情Key在大促期间要达到140万QPS,单个Redis分片极限只能扛10万QPS,这个差距是14倍。如果什么都不做,结果只能是分片被打爆,Redis集群进入频繁的MOVED重定向,客户端连接堆积,最终大面积超时。

弥补这个差距的办法有两种思路。第一种是把热点Key拆成多个“逻辑分片Key”,每个分片Key承载一部分读流量,让流量均匀散落到多个Redis节点上。第二种是在Redis前面再加一层本地缓存,把高频重复的读请求直接拦截在应用进程内,根本不让它走到网络。

这两种思路并不互斥,恰恰是三级缓存架构里最关键的两个环节。Redis侧负责“横向扩容”,本地缓存侧负责“垂直拦截”。两个手段叠在一起,再配合合理的热点识别机制,140万QPS打在一个Key上就不再有威胁。下面就从架构设计的角度,把每一层怎么搭、怎么协作讲清楚。

2. 三级缓存架构设计:每一级到底在解决什么问题

2.1 L1/L2/L3分层定位,先看一张职责表

三级缓存的每一级都有明确的定位,不是拍脑袋定的。我建议直接按这张表来设计:

L1 本地缓存(Caffeine/Guava):放在应用进程内,速度最快,微秒级返回,目标命中率60%到80%,主要拦截重复热读。容量小,一般几万条,且进程重启即丢失。

L2 分布式缓存(Redis Cluster):独立部署,多实例共享,毫秒级返回,目标综合命中率95%以上,是承接本地未命中请求的主力。容量可扩展,按需扩容,具备持久化能力。

L3 数据源(MySQL/其他服务):最终兜底,负责把未命中缓存的请求转换为数据库查询或远程调用。吞吐有限,只能承接极少数真正需要回源的请求。

三级之间逐层穿透,每一层的命中率决定了下一层的压力。如果L1命中率能做到75%,L2命中率能做到95%,那么真正打到MySQL的请求就只有总流量的1.25%。这种情况下,1万QPS的数据库能力支撑200万QPS的整体流量绰绰有余。

2.2 为什么先查L1再查L2,顺序不能乱

很多人会问,既然Redis集群已经很能扛了,为什么还要在应用里加一层本地缓存?答案藏在网络开销和进程开销的差距里。Redis再快,也是一次网络往返。在大促高峰期,网络抖动、连接池等待、序列化开销都会放大延迟。而本地缓存直接从JVM堆内存里取值,连TCP握手都不用,响应时间稳定在微秒级。

顺序问题也值得深究。请求进来,一定要先查L1,再查L2,最后回源L3。如果不先查L1,直接查Redis,那么L1就形同虚设。如果先回源L3再查缓存,那就完全反了。

还有一个容易被忽略的细节:L1缓存需要设置合理的过期时间和最大容量,不是越多越好。如果本地缓存无限增长,会导致JVM堆内存飙升,频繁Full GC,反而拖垮应用自身的吞吐。一般情况下,L1容量控制在几千到几万条比较合适,用Caffeine的maximumSize限制,配合expireAfterWrite控制过期。

2.3 缓存一致性这样保证,读到旧数据别慌

三级缓存必然面临缓存一致性问题。L2和L3之间好处理,用经典的Cache Aside模式:更新数据库,然后删除Redis缓存,下次读取时再回源重建。难点在于L1本地缓存怎么同步失效。总不能每个应用实例都去轮询数据库吧。

我的做法是用Redis的Pub/Sub机制做缓存失效广播。当某个Key在L2被删除时,通过Redis发布一条消息,所有应用实例收到后删除自己本地缓存里的对应Key。这个方案实现成本很低,一致性延迟在几百毫秒以内,对绝大多数业务场景完全够用。

再补充一个容易踩的坑:更新数据库后直接“更新”Redis缓存而不是“删除”,是很多初学者最容易犯的错误。因为并发更新顺序不确定,后写的值可能覆盖先写的值,导致缓存和数据库的值不一致。简单粗暴的“删除”反而最安全,哪怕并发高,也只是多一次缓存击穿的回源成本,不会产生数据错乱。

3. 热点Key识别与分片:整个方案最核心的难点

3.1 一个热点Key打崩分片的完整过程

回到文章开头的场景。商品详情Key在Redis集群里按哈希槽分布在某个分片上,所有读请求都走Redis Cluster的CRC16哈希路由。不管集群有多少个分片,同一个Key的哈希槽永远落在同一个分片上。所以当这个Key成为热点时,其他分片再空闲,也帮不了它分担流量。

一个热点Key打崩分片,通常是这样一个过程:流量暴涨,分片CPU先冲高到80%以上,然后是网络带宽打满,接着是客户端连接超时,最后是积压请求把该分片的内存也拖垮,触发内存淘汰甚至OOM。再严重一点,这个分片挂了,集群会重新选举主从,但业务已经大面积超时了。

这也是为什么“光扩容Redis集群”解决不了热点Key问题——流量不分散,扩容的节点再多也白搭。典型的“木桶效应”:集群的瓶颈,永远是最热的那一个分片。

3.2 如何高效识别热点Key,三个手段叠加

识别热点Key是整个方案的前置条件,识别不准,分片策略就无从谈起。我总结了三种在实践中真实有效的识别手段,建议叠加使用。

第一种是访问计数。在应用层用一个带滑动窗口的计数器,统计每个Key在当前时间窗口内的访问次数。超过阈值就标记为热点Key,纳入分片策略。这种方式的优点是精准到业务Key,缺点是每个请求都要做一次计数,性能消耗要控制好。我通常用ConcurrentHashMap配合LongAdder实现,这个后面代码部分会展开。

第二种是代理层采样统计。如果你的系统前面有自研网关,可以在网关层记录请求URI的访问频率。这种方式不侵入业务代码,但只能统计到URL级别,拿不到URL背后的Key全名,适合做粗粒度的热点发现。

第三种是Redis侧的命令级统计。Redis 6.2及以上版本提供的HOTKEYS相关命令,可以查看当前分片上的热点信息;但它是抽样结果,不是精确计数,只能作为辅助参考。还可以用redis-cli带上--hotkeys参数周期性扫描大Key和热点Key,把结果喂给监控系统。

3.3 分片算法与副本数量的完整推导

识别出热点Key之后,分片策略怎么设计?比如原Key是goods:detail:1001,我会把它拆成N个逻辑分片Key:

goods:detail:1001:0 goods:detail:1001:1 goods:detail:1001:2 goods:detail:1001:3 ... goods:detail:1001:N-1

每个分片Key存储的数据完全一样,读取时从N个分片里随机选一个。这样CRCT16的哈希运算就会把N个Key尽量均匀地分散到不同哈希槽上。如果N足够大,就能让流量均匀散到多个分片节点。

N怎么算?这里给一个工业级的推导过程:

  • 预估热点Key总读QPS,记为Q_total,比如140万(可根据线上监控数据估算)
  • 确定单一逻辑分片Key能承受的安全QPS,记为Q_shard,保守取5万到8万
  • 那么N = Q_total / Q_shard,再乘以冗余系数1.5到2,避免单分片偶发抖动

代入数字:140万 / 7万 ≈ 20,再乘以冗余系数1.5,得到N=30。也就是说这个热点Key需要拆成30个分片副本。

这里要注意一个取舍:分片副本数不是越多越好。每个副本都要占Redis内存,如果一个大Value是10KB,30个副本就是300KB,虽然不多,但如果有100个热点Key呢。另外,如果这个Key不是“读多写极少”而是“读写均衡”,分片副本的写放大问题就必须考虑进来。所以分片只适合“读多写极少”的热点Key,对写频繁的Key,应该用其他方案(比如读写分离或数据重构)。

4. Redis集群配置与落地实操:完整配置照着用

4.1 集群节点规划与资源分配思路

在部署Redis集群之前,先想清楚节点规模和内存规划。我建议生产环境至少部署3主3从,也就是6个节点,这样任何一个节点宕机,主从切换后有冗余,集群依然可用。

内存规划这块,别把maxmemory设得太大。Redis的内存最好不要超过物理内存的70%,要给操作系统和持久化过程留出余地。一个节点maxmemory设4GB,6个节点总共可用缓存容量就是12GB(从节点理论上也在复制数据,比例按主节点算)。如果业务缓存总量估算超过这个值,就要加节点。

节点数量增多后,客户端路由表会更大,但Redis Cluster的MOVED重定向机制能自动处理,连接池资源会相应增加。总的原则是:节点数量 = 预估缓存总量 / 单节点安全内存容量。宁可多部署一两个节点,也别让单个节点内存打满。

4.2 核心redis.conf配置逐项解析

直接给一份生产级别的基础配置,文件名为redis.conf,每台节点保持一致,只需要修改端口和集群配置文件名称对应的节点信息:

# 开启集群模式 cluster-enabled yes # 集群配置文件,由Redis节点自动维护,不需要人工修改 cluster-config-file nodes-6379.conf # 节点间通信超时时间,单位毫秒 cluster-node-timeout 15000 # 开启AOF持久化,防止机器重启后缓存数据大量丢失 appendonly yes appendfilename "appendonly-6379.aof" appendfsync everysec # 内存上限与淘汰策略 maxmemory 4gb # 使用LRU近似淘汰算法,应对缓存容量不足,只淘汰不用的Key maxmemory-policy allkeys-lru # 网络相关 tcp-backlog 511 timeout 0 tcp-keepalive 300 # 慢查询日志阈值,单位微秒 slowlog-log-slower-than 10000 slowlog-max-len 128

重点讲两个参数。maxmemory-policy我习惯用allkeys-lru而不是volatile-lru,因为热点分片Key如果设置了过期时间,volatile策略只淘汰有过期时间的Key,而allkeys-lru对所有Key一视同仁。对于缓存场景,宁可淘汰冷数据,也不能让内存打满。appendfsync everysec是性能和安全性的折中方案:每秒刷一次AOF,极端情况下最多丢1秒数据,但换来了平稳的写吞吐。

4.3 客户端连接池与超时参数:影响吞吐的关键

连接池配置往往是被忽视的环节。很多线上事故,Redis本身没挂,是客户端连接池被打满,导致应用线程全部阻塞在获取连接上。用Spring Boot搭配Lettuce连接池时,我建议这样配置:

spring: data: redis: cluster: nodes: - 10.0.0.11:7000 - 10.0.0.12:7001 - 10.0.0.13:7002 - 10.0.0.14:7000 - 10.0.0.15:7001 - 10.0.0.16:7002 max-redirects: 3 timeout: 3000ms lettuce: pool: max-active: 1000 max-idle: 200 min-idle: 50 max-wait: 3000ms shutdown-timeout: 200ms

max-active设1000意味着同一时刻每个应用节点最多创建1000个Redis连接。如果应用节点是30个实例,就是最多3万个连接打到集群上,这个数量对Redis集群来说已经不小了,需要结合集群的最大连接数配置综合评估。max-wait设3000ms很关键,它决定获取连接时的最大等待时间,避免线程无限挂起。

4.4 集群的启动与验证步骤

集群部署我用Docker Compose演示,实际生产环境可以换成物理机或云主机,原理一致:

version: '3.8' services: redis-7000: image: redis:7.0-alpine container_name: redis-7000 command: redis-server /etc/redis/redis.conf --port 7000 network_mode: host volumes: - /data/redis-7000:/data - /etc/redis/redis.conf:/etc/redis/redis.conf redis-7001: image: redis:7.0-alpine container_name: redis-7001 command: redis-server /etc/redis/redis.conf --port 7001 network_mode: host volumes: - /data/redis-7001:/data - /etc/redis/redis.conf:/etc/redis/redis.conf # 其余7002-7005节点同理

注意所有节点使用同一份redis.conf,但通过不同的--port参数区分。启动完所有节点后,执行集群初始化和分配哈希槽:

redis-cli --cluster create \ 10.0.0.11:7000 10.0.0.12:7001 10.0.0.13:7002 \ 10.0.0.14:7000 10.0.0.15:7001 10.0.0.16:7002 \ --cluster-replicas 1

--cluster-replicas 1表示每个主节点搭配1个从节点。初始化完成后,执行redis-cli -c -p 7000 cluster info查看输出,cluster_state:ok和cluster_slots_assigned:16384就是集群正常工作的标志。我每次搭建完集群,都会再验证一次故障转移:手动SHUTDOWN一个主节点,观察从节点能否在几十秒内自动提升为新的主节点,这一步能提前发现配置问题。

5. 后端Java落地代码与关键实现:可直接抄进项目

5.1 缓存管理器:三级缓存读取主流程

直接给一个最核心的缓存管理器实现。我这里用了Spring Boot的RedisTemplate配合Caffeine缓存,完整展示三级缓存的读取链路:

@Service public class CacheManager { private static final Logger log = LoggerFactory.getLogger(CacheManager.class); private static final String LOCK_PREFIX = "lock:cache:"; private static final long LOCK_EXPIRE_MS = 3000L; private final Cache<String, Object> localCache; private final RedisTemplate<String, Object> redisTemplate; private final StringRedisTemplate stringRedisTemplate; private final HotKeyRouter hotKeyRouter; public CacheManager(Cache<String, Object> localCache, RedisTemplate<String, Object> redisTemplate, StringRedisTemplate stringRedisTemplate, HotKeyRouter hotKeyRouter) { this.localCache = localCache; this.redisTemplate = redisTemplate; this.stringRedisTemplate = stringRedisTemplate; this.hotKeyRouter = hotKeyRouter; } public Object get(String key, CacheLoader loader) { // 1. 先读L1本地缓存 Object localValue = localCache.getIfPresent(key); if (localValue != null) { return localValue; } // 2. 再读L2 Redis缓存,热点Key走分片路由 String shardKey = hotKeyRouter.selectShardKey(key); Object redisValue = redisTemplate.opsForValue().get(shardKey); if (redisValue != null) { localCache.put(key, redisValue); return redisValue; } // 3. 都未命中,回源L3数据库,用互斥锁防击穿 String lockKey = LOCK_PREFIX + key; boolean locked = tryLock(lockKey); if (locked) { try { // 双重检查,拿锁后再次查询Redis,可能已被其他线程回填 redisValue = redisTemplate.opsForValue().get(shardKey); if (redisValue != null) { localCache.put(key, redisValue); return redisValue; } Object dbValue = loader.load(); if (dbValue == null) { // 防穿透:空值也缓存,设置短TTL redisTemplate.opsForValue().set(shardKey, "", 30, TimeUnit.SECONDS); return null; } long ttl = 600L + ThreadLocalRandom.current().nextLong(300L); redisTemplate.opsForValue().set(shardKey, dbValue, ttl, TimeUnit.SECONDS); localCache.put(key, dbValue); return dbValue; } finally { unlock(lockKey); } } else { // 拿不到锁,短暂sleep后重试一次,尽量避免线程空转 try { Thread.sleep(50); } catch (InterruptedException e) { Thread.currentThread().interrupt(); } return get(key, loader); } } }

细节说明:回源时TTL加了一个0到300秒的随机偏移量,这样不同Key的过期时间不会同时到期,能有效规避“缓存雪崩”。如果Redis查到的值不存在,说明缓存没有回填,此时拿不到锁的线程睡50毫秒再重试,而不是直接回源数据库,能把数据库压力控制在一个线程的粒度。

5.2 热点Key分片路由与动态探测组件

热点Key路由器是整个分片逻辑的核心组件,维护一份热点Key名单,提供“选分片”和“获取全部分片”的能力:

@Component public class HotKeyRouter { private final Set<String> hotKeySet = ConcurrentHashMap.newKeySet(); private static final int DEFAULT_SHARD_COUNT = 30; public boolean isHotKey(String key) { return hotKeySet.contains(key); } public String selectShardKey(String key) { if (!isHotKey(key)) { return key; } int index = ThreadLocalRandom.current().nextInt(DEFAULT_SHARD_COUNT); return key + ":shard:" + index; } public List<String> allShardKeys(String key) { List<String> keys = new ArrayList<>(DEFAULT_SHARD_COUNT); for (int i = 0; i < DEFAULT_SHARD_COUNT; i++) { keys.add(key + ":shard:" + i); } return keys; } public void promote(String key) { hotKeySet.add(key); } public void demote(String key) { hotKeySet.remove(key); } }

热点探测用滑动窗口计数器,每个Key维护一个LongAdder,每秒统计一次窗口内的访问次数,超过阈值自动加入热点名单:

@Component public class HotKeyDetector implements Runnable { private static final long WINDOW_MS = 1000L; private static final long HOT_THRESHOLD = 1000L; private final ConcurrentHashMap<String, LongAdder> counter = new ConcurrentHashMap<>(); private final HotKeyRouter hotKeyRouter; public HotKeyDetector(HotKeyRouter hotKeyRouter) { this.hotKeyRouter = hotKeyRouter; } public void access(String key) { counter.computeIfAbsent(key, k -> new LongAdder()).increment(); } @Override public void run() { while (true) { try { Thread.sleep(WINDOW_MS); for (Map.Entry<String, LongAdder> entry : counter.entrySet()) { long count = entry.getValue().sumThenReset(); if (count >= HOT_THRESHOLD) { hotKeyRouter.promote(entry.getKey()); } else { hotKeyRouter.demote(entry.getKey()); } } } catch (InterruptedException e) { Thread.currentThread().interrupt(); break; } } } }

这里有个性能点值得注意:用LongAdder而不是AtomicLong,因为高并发下多线程同时累加同一个Key时,LongAdder的竞争开销远低于AtomicLong。还有一个坑是sumThenReset()在遍历过程中是线程安全的,但当窗口内某个Key的计数被重置,而同时又有新的请求累加进来,统计会有一点损耗,这个问题在工业实践中可接受,不必追求绝对精确。

5.3 缓存更新与失效策略:写流程的关键

读流程看完了,再看写流程。热点Key的无效化,一定要把所有分片副本全部删除,只删一个分片,会让其他分片继续返回旧值:

public void invalidate(String key) { // 1. 删除L1本地缓存 localCache.invalidate(key); // 2. 删除L2 Redis缓存,热点Key删所有分片副本 if (hotKeyRouter.isHotKey(key)) { List<String> shardKeys = hotKeyRouter.allShardKeys(key); redisTemplate.delete(shardKeys); } else { redisTemplate.delete(key); } // 3. 广播失效消息,让其他应用实例删除各自的本地缓存 stringRedisTemplate.convertAndSend("cache:invalid", key); }

与此同时,每个应用实例要监听这个广播通道,收到消息后删除自己本地缓存里的对应Key:

@Component public class CacheInvalidReceiver { private final Cache<String, Object> localCache; public CacheInvalidReceiver(Cache<String, Object> localCache) { this.localCache = localCache; } @Subscribe public void onMessage(String key) { localCache.invalidate(key); } }

当DB更新完成后,先执行invalidate删除缓存,再配合一个延迟双删兜底。具体做法是:更新数据库后,删一次缓存;然后在一个延迟线程池里,等待500毫秒到1秒后再删一次。为什么需要第二次删除?因为在极端并发的读写交错场景下,一个读线程可能在第一次删除前已经查到了旧的数据库值,又刚好在删除操作完成后把这个旧值写回Redis,延迟双删就是为了把这种“漏网之鱼”再清一次。

5.4 防击穿互斥锁的两个实现细节

上面代码里已经有了互斥锁的用法,但有两个细节我必须单独强调。第一个是锁的过期时间不能太短,否则在高负载下持锁线程还没回源完,锁就被释放了,其他线程蜂拥而至击穿DB。一般锁的过期时间要大于回源数据库的预估耗时,可以动态根据业务调整,极端场景下还要有一个“守护线程续期”的机制。

第二个细节是tryLock不能用普通的SETNX一把梭。生产环境建议用Redisson的分布式锁,它内部有看门狗机制,会对没有显式释放的锁进行自动续期,避免持锁线程卡死导致锁永久过期的问题。如果坚持自己用SETNX实现,锁的Value一定要放一个唯一标识,释放时用Lua脚本比对后再删除,防止误删别人的锁:

private boolean tryLock(String key) { String value = UUID.randomUUID().toString(); Boolean success = stringRedisTemplate.opsForValue() .setIfAbsent(key, value, LOCK_EXPIRE_MS, TimeUnit.MILLISECONDS); if (Boolean.TRUE.equals(success)) { // 将value存入ThreadLocal,解锁时比对 lockValueHolder.set(value); return true; } return false; } private void unlock(String key) { String value = lockValueHolder.get(); if (value == null) { return; } // Lua脚本:比对value一致时删除,防止误删 String luaScript = "if redis.call('get', KEYS[1]) == ARGV[1] then return redis.call('del', KEYS[1]) else return 0 end"; stringRedisTemplate.execute(new DefaultRedisScript<>(luaScript, Long.class), List.of(key), value); lockValueHolder.remove(); }

这里用ThreadLocal保存每个线程持有的锁标识,保证同一个线程解锁时不会被其他线程的删除操作干扰。

6. 线上常见问题与排查实录

6.1 缓存穿透/击穿/雪崩,一次分清并给方案

这三个概念经常被混在一起说,实际处理方案完全不同。缓存穿透指查询一个根本不存在的数据,每次都会穿过缓存打到数据库,解决方案是布隆过滤器拦截,或者把空值也缓存起来,TTL设短一些,比如30秒。缓存击穿指缓存中某个热点Key过期,同时大量请求打过来,全部回源数据库,解决方案是前面那份代码里的互斥锁,让只有一个请求去回源。缓存雪崩指大量Key在同一时间过期失效,导致数据库压力瞬间飙升,解决方案是TTL加随机偏移量,让过期时间均匀分布。

这三个问题在这套架构里都有对应的防御代码,但真正上线前还是要逐个场景做压测验证。我自己遇到过最尴尬的一次是:代码里写了互斥锁,但锁过期时间只有500毫秒,热点Key回源数据库需要800毫秒,结果锁提前释放,后面冲进来的大量请求继续击穿DB,代码形同虚设。

6.2 热点Key分片后的数据一致性偏差

分片策略做好后,最容易出的问题就是某次数据更新后,部分分片还是旧值。原因通常是更新流程里只删了主Key,没有删分片Key,或者删除分片Key时正好有读请求把旧值回填到了某个分片上。排查方法是:确认所有写操作都走了统一的invalidate方法,并且invalidate内部能感知热点Key并删除所有分片副本。线上另外一个常见原因是热点Key名单在更新期间发生了变动:某个Key先被识别为热点走分片读取,但写操作到来时它已经被移出热点名单,导致只删了主Key,几十个分片副本残留旧值。

我的解法是用一个统一的“写后删除”入口,不管是更新、删除还是过期回填,都强制绕过分片路由逻辑去清理。同时定期跑一个对账任务,把热点Key的每个分片副本的过期时间对齐,防止某个副本长期存活。

6.3 大Value导致的慢查询与带宽打满

热点Key如果本身就是一个很大的Value,比如把一个几千KB的JSON列表直接塞进Redis,就算分片做得再好,单次GET的耗时也会很高,网络带宽更是早早打满。优化方向有两个:第一,Value按照业务字段拆分成多个小Key,只获取真正需要的字段;第二,把JSON换成更紧凑的序列化方案。大Value场景下,Redis的慢查询日志会频繁出现GET命令耗时超过100毫秒的记录,用SLOWLOG GET就可以查出来。

另外需要留意网络带宽的监控。很多Redis集群不是CPU先挂,而是网卡先被打满。热点Key分片只能分散CPU压力,不能减少数据返回的总字节数。如果单Key的Value超过10KB,就要考虑业务侧做精简,或者在客户端做压缩。

6.4 集群扩容与数据迁移避坑

流量继续上涨后,Redis集群节点不够,需要加节点。Redis Cluster扩容流程本身不复杂:新节点加入、迁移哈希槽、确认数据分布。但有两个坑特别值得提醒。第一个坑是迁移过程中部分哈希槽处于 migrating 状态,客户端访问会收到ASK重定向,很多老版本的客户端库处理不好,导致大量报错。升级到较新的Lettuce或者Jedis版本一般能解决。第二个坑是迁移期间不要去执行热点Key分片副本的手动清理操作,否则可能打到正在迁移的槽位上,产生意想不到的异常。

扩容前一定要先压测,确认新节点的内存和CPU余量足够。不要等集群快挂了才扩容,因为在集群高负载状态下做哈希槽迁移,本身就是一种额外压力,容易触发雪崩。

7. 最后分享几个实际落地时的经验

这套方案在我维护的多个高并发项目里都落地过,有一个经验是:热点Key分片数不是一劳永逸的。每次大促之前,我都会重新评估历史流量数据,调整分片数量,并上线一个“热点Key动态探测”的任务把新出现的爆款商品提前识别出来,而不是等活动开始了之后被监控告警牵着鼻子走。

还有一个小技巧:在业务代码的入口处,把所有获取缓存的调用统一收敛到CacheManager里,不要到处直接操作RedisTemplate。这样后续不管是调整分片数、增加缓存等级还是优化监控指标,都只需要改一个类,不至于在几十个Service里东一榔头西一棒子地找代码。

做高并发架构,很多人追求“极致性能”,但我个人更看重“可控的复杂度”。三级缓存加热点Key分片,本身已经是相对复杂的方案了,所以在实现上要尽量收敛变数:连接池参数固定、锁过期时间固定、分片算法固定、失效流程固定。线上稳定性,往往不是来自于某个惊天动地的奇技淫巧,而是来自于每一个普通细节都被提前想到了。

版权声明: 本文来自互联网用户投稿,该文观点仅代表作者本人,不代表本站立场。本站仅提供信息存储空间服务,不拥有所有权,不承担相关法律责任。如若内容造成侵权/违法违规/事实不符,请联系邮箱:809451989@qq.com进行投诉反馈,一经查实,立即删除!
网站建设 2026/10/16 3:31:23

用电信息采集通信协议解析:帧格式、校验和与测试工具实战

简介&#xff1a;面向电力行业技术人员&#xff0c;这份资源包整合了电力用户用电信息采集系统的核心通信协议与配套测试工具&#xff0c;覆盖DL/T 645、376.1/376.2/376.3及698等标准&#xff0c;可用于智能电表、集中器与主站之间的数据交互开发、协议解析和现场调试&#xf…

作者头像 李华
网站建设 2026/10/16 3:31:22

存储层次结构详解:从CPU缓存到磁盘的性能优化指南

最近在复盘计算机系统的学习笔记&#xff0c;正好翻到“1.6 存储设备形成层次结构”这一节。这是整门课里看起来最简单、后劲最大的内容之一——核心只有一张金字塔图&#xff0c;但几乎所有和性能、系统设计有关的讨论&#xff0c;最后都得绕回到这张图上。不管是写代码时纠结…

作者头像 李华
网站建设 2026/10/16 3:31:12

Kalibr相机-IMU联合标定实战:从标定板制作到外参验证

/* MD / 富文本中的 .toc(含博客园搬家等嵌套结构);.toc-box 在侧栏,不受影响 */#content_views .toc,/* 编辑器常在目录前后插入空 p(:empty 仍占 20px),一并去掉避免顶空隙 */#content_views.markdown_views > p:empty:has(+ .toc),#content_views.markdown_views …

作者头像 李华
网站建设 2026/10/16 3:30:37

两串锂电池无损均衡:MP2642GR-Z与PIC18F57Q43协同设计

/* MD / 富文本中的 .toc(含博客园搬家等嵌套结构);.toc-box 在侧栏,不受影响 */#content_views .toc,/* 编辑器常在目录前后插入空 p(:empty 仍占 20px),一并去掉避免顶空隙 */#content_views.markdown_views > p:empty:has(+ .toc),#content_views.markdown_views …

作者头像 李华
网站建设 2026/10/16 3:29:48

《道德经》第七十七章:天道本质是张弓,损有余而补不足

读《道德经》第七十七章&#xff0c;我第一次强烈感受到“天道本质”这四个字的分量。乍看它在讲社会分配——“天之道&#xff0c;损有余而补不足&#xff1b;人之道&#xff0c;损不足以奉有余”——像是资源如何流动的老话题。真正读进去才发现&#xff0c;它讲的不是分配方…

作者头像 李华
网站建设 2026/10/16 3:29:07

SMB协议导致游戏卡顿?三步诊断与终极优化方案

如果你和我一样&#xff0c;为了给电脑腾出宝贵的硬盘空间&#xff0c;把几十上百GB的游戏塞进了NAS或者另一台电脑的共享文件夹&#xff0c;然后通过映射的网络盘符直接打开游戏——那么过地图时长得离谱的加载、打团时突然出现的画面冻结、以及每隔几秒就来一次的掉帧&#x…

作者头像 李华