☰
SpringBoot 3 本地缓存金字塔:接口性能提升 10 倍的实战
2026/10/10 4:22:07 网站建设 项目流程

先交代一下背景。前阵子某个内部项目在做大促前的性能摸底时,发现一个批量查询接口的 P99 延迟已经到 780ms,QPS 一上去就触发告警。我一开始也是按老套路处理——加 Redis 缓存,把缓存命中率刷到 90% 以后,平均响应时间确实降下来了,但离性能目标还差一大截。真正帮我解决这个问题的,是直接在应用进程内搭一套本地缓存「金字塔」。这篇文章就把 SpringBoot 3 下这套金字塔的实现过程、压测数据和踩坑记录完整写出来,给同样被接口性能卡住的朋友一个参考。

1. 为什么接口加速10倍要先从“本地缓存”开刀

1.1 一次查询的耗时拆解:Redis 不是终点

接口慢,往往不是慢在业务代码本身,而是慢在“跨网络拿数据”这件事上。一个批量查询接口,哪怕只读几个商品的聚合字段,也要经历 HTTP 请求解析、业务逻辑计算、缓存查询、数据库查询、结果序列化这一整条链路。其中真正耗时的大头通常在外层存储或数据库,而不是那几行 for 循环。

加 Redis 缓存能解决一部分问题,但注意:Redis 再快也是一次网络往返。以一个典型的内网部署来看,业务服务器到 Redis 的单次 RTT 大约在 0.3ms 到 1ms,再加上序列化/反序列化、连接池获取、IO 线程调度,一次真正的缓存读取可能要消耗 2ms 到 5ms。如果接口是一次性要组装 20 个数据的聚合接口,那么单纯 Redis 命中也可能要累计出几十毫秒。

更麻烦的是,Redis 并不是为本机高频热点设计的。你有没有遇到过这种场景:Redist 命中率已经很高,但接口 RT 始终降不下来?问题就出在每读一次缓存都要走一遍网络栈,这个固定开销在低并发时看不出什么,在高并发下会直接把服务打满。

本地缓存和 Redis 的本质区别,是它根本不走网络。数据就在当前 JVM 进程的堆内存里,读一次是纳秒到微秒级,几乎是 Redis 的千分之一。所以在读多写少、热点集中的场景里,本地缓存带来的提升往往比加 Redis 更明显。

1.2 金字塔到底分几层,每层该放什么

所谓“金字塔”,本质上是一套按访问速度与数据容量分级的缓存结构。越靠近顶部的层,速度越快、容量越小、存储的数据越“热”;越靠近底部的层,速度越慢、容量越大、存储的数据越冷。整个金字塔从上到下依次是:

  • L0 热点层:极少数高频 key,放在ConcurrentHashMap里,无过期时间,由业务主动清理或自然淘汰。
  • L1 秒级层:短 TTL 的 Caffeine 缓存,放那些允许 30 秒左右过期、需要快速反映变化的业务数据。
  • L2 分钟级层:长 TTL 的 Caffeine 缓存,放那些稳定、变更不频繁的配置类或聚合类数据。
  • DB 兜底层:真正查询数据库,并把结果逐级回填到上面各层。

为什么需要两层 Caffeine 而不用一个大缓存?因为不同数据的新鲜度要求不同。有些数据允许 30 秒延迟,有些允许 5 分钟;如果全部塞进一个缓存,就必须按最严格的那个 TTL 设置,这样很多本可以被长缓存命中很久的冷数据也会频繁失效。拆成两层后,各层可以采用不同的容量和 TTL,灵活性立刻高很多。

L0 热点层则是为“连 Caffeine 查找都不太想多走一步”的极端高频请求准备的,容量非常小,一般只放几十上百个 key,靠的是ConcurrentHashMap的 O(1) 查表。虽然 Caffeine 本身的查找损耗已经是纳秒级,但从架构上把热路径单独拆出来,便于做热点统计、主动预热和问题排查。后面你会看到,它还能配合热点识别机制做动态升级。

1.3 适用边界:不是所有接口都适合金字塔

金字塔这套东西天生适合“读多写少、数据量可控、允许短暂一致延迟”的接口。比较典型的包括:商品聚合信息、用户标签摘要、动态配置、字典数据、权限树等。它们的特点是同一个 key 会被非常多的请求反复读取,而且数据被更新后,有几秒到几分钟的“容忍窗口”。

