为什么要用分布式缓存?本地缓存呢?多级缓存一致性如何保证?

去年面美团的时候,面试官看着我的简历问:"我看你们项目里有个全局字典模块,里面那些省市代码、订单状态配置,是怎么做缓存的?"

我为了展现系统的高并发架构,立马拔高音量说:"为了保证系统的高可用和拓展性,我们专门用了一套 Redis 集群,每次业务流转都去 Redis 里查字典,绝对扛得住大流量。"

突然,面试官没忍住笑了出来。我看到他摇了摇头,才意识到事情不对------当时我们那个字典表总共也就不到五百条记录,而且大半年都不会改动一次。

面试被挂后才懂:大炮打蚊子,说的就是我这种"强行上架构"的过度设计。明明引入一个 Caffeine 本地缓存能完美解决的问题,非要为了高大上走一遍 Redis 的网络 I/O。不仅增加了部署成本和系统脆弱性,还白白拖慢了响应速度。

段子归段子,在真实的业务开发中,搞懂不同缓存架构的适用边界,特别是本地缓存和分布式缓存的抉择,确实是系统设计的核心基本功,也是考察候选人是否具备"务实架构思维"的高频考点。

现在大环境这么卷,面试官越来越看重我们解决实际问题的能力,而不是堆砌技术栈。今天分享几道缓存选型和基础概念相关的面试题,希望对大家有帮助:

  1. ⭐️ 为什么有些场景下,用 Redis 反而不如本地缓存香?
  2. ⭐️ 单体架构下,JDK 自带的 Map、Guava Cache 和 Caffeine 到底该怎么选?
  3. 引入分布式缓存(如 Redis)会给系统带来哪些隐患和额外成本?
  4. ⭐️ 面对极高并发的秒杀场景,如何将本地缓存与 Redis 结合做成多级缓存?
  5. 多级缓存架构下,如何低成本保证缓存的数据一致性?

Java 面试 & 后端通用面试指南 (Github 收获 155K+ Star, 600+ 位贡献者共同参与维护和完善): JavaGuide 网站javaguide.cn)。

缓存的基本思想

很多同学只知道缓存可以提高系统性能以及减少请求 响应时间(Response Time),但是,不太清楚缓存的本质思想是什么。

缓存的基本思想其实很简单,就是我们非常熟悉的 空间换时间 这一经典性能优化策略的运用。所谓空间换时间,也就是用更多的存储空间来存储一些可能重复使用或计算的数据,从而减少数据的重新获取或计算的时间。

说到空间换时间,除了缓存之外,你还能想到什么其他的例子吗?这里再列举几个常见的:

  • 索引:索引是一种将数据库表中的某些列或字段按照一定的排序规则组织成一个单独的数据结构,虽然需要额外占用空间,但可以大大提高检索效率,降低数据排序成本。
  • 数据库表字段冗余:将经常联合查询的数据冗余存储在同一张表中,以减少对多张表的关联查询,进而提升查询性能,减轻数据库压力。
  • CDN(内容分发网络):将静态资源分发到多个边缘节点以实现就近访问,进而加快静态资源的访问速度,减轻源站服务器以及带宽的负担。

编程需要学会归纳总结,将自己学到的东西串联起来!假如你在面试的时候,能聊到这些,面试官一定会对你有一个好印象的。

不要把缓存想的太高大上,虽然,它的确对系统的性能提升的性价比非常高。当我们在学习并应用缓存的时候,你会发现缓存的思想实际在 CPU、操作系统或者其他很多地方都被大量用到。

比如,CPU Cache 缓存的是内存数据,用于解决 CPU 处理速度与内存访问速度不匹配的问题;内存缓存的是硬盘数据,用于解决硬盘 I/O 速度过慢的问题。

再比如,为了提高虚拟地址到物理地址的转换速度,操作系统在页表方案基础之上引入了 转址旁路缓存 (Translation Lookaside Buffer,TLB,也被称为快表)。

