news 2026/10/1 10:45:15

后端缓存实战:Redis穿透、击穿、雪崩与一致性方案

作者头像

张小明

前端开发工程师

1.2k 24
文章封面图
后端缓存实战:Redis穿透、击穿、雪崩与一致性方案

1. 缓存到底帮后端扛住了什么

做后端这些年,缓存数据应该是我最常打交道的技术之一了。不管你是刚入门准备面试,还是已经维护了几个老项目,只要系统一有性能问题,第一反应基本都是“加缓存”。这个思路本身没错,但缓存不是万能的,用不好反而会引入一堆新麻烦。

这篇笔记我会从后端工程师日常干活的角度,把缓存的原理、选型、经典故障、数据一致性、实战写法以及踩过的坑一次性说清楚。内容偏向 Java 后端 + Redis 这条技术线,但核心思路对所有后端语言都通用,无论你是正在学后端的初学者,还是被线上问题折磨的老手,都能在这里找到点有用的东西。

先解释一个容易混淆的点:这里的“后端”指的是服务端软件开发,不是芯片设计领域那个数字后端。有些热词里提到的“数字后端”是 IC 设计流程里的概念,跟本文的缓存数据不是一回事,大家别串台了。

1.1 没有缓存的系统每天都在重复劳动

我见过不少团队,系统上线初期数据量小,用户少,所有请求都直接查数据库,一切岁月静好。等到用户量上来,数据库连接开始告警,CPU 飙升,接口响应从几十毫秒变成几秒钟,这时候大家才意识到:系统里大部分查询其实都在做重复劳动。

举个例子,一个电商系统的首页商品推荐,一万个用户请求过来,每个请求都执行同样的 SQL,从数据库里读出同样的数据,这其实就是浪费。数据库做一次查询可能要 5 到 20 毫秒,听起来不算慢,但并发一高,数据库的连接池被占满,新的请求只能排队,接口自然就慢了。

缓存的核心价值就是用内存换取数据库的解脱。内存的读取速度是纳秒级别,通常比数据库查询快一到两个数量级,把高频读取的数据放在内存里,让绝大多数请求根本不触达数据库,数据库的压力就能大幅下降。

1.2 缓存的标准读取流程

缓存的读取策略其实非常朴素,就三步:

  1. 请求进来,先去缓存查数据。
  2. 缓存命中了,直接把数据返回给调用方。
  3. 缓存没命中,去数据库查,然后把结果写回缓存,再返回。

这个流程看起来简单,但里面隐藏了一个关键点:缓存未命中的那一刻,其实是风险最高的窗口期。如果突然有一大堆请求同时未命中,数据库会被瞬间打爆,这就是后面要说的击穿和雪崩问题。

用生活场景类比的话,缓存就像是厨房里的备菜柜。客人点菜的时候,厨师先看备菜柜里有没有切好的菜,有就直接下锅,没有才去冰箱拿原材料现场处理。备菜柜的存在让出菜速度快了很多,但备菜柜里的菜会变质,需要定期更换,这就是缓存的过期时间。

2. 方案选型:本地缓存还是分布式缓存

很多后端新人第一次接触缓存就直接上 Redis,但 Redis 不是唯一的答案。不同场景下,选型逻辑完全不一样。

2.1 本地缓存为什么不够用

本地缓存就是跑在应用进程内部的缓存,Java 生态里常见的有 Caffeine、Guava Cache、Ehcache。本地缓存的优势是极致的快,因为数据就在当前进程里,连网络 IO 都省了,读取速度是纯内存操作。

但本地缓存的短板也很明显:

  • 多实例部署时数据不一致。两台服务器各自缓存一份数据,其中一台更新了,另一台还是旧数据。
  • 内存浪费。每个实例都存一份同样的热点数据,内存整体利用率低。
  • 无法跨实例共享。一个用户请求被负载均衡分到了 A 实例,后续操作被分到了 B 实例,B 实例里没有缓存,又得重新查一次数据库。

