基于 Laravel 和 Redis 的点赞功能设计

思路:

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'));
    }

边学边做边分享, 有很多不足, (甚至不知道自己的思路是否正确), 期待指正, 感谢.

本帖由系统于 1个月前 自动加精
《L01 基础入门》
我们将带你从零开发一个项目并部署到线上,本课程教授 Web 开发中专业、实用的技能,如 Git 工作流、Laravel Mix 前端工作流等。
《L05 电商实战》
从零开发一个电商项目,功能包括电商后台、商品 & SKU 管理、购物车、订单管理、支付宝支付、微信支付、订单退款流程、优惠券等
讨论数量: 10
GDDD

.....一个异步任务就完事了.你搞这多

2个月前
jake666

@GDDD 异步任务也不能解决并发的问题啊

2个月前
GDDD

@jake666 1文章也不能解决2异步任务可以放在其他服务器执行 这是不是减小服务器压力了.
我能力不行 不谈并发

2个月前

取消赞存在bug,没有置数据库点赞记录为无效状态

2个月前

@liuzm_coder 是的, 改过来了, 谢谢 :star2:

2个月前
龙玉箫

如果点赞数据只统计数量,不在Redis里使用sMembers方法,推荐你使用pfadd来做点赞。

2个月前

@龙玉箫 哇, 我真的第一次知道这个 :grin: 学到了, 虽然不适合我这个点赞需求 ( 我设计的这个点赞功能是参考 Summer 之前发的一篇文章: 合并点赞和收藏 ). 但是我一定会用到项目的其他地方的, thx all the same.

2个月前

代码超级规范,注释超级清晰,赞!我没有做过点赞,感觉做的挺好的,唯一就是代码那里的两个for循环,我没有仔细看是做什么的,但是感觉怕怕,总的来说非常不错啦,也学习了

1个月前

@HI 感谢肯定, 很受用的说 :blush:

1个月前

先码住 正好可以在自己项目中试试

1个月前

请勿发布不友善或者负能量的内容。与人为善,比聪明更重要!