乐趣区

关于缓存:社区收藏缓存设计重构实战

一、背景

社区珍藏业务是一个典型的读多写少的场景,社区各种外围 Feeds 流都须要依赖用户是否珍藏的数据判断,晚期缓存设计时因为流量不是很大,未体现出显著的问题,近期通过监控平台等相干伎俩发现了相干的一些问题,因而咱们针对这些问题对缓存做了重构设计,以保障珍藏业务的性能和稳定性。

二、问题剖析定位

2.1 接口 RT 偏大

通过监控平台查看「判断是否珍藏接口」的 RT 在最高在 8ms 左右,该接口的次要作用是判断指定单个用户是否已珍藏一批内容,其实如果缓存命中率高的话,接口 RT 就应该趋近于 Redis 的 RT 程度,也就是 1 -2ms 左右。

(图中有单根尖刺,这个具体问题要具体分析优化,咱们这里次要论述整体程度的优化)

2.2 Redis&MySQL 拜访 QPS 偏高

通过监控平台能够看到从上游服务过去的珍藏查问 QPS 绝对拜访 Redis 缓存的 QPS 放大了 15 倍,并且 MySQL 查问的最高 QPS 占上游访问量靠近 37%,这阐明缓存并没有很高的命中率,导致回表查问的概率还是很大。

QPS 访问量见下图:

Redis 访问量

MySQL 访问量

基于以上剖析咱们当初有了明确的优化切入点,接下来咱们来看下具体的找下起因是什么。
接下来咱们来看一下伪代码的实现:

// 判断用户是否对指定的动静珍藏
func IsLightContent(userId uint64,contentIds []uint64){
    index := userId%20
    cacheKey := key + "_" + fmt.Sprintf("%d", index)
    pipe := redis.GetClient().Pipeline()
    for _, item := range contentIds {InitCache(userId, contentId)
        pipe.SisMember(cacheKey, userId)
    }
    pipe.Exec()
    //......
}

// 缓存初始化判断,不存在则初始化数据缓存
func InitCache(userId uint64,contentId uint64){
    index := userId%20
    cacheKey := key + "_" + fmt.Sprintf("%d", index)
    ttl,_ := redis.GetClient().TTL(cacheKey)
    if ttl <= 0{//key 不存在或者未设置过期工夫
        // query from db
        // sql := "select userId from trendFav where userId%20 = index and content_id = contentId"
        // save to redis
    }else{redis.GetClient().Expire(cacheKey,time.Hour()*48)
    }
}

从下面的伪代码中,咱们可能很清晰的看到,该办法会遍历内容 id 汇合,而后对每个内容去查问缓存下来的用户汇合,判断该以后用户是否珍藏。也就是说缓存设计是依照内容维度和用户 1:N 来设计的,将单个动静下所有珍藏过内容的用户 id 查出来缓存起来。并且基于大 Key 的思考,代码又将用户汇合分片成 20 组。这无疑又再次放大了 Redis 缓存 Key 的数量。并且每个 Key 都应用 TTL 命令来判断是否过期。这样一来 Redis 的 QPS 和缓存 Key 就会被放大很多倍。

正是因为分片策略 + 缓存时效短,导致了 MySQL 查问的 QPS 居高不下

三、解决方案

基于以上对问题的剖析定位,咱们思考的解决思路就是一次接口申请升高 Redis 查问操作,尽可能减少放大的状况,初步判断有如下两个实现门路:

  • 去掉遍历内容查问,改为一次性查问
  • 去掉用户集分片存储,改为单 Key 存储

上游的调用参数用户和内容是一对多的关系,因而要实现的 Redis 查问也是要满足一对多的关系,那么不言而喻咱们的缓存应该是依照用户的维度来存储曾经珍藏过的内容汇合。

用户珍藏的内容比拟少的话,咱们很简略的就能够从数据库全副查问进去放在缓存,但如果用户珍藏的内容比拟多呢,那也会可能造成大 Key 问题,如果持续分片存储的话又会回到了原来的计划。咱们探讨出以下两种计划:

计划 1. 解决大数据大部分惯例思路就是要么分片,要么冷热拆散

