记一次评论系统缓存优化实践

背景

该项目是当时公司最重点落地战略性的项目之一,离开项目组时DAU达到了6000W以上,主要做富媒体feed流。评论指的是对单条feed的评论,要求评论近实时展示。本文主要分析缓存模型。

公司当时的缓存中间件主要是couchbase,所以主要是使用了couchbase缓存。倒也不影响对缓存架构的理解。

优化过程

第一版评论系统

主要的需求是,第一可展示评论,第二可翻页(一页20条以内)
由于项目初期用户量不大,评论量自然有限,同时feed的热点数据也比较少,所以缓存设计上相对简单。
仅缓存最新的300条评论,一个 kv缓存,缓存300条评论的所有内容,包括id,uid,楼号,评论详情等,json序列化。
有新的写入,会更新300条缓存。
起初运行良好,热点数据缓存命中率比较高,请求也基本都分布在前几页,穿透数据库情况极少。

第一次瓶颈 - 读写压力暴增

原因

后来加入了新功能,其中有流量大咖参与的活动类feed,带来了大量的用户,具有很强的热点效应。每条活动类feed的评论可能达到几万条甚至更多,同时参与活动人数可能高达数十万。

  • 现在遇到了什么问题
  1. 同时缓存300条评论,1个value大者可能就有几十上百K,突发流量,频繁的缓存读取,让内网带宽称为瓶颈,经常物理机带宽报警。
  2. 频繁的序列化,反序列化也增大了cpu的压力。
  3. 频繁的有新评论加入,每次都会重刷缓存数据,造成大量的缓存写入。

解决思路

我们来思考下用户请求的场景,对于评论数据,往往是一次只取10-20条数据,通常都分布在前十页。甚至于通常是随着feed列表或者feed首页展示第一页。那每次从缓存取300条全量数据就显得有些重了。

好,如何优化?

首先我们把300数据只存索引,也即评论id,这些value数据量大幅下降,从缓存中多读取的无用数据也变少了。我们称作id列表缓存。

那具体的评论内容怎么办?评论内容按照每条内容一个kv缓存来处理,从索引缓存中取出id,然后再去缓存中批量取,这样每次只需要取出一页的数据即可,也大量的缩减了网络延迟、带宽和cpu的消耗。我们成为评论的实体缓存。

是否还能进一步优化?当然可以。评论内容基本没变化,我们可以利用二级缓存机制,增加本地缓存,热点内容的缓存命中率很高,可以减少90%以上的集中式缓存读取

至此一段时间内,网络带宽、cpu均保持正常,维持了一段时间的安稳。

第二次瓶颈 - 深翻页导致的性能下降(缓存命中率低)

原因

评论增加了盖楼功能,运营会做一些活动,根据盖的楼层送礼品,重点是通常在大V用户的feed下做活动!!
于是产生了大量用户向下深层翻页的情况,甚至翻阅上千页以上。
这下麻烦了,之前仅缓存了前300条评论id,如果用户深翻页那必然无法命中缓存,穿透数据库,导致了大量的评论请求超时,引起了不少客诉。
运营场景又倒逼我们做技术优化。

解决思路

  1. 首先明确,我们要解决的问题是缓存命中率问题。
  2. 是什么的缓存命中率?id列表缓存,还是评论实体缓存?
    上文提到,我们设计了2种缓存,300条最新评论id的列表缓存,和评论的实体缓存。
    对于实体缓存来说,只要被访问过,便会种一段时间缓存,热点数据缓存命中率比较高。
    id列表缓存由于只缓存了300条,所以,深翻页一定会穿透,这就是我们要解决的主要矛盾。
  3. 能否通过增加缓存的评论id条数解决问题?
    不能。单纯的增加缓存的id条数,增加的少了仅是多支持了几页的翻页,增加的多了,有会产生和第一次性能瓶颈一样的问题,导致带宽和cpu瓶颈。
  4. 那能不能分段缓存,比如前300个缓存一条记录,然后后边每300个缓存一条记录?
    bingo,思路很好。提高了扩展性,增加了缓存命中率。但是我们需要思考下细节。这么做有什么问题?
    如果分页来查找,我们怎么做?这个简单,根据每一页计算出这一页落在哪个index上,取对应index的缓存即可。比如每页20条,第二页是最新的21-40,落在0-300的区间内。
    考虑下新增评论,我们存了同一条feed的两组index缓存,分别是最新的1-300条,301-600条,如果新增一条要怎么办?如果要保证实时展示的话,我们就要刷新列表缓存,由于第一条index缓存已经满员了,要把多余的部分转到下一个index区间,循环下去……天,那要刷新这条feed的所有列表缓存……缓存写入量必然大增。如果不刷新,很难保证列表缓存实时性。
    如果不刷新,把新数据单独做个列表缓存呢?这个度不好把握,也不好判断什么时候该取下一组index数据。总之比较复杂。
  5. 有没有更好的解决办法?
    肯定有,要不然这次优化不叫成功。
    这个需求有一个很巧的地方,就是评论带楼层。
    巧在哪里?
    我们之前对评论的排序都是根据id(或者创建时间)倒序排列,因为id是所有评论的唯一标识,所以仅根据id无法确认该评论在列表中的具体位置。但是楼层不然,是第几楼,就意味着是该feed下的第几个评论。
    确认了位置有啥用?
    第4条我们提到,主要的问题是实时更新的性能问题。看到楼层这个变量,灵机一动,我们肯定是能拿到feed当前的最高楼层的,那第几页取哪几层的数据也是一目了然。比如最高楼层1001,一页20条,第2页数据就是第981-962层的评论(小学数据题)。
    那如果这样设计列表缓存呢?将列表缓存按照固定的楼层分段,比如第1-300层一段,第301-600层一段,依次类推。如果当前最高楼层是1001,则落在901-1200的分段内。
    key value
    index_300_1 第1-300层的id列表[{楼层,id},{楼层,id}]
    也就是我们通过楼层就能知道去取那个分段的列表数据,而且每个分段存储的楼层是固定的。注意是固定的,固定的有什么好处?那就是如果我新来了一条评论,知道他的楼层,那就知道要往那个分段里更新数据,已经满了的分段数据是不会变化的。嗯...好像解决了更新数据量过大的问题!

