Redis作为应对高并发场景的利器,它是如何实现高性能的呢?
- IO多路复用
传统对于并发情况,假如一个进程不行,那搞多个进程不就可以同时处理多个客户端连接了么?多进程是可以解决一些并发问题,但是还是有一些问题,上下文切换开销,线程循环创建,从PCB来回恢复效率较低。随着客户端请求增多,那么线程也随着请求数量直线上升,如果是并发的时候涉及到数据共享访问,有时候涉及到使用锁来控制范围顺序,影响其他线程执行效率。
在学NIO之前得先去了解IO模型
(1)同步阻塞IO(Blocking IO):即传统的IO模型。
(2)同步非阻塞IO(Non-blocking IO):默认创建的socket都是阻塞的,非阻塞IO要求socket被设置为NONBLOCK。注意这里所说的NIO并非Java的NIO(New IO)库。
(3)多路复用IO(IO Multiplexing):即经典的Reactor设计模式,有时也称为异步阻塞IO,Java中的Selector和Linux中的epoll都是这种模型。
(4)异步IO(Asynchronous IO):即经典的Proactor设计模式,也称为异步非阻塞IO。
- 单线程
- 没有了访问共享资源加锁的性能损耗
- 开发和调试非常友好,可维护性高
- 减少多个线程上下文切换带来的额外开销
- 基于内存存储
google 工程师Jeff Dean 首先在他关于分布式系统的ppt文档列出来的,到处被引用的很多。
1纳秒等于10亿分之一秒,= 10 ^ -9 秒
操作 | 耗时 |
L1 cache reference 读取CPU的一级缓存 | 0.5 ns |
Branch mispredict(转移、分支预测) | 5 ns |
L2 cache reference 读取CPU的二级缓存 | 7 ns |
Mutex lock/unlock 互斥锁\解锁 | 100 ns |
Main memory reference 读取内存数据 | 100 ns |
Compress 1K bytes with Zippy 1k字节压缩 | 10,000 ns |
Send 2K bytes over 1 Gbps network 在1Gbps的网络上发送2k字节 | 20,000 ns |
Read 1 MB sequentially from memory 从内存顺序读取1MB | 250,000 ns |
Round trip within same datacenter 从一个数据中心往返一次,ping一下 | 500,000 ns |
Disk seek 磁盘搜索 | 10,000,000 ns |
Read 1 MB sequentially from network 从网络上顺序读取1兆的数据 | 10,000,000 ns |
Read 1 MB sequentially from disk 从磁盘里面读出1MB | 30,000,000 ns |
Send packet CA->Netherlands->CA 一个包的一次远程访问 | 150,000,000 ns |
可以看到内存和磁盘操作的性能差距是巨大的。这也是redis之所以读写如此快的原因。
- 高效数据结构
常用的六大Redis的数据结构,及他们各自的底层实现结构
- string hash list set sortset(zset)
- string的底层实现是简单动态字符串(SDS -simple dynamic string)
- hash的底层实现是hash表或则压缩列表(ziplist)
- list的底层实现是双向列表(quicklist)或者压缩列表
- set的底层实现是hash表(hashtable)或者整数数组
- sortset(zset)的底层实现是压缩列表或者跳表
各个数据结构的底层实现概览
https://www.jianshu.com/p/45def7c525dd
- 写时拷贝(CopyOnWrite)
redis会从主进程中通过fork()系统调用,创建一个子进程,将父进程的 虚拟内存 与 物理内存 映射关系复制到子进程中,并将设置内存共享的,子进程只负责将内存里面数据写入到rdb进行持久化操作,如果在操作的时候主进程对内存修改了,使用写时拷贝技术,将对应的内存创建一个副本然后进行写入持久化。
RDB的流程:
当redis需要做持久化时,会fork一个子进程
redis根据pid判断下一步操作,如果是子进程,即pid==0时,则负责将内容写入临时RDB文件,完成之后通知父进程。
而父进程则继续处理命令并轮询等待子进程信号完成BGSAVE(“后台保存”)功能
pid = fork();
if(pid == 0){
// 写入临时RDB文件
rdbsave();
// 通知父进程
signal_parent();
} else if(pid > 0){
// 父进程继续处理请求,并轮询子进程信号
handle_request_and_wait_signal();
}
伪代码很简单,Redis每次快照持久化都是将内存数据完整写入到磁盘一次,并不是增量的只同步新数据。
如果数据量大的话,而且写操作比较多,必然会引起大量的磁盘io操作,可能会严重影响性能
因此redis会fork一个子进程出来干活,这也是为什么线上禁止使用SAVE命令的原因:可能会导致Redis阻塞!
写时复制(Copy-On-Write),由前文可知,Linux复制子进程时,并不会为所有程序空间的块都分配物理块,写时复制技术在Fork技术上有了进一步的优化,Text段也不重新分配物理内存,也就是刚分配时是下面这种形式:
写时复制:内核只为新生成的子进程创建虚拟空间结构,它们来复制于父进程的虚拟空间结构,但是不为这些段分配物理内存,任何段都不分配,它们共享父进程的物理空间,当父子进程中有更改相应段的行为发生时,再为子进程相应的段分配物理空间,例如途中的Stack块,注意重新分配是以内存页,也就是pagecache(4k)为基本单位的。
https://www.shuzhiduo.com/A/o75N6Nk95W/
- 客户端管道批量命令
redis在客户端程序中做了一些优化引入了一个管道(pipelining)概念。
管道会把多条无关命令批量执行,以减少多个命令分别执行带来的网络交互时间,在一些批量操作数据的场景。
redis客户端执行一条命令分4个过程:
发送命令-〉命令排队-〉命令执行-〉返回结果
这个过程称为Round trip time(简称RTT, 往返时间),Redis的原生批命令(mget和mset)有效节约了RTT,但大部分命令(如hgetall,并没有mhgetall)不支持批量操作,需要消耗N次RTT ,这个时候需要pipeline来解决这个问题。
pipeline可以将多次IO往返的时间缩减为一次RTT ,前提是pipeline执行的指令之间没有因果相关性。
- 零拷贝技术
我们知道,在服务端,处理消费的大致逻辑是这样的:
首先,从文件中找到消息数据,读到内存中;
然后,把消息通过网络发给客户端。
这个过程中,数据实际上做了 2 次或者 3 次复制:
- 从文件复制数据到 PageCache 中,如果命中 PageCache,这一步可以省掉;
- 从 PageCache 复制到应用程序的内存空间中,也就是我们可以操作的对象所在的内存;
- 从应用程序的内存空间复制到 Socket 的缓冲区,这个过程就是我们调用网络应用框架的 API 发送数据的过程。
使用零拷贝技术可以把这个复制次数减少一次,上面的 2、3 步骤两次复制合并成一次复制。直接从 PageCache 中把数据复制到 Socket 缓冲区中,这样不仅减少一次数据复制,更重要的是,由于不用把数据复制到用户内存空间,DMA 控制器可以直接完成数据复制,不需要 CPU 参与,速度更快。