针对 `calculateActiveUsers` 的一些优化

教程中 calculateActiveUsers 方法用到了8次查询,把一些排序和判断都放在代码里了

trait ActiveUserHelper
{

    private function calculateActiveUsers()
    {
        $this->calculateTopicScore();
        $this->calculateReplyScore();

        // 数组按照得分排序
        $users = array_sort($this->users, function ($user) {
            return $user['score'];
        });

        // 我们需要的是倒序,高分靠前,第二个参数为保持数组的 KEY 不变
        $users = array_reverse($users, true);

        // 只获取我们想要的数量
        $users = array_slice($users, 0, $this->user_number, true);

        // 新建一个空集合
        $active_users = collect();

        foreach ($users as $user_id => $user) {
            // 找寻下是否可以找到用户
            $user = $this->find($user_id);

            // 如果数据库里有该用户的话
            if ($user) {

                // 将此用户实体放入集合的末尾
                $active_users->push($user);
            }
        }

        // 返回数据
        return $active_users;
    }

    private function calculateTopicScore()
    {
        // 从话题数据表里取出限定时间范围($pass_days)内,有发表过话题的用户
        // 并且同时取出用户此段时间内发布话题的数量
        $topic_users = Topic::query()->select(DB::raw('user_id, count(*) as topic_count'))
                                     ->where('created_at', '>=', Carbon::now()->subDays($this->pass_days))
                                     ->groupBy('user_id')
                                     ->get();
        // 根据话题数量计算得分
        foreach ($topic_users as $value) {
            $this->users[$value->user_id]['score'] = $value->topic_count * $this->topic_weight;
        }
    }

    private function calculateReplyScore()
    {
        // 从回复数据表里取出限定时间范围($pass_days)内,有发表过回复的用户
        // 并且同时取出用户此段时间内发布回复的数量
        $reply_users = Reply::query()->select(DB::raw('user_id, count(*) as reply_count'))
                                     ->where('created_at', '>=', Carbon::now()->subDays($this->pass_days))
                                     ->groupBy('user_id')
                                     ->get();
        // 根据回复数量计算得分
        foreach ($reply_users as $value) {
            $reply_score = $value->reply_count * $this->reply_weight;
            if (isset($this->users[$value->user_id])) {
                $this->users[$value->user_id]['score'] += $reply_score;
            } else {
                $this->users[$value->user_id]['score'] = $reply_score;
            }
        }
    }
}

打开页面时可以看到执行了以下 8 次查询

  1. select user_id, count(*) as topic_count from topics where created_at >= '2018-08-09' group by user_id
  2. select user_id, count(*) as reply_count from replies where created_at >= '2018-08-09' group by user_id
  3. select * from users where users.id = '6' limit 1
  4. select * from users where users.id = '2' limit 1
  5. select * from users where users.id = '3' limit 1
  6. select * from users where users.id = '1' limit 1
  7. select * from users where users.id = '5' limit 1
  8. select * from users where users.id = '4' limit 1

我尝试把逻辑都放在 sql 中,重写了这个方法

trait ActiveUserHelper
{
    private function calculateActiveUsers()
    {
        $day = Carbon::now()->subDays($this->pass_days);
        $sql = <<<SQL
            SELECT u.*
            FROM (
                SELECT user_id, SUM(score) AS score
                FROM (
                    SELECT user_id, COUNT(*) * $this->topic_weight AS score
                    FROM topics
                    WHERE created_at >= '$day'
                    GROUP BY user_id
                    UNION ALL
                    SELECT user_id, COUNT(*) * $this->reply_weight AS score
                    FROM replies
                    WHERE created_at >= '$day'
                    GROUP BY user_id
                ) t
                GROUP BY t.user_id
                ORDER BY score DESC
                LIMIT 6
            ) us
                JOIN users u ON us.user_id = u.id
SQL;
        $active_users = DB::select($sql);
        return $active_users;
    }
}

这样一句 sql 就可以解决这个方法需要做的事情,我想在查询数据库这块应该能省则省。
另外我尝试着用 Laravel 的查询构造器实现了上面的 sql 语句,效果是一样的也是只执行一句 sql。