如何用

其实刚刚的思路已经把具体的方案描述差不多了

  1. 根据楼层来对列表缓存分段
    key value
    index_300_1 第1-300层的id列表[{楼层,id},{楼层,id}]
    index_600_301 第301-600层的id列表[{楼层,id},{楼层,id}]
  2. 取最高楼层是930的首页数据,也即第930-911层
    均落在index_1200_901的区间范围内,则取该段的缓存即可
  3. 取最高楼层是930的第二页数据,也即第910-891层
    落在index_1200_901和index_900_601,两个分段内,则从两个分段缓存中取出对应id
  4. 当前最高楼层是930,新增一条记录,则为931层
    落在index_1200_901的区间范围内,只需更新该段缓存即可。

第三次瓶颈 - 资源消耗过大

随着业务发展,内容越来越广,用户也越来越多,诚然缓存会过期,但耐不住数据量的庞大,couchbase占用的内存一路猛升。从一开始几十G的物理机一直增长到了上T。
物理机资源太贵了,所以要思考怎么减少内存占用。这里简化过程,直接说结果。之前用的序列化方式是JSON格式,这种格式可读性强,但是占用的内存大、序列化性能也不理想。调研了protobuf协议,将序列化方式改为protobuf后,内存使用率降到了之前的10%-20%,内存利用率提高了5-10倍!极大减小了成本。同时因为更高的序列化反序列化性能,cpu使用率也得到了相应改善。

其他优化

经过上面的几次优化,不论是性能还是资源成本的角度看,在当时都达到了不错的效果。但是依然有很多的优化空间。

  1. 缓存预取
    上面提到对列表缓存使用楼层的进行的分段优化,对于热点数据深分页的问题效果不错。后来针对这一过程又做了进一步的优化,缓存预取。通常情况下用户只访问第一段的数据就够了,缓存命中率也很高。某些热点feed,用户会进行深分页,我们从用户开始访问到第三段起(通常超过了300条数据),就认为该feed可能是此类热点feed,如果缓存中不存在,那我们会同时异步预加载后边2段数据,这样用户一直向下翻的时候,基本都命中缓存,进一步提高了性能。
  2. 分页模式改造
    通常,我们的分页都是传递两个参数,pageSize,page,表示每页多少条,取第几页,这种形式存在一定问题
    从持久化存储系统的角度来看,是有性能瓶颈的。拿mysql举例,如果要做深分页,会扫描该页之前的所有数据,可能是比较消耗资源的磁盘扫描(自行查阅资料喽)。
    从用户体验角度来看,如果用户翻页过程中,有新增feed,那有可能会有翻页重复数据。比如第20条变成了第21条。
    所以,对分页方式做了个优化。每次传递上一页的最后一个元素(这里是楼层,lastFloor),和一页取多少个数据,我们以游标的形式取数据避免了重复数据的出现,当然如果出现在查询mysql的场景中,也避免了过多的磁盘扫描

总结与思考

  1. 我们为什么用缓存以及如何用。
    用缓存的原因就不必多说了,就是性能。因为内存和磁盘的性能差异,有如天壤之别,比cpu高速缓存和内存差异还要大。
    如何使用,其实我们要理解一下缓存的局部性原理就会有一种拨云见日的感觉
    缓存局部性原理包括缓存的空间局部性原理和时间局部性原理。这块知识在讲硬件时可能用的比较多,尤其是cpu的寄存器、高速缓存设计。
    _在CPU访问寄存器时,无论是存取数据或存取指令,都趋于聚集在一片连续的区域中,这就被称为局部性原理。
    时间局部性(temporal locality)
    时间局部性指的是:被引用过一次的存储器位置在未来会被多次引用(通常在循环中)。
    空间局部性(spatial locality)
    如果一个存储器的位置被引用,那么将来他附近的位置也会被引用。_
    其实不只是cpu缓存,扩展到我们的软件系统设计也是一个道理。回想下,上文怎么用的局部性原理?
    访问过的数据种缓存,供后续访问使用,就是应用的时间局部性原理
    访问一个分段的数据,将后边分段的数据预先加载到缓存,提高缓存命中率,就是用的空间局部性原理
  2. 其他缓存中间件可以吗?
    何止是可以,甚至可以做的更好。当然万变不离其宗,原理是一样的。比如我们如果用redis的list结构,每次从缓存中取分段数据,都没必要取出整段,只取需要的一部分即可。
  3. 这里只说了缓存设计,其中还有很多值得讨论的其他技术架构点,比如如何持久化评论,持久化和更新缓存的流程是怎样的,如何保证数据一致性等,都值的我们探讨。

好累,总结一次不容易。

  • 2
    点赞
  • 8
    收藏
    觉得还不错? 一键收藏
  • 1
    评论

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论 1
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值