ARTICLE DETAIL

资讯详情

深耕郑州网站建设与运营推广的一线实战洞察。

SpringBoot整合Ehcache本地缓存实战:配置、注解与避坑

SpringBoot整合Ehcache本地缓存实战:配置、注解与避坑 做了几年Java后端只要接口QPS稍微上来一点第一个被盯上的就是数据库。明明一张配置表半天不动却每一次请求都全量查一遍库这种浪费我反正是看不下去的。这时候缓存就得上了而很多人一上来就无脑搞Redis其实在单机应用、高并发读少写、数据一致性要求不那么苛刻的场景里本地缓存反而是性价比最高的选择。Ehcache作为老牌本地缓存框架和SpringBoot整合非常顺手注解一键搞定无需额外部署中间件项目里引入即用。这篇就基于我的实际落地经验把SpringBoot整合Ehcache(本地)缓存从选型、配置、注解用法到避坑完整讲一遍。1. 本地缓存选型为什么是Ehcache而不是无脑上Redis1.1 先想清楚场景本地缓存解决什么问题本地缓存说白了就是把数据直接放在JVM内存里应用自己管理一份热点数据的副本。它的核心优势只有两个快以及省。快是因为数据就在本进程内存中连网络IO都省了读取耗时一般在微秒到几十微秒级别而Redis再怎么快一次网络往返也要零点几毫秒起步。省是省在部署上不用额外维护一个缓存中间件尤其适合小型项目、单体应用、内网部署的工具类系统。但本地缓存也有明确的短板每个实例各存一份数据不一致是天然存在的。假如你有多个应用实例A实例更新了数据B实例的缓存还在用旧值这个延迟在某些业务里能忍在某些业务里就是事故。所以用本地缓存前一定要先判断场景。我的经验是适合用本地缓存的数据有三类读多写少、允许短时间不一致、单机或极少实例部署。典型例子是数据字典、系统配置、商品详情类的低敏数据。反过来说如果数据更新频繁、对一致性要求极高或者应用已经有多实例负载均衡那就老老实实上Redis这种分布式缓存别拿本地缓存硬扛。1.2 Ehcache、Caffeine、ConcurrentHashMap怎么选Java生态里做本地缓存绕不开三个选择Ehcache、Caffeine以及看起来最简单实则隐患最多的ConcurrentHashMap。先泼一盆冷水直接拿ConcurrentHashMap当缓存用的我见过太多了。它本质是一个并发安全的Map压根没有“过期淘汰”这个概念。你往里面put一个key除非手动remove否则它就在内存里待一辈子最终的结果就是内存泄漏、老年代飙升、GC压力越来越大。更别提什么LRU/LFU淘汰、TTL过期、命中率统计这些缓存该有的能力全都没有。如果你的缓存逻辑只是临时放一下、能接受手动清理那用它没毛病但一旦数据多了就会成为事故苗子。再看Caffeine和Ehcache这俩才是真正的“本地缓存框架”。Caffeine是后来者以高性能著称内部基于ConcurrentHashMap改良支持TTL、LRU、W-TinyLFU淘汰策略API设计非常现代和Spring Cache的整合也很顺畅是目前很多新项目的首选。Ehcache则胜在“老而弥坚”从上个时代的Hibernate二级缓存一路走过来2.x时代遍地都是3.x版本虽然API改成了JCache(JSR-107)风格但生态成熟、文档齐全尤其擅长做堆内存、堆外内存、磁盘三层存储架构而且自带缓存管理后台。我的选型建议其实很朴素如果你追求极致性能并且缓存数据量不大、纯内存就够用Caffeine更合适如果你需要缓存可以落地到磁盘、需要超出堆内存限制的存储能力或者团队对Ehcache的历史用法更熟悉那就选Ehcache。这篇既然讲的是Ehcache我们就把它讲透。维度Ehcache 3.xCaffeineConcurrentHashMap过期策略TTL/TI支持堆内、堆外、磁盘TTL支持多种淘汰算法不支持淘汰策略LRU/LFU等W-TinyLFU不支持最大容量控制支持(entries或MB)支持不支持持久化支持磁盘持久化不支持不支持Spring整合复杂度低(自动配置)低(自动配置)高(需自己包装)适用场景需要多层存储、历史系统高性能纯内存缓存极简单临时存取2. SpringBoot整合Ehcache的配置全解2.1 依赖与版本搭配避开这些坑SpringBoot整合Ehcache第一步就是引入依赖。Spring Boot本身提供了一套基于spring-boot-starter-cache的缓存抽象它不关心你底层用哪种缓存实现只负责把Cacheable这类注解翻译成对应的缓存读写操作。所以我们只需要引入starter再把Ehcache的实现类放到classpath里Spring Boot的自动配置机制会自动识别并创建对应的CacheManager。Maven依赖长这样dependencies !-- Spring缓存抽象 -- dependency groupIdorg.springframework.boot/groupId artifactIdspring-boot-starter-cache/artifactId /dependency !-- Ehcache 3.x 实现 -- dependency groupIdorg.ehcache/groupId artifactIdehcache/artifactId version3.10.8/version /dependency /dependencies这里有三个细节一定要留意第一Ehcache的groupId。我见过有同事从老项目里复制了net.sf.ehcache的依赖过来那是Ehcache 2.x的坐标API和3.x完全不兼容。3.x必须用org.ehcache:ehcache不要搞混。第二Spring Boot 3.x引入了Jakarta EE规范如果你用的是Spring Boot 3.2以上版本注意确认你的Ehcache版本是否兼容3.10.x系列在Spring Boot 3.x下实测是没问题的。第三如果你还要用注解的JCache标准API(JSR-107)比如CacheResult这类那还得额外引入javax.cache:cache-api依赖但日常用Spring的Cacheable就够了没必要画蛇添足。版本锁定方面我的建议是别在这上面追求“最新”稳定即可。Ehcache 3.10.x是目前最稳的版本线Spring Boot 2.7和3.x都能用别轻易上4.x(如果你看到的话)生态迁移成本不划算。2.2 ehcache.xml核心参数逐项拆解引入依赖后我们还需要一份Ehcache的配置文件默认叫ehcache.xml放在src/main/resources下。Spring Boot会自动去classpath根目录找这个文件名如果找不到就会用缺省配置但缺省的堆内存限制只有64MB生产环境显然不够用所以必须自己写。一份最基础的ehcache.xml长这样config xmlns:xsihttp://www.w3.org/2001/XMLSchema-instance xmlnshttp://www.ehcache.org/v3 xsi:schemaLocationhttp://www.ehcache.org/v3 https://www.ehcache.org/schema/ehcache-core-3.10.xsd !-- 持久化目录只有配置了它disk持久化才可用 -- persistence directory/var/data/ehcache / !-- 定义一个缓存模板方便多个cache复用配置 -- cache-template namedefaultTemplate key-typejava.lang.String/key-type value-typejava.lang.Object/value-type expiry ttl unitminutes10/ttl /expiry heap unitentries5000/heap /cache-template !-- 实际的缓存用户详情缓存 -- cache aliasuserDetailCache uses-templatedefaultTemplate expiry ttl unitminutes30/ttl /expiry heap unitentries10000/heap /cache !-- 字典缓存过期时间长一些因为字典数据极少变动 -- cache aliasdictCache uses-templatedefaultTemplate expiry ttl unithours2/ttl /expiry /cache /config这里有几个参数需要重点理解。key-type和value-type声明缓存的key和value类型。Spring Cache的注解中key通常是String、Longvalue则是业务对象。类型写错会在运行期抛ClassCastException所以如果业务对象的类型不固定建议value-type直接写java.lang.Object省心。expiry过期策略。最常用的是TTL(Time To Live)也就是从上一次写入或更新成功开始经过固定时间后过期。还有一种TTI(Time To Idle)指的是空闲多长时间过期意思是只要这个key一直被访问就永远不会过期适合“访问越频繁越不想让它消失”的热点数据。日常90%的场景用TTL就够了TTI反而容易把一些冷数据长期留在内存里不好控制。heap堆内缓存的最大容量单位可以是条数(entries)也可以是MB。这里我强烈建议优先用entries因为它只限制缓存对象个数不会因为单个对象很大就导致内存暴涨也不易引发频繁GC。如果某个缓存里放的是大对象比如几MB的序列化JSON那才考虑用MB计量。想用上堆外内存和磁盘存储加一段resources配置就行cache aliasbigDataCache resources heap unitentries10000/heap offheap unitMB512/offheap disk unitMB persistenttrue1024/disk /resources /cache这个三层架构是Ehcache区别于Caffeine的最大卖点。堆内存放热点中的热点速度最快堆外内存不受JVM堆大小限制能放更多数据但访问稍慢磁盘存储用于容纳超大数据集重启后还能通过persistent恢复。实际项目中我一般只开heap和offheap两层disk用得不多因为一旦用了disk缓存读写就要经过磁盘IO反而拉低了整体速度。2.3 自动装配原理与CacheManager配置依赖和配置文件都到位后Spring Boot的自动配置会帮你完成剩下的脏活累活。具体来说CacheAutoConfiguration会在classpath里发现Ehcache的相关类后自动创建EhCacheCacheManager并读取classpath根目录下的ehcache.xml来初始化缓存实例。这里再聊一下Spring Boot自动装配的原理其实就一句话Spring Boot在启动时会扫描META-INF/spring.factories文件发现CacheAutoConfiguration它的条件注解ConditionalOnClass和ConditionalOnMissingBean会判断当前环境是否已有CacheManager如果没有就用默认策略帮你new一个。所以你什么都不用配只要把依赖放进去它就能跑起来这也就是为什么很多入门者搞不懂“我明明一行代码没写怎么就有cacheManager了”。不过自动配置也有自己的脾气。如果你用的是非默认配置文件比如不想叫ehcache.xml或者放在别的目录那必须在application.yml里显式指定路径spring: cache: type: ehcache ehcache: config: classpath:config/ehcache-custom.xmltype: ehcache是强制指定缓存类型正常来说Spring Boot能自动识别但显式写出来可以防止classpath里同时存在Redis依赖时Spring Boot优先选择了Redis作为CacheManager这种“多缓存依赖共存时选错实现”的坑我是踩过的。当时项目里引入Redis做其它用途结果Spring Boot自动把缓存管理器换成了RedisCacheManager所有Cacheable注解全跑到Redis去了本地缓存成了摆设排查了半天才发现是这个原因。记住了多个缓存实现共存时必须显式声明spring.cache.type。3. 缓存注解实战三个注解覆盖90%场景3.1 Cacheable读缓存key生成与条件表达式的讲究Spring缓存抽象的编程模型非常简单核心就是三个注解Cacheable、CachePut、CacheEvict。先用好这三个日常90%的缓存需求都覆盖了。Cacheable是“读缓存”的入口。方法被调用时Spring会先检查缓存里有没有对应的key有就直接返回缓存结果方法体根本不会执行没有才执行方法体并把返回值写入缓存。用法看代码Service public class UserService { Cacheable(cacheNames userDetailCache, key #userId) public User getDetail(Long userId) { // 这里只会在缓存未命中时执行 return userMapper.selectById(userId); } }这段代码的意思查询用户详情时先去userDetailCache这个缓存里按userId找找不到再查数据库查完把结果放进缓存。key这里用的是SpEL表达式。Spring Cache默认的key生成策略是把所有方法参数组合起来比如getDetail(1001L)的默认key就是1001。但实际业务里一个方法往往有多个参数默认策略会把它们全拼起来所以显式指定key反而更可控。key支持很多写法#userId取参数名#p0取第一个参数参数没名字时可用兼容性好#user.id取参数的某个属性#root.methodName取方法名适合作为key前缀拼接多说一句SpEL表达式只适合表达简单的key逻辑如果你需要很复杂的key生成规则不要硬写在注解里还是实现KeyGenerator接口更清晰。Cacheable还有两个兄弟属性condition和unless。condition在方法调用前就判断如果为false这个方法压根不参与缓存逻辑每次都是真实调用unless则在方法执行完毕后根据返回值判断如果为true返回值不会被放入缓存。举个例子Cacheable(cacheNames userDetailCache, key #userId, condition #userId 0, unless #result null) public User getDetail(Long userId) { return userMapper.selectById(userId); }condition意思是只有userId大于0时才启用缓存负数这种异常入参直接透传数据库unless意思是如果返回null就别把null缓存进去。这两个属性看起来简单但用好了可以规避大量缓存穿透问题我后面会专门展开讲。3.2 CachePut与CacheEvict写和删不出错读写不分离的缓存体系是没有意义的。数据在库里变了缓存里的旧值还在读到的就是脏数据。Spring提供了两个注解处理这个事。CachePut表示“方法执行一定成功并把返回值更新到缓存中”。它和Cacheable最核心的区别是Cacheable在命中缓存时方法不执行CachePut则每次都会执行方法并且无论缓存里原来有没有都把最新返回值写进去。典型用法是在更新操作后同步刷新缓存CachePut(cacheNames userDetailCache, key #user.id) public User updateUser(User user) { userMapper.updateById(user); return user; // 注意返回值会覆盖旧缓存 }这里有个新手最容易踩的坑CachePut如果返回void那缓存里就会被放入null这是没意义的。所以这个方法必须返回更新后的完整对象而不是“影响行数”这类结果。如果更新方法返回值不是业务对象我更推荐拆两步先执行更新再通过CacheEvict把旧缓存删掉让下次查询时重新加载。CacheEvict就是“删缓存”。既然删了下次读取时缓存未命中就会重新查库写入数据自然就新鲜了。常用的几种写法// 删除指定key的缓存 CacheEvict(cacheNames userDetailCache, key #id) public void deleteUser(Long id) { ... } // 删除整个userDetailCache缓存的所有条目 CacheEvict(cacheNames userDetailCache, allEntries true) public void clearUserCache() { ... } // 在方法执行前就删除缓存避免方法抛出异常时缓存没删掉 CacheEvict(cacheNames userDetailCache, key #id, beforeInvocation true) public void deleteUserEager(Long id) { ... }beforeInvocation默认是false意思是在方法执行成功之后才删除缓存。如果方法执行过程中抛了异常缓存就不会被删因为Spring默认认为“操作失败了就别动缓存”。但某些场景下你希望即使方法报错旧缓存也别留在那那就把beforeInvocation设为true。3.3 过期策略与驱逐策略怎么配合注解只解决了缓存读写的触发时机问题缓存里数据什么时候失效还得靠我们在ehcache.xml里配置的expiry和heap来兜底。这里有一个特别重要的认知Spring Cache的注解层面没有“过期时间”这个参数TTL的有效期完全由底层CacheManager决定也就是ehcache.xml里配置的expiry。同一个cacheNames注解上只能指定用什么缓存具体过期多久还是配置文件说了算。所以配置文件里的缓存alias必须和注解的cacheNames一一对应。比如我们前面定义了userDetailCache注解里写cacheNames userDetailCache它才会落到这个缓存实例上使用这里配置的30分钟TTL、10000条目上限。如果注解里写了个userDetailCache但配置文件里没有这个aliasEhcache会启动报错告诉你找不到对应的缓存。驱逐策略这块heap容量到达上限后Ehcache会按照内部的驱逐算法把一些条目挤出去。Ehcache 3默认采用近似LRU策略也就是优先淘汰最久没被访问的数据为新数据腾位置。理解了这一点你就明白为什么TTL和heap上限是两套独立的东西TTL管的是“时间到了就过期”heap上限管的是“空间不够就先扔”。两者共同决定一个key从写入到真正消失的实际时刻是哪一个先触发。实战中我的习惯是给不同类别的数据设置差异化的过期时间而不是所有缓存统一一个TTL。字典类可以2小时用户详情类30分钟验证码这类干脆5分钟这也是为什么我强调用cache-template批量定义模板而不是写死每一处结构干净多了。4. 完整案例用户详情缓存从0到14.1 场景设计与缓存命名规范纸上谈兵没意思我拿一个最常见的用户详情查询场景完整走一遍。假设我们有一个User表数据量有几百万用户详情接口的QPS大概在两千左右大部分请求都在重复查同几个活跃用户的数据这就是典型的本地缓存适用场景。第一步是设计缓存名和key。缓存命名这块我强烈建议定个规范别想到啥写啥。我的规则是“业务模块场景”比如userDetailCache、dictCategoryCache、orderStatusCache一眼就能看出这个缓存是干嘛用的。key的规则是“业务主键”比如#userId如果查询条件复杂且有多个维度那就拼成#type : #code。不推荐的写法是把整个查询对象序列化后当作key那种key可读性极差出了问题都不好排查。第二步是设定各项参数。用户详情这种数据更新不太频繁但存在一致性要求中等我把TTL设为30分钟heap上限设为10000条。再想想如果有个超级热点用户就算10000条容量被其他冷数据占满这个热点也可能被驱逐那就可以针对这种key再单独定时预热一次或者在业务层面给热点用户单独保活。4.2 Service层代码与测试验证整个实现只需要三步配置依赖、写Service方法、加注解。依赖和配置前面已经搞定Service层代码长这样Service public class UserService { private final UserMapper userMapper; public UserService(UserMapper userMapper) { this.userMapper userMapper; } Cacheable(cacheNames userDetailCache, key #userId, unless #result null) public User getDetail(Long userId) { // 模拟慢查询实际项目中这里是SQL System.out.println(查询数据库: userId); return userMapper.selectById(userId); } CachePut(cacheNames userDetailCache, key #user.id) public User updateUser(User user) { userMapper.updateById(user); return user; } CacheEvict(cacheNames userDetailCache, key #userId) public void deleteCache(Long userId) { // 执行删除或其他操作这里不关心返回值 } }然后写个临时接口测试一下RestController RequestMapping(/user) public class UserController { private final UserService userService; public UserController(UserService userService) { this.userService userService; } GetMapping(/{id}) public User getUser(PathVariable Long id) { return userService.getDetail(id); } }启动应用后连调两次接口你会发现第一次控制台打印了“查询数据库: 1”第二次调用时这条日志消失了直接返回了缓存里的数据说明缓存已经命中。这里我用了一个小技巧在Service方法里打印日志用来判断方法体是否真的执行了这是验证缓存命中与否最直观的方式。如果你希望更精确地验证缓存命中率可以在Ehcache层面加统计信息。通过注入CacheManager拿到底层Cache对象调用getRuntime().getStatistics()就能读取命中次数、未命中次数等指标RestController RequestMapping(/cache) public class CacheStatController { private final CacheManager cacheManager; public CacheStatController(CacheManager cacheManager) { this.cacheManager cacheManager; } GetMapping(/stat) public MapString, Object stat() { org.ehcache.CacheObject, Object nativeCache (org.ehcache.CacheObject, Object) cacheManager.getCache(userDetailCache); var stats nativeCache.getRuntime().getStatistics(); return Map.of( hitCount, stats.getCacheHits(), missCount, stats.getCacheMisses(), hitRate, stats.getCacheHitRate() ); } }4.3 多级缓存与缓存预热的扩展用户详情这一个案例跑通之后可以顺手想想扩展。一个很自然的演进方向是“多级缓存”也就是本地Ehcache之上再加一层Redis。架构上很简单请求先查Ehcache没命中再查Redis还没有才落库然后自底向上逐级回填。多级缓存的收益是显而易见的减少了Redis的压力也让热点数据真正留在了应用进程内。但代价是两级缓存之间的一致性更难保证Redis和Ehcache各存了一份其中任何一层更新失败都会造成短暂脏读。我的建议是两级的TTL要有明显的梯次本地缓存设短一些(比如1分钟)Redis设长一些(比如10分钟)这样即使本地缓存出现脏数据也会因为短TTL很快自愈。缓存预热也是实战中值得做的一件事。刚启动的应用缓存是空的如果流量立刻打进来数据库会在短时间内被读请求压满这就是所谓的冷启动。预热最简单的方式是实现ApplicationRunner接口在应用启动完成后主动加载一批热点数据到缓存Component public class CachePreloadRunner implements ApplicationRunner { private final UserService userService; public CachePreloadRunner(UserService userService) { this.userService userService; } Override public void run(ApplicationArguments args) { // 预热活跃用户前100条 ListLong hotIds userService.findHotUserIds(100); hotIds.forEach(userService::getDetail); System.out.println(用户详情缓存预热完成: hotIds.size() 条); } }预热这个坑很多人不在乎但线上我是踩过的。某次发布新版本后所有缓存清空瞬时流量把只读库打到CPU百分百应用启动花了十分钟才缓过来。后来我把热点数据的预热逻辑加上重启后能平稳过渡代价只是启动时多花了几秒。5. 常见问题与避坑实录5.1 注解失效同类调用的陷阱这是Spring缓存注解使用中翻车率最高的问题没有之一。很多人会发现自己在Service内部调用另一个加了Cacheable注解的方法缓存就是不生效每次请求都真的去查库了。原因很简单Cacheable是通过Spring AOP代理实现的。Spring会为Bean生成一个代理对象当外部通过代理调用方法时代理才会拦截并执行缓存逻辑。但如果你在同一个Bean内部直接用this调用另一个方法调用的是原始对象的方法代理拦截不到缓存注解自然就成了摆设。Service public class UserService { public User getDetailWrapper(Long userId) { // 这里直接调用了本类方法等价于this.getDetail(userId)缓存不生效 return getDetail(userId); } Cacheable(cacheNames userDetailCache, key #userId) public User getDetail(Long userId) { return userMapper.selectById(userId); } }解决这个问题有几种常见套路。第一种把被缓存的方法拆到另一个Service里比如新建一个UserCacheService然后让UserService去调用它这是最干净的做法。第二种注入自身代理在类里加一个Resource UserService self;然后通过self.getDetail(userId)调用Spring注入的是代理对象缓存就能生效。第三种是用AopContext.currentProxy()拿到当前代理但需要配置EnableAspectJAutoProxy(exposeProxy true)不推荐代码里到处都是强转可读性太差。顺带提醒一句Spring Boot 2.x之后默认使用CGLIB代理所以这里的“自身注入”用接口或类都可以但如果方法被private或final修饰CGLIB也没辙因为CGLIB是通过生成子类来代理的final方法无法被子类重写private方法外部根本不可见。所以被Cacheable修饰的方法老老实实写成public别炫技。5.2 缓存穿透与空值缓存缓存穿透是指查询一个一定不存在的数据缓存里没有数据库里也没有于是每次请求都穿到数据库。如果有恶意攻击者不断用随机ID轰击你的查询接口数据库会秒秒钟被拖垮。解决穿透的第一个手段是缓存空值。比如查询用户ID999999数据库返回null这个时候把空值也缓存进去只是TTL设短一点。但前面我的例子中unless #result null就是不想缓存null这和缓存空值的思路是冲突的。如果你决定缓存空值就把unless去掉然后在缓存配置里允许null值。Spring Cache的默认行为是允许null的但Ehcache 3对null的处理更严格它不会直接缓存null而是用NullValue这个内部对象包装如果value-type指定成了具体类型序列化时反而会出问题。我的做法是把value-type统一配为java.lang.Object规避这个麻烦。第二个手段是布隆过滤器。在缓存前面再加一层布隆过滤器数据库里不存在的ID直接在这里就被过滤掉请求根本不会打到缓存和数据库。布隆过滤器的缺点是存在误判率(会把一些不存在的ID判为存在)但判断结果“一定不存在”是绝对准确的。对用户ID这种有规律的数据布隆过滤器效果极好唯一的代价是需要维护一份全量ID集合数据量特别大时布隆过滤器那块也要额外设计。我的实际经验是中小系统先做空值缓存就够了TTL设置5分钟能挡住绝大多数穿透流量。如果攻击流量实在凶猛再引入布隆过滤器别一上来就上重武器。5.3 序列化与类型擦除问题Ehcache 3在缓存非基本类型对象时会涉及序列化。如果你在配置里把value-type写死了某个具体类比如com.example.User那么缓存读写时就会用Jackson或Java原生序列化对这个类做转换这里有几个高频报错。首当其冲的是ClassCastException。原因往往是配置里的value-type类型和注解方法实际返回类型不一致。比如value-type配了String但方法返回的是User对象运行到第二次读取缓存时直接转型失败。我见过的最隐秘版本是方法的返回类型是ListUser但value-type配的是User缓存里实际上放了一整个List读取时按User反序列化当场炸掉。出现这类问题最简单的排查方式就是先把value-type统一改成java.lang.Object让Ehcache不要自作聪明地做类型强校验。其次是SerializationException这是对象没实现Serializable接口导致的。有一些框架内部类比如某个通用返回结果类确实没实现序列化接口直接放入Ehcache就会在写入时报错。解决方式无非是两个要么让类实现Serializable要么换一种序列化机制。Ehcache 3默认用的Java序列化性能和跨语言兼容性都比较差如果有跨语言需求或者性能要求高建议配置Serializer比如Kryo或者Protobuf。这里多说一句本地缓存的数据都在JVM内部理论上可以不序列化直接持有对象引用但Ehcache出于安全的考虑默认会做拷贝(或者说支持序列化策略)如果你的对象很大、序列化又慢缓存性能会明显下降。评估下来是性能优先还是安全优先取决于你自己的场景。我是倾向于在大对象上使用堆外内存时才认真考虑序列化方式纯堆内的缓存直接放引用速度快得多因为不涉及堆外和磁盘序列化并不是必经步骤但一旦配了offheap或者disk序列化就跑不掉了。5.4 本地缓存的数据一致性问题最后聊一个所有本地缓存都绕不开的痛数据一致性。本地缓存天然就是每个实例各存一份你没法像Redis那样通过分布式协议同步数据。所以如果你的服务是多实例部署前面那套“更新后删缓存”的逻辑只能在当前实例生效其他实例的缓存还是旧的。这个问题不解决上线即事故。我的处理思路按严重程度分三档。第一档数据一致性要求不高让不同实例短暂不一致也能接受那就啥也不用做把TTL设短一点自愈即可最省事。第二档数据一致性要求中等可以引入消息机制当某个实例更新了数据后发一条消息到Redis的PubSub或者MQ其他实例收到消息后执行CacheEvict清理本地缓存这个方案实现成本中等效果也不错。第三档数据一致性要求很高那就直接把这一层数据从本地缓存里挪走改用Redis或干脆不走缓存。这里还要提一个事务和缓存的联动坑。Service里最常见的是把缓存注解和事务注解放在同一个方法上比如先查询更新数据库、再CacheEvict删缓存。但如果数据库事务还没有提交缓存已经被删了此时恰好有一个并发请求进来发现缓存未命中直接查询数据库查到的是事务未提交前的旧数据然后把旧数据放进了缓存妥妥的脏数据。解决思路有两种一是让缓存操作在事务提交后执行Spring提供了TransactionSynchronizationManager.registerSynchronization可以在afterCommit阶段删缓存二是干脆让缓存注解不要和事务注解混在同一个方法上把缓存那层抽出去。我自己的习惯是如果这个操作本身事务逻辑简单强行把缓存删了影响也不大那我直接用CacheEvict如果是复杂事务、并发写频繁我会用事务同步机制确保缓存删除在事务成功提交后执行。宁可代码复杂一点也别让脏数据悄悄溜进缓存。最后分享一点个人的落地经验做缓存设计这么多年我最大的体会是缓存不是越复杂越好而是越可控越好。很多人一上来就想着多级缓存、分布式缓存、布隆过滤器全套上结果系统复杂度上去了问题排查难度也上去了收益却没那么明显。在我看来Ehcache本地缓存最好的使用姿势是把那些读多写少、一致性容忍度高的数据稳定地缓存起来把TTL和容量设好把更新和删除逻辑理清楚命中率自然就高了。另外一个小技巧送给大家给所有缓存统一打印一条命中日志比如CacheHIT userDetailCache key1001 cost0.2ms这个日志在开发和联调阶段能帮你快速发现大量缓存没有命中的问题。我当时就是靠这个日志排查出了一个同事配置错误导致的缓存命中率长期为0的诡异Bug。这种小细节往往比复杂的监控系统更能救命。
返回列表