在一些网络服务系统中,Redis的性能可能比MySQL等硬盘数据库的性能更重要。例如,微博将热门微博[1]和最新的用户关系存储在Redis中,大量查询命中Redis而不是MySQL。那么,我们可以为Redis服务做哪些性能优化呢?换句话说,应该避免哪些性能浪费?Redis性能基础在讨论优化之前,我们需要知道Redis服务本身有一些特点,比如单线程运行。除非修改Redis的源代码,否则这些特性是我们思考性能优化的基础。那么,Redis的基本特性有哪些是我们需要考虑的呢?Redis的项目介绍总结了它的特点:Redis是一个持久化在磁盘上的内存数据库。数据模型是key-value,但是支持很多不同种类的值。首先,Redis使用操作系统提供的虚拟内存来存储数据。而且,这个操作系统一般指的是Unix。Redis也可以在Windows上运行,但需要特殊处理。如果您的操作系统使用交换空间,那么Redis数据实际上可能存储在磁盘上。其次,Redis支持持久化,可以将数据保存在硬盘上。很多时候,我们确实需要实现持久化来实现备份、数据恢复等需求。但是持久化并不是凭空发生的,它也会占用一些资源。第三,Redis以key-value的方式读写,value可以包含很多不同类型的数据;此外,数据类型的底层存储为不同的结构。不同的存储结构决定了数据增删改查的复杂度和性能开销。最后,上面介绍中没有提到的是,Redis在大多数时候是运行在单线程[2](single-threaded)上的,即同时只占用一个CPU,并且只有一条指令是运行,并行读写不存在。很多操作带来的延迟问题都可以在这里得到解答。关于最后一个特点,为什么Redis是单线程的,却能有很好的性能(根据Amdahl定律,优化耗时进程更有意义)。I/O多路复用机制[3]在处理客户端请求时不会阻塞主线程;Redis简单的执行(大部分指令)一条指令不到1微秒[4],这样一个单核CPU就可以处理100万条指令(大概对应几十万条请求),不需要实现多核线程(网络是瓶颈[5])。优化网络延迟Redis的官方博客在几个地方都说性能瓶颈更有可能是网络[6],那么我们如何在网络上优化延迟呢?首先,如果使用单机部署(应用服务和Redis在同一台机器上),使用Unix进程间通信请求Redis服务,比localhostLAN(学名loopback)速度更快。官方文档[7]是这么说的,仔细想想,理论上应该是一样的。但是很多公司的业务规模单机部署无法支撑,所以必须使用TCP。Redis客户端和服务器之间的通信一般使用TCP长连接。如果客户端发送请求后需要等待Redis返回结果才能发送下一条命令,则客户端与Redis的多次请求会形成如下关系:(注:如果你要发送的key不是特别长,一个TCP包完全可以放下Redis命令,所以只画了一个push包),这样在这两个请求中,客户端都需要经历一段时间的网络传输时间。但如果可能,您可以使用多键命令来合并请求。例如,两个GET密钥可以与MGETkey1key2合并。这样一来,在实际通信中,请求的次数也减少了,延迟自然也就提高了。如果它不能与多键命令合并,例如SET,则不能合并GET。该怎么办?Redis中至少有两种方法可以将多条指令组合成一个请求,一种是MULTI/EXEC,另一种是script。前者本来是一种构造Redis事务的方法,但是确实可以将多条指令组合成一个请求,它的通信过程如下。至于脚本,最好使用缓存脚本的sha1hashkey来调用脚本,这样通信流量更小。这确实进一步减少了网络传输时间,不是吗?但是这种方式需要要求本次交易/脚本涉及的key都在同一个节点上,所以酌情考虑。如果我们考虑了以上方法,还是没有办法合并多个请求,我们也可以考虑合并多个响应。例如,合并两条回复消息:这样,理论上可以节省一次回复所用的网络传输时间。这就是管道的作用。举个ruby客户端使用pipeline的例子:require'redis'@redis=Redis.new()@redis.pipelineddo@redis.get'key1'@redis.set'key2''somevalue'end#=>[1,2]据说有些语言的客户端甚至默认使用pipeline来优化延迟,比如node_redis。另外,一个TCP数据包中不能放入任意数量的回复消息。如果请求太多,回复数据很长(比如得到一个很长的字符串),TCP还是会分包传输,但是使用pipeline,还是可以减少传输次数的。与上面的其他方法不同,管道不是原子的。所以在cluster状态的cluster上,比那些atomicmethods更容易实现pipeline。总结一下:使用unix进程间通信,如果单机部署使用多键指令组合多条指令,减少请求数量,有条件的话使用事务,脚本组合请求和响应,使用管道组合响应,慎用long-runningoperationsinlarge在数据量很大的情况下,有些操作的执行时间会比较长,比如KEYS*,LRANGEmylist0-1等算法复杂度为O(n)的指令.因为Redis只使用一个线程进行数据查询,如果这些指令耗时过长,就会阻塞Redis,造成很大的延迟。虽然官方文档说KEYS*的查询速度非常快,扫描100万个key只需要40毫秒(在普通笔记本电脑上)(参见:https://redis.io/commands/keys),但是几十个ms对于性能要求高的系统来说,不算短,更不用说如果有上亿个key(一台机器可能存储上亿个key,比如一个100字节的key,1亿个key才10GB),时间较长。因此,尽量不要在生产环境的代码中使用这些执行速度慢的指令,Redis的作者在博客[8]中也提到过。另外,运维同学在查询Redis的时候,尽量不要使用。甚至,RedisEssential一书建议使用rename-commandKEYS''来禁用这个耗时的命令。除了这些耗时的指令,Redis中的事务和脚本可以将多个命令组合成一个原子执行过程,所以也可能会占用Redis很长时间,需要注意。如果想找出生产环境中使用的“慢指令”,可以使用SLOWLOGGETcount查看最近count条执行时间较长的指令。至于算多长时间,可以通过在redis.conf中设置slowlog-log-slower-than来定义。除此之外,一个可能很慢的指令是DEL,很多地方都没有提到,但是在redis.conf文件的注释[9]中提到了。总而言之,当DEL是一个大对象时,可能需要很长时间(甚至几秒)才能回收相应的内存。因此,推荐使用DEL的异步版本:UNLINK。后者会在不阻塞原线程的情况下,启动一个新的线程来删除目标key。此外,当一个key过期时,Redis一般需要同步删除它。删除密钥的一种方法是每秒检查10次是否设置了到期时间的密钥。这些密钥存储在全局结构中,可以通过server.db->e??xpires访问。检查的方法是:随机取出20把钥匙,删除过期的钥匙。如果刚才的20个key中有超过25%(也就是5个以上)过期,Redis认为过期的key相当多,继续重复步骤1,直到满足退出条件:其中的key拿出来一次过去的钥匙就没有那么多了。这里对性能的影响是,如果真的有很多key同时过期,那么Redis真的会一直循环执行删除,占用主线程。对此,Redis作者的建议[10]是要警惕EXPIREAT指令,因为它更容易导致key同时过期。我还看到了一些为密钥到期时间设置随机数的建议。最后,redis.conf还提供了一个方法让keys的过期删除操作异步进行,即在redis.conf中设置lazyfree-lazy-expireyes。优化数据结构,使用正确的算法一种数据类型(如字符串、列表)的增删改查的效率是由其底层存储结构决定的。我们在使用一种数据类型时,可以适当注意其底层的存储结构和算法,避免使用过于复杂的方法。举两个例子:ZADD的时间复杂度是O(log(N)),比其他数据类型增加一个新元素要复杂,所以慎用。如果Hash类型值的字段数有限,很可能会使用ziplist的结构进行存储,ziplist的查询效率可能没有相同字段数的hashtable高。如果需要,可以调整Redis的存储结构。除了时间性能的考虑,有时我们还需要节省存储空间。比如上面提到的ziplist结构,相对于hashtable结构来说更节省存储空间(RedisEssentials的作者在hashtable和ziplist结构的Hash中分别插入了500个字段,每个字段和value都是15个字符左右的字符串。)结果是,hashtable结构使用了ziplist空间的4倍。)。但是对于节省空间的数据结构,其算法的复杂性可能很高。因此,这里需要在具体问题面前做出取舍。欢迎关注公众号:朱小四的博客,回复:1024,即可收到redis独家资讯。如何做出更好的取舍?我觉得还是得深挖Redis的存储结构,才能让自己安心。我们下次再谈这方面的内容。以上三点是编程层面的考虑,写程序的时候要注意。以下几点同样会影响Redis的性能,但要解决,不仅仅是代码层面的调整,还有架构和运维方面的考虑。考虑操作系统和硬件是否影响Redis运行的外部环境,即操作系统和硬件显然也会影响Redis的性能。在官方文档中,给出了一些例子:CPU:IntelCPU优于AMDOpteron系列。这导致fork命令的速度变慢(fork是用来持久化的),尤其是Xen做虚拟化的时候。内存管理:在Linux操作系统中,为了让translationlookasidebuffer,即TLB,管理更多的内存空间(TLB只能缓存有限数量的页面),操作系统让一些内存页面变大,比如2MB或1GB,而不是通常的4096字节,这些大内存页面被称为大页面。同时,为了方便程序员使用这些大内存页,在操作系统中实现了透明巨页(THP)机制,使得大内存页对他们来说是透明的,可以像普通内存页一样使用。但是这种机制并不是数据库所需要的。可能是因为THP会让内存空间紧凑连续。如mongodb文档[11]所述,数据库需要稀疏内存空间,请禁用THP功能。Redis也不例外,但是Redis官方博客给出的原因是使用大内存页会减慢bgsave时fork的速度;如果fork之后,这些内存页在原来的进程中被修改了,需要复制(也就是copyonwrite),这样的复制会消耗大量的内存(毕竟人家都是hugepages,复制一份消耗很多成本)。因此,请关闭操作系统中的透明大页面功能。交换空间:当交换空间文件上存储了一些内存页,Redis要请求这些数据时,操作系统会阻塞Redis进程,然后从交换空间中取出需要的页放入内存。这涉及到整个流程的阻塞,所以可能会造成延迟问题。一种解决方案是禁止使用swap空间(RedisEssentials中建议,如果内存空间不足,请使用其他方法处理)。考虑由持久性引起的开销。Redis的一个重要功能就是持久化,就是将数据复制到硬盘中。基于持久化,有Redis数据恢复等功能。但是维护这个持久化功能也有性能开销。首先,RDB是完全持久化的。这种持久化方式是将Redis中的全量数据打包成rdb文件,放到硬盘上。但是RDB持久化过程是通过从原进程fork一个子进程来进行的,fork系统调用是需要时间的。根据RedisLab6年前的实验[12],在一个新的AWSEC2m1.small^13上,fork一个占用1GB内存的Redis进程需要700+毫秒,而在这段时间里,redis不能处理请求。虽然现在的机器应该比当时的好,但是也要考虑fork的开销。为此,使用合理的RDB持久化间隔,不要太频繁。接下来我们来看另一种持久化方式:AOF增量持久化。这种持久化方式会将你发给redis服务器的指令以文本的形式保存下来(格式遵循redis协议)。在这个过程中,会调用两个系统调用,一个是write(2),是同步的,另一个是fsync(2),是异步完成的。这两种情况都可能是延迟问题的原因:write可能因为输出缓冲区已满而被阻塞,或者内核正在将缓冲区中的数据同步到硬盘。fsync的作用是保证write写入aof文件的数据落到硬盘上。在7200转的硬盘上,可能会延迟20毫秒左右,消耗还是挺大的。更重要的是,在进行fsync时,写入可能会被阻塞。其中,write的阻塞似乎是可以接受的,因为没有更好的方法将数据写入文件。但是对于fsync,Redis允许三种配置,选择哪一种取决于备份时效性和性能之间的平衡:always:当appendfsync设置为always时,fsync会和client的指令同步执行,所以最容易造成延迟问题,但备份的及时性是最好的。everysec:每秒异步执行fsync。这时候redis的性能会好一些,但是fsync可能还是会blockwrite,这是一个折中的选择。no:redis不会主动启动fsync(不是从不fsync,那不太可能),而是内核决定什么时候fsync采用分布式架构——读写分离,数据分片,我们都是基于单一平台,或者用于优化的单个Redis服务。接下来我们考虑在网站规模变大的情况下,使用分布式架构来保证Redis性能的问题。首先说说在什么情况下必须(或最好)使用分布式架构:数据量大,单台服务器不可能装在内存中。比如1T的量级,需要服务于单台高可用服务器的请求压力。过大解决这些问题可以采用数据分片或主从分离,或两者兼有(即在集群节点上进行分片,同时设置主从结构)。这样的架构可以增加一个新的性能提升的切入点:将慢速指令发送到一些从库执行,将持久化功能放在一个很少使用的从库上,在它前面拆分一些大列表,两者都是基于单-Redis的线程特性,利用其他进程(甚至机器)来补充性能。当然,使用分布式架构也可能会对性能产生影响。比如请求需要转发,数据需要不断复制分发。(待查)其实影响Redis性能的东西很多,比如activerehashing(keys主表的rehashing,每秒10次,关掉可以提升一点性能),但是这篇博客已经写的很长。而且,更重要的是不要收集别人提过的问题,然后死记硬背;而是要掌握Redis的基本原理,用不变的方式去解决新的问题。
