1. 缓存到底帮后端扛住了什么
做后端这些年,缓存数据应该是我最常打交道的技术之一了。不管你是刚入门准备面试,还是已经维护了几个老项目,只要系统一有性能问题,第一反应基本都是“加缓存”。这个思路本身没错,但缓存不是万能的,用不好反而会引入一堆新麻烦。
这篇笔记我会从后端工程师日常干活的角度,把缓存的原理、选型、经典故障、数据一致性、实战写法以及踩过的坑一次性说清楚。内容偏向 Java 后端 + Redis 这条技术线,但核心思路对所有后端语言都通用,无论你是正在学后端的初学者,还是被线上问题折磨的老手,都能在这里找到点有用的东西。
先解释一个容易混淆的点:这里的“后端”指的是服务端软件开发,不是芯片设计领域那个数字后端。有些热词里提到的“数字后端”是 IC 设计流程里的概念,跟本文的缓存数据不是一回事,大家别串台了。
1.1 没有缓存的系统每天都在重复劳动
我见过不少团队,系统上线初期数据量小,用户少,所有请求都直接查数据库,一切岁月静好。等到用户量上来,数据库连接开始告警,CPU 飙升,接口响应从几十毫秒变成几秒钟,这时候大家才意识到:系统里大部分查询其实都在做重复劳动。
举个例子,一个电商系统的首页商品推荐,一万个用户请求过来,每个请求都执行同样的 SQL,从数据库里读出同样的数据,这其实就是浪费。数据库做一次查询可能要 5 到 20 毫秒,听起来不算慢,但并发一高,数据库的连接池被占满,新的请求只能排队,接口自然就慢了。
缓存的核心价值就是用内存换取数据库的解脱。内存的读取速度是纳秒级别,通常比数据库查询快一到两个数量级,把高频读取的数据放在内存里,让绝大多数请求根本不触达数据库,数据库的压力就能大幅下降。
1.2 缓存的标准读取流程
缓存的读取策略其实非常朴素,就三步:
- 请求进来,先去缓存查数据。
- 缓存命中了,直接把数据返回给调用方。
- 缓存没命中,去数据库查,然后把结果写回缓存,再返回。
这个流程看起来简单,但里面隐藏了一个关键点:缓存未命中的那一刻,其实是风险最高的窗口期。如果突然有一大堆请求同时未命中,数据库会被瞬间打爆,这就是后面要说的击穿和雪崩问题。
用生活场景类比的话,缓存就像是厨房里的备菜柜。客人点菜的时候,厨师先看备菜柜里有没有切好的菜,有就直接下锅,没有才去冰箱拿原材料现场处理。备菜柜的存在让出菜速度快了很多,但备菜柜里的菜会变质,需要定期更换,这就是缓存的过期时间。
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 把旧值写回缓存,导致缓存里长期存着旧数据。
解决这个问题的常用手段叫延迟双删。流程是:
- 更新数据库。
- 删除缓存。
- 休眠一小段时间,比如 500 毫秒。
- 再删一次缓存。
第一次删除是为了清掉缓存中的旧数据,第二次删除是为了清掉“旧读请求回填的脏数据”。中间休眠的这段时间,就是为了让并发读请求完成回填操作。
延迟双删有个隐患:如果缓存里根本还没数据,休眠时间就白等了,白白增加接口耗时。所以有些实现会改成异步延迟双删,通过 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 会挂、数据会不一致”,在这个前提下做设计,才不容易翻车。