trait ActiveUserHelper
{
    private function calculateActiveUsers()
    {
        //近期发表话题的用户话题得分
        $users_topic_score_query = DB::table('topics')
            ->select(DB::raw("user_id, count(*)*$this->topic_weight as score"))
            ->where('created_at', '>=', Carbon::now()->subDays($this->pass_days))
            ->groupBy('user_id');

        //近期发表回复的用户回复得分
        $users_reply_score_query = DB::table('replies')
            ->select(DB::raw("user_id, count(*)*$this->reply_weight as score"))
            ->where('created_at', '>=', Carbon::now()->subDays($this->pass_days))
            ->groupBy('user_id');

        //用户的话题和回复得分的联合
        $users_topic_reply_score_query = $users_topic_score_query->unionAll($users_reply_score_query);

        //总得分最高的前6个用户的id
        $users_score_query =  DB::table(DB::raw("({$users_topic_reply_score_query->toSql()}) as users_score"))
            ->selectRaw("user_id, sum(score) as score")
            ->groupBy('user_id')
            ->orderBy('score', 'desc')
            ->limit(6);

        //总得分最高的前6个用户
        $active_users_query = DB::table(DB::raw("({$users_score_query->toSql()}) as users_score"))
            ->join('users', 'users.id', '=', 'users_score.user_id')
            ->select("users.*")
            ->mergeBindings($users_topic_reply_score_query);

        return $active_users_query->get();
    }
}

通过调试发现教程中的代码用时在 200-300ms,优化后的两种方法运行用时 20-50ms。效果还是蛮显著的,不过很奇怪的是查询构造器比纯 sql 竟然表现还要好一些。

写使用查询构造器的方法时花了好多时间,有些内容在文档上也没有描述(比如:DB::table(DB::raw("({$query->toSql()}) as xxx")) ->mergeBindings($users_topic_reply_score_query)),借助谷歌终于还是写好了,这个过程帮助我加深了对查询构造器的理解。

本帖已被设为精华帖!
本帖由系统于 4年前 自动加精
《L01 基础入门》
我们将带你从零开发一个项目并部署到线上,本课程教授 Web 开发中专业、实用的技能,如 Git 工作流、Laravel Mix 前端工作流等。
《L03 构架 API 服务器》
你将学到如 RESTFul 设计风格、PostMan 的使用、OAuth 流程,JWT 概念及使用 和 API 开发相关的进阶知识。
讨论数量: 9

你这个好是好,当是作者要考虑到学习者的接受能力啊......

5年前 评论

真的厉害,学习了

5年前 评论

mark一下,有机会实践一波

5年前 评论

根据上面的代码用集合 Collection 的方式实现了一个版本,其他一样的代码就不贴了

trait ActiveUserCollectionHelper
{
    public function calculateAndCacheActiveUsers()
    {
        // 取得活跃用户列表
        $active_users = $this->calculateActiveUsers();
        // 并加以缓存
        $this->cacheActiveUsers($active_users);
    }

    private function calculateActiveUsers()
    {
        $this->calculateTopicScore();
        $this->calculateReplyScore();

        if (!$this->users) {
            return collect([]);
        }

        // 按照得分倒序排序,只获取我们想要的数量
        return collect($this->users)->sortByDesc(function ($user) {
            return $user['score'];
        })->slice(0, $this->user_number)->map(function ($item) {
            return (object)$item; //将数组转换成对象,前端模版无需修改
        });
    }

    private function calculateTopicScore()
    {
        // 从话题数据表里取出限定时间范围($pass_days)内,有发表过话题的用户
        // 并且同时取出用户此段时间内发布话题的数量
        $topic_users = User::withCount(['topics' => function ($query) {
            $query->where('created_at', '>=', Carbon::now()->subDays($this->pass_days));
        }])
            ->orderBy('topics_count', 'desc')->get();

        if ($topic_users->isNotEmpty()) {
            $topic_users->map(function ($topic_user) {
                $topic_score = $topic_user->topic_count * $this->topic_weight;
                if (isset($this->users[$topic_user->id])) {
                    $this->users[$topic_user->id]['score'] += $topic_score;
                } else {
                    $this->users[$topic_user->id]['score'] = $topic_score;
                }
                $this->users[$topic_user->id]['avatar'] = $topic_user->avatar;
                $this->users[$topic_user->id]['name'] = $topic_user->name;
                $this->users[$topic_user->id]['id'] = $topic_user->id;
                return $this->users;
            });
        }
    }

    private function calculateReplyScore()
    {
        // 从回复数据表里取出限定时间范围($pass_days)内,有发表过回复的用户
        // 并且同时取出用户此段时间内发布回复的数量
        $replies_users = User::withCount(['replies' => function ($query) {
            $query->where('created_at', '>=', Carbon::now()->subDays($this->pass_days));
        }])
            ->orderBy('replies_count', 'desc')->get();

        if ($replies_users->isNotEmpty()) {
            $replies_users->map(function ($reply_user) {
                $reply_score = $reply_user->replies_count * $this->reply_weight;
                if (isset($this->users[$reply_user->id])) {
                    $this->users[$reply_user->id]['score'] += $reply_score;
                } else {
                    $this->users[$reply_user->id]['score'] = $reply_score;
                }
                $this->users[$reply_user->id]['avatar'] = $reply_user->avatar;
                $this->users[$reply_user->id]['name'] = $reply_user->name;
                $this->users[$reply_user->id]['id'] = $reply_user->id;
                return $this->users;
            });
        }
    }
}

