本文内容提要
- Redis为什么这么快
1.1. 数据结构SDS的妙用
1.2. 性能优良的事件模型驱动
1.3. 基于内存的操作 - Redis为什么这么靠谱
2.1. AOF持久化
2.2. RDB持久化
2.3. Sentinel高可用 - Redis6.x多线程一览
- Redis最佳实践
Part1Redis为什么这么快
1.1数据结构SDS的妙用
我们知道redis的底层是用c语言来编写的,但是,数据结构确没有直接套用C的结构,而是根据redis的定位自建了一套数据结构。
C语言中的字符串
SDS定义下的字符串结构:
可以看到,相比于C语言来说,也就多了几个字段,分别用来标识空闲空间和当前数据长度,但简直是神来之笔:
- 可以O(1)复杂度获取字符串长度;有len字段的存在,无需像C结构一样遍历计数。
- 杜绝缓存区溢出;C字符串不记录已占用的长度,所以需要提前分配足够空间,一旦空间不够则会溢出。而有free字段的存在,让SDS在执行前可以判断并分配足够空间给程序
- 减少字符串修改带来的内存重分配次数;有free字段的存在,使SDS有了空间预分配和惰性释放的能力。
- 对二进制是安全的;二进制可能会有字符和C字符串结尾符 '\0' 冲突,在遍历和获取数据时产生截断异常,而SDS有了len字段,准确了标识了数据长度,不需担心被中间的 '\0' 截断。
上面的内容以字符串来说明SDS和C语言数据结构的差异和优势。顺便来看看链表、hash表、跳表分别被Redis设计成了什么样的数据结构:
可以看到,Redis在设计数据结构的时候出发点是一致的。总结起来就是一句话:空间换时间。
用牺牲存储空间和微小的计算代价,来换取数据的快速操作
1.2性能优良的事件驱动模式
redis6.x之前,一直在说单线程如何如之何的好。
那么,具体单线程体现在哪里,又是怎么完成数据读写工作的呢?
$ 单线程
关于新版本的多线程模型在后面小节单独说,这里先说单线程。
所谓单线程是指对数据的所有操作都是由一个线程按顺序挨个执行的,使用单线程可以:
- 避免了不必要的上下文切换和竞争条件,也不存在多进程或者多线程导致的切换而消耗CPU;
- 不用去考虑各种锁的问题,不存在加锁释放锁操作,没有因为可能出现死锁而导致的性能消耗。
然而,使用了单线程的处理方式,就意味着到达服务端的请求不可能被立即处理。
那么怎么来保证单线程的资源利用率和处理效率呢?
$ IO多路复用和事件驱动
Redis服务端,从整体上来看,其实是一个事件驱动的程序,所有的操作都以事件的方式来进行。
如图所示,Redis的事件驱动架构由套接字、I/O多路复用、文件事件分派器、事件处理器四个部分组成:
套接字(Socket),是对网络中不同主机上的应用进程之间进行双向通信的端点的抽象。
I/O多路复用,通过监视多个描述符,当描述符就绪,则通知程序进行相应的操作,来帮助单个线程高效的处理多个连接请求。
Redis为每个IO多路复用函数都实现了相同的API,因此,底层实现是可以互换的。
Reids默认的IO多路复用机制是epoll,和select/poll等其他多路复用机制相比,epoll具有诸多优点:
| 并发连接限制 | 内存拷贝 | 活跃连接感知 |
epoll | 没有最大并发连接的限制 | 共享内存,无需内存拷贝 | 基于event callback方式,只感知活跃连接 |
select | 受fd限制,32位机默认1024个/64位机默认2048个 | 把fd集合从用户态拷贝到内核态 | 只能感知有fd就绪,但无法定位,需要遍历+轮询 |
poll | 采用链表存储fd无最大并发连接数限制 | 同select | 同select,需遍历+轮询 |
事件驱动,Redis设计的事件分为两种,文件事件和时间事件,文件事件是对套接字操作的抽象,而时间事件则是对一些定时操作的抽象。
文件事件:
- 客户端连接请求(AE_READABLE事件)
- 客户端命令请求(AE_READABLE事件)和事
- 服务端命令回复(AE_WRITABLE事件)
时间事件: 分为定时事件和周期性时间;redis的所有时间事件都存放在一个无序链表中,当时间事件执行器运行时,需要遍历链表以确保已经到达时间的事件被全部处理。
可以看到,Redis整个执行方案是通过高效的I/O多路复用件驱动方式加上单线程内存操作来达到优秀的处理效率和极高的吞吐量。
1.3基于内存的操作
上面的小节也提到了,redis之所以可以使用单线程来处理,其中的一个原因是,内存操作对资源损耗较小,保证了处理的高效性。
如此宝贵的内存资源,Redis是怎么维护和管理的呢?
$ 除了增删改查还有哪些维护性操作[1]
命中率统计,在读取一个键之后,服务器会根据键是否存在来更新服务器的键空间命中次数或键空间不命中次数。
LRU时间更新,在读取一个键之后,服务器会更新键的LRU时间,这个值可以用于计算键的闲置时间。
惰性删除,如果服务器在读取一个键时发现该键已经过期,那么服务器会先删除这个过期键,然后才执行余下的其他操作。
键的dirty标识,如果有客户端使用WATCH命令监视了该键,服务器会将这个键标记为dirty,让事务程序注意到这个键已经被修改过。每次修改都会对dirty加一,用于触发持久化和复制。
数据库通知,“如果服务器开启了数据库通知功能,那么在对键进行修改之后,服务器将按配置发送相应的数据库通知”
$ Redis何如管理内存
过期键删除,内存和CPU资源都是宝贵的,Redis通过定期删除设定合理的执行时长和执行频率,配合惰性删除兜底的方式,来达到CPU时间占用和内存浪费之间的平衡。
数据淘汰,如果key生产的太快,定期删除操作跟不上新生产的速率,而这些key又很少被访问无法触发惰性删除,是否会把内存撑爆?回答是不会,因为redis有数据淘汰策略:
- noeviction:当内存不足以容纳新写入数据时,新写入操作会报错。
- allkeys-lru:当内存不足以容纳新写入数据时,,移除最近最少使用的 Key。
- allkeys-random:当内存不足以容纳新写入数据时,随机移除某个 Key。
- volatile-lru:当内存不足以容纳新写入数据时,在设置了过期时间的键空间中,移除最近最少使用的 Key。
- volatile-random:当内存不足以容纳新写入数据时,在设置了过期时间的键空间中,随机移除某个 Key。
- volatile-ttl:当内存不足以容纳新写入数据时,在设置了过期时间的键空间中,有更早过期时间的 Key 优先移除。
值得一提的是,这里的lru和平常我们所熟知的lru还不完全一样,redis使用的是采样概率的思想,省略了双向链表的内存消耗。
Redis 会在每一次处理命令的时候判断是否达到了最大限制,如果达到则使用对应的算法去删除涉及到的Key,这时,我们前面所维护过键的LRU值就会派上用场了。
Part2Redis为什么这么靠谱
天有不测风云,服务器也有趴窝的时候,Redis这个基于内存的存储遇到服务器宕机该怎么应对呢?
2.1RDB持久化
持久化是一种常见的解决方案,那么,我们首先能想到的最简单的持久化方案,就是每隔一段时间把内存里的数据保存一次,来避免绝大部分数据的丢失。这也是Redis的RDB持久化得思路。
RDB有两种方式,save和bgsave
save,会阻塞服务器的其他操作,直到save执行完成,所以,这个期间的所有命令请求都会被拒绝。对客户端影响较大。
BGSave,由子进程进行数据保存,期间redis仍然可以继续处理客户端请求。为了防止竞争和冲突,bgsave被设计成和save/bgrewriteaof操作互斥。
Redis服务器默认每100毫秒执行一次,如果数据库修改次数(dirty计数器)大于设置的阈值,并且距离上次执行保存的时间(lastsave属性)大于设置的阈值,则执行保存操作。
因为是统一批量的保存操作,rdb文件有二进制存储、结构紧凑、空间消耗少、恢复速度快等特点,在持久化方案上不可或缺。
2.2AOF持久化
然而,因为bgsave的周期间隔和保存触发条件等原因,在服务器宕机时,不可避免的会丢失一部分最新的数据。这就需要一些辅助手段来做持久化补充。
RDB保存的是键值对,而AOF则用来保存写命令。
为什么AOF保存的是命令,而不是键值对呢?
Coder的技术之路认为,一是因为aof刷盘,是在文件事件处理过程当中的,具体位置是在结束一个事件循环之前,调用追加函数进行,所以,使用请求命令来存储更方便;二是如果遇到追加过程中命令被破坏,也可以通过redis-check-aof来恢复(命令恢复起来比较方便)。
AOF刷盘策略,由于aof追加动作是和客户端请求处理串行执行的,所以每次都刷盘对性能影响较大,因此都是先追加到aof_buf缓存区里,而是否同步到AOF文件中则依赖always、everysec(默认)、no的刷盘配置。想比everysec ,always对性能影响较大,而no则容易丢失数据。
AOF文件重写压缩,AOF因为保存了请求命令,自然要比RDB更大,并且随着程序的运行,会越来越大,然而,文件中有很多冗余的命令数据是可以压缩的,因为对于某个键值对,某一时刻只会有一个状态。
那么,在重写过程中新产生的操作该怎么办呢?
2.3Sentinel高可用解决方案
上面两个小节,主要是在阐述单机服务器的数据稳定性保障,那么,如果是多机、多进程该怎么来保障呢?
哨兵的作用:监视服务节点的健康
当主节点宕机时,由哨兵感知,并在从节点中重新选举主节点:
同时,sentinel还会监视宕机的master节点,恢复之后会将其设置为从节点加入集群。
除了主从切换的sentinel方案,还有Cluster集群模式来保障redis的高可用,用来解决主从复制的存储浪费问题。
Part3Redis6.x的多线程
之前已经阐述过了单线程模型的整体流程,这里不太赘述。
Redis的多线程模型,不是传统意义上的多线程并发,而是把socket解析回写的这部分操作并行化,以解决IO上的时间消耗带来的系统瓶颈。
对客户端的任何请求,其实还是主线程在执行,避免了操作相同数据时线程间的竞争,把io部分并行化,降低了io对资源的损耗,从而提升了系统的吞吐量。仔细想来,感觉和rpc中的异步调用差不多意思,都是绑定来源,等待处理完成后给给各来源返回对应结果。
Part4Redis最佳实践
Redis被当做分布式缓存的应用场景非常普遍,有关缓存穿透、缓存击穿、缓存雪崩、数据漂移、缓存踩踏、缓存污染、热点key等常见问题,在上一篇文章 诸多策略,缓存为王中已经有了详细阐述,这里不再重复。
这里主要给出一些日常开发中的关注点:
- Key的设计。尽量控制key的长度,一是过长会占用较多空间,二是我们知道键空间是字典类型,即时本身在查找过程中很快,过长的键也会对比较判断时间有所增加。
- 批量命令的使用。因为redis操作绝大部分都耗在网络传输上,将多次传输改为一次传输,大概率会提升效果。
- value的大小。尽量避免大value,原因同上,value太大会影响网络传输效率。比如,之前的一次经历,批量获取了200个商品的信息(信息比较多,可以认为是大value),发现很慢,后来把200拆成了4个50,并行去调用,效果提升的比较明显。这个问题也可以考虑用数据压缩的方式进行优化
- 复杂命令的使用。比如排序、聚合等等操作,应该在离线阶段就处理完毕,然后再存入缓存,而不是在线使用复杂命令去计算。
- 善用数据结构。redis丰富的数据结构对支撑业务有天然的优势,比如,之前曾用消息队列配合bitmap数据结构存储和维护商品的多个状态(库存、上下架、秒杀、黑白名单等),getbit来直接判断该商品是否允许展示。