Redis核心原理与应用实践

Redis核心原理与应用实践

在不少场景下都会使用Redis,可是到了深层次的时候就了解的不是那么深入,以致于在面试的时候常常会遇到卡壳的现象,学习知识要作到系统和深刻,不要把Redis想象的过于复杂,和Mysql同样,是个读取数据的软件。php

有一个理解是Redis是key value缓存服务器,更多的优势在于对value的操做更加丰富。linux

安装

yum install redis  #yum安装
brew install redis # brew安装
redis-cli

Redis 基础数据结构

Redis 有 5 种基础数据结构,分别为:string (字符串)、list (列表)、set (集合)、hash (哈希) 和 zset (有序集合)。程序员

string (字符串)

Redis 的字符串是动态字符串,是能够修改的字符串,内部结构实现上相似于 Java 的 ArrayList,采用预分配冗余空间的方式来减小内存的频繁分配,如图中所示,内部为当前字 符串实际分配的空间 capacity 通常要高于实际字符串长度 len。当字符串长度小于 1M 时, 扩容都是加倍现有的空间,若是超过 1M,扩容时一次只会多扩 1M 的空间。须要注意的是 字符串最大长度为 512M。web

键值对面试

set name codehole
get name

批量键值对redis

mget name1 name2 name3 # 返回一个列表
mset name1 boy name2 girl name3 unknown

过时和 set 命令扩展算法

expire name 5 # 5s 后过时
setnx name codehole # 若是 name 不存在就执行 set 建立

计数:若是 value 值是一个整数,还能够对它进行自增操做。自增是有范围的,它的范围是 signed long 的最大最小值,超过了这个值,Redis 会报错。sql

set codehole 9223372036854775807

list (列表)

Redis 的列表至关于 Java 语言里面的 LinkedList,注意它是链表而不是数组。这意味着 list 的插入和删除操做很是快,时间复杂度为 O(1),可是索引定位很慢,时间复杂度为 O(n),这点让人很是意外。shell

当列表弹出了最后一个元素以后,该数据结构自动被删除,内存被回收。数据库

Redis 将链表和 ziplist 结合起来组成了 quicklist。也就是将多个 ziplist 使用双向指针串起来使用。这样既知足了快速的插入删除性能,又不会出现太大的空 间冗余。

lpush/lpop
rpush/rpop
lindex

hash (字典)

Redis 的字典至关于数据中的散列表,也就是HashMap,一样的数组 + 链表二维结构。第一维 hash 的数组位置碰撞 时,就会将碰撞的元素使用链表串接起来,Redis 的字典的值只能是字符串。

set (集合)

Redis 的集合至关于 Java 语言里面的 HashSet,它内部的键值对是无序的惟一的。它的 内部实现至关于一个特殊的字典,字典中全部的 value 都是一个值 NULL。

当集合中最后一个元素移除以后,数据结构自动删除,内存被回收。 set 结构能够用来 存储活动中奖的用户 ID,由于有去重功能,能够保证同一个用户不会中奖两次。

zset (有序列表)

有序列表是面试官常考察的一个知识点,有序列表是使用了跳表这个数据结构。

应用 1:千帆竞发 —— 分布式锁

在面试中常常考察到大并发下的解决方案,使用Redis中的分布式锁就是一个很好的解决方案。所谓原子操做是指不会被线程调度机制打断的操 做;这种操做一旦开始,就一直运行到结束,中间不会有任何 context switch 线程切换。

分布式锁本质上要实现的目标就是在 Redis 里面占一个“茅坑”,当别的进程也要来占 时,发现已经有人蹲在那里了,就只好放弃或者稍后再试。

咱们在拿到锁以后,再给锁加上一个过时时间,好比 5s,这样即便中间出现异常也 能够保证 5 秒以后锁会自动释放。

$redis->setnx($key,time()+$expire); # 加锁
$redis->del($key); # 解锁