拿日常使用的浏览器来说,它会对访问过的图片或静态文件进行缓存(浏览器缓存),这样下次访问相同页面时加载速度会显著提升。

我们日常开发中用到的缓存,其中的数据通常存储于 RAM (内存)中,访问速度极快。为了避免内存数据在重启或宕机后丢失,许多缓存中间件(如 Redis )提供了磁盘持久化机制。相比于关系型数据库(如 MySQL),缓存的访问速度和并发支持量都要高出几个数量级。在数据库之上增加一层缓存,是保护底层存储、提升系统吞吐量的核心手段。

缓存的分类

接下来,我们来看看日常开发中用到的缓存通常被分为哪几种。

本地缓存

什么是本地缓存?

这个实际在很多项目中用的蛮多,特别是单体架构的时候。数据量不大,并且没有分布式要求的话,使用本地缓存还是可以的。

本地缓存位于应用内部,其最大的优点是应用存在于同一个进程内部,请求本地缓存的速度非常快,不存在额外的网络开销。

常见的单体架构图如下,我们使用 Nginx 来做负载均衡,部署两个相同的应用到服务器,两个服务使用同一个数据库,并且使用的是本地缓存。

注意: 在集群模式下使用本地缓存,必须考虑负载均衡策略 。如果 Nginx 使用默认的轮询(Round-Robin),同一个用户的请求会随机落在不同机器,导致本地缓存命中率极低。解决方案如下:

  1. 网关层:使用一致性哈希或 Sticky Session,保证同一用户的请求固定打到同一台机器。但此方案会破坏微服务的无状态特性,导致节点扩缩容时缓存命中率剧烈震荡,且极易引发单点热点问题,现代云原生架构中较少采用。
  2. 应用层:仅将本地缓存用于**"全局几乎不变"**的数据(如配置字典),而非用户维度数据。

本地缓存的方案有哪些?

1、JDK 自带的 HashMapConcurrentHashMap

JDK 自带的 ConcurrentHashMap 是高并发下最基础的键值对容器。虽然常被新手用作极简缓存,但它本质上只是一个数据结构 ,并非真正的缓存框架。它缺乏缓存必备的核心机制:过期淘汰策略(TTL/TTI)容量驱逐算法(如 LRU/W-TinyLFU)以及命中率监控

因此,大部分场景来说不会直接使用这两者当做缓存。一个稍微完善一点的缓存框架至少要提供:过期时间淘汰机制命中率统计这三点。

2、 EhcacheGuava CacheSpring Cache 这三者是使用的比较多的本地缓存框架。

  • Ehcache 的话相比于其他两者更加重量。不过,相比于 Guava CacheSpring Cache 来说, Ehcache 支持可以嵌入到 hibernate 和 mybatis 作为多级缓存,并且可以将缓存的数据持久化到本地磁盘中、同时也提供了集群方案(比较鸡肋,可忽略)。
  • Guava CacheSpring Cache 两者的话比较像。Guava 相比于 Spring Cache 的话使用的更多一点,它提供了 API 非常方便我们使用,同时也提供了设置缓存有效时间等功能。它的内部实现也比较干净,很多地方都和 ConcurrentHashMap 的思想有异曲同工之妙。
  • 使用 Spring Cache 的注解实现缓存的话,代码会看着很干净和优雅,但需警惕底层实现陷阱:Spring Cache 本身只是一层抽象接口,若底层直接使用默认的 ConcurrentMapCacheManager(底层是无容量限制的 ConcurrentHashMap),在缓存大量动态 Key 时极易导致 OOM。因此,生产中必须将其底层实现绑定为配置了最大容量的 Caffeine 或 Redis。

3、后起之秀 Caffeine。

相比于 Guava 来说 Caffeine 在各个方面比如性能都要更加优秀,一般建议使用其来替代 Guava 。并且, GuavaCaffeine 的使用方式很像!

使用 Caffeine 创建本地缓存的代码示例,用到了建造者模式:

java 复制代码
// 使用 Caffeine 创建本地缓存示例
Cache<String, String> cache = Caffeine.newBuilder()
        // 设置写入后 10 分钟过期(本地缓存通常设置较短过期时间)
        .expireAfterWrite(10, TimeUnit.MINUTES)
        // 初始容量
        .initialCapacity(100)
        // 最大条数限制
        .maximumSize(500)
        // 开启统计功能
        .recordStats()
        .build();

本地缓存有什么痛点?

本地的缓存的优势非常明显:低依赖轻量简单成本低

但是,本地缓存存在下面这些缺陷:

  • 本地缓存应用耦合,对分布式架构支持不友好,比如同一个相同的服务部署在多台机器上的时候,各个服务之间的缓存是无法共享的,因为本地缓存只在当前机器上有。
  • 本地缓存容量受服务部署所在的机器限制明显。 如果当前系统服务所耗费的内存多,那么本地缓存可用的容量就很少。

分布式缓存

什么是分布式缓存?

我们可以把分布式缓存(Distributed Cache) 看作是一种内存数据库的服务,它的最终作用就是提供缓存数据的服务。

分布式缓存脱离于应用独立存在,多个应用可直接的共同使用同一个分布式缓存服务。

如下图所示,就是一个简单的使用分布式缓存的架构图。我们使用 Nginx 来做负载均衡,部署两个相同的应用到服务器,两个服务使用同一个数据库和缓存。

使用分布式缓存之后,缓存服务可以部署在一台单独的服务器上,即使同一个相同的服务部署在多台机器上,也是使用的同一份缓存。 并且,单独的分布式缓存服务的性能、容量和提供的功能都要更加强大。

软件系统设计中没有银弹,往往任何技术的引入都像是把双刃剑。 你使用的方式得当,就能为系统带来很大的收益。否则,只是费了精力不讨好。

简单来说,为系统引入分布式缓存之后往往会带来下面这些问题:

  • 系统复杂性增加 :引入缓存之后,你要维护缓存和数据库的数据一致性、维护热点缓存、保证缓存服务的高可用等等。
  • 系统运维与资源成本增加:引入分布式缓存意味着系统需要额外部署并维护一个高可用的缓存中间件集群,这不仅消耗宝贵的内存资源,也提高了运维门槛。

分布式缓存的方案有哪些?

分布式缓存的话,比较老牌同时也是使用的比较多的还是 MemcachedRedis 。不过,现在基本没有看过还有项目使用 Memcached 来做缓存,都是直接用 Redis

Memcached 是分布式缓存最开始兴起的那会,比较常用的。后来,随着 Redis 的发展,大家慢慢都转而使用更加强大的 Redis 了。

有一些大厂也开源了类似于 Redis 的分布式高性能 KV 存储数据库,例如,腾讯开源的 Tendis 。Tendis 基于知名开源项目 RocksDB 作为存储引擎 ,100% 兼容 Redis 协议和 Redis4.0 所有数据模型。关于 Redis 和 Tendis 的对比,腾讯官方曾经发过一篇文章:Redis vs Tendis:冷热混合存储版架构揭秘 ,可以简单参考一下。

不过,从 Tendis 这个项目的 Github 提交记录可以看出,Tendis 开源版几乎已经没有被维护更新了,加上其关注度并不高,使用的公司也比较少。因此,不建议你使用 Tendis 来实现分布式缓存。

目前,比较业界认可的 Redis 替代品还是下面这两个开源分布式缓存(都是通过碰瓷 Redis 火的):

  • Dragonfly:一种针对现代应用程序负荷需求而构建的内存数据库,完全兼容 Redis 和 Memcached 的 API,迁移时无需修改任何代码,号称全世界最快的内存数据库。
  • KeyDB: Redis 的一个高性能分支,专注于多线程、内存效率和高吞吐量。

不过,个人还是建议分布式缓存首选 Redis ,毕竟经过这么多年的生产考验,生态也这么优秀,资料也很全面。

相关阅读:

多级缓存

什么是多级缓存?为什么要用?

我们这里只来简单聊聊 本地缓存 + 分布式缓存 的多级缓存方案,这也是最常用的多级缓存实现方式。

这个时候估计有很多小伙伴就会问了:既然用了分布式缓存,为什么还要用本地缓存呢?

本地缓存和分布式缓存虽然都属于缓存,但本地缓存的访问速度要远大于分布式缓存,这是因为访问本地缓存不存在额外的网络开销,我们在上面也提到了。

不过,一般情况下,我们也是不建议使用多级缓存的,这会增加维护负担(比如你需要保证一级缓存和二级缓存的数据一致性)。而且,其实际带来的提升效果对于绝大部分业务场景来说其实并不是很大。

这里简单总结一下适合多级缓存的两种业务场景:

  • 缓存的数据不会频繁修改,比较稳定;
  • 数据访问量特别大比如秒杀场景。

多级缓存方案中,第一级缓存(L1)使用本地内存(比如 Caffeine)),第二级缓存(L2)使用分布式缓存(比如 Redis)。

读取缓存数据的时候,我们先从 L1 中读取,读取不到的时候再去 L2 读取。这样可以降低 L2 的压力,减少 L2 的读次数。如果 L2 也没有此数据的话,再去数据库查询,数据查询成功后再将数据写入到 L1 和 L2 中。

多级缓存开源实现推荐:

  • J2Cache:基于本地内存和 Redis 的两级 Java 缓存框架。
  • JetCache:阿里开源的缓存框架,支持多级缓存、分布式缓存自动刷新、 TTL 等功能。

多级缓存一致性如何保证?

在多级缓存系统中,保证强一致性成本太高,业界的几个提供多级缓存功能的缓存框架基本都是最终一致性保证。例如,可以使用 Redis 的发布/订阅机制、Redis Stream 或者消息队列来确保当一个实例的本地缓存发生变化时,其他实例能够及时更新其本地缓存,以保持缓存一致性。

政采云技术的方案是 Canal + 广播消息,这里简单介绍一下:

  1. DB 修改数据:首先在数据库中进行数据修改。
  2. 通过监听 Canal 消息,触发缓存的更新:使用 Canal 监听数据库的变更操作,当检测到数据变化时,触发缓存更新。
  3. 同步 Redis 缓存:对于 Redis 缓存,因为集群中只共享一份数据,所以直接同步缓存即可。
  4. 同步本地缓存:由于本地缓存分布在不同的 JVM 实例中,需要借助广播消息队列(MQ)机制,将更新通知广播到各个业务实例,从而同步本地缓存。

⚠️ 警惕消息乱序导致脏数据 :在并发修改场景下,Canal 监听到多次更新推送到 MQ,由于网络延迟,消费端处理消息的顺序可能与 DB 实际执行顺序不一致,导致旧版本数据覆盖新版本数据。生产环境中,更新缓存的操作建议改为 Delete(失效缓存) 而非 Update,迫使下次请求重新从 DB 拉取最新数据,配合兜底的 TTL 机制,最大程度避免脏数据残留。

相关阅读:

相关推荐
一个有温度的技术博主2 小时前
Redis系列四:redis的启动配置
数据库·redis·缓存
yuanlaile2 小时前
2026后端趋势:Java 老了?Go 才是未来?
java·后端·golang·go与java·后端学什么
我爱娃哈哈2 小时前
SpringBoot + 事件溯源 + CQRS:高一致性与高性能读写分离架构
后端
Java水解2 小时前
Go map 底层原理
后端
尽兴-2 小时前
构建坚如磐石的 Redis 服务:数据安全性与高可用架构全解析
数据库·redis·架构·主从·aof·哨兵·rdb
南方的耳朵2 小时前
Linux 创建 TAP 类型虚拟设备的命令
后端
运维 小白3 小时前
3. 部署redis服务并监控redis
数据库·redis·缓存
掘金码甲哥3 小时前
MetalLB才是给Ingress这个老登做负重前行的那个男人
后端
野犬寒鸦3 小时前
从零起步学习计算机操作系统:内存管理篇
服务器·后端·学习·缓存·面试