Redis——进阶篇

发布订阅模式

列表list使用发布订阅模式的局限性

之前说可以通过队列的rpush和lpop可以实现消息队列,但是消费者需要不停地调用lpop查看list中是否有等待处理的消息。为了减少通信消耗,可以sleep()一段时间再调用lpop,如此会有两个问题:

  1. 如果生产者生产消息的速度远大于消费者消费信息的速度,List会占用大量的内存。
  2. 消息的实时性降低。

改变思路,List提供一个阻塞式的出栈命令:blpop,没有任何元素可以弹出的时候,连接会被阻塞。

基于此方式实现的订阅发布,不支持一对多的消息分发。

发布订阅模式

除了通过List实现消息队列之外,Redis还提供了一组命令实现pub/sub模式。

这种方式,发送者和接收者没有直接关联,接收者也不需要持续尝试获取消息。

订阅频道

首先,我们有很多的频道(chennel),我们也可以把这写频道理解为queue。订阅者可以订阅一个或多个频道。消息的发布者可以给指定的频道发布消息。只要有消息到达了频道,所有订阅了这个频道的订阅者都会收到这条消息。

需要注意的是,发出去的消息不会被持久化,因为它已经从队列里面移除了,所以消费者只能收到它开始订阅这个频道之后发布的消息。

下面可以看一下发布订阅命令的使用方法。

订阅者订阅:可以一次订阅多个,比如订阅3个频道

subscribe channel-1 channel-2 channel-3

发布者可以向指定频道发布消息(并不支持一次向多个频道发送消息)

publish channel-1 kingTest

当然也提供了取消订阅命令(不能在订阅状态下使用)

unsubscribe channel-1

根据规则(Pattern)订阅频道

支持?和*占位符。?代表一个字符,*代表0个或多个字符。

消费端一,关注新闻:

psubscribe news*

生产者,发布消息

publish news-weather rain
publish news-sport NBA
publish news-music song

可以看到发布的消息都是存在news开头的信息,消费端都将获取这些消息。

Redis事务

为什么要使用事务

我们知道Redis的单个命令是原子性的,如果涉及到多个命令的时候,需要把多个命令作为一个不可分割的处理序列,就需要用到事务。

例如我们之前说的用setnx实现分布式锁,我们先set然后设置expire,防止del发生异常的时候锁不会被释放,业务处理完了以后再del,这三个动作我们就希望他们作为一组命令执行。

Redis的事务有两个特点:

  1. 按进入队列的顺序执行;
  2. 不会受到其他客户端的请求的影响。

Redis的事务涉及到四个命令:MULTI(开启事务),EXEC(执行事务),DISCARD(取消事务),WATCH(监视)

事务的用法

转账场景

A给B各有1000元,A需要给B转账500元。那么A账户少了500元,B账户多了500元。这一系列操作必须保证原子性。

set A 1000
set B 1000

multi
decrby A 500
incrby B 500
exec

get A
get B

通过multi的命令开启事务。事务不能嵌套,多个multi命令效果一样。

multi执行后,客户端可以继续向服务器发送任意多条命令,这些命令不会立即被执行,而是被放到一个队列中,当exec命令被调用时,所有队列中的命令才会被执行。

通过exec的命令执行事务。如果没有执行exec,所有的命令都不会被执行。

如果中途不想执行事务了呢?可以调用discard可以清空事务队列,放弃执行。

multi
set k1 1
set k2 2
set k3 3
discard

watch命令

在Redis中还提供了一个watch命令。

它可以为Redis事务提供CAS乐观锁行为(Check and Set / Compare and Swap),也就是多个线程更新变量的时候,会跟原值做比较,只有它没有被其他线程修改的情况下,才更新成新的值。

我们可以用watch监视一个或多个Key,如果开启事务之后,至少有一个被监视key键在exec执行之前被修改了,那么整个事务都会被取消(key提前过期除外)。可以用unwatch取消。

# client1
set balance 1000
watch balance
multi
incrby balance 100

# client2
decrby balance 100

# client1
exec
get balance

事务可能遇到的问题

我们将事务执行遇到的问题分成两种,一种是在执行exec之前发生错误,一种是在执行exec之后发生错误。

在执行exec之前发生错误

入队的命令存在语法错误,包括参数数量,参数名等等(编译器错误)。

在这种情况下事务会被拒绝执行,也就是队列中所有的命令都不会得到执行。

在执行exec之后发生错误

比如类型错误,对String使用了hash的命令,这是一种运行时错误。

这种情况,发现一个问题,错误发生之前到multi命令之后的命令,是执行成功的。也就是说在这种发生了运行时异常的情况下,只有错误的命令没有被执行,但是其他命令没有收到影响。

这显然不符合我们对原子性的定义,也就是我们没办法用Redis这种事务机制来实现原子性,保证数据的一致性。

Lua脚本