当然,本地缓存也不是一无是处。我见过不少项目把本地缓存用在一些几乎不变的系统配置上,比如字典数据、静态规则,每一台机器只需要在启动时加载一次,之后本地读取就行。这种场景用本地缓存非常合适。

2.2 分布式缓存为什么主流选 Redis

一旦系统需要多实例共享缓存,就得引入分布式缓存。市面上可选的有 Redis、Memcached、Tair 等,但目前主流就是 Redis。

Redis 能成为标配,主要有几个原因:

  • 数据结构丰富。String、Hash、List、Set、ZSet 都能存,业务适配性很强。
  • 自带过期机制。TTL 设置非常方便,这是很多缓存场景的刚需。
  • 持久化选项灵活。虽然缓存不是数据库,但 Redis 提供 RDB 和 AOF 两种持久化方式,可以在性能和可靠性之间取舍。
  • 高可用方案成熟。主从复制、哨兵模式、集群模式都有成熟的实践。

我做了一个简单的对比表,方便新手理解本地缓存和 Redis 的定位:

对比维度本地缓存(Caffeine)分布式缓存(Redis)
读取速度极快,无网络开销快,但有网络传输成本
数据一致性多实例不一致全局基本一致
容量上限受单机内存限制可横向扩展
故障影响进程挂了缓存没了需要高可用方案防止单点
适用场景单机配置、极小热点数据共享热点数据、锁、计数、会话

2.3 我常用的组合方式

实际项目中,我经常采用两级缓存的思路:一级本地缓存挡住超高频率的小范围热点,二级 Redis 缓存共享全局限的热点数据。

例如用户维度的基础信息,同一个用户在一段时间内会频繁访问,但单个用户的访问频率又不足以让本地缓存产生内存压力。这时候本地缓存放最近访问的少量用户数据,Redis 放全量热点同数据,读取时先查本地,再查 Redis,最后查数据库。

两级缓存的复杂度在于更新时要同时失效两层缓存,一旦没处理好,可能出现本地缓存还是旧数据、Redis 已是新数据的怪现象。所以我一般只在数据实时性要求不高的场景使用两级缓存,比如一周不变化的配置项、标签数据等。实时性要求高的业务,直接用 Redis 单级缓存就够了。

3. 三个经典故障:穿透、击穿、雪崩

这几乎是后端缓存面试必问的题,也是线上系统最容易出事故的场景。很多公司都经历过某一刻数据库突然被打爆,追查下来基本都是这三类问题的变种。

3.1 缓存穿透:查了个不存在的数据

缓存穿透指的是请求的数据在缓存和数据库中都不存在,导致每次请求都直接打到了数据库。

举个例子,一个商城系统,用户查询订单详情。如果攻击者伪造了一批不存在的订单号,比如负数订单号、超长订单号,系统先去缓存查,缓存没有,再去数据库查,数据库也没有。正常情况下这是无害的,但如果攻击者用脚本高频发起请求,每一次都会穿透缓存直达数据库,数据库很快就会被拖垮。

应对穿透的主流方案有三个:

  • 缓存空值。数据库查询结果为 null 时,也在缓存里存一份空值,并设置一个较短的过期时间,比如 5 分钟。这样后续同样的请求会在缓存里直接拿到空值,不再打到数据库。
  • 布隆过滤器。把可能存在的数据 ID 提前写入布隆过滤器,请求进来先判断 ID 是否在集合内,不在就直接返回,根本不查数据库。布隆过滤器的缺点是存在误判率,而且标准版不支持删除。
  • 参数校验。在接口入口校验参数的合法性,比如 ID 必须为正整数,不合法直接拒绝。

我自己的习惯是缓存空值为主、参数校验为辅。布隆过滤器虽然性能好,但维护成本偏高,数据量不大时没必要上。缓存空值要注意一个问题:空值也要设置过期时间,否则会堆积大量空 key 占用内存。

3.2 缓存击穿:一个热点 key 过期

缓存击穿和穿透很容易混淆。击穿指的是一个热点 key 在过期的瞬间,大量请求同时找不到缓存,全部打到数据库。

新闻热点场景最典型。一条爆炸性新闻同时被千万用户访问,缓存里存了这条新闻的数据,过期时间到了,刚好缓存被清掉,下一瞬间所有用户都来请求,缓存全部未命中,数据库直接被压垮。

解决击穿的核心思路就两个方向:

  • 互斥锁。缓存未命中时,先获取分布式锁,只有拿到锁的请求才允许查数据库,其他请求等待一段时间后重新查缓存。这种方式能保证同一时刻只有一个请求在查库,但实现上要小心死锁和等待超时。
  • 逻辑过期。缓存中存的值不依赖 Redis 的 TTL,而是在 value 里额外存一个过期时间戳。读取时如果发现逻辑时间已过期,就尝试获取互斥锁,拿到锁的请求去后台重建缓存并返回旧值,拿不到锁的请求直接返回旧值或短暂等待后重试。这种方式在秒杀场景下很常用,因为用户几乎无感知。

互斥锁和逻辑过期的对比其实很明显:互斥锁实现简单但可能会阻塞请求,逻辑过期吞吐量高但实现复杂,而且数据短暂不一致。小项目我建议直接用互斥锁,大并发场景再考虑逻辑过期方案。

3.3 缓存雪崩:大量 key 同时失效

雪崩和击穿的区别在于范围。击穿是单个热点 key,雪崩是一大批 key 在同一时刻集体失效,或者 Redis 本身挂了。

想象一下,业务上线时开发图省事,把所有商品的缓存过期时间都设置成了同一个值,比如 24 小时后一整批商品数据全部过期。如果系统刚好在那时迎来访问高峰,每条商品数据都未命中缓存,所有请求一起涌入数据库,数据库瞬间就崩了。

应对雪崩,我常用的手段有:

  • 过期时间加随机值。比如基础过期时间 1 小时,再加 0 到 10 分钟的随机数,让每个 key 的过期时间分散开,避免集体失效。
  • Redis 高可用。哨兵或集群模式保证 Redis 不轻易宕机。
  • 服务降级和熔断。数据库扛不住时,直接返回降级数据或错误提示,保证系统不整体瘫痪。

这三种手段可以同时用,而且在大型系统里几乎都是标配。不要只做其中一种,因为现实中故障往往是组合出现的。

4. 数据一致性:到底该写缓存还是删缓存

缓存和数据库双写的一致性,是后端工程里讨论最多的问题之一。不少团队在产品初期都想过“先更新数据库,再把新数据写进缓存”这种方案,但实际上我强烈不推荐。

4.1 Cache Aside 模式

业界最经典的模式是 Cache Aside,规则就是两句话:读时先读缓存,缓存没有读数据库再回填缓存;写时先更新数据库,然后删除缓存。

为什么不是更新缓存而是删除缓存?原因在于更新缓存存在两个隐患:

  • 并发写后读可能读到旧值。写请求 A 更新数据库为新值,写请求 B 也更新数据库为另一个新值,如果 A 后写缓存、B 先写缓存,缓存里可能留下旧值。
  • 一些写后的值根本不会被读。如果某个业务字段频繁更新但很少读取,每次更新都写缓存纯属浪费。

删除缓存的意义在于,让下一次读取时发现未命中,再从数据库拉最新值回填。这样设计虽然简单,但能保证数据最终一致。

4.2 延迟双删

Cache Aside 在低并发下很好用,但在并发读写下有一个经典问题:读请求 A 查数据库拿到旧值,还没写回缓存时,写请求 B 更新数据库并删除缓存,然后读请求 A 把旧值写回缓存,导致缓存里长期存着旧数据。

解决这个问题的常用手段叫延迟双删。流程是:

  1. 更新数据库。
  2. 删除缓存。
  3. 休眠一小段时间,比如 500 毫秒。
  4. 再删一次缓存。

第一次删除是为了清掉缓存中的旧数据,第二次删除是为了清掉“旧读请求回填的脏数据”。中间休眠的这段时间,就是为了让并发读请求完成回填操作。

延迟双删有个隐患:如果缓存里根本还没数据,休眠时间就白等了,白白增加接口耗时。所以有些实现会改成异步延迟双删,通过 MQ 或其他异步机制触发第二次删除,不阻塞主线程。

4.3 最终一致与 binlog 订阅

如果要追求更强的最终一致性,可以在数据库层面做文章。用 Canal 这类工具订阅 MySQL 的 binlog,数据库数据变更后,binlog 里有记录,订阅方根据变更内容主动删除对应缓存。

这种方案的优点是代码侵入性小,业务代码里不需要写一串删除逻辑。缺点是需要额外部署 Canal 和 MQ,运维成本上去了。中小项目我不建议一上来就上这套,Cache Aside 加延迟双删已经能应付绝大多数场景。

说句实在话,后端工程师追求的数据一致性永远是“最终一致”,而不是“实时一致”。在缓存和数据库之间,想做到任何时刻完全一致,代价会大到不现实。很多团队最后都接受了“短暂不一致可以容忍,但不能长时间不一致”的原则。

5. 实战:Spring Boot + Redis 做热点数据缓存

理论知识聊完了,讲讲实际怎么落地。这里我以 Java 后端 + Spring Boot + Redis 为例,这是目前中小企业后端项目最常见的组合之一。

5.1 环境准备与配置

在 Spring Boot 项目里引入 Redis 非常简单,pom 里加依赖:

<dependency> <groupId>org.springframework.boot</groupId> <artifactId>spring-boot-starter-data-redis</artifactId> </dependency>

application.yml 里的配置:

spring: redis: host: 127.0.0.1 port: 6379 password: timeout: 3000ms lettuce: pool: max-active: 8 max-idle: 8 min-idle: 0

这里有个很常见的坑:Spring Boot 默认的 RedisTemplate 使用的序列化器是 JDK 序列化,直接存对象会导致 Redis 里存的数据是一堆二进制乱码,既没法在 Redis 客户端里直观查看,跨语言读取也会出问题。

我的做法是自定义一个 StringRedisTemplate 专门存 String 结构的数据,或者自定义 RedisTemplate 并指定 Jackson 序列化器,让 key 用 String 序列化、value 用 JSON 序列化。这样存进去的是可读的 JSON,排查问题方便得多。

5.2 缓存注解还是手动缓存

Spring 提供了一套声明式缓存注解,接口上加 @Cacheable 就能自动缓存返回值。用起来很方便,但坑也不少。

一个典型的例子:

@Cacheable(value = "product", key = "#id", unless = "#result == null") public Product getProductById(Long id) { return productMapper.selectById(id); }

这注解有几个地方新手容易踩坑:

  • key 的 SpEL 表达式写错,导致缓存 key 不符合预期。
  • 默认没有全局过期时间,缓存会永久存下去。
  • 方法内部调用时,注解不生效,因为 Spring 的动态代理不会拦截同类内部调用。
  • 返回值必须能序列化,否则会报错。

我在实际项目中更偏向手动操作 RedisTemplate,因为思路更显式。比如这样:

public Product getProductById(Long id) { String cacheKey = "product:detail:" + id; Object cached = redisTemplate.opsForValue().get(cacheKey); if (cached != null) { return JSON.parseObject(cached.toString(), Product.class); } Product product = productMapper.selectById(id); if (product != null) { redisTemplate.opsForValue().set(cacheKey, JSON.toJSONString(product), 30, TimeUnit.MINUTES); } return product; }

手动缓存的好处在于每一步都在你的掌控里,尤其是缓存过期时间、空值缓存、异常处理,这些用注解反而容易藏问题。当然,如果团队规范做得好,注解确实能让代码更简洁,这是个取舍问题。

5.3 在若依这类框架里看到的缓存用法

如果你用过若依框架,也就是 RuoYi,会发现它封装了一个 RedisCache 工具类,里面就是各种 getCacheObject、setCacheObject、deleteObject。这个工具类本质上是对 RedisTemplate 做了一层薄封装,把序列化和过期时间统一处理了。

