Redis详解(1)--原理和机制

一、性能


1 性能测试

测试环境: RHEL 6.3 / HP Gen8 Server/ 2 * Intel Xeon 2.00GHz(6 core) / 64G DDR3 memory / 300G RAID-1 SATA / 1 master(writ AOF), 1 slave(write AOF & RDB)

数据准备: 预加载两千万条数据,占用10G内存。 

测试工具:自带的redis-benchmark,默认只是基于一个很小的数据集进行测试,调整命令行参数如下,就可以开100条线程(默认50),SET 1千万次(key在0-1千万间随机),key长21字节,value长256字节的数据。
1
redis-benchmark -t SET -c 100 -n 10000000 -r 10000000 -d 256
测试结果(QPS):

1.SET:4.5万,

2.GET:6万 ,

3.INCR:6万,

4.真实混合场景: 2.5万SET & 3万GET
单条客户端线程时6千TPS,50与100条客户端线程差别不大,200条时会略多。
Get/Set操作,经过了LAN,延时也只有1毫秒左右,可以反复放心调用,不用像调用REST接口和访问数据库那样,每多一次外部访问都心痛。
资源监控:
1.CPU: 占了一个处理器的100%,总CPU是4%(因为总共有2CPU*6核*超线程 = 24个处理器),可见单线程下单处理器的能力是瓶颈。 AOF rewrite时另一个处理器占用50-70%。
2.网卡:15-20 MB/s receive, 3Mb/s send(no slave) or 15-20 MB/s send (with slave) 。当把value长度加到4K时,receive 99MB/s,已经到达千兆网卡的瓶颈,TPS降到2万。
3.硬盘:15MB/s(AOF append), 100MB/s(AOF rewrite/AOF load,普通硬盘的瓶颈),
 

2 为什么快

主要是以下几点点


一)、纯内存操作

 数据存放在内存中,内存的响应时间大约是 100纳秒 ,这是Redis每秒万亿级别访问的重要基础。


二)、单线程操作,避免了频繁的上下文切换

虽然是采用单线程,但是单线程避免了不必要的上下文切换和竞争条件,也不存在多进程或者多线程导致的切换而消耗 CPU;虽然作者认为CPU不是瓶颈,内存与网络带宽才是。但实际测试时并非如此,见上。


三)、采用了非阻塞I/O多路复用机制

多路I/O复用模型是利用 select、poll、epoll 可以同时监察多个流的 I/O 事件的能力,在空闲的时候,会把当前线程阻塞掉,当有一个或多个流有 I/O 事件时,就从阻塞态中唤醒,于是程序就会轮询一遍所有的流(epoll 是只轮询那些真正发出了事件的流),并且只依次顺序的处理就绪的流,这种做法就避免了大量的无用操作。这里“多路”指的是多个网络连接,“复用”指的是复用同一个线程。加上Redis自身的事件处理模型将epoll中的连接,读写,关闭都转换为了事件,不在I/O上浪费过多的时间。 

四)、纯ANSI C编写。
不依赖第三方类库,没有像memcached那样使用libevent,因为libevent迎合通用性而造成代码庞大,所以作者用libevent中两个文件修改实现了自己的epoll event loop。微软的兼容Windows补丁也因为同样原因被拒了。
快,原因之一是Redis多样的数据结构,每种结构只做自己爱做的事,当然比数据库只有Table,MongogoDB只有JSON一种结构快了。

 

   

 

 

二、I/O复用模型和Reactor 设计模式


详情请看:使用 libevent 和 libev 提高网络应用性能——I/O模型演进变化史 

Redis内部实现采用epoll+自己实现的简单的事件框架。 epoll中的读、写、关闭、连接都转化成了事件,然后利用epoll的多路复用特性, 绝不在io上浪费一点时间:

1、I/O 多路复用的封装

I/O 多路复用其实是在单个线程中通过记录跟踪每一个sock(I/O流) 的状态来管理多个I/O流。
Redis详解(1)--原理和机制_第1张图片

因为 Redis 需要在多个平台上运行,同时为了最大化执行的效率与性能,所以会根据编译平台的不同选择不同的 I/O 多路复用函数作为子模块,提供给上层统一的接口。

redis的多路复用, 提供了select, epoll, evport, kqueue几种选择,在编译的时候来选择一种。

select是POSIX提供的, 一般的操作系统都有支撑;
epoll 是LINUX系统内核提供支持的;
evport是Solaris系统内核提供支持的;
kqueue是Mac 系统提供支持的;

