分布式缓存详解:从原理到高并发实践
1. 分布式缓存
1.1 什么是分布式缓存
分布式缓存是指独立于业务服务和数据库之外的缓存系统,多个应用实例可以通过网络共同访问它。常见的分布式缓存中间件有 Redis、Memcached 等。
在高并发系统中,如果每次请求都直接访问数据库,数据库很容易成为性能瓶颈。分布式缓存的作用,就是把高频访问、变化不那么频繁的数据提前放到缓存中,让应用优先读取缓存,从而减少数据库压力。
典型读取流程如下:
客户端请求 ↓ 业务服务 ↓ 先查询缓存 ↓ 缓存命中:直接返回 缓存未命中:查询数据库 ↓ 数据库返回数据 ↓ 写入缓存 ↓ 返回客户端这种模式通常叫做 Cache Aside,也叫旁路缓存模式。
1.2 为什么需要分布式缓存
分布式缓存主要解决以下问题:
- 降低数据库访问压力
- 提升接口响应速度
- 提高系统吞吐量
- 承接突发流量
- 减少重复计算
- 降低复杂查询成本
例如商品详情页、用户信息、首页配置、热门榜单、权限信息、字典数据等,都适合放入缓存。
1.3 本地缓存和分布式缓存的区别
| 类型 | 数据位置 | 优点 | 缺点 |
|---|---|---|---|
| 本地缓存 | 应用进程内 | 速度快,无网络开销 | 多实例数据不一致,容量有限 |
| 分布式缓存 | 独立缓存服务 | 多实例共享,容量更大 | 有网络开销,依赖缓存服务稳定性 |
实际项目中,经常会把本地缓存和分布式缓存结合使用:
应用服务 ↓ 本地缓存 ↓ Redis 分布式缓存 ↓ 数据库1.4 常见缓存数据
适合缓存的数据通常有这些特点:
- 访问频率高
- 数据变化频率低
- 查询数据库成本高
- 对实时一致性要求不是特别强
- 可以接受短时间旧数据
常见缓存对象包括:
- 用户基础信息
- 商品详情
- 商品类目
- 首页配置
- 活动配置
- 权限菜单
- 系统字典
- 热门排行榜
1.5 常见缓存读取代码
publicUserDTOgetUser(LonguserId){Stringkey="user:profile:"+userId;UserDTOcacheUser=redisTemplate.opsForValue().get(key);if(cacheUser!=null){returncacheUser;}UserDTOdbUser=userMapper.selectById(userId);if(dbUser!=null){redisTemplate.opsForValue().set(key,dbUser,30,TimeUnit.MINUTES);}returndbUser;}这个逻辑看起来简单,但在高并发场景下,会引出缓存雪崩、缓存穿透、缓存击穿、缓存更新和缓存降级等问题。
2. 缓存雪崩
2.1 什么是缓存雪崩
缓存雪崩是指大量缓存 key 在同一时间失效,或者缓存服务整体不可用,导致大量请求瞬间打到数据库,最终把数据库压垮。
常见场景包括:
- 大量 key 设置了相同过期时间
- Redis 集群故障
- 缓存节点重启后大量缓存丢失
- 活动开始时缓存还没有预热
- 定时任务批量删除或刷新缓存
2.2 缓存雪崩的危害
缓存雪崩的危险在于它不是单个请求失败,而是可能引发链路级故障:
- 数据库 QPS 突然升高
- 数据库连接池被打满
- 慢 SQL 增多
- 应用线程阻塞
- 上游服务重试加剧压力
- 接口大量超时
- 系统整体不可用
2.3 解决方案一:过期时间加随机值
不要让大量缓存 key 在同一时间过期。可以在基础 TTL 上增加随机时间。
longbaseTtl=30*60;longrandomTtl=ThreadLocalRandom.current().nextLong(5*60);longfinalTtl=baseTtl+randomTtl;redisTemplate.opsForValue().set(key,value,finalTtl,TimeUnit.SECONDS);这样可以让缓存过期时间分散,避免集中失效。
2.4 解决方案二:热点数据逻辑过期
对于核心热点数据,可以不直接依赖 Redis 的物理过期时间,而是在缓存 value 中保存逻辑过期时间。
当请求发现数据逻辑过期时,不立即删除缓存,而是:
- 先返回旧数据
- 后台异步刷新缓存
- 刷新完成后替换旧缓存
这种方式可以避免热点 key 过期瞬间所有请求都回源数据库。
2.5 解决方案三:多级缓存
可以使用本地缓存加 Redis 的方式降低 Redis 故障影响。
请求 ↓ 本地缓存 ↓ Redis ↓ 数据库当 Redis 出现短暂异常时,本地缓存仍然可以承接一部分读流量。
2.6 解决方案四:限流、熔断和降级
缓存异常时,不能让所有请求都直接访问数据库。可以增加保护措施:
- 对数据库回源请求限流
- 对非核心接口熔断
- 返回默认数据
- 返回旧缓存数据
- 临时关闭非核心功能
2.7 缓存雪崩总结
| 问题 | 解决方式 |
|---|---|
| 大量 key 同时过期 | TTL 加随机值 |
| 热点 key 失效 | 逻辑过期、异步刷新 |
| Redis 故障 | 多级缓存、限流、熔断 |
| 数据库被打满 | 控制回源流量 |
| 活动流量突增 | 提前缓存预热 |
3. 缓存穿透
3.1 什么是缓存穿透
缓存穿透是指请求查询的数据既不在缓存中,也不在数据库中。
由于数据库查不到数据,所以通常不会写入缓存。这样一来,每次请求都会绕过缓存,直接访问数据库。
例如:
- 查询不存在的用户 ID
- 查询不存在的商品 ID
- 恶意构造随机订单号
- 爬虫请求大量非法参数
3.2 缓存穿透的危害
缓存穿透会让缓存层失去保护作用。即使 Redis 正常,请求依然会持续打到数据库。
常见表现包括:
- 缓存命中率下降
- 数据库空查询增多
- 数据库 QPS 异常升高
- 接口响应时间变长
- 非法请求拖垮正常业务
3.3 解决方案一:参数校验
最基础的方式是在请求入口做参数校验,把明显非法的请求挡在数据库之前。
例如:
- ID 必须大于 0
- 分页大小必须有上限
- 枚举值必须合法
- 查询时间范围不能过大
- 业务标识必须符合格式
非法请求不应该继续访问缓存和数据库。
3.4 解决方案二:缓存空值
当数据库查询结果为空时,也把空结果写入缓存,并设置较短过期时间。
publicProductDTOgetProduct(LongproductId){Stringkey="product:detail:"+productId;ProductDTOcacheProduct=redisTemplate.opsForValue().get(key);if(cacheProduct!=null){returncacheProduct.isEmpty()?null:cacheProduct;}ProductDTOdbProduct=productMapper.selectById(productId);if(dbProduct==null){redisTemplate.opsForValue().set(key,ProductDTO.empty(),5,TimeUnit.MINUTES);returnnull;}redisTemplate.opsForValue().set(key,dbProduct,30,TimeUnit.MINUTES);returndbProduct;}空值缓存要注意:
- TTL 不要太长
- 要区分缓存不存在和真实空值
- 数据创建成功后要删除空值缓存
- 防止大量随机 key 占用内存
3.5 解决方案三:布隆过滤器
布隆过滤器可以快速判断一个数据是否一定不存在。
请求流程:
请求进入 ↓ 布隆过滤器判断 ↓ 一定不存在:直接返回 可能存在:继续查询缓存 ↓ 缓存未命中再查询数据库布隆过滤器适合大规模存在性判断,例如:
- 商品 ID 是否存在
- 用户 ID 是否存在
- 订单号是否合法
- 黑名单、白名单判断
它的特点是:
- 查询速度快
- 占用空间小
- 可以判断一定不存在
- 不能判断一定存在
- 存在一定误判率
3.6 缓存穿透总结
| 问题 | 解决方式 |
|---|---|
| 非法参数 | 参数校验 |
| 数据不存在 | 缓存空值 |
| 大量随机 key | 布隆过滤器 |
| 恶意请求 | 限流、黑名单、风控 |
| 空值缓存过多 | 设置短 TTL,控制 key 数量 |
4. 缓存预热
4.1 什么是缓存预热
缓存预热是指在系统正式承接流量之前,提前把核心热点数据加载到缓存中。
如果没有缓存预热,系统刚启动、刚发布或活动刚开始时,缓存命中率会很低,大量请求会直接访问数据库。
4.2 为什么需要缓存预热
缓存预热可以解决冷启动问题。
常见冷启动场景包括:
- 服务刚发布
- Redis 刚重启
- 活动刚开始
- 新功能刚上线
- 缓存被批量清理
- 大促前流量突然进入
如果不提前加载缓存,数据库可能在缓存建立起来之前就已经被打满。
4.3 适合预热的数据
适合预热的数据通常是访问量高、范围可预测的数据。
例如:
- 首页配置
- 热门商品
- 活动商品
- 秒杀库存
- 推荐列表
- 排行榜
- 系统字典
- 权限配置
不适合预热的数据:
- 访问频率很低的数据
- 数据量过大的冷数据
- 变化极其频繁的数据
- 无法提前预测访问范围的数据
4.4 预热方式一:应用启动时预热
服务启动后加载少量核心配置。
@PostConstructpublicvoidwarmUpCache(){List<SystemConfig>configs=configMapper.selectAllEnabled();for(SystemConfigconfig:configs){Stringkey="config:"+config.getCode();redisTemplate.opsForValue().set(key,config,1,TimeUnit.HOURS);}}这种方式适合数据量小、加载速度快的场景。
4.5 预热方式二:定时任务预热
通过定时任务定期刷新缓存。
@Scheduled(cron="0 */10 * * * ?")publicvoidrefreshHotProductCache(){List<ProductDTO>products=productMapper.selectHotProducts();for(ProductDTOproduct:products){Stringkey="product:detail:"+product.getId();redisTemplate.opsForValue().set(key,product,30,TimeUnit.MINUTES);}}适合榜单、热门商品、推荐数据等场景。
4.6 预热方式三:活动前批量预热
在秒杀、大促、直播等场景中,需要在活动开始前提前预热缓存。
预热内容通常包括:
- 活动信息
- 商品详情
- 库存信息
- 价格信息
- 优惠信息
- 限购规则
预热任务要支持重复执行,避免失败后无法重试。
4.7 缓存预热注意事项
缓存预热不是越多越好。
需要注意:
- 控制预热数据范围
- 分批加载,避免瞬间打满 Redis
- 预热失败要有告警
- 预热任务要支持重试
- 预热过程要记录日志
- 避免大量冷数据挤占缓存空间
5. 缓存更新
5.1 为什么缓存更新复杂
缓存更新的核心问题是:数据库和缓存是两个独立系统。
只要存在两个存储系统,就一定会面临一致性问题。例如:
- 数据库更新成功,缓存删除失败
- 缓存更新成功,数据库事务回滚
- 并发读写导致旧数据重新写入缓存
- 多个服务都能修改同一份数据
- 消息异步刷新失败
所以缓存更新不能只看单次操作成功,还要考虑并发、失败和补偿。
5.2 常见策略一:先更新数据库,再删除缓存
这是业务系统中最常见的方案。
@TransactionalpublicvoidupdateUser(UserUpdateRequestrequest){userMapper.updateById(request.toEntity());Stringkey="user:profile:"+request.getUserId();redisTemplate.delete(key);}为什么是删除缓存,而不是更新缓存?
因为数据库是最终数据源。删除缓存后,下一次读取会重新查询数据库并写入缓存。这样可以减少缓存对象组装复杂、字段遗漏、并发覆盖等问题。
5.3 常见策略二:延迟双删
在高并发场景下,可能出现旧数据重新写入缓存的问题。
典型流程:
请求 A 更新数据库 请求 B 查询缓存未命中 请求 B 查询数据库旧数据 请求 A 删除缓存 请求 B 把旧数据写入缓存可以使用延迟双删降低风险:
publicvoidupdateProduct(ProductUpdateRequestrequest){productMapper.updateById(request.toEntity());Stringkey="product:detail:"+request.getProductId();redisTemplate.delete(key);delayQueue.submit(()->redisTemplate.delete(key),500,TimeUnit.MILLISECONDS);}延迟双删不是强一致方案,只是降低并发场景下旧缓存残留的概率。
5.4 常见策略三:消息队列异步刷新
数据库更新成功后发送消息,由消费者异步删除或刷新缓存。
优点:
- 解耦业务逻辑和缓存处理
- 支持失败重试
- 适合跨服务缓存同步
- 可以记录消费状态
缺点:
- 存在短暂延迟
- 依赖消息可靠性
- 需要处理重复消费
- 需要补偿机制
5.5 常见策略四:监听数据库变更
可以通过 Binlog 或 CDC 监听数据库变更,然后自动删除或刷新缓存。
适合以下场景:
- 多个服务都能修改同一张表
- 缓存更新入口很多
- 很难在业务代码中覆盖所有变更点
- 需要统一处理缓存失效
5.6 缓存更新建议
| 场景 | 推荐方案 |
|---|---|
| 普通业务数据 | 更新数据库后删除缓存 |
| 高并发热点数据 | 删除缓存加互斥锁或逻辑过期 |
| 跨服务数据变更 | 消息队列或 CDC |
| 强一致数据 | 不依赖缓存作为最终结果 |
| 更新频繁数据 | 缩短 TTL,减少复杂缓存 |
| 删除失败 | 重试、补偿、告警 |
5.7 缓存 Key 设计建议
好的 key 设计可以降低缓存更新难度。
建议:
- key 中包含业务域
- key 中包含唯一 ID
- 命名规则统一
- 避免超长 key
- 避免把不稳定参数拼进 key
- 批量缓存要考虑局部失效问题
示例:
user:profile:{userId} product:detail:{productId} activity:sku:{activityId}:{skuId} config:global:{configKey}6. 缓存降级
6.1 什么是缓存降级
缓存降级是指当缓存系统异常、数据库压力过高或接口响应变慢时,系统主动降低部分功能或数据实时性要求,优先保证核心链路可用。
降级的本质是取舍:异常情况下,不追求所有功能都完整,而是优先保证系统不被拖垮。
6.2 什么时候需要缓存降级
常见触发场景包括:
- Redis 连接超时
- Redis 集群故障
- 缓存命中率突然下降
- 数据库连接池接近打满
- 核心接口响应时间明显升高
- 活动流量超过预估
- 下游服务不可用
6.3 降级方式一:返回兜底数据
当缓存和数据库都不可用时,可以返回默认数据或上一次成功的数据。
适合场景:
- 首页推荐
- 活动配置
- 排行榜
- 商品非核心字段
- 用户展示信息
不适合场景:
- 支付金额
- 账户余额
- 订单状态
- 库存扣减
- 权限核心判断
6.4 降级方式二:本地缓存兜底
业务服务可以保存最近一次成功读取的数据。当 Redis 出现异常时,短时间内返回本地缓存。
publicConfigDTOgetGlobalConfig(){try{ConfigDTOconfig=redisTemplate.opsForValue().get("config:global");if(config!=null){localCache.put("config:global",config);returnconfig;}}catch(Exceptione){ConfigDTOlocalConfig=localCache.getIfPresent("config:global");if(localConfig!=null){returnlocalConfig;}}returndefaultConfig();}6.5 降级方式三:限制数据库回源
缓存异常时,最危险的是所有请求直接打到数据库。
可以对数据库回源做限流:
- 只允许少量请求访问数据库
- 其他请求返回兜底数据
- 非核心请求快速失败
- 热点接口返回旧数据
这样可以保护数据库不被瞬间打满。
6.6 降级方式四:关闭非核心功能
在极端情况下,可以临时关闭部分非核心能力。
例如:
- 关闭个性化推荐
- 关闭实时排行榜
- 关闭复杂筛选
- 关闭非核心统计
- 关闭部分营销模块
- 降低页面展示模块数量
6.7 降级方式五:配置开关控制
降级能力应该通过配置中心动态控制,而不是临时改代码发布。
常见开关包括:
- 是否启用本地缓存兜底
- 是否关闭某个页面模块
- 是否限制数据库回源
- 是否返回默认配置
- 是否启用只读模式
- 是否缩短接口超时时间
6.8 缓存降级原则
缓存降级需要提前设计,不能等故障发生后再临时补。
核心原则:
- 核心链路优先
- 非核心功能可牺牲
- 读请求可以返回旧数据
- 写请求要谨慎降级
- 资金、订单、权限类数据不能随意兜底
- 降级行为必须有日志
- 降级开关必须可快速恢复
6.9 总结
分布式缓存可以显著提升系统性能,但它不是简单地加一个 Redis。真正稳定的缓存系统,需要同时考虑缓存雪崩、缓存穿透、缓存预热、缓存更新和缓存降级。
整体来看:
| 章节 | 核心问题 | 关键方案 |
|---|---|---|
| 1. 分布式缓存 | 如何用缓存提升系统性能 | Cache Aside、多级缓存、合理 TTL |
| 2. 缓存雪崩 | 大量缓存同时失效 | TTL 随机化、逻辑过期、限流熔断 |
| 3. 缓存穿透 | 查询不存在的数据 | 参数校验、空值缓存、布隆过滤器 |
| 4. 缓存预热 | 冷启动时缓存命中率低 | 启动预热、定时预热、活动前预热 |
| 5. 缓存更新 | 数据库和缓存不一致 | 更新数据库后删除缓存、MQ、CDC |
| 6. 缓存降级 | 缓存异常时保护核心链路 | 兜底数据、本地缓存、限流、开关控制 |
一个成熟的分布式缓存方案,应该具备高命中率、可控一致性、故障保护能力、清晰的更新策略和可快速启停的降级能力。缓存设计得好,系统会更快、更稳;缓存设计不好,它也可能成为放大故障的关键因素。