#新版本加锁
#NX意思为SET IF NOT EXIST,即当key不存在时,咱们进行set操做;
#若key已经存在,则不作任何操做;
#PX意思是给这个key加一个过时设置
$redis->set($resource, $token, ['NX', 'PX' => 10 ]);
<?php
class RedLock
{
    private $retryDelay;
    private $retryCount;
    private $clockDriftFactor = 0.01;
    private $quorum;
    private $servers = array();
    private $instances = array();
    function __construct(array $servers, $retryDelay = 200, $retryCount = 3)
    {
        $this->servers = $servers;
        $this->retryDelay = $retryDelay;
        $this->retryCount = $retryCount;
        $this->quorum  = min(count($servers), (count($servers) / 2 + 1));
    }
    public function lock($resource, $ttl)
    {
        $this->initInstances();
        $token = uniqid();
        $retry = $this->retryCount;
        do {
            $n = 0;
            $startTime = microtime(true) * 1000;
            foreach ($this->instances as $instance) {
                if ($this->lockInstance($instance, $resource, $token, $ttl)) {
                    $n++;
                }
            }
            # Add 2 milliseconds to the drift to account for Redis expires
            # precision, which is 1 millisecond, plus 1 millisecond min drift
            # for small TTLs.
            $drift = ($ttl * $this->clockDriftFactor) + 2;
            $validityTime = $ttl - (microtime(true) * 1000 - $startTime) - $drift;
            if ($n >= $this->quorum && $validityTime > 0) {
                return [
                    'validity' => $validityTime,
                    'resource' => $resource,
                    'token'    => $token,
                ];
            } else {
                foreach ($this->instances as $instance) {
                    $this->unlockInstance($instance, $resource, $token);
                }
            }
            // Wait a random delay before to retry
            $delay = mt_rand(floor($this->retryDelay / 2), $this->retryDelay);
            usleep($delay * 1000);
            $retry--;
        } while ($retry > 0);
        return false;
    }
    public function unlock(array $lock)
    {
        $this->initInstances();
        $resource = $lock['resource'];
        $token    = $lock['token'];
        foreach ($this->instances as $instance) {
            $this->unlockInstance($instance, $resource, $token);
        }
    }
    private function initInstances()
    {
        if (empty($this->instances)) {
            foreach ($this->servers as $server) {
                list($host, $port, $timeout) = $server;
                $redis = new \Redis();
                $redis->connect($host, $port, $timeout);
                $this->instances[] = $redis;
            }
        }
    }
    private function lockInstance($instance, $resource, $token, $ttl)
    {
        return $instance->set($resource, $token, ['NX', 'PX' => $ttl]);
    }
    private function unlockInstance($instance, $resource, $token)
    {
        $script = ' if redis.call("GET", KEYS[1]) == ARGV[1] then return redis.call("DEL", KEYS[1]) else return 0 end ';
        return $instance->eval($script, [$resource, $token], 1);
    }
}
?>

这里解释一下,数据存储在不一样的服务器上,加锁和解锁须要原子性操做,最后在解锁的时候,使用了lua的脚本实现。

应用 2:缓兵之计 —— 延时队列

Redis 的 list(列表) 数据结构经常使用来做为异步消息队列使用,使用rpush/lpush操做入队列, 使用 lpop 和 rpop 来出队列。

但是若是队列空了,客户端就会陷入 pop 的死循环,不停地 pop,没有数据,接着再 pop, 又没有数据。这就是浪费生命的空轮询。空轮询不但拉高了客户端的 CPU,redis 的 QPS 也 会被拉高,若是这样空轮询的客户端有几十来个,Redis 的慢查询可能会显著增多。

好的解决办法是那就是 blpop/brpop。

阻塞读在队列没有数据的时候,会当即进入休眠状态,一旦数据到来,则马上醒过来。消 息的延迟几乎为零。用 blpop/brpop 替代前面的 lpop/rpop,就完美解决了上面的问题。

锁冲突处理

上节课咱们讲了分布式锁的问题,可是没有提到客户端在处理请求时加锁没加成功怎么办。 通常有 3 种策略来处理加锁失败:

  • 直接抛出异常,通知用户稍后重试;
  • sleep 一会再重试;
  • 将请求转移至延时队列,过一会再试;

应用 3:四两拨千斤 —— HyperLogLog

这就是本节要引入的一个解决方案,Redis 提供了 HyperLogLog 数据结构就是用来解决 这种统计问题的。HyperLogLog 提供不精确的去重计数方案,虽然不精确可是也不是很是不 精确,标准偏差是 0.81%,这样的精确度已经能够知足上面的 UV 统计需求了。

