基於 Laravel 和 Redis 的點贊功能設計

Rachel發表於2019-03-13

思路:

Redis 儲存隨後批量刷回資料庫

資料表設計:

Mysql 設計部分:

建兩個表:

likes:

存每篇文章的贊計數, 欄位: post_id, count

user-like-post:

存點讚的具體細節, 主要是 post_id 和 user_id. 可以根據業務需求冗餘其他欄位, 比如我還加了 post_title, user_name 等欄位.

Redis 設計部分:

post-set:

在 Redis 中弄一個 set 存放所有被點讚的文章;

post-user-like-set-{ $post_id }:

對每個post以post_id作為key, 搞一個 set 存放所有對該 post 點讚的使用者;

post-{ $post_id }-counter:

對每個 post 維護一個計數器, 用來記錄當前在 Redis 中的點贊數,因為要存的值只是一個數字, 而且需要加一/減一, 所以我選擇用 string 型別來儲存.
這裡我們只用 counter 記錄尚未同步到 Mysql 中的點贊數(可以為負),每次刷回 Mysql 中時將 counter 中的資料和資料庫已有的贊數相加即可。

post-user-like-{ $post_id }-{ $user_id } (2019.3.15 新增)

儲存點贊快照, 這個資料型別是 hash, 把 post_id 和 user_id 的組合作為鍵, 來唯一標識每個使用者與每篇文章的對應關係. 裡面可以根據業務需求放欄位, 原則是儲存使用者點讚的文章列表需要的內容, 這樣 Redis 部分從這裡取就可以了, 無需再查資料庫.

{ $user_id }-liked-posts (2019.3.15 新增)

以 user_id 為鍵, 以點讚的時間戳為 score, 用 ordered set 型別存放每個使用者贊過的文章, 為了方便以點贊時間為順序取出每個使用者贊過的文章列表.


使用者點贊/取消贊

獲取 user_id, post_id,查詢該使用者是否已經點過贊,已點過則取消之前的點贊記錄,這裡需要注意的是使用者點讚的記錄可能在資料庫中,也可能在快取中,所以查詢的時候快取和資料庫都要查詢。

將使用者的點贊/取消讚的情況記錄在 Redis 中,具體為:

1. 寫入 post-set:

將 post_id 寫入 post-set

2. 寫入 post-user-like-set-{ $post_id }:

將 user_id 寫入 post-user-like-set-{ $post_id }

3. 更新 post-{ $post_id }-counter

這裡的更新稍晚複雜一點,需要和前面一樣先獲取當前使用者是否對這個 post 點過贊. 如果點過,則本次是取消贊, count 減一, 如果沒點過,本次是點贊,count 加一。

4. 更新 post-user-like-{ $post_id }-{ $user_id }

記錄每次點讚的快照, 在我的專案中, 我記錄了 post_id, user_id, post_title, post_description, user_name, user_avatar, ctime (建立時間) 這些欄位.

程式碼實現:

(為了節約篇幅, 請大家自行把開始提到的兩個 Myql 表建一下, 還要建立一個 Like Model 檔案)

建立 LikeController:

php artisan make:controller LikeController

路由:

// 點贊
Route::post('/like', 'LikeController@like');

LikeController 的 like 方法:

public function like()
    {
        // 獲取當前登入使用者的資訊
        $user_id = request()->user()->id;
        $user_name = request()->user()->name;
        $user_avatar = request()->user()->avatar;

        // 獲取被點讚的文章的資訊
        $post_id = request('id');
        $title = request('post_title');
        $description = request('post_description');

        // post_set 用 Redis 的 set 型別, 儲存所有被 like 的文章
        Redis::sadd('post_set', $post_id);

        // 根據 post_id 和 user_id, 查詢 user_like_post 表, 看當前登入使用者是否有曾經贊過這篇文章的記錄
        $mysql_like = DB::table('user_like_post')->where('post_id', $post_id)->where('user_id', $user_id)->first();
        /*
        根據 post_id 和 user_id, 查詢 redis 裡是否有當前登入使用者是否有曾經贊過這篇文章的記錄.
        利用 set 值是要求唯一的特點:
        如果當前使用者曾經贊過這篇文章, 則新增不成功, sadd() 返回 0;
        如果沒有贊過, 則會將當前使用者 id 新增到這篇文章的 set 裡, 並且返回 1.
        */
        $redis_like = Redis::sadd($post_id, $user_id);

        // 如果 Mysql 中沒有記錄, 且 Redis 新增成功, 點贊成功
        if (empty($mysql_like) && $redis_like) {
            // 將這篇文章的點贊計數 加一
            Redis::incr('likes_count' . $post_id);
            // 給點讚的使用者的 ordered set 裡增加文章 ID
            Redis::zadd('user:' . $user_id, strtotime(now()), $post_id);
            // 用 hash 儲存每一個讚的快照
            Redis::hmset('post_user_like_'.$post_id.'_'.$user_id,
                'user_id', $user_id,
                'user_name', $user_name,
                'user_avatar', $user_avatar,
                'post_id', $post_id,
                'post_title', $title,
                'post_description', $description,
                'ctime', now()
            );

            //返回點贊成功
            return [
                'code' => 200,
                'msg'  => 'LIKE',
            ];
            // 反之, 不管是 Mysql 中還是 Redis 中有過點贊記錄, 此次操作均被視為取消點贊
        } else {
            // 將這篇文章的點贊計數減一
            Redis::decr('likes_count' . $post_id);
            // 從這篇文章的 set 中, 刪除當前使用者 ID
            Redis::srem($post_id, $user_id);
            // 從當前使用者讚的文章集合中, 刪除這篇文章
            Redis::zrem('user:' . $user_id, $post_id);
            // 從 mysql 中刪除這條點贊記錄
            DB::table('user_like_post')->where('post_id', $post_id)->where('user_id', $user_id)->delete();

            // 返回為取消點贊
            return [
                'code' => 202,
                'msg'  => 'UNLIKE',
            ];
        }
    }

