分布式缓存
分布式缓存通过 Redis 等系统解决本地缓存无法共享和可扩展性差的问题,典型读流程是先查缓存、未命中再查数据库并写回缓存;同时需要通过缓存空值和布隆过滤器防止缓存穿透,通过随机过期时间和 Redis 高可用防止缓存雪崩,并采用“先更新数据库,再删除缓存”的策略尽量保证缓存与数据库最终一致。
一、核心主线
本节讲的是高并发读场景的第三种通用方案:分布式缓存。
本地缓存性能很高,但存在明显限制:
- 多个服务进程之间无法共享;
- 与编程语言和进程绑定;
- 服务进程携带缓存数据后变成有状态服务,可扩展性变差;
- 服务进程重启后,本地缓存全部丢失。
因此,需要一种支持多进程共享、语言无关、可扩展、可持久化的缓存系统,这就是分布式缓存。
典型链路:
1 | 读请求 |
二、分布式缓存选型
1. Memcached 与 Redis
主流分布式缓存包括:
- Memcached;
- Redis。
二者都可以实现缓存数据共享,并且与编程语言无关。
但在互联网应用中,Redis 更常见。
2. Redis 更流行的原因
| 能力 | Memcached | Redis |
|---|---|---|
| 数据类型 | 主要支持字符串 | 支持 String、List、Set、Hash、Sorted Set 等 |
| 数据持久化 | 不支持 | 支持 RDB 和 AOF |
| 高可用 | 能力较弱 | 支持主从复制、主从切换 |
| 分布式能力 | 依赖客户端一致性哈希 | 支持 Redis Cluster,也有 Codis、Twemproxy 等方案 |
Redis 的优势可以概括为:
- 数据结构丰富;
- 可持久化;
- 高可用能力较强;
- 可扩展能力较强。
因此,Redis 通常是互联网应用分布式缓存的首选。
三、Redis 缓存基本使用方式
1. 典型读取流程
使用 Redis 缓存时,常见逻辑如下:
1 | 查询 Redis |
这种模式可以让热点数据尽量停留在 Redis 中,减少数据库读请求压力。
2. 为什么要设置过期时间
缓存数据必须设置合适的过期时间。
主要原因有两个。
2.1 避免无效数据长期占用内存
如果缓存数据永不过期,访问频率极低甚至不再访问的数据会一直占用 Redis 内存。
设置过期时间后:
- 不再访问的数据会自动删除;
- 经常访问的数据可以通过刷新过期时间继续保留;
- Redis 内存可以被更有效利用。
2.2 作为数据不一致的兜底机制
数据库和 Redis 之间可能因为故障、并发或更新失败产生短暂不一致。
如果缓存过期时间是 10 秒,那么即使发生不一致,最多也只持续约 10 秒。
1 | 缓存与数据库不一致 |
过期时间可以帮助系统实现最终一致性兜底。
四、缓存穿透
1. 什么是缓存穿透
缓存穿透指的是:请求访问的数据既不在 Redis 中,也不在数据库中。
流程:
1 | 请求非法数据 |
如果黑客持续请求不存在的数据,所有请求都会绕过缓存直接访问数据库,可能导致数据库被压垮。
2. 缓存空值
一种解决方式是:当数据库中也找不到数据时,在 Redis 中缓存一个空值。
1 | 数据库不存在 |
优点:
- 实现简单;
- 可以阻止对同一条非法数据的重复数据库访问。
缺点:
- 如果攻击者请求大量不同非法 Key,会在 Redis 中写入大量空值;
- 无用空值可能挤掉正常缓存;
- 缓存命中率下降;
- 数据库仍然可能面临风险。
3. 布隆过滤器
布隆过滤器适合解决大量非法 Key 导致的缓存穿透。
它由两部分组成:
1 | 固定长度为 m 的二进制向量 |
3.1 写入数据
当某个数据加入布隆过滤器:
- 使用 k 个哈希函数计算 k 个哈希值;
- 每个哈希值对 m 取模;
- 将二进制向量中对应的 k 个位置设置为 1。
3.2 查询数据
查询某数据是否存在时:
- 使用相同 k 个哈希函数计算位置;
- 检查二进制向量对应位置。
判断规则:
| 检查结果 | 结论 |
|---|---|
| 任意位置为 0 | 数据一定不存在 |
| 所有位置为 1 | 数据可能存在 |
布隆过滤器会有“误判存在”的可能,但不会把不存在的数据误判为一定不存在。
4. 布隆过滤器防穿透流程
1 | 请求数据 |
如果数据库仍查不到,再在 Redis 中写入空值。
这样可以大幅减少非法请求对数据库的访问。
五、缓存雪崩
1. 什么是缓存雪崩
缓存雪崩指的是:大量缓存数据在同一时间失效,导致请求同时涌向数据库。
1 | 大量缓存同时过期 |
缓存雪崩和缓存穿透的区别:
| 问题 | 原因 |
|---|---|
| 缓存穿透 | 请求的数据不存在,绕过缓存打到数据库 |
| 缓存雪崩 | 大量缓存同时失效,请求集中打到数据库 |
2. 诱因一:大量数据过期时间相同
如果大量缓存设置了相同过期时间,就可能在某一时刻同时失效。
解决方案:
设置过期时间时增加小范围随机值。
例如:
1 | 基础过期时间:10 分钟 |
这样可以让缓存过期时间分散,避免同一时刻大面积失效。
3. 诱因二:Redis 服务宕机
如果 Redis 整体不可用,所有读请求都会回源数据库。
解决方案:
- 使用高可用 Redis 集群架构;
- 采用主从、哨兵或 Redis Cluster;
- 降低 Redis 单点故障概率;
- 必要时配合限流和降级保护数据库。
六、缓存更新问题
1. 为什么缓存更新复杂
当数据库中的数据发生变化时,缓存也需要随之变化。
看似只有两步:
1 | 更新数据库 |
但实际会遇到很多问题:
- 先更新数据库还是先更新缓存?
- 修改缓存数据,还是直接删除缓存?
- 并发读写是否会导致数据不一致?
- 第一步成功、第二步失败时怎么办?
本节分析了四种方案。
2. 方案一:先修改缓存,再更新数据库
流程:
1 | 修改缓存 |
问题:
- 并发写请求可能导致缓存和数据库顺序不一致;
- 如果缓存修改成功但数据库更新失败,需要回滚缓存;
- Redis 不支持类似数据库事务的完整回滚能力。
结论:不推荐。
3. 方案二:先更新数据库,再修改缓存
流程:
1 | 更新数据库 |
问题:
并发写请求可能出现顺序错乱。
例如:
1 | 请求 A:数据库写 b |
结果:
1 | 数据库 = c |
缓存变成旧值。
结论:不推荐。
4. 方案三:先删除缓存,再更新数据库
流程:
1 | 删除缓存 |
问题:
并发读写可能导致旧数据重新写回缓存。
典型过程:
1 | 写请求 A 删除缓存 |
结果:
1 | 数据库 = b |
缓存仍然是旧数据。
结论:存在风险,不推荐作为首选。
5. 方案四:先更新数据库,再删除缓存
流程:
1 | 更新数据库 |
这是本节推荐方案。
5.1 为什么推荐删除缓存,而不是修改缓存
删除缓存后,下一次读请求会:
1 | 缓存未命中 |
这样可以避免并发写请求直接把旧值写入缓存。
5.2 并发场景下的优势
无论并发写顺序如何,最后都会删除缓存。
因此,后续读请求要么:
- 读不到缓存,回源数据库拿最新值;
- 或者由读请求重新写入最新数据。
这样可以大概率保证缓存和数据库一致。
6. 删除缓存失败怎么办
如果数据库更新成功,但删除缓存失败,可以进行重试。
可选方案:
- 写请求中启动异步线程重试删除;
- 使用消息队列监听数据库变更并删除缓存;
- 依赖缓存过期时间兜底。
更推荐简单做法:
删除缓存失败时执行一次异步重试;如果重试也失败,则依赖缓存过期时间兜底。
原因是:
- 删除失败概率很低;
- 为缓存设置过期时间已经可以保证最终一致;
- 没必要为低概率问题引入过重的系统复杂度。
七、四种缓存更新方案对比
| 方案 | 流程 | 主要问题 | 是否推荐 |
|---|---|---|---|
| 先修改缓存,再更新数据库 | Cache → DB | 数据库失败时缓存回滚困难,并发写不一致 | 不推荐 |
| 先更新数据库,再修改缓存 | DB → Cache | 并发写可能导致旧值覆盖新值 | 不推荐 |
| 先删除缓存,再更新数据库 | Delete Cache → DB | 并发读可能把旧数据写回缓存 | 不推荐首选 |
| 先更新数据库,再删除缓存 | DB → Delete Cache | 删除失败需重试或过期兜底 | 推荐 |
推荐方案:
1 | 更新数据库成功 |
八、核心架构思想
- 分布式缓存解决了本地缓存无法共享、语言绑定、状态化和重启丢失的问题。
- Redis 因为数据类型丰富、可持久化、高可用和分布式能力强,常作为分布式缓存首选。
- 缓存读取流程通常是先查缓存,未命中再查数据库,并把结果写回缓存。
- 缓存数据应设置过期时间,用于释放无效数据和兜底最终一致性。
- 缓存穿透是非法请求绕过缓存直接打到数据库。
- 缓存空值可以拦截同一非法 Key,但无法很好应对大量不同非法 Key。
- 布隆过滤器适合在访问数据库前判断数据是否一定不存在。
- 缓存雪崩是大量缓存同时失效或 Redis 宕机导致请求集中打到数据库。
- 随机化过期时间可以避免大量缓存同一时刻过期。
- Redis 高可用架构可以降低缓存系统整体不可用的概率。
- 缓存更新不能简单地同时修改数据库和缓存,必须考虑并发读写顺序。
- 推荐策略是先更新数据库,再删除缓存。
- 删除缓存失败时,可以异步重试,并用缓存过期时间兜底。
- 分布式缓存设计的核心权衡是性能、可用性、一致性和复杂度。