HyperLogLog 数据结构是 Redis 的高级数据结构,它很是有用,可是使人感到意外的 是,使用过它的人很是少。

应用 4:层峦叠嶂 —— 布隆过滤器

布隆过滤器能够理解为一个不怎么精确的 set 结构,当你使用它的 contains 方法判断某 个对象是否存在时,它可能会误判。可是布隆过滤器也不是特别不精确,只要参数设置的合 理,它的精确度能够控制的相对足够精确,只会有小小的误判几率。

Redis4.0之后出现的。

应用 5:爱财如命 —— 漏斗限流

Redis 4.0 提供了一个限流 Redis 模块,它叫 redis-cell。该模块也使用了漏斗算法,并 提供了原子的限流指令。有了这个模块,限流问题就很是简单了。

cl.throttle laoqian:reply 15 30 60
1) (integer) 0 # 0 表示容许,1 表示拒绝 
2) (integer) 15 # 漏斗容量 capacity
3) (integer) 14 # 漏斗剩余空间 left_quota
4) (integer) -1 	# 若是拒绝了,须要多长时间后再试(漏斗有空间了,单位秒) 
5) (integer) 2 #多长时间后,漏斗彻底空出来(left_quota==capacity,单位秒)

应用 6:近水楼台 —— GeoHash

Redis 在 3.2 版本之后增长了地理位置 GEO 模块,意味着咱们可使用 Redis 来实现
摩拜单车「附近的 Mobike」、美团和饿了么「附近的餐馆」这样的功能了。

在使用 Redis 进行 Geo 查询时,咱们要时刻想到它的内部结构实际上只是一个 zset(skiplist)。经过 zset 的 score 排序就能够获得坐标附近的其它元素 (实际状况要复杂一 些,不过这样理解足够了),经过将 score 还原成坐标值就能够获得元素的原始坐标。

127.0.0.1:6379> geoadd company 116.48105 39.996794 juejin
(integer) 1
127.0.0.1:6379> geoadd company 116.514203 39.905409 ireader
(integer) 1
127.0.0.1:6379> geoadd company 116.489033 40.007669 meituan
(integer) 1
127.0.0.1:6379> geoadd company 116.562108 39.787602 jd 116.334255 40.027400 xiaomi 
(integer) 2

原理 1:鞭辟入里 —— 线程 IO 模型

Redis 是个单线程程序 。

也许你会怀疑高并发的 Redis 中间件怎么多是单线程。很抱歉,它就是单线程,你的 怀疑暴露了你基础知识的不足。莫要瞧不起单线程,除了 Redis 以外,Node.js 也是单线 程,Nginx 也是单线程,可是它们都是服务器高性能的典范。

Redis 单线程为何还能这么快?

由于它全部的数据都在内存中,全部的运算都是内存级别的运算。正由于 Redis 是单线 程,因此要当心使用 Redis 指令,对于那些时间复杂度为 O(n) 级别的指令,必定要谨慎使 用,一不当心就可能会致使 Redis 卡顿。

Redis 单线程如何处理那么多的并发客户端链接?

这个问题,有不少中高级程序员都没法回答,由于他们没听过多路复用这个词汇,不知 道 select 系列的事件轮询 API,没用过非阻塞 IO。

多路复用

事件轮询 API 就是用来解决这个问题的,最简单的事件轮询 API 是 select 函数,它是 操做系统提供给用户程序的 API。输入是读写描述符列表 read_fds & write_fds,输出是与之 对应的可读可写事件。同时还提供了一个 timeout 参数,若是没有任何事件到来,那么就最多 等待 timeout 时间,线程处于阻塞状态。一旦期间有任何事件到来,就能够当即返回。时间过 了以后仍是没有任何事件到来,也会当即返回。

拿到事件后,线程就能够继续挨个处理相应 的事件。处理完了继续过来轮询。因而线程就进入了一个死循环,咱们把这个死循环称为事 件循环,一个循环为一个周期。

由于咱们经过 select 系统调用同时处理多个通道描述符的读写事件,所以咱们将这类系 统调用称为多路复用 API。现代操做系统的多路复用 API 已经再也不使用 select 系统调用,而 改用 epoll(linux) kqueue(freebsd & macosx), 由于 select 系统调用的性能在描述符特别多时性能会很是差。它们使用起来可能在形式上略有差别,可是本质上都是差很少的,均可以使
用上面的伪代码逻辑进行理解。

服务器套接字 serversocket 对象的读操做是指调用 accept 接受客户端新链接。什么时候有新连 接到来,也是经过 select 系统调用的读事件来获得通知的。

指令队列

Redis 会将每一个客户端套接字都关联一个指令队列。客户端的指令经过队列来排队进行
顺序处理,先到先服务。

响应队列

Redis 一样也会为每一个客户端套接字关联一个响应队列。Redis 服务器经过响应队列来将 指令的返回结果回复给客户端。 若是队列为空,那么意味着链接暂时处于空闲状态,不须要 去获取写事件,也就是能够将当前的客户端描述符从 write_fds 里面移出来。等到队列有数据 了,再将描述符放进去。避免 select 系统调用当即返回写事件,结果发现没什么数据能够 写。出这种状况的线程会飙高 CPU。

定时任务

服务器处理要响应 IO 事件外,还要处理其它事情。好比定时任务就是很是重要的一件事。若是线程阻塞在 select 系统调用上,定时任务将没法获得准时调度。那 Redis 是如何解 决这个问题的呢?

Redis 的定时任务会记录在一个称为最小堆的数据结构中。这个堆中,最快要执行的任 务排在堆的最上方。在每一个循环周期,Redis 都会将最小堆里面已经到点的任务当即进行处理。处理完毕后,将最快要执行的任务还须要的时间记录下来,这个时间就是 select 系统调用的 timeout 参数。由于 Redis 知道将来 timeout 时间内,没有其它定时任务须要处理,因此 能够安心睡眠 timeout 的时间。

Nginx 和 Node 的事件处理原理和 Redis 也是相似的。

原理 2:交头接耳 —— 通讯协议

Redis 的做者认为数据库系统的瓶颈通常不在于网络流量,而是数据库自身内部逻辑处 理上。因此即便 Redis 使用了浪费流量的文本协议,依然能够取得极高的访问性能。Redis 将全部数据都放在内存,用一个单线程对外提供服务,单个节点在跑满一个 CPU 核心的情 况下能够达到了 10w/s 的超高 QPS。

RESP(Redis Serialization Protocol):RESP 是 Redis 序列化协议的简写。它是一种直观的文本协议,优点在于实现异常简 单,解析性能极好。

Redis 协议将传输的结构数据分为 5 种最小单元类型,单元结束时统一加上回车换行符
号\r\n。

原理 3:未雨绸缪 —— 持久化

Redis 的持久化机制有两种,第一种是快照,第二种是 AOF 日志。快照是一次全量备份,AOF 日志是连续的增量备份。

快照是内存数据的二进制序列化形式,在存储上很是紧凑,而 AOF 日志记录的是内存数据修改的指令记录文本。AOF 日志在长期的运行过程当中会 变的无比庞大,数据库重启时须要加载 AOF 日志进行指令重放,这个时间就会无比漫长。 因此须要按期进行 AOF 重写,给 AOF 日志进行瘦身。

快照原理

咱们知道 Redis 是单线程程序,这个线程要同时负责多个客户端套接字的并发读写操做 和内存数据结构的逻辑读写。

在服务线上请求的同时,Redis 还须要进行内存快照,内存快照要求 Redis 必须进行文 件 IO 操做,可文件 IO 操做是不能使用多路复用 API。

这意味着单线程同时在服务线上的请求还要进行文件 IO 操做,文件 IO 操做会严重拖 垮服务器请求的性能。还有个重要的问题是为了避免阻塞线上的业务,就须要边持久化边响应 客户端请求。持久化的同时,内存数据结构还在改变,好比一个大型的 hash 字典正在持久 化,结果一个请求过来把它给删掉了,还没持久化完呢,这尼玛要怎么搞?

那该怎么办呢? Redis 使用操做系统的多进程 COW(Copy On Write) 机制来实现快照持久化, 这个机制 颇有意思,也不多人知道。

fork(多进程)

Redis 在持久化时会调用 glibc 的函数 fork 产生一个子进程,快照持久化彻底交给子进 程来处理,父进程继续处理客户端请求。子进程刚刚产生时,它和父进程共享内存里面的代 码段和数据段。这时你能够将父子进程想像成一个连体婴儿,共享身体。这是 Linux 操做系统的机制,为了节约内存资源,因此尽量让它们共享起来。在进程分离的一瞬间,内存的增加几乎没有明显变化。

子进程作数据持久化,它不会修改现有的内存数据结构,它只是对数据结构进行遍历读
取,而后序列化写到磁盘中。可是父进程不同,它必须持续服务客户端请求,而后对内存
数据结构进行不间断的修改。

这个时候就会使用操做系统的 COW 机制来进行数据段页面的分离。数据段是由不少操 做系统的页面组合而成,当父进程对其中一个页面的数据进行修改时,会将被共享的页面复 制一份分离出来,而后对这个复制的页面进行修改。这时子进程相应的页面是没有变化的, 仍是进程产生时那一瞬间的数据。

随着父进程修改操做的持续进行,愈来愈多的共享页面被分离出来,内存就会持续增 长。可是也不会超过原有数据内存的 2 倍大小。另一个 Redis 实例里冷数据占的比例往 往是比较高的,因此不多会出现全部的页面都会被分离,被分离的每每只有其中一部分页 面。每一个页面的大小只有 4K,一个 Redis 实例里面通常都会有成千上万的页面。

子进程由于数据没有变化,它能看到的内存里的数据在进程产生的一瞬间就凝固了,再 也不会改变,这也是为何 Redis 的持久化叫「快照」的缘由。接下来子进程就能够很是安 心的遍历数据了进行序列化写磁盘了。

AOF(追加日志) 原理

AOF 日志存储的是 Redis 服务器的顺序指令序列,AOF 日志只记录对内存进行修改的 指令记录。

假设 AOF 日志记录了自 Redis 实例建立以来全部的修改性指令序列,那么就能够经过 对一个空的 Redis 实例顺序执行全部的指令,也就是「重放」,来恢复 Redis 当前实例的内 存数据结构的状态。

Redis 会在收到客户端修改指令后,先进行参数校验,若是没问题,就当即将该指令文 本存储到 AOF 日志中,也就是先存到磁盘,而后再执行指令。这样即便遇到突发宕机,已 经存储到 AOF 日志的指令进行重放一下就能够恢复到宕机前的状态。

Redis 在长期运行的过程当中,AOF 的日志会越变越长。若是实例宕机重启,重放整个 AOF 日志会很是耗时,致使长时间 Redis 没法对外提供服务。因此须要对 AOF 日志瘦身。

AOF重写

Redis 提供了 bgrewriteaof 指令用于对 AOF 日志进行瘦身。其原理就是开辟一个子进 程对内存进行遍历转换成一系列 Redis 的操做指令,序列化到一个新的 AOF 日志文件中。 序列化完毕后再将操做期间发生的增量 AOF 日志追加到这个新的 AOF 日志文件中,追加 完毕后就当即替代旧的 AOF 日志文件了,瘦身工做就完成了。

fsync

AOF 日志是以文件的形式存在的,当程序对 AOF 日志文件进行写操做时,其实是将 内容写到了内核为文件描述符分配的一个内存缓存中,而后内核会异步将脏数据刷回到磁盘的。

这就意味着若是机器忽然宕机,AOF 日志内容可能尚未来得及彻底刷到磁盘中,这个 时候就会出现日志丢失。那该怎么办?

Linux 的 glibc 提供了 fsync(int fd) 函数能够将指定文件的内容强制从内核缓存刷到磁 盘。只要 Redis 进程实时调用 fsync 函数就能够保证 aof 日志不丢失。可是 fsync 是一个 磁盘 IO 操做,它很慢!若是 Redis 执行一条指令就要 fsync 一次,那么 Redis 高性能的 地位就不保了。

因此在生产环境的服务器中,Redis 一般是每隔 1s 左右执行一次 fsync 操做,周期 1s 是能够配置的。这是在数据安全性和性能之间作了一个折中,在保持高性能的同时,尽量 使得数据少丢失。

