2014年5月

一台Memcache通常不能满足我们的需求,这就需要分布式部署。Memcached分布式部署方案通常会采用两种方式,一种是普通Hash分布,一种是一致性Hash分布。本篇将以PHP作为客户端,来分析两种方案。
一、普通Hash分布:

<?php
function test($key='name'){
    $md5 = substr(md5($key), 0, 8);
    $seed = 31;
    $hash = 0;
    for($i=0; $i<8; $i++){
        $hash = $hash * $seed + ord($md5[$i]);
    }
    return $hash & 0x7FFFFFFF;
}

$memcacheList = array(
        array('host'=>'192.168.1.2', 'port'=>6379),
        array('host'=>'192.168.1.3', 'port'=>6379),
        array('host'=>'192.168.1.4', 'port'=>6379),
        array('host'=>'192.168.1.5', 'port'=>6379),
);
$key = 'username';
$value = 'lane';
//根据KEY获取hash
$hash = $this->test($key);
$count = count($memcacheList);
$memcache = $memcacheList[$hash % $count];
$mc = new Memcached($memcache);
$mc->set($key, $value);
?>

代码很简单,一个Hash函数,根据所需要的key,将他md5后取前8位,然后经过Hash算法返回一个整数。将这个整数对服务器总数求模。得到的就是服务器列表的编号。这种方式的缺点是服务器数量改变后,同一个key不同hash,将取不到值了。

二、一致性Hash分布
一致性Hash尽管也会造成数据的丢失,但是损失是最小的。
将2的32次方-1想象成一个圆环,服务器列表在上面排列。根据key通过hash算法求得在圆环上的位置,那么所需要的服务器的位置在key的位置前面最近的一个(顺时针)。

<?php
class FlexiHash{
    //服务器列表
    private $serverList = array();
    //是否排序
    private $isSort = false;

    /**
     * Description: Hash函数,将传入的key以整数形式返回
     * @param string $key
     * @return int
     */
    private function myHash($key){
        $md5 = substr(md5($key), 0, 8);
        $seed = 31;
        $hash = 0;
        for($i=0; $i<8; $i++){
            $hash = $hash * $seed + ord($md5[$i]);
        }
        return $hash & 0x7FFFFFFF;
    }

    /**
     * Description: 添加新服务器
     * @param $server
     */
    public function addServer($server){
        $hash = $this->myHash($server);
        if(!isset($this->serverList[$hash])){
            $this->serverList[$hash] = $server;
        }
        $this->isSort = false;
        return true;
    }

    /**
     * Description: 删除指定服务器
     * @param $server
     * @return bool
     */
    public function removeServer($server){
        $hash = $this->myHash($server);
        if(isset($this->serverList[$hash])){
            unset($this->serverList[$hash]);
        }
        $this->isSort = false;
        return true;
    }

    /**
     * Description: 根据要操作的KEY返回一个操作的服务器信息
     * @param $key
     * @return mixed
     */
    public function lookup($key){
        //将指定的KEYhash出一个整数
        $hash = $this->myHash($key);
        if($this->isSort !== true){
            krsort($this->serverList);
            $this->isSort = false;
        }
        foreach($this->serverList as $key=>$server){
            if($key <= $hash){
                return $server;
            }
        }
        return array_pop($this->serverList);
    }
}
//使用方法
$mc = new FlexiHash();
$mc->addServer('192.168.1.2');
$mc->addServer('192.168.1.3');
$mc->addServer('192.168.1.4');
$mc->addServer('192.168.1.5');

echo 'KEY=key1时,操作的服务器为:'.$mc->lookup('key1').'<br>';
echo 'KEY=key1时,操作的服务器为:'.$mc->lookup('key2').'<br>';
echo 'KEY=key1时,操作的服务器为:'.$mc->lookup('key3').'<br>';
echo 'KEY=key1时,操作的服务器为:'.$mc->lookup('key4').'<br>';
echo 'KEY=key1时,操作的服务器为:'.$mc->lookup('key5').'<br>';
?>

Redis是实时广播推送的一把好手。Redis提供了发布-订阅者模式。发布消息是PUBLISH 频道名 内容的格式。如:

PUBLISH fm97 hello world

这样,所有订阅fm7频道的用户就可以收到hello world了。PUBLISH返回值是收到的订阅者个数。订阅命令是SUBSCRIBE。如:

SUBSCRIBE fm97

在输入Redis订阅命令之后,值可以输入SUBSCRIBE/UNSUBSCRIBE/PSUBSCRIBE/PUNSUBSCRIBE这四个命令,不然会报错。在SUBSCRIBE模式下,收到的消息第一行是subscribe。第二行是频道名称fm97,第三行是当前的订阅数量。也可能是收到的消息第一行是message。第二行是频道名称fm97,第三行是广播内容hello world。
退定则是UNSUBSCRIBE,如

UNSUBSCRIBE fm97

PSUBSCRIBE 通过通配符来进行订阅,如:

PSUBSCRIBE fm?*

这就订阅了fm开头的所有频道,但不会订阅fm这个频道。
PUNSUBSCRIBE同理。不说啦~

Redis是天生的队列好手。RPOP,LPUSH就可以看到。生产者是队列任务的提出方,消费者队列任务的执行方。生产者提出大量的任务,他们排队一个接一个的被消费者执行。执行一个就RPOP,提出一个新任务就LPUSH。如果要插队呢?就RPUSH。Redis队列的伪代码:

//无限循环
loop
    $task = RPOP queue
    if($task)
        execute($task)
    else
        sleep(1)

一个无限循环,从队列的最头部弹出一个任务,如果该任务存在则执行,如果不存在则睡眠,1秒后再次进入循环。这段代码实现了对队列任务的死循环来进行监听任务列表。这样并不好,每1秒扫描一次,如果一晚上都没有呢,那不是在白白浪费资源吗。这时候借助Redis 队列命令家族中的BRPOP。如果队列列表中有任务则弹出,如果没有任务就一直将连接阻塞,直到有新的任务加入才会放开。

//无限循环
loop
    $task = BRPOP queue 0
    execute($task)

BRPOP第一个参数是键,第二个参数是时间,如果时间为0则没有新任务加入的时候永久阻塞。
Redis队列家族是可以进行优先级的。比如有三个列表任务列表,queue1,queue2,queue3。那么那个优先级高就拍在前面:

    BRPOP queue2 queue3 queue1 0

如果队列2中有任务则优先弹出任务2。

Redis SORT是由Redis提供的一个排序命令。集合中的标签是无序的,可以使用SORT排序。如:

redis>SADD jihe 5
(integer) 1
redis>SADD jihe 1
(integer) 1
redis>SADD jihe 2
(integer) 1
redis>SADD jihe 8
(integer) 1
redis>SORT jihe
1) "1"
2) "2"
3) "5"
4) "8"

如果使用Redis SORT排序的不是数字,是字母,将他们按照字典的顺序排名,则需要使用

SORT jihe ALPHA

如果不加ALPHA参数,则会报错,提示:(error) ERR One or more scores can't be converted into double。我们还可以使用关系型数据库的DESC进行倒序排序和LIMIT offset count来限定获取的条数

SORT jihe DESC LIMIT 0 2

还可以对Redis SORT命令添加BY参数。一条语句只能有一个BY参数。这时,SORT不会根据自身的值排序,比如(1,5,2,8和a,A,g,B),而是根据指定的另一个键中的字段来排序。如:

SORT tag:redis:article BY article:*->time DESC

解释:根据tag:redis:article中的值(tag是redis的文章ID),来组合成一个新的key就是article:(ag:redis:article中的一个值):time。获取到tag是redis的文章ID列表,然后根据他们的发布时间来排序。
Redis SORT命令还有个GET参数,GET参数类似在关系型数据库中的关联查询。比如查询tag是redis的文章ID列表,将列表根据发布时间倒序排序,然后获取每个文章的标题。GET可以有多个:

SORT tag:redis:article BY article:*->time DESC GET article:*->title GET article:*->time GET #

GET #的意思是,将文章ID返回回来,你可以写GET article:*->id,也可以写GET #。
Redis SORT命令还有个参数是STORE,是将排序后的内容存储到一个新的key中。新key的类型是列表类型,如果存在则会覆盖。这个时候可以用EXPIRE来设置缓存:

SORT tag:redis:article BY article:*->time DESC GET article:*->title GET article:*->time GET # STORE resultKey

Redis的SORT命令是Redis最复杂最强大的命令之一,时间复杂度是O(n+mLOGm)。n是待排序的列表长度,m是返回的元素个数。减少n和m会提高SORT的性能。

Redis对键提供生存时间,在不指定生存时间时,生存时间是永久。时间到期后Redis会自动删除这个键。可以用EXPIRE命令,时间单位时秒,如果一个键是被设为有限的生存时间,那么在SET key进行重新赋值的时候会被再次设为永久:

SET session:captcha sd2a
EXPIRE session:captcha 600

取消生存时间,将键的生存时间设为永久,是PERSIST:

PERSIST session:captcha

查看一个键的生存时间用TTL命令,-1表示永久或者以及到期被删除。

TTL session:captcha

在Redis的INCR,LPUSH,HSET,ZREM等命令时不会改变生存时间的。
想要精确到毫米来控制时间,就需要PEXPIRE即可,使用PTTL查看剩余时间。
如果想要给定一个到期的时间而不是多少秒后到期呢?就需要EXPIREAT和PEXPIREAT。EXPIREAT的参数是到期时的时间戳(秒),PEXPIREAT的参数是到期时间是时间戳(毫秒)

SET session:captcha sd2a
EXPIREAT session:captcha 1399902009
PEXPIREAT session:captcha 1399902009000

应用场景一:访问频率限制:我们限定每个用户1分钟只能浏览10个页面。伪代码如下:

$isExists = EXISTS limit:user1:192.168.1.2
if($isExists){
    $num = INCR limit:user1:192.168.1.2
    if($num > 10){
        print '超过限制'
        exit
    }
}else{
    MULTI
    INCR limit:user1:192.168.1.2
    EXPIRE limit:user1:192.168.1.2 60
    EXEC
}

我们用了事务的原因是因为,加入在执行了INCR limit:user1:192.168.1.2之后,在执行EXPIRE limit:user1:192.168.1.2 60之前,客户端被关闭了。那么这个键和值就会被持久化保存。且该ID终身只能访问10次了。这就太糟糕了。

应用场景二:实现缓存。计算一万名用户的排行榜,是很耗费资源的,那么我们把数据在第一次计算后存进一个key,然后对这个key设置生存时间。在1个小时后生存时间到期,key被删除,再次进行计算新排名并保存的一个临时key。我们用伪代码实现:

//战斗排行榜
$rank = GET cache:rank:fight
if not $rank
    $rank = 计算排名()
    MULTI
    SET cache:rank:fight $rank
    EXPIRE cache:rank:fight 3600
    EXEC

Redis是内存存储的数据库,假如内存被缓存占满了,Redis会根据配置文件来删除一定的缓存。配置项是Redis的配置文件中的maxmemory参数,单位是字节。超过这个限制之后,会根据配置文件的maxmemory-policy参数来删除不需要的键。maxmemory-policy的可选规则是如下四种:
1、volatile-lru:使用LRU算法删除一个键(设置了生存时间的键)。
2、allkey-lru:使用LRU算法删除一个键。
3、volatile-random:随即删除一个键(设置了生存时间的键)。
4、allkey-random:随即删除一个键。
5、volatile-ttl:删除生存时间即将过期的一个键。是随即取出来N个键,然后删除N个键中即将过期的键,而不是遍历所有的键删除即将过期的。N是几?配置文件配的。
6、nevication:不删除,返回错误。