不知道你有没有听说过一个问题,Redis 为什么还能这么快?对于这个问题我们将如何考虑呢?其实在我看来无非是基于以下4点:
本文将深入了解 线程的 IO 模型 与 通信协议,掌握其内部持久化、管道与事务机制。
Redis 是单线程的,这句话没错,不过是在6.0版本以前,在6.0版本更新后,添加了多线程IO处理。
Redis 单线程为什么还能这么快?
因为它所有的数据都在内存中,所有的运算都是内存级别的运算。正因为 Redis 是单线程,所以要小心使用 Redis 指令,对于那些时间复杂度为 O(n) 级别的指令,一定要谨慎使用,一不小心就可能会导致 Redis 卡顿。
Redis 单线程如何处理那么多的并发客户端连接?
这个问题,有很多中高级程序员都无法回答,因为他们没听过多路复用这个词汇,不知 道 select 系列的事件轮询 API,没用过非阻塞 IO。
当我们调用套接字的读写方法,默认它们是阻塞的,比如 read
方法要传递进去一个参数n,表示读取这么多字节后再返回,如果没有读够线程就会卡在那里,直到新的数据到来或者连接关闭了,read
方法才可以返回,线程才能继续处理。而 write
方法一般来说不会阻塞,除非内核为套接字分配的写缓冲区已经满了,write
方法就会阻塞,直到缓存区中有空闲空间挪出来了
非阻塞 IO 在套接字对象上提供了一个选项 Non_Blocking
,当这个选项打开时,读写方法不会阻塞,而是能读多少读多少,能写多少写多少。能读多少取决于内核为套接字分配的读缓冲区内部的数据字节数,能写多少取决于内核为套接字分配的写缓冲区的空闲空间字节数。读方法和写方法都会通过返回值来告知程序实际读写了多少字节。
有了非阻塞 IO 意味着线程在读写 IO 时可以不必再阻塞了,读写可以瞬间完成然后线程可以继续干别的事了。
非阻塞 IO 有个问题,那就是线程要读数据,结果读了一部分就返回了,线程如何知道何时才应该继续读。也就是当数据到来时,线程如何得到通知。写也是一样,如果缓冲区满了,写不完,剩下的数据何时才应该继续写,线程也应该得到通知。
事件轮询 API 就是用来解决这个问题的,最简单的事件轮询 API 是 select
函数,它是操作系统提供给用户程序的 API。输入是读写描述符列表 read_fds & write_fds
,输出是与之对应的可读可写事件。同时还提供了一个 timeout
参数,如果没有任何事件到来,那么就最多等待 timeout 时间,线程处于阻塞状态。一旦期间有任何事件到来,就可以立即返回。时间过了之后还是没有任何事件到来,也会立即返回。拿到事件后,线程就可以继续挨个处理相应的事件。处理完了继续过来轮询。于是线程就进入了一个死循环,我们把这个死循环称为事件循环,一个循环为一个周期。
每个客户端套接字 socket
都有对应的读写文件描述符。
shread_events, write_events = select(read_fds, write_fds, timeout)
for event in read_events:
handle_read(event.fd)
for event in write_events:
handle_write(event.fd)
handle_others() # 处理其它事情,如定时任务等
因为我们通过 select
系统调用同时处理多个通道描述符的读写事件,因此我们将这类系统调用称为多路复用 API。现代操作系统的多路复用 API 已经不再使用 select 系统调用,而改用 epoll(linux)
和kqueue(freebsd & macosx)
,因为 select
系统调用的性能在描述符特别多时性能会非常差。它们使用起来可能在形式上略有差异,但是本质上都是差不多的,都可以使用上面的伪代码逻辑进行理解。
服务器套接字 server socket
对象的读操作是指调用 accept
接受客户端新连接。何时有新连接到来,也是通过 select
系统调用的读事件来得到通知的。
提示
事件轮询 API 就是 Java 语言里面的 NIO 技术。Java 的 NIO 并不是 Java 特有的技术,其它计算机语言都有这个技术,只不过换了一个词汇,不叫 NIO 而已。
Redis 会将每个客户端套接字都关联一个指令队列。客户端的指令通过队列来排队进行顺序处理,先到先服务。
Redis 同样也会为每个客户端套接字关联一个响应队列。Redis 服务器通过响应队列来将指令的返回结果回复给客户端。 如果队列为空,那么意味着连接暂时处于空闲状态,不需要去获取写事件,也就是可以将当前的客户端描述符从 write_fds 里面移出来。等到队列有数据了,再将描述符放进去。避免 select
系统调用立即返回写事件,结果发现没什么数据可以写。出这种情况的线程会飙高 CPU。
服务器处理要响应 IO 事件外,还要处理其它事情。比如定时任务就是非常重要的一件事。如果线程阻塞在select 系统调用上,定时任务将无法得到准时调度。那 Redis 是如何解决这个问题的呢?
Redis 的定时任务会记录在一个称为最小堆的数据结构中。这个堆中,最快要执行的任务排在堆的最上方。在每个循环周期,Redis 都会将最小堆里面已经到点的任务立即进行处理。处理完毕后,将最快要执行的任务还需要的时间记录下来,这个时间就是 select 系统调用的 timeout 参数。因为 Redis 知道未来 timeout 时内,没有其它定时任务需要处理,所以可以安心睡眠 timeout 的时间。
Redis 6.0 引入了多线程I/O模型,这是为了提高Redis的网络性能。在以前的版本中,Redis 主要依赖于单线程来处理客户端的命令请求。虽然这种方式在大多数情况下表现良好,但在高并发场景下,单线程模型可能会成为瓶颈,尤其是在进行大量网络通信时。
在Redis 6.0中,引入了多线程I/O处理,主要目的是为了 并行地处理网络I/O操作 ,从而提高Redis处理客户端请求的能力。这一改变主要体现在以下几个方面:
多线程用于处理客户端的命令请求:
在Redis 6.0之前,所有的命令执行都是单线程的,这意味着即使有多个客户端并发请求,它们仍然会按顺序逐个处理。
从Redis 6.0开始,引入了多线程处理网络I/O,这意味着当Redis接收到一个客户端的请求时,它会使用一个或多个工作线程来并行地读取数据、解析命令、执行命令等。
配置多线程:
Redis 6.0允许用户配置使用多少个I/O线程。默认情况下,这个值可能为0(表示不使用多线程),但你可以通过io-threads配置项来设置具体的线程数。例如,设置io-threads 4表示使用4个I/O线程。
注意:虽然I/O操作是多线程处理的,但命令的执行仍然是单线程的(除非使用了模块化扩展)。
性能提升:
多线程可以显著提高Redis在处理高并发网络请求时的性能,特别是在网络I/O密集型的应用场景中。
但是,需要注意的是,多线程并不意味着所有操作都能并行化。例如,某些命令(如内存操作)仍然会按顺序执行,以保证操作的原子性和一致性。
配置示例:
shredis-server --port 6379 --io-threads 4
这条命令启动了一个Redis服务器实例,并配置了4个I/O线程。
Redis 的作者认为数据库系统的瓶颈一般不在于网络流量,而是数据库自身内部逻辑处理上。所以即使 Redis 使用了浪费流量的文本协议,依然可以取得极高的访问性能。Redis 将所有数据都放在内存,用一个单线程对外提供服务,单个节点在跑满一个 CPU 核心的情 况下可以达到了 10w/s 的超高 QPS。
RESP 是 Redis 序列化协议的简写。它是一种直观的文本协议,优势在于实现异常简单,解析性能极好。
Redis 协议将传输的结构数据分为 5 种最小单元类型,单元结束时统一加上回车换行符 号\r\n。
客户端向服务器发送的指令只有一种格式,多行字符串数组。比如一个简单的 set 指令
set author codehole
会被序列化成下面的字符串。
text*3\r\n$3\r\nset\r\n$6\r\nauthor\r\n$8\r\ncodehole\r\n
Redis 的数据全部在内存里,如果突然宕机,数据就会全部丢失,因此必须有一种机制来保证 Redis 的数据不会因为故障而丢失,这种机制就是 Redis 的持久化机制。
Redis 的持久化机制有两种,第一种是 RDB 快照,第二种是 AOF 日志。
快照是一次全量备份,AOF 日志是连续的增量备份。快照是内存数据的二进制序列化形式,在存储上非常紧凑,而 AOF 日志记录的是内存数据修改的指令记录文本。
RDB是Redis DataBase的缩写,Redis 中默认的持久化方式。按照一定的时间将内存的数据以快照的形式保存到硬盘中,对应产生的数据文件为dump.rdb。通过配置文件中的save参数来定义快照的周期。
我们知道 Redis 是单线程程序,这个线程要同时负责多个客户端套接字的并发读写操作 和内存数据结构的逻辑读写。
在服务线上请求的同时,Redis 还需要进行内存快照,内存快照要求 Redis 必须进行文 件 IO 操作,可文件 IO 操作是不能使用多路复用 API。
这意味着单线程同时在服务线上的请求还要进行文件 IO 操作,文件 IO 操作会严重拖垮服务器请求的性能。还有个重要的问题是为了不阻塞线上的业务,就需要边持久化边响应客户端请求。持久化的同时,内存数据结构还在改变,比如一个大型的 hash 字典正在持久化,结果一个请求过来把它给删掉了,还没持久化完呢,这尼玛要怎么搞?
那该怎么办呢?Redis 使用 操作系统的多进程 COW(Copy On Write) 机制 来实现快照持久化,这个机制很有意思,也很少人知道。多进程 COW 也是鉴定程序员知识广度的一个重要指标。
Redis 在持久化时会调用 glibc 的函数 fork 产生一个子进程,快照持久化完全交给子进程来处理,父进程继续处理客户端请求。子进程刚刚产生时,它和父进程共享内存里面的代码段和数据段。这时你可以将父子进程想像成一个连体婴儿,共享身体。这是 Linux 操作系统的机制,为了节约内存资源,所以尽可能让它们共享起来。在进程分离的一瞬间,内存的增长几乎没有明显变化。
AOF持久化(即Append Only File持久化),则是将Redis执行的每次写命令记录到单独的 aof 日志文件中,当重启Redis会重新将持久化的日志中文件恢复数据。
AOF 日志存储的是 Redis 服务器的顺序指令序列,AOF 日志只记录对内存进行修改的 指令记录。
假设 AOF 日志记录了自 Redis 实例创建以来所有的修改性指令序列,那么就可以通过对一个空的 Redis 实例顺序执行所有的指令,也就是「重放」,来恢复 Redis 当前实例的内 存数据结构的状态。
Redis 会在收到客户端修改指令后,先进行参数校验,如果没问题,就立即将该指令文本存储到 AOF 日志中,也就是先存到磁盘,然后再执行指令。这样即使遇到突发宕机,已经存储到 AOF 日志的指令进行重放一下就可以恢复到宕机前的状态。
Redis 在长期运行的过程中,AOF 的日志会越变越长。如果实例宕机重启,重放整个 AOF 日志会非常耗时,导致长时间 Redis 无法对外提供服务。所以需要定期对 AOF 日志瘦身。
Redis 提供了 bgrewriteaof 指令用于对 AOF 日志进行瘦身。其原理就是开辟一个子进 程对内存进行遍历转换成一系列 Redis 的操作指令,序列化到一个新的 AOF 日志文件中。
序列化完毕后再将操作期间发生的增量 AOF 日志追加到这个新的 AOF 日志文件中,追加 完毕后就立即替代旧的 AOF 日志文件了,瘦身工作就完成了。
AOF 日志是以文件的形式存在的,当程序对 AOF 日志文件进行写操作时,实际上是将内容写到了内核为文件描述符分配的一个内存缓存中,然后内核会异步将脏数据刷回到磁盘的。
这就意味着如果机器突然宕机,AOF 日志内容可能还没有来得及完全刷到磁盘中,这个时候就会出现日志丢失。那该怎么办?
Linux 的 glibc 提供了 fsync(int fd)函数可以将指定文件的内容强制从内核缓存刷到磁盘。只要 Redis 进程实时调用 fsync 函数就可以保证 aof 日志不丢失。但是 fsync 是一个磁盘 IO 操作,它很慢!如果 Redis 执行一条指令就要 fsync 一次,那么 Redis 高性能的地位就不保了。
所以在生产环境的服务器中,Redis 通常是每隔 1s 左右执行一次 fsync 操作,周期 1s 是可以配置的。这是在数据安全性和性能之间做了一个折中,在保持高性能的同时,尽可能 使得数据少丢失。
Redis 同样也提供了另外两种策略,一个是永不 fsync——让操作系统来决定合适同步磁盘,很不安全,另一个是来一个指令就 fsync 一次——非常慢。但是在生产环境基本不会使 用,了解一下即可。
sh#aof持久化策略的配置
#no表示不执行fsync,由操作系统保证数据同步到磁盘,速度最快
#always表示每次写入都执行fsync,以保证数据同步到磁盘
#everysec表示每秒执行一次fsync,可能会导致丢失这1s数据
# appendfsync always
appendfsync everysec
# appendfsync no
一般来说, 如果想达到足以媲美 SQL 的数据安全性,你应该同时使用两种持久化功能。在这种情况下,当 Redis 重启的时候会优先载入AOF文件来恢复原始的数据,因为在通常情况下AOF文件保存的数据集要比RDB文件保存的数据集要完整。
如果你非常关心你的数据,但仍然可以承受数分钟以内的数据丢失,那么你可以只使用RDB持久化。
有很多用户都只使用AOF持久化,但并不推荐这种方式,因为定时生成RDB快照(snapshot)非常便于进行数据库备份, 并且 RDB 恢复数据集的速度也要比AOF恢复的速度要快,除此之外,使用RDB还可以避免AOF程序的bug。
如果你只希望你的数据在服务器运行的时候存在,你也可以不使用任何持久化方式。
大多数同学一直以来对 Redis 管道有一个误解,他们以为这是 Redis 服务器提供的一种 特别的技术,有了这种技术就可以加速 Redis 的存取效率。但是实际上 Redis 管道 (Pipeline) 本身并不是 Redis 服务器直接提供的技术,这个技术本质上是由客户端提供的,跟服务器没有什么直接的关系。下面我们对这块做一个深入探究。
当我们使用客户端对 Redis 进行一次操作时,如下图所示,客户端将请求传送给服务 器,服务器处理完毕后,再将响应回复给客户端。这要花费一个网络数据包来回的时间。
如果连续执行多条指令,那就会花费多个网络数据包来回的时间。如下图所示。
回到客户端代码层面,客户端是经历了写-读-写-读四个操作才完整地执行了两条指令。
现在如果我们调整读写顺序,改成写—写-读-读,这两个指令同样可以正常完成。
两个连续的写操作和两个连续的读操作总共只会花费一次网络来回,就好比连续的 write 操作合并了,连续的 read 操作也合并了一样。
这便是管道操作的本质,服务器根本没有任何区别对待,还是收到一条消息,执行一条消息,回复一条消息的正常的流程。客户端通过对管道中的指令列表改变读写顺序就可以大幅节省 IO 时间。管道中指令越多,效果越好。
本质
它并不是服务器的什么特性,而是客户端通过改变了读写的顺序带来的性能的巨大提升。
为了确保连续多个操作的原子性,一个成熟的数据库通常都会有事务支持,Redis 也不例外。Redis 的事务使用非常简单,不同于关系数据库,我们无须理解那么多复杂的事务模型,就可以直接使用。不过也正是因为这种简单性,它的事务模型很不严格,这要求我们不能像使用关系数据库的事务一样来使用 Redis。
Redis 事务与数据库事务类似,分别是 multi/exec/discard。multi 指示事务的开始,exec 指示事务的执行,discard 指示事务的丢弃
sh> multi
OK
> incr books
QUEUED
> incr books
QUEUED
> exec
(integer) 1
(integer) 2
上面的指令演示了一个完整的事务过程,所有的指令在 exec 之前不执行,而是缓存在 服务器的一个事务队列中,服务器一旦收到 exec 指令,才开执行整个事务队列,执行完毕 后一次性返回所有指令的运行结果。因为 Redis 的单线程特性,它不用担心自己在执行队列的时候被其它指令打搅,可以保证他们能得到的「原子性」执行。
总结说:redis事务就是一次性、顺序性、排他性的执行一个队列中的一系列命令。
考虑到一个业务场景,Redis 存储了我们的账户余额数据,它是一个整数。现在有两个并发的客户端要对账户余额进行修改操作,这个修改不是一个简单的 incrby 指令,而是要对余额乘以一个倍数。Redis 可没有提供 multiplyby 这样的指令。我们需要先取出余额然后在内存里乘以倍数,再将结果写回 Redis。
这就会出现并发问题,因为有多个客户端会并发进行操作。我们可以通过 Redis 的分布式锁来避免冲突,这是一个很好的解决方案。分布式锁是一种悲观锁,那是不是可以使用乐观锁的方式来解决冲突呢?
Redis 提供了这种 watch 的机制,它就是一种乐观锁。有了 watch 我们又多了一种可以 用来解决并发修改的方法。 watch 的使用方式如下:
Redis 禁止在 multi 和 exec 之间执行 watch 指令,而必须在 multi 之前做好盯住关键变量,否则会出错。
三个阶段:
Redis事务功能是通过MULTI、EXEC、DISCARD和WATCH 四个原语实现的
WATCH 命令是一个乐观锁,可以为 Redis 事务提供 check-and-set (CAS)行为。 可以监控一个或多个键,一旦其中有一个键被修改(或删除),之后的事务就不会执行,监控一直持续到EXEC命令。
MULTI命令用于开启一个事务,它总是返回OK。 MULTI执行之后,客户端可以继续向服务器发送任意多条命令,这些命令不会立即被执行,而是被放到一个队列中,当EXEC命令被调用时,所有队列中的命令才会被执行。
EXEC:执行所有事务块内的命令。返回事务块内所有命令的返回值,按命令执行的先后顺序排列。 当操作被打断时,返回空值 nil 。
通过调用DISCARD,客户端可以清空事务队列,并放弃执行事务, 并且客户端会从事务状态中退出。
UNWATCH:命令可以取消watch对所有key的监控。
本文作者:柳始恭
本文链接:
版权声明:本博客所有文章除特别声明外,均采用 BY-NC-SA 许可协议。转载请注明出处!