前言

点赞其实是一个很有意思的功能。基本的设计思路有大致两种, 一种自然是用mysql(写了几百行的代码都还没写完,有毒)啦。数据库直接落地存储, 另外一种就是利用点赞的业务特征来扔到redis(或memcache)中, 然后离线刷回mysql等。
我这里所讲的功能都是基于我之前的项目去说的,所以有些地方可以不用管的,我主要是记录这个功能的实现思路,当你理解了,基本想用什么鬼语言写都一样的。

直接写入Mysql

直接写入Mysql是最简单的做法。

做三个表即可,

  • comment_info
    记录文章的主要内容,主要有like_count,hate_count,score这三个字段是我们本次功能的主要字段。
  • comment_like 记录文章被赞的次数,已有多少人赞过这种数据就可以直接从表中查到;
  • user_like_comment 记录用户赞过了哪些文章, 当打开文章列表时,显示的有没有赞过的数据就在这里面;
缺点

数据库读写压力大
热门文章会有很多用户点赞,甚至是短时间内被大量点赞, 直接操作数据库从长久来看不是很理想的做法

redis存储随后批量刷回数据库

redis主要的特点就是快, 毕竟主要数据都在内存嘛;
另外为啥我选择redis而不是memcache的主要原因在于redis支持更多的数据类型, 例如hash, set, zset等。

下面具体的会用到这几个类型。

优点
  • 性能高
  • 缓解数据库读写压力
    其实我更多的在于缓解写压力, 真的读压力, 通过mysql主从甚至通过加入redis对热点数据做缓存都可以解决,
    写压力对于前面的方案确实是不大好使。
缺点
  • 开发复杂
    这个比直接写mysql的方案要复杂很多, 需要考虑的地方也很多;
  • 不能保证数据安全性
    redis挂掉的时候会丢失数据, 同时不及时同步redis中的数据, 可能会在redis内存置换的时候被淘汰掉;
    不过对于我们点赞而已, 稍微丢失一点数据问题不大;

其实上面第二点缺点是可以避免的,这就涉及到redis 的一些设计模式,不懂没关系,我尽量详细的写,后面我会给出如何解决这个缺点。

设计功能前知识准备
将要用到的redis数据类型(具体的类型说明,请看底部链接,有详细说明):
  • zset 这个类型主要用来做排序或者数字的增减,这里被用作like 和hate的数字记录,以及热度的记录。
  • set
    这个是无序集合,主要用来记录今天需不需要更新,将今天被点赞(包括点讨厌)过的文章id记录下来,方便晚上或者有时间对这部分数据更新。
  • hash
    这个是散列,主要用来存储数据以及索引。这里被用来记录用户对哪个文章点了什么,方便下次判断(我看过一些网上的介绍使用set来记录,那个也可以,但是本人觉得这样做更省空间,以及方便管理,再有就是hash的速度快)。
  • list 这个是队列大佬,我们的数据能不能 安全 回到mysql就靠它了。
关于热度如何去判断:

大家都知道,文章获得点赞数越高,文章的热度就越高,那么怎么判断呢?不就直接记录点赞数就行啦,但是对于最新的文章怎么办?例如有一篇文章一年前发布的,获得50个赞,有篇最新文章获得49个赞,但是按照上面所说的一年前的文章热度还比最新的高,这就不合理了,文章都是时效性,谁都想看最新最热的。

so!我们要换个方法去处理这个时效性,绝大部分语言都有 时间戳 生成的方法,时间戳随着时间越新,数字越大,直接将时间戳初始化赋值给文章的score,这样最新的文章相比以前的文章就会靠前了。接着是点赞对score的影响,我们假设一天得到20个赞算是一天最热,一天606024=86400秒,然后得到一个赞就是得到86400 / 20 = 4320分。具体数字看自己的业务需求定,我只是举例子而已。点hate当然也会减去相应的数字。

激动时刻!直接上代码了!里面有详细注释!
<?php

class Good
{
    public $redis = null;

    //60*60*24/20=4320,每个点赞得到的分数,反之即之。
    public $score = 4320;

    //点赞增加数,或者点hate增加数
    public $num = 1;

    //init redis
    public $redis_host = "127.0.0.1";
    public $redis_port = "6379";
    public $redis_pass = "";

    public function __construct()
    {
        $this->redis = new Redis();
        $this->redis->connect($this->redis_host,$this->redis_port);
        $this->redis->auth($this->redis_pass);
    }

