写在前面

最近在写数据报表逻辑,里面大部分场景都是统计数据,对数据时效性要求不高,不过要求数据能立即响应用户。于是尝试借助缓存来加快响应用户的时间,主要用到了分页列表缓存。

直接缓存分页列表

说明

最简单,也是最能想到的方法就是直接缓存分页列表。举个例子,如下所示,直接将每页的数据以列表形式进行缓存:

对应的伪代码如下所示:

1
2
3
4
5
6
7
8
9
public List<Product> getPageList(String param, int pageNum, int pageSize) {
String key = "productPageList:pageNum:" + pageNum+ "pageSize:" + pageSize+ "param:" + param ;
List<Product> productList = cacheUtil.get(key);
if(productList != null)return productList;
productList = queryFromDB(param,pageNum,pageSize);
if(productList != null) {
cacheUtil.set(key , productList , Constants.ExpireTime);
}
}

优缺点

这种方式优点就是操作简单,性能也快,缺点就是缓存的粒度太粗。如果列表中数据发生增删,为了保证数据的一致性,需要修改分页列表缓存。

当然了,可以有两种方式来解决 :
(1)利用缓存的过期时间来惰性删除,不过这种要求业务能接受;
(2)使用Redis提供的keys命令来找到对应的分页缓存,之后执行删除操作。不过keys命令对性能影响很大,会导致 Redis产生较大的延迟,这一点在并发性要求高的业务下是很难满足的 。而且生产环境使用keys命令比较危险,很容易出现问题,因此不推荐使用keys命令。

先查询对象ID列表,再缓存每个对象

前面说过直接缓存分页列表,导致缓存粒度较粗,很难保证数据的一致性,因此我们可以尝试细粒度缓存,即先查询对象ID列表,再缓存每个对象。

说明

举个例子,如下所示,首先我们查询分页对象ID列表,然后再缓存每一个对象,后续通过对象ID和缓存的对象来聚合形成列表返回给前端:

对应的伪代码如下所示:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
public List<Product> getPageList(String param, int pageNum, int pageSize) {
//1、构建返回对象
List<Product> result = new ArrayList<>(pageSize);
//2、从数据库中查询分页ID列表
List<Long> productIdList = queryproductIdListFromDB(param,pageNum,pageSize);
//3、为空则直接返回空列表
if(CollectionUtils.isEmpty(productIdList)){
return new ArrayList();
}
//4、批量从缓存中获取缓存对象列表
Map<Long, Product> cacheProductMap = cacheUtil.mget(productIdList);
//5、缓存中可能没有一些对象(没命中对象),那么需要将这些对象ID存起来
List<Long> noHitpProductIdList = new ArrayList<>(productIdList.size());
noHitpProductIdList.forEach(productId->{
if(!cacheProductMap.containsKey(productId)){
noHitpProductIdList.add(productId);
}
})
//6、将前面没有命中的对象从数据库中查询并添加到缓存中
List<Product> noHitProductList = queryFromDBByIds(noHitpProductIdList);
if(CollectionUtils.isNotEmpty(noHitProductList)){
Map<Long, Product> noHitProductMap = noHitProductList.stream()
.collect(Collectors.toMap(Product::getId, Function.identity()));
//将没有命中的对象添加到缓存中
cacheUtil.mset(noHitProductMap);
//将没有命中的对象添加到聚合Map中
cacheProductMap.putAll(noHitProductMap);
}
//7、组装返回对象
productIdList.forEach(productId->{
Product product = cacheProductMap.get(productId);
if(product != null){
result.add(product);
}
});
return result;
}

下面简单解释一下上述代码的含义:
(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
2
3
if(CollectionUtils.isEmpty(productIdList)){
return new ArrayList();
}

(4)批量从缓存中获取缓存对象列表:

1
Map<Long, Product> cacheProductMap = cacheUtil.mget(productIdList);

这里我们采用的是分布式缓存系统Redis,它天然支持批量查询命令,如mget、mset等。
(5)缓存中可能没有一些对象(没命中对象),那么需要将这些对象ID存起来:

1
2
3
4
5
6
List<Long> noHitpProductIdList = new ArrayList<>(productIdList.size());
noHitpProductIdList.forEach(productId->{
if(!cacheProductMap.containsKey(productId)){
noHitpProductIdList.add(productId);
}
})

由于缓存中可能出现对象过期或者其他原因,导致缓存没有命中,此时我们需要找到哪些对象是没有命中的。
(6)将前面没有命中的对象从数据库中查询并添加到缓存中:

1
2
3
4
5
6
7
8
9
List<Product> noHitProductList = queryFromDBByIds(noHitpProductIdList);
if(CollectionUtils.isNotEmpty(noHitProductList)){
Map<Long, Product> noHitProductMap = noHitProductList.stream()
.collect(Collectors.toMap(Product::getId, Function.identity()));
//将没有命中的对象添加到缓存中
cacheUtil.mset(noHitProductMap);
//将没有命中的对象添加到聚合Map中
cacheProductMap.putAll(noHitProductMap);
}

可以看到首先我们从数据库中批量查询出未命中的对象列表,其实就相当于执行如下SQL:

1
select * from t_product where id in (1,2,3......);

之后就将命中的对象添加到缓存和聚合Map中。
(7)组装返回对象:

1
2
3
4
5
6
productIdList.forEach(productId->{
Product product = cacheProductMap.get(productId);
if(product != null){
result.add(product);
}
});

可以看到此时最坏的情况就是经过两次网络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
2
int start = (pageNum -1) * pageSize;
int stop = start + pageSize - 1;

这样通过ZREVRANGE命令,我们就可以查询出动态ID列表了。在获取到动态ID列表之后,我们还需要缓存每个动态对象的信息,这些动态对象包括详情、评论、点赞、转发等内容,这些内容是需要单独做缓存的:

动态信息 Redis存储方式
动态 使用Hash来存储动态详情
点赞 使用ZSET来存储userId,前端显示用户头像,使用String缓存用户信息
收藏 使用String来存储userId和FeedId的映射关系
评论 使用ZSET来存储commentId,使用String缓存评论详情信息

当然,无论是查询还是更新缓存,笔者都是建议采用批量操作,这样效率更高。如果缓存对象结构简单,那么可以使用mgethmget等命令;若结构复杂,可以使用pipleline,Lua脚本等方式。 笔者采用Redis的pipleline来实现批量操作。

“缓存对象ID列表同时缓存每个对象”这种方案的流程如下所示:
(1)使用ZSet来存储动态 ID 列表,并通过传入分页参数和ZREVRANGE命令来动态查询ID列表;
(2)通过传递动态ID列表参数,并采用Redis的pipleline功能,从缓存中批量获取动态的详情,评论,点赞,收藏等数据 ,最终组装成所需列表并返回。

总结

本篇主要学习了分页缓存的三种方案,如下所示:
(1)直接缓存分页列表;
(2)先查询对象ID列表,再缓存每个对象;
(3)缓存对象ID列表同时缓存每个对象。
这三种方式层层递进,最终采用细粒度控制缓存对象和批量加载缓存对象,进而实现快速响应用户请求这一目的。