云图库项目扩展,三种缓存方案 + 缓存同步方案及未来扩展思路

  首先非常感谢大家对我项目的认可和支持。在上一篇文章中,我介绍了我的云图库项目。这篇文章重点介绍我在项目中实现的缓存方案、缓存同步方案以及未来扩展思路。如果大家觉得我的代码和方案有所帮助,帮忙点一下Star,感谢。   线上地址:https://imagecore.netlify.app/   开源地址:https://github.com/Remon-16/imagecore-backend

版本说明

  下列是我使用的一些组件的版本。Redis和MySQL的搭建教程在网络上比较多,这里不再赘述。有关Canal + RocketMQ环境搭建部分请看文章:Canal + RocketMQ监听MySQL环境搭建

Redis 7.4.3 MySQL 5.7.44 Canal 1.1.8 RocketMQ 4.9.7

Canal + RocketMQ缓存同步方案

方案介绍

  我在这里介绍一下Canal + RocketMQ的总体思路,相关代码入口在开源仓库的CanalMessageConsumer类里。   1、在Canal的instance.properties文件中设置要监听的表,哪个表应用了缓存,就监听哪个表,不要设置全部监听,不然也是一种资源浪费。   2、handleEntryList方法会返回Map<String, List< CanalHandleVo>>,里面放着这一批数据库表的变动信息。你可以在cacheHandle方法中新增其他数据库表的处理入口。   3、cacheHandle中新增好数据库表的入口之后,就要去对应的Service里编写处理数据的方法。下面介绍我自己项目中的处理方法。

点赞功能缓存方案

方案介绍

  点赞功能的缓存方案使用的是站内教程:亿级流量点赞系统教程,详细内容这里就不再赘述。代码入口在图片服务的ThumbController,以及SyncThumb2DBJob(定时任务)。示意图如下:

mermaid
复制代码
flowchart LR A[用户] --> B[点赞 / 取消] B --> C[Redis<br>更新点赞状态] C --> D[定时任务同步数据] D --> E[(数据库<br>持久化存储)]

  使用该方案的原因是,点赞在用户操作上,是一个非常简单并且快速的操作。我个人认为这个功能需要比较快的响应。所以点赞和取消点赞的操作都是在Redis上进行,之后由定时任务同步到数据库中。在点赞表中,由于用户Id和图片Id有一个唯一索引的约束,所以能够保证图片点赞的最终一致性。   在查询时,图片表中自带一个点赞数量的列,缓存中也会为每个图片单独维护一个点赞数量。如果相关的图片点赞数据命中缓存,那么就使用缓存中的数据。如果没有,则直接使用图片表中自己带出的点赞数量,并且将该数据同步到缓存中。这样设计可能会带来不一致的问题,但最大的好处就是快(因为所有查询点赞相关的内容都是走缓存,不会去查数据库)。   我在做这个功能的时候是这样想的:一般用户可能更加在乎一个图片自己有没有点过赞,不会去不断地刷新看着点赞量的增长情况(而且如果真的有超级热门内容,点赞数量会显示xxK,xxW)。而一个网站绝大多数用户都是浏览者,而且图片查询又是一个图库网站比较核心的内容,所以我在这块的设计最终还是偏向了一般用户的体验。   内容的发布者会比较在意自己发布的内容是否有人点赞,可能时不时的会去查询自己发布的内容。对于发布者,我是在点赞信息入库的时候,给发布者结算积分以及发送点赞信息的通知(每十秒入库一次)。这样就极大程度上保证了发布者能够及时看到准确的点赞数量。如果未来需要扩展一个给发布者结算奖金的功能,可能会在某一个时刻,直接查询点赞表。因为金额结算需要很大的确定性,而查表有最终一致性做保证。   综上,我就使用了这样的一个设计思路。如果有什么疑问或者建议,都可以在评论区中讨论。

图片查询功能缓存方案