反过来,强一致要求极高的数据,比如库存扣减、订单状态、现金余额,就不适合多层本地缓存。你可以在这些场景里保留 Redis 做一级加速,但要靠分布式锁、版本号、消息通知等手段严格保证一致性,本地缓存最好直接让开。

还要特别提醒一下:本地缓存是进程内缓存,如果服务有多个实例,每台机器都会有一份自己的拷贝。这意味着同一份数据在不同实例上可能出现不同的过期时刻,极端情况下某台机器缓存刚过期回源,另一台机器还命中着旧值。只要业务能接受短暂不一致,问题不大;如果接受不了,就必须引入可靠的通知失效机制。这一点在后面一致性章节会详细展开。

2. 改造前的基线打点与缓存环境搭建

2.1 拿真实压测数据说话

任何性能优化,第一步都是先把基线打准,否则后面无从对比。我这次优化的是模拟项目 X 里的一个批量商品详情接口,接口路径类似“/api/v1/product/infos”,入参是一批商品 ID,返回每个商品的聚合信息,SQL 涉及多张表 join,单次查询平均耗时在 600ms 到 800ms。

当时的压测环境是 4 核 8G 的普通单机,JDK 17,SpringBoot 3.2 版本。我用 JMeter 模拟了 200 个并发用户持续压测 3 分钟,数据如下:

方案P50 RT(ms)P99 RT(ms)QPSCPU 使用率
无缓存直查 DB520780520100%
只加 Redis 缓存38046076096%
单层 Caffeine 本地缓存3560320042%
本地金字塔 L0+L1+L2912540031%

注意,单层 Caffeine 已经让 RT 从 780ms 掉到 60ms,但最终的金字塔把 P99 压到了 12ms 左右。为什么单层不够?因为单层 Caffeine 受限于单一 TTL,数据一旦过期就会退化为整批回源;而且没有 L0,热点 key 每次仍然要走一遍 Caffeine 的统计逻辑。虽然差距没有那么大,但在极端波段下,金字塔的平坦度和稳定性明显更好。

2.2 依赖引入与版本选择

SpringBoot 3 使用 Caffeine 非常简单,Maven 里加一个依赖即可:

<dependency> <groupId>com.github.ben-manes.caffeine</groupId> <artifactId>caffeine</artifactId> <version>3.1.8</version> </dependency>

如果你的项目已经引入了spring-boot-starter-cache,它会自动创建基于 Caffeine 的缓存管理器。但我这次没有用 Spring 的 CacheManager,原因是多层级缓存的手动控制更灵活,比如同一 key 要穿透 L1/L2,或者需要自定义空值标记,用注解太绕,不如直接封装一个缓存服务类来得干净。

从版本选型上说,Caffeine 3.x 对应 Java 11+,配合 SpringBoot 3 使用的 JDK 17 没有问题。如果你还在用 Java 8,就只能停留在 Caffeine 2.x。另外提醒一句,不要在一个项目里混用 Caffeine 2 和 3 的 API,依赖冲突处理起来很烦。

2.3 为什么不直接依赖 @Cacheable 注解

很多 SpringBoot 项目习惯用@Cacheable注解,看起来简单,但遇到金字塔这种多级结构会很不舒服。第一,注解粒度是方法级,缓存 key 的构建规则和失效控制都隐藏在代理里,出了问题不好排查。第二,注解默认走 CacheManager,如果配置多级缓存,需要额外写 CacheManager 的实现,复杂度反而上去了。第三,我们经常要拿到缓存统计指标去判断一层命中率,注解方案拿这些数据会比较绕。

所以我的建议是:在中小型项目里,直接用 Caffeine 原生 API 自己封装一个CacheService,代码也就几十行,可读性和控制力都比注解好。后面所有代码都基于这个思路展开。

3. 双 Caffeine 层加 L0 热点层的完整落地

3.1 L0 热点层的设计与自清理

L0 我直接用一个非常轻量的类来实现,内部放一个ConcurrentHashMap,容量上限设为 128。为什么是 128?因为这个层只承接最高的热点,放多了不仅浪费内存,还会让无过期缓存的数据脏概率上升。代码如下:

public class HotLayer<K, V> { private final Map<K, HotEntry<V>> map = new ConcurrentHashMap<>(); private final Queue<K> accessQueue = new ConcurrentLinkedQueue<>(); private final int maxKeys; public HotLayer(int maxKeys) { this.maxKeys = maxKeys; } public V get(K key) { HotEntry<V> entry = map.get(key); return entry == null ? null : entry.value; } public void put(K key, V value) { if (!map.containsKey(key) && map.size() >= maxKeys) { K eldest = accessQueue.poll(); if (eldest != null) { map.remove(eldest); } } map.put(key, new HotEntry<>(value)); accessQueue.offer(key); } public void remove(K key) { map.remove(key); } private static class HotEntry<V> { private final V value; HotEntry(V value) { this.value = value; } } }

这里的accessQueue只做近似 LRU 淘汰,不追求绝对精确。因为容量很小,即使淘汰不够精准,对性能也没有多大影响,但代码简单了很多。另一个关键是 L0 不设置过期时间,所以写数据时必须格外谨慎,只允许放那些已经确定会在外部主动失效的数据,或者干脆不放写操作频繁的 key。

@Configuration public class PyramidCacheConfig { @Bean public HotLayer<String, Object> hotLayer() { return new HotLayer<>(128); } @Bean public Cache<String, Object> l1Cache() { return Caffeine.newBuilder() .maximumSize(5_000) .expireAfterWrite(Duration.ofSeconds(30)) .recordStats() .build(); } @Bean public Cache<String, Object> l2Cache() { return Caffeine.newBuilder() .maximumSize(50_000) .expireAfterWrite(Duration.ofMinutes(5)) .recordStats() .build(); } }

3.2 L1 秒级缓存与 L2 分钟级缓存的分工

L1 和 L2 虽然都用 Caffeine,但定位完全不同。L1 更像是“新鲜度缓冲区”,TTL 短、容量小,存放最近 30 秒内被频繁访问的数据;它的主要价值是挡住大部分请求。L2 是“稳定数据池”,TTL 长、容量大,放那些几分钟甚至更长时间不变的数据,比如商品的基本属性、静态配置等。

读取逻辑是从上到下逐层找:先看 L0,再看 L1,再看 L2,最后回源 DB 并逐级回填。这里有个细节:如果 L1 命中,我会顺手把数据放回 L0;如果 L2 命中,会放入 L1 和 L0。这样做的好处是,一个 key 一旦被请求几次,就会从 L2 升到 L0,成为真正的热 key。

L1 和 L2 的另一层价值是故障隔离。假设某个 key 在 L2 中因为 TTL 到期被淘汰,突增流量瞬间打过来,L1 可能还有旧值可以扛住一阵。即便 L1 也失效了,Caffeine 的并发构建机制也会保证回源压力不会无限扩大,这部分在击穿小节会说。

3.3 缓存代理封装与回源加载

把所有层级串起来的是一个PyramidCacheService,它的核心能力就是一个带回源函数的get方法:

@Component public class PyramidCacheService { private static final Object NULL_MARK = new Object(); private final HotLayer<String, Object> hotLayer; private final Cache<String, Object> l1Cache; private final Cache<String, Object> l2Cache; public PyramidCacheService(HotLayer<String, Object> hotLayer, @Qualifier("l1Cache") Cache<String, Object> l1Cache, @Qualifier("l2Cache") Cache<String, Object> l2Cache) { this.hotLayer = hotLayer; this.l1Cache = l1Cache; this.l2Cache = l2Cache; } public <T> T get(String group, String key, Class<T> type, Supplier<T> loader) { String innerKey = group + ":" + key; Object hotVal = hotLayer.get(innerKey); if (hotVal != null) { return type.cast(hotVal); } Object l1Val = l1Cache.getIfPresent(innerKey); if (l1Val != null) { if (l1Val == NULL_MARK) { return null; } hotLayer.put(innerKey, l1Val); return type.cast(l1Val); } Object l2Val = l2Cache.getIfPresent(innerKey); if (l2Val != null) { if (l2Val == NULL_MARK) { return null; } l1Cache.put(innerKey, l2Val); hotLayer.put(innerKey, l2Val); return type.cast(l2Val); } return loadAndCache(innerKey, type, loader); } private <T> T loadAndCache(String innerKey, Class<T> type, Supplier<T> loader) { Object value = l2Cache.get(innerKey, k -> { T loaded = loader.get(); return loaded == null ? NULL_MARK : loaded; }); if (value == NULL_MARK) { l1Cache.put(innerKey, NULL_MARK); return null; } l1Cache.put(innerKey, value); hotLayer.put(innerKey, value); return type.cast(value); } public void evict(String group, String key) { String innerKey = group + ":" + key; hotLayer.remove(innerKey); l1Cache.invalidate(innerKey); l2Cache.invalidate(innerKey); } }