Lua是一种轻量级脚本语言,它是用C语言编写的,跟数据的存储过程有点类似。使用Lua脚本来执行Redis命令的好处:

  1. 一次发送多个命令,减少网络开销。
  2. Redis会将整个脚本作为一个整体执行,不会被其他请求打断,保持原子性。
  3. 对于复杂的组合命令,我们可以放在文件中,可以实现程序之间的命令集复用。

在Redis中调用Lua脚本

使用eval方法,语法格式:

eval lua-script key-num [key1 key2 key3 ....] [value1 value2 value3 ....]
  • eval代表执行Lua语言的命令
  • lua-script代表Lua语言脚本内容
  • key-num表示参数中有多少个key,需要注意的是Redis中key是从1开始的,如果没有key的参数,那么写0.
  • [key1 key2 key3...]是key作为参数传递给Lua语言,也可以不填,但是需要和key-num的个数对应
  • [value1 value2 value3...]这些参数传递给Lua语言,他们是可填可不填,与key1...对应。

示例,返回一个字符串,0个参数

eval "return 'Hello World'" 0

在Lua脚本中调用Redis命令

使用redis.call(command,key [param1,param2...])进行操作。语法格式:

eval "redis.call('set',KEYS[1],ARGV[1])" 1 lua-key lua-value
  • command是命令,包括set,get,del等
  • key是被操作的键
  • param1,param2...代表给key的参数。

注意与Java不同的是,定义只有形参,调用只有实参。

Lua是在调用时用key表示形参,argv表示参数值(实参)

设置键值对

在Redis中调用Lua脚本执行Redis命令

eval "return redis.call('set',KEYS[1],ARGV[1])" 1 test 2673
get test

在redis-cli中直接写Lua脚本不够方便,也不能实现编辑和复用,通常我们会把脚本放在文件里面,然后执行这个文件。

在Redis中调用Lua脚本文件中的命令

创建Lua脚本文件:

cd /home/soft/redis5.0.5/src
vim king.lua

Lua脚本内容,先设置,再取值:

redis.call('set','king','niubi')
return redis.call('get','king')

在Redis客户端中调用Lua脚本

redis-cli --eval king.lua 0

缓存Lua脚本

为什么要缓存

在脚本比较长的情况下,如果每次调用脚本都需要把整个脚本给Redis服务端,会产生比较大的网络开销。为了解决这个问题,Redis提供了EVALSHA命令,允许开发者通过脚本内容的SHA1摘要来执行脚本。

如何缓存

Redis在执行script load命令时会计算脚本SHA1摘要并记录在脚本缓存中,执行EVALSHA命令时Redis会根据提供的摘要从脚本缓存中查找对应的脚本内容,如果找到了则执行脚本,否则会返回错误:"NOSCRIPT No matching script. Please use EVAL."

自乘案例

Redis有incrby这样的自增命令,但是没有自乘,比如乘以3,乘以4等。

我们可以写一个自乘运算,让它乘以后面的参数:

local curVal = redis.call("get", KEYS[1])
if curVal == false then
    curVal = 0
else
    curVal = tonumber(curVal)
end
curVal = curVal * tonumber(ARGV[1])
redis.call("set", KEYS[1], curVal)
return curVal

把这个脚本变成单行,语句之间使用分号隔开

local curVal = redis.call("get", KEYS[1]); if curVal == false then curVal = 0 else curVal = tonumber(curVal) end; curVal = curVal * tonumber(ARGV[1]); redis.call("set", KEYS[1], curVal); return curVal

script load '命令'

script load 'local curVal = redis.call("get", KEYS[1]); if curVal == false then curVal = 0 else curVal = tonumber(curVal) end; curVal = curVal * tonumber(ARGV[1]); redis.call("set", KEYS[1], curVal); return curVal'

此时会返回一个SHA1摘要ID:"be4f93d8a5379e5e5b768a74e77c8a4eb0434441"

调用:

set num 2
evalsha be4f93d8a5379e5e5b768a74e77c8a4eb0434441 1 num 6

脚本超时

Redis的指令执行本身时单线程的,这个线程还要执行客户端的Lua脚本,如果Lua脚本执行超时或者陷入了死循环,是不是没有办法为客户端提供服务了呢?

为了方式某个脚本执行时间过长导致Redis无法提供服务,Redis提供了lua-time-limit参数限制脚本的最长运行时间,默认为5秒。

redis.conf配置文件中 :lua-time-limit 5000

当脚本运行时间超过这一限制后,Redis将开始接受其他命令但不会执行(以确保脚本原子性,因为此时脚本并没有被终止),而是会返回“BUSY”错误。

Redis提供了一个script kill命令来终止脚本的执行。新开一个客户端,执行:

script kill

如果当前执行的Lua脚本对Redis的数据进行了修改(set,del等),那么通过script kill命令是不能终止脚本运行的。

要保证脚本运行的原子性,如果脚本执行了一部分终止,那就违背了脚本原子性的要求。最终要保证脚本要么都执行,要么都不执行。

遇到这种情况,脚本干不掉,只能通过shutdown nosave命令来强行终止redis。

Redis为什么会这么快

Redis到底有多快?

官方提供测试用例,命令如下

