Surest

面朝大海,春暖花开

ThinkPhp5 中 排行榜的设计

ThinkPhp排行榜的设计

需求分析

  • 排行功能

    • 点击量+评分 = 权重 -> 进行排名

    • 定时更新排行榜数据

    • 排行数据按照分类进行划分, 全部分类包含所有分类数据

  • 提升排名

    • 置顶功能 :直接提升排名到 第一 或者 前面

    • 直接修改排名分数

    • 访问量、阅读量增多则自动提升排名

  • 取消排名

    • 降权, 直接不在排行榜进行显示
  • 关键字搜索

需求实现

首先,我们来对比一下 Redis 和 mysql 的区别

Redis 支持 string 、 hash 、 list 、 set 、 sort set 等多种数据结构,并以内存的形式存储数据,且可持久化到本地中

mysql 则是存放在数据库中,在高并发的场景下需要极度依赖于硬件及其他各种优化情况

具体的对比我就不加分析

同时根据需求,我们需要存储的数据有 排名数据、折扣信息 这两样, 所以我们需要寻找合适的结构来保存


数据结构的选择

  • hash

Redis hash 是一个string类型的field和value的映射表,hash特别适合用于存储对象。
Redis 中每个 hash 可以存储 232 - 1 键值对(40多亿)。

使用存储对象、可以保存大量的 键值对、可以当个修改 字段信息值,这些都是保存对象和数组的好玩意

用来保存折扣信息

  • 有序集合(sorted set)

每个元素都会关联一个double类型的分数。redis正是通过分数来为集合中的成员进行从小到大的排序。

有序集合的成员是唯一的,但分数(score)却可以重复。

是通过哈希表实现的,所以添加,删除,查找的复杂度都是O(1)。 集合中最大的成员数为 232 - 1 (4294967295, 每个集合可存储40多亿个成员)。

分析: 排行需要进行自动排行减少查找等操作、需要进行保存大量的成员key值, 同时可以根据自定义的分数进行修改排行位置,所以 sorted set 是最适合的结构

功能刨析分块实现

由于 排行榜 、 redis 是扩展方法,所以采用 server 层来保存数据

  • 安装 phpredis

