巧用分页列表缓存,快速响应用户请求
写在前面
最近在写数据报表逻辑,里面大部分场景都是统计数据,对数据时效性要求不高,不过要求数据能立即响应用户。于是尝试借助缓存来加快响应用户的时间,主要用到了分页列表缓存。
直接缓存分页列表
说明
最简单,也是最能想到的方法就是直接缓存分页列表。举个例子,如下所示,直接将每页的数据以列表形式进行缓存:
对应的伪代码如下所示:
1 | public List<Product> getPageList(String param, int pageNum, int pageSize) { |
优缺点
这种方式优点就是操作简单,性能也快,缺点就是缓存的粒度太粗。如果列表中数据发生增删,为了保证数据的一致性,需要修改分页列表缓存。
当然了,可以有两种方式来解决 :
(1)利用缓存的过期时间来惰性删除,不过这种要求业务能接受;
(2)使用Redis提供的keys
命令来找到对应的分页缓存,之后执行删除操作。不过keys
命令对性能影响很大,会导致 Redis产生较大的延迟,这一点在并发性要求高的业务下是很难满足的 。而且生产环境使用keys
命令比较危险,很容易出现问题,因此不推荐使用keys
命令。
先查询对象ID列表,再缓存每个对象
前面说过直接缓存分页列表,导致缓存粒度较粗,很难保证数据的一致性,因此我们可以尝试细粒度缓存,即先查询对象ID列表,再缓存每个对象。
说明
举个例子,如下所示,首先我们查询分页对象ID列表,然后再缓存每一个对象,后续通过对象ID和缓存的对象来聚合形成列表返回给前端:
对应的伪代码如下所示:
1 | public List<Product> getPageList(String param, int pageNum, int pageSize) { |
下面简单解释一下上述代码的含义:
(1)构建返回对象:
1 | List<Product> result = new ArrayList<>(pageSize); |
(2)从数据库中查询分页ID列表:
1 | List<Long> productIdList = queryproductIdListFromDB(param,pageNum,pageSize); |
其实这个就相当于执行如下的SQL语句:
1 | select id from t_product order by id limit (pageNum -1)* size,size; |
(3)为空则直接返回空列表:
1 | if(CollectionUtils.isEmpty(productIdList)){ |
(4)批量从缓存中获取缓存对象列表:
1 | Map<Long, Product> cacheProductMap = cacheUtil.mget(productIdList); |
这里我们采用的是分布式缓存系统Redis,它天然支持批量查询命令,如mget、mset等。
(5)缓存中可能没有一些对象(没命中对象),那么需要将这些对象ID存起来:
1 | List<Long> noHitpProductIdList = new ArrayList<>(productIdList.size()); |
由于缓存中可能出现对象过期或者其他原因,导致缓存没有命中,此时我们需要找到哪些对象是没有命中的。
(6)将前面没有命中的对象从数据库中查询并添加到缓存中:
1 | List<Product> noHitProductList = queryFromDBByIds(noHitpProductIdList); |
可以看到首先我们从数据库中批量查询出未命中的对象列表,其实就相当于执行如下SQL:
1 | select * from t_product where id in (1,2,3......); |
之后就将命中的对象添加到缓存和聚合Map中。
(7)组装返回对象:
1 | productIdList.forEach(productId->{ |
可以看到此时最坏的情况就是经过两次网络IO,第一次是数据库查询IO,第二次是Redis查询IO,我们就能得到所需要的数据。
“先查询对象ID列表,再缓存每个对象”这种方案灵活性很高,我们还可以将查询对象ID列表这一操作从数据库中变为从Redis、ElasticSearch等。下图是博客文章的搜索流程:
可以看到,此时搜索的分页结果中只包含业务对象 ID ,而对象的详细信息则从缓存和数据库中获取。
缓存对象ID列表同时缓存每个对象
记得之前笔者在写一个社交APP的时候,遇到过Feed流的情况,需要以瀑布流的形式展示用户所关注的好友的动态。当时采用了推模式并结合Redis的ZSet数据结构来实现,将每一条动态ID存储在ZSet中,ZSet是一种有序的数据结构,由多个有序的唯一字符串组成,每个字符串都关联一个浮点类型的分数。
ZSet 使用的是 member -> score 结构 ,如下所示:
member是被排序的标识,也是默认的第二排序维度( 即score相同时,Redis以member的字典序排列);而score则是被排序的分值,存储类型是double。
我们可以使用ZSet来存储动态 ID 列表,其中member是动态编号,score是创建时间,然后通过ZSet 的ZREVRANGE
命令就可以实现分页效果,它用于按照成员的分数从大到小返回有序集合中的指定范围的成员:
1 | ZREVRANGE key start stop [WITHSCORES] |
当然了,为了实现分页效果,我们上面的start和stop的取值如下:
1 | int start = (pageNum -1) * pageSize; |
这样通过ZREVRANGE
命令,我们就可以查询出动态ID列表了。在获取到动态ID列表之后,我们还需要缓存每个动态对象的信息,这些动态对象包括详情、评论、点赞、转发等内容,这些内容是需要单独做缓存的:
动态信息 | Redis存储方式 |
---|---|
动态 | 使用Hash来存储动态详情 |
点赞 | 使用ZSET来存储userId,前端显示用户头像,使用String缓存用户信息 |
收藏 | 使用String来存储userId和FeedId的映射关系 |
评论 | 使用ZSET来存储commentId,使用String缓存评论详情信息 |
当然,无论是查询还是更新缓存,笔者都是建议采用批量操作,这样效率更高。如果缓存对象结构简单,那么可以使用mget
、hmget
等命令;若结构复杂,可以使用pipleline,Lua脚本等方式。 笔者采用Redis的pipleline来实现批量操作。
“缓存对象ID列表同时缓存每个对象”这种方案的流程如下所示:
(1)使用ZSet来存储动态 ID 列表,并通过传入分页参数和ZREVRANGE
命令来动态查询ID列表;
(2)通过传递动态ID列表参数,并采用Redis的pipleline功能,从缓存中批量获取动态的详情,评论,点赞,收藏等数据 ,最终组装成所需列表并返回。
总结
本篇主要学习了分页缓存的三种方案,如下所示:
(1)直接缓存分页列表;
(2)先查询对象ID列表,再缓存每个对象;
(3)缓存对象ID列表同时缓存每个对象。
这三种方式层层递进,最终采用细粒度控制缓存对象和批量加载缓存对象,进而实现快速响应用户请求这一目的。