方案介绍

  在云图库项目中,首页需要多维检索。一个简单的方法就是将查询的Request类进行MD5运算,将得到的值作为缓存的Key,查询结果作为Value进行存储。如果有完全相同的查询过来,MD5的结果也会是一致的,也就命中了缓存。但这样做就会产生一个问题:如果首页图片的名称、URL等内容发生了更新,或者图片被发布者或管理员删除,再或者有新的图片发布,怎么解决缓存问题?   对于这块的内容,在做之前,我问过Deep Seek,那些大厂是如何做首页缓存的。它给我的回复是,大厂的首页缓存是拼接出来的,比如采取用户感兴趣的内容拼接50%,再加上全站热门内容拼接30%,再加上其他热门内容或者广告占据20%。然后我打开了B站手机端首页,我发现似乎不管是向上刷新,还是往回刷新,都能刷出新内容。当时我感觉大厂的方案,我一个小网站可能还是用不了,整体上可能还是需要使用上一段中提到的方案。   如果没有缓存同步方案,就只能是把首页缓存过期时间限制在10分钟左右,短时间内的不一致也不会有问题。在结合Canal+RocketMQ同步缓存的方案之后,上述的问题得到了一定程度的解决。Canal在监听MySQL的binlog时,如果发生了增删改,那么会通过MQ发送消息。哪个表的哪一行数据发生了什么样的变动,这些信息都能拿到(当然前提是Canal的设置中需要监听那个表,不然也不会发消息)。后端接收到消息后,就可以进行针对性的处理。   首页流程如下。首页缓存的代码入口:PictureController下的listPictureVOByPageWithCache

mermaid
复制代码
flowchart LR A[客户端请求] --> B[缓存Key拼接 <br> md5(Request)] --> C{缓存是否存在?} C -- 是 --> D[直接返回] C -- 否 --> E[查询数据库] --> F[写入缓存] --> D

  更新/删除过程如下:

mermaid
复制代码
flowchart LR A[用户更新图片] --> B[数据变更] --> C[Canal监听] --> D[消息队列] --> E[模糊匹配<br>首页缓存keys] subgraph 缓存更新/删除策略 E --> F[遍历到目标缓存] F --> G[修改/删除缓存] end

  对于新发布的图片,由于需要审核,本来就不是刚发布即可查询的,所以可以不做处理。如果是更新,可以使用Redis的批量查询,将所有首页缓存全部查出来,按照图片Id去查找哪个缓存中有那个图片,同步改掉即可。对于删除操作,可以直接把对应的缓存删除,这样再查询的时候就直接去数据库查询了。   当然这样做可能会一些小问题。比如每页10条数据,那么第一页删掉一个图片,再查数据库会把本来第二页的第一条数据放在第一页的末尾。当用户翻到第二页的时候,就会发现一个一模一样的图片。还有就是新过审的内容也不会及时发布出来(因为很难确定哪个缓存是第一页的,即便可以New一个Request再MD5,也很难解决多为多维检索的情况)。最终对于首页缓存,过期时间设置为1小时±1小时,也就是可能最长2小时时间内存在不一致的问题。但这已经比之前好太多了。   对于图片详情页的缓存更新,那就简单太多了。通过图片Id作为缓存Key,查询结果作为Value。更新和删除时可以直接通过Canal拿到数据,之后该更新更新,该删除删除。新发布的不用管,等到首页查出来的时候,通过数据库后往缓存里更新即可。   详情页缓存流程如下。代码入口:PictureController下的getPictureVOById

mermaid
复制代码
flowchart LR A[客户端请求] --> B[缓存Key拼接<br>前缀:pictureId] --> C{缓存是否存在?} C -- 是 --> D[直接返回] C -- 否 --> E[查询数据库] --> F[写入缓存] --> D

  更新/删除过程流程如下:

mermaid
复制代码
flowchart LR A[用户更新图片] --> B[数据变更] --> C[Canal监听] --> D[消息队列] --> E[查找目标缓存] subgraph 缓存更新/删除策略 E --> F[修改/删除缓存] end

待扩展点

  手动刷缓存: 可以给管理员增加一个手动刷缓存的接口。在存储首页缓存的时候,将Request和查询结果一起封装到一个VO类里面。Redis批量查询首页缓存,然后直接返回查询到的JSON String。管理员在前端加一个“刷新缓存”和“删除缓存”的按钮,就可以按需刷新或删除。刷新缓存就是把JSON String解析出来,拿到Request,然后从数据库里查一遍put进缓存即可。删除就是拿着Key直接删掉缓存。

图片评论功能缓存方案

方案介绍

  评论区的功能主要是起一个用户之间交互的作用,应当满足两个条件:1快速响应 2实时更新。由于需要输入和提交,评论的新增以及更新不像点赞操作那样便捷和快速,所以我的主体思路就是新增和更新先进入数据库,之后再通过Canal更新到缓存中。   评论区缓存流程如下,以一级评论为例(为了美观,减少冗余,省略缓存是否存在的分支)。代码入口:PictureCommentController下的getPictureCommentRootVo和getPictureCommentVo

mermaid
复制代码
flowchart LR A[客户端请求] --> B[缓存Key拼接<br>前缀:pictureId] --> C[查询ZSet] --> D[查询评论详情] --> F[缓存命中则返回]

  新增流程如下。

mermaid
复制代码
flowchart LR A[用户新增评论] --> B[数据变更] --> C[Canal监听] --> D[消息队列] --> E[插入ZSet] subgraph 缓存更新策略 E --> F[新增评论详情] F --> G[更新total] end

  更新流程如下。

mermaid
复制代码
flowchart LR A[用户新增评论] --> B[数据变更] --> C[Canal监听] --> D[消息队列] --> E[查询ZSet] subgraph 缓存更新策略 E --> F[查询评论详情] F --> G[修改详情缓存] end

  删除流程如下。

mermaid
复制代码
flowchart LR A[用户删除评论] --> B[数据变更] --> C[Canal监听] --> D[消息队列] --> E[删除ZSet<br>中的相关记录] subgraph 缓存删除策略 E --> F[删除详情缓存] F --> G[更新total] end

  为了满足上面提供的两个条件,必须使用高效的缓存结构。Redis的ZSet就是一个很好的选择。ZSet是一个天然有序的结构,分别有三个参数,key,value和score。把评论的Id设置为value, createTime设置为score(通过getTime()转成Long),具体效果如下所示。可以看出,它是通过score的升序排列的。

text
复制代码
redisTemplate.opsForZSet().addIfAbsent(redisKey, value, score); // 查询Key的结果 [ { "value": "1962531059353346049", "score": 1756738841000 }, { "value": "1962532931002785793", "score": 1756739287000 }, { "value": "1962795654865342466", "score": 1756801925000 }, …… ]

  评论区排序的方式一般有三种:最先评论的在前(createTime升序),最新评论的在前(createTime降序),最热评论在前(需要引入热度的概念)。按热度排序的方式是一个待扩展点,在下面进行讨论。前面两种方式,我个人的设计如下:

text
复制代码
// 以一级评论为例 // 升序Key Key前缀:pictureId:sortOrder:ascend // 降序Key Key前缀:pictureId:sortOrder:descend

  这样设计也就表示同一个评论的Id可能会被放在两个ZSet里。这里可能会有野生吴彦祖要问了:那不是浪费缓存空间嘛? 确实可能会造成浪费。但是我们可以设想一下,假设一个图片有100个一级评论,按照最先评论在前原则,第一页应当查出前面10条。如果按照最新评论原则,第一页应当查出90-100这10条。如果一个用户,先按照最先评论原则查出了第一页,这时缓存里就是前面10条的结果。如果这时候用户切换到最新评论模式,他将会看到什么呢?没错,前面10条评论的倒叙,而不是90-100条的倒叙,这样就会出现问题。相应的,按照两种不同原则分别建立ZSet,就可以防止这种情况。   用户查询评论的时候,首先查询ZSet,命中之后再通过Id去查询缓存里面的评论详情。这样设计就缓解了缓存空间的消耗,同时响应也会比较好。这里可能又有野生吴彦祖要问了:那你是怎么分页的?怎么判断该查缓存,还是改查数据库的?我的方案就是把评论的总数也存入缓存,先查询total在不在,然后再从ZSet里查询对应页数的Id,最后再去查评论详情,代码如下