#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

为了将所有 IO 复用统一,Redis 为所有 IO 复用统一了类型名 aeApiState,对于 epoll 而言,类型成员就是调用 epoll_wait所需要的参数
接下来就是一些对epoll接口的封装了:包括创建 epoll(epoll_create),注册事件(epoll_ctl),删除事件(epoll_ctl),阻塞监听(epoll_wait)等
创建 epoll 就是简单的为 aeApiState 申请内存空间,然后将返回的指针保存在事件驱动循环中
注册事件和删除事件就是对 epoll_ctl 的封装,根据操作不同选择不同的参数
阻塞监听是对 epoll_wait 的封装,在返回后将激活的事件保存在事件驱动中



 

Reactor 设计模式:事件驱动循环流程

Redis 服务采用 Reactor 的方式来实现文件事件处理器(每一个网络连接其实都对应一个文件描述符) 

当 main 函数初始化工作完成后,就需要进行事件驱动循环,而在循环中,会调用 IO 复用函数进行监听
在初始化完成后,main 函数调用了 aeMain 函数,传入的参数就是服务器的事件驱动

Redis 对于时间事件是采用链表的形式记录的,这导致每次寻找最早超时的那个事件都需要遍历整个链表,容易造成性能瓶颈。而 libevent 是采用最小堆记录时间事件,寻找最早超时事件只需要 O(1) 的复杂度
 

Redis详解(1)--原理和机制_第2张图片

如上图,IO多路复用模型使用了Reactor设计模式实现了这一机制。

通过Reactor的方式,可以将用户线程轮询IO操作状态的工作统一交给handle_events事件循环进行处理。

用户线程注册事件处理器之后可以继续执行做其他的工作(异步),而Reactor线程负责调用内核的select/epoll函数检查socket状态。当有socket被激活时,则通知相应的用户线程(或执行用户线程的回调函数),执行handle_event进行数据读取、处理的工作。由于select/epoll函数是阻塞的,因此多路IO复用模型也被称为异步阻塞IO模型。注意,这里的所说的阻塞是指select函数执行时线程被阻塞,而不是指socket。一般在使用IO多路复用模型时,socket都是设置为NONBLOCK的,不过这并不会产生影响,因为用户发起IO请求时,数据已经到达了,用户线程一定不会被阻塞。

 

redis线程模型:

如图所示:

Redis详解(1)--原理和机制_第3张图片

简单来说,就是。我们的redis-client在操作的时候,会产生具有不同事件类型的socket。在服务端,有一段I/0多路复用程序,将其置入队列之中。然后,IO事件分派器,依次去队列中取,转发到不同的事件处理器中。

 

3、数据结构说明


http://redis.io/topics/data-types

2.1 Key

1、key越短越好:Redis是个内存数据库,Key键越短你需要的空间就越少,因此key不能太长,比如1024字节。

举个例子:在一个32位的Redis服务器上,如果储存一百万个键,每个值的长度是32-character,那么在使用6-character长度键名时,将会消耗大约96MB的空间,但是如果使用12-character长度的键名时,空间消耗则会提升至111MB左右。随着键的增多,15%的额外开销将产生重大的影响。

2、key命名要表达清楚意思。建议用”:”分隔域划分键名,用”.”作为单词间的连接,如”comment:1234:reply.to”。

使用合适的命名方法会简化你的数据库管理,当你通过你的应用程序或者服务做键的命名空间时,你就可以在数据迁移、转换或者删除时轻松的识别。

Redis另一个常见用例是作为热数据项作的第二数据存储,大部分的数据被保存在其他的数据库中,比如PostgreSQL或MongoDB。在这些用例中,当数据从主存储移除时,开发者经常会忘记删除Redis中对应的数据。这种存在跨数据存储的情况下,通常需要做级联删除,这种情况下,可以通过在Redis配置保存特定数据项的所有识别符来实现,从而保证数据在主数据库被删除后,系统会调用一个清理程序来删除所有相关副本和信息。

 

 

2.2 String

String是最简单的类型,一个key对应一个value,string类型是二进制安全的。Redis的string可以包含任何数据,比如jpg图片或者序列化的对象(PHP中对象序列化函数serialize)

       内部实现,其本质是一个byte数组,字符串的大小被限制在512M以内

struct sdshdr {  
      long len; //buf数组的长度  
      long free; //buf数组中剩余可用字节数  
      char buf[]; //存储实际字符串内容  
}  

 