原理 4:雷厉风行 —— 管道

大多数同窗一直以来对 Redis 管道有一个误解,他们觉得这是 Redis 服务器提供的一种 特别的技术,有了这种技术就能够加速 Redis 的存取效率。可是实际上 Redis 管道 (Pipeline) 自己并非 Redis 服务器直接提供的技术,这个技术本质上是由客户端提供的, 跟服务器没有什么直接的关系。

当咱们使用客户端对 Redis 进行一次操做时,以下图所示,客户端将请求传送给服务器,服务器处理完毕后,再将响应回复给客户端。这要花费一个网络数据包来回的时间。

两个连续的写操做和两个连续的读操做总共只会花费一次网络来回,就比如连续的 write 操做合并了,连续的 read 操做也合并了同样。

这即是管道操做的本质,服务器根本没有任何区别对待,仍是收到一条消息,执行一条 消息,回复一条消息的正常的流程。客户端经过对管道中的指令列表改变读写顺序就能够大 幅节省 IO 时间。管道中指令越多,效果越好。

<?php
 $redis = new Redis();   
$redis->connect('10.1.132.86', 6379); 
# 使用管道 
$pipe = $redis->multi(Redis::PIPELINE);   
for ($i = 0; $i <  10000; $i++) {   
    $pipe->set("key::$i", str_pad($i, 4, '0', 0));   
    $pipe->get("key::$i");   
}   
      
$replies = $pipe->exec(); 
echo " "; print_r($replies);

原理 5:同舟共济 —— 事务

为了确保连续多个操做的原子性,一个成熟的数据库一般都会有事务支持,Redis 也不 例外。Redis 的事务使用很是简单,不一样于关系数据库,咱们无须理解那么多复杂的事务模 型,就能够直接使用。不过也正是由于这种简单性,它的事务模型很不严格,这要求咱们不 能像使用关系数据库的事务同样来使用 Redis。

上面的指令演示了一个完整的事务过程,全部的指令在 exec 以前不执行,而是缓存在 服务器的一个事务队列中,服务器一旦收到 exec 指令,才开执行整个事务队列,执行完毕 后一次性返回全部指令的运行结果。由于 Redis 的单线程特性,它不用担忧本身在执行队列 的时候被其它指令打搅,能够保证他们能获得的「原子性」执行。

开启事务

try {
    $redis = new Redis();
    $redis->connect('192.168.75.132', 6379);
    //开启事务
    $redis->multi();
    $redis->setex('keyTest', 60, 1);
    $redis->get('keyTest');
    $redis->incr('keyTest');
    $redis->get('keyTest');
    //执行事务
    $ret = $redis->exec();
    print_r($ret);
} catch (Exception $e){
    echo $e->getMessage();
}

结束事务

try {
    $redis = new Redis();
    $redis->connect('192.168.75.132', 6379);
    //先设置缓存keyTest为1
    $redis->setex('keyTest', 60, 1);
    //开启事务
    $redis->multi();
    $redis->setex('keyTest', 60, 10);
    $redis->get('keyTest');
    $redis->incr('keyTest');
    $redis->get('keyTest');
    //取消事务
    $redis->discard();
    $ret = $redis->get('keyTest');
    var_dump($ret);
    //查看keyTest
} catch (Exception $e){
    echo $e->getMessage();
}

原理 6:小道消息 —— PubSub

前面咱们讲了 Redis 消息队列的使用方法,可是没有提到 Redis 消息队列的不足之
处,那就是它不支持消息的多播机制。

消息多播放

消息多播容许生产者生产一次消息,中间件负责将消息复制到多个消息队列,每一个消息
队列由相应的消费组进行消费。它是分布式系统经常使用的一种解耦方式,用于将多个消费组的
逻辑进行拆分。支持了消息多播,多个消费组的逻辑就能够放到不一样的子系统中。

若是是普通的消息队列,就得将多个不一样的消费组逻辑串接起来放在一个子系统中,进
行连续消费。

PubSub

为了支持消息多播,Redis 不能再依赖于那 5 种基本数据类型了。它单独使用了一个模 块来支持消息多播,这个模块的名字叫着 PubSub,也就是 PublisherSubscriber,发布者订阅 者模型。

PubSub 缺点

