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

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

本作品采用《CC 协议》,转载必须注明作者和本文链接
本帖由系统于 5年前 自动加精
《L04 微信小程序从零到发布》
从小程序个人账户申请开始,带你一步步进行开发一个微信小程序,直到提交微信控制台上线发布。
《L02 从零构建论坛系统》
以构建论坛项目 LaraBBS 为线索,展开对 Laravel 框架的全面学习。应用程序架构思路贴近 Laravel 框架的设计哲学。
讨论数量: 17
GDDD

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

5年前 评论

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

5年前 评论
GDDD

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

5年前 评论

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

5年前 评论

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

5年前 评论
李铭昕

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

5年前 评论

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

5年前 评论

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

5年前 评论

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

5年前 评论

我用yaf框架做了一个点赞的功能。实现思路大家都是差不多的,不同的是我的数据没有异步回传给Mysql,每次前端都是直接插redis上的数据,感觉没必要把这些同步给mysql

4年前 评论

为什么一定要查数据库呢?使用缓存的目的不就是减少数据库的连接, 减轻服务器压力吗?这个让我不是很明白, 既然已经使用了缓存机制,可以使用redis 的持久化做到永久保存,当然了, 该备份缓存的时候要备份缓存。

4年前 评论

你的这个取消赞时还是操作了数据库,达不到点赞减少数据库压力的问题,你可以加一个增量点赞属性快照hash,用于保存点赞的状态,比如like_attr_${post_id}_{$user_id}用来保存点赞状态,点赞时间等等,定时刷新数据库的时候 取来自定义入库还是删除点赞记录之内的
我使用golang实现过一个版本你可以看一看
博客:自己封装的一些业务小组件

3年前 评论

Redis::flushDB 执行完schedule后,清空缓存,如何这时新的数据写入,岂不是误删除了

4个月前 评论

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