这段代码里有几个容易被忽略的细节。

  • Caffeine 不允许存null值,所以空数据用NULL_MARK这个单例对象代替,从而避免缓存穿透。
  • l2Cache.get(innerKey, k -> ...)是 Caffeine 的原子构建方法,同一时刻同一个 key 只会有一个线程执行加载函数,其他线程等待结果,天然解决了缓存击穿问题。
  • 回源成功后会逐级写入 L1 和 L0,让热数据自动向金字塔上层移动。

3.4 写接口如何联动清缓存

缓存一致性最直接的手段是“写的时候主动失效”。我在修改商品信息的接口里,事务提交成功后立刻调用evict,把三层全都清掉:

@Transactional public ProductInfo updateProduct(ProductInfo info) { productMapper.update(info); cacheService.evict("product", info.getId()); return info; }

这里有一个顺序问题:不能先清缓存再更新数据库。如果先清缓存,紧接着有读请求进来发现缓存没有数据,就会回源拿到旧数据并且写回缓存,等于清了个寂寞。正确的做法是更新数据库成功后再清缓存,或者采用先写库再延迟双删的思路。

对于更新频率很高的场景,我还会配合一个异步预热任务:清缓存后立即从数据库加载一次新数据,主动填回 L2,避免清空瞬间出现大量回源。伪代码思路如下:

public void refreshProduct(ProductInfo info) { productMapper.update(info); cacheService.evict("product", info.getId()); CompletableFuture.runAsync(() -> { ProductInfo fresh = productMapper.selectById(info.getId()); cacheService.get("product", info.getId(), ProductInfo.class, () -> fresh); }); }

这种“写后主动预热”的方案说白了就是用一点额外的数据库压力,换取缓存空窗期的平稳过渡。

4. 从 760ms 到 12ms:压测结果与瓶颈转移

4.1 压测工具与场景设置

压测我用的是 JMeter,设置了 200 个线程,循环 2 分钟。服务的启动参数里加上了-Xmx4g,并且固定了 Caffeine 的内存上限。压测时重点观察三个指标:P50 延迟、P99 延迟、QPS。

没有缓存的基线是最好测的,直接打接口就行。但要注意,压测时数据库连接池、GC 频率都会干扰结果,所以每组测试至少跑 2 次,取稳定值。我在改动代码前把基线数据保存下来,谁再质疑优化效果,直接甩数据说话。

4.2 四组对比数据与结果解读

回到前面的表格:无缓存时 P99 是 780ms,CPU 已经打满;加 Redis 缓存后 P99 降到 460ms,但 CPU 依然居高不下,因为大量 CPU 花在 Redis 连接序列化和网络栈上。这一步说明 Redis 能减轻数据库压力,却没有把接口的固定成本降下来。

单层 Caffeine 的效果很惊人,P99 从 460ms 直接降到 60ms。这说明本地缓存跳过了网络往返,收益是非常确定的。但单层 Caffeine 的问题在于 TTL 单一、热点识别不足,流量波峰时会看到明显的小尖刺。加上 L0 和第二层 Caffeine 后,P99 稳定到 12ms 左右,QPS 从 520 提升到 5400,吞吐提升了接近 10 倍。

有人可能会问:P99 都降到 12ms 了,为什么标题说“加速 10 倍”而不是“加速 65 倍”?因为所谓的倍数也要看基线口径。从用户可感知的角度,原来 780ms 的接口现在 12ms 是几十倍的提升;但换到吞吐维度,QPS 提升了大概 10 倍。标题里的 10 倍更像是保守说法,方便读者理解。

4.3 长尾延迟为什么更值得关注

除了平均值,我更关注 P99 和 P95 这条长尾曲线。加了 Redis 后,平均 RT 看起来还行,但 P99 仍然很高,说明高负载下 Redis 连接、序列化这些操作会被放大。本地缓存金字塔的最大好处,是让 P99 和 P50 非常接近。12ms 和 9ms 只差 3ms,这意味着几乎不存在“掉队请求”,对上游服务的超时设置非常友好。