所有常用命令的复杂度都是O(1),普通的Get/Set方法,可以用来做Cache,存Session,为了简化架构甚至可以替换掉Memcached。

Incr/IncrBy/IncrByFloat/Decr/DecrBy,可以用来做计数器,做自增序列。key不存在时会创建并贴心的设原值为0。IncrByFloat专门针对float,没有对应的decrByFloat版本?用负数啊。

SetNx, 仅当key不存在时才Set。可以用来选举Master或做分布式锁:所有Client不断尝试使用SetNx master myName抢注Master,成功的那位不断使用Expire刷新它的过期时间。如果Master倒掉了key就会失效,剩下的节点又会发生新一轮抢夺。

2.3 Hash

Key-HashMap结构,相比String类型将这整个对象持久化成JSON格式,Hash将对象的各个属性存入Map里,可以只读取/更新对象的某些属性。这样有些属性超长就让它一边呆着不动,另外不同的模块可以只更新自己关心的属性而不会互相并发覆盖冲突。

2.4 List

List是一个双向链表,支持双向的Pop/Push,江湖规矩一般从左端Push,右端Pop——LPush/RPop,而且还有Blocking的版本BLPop/BRPop,客户端可以阻塞在那直到有消息到来,所有操作都是O(1)的好孩子,可以当Message Queue来用。当多个Client并发阻塞等待,有消息入列时谁先被阻塞谁先被服务。任务队列系统Resque是其典型应用。

有RPopLPush/ BRPopLPush,弹出来返回给client的同时,把自己又推入另一个list,LLen获取列表的长度。

2.5 Set

Set就是Set,可以将重复的元素随便放入而Set会自动去重,底层实现也是hash table。

2.6 Sorted Set

有序集,元素放入集合时还要提供该元素的分数。

Sorted Set的实现是hash table(element->score, 用于实现ZScore及判断element是否在集合内),和skip list(score->element,按score排序)的混合体。 skip list有点像平衡二叉树那样,不同范围的score被分成一层一层,每层是一个按score排序的链表。

ZAdd/ZRem是O(log(N)),ZRangeByScore/ZRemRangeByScore是O(log(N)+M),N是Set大小,M是结果/操作元素的个数。可见,原本可能很大的N被很关键的Log了一下,1000万大小的Set,复杂度也只是几十不到。当然,如果一次命中很多元素M很大那谁也没办法了。

2.8 Lua Script

Redis2.6内置的Lua Script支持,可以在Redis的Server端一次过运行大量逻辑,就像存储过程一样,避免了海量中间数据在网路上的传输。

Lua自称是在Script语言里关于快的标准,Redis选择了它而不是流行的JavaScript。
因为Redis的单线程架构,整个Script默认是在一个事务里的。
Script里涉及的所有Key尽量用变量,从外面传入,使Redis一开始就知道你要改变哪些key。(but why?)
Eval每次传输一整段Script比较费带宽,可以先用Script Load载入script,返回哈希值。然后用EvalHash执行。因为就是SHA-1,所以任何时候执行返回的哈希值都是一样的。
内置的Lua库里还很贴心的带了CJSON,可以处理json字符串。
一段用Redis做Timer的示例代码,下面的script被定期调用,从以触发时间为score的sorted set中取出已到期的Job,放到list中给Client们blocking popup。
 
— KEYS: [1]job:sleeping, [2]job:ready
— ARGS: [1]currentTime
— Comments: result is the  job id
local jobs=redis.call(‘zrangebyscore’, KEYS[1], ‘-inf’, ARGV[1])
local count = table.maxn(jobs)
 
if count>0  then
  — Comments: remove from Sleeping Job sorted set
  redis.call(‘zremrangebyscore’, KEYS[1], ‘-inf’, ARGV[1])
 
  — Comments: add to the Ready Job list
  — Comments: can optimize to use lpush id1,id2,… for better performance
  for i=1,count do
    redis.call(‘lpush’, KEYS[2], jobs[i])
  end
end

 

在Redis使用过程中,Lua脚本的支持无疑给开发者提供一个非常友好的开发环境,从而大幅度解放用户的创造力。如果使用得当,Lua脚本可以给性能和资源消耗带来非常大的改善。取代将数据传送给CPU,脚本允许你在最接近数据的地方执行逻辑,从而减少网络延时和数据的冗余传输。