    /**
    * @param int $user_id 用户id
    * @param int $type 点击的类型 1.点like,2.点hate
    * @param int $comment_id 文章id
    * @return string json;
    */
    public function click($user_id,$type,$comment_id)
    {
        //判断redis是否已经缓存了该文章数据
        //使用:分隔符对redis管理是友好的
        //这里使用redis zset-> zscore()方法
        if($this->redis->zscore("comment:like",$comment_id))
        {
            //已经存在
            //判断点的是什么
            if($type==1)
            {
                //判断以前是否点过,点的是什么?
                //redis hash-> hget()
                $rel = $this->redis->hget("comment:record",$user_id.":".$comment_id);
                if(!$rel)
                {
                    //什么都没点过
                    //点赞加1
                    $this->redis->zincrby("comment:like",$this->num,$comment_id);
                    //增加分数
                    $this->redis->zincrby("comment:score",$this->score,$comment_id);
                    //记录上次操作
                    $this->redis->hset("comment:record",$user_id.":".$comment_id,$type);

                    $data = array(
                        "state" => 1,
                        "status" => 200,
                        "msg" => "like+1",
                    );
                }
                else if($rel==$type)
                {
                    //点过赞了
                    //点赞减1
                    $this->redis->zincrby("comment:like",-($this->num),$comment_id);
                    //增加分数
                    $this->redis->zincrby("comment:score",-($this->score),$comment_id);
                    $data = array(
                        "state" => 2,
                        "status" => 200,
                        "msg" => "like-1",
                    );
                }
                else if($rel==2)
                {
                    //点过hate
                    //hate减1
                    $this->redis->zincrby("comment:hate",-($this->num),$comment_id);
                    //增加分数
                    $this->redis->zincrby("comment:score",$this->score+$this->score,$comment_id);
                    //点赞加1
                    $this->redis->zincrby("comment:like",$this->num,$comment_id);
                    //记录上次操作
                    $this->redis->hset("comment:record",$user_id.":".$comment_id,$type);

                    $data = array(
                        "state" => 3,
                        "status" => 200,
                        "msg" => "like+1",
                    );
                }
            }
            else if($type==2)
            {
                //点hate和点赞的逻辑是一样的。参看上面的点赞
                $rel = $this->redis->hget("comment:record",$user_id.":".$comment_id);
                if(!$rel)
                {
                    //什么都没点过
                    //点hate加1
                    $this->redis->zincrby("comment:hate",$this->num,$comment_id);
                    //减分数
                    $this->redis->zincrby("comment:score",-($this->score),$comment_id);
                    //记录上次操作
                    $this->redis->hset("comment:record",$user_id.":".$comment_id,$type);

                    $data = array(
                        "state" => 4,
                        "status" => 200,
                        "msg" => "hate+1",
                    );
                }
                else if($rel==$type)
                {
                    //点过hate了
                    //点hate减1
                    $this->redis->zincrby("comment:hate",-($this->num),$comment_id);
                    //增加分数
                    $this->redis->zincrby("comment:score",$this->score,$comment_id);

                    $data = array(
                        "state" => 5,
                        "status" => 200,
                        "msg" => "hate-1",
                    );
                    return $data;
                }
                else if($rel==2)
                {
                    //点过like
                    //like减1
                    $this->redis->zincrby("comment:like",-($this->num),$comment_id);
                    //增加分数
                    $this->redis->zincrby("comment:score",-($this->score+$this->score),$comment_id);
                    //点hate加1
                    $this->redis->zincrby("comment:hate",$this->num,$comment_id);

                    $data = array(
                        "state" => 6,
                        "status" => 200,
                        "msg" => "hate+1",
                    );
                    return $data;
                }
            }
        }
        else
        {
            //未存在 (这里需要从数据库获取最新的点赞数)
            if($type==1)
            {
                //点赞加一 (数据库存在则加上数据库的值)
                $this->redis->zincrby("comment:like",$this->num,$comment_id);
                //分数增加
                $this->redis->zincrby("comment:score",$this->score,$comment_id);
                $data = array(
                    "state" => 7,
                    "status" => 200,
                    "msg" => "like+1",
                );
            }
            else if($type==2)
            {
                //点hate加一
                $this->redis->zincrby("comment:hate",$this->num,$comment_id);
                //分数减少
                $this->redis->zincrby("comment:score",-($this->score),$comment_id);

                $data = array(
                    "state" => 8,
                    "status" => 200,
                    "msg" => "hate+1",
                );
            }
            //记录
            $this->redis->hset("comment:record",$user_id.":".$comment_id,$type);
        }

        //判断是否需要更新数据
        $this->ifUploadList($comment_id);

        return $data;
    }