这个思路值得借鉴。实际项目中我也喜欢把缓存的 key 前缀、过期时间等集中管理,而不是散落在业务代码里。比如统一建一个 CacheConstants 常量类:

public class CacheConstants { public static final String LOGIN_TOKEN_KEY = "login_tokens:"; public static final String PRODUCT_KEY = "product:detail:"; public static final long DEFAULT_EXPIRE = 30L; }

这样一来,改前缀或者调过期时间都只动一个文件,不会满项目找魔法值。

另外,前后端分离的项目里,登录 token 存 Redis 是标配做法,这在若依里体现得很明显。用户登录后,服务端生成一个随机 token,把用户信息和权限数据存进 Redis,设置过期时间,后续请求带上 token 就能在 Redis 里直接查到会话信息,不用每次去数据库查用户表。这种做法既能实现会话管理,也能顺便控制登录状态的有效期。

再往深一步说,前后端分离场景下,按钮重复提交问题也常借助 Redis 解决。比如提交订单时,前端把订单号带过来,后端先用 SETNX 在 Redis 里加锁,如果 key 已经存在说明正在处理中,直接拒绝重复提交。这就是 Hessian 锁或者说分布式锁的一种应用,底层依赖的同样是缓存数据。

6. 缓存实战中的坑与排查心得

缓存好不好用,用一阵子就露馅了。下面这些坑是我自己在项目中真实踩过的,每一条都对应过线上问题。

6.1 Key 设计规范

缓存 key 的设计是个看起来小、影响很大的事。我见过有人直接用 userId 当 key,也见过直接把整个请求参数拼进去当 key,结果就是 cache 命中率极低,或者不同业务之间互相覆盖。

我的习惯是:业务模块名 + 主体标识 + 具体字段,用冒号分隔。例如:

product:detail:1001 user:profile:9527 order:list:1001:page:1

这样设计有几个好处:可读性强,Redis 客户端里一眼能看出属于什么业务;方便批量删除,比如要清掉某用户所有缓存,可以直接用user:profile:9527*模式匹配删除。

key 的长度也要控制。Redis 的 key 越短越好,几百字节的 key 会浪费内存和网络带宽。

6.2 大 Key 和热 Key

Redis 里有两个经典故障模式,做后端的必须会识别。

大 key 指的是某个 key 的 value 非常大,比如一个 list 存了几百万个元素,或者一个字符串有几 MB。大 key 的危害在于:存取时网络传输时间长,Redis 单线程模型下会阻塞其他命令的执行,迁移或扩容时也可能导致卡顿。

排查方法很简单,Redis 自带的--bigkeys参数可以直接扫描大 key:

redis-cli --bigkeys

热 key 指的是某个 key 被超高频率访问,Redis 单实例的 CPU 被打满,影响同实例上其他 key 的访问。解决热 key 的手段有:给 key 拼接随机后缀分散到多个实例、加本地缓存、限流控制。

6.3 缓存过期时间别拍脑袋

给缓存设置多长的过期时间,看似随意,其实有讲究。设太短,缓存效果差,数据库压力没减下去;设太长,数据不及时更新,用户看到旧数据。

我的经验是:过期时间取决于业务能够接受多长的数据延迟。比如商品库存数量,延迟 5 秒都可能导致超卖纠纷,那就不适合加缓存,或者只缓存极短时间。而商品介绍、类目名称这类信息,延迟 30 分钟用户感知不到,就可以放心设长一点。

还有一个技巧:热点大促期间,可以把正常过期时间缩短,动态调整为平时的三分之一,避免大量 key 集中在高峰时段过期。配合上文的随机值方案,能有效降低雪崩风险。

6.4 敏感数据不要直接进缓存

这条很多人容易忽略。缓存里存数据时,一定要思考数据本身是否敏感。用户手机号、身份证号、密码摘要这类隐私数据,哪怕 Redis 部署在内网,也尽量别直接缓存放明文。