在Redis中,Lua一个非常经典的用例就是数据过滤或者将数据聚合到应用程序。通过将处理工作流封装到一个脚本中,你只需要调用它就可以在更短的时间内使用很少的资源来获取一个更小的答案。

提示:Lua确实非常棒,但是同样也存在一些问题,比如很难进行错误报告和处理。一个明智的方法就是使用Redis的Pub/Sub功能,并且让脚本通过专用信道来推送日志消息。然后建立一个订阅者进程,并进行相应的处理。


2.9使用合适的数据结构

 

不管是内存使用或者是性能,有的时候数据结构将产生很大的影响,下面是一些可以参考的最佳实践:

1、使用hash取代将数据存储为数千(或者数百万)独立的字符串。哈希表是非常有效率的,并且可以减少你的内存使用(因为小Hashes会被编码成一个非常小的空间);同时,哈希还更有益于细节抽象和代码可读。

2、合适时候,使用list代替set。如果你不需要使用set特性,List在使用更少内存的情况下可以提供比set更快的速度。

3、Sorted sets是最昂贵的数据结构,不管是内存消耗还是基本操作的复杂性。如果你只是需要一个查询记录的途径,并不在意排序这样的属性,那么轻建议使用哈希表。

4、Redis中一个经常被忽视的功能就是bitmaps或者bitsets(V2.2之后)。Bitsets允许你在Redis值上执行多个bit-level操作,比如一些轻量级的分析。

 

5、使用bit位级别操作和byte字节级别操作来减少不必要的内存使用

 

 

4、数据一致性:事务


      用Multi(Start Transaction)、Exec(Commit)、Discard(Rollback)实现。 在事务提交前,不会执行任何指令,只会把它们存到一个队列里,不影响其他客户端的操作。在事务提交时,批量执行所有指令。《Redis设计与实现》中的详述。

注意,Redis里的事务,与我们平时的事务概念很不一样:

它仅仅是保证事务里的操作会被连续独占的执行。因为是单线程架构,在执行完事务内所有指令前是不可能再去同时执行其他客户端的请求的。
它没有隔离级别的概念,因为事务提交前任何指令都不会被实际执行,也就不存在”事务内的查询要看到事务里的更新,在事务外查询不能看到”这个让人万分头痛的问题。
它不保证原子性——所有指令同时成功或同时失败,只有决定是否开始执行全部指令的能力,没有执行到一半进行回滚的能力。在redis里失败分两种,一种是明显的指令错误,比如指令名拼错,指令参数个数不对,在2.6版中全部指令都不会执行。另一种是隐含的,比如在事务里,第一句是SET foo bar, 第二句是LLEN foo,对第一句产生的String类型的key执行LLEN会失败,但这种错误只有在指令运行后才能发现,这时候第一句成功,第二句失败。还有,如果事务执行到一半redis被KILL,已经执行的指令同样也不会被回滚。
Watch指令,类似乐观锁,事务提交时,如果Key的值已被别的客户端改变,比如某个list已被别的客户端push/pop过了,整个事务队列都不会被执行。

 

5、配置详解


1. Redis默认不是以守护进程的方式运行,可以通过该配置项修改,使用yes启用守护进程

    daemonize no

2. 当Redis以守护进程方式运行时,Redis默认会把pid写入/var/run/redis.pid文件,可以通过pidfile指定

    pidfile /var/run/redis.pid

3. 指定Redis监听端口,默认端口为6379,作者在自己的一篇博文中解释了为什么选用6379作为默认端口,因为6379在手机按键上MERZ对应的号码,而MERZ取自意大利歌女Alessia Merz的名字

    port 6379

4. 绑定的主机地址

    bind 127.0.0.1

5.当 客户端闲置多长时间后关闭连接,如果指定为0,表示关闭该功能

    timeout 300

6. 指定日志记录级别,Redis总共支持四个级别:debug、verbose、notice、warning,默认为verbose

    loglevel verbose

7. 日志记录方式,默认为标准输出,如果配置Redis为守护进程方式运行,而这里又配置为日志记录方式为标准输出,则日志将会发送给/dev/null

    logfile stdout

8. 设置数据库的数量,默认数据库为0,可以使用SELECT 命令在连接上指定数据库id

    databases 16

9. 指定在多长时间内,有多少次更新操作,就将数据同步到数据文件,可以多个条件配合

    save

    Redis默认配置文件中提供了三个条件:

    save 900 1

    save 300 10

    save 60 10000

    分别表示900秒(15分钟)内有1个更改,300秒(5分钟)内有10个更改以及60秒内有10000个更改。

 