    public function ifUploadList($comment_id)
    {
        date_default_timezone_set("Asia/Shanghai"); 
        $time = strtotime(date('Y-m-d H:i:s'));

        if(!$this->redis->sismember("comment:uploadset",$comment_id))
        {
            //文章不存在集合里,需要更新
            $this->redis->sadd("comment:uploadset",$comment_id);
            //更新到队列
            $data = array(
                "id" => $comment_id,
                "time" => $time,
            );
            $json = json_encode($data);
            $this->redis->lpush("comment:uploadlist",$json);
        }
    }
}

//调用
$user_id = 100;
$type = 1;
$comment_id= 99;
$good = new Good();
$rel = $good->click($user_id,$type,$comment_id);
var_dump($rel);
温馨提示:

1.上面代码只是一个实现的方法之一,里面的代码没精分过,适合大部分小伙伴阅读。用心看总有收获。

2.对于第三方接口,应该在外面包装多一层的,但是边幅有限,我就不做这么详细,提示,大家可以作为参考。

3.剩下的将数据返回数据的方法,等下篇再继续了。欢迎大家来交流心得。

Redis数据刷回数据库

里面还会涉及到如何将错误信息以及提示信息保存到文件里,方便以后的运维,再有就是如何使用PHP写进程BAT。

知识准备

首先针对上篇提到的关于redis刷回数据库的安全性的设计模式,因为我们使用的是list来做数据索引,所以在我们将list数据提取出来的时候,一旦redis在这时候出现异常,就会导致刚提取出来的数据丢失!有些小伙伴就说,丢失就丢失呗,才一点数据。但是我们做程序,就应该以严谨为基础,所以下面就来说下Redis List这位大佬给我们提供了什么帮助。

  • Redis List -》RpopLpush()函数
  • 使用方法:RPOPLPUSH source destination
  • 说明:命令RPOPLPUSH在一个原子时间内,执行以下两个动作:①命令RPOPLPUSH在一个原子时间内,执行以下两个动作;②将source弹出的元素插入到列表destination,作为destination列表的的头元素。
  • 设计模式:
    Redis的列表经常被用作队列(queue),用于在不同程序之间有序地交换消息(message)。一个程序(称之为生产者,producer)通过LPUSH命令将消息放入队列中,而另一个程序(称之为消费者,consumer)通过RPOP命令取出队列中等待时间最长的消息。
    不幸的是,在这个过程中,一个消费者可能在获得一个消息之后崩溃,而未执行完成的消息也因此丢失。
    使用RPOPLPUSH命令可以解决这个问题,因为它在返回一个消息之余,还将该消息添加到另一个列表当中,另外的这个列表可以用作消息的备份表:假如一切正常,当消费者完成该消息的处理之后,可以用LREM命令将该消息从备份表删除。
先来一段代码!我们的主体部分:

index.php:

<?php
require_once(__DIR__."/Mysql.class.php");
require_once(__DIR__."/Redis.class.php");
require_once(__DIR__."/Output_Log.class.php");


$rel = true;        //无限循环的变量
$num = 0;            //用来没数据时的判断依据
date_default_timezone_set("Asia/Shanghai"); 
$now = date("Y-m-d H:i:s");        //当前时间
//file log
$txt = dirname(__DIR__)."/Script_Log/clickgood_log.txt";
$output = new OutputLog();
$test = $output->open($txt,"a+");

while($rel)
{
    $redis = new RedisCtrl();

    //开始干活
    if($num==0){
        //这里就是将信息输出到文件里记录,下面很多地方都是一样的。
        $text = "start ".$name."\n";
        echo $text;
        $output->write($test,$text);
    }

    //获取备份队列的长度
    $copylistlength = $redis->llen("comment:uploadcopylist"); 

    //我这里展示的是第一数据回滚到mysql,小伙伴想批量回滚的,自己改装下就可以用了。
    //自己动手丰衣足食!
    if($copylistlength>1)
    {
        //由于是单一数据回滚,所以我要判断它是否超过我设定的值,小伙伴们最好也自己定一个阈值。
        //report error
        echo $now." ->false\n";
        $rel = false;
        return;
    }
    else if($copylistlength==1)
    {
        //这里判断防止上次redis出现错误,导致数据没有及时回到mysql
        $data = $redis->rpop("comment:uploadcopylist");
        $rel = $redis->UpdateClickGoodDataToMysql($data);
        $text = $rel."\n";
        echo $text;
        $output->write($test,$text);
    }
    else
    {
        //获取主要队列的长度
        $listlength = $redis->llen("comment:uploadlist");
        if ($listlength>0) {
            //使用之前说到的设计模式
            $data = $redis->rpoplpush("comment:uploadlist","comment:uploadcopylist");

            $rel = $redis->UpdateClickGoodDataToMysql($data);
            $text = $rel."\n";
            echo $text;
            $output->write($test,$text);
        }else{
            // 队列为空
            // 打印关闭信息,这里的写法算是维持进程窗口不关闭,需要手动关闭
            // 如果想让它执行完自动关闭的,
            // 把下面改写成$rel = false;
            if($num<=3){
                $text = $now." -> please close .\n";
                echo $text;
                $output->write($test,$text);
                $num++;
            }
            else
            {
                $output->close($test);
            }
        }
    }

}

