前言:
参照 《redis深度历险-核心原理与应用实践》
一、线程IO模型
概述:
- redis是一个单线程程序,它将所有的数据存储于内存中,所有运算都是内存级别的运算。正因为redis是单线程程序,对于时间复杂度为O(n)的指令需要小心使用。
- redis使用多路复用来处理客户端连接
1. 非阻塞IO
阻塞IO:
当我们调用socket读写套接字时,默认是阻塞的,例如read方法需要传递参数n,表示最多读取n个字节的数据后返回。若无数据,线程就会阻塞;write在内核为套接字分配的写缓冲区满了会阻塞
非阻塞IO:
非阻塞io在套接字对象上提供了Non_Blocking,当选项打开,读写方法不会阻塞,而是能读多少读多少,能写多少写多少。读方法和写方法会通过返回值告知程序实际读写了多少字节。
2. 事件轮询(多路复用)
非阻塞IO的问题:线程如何知道何时可以开始读/写数据
解决方法:
方法1:通过事件轮询来解决问题(select)。
- select入参为read_fds或write_fds,输出为可读写的事件。
- select还提供了timeout参数,如果没有事件到来,可阻塞等待timeout时刻,阻塞期间有事件到达或阻塞超时,则立即返回。
- 通过select获取事件后,处理程序可轮询处理事件。由此可见,select处理事件的总流程是一个循环,又称为事件循环
read_events,write_events = select(read_fds, write_fds, timeout)
# 读写处理句柄可使用线程池处理,redis为单线程,故不使用线程池
for event in read_events:
deal_read(read_events)
for event in write_events:
deal_write(write_events)
# 处理其他事件
deal_other()
方法二:epoll或kqueue
- select系统调用在描述符很多时,性能会变得很差(select轮询时,需要遍历读/写描述符集合,且可能会有许多空循环,消耗cpu资源)
- 使用epoll实现多路复用的处理流程和上述伪代码类似
3. 指令队列与响应队列
指令队列:redis为每个客户端关联一个指令队列,客户端的指令会通过队列进行顺序处理,先来先服务。
响应队列:redis会为每个客户端套接字关联一个响应队列,redis服务器通过响应队列将指令的返回结果回复给客户端。如果响应队列为空,则意味着连接处于空闲状态,可以将当前的客户端描述符从write_fps中移除。当队列有数据了,再将描述符放入
4. 定时任务
服务器除了要处理IO事件外,还需要处理其他事件,例如定时任务。
redis定时任务处理流程:
- redis定时任务会记录在一个最小堆中,堆顶为需要最快执行的任务。在每个循环周期中,redis会把最小堆里面已经到时间点的任务进行处理
- 处理完已到期的任务后,reids会将最快要执行的任务下一次执行的间隔时间记录下来,这个时间就是select的timeout参数(epoll_wait的timeout参数)。通过该操作,redis知道在未来的timeout时间内没有其他定时任务需要处理,可以安心阻塞等待响应的请求
二、通信协议
redis作者认为,数据库系统一般瓶颈不在网络流量,因此使用了费流量的文本协议。redis将所有数据放在内存中,用一个单线程对外提供服务,单个节点在跑满一个cpu核心的情况下可以达到10w/s的QPS
1. RESP
概述:resp是redis序列化协议简写,是一种直观的文本协议,优势在于实现简单,解析性能好
a. RESP结构
基本特点:RESP将传输的数据结构分为5种最小单元类型,单元结束时统一加上回车换行符号 \r\n
结构:
- 单行字符串以 "+" 符号开头
- 多行字符串以 "$" 符号开头,后跟字符串长度
- 整数值以 ":" 符号开头,后跟整数的字符串形式
- 错误消息以 "-" 符号开头
- 数组以 "*" 开头,后跟数组长度
b. 例子
单行字符串:
+hello world\r\n
多行字符串:
$11\r\nhello world\r\n
整数1024:
:1024\r\n
错误:
-Error error\r\n
数组[1,2,3]:
*3\r\n:1\r\n:2\r\n:3\r\n
NULL(NULL用多行字符串表示,不过长度为-1):
$-1\r\n
空串(空串用多行字符串表示,长度为0):
$0\r\n
2. 客户端 -> 服务端
客户端向服务器发送指令的格式只有一种-多行字符串数组。
例子:
指令 set books java
*3\r\n$3\r\nset\r\n$5\r\nbooks\r\n$4\r\njava\r\n
3. 服务端 -> 客户端
服务端响应客户端请求时,有多种数据结构
a. 单行字符串响应
127.0.0.1:6379> set books java
OK
此处返回的"ok"是单行字符串,编码为: +ok
b. 错误响应
127.0.0.1:6379> incr books
(error) ERR value is not an integer or out of range
此处返回的错误信息编码为:
-ERR value is not an integer or out of range
c. 整数响应
127.0.0.1:6379> set val 1
OK
127.0.0.1:6379> incr val
(integer) 2
此处2为整型,编码为:
:2
d. 多行字符串响应
127.0.0.1:6379> get books
"java"
此处编码为多行字符串(用引号括起来),编码为:
$4\r\njava\r\n
e. 数组响应
127.0.0.1:6379> hmset info name zzh phone 1234
OK
127.0.0.1:6379> hgetall info
1) "name"
2) "zzh"
3) "phone"
4) "1234"
此处返回字符串数组,编码为:
*4\r\n$4\r\nname\r\n$3\r\nzzh\r\n$5\r\nphone\r\n$4\r\n1234\r\n
d. 嵌套
127.0.0.1:6379> scan 0
1) "4608"
2) 1) "key5008"
2) "key2838"
3) "key4704"
4) "key7056"
5) "key3291"
6) "key3463"
7) "key8838"
8) "key7963"
9) "key758"
10) "key8963"
编码为数组,一部分为游标值(多行字符串),一部分为数组多行字符串(返回值)
5. 小结
redis协议中有很多冗余的回车换行符,但简单、易理解、易解析
6. redis协议解析/编码器实现(TODO)
三、持久化
概述:redis持久化方式有两种
- 快照:一次全量备份,内存数据二进制序列化,存储上十分紧凑
- AOF日志:连续的增量备份,记录内存数据修改的指令文本。AOF占存储空间大,数据库重启时需要加载AOF日志进行指令重放,耗时长,因此需要定期进行AOF重写
1. 快照原理
a. 概述
redis执行快照时需要进行文件IO操作,不能使用多路复用API。这意味着redis单线程服务会被备份影响性能。redis使用多进程的COW(copy on write)机制进行持久化
b. fork多进程(参照操作系统COW)
过程:
- redis在持久化时,会调用glibc库中的函数fork出一个子进程,快照持久化操作由子进程完成
- 子进程创建初期,与父进程共享内存代码段与数据段。
- 父进程在子进程进行数据持久化时,由于不断接受处理客户端请求,会改变内存数据,此时会使用COW机制,将父进程修改的页复制出一份出来,然后对页面进行修改。
- 子进程在数据持久化时,不对页面进行修改,只需要遍历原来的页面并进行IO操作即可
- 随着父进程修改操作的持续进行,越来越多的共享页面被分离出来,内存会持续增加,但不会超过原内存的2倍(redis中冷数据占比一般比较高,很少出现所有页面分离的情况)
2. AOF原理
a. 概述
定义:AOF存储的是redis服务器顺序指令序列。如果AOF日志记录了redis实例创建以来所有修改指令序列,那么可以通过一个空的redis实例顺序执行这些序列(重放),得到当前实例的内存状态
执行流程:
- redis在接收到客户端请求时,会进行参数校验、逻辑处理,若没问题,则立即将指令存储于AOF日志中(先执行指令再存储日志)
- redis在执行过程中,AOF日志会越来越大,若实例宕机,重放整个AOF日志耗时很大,会导致redis长时间无法处理请求,异常需要进行日志重写
b. AOF日志重写
概述:redis提供了bgrewriteaof指令,用于AOF日志进行重写
处理流程:
- 开辟一个子进程对内存进行遍历,转换为一系列redis操作指令,序列化得到一个新的AOF日志。
- 序列化完毕后,将操作期间发生的增量AOF日志追加到新的AOF文件中,追加完毕后立即替换旧的AOF文件
c. fsync
redis宕机时,AOF可能会出现的问题:
- AOF日志以文件形式存在,程序对AOF日志进行写操作时,实际上是将内容写入到操作系统内核为文件操作符分配的内存缓冲中,然后内核会异步将数据刷到磁盘。
- 若在内核将数据刷入磁盘期间,redis宕机,AOF日志内容可能还没来得及完全刷到磁盘,可能会出现日志丢失。
解决方法:
- linux的glibc提供了fsync(int fd)函数,可以将指定文件内容强制从内核缓存刷新到磁盘中,若redis实时调用fsync函数,则可以保证AOF日志不丢失
- 若redis每执行一条指令,就调用一次fsync,会极大影响redis的性能。redis的fsync周期是可配置的,通常设置为1s刷新一次
- redis同时还提供了两种fsync机制,一种是永不调用fsync(宕机后数据丢失数量可能会很多),一种是来一个指令调用一次fsync(性能太差)
3. 运维
a. 概述
- 快照是通过开启子进程的方式进行的,比较消耗资源,遍历整个内存,大块写磁盘会加重系统负担
- AOF的fsync是一个耗时的IO操作,会降低redis性能,增加系统IO负担
b. 运维配置
- 通常redis主节点不会进行持久化操作,持久化操作主要在从节点进行(从节点是备份节点,没有来自客户端的请求压力,资源往往比较充沛)
- 如果出现网络分区,从节点长期连不上主节点,就会导致数据不一致问题,此时若主节点宕机,就会出现数据丢失问题。因此在生产环境中要做好实时监控,保证网络通畅或者能快速修复故障。
- 还应该增加从节点数量,降低网络分区的概率,只要有一个从节点数据同步正常,数据就不会轻易丢失
4. redis混合持久化
混合持久化出现原因:重启redis时,很少使用rdb来恢复内存状态,因为会丢失大量数据;因此通常使用AOF日志重放,但redis数据重放在实例很大时,往往速度很慢,为了解决这个问题,redis4.0提供了新的持久化选项-混合持久化。
流程:
- 将rdb文件的内容和AOF日志存放在一起,AOF不再是全量日志,而是自持久化开始至持久化结束这段时间内的增量AOF日志
- 在redis实例重启时,可以先加载rdb的内容,然后重放增量AOF日志,重启效率相对于全量重放AOF大大提高
四、管道
1. redis的消息交互
流程:
- 使用客户端对redis进行一次操作时,客户端将请求传送给服务器端,服务器端处理完后,再将响应回复给客户端,此时需要消耗一个网络数据包来回传送的时间。连续执行多条指令的示意图如下
- 若客户端将两个连续的写/读操作替换为多个连续的写和多个连续的读,即可节省网络开销。可见,pipeline是redis客户端进行的优化
2. 管道压力测试
redis自带了压力测试工具redis-benchmark,测试流程如下:
1. 对set指令进行压测
c:\redis>redis-benchmark.exe -t set -q
SET: 74962.52 requests per second
由此可见,set的QPS大约是5w/s
2. 加入管道选项参数(-P,表示单个管道内并行请求数量)
c:\redis>redis-benchmark.exe -t set -P 2 -q
SET: 145348.83 requests per second
此时,QPS上升到了14w/s,继续增加管道内并行请求数量,当-P参数到达300时,QPS上不了,此时已经到达瓶颈(100w/s)
3. 深入管道本质
请求交互流程(网络传输数据的流程,后续网络篇会详细解说):
- 客户端进程调用write将消息写入操作系统内核为套接字分配的发送缓冲区中
- 客户端操作系统将发送缓冲区的数据发送到网卡,网卡硬件将数据通过路由传送至服务器端网卡
- 服务器端操作系统内核将网卡的数据放入内核为套接字分配的接收缓冲区中
- 服务器端进程将数据从接收缓存区中读取,并进行处理
- 服务器端进行将处理后的结果写入内核为服务器端进程的套接字分配的发送缓冲区中
- 服务器端内核将发送缓冲区数据写入网卡中,网卡通过路由将数据发送至客户端网卡
- 客户端操作系统将网卡中的数据拷贝到内核为套接字分配的接收缓冲区中
- 客户端进程从接收缓存区中读取数据并处理
对于管道而言,连续的write操作并不耗时(直接写入客户端网卡),之后只需等待一个read操作返回,便可直接读取读缓冲区的数据,相对单个请求,节省了网络开销
五、事务
1. redis事务基本用法
redis事务的操作指令有 multi(相当于begin)、exec(相当于commit)、discard(相当于rollback)。
127.0.0.1:6379> multi
OK
127.0.0.1:6379> incr books
QUEUED
127.0.0.1:6379> incr books
QUEUED
127.0.0.1:6379> exec
1) (integer) 1
2) (integer) 2
所有指令在exec之前不执行,而是缓存在服务器的一个事务队列中。服务器一旦接收到exec指令,就开始执行整个事务队列,执行完毕后一次性返回所有指令执行的结果。由于redis是单线程的,不需要担心事务执行期间被打断,从而保证了事务能原子的执行
2. 原子性
定义:事务的原子性是指事务要么全部成功,要么全部失败
127.0.0.1:6379> multi
OK
127.0.0.1:6379> set book python
QUEUED
127.0.0.1:6379> incr book
QUEUED
127.0.0.1:6379> set testbook java
QUEUED
127.0.0.1:6379> exec
1) OK
2) (error) ERR value is not an integer or out of range
3) OK
127.0.0.1:6379> get book
"python"
127.0.0.1:6379> get testbook
"java"
由示例代码可见,redis事务执行到中间遇到失败了,后续指令会继续执行,因此redis事务并不具备原子性。仅仅能满足事务的隔离性(执行当前事务时,不被其他指令干扰)
3. discard
定义:discard用于丢弃事务缓存队列中的所有指令,需要在exec之前执行
127.0.0.1:6379> multi
OK
127.0.0.1:6379> set name zzh
QUEUED
127.0.0.1:6379> set name1 wsl
QUEUED
127.0.0.1:6379> discard
OK
127.0.0.1:6379> get name
(nil)
127.0.0.1:6379> get name1
(nil)
4. 优化
redis执行事务时,往往会执行多条指令,客户端每发送一条指令,会产生一次网络读写。因此客户端在执行事务时,可以结合pipeline使用,将多次IO操作压缩为单次操作。
package redis.Transaction;
import redis.clients.jedis.HostAndPort;
import redis.clients.jedis.Jedis;
import redis.clients.jedis.Pipeline;
public class transactionWithPipeline {
public static void main(String[] args) {
Jedis jedis = new Jedis(new HostAndPort("127.0.0.1", 6379));
Pipeline pipeline = jedis.pipelined();
pipeline.multi();
pipeline.set("name", "zzh");
pipeline.set("age", "1");
pipeline.incr("age");
pipeline.exec();
pipeline.close();
}
}
5. watch
watch指令用法:watch指令会在事务开始时,盯住一个或多个关键变量,当事务执行时,redis会检查关键变量自watch之后是否被修改,若被修改,exec指令会之间返回null通知客户端执行失败
127.0.0.1:6379> watch book
OK
127.0.0.1:6379> incr book
(integer) 2
127.0.0.1:6379> multi
OK
127.0.0.1:6379> incr book
QUEUED
127.0.0.1:6379> exec
(nil)
注意事项:redis禁止multi与exec之间执行watch指令
例子:余额加倍
package redis.Transaction;
import redis.clients.jedis.HostAndPort;
import redis.clients.jedis.Jedis;
import redis.clients.jedis.Pipeline;
import redis.clients.jedis.Response;
import java.util.List;
public class TranscationDemo {
public static void main(String[] args) {
Jedis jedis = new Jedis(new HostAndPort("127.0.0.1", 6379));
System.out.println(deal(jedis, "zzh"));
}
public static int deal(Jedis jedis, String user) {
String key = String.format("account_%s", user);
while (true) {
jedis.watch(key);
try {
int val = Integer.parseInt(jedis.get(key));
Pipeline pipeline = jedis.pipelined();
pipeline.multi();
System.out.println("done");
Thread.sleep(5000);
System.out.println("end sleep");
pipeline.set(key, String.valueOf(val * 2));
Response<List<Object>> resp = pipeline.exec();
pipeline.close();
List<Object> respData = resp.get();
if (respData != null && respData.size() > 0) {
break;
} else {
System.out.println("watch fail");
}
} catch (Exception e) {
e.printStackTrace();
}
}
return Integer.parseInt(jedis.get(key));
}
}
六、pubsub(pubsub可使用kafka代替,暂不研究,后续补充内容)
七、小对象压缩
1. 32bit vs 64bit
redis如果使用32bit进行编译,内部所有数据结构所使用的指针空间占用会少一半,如果redis使用内存不超过4g,可以考虑使用32bit编译;如果内存不足,可以通过增加实例的方式解决
2. 小对象压缩存储(ziplist)
定义:若redis内部管理的集合数据机构很小,则会使用紧凑存储形式压缩存储
ziplist:ziplist是一个紧凑的字节数组结构,每个元素之间紧挨着。ziplist在redis中的应用:
- hash结构中的应用:key和val会作为两个entry被相邻存储
127.0.0.1:6379> hset hello a 1
(integer) 1
127.0.0.1:6379> object encoding hello
"ziplist"
- zset结构中的应用:value和score会被作为两个entry相邻存储
127.0.0.1:6379> zadd word 1 a
(integer) 1
127.0.0.1:6379> object encoding word
"ziplist"
- intset中的应用:若整数可以用uint16表示,则intset是16位的数组,若新加入的整数超过了16位,那么数组每个元素升级为uint32;若新加入的元素是uint64,数组则升级为uint64;若set存储的是字符串,则数组升级为hashtable
127.0.0.1:6379> sadd data 1 2 3 4 5
(integer) 5
127.0.0.1:6379> object encoding data
"intset"
127.0.0.1:6379> sadd data haha
(integer) 1
127.0.0.1:6379> object encoding data
"hashtable"
ziplist存储下限:随着集合对象的元素不断增加,或某个value值不断变大,小对象存储会升级成标准结构。redis小对象存储限制条件如下:
例子(hash结构任意entry的value长度大于64,使用标准结构存储):
// val超过64
127.0.0.1:6379> hset testdata test 1
(integer) 1
127.0.0.1:6379> object encoding testdata
"ziplist"
127.0.0.1:6379> hset testdata test1 111111111111111111111111111111111111111111111111111111111111111111111111111111111
(integer) 1
127.0.0.1:6379> object encoding testdata
"hashtable"
// key超过64
127.0.0.1:6379> hset test1 test 1
(integer) 1
127.0.0.1:6379> object encoding test1
"ziplist"
127.0.0.1:6379> hset test1 test1111111111111111111111111111111111111111111111111111111111111111111111111111 1
(integer) 1
127.0.0.1:6379> object encoding test1
"hashtable"
3. 内存回收机制
redis内存回收特点:
- 操作系统是以页为单位回收内存的,页上只要还有一个key使用,就不会回收数据
- 使用flushdb,内存会被回收,原因是因为所有key都被删除,大部分页面已经删除干净,会立即被操作系统回收
- redis虽然不能保证立即回收被删除的key的空间,但会重新使用尚未回收的内存(内存池?)
4. 内存分配算法
redis为了维护自身结构的简单性,将内存分配的细节交给了第三方内存分配库区实现。目前redis可用的库有jemalloc(默认)和tcmalloc,细节后续研究
127.0.0.1:6379> info memory
# Memory
used_memory:691224
used_memory_human:675.02K
used_memory_rss:654312
used_memory_rss_human:638.98K
used_memory_peak:691224
used_memory_peak_human:675.02K
total_system_memory:0
total_system_memory_human:0B
used_memory_lua:37888
used_memory_lua_human:37.00K
maxmemory:0
maxmemory_human:0B
maxmemory_policy:noeviction
mem_fragmentation_ratio:0.95
mem_allocator:jemalloc-3.6.0