PubSub 的生产者传递过来一个消息,Redis 会直接找到相应的消费者传递过去。若是一 个消费者都没有,那么消息直接丢弃。若是开始有三个消费者,一个消费者忽然挂掉了,生 产者会继续发送消息,另外两个消费者能够持续收到消息。可是挂掉的消费者从新连上的时 候,这断连期间生产者发送的消息,对于这个消费者来讲就是完全丢失了。

若是 Redis 停机重启,PubSub 的消息是不会持久化的,毕竟 Redis 宕机就至关于一个 消费者都没有,全部的消息直接被丢弃。

正是由于 PubSub 有这些缺点,它几乎找不到合适的应用场景。

订阅端代码以下:
<?php
$redis = new Redis();
$redis->connect('localhost', 6379);
$redis->subscribe(['order'], function ($redis, $chan, $msg) {
    var_dump($redis);
    var_dump($chan);
    var_dump($msg);
});

值得一提的是subscribe函数的第一个参数是一个数组,这意味着能够订阅多个发布端,回调函数里面有3个参数,第一个是redis实例,第二个是订阅的频道,第三个是订阅的消息内容,在命令下运行该文件就会进入等待发布端发布消息的阻塞状态!

发布端代码以下:
<?php
$redis = new Redis();
$redis->connect('localhost', 6379);
$order = [
    'id' => 1,
    'name' => '小米6',
    'price' => 2499,
    'created_at' => '2017-07-14'
];
$redis->publish("order", json_encode($order));

在命令行下运行该代码,就会发现订阅端那边输出了消息:

class Redis#1 (1) {
  public $socket =>
  resource(5) of type (Redis Socket Buffer)
}
string(5) "order"
string(70) "{"id":1,"name":"\u5c0f\u7c736","price":2499,"created_at":"2017-07-14"}"

原理 7:开源节流 —— 小对象压缩

Redis 是一个很是耗费内存的数据库,它全部的数据都放在内存里。若是咱们不注意节约使用内存,Redis 就会由于咱们的无节制使用出现内存不足而崩溃。Redis 做者为了优化数 据结构的内存占用,也苦心孤诣增长了很是多的优化点,这些优化也是以牺牲代码的可读性 为代价的,可是毫无疑问这是很是值得的,尤为像 Redis 这种数据库。

小对象压缩存储 (ziplist)

若是 Redis 内部管理的集合数据结构很小,它会使用紧凑存储形式压缩存储。

这就比如 HashMap 原本是二维结构,可是若是内部元素比较少,使用二维结构反而浪 费空间,还不如使用一维数组进行存储,须要查找时,由于元素少进行遍历也很快,甚至可 以比HashMap 自己的查找还要快。

Redis 的 ziplist 是一个紧凑的字节数组结构,以下图所示,每一个元素之间都是紧挨着
的。

内存回收机制

Redis 并不老是能够将空闲内存当即归还给操做系统。

若是当前 Redis 内存有 10G,当你删除了 1GB 的 key 后,再去观察内存,你会发现 内存变化不会太大。缘由是操做系统回收内存是以页为单位,若是这个页上只要有一个 key 还在使用,那么它就不能被回收。Redis 虽然删除了 1GB 的 key,可是这些 key 分散到了 不少页面中,每一个页面都还有其它 key 存在,这就致使了内存不会当即被回收。

不过,若是你执行 flushdb,而后再观察内存会发现内存确实被回收了。缘由是全部的 key 都干掉了,大部分以前使用的页面都彻底干净了,会当即被操做系统回收。

Redis 虽然没法保证当即回收已经删除的 key 的内存,可是它会重用那些还没有回收的空 闲内存。这就比如电影院里虽然人走了,可是座位还在,下一波观众来了,直接坐就行。而 操做系统回收内存就比如把座位都给搬走了。

内存分配算法

内存分配是一个很是复杂的课题,须要适当的算法划份内存页,须要考虑内存碎片,需
要平衡性能和效率。

Redis 为了保持自身结构的简单性,在内存分配这里直接作了甩手掌柜,将内存分配的 细节丢给了第三方内存分配库去实现。目前 Redis 可使用 jemalloc(facebook) 库来管理内 存,也能够切换到 tcmalloc(google)。由于 jemalloc 相比 tcmalloc 的性能要稍好一些,因此 Redis 默认使用了 jemalloc。