SQL 如下:两条 SQL 解决,然后使用集合的方法去排序,只获取我们想要的数量
file

5年前 评论

躺床上想了想,还能再优化,一条 SQL 搞定。


trait ActiveUserCollectionHelper
{
    private function calculateActiveUsers()
    {
        $active_users = $this->calculateScore();

        if ($active_users->isEmpty()) {
            return collect([]);
        }

        // 按照得分倒序排序,只获取我们想要的数量
        return $active_users->sortByDesc('score')->slice(0, $this->user_number);
    }

    private function calculateScore()
    {
        // 从用户表里取出用户限定时间范围($pass_days)内,发表过的话题数量和回复数量
        $active_users = User::where(function ($query) {
            $query->wherehas('topics', function ($query) {
                $query->where('created_at', '>=', Carbon::now()->subDays($this->pass_days));
            })
                ->orWhereHas('replies', function ($query) {
                    $query->where('created_at', '>=', Carbon::now()->subDays($this->pass_days));
                });
        })
            ->withCount(['topics' => function ($query) {
                $query->where('created_at', '>=', Carbon::now()->subDays($this->pass_days));
            }])->withCount(['replies' => function ($query) {
                $query->where('created_at', '>=', Carbon::now()->subDays($this->pass_days));
            }])
            ->latest('topics_count')
            ->latest('replies_count')
            ->get();

        if ($active_users->isNotEmpty()) {
            $active_users->map(function ($user) {
                $score = $user->topic_count * $this->topic_weight + $user->replies_count * $this->reply_weight;
                $user->score = $score;
                return $user;
            });
        }

        return $active_users;
    }
}

SQL 查询如下: (测试方便取的是30天内的活跃用户)

SELECT `users`.*,
       (SELECT Count(*)
        FROM   `topics`
        WHERE  `users`.`id` = `topics`.`user_id`
           AND `created_at` >= '2018-12-13 12:04:29') AS `topics_count`,
       (SELECT Count(*)
        FROM   `replies`
        WHERE  `users`.`id` = `replies`.`user_id`
           AND `created_at` >= '2018-12-13 12:04:29') AS `replies_count`
FROM   `users`
WHERE  ( EXISTS (SELECT *
                 FROM   `topics`
                 WHERE  `users`.`id` = `topics`.`user_id`
                    AND `created_at` >= '2018-12-13 12:04:29')
      OR EXISTS (SELECT *
                 FROM   `replies`
                 WHERE  `users`.`id` = `replies`.`user_id`
                    AND `created_at` >= '2018-12-13 12:04:29') )
ORDER  BY `topics_count` DESC,
          `replies_count` DESC

还有个问题,你会发现我们不断重复写

 $query->where('created_at', '>=', Carbon::now()->subDays($this->pass_days));

这可以抽离成 model 的一个 scope 方法

例如
Models/Model.php


use Carbon\Carbon;
class Model extends EloquentModel
{
.
.
.
    public function scopeCreatedGreatThanPassDays($query, $pass_days)
    {
        return $query->where('created_at', '>=', Carbon::now()->subDays($pass_days));
    }

那么

$query->where('created_at', '>=', Carbon::now()->subDays($this->pass_days));

这一句可以改成

$query->createdGreatThanPassDays($this->pass_days);
5年前 评论

数据查询数量少不一定效率高,这么多子查询有没考虑数据库的效率?这样写无非把循环交给数据库了。:)最终效率高低可以监测数据的读取速度。

5年前 评论

@xiaoyi1225
你说的没错 我宁愿查询两次也不会用子查询 子查询非常非常影响效率 但我没用子查询 我们可能对子查询的理解有些不同
还有就是实际的响应速度提高了十倍 从 200-300ms 到 20-50ms

5年前 评论

我可能还是小学生水平 , 你这些我不会用, 唉。

4年前 评论

@jxdr 学习呢嘛,我也不会用。只是 sql 用的比较多,所以在看到这部分内容的时候很自然就想从 sql 角度去尝试优化。之后构造器的方式查了很多内容才写出来的。一边学一边想,想完再尝试去实践而已。

4年前 评论

讨论应以学习和精进为目的。请勿发布不友善或者负能量的内容,与人为善,比聪明更重要!