《Redis深度历险:核心原理和应用实践》读书笔记

1:线程 IO 模型

Redis 是单线程。
因为所有数据都存放在内存中,所有运算都是内存级别的运算。因为redis是单线程,所以谨慎使用复杂度为O(n)级别的指令,一不小心就会造成redis卡顿。

非阻塞IO

当调用socket的读写方法,默认是阻塞的。比如read方法传递一个参数n,表示读取这么多字节后再返回,如果没有读够线程就会卡顿在那里,直到新的数据到来或者链接关闭,read方法才返回,线程才继续处理。而write方法一般来说不会阻塞,除非内核为scoket分配的缓冲区已经满了,write方法阻塞,直到缓存区中有空闲空间。

阻塞IO
非阻塞IO在socket上提供一个选项Non_Blocking,当这个选项打开时,读写方法不会阻塞,而是能写多少写多少,能读多少读多少。读写方法通过返回值告诉程序实际读写了多少字节。

事件轮询(多路复用)

非阻塞IO有个问题,若线程读数据,结果读了一部分就返回了,线程如何知道何时才能继续读。也就是数据到来时,线程如何得到通知。写也是一样,缓冲区满了,写不完,剩下的数据何时才能继续写。

事件轮询
事件轮询API就解决这个问题,最简单的事件轮询API就是select函数,它是操作系统提供给用户程序的API。输入是读写描述符列表read_fdswrite_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:

  1. 单行字符串,以 + 开头
  2. 多行字符串,以 $ 开头,后跟字符串长度
  3. 整数值,以 :开头,后跟整数的字符串形式
  4. 错误消息,以 - 开头
  5. 数组以 * 开头,后跟数组长度

3: 持久化

持久化机制分两种:快照 、AOF日志。
快照是一次全量备份,是内存数据的二进制序列化形式,在存储上非常紧凑;
AOF日志是连续的增量备份,是内存数据修改的指令记录文本,在长期运行中会变得庞大,数据库重启时就需要加载AOF进行指令重放,这个时间无比漫长,所以需要定期进行AOF重写。

redis持久化

快照原理

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是一个紧凑的字节数组数据结构
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集合
intset
如果整数可以用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

  • 0
    点赞
  • 0
    收藏
    觉得还不错? 一键收藏
  • 1
    评论

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论 1
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值