redis使用总结

1.redis的使用场景

项目中存在多种redis的使用场景

1.1 场景一:缓存(key,value)

缓存是一个非常常见的场景,在项目中,可以将mysql中的一部分数据查询的结果缓存到redis中,以此来获取更快的查询速度。

以用户信息缓存为例,我的做法如下:

  • 1.设定用户信息缓存的规则,比如userinfo128代表用户id为128的用户信息缓存。
  • 2.在query语句执行时,先检查userinfo128是否存在,如果存在,直接取出结果返回,否则执行sql语句进行查询,并将查询结果缓存。
  • 3.在update和delete语句执行时,删除userinfo128的缓存,这样下一次query时就会自动更新缓存了。

1.2 timeline(sorted set)

timeline非常经典的场景就是微博,朋友圈这种,每个用户都能在自己的timeline上获取到按时间排序的其他用户发布的动态。这里有以前总结过的一篇文章:朋友圈式的TIMELINE设计方案

其实这个 timeline 我一开始的实现是用 list 去做的,但是用list会存在一个问题:因为推送动态可能同时发生,导致不是严格的按照时间排序。

1.3 推送用户集合(set)

这个功能其实就相当于维持一个用户的粉丝列表。这个列表是一个会经常发生变化的集合,并且在项目中是根据用户的关系链计算出来的,单次的查询会消耗很多的时间,因此做成一个集合,在需要推送动态之类的内容时,直接从redis的集合中查询,会节约很多的时间。

1.4 任务队列(list)

整个项目中很多的操作都是异步的,比如发短信,发邮件,推送用户动态等等,使用redis作为任务队列是很简单的,使用它的list结构,然后使用lpush/rpop对,一边push进任务,另一边有一个单独的后台进程pop出任务进行执行。

当然lpush/rpop并不是很好的一个选择,更好的选择是lpush/brpop,使用阻塞版本的pop指令,可以减少很多不必要的轮询。

在使用这样的任务队列时,还需要考虑到一个问题,如果在取出一个任务时进程崩溃,那么这个任务就彻底的丢失了。因此还可以使用 rpoplpush 或者阻塞版本的 brpoplpush ,取出一个任务的同时备份到另一个队列。如果执行成功的话就再lrem掉这个备份即可。关于队列的更详细的使用在第7大点有更详细的说明。

当然, Redis其实并不推荐作为任务队列的实现,如果需要的话,可以尝试使用Redis作者的另一个项目:disque,或者是kafka。

 

1.5 计数器(hash)

计数器我认为也算是 redis 一个常用的功能了,我认为原因有以下:

  • 1.很多场景下的计数功能都是一个非常高频的操作,使用 redis 会拥有极高的性能。
  • 2.redis支持原子性的自增(incre)操作,不用担心CAS(check and set)的问题。
  • 3.传统数据库,如mysql,如果是MyISAM,单次的更新会带来表锁,如果是InnoDB,则带来行锁,影响并发度。

计数器的使用很简单,直接对某个key做 incr 操作,或者对某个 hash 的 key 做 hincrby 操作即可。

 

2.php在使用redis时,多个数据库切换的困扰

项目中使用的是phpredis这个扩展,在使用pconnect保持redis长连接时,所有对redis的操作会共用同一个redis连接。这就导致:多个进程同时使用一个redis连接,并且多个进程使用的数据库不同时导致错误。比如下方的操作:

// 进程1做以下操作
redis->select(0);redis->set("key1", "val1");

// 进程2做以下操作
redis->select(1);redis-set("key2", "val2");

但是在redis的server端,所做的操作可能如下:

select(0)
select(1)
set("key2", "val2")
set("key1", "val1")

这样就导致key1的存储错误

所以我必须在所有这样的操作中,使用MULTI/EXEC对去解决这个问题。以上代码变成:

// 进程1做以下操作
redis->multi();redis->select(0);
redis->set("key1", "val1");result->exec();

// 进程2做以下操作
redis->multi();redis->select(1);
redis-set("key2", "val2");result->exec();

事实上,使用 redis 时,同时使用多个数据库并不推荐。因为在 redis 集群中是不支持 select 命令的。

3.redis多个数据库之间的切换,对性能有影响吗?

在探讨这个问题之前,摘录官网上对 select 命令的说明:

Since the currently selected database is a property of the connection, clients should track the currently selected database and re-select it on reconnection. While there is no command in order to query the selected database in the current connection, the CLIENT LIST output shows, for each client, the currently selected database.

大致意思可以翻译为:

因为当前选中的数据库是连接的一个属性,每个客户端连接都跟踪记录了当前选中的数据库,在重新连接时会重新选择数据库。虽然没有命令是为了查询当前连接选中的数据库,但是 CLIENT LIST 的输出会显示,每个客户端当前选中的是哪个数据库。

因此 select 操作只是修改了当前连接的属性。

4.redis 有 16 个数据库,目的是什么,最佳的使用方式是什么?为什么 redis 集群不支持 select?

同样的摘录一段官网的介绍

Redis different selectable databases are a form of namespacing: all the databases are anyway persisted together in the same RDB / AOF file. However different databases can have keys having the same name, and there are commands available like FLUSHDB, SWAPDB or RANDOMKEY that work on specific databases.

  In practical terms, Redis databases should mainly used in order to, if needed, separate different keys belonging to the same application, and not in order to use a single Redis instance for multiple unrelated applications.

When using Redis Cluster, the SELECT command cannot be used, since Redis Cluster only supports database zero. In the case of Redis Cluster, having multiple databases would be useless, and a worthless source of complexity, because anyway commands operating atomically on a single database would not be possible with the Redis Cluster design and goals.

大意如下:

Redis 多个不同的可选择的数据库是命名空间的一个表现形式:所有的数据库都会在同一个 RDB/AOF 文件中进行持久化。当然不同的数据库可以拥有同样的名字的键,同样的也有一些类似 FLUSHDB, SWAPDB 或 RANDOMKEY 这样的命名专门在数据库上工作的。

实际上,Redis 数据库应该主要用来分离属于一个应用的不同的键,而不是为了使用一个单独的 Redis 实例服务于多个不相关的应用。

当使用 Redis 集群时,SELECT 命名就不能使用了,因为 Redis 集群仅仅支持数据库0。在 Redis 集群的案例中,拥有多个数据库是无用的,是一种毫无价值的复杂性的来源,因为 Redis 集群的设计和目标是不可能支持 SELECT 命令的。

这里解释了为什么 Redis 被设计为有多个数据库,是为了分离同一个应用中不同的键而设计的,但是不能在多个不相关的应用中使用同一个 Redis 实例。并且还要注意在 Redis 集群中无法使用 SELECT ,因此在项目中还是不用为好。

5.redis是单进程的,如何理解?

我在第一次看到这句话时,是很不理解的。对于这种应用,不可能只有一个进程在工作啊。但是在深入了解之后,明白了这里的单进程指的是:处理 Redis 命令是单进程的。

也就是说,同一时间,在并发和并行的层面上来说,都只有一个 Redis 命令被执行。这样设计的理由我的理解有以下:

  • Redis是内存数据库,所有的操作耗时都视CPU的运行速度而定,IO不可能是瓶颈,并行/并发处理带来的意义不大。
  • 并行/并发会增加应用的复杂度

6.redis中使用队列的问题

在1.4小节中我提到了使用Redis作为任务队列的场景。在使用时遇到了程序运行一段时间之后,无法使用brpop获取数据的问题,并且这个程序的连接依然是存活的。

于是我用 CLIENT LIST 查看当前的连接客户端。发现服务器中有大量连接,但是很多连接的 idle 特别长,明显是很久以前的连接,这些连接我可以肯定是已经断开的。经过检查之后,发现 Redis.conf 中的 tcp-keepalive 项我设置为0了,设置为0就不会检查连接是否存活,从而导致连接一直存在。以前将 tcp-keepalive 设置为60,

那这跟 brpop 无法从 Redis 中获取数据有什么关系呢?以下是个人的猜想时间。

这要从Redis的block模型说起。Redis的网络连接是epoll模型的,所以是一个异步的io,肯定不会block一个连接。那么Redis server为了实现这样的block操作,会维持一个内部的哈希表,这个哈希表保存了哪个key上阻塞了哪些客户端。如下图所示:
此处输入图片的描述

如果此时list key1中被push进了一个值,key1就被置为ready状态,然后从链表头部取出client2,将值传给它。可能在我自己的测试中,有大量的已经断开连接客户端阻塞在key1上,但是因为tcp-keepalive为0,没有被及时清除。导致以上的结果。(目前的水平只能这么解释了,虽然还有很多地方说不通)

redis中的事务与锁

这篇文章是我在查找如何对redis中的值做原子操作时的一系列笔记,虽然最初的目的只是研究有哪些方式可以实现事务(transaction)操作,但是后来的延伸很多,所以我认为有必要做一些笔记防止忘记。

1.redis中的事务

redis中的事务其实并不满足数据库事务的四个特性:原子性(Atomicity)、一致性(Consistency)、隔离型(Isolation)、持久性(Durability),简称ACID。redis只能在一致性和隔离性上提供保证,而原子性和持久性是无法保证的。因为redis的事务操作如果中断,无法回滚,满足不了原子性,并且redis的持久化策略有很多中,比如在纯内存模式下是无法持久化数据库更改的,满足不了持久性。

所以下面针对与redis事务的讨论都是有局限性的。仅仅是指对redis数据进行操作时,不会受到其他客户端的干扰。举例来说:客户端A读取keyA,修改keyA中间,不会受到客户端B的影响。

    //客户端A执行以下代码
    //获取user1的性别,如果性别是男,头像设置为男性角色
    //否则头像设置为女性角色
    gender =redis->get('user1_gender');
    if(gender === 'male'){redis->set('user1_photo','male.png');
    }else{
        $redis->set('user1_photo','female.png');
    }
    //客户端B执行以下代码
    //更改user1的性别,如果是男则改为女,如果是女则改为男
    //并根据性别设置头像
    gender =redis->get('user1_gender');
    if(gender === 'male'){redis->set('user1_gender','female');
        redis->set('user1_photo','female.png');
    }else{redis->set('user1_gender','male');
        $redis->set('user1_photo','male.png');
    }

如果客户端A、B同时只有一个执行,那么性别和头像一定是对应的。但是A、B同时执行,并且执行顺序如下:
时序图

那么执行结果就是user1性别为女,但头像为男性角色。

所以针对于这种情况,我们需要一定的措施去预防。

redis的事务实现方式有多种。据我所知有三种方式:

  • 1.MULTI/EXEC
  • 2.WATCH/UNWATCH
  • 3.lua脚本

1.1 MULTI/EXEC

MULTI/EXEC是成对出现的指令。大家都知道,redis执行指令是单线程的,也就是说所有指令都处于一个队列,一个一个执行。MULTI/EXEC可以保证所有的指令都会被同时放松到redis中,中间不会掺杂来自其他客户端的指令。

但是这个针对于上述情况并不适用。因为我们需要在GET到数据之后,才能做下面的更新操作。

1.2 WATCH/UNWATCH

在redis中我们可以使用watch来监视一个值。

redis> WATCH name
OK

redis> MULTI
OK

redis> SET name peter
QUEUED

redis> EXEC
(nil)

比如这段代码,在我们WATCH name后,如果另外一个客户端修改了name的值,那么这个客户端再次修改name则无法成功。这其实就是乐观锁的一种实现。它保证了代码的执行结果不会错乱。用一开始的例子来说,就是不会出现性别和头像不对应的情况。

1.3 lua脚本
redis中可以执行lua脚本来完成事务操作。lua脚本和MULTI/EXEC很像,也就是说在lua脚本执行的过程中,redis是不执行其他客户端的指令的。

但是lua脚本的不同之处在于,你可以使用GET操作来获取数据并判断,再执行后来的操作。

2.在redis操作中使用锁

在多线程环境中,为了防止资源出现race condition,需要借助锁来互斥的访问资源。在这里也是一样的,user1_gender就是我们要互斥访问的资源。

还是上述那个例子,如果我们使用悲观锁,只有获得锁的客户端才能读取和修改user1的的值,也可以很好的解决这个问题。

伪代码如下:

$can_lock = lock('user1_gender'); //得到锁
if($can_lock){
    do_something();
    $release_lock('user1_gender');  //释放锁
}

不同于多线程环境下的是,我们这里的锁的范围是针对于不同的客户端。因此没法使用基于系统的、或者基于语言的锁,而是得使用分布式的锁。这样的分布式锁我们同样可以借助于redis来实现。

整个分布式锁的实现可以概括为以下几个步骤:

    1. 获得锁。得到要锁的资源的唯一hash:lockname,以及一个随机字符串:identifier,设置expire:20s,这个expire就是锁的有效期,在有效期后锁会自动释放,防止出现死锁。在redis中使用setnx(lockname,identifier,expire)。这句代码的意思是:如果redis中不存在lockname,则存入lockname,值为identifier,过期时间是expire。
    1. 第一步我们就得到了一个锁,这一步我们开始执行获得锁之后的代码
    1. 释放锁。我们根据lockname来从redis中查找。如果get(lockname) == identifier,则表示我们仍然持有这把锁,使用delete(lockname)来释放锁。如果不等于,说明我们已经不持有这把锁了,则什么也不做。

那么lock函数可以用以下代码来描述:

function lock(lockname,identifier,expire = 20){acquire_timeout = 10;      //花费10秒去获得锁,否则就放弃
    end = time() +acquire_timeout
    while (time() < end){can_lock = redis->setnx(lockname,identifier,expire);
        if($can_lock){
            return true;
        }
        sleep(0.1);
    }

    return false;
}
    function release_lock(lockname,identifier){
        redis->watch(lockname);
        try{
            if(redis->get(lockname) === identifier){
                //锁仍然持有,释放锁redis->delete(lockname);
                return true;
            }redis->unwatch();
        }catch(Exception $e){

        }

        return false;
    }

这样我们就很轻松的得到了借助于redis实现的分布式锁。但是这样的实现方式依然是有问题的。

问题1:如果在某个客户端获得锁后,redis主服务器宕机了,那么即使我们使用了主从备份,从属服务器被提升为主服务器,因为redis备份是异步的原因,这里的锁是没法及时同步到从属服务器的。

问题2:如果一个客户端在获得锁后,执行的操作超过了锁的有效期,锁被自动释放了。那么后续的操作是没法受到锁的保护的。

问题2的解决方案可以是watch,在获取锁后,可以立刻watch资源,然后再执行余下操作。

问题1的解决方案则是接下来要介绍的redlock算法

3.redlock

redlock算法是Redis的作者antirez提出来的。
可以被概述为以下几个步骤:

  • 1.获取当前时间(毫秒数):start_time。

  • 2.按顺序获得N个Redis节点的锁(使用相同的key和identifier,并设置初始有效时间:init_validity_time)。在获得每个redis结点的锁的时候,都要设置一个timeout参数,这个timeout要远小于锁的自动释放时间。例如:如果锁的自动释放时间是10s,timeout应该为~5-55ms(还得视网络情况决定)。这样可以防止在获取锁时,节点宕机,导致耗时过长锁被释放了。如果获取锁失败则立刻获取下一个redis节点的锁

  • 3.client计算为了获取锁花了多长时间:used_time = current_time – start_time。当且仅当client可以获取大多数实例的时候(至少N / 2 + 1个),所花费的时间小于锁的有效时间,才认为获得了锁。

  • 4.如果获得了锁,重新计算锁的有效时间:validity_time = init_validity_time – used_time

  • 5.如果锁获取失败(无法获取N/2 + 1个节点的锁,或者有效时间validity_time是负数),则释放所有实例的锁(即使获得锁的时候失败了,这主要是考虑到有的时候锁获得成功了,但是告知客户端时网络异常)。

这很好的解决了上述的问题1,通过多个redis节点了来保证分布式锁服务的可靠性。

参考文章

Distributed locks with Redis

基于Redis的分布式锁到底安全吗(上)?

基于Redis的分布式锁到底安全吗(下)?

redis事务

6.2.3 Building a lock in Redis

朋友圈式的Timeline设计方案

一、前言

几乎每个人都会发朋友圈、微博,一个看似简单的发布功能,实际在背后是经过精细设计的组织架构。这里有一篇关于新浪微博的架构设计的演讲,主要是讲了通过redis+缓存的使用,来实现发微博的实时性和高效性。

二、Timeline

目前来说,大多数发朋友圈、发微博这种架构的设计都是Timeline的方式。以微博为例,每个用户都有一个自己的Timeline,用户查看微博时,只从自己的Timeline上获取数据。而如果我们是使用普通的sql查询,那么查询可能就是select weibo from table where table.userId in (select userId from guanzhu where guanzhu.userId = 'me')。也就是先查询我所关注的人,再查询所有我关注的人发的微博,然后以时间排序返回。而且因为数据的变化会非常的快,根本没有办法去做缓存,并且刷微博又是一个非常高频的动作,因此普通的sql查询必然无法满足。

而Timeline方案,其实就是以空间换时间的一种方案,假设现在姚晨(1000万粉丝)发布了一条微博,在姚晨的视角里,她已经发布完成了,但是实际上此时并不是她的所有粉丝都能立刻看到这条微博。这个发布动作做了异步处理,此时正在向关注她的微博的粉丝的Timeline上推送(当然微博也不可能真的向她所有粉丝推送,可能会去除一些长期不上线的用户以及僵尸粉)。

因此Timeline方案最复杂的地方就在于发微博这个环节而不是刷新微博这个环节了。发微博相对于刷新微博是非常低频次的,并且只要自己能实时看到就行,并不要求其他人都能实时看到,大大降低了复杂度。

三、使用redis配合完成Timeline的设计。

redis是一款内存数据库,但是它的数据也可以持久化的磁盘上。因为是内存数据库,它最擅长的就是高频次的读取和写入。并且redis支持多种数据结构,其中list数据结构非常符合Timeline的需求。

在redis中,可以创建命名的list(系统中现在使用了zset去保存timeline,比list更优),并且可以对list做push、pop、range操作。比如id为28的用户,他的timeline可以是名为u28的list,然后使用push来向用户的Timeline增加新的推文、使用range来获取指定范围内的推文。

下面的php代码演示了如何从Timeline上获取推文信息

/**
 * 获取一页时间轴上的推文id
 * @param userId 用户id
 * @parampageIndex 页码
 * @param pageSize  页大小
 * @return array 一页postId
 */
public function getTimeline(userId,pageIndex,pageSize){
    end = (1 -pageIndex) * pageSize - 1;start = end -pageSize + 1;

    result =this->redis->lRange(this->getUserTimelineKey(userId),start,end);

    return result;
}

/**
 * 获取用户时间轴上推文的总数
 * @paramuserId 用户id
 * @return int 总数
 */
public function getTimelinePostCount(userId){
    returnthis->redis->lSize(this->getUserTimelineKey(userId));
}

四、php如何异步推送到其他用户的Timeline

php的机制决定了它无法完成异步操作(一个php文件执行完成之后线程会直接被销毁),因此这里同样是采用了Redis当做任务队列来解耦合,实现异步操作。一个用户发布推文之后,使用下面的setPostTask(pushUserIds,postId,$userId),将发布推文放到任务队列中然后直接返回。在这里,对用户自己的Timeline做了特殊处理,发推文之后立马给自己的timeline同步推送,其他用户的timeline是异步推送。

        /**
         * 设置推送任务
         * @param pushUserIds 要推送的userId
         * @parampostId      推文内容
         * @return mix 任务推送状态
         */
        public function setPostTask(pushUserIds,postId,userId){

            //为了用户第一时间看到自己发的,这里特殊处理用户自己发的推文this->redis->rPush(this->getUserTimelineKey(userId),postId);userIds = array();
            foreach(pushUserIds asvalue){
                array_push(userIds,value['userId']);
            }
            data['u'] =userIds;
            data['p'] =postId;
            str = \json_encode(data);
            return this->redis->rPush(GLOBALS['redis_post'],$str);
        }

于此同时,一个以cli模式运行的php脚本从任务队列中顺序获取所有的推送任务,向其他用户的Timeline上推送。

/**
 * 获取推送推文的任务
 * @return mix 推送结果
 */
public function getPostTask(){
    return this->redis->lPop(GLOBALS['redis_post']);
}

/**
 * 将推文id推送到所有用户的timeline中
 * @TODO 现在是同步的方式
 * @param pushUserIds 要推送的用户id
 * @parampostId 推文id
 * @return boolean  true操作成功
 */
public function pushToTimeline(pushUserIds,postId){
    foreach(pushUserIds asvalue){
        result =this->redis->rPush(this->getUserTimelineKey(value),$postId);
    }
    return true;
}

cli模式运行的php脚本代码:

<?php
/**
 * 从Redis队列中轮询要执行的任务,并执行推送
 *
 */
require_once('vendor/autoload.php');
use DB\PostDB;

postDB = new PostDB();postContent = "";
while(true){

    while( (postContent =postDB->getPostTask()) != FALSE ){
        post = json_decode(postContent,true);
        var_dump(post['u']);postDB->pushToTimeline(post['u'],post['p']);
    }

    sleep(1);

}

即使这个推送会花费一些时间,但是没有任何用户的体验受到了影响,发布推文的用户timeline因为是特殊处理,所以他能立马看到自己发的推文,其他用户虽然无法立刻看到,但是其他用户对“立刻看到”的需求几乎没有(“立刻看到”指的是刚发布的一瞬间,绝大多数人对于延迟几秒看到根本无所谓)。并且这个架构可以很轻松的横向扩展。

五、一些其他问题

5.1 内存很贵,尽量压缩

redis是内存数据库,内存相对于磁盘的价格要贵上很多倍,因此存在redis中的内容要尽量的小。比如存的是Json,那么Json的字段要尽量的小(直接使用1,2,3,4,5这种也完全可以,即使损失了可读性),同时Json也可以考虑改成谷歌的Protocol Buffers等等。

5.2 timeline里应该存什么

使用Redis存储了每个用户的Timeline,在我的实现中,timeline中存储的是每篇推文的id,然后获取到id之后再去mysql数据库中查询,但是这样的设计虽然大大降低了查询的复杂度,但是并没有降低mysql的IO负荷,如果把推文内容直接存储到Timeline中呢?这样的话如果用户要删除一篇推文,如何对应的删除其他用户的timeline中的推文?这个地方一直是我无法解决的疑惑。

这里有一篇关于twitter使用redis解决timeline的方案,这篇方案中使用的不是list来保存用户的post,而是zset,zset中每条数据由推文的id和时间戳组成,通过时间来排序。仔细考虑一下,list因为是根据推送时间来排序的,可能出现后发的推文出现在靠前的位置(当然我认为是可以通过隐藏描述显示来解决这个问题,因为错位几秒并不影响)。

所以理论上来说,上面那篇文章介绍的方案和我的方案是一致的,只是我把推文的源放在mysql上,twitter直接是存储在redis中来获取更快的速度。