高频面试题(一):找出按照点赞数量前 N 个数据?

266 阅读3分钟

一、要求

设计一个高性能方案,要求:

  • 综合考虑可以怎么利用缓存,包括 Redis 和本地缓存,设计方案有无亮点?
  • 考虑怎样的业务折中?【一般由产品经理决定能接受怎样的折中方案】
  • 绘制 UML 序列图,至少包含两张图:缓存命中与未命中的场景(评论区请你来实现 doge)
  • 使用 wrk 输入不同参数进行性能测试(评论区请你来实现 doge)

二、方案

利用 zset 实现 LikeTop() 方法,主要使用其 ZINCRBYZRangeWithScores 方法

image.png

(1)repo 层

IncrLike() 中调用 IncrRankingIfPresent() 方法

func (c *CachedInteractiveRepository) IncrLike(ctx context.Context, biz string, bizId int64, uid int64) error {
    // note 命名:区别于阅读数 +1 的命名,因为 dao 层除了实现点赞数 +1,还要标记已赞状态
    err := c.dao.InsertLikeInfo(ctx, biz, bizId, uid)
    if err != nil {
       return err
    }
    err = c.cache.IncrLikeCntIfExist(ctx, biz, bizId)
    if err != nil {
       return err
    }
    return c.cache.IncrRankingIfPresent(ctx, biz, bizId)
}

(2)cache 层

LikeTop 方法返回的就是按照点赞数 top100 的数据

func (c *RedisInteractiveCache) LikeTop(ctx context.Context, biz string) ([]domain.Interactive, error) {
    var start int64 = 0
    var end int64 = 99
    res, err := c.client.ZRangeWithScores(ctx, c.rankingKey(biz), start, end).Result()
    if err != nil {
       return nil, err
    }
    interactives := make([]domain.Interactive, 0, len(res))
    for _, z := range res {
       val, _ := strconv.ParseInt(z.Member.(string), 10, 64)
       interactives = append(interactives, domain.Interactive{
          BizId:   val,
          Biz:     biz,
          LikeCnt: int64(z.Score),
       })
    }
    return interactives, nil
}

某数据点赞数增加对应的 IncrRankingIfExist() 方法

func (c *RedisInteractiveCache) IncrRankingIfExist(ctx context.Context, biz string, bizId int64) error {
    _, err := c.client.Eval(ctx, luaRankingCnt, []string{c.rankingKey(biz)}, bizId).Result()
    return err
}

对应的 luaRankingCnt lua 脚本

local zsetName = KEY[1]
local member2incr = ARGV[1]

-- 判断redis中该key是否存在
local exist = redis.call("EXISTS", zsetName)
if exist == 1 then
    newScore = redis.call("ZINCRBY", zsetName, 1, member2incr)
    return 1
else
    return 0
end

设置某数据在 redis 中对应的 zset 缓存方法:

func (c *RedisInteractiveCache) SetRankingScore(ctx context.Context, biz string, bizId int64, score int64) error {
    _, err := c.client.Eval(ctx, luaRankingSet, []string{c.rankingKey(biz)}, bizId, score).Result()
    return err
}

对应的 luaRankingSet lua 脚本

local zsetName = KEY[1]
local member2incr = ARGV[1]
local newScore = ARGV[2]  -- 指定的分数,如果未指定则默认为1

local exists = redis.call("EXISTS", zsetName)

if exists == 0 then
    redis.call("ZADD", zsetName, newScore, member2incr)
end


-- 获取指定元素的当前分数
local currentScore = redis.call("ZSCORE", zsetName, member2incr)

if currentScore then
    -- 如果元素存在,将分数加1
    local newScore = newScore + 1
    redis.call("ZADD", zsetName, newScore, member2incr)
else
    -- 如果元素不存在,将分数设置为指定的分数
    redis.call("ZADD", zsetName, newScore, member2incr)
end

批量将数据加载到 zset 中的方法

// BatchSetRankingScore 将所有 interactive 存进 zset 中
func (c *RedisInteractiveCache) BatchSetRankingScore(ctx context.Context, biz string, interactives []domain.Interactive) error {
    zs := make([]redis.Z, 0, len(interactives))
    for _, interactive := range interactives {
       zs = append(zs, redis.Z{
          Score:  float64(interactive.LikeCnt),
          Member: interactive.BizId,
       })
    }
    return c.client.ZAdd(ctx, c.rankingKey(biz), zs...).Err()
}

三、补充方案(进阶)

(1)引入本地缓存,并定时刷新,比如说每 5s 调用 LikeTop(),放入本地缓存。也可以考虑不全局排序,仅定时计算 top1000,然后借助 zset 来实时维护 top1000score ,每次选 top100 时都从这 1000 名中选,每 30min 再更新前 1000 名的 score

(2)如果有一亿个数据,无法全部放入 zset,该怎么办?可以只维护近期(如近三天)的点赞数(业务折中),也可以 分key ,分成 100 * 1000,000个数据,存入 100 个 key中。