Redis深度历险:核心原理和应用实践——第2篇 原理篇
1:线程 IO 模型
Redis 是单线程。
因为所有数据都存放在内存中,所有运算都是内存级别的运算。因为redis是单线程,所以谨慎使用复杂度为O(n)级别的指令,一不小心就会造成redis卡顿。
非阻塞IO
当调用socket的读写方法,默认是阻塞的。比如read
方法传递一个参数n
,表示读取这么多字节后再返回,如果没有读够线程就会卡顿在那里,直到新的数据到来或者链接关闭,read
方法才返回,线程才继续处理。而write
方法一般来说不会阻塞,除非内核为scoket分配的缓冲区已经满了,write
方法阻塞,直到缓存区中有空闲空间。
非阻塞IO在socket上提供一个选项Non_Blocking
,当这个选项打开时,读写方法不会阻塞,而是能写多少写多少,能读多少读多少。读写方法通过返回值告诉程序实际读写了多少字节。
事件轮询(多路复用)
非阻塞IO有个问题,若线程读数据,结果读了一部分就返回了,线程如何知道何时才能继续读。也就是数据到来时,线程如何得到通知。写也是一样,缓冲区满了,写不完,剩下的数据何时才能继续写。
事件轮询API就解决这个问题,最简单的事件轮询API就是select
函数,它是操作系统提供给用户程序的API。输入是读写描述符列表read_fds
和write_fds
,输出的是与之对应的可读可写事件。同时还提供一个timeout
参数,如果没有任何事件到来,那就最多等待timeout
时间。一旦有任何事件到来,就可以立即返回。时间过了之后,没有任何事件到来,也会立即返回。拿到事件后,线程就可以继续挨个处理相应的事件。处理完了继续过来轮询。于是线程就进入一个死循环,我们把这个死循环称为事件循环,一个循环为一个周期。
每个客户端socket都有对应的读写文件描述符。
因为通过select
系统调用同时处理多个通道描述符的读写事件,所以将这类系统调用称为多路复用API。现在操作系统的多路复用API已经不再使用select
而该用epoll(linux)和kqueue(freesd & macosx)
服务端serversocket的读操作是指调用accept接受客户端新连接。何时有新连接到来,也是通过select系统调用的读事件来得到通知的。
事件轮询API就是Java中的NIO技术。
指令队列
redis会为每个客户端socket都关联一个指令队列。客户端通过指令队列来排队进行顺序处理,先到先服务。
响应队列
redis也会为每个客户端socket都关联一个响应队列。redis服务器通过响应队列来将指令的返回结果回复给客户端。如果队列为空,那就意味着连接暂时处于空闲状态,不需要去获取写事件,也就是可以将当前客户端描述符从write_fds
中移出来。等队列有数据了,再将描述符放进去。避免select系统调用立即返回写事件,结果发现没有什么事件可以写。这种情况会飙高CPU。
定时任务
服务器除了响应IO还需要处理其他事情,比如定时任务。
redis的定时任务记录在一个最小堆上,最快要执行的任务排列在堆的最上方。在每一个循环周期中,redis将已经到点的任务立即进行处理。处理完毕后,将最快要执行的任务还需要的时间记录下来,这个时间就是select系统调用的timeout
参数。
2: 通信协议
redis作者认为数据库系统的瓶颈一般不在于网络流量,而是数据库自身内部逻辑处理上。即使redis使用了浪费流量的文本协议,依然可以取得极高的访问性能。redis将所有数据都放在内存,使用一个单线程对外提供服务,单节点在跑满一个CPU核心的情况下可以达到10w/s的超高QPS
RESP(Redis Serialization Protocol)
RESP:redis序列化协议。直观的文本协议,优势在于实现异常简单,解析性能极好。
RESP将传输的结构分为5种最小单元类型,结束时统一加上回车换行符\r\n:
- 单行字符串,以 + 开头
- 多行字符串,以 $ 开头,后跟字符串长度
- 整数值,以 :开头,后跟整数的字符串形式
- 错误消息,以 - 开头
- 数组以 * 开头,后跟数组长度
3: 持久化
持久化机制分两种:快照 、AOF日志。
快照是一次全量备份,是内存数据的二进制序列化形式,在存储上非常紧凑;
AOF日志是连续的增量备份,是内存数据修改的指令记录文本,在长期运行中会变得庞大,数据库重启时就需要加载AOF进行指令重放,这个时间无比漫长,所以需要定期进行AOF重写。
快照原理
redis使用操作系统多进程COW(Copy On Write)机制来实现快照持久化
fork(多进程)
redis在持久化时调用glibc的函数fork
产生一个子进程,快照持久化完全交给子进程来处理,父进程继续处理客户端请求。子进程刚刚产生时,它和父进程共享内存里的代码段和数据段。
fork
函数会在父子进程同时返回,在父进程里返回子进程的pid,在子进程中返回0。若操作系统内存资源不足,pid就是负数,表示fork
失败。
操作系统的COW机制
来进行数据段页面的分离。数据段是由很多操作系统的page组合而成,当父进程对其中一个page的数据进行修改时,将会被共享的page复制一份分离出来,然后对这个复制的page进行修改。这时子进程相应的page是没有变化的,还是进程产生时那一瞬间的数据。
随着父进程进行修改操作,越来越多共享页面被分离出来,内存就会持续增长。但是也不会超过原有数据内存的2倍。另外redis实例里冷数据占的比例往往比较高。每个页面的大小只有4K,一个redis实例里面一般都会有成千上万的页面。
AOF原理
AOF日志只记录对内存进行修改的指令记录。
假设AOF记录了自redis实例创建以来所有的修改性指令序列,那么就可以通过对一个空的redis实例顺序执行所有的指令,也就是重放,来恢复redis当前实例的内存数据结构的状态。
redis收到修改指令后,进行参数校验、逻辑处理,如果没问题就将指令文本存储到AOF日志中。也就是说,先执行指令才将日志存盘
AOF重写
redis在长期运行中,AOF日志会越来越长。如果实例宕机重启,重放整个日志会非常耗时。
redis提供bgrewriteaof指令用于对AOF日志进行瘦身,其原理就是开辟一个子进程对内存进行遍历,转换成一系列redis操作指令,序列化到一个新的AOF日志文件中。序列化完毕后再将操作期间发生的增量AOF追加到这个新的AOF日志文件中,追加完毕后就可以替代旧的AOF日志文件了。
fsync
当程序对AOF日志进行写操作,实际上是将内容写到了内核为文件描述符分配的一个内存缓冲区,然后内核会异步将脏数据刷回磁盘。
若机器宕机,日志可能还没来得及完全刷到磁盘中,这时会出现日志丢失,那该怎么办?
Linux的glibc提供了fsync(int fd)函数就可以保证AOF日志不丢失。但是fsync是磁盘IO操作,很慢。每执行一条指令就fsync一次,显然不行。
在生产环境中,redis通常每隔1s左右执行一次fsync。
运维
快照通过开启子进程方式,比较耗费资源,遍历整个内存,大块写磁盘会加重系统负载。
AOF的fsync是一个耗时IO,会降低redis性能,同时增加系统IO。
通常redis主节点不会进行持久化操作,在从节点进行持久化操作。
如果网络分区,从节点长期连接不上主节点,就是出现数据不一致问题,因此要做好实时监控。
Redis 4.0 混合持久化
RDB 持久化能够快速地储存和恢复数据, 但是在服务器停机时却会丢失大量数据;(rdb保存的是某一时间点上的数据,如果未到下一个保存时间点机器发生故障,就会损失数据)
AOF 持久化能够有效地提高数据的安全性, 但是在储存和恢复数据方面却要耗费大量的时间(重放AOF相对于rdb要慢很多)。
Redis 4.0 推出了 混合持久化:将rdb文件内容和增量的AOF日志文件存储到一起。这里的AOF是自持久化开始到持久化结束的这段时间发生的增量AOF日志,通常这部分AOF日志很小。
4:管道
Redis 的消息交互
当使用客户端对redis进行一次操作,客户端将请求传送给服务器,服务器处理完毕后,再将响应回复给客户端。这要花费一个网络数据包来回的时间。
连续执行多条指令,就会花费多个网络数据包来回时间
客户端层面,经历4个操作才完整执行两条指令
调整顺序,两条指令同样可以完成
两个连续的写和两个连续的读总共花费一次网络来回
服务器根本没有任何区别对待,还是收到一条消息,执行一条消息,回复一条消息的流程。客户端通过对管道中的指令列表改变读写顺序就可以节省IO时间。管道中的指令越多,效果越好。
管道压力测试
redis压力测试工具redis-benchmark
深入理解管道本质
(1)客户端进程调用write将消息写到内核为socket分配的发送缓冲区send buffer
(2)客户端内核将send buffer的内容发送到网卡,网卡硬件将数据通过“网际路由”送到服务器的网卡
(3)服务器内核将网卡数据放到内核为socket分配的接收缓冲区recv buffer
(4)服务器进程调用read从recv buffer中取出消息进行处理
(5)服务器进程调用write将响应消息写到内核为socket分配的发送缓冲区send buffer
(6)服务器内核将send buffer的内容发送到网卡,网卡硬件将数据通过“网际路由”送到客户端的网卡
(7)客户端内核将网卡数据放到内核为socket分配的接收缓冲区recv buffer
(8)客户端进程调用read从recv buffer中取出消息返回给上层业务逻辑进行处理
(9)结束
write操作只负责将数据写到send buffer然后就返回了,剩下的事交给内核异步地将数据送到目标机器。但是如果send buffer满了,那么就需要等待空出空闲来,这是写操作的真正耗时。
read操作只负责将数据从recv buffer中取出来就可以了,但是如果缓冲区是空的,那么就需要等待数据到来,这是读操作的真正耗时。
对于value=redis.get(key)这样简单请求,write几乎没有耗时,而read比较耗时。
对于管道,连续的write操作根本没有耗时,之后第一个read操作会等待一个网络的来回开销,然后所有的响应消息都已经送回内核的recv buffer了,后续read操作直接就可以从缓冲拿到结果,瞬间就返回了。
5: 事务
Redis事务的基本使用
相对于数据库的begin、commit、rollback,redis对应操作指令为multi、exec、discard(丢弃)
redis事务不支持回滚
>multi
OK
>incr books
QUEUED
>incr books
QUEUED
>exec
(integer) 1
(integer) 2
原子性
事务的原子性:要么全部成功,要么全部失败
>multi
OK
>set books iamastring
QUEUED
>incr books
QUEUED
>set poorman iamdesperate
QUEUED
>exec
1)OK
2)(error)ERR value is not an integer or out of range
3)OK
>get books
iamastring
>get poorman
iamdesperate
redis的事务不满足原子性,仅仅满足隔离性,因为redis单线程,不用担心自己在执行队列的时候被其他指令打扰
discard(丢弃)
>get books
(nil)
>multi
OK
>incr books
QUEUED
>incr books
QUEUED
>discard
OK
>get books
(nil)
优化
每发送一个指令到事务缓冲队列时都要经过一次网络读写,当一个事务内部的指令较多时,需要的网络IO时间也会线性增长,因此通常redis客户端在执行事务时都会结合pipline一起使用,这样可以将多次IO操作压缩为单次IO
pipe = redis.pipeline(transaction = true)
pipe.multi()
pipe.incr("books")
pipe.incr("books")
values=pipe.execute()
watch
若有多个客户端并发进行操作,可以通过redis的分布式锁来避免冲突。分布式锁是一种悲观锁,redis提供watch机制,是一种乐观锁
使用方法:
while True
do_watch()
commands()
multi()
send_commands()
try:
exec()
break
except WatchError:
continue
exec指令会顺序检查执行缓存的事务队列,检查关键变量自watch之后是否被修改了(包括当前事务所在的客户端)。若被修改,exec返回NULL告知客户端失败
> watch books
OK
>incr books #被修改
(integer) 1
>multi
OK
>incr books
QUEUED
>exec #事务执行失败
(nil)
注意事项
redis禁止在multi与exec之间执行watch
实现余额的加倍操作
public Class TransactionDemo{
public static void main(String[] args){
Jedis jedis = new Jedis();
String userId = "abc";
String key = keyFor(userId);
jedis.setnx(key, String.valueOf(5)); #setnx做初始化
System.out.println(doubleAccount(jedis, userId));
jedis.close();
}
public static int doubleAccount(Jedis jedis, String userId){
String key = keyFor(userId);
while(true){
jedis.watch(key);
int value = Integer.parseInt(jedis.get(key));
value *= 2; //加倍
Transaction tx = jedis.multi();
tx.set(key, String.valueOf(value));
List<Object> res = tx.exec();
if(res != null){
break; //成功
}
return Integer.parseInt(jedis.get(key)); //重新获取余额
}
}
public static String keyFor(String userId){
return String.format("account_%s", userId);
}
}
6:PubSub
消息多播
消息多播允许生产者只生产一次消息,由中间件负责将消息复制到多个消息队列,每个消息队列由相应的消费组进行消费。
如果是普通的消息队列,就是将多个不同的消息组逻辑串联起来放在一个子系统中,进行连续消费。
PubSub
为了支持消息多播,redis单独使用了一个模块支持,PubSub:PublisherSubscriber(发布者/订阅者模式)
模式订阅
消息结构
PubSub缺点
消息不会持久化
补充
redis5.0新增了Stream,给redis带来了持久化消息队列,从此PubSub作为消息队列的功能可以消失了
7:小对象压缩
redis所有数据都放在内存中,需优化数据结构的内存占用
32bit Vs 64bit
使用32bit进行编译,所使用指针空间占用会减少一半,如果redis使用内存不超过4GB,可以考虑使用32bit编译
小对象压缩存储(ziplist)
如果redis内部管理的集合数据结构很小,它会使用紧凑存储形式压缩存储。
比如HashMap本来是二维结构,但是如果内部元素比较少,使用二维结构反而浪费空间
public class ArrayMap<K, V>{
private List<K> keys = new ArrayList();
private List<V> values = new ArrayList();
public V put(K k, V v){
for(int i = 0; i < keys.size(); i++){
if(keys.get(i).equals(k)){
V oldv = values.get(i);
values.set(i, v);
return oldv;
}
}
keys.add(k);
values.add(v);
return null;
}
public V get(K k){
for(int i = 0; i < keys.size(); i++){
if(keys.get(i).equals(k)){
return values.get(i);
}
}
return null;
}
public V delete(K k){
for(int i = 0; i < keys.size(); i++){
if(keys.get(i).equals(k)){
keys.remove(i);
return values.remove(i);
}
}
return null;
}
}
redis的ziplist
是一个紧凑的字节数组数据结构
如果存储的是hash,那么key和value会作为两个entry被相邻存储
>hset hello a 1
(integer) 1
>hset hello b 2
(integer) 1
>hset hello c 3
(integer) 1
>object encoding hello
"ziplist"
如果存储的是zset,那么key和value会作为两个entry被相邻存储
>zadd world 1 a
(integer) 1
>zadd world 2 b
(integer) 1
>zadd world 3 c
(integer) 1
>object encoding world
"ziplist"
redis的intset
是一个紧凑的整数数组结构,用于存放元素都是整数且元素个数比较少的set集合
如果整数可以用uint16表示,那么intset的元素就是16位的数组,如果新加入的整数超过了uint16的表示范围,那就使用uint32表示,如果超过uint32的表示范围,那就使用uint64表示。
>sadd hello 1 2 3
(integer) 3
>object encoding hello
"intset"
如果set存储的是字符串,那么sadd立即升级为hashtable。Java的HashSet内部是使用HashMap实现的
>sadd hello yes no
(integer) 2
>object encoding hello
"hashtable"
redis规定小对象存储结构的限制条件如下:
hash-max-ziplist-entries 512 # hash元素个数超过512就必须用标准
hash-max-ziplist-value 64 # hash元素的key/value长度
list-max-ziplist-entries 512 # list元素个数超过512就必须用标准
list-max-ziplist-value 64 # list元素长度
zset-max-ziplist-entries 128 # zset元素个数超过128就必须用标准
zset-max-ziplist-value 64 # zset元素长度
set-max-inset-entries 512 #set整数元素个数超过512就必须用标准
内存回收机制
redis并不是总将空闲内存归还给操作系统。
操作系统以页为单位回收内存,这个页上只要还有一个key在使用,那么它就不会被回收。
redis虽然无法保证立即回收已经删除的key内存,但是它会重新使用那些尚未回收的空闲内存。
内存分配算法
redis为了保持自身结构的简单性,将内存分配的细节丢给了第三方内存分配库去实现
可以使用jemalloc(Facebook)、tcmalloc(Goolge)
>info memory