因为业务逻辑的特点,举荐流下用户看到的内容绝大部份根本都是一年以内的,咱们能够缓存用户一年以内的珍藏内容,这样就限度了用户珍藏的极其数量。如果看到的内容公布超过一年工夫,能够用 MySQL 间接查问,这种场景的 case 概率是很小的。但认真思考了下实现,这个须要依赖业务方,咱们须要去查问内容的公布工夫,以此来判断是否在咱们的缓存内,这样会减轻整个接口的逻辑,反而得失相当,因而该思路很快就被否定了。

计划 2. 既然不能依赖第三方,就是要从本身领有的信息上,来可能缓存一部分最热的数据,使得查问可能大范畴落到这些数据

咱们目前只有内容 id,而内容 id 都是纯数字,数字自身的话能够依照大小来排列。业务查问自身都是最近一段时间的内容,所以查问的内容 id 都是近期较大的 id。那咱们能够依照内容 id 降序排列,取用户珍藏过的若干条数据来缓存。只有查问的 id 都比缓存最小的 id 大,那么咱们就能够只通过缓存来判断出用户是否珍藏这些内容了。

示例:
初始化缓存时咱们依照内容 id 降序排列,拿到前 5000 个内容 id:

  1. 如果查问后果不满 5000,那么这个用户缓存了全副珍藏记录,此时小缓存的内容 id 为 0
  2. 如果大于等于 5000,阐明还有局部未缓存的记录,此时最小缓存的内容 id 为第 5000 个内容 ID

等到查问判断时,将查问的内容 id 数组和缓存的最小内容 id 比照,如果全副大于,则阐明都在缓存范畴内,如果有小于,则是超过缓存范畴,届时独自去数据库判断,当然这种概率在业务上的产生几率是比拟小的。

这里缓存的数量的抉择显得尤为重要,如果太小,那缓存的命中率不高,导致 MySQL 回表查问概率变大,如果太大,则初始化时比拟消耗工夫,或产生大 Key 问题。通过分析线上数据,目前以 5000 这个数字可能比拟好的衡量。

上面是查问缓存判断流程图:

缓存形式由原来的 set 构造,改为 Hash 构造,TTL 缩短到 7 * 24 hour。

这样一来,原来的独立调用的 TTL 和 sismember 命令,能够合并成一个 Hmget 命令,缩小了一半的 Redis 拜访次数,这个改良收益是相当可观的。

四、优化成绩

截止本文撰写时,咱们对珍藏的性能进行了优化革新并上线,获得了很不错的停顿。所有数据为最近 7 天的数据 4.14 – 4.20, 优化成果在 4.15 号 17 点左右开始。

4.1 RPC 接口响应 RT 升高

1 IsCollectionContent

RPC 接口,判断动静是否缓存。均匀 RT 进步了靠近 3 倍。并且 RT 比较稳定

4.2 Redis 负载升高

1 TTL 查问

查问 Key 有效期,用来判断缩短 Key 有效期。QPS 间接降到 0

2 SISMEMBER 查问

原来旧的珍藏缓存查问,曾经改为 HMGET 查问 QPS 升高到 0

3 HMGET 查问

新的珍藏缓存查问 QPS 数量和上游过去查问的 QPS 正好能对应上

4 Redis 内存升高

新的缓存较旧缓存在占用内存和 Key 数量这 2 个指标均升高了 3 倍左右

4.3 MySQL 负载升高

1 content_collection 表 select 查问升高

QPS 升高了 24 倍左右并且放弃在一个比较稳定的水位

2 MySQL 连贯并发数升高

查问 QPS 的缩小也升高了并发连接数,大略升高了 3 倍左右,最终也升高了期待连贯次数

五、总结

通过对本次问题的剖析和解决,不难看出一个良好的缓存设计对于服务来说是如许的重要。好的缓存设计不仅可能晋升性能,同时能够升高资源应用,整体晋升了资源利用率。同时上游的流量和上游根本持平,在流量上升时,不会对上游造成很大的压力,这样服务整体的抗并发能力也晋升了很多。

* 文 /Sky
 @得物技术公众号

退出移动版