在微服务调用链里,上游服务常常设置 200ms 或 300ms 的超时。接口长尾一旦吃掉剩余预算,你就不得不无限扩大超时时间,或者反复重试,最终导致整条链路的资源被白白消耗。把长尾压平,比单纯追求平均 RT 更有工程价值。

5. 金字塔能不能站稳:击穿、穿透、雪崩与一致性

5.1 击穿:缓存构建合并是关键

缓存击穿指一个热点 key 过期的一瞬间,大量并发请求同时回源打到数据库。解决这个问题最简单的办法,就是让同一 key 的并发回源合并成一次。Caffeine 的get(key, mappingFunction)已经内置了这个能力,前面的PyramidCacheService正是依赖这一点。

具体行为是:多个线程同时调用l2Cache.get(innerKey, k -> loader)时,只有一个线程执行 loader 函数,其他线程阻塞等待结果。这样数据库最多只会收到一个回源请求。和加锁相比,这段逻辑几乎零成本。所以不要在 Caffeine 外面再加一层分布式锁,那是重复劳动。

如果回源耗时特别久,比如超过 1 秒,等待的线程会一直阻塞,这时候可以给加载函数设置超时,或者在 Caffeine 上配置refreshAfterWrite实现异步刷新。异步刷新的好处是过期后不会阻塞读,而是用旧值继续顶上,同时后台加载新值。

5.2 穿透:空值缓存与布隆过滤器

穿透是指请求查询一个根本不存在的数据,缓存里永远没有,导致每次都回源。这种攻击经常集中在一批不存在的 ID 上,数据库会被白打。我首先应对的手段是空值缓存:查询结果为 null 时,往缓存里放NULL_MARK,短时间内不会再次回源。

Object value = l2Cache.get(innerKey, k -> { T loaded = loader.get(); return loaded == null ? NULL_MARK : loaded; });

值得注意的是,NULL_MARK 在 L1 层也会短暂存储,但它不应该占用 L2 的长 TTL。更精细的做法是给空 key 单独设置短过期时间,比如 10 到 30 秒。如果穿透量很大,可以在业务入口加布隆过滤器或前缀位图,拦截不存在的 ID。布隆过滤器适合无法预判 ID 是否合法、但集合总量又不大的场景;如果 ID 本身有明确前缀规则,直接用字符串匹配判断往往更快。

5.3 雪崩:TTL 抖动与本地熔断开关

雪崩是大量 key 同时过期,导致回源流量瞬间暴涨。最常见的原因是我们在初始化缓存时给所有 key 设置了相同的 TTL,齐刷刷到点失效。解决办法是让过期时间尽量分散,比如在创建缓存对象时加上一个随机偏移量。

Caffeine 提供了Expiry接口支持自定义过期策略,可以在expireAfterCreate里加随机抖动,示例思路如下:

.expireAfter(new Expiry<String, Object>() { @Override public long expireAfterCreate(String key, Object value, long currentTime) { long ttl = ThreadLocalRandom.current().nextLong(20, 40); return TimeUnit.SECONDS.toNanos(ttl); } @Override public long expireAfterUpdate(String key, Object value, long currentTime, long currentDuration) { return currentDuration; } @Override public long expireAfterRead(String key, Object value, long currentTime, long currentDuration) { return currentDuration; } })

这样各个 key 的失效时间就不会集中在同一秒。除了抖动,我还习惯给本地缓存加一个全局熔断开关。当监控发现缓存命中率异常下降,或者内存占用持续攀升、GC 异常频繁时,可以手动把缓存层暂时关掉,让请求直接走 DB 兜底。虽然 DB 压力会升上去,但总比把 JVM 内存撑爆、整个服务不可用要强。

实现这个开关非常简单,用一个AtomicBoolean标记,在读取入口判断即可。注意关闭缓存前先停止继续写入,否则会出现“开关已关但还是大量访问缓存”的假状态。

5.4 一致性:主动失效与版本校验

多层本地缓存的最大软肋是数据一致性。尤其是 L0 层的ConcurrentHashMap没有过期时间,如果外部更新后漏掉了失效方法,一个旧值可能被反复服务几千次。所以我在所有写操作里都强制走evict,并约定:任何修改数据的 Service 方法,都要在事务提交后调用对应的evict。

