总结两种常见的长列表分页缓存策略

通常,对于长列表加载的场景,都需要进行分页, 如最近的世界杯体育垂站项目中的赛程页,评论流,直播流。而为了提高分页加载的性能,往往需要对分页进行缓存。 下面总结对两种常见的分页缓存的策略, 适用场景以及各自的优缺点。
 
 

策略一: 直接对分页结果缓存

顾名思义,就是直接缓存每次分页查询的结果。
 
总结两种常见的长列表分页缓存策略
 
适用场景
  • 列表长度无法提前预知
  • 数据和排序不经常变更
 
优点:
  • 实现简单
  • 通常能获得不错的性能。由于直接缓存分页的结果,因此只需一次IO就能把数据查出来
 
缺点
  • 无法及时更新
随着列表增长,一个对象可能从第1页转移到第2页。因此当某个对象发生变更(或排序权重发生变化)后,无法判断该更新哪几页的缓存;除非同步维护一张倒排表,记录每个对象ID到所有缓存键值的映射关系, 但对于一般web应用实现成本有点高
 
  • 数据不一致
由于无法做到实时主动更新,因此失效时间不宜设置过长,这就需要根据实际业务场景(比如允许一定的更新延迟) 选取一个能接受的值, 而在缓存失效之前,需要忍受数据不一致。
 
  • 缓存键值(cacheKey)设计或使用不当, 可能会产生大量无效的缓存垃圾
假设分页查询的条件是 _prev_pos 和 size,_prev_pos为上一页最后一个对象的_pos值, 即从某个对象开始向前(或向后)检索size个对象, 则cacheKey = (_prev_pos+size), 选择不同的_prev_pos 和 size 会生成 不同的cacheKey。
类似的还有cacheKey= md5(url) , cacheKey = (startTime + endTime), 使用不当时也会产生大量垃圾cacheKey.
产生大量垃圾cacheKey的直接后果是,缓存空间会很快被耗尽,频繁触发LRU,最终影响应用的性能
 
 
 

策略二:缓存整个对象ID列表,再分别对每个对象建立缓存

 
总结两种常见的长列表分页缓存策略
预先把整个对象ID列表缓存起来,由程序实现分页逻辑,分页查询的结果是一个ID数组, 然后再根据每个ID从缓存中查找对象,最后组装成一个对象数组返回
 
适用场景
  • 列表长度有限
  • 对象数据或排序权重需要频繁更新
 
优点
  • 数据一致
在这种存储结构下, 当对象数据和排序权重发生变更时,能及时更新对应的缓存块,避免出现缓存的数据和数据库不一致的情况。又由于每次分页查询都是一次动态计算的结果,因此只要缓存更新了,就一定能拿到最新的结果
 
  • 缓存空间的大小是恒定且能提前预估
 
  • 缓存块能设置比较长的过期时间,不用担心缓存失效
 
缺点
  • IO次数 = n + 1 ( n 为每页的条数),为了保证性能, n 通常不能选得过大
  • 列表长度和 分页逻辑的算法 直接影响查询性能
  • 实现成本略高
 
 

策略3:缓存分页结果,并定时更新前几页缓存

在策略1的基础上,增加一个定时任务,定时刷新前几页的缓存, 从而尽量保证前几页的缓存是最新的。
 
由于在某些业务场景下,用户只会浏览前几页的内容,比如用户一般只会关注未来 1 ~ 2周的体育赛事, 因此只要保证前几页的内容是最新的即可。
 
 

总结

在项目实践中,我发现没有一种缓存策略能完美解决所有问题,往往需要在 性能 和 数据一致性之间寻找一个平衡点。 比如对于体育赛程列表,由于更新频率不高的特点,适合采用策略1 对赛程列表进行分页缓存,但是对于比赛直播流, 则采用策略2或策略3比较合适,特别对直播流有较强的人工运营需求(比如往流中插入一些竞猜题目或用户的评论).