小区门口那家便利店,店主老王很会做生意。他把卖得最快的牛奶、鸡蛋放在收银台边上的黄金货架上,顾客进门顺手就拿,根本不用去仓库翻。数据库就像小区后门的仓库,每次跑去取货都要花时间;而黄金货架就是本地缓存——伸手就能拿到,前提是你知道该摆什么货上去。 计算机世界也一样。一个 Java 服务每秒可能要读上千次用户数据,如果每次都去数据库查,网络 IO 和磁盘 IO 的延迟会把服务拖得喘不过气。于是聪明的工程师在应用进程内加了一个“黄金货架”——本地缓存,把热数据直接放在内存里,读取是纳秒级,比数据库的毫秒级快几百倍。而 Caffeine,就是 Java 生态里最会“摆货架”的金牌店长,它用一套叫做 W-TinyLFU 的算法,让有限的货架空间永远装着最热门的商品。
掌握 Caffeine 的配置、淘汰机制与监控方法,你就能在 AI 服务后端快速搭建高性能本地缓存层,用更少的机器扛住更高的 QPS,是 AI 工程化落地中绕不开的硬技能。
可视化演示
展示操作系统内存分配与释放过程,观察外部碎片的产生:进程分配和释放后内存空间碎片化,影响后续大块分配。
本节配有交互动效(进入视口后自动加载,约 3KB)。
内存分配 进程分配:从空闲区划分所需空间 进程释放:归还内存产生空闲块 外部碎片:不连续的空闲块
本地缓存概述
小区门口那家便利店,店主老王很会做生意。他把卖得最快的牛奶、鸡蛋放在收银台边上的黄金货架上,顾客进门顺手就拿,根本不用去仓库翻。你下单后要访问的“仓库”就是数据库——每次去仓库翻货要跑几十米,而黄金货架就是本地缓存,伸手就够得着。如果这个黄金货架上摆的都是卖不动的货,顾客照样得去仓库,缓存的性能优势也就荡然无存。
计算机世界也一样。一个 Java 服务每秒可能要读上千次用户数据,如果每次都去数据库查询,网络 IO 和磁盘 IO 的延迟会让服务像堵车一样缓慢。于是工程师在应用进程内加了一个“黄金货架”——本地缓存,把热数据直接放在内存里,读取耗时是纳秒级,比数据库的毫秒级快好几个数量级。Caffeine 正是 Java 生态里最擅长打理这个货架的库,它以高性能著称,底层用 W-TinyLFU 算法保证了极高的命中率。
缓存到底有没有用,取决于一个核心指标:命中率。命中意味着顾客在前台直接拿到了商品,未命中就必须跑到仓库取。定义命中次数为 H,未命中次数为 M,则命中率 h 为:
回到小店:如果黄金货架放的是畅销货,命中率自然高,顾客满意、老板省心。Caffeine 这位“金牌店长”的厉害之处,就是总能让最热的货出现在最顺手的位置——这正是下一节要讲的 W-TinyLFU 摆货算法。
- H(命中次数)顾客直接在黄金货架上拿到商品,一次都没去仓库,这是缓存起作用的次数。
- M(未命中次数)货架上没有想要的货,只能跑到仓库(数据库)取的次数。
- 分母求和总请求次数 = 命中 + 未命中,把所有请求都算进来。
- 整体求比命中次数占全部请求的比例,就是命中率 h,它界于 0 到 1 之间。
W-TinyLFU算法
老王管理黄金货架有一套自己的土规矩:新进的货先放在收银台右手边的小试销筐里,卖得动就挪到主货架底层,连续畅销的才会被摆到最显眼的黄金区。这样一来,新爆款不会被埋没,老面孔也别想长期霸占最好的位置。这套“土规矩”在 Caffeine 里有一个严谨的名字:W-TinyLFU(Window Tiny LFU),即“带窗口的极小频率优先”算法。
W-TinyLFU 把缓存空间拆成三块:窗口区(Window)、观察区(Probation)和黄金区(Protected)。窗口区约占总容量的 1%,用来“试销”刚进来的新条目;观察区是普通货架,存放有一定热度但还没爆发的条目;黄金区占主缓存的大部分,只留给访问频率最高的“招牌商品”。每个 key 都有一个频率计数,频率越高,越有资格往上晋级。
窗口区大小 w 由总容量 C 决定,Caffeine 默认按 1% 划拨:
窗口区满了之后,频率最低的“新货”会被赶进主缓存区;主缓存区里频率垫底的条目会被整体淘汰出局。与此同时,Caffeine 会周期性对所有频率计数做“减半”处理(频率衰减),让过气网红让位给新晋热点。这正是 W-TinyLFU 的精髓:既看访问频率,又给新条目机会,不像传统 LFU 那样让老数据永远霸榜。老王的三层货架规则,让同样大小的缓存空间,命中率比简单 LRU 高出一大截。
- C(总容量)货架总共有多少个货位,对应缓存最大条目数 maximumSize。
- C / 100 并向下取整试销筐只分配约 1% 的货位,取整得到一个整数。
- 外层 max 兜底即便容量很小,也至少留 1 个试销位,保证新条目永远有机会进场。
快速入门
想开店,先装修、办执照、进货架。想在 Java 项目里用 Caffeine,第一步也是“进货”——引入依赖,然后把货架搭起来。Maven 用户只需要在 pom.xml 中添加一行坐标,Gradle 用户也有对应的单行依赖声明。依赖到手,相当于货架原材料已经送到店门口。
接下来用构建器 Caffeine.newBuilder() 搭货架。你可以像定店规一样,指定货架容量、过期时间、是否记录统计等策略。构建器是 Caffeine 的“装修队”,把所有配置攒齐后,调用 build() 就能开张。build() 有两种玩法:不传参数得到基础的 Cache,适合手动上架取货;传入 CacheLoader 则得到 LoadingCache,相当于请了一个“自动补货员”。
实际写起来非常简单:
这段代码的意思是:开一个最多放 1000 件商品的货架,5 分钟没人碰的商品自动下架,同时开启销售统计。调用 get("u-1001") 时,如果货架上有这个用户就直接返回;如果没有,自动补货员就会执行 userService.findById("u-1001") 去数据库加载,然后把结果摆上货架。
缓存带来的收益可以用平均耗时公式估算。假设命中率为 h,缓存读取耗时为 T_cache,回源数据库耗时为 T_source,则每次请求的平均耗时为:
开店流程走完,缓存货架正式营业。门店开起来只是第一步,货架怎么摆、过期商品怎么处理、谁来补货,才是决定这家店能不能赚钱的关键——这就是下一节的“店规配置”。
- h 与 (1-h)h 是前台拿货的比例,(1-h) 是跑去仓库取货的比例。
- T_cache在本地内存货架拿货的耗时,通常是纳秒到微秒级,几乎可以忽略。
- T_source回源到数据库的耗时,包含网络和磁盘开销,通常是毫秒级,甚至更慢。
- 加权求和把两条路径的耗时按比例加权,得到平均每次请求的期望耗时。
LoadingCache<String, User> cache = Caffeine.newBuilder() .maximumSize(1000) .expireAfterAccess(5, TimeUnit.MINUTES) .recordStats() .build(key -> userService.findById(key)); User user = cache.get("u-1001");
策略配置
货架开起来了,老王开始定店规:货架位有限,不能什么货都往里塞;生鲜有保质期,过期必须下架;长期没人碰的滞销品也要定期撤掉。Caffeine 的策略配置,就是给缓存货架写一本“店规手册”。
容量上限用 maximumSize 指定,这是货架的硬边界。超过上限时,Caffeine 会按 W-TinyLFU 规则淘汰低频条目,保证新货能进场。除了数量上限,还可以用 maximumWeight 配权重上限,适合内存占用差异大的对象——就像大件家电占的货架面积和小件零食完全不同。
过期策略决定商品什么时候下架。expireAfterWrite 是“固定保质期”:从商品上架那一刻计时,满指定时长就过期,哪怕期间被买了很多次。expireAfterAccess 是“冷落期限”:从最后一次被访问开始计时,如果长时间没人碰就下架,适合热点随时间漂移的场景。刷新策略 refreshAfterWrite 则是“后台补货”:到期后不直接删掉旧货,而是让后台线程加载新值,期间读请求仍然返回旧值,用户体验无损。驱逐监听 removalListener 会在条目被淘汰时通知你,相当于撤架时给店员发一条短信。
过期与刷新的核心判断逻辑可以浓缩为一个布尔表达式:
店规定清楚,货架才能自动运转。老王不需要天天盯着每个商品,店员(业务代码)按照规则执行即可。但光有规则还不够——月底要看报表,才能知道哪些货该多进、哪些货该撤架,这就引出下一节的统计与监控。
- t_now - t_write从商品上架那一刻到现在过去了多久,这是“保质期时钟”。
- T_write(expireAfterWrite 阈值)店规里写的保质期,比如 5 分钟,超时就下架。
- t_now - t_access商品最后一次被顾客拿起到现在过去了多久,这是“冷落时钟”。
- 逻辑或 ∨两条过期条件只要满足任意一条,就判定商品过期,触发淘汰。
异步与统计
老王请了个勤快的店员小李:顾客要货而前台没有时,小李会立刻跑去仓库取,顾客不用站在原地干等;月底老王还会翻看销售报表,分析哪种货卖得好、哪种货占着地方不赚钱。Caffeine 的异步加载和统计信息,扮演的正是小李和销售报表的角色。
异步加载通过 AsyncCache 实现,它的核心方法是 asyncGet(key, loader),返回的是 CompletableFuture。调用线程不会阻塞在数据库查询上,而是先拿到一个“取货凭证”,货到了再兑现结果。这对 AI 服务特别友好——推理链路中一次特征读取可能涉及多个远程调用,异步化能让线程资源被更充分地利用。
统计信息则需要主动开启。构建时调用 recordStats(),之后就能通过 cache.stats() 拿到 CacheStats 对象,里面包含命中率、未命中次数、驱逐计数、加载耗时等指标。这就相当于给货架装了仪表盘:hitRate 是整体命中率,evictionCount 是被迫下架的商品数,loadCount 是补货员跑仓库的次数。异步刷新 refreshAfterWrite 配合异步加载器,可以让补货完全在后台进行,读请求永远不被阻塞。
在监控视角下,未命中率比命中率更能暴露问题。未命中率与命中率互为补集:
有了报表,老王知道哪款该补货、哪款该下架;有了 stats,开发者知道容量该调大还是调小、过期时间该加长还是缩短。统计不是锦上添花,而是让缓存策略从“拍脑袋”变成“看数据”的关键一步。
- 1 - h命中率和未命中率的互补关系,就像“卖掉的货”和“没卖掉的货”合起来才是全部进货。
- M / (H + M)未命中次数除以总请求次数,直接得到未命中比例。
- 整体等式两个表达式等价,都是从不同角度度量“有多少请求没能从货架直接拿货”。
实战最佳实践
老店长征战多年,攒下几条宝贵心得。第一条是别盲目堆容量:货架越大成本越高,关键是让容量匹配热点规模。实战中可以用一个简单的估算法:容量 C 至少要能装下“回源一次期间涌入的新请求量”,否则缓存会因频繁淘汰而形同虚设。
设峰值请求率为 R_peak(每秒请求数),单次回源加载耗时为 T_reload(秒),那么回源期间可能到达的新请求数就是:
第二条心得是两级缓存:Caffeine 挡第一层热读,Redis 做第二层共享兜底。Caffeine 是进程内缓存,各实例不共享;Redis 是分布式缓存,适合多副本一致性和持久化。AI 服务通常先查 Caffeine,未命中再查 Redis,最后才回源数据库,每一层都在为下一层挡掉一部分流量。第三条心得是用统计量化收益:上线前后对比 P99 延迟和数据库 QPS,用数字证明缓存的价值。最后别忘了处理缓存穿透——查询不存在的数据时,要给空值也做一个占位,否则每次都穿透到数据库。
老店长的经验说到底就是一句话:好的摆货是算过账的,不是凭感觉。容量、策略、监控三位一体,缓存才会真正成为 AI 服务的加速器,而不是一个占用内存却帮不上忙的花架子。
- R_peak高峰期每秒到达的请求数量,可以用压测或监控得到。
- T_reload从数据库加载一个条目需要的时间,单位换算成秒。
- 乘积回源期间新请求总量,容量至少要大于这个数,缓存才不会被击穿。
- 向上取整货架位是整数,容量取整后作为 maximumSize 的起点。
AI 应用场景
AI特征服务中的特征结果缓存
阶段:特征工程 / 在线推理预处理
解决的问题:推荐系统里同一个用户特征在单次请求的多个环节被反复读取,每次从 KV 存储回源耗时高,且重复计算浪费 CPU 资源。传统做法是每次实时拼接特征,导致单请求特征链路耗时长达几十毫秒。
具体案例:某日活 500 万的推荐服务,用户行为特征由多个特征源拼接而成。后端用 LoadingCache<Long, UserFeature> 缓存用户特征,maximumSize(200_000)、expireAfterAccess(10, TimeUnit.MINUTES),recordStats() 开启统计。上线后特征读取命中率达到 85%,平均耗时从 45ms 降到 6ms,数据库 QPS 下降约 70%。
LLM网关的幂等与结果复用缓存
阶段:推理服务网关 / API编排
解决的问题:LLM 应用中相同的 Prompt 前缀、内容安全审核请求短时间内会重复到达,每次都调用大模型或审核服务既烧钱又费时。传统网关对重复请求没有短时记忆,白白浪费资源。
具体案例:某企业内部 LLM 网关用 Caffeine 缓存“请求哈希 → 审核结论”,expireAfterWrite(5, TimeUnit.SECONDS) 只缓存几秒,配合 Redis 做跨节点共享。重复内容审核请求直接命中本地缓存,审核服务调用量降低 40%,网关 P99 延迟从 800ms 降到 200ms。
Python 演示
import random class TinyLFUCache: """Simplify W-TinyLFU cache with window + main + frequency decay.""" def __init__(self, capacity=100, window_ratio=0.01, freq_max=20): self.capacity = max(2, capacity) self.window_cap = max(1, int(capacity * window_ratio)) self.main_cap = max(1, self.capacity - self.window_cap) self.freq_max = freq_max self.window = {} self.main = {} self.freq = {} self.hits = 0 self.misses = 0 self.gc_count = 0 def _incr(self, key): self.freq[key] = min(self.freq.get(key, 0) + 1, self.freq_max) def _decay(self): for key in list(self.freq): self.freq[key] = max(1, self.freq[key] // 2) drop = [key for key, val in self.freq.items() if val <= 2] for key in drop: self.freq.pop(key, None) self.window.pop(key, None) self.main.pop(key, None) def get(self, key): self.gc_count += 1 if self.gc_count % 100 == 0: self._decay() if key in self.window: self._incr(key) self.hits += 1 return self.window[key] if key in self.main: self._incr(key) self.hits += 1 return self.main[key] self.misses += 1 return None def put(self, key, value): if key in self.window or key in self.main: self._incr(key) return if len(self.window) >= self.window_cap: victim = min(self.window, key=lambda k: self.freq.get(k, 0)) old_val = self.window.pop(victim) if len(self.main) >= self.main_cap: drop = min(self.main, key=lambda k: self.freq.get(k, 0)) self.main.pop(drop) self.freq.pop(drop, None) self.main[victim] = old_val self.window[key] = value self.freq[key] = 1 def get_stats(self): total = self.hits + self.misses return { "hits": self.hits, "misses": self.misses, "hit_rate": round(self.hits / total, 4) if total else 0.0, "window_size": len(self.window), "main_size": len(self.main), } def main(): cache = TinyLFUCache(capacity=20, window_ratio=0.05) keys = ["user_%d" % (i % 8) for i in range(500)] for key in keys: if key in cache.window or key in cache.main: cache.get(key) else: cache.put(key, key.upper()) stats = cache.get_stats() print("hit_rate=%s" % stats["hit_rate"]) print("hits=%d misses=%d" % (stats["hits"], stats["misses"])) if __name__ == "__main__": main()
self.window_cap = max(1, int(capacity * window_ratio))窗口区按 1% 分配,但至少留 1 个试销位,对应 Caffeine 的默认比例。victim = min(self.window, key=lambda k: self.freq.get(k, 0))窗口区满时,驱逐频率最低的新条目,把位置让给更新的货。if len(self.main) >= self.main_cap:主缓存区也满时,再淘汰一次低频条目,保证货架总量不超限。drop = [key for key, val in self.freq.items() if val <= 2]频率衰减后低于阈值的条目整体清出,模拟“过气商品下架”。if self.gc_count % 100 == 0: self._decay()每 100 次操作触发一次频率衰减,让历史热度逐步褪色。"hit_rate": round(self.hits / total, 4)用命中与总请求之比计算命中率,对应正文中的 h = H/(H+M)。
常见误区
误区缓存就是拿来提速的,maximumSize 设成好几 GB,命中率肯定更高,服务也会更快,反正现在内存很便宜。
正解本地缓存挤占 JVM 堆内存,过大的容量会加剧垃圾回收停顿,反而拖慢整体性能。容量应该由热点数据规模决定,用 stats 里的命中率曲线找拐点:容量从 1 万提到 10 万命中率才涨 2%,就说明 1 万已经够用,盲目堆容量是浪费。货架不是越大越好,而是刚好装下热销品最好。
为何常见:直觉上“放得越多越容易命中”,忽略了内存是共享的昂贵资源,以及 GC 带来的隐性成本。
误区都是缓存,Caffeine 又快又省事,就不用再搭 Redis 了,把用户会话和共享数据全塞进 Caffeine 就行。
正解Caffeine 是进程内本地缓存,每个 Java 实例各存各的,多副本之间不共享数据;Redis 是跨进程的分布式缓存,适合多实例共享、持久化和发布订阅。正确做法是两级缓存:Caffeine 挡第一层热读,Redis 做第二层共享兜底。老王店里的黄金货架再厉害,也替代不了小区外的配送中心。
为何常见:没分清“本地”与“分布式”的边界,看到 Caffeine 性能数据漂亮就误以为它是全场景替代品。
误区只要加了 Caffeine,所有请求都会变快,因为内存读取肯定比数据库快,这是铁律。
正解命中率低的时候,缓存查询本身有开销,回源成本一点没少,整体反而变慢;更糟的是缓存穿透——查询一个不存在的 key,会绕过缓存直接打到数据库,货架上永远没货。正确做法是先分析热点分布,对空结果做空值占位,并设置合理的过期时间。缓存只对热数据有效,不是万能加速器。
为何常见:把“缓存对热数据有效”夸大成“对所有请求有效”,忽略了命中率这个前提。
误区W-TinyLFU 的频率计数就是给每个 key 记访问次数,次数最多的永远留在缓存里就行了,不用管什么衰减。
正解如果不做频率衰减,历史超高频条目会永远霸占黄金区,新晋热点根本挤不进来,这就是缓存污染。W-TinyLFU 周期性把所有计数减半,让旧热度褪色、新热度有机会上位。就像老王的畅销榜每季度重算一次,去年的爆款今年可能已经过气,得给新品让位。
为何常见:只看到“计频”没看到“老化”,以为 LFU 是一个静止的计数排行榜。
误区建好缓存后设置一个超大容量和很长的过期时间,线上跑着就行,stats 那些指标没什么用。
正解命中率、驱逐数、加载耗时必须纳入监控。命中率长期低于 50%,说明容量或策略没有匹配热点;驱逐数突然暴增,说明容量过小;加载耗时变大,说明回源链路出了问题。Caffeine 的 recordStats() + stats() 就是给缓存装仪表盘,没有仪表盘的货架,出了问题只能瞎猜。
为何常见:缓存是“隐形”基础设施,功能做通了就觉得完事,忽略了运行成本与收益需要持续观测。
面试题
Q1. Java 生态中性能出色的本地缓存库 Caffeine,其底层淘汰算法是什么?
- A.最近最少使用 LRU 算法
- B.最不经常使用 LFU 算法
- C.窗口版 LFU 算法 W-TinyLFU← 正确答案
- D.先进先出 FIFO 算法
答案:C。Caffeine 使用 W-TinyLFU(带窗口的极致频率优先)算法,它在 LFU 基础上增加了窗口区,既统计访问频率又给新条目机会。LRU 只看最近访问时间,FIFO 只看进入顺序,都不是 Caffeine 的核心。
Q2. 以下关于本地缓存 Caffeine 与分布式缓存 Redis 的说法,正确的是?
- A.Caffeine 是 Redis 的完整替代方案
- B.Redis 是进程内缓存而 Caffeine 跨进程
- C.Caffeine 数据存本机内存速度快← 正确答案
- D.Caffeine 支持多节点数据共享
答案:C。Caffeine 是进程内本地缓存,数据存在 JVM 堆内存中,读取极快但多实例不共享;Redis 是独立部署的分布式缓存,支持跨节点共享。两者是互补关系,通常组成两级缓存,而不是互相替代。
Q3. 配置 expireAfterWrite(5, TimeUnit.SECONDS) 后,缓存条目会怎样过期?
- A.每次访问后 5 秒过期
- B.写入后满 5 秒即过期← 正确答案
- C.没有任何访问才延迟过期
- D.容量满时才触发过期清理
答案:B。expireAfterWrite 从写入时刻计时,满 5 秒就失效,属于“固定保质期”;expireAfterAccess 才从最后一次访问计时。如果每次访问都延长 5 秒,那是 Access 语义而不是 Write 语义,两者不能混淆。
Q4. 在 W-TinyLFU 算法中,缓存条目可能在哪些区域间移动?
- A.窗口区与观察区之间流动← 正确答案
- B.观察区与黄金区之间流动← 正确答案
- C.从黄金区直接降级到窗口区
- D.新条目只能先进窗口区域← 正确答案
答案:ABD。新条目先进入窗口区试销,窗口区溢出后按频率进入观察区,频率继续上升则晋升黄金区。降级通常是被淘汰而非回到窗口区,因此 C 错误。三条流动路径恰恰体现了“给新品机会、让热品晋级”的设计。
Q5. Caffeine 中窗口缓存区(Window Cache)默认占用总容量的比例约为多少?
- A.0.5% 左右的默认占比
- B.占总数量的百分之十
- C.约占总容量的百分之一← 正确答案
- D.百分之五十到八十左右
答案:C。Caffeine 的窗口区默认约占总容量的 1%,计算公式为 max(1, C/100)。窗口区是给新条目的“试销筐”,比例虽小但作用关键,既让新热点有机会进场,又不挤占主缓存区的黄金位置。
Q6. 为什么 W-TinyLFU 需要定期对频率计数做衰减?
- A.防止计数值溢出造成崩溃
- B.让历史高频旧条目给新条目让位← 正确答案
- C.让所有缓存条目频率归零重计
- D.配合垃圾回收机制减少内存占用
答案:B。频率衰减的核心目的是对抗缓存污染。如果不衰减,历史高频数据会永远霸占黄金区,新晋热点无法进入。减半而不是归零,是为了保持“老爆款仍有余热,但热度随时间褪去”的平衡,让货架始终跟上业务变化。
Q7. 关于 Caffeine 的异步刷新 refreshAfterWrite,正确的说法有哪些?
- A.后台异步加载新值不阻塞读请求← 正确答案
- B.刷新失败时旧缓存值仍可以使用← 正确答案
- C.刷新完成前会清空旧缓存值
- D.刷新操作需显式调用 refresh 方法
答案:AB。refreshAfterWrite 到期后,Caffeine 在后台触发异步加载,读请求继续返回旧值,因此不阻塞;刷新失败时旧值不会被清除,仍可继续提供服务。刷新是自动触发的,无需显式调用 refresh。C 和 D 都与异步刷新的设计相悖。
Q8. 如何开启 Caffeine 的统计信息采集?
- A.构建时调用 recordStats 方法← 正确答案
- B.构建后调用 asMap 查看
- C.通过 CacheLoader 实现统计
- D.使用 AsyncCache 自动统计
答案:A。在 Caffeine.newBuilder() 链式调用中加上 recordStats(),构建后就能用 cache.stats() 获取 CacheStats,包含命中率、驱逐数、加载耗时等指标。asMap 是视图方法,CacheLoader 管加载,都不会自动采集统计信息。
Q9. 缓存污染(Cache Pollution)在 W-TinyLFU 语境下指的是什么?
- A.缓存中数据被错误地篡改丢失
- B.低频数据挤占高频数据空间← 正确答案
- C.缓存数据与数据库数据不一致
- D.多个缓存实例之间同步延迟
答案:B。缓存污染指低频或过时的冷数据占据缓存空间,导致高频新数据无法进入。W-TinyLFU 用频率衰减和窗口区来对抗污染,让旧热度让位给新热点。数据篡改、一致性、同步延迟属于其他维度的问题,与污染定义不符。
Q10. 在 AI 服务的 Java 后端中,Caffeine 常见的落地方式有哪些?
- A.缓存特征计算结果避免重复计算← 正确答案
- B.缓存 Token 信息或模型产物← 正确答案
- C.替换数据库存储业务核心的订单
- D.与 Redis 组成两级缓存体系← 正确答案
答案:ABD。AI 后端典型场景包括缓存特征向量、Token 校验结果、模型推理产物等,用本地缓存扛住高频重复读;同时与 Redis 组成两级缓存,Caffeine 挡第一层、Redis 做共享兜底。Caffeine 是缓存不是存储引擎,不能替代数据库持久化核心业务数据,C 错误。