10. 指定存储至本地数据库时是否压缩数据,默认为yes,Redis采用LZF压缩,如果为了节省CPU时间,可以关闭该选项,但会导致数据库文件变的巨大

    rdbcompression yes

11. 指定本地数据库文件名,默认值为dump.rdb

    dbfilename dump.rdb

12. 指定本地数据库存放目录

    dir ./

13. 设置当本机为slav服务时,设置master服务的IP地址及端口,在Redis启动时,它会自动从master进行数据同步

    slaveof

14. 当master服务设置了密码保护时,slav服务连接master的密码

    masterauth

15. 设置Redis连接密码,如果配置了连接密码,客户端在连接Redis时需要通过AUTH 命令提供密码,默认关闭

    requirepass foobared

16. 设置同一时间最大客户端连接数,默认无限制,Redis可以同时打开的客户端连接数为Redis进程可以打开的最大文件描述符数,如果设置 maxclients 0,表示不作限制。当客户端连接数到达限制时,Redis会关闭新的连接并向客户端返回max number of clients reached错误信息

    maxclients 128

17. 指定Redis最大内存限制,Redis在启动时会把数据加载到内存中,达到最大内存后,Redis会先尝试清除已到期或即将到期的Key,当此方法处理 后,仍然到达最大内存设置,将无法再进行写入操作,但仍然可以进行读取操作。Redis新的vm机制,会把Key存放内存,Value会存放在swap区

    maxmemory

18. 指定是否在每次更新操作后进行日志记录,Redis在默认情况下是异步的把数据写入磁盘,如果不开启,可能会在断电时导致一段时间内的数据丢失。因为 redis本身同步数据文件是按上面save条件来同步的,所以有的数据会在一段时间内只存在于内存中。默认为no

    appendonly no

19. 指定更新日志文件名,默认为appendonly.aof

     appendfilename appendonly.aof

20. 指定更新日志条件,共有3个可选值: 
    no:表示等操作系统进行数据缓存同步到磁盘(快) 
    always:表示每次更新操作后手动调用fsync()将数据写到磁盘(慢,安全) 
    everysec:表示每秒同步一次(折衷,默认值)

    appendfsync everysec

 

21. 指定是否启用虚拟内存机制,默认值为no,简单的介绍一下,VM机制将数据分页存放,由Redis将访问量较少的页即冷数据swap到磁盘上,访问多的页面由磁盘自动换出到内存中(在后面的文章我会仔细分析Redis的VM机制)

     vm-enabled no

22. 虚拟内存文件路径,默认值为/tmp/redis.swap,不可多个Redis实例共享

     vm-swap-file /tmp/redis.swap

23. 将所有大于vm-max-memory的数据存入虚拟内存,无论vm-max-memory设置多小,所有索引数据都是内存存储的(Redis的索引数据 就是keys),也就是说,当vm-max-memory设置为0的时候,其实是所有value都存在于磁盘。默认值为0

     vm-max-memory 0

24. Redis swap文件分成了很多的page,一个对象可以保存在多个page上面,但一个page上不能被多个对象共享,vm-page-size是要根据存储的 数据大小来设定的,作者建议如果存储很多小对象,page大小最好设置为32或者64bytes;如果存储很大大对象,则可以使用更大的page,如果不 确定,就使用默认值

     vm-page-size 32

25. 设置swap文件中的page数量,由于页表(一种表示页面空闲或使用的bitmap)是在放在内存中的,,在磁盘上每8个pages将消耗1byte的内存。

     vm-pages 134217728

26. 设置访问swap文件的线程数,最好不要超过机器的核数,如果设置为0,那么所有对swap文件的操作都是串行的,可能会造成比较长时间的延迟。默认值为4

     vm-max-threads 4

27. 设置在向客户端应答时,是否把较小的包合并为一个包发送,默认为开启

    glueoutputbuf yes

28. 指定在超过一定的数量或者最大的元素超过某一临界值时,采用一种特殊的哈希算法

    hash-max-zipmap-entries 64

    hash-max-zipmap-value 512

29. 指定是否激活重置哈希,默认为开启(后面在介绍Redis的哈希算法时具体介绍)

    activerehashing yes

30. 指定包含其它的配置文件,可以在同一主机上多个Redis实例之间使用同一份配置文件,而同时各个实例又拥有自己的特定配置文件

    include /path/to/local.conf

你可能感兴趣的:(Redis)