cd /home/soft/redis-5.0.5/src
redis-benchmark -t set,lpush -n 100000 -q

执行结果

🐂吧。每秒处理10w+次set请求和lphsh请求。

redis-benchmark -n 100000 -q script load "redis.call('set','foo','bar')"

 每秒10.7w次lua脚本调用。

不禁想问为什么这么快?

总结:

  1. 纯内存结构
  2. 单线程
  3. 多路复用

内存

KV结构的内存数据库,时间复杂度O(1)。

单线程

单线程有什么好处

  1. 没有创建线程、销毁线程带来的消耗
  2. 避免了上线文切换导致的CPU消耗
  3. 避免了线程之间带来的竞争问题,例如加锁释放锁死锁等问题

异步非阻塞

异步非阻塞I/O,多路复用处理并发连接。

Redis为什么是单线程的

因为,单线程已经完全够用了,CPU不是Redis的瓶颈。Redis的瓶颈最有可能是机器内存或者网络带宽。既然单线程容易实现,而且CPU不会成为瓶颈,那么顺理成章了

单线程为什么这么快

因为Redis是基于内存的操作,我们先从内存开始说起。

虚拟存储器

名词解释:主存:内存;辅存:硬盘

计算机主存可以看作一个由M个连续的字节大小的单元组成的数组,每个字节由一个唯一的地址,这个地址叫做物理地址(PA)。早期计算机中,如果CPU需要内存,使用物理寻址,直接访问主存储器。

这种方式有几个弊端:

  1. 在多用户多任务操作系统中,所有的进程共享主存,如果每个进程都独占一块物理地址空间,主存很快就会被用完。我们希望在不同的时刻,不同的进程可以共用同一块物理地址空间。
  2. 如果所有进程都是直接访问物理内存,那么一个进程就可以修改其他进程的内存,导致物理地址空间被破坏,程序运行就会出现异常。

为了解决这些问题,我们就想了一个办法,在CPU和主存之间增加一个中间层。CPU不再使用物理地址访问,而是访问一个虚拟地址,由这个中间层把地址转换成物理地址,最终获得数据。这个中间层就叫做虚拟存储器(Virtual Memory)。

具体的操作如下所示:

在每一个进程开始创建的时候,都会分配一段虚拟地址,然后通过虚拟地址和物理地址的映射来获取真实数据,这样进程就不会直接接触到物理地址,甚至不知道自己调用的哪块物理地址的数据。

目前,大多数操作系统都使用了虚拟内存,如Windows系统的虚拟内存、Linux系统的交换空间等等。Windows的虚拟内存是磁盘空间的一部分。

cat /proc/cpuinfo

总结:引入虚拟内存,可以提供更大的地址空间,并且地址空间是连续的,是的程序编写、链接更加简单。并且可以对物理内存进行隔离,不同的进程操作互不影响。还可以通过把同一块物理内存映射到不同的虚拟地址空间实现内存共享。

用户空间和内核空间

为了避免用户进程直接操作内核,保证内核安全,操作系统将虚拟内存划分为两部分,一部分是内核空间,一部分是用户空间。

内核是操作系统的核心,独立于普通的应该用程序,可以访问受保护的内存空间,也有访问底层硬件设备的权限。

内核空间中存放的是内核代码和数据,而进程的用户空间中存放的是用户程序的代码和数据。不管是内核空间还是用户空间,它们都处于虚拟空间中,都是对物理地址的映射。

在Linux系统中,内核进程和用户进程所占的虚拟内存比例是1:3 。

当进程运行在内核空间时就处于内核态,而进程运行在用户空间时则处于用户态。

进程在内核空间以执行任意命令,调用系统的一切资源;在用户空间只能执行简单的运算,不能直接调用系统资源,必须通过系统接口(又称system call),才能向内核发出指令。

us:代表CPU消耗在User space的时间百分比;

sy:代表CPU消耗在Kernel space的时间百分比。

进程切换(上下文切换)

多任务操作系统是怎么实现运行远大于CPU数量的任务个数的?当然,这些任务实际上并不是真的在同时运行,而是因为系统通过时间片分片算法,在很短的时间内,将CPU轮流分配给它们,造成多任务同时运行的错觉。

为了控制进程的执行,内核必须有能力挂起正在CPU上运行的进程,并恢复以前挂起的某个进程的执行。这种行为被称为进程切换。

什么叫上下文?

在每个任务运行前,CPU都需要知道任务从哪里加载、又从哪里开始运行,也就是说,需要系统事先设置好CPU寄存器和程序计数器,这个叫做CPU的上下文。

而这些保存下来的上下文,会存储在系统内核中,并在任务重新调度执行时再次加载进来。这样就能保证任务原来的状态不受影响,让任务看起来还在连续运行。

在切换上下文的时候,需要完成一系列的工作,这是一个很消耗资源的操作。

进程的阻塞

正在运行的进程由于提出系统服务请求(如I/O操作),但因为某种原因未得到操作系统的立即响应,该进程只能把自己变成阻塞状态,等待响应的时间出现后才被唤醒。进程在阻塞状态不占用CPU资源。

文件描述符FD

Linux系统将所有设备都当作文件来处理,而Linux用文件描述符来标识每个文件对象。

文件描述符(File Descriptor)是内核为了高效管理已被打开的文件所创建的索引,用于指向被打开的文件,所有执行I/O操作的系统调用都通过文件描述符;文件描述符是一个简单的非负整数,用以表明每个被进程打开的文件。

Linux系统里面有三个标准文件描述符。

  • 0:标准输入(键盘)
  • 1:标准输出(显示器)
  • 2:标准错误输出(显示器)

传统I/O数据拷贝

以读操作为例:

当应用程序执行read系统调用读取文件描述符(FD)的时候,如果这块数据已经存在与用户进程的页内存中,就直接从内存中读取数据。如果数据不存在,则先将数据从磁盘加载到内核缓冲区中,在从内核缓冲区拷贝到用户进程的页内存中。(两次拷贝,两次user和kernel的上下文切换)。

I/O的阻塞到底阻塞在哪里?

Blocking I/O

当使用read或write对某个文件描述符进行过读写时,如果当前FD不可读,系统就不会对其他的操作做出响应。从设备复制数据到内核缓冲区时阻塞的,从内存缓冲区拷贝到用户空间,也是阻塞的,知道copy complete,内核返回结果,用户进程才接触block的状态。

为了解决阻塞的问题,我们有几个思路。

  1. 在服务端创建多个线程或者使用线程池,但是在高并发的情况下需要的线程会很多,系统无法承受,而且创建和释放线程都需要消耗资源。
  2. 由请求方定期轮询,在数据准备完毕后再从内核缓存缓冲区复制数据到用户空间(非阻塞式I/O),这种方式会存在一定的延迟。

能不能用一个线程处理多个客户端请求?

I/O多路复用(I/O Multiplexing)

  • I/O:指的是网络I/O。
  • 多路:指的是多个TCP连接(Socket或Channel)
  • 复用:指的是复用一个或多个线程

它的基本原理就是不再由应用程序自己监视连接,而是由内核替应用程序监视文件描述符。

客户端在操作的时候,会产生具有不同事件类型的socket。在服务端,I/O多路复用程序(I/O Multiplexing Module)会把消息放入队列中,然后通过文件事件分派器(File event Dispatcher),转发到不同的事件处理器中。

多路复用有很多的实现,以select为例,当用户进程调用了多路复用器,进程会被阻塞。内核会监视多路复用器负责的所有socket,当任何一个socket的数据准备好了,多路复用器就会返回。这时候用户进程再调用read操作,把数据从内核缓冲区拷贝到用户空间。

所以,I/O多路复用的特点是通过一种机制一个进程能同时等待多个文件描述符,而这些文件描述符(套接字描述符)其中的任意一个进入读就绪(readable)状态,select()函数就可以返回。

Redis的多路复用,提供了select,epoll,evport,kqueue几种选择,在编译的时候来选择一种。源码:ae.c

/* Include the best multiplexing layer supported by this system.
 * The following should be ordered by performances, descending. */
#ifdef HAVE_EVPORT
#include "ae_evport.c"
#else
    #ifdef HAVE_EPOLL
    #include "ae_epoll.c"
    #else
        #ifdef HAVE_KQUEUE
        #include "ae_kqueue.c"
        #else
        #include "ae_select.c"
        #endif
    #endif
#endif
  • evport:是Solaris系统内核提供支持的;
  • epoll:是Linux系统内核提供支持的;
  • kqueue:是Mac系统提供支持的;
  • select:是Posix提供的,一般的操作系统都有支撑。

分别对应源码:ae_epoll.c、ae_select.c、ae_kqueue.c、ae_evport.c

内存回收

Redis所有的数据都是存储在内存中的,在某些情况下需要对占用的内存空间进行回收。内存回收主要分为两类,一类是key过期,一类是内存使用达到上限(max_memory)触发内存淘汰。

过期策略

要实现key过期,我们有几种思路。

定时过期(主动淘汰)

每个设置过期时间的key都需要创建一个定时器,到过期时间就会立即清除。该策略可以立即清除过期的数据,对内存很友好;但是会占用大量的CPU资源去处理过期的数据,从而影响缓存的响应时间和吞吐量。

惰性过期(被动淘汰)

只有当访问一个key时,才会判断该key是否已过期,过期则清除。该策略可以最大化地节省CPU资源,却对内存非常不友好。极端情况下可能出现大量的过期key没有再次被访问,从而不会被清除,占用大量内存。

例如:String,在getCommand里面会调用expireIfNeeded

server.c    expireIfNeeded(redisDb *db, robj *key)

第二种情况,每次写入key时,发现内存不够,调用activeExpireCycle释放一部分内存。

expire.c    activeExpireCycle(int type)

定期过期

源码:server.h

/* Redis database representation. There are multiple databases identified
 * by integers from 0 (the default database) up to the max configured
 * database. The database number is the 'id' field in the structure. */
typedef struct redisDb {
    dict *dict;                 /* The keyspace for this DB,所有的键值对 */
    dict *expires;              /* Timeout of keys with a timeout set,设置了过期时间的键值对 */
    dict *blocking_keys;        /* Keys with clients waiting for data (BLPOP)*/
    dict *ready_keys;           /* Blocked keys that received a PUSH */
    dict *watched_keys;         /* WATCHED keys for MULTI/EXEC CAS */
    int id;                     /* Database ID */
    long long avg_ttl;          /* Average TTL, just for stats */
    list *defrag_later;         /* List of key names to attempt to defrag one by one, gradually. */
} redisDb;

每隔一定的时间,会扫描一定数量的数据库的expires字典中一定数量的key,并清除其中已过期的key。该策略是前两者的一个折中方案。通过调整定时扫描的时间间隔和每次扫描的限定耗时,可以在不同情况下使得CPU和内存资源达到最优的平衡效果。

Redis中同时使用了惰性过期和定期过期两种过期策略。

当两种方式都不过期,Redis内存满了怎么办?

淘汰策略

Redis的内存淘汰策略,是指当内存使用达到最大内存极限时,需要使用淘汰算法来决定清理掉哪些数据,以保证新数据的存入。

最大内存设置

redis.conf参数配置:

# maxmemory <bytes>

如果不设置maxmemory或者设置为0,64位系统不限制内存,32位系统最多使用3GB内存

动态修改:

config set maxmemory 2GB

到达最大内存以后怎么办?

淘汰策略maxmemory-policy

redis.conf

# The default is:
#
# maxmemory-policy noeviction

策略分别有如下几种:

# MAXMEMORY POLICY: how Redis will select what to remove when maxmemory
# is reached. You can select among five behaviors:
#
# volatile-lru -> Evict using approximated LRU among the keys with an expire set.
# allkeys-lru -> Evict any key using approximated LRU.
# volatile-lfu -> Evict using approximated LFU among the keys with an expire set.
# allkeys-lfu -> Evict any key using approximated LFU.
# volatile-random -> Remove a random key among the ones with an expire set.
# allkeys-random -> Remove a random key, any key.
# volatile-ttl -> Remove the key with the nearest expire time (minor TTL)
# noeviction -> Don't evict anything, just return an error on write operations.

先从算法来看:

LRU,Least Recently Used:最近最少使用。判断最近被使用的时间,目前最远的数据优先被淘汰。

LFU,Least Frequently Used:最不常用。

random:随机删除。

策略 含义
volatile-lru 根据LRU算法删除设置了超时属性(expire)的键,知道腾出足够内存为止。如果没有可删除的键对象,回退到noeviction策略。
allkeys-lru

根据LRU算法删除键,不管数据有没有设置超时属性,知道腾出足够内存为止。

volatile-lfu 在带有过期时间的键中选择最不常用的。
allkeys-lfu 在所有的键中选择最不常用的,不管数据有没有设置超时属性。
volatile-random 在带有过期时间的键中随机选择。
allkeys-random 随机删除所有键,直到腾出足够内存为止。
volatile-ttl 根据键值对象的ttl属性,删除最近将要过期数据。如果没有,回退到noeviction策略。
noeviction 默认策略,不会删除任何数据,拒绝所有写入操作并返回客户端错误信息(error)OOM command not allowed when used memory,此时Redis只响应读操作。

 

 

 

 

 

 

 

 

 

 

如果没有符合前提条件的key被淘汰,那么volatile-lru、volatile-random、volatile-ttl相当于noeviction(不做内存回收)。

动态修改淘汰策略:

config set maxmemory-policy volatile-lru

建议使用volatile-lru,在保证正常服务的情况下,优先删除最近最少使用的key。

LRU淘汰原理

Redis LRU对传统的LRU算法进行了改良,通过随机采样来调整算法的精度。

如果淘汰策略是LRU,则根据配置的采样值maxmemory_samples(默认是5个),随机从数据库中选择m个key,淘汰其中热度最低的key对应的缓存数据。所以采样参数m配置的数值越大,就越能精确的查找到待淘汰的缓存数据,但是也会消耗更多的CPU计算,执行效率随之降低。

  • 那么应该如何找出热度最低的数据?

Redis中所有对象结构都有一个lru字段,且使用了unsigned的低24位,这个字段用来记录对象的热度。对象被创建时会记录lru值。在被访问的时候也会更新lru的值。但是不是获取系统当前的时间戳,而是设置为全局变量server.lruclock的值。

源码:server.h

typedef struct redisObject {
    unsigned type:4;
    unsigned encoding:4;
    unsigned lru:LRU_BITS; /* LRU time (relative to global lru_clock) or
                            * LFU data (least significant 8 bits frequency
                            * and most significant 16 bits access time). */
    int refcount;
    void *ptr;
} robj;
  • server.lruclock的值怎么来的?