redis操作类 Redis.class.php:

<?php
class RedisCtrl
{
    //init redis
    static $redisIp = "127.0.0.1";
    static $redisPort =6379;
    static $redisPass ="";
    public $redis = null;

    //Redis
    public function __construct()
    {
        $this->redis = new Redis();
        $this->redis->connect(self::$redisIp,self::$redisPort);
        $this->redis->auth(self::$redisPass);
    }

    public function llen($key)
    {
        $rel = $this->redis->llen($key);
        return $rel;
    }

    public function rpop($key)
    {
        $rel = $this->redis->rpop($key);
        return $rel;
    }

    public function rpoplpush($source,$destination)
    {
        $rel = $this->redis->rpoplpush($source,$destination);
        return $rel;
    }

    public function UpdateClickGoodDataToMysql($data)
    {
        //get id and time from redis list
        $result = json_decode($data,true);
        $id = $result['id'];
        $time = $result['time'];
        $arr = array();

        //like
        $like = $this->redis->zscore("comment:like",$id);
        $like = $like?$like:0;
        //hate
        $hate = $this->redis->zscore("comment:hate",$id);
        $hate = $hate?$hate:0;

        $sql = "update comment_info set like_count=".$like.", hate_count=".$hate." where id=".$id;
        $arr[] = $sql;
        //update sql
        $mysql = new MysqlCtrl();
        $mysql->saveMySQL($arr);

        //更新完,将set集合里需要更新的id去掉
        $this->redis->srem("comment:uploadset",$id);
        //更新完毕,将备份队列里的数据去掉
        $this->redis->lrem("comment:uploadcopylist",$data);

        return $sql."\n";
    }
}

mysql类 Mysql.class.php

<?php
//封装函数
class MysqlCtrl
{
    //初始化参数
    //数据库参数配置
    static $dbms = "mysql";
    static $host = Your host;
    static $user = Your user;
    static $pass =  Your pass;
    static $database = Your database;
    //睡眠时间
    static $sleep = 1;

    public $dsn = null;
    public $dbh = null;

    public function __construct()
    {
        $this->dsn = self::$dbms.":host=".self::$host.";dbname=".self::$database;
        //return $dsn;
        try {
              $this->dbh = new PDO($this->dsn, self::$user, self::$pass);
              echo "Connected\n";
        } catch (Exception $e) {
            echo $this->dsn;
              die("Unable to connect: " . $e->getMessage());
        }
    }
    
    //保存数据到数据库
    //PDO
    public function saveMySQL($arr)
    {

        try {  
              $this->dbh->setAttribute(PDO::ATTR_ERRMODE, PDO::ERRMODE_EXCEPTION);

              $this->dbh->beginTransaction();

              $count = count($arr);
              for ($i=0; $i < $count; $i++) { 
                  $this->dbh->exec($arr[$i]);
              }
        
              $this->dbh->commit();
          
        } catch (Exception $e) {
              $this->dbh->rollBack();
              echo "Failed: " . $e->getMessage()."\n";
              $json = json_encode($arr);
              echo "False-SQL: ".$json."\n";
              exit();
        }
    }
}

输出信息到文件的类 Output_Log.class.php

<?php
class OutputLog
{
    public function open($name,$r)
    {
        $text = fopen($name, $r);
        return $text;
    }

    public function write($name,$title)
    {
        $rel = fwrite($name, $title);
        return $rel;
    }

    public function close($name)
    {
        fclose($name);
    }
}

clickgood_log.txt 这里是保存输出的信息,里面是空白的。

hellO world

上面这些就是整套数据保存到mysql的操作,这是本人源码copy过来的,所以细分程度比较高,但是可扩展性也很高。有什么错误的地方希望小伙伴们能提出,谢谢。