对于某些极端场景,比如配置中心推送更新后,多个实例都要及时刷新,除了调用 evict,还可以引入版本号校验。做法是缓存里存的对象附带一个 version,L0 命中后先对比当前最新版本号,不一致则放弃缓存并重新回源。这样即便漏了某层失效,也有最后一层兜底。

另外,不要试图在本地缓存里解决全局一致性,那是不现实的。把“本地缓存允许短暂过期”这个前提提前和业务对齐,让团队知道几个毫秒乃至几秒的旧数据是可接受的,整个系统的设计会顺畅很多。

6. 内存预算、热key监控与后续可扩展的方向

6.1 给 Caffeine 定一个“够用又不 OOM”的内存预算

本地缓存直接占用 JVM 堆内存,预算必须提前算清楚。一个粗略的估算法是:假设每条缓存对象平均占 1KB,要给整个缓存层分配 200MB,那 maximumSize 大约是 20 万。如果对象里挂着大字符串或嵌套集合,平均可能到 5KB 甚至更高,那就得下调容量。

在小项目里,我倾向直接用maximumSize控制条数,不再用maximumWeight。条数一目了然,排查时也更方便。如果缓存的数据尺寸差异特别大,可以用weigher计算权重,再配合maximumWeight设置总内存上限。

Caffeine.newBuilder() .maximumWeight(200 * 1024 * 1024) .weigher((String key, Object value) -> estimateMemorySize(value)) .build();

但要慎用 weight,因为estimateMemorySize如果估计得不准,整个上限就是摆设。普通项目里还是把对象做得尽量轻量,再按条数控制比较稳妥。

6.2 通过统计指标观察金字塔各层健康度

Caffeine 的recordStats()打开后,能拿到命中次数、未命中次数、加载成功次数、加载耗时等指标。我在项目里为每组缓存加了一个定时打印任务,每分钟输出一次 L1 和 L2 的命中率。

实际操作中我见过不少案例,L1 命中率只有 30%,L2 却有 90%。这种情况下,本地缓存本身没什么大问题,问题在于 L1 的 TTL 设得太短,很多数据还没来得及在秒级层被命中就过期了。调整策略通常是把 L1 的 TTL 从 30 秒适当拉长,或者把 L0 的容量加大一点,让真正的热点能够在 L0 停留更久。指标是调整的指挥棒,别光凭感觉调参。

6.3 从本地金字塔走向“本地+分布式”混合金字塔

如果你的服务有多个实例,本地缓存必然存在数据重复存储的问题。这时候可以把金字塔扩展成混合结构:L0/L1 依然留在本地,L2 接 Redis,DB 最后兜底。这样本地缓存负责挡住本实例热点,Redis 负责跨实例共享和冷数据回源。L2 未命中再去查 Redis,Redis 未命中再去查 DB,并将结果逐级回填。

这种混合结构的优势是,实例之间的缓存失效不会互相影响,同时数据库回源压力大幅降低。缺点是链路变长,需要额外处理 Redis 网络异常时的降级策略。我的建议是,如果单机 QPS 已经足够高,数据量也不大,纯本地金字塔就够了;只有遇到多实例数据不一致、或者内存不够放全量热数据时,再考虑混合方案。

6.4 后续还能继续做的三件事

第一,热点识别自动化。当前 L0 是靠命中路径被动升级,更主动的做法是统计每个 key 在一段时间内的访问次数,超过阈值就自动放入 L0,长期未命中则自动降级。

第二,缓存预热。服务启动后,把已知的高频 key 列表提前加载到金字塔里,避免刚启动那段时间所有请求直接打 DB。预热代码可以在ApplicationRunner里执行,也可以挂到配置中心的变更事件里。

第三,对 Caffeine 参数做动态调整。如果项目接入了配置中心,可以把 L1 的 TTL、L2 的 maximumSize 都做成配置项,调参不用重启服务,压测和治理会方便很多。

我在实际项目里做完这套本地缓存金字塔后,最大的体会是:性能优化不是“加中间件”,而是想清楚每个请求到底被什么拖慢。早点找到那个根因,一个几十行的封装就能换来几倍甚至几十倍的性能提升。最后提醒一句,这套方案只适合读多写少、容忍短暂数据延迟的业务;如果数据强一致要求苛刻,别硬套金字塔。先用一层 Caffeine 扛住流量,观察命中率和热点分布,再决定要不要往上加层,这样不会过度设计。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询