Redis中有个定时处理的函数serverCron,默认每100ms调用函数updateCachedTime更新一次全局变量servver.lruclock的值,它记录的是当前unix时间戳。

源码:server.c

/* We take a cached value of the unix time in the global state because with
 * virtual memory and aging there is to store the current time in objects at
 * every object access, and accuracy is not needed. To access a global var is
 * a lot faster than calling time(NULL) */
void updateCachedTime(void) {
    time_t unixtime = time(NULL);
    atomicSet(server.unixtime,unixtime);
    server.mstime = mstime();

    /* To get information about daylight saving time, we need to call localtime_r
     * and cache the result. However calling localtime_r in this context is safe
     * since we will never fork() while here, in the main thread. The logging
     * function will call a thread safe version of localtime that has no locks. */
    struct tm tm;
    localtime_r(&server.unixtime,&tm);
    server.daylight_active = tm.tm_isdst;
}
  • 为什么不获取精确的时间而是放在全局变量中?不会有延迟的问题吗?

这样函数lookupKey中更新数据的lru热度值时,就不用每次调用系统函数time,可以提高执行效率。

当对象里面已经有了lru字段的值,就可以评估对象的热度了。

函数estimateObjectIdleTime评估指定对象的lru热度,思想就是对象的lru值和全局的server.lruclock的差值越大(越久没有得到更新),该对象热度越低。

源码:evict.c

/* Given an object returns the min number of milliseconds the object was never
 * requested, using an approximated LRU algorithm. */
unsigned long long estimateObjectIdleTime(robj *o) {
    unsigned long long lruclock = LRU_CLOCK();
    if (lruclock >= o->lru) {
        return (lruclock - o->lru) * LRU_CLOCK_RESOLUTION;
    } else {
        return (lruclock + (LRU_CLOCK_MAX - o->lru)) *
                    LRU_CLOCK_RESOLUTION;
    }
}

server.lruclock只有24位,按秒为单位来表示才能存储194天。当超过24bit能表示的最大时间的时候,它会从头开始计算。

源码:server.h

#define LRU_CLOCK_MAX ((1<<LRU_BITS)-1) /* Max value of obj->lru */

在这种情况下,可能会出现对象的lru大于server.lruclock的情况,如果这种情况出现那么就两个相加而不是象间来求最久的key。

  • 为什么不用常规的哈希表+双向链表的方式实现?

需要额外的数据结构,消耗资源。而Redis LRU算法在sample为10的情况下,已经能接近传统LRU算法了。

  • 除了消耗资源之外,传统LRU还有什么问题?

如图所示,假设A在10秒内被访问了5次,而B在10秒内被访问了3次。因为B最后一次被访问的时间比A要晚,在同等的情况下,A反而先被回收。

LFU淘汰原理

server.h

typedef struct redisObject {
    unsigned type:4;
    unsigned encoding:4;
    unsigned lru:LRU_BITS; /* LRU time (relative to global lru_clock) or
                            * LFU data (least significant 8 bits frequency
                            * and most significant 16 bits access time). */
    int refcount;
    void *ptr;
} robj;

当这24bits用作lfu时,其被分为两部分:

  • 高16位用来记录访问时间(单位为分钟,ldt,last decrement time)
  • 低8位用来记录访问频率,简称counter(logc,logistic counter)

counter是用基于概率的对数计数器实现的,8位可以表示百万次的访问频率。对象被读写的时候,lfu的值会被更新。

db.c —— lookupKey

/* Update LFU when an object is accessed.
 * Firstly, decrement the counter if the decrement time is reached.
 * Then logarithmically increment the counter, and update the access time. */
void updateLFU(robj *val) {
    unsigned long counter = LFUDecrAndReturn(val);
    counter = LFULogIncr(counter);
    val->lru = (LFUGetTimeInMinutes()<<8) | counter;
}

增长的速率由,lfu-log-factor越大,counter增长的越慢

redis.conf

# The default lfu-log-factor is 10. This is a table of how the frequency
# counter changes with a different number of accesses with different
# logarithmic factors:
#
# +--------+------------+------------+------------+------------+------------+
# | factor | 100 hits   | 1000 hits  | 100K hits  | 1M hits    | 10M hits   |
# +--------+------------+------------+------------+------------+------------+
# | 0      | 104        | 255        | 255        | 255        | 255        |
# +--------+------------+------------+------------+------------+------------+
# | 1      | 18         | 49         | 255        | 255        | 255        |
# +--------+------------+------------+------------+------------+------------+
# | 10     | 10         | 18         | 142        | 255        | 255        |
# +--------+------------+------------+------------+------------+------------+
# | 100    | 8          | 11         | 49         | 143        | 255        |
# +--------+------------+------------+------------+------------+------------+
#
# lfu-log-factor 10

如果计数器只会递增不会递减,也不能体现对象的热度。没有被访问的时候,计数器怎么递减呢?

减少的值由衰减因子lfu-decay-time(分钟)来控制,如果值是1的话,N分钟没有访问就要减少N。

redis.conf配置文件

# lfu-decay-time 1

持久化机制

Redis速度快,很大一部分原因是因为它所有的数据都存储在内存中。如果断电或者宕机,都会导致内存中的数据丢失。为了实现重启后数据不丢失,Redis提供了两种持久化的方案,一种是RDB快照(Redis DataBase),一种是AOF(Append Only File)。

RDB

RDB是Redis默认的持久化方案。当满足一定条件的时候,会把当前内存中的数据写入磁盘,生成一个快照文件dump.rdb(文件名,写入路径可修改)。Redis重启会通过加载dump.rdb文件恢复数据。

RBD触发条件

1、自动触发

配置规则触发

redis.conf,SNAPSHOTTING,其中定义了触发数据保存到磁盘的触发频率。如果不需要RDB方案,注释save或者配置成空字符串""。

################################ SNAPSHOTTING  ################################
#
# Save the DB on disk:
#
#   save <seconds> <changes>
#
#   Will save the DB if both the given number of seconds and the given
#   number of write operations against the DB occurred.
#
#   In the example below the behaviour will be to save:
#   after 900 sec (15 min) if at least 1 key changed
#   after 300 sec (5 min) if at least 10 keys changed
#   after 60 sec if at least 10000 keys changed
#
#   Note: you can disable saving completely by commenting out all "save" lines.
#
#   It is also possible to remove all the previously configured save
#   points by adding a save directive with a single empty string argument
#   like in the following example:
#
#   save ""

save 900 1
save 300 10
save 60 10000

注意以上三条配置并不冲突,只要满足任意一条都会触发。

RDB文件位置和目录:

# Compress string objects using LZF when dump .rdb databases?
# For default that's set to 'yes' as it's almost always a win.
# If you want to save some CPU in the saving child set it to 'no' but
# the dataset will likely be bigger if you have compressible values or keys.
# 是否是LZF压缩rdb文件
rdbcompression yes

# Since version 5 of RDB a CRC64 checksum is placed at the end of the file.
# This makes the format more resistant to corruption but there is a performance
# hit to pay (around 10%) when saving and loading RDB files, so you can disable it
# for maximum performances.
#
# RDB files created with checksum disabled have a checksum of zero that will
# tell the loading code to skip the check.
# 开启数据校验
rdbchecksum yes

# The filename where to dump the DB
# rdb文件名
dbfilename dump.rdb

# The working directory.
#
# The DB will be written inside this directory, with the filename specified
# above using the 'dbfilename' configuration directive.
#
# The Append Only File will also be created inside this directory.
#
# Note that you must specify a directory here, not a file name.
# 文件存储路径
dir ./

RDB还有两种触发方式:

  • shutdown触发,保证服务器正常关闭
  • flushall,RDB文件是空的,无意义。

2、手动触发

如果我们需要重启服务或者迁移数据,这个时候就需要手动触发RDB快照保存。Redis提供两条命令:

  • save

save在生成快照的时候会阻塞当前Redis服务器,Redis不能再处理其他命令。如果内存中的数据比较多,会造成Redis长时间阻塞。生产环境不建议使用这条命令。

为了解决这个问题,Redis提供了第二种方式。

  • bgsave

执行bgsave时,Redis会在后台异步进行快照操作,快照同时还可以响应客户端请求。

具体操作是Redis进程进行fork操作创建子进程(copy-on-write),RDB持久化过程由子进程负责,完成后自动结束。它不会记录fork之后的命令。阻塞只发生在fork阶段,一般时间很短。

用lastsave命令可以查看最近一次成功生成快照的时间。

RDB数据的恢复

1、shutdown触发持久化

2、模拟数据丢失

3、通过备份文件恢复数据

RBD文件的优劣

  1. 优势
    1. RBD是一个分常紧凑(compact)的文件,它保存了redis在某个时间点上的数据集。这种文件非常适合用于进行备份和灾难恢复。
    2. 生成RDB文件的时候,redis主进程会fork()一个子进程来处理所有保存工作,主进程不需要进行任何磁盘I/O操作。
    3. RDB在恢复大数据集时的速度比AOF的恢复速度快。
  2. 劣势
    1. RDB方式数据没办法做到实时持久化。因为bgsave每次运行都要执行fork操作创建子进程,频繁执行成本过高。
    2. 在一定间隔时间做一次备份,所以如果redis意外宕机,就会丢失最后一次快照之后的所有修改(部分数据丢失)。如果数据相对重要,损失降低到最小,需要使用AOF方式进行持久化。

AOF

AOF:Redis默认不开启。AOF采用日志的形式来记录每个写操作,并追加到文件中。开启后,执行更改Redis数据的命令时,就会把命令写入到AOF文件中。

Redis重启时会根据日志文件的内容把写指令从前到后执行一次以完成数据的恢复工作。

AOF的配置

配置文件redis.conf

# 是否开启AOF
appendonly no

# The name of the append only file (default: "appendonly.aof")
# AOF写文件的文件名
appendfilename "appendonly.aof"

AOF触发