这里使用的由于是 tp5 所以采用的是phpredis

  • 获取 redis 实例子

      namespace app\common\server;
    
      /**
       * 这是一个耦合 redis 的实例
       *
       * Class Redis
       */
      classRedis{
    
          protected $redis = null;
    
          protected $host;
    
          protected $port;
    
          protected $db = 5;
    
          public function__construct(){
              $this->setConfig(); # 加载配置
              $redis = new \Redis();
              $redis->connect($this->host, $this->port);
              $redis->select($this->db);  # 选择redis数据库
              $this->redis = $redis;
          }
    
          # 单例模式实例化自身
          public static functiongetInstance(){
             $redis = new self();
             return $redis->redis;
          }
    
          /**
           * 加载配置文件
           *
           * @throws \think\Exception
           */
          public functionsetConfig(){
              $host = config('redis.host');
              $port = config('redis.port');
              if( !$host || !$port ) {
                  throw new \think\Exception('请检查是否存在redis配置,目标 config/extra/redis.php');
              }
              $this->host = $host;
              $this->port = $port;
          }
      }
  • 排行榜的初始化

    • 分类的关联查询, 查询出折扣的详情、分类名称、发布人、以及最重要的阅读量、评论量

       # namespace app\common\server;
      # class Rank
      /**
       * 获取分类对应的文章信息
       */
      public functiongetDiscountByCategory(){
          $categories = $this->getCategoryField(['id', 'category_name']);
      
          $data = [];
          foreach ($categories as $key => $category) {
              $data[$key]['category_name'] = $category['category_name'];
              $data[$key]['category_id'] = $category['id'];
              $data[$key]['discounts'] = $this->getDiscount($category['id']);
          }
      
          return $data;
      }
      
      # 获取分类下的折扣信息
      public functiongetDiscount($cid){
          $disconts = Discount::all(function($query)use($cid){
              $query->where('category_id', $cid);
              $query->field($this->field);
              $query->with(['user' => function($query){
                  $query->field(['id', 'username']);
              }]);
      
              $query->order('page_view', 'desc');
              $query->order('comment_count', 'desc');
              $query->order('create_time', 'desc');
              $query->limit(0, 100);
          });
      
          foreach ($disconts as &$discont) {
              $discont['username'] = $discont->user->username ?? null;
              unset($discont['user']);
          }
      
          return $disconts;
      }
      • 取出数据后,计算权重,并且写入到 redis 中

          # 获取到了排行榜的数据
          $ranks = $rank->getData();
          $isCached = $rank->init_rank($ranks);
        
          public functiongetData(){
              # 获取有阅读量有访问量的折扣
              $discounts = $this->getDiscountByCategory();
        
              return $discounts;
          }
        
              * 初始化排行榜数据
               *     @param $data 排行的数据
              */
              public functioninit_rank($data){
                  $redis = Redis::getInstance();
                  $data = $this->init_calcute_data($data);
        
                  # 清除已知的key
                  $this->clearKey();
        
                  # 保存所有键值的key
                  $akey = $this->rank_c_all_key;
        
                  foreach ($data as $key => $discounts) {
                      # 取出 key 标识
                      $key = (int)$discounts['category_id'];
        
                      # sortset 保存的是分类的key, 用于分类排行
                      $ckey = $this->rank_ckey_prefix . $key;
        
                      # 开始缓存的是 / 分类下面的单个数据
                      foreach ($discounts['discounts'] as $discount) { # $discount 是单个数据
                          $dkey = $this->rank_dkey_prefix . $discount->id;
        
                          $redis->hMSet($dkey, $discount->toArray()); # 单个数据缓存完成之后, 得到了这个数据的键 / 开始写入到 set sortset 数据中
        
                          $score = $discount['score'];
                          $redis->zAdd($akey, $score, $dkey);
                          $redis->zAdd($ckey, $score, $dkey);
                      }
        
                      # 推送到list中 / 告知我已经缓存了这个数据
                      $redis->lPush($this->rank_list, $ckey);
                  }
        
                  $len = $redis->lLen($this->rank_list);
        
                  if( $len === count($data) ) {
                      return true;
                  }
              return false;
          }
  • 初始化完成

  • 获取单个折扣的数据

    防止缓存穿透

       /**
       * 请求缓存中的折扣信息数据,防止数据已经失效,针对获取redis失败后重新缓存起来
       *
       * @param $key
       * @param $ckey
       */
      public functiongetDiscountByRedis($key, $ckey = null){
          $redis = Redis::getInstance();
          $discount = $redis->hGetAll($key);
          # 检查是否带正确的key 名称, 不是的话拼接为正确的key名称
          if( !preg_match("/$this->rank_dkey_prefix/", $key) ) {
              $key = $this->rank_dkey_prefix . $key;
          }
    
          if( !preg_match("/$this->rank_ckey_prefix/", $ckey) ) {
              $ckey = $this->rank_ckey_prefix . $ckey;
          }
    
          if ( !$discount || empty($discount) ) { # 为空的情况下重新缓存
              $did = (int)str_replace($this->rank_dkey_prefix, '', $key);
              $discount = (new Discount())->field($this->field)->find($did);
              if( $discount ) {  # 当数据库中的折扣信息也被删除之后
                  $discount = $discount->toArray();
                  $discount['score'] = $this->calclute_weight($discount['page_view'], $discount['comment_count']);
                  $redis->hMSet($key, $discount);
              }else{
                  if( !is_null($ckey) ) {
                      # 从redis zset 中移除这个key
                      $redis->zRem($ckey, $key);
                      return null;
                  }
              }
          }
          return $discount;
  • 修改权重

       /**
       * 插入排名或者更新排名
       * @param $discount
       * @param $score 分数
       * @return bool|string
       * @throws \think\Exception
       */
      public functioninsert_rank($discount, $score){
          # 插入到指定的分类中
          $redis = Redis::getInstance();
          # 检查分类的 检查 自己是否存在于 redis 中
          $key = $this->rank_dkey_prefix . $discount->id;
          $ckey = $this->rank_ckey_prefix . $discount->category_id;
          $discount = $this->getDiscountByRedis($key, $ckey);
          $discount['score'] = $score;
    
          if( !is_array($discount) ) {
              $discount = $discount->toArray();
          }
    
          $redis->hMSet($key, $discount); # 重新设置 score
    
          if( $discount && !empty($discount) ) {
              # 添加到集合中去
              $redis->zAdd($ckey, $score, $key);
              # 添加到全部 * 中, 用户全部分类的排行更新
              $redis->zAdd($this->rank_c_all_key, $score, $key);
    
              # 添加自定义的 排行数据key 中, 用于更新了排行之后进行写入
              $redis->zAdd($this->user_defined, $score, $key);
    
              $rankInAllIndex = $redis->zRevRank($this->rank_c_all_key, $key) + 1;
              $rankInCategoryIndex = $redis->zRevRank($ckey, $key) + 1;
    
              return "更新成功,在分类中的排行现在是 {$rankInCategoryIndex}, 在全部分类中的排行是: $rankInAllIndex";
          }
    
          return false;
      }
- 置顶功能

    /**
     * * 置顶功能
     * @param mixed $mix_id 折扣的id,可数组可int
     * @param bool $isCache 是否为取消排行
     * @return int 返回的是置顶成功的条数
     * @throws \think\Exception
     * @throws \think\db\exception\DataNotFoundException
     * @throws \think\db\exception\ModelNotFoundException
     * @throws \think\exception\DbException
     */
    public functionrank_stick($ids, $cid = '*', $isCache = false){
        $ids = \json_decode($ids, true);

        $susses_len = 0;
        foreach ($ids as $id) {
            # 分类id
            $discount = Discount::where('id', $id)->field('category_id')->find();
            if( $discount ) { # 确保数据存在更新分数
                $discount = $this->getDiscountByRedis($id, $cid); # 从缓存中读取redis信息
                $score = $this->getScorestByCategory($cid); # 当前分类最高的分数
                if( !is_null($discount) && $score ) {

                    # 取消置顶
                    if( $isCache ) {
                        $this->update_rank($discount);
                        ++ $susses_len;
                    }else{
                        $this->insert_rank($discount, $score + 1); # 在基础上加1
                        ++ $susses_len;
                    }

                }
            }
        }

        return $susses_len;
    }
标签: 排行榜的设计

评论: