开头先说个真实感受:做后端这几年,我接手的系统里几乎没有哪个能躲过缓存这一关。尤其是QPS一上来,数据库先报警,DBA半夜打电话,领导催着优化——最后方案基本上都指向同一个东西:分布式缓存。
这篇博文把“分布式缓存系统实现”这件事从头到尾捋一遍。不是给你堆概念,而是按我实际做过的项目来拆:从为什么需要缓存、怎么选型,到Redis集群搭建、客户端代码落地,再到上线后真实遇到的高频问题。内容偏工程实践,适合正在做高并发改造的开发、架构岗同学参考,刚入门的也能对照着把项目搭起来。
1. 项目背景与整体设计思路
1.1 为什么需要分布式缓存
先解决一个最基础的问题:为什么要用分布式缓存,而不能单靠数据库硬扛?
很多业务系统最初的架构非常简单:应用服务直连MySQL,读写都走SQL。这种模式在低并发阶段完全没问题,但一旦流量涨起来,瓶颈会非常明显。我经历过一个真实场景:某个读接口被外部高频轮询,单日调用量几千万次,数据库CPU持续跑满,大量慢查询把连接池打满,最终导致整个服务不可用。后来排查发现,真正变化的数据量很小,90%以上的请求都在读同一批热点数据。
这就是典型的“读多写少”场景,也是分布式缓存最擅长的领域。把高频访问的数据放到内存里,让请求优先打到缓存,数据库压力瞬间就能降下来。从数据来看,单节点Redis的QPS可以轻松跑到10万以上,而单库MySQL的稳定读QPS通常也就几千到一两万,中间差了一个数量级。用缓存扛住大部分读流量,数据库只处理真正需要落盘的写操作和少量未命中缓存的数据补充,整个系统的吞吐能力和稳定性都会上一个台阶。
但要注意,缓存不是银弹。如果业务本身写多读少,或者数据变更极其频繁,缓存带来的收益会大打折扣。这也是为什么我在做系统设计时,第一件事不是选技术栈,而是先看业务特征:读多写少才值得上缓存,热点集中才有明显的缓存价值,数据一致性要求不苛刻才能接受缓存的最终一致性代价。
1.2 缓存选型:为什么我选了Redis而不是Memcached
分布式缓存的选型,业界基本就是Redis和Memcached两个方向。两者都经历过大规模生产环境验证,但定位有明显差异。
Memcached的优势是简单、纯粹、内存管理效率高,多线程模型在单机上能充分利用多核CPU。它只支持最简单的KV结构,value最大1MB,没有持久化,也没有原生的集群方案。早期很多互联网公司用它扛过超大流量,但今天新项目里见的越来越少。
Redis的优势在于数据结构丰富,除了String,还有Hash、List、Set、ZSet等,业务上可以直接用Redis实现排行榜、分布式锁、限流、消息队列等场景。它支持RDB和AOF两种持久化方式,虽然缓存场景一般不依赖持久化,但关键时刻能救数据。集群方面,Redis有主从复制、哨兵和Cluster三种模式,官方自带分片能力,这是Memcached不具备的。
我在项目中之所以选Redis,理由很直接:第一,业务场景不只有KV缓存,还需要分布式锁、幂等控制、限流等能力,Redis一套搞定;第二,Memcached的纯内存模式在重启后缓存全部丢失,而Redis至少能通过持久化快速恢复预热,对核心高可用要求更友好;第三,社区生态差距太大,Spring Boot、Redisson、各种云厂商对Redis的支持都是开箱即用,Memcached的周边工具明显偏少。
如果你对两者还有一些纠结,可以记住这个结论:除了极少数特殊场景(比如你需要一个超级简单的纯KV缓存、对QPS有极致追求且数据允许全丢),Redis基本是更稳妥的选择。我后来甚至没有再认真评估过Memcached,因为Redis已经覆盖了所有需求。
1.3 整体架构:缓存层放在哪,请求怎么走
确定选型之后,接下来要考虑的是整体结构。我的做法是把缓存层独立出来,放在应用服务和数据库之间,形成一条完整的读写链路:应用服务 → 缓存集群 → 数据库。
读请求的处理逻辑是:先查Redis,命中就直接返回,不命中再去查数据库,然后把查询结果回填到Redis,并设置过期时间。写请求的处理逻辑是:先更新数据库,再删除对应的缓存key,保证下一次读请求能够拿到最新数据。
集群层面,我使用的是Redis Cluster模式,三主三从六个节点。主节点负责处理读写请求,从节点负责故障转移和数据备份。Cluster的好处是通过哈希槽(Hash Slot)把数据自动分布到多个节点上,默认有16384个槽位,客户端请求会根据key的CRC16计算结果路由到对应节点,整体容量可以水平扩展。
引入缓存层看似简单,实际上对业务是有侵入的。这里有几个需要提前想清楚的问题:哪些数据适合缓存、缓存key怎么命名、缓存粒度是整对象还是字段级、数据库和缓存的一致性怎么保证。我在项目中专门做了一个缓存管理器,统一封装所有缓存操作,避免每个业务开发都自己写一套。
还有一个比较关键的设计原则:不要在业务代码里写一大堆if-else去判断缓存是否存在,而是把“查缓存-查库-回填”这段逻辑抽象出来,让调用方只关心数据获取。这样做的好处是,后续无论调整缓存策略还是切换序列化方案,都不用改动业务代码。我后面会详细讲这个封装怎么实现。
需要模型API调用? 免费领10W Token,多模型网关一键接入 Claude、DeepSeek 等主流模型。
2. 核心机制与原理解析
2.1 读写策略:Cache Aside模式是最稳的基石
分布式缓存最经典的读写策略是Cache Aside,也叫旁路缓存。这套模式看起来简单,但细节非常多,先把两个核心流程写清楚。
读请求流程:
- 应用先查缓存,如果缓存存在直接返回。
- 缓存不存在,查数据库。
- 数据库查到结果之后,回填缓存并设置过期时间,然后返回结果。
写请求流程:
- 先更新数据库,确保数据落库。
- 删除Redis中的旧缓存。
很多人会问:为什么写入时要删除缓存,而不是更新缓存?这里有两个原因。
第一,更新缓存会产生“写多读少”场景下的浪费。如果业务对某个key写入非常频繁,但读取频率很低,每次都更新缓存会白白消耗CPU和内存。而删除缓存是惰性的,只有下次读请求发生时才会重新回填。
第二,更新缓存会引入并发更新的一致性风险。多个请求同时写数据库时,如果它们也同时更新缓存,最终哪个值留在缓存里完全取决于到达顺序,可能就出现了缓存里存了旧值的情况。删除缓存就不存在这个问题,因为反正下次读的时候会回填最新值。
当然,Cache Aside模式也有一个经典痛点:在“先更新数据库、再删除缓存”的链路里,如果删除缓存失败,就会导致脏数据长期存在。这个问题我放到后面数据一致性部分详细讲,这里先记住基本模式。
做缓存架构时千万不要本末倒置。有些团队喜欢一上来就搞Read Through、Write Through、Write Behind这些更复杂的策略,但实际落地时,90%的业务场景用Cache Aside已经足够了,剩下的10%靠延迟双删和binlog订阅来兜底。我见过太多把系统搞复杂然后又回滚到Cache Aside的例子。
2.2 数据一致性:延迟双删和binlog订阅,到底怎么选
缓存和数据库的数据一致性,是分布式缓存实现里最让人头疼的问题。理想状态是强一致,但分布式环境下没有银弹,我们追求的是最终一致性,而且要让不一致的时间窗口尽可能小。
先说说最基础的做法:延迟双删。流程是:
- 先更新数据库。
- 删除Redis中的缓存。
- 休眠一小段时间(通常是几百毫秒)。
- 再次删除Redis中的缓存。
为什么要删两次?因为存在一个非常隐蔽的竞态:请求A更新数据库,请求B在A更新后立即读取数据,此时缓存还未删除,B拿到了旧缓存;然后B把旧数据回填到缓存(如果B走的是缓存未命中的回填逻辑),覆盖了A本来准备删除的key。延迟双删就是在A第二次删除时把这个覆盖的脏数据清掉。
延迟时间怎么定?我的经验是取业务上读请求的平均耗时,再加一些冗余。比如服务本身的响应时间一般在50ms以内,延迟双删的等待时间就设置在300~500ms,确保并发读请求基本都已完成流程。
延迟双删的问题在于,如果第二次删除失败,还是会留下一段脏数据。更可靠的做法是订阅数据库binlog,通过Canal等工具解析MySQL的增量日志,监听到数据变更后异步删除对应缓存。这个方案的优点是不侵入业务代码,哪怕业务代码里漏写了Redis删除操作,binlog也能兜住。缺点是引入了额外的中间件,运维成本提升。
我当时在项目中的选择是两者结合:核心场景用延迟双删保证即时一致性,同时用binlog订阅做最终兜底。如果删除缓存失败,就放到重试队列里不断重试,直到成功为止。这套机制跑了大半年,基本没有出现过因为缓存导致的数据严重不一致。
2.3 过期策略与内存淘汰:细节决定缓存系统稳不稳
Redis的key过期策略,官方文档讲得很清楚,但真正上线前依然有不少细节需要注意。
Redis的过期删除机制分为两类:惰性删除和定期删除。惰性删除是指客户端访问某个key时,Redis会检查它是否已过期,如果过期就删除并返回空;定期删除是指Redis每隔一段时间随机抽一批设置了过期时间的key,检查并删除其中的过期key。两者配合使用,既避免了对内存的定时全量扫描造成的高CPU开销,也保证了过期key不会一直占着内存不放。
不过,这里有一个很多人忽略的坑:如果大量key在同一时刻过期,Redis的删除操作会集中爆发,导致CPU突刺,同时大量请求会同时穿透到数据库,形成缓存雪崩。解决方案很简单,就是给过期时间加上一个随机偏移量。比如原来统一设置3600秒,现在改成3600±300秒的随机值,让过期时间点均匀分散。
内存淘汰策略也值得认真选。Redis默认是noeviction,也就是内存满了之后不淘汰任何key,写新数据直接报错。这在缓存场景里基本不可用。我通常设置的是allkeys-lru:整个key空间按照LRU算法淘汰最久未使用的key,这样即使业务上忘了设置过期时间,Redis也能在内存压力下自动腾出空间。
这里多说一句,allkeys-lru和volatile-lru的区别很多人搞混。volatile-lru只淘汰设置了过期时间的key,如果大部分key都没设过期时间,这个策略等于没用。而allkeys-lru对所有key一视同仁。缓存的本质就是允许丢弃的临时数据,所以我推荐非特殊场景都用allkeys-lru。当然,如果个别key的数据不允许被淘汰,那就不要把它放在Redis里,换一种方式存储。
3. 系统实现与关键代码
3.1 Redis Cluster集群搭建:三主三从实测过程
这一节讲我实际搭建Redis Cluster的过程,用的版本是Redis 6.2。生产环境建议至少6个节点,三主三从,分布在不同的物理机或可用区,避免单点故障。
第一步,准备6个Redis实例的配置文件。核心配置有这几项:
- port:每个实例的端口不同(7000~7005)。
- cluster-enabled yes:开启集群模式。
- cluster-config-file:节点保存集群状态的配置文件。
- appendonly yes:开启AOF持久化,缓存重启后能加速预热。
bash复制# redis-7000.conf 示例
port 7000
daemonize yes
dir /data/redis/7000
pidfile /var/run/redis_7000.pid
cluster-enabled yes
cluster-config-file nodes-7000.conf
appendonly yes
第二步,逐个启动Redis实例。启动后每个节点都还是独立的,需要用redis-cli把它们组装成集群:
bash复制redis-cli --cluster create \
192.168.1.10:7000 192.168.1.11:7001 192.168.1.12:7002 \
192.168.1.13:7003 192.168.1.14:7004 192.168.1.15:7005 \
--cluster-replicas 1
--cluster-replicas 1表示每个主节点配一个从节点,集群会自动把16384个哈希槽均分到三个主节点上,并把剩下的三个节点设为对应主节点的从节点。
第三步,验证集群状态:
bash复制redis-cli -c -h 192.168.1.10 -p 7000 cluster info
看到cluster_state:ok和cluster_slots_assigned:16384就说明集群创建成功了。
实际操作中有一个比较常见的坑:Redis Cluster对网络环境很敏感,如果节点之间无法互通,会出现fail状态。所以搭建前一定要检查所有端口是否开放、防火墙策略是否正确,节点之间用redis-cli -h <ip> -p <port> ping互相测一遍。
3.2 客户端封装:基于Spring Boot的缓存服务实现
集群搭好之后,客户端封装是系统实现的重头戏。我用的技术栈是Spring Boot 2.7 + RedisTemplate,底层连接池用的是Lettuce。
先看核心配置:
yaml复制spring:
redis:
cluster:
nodes:
- 192.168.1.10:7000
- 192.168.1.11:7001
- 192.168.1.12:7002
lettuce:
pool:
max-active: 200
max-idle: 50
min-idle: 10
max-wait: 3000ms
timeout: 5000ms
连接池参数里,max-active和max-wait是两个最需要关注的值。如果max-active太小,高并发下客户端会拿不到连接直接报错;如果max-wait太小,瞬间流量会直接把请求打挂。我的经验值是200左右,具体根据业务QPS和单个请求的Redis耗时来调,估算公式大概是:预估QPS乘以单次操作耗时,再乘一个2到3的冗余系数。
然后是序列化方案的选择。默认的JdkSerializationRedisSerializer虽然能用,但序列化后体积大、占用内存多,跨语言也不友好。我统一用的是Jackson JSON序列化,生产格式为JSON字符串,可读性好,排查问题方便。
java复制@Configuration
public class RedisConfig {
@Bean
public RedisTemplate<String, Object> redisTemplate(RedisConnectionFactory factory) {
RedisTemplate<String, Object> template = new RedisTemplate<>();
template.setConnectionFactory(factory);
StringRedisSerializer stringSerializer = new StringRedisSerializer();
GenericJackson2JsonRedisSerializer jsonSerializer =
new GenericJackson2JsonRedisSerializer();
template.setKeySerializer(stringSerializer);
template.setHashKeySerializer(stringSerializer);
template.setValueSerializer(jsonSerializer);
template.setHashValueSerializer(jsonSerializer);
template.afterPropertiesSet();
return template;
}
}
接下来是缓存服务封装,核心是提供一个queryWithCache方法,把“查缓存-查库-回填”的逻辑统一收口:
java复制@Service
public class CacheService {
@Autowired
private RedisTemplate<String, Object> redisTemplate;
public <T> T queryWithCache(String key, long expireSeconds,
Supplier<T> databaseLoader) {
// 1. 从缓存读取
Object cacheValue = redisTemplate.opsForValue().get(key);
if (cacheValue != null) {
return (T) cacheValue;
}
// 2. 加锁防击穿,这里简化处理,实际使用分布式锁
synchronized (key.intern()) {
// double check:防止其他线程已经重建缓存
cacheValue = redisTemplate.opsForValue().get(key);
if (cacheValue != null) {
return (T) cacheValue;
}
// 3. 缓存未命中,加载数据库
T result = databaseLoader.get();
if (result != null) {
redisTemplate.opsForValue().set(key, result, expireSeconds, TimeUnit.SECONDS);
}
return result;
}
}
}
这段代码里synchronized (key.intern())是单机版的防击穿措施。生产环境多个应用实例时,必须换成分布式锁,我后面会专门讲。
3.3 缓存穿透、击穿、雪崩:三个高并发经典问题的防护代码
写缓存系统的过程中,三个以“缓存”开头的高频技术词你一定躲不掉:穿透、击穿、雪崩。三者的本质完全不同,需要分别处理。
缓存穿透是指查询一个根本不存在的数据。由于数据不存在,缓存里永远没有值,每次请求都会打到数据库。如果某个请求被恶意构造,比如持续用一个不存在的ID去查,数据库会被打爆。解决的方案有两个:一是布隆过滤器,在请求进来时先判断ID是否可能存在于数据库,如果不存在直接返回空;二是对空结果也做缓存,设置较短的过期时间,比如60秒,防止同一类请求反复穿透。
我当时用的是“空值缓存+短过期时间”方案,实现简单,效果明显。布隆过滤器虽然更精准,但需要在数据库初始化时构建全量ID集合,对数据频繁变化的业务来说维护成本高。这里给出一段空值缓存的代码逻辑:
java复制T result = databaseLoader.get();
if (result == null) {
// 空值也缓存,60秒过期,防止穿透
redisTemplate.opsForValue().set(key, new NullValue(), 60, TimeUnit.SECONDS);
} else {
redisTemplate.opsForValue().set(key, result, expireSeconds, TimeUnit.SECONDS);
}
这里要注意,不能直接把null塞进Redis值里,因为RedisTemplate会把null当作空值处理,读取时无法区分“缓存没有这个key”和“缓存了空值”。我的做法是自定义一个NullValue占位对象。
缓存击穿是指某个热点key在过期的瞬间,大量请求同时看到缓存未命中,于是全部打到数据库。解决思路是互斥锁,让只有一个线程去重建缓存,其他线程等待或者直接返回旧值。上面CacheService里的synchronized改造成分布式锁就足够了。
缓存雪崩是指大量key在同一时间内集中过期,导致请求全部落到数据库,数据库压力骤增。解决办法是过期时间加随机偏移,这一点我前面已经提过,是所有缓存key写入时必须养成的习惯。
3.4 分布式锁:Redis如何实现可靠的互斥机制
缓存重建、定时任务防重、库存扣减,这几个场景都需要分布式锁。Redis实现分布式锁最常见的两种方式原生SET和Redisson。
原生SET命令的正确姿势是:
java复制// 加锁
String lockKey = "lock:hot_key";
String requestId = UUID.randomUUID().toString();
boolean locked = redisTemplate.opsForValue()
.setIfAbsent(lockKey, requestId, 10, TimeUnit.SECONDS);
if (locked) {
try {
// 执行重建缓存逻辑
} finally {
// 释放锁,必须校验是当前线程的锁
String currentValue = (String) redisTemplate.opsForValue().get(lockKey);
if (requestId.equals(currentValue)) {
redisTemplate.delete(lockKey);
}
}
}
加锁必须使用set key value NX PX原子命令,不能先setnx再expire,否则如果进程在两步之间崩溃,锁会永远不释放。
释放锁时必须先判断value是否等于自己写入的requestId,然后再删除。这是为了防误删:如果锁已经过期被其他线程抢到,当前线程如果直接删除会把自己的锁删掉,导致互斥失效。
这段代码在生产环境里能跑,但还有很多边界坑,比如锁过期时间设置多少合适、业务执行时间超过锁过期时间怎么办。我的建议是:如果项目里已经有Redisson,直接用RLock,它的看门狗会自动续期,省去手工处理超时的问题。如果不想额外引包,那就把锁过期时间设置得比业务峰值耗时长2~3倍,宁可多等也不能提前失效。
4. 常见问题与排查技巧实录
4.1 热key问题:单节点CPU飙高怎么解决
Redis Cluster虽然能把数据分散到多个节点,但如果某个key特别热,所有请求总会路由到同一个节点,这个节点的CPU和带宽会成为瓶颈。我遇到过一个案例:一个用户维度的热点key每秒被访问几十万次,对应节点CPU直接打满。
排查热key有几个常用手段。第一,使用redis-cli --hotkeys命令分析,它会基于redis的object freq机制输出访问频率最高的key。不过要注意,这个命令需要先设置CONFIG SET maxmemory-policy allkeys-lfu才能工作。
第二,在客户端侧做本地缓存兜底。把热点key的数据在应用本地内存里缓存一定时间,比如1秒或5秒,这样绝大多数请求直接走本地内存,根本不触达Redis。本地缓存可以采用Caffeine,性能很好。但要注意本地缓存的更新问题,最稳妥的做法是给每个热点key设置极短的本地过期时间,用时间换一致性。
第三,热key备份分散读写。把一个key的访问量分散到key#1、key#2、key#3等副本上,应用端随机读一个副本。这个方案对纯读场景效果很好,但写入时需要同步写所有副本。
热key问题最大的困难在于“预判”。仅靠上线后排查往往已经造成影响了。我的经验是:对频繁访问的数据和接口要有监控预警,一旦Redis节点CPU持续超过70%就立即排查热点,而不是等报警了才动手。
4.2 大key问题:删除卡顿和带宽占用
大key指的是value特别大的key,比如一个Hash里存了几十万个字段,或者一个String值有几十MB。大key的危害主要体现在几个方面。
Redis删除一个占用内存很大的key时,会阻塞整个单线程的事件循环。如果业务直接调用DEL删除一个几百万字段的Hash,Redis服务可能会卡顿几秒,期间的读写请求全部排队,对在线业务来说是灾难。
排查大key的方式是用redis-cli --bigkeys扫描,它会遍历Redis并输出每个类型中最大的key。这个命令也要注意在低峰期执行,因为全量扫描有一定开销。
解决大key问题的思路是拆分或分批删除。比如超大Hash可以拆成多个小Hash,将user:10001的字段拆成user:10001:1、user:10001:2等多个key;删除时用SCAN配合HDEL分批删除,千万不要一把梭直接DEL。用UNLINK命令也可以,它是异步删除,不会阻塞主线程,但如果内存碎片非常严重,UNLINK之后的内存回收可能还需要额外处理。
大key的另一个隐患是网络带宽打满。一个5MB的value被并发读取时,每秒可能产生几个GB的出口流量,直接把网卡跑满。所以对大value一定要做业务层限制,通常超过10KB的值我就需要和业务方确认是否真的适合放Redis。
4.3 缓存与数据库不一致的排查思路
缓存与数据库偶尔不一致,是每个分布式缓存系统都会遇到的情况。整理几个最常见的导致不一致的原因和排查方式。
原因一是删除缓存失败。数据库更新成功,但Redis删除操作的返回结果被忽略。尤其是使用Lettuce连接池时,如果连接断掉,delete命令抛了异常却被吞掉,容易留下脏缓存。排查时看业务日志是否包含Redis相关的warning或error,同时在写路径上增加失败重试机制。
原因二是不合理的“更新缓存”逻辑。有些同学图省事,在更新数据库后直接调set更新缓存,这样就可能出现并发覆盖脏数据的问题。最有效的排查方式是看代码里是否有写业务链路直接调用缓存更新而非缓存删除的地方。
原因三是回填覆盖。延迟双删没做或者等待时间太短,并发读请求把旧数据回填到Redis。这种情况可以通过在缓存值里带上业务时间戳来辅助判断,回填时如果发现数据库时间比缓存里的时间旧,就不回填。
排查不一致问题,我的通用方法是:先在Redis里查到这个key的value和它的TTL,再对比数据库里的当前值,看两者变化的时间线。同时打开日志,把写请求和缓存删除操作的执行时间打出来,基本能定位是哪个环节出了问题。
4.4 监控与告警:缓存系统上线后必须盯住的指标
缓存系统做完了,不代表可以高枕无忧。没有监控就相当于盲开车,这是我做项目最深刻的教训之一。
我会把这些指标纳入监控:
- 命中率(hit_rate):通过
INFO stats查看keyspace_hits和keyspace_misses。命中率如果低于70%,说明缓存价值有限,重点排查过期时间设置是否合理。 - 内存使用量:监控
used_memory,当超过maxmemory的80%时要预警,防止频繁触发内存淘汰。 - 连接数:
connected_clients。连接数异常上涨往往意味着客户端连接池泄漏或者慢请求堆积。 - 阻塞的客户端:
blocked_clients,如果长时间不为0,说明有BRPOP等阻塞命令在执行,同样需要关注。 - 慢查询:设置
slowlog-log-slower-than 10000(10毫秒),缓慢命令说明key太大或命令本身复杂,需要重点排查。
告警阈值我给一个参考:Redis节点CPU超过70%持续5分钟、命中率低于80%持续10分钟、内存使用超过maxmemory的80%、连接数超过连接池预估值120%。这些值不是固定的,需要根据业务量级持续调整。
监控工具方面,我自己用的是Prometheus + redis_exporter + Grafana,对中小团队完全够用。redis_exporter能自动采集几乎全部需要的指标,配置好告警规则之后,Redis集群的稳定性就基本有保障了。
最后说一点个人感受。分布式缓存这件事,最难的不是搭建集群、不是写缓存代码,而是上线之后的长期治理:key怎么规范命名、大key怎么及时清掉、热点怎么提前发现、容量怎么预估。这些脏活累活才是决定一个缓存系统能稳定运行多久的关键。我现在接手一个系统,第一件事永远是看它的Redis监控面板和key分布情况,而不是听介绍文档。如果你也正在做类似的系统,不妨在这些“看不见”的地方多花点心思,回报会比想象中来得快。