由于操作系统的缓存机制,AOF数据并没有真正的写入硬盘,而是进入了系统的硬盘缓存。那么何时把缓冲区的内容写入到AOF文件呢?

redis.conf

# always 表示每次写入都执行 fsync,以保证数据同步到磁盘,效率很低
# appendfsync always

# everysec 表示每秒执行一次 fsync,可能会导致丢失这 1s 数据。通常选择 everysec,兼顾安全性和效率
appendfsync everysec

# no 表示不执行 fsync,由操作系统保证数据同步到磁盘,速度最快,但是不太安全
# appendfsync no

AOF文件处理

由于AOF持久化是Redis不断将写命令记录到AOF文件中,随着Redis不断的进行,AOF的文件会越来越大;文件越大,占用的服务器内存就会越大,同时AOF恢复所需要的时间越长。

当重复的命令重复执行时,Redis时如何处理的呢?例如 set king test 命令执行了1w次。

为了解决这个问题,Redis新增了重写机制,当AOF文件的大小超过所设定的阈值时,Redis就会启动AOF文件的内容压缩,只保留可以恢复数据的最小指令集。

可以使用命令:bgrewriteaof

AOF文件重写并不是对源文件进行重新整理,而是直接读取服务器现有的键值对,然后用一条命令去代替之前记录这个键值对的多条命令,生成一个新的文件后替换原来的AOF文件。

redis.conf

# Automatic rewrite of the append only file.
# Redis is able to automatically rewrite the log file implicitly calling
# BGREWRITEAOF when the AOF log size grows by the specified percentage.
#
# This is how it works: Redis remembers the size of the AOF file after the
# latest rewrite (if no rewrite has happened since the restart, the size of
# the AOF at startup is used).
#
# This base size is compared to the current size. If the current size is
# bigger than the specified percentage, the rewrite is triggered. Also
# you need to specify a minimal size for the AOF file to be rewritten, this
# is useful to avoid rewriting the AOF file even if the percentage increase
# is reached but it is still pretty small.
#
# Specify a percentage of zero in order to disable the automatic AOF
# rewrite feature.
# 默认值为100,aof自动重写配置,当目前aof文件大小超过上次重写的aof文件大小的百分比 100% 进行重写。
auto-aof-rewrite-percentage 100
# 默认64mb,设置允许重写的最小aof文件大小,避免了达到约定百分比但是文件任然很小的情况发生重写。
auto-aof-rewrite-min-size 64mb

重写过程中的AOF文件更改问题

另外有两个与AOF相关的参数

# 在 aof 重写或者写入 rdb 文件的时候,会执行大量 IO,此时对于 everysec和always的aof模式来说,
# 执行fsync会造成阻塞过长时间,no-appendfsync-on-rewrite 字段设置为默认设置为 no。
# 如果对延迟要求很高的应用,这个字段可以设置为yes,否则还是设置为no,这样对持久化特性来说这是更安全的选择。
# 设置为yes表示rewrite 期间对新写操作不 fsync,暂时存在内存中,等rewrite完成后再写入,默认为no,建议修改为yes。
# Linux的默认 fsync策略是 30 秒。可能丢失 30 秒数据。
no-appendfsync-on-rewrite no

# aof 文件可能在尾部是不完整的,当 redis 启动的时候,aof 文件的数据被载入内存。
# 重启可能发生在 redis所在的主机操作系统宕机后,尤其在ext4文件系统没有加上data=ordered选项,出现这种现象。
# redis 宕机或者异常终止不会造成尾部不完整现象,可以选择让 redis退出,或者导入尽可能多的数据。
# 如果选择的是 yes,当截断的 aof 文件被导入的时候,会自动发布一个 log 给客户端然后 load。
# 如果是 no,用户必须手动 redis-check-aof 修复 AOF文件才可以。默认值为 yes。
aof-load-truncated yes

AOF数据恢复

重启Rdis之后就会进行AOF文件的恢复。

AOF优劣

  1. 优势
    1. AOF持久化方法提供了多种同步频率,即使使用默认的同步频率每秒同步一次,Redis最多也就会丢失1秒的数据。
  2. 缺点
    1. 对于具有相同数据的Redis,AOF文件通常会比RDB文件占用更大空间。除非触发rewrite,否则重复命令过多。
    2. 虽然AOF提供了多种同步的频率,默认情况下,每秒同步一次的频率也具有较高的性能。在高并发的情况下,RDB比AOF具有更好的性能。

RBD比之AOF

我们应该如何合理的选择这两种方案呢?

如果可以忍受一小段时间内数据的丢失,毫无疑问RDB是最好的,定时生成RDB快照非常便于进行数据库的备份,并且RDB恢复数据集的速度也要比AOF恢复的速度要快。

否则就是用AOF重写。但是一般情况下建议不要单独使用某一种持久化机制,而是结合两种一起使用,在这种情况下,当redis重启的时候会优先载入AOF文件来恢复原始的数据,因为在通常情况下AOF文件保存的数据集要比RDB文件保存的数据集要更加完整。

 

LRU

發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章