以上就實現了點贊對 Redis 資料庫的操作.

下面是前端請求部分, 我用的是 Axios (因為它是基於 Bootstrap 的包, 我的專案裡已有 Bootstrap, 所以無需額外安裝, 如果需要可以檢視文件安裝)

Html 部分:
<div class="row" id="like">
    <span class="text-muted"><span >{{ $like_counts }}</span> 人點贊</span>
</div>
JS 部分:
$('#like').click(function () {
    // 指定 post 請求, 及請求的 url
    axios.post('/like', {
        //設定請求引數, 被贊文章的 ID
        id: "{{ $post->id }}",
        post_title: "{{ $post->title }}",
        post_description: "{{ $post->description }}",
    }).then(function (response) {
        var a = $('#like span span').text();
        if (response.data.code == 200) {
            //如果返回 200, 則表示點贊成功, 將頁面現實的點贊數 +1
            $('#like span span').text(++a);
        } else if (response.data.code == 202) {
            //如果返回 200, 則表示取消點贊, 將頁面現實的點贊數 -1
            $('#like span span').text(--a);
        }
    }).catch(function (error) {
        console.log(error);
    });
});

頁面展示部分

開啟一篇文章的時候, 需要顯示這篇文章目前有多少贊, 這個統計數需要是 Mysql 和 Redis 的和. 在我專案裡, 點贊數是展示在文章詳情頁的, 這裡只展示獲取點贊書的程式碼段:

// 文章詳情頁
    public function show($id)
    {
        .........

        // 獲取文章的點贊數
        // 初始化點贊數的值為 0
        $like_counts = 0;
        // 獲取 Redis 中的點贊數
        $count_in_redis = Redis::get('likes_count'.$id);
        if (!is_null($count_in_redis)) {
            $like_counts += $count_in_redis;
        }

        // 獲取 Mysql 的點贊數
        $count_in_mysql = Like::where('post_id', $id)->first();
        if (!empty($count_in_mysql)) {
            // 加和
            $like_counts += $count_in_mysql->count;
        }

        ..........

    }    

設定定時任務刷回資料庫

思路:
迴圈從 post_set 中 pop 出來一個 post_id 至到空

    根據 { $post_id }, 每次從 post_user_like_set_{ $post_id } 中 pop 出來一個 user_id 直到空

        根據 post_id, user_id, 資料寫入 user_like_post 表中

        將 post_{ $post_id }_counter中的資料和 post_like 中的資料相加, 將結果寫入到 likes 表中
實現:

建立一個定時任務:

php artisan make:command SaveLikesToDisk

檔案位置 app/console/commands:

class SaveLikesToDisk extends Command
{
    // 設定定時任務時用
    protected $signature = 'likestodisk:save';

    // 無用, 所以我也沒寫
    protected $description = 'Command description';

    // 目前不知道啥用
    public function __construct()
    {
        parent::__construct();
    }