info memory

原理 8:有备无患 —— 主从同步

不少企业都没有使用到 Redis 的集群,可是至少都作了主从。有了主从,当 master 挂 掉的时候,运维让从库过来接管,服务就能够继续,不然 master 须要通过数据恢复和重启 的过程,这就可能会拖很长的时间,影响线上业务的持续服务。

在了解 Redis 的主从复制以前,让咱们先来理解一下现代分布式系统的理论基石—— CAP 原理。

CAP 原理

CAP 原理就比如分布式领域的牛顿定律,它是分布式存储的理论基石。自打 CAP 的论 文发表以后,分布式存储中间件犹如雨后春笋般一个一个涌现出来。

  • C - Consistent ,一致性
  • A - Availability ,可用性
  • P - Partition tolerance ,分区容忍性

分布式系统的节点每每都是分布在不一样的机器上进行网络隔离开的,这意味着必然会有
网络断开的风险,这个网络断开的场景的专业词汇叫着「网络分区」。

在网络分区发生时,两个分布式节点之间没法进行通讯,咱们对一个节点进行的修改操
做将没法同步到另一个节点,因此数据的「一致性」将没法知足,由于两个分布式节点的
数据再也不保持一致。除非咱们牺牲「可用性」,也就是暂停分布式节点服务,在网络分区发
生时,再也不提供修改数据的功能,直到网络情况彻底恢复正常再继续对外提供服务。

一句话归纳 CAP 原理就是,网络分区发生时,一致性和可用性两难全。

最终一致性

Redis 的主从数据是异步同步的,因此分布式的 Redis 系统并不知足「一致性」要求。 当客户端在 Redis 的主节点修改了数据后,当即返回,即便在主从网络断开的状况下,主节 点依旧能够正常对外提供修改服务,因此 Redis 知足「可用性」。

Redis 保证「最终一致性」,从节点会努力追赶主节点,最终从节点的状态会和主节点 的状态将保持一致。若是网络断开了,主从节点的数据将会出现大量不一致,一旦网络恢 复,从节点会采用多种策略努力追遇上落后的数据,继续尽力保持和主节点一致。

主从同步

Redis 同步支持主从同步和从从同步,从从同步功能是 Redis 后续版本增长的功能,为 了减轻主库的同步负担。后面为了描述上的方便,统一理解为主从同步。

增量同步

Redis 同步的是指令流,主节点会将那些对本身的状态产生修改性影响的指令记录在本 地的内存 buffer 中,而后异步将 buffer 中的指令同步到从节点,从节点一边执行同步的指 令流来达到和主节点同样的状态,一遍向主节点反馈本身同步到哪里了 (偏移量)。

由于内存的 buffer 是有限的,因此 Redis 主库不能将全部的指令都记录在内存 buffer 中。Redis 的复制内存 buffer 是一个定长的环形数组,若是数组内容满了,就会从头开始覆 盖前面的内容。

若是由于网络情况很差,从节点在短期内没法和主节点进行同步,那么当网络情况恢 复时,Redis 的主节点中那些没有同步的指令在 buffer 中有可能已经被后续的指令覆盖掉 了,从节点将没法直接经过指令流来进行同步,这个时候就须要用到更加复杂的同步机制 — — 快照同步。

快照同步

快照同步是一个很是耗费资源的操做,它首先须要在主库上进行一次 bgsave 将当前内 存的数据所有快照到磁盘文件中,而后再将快照文件的内容所有传送到从节点。从节点将快照文件接受完毕后,当即执行一次全量加载,加载以前先要将当前内存的数据清空。加载完毕后通知主节点继续进行增量同步。

在整个快照同步进行的过程当中,主节点的复制 buffer 还在不停的往前移动,若是快照同 步的时间过长或者复制 buffer 过小,都会致使同步期间的增量指令在复制 buffer 中被覆 盖,这样就会致使快照同步完成后没法进行增量复制,而后会再次发起快照同步,如此极有 可能会陷入快照同步的死循环。

因此务必配置一个合适的复制 buffer 大小参数,避免快照复制的死循环。