作者: 刘思宁整理:朱⼩厮
www.jianshu.com/p/67093716547b
在⼀些⽹络服务的系统中,Redis 的性能,可能是⽐ MySQL 等硬盘数据库的性能更重要的课题。⽐如微博,把热点微博[1],最新的⽤户关系,都存储在 Redis 中,⼤量的查询击中 Redis,⽽不⾛ MySQL。
那么,针对 Redis 服务,我们能做哪些性能优化呢?或者说,应该避免哪些性能浪费呢?
Redis 性能的基本⾯
在讨论优化之前,我们需要知道,Redis 服务本⾝就有⼀些特性,⽐如单线程运⾏。除⾮修改 Redis 的源代码,不然这些特性,就是我们思考性能优化的基本⾯。
那么,有哪些 Redis 基本特性需要我们考虑呢?Redis 的项⽬介绍中概括了它特性:
Redis is an in-memory database that persists on disk. The data model is key-value, but many different kind of values aresupported.
⾸先,Redis 使⽤操作系统提供的虚拟内存来存储数据。⽽且,这个操作系统⼀般就是指 Unix。Windows 上也能运⾏ Redis,但是需要特殊处理。如果你的操作系统使⽤交换空间,那么 Redis 的数据可能会被实际保存在硬盘上。
其次,Redis ⽀持持久化,可以把数据保存在硬盘上。很多时候,我们也确实有必要进⾏持久化来实现备份,数据恢复等需求。但持久化不会凭空发⽣,它也会占⽤⼀部分资源。
第三,Redis 是⽤ key-value 的⽅式来读写的,⽽ value 中⼜可以是很多不同种类的数据;更进⼀步,⼀个数据类型的底层还有被存储为不同的结构。不同的存储结构决定了数据增删改查的复杂度以及性能开销。
最后,在上⾯的介绍中没有提到的是,Redis ⼤多数时候是单线程运⾏[2]的(single-threaded),即同⼀时间只占⽤⼀个 CPU,只能有⼀个指令在运⾏,并⾏读写是不存在的。很多操作带来的延迟问题,都可以在这⾥找到答案。
关于最后这个特性,为什么 Redis 是单线程的,却能有很好的性能(根据 Amdahl’s Law,优化耗时占⽐⼤的过程,才更有意义),两句话概括是:Redis 利⽤了多路 I/O 复⽤机制[3],处理客户端请求时,不会阻塞主线程;Redis 单纯执⾏(⼤多数指令)⼀个指令不到 1 微秒[4],如此,单核 CPU ⼀秒就能处理 1 百万个指令(⼤概对应着⼏⼗万个请求吧),⽤不着实现多线程(⽹络才是瓶颈[5])。
优化⽹络延时
Redis 的官⽅博客在⼏个地⽅都说,性能瓶颈更可能是⽹络[6],那么我们如何优化⽹络上的延时呢?
⾸先,如果你们使⽤单机部署(应⽤服务和 Redis 在同⼀台机器上)的话,使⽤ Unix 进程间通讯来请求 Redis 服务,速度⽐ localhost局域⽹(学名 loopback)更快。官⽅⽂档[7]是这么说的,想⼀想,理论上也应该是这样的。但很多公司的业务规模不是单机部署能⽀撑的,所以还是得⽤ TCP。
Redis 客户端和服务器的通讯⼀般使⽤ TCP 长链接。如果客户端发送请求后需要等待 Redis 返回结果再发送下⼀个指令,客户端和 Redis的多个请求就构成下⾯的关系:
(备注:如果不是你要发送的 key 特别长,⼀个 TCP 包完全能放下 Redis 指令,所以只画了⼀个 push 包)这样这两次请求中,客户端都需要经历⼀段⽹络传输时间。
但如果有可能,完全可以使⽤ multi-key 类的指令来合并请求,⽐如两个 GET key 可以⽤ MGET key1 key2 合并。这样在实际通讯中,请求数也减少了,延时⾃然得到好转。
如果不能⽤ multi-key 指令来合并,⽐如⼀个 SET,⼀个 GET ⽆法合并。怎么办?
Redis 中有⾄少这样两个⽅法能合并多个指令到⼀个 request 中,⼀个是 MULTI/EXEC,⼀个是 script。前者本来是构建 Redis 事务的⽅法,但确实可以合并多个指令为⼀个 request,它到通讯过程如下。⾄于 script,最好利⽤缓存脚本的 sha1 hash key 来调起脚本,这样通讯量更⼩。
这样确实更能减少⽹络传输时间,不是么?但如此以来,就必须要求这个 transaction / script 中涉及的 key 在同⼀个 node 上,所以要酌情考虑。
如果上⾯的⽅法我们都考虑过了,还是没有办法合并多个请求,我们还可以考虑合并多个 responses。⽐如把 2 个回复信息合并:
这样,理论上可以省去 1 次回复所⽤的⽹络传输时间。这就是 pipeline 做的事情。举个 ruby 客户端使⽤ pipeline 的例⼦:
require 'redis'
@redis = Redis.new()@redis.pipelined do @redis.get 'key1'
@redis.set 'key2' 'some value'end
# => [1, 2]
据说,有些语⾔的客户端,甚⾄默认就使⽤ pipeline 来优化延时问题,⽐如 node_redis。
另外,不是任意多个回复信息都可以放进⼀个 TCP 包中,如果请求数太多,回复的数据很长(⽐如 get ⼀个长字符串),TCP 还是会分包传输,但使⽤ pipeline,依然可以减少传输次数。
pipeline 和上⾯的其他⽅法都不⼀样的是,它不具有原⼦性。所以在 cluster 状态下的集群上,实现 pipeline ⽐那些原⼦性的⽅法更有可能。⼩结⼀下:
1. 使⽤ unix 进程间通信,如果单机部署
2. 使⽤ multi-key 指令合并多个指令,减少请求数,如果有可能的话3. 使⽤ transaction、script 合并 requests 以及 responses4. 使⽤ pipeline 合并 response
警惕执⾏时间长的操作
在⼤数据量的情况下,有些操作的执⾏时间会相对长,⽐如 KEYS *,LRANGE mylist 0 -1,以及其他算法复杂度为 O(n) 的指令。因为Redis 只⽤⼀个线程来做数据查询,如果这些指令耗时很长,就会阻塞 Redis,造成⼤量延时。
尽管官⽅⽂档中说 KEYS * 的查询挺快的,(在普通笔记本上)扫描 1 百万个 key,只需 40 毫秒(参见:
https://redis.io/commands/keys),但⼏⼗ ms 对于⼀个性能要求很⾼的系统来说,已经不短了,更何况如果有⼏亿个 key(⼀台机器完全可能存⼏亿个 key,⽐如⼀个 key 100字节,1 亿个 key 只有 10GB),时间更长。
所以,尽量不要在⽣产环境的代码使⽤这些执⾏很慢的指令,这⼀点 Redis 的作者在博客[8]中也提到了。另外,运维同学查询 Redis 的时候也尽量不要⽤。甚⾄,Redis Essential 这本书建议利⽤ rename-command KEYS '' 来禁⽌使⽤这个耗时的指令。
除了这些耗时的指令,Redis 中 transaction,script,因为可以合并多个 commands 为⼀个具有原⼦性的执⾏过程,所以也可能占⽤ Redis很长时间,需要注意。
如果你想找出⽣产环境使⽤的「慢指令」,那么可以利⽤ SLOWLOG GET count 来查看最近的 count 个执⾏时间很长的指令。⾄于多长算长,可以通过在 redis.conf 中设置 slowlog-log-slower-than 来定义。
除此之外,在很多地⽅都没有提到的⼀个可能的慢指令是 DEL,但 redis.conf ⽂件的注释[9]中倒是说了。长话短说就是 DEL ⼀个⼤的object 时候,回收相应的内存可能会需要很长时间(甚⾄⼏秒),所以,建议⽤ DEL 的异步版本:UNLINK。后者会启动⼀个新的 thread 来删除⽬标 key,⽽不阻塞原来的线程。
更进⼀步,当⼀个 key 过期之后,Redis ⼀般也需要同步的把它删除。其中⼀种删除 keys 的⽅式是,每秒 10 次的检查⼀次有设置过期时间的 keys,这些 keys 存储在⼀个全局的 struct 中,可以⽤ server.db->expires 访问。检查的⽅式是:
1. 从中随机取出 20 个 keys2. 把过期的删掉。
3. 如果刚刚 20 个 keys 中,有 25% 以上(也就是 5 个以上)都是过期的,Redis 认为,过期的 keys 还挺多的,继续重复步骤 1,直到满⾜退出条件:某次取出的 keys 中没有那么多过去的 keys。 这⾥对于性能的影响是,如果真的有很多的 keys 在同⼀时间过期,那么 Redis 真的会⼀直循环执⾏删除,占⽤主线程。
对此,Redis 作者的建议[10]是警惕 EXPIREAT 这个指令,因为它更容易产⽣ keys 同时过期的现象。我还见到过⼀些建议是给 keys 的过期时间设置⼀个随机波动量。最后,redis.conf 中也给出了⼀个⽅法,把 keys 的过期删除操作变为异步的,即,在 redis.conf 中设置 lazyfree-lazy-expire yes。
优化数据结构、使⽤正确的算法
⼀种数据类型(⽐如 string,list)进⾏增删改查的效率是由其底层的存储结构决定的。
我们在使⽤⼀种数据类型时,可以适当关注⼀下它底层的存储结构及其算法,避免使⽤复杂度太⾼的⽅法。举两个例⼦:
1. ZADD 的时间复杂度是 O(log(N)),这⽐其他数据类型增加⼀个新元素的操作更复杂,所以要⼩⼼使⽤。
2. 若 Hash 类型的值的 fields 数量有限,它很有可能采⽤ ziplist 这种结构做存储,⽽ ziplist 的查询效率可能没有同等字段数量的hashtable 效率⾼,在必要时,可以调整 Redis 的存储结构。 除了时间性能上的考虑,有时候我们还需要节省存储空间。⽐如上⾯提到的 ziplist 结构,就⽐ hashtable 结构节省存储空间(RedisEssentials 的作者分别在 hashtable 和 ziplist 结构的 Hash 中插⼊ 500 个 fields,每个 field 和 value 都是⼀个 15 位左右的字符串,结果是hashtable 结构使⽤的空间是 ziplist 的 4 倍。)。但节省空间的数据结构,其算法的复杂度可能很⾼。所以,这⾥就需要在具体问题⾯前做出权衡。欢迎关注公众号:朱⼩厮的博客,回复:1024,可以领取redis专属资料。
如何做出更好的权衡?我觉得得深挖 Redis 的存储结构才能让⾃⼰安⼼。这⽅⾯的内容我们下次再说。
以上这三点都是编程层⾯的考虑,写程序时应该注意啊。下⾯这⼏点,也会影响 Redis 的性能,但解决起来,就不只是靠代码层⾯的调整了,还需要架构和运维上的考虑。
考虑操作系统和硬件是否影响性能
Redis 运⾏的外部环境,也就是操作系统和硬件显然也会影响 Redis 的性能。在官⽅⽂档中,就给出了⼀些例⼦:
1. CPU:Intel 多种 CPU 都⽐ AMD 皓龙系列好
2. 虚拟化:实体机⽐虚拟机好,主要是因为部分虚拟机上,硬盘不是本地硬盘,监控软件导致 fork 指令的速度慢(持久化时会⽤到fork),尤其是⽤ Xen 来做虚拟化时。
3. 内存管理:在 linux 操作系统中,为了让 translation lookaside buffer,即 TLB,能够管理更多内存空间(TLB 只能缓存有限个page),操作系统把⼀些 memory page 变得更⼤,⽐如 2MB 或者 1GB,⽽不是通常的 4096 字节,这些⼤的内存页叫做 huge
pages。同时,为了⽅便程序员使⽤这些⼤的内存 page,操作系统中实现了⼀个 transparent huge pages(THP)机制,使得⼤内存页对他们来说是透明的,可以像使⽤正常的内存 page ⼀样使⽤他们。但这种机制并不是数据库所需要的,可能是因为 THP 会把内存空间变得紧凑⽽连续吧,就像mongodb 的⽂档[11]中明确说的,数据库需要的是稀疏的内存空间,所以请禁掉 THP 功能。Redis 也不例外,但 Redis 官⽅博客上给出的理由是:使⽤⼤内存 page 会使 bgsave 时,fork 的速度变慢;如果 fork 之后,这些内存 page 在原进程中被修改了,他们就需要被复制(即 copy on write),这样的复制会消耗⼤量的内存(毕竟,⼈家是 huge pages,复制⼀份消耗成本很⼤)。所以,请禁⽌掉操作系统中的 transparent huge pages 功能。
4. 交换空间:当⼀些内存 page 被存储在交换空间⽂件上,⽽ Redis ⼜要请求那些数据,那么操作系统会阻塞 Redis 进程,然后把想要的 page,从交换空间中拿出来,放进内存。这其中涉及整个进程的阻塞,所以可能会造成延时问题,⼀个解决⽅法是禁⽌使⽤交换空间(Redis Essentials 中如是建议,如果内存空间不⾜,请⽤别的⽅法处理)。
考虑持久化带来的开销
Redis 的⼀项重要功能就是持久化,也就是把数据复制到硬盘上。基于持久化,才有了 Redis 的数据恢复等功能。但维护这个持久化的功能,也是有性能开销的。 ⾸先说,RDB 全量持久化。
这种持久化⽅式把 Redis 中的全量数据打包成 rdb ⽂件放在硬盘上。但是执⾏ RDB 持久化过程的是原进程 fork 出来⼀个⼦进程,⽽fork 这个系统调⽤是需要时间的,根据Redis Lab 6 年前做的实验[12],在⼀台新型的 AWS EC2 m1.small^13 上,fork ⼀个内存占⽤ 1GB 的Redis 进程,需要 700+ 毫秒,⽽这段时间,redis 是⽆法处理请求的。
虽然现在的机器应该都会⽐那个时候好,但是 fork 的开销也应该考虑吧。为此,要使⽤合理的 RDB 持久化的时间间隔,不要太频繁。接下来,我们看另外⼀种持久化⽅式:AOF 增量持久化。
这种持久化⽅式会把你发到 redis server 的指令以⽂本的形式保存下来(格式遵循 redis protocol),这个过程中,会调⽤两个系统调⽤,⼀
个是 write(2),同步完成,⼀个是 fsync(2),异步完成。这两部都可能是延时问题的原因:
1. write 可能会因为输出的 buffer 满了,或者 kernal 正在把 buffer 中的数据同步到硬盘,就被阻塞了。
2. fsync 的作⽤是确保 write 写⼊到 aof ⽂件的数据落到了硬盘上,在⼀个 7200 转/分的硬盘上可能要延时 20 毫秒左右,消耗还是挺⼤的。更重要的是,在 fsync 进⾏的时候,write 可能会被阻塞。其中,write 的阻塞貌似只能接受,因为没有更好的⽅法把数据写到⼀个⽂件中了。但对于 fsync,Redis 允许三种配置,选⽤哪种取决于你对备份及时性和性能的平衡:
1. always:当把 appendfsync 设置为 always,fsync 会和客户端的指令同步执⾏,因此最可能造成延时问题,但备份及时性最好。2. everysec:每秒钟异步执⾏⼀次 fsync,此时 redis 的性能表现会更好,但是 fsync 依然可能阻塞 write,算是⼀个折中选择。3. no:redis 不会主动出发 fsync (并不是永远不 fsync,那是不太可能的),⽽由 kernel 决定何时 fsync
使⽤分布式架构 —— 读写分离、数据分⽚
以上,我们都是基于单台,或者单个 Redis 服务进⾏优化。下⾯,我们考虑当⽹站的规模变⼤时,利⽤分布式架构来保障 Redis 性能的问题。
⾸先说,哪些情况下不得不(或者最好)使⽤分布式架构:
1. 数据量很⼤,单台服务器内存不可能装得下,⽐如 1 个 T 这种量级2. 需要服务⾼可⽤3. 单台的请求压⼒过⼤
解决这些问题可以采⽤数据分⽚或者主从分离,或者两者都⽤(即,在分⽚⽤的 cluster 节点上,也设置主从结构)。这样的架构,可以为性能提升加⼊新的切⼊点:1. 把慢速的指令发到某些从库中执⾏
2. 把持久化功能放在⼀个很少使⽤的从库上3. 把某些⼤ list 分⽚
其中前两条都是根据 Redis 单线程的特性,⽤其他进程(甚⾄机器)做性能补充的⽅法。
当然,使⽤分布式架构,也可能对性能有影响,⽐如请求需要被转发,数据需要被不断复制分发。(待查)
后话
其实还有很多东西也影响 Redis 的性能,⽐如 active rehashing(keys 主表的再哈希,每秒 10 次,关掉它可以提升⼀点点性能),但是这篇博客已经写的很长了。⽽且,更重要不是收集已经被别⼈提出的问题,然后记忆解决⽅案;⽽是掌握 Redis 的基本原理,以不变应万变的⽅式决绝新出现的问题。
因篇幅问题不能全部显示,请点此查看更多更全内容