我见过一个项目,为了省事把用户完整信息对象整体存进了缓存,包括手机号和邮箱。后来 Redis 被拖库,泄露出来的就是一大批明文隐私数据。之后我养成了习惯,缓存里只存必要字段,像手机号这类的显示,宁可查一次数据库,也不放进缓存。

这不仅是技术问题,也是合规和道德问题。后端工程师做技术决策时,数据安全这条底线必须守住。

7. 最后分享一点个人体会

做了这么多年后端,我对缓存有一个越来越深的体会:缓存是性能优化里的第一道防线,但永远不是唯一的防线。一个系统如果只能靠缓存续命,说明基础架构和数据访问层本身就有问题。

我见过一些老项目,内存里塞了几百个缓存 key,从配置到业务数据什么都有,过期时间也乱七八糟。后来排查问题时发现,很多缓存根本没生效,反而增加了代码复杂度和内存压力。缓存和收拾电脑磁盘上的临时文件有点像——长时间不清理,系统只会越来越臃肿,定期审查缓存里的 key 是否还需要保留、过期时间是否合理,是非常必要的维护动作。

对于刚开始学后端的同学,我的建议是先把这个项目的缓存代码读一遍,问自己三个问题:缓存被谁写、何时过期、缓存失效后从哪里回填。把这三个问题理清楚,你对这个系统的缓存设计就比多数人都明白了。写代码的时候,也永远默认“缓存会丢、Redis 会挂、数据会不一致”,在这个前提下做设计,才不容易翻车。

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

绿幕虚拟直播全攻略:抠像原理、设备布光与OBS实战指南

1. 绿幕虚拟直播到底在火什么1.1 先搞清楚什么是虚拟直播这两年直播行业变化太快&#xff0c;但“绿幕虚拟直播”并不是一个新鲜概念。说白了&#xff0c;就是用一块绿色背景布/墙把人拍下来&#xff0c;再用软件把绿色去掉&#xff0c;把主播“抠”出来&#xff0c;放到任意一…

作者头像 李华
网站建设 2026/10/1 10:43:24

Python+LangGraph构建可运维AI工作流实战指南

1. 这不是写代码&#xff0c;是给AI装上业务大脑的实操手册“Python Agent SDK&#xff1a;把业务场景转化为自动化工作流的完整流程”——这句话乍看像技术文档标题&#xff0c;但在我过去三年带团队落地27个AI Agent项目的过程中&#xff0c;它真正意味着&#xff1a;用Pyth…

作者头像 李华
网站建设 2026/10/1 10:43:03

Bagging回归预测实战:从集成学习原理到小样本仿真数据应用

做回归预测这些年&#xff0c;我经常碰到一个类似的问题&#xff1a;明明单模型在训练集上已经跑出很高的分数&#xff0c;可一旦换到新数据上&#xff0c;预测结果就像过山车一样忽高忽低&#xff0c;方差大到让人怀疑人生。直到我把Bagging这套集成模型真正用到数据回归预测里…

作者头像 李华
网站建设 2026/10/1 10:42:14

SpringBoot多数据源配置实战:MySQL与SqlServer动态切换与MyBatisPlus分页

我第一次把 SpringBoot 跑多数据源&#xff0c;是在一个制造业项目里接报表系统时。订单实时数据在 MySQL&#xff0c;供应商档案在集团老平台的 SqlServer&#xff0c;两边业务还要经常联查。当时领导轻描淡写说了句“加个数据源就行”&#xff0c;我实际折腾了好几天才把路由…

作者头像 李华
网站建设 2026/10/1 10:41:54

国庆|万家同庆日,山河共欢歌

七十七载栉风沐雨&#xff0c;七十七载砥砺前行。 今日之中国&#xff0c;山河壮丽&#xff0c;气象万千。 祖国的每一步发展&#xff0c; 都凝聚着亿万奋斗者的坚守与付出。 盛世华诞&#xff0c;举国同庆。 祝愿伟大祖国繁荣昌盛&#xff01;

作者头像 李华