@Cacheable和@CacheEvict 缓存失效策略
引入
探讨
q:
我大概了解了一下 其实本质也是一样的 在有关查询的方法上标记@Cacheable注解 自定义一个keyGenerator用于设置key(比如项目名:模块名:方法名:查询条件) 接下来将传入该参数调用该方法的返回结果作为value 存储在制定的缓存工具中(如redis) 在有关更新删除的方法上标记@CacheEvict注解用于缓存失效清除缓存 所以本质还是不变的 只是将自己手动编写拼接key 存入value的工作简化了罢了 至于如何设计缓存失效还是需要自己设计 其实我的疑惑更多的是 如何设计缓存失效 比如 我查询 风景图片 然后@Cacheable自动帮我将类似 YunPicture:pictrue:listPicturesByCursor:风景 作为key 将查询到的响应作为value存入redis (假设这里面为图片1 图片2 图片3) 那么下次再搜索风景时 会自动从redis中找key YunPicture:pictrue:listPicturesByCursor:风景 得到结果图片1 图片2 图片3 而无需调用方法 就现在来看 确实是更加简便了 因为无需手写从redis查询 添加的逻辑 而接下来 也是我的重点关注问题 缓存清除策略也只是简单基于key 如果我修改了图片2 我该如何定位哪些key的value里面有图片2呢 换句话说 我该怎么精确的清除相关缓存呢 那么只能清除所有吧 原来我就是这样做的 除非说能够精确的缓存 比如查询图片2 以图片2的id为key存入缓存 修改图片2 清除key图片2的id缓存 所以感觉 对于精确查询来说 这些效果就会非常理想 而对于模糊查询来说 缓存失效策略就会变得非常复杂 至此我的理解是否正确 帮我进行整理 我们先不急着代码实现 先就这些问题进行一个讨论
a:
嗯,用户现在在讨论关于缓存失效策略的问题,特别是如何在更新或删除数据时精确清除相关缓存。他们提到,当使用模糊查询(比如按“风景”搜索图片)时,缓存键可能包含多个数据项(如图片1、图片2、图片3),而如果其中某个图片被修改,如何定位并清除所有相关的缓存键是一个挑战。用户之前的解决方案是清除所有缓存,但这样效率不高,他们想知道有没有更好的方法。 首先,我需要确认用户对Spring的缓存注解的理解是否正确。用户提到使用@Cacheable和@CacheEvict注解,确实可以简化缓存的存取和清除,但缓存失效策略的设计仍然需要开发者自己处理。用户的问题核心在于如何高效地管理模糊查询的缓存,避免全量清除。 接下来,我需要分析用户提到的两种场景:精确查询和模糊查询。对于精确查询(如按ID查询图片),缓存键可以基于ID设计,这样在更新或删除时,只需清除该ID对应的缓存即可,这确实高效。但对于模糊查询(如按标签或分类),缓存键可能涉及多个数据项,导致无法精确清除,只能全量清除,这可能影响性能。 用户可能希望了解如何在模糊查询场景下更精确地管理缓存失效。我需要考虑可能的解决方案,比如使用更细粒度的缓存键设计,或者结合其他策略如版本号、时间戳等。例如,可以将查询条件和版本号结合,当数据更新时,更新版本号,从而使得旧缓存自动过期。或者,使用事件驱动的方式,在数据更新时触发缓存清除,但需要知道哪些缓存键需要清除,这可能需要维护一个反向索引,记录哪些缓存键包含该数据项,这在技术上可行但复杂。 另外,用户可能没有考虑到缓存的TTL(生存时间),即使无法精确清除,设置合理的TTL也能在一定时间内自动清理,减少不一致的风险。但用户可能希望更即时的清除,尤其是对实时性要求高的场景。 我需要总结用户的问题,并给出清晰的分析,确认他们的理解是否正确,同时提供可能的解决方案和权衡。例如,确认精确查询确实容易处理,而模糊查询需要权衡缓存效率和清除复杂度,可能需要接受一定的不精确,或者采用其他策略如分层缓存、版本控制等。 最后,确保回答结构清晰,分点说明问题、解决方案和权衡,帮助用户理清思路,并为后续的代码实现提供指导。
你的理解是正确的,但需要进一步补充和完善。
以下是针对缓存失效策略的详细分析和设计思路:
一、你的理解总结
@Cacheable简化了缓存操作:
自动管理缓存的存取,无需手动编写Redis操作代码。
通过key属性或keyGenerator自定义缓存键的生成逻辑。
缓存失效的核心挑战:
精确查询(如按ID查询):缓存键与数据ID强关联,清除策略简单(如清除单个键)。
模糊查询(如按标签、分类):缓存键与多个数据项相关联,无法直接定位需要清除的缓存项,导致只能全量清除或接受不一致性。
你的疑问:
如何设计模糊查询的缓存失效策略?
是否只能全量清除缓存?是否存在更优解?
二、关键问题分析
- 模糊查询的缓存失效难点
场景示例:
用户搜索“风景”标签,缓存键为YunPicture:picture:listByCursor:风景,缓存值包含图片1、图片2、图片3。
如果图片2被更新或删除,需要定位并清除所有包含图片2的缓存项(如“风景”、“热门”等标签的缓存)。
问题本质:
多对多关系:一个数据项可能被多个缓存键引用(如图片2同时属于“风景”和“热门”标签)。
反向索引缺失:无法直接根据数据ID快速找到所有相关的缓存键。
- 现有方案的局限性
全量清除:
优点:简单直接,确保一致性。
缺点:性能差,尤其在高频查询场景下。
精确清除:
优点:高效,无冗余。
难点:需要维护反向索引(如记录每个数据项被哪些缓存键引用),实现复杂。
三、解决方案与权衡
以下提供几种可行方案,需根据业务场景选择:
- 方案一:全量清除 + 短TTL
策略:
对模糊查询的缓存设置较短的TTL(如5分钟)。
数据更新时,仅清除与该数据直接关联的精确查询缓存(如图片2的ID键),模糊查询的缓存依赖TTL自动过期。
适用场景:
数据更新频率不高,或允许短暂的不一致性。
优点:
实现简单,无需维护反向索引。
缺点:
缓存命中率可能下降,因TTL较短。
- 方案二:基于条件的缓存失效(推荐)
策略:
缓存键设计:将查询条件和版本号结合,例如:
key = “YunPicture:picture:listByCursor:风景:version=123”
版本号机制:
每个模糊查询的条件(如标签“风景”)维护一个版本号。
当数据更新时,根据更新的数据关联的条件(如标签),更新对应版本号。
版本号变化会强制缓存失效,因为键中的版本号已变化。
实现步骤:
维护版本号:
在数据库中为每个模糊查询条件(如标签)维护一个版本号字段。
例如,标签“风景”的版本号存储在Tag表中。
缓存键生成:
在查询时,动态拼接版本号到键中:
key = String.format("YunPicture:picture:listByCursor:%s:version=%d",
request.getCategory(),
tagService.getVersion(request.getCategory()));
版本号更新:
当数据更新时(如图片2修改标签),更新相关标签的版本号:
@CacheEvict(value = "pictureListCache", allEntries = true)
public boolean updatePicture(PictureUpdateRequest request) {
// 更新图片标签...
tagService.incrementVersion("风景"); // 假设图片2属于“风景”标签
return true;
}
优点:
精确控制缓存失效范围(仅与版本号关联的条件)。
无需维护复杂的反向索引。
缺点:
需要维护版本号字段,增加数据库复杂度。
- 方案三:反向索引(复杂但精准)
策略:
维护一个反向索引表,记录每个数据项被哪些缓存键引用。
例如:
{
"picture2": ["YunPicture:picture:listByCursor:风景", "YunPicture:picture:listByCursor:热门"]
}
实现步骤:
缓存存入时:
将数据项ID与缓存键关联,写入反向索引表。
数据更新时:
根据数据项ID查询所有关联的缓存键,逐一清除。
缓存清除时:
清除缓存后,同步更新反向索引表。
优点:
可精确清除所有相关缓存。
缺点:
实现复杂,需额外维护索引表。
性能开销较大(需频繁操作索引表)。
- 方案四:分层缓存设计
策略:
第一层(强一致性层):精确查询的缓存(如按ID查询),使用@CacheEvict精准清除。
第二层(弱一致性层):模糊查询的缓存,接受短暂不一致,通过TTL或版本号机制保证最终一致性。
适用场景:
用户对模糊查询的实时性要求较低(如首页推荐列表)。
四、你的问题的最终答案
- 你的理解是否正确?
是的,但需要补充:
模糊查询的缓存失效确实难以精确控制,只能通过TTL、版本号或反向索引等策略间接处理。
精确查询的缓存失效可以完全解决,因为键与数据ID直接关联。
- 如何设计模糊查询的缓存失效?
推荐方案:版本号机制(方案二):
实现步骤:
在Tag或Category实体中添加version字段。
在查询时,将版本号拼接到缓存键中。
数据更新时,更新相关标签的版本号,触发缓存失效。
示例代码:
// 查询方法
@Cacheable(value = "pictureListCache",
key = "#request.generateCacheKeyWithVersion()")
public PictureCursorResponse listPicturesByCursor(PictureCursorRequest request) {
// ...
}
// 在PictureCursorRequest中添加generateCacheKeyWithVersion()
public String generateCacheKeyWithVersion() {
return String.format("YunPicture:picture:listByCursor:%s:version=%d",
this.category,
tagService.getVersion(this.category));
}
- 是否只能全量清除?
不一定:
通过版本号机制,可以局部清除与特定条件相关的缓存,而非全量清除。
例如,更新“风景”标签的数据时,仅更新“风景”标签的版本号,其他标签的缓存不受影响。
五、总结建议
优先选择版本号机制:
简单高效,适用于大多数模糊查询场景。
版本号可通过数据库字段实现,无需复杂索引。
精确查询直接使用@CacheEvict:
例如,对getPictureById方法的缓存,更新时直接清除该ID的缓存。
根据业务场景权衡:
对实时性要求高的场景(如订单状态),选择方案三(反向索引)。
对性能要求高的场景(如首页推荐),选择方案一或方案四。
通过以上策略,可以平衡缓存效率与一致性,避免全量清除的性能问题。
q2
意思是说 第一次搜索风景时 构建了YunPicture:picture:listByCursor:风景:version=1的key存入 version从风景标签的额外版本信息中得来 如果更新了图片2(从属于标签风景) 那么风景标签的版本号也会修改 比如修改成2 那么再次查询时 构建的key为YunPicture:picture:listByCursor:风景:version=2 就不会命中之前的旧缓存 实现数据一致性 (这样的话就不需要设计缓存失效 让无效的YunPicture:picture:listByCursor:风景:version=1自动过期清理)这样确实很巧妙 利用key的不同规避了清理策略问题 但我在想应该如何设计呢 只能根据标签来做这个版本号机制吗 我图片的信息有很多而不止标签一个 如果其他信息修改 比如分类 介绍 名称 又该怎么做呢 另外我的标签是作为一个json字段存在图片表里的 而不是分出一个tag表 如果要应用这个版本号方案 貌似最好的做法就是仅支持tag这种对于检索来说比较的重要信息的较高一致性 而简介 名称 这类与搜索关系较小的就可以保持弱一致性 (虽然搜索功能也支持用简介名称进行模糊查询 但其实影响不大 它不会影响页面的初始展示 不会带来太大的影响 而标签是可以在页面直接分类展示出来的 如果修改后没即时同步就会给人体验带来不适 )而要实现这个所谓的版本号方案 貌似必须要我将标签 分类 抽离出单独的表 在里面对每种标签、分类添加一个version字段 如今的表结构(标签分类作为字段存在picture中)貌似是做不到版本号方案的吧 我的理解是否正确
a2:
嗯,用户现在在讨论如何在现有的表结构下实现版本号机制,特别是当标签和其他信息存储在图片表中而不是单独的标签表时。用户提到他们的标签是作为JSON字段存储在图片表里的,这可能让版本号方案难以实施。我需要先确认用户当前的表结构,然后分析可能的解决方案。 首先,用户提到标签是JSON字段,这可能意味着标签信息直接存储在图片表的某个字段中,而不是通过关联表来管理。这种情况下,如果要为每个标签维护版本号,可能需要额外的步骤,比如在更新图片标签时,同时更新相关标签的版本号。但问题在于,如果标签没有独立的表,如何跟踪每个标签的版本? 用户可能需要将标签拆分成单独的表,这样每个标签可以有自己的版本号字段。但用户可能不想改动现有表结构,所以需要考虑其他方法。例如,可以在图片表中为每个标签维护一个全局版本号,但这样可能不够精确,因为每个标签的更新都会影响所有相关查询。 另一个思路是,当图片的标签或分类被修改时,根据修改的内容,动态更新相关的标签版本号。例如,当图片的标签从“风景”改为“城市”,则需要更新这两个标签的版本号。但如果没有标签表,这可能需要在业务逻辑层维护一个版本号映射,比如在Redis中存储标签到版本号的映射,这样即使没有独立的标签表,也可以通过业务逻辑来维护版本号。 此外,用户提到简介和名称等字段对搜索的影响较小,可以接受弱一致性,而标签和分类需要强一致性。因此,可能只需要为标签和分类设计版本号机制,而其他字段可以依赖TTL自动过期。这样,即使不拆分表,也可以通过业务逻辑来维护版本号,比如在每次更新标签时,增加一个全局版本号,或者为每个标签维护一个计数器。 用户可能担心现有表结构无法支持版本号方案,但通过业务逻辑层的处理,比如使用Redis来存储标签版本号,或者在图片表中添加一个版本号字段,每当标签或分类变化时更新该字段,这样即使标签存储在图片表中,也可以通过图片的版本号来更新相关查询的缓存键。例如,缓存键可以包含图片的版本号,这样当图片更新时,新的版本号会导致缓存键变化,从而失效旧缓存。 总结来说,用户可能需要调整缓存键的设计,将版本号与图片的修改相关联,而不仅仅是标签。或者,即使标签存储在图片表中,也可以在业务逻辑层维护标签的版本号,例如在每次修改标签时,更新一个全局的或按标签的版本号计数器,这样即使没有独立的标签表,也能实现版本号机制。需要具体分析用户当前的数据库结构和业务逻辑,提供可行的解决方案,可能包括表结构调整或业务逻辑层的优化。
你的理解基本正确,但可以通过以下方式在现有表结构下实现版本号机制,无需拆分标签到独立表:
一、当前表结构的挑战与解决方案
- 标签存储为JSON字段的问题
问题:
标签作为图片表的JSON字段,无法直接关联到独立的标签表。
无法通过数据库字段直接维护标签的版本号。
解决方案:
业务逻辑层维护版本号:
在Service层或Redis中维护一个标签到版本号的映射(如tagVersionMap)。
每次修改标签时,更新对应标签的版本号。
- 实现步骤
维护标签版本号:
使用Redis或数据库表(如TagVersion)存储标签到版本号的映射:
{
"风景": 2,
"热门": 5,
"科技": 1
}
动态生成缓存键:
查询时,根据标签查询参数动态获取版本号,拼接缓存键:
// 在PictureCursorRequest中添加获取版本号的逻辑
public String generateCacheKeyWithVersion() {
String category = this.getCategory(); // 当前查询的分类或标签
int version = tagVersionService.getVersion(category); // 从Redis或数据库获取版本号
return String.format("YunPicture:picture:listByCursor:%s:version=%d", category, version);
}
更新版本号:
当图片的标签或分类被修改时,更新对应标签的版本号:
// 在更新图片标签的Service层
@Transactional
public boolean updatePicture(PictureUpdateRequest request) {
Picture picture = pictureService.getById(request.getId());
// 假设标签是JSON字段,解析旧标签和新标签
List<String> oldTags = JSONUtil.parseArray(picture.getTags(), String.class);
List<String> newTags = JSONUtil.parseArray(request.getTags(), String.class);
// 计算新增/删除的标签
Set<String> addedTags = newTags.stream().filter(t -> !oldTags.contains(t)).collect(Collectors.toSet());
Set<String> removedTags = oldTags.stream().filter(t -> !newTags.contains(t)).collect(Collectors.toSet());
// 更新标签版本号
if (!addedTags.isEmpty() || !removedTags.isEmpty()) {
addedTags.forEach(tag -> tagVersionService.incrementVersion(tag));
removedTags.forEach(tag -> tagVersionService.incrementVersion(tag));
}
// 更新图片数据
picture.setTags(JSONUtil.toJsonStr(newTags));
boolean result = pictureService.updateById(picture);
return result;
}
二、扩展到其他字段(如分类、名称)
- 分类字段的处理
如果分类是图片表中的独立字段(如category),可以类似标签处理:
维护一个分类到版本号的映射(如categoryVersionMap)。
查询时,将分类版本号拼接到缓存键中:
public String generateCacheKeyWithVersion() {
String category = this.getCategory();
int categoryVersion = categoryVersionService.getVersion(category); // 分类版本号
int tagVersion = tagVersionService.getVersion(category); // 标签版本号
return String.format("YunPicture:picture:listByCursor:%s:categoryVer=%d:tagVer=%d",
category, categoryVersion, tagVersion);
}
- 名称/简介的弱一致性
对于名称、简介等与搜索相关性较低的字段:
无需版本号机制,通过短TTL(如5分钟)自动过期。
缓存键设计示例:
// 基于搜索关键词的缓存键(如名称模糊查询)
public String generateCacheKey() {
return String.format("YunPicture:picture:listByCursor:keyword=%s:TTL=300", this.keyword);
}
四、总结
- 无需拆分标签表:
通过业务逻辑层维护标签版本号(Redis或数据库表),无需修改现有表结构。
核心是将版本号与查询条件关联,而非依赖标签表。
- 字段处理策略:
强一致性字段(如标签、分类):使用版本号机制。
弱一致性字段(如名称、简介):依赖短TTL。
- 优势:
灵活适配现有表结构。
可扩展到其他字段(如分类)。
无需全量清除缓存,仅更新相关标签/分类的版本号。
五、注意事项
版本号的粒度:
如果标签是数组(如图片可有多个标签),需确保所有相关标签的版本号都被更新。
可通过遍历修改前后的标签集合,逐一更新版本号。
性能优化:
使用Redis的原子操作(如INCR)更新版本号,避免并发问题。
对高频查询的标签版本号,可缓存到本地内存(如Spring的@Cacheable)。
通过上述方案,你可以在现有表结构下实现版本号机制,解决模糊查询的缓存一致性问题。
q3
我们现在来探讨细节问题 对于图片的名称 简介这类字段的修改问题 我们确定了 因为对界面展示用户体验影响不大 可以允许弱一致性 使用ttl机制自动失效 现在主要是对tag和分类字段的更新 应用版本号机制的讨论 我不希望动表结构 我有一个设想 这个版本是不是也可以存在redis里面呢 我可能表述不是很清楚 因为只是一个突发奇想 还没细致考虑 大概就是 版本号其实并不需要持久 只是作为短期的一个标识 在一段时间内 进行了修改版本++ 版本号的缓存失效后 又清零 只需要保证版本号的缓存失效在查询结果的缓存失效之后即可 你能否get到我的意思 举个例子 在一段时间内 我查询了风景 设置key YunPicture:picture:listByCursor:风景::tagVer=缓存中提取的风景标签版本号(如果存在) 如果在这段时间内 修改了图片的标签 比如从汽车变为了风景 那么缓存中风景标签的版本号++ 汽车标签的版本号++ 下次再按照风景搜 所得到的key就不一样了无法命中旧缓存 要从数据库中查 是可以做到信息同步的 最关键的地方在这里 我的旧key是会过期的 相应的 正确存储的风景版本号1 也就没有意义的 它是不是也可以过期 只要保证标签版本的过期在要用的数据之后就行了 那么就可以清除标签版本号的缓存 下次又从1开始 而无需持久化的累加? 这样整个失效策略就可以完全依靠redis实现 ? 我这只是一种设想 可能还有很多细节没有考虑到 反正目的就是 不动表结构 实现版本号的缓存失效策略 你分析一下我的想法
嗯,用户现在有一个关于如何在不修改表结构的情况下实现标签和分类的版本号机制的问题。他们之前讨论了使用Redis来维护版本号,而不是在数据库中添加字段。用户的想法是,版本号不需要持久化,只需要在Redis中临时存储,并且版本号的过期时间比查询结果的缓存时间长,这样当查询结果过期后,版本号也会过期,从而重新开始计数。我需要仔细分析这个想法的可行性,以及可能的问题。 首先,用户提到的版本号在Redis中存储,并且设置一个比查询结果的TTL更长的过期时间。例如,查询结果的TTL是5分钟,而版本号的TTL是10分钟。这样,当查询结果过期后,版本号仍然存在,确保在旧查询结果失效后,新查询使用新的版本号。但当版本号过期后,它会被清除,下次重新开始计数。这样可以避免数据库表结构的修改,完全依赖Redis。 接下来,我需要考虑这个方案的优缺点。优点是确实不需要修改现有表结构,实现起来相对简单。版本号的维护完全在Redis中完成,减少了数据库的负担。但潜在的问题可能有几个:
- 版本号的连续性问题:如果版本号在过期后被重置为初始值(比如0或1),那么当版本号被重置后,如果在短时间内再次修改标签,可能会导致版本号重复,从而导致缓存键冲突。例如,假设版本号从1变为2,然后过期后重置为0,如果再次修改标签,版本号变为1,而之前的缓存键可能还存在,但此时的版本号是1,而实际数据已经变化,这可能导致缓存命中旧数据。
- 时间同步问题:需要确保版本号的TTL比查询结果的TTL长,否则版本号可能在查询结果仍然有效时过期,导致后续查询无法正确生成新的缓存键。例如,如果查询结果的TTL是5分钟,而版本号的TTL是3分钟,那么在3分钟后,版本号过期,但查询结果可能还在缓存中,此时新的查询会使用过期的版本号(可能为null或默认值),导致缓存键错误。
- 并发问题:当多个修改操作同时发生时,版本号的递增是否会出现竞争条件?比如,两个并发的修改请求可能导致版本号只增加了一次,而不是两次。这需要Redis的原子操作来保证,比如使用INCR命令,这样可以避免问题。
- 缓存键的生成逻辑:需要确保在生成缓存键时,即使版本号不存在(比如刚启动服务或版本号过期),也能有一个默认值,或者从数据库中获取初始版本号。不过用户提到完全依赖Redis,所以可能需要处理这种情况。 接下来,我需要验证用户的思路是否可行。例如,假设版本号在Redis中存储,过期时间为查询结果的TTL加上一定时间。当版本号过期后,再次修改标签时,版本号会从初始值(比如0)重新开始递增。这样,虽然版本号可能在某个时间点重复,但因为查询结果的缓存已经过期,所以新的查询会重新生成新的缓存键,而旧的缓存键已经失效,所以不会出现缓存不一致的问题。例如:
- 初始时,标签“风景”的版本号是0,缓存时间为10分钟。 - 用户查询“风景”,缓存键是风景:version=0,TTL为5分钟。
- 1分钟后,修改标签,版本号变为1。 - 5分钟后,原查询的缓存过期,此时版本号还存在(因为还有5分钟剩余时间),所以新查询会使用版本号1,生成新的缓存键。
- 10分钟后,版本号过期,变为0。 - 此时如果再次修改标签,版本号又从0开始增加到1,但此时之前的缓存已经过期,所以不会有冲突。 这种情况下,虽然版本号可能重复,但缓存键的生成是基于当前版本号,而旧的缓存已经过期,所以不会导致缓存命中旧数据。因此,这个方案在理论上是可行的。 不过,需要注意版本号的初始化问题。例如,当服务重启后,Redis中的版本号可能丢失,导致初始版本号为0,这可能会导致缓存键的版本号与实际的修改次数不一致,但因为缓存已经过期,所以不会出现缓存不一致的问题。因此,初始化问题可能可以接受。 另外,需要确保版本号的递增是原子的。在Redis中使用INCR命令可以保证这一点,避免并发问题。 总结用户的思路是可行的,但需要注意TTL的设置和版本号的原子操作。可能还需要考虑在服务启动时如何初始化版本号,但根据用户的需求,可能可以接受Redis中的版本号丢失,因为它们只是临时的,不影响数据一致性。
你的想法非常巧妙,且完全可行!通过将版本号存储在Redis中并设置合理的TTL(Time To Live),可以完全避免修改数据库表结构,同时实现版本号机制。以下是详细分析和实现方案:
一、你的想法的核心优势
无需修改表结构:版本号完全由Redis维护,无需在数据库中添加字段。
自动过期与重置:版本号在Redis中按需递增,过期后自动重置为初始值,避免数据膨胀。
实现简单:利用Redis的原子操作(如INCR)和TTL特性,无需复杂逻辑。
二、具体实现方案
- 版本号的存储与TTL设置
存储结构:
每个标签/分类的版本号存储为Redis的键值对:
tag_version:风景:1
category_version:科技数码:5
TTL设置:版本号的TTL应大于查询结果的TTL,确保在查询结果失效后,版本号仍存在。
例如:查询结果的TTL为5分钟,版本号的TTL设为10分钟。
- 版本号的生成与更新
初始化:
第一次查询时,若Redis中无版本号,则初始化为0并设置TTL:
// 在Service层
public int getVersion(String tag) {
String key = "tag_version:" + tag;
Integer version = redisTemplate.opsForValue().get(key);
if (version == null) {
version = 0;
redisTemplate.opsForValue().set(key, version, 10, TimeUnit.MINUTES); // TTL=10分钟
}
return version;
}
版本号递增:
使用Redis的原子操作INCR保证并发安全:
// 更新标签时触发版本号递增
public void incrementVersion(String tag) {
String key = "tag_version:" + tag;
redisTemplate.opsForValue().increment(key, 1);
// 确保版本号的TTL始终为10分钟(可能需要重置)
redisTemplate.expire(key, 10, TimeUnit.MINUTES);
}
缓存键的生成逻辑
查询时动态拼接版本号:
public String generateCacheKey() {
String category = this.getCategory();
List<String> tags = this.getTags();
// 获取标签版本号(多个标签取最大值或拼接)
int tagVersion = tags.stream()
.map(tag -> tagVersionService.getVersion(tag))
.max(Integer::compare)
.orElse(0);
// 获取分类版本号
int categoryVersion = categoryVersionService.getVersion(category);
return String.format(
"YunPicture:picture:listByCursor:category=%s:tags=%s:tagVer=%d:categoryVer=%d",
category,
String.join(",", tags),
tagVersion,
categoryVersion
);
}
TTL的协调设计
查询结果的TTL:设置为5分钟(@Cacheable的默认TTL)。
版本号的TTL:设置为10分钟(比查询结果长)。
效果:
当查询结果过期后(5分钟),版本号仍存在(剩余5分钟)。
当版本号过期后(10分钟),下次查询会重新初始化版本号为0,但此时旧的查询缓存已失效,不会导致数据不一致。
三、关键点验证与问题规避
- 版本号重置问题
问题:版本号过期后重置为0,是否会导致缓存键冲突?
解答:
不会,因为旧的缓存键(如tagVer=5)已过期,新查询会重新生成新的键(如tagVer=0),但此时数据可能已变化,但缓存已失效,会重新查询数据库。
极端情况:如果版本号重置后恰好与旧版本号冲突,但旧缓存已失效,不会导致数据不一致。
- 并发修改问题
问题:多个并发修改请求是否会导致版本号跳变?
解答:
通过Redis的INCR原子操作保证递增的线程安全,不会出现漏计或重复计数。
- 服务重启问题
问题:服务重启后Redis中的版本号丢失,如何处理?
解答:
版本号是临时标识,丢失后重新初始化为0,不影响功能:
旧缓存已过期,新查询会重新生成键,数据一致性由数据库保证。
四、完整代码示例
- 版本号服务(Redis实现)
@Service
public class TagVersionService {
@Autowired
private RedisTemplate<String, Integer> redisTemplate;
public int getVersion(String tag) {
String key = "tag_version:" + tag;
Integer version = redisTemplate.opsForValue().get(key);
if (version == null) {
version = 0;
redisTemplate.opsForValue().set(key, version, 10, TimeUnit.MINUTES);
}
return version;
}
public void incrementVersion(String tag) {
String key = "tag_version:" + tag;
redisTemplate.opsForValue().increment(key, 1);
redisTemplate.expire(key, 10, TimeUnit.MINUTES);
}
}
- 缓存键生成逻辑
public class PictureCursorRequest {
private String category;
private List<String> tags;
public String generateCacheKey() {
// 获取分类版本号
int categoryVer = categoryVersionService.getVersion(category);
// 获取标签版本号(多个标签取最大值)
int tagVer = tags.stream()
.mapToInt(tag -> tagVersionService.getVersion(tag))
.max()
.orElse(0);
return String.format(
"YunPicture:picture:listByCursor:category=%s:tags=%s:categoryVer=%d:tagVer=%d",
category,
String.join(",", tags),
categoryVer,
tagVer
);
}
}
- 更新标签时触发版本号递增
@Transactional
public boolean updatePictureTags(Picture picture, List<String> oldTags, List<String> newTags) {
// 计算新增/删除的标签
Set<String> addedTags = newTags.stream().filter(t -> !oldTags.contains(t)).collect(Collectors.toSet());
Set<String> removedTags = oldTags.stream().filter(t -> !newTags.contains(t)).collect(Collectors.toSet());
// 更新标签版本号
addedTags.forEach(tag -> tagVersionService.incrementVersion(tag));
removedTags.forEach(tag -> tagVersionService.incrementVersion(tag));
// 更新图片的标签字段
picture.setTags(JSONUtil.toJsonStr(newTags));
return pictureService.updateById(picture);
}
五、总结
你的方案完全可行,且具有以下优势:
无需修改表结构:版本号完全由Redis管理。
自动清理:版本号和查询缓存均按需过期,无需人工维护。
高性能:Redis的原子操作和TTL机制保证了高效性。
唯一需要注意的点:确保版本号的TTL始终大于查询结果的TTL,避免版本号在查询缓存仍有效时提前过期。例如,查询结果设置为5分钟,版本号设为10分钟即可。
q4
最后一个问题 也是细节问题 就是分类和标签是否可以同时应用在一个搜索条件上 是否会影响缓存命中 因为我的查询功能是模糊查询 还是以搜索风景为例 我并不知道这个风景是标签还是分类还是名称或是简介 我们可以不管名称简介因为无需对其设计这种版本号模式 而风景和标签这里就需要考虑一下了 如果是拼接成这样 YunPicture:picture:listByCursor:风景:categoryVer=缓存中的风景分类版本号:tagVer=缓存中的风景标签版本号 而如果风景是标签 且根本不存在风景这个分类 情况会怎么样 貌似没影响 因为以之前的逻辑 如果不存在 会设置为0 也因为不存在所以压根不会被修改 貌似不会对缓存命中造成影响 再想想其他边界情况 如果一个修改 会不会影响另一个的缓存命中 导致原本应该命中的而没命中 好像也不会出现这个问题 因为在分类和标签的设计上是保证了不重复的 分类范围更大 标签范围更小 且不存在某个分类等于某个标签 反正这个问题就是 分析如果设计成YunPicture:picture:listByCursor:%s:categoryVer=%d:tagVer=%d这样的形式 是否与之前的思路一脉相承 是否合理
a4
嗯,用户现在在讨论分类和标签同时应用在搜索条件时的缓存命中问题。他们之前已经设计了使用版本号来处理标签和分类的缓存失效,但现在担心如果分类和标签同时存在,或者其中一个不存在时,会不会影响缓存的正确性。 首先,我需要回顾之前的讨论。用户提到,当搜索条件包含分类和标签时,缓存键会同时包含分类版本和标签版本。例如,如果搜索的是“风景”作为标签,而分类中没有“风景”,那么分类版本号可能为0,但标签版本号会根据标签的变化而递增。用户想知道这种情况下是否会影响缓存命中,或者是否会导致不必要的缓存失效。 接下来,我需要考虑用户提到的边界情况。比如,当分类不存在时,版本号会被初始化为0,而标签存在时版本号会递增。这时候,缓存键中的分类版本是0,而标签版本是正确的,所以当标签被修改时,标签版本增加,缓存键变化,旧缓存失效,这应该是正确的。 另外,用户担心如果分类和标签有重叠名称,比如分类和标签都存在“风景”,这时候修改标签的版本号会不会影响分类的版本号。但根据之前的逻辑,分类和标签是分开处理的,它们的版本号是独立维护的,因此修改标签的版本不会影响分类的版本,反之亦然。因此,这种情况下缓存键的组合应该是正确的。 还需要考虑当搜索条件同时包含分类和标签时,缓存键是否能正确反映两者的版本。例如,如果用户搜索的是分类为“科技数码”,标签为“高清”,那么缓存键会包含分类版本和标签版本。当分类被修改时,分类版本增加,导致缓存键变化,而标签版本不变,这样缓存就会失效,这正是需要的。 用户可能还担心如果分类和标签的名称相同但属于不同维度,比如分类是“风景”而标签也是“风景”,这时候它们的版本号是否会被混淆。但根据设计,分类和标签的版本号是分别存储的,比如分类版本是category_version:风景,标签版本是tag_version:风景,所以它们的版本号是独立的,不会互相影响。 最后,用户的问题核心是确认这种设计是否合理,会不会导致缓存命中率下降或错误失效。根据分析,只要版本号是独立维护的,并且缓存键正确组合了分类和标签的版本,那么即使分类或标签不存在,初始化为0也不会影响命中,因为当数据不存在时,版本号不会被修改,所以缓存键中的版本号保持稳定,直到对应的分类或标签被创建或修改。 总结来说,这种设计是合理的,能够有效处理分类和标签同时存在的搜索条件,且边界情况也能正确处理。
- 边界情况分析
情况1:分类不存在,标签存在
示例:
查询条件:分类="风景"(不存在),标签=["风景"](存在)。
逻辑处理:
分类版本:categoryVersion("风景") → 初始化为0。
标签版本:tagVersion("风景") → 根据实际修改次数递增。
缓存键:
YunPicture:picture:listByCursor:category=风景:tags=风景:categoryVer=0:tagVer=3
效果:
分类版本为0,但标签版本会随着标签修改递增,确保缓存失效。
分类不存在不影响标签版本的更新,缓存键能正确反映标签的版本变化。
情况2:分类存在,标签不存在
示例:
查询条件:分类="科技数码"(存在),标签=[](无标签)。
逻辑处理:
分类版本:categoryVersion("科技数码") → 根据分类修改递增。
标签版本:tagVersion(null) → 默认为0。
缓存键:
YunPicture:picture:listByCursor:category=科技数码:tags=:categoryVer=5:tagVer=0
效果:
标签版本为0,但分类版本会随着分类修改递增,确保缓存失效。
情况3:分类和标签名称相同但维度不同
示例:
查询条件:分类="风景"(存在),标签=["风景"](存在)。
逻辑处理:
分类版本和标签版本是独立的:
categoryVersion("风景") → 独立维护。
tagVersion("风景") → 独立维护。
缓存键:
YunPicture:picture:listByCursor:category=风景:tags=风景:categoryVer=2:tagVer=3
效果:
分类和标签的版本号互不影响,修改分类或标签时,各自的版本号独立递增,缓存键动态变化。
二、缓存失效的正确性验证
- 分类被修改
示例:
原分类版本:categoryVer=2。
修改分类的某个属性(如新增/删除图片到该分类) → 分类版本递增为3。
结果:
缓存键中的categoryVer变为3,无法命中旧缓存,强制查询数据库。
- 标签被修改
示例:
原标签版本:tagVer=3。
修改某个图片的标签(如添加/删除"风景"标签) → 标签版本递增为4。
结果:
缓存键中的tagVer变为4,无法命中旧缓存,强制查询数据库。
- 分类和标签同时被修改
示例:
原版本:categoryVer=2, tagVer=3。
分类和标签均被修改 → 版本变为categoryVer=3, tagVer=4。
结果:
缓存键完全变化,无法命中旧缓存,确保数据一致性。
三、缓存键的唯一性与正确性
- 唯一性保障
分类和标签的组合唯一:
即使分类和标签名称相同,但维度不同(分类 vs 标签),它们的版本号是独立的,缓存键能正确区分。
动态字段覆盖:
查询条件中的category和tags是显式参数,确保不同查询条件的缓存键不会冲突。
- 无效参数的处理
空值或默认值:
若分类或标签不存在,版本号初始化为0,但不会影响其他有效字段的版本号。
例如,tags=[]时,tagVer=0,但分类版本正常递增。
实现
整体流程
依赖引入:确保项目包含Spring Cache和Redis依赖。
Redis配置:启用缓存并配置Redis连接。
版本号服务:实现标签和分类版本号的Redis管理。
缓存键生成:在PictureCursorRequest中动态生成包含版本号的缓存键。
缓存注解应用:在listPicturesByCursor方法上使用@Cacheable。
版本号更新:在修改标签或分类时,触发版本号递增。
精确查询缓存:可选,对精确查询添加@Cacheable和@CacheEvict。
详细实现步骤
引入依赖
在pom.xml中添加以下依赖:
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-cache</artifactId>
</dependency>
配置Redis缓存
在application.yml中配置Redis连接和默认TTL:
spring:
redis:
host: localhost
port: 6379
cache:
redis:
time-to-live: 300s # 默认缓存TTL为5分钟
在主应用程序类中启用缓存:
package com.zwnsyw.yunpicturebackend;
import org.springframework.boot.SpringApplication;
import org.springframework.boot.autoconfigure.SpringBootApplication;
import org.springframework.cache.annotation.EnableCaching;
import org.springframework.context.annotation.EnableAspectJAutoProxy;
import org.springframework.scheduling.annotation.EnableScheduling;
@SpringBootApplication
@EnableAspectJAutoProxy(exposeProxy = true)
@EnableScheduling
@EnableCaching
public class YunPictureBackendApplication {
public static void main(String[] args) {
SpringApplication.run(YunPictureBackendApplication.class, args);
}
}
创建Service,通过Redis管理版本号:
TagVersionService
package com.zwnsyw.yunpicturebackend.service;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.data.redis.core.RedisTemplate;
import org.springframework.stereotype.Service;
import java.util.concurrent.TimeUnit;
@Service
public class TagVersionService {
@Autowired
private RedisTemplate<String, Object> redisTemplate;
private static final String KEY_PREFIX = "tag_version:";
public int getVersion(String tag) {
String key = KEY_PREFIX + tag;
Object versionObj = redisTemplate.opsForValue().get(key);
if (versionObj == null) {
int version = 0;
setVersion(tag, version);
return version;
}
return Integer.parseInt(versionObj.toString());
}
public void incrementVersion(String tag) {
String key = KEY_PREFIX + tag;
Object versionObj = redisTemplate.opsForValue().get(key);
int currentVersion = versionObj == null ? 0 : Integer.parseInt(versionObj.toString());
int newVersion = currentVersion + 1;
setVersion(tag, newVersion);
}
private void setVersion(String tag, int version) {
String key = KEY_PREFIX + tag;
redisTemplate.opsForValue().set(key, version);
setTTL(key);
}
private void setTTL(String key) {
redisTemplate.expire(key, 10, TimeUnit.MINUTES); // 版本号TTL为10分钟
}
}
CategoryVersionService
package com.zwnsyw.yunpicturebackend.service;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.data.redis.core.RedisTemplate;
import org.springframework.stereotype.Service;
import java.util.concurrent.TimeUnit;
@Service
public class CategoryVersionService {
@Autowired
private RedisTemplate<String, Object> redisTemplate;
private static final String KEY_PREFIX = "category_version:";
public int getVersion(String tag) {
String key = KEY_PREFIX + tag;
Object versionObj = redisTemplate.opsForValue().get(key);
if (versionObj == null) {
int version = 0;
setVersion(tag, version);
return version;
}
return Integer.parseInt(versionObj.toString());
}
public void incrementVersion(String tag) {
String key = KEY_PREFIX + tag;
Object versionObj = redisTemplate.opsForValue().get(key);
int currentVersion = versionObj == null ? 0 : Integer.parseInt(versionObj.toString());
int newVersion = currentVersion + 1;
setVersion(tag, newVersion);
}
private void setVersion(String tag, int version) {
String key = KEY_PREFIX + tag;
redisTemplate.opsForValue().set(key, version);
setTTL(key);
}
private void setTTL(String key) {
redisTemplate.expire(key, 10, TimeUnit.MINUTES); // 版本号TTL为10分钟
}
}
生成缓存键
CacheConfig
package com.zwnsyw.yunpicturebackend.config;
import com.zwnsyw.yunpicturebackend.service.CategoryVersionService;
import com.zwnsyw.yunpicturebackend.service.TagVersionService;
import com.zwnsyw.yunpicturebackend.service.impl.utils.PictureCursorKeyGenerator;
import org.springframework.cache.interceptor.KeyGenerator;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
@Configuration
public class CacheConfig {
// @Bean("pictureCursorKeyGenerator")
// public KeyGenerator pictureCursorKeyGenerator(
// TagVersionService tagVersionService,
// CategoryVersionService categoryVersionService) {
// PictureCursorKeyGenerator generator = new PictureCursorKeyGenerator();
// generator.setTagVersionService(tagVersionService);
// generator.setCategoryVersionService(categoryVersionService);
// return generator;
// }
}
PictureCursorKeyGenerator
package com.zwnsyw.yunpicturebackend.service.impl.utils;
import com.zwnsyw.yunpicturebackend.model.dto.PictureRequest.PictureCursorRequest;
import com.zwnsyw.yunpicturebackend.service.CategoryVersionService;
import com.zwnsyw.yunpicturebackend.service.TagVersionService;
import lombok.Setter;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.cache.interceptor.KeyGenerator;
import org.springframework.stereotype.Component;
import java.lang.reflect.Method;
import java.util.List;
@Setter
@Component("pictureCursorKeyGenerator")
public class PictureCursorKeyGenerator implements KeyGenerator {
@Autowired
private CategoryVersionService categoryVersionService;
@Autowired
private TagVersionService tagVersionService;
@Override
public Object generate(Object target, Method method, Object... params) {
if (params.length == 0 || !(params[0] instanceof PictureCursorRequest)) {
return "default_key";
}
PictureCursorRequest request = (PictureCursorRequest) params[0];
String searchText = request.getSearchText();
String category = request.getCategory();
List<String> tags = request.getTags();
// 处理searchText作为分类或标签的情况
int searchTextTagVer = 0;
if (searchText != null && !searchText.isEmpty()) {
searchTextTagVer = tagVersionService.getVersion(searchText);
}
int searchTextCategoryVer = 0;
if (searchText != null && !searchText.isEmpty()) {
searchTextCategoryVer = categoryVersionService.getVersion(searchText);
}
// 获取版本号
int categoryVer = 0;
if (category != null) {
categoryVer = categoryVersionService.getVersion(category);
}
int tagVer = 0;
if (tags != null && !tags.isEmpty()) {
tagVer = tags.stream()
.map(tag -> tagVersionService.getVersion(tag))
.max(Integer::compare)
.orElse(0);
}
// 构造缓存键
return String.format(
"YunPicture:picture:listByCursor:" +
"searchText=%s:" +
"category=%s:categoryVer=%d:" +
"tags=%s:tagVer=%d:" +
"searchTextTagVer=%d:searchTextCategoryVer=%d",
searchText,
category,
categoryVer,
tags != null ? String.join(",", tags) : "",
tagVer,
searchTextTagVer,
searchTextCategoryVer
);
}
}
在Service层应用@Cacheable
在PictureService的listPicturesByCursor方法上添加@Cacheable:
@Cacheable(value = "pictureListCache",
keyGenerator = "pictureCursorKeyGenerator")
@Override
public PictureCursorResponse listPicturesByCursor(PictureCursorRequest pictureCursorRequest) {
注意需要将返回类型设成可序列化 否则会报错
package com.zwnsyw.yunpicturebackend.model.vo;
import lombok.AllArgsConstructor;
import lombok.Data;
import lombok.NoArgsConstructor;
import java.io.Serializable;
import java.util.List;
@Data
@NoArgsConstructor
@AllArgsConstructor
public class PictureCursorResponse implements Serializable {
/**
* 游标
*/
private String cursor;
/**
* 是否为最后一个数据
*/
private boolean isLast = false;
/**
* 图片列表
*/
private List<PictureVO> records;
private static final long serialVersionUID = 1L;
}
在修改标签/分类时更新版本号
在修改标签或分类的业务逻辑中调用版本号服务
/**
* 编辑图片(给用户使用)
*/
@PostMapping("/edit")
@PreAuthorizeRole("USER")
public BaseResponse<Boolean> editPicture(
@RequestBody @Valid PictureEditRequest pictureEditRequest,
HttpServletRequest request) {
ThrowUtils.throwIf(pictureEditRequest == null || pictureEditRequest.getId() <= 0,
ErrorCode.PARAMS_ERROR, "请求参数错误");
LoginUserVO loginUser = userService.getLoginUser(request);
long id = pictureEditRequest.getId();
Picture oldPicture = pictureService.getById(id);
ThrowUtils.throwIf(oldPicture == null, ErrorCode.NOT_FOUND_ERROR, "图片不存在");
ThrowUtils.throwIf(!oldPicture.getUserId().equals(loginUser.getId())
&& !loginUser.getRoles().contains(ADMIN_ROLE),
ErrorCode.NO_AUTH_ERROR, "无权编辑图片信息");
// 处理图片信息
Picture picture = new Picture();
BeanUtils.copyProperties(pictureEditRequest, picture);
if (pictureEditRequest.getTags() != null) {
picture.setTags(JSONUtil.toJsonStr(pictureEditRequest.getTags()));
}
picture.setEditTime(new Date());
pictureService.validPicture(picture);
// --- 新增:版本号更新逻辑 ---
// 1. 处理分类变化
String oldCategory = oldPicture.getCategory();
String newCategory = pictureEditRequest.getCategory();
if (!Objects.equals(oldCategory, newCategory)) {
// 更新旧分类版本号
if (oldCategory != null) {
categoryVersionService.incrementVersion(oldCategory);
}
// 更新新分类版本号
if (newCategory != null) {
categoryVersionService.incrementVersion(newCategory);
}
}
// 2. 处理标签变化
List<String> oldTags = oldPicture.getTags() != null
? JSONUtil.toList(oldPicture.getTags(), String.class)
: Collections.emptyList();
List<String> newTags = pictureEditRequest.getTags() != null
? pictureEditRequest.getTags()
: Collections.emptyList();
// 计算新增和移除的标签
Set<String> addedTags = new HashSet<>(newTags);
Set<String> removedTags = new HashSet<>(oldTags);
// 保留差异
oldTags.forEach(addedTags::remove); // 新增的标签
newTags.forEach(removedTags::remove); // 移除的标签
// 递增所有新增和移除的标签版本号
for (String tag : addedTags) {
tagVersionService.incrementVersion(tag);
}
for (String tag : removedTags) {
tagVersionService.incrementVersion(tag);
}
// --- 新增结束 ---
pictureService.fillReviewParams(picture, loginUser);
boolean result = pictureService.updateById(picture);
ThrowUtils.throwIf(!result, ErrorCode.OPERATION_ERROR, "图片编辑失败");
// 生成消息并推送
Message message = new Message();
message.setUserId(oldPicture.getUserId());
message.setTitle("图片待审核通知");
message.setContent("您的图片《" + oldPicture.getName() + "》已成功上传,请耐心等待管理员审核;");
message.setType(MessageTypeEnum.REVIEW_MESSAGE.getValue());
messageService.addMessage(message);
return ResultUtils.success(true);
}
精确查询的缓存(可选)
例如,对getPictureById方法添加缓存:
@Cacheable(value = "pictureByIdCache", key = "#id")
@Override
public PictureVO getPictureById(Long id) {
Picture picture = getById(id);
if (picture == null) {
return null;
}
return convertToPictureVO(picture);
}
在删除时清除对应缓存:
@CacheEvict(value = "pictureByIdCache", key = "#id")
@Override
public boolean deletePicture(Long id, String url) {
//todo 数据库中是逻辑删除 这里是删除云上的图片实体 无法恢复 需要考虑是否执行这段逻辑
if (url != null) {
fileManager.deletePicture(getFullUrl(url));
}
boolean result = this.removeById(id);
ThrowUtils.throwIf(!result, ErrorCode.OPERATION_ERROR, "图片删除失败");
return true;
}
修改时清除对应缓存:
@Transactional(rollbackFor = Exception.class)
@CacheEvict(value = "pictureByIdCache", key = "#pictureUpdateRequest.id")
@Override
public boolean updatePicture(PictureUpdateRequest pictureUpdateRequest, LoginUserVO loginUser) {
Picture oldPicture = this.getById(pictureUpdateRequest.getId());
ThrowUtils.throwIf(oldPicture == null, ErrorCode.NOT_FOUND_ERROR, "图片不存在");
// 只更新需要修改的字段
Picture updatedPicture = createUpdatedPicture(pictureUpdateRequest, oldPicture);
validPicture(updatedPicture);
this.fillReviewParams(updatedPicture, loginUser);
boolean result = this.updateById(updatedPicture);
ThrowUtils.throwIf(!result, ErrorCode.OPERATION_ERROR, "图片更新失败");
return true;
}
在更新的时候用@CachePut如何 其实不太行 最好直接清掉就好
@CacheEvict vs @CachePut的区别:
@CacheEvict:用于清除缓存中的特定条目(如更新或删除操作后)。
@CachePut:在方法执行后,将方法的返回值存入缓存,但不会清除原有条目。适用于需要保留旧数据但希望同时缓存新数据的场景。
在这里如果要用@CachePut的话 还需要把返回值从boolen改成picturevo
@Transactional(rollbackFor = Exception.class)
@CachePut(value = "pictureByIdCache", key = "#pictureUpdateRequest.id")
@Override
public PictureVO updatePicture(PictureUpdateRequest pictureUpdateRequest, LoginUserVO loginUser) {
// ... 原有逻辑(更新数据库)...
// 获取更新后的图片对象并返回
Picture updatedPicture = getById(pictureUpdateRequest.getId());
return convertToPictureVO(updatedPicture);
}
方法返回类型必须匹配 @CachePut:
@CachePut需要方法返回一个对象(如PictureVO),以便将返回值存入缓存。
如果方法返回boolean,@CachePut无法将boolean存入缓存,会导致异常。
事务与缓存的同步:
如果使用@CachePut,确保缓存更新在事务提交后执行,以避免脏读。可以通过配置cacheManager的事务支持或显式调用flush操作。
性能优化:
如果更新操作频繁且数据量大,@CacheEvict可能更高效,因为它直接清除缓存,避免存储过期数据。
如果需要保留历史版本或需要原子性更新,@CachePut更合适。
总总总总结
Spring Cache 常用用法与缓存失效策
一、核心思想与设计原则
缓存失效的两种场景
精确查询(如按ID查询): 缓存键与数据ID直接绑定,修改数据时直接清除对应缓存键,简单高效。
@CacheEvict(value = "pictureByIdCache", key = "#id")
public boolean deletePicture(Long id, String url) { ... }
模糊查询(如按标签、分类搜索):
较难设置缓存失效策略
问题本质:
多对多关系:一个数据项可能被多个缓存键引用(如图片2同时属于“风景”和“热门”标签)。
反向索引缺失:无法直接根据数据ID快速找到所有相关的缓存键。
解决方案:全量清除+短TTL(笨拙命中率低)、反向索引 记录将value作key 再维护一个缓存(冗余繁重多对多网)、基于条件的缓存失效 利用版本号机制
需结合版本号机制,通过动态生成缓存键(包含版本号)实现缓存失效。
@Cacheable(value = "pictureListCache", keyGenerator = "pictureCursorKeyGenerator")
public PictureCursorResponse listPicturesByCursor(PictureCursorRequest request) { ... }
版本号机制实现
- 核心思路
版本号与查询条件绑定: 每个查询条件(如标签、分类)维护独立的版本号,当数据变更时,递增对应版本号,导致缓存键变化,旧缓存失效。
Redis存储版本号: 版本号存储在Redis中,避免修改数据库表结构,且通过TTL(Time To Live)自动清理旧版本号。(常用方法是分标签表 分分类表 加版本号字段 持久化版本号 但这里我们修改不是特别频繁 所以可以用redis维护版本号 循环使用)
- 具体实现
版本号服务:通过Redis原子操作维护版本号:
@Service
public class TagVersionService {
private final RedisTemplate<String, Object> redisTemplate;
public int getVersion(String tag) {
String key = "tag_version:" + tag;
return (int) redisTemplate.opsForValue().get(key); // 默认初始化为0
}
public void incrementVersion(String tag) {
String key = "tag_version:" + tag;
redisTemplate.opsForValue().increment(key, 1);
redisTemplate.expire(key, 10, TimeUnit.MINUTES); // 设置TTL为10分钟
}
}
动态生成缓存键: 在KeyGenerator中拼接版本号:
@Component("pictureCursorKeyGenerator")
public class PictureCursorKeyGenerator implements KeyGenerator {
@Override
public Object generate(Object target, Method method, Object... params) {
PictureCursorRequest request = (PictureCursorRequest) params[0];
int tagVer = tagVersionService.getVersion(request.getTags().get(0));
int categoryVer = categoryVersionService.getVersion(request.getCategory());
return String.format("YunPicture:listByCursor:category=%s:tagVer=%d:categoryVer=%d",
request.getCategory(), tagVer, categoryVer);
}
}
二、Spring Cache 核心注解与用法
@Cacheable:缓存查询结果
作用:将方法返回值存入缓存,避免重复查询。
参数:
value:指定缓存名称(如"pictureListCache")。
key/keyGenerator:定义缓存键,支持SpEL表达式或自定义生成器。
示例:
@Cacheable(value = "pictureByIdCache", key = "#id")
public PictureVO getPictureById(Long id) { ... }
@CacheEvict:清除缓存
作用:在方法执行后清除指定缓存。
参数:
value:指定缓存名称。
key:指定清除的键(如#id)。
allEntries:是否清除所有缓存条目(全局清除)。
示例:
@CacheEvict(value = "pictureByIdCache", key = "#id")
public boolean updatePicture(PictureUpdateRequest request) { ... }
@CachePut:更新缓存而不清除
作用:将方法返回值存入缓存,但保留旧数据(适用于部分更新场景)。
注意:返回值类型必须与缓存值类型一致,且需手动维护旧数据。
示例:
@CachePut(value = "pictureByIdCache", key = "#pictureUpdateRequest.id")
public PictureVO updatePicture(PictureUpdateRequest request) { ... }
三、最佳实践与注意事项
- 缓存键设计原则
唯一性:确保不同参数生成不同的键。
简洁性:避免冗长键名,可通过拼接参数或使用KeyGenerator。
时效性:结合版本号或时间戳,避免缓存过期前数据已变更。
- TTI与TTL设置
缓存TTL:通过@Cacheable的cacheManager配置或Redis的expire方法设置。
版本号TTL:比查询缓存的TTL长,确保版本号在缓存失效后仍存在。
// Redis配置示例
redisTemplate.expire(key, 10, TimeUnit.MINUTES); // 版本号TTL设为10分钟
- 并发控制
Redis原子操作:使用INCR确保版本号递增的线程安全。
缓存穿透防护:对空值缓存(如null),设置短TTL。
@Cacheable(value = "pictureByIdCache", key = "#id")
public PictureVO getPictureById(Long id) {
Picture picture = getById(id);
if (picture == null) return null; // 空值缓存,避免重复查询数据库
return convertToPictureVO(picture);
}
- 缓存与数据库一致性
最终一致性:通过版本号机制容忍短暂不一致,依赖数据库作为最终数据源。
事务管理:在@Transactional方法中更新版本号,确保数据和缓存操作的原子性。
四、常见问题与解决方案
- 缓存雪崩(大量缓存同时失效)
解决方案:
设置随机TTL(如TTL ± 10%)。
使用Redis的集群和分片。
- 缓存击穿(热点Key频繁失效)
解决方案:
对热点Key设置永不过期,或使用逻辑过期(记录失效时间)。
- 缓存与事务的关联
注意:
@CacheEvict默认在事务提交后执行,确保缓存与数据库状态一致。
若需在事务中执行,可通过cacheManager手动操作。
- 性能调优
缓存分级:
一级缓存(如Redis):存储高频数据。
二级缓存(如本地缓存):减少网络开销。
统计监控:
使用@EnableCaching的统计功能,或通过Redis的INFO命令监控命中率。
项目分区导航:⬅️ 03-AI 图片编辑 | 04-@Cacheable和@CacheEvict 缓存失效策略 | ➡️ 05-redis与session
💬 评论