凌晨两点,大促流量开始往上冲。监控大屏上的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: 200msmax-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分片,本身已经是相对复杂的方案了,所以在实现上要尽量收敛变数:连接池参数固定、锁过期时间固定、分片算法固定、失效流程固定。线上稳定性,往往不是来自于某个惊天动地的奇技淫巧,而是来自于每一个普通细节都被提前想到了。