text
复制代码
相关方法:CacheManager的querySortedValues Long total = getTotal(sortedTotalKey); if (total == null) { return null; } SortedCacheResult sortedCacheResult = new SortedCacheResult(); sortedCacheResult.setTotal(total); // 总数为 0 也算命中 if(ZERO.equals(total)){ sortedCacheResult.setValueMap(new LinkedHashMap<>()); return sortedCacheResult; } // 2. 从zSet里查询id String zSetKey = RedisKeyUtil.buildRedisKey(sortedKey); Set<Object> idSet = null; if(DESC.equals(sortOrder)){ idSet = redisTemplate.opsForZSet().reverseRange(zSetKey, (page - 1) * size, page * size - 1); }else{ // 默认升序 idSet = redisTemplate.opsForZSet().range(zSetKey, (page - 1) * size, page * size - 1); } // 满足下列条件,直接返回一个空 if(idSet == null || idSet.isEmpty() || (idSet.size() != size && !ZERO.equals(total - (page - 1) * size - idSet.size()))){ return null; } // 3. 从id里获取值 ……

  下面这个条件就是一个重点,必须要校验一下数量。如果不加这一行,假设某一个用户在新增评论时,正好ZSet缓存过期了,那么他新增了一条进去,最终Canal也新建了一个ZSet,里面就这一条数据。然后另一个用户一查询,刚好命中了这只有一条记录的缓存,那数据就不对了。

text
复制代码
idSet.size() != size && !ZERO.equals(total - (page - 1) * size - idSet.size())

  查询到此就算是介绍完了。更新缓存的话,简单来说就是数据库新增、修改、删除,缓存里面也做同样的操作(操作ZSet和对应的评论详情),可以参考PictureCommentDomainServiceImpl类里面canal开头的方法。

待扩展点

  评论新增和编辑优化: 目前评论的新增和编辑都是直接和数据库进行交互,如果数据库的压力比较大,那么上述两个操作的响应有可能会变慢。可以将这两个操作通过Rocket MQ进行解耦,把新增和编辑的结果发送给MQ,通过其他的接口或者应用进行消费。Canal更新缓存那里应该不用改,它是监听binlog变化之后就会更新缓存,本身也是解耦的。不过这样可能会进一步加大缓存与数据库不一致的时间,我也在想能不能通过某种方式去判断数据库是否比较忙,不忙直接进数据库,忙的话走MQ。   引入评论热度: 可以结合点赞和回复量,制定一些功能,在发生点赞或者回复的时候,改变一下ZSet中的score。我认为可以新建一个ZSet,初始还是以createTime作为初始化,之后直接在上面减就行,这样就保证了创建时间早的初始热度高。然后在数据库表里增加一个热度的列,把改好的数值更新进去即可。   引入Lua脚本更新缓存: 目前这个版本在canal更新缓存时,都是一条一条更新的,这样有可能会带来短时不一致。可以把更新缓存的地方打包成一个Lua脚本,一次就把所有该更新的都更新好,这样更好一点。目前的方案因为Canal是在binlog变化之后才会发送消息,我个人理解这个方案也是能够保证最终一致性。

总结

  以上就是我的云图库中实现的几种缓存方案、同步方案以及未来扩展点。如果大家有什么问题或者建议,欢迎在评论区中告诉我。

0个评论
点击登录,快来和大家讨论吧~
表情
图片
暂无评论
午后茶
下载 APP