Redis作为应对高并发场景的利器,它是如何实现高性能的呢?

  1. IO多路复用
    传统对于并发情况,假如一个进程不行,那搞多个进程不就可以同时处理多个客户端连接了么?多进程是可以解决一些并发问题,但是还是有一些问题,上下文切换开销,线程循环创建,从PCB来回恢复效率较低。随着客户端请求增多,那么线程也随着请求数量直线上升,如果是并发的时候涉及到数据共享访问,有时候涉及到使用锁来控制范围顺序,影响其他线程执行效率。
    在学NIO之前得先去了解IO模型

(1)同步阻塞IO(Blocking IO):即传统的IO模型。

redisson需要的jar redis的nio_java

(2)同步非阻塞IO(Non-blocking IO):默认创建的socket都是阻塞的,非阻塞IO要求socket被设置为NONBLOCK。注意这里所说的NIO并非Java的NIO(New IO)库。

redisson需要的jar redis的nio_redis_02

(3)多路复用IO(IO Multiplexing):即经典的Reactor设计模式,有时也称为异步阻塞IO,Java中的Selector和Linux中的epoll都是这种模型。

redisson需要的jar redis的nio_子进程_03

redisson需要的jar redis的nio_java_04

(4)异步IO(Asynchronous IO):即经典的Proactor设计模式,也称为异步非阻塞IO。

  1. 单线程
  • 没有了访问共享资源加锁的性能损耗
  • 开发和调试非常友好,可维护性高
  • 减少多个线程上下文切换带来的额外开销
  1. 基于内存存储

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之所以读写如此快的原因。

  1. 高效数据结构
    常用的六大Redis的数据结构,及他们各自的底层实现结构
  • string hash list set sortset(zset)
  • string的底层实现是简单动态字符串(SDS -simple dynamic string)
  • hash的底层实现是hash表或则压缩列表(ziplist)
  • list的底层实现是双向列表(quicklist)或者压缩列表
  • set的底层实现是hash表(hashtable)或者整数数组
  • sortset(zset)的底层实现是压缩列表或者跳表

各个数据结构的底层实现概览

redisson需要的jar redis的nio_redisson需要的jar_05

https://www.jianshu.com/p/45def7c525dd

  1. 写时拷贝(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段也不重新分配物理内存,也就是刚分配时是下面这种形式:

redisson需要的jar redis的nio_缓存_06

写时复制:内核只为新生成的子进程创建虚拟空间结构,它们来复制于父进程的虚拟空间结构,但是不为这些段分配物理内存,任何段都不分配,它们共享父进程的物理空间,当父子进程中有更改相应段的行为发生时,再为子进程相应的段分配物理空间,例如途中的Stack块,注意重新分配是以内存页,也就是pagecache(4k)为基本单位的。

https://www.shuzhiduo.com/A/o75N6Nk95W/

  1. 客户端管道批量命令
    redis在客户端程序中做了一些优化引入了一个管道(pipelining)概念。
    管道会把多条无关命令批量执行,以减少多个命令分别执行带来的网络交互时间,在一些批量操作数据的场景。

redis客户端执行一条命令分4个过程:

发送命令-〉命令排队-〉命令执行-〉返回结果

这个过程称为Round trip time(简称RTT, 往返时间),Redis的原生批命令(mget和mset)有效节约了RTT,但大部分命令(如hgetall,并没有mhgetall)不支持批量操作,需要消耗N次RTT ,这个时候需要pipeline来解决这个问题。

pipeline可以将多次IO往返的时间缩减为一次RTT ,前提是pipeline执行的指令之间没有因果相关性。

redisson需要的jar redis的nio_redisson需要的jar_07

  1. 零拷贝技术
    我们知道,在服务端,处理消费的大致逻辑是这样的:
    首先,从文件中找到消息数据,读到内存中;
    然后,把消息通过网络发给客户端。
    这个过程中,数据实际上做了 2 次或者 3 次复制:
  • 从文件复制数据到 PageCache 中,如果命中 PageCache,这一步可以省掉;
  • 从 PageCache 复制到应用程序的内存空间中,也就是我们可以操作的对象所在的内存;
  • 从应用程序的内存空间复制到 Socket 的缓冲区,这个过程就是我们调用网络应用框架的 API 发送数据的过程。
    使用零拷贝技术可以把这个复制次数减少一次,上面的 2、3 步骤两次复制合并成一次复制。直接从 PageCache 中把数据复制到 Socket 缓冲区中,这样不仅减少一次数据复制,更重要的是,由于不用把数据复制到用户内存空间,DMA 控制器可以直接完成数据复制,不需要 CPU 参与,速度更快。

redisson需要的jar redis的nio_redisson需要的jar_08