    /**
     * Execute the console command.
     *
     * @return mixed
     */
    public function handle()
    {
        // 求出 Redis 中共有多少篇文章被點讚了, 這裡得到是一個整數值
        $liked_posts = Redis::scard('post_set');
        // 有多少篇文章被贊, 就迴圈多少次
        for ($i = 0; $i < $liked_posts; $i++) {
            // 從存放被讚的文章的 set 中 pop 出一篇文章, 即獲得 post_id. spop() 方法的特點是隨機返回一個值, 並從 set 中刪除這個值
            $post_id = Redis::spop('post_set');
            // 根據上面取出的文章 ID, 檢視這篇文章的 set 裡共有多少個使用者點贊
            $users = Redis::scard($post_id);
            // 有多少使用者, 就迴圈多少次
            for ($j = 0; $j < $users; $j++) {
                // 取出一個給這篇文章點讚的使用者
                $user_id = Redis::spop($post_id);
                // 根據文章 ID 和使用者 ID, 從儲存點贊快照的 hash 裡取出所有資訊
                $key = 'post_user_like_'.$post_id.'_'.$user_id;

                $post_title = Redis::hget($key, 'post_title');
                $post_description = Redis::hget($key, 'post_description');
                $user_name = Redis::hget($key, 'user_name');
                $user_avatar = Redis::hget($key, 'user_avatar');
                $ctime = Redis::hget($key, 'ctime');

                // 把資訊存入 user_like_post 表, 也就是儲存點讚的具體細節
                DB::table('user_like_post')->insert([
                    'user_id' => $user_id,
                    'post_id' => $post_id,
                    'post_title' => $post_title,
                    'post_description' => $post_description,
                    'user_name' => $user_name,
                    'user_avatar' => $user_avatar,
                    'created_at' => $ctime
                ]);
            }

            // 根據文章 ID 從點贊計數的 set 裡取出這篇文章共有多少個贊
            $count = Redis::get('likes_count' . $post_id);

            // 根據文章 ID 檢視 Mysql likes 表, 看原來是否有這篇文章的記錄
            $res = DB::table('likes')->where('post_id', $post_id)->first();
            if ($res) {
                // 如果原來有這篇文章的記錄, 看原來有多少個贊
                $old_count = $res->count;
                // 把原來的贊和新的贊加和後, 更新 Mysql 資料庫
                $count += $old_count;
                DB::table('likes')->where('post_id', $post_id)->update(['count' => $count]);
            }else{
                // 如果原來沒有這篇文章的記錄, 插入記錄
                DB::table('likes')->updateOrInsert([
                    'post_id' => $post_id,
                    'count' => $count,
                ]);
            }
        }
        // 清空快取
        Redis::flushDB();
    }

在 app/console/Kernel.php 中註冊:

protected $commands = [
        \App\Console\Commands\SaveLikesToDisk::class,
];

protected function schedule(Schedule $schedule)
{
    // 這裡用到了剛才設定的任務名稱
    $schedule->command('likestodisk:save')
        ->timezone('Asia/Shanghai')
        // Laravel 提供了從一分鐘到一年的各種長度的時間函式,我設定的是每天往 Mysql 裡匯入一次, 測試的時候, 可以暫時把這裡改成 everyminute()
        ->daily();
}

定時任務程式碼部分設定完成, 簡單測試一下, 隨便找偏文章點個贊, 在終端執行:

php artisan schedule:run

如果有如下輸出, 就表示任務執行成功啦:

Running scheduled command: '/usr/local/Cellar/php/7.2.12_2/bin/php' 'artisan' likestodisk:save > '/dev/null' 2>&1

這時可以去資料庫裡看一下, 應該看到 likes 表和 user_like_post 表都多了這篇文章的點贊記錄.
然後在 Redis 終端執行:

127.0.0.1:6379> keys *

應該有如下輸出, 表示資料已匯入 Mysql, 並清空 Redis:

(empty list or set)

目前這個任務是需要不斷的執行這個這個命令定時器才能不斷的執行,所以就需要 linux 的系統功能的幫助,在命令列下執行下面的命令:

crontab -e

執行完以上的命令之後,會出現一個處於編輯狀態的檔案,在檔案中填入以下內容:

* * * * * /usr/local/Cellar/php/7.2.12_2/bin/php /Users/rachel/Sites/edu-system/artisan schedule:run

然後儲存,關閉。上面命令的含義是每隔一分中就執行一下 schedule:run 命令。這樣一來,前面定義的任務就可以不斷的按照定義的時間間隔不斷的執行,定時任務的功能也就實現了。
注: 這是我第一次接觸定時任務, 也是第一次用 crontab -e 命令, 所以執行的並不順利, 對那串很長的命令解釋一下, 給大家參考, if you are also new.

基於 Laravel 和 Redis 的點贊功能設計


2019.03.15 新增內容

使用者檢視自己點贊/收藏的所有文章
    // 檢視我所有的贊過/收藏的文章
    public function index()
    {
        $user_id = request()->user()->id;
        // 從 Mysql 中取出當前登入使用者所有的點贊文章
        $post_mysql = DB::table('user_like_post')->where('user_id', $user_id)->orderBy('created_at')->get();

        // 從 Redis 中取出當前使用者點贊文章的 id
        $post_in_redis = Redis::zrange('user'.$user_id, 0, -1);
        if ($post_in_redis) {
            // 由於 sorted set 儲存的原則是 score 值由小到大排序, 最新收藏的時間戳的值肯定是最大的, 會排在後面, 所以這裡將上面取出來的陣列倒序遍歷
            foreach (array_reverse($post_in_redis) as $post_id) {
                // 根據文章 id 和使用者 id 從點贊快照中取出點讚的相關資訊
                $posts_redis[] = Redis::hgetall('post_user_like_'.$post_id.'_'.$user_id);
            }
            // 合併 Mysql 和 Redis 裡的資料
            $posts = array_merge($posts_redis, json_decode($post_mysql, 1));
        }else{
            $posts = $post_mysql->toArray();
        }
        return view('web.likes.index', compact('posts'));
    }

邊學邊做邊分享, 有很多不足, (甚至不知道自己的思路是否正確), 期待指正, 感謝.

相關文章