Redis核心原理与应用实践
在很多场景下都会使用Redis,但是到了深层次的时候就了解的不是那么深刻,以至于在面试的时候经常会遇到卡壳的现象,学习知识要做到系统和深入,不要把Redis想象的过于复杂,和Mysql一样,是个读取数据的软件。
有一个理解是Redis是key value缓存服务器,更多的优点在于对value的操作更加丰富。
安装
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。
键值对
set name codehole
get name
批量键值对
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 会报错。
set codehole 9223372036854775807
list (列表)
Redis 的列表相当于 Java 语言里面的 LinkedList,注意它是链表而不是数组。这意味着 list 的插入和删除操作非常快,时间复杂度为 O(1),但是索引定位很慢,时间复杂度为 O(n),这点让人非常意外。
当列表弹出了最后一个元素之后,该数据结构自动被删除,内存被回收。
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 大小参数,避免快照复制的死循环。
来源:oschina
链接:https://my.oschina.net/u/4373202/blog/3236205