本地缓存实战指南:从Caffeine选型到缓存击穿与一致性排查
发布时间:2026/9/24 23:18:46来源:尧图网络
做了几年后端接手的系统一多你就会发现一个特别朴素的道理性能问题十有八九是数据访问太慢而数据访问太慢的根源十有八九是数据库扛了太多本该由缓存扛的流量。尤其是当你的接口被同一个热点数据频繁打爆或者列表页、详情页的查询QPS高居不下时最直接、成本最低、见效最快的手段就是先做一层本地缓存。本地缓存这个概念说白了就是把数据放到应用进程的内存里下次再要的时候直接从内存拿根本不去查数据库、不去调远程服务。它不像Redis那样要维护一套独立的集群也不引入网络开销对于单机应用或者对数据一致性要求不那么苛刻的场景本地缓存几乎是最理想的第一道防线。这篇文章我不打算讲太虚的理论而是从一个实际项目的角度把“如何实现本地缓存”这件事从头到尾拆开聊清楚选型、设计、落地、排坑的完整链路。内容主要面向有基本Java基础、想给自己的服务做性能优化的后端开发。跟着走一遍你自己就能在项目里快速落地一套可靠的本地缓存方案。1. 先搞清楚什么时候该用本地缓存本地缓存不是万能药用得好是利器用不好就是给项目埋雷。很多同学一上来就想着把整个数据库表都塞进内存这种做法我见过太多次最后基本都会因为内存溢出或者数据不一致被回滚。所以在动手写代码之前必须先想清楚你的场景到底适不适合本地缓存1.1 本地缓存和分布式缓存的边界先做一个明确的区分。我在实际项目里的判断标准很简单一句话能放本地缓存的一定是“每个节点都能独立算出来、并且容忍一定程度的不一致”的数据。举个例子你的服务部署了三个实例用户请求经过负载均衡随机打到这三台机器上。如果你把用户信息放到本地缓存那用户第一次请求打到A机器信息缓存到A的内存里第二次请求打到B机器B的内存里没有又得查一次数据库。虽然最终还是能查到但缓存命中率会因为多实例而大打折扣。如果是这种数据就应该放到Redis这种分布式缓存里大家共享一份。反过来像商品的基础信息、配置中心的参数、字典数据、组织架构这种“读多写少、变化频率低、所有实例看到的值基本一致也无所谓”的数据放在本地缓存里就非常合适。每个实例自己保有一份访问速度是纳秒级的连网络IO都省了。这里我给一个判断依据你可以直接拿去用判断维度适合本地缓存适合分布式缓存如Redis数据规模小几百KB到几百MB以内大可以支撑几十GB甚至更大更新频率很低分钟甚至小时级别更新一次相对频繁但读取远大于写入一致性要求弱一致允许各节点短暂不同步强一致或较严格的一致性访问模式同一份数据被大量并发读取多实例共享、跨节点访问预算与运维成本零额外成本进程内搞定需要维护独立服务集群1.2 这类缓存的读写比决定了收益上限我一直强调一个点缓存的价值取决于读多写少的程度也就是读写比。如果一个接口的读写比是 1:1甚至写入比读取还频繁那你每缓存一次可能没过多久数据就变了缓存还没来得及被复用就被失效了反而增加复杂度。正常的本地缓存使用场景读写比至少在 10:1 以上最好能达到 100:1 这种量级。举个典型例子我之前做过一个活动运营后台运营人员每天只改几次活动配置但用户端的活动展示接口每秒钟有上千次请求。这种情况不搞缓存就太亏了。因为配置数据本身是经过审核的、发布频率极低、而且所有用户看到的内容必须一致非常适合做本地缓存 定时刷新的组合策略。2. 本地缓存的几种实现方案与选型本地缓存的具体实现从简单到复杂基本有四个层次手写一个Map、用Guava Cache、用Caffeine、以及引入分布式缓存框架。各有各的适用场景我逐个拆给你看。2.1 手写ConcurrentHashMap很多坑在后面等你先说说大家最熟悉的手写方案。很多初学者第一次做本地缓存就写一个ConcurrentHashMapString, Object然后get的时候先查Map查不到再查数据库查完塞回去。代码大概长这样public class LocalCache { private static final MapString, Object CACHE new ConcurrentHashMap(); public Object get(String key) { Object value CACHE.get(key); if (value ! null) { return value; } // 模拟从数据库查询 value queryFromDB(key); CACHE.put(key, value); return value; } }说实话这个方案能解决一部分问题但离“可用”还有很长的距离。你至少会遇到这几个问题没有过期机制数据永远留在Map里占着内存不放。如果key是动态生成的比如商品ID时间久了Map会越来越大最终触发Full GC。没有淘汰策略就算你加了过期时间当缓存条目达到一定数量时也没有办法自动把不常用的旧数据淘汰掉。并发穿透问题当缓存条目还没有建立时如果突然涌进来大量请求它们会发现缓存里没有于是同时去查数据库这就是经典的缓存击穿。所以说手写Map只适合用来做“临时性、小规模、非常低频”的缓存比如单次请求内复用某个计算结果。真正要上生产环境还是得用成熟的开源库。2.2 Guava Cache与Caffeine一次全面的对比在Java生态里最成熟的两个本地缓存库就是Guava Cache和Caffeine。Caffeine可以理解为Guava Cache的加强版两者API高度兼容但Caffeine的底层数据结构、淘汰算法、并发性能都要优于Guava。特性Guava CacheCaffeine淘汰算法LRU最近最少使用W-TinyLFU高频近期结合并发性能较好分段锁优秀基于ConcurrentHashMap升级异步加载不支持支持异步刷新统计信息基础丰富支持命中率、加载耗时等内存占用量估算不支持支持基于权重估算生态活跃度维护较少活跃Spring Boot默认推荐重点说一下Caffeine的W-TinyLFU算法。LRU有一个典型问题一个数据在短时间内被大量访问之后再也不访问它也会一直占着位置。而W-TinyLFU结合了访问频率和访问新鲜度它能识别出哪些数据是“高频热点”即使很久没被访问也尽量保留那些只有一次性高并发、之后毫无价值的数据很快就会被淘汰。我用Caffeine替换Guava之后最直观的感受就是缓存命中率明显提升了内存占用反而下降了。对于新项目我不建议再用Guava Cache了直接上Caffeine就好。如果你还在用Guava只能说明项目历史包袱太重迁移的意愿不够强。2.3 场景化选型建议为了不让你看了一堆理论还在纠结我直接给几个结论性的建议单体应用、数据量在万级以内Caffeine配置简单、性能极好是首选。需要和Spring Boot深度整合直接引入caffeine或者spring-boot-starter-cache用Cacheable注解就能搞定开发效率很高。已经用了Redis但想加一层进程内缓存做二级缓存可以考虑Caffeine做一级缓存Redis做二级缓存。就是不想引入第三方依赖项目对依赖数量极度敏感那也要自己封装一个带有过期时间和淘汰策略的缓存工具类不能裸写ConcurrentHashMap。3. 核心实操基于Caffeine落地一套本地缓存方案确定下来之后接下来的重点就是如何落地。这里我会从头到尾演示一套基于Caffeine的本地缓存实战代码同时把每个参数、每个配置的含义讲清楚。3.1 引入依赖并理解核心配置参数第一步在pom.xml里引入Caffeinedependency groupIdcom.github.ben-manes.caffeine/groupId artifactIdcaffeine/artifactId version3.1.8/version /dependency接下来是最关键的构建过程。Caffeine缓存对象是通过Caffeine.newBuilder()模式创建的里面有几个核心参数需要仔细斟酌。CacheString, ConfigItem cache Caffeine.newBuilder() .maximumSize(10_000) // 缓存最多存放1万个条目 .expireAfterWrite(5, TimeUnit.MINUTES) // 写入5分钟后过期 .recordStats() // 开启命中率统计 .build();这几个参数里maximumSize是条目数量的上限一般根据内存预算和场景来定。比如你要控制缓存占用不超过200MB每个条目平均2KB那上限可以设为10万条。expireAfterWrite适合“写入后固定时间过期”的场景比如配置数据每5分钟刷新一次。除了expireAfterWrite还有expireAfterAccess——最后访问后固定时间过期适合频繁访问需要续期的场景以及expireAfter——基于自定义过期策略最灵活。3.2 用CacheLoader实现“读时回源”Caffeine有两种加载模式一种是像上面那样手动get然后自己判断是否需要回源另一种是使用LoadingCache通过CacheLoader定义数据加载逻辑当缓存未命中时Caffeine会自动调用加载逻辑回源。LoadingCacheString, ConfigItem loadingCache Caffeine.newBuilder() .maximumSize(10_000) .refreshAfterWrite(3, TimeUnit.MINUTES) .build(new CacheLoaderString, ConfigItem() { Override public Nullable ConfigItem load(String key) { // 这里执行真正的数据查询逻辑 return queryConfigFromDB(key); } });这里注意refreshAfterWrite和expireAfterWrite的配合。refreshAfterWrite控制在数据写入多久后下一次访问时异步刷新数据expireAfterWrite控制在多久后强制失效。两者配合可以做到数据3分钟异步刷新5分钟强制过期。这样可以避免缓存击穿——即使刷新失败旧数据还能继续服务一段时间。3.3 避免缓存击穿的实战技巧缓存击穿是最典型的坑。场景是这样的一个热点key即将过期或者一开始还没被缓存此时恰好有上千个请求同时来查这个key。如果每个请求都回源查库数据库瞬间被打满。你可能会想那我在缓存里加一个互斥锁不就行了用Synchronized或者ReentrantLock让同一时刻只有一个请求回源其他请求等待。这个思路是对的但要注意锁的粒度必须控制在单个key上而不是所有key共用一把锁。Caffeine对这个场景有原生支持LoadingCache.get方法内部已经做了并发控制——当多个线程同时获取同一个key且缓存缺失时只有一个线程会执行CacheLoader.load其他线程会等待其结果。这一点Caffeine的设计做得很完善这也是我推荐直接用Caffeine而不是手写Map的核心理由之一。3.4 缓存预热与存活性检查生产环境中一个缓存刚建立时命中率必然很低因为所有数据都是“冷”的。如果系统重启之后瞬间有大流量进来数据库压力会非常大。我习惯在服务启动之后做一个缓存预热任务把核心热点数据提前加载进缓存。Component public class CacheWarmer implements ApplicationRunner { Autowired private ConfigService configService; Override public void run(ApplicationArguments args) { ListString hotKeys configService.listHotKeys(); hotKeys.forEach(key - loadingCache.put(key, configService.getConfig(key))); log.info(缓存预热完成共加载 {} 条配置, hotKeys.size()); } }还有一种场景需要额外注意缓存中的数据虽然没过期但DB中的值已经被修改了。如果业务允许延迟几秒钟生效那refreshAfterWrite就够了如果业务要求尽快失效可以在写操作时主动调用loadingCache.invalidate(key)把失效逻辑精确到key。4. 手写一个更轻量的本地缓存工具类上一部分讲的Caffeine方案已经可以应对绝大多数场景了。不过还是会有一些时候你不想引入这么重的依赖或者你需要更精细地控制整个缓存生命周期。这时候自己封装一个轻量的缓存工具类也是不错的选择。但前提是不能用裸的ConcurrentHashMap直接返回至少要解决掉过期和淘汰的问题。4.1 设计一个包含过期时间的基本结构首先我们需要一个内部结构把“数据”和“过期时间”绑在一起。缓存对象不能直接存储裸值而是存一个包装对象。同时你还得想清楚如果内存一直在涨怎么办轻量方案可以基于访问顺序做简单的LRU淘汰这里直接用了LinkedHashMap。public class SimpleLocalCacheK, V { private static class CacheEntryV { final V value; final long expireAt; CacheEntry(V value, long expireAt) { this.value value; this.expireAt expireAt; } } private final MapK, CacheEntryV cache new LinkedHashMap(); private final long expireAfterWrite; private final int maxSize; public SimpleLocalCache(long expireAfterWrite, int maxSize) { this.expireAfterWrite expireAfterWrite; this.maxSize maxSize; } public synchronized V get(K key) { CacheEntryV entry cache.get(key); if (entry null) { return null; } if (entry.expireAt System.currentTimeMillis()) { cache.remove(key); return null; } return entry.value; } public synchronized void put(K key, V value) { long expireAt System.currentTimeMillis() expireAfterWrite; cache.put(key, new CacheEntry(value, expireAt)); // 如果超出最大容量移除最早的条目这里只是最基本的做法 if (cache.size() maxSize) { K oldestKey cache.keySet().iterator().next(); cache.remove(oldestKey); } } }这个实现里我用了synchronized保证线程安全但在高并发场景下锁竞争会很大。如果真有高并发的需求你可以升级为ConcurrentHashMap 分段锁。不过说实话如果你发现自己要写第二版、第三版才能满足需求就直接上Caffeine吧它把这些坑都替你趟过了。4.2 补充定时清理和监控能力上面这个工具类还有一个隐患如果key一直不被访问过期的条目会一直留在Map里。虽然get时能发现过期并移除但那些永远不被访问的过期条目会占据内存直到触发淘汰。所以需要做一个定时的后台清理任务。public void init() { scheduledExecutor.scheduleAtFixedRate(() - { long now System.currentTimeMillis(); cache.entrySet().removeIf(entry - entry.getValue().expireAt now); }, 1, 1, TimeUnit.MINUTES); }这个定时清理还有一个好处你可以同时打印缓存大小、内存占用等指标排到监控系统里。我在生产环境就吃过一次亏因为缓存里堆积了大量带时间戳的临时key内存持续增长最后Young GC频繁接口RT上升。加了定时清理之后内存稳稳当当地保持在一个水位线上。所以不管用什么方案监控和清理能力绝对不能省。5. 生产环境里的几个关键问题排查方案落地之后真正的挑战才开始。你可能遇到命中率低、数据不一致、内存增长、缓存击穿等各种各样的问题。这一节专门来梳理高频故障场景和对应的排查思路都是我实际踩过或者帮助别人排查过的经验。5.1 缓存命中率上不去怎么办你辛辛苦苦加了缓存但命中率一直徘徊在50%以下这意味着有一半的请求还是穿透到了数据库。遇到这种情况第一步是开启Caffeine的recordStats()然后把命中率暴露到监控里。CacheStats stats loadingCache.stats(); double hitRate stats.hitRate(); long missCount stats.missCount();命中率低的常见原因有三个key设计不合理。比如把用户ID拼到key里那每个用户都有自己的缓存条目复用性很差。如果所有用户看到的数据是一样的key里就不应该包含用户维度。过期时间太短。数据刚写进去还没来得及被复用几次就过期了下个请求又来重新加载。缓存容量太小。条目不断被淘汰导致该命中的没有命中。针对这些原因我的处理思路是先看监控里每个key的访问频次和淘汰次数找出高频但被淘汰的key针对性地调整参数。比如把这类key的过期时间调长、或者提高缓存容量上限。5.2 缓存与数据库的一致性如何保障这是本地缓存绕不开的灵魂拷问。你数据库里改了本地缓存怎么同步如果同步不及时用户看到的还是旧数据。解决的思路一般有这几种设置较短的过期时间。最通用的方案比如配置数据5分钟过期最多5分钟内读到旧数据。主动失效。在写入、修改、删除业务数据时同步调用invalidate(key)把缓存清掉。这种方式响应最快但要求所有写操作都能感知到缓存。定时刷新。后台任务每隔一段时间全量或增量加载数据到缓存。消息通知。在分布式架构下如果其他服务修改了数据通过MQ广播消息让所有节点的本地缓存都失效。实际的项目中经常是“过期时间 主动失效”双管齐下。主动失效保证大部分更新能及时生效过期时间兜底防止由于某些异常漏删了缓存。5.3 高频热点key被淘汰的坑很多人会忽略一个问题Caffeine的容量是“条目数”而不是“内存大小”。默认的淘汰策略是按照条目数来的如果你缓存的对象体积差别很大可能出现内存没超但是某个高频热点key被淘汰了的情况。解决办法是用maximumWeight代替maximumSize给不同对象设置不同的权重。比如一个大对象权重是100一个小对象权重是1那么缓存总权重可控就能更精细化地分配内存。CacheString, Object cache Caffeine.newBuilder() .maximumWeight(100_000) .weigher((key, value) - value.toString().length() / 1024) .build();这里weigher返回的是对象的“代价”可以简单理解成估算的内存占用。我一般用对象序列化后的大小或者字段数量来估算。当然这只是估算真正精确的还是要用JVM的jmap或者VisualVM去看。6. 多级缓存与最佳实践总结6.1 本地缓存 Redis的分层架构当系统规模变大、实例数变多本地缓存有时就不够用了因为每个实例持有一份数据数据总量是实例数的倍数。你的选择有两个一是加大缓存容量让每个实例都能装下所有热点数据二是引入二级缓存让Redis统一管理一份本地缓存只保存最热的数据。我比较推荐第二种方案。具体的请求查询路径是这样的先查本地Caffeine命中直接返回未命中查RedisRedis未命中查数据库回填Redis并回填本地缓存返回给调用方。用代码表示大概是public ConfigItem getConfig(String key) { ConfigItem config localCache.getIfPresent(key); if (config ! null) { return config; } String redisKey config: key; ConfigItem config redisTemplate.opsForValue().get(redisKey); if (config null) { config queryFromDB(key); redisTemplate.opsForValue().set(redisKey, config, 10, TimeUnit.MINUTES); } localCache.put(key, config); return config; }这个架构的好处是本地缓存解决了超热数据的访问速度问题Redis解决了多实例共享和一致性问题数据库只承担最终的兜底压力。多级缓存多了一层复杂度但如果你的系统并发确实很高这个复杂度就值得付出。6.2 一套可以直接落地的配置范式最后我在文章开头说要给一套可以直接复制的方案在这里交个底。如果你的场景是“字典表、配置项、低频更新数据”的查询加速下面的配置组合可以直接拿过去调参使用LoadingCacheString, ConfigItem cache Caffeine.newBuilder() .maximumSize(5_000) .expireAfterWrite(10, TimeUnit.MINUTES) .refreshAfterWrite(5, TimeUnit.MINUTES) .recordStats() .build(new CacheLoaderString, ConfigItem() { Override public Nullable ConfigItem load(String key) { return queryFromDB(key); } });参数含义maximumSize最多缓存5000个配置项可以根据实际配置总量调整expireAfterWrite10分钟后强制过期refreshAfterWrite5分钟后下一次访问时异步刷新recordStats开启监控方便看命中率CacheLoader未命中时自动回源加载。如果是Shiro权限、用户Token这类数据访问非常频繁而且需要快速失效可以把expireAfterWrite缩短到2~3分钟同时增加主动失效机制。6.3 最后说点实在的本地缓存做过很长时间我对它的理解也在不断变化。单看技术实现它确实不难难的是对场景的判断和参数的调优。很多系统并不是不会用缓存而是“滥用缓存”把数据一致性要求很高的内容也放进了本地缓存或者设置的过期时间过长导致业务数据严重滞后。我自己的原则很简单缓存是用来扛热点的不是用来承载业务逻辑的。每一个数据要不要缓存、缓存多久、如何失效都应该有明确的业务依据而不是拍脑袋。如果你现在正被接口慢、数据库压力大的问题困扰不妨先别急着上复杂的中间件先审视一下自己的数据访问模式把最热的几条查询路径找出来用Caffeine加上一层本地缓存。从实现到验证通常半天时间就能完成而收益却是立竿见影的。这大概也是“本地缓存”这个看似老生常谈的话题至今仍然值得好好研究的原因。
网站建设高端定制企业官网