Redis学习之旅~原理篇
内容依旧来自<redis深度历险>
核心原理
线程IO模型
单线程非阻塞IO
- redis是单线程模型。redis的指令很快,主要就是由于所有的运算都在内存,省去了磁盘IO的开销。由于是单线程,时间复杂度较高的指令和存储的key过大,都会导致redis卡顿。
- 多路复用:使用的是非阻塞IO,这个就类似java的NIO。一般我们使用阻塞io进行读取的时候,read方法需要读取n个字节,如果一个字节都没有,线程就会在那里等待,一定要读够n个字节才能返回,线程才能做其他事。非阻塞IO就是就是,打开套接字的时候,读写不再阻塞。实际写了多少盒读了多少,会立马又返回值告诉程序实际读写多少字节。redis的线程不会因为读写而停顿,读写完的瞬间,就可以去处理其他业务。
- 事件轮询:非阻塞IO没有解决的问题就是,线程要读或者写的数据,在读取了一部分就返回了。这时,线程肯定不能把数据直接返回给调用端。需要一个什么机制来保证相应线程数据到来的时候,线程能够被通知到。最简单的事件轮询api就是select函数。输入是读写描述符列表,同时对线程调用还提供了一个timeout参数。这个参数意味着,线程会等待timeout值得时间。如果在等待期间,有任何事件到来,就可以立即返回。拿到事件以后,线程就可以继续处理相应的事件。这里,需要写一个死循环,成为事件循环
while (true) {
eventList = select(readFds, writeFds, timeout);
for (event in eventList) {
handleEvent(event);
}
}
- 指令队列和响应队列:redis会将每个客户端的套接字都关联一个指令队列,所有的指令都放到队列中进行顺序处理。如果redis有多个客户端连接的话,那就是先到的队列先处理。响应队列也是一样,通过队列将结果返回给客户端。如果队列为空的话,事件轮询是不是就不应该再去轮询这个队列了呢?redis的做法就是,如果队列为空,就把队列的文件描述符write_fds进行移除,然后移除事件轮询,等到队列有数据了,再给这个队列添加写文件描述符。这样可以避免redis的select获取到队列以后,发现没东西可写就立即返回。
- 定时任务:redis除了处理指令以外,还需要处理其他的业务,比如定时任务,备份等。redis的定时任务存储在最小堆那里,维护一个最小堆所需要的时间是nlogn,时间复杂度不算高,基本线性时间。在事件循环的周期里面,redis会对最小堆里面的已经到时间点的定时任务进行处理。处理完毕以后,就会将下一个即将要执行的定时任务的时间获取到,这个时间就是select函数这个线程的睡眠时间。在这个时间区间之内,是可以预期没有其他任务需要处理的,可以休眠。但是,如果当休眠的时候有指令到来,select函数就会被激活,进行下一轮的事件循环。处理完指令以后,再去堆那里获取定时任务,如果有就执行,没有,就刷新timeout。
通信协议
背景
- redis的作者认为,数据库的瓶颈不在网络流量,而在于内部的逻辑处理上面。Redis的传输协议是RESP协议,这个协议有很多的字符冗余,会浪费网络流量,但是其优势在于解析性能极好。
最小单元类型,每个单元结束时候以\r\n结束
- 单行字符串以"+"开头
- 多行字符串以"$"开头,后面跟上字符串长度
- 整数值以":"开头,后面跟整数的字符串形式
- 错误消息以"-"开头
- 数组以"*"开头,后面跟数组的长度
- 单行字符串redis,表示为: +redis\r\n
- 多行字符串hello world,表示为: $11\r\nhello world \r\n
- 整数100,表示为: :100
- 错误, -Wrong\r\n
- 数组[1,2,3],表示为: *3\r\n:1\r\n:2\r\n:3\r\n
- 客户端发送的指令和服务器返回的响应,也是这五种单元类型的组合
- set指令set a a, 表示为一个字符串数组,*3\r\n1\r\na\r\n$1\r\na\r\na
上面列举了这么多个类型,可以看出redis的传输协议里面有大量冗余的回车换行符。虽然它浪费了部分空间,但是胜在简洁。这里我需要思考的就是,性能并不总是一切,简单性、易理解和易实现也是要权衡的问题。
redis持久化
- redis的备份有rbd和aof两种。这两种方式都有自己的不足。
- rbd快照全量备份的话,在服务器宕机的时候会丢失数据
- aof增量备份的话,日志文件会变得无比巨大,这时就需要有一个定时任务去对aof文件进行整理。
- 从上面我们知道,redis是单线程程序,线程需要处理指令和定时任务,进行快照备份是需要进行文件io的,这个会严重拖慢redis服务器的性能。那么,redis是如何实现一边处理线上指令,一边进行快照备份的呢?进行快照备份的时候,是如何解决内存数据结构改变的问题?
- redis是使用操作系统的多进程特性来进行快照持久化的。在要进行持久化的时候,redis会fork一个子进程,快照持久化就完全交给子进程处理。子进程和父进程共享内存里面的代码段和数据段。在子进程产生的一瞬间,内存的增长几乎是没有明显变化。
- 使用子进程做数据持久化,不会修改现有的内存数据,只是对数据结构进行遍历读取,然后序列化存储到磁盘中。如果这时父进程正在修改共享的数据的时候,父进程会对要修改的页面复制一份,分离出来,子进程看到的数据还是子进程产生时候的数据,所以称为
快照
。这样有页面被分离的时候,内存会有相应的增长,但是也不会超过原来内存的2倍。 - redis的AOF日志存储的就是服务器顺序指令,只会记录修改数据的指令。这个备份是不会fork一个子进程。redis是先执行命令,然后才将日志存盘。为何要这样呢?这是造成redis不支持事务回滚的原因,因为发生异常的时候,没有用来进行回滚的日志。这一点和mysql不一样,mysql是先做日志,再做操作,所以mysql支持回滚。
- redis提供了bgrewriteaof指令用于对aof日志文件进行瘦身,其原理就是开辟一个子进程对内存进行遍历,转换成为一系列的redis操作指令,序列化成为一个新的aof日志文件中。这个操作完成以后,再将期间发生的增量aof文件追加到新的aof文件中,这样就用新的文件替换旧的文件。
- fsync:aof日志是异步写到文件中的。这时候有一个问题,如果服务器在写磁盘的时候突然宕机,就会导致内容没有来得及刷入磁盘,日志进行丢失。Linux提供了fsync(写设备命令),fwrite只是写入到缓冲区,加上fsync(fileno(fp))。该函数返回后,才能保证写入到了物理介质上。只要redis实时调用fsync命令,就能保证日志不丢失。但是,这个操作就涉及io了,会很慢。我们有三种设置,一种是永远不调用fsync(存盘完全交给操作系统),一种是每个指令都调用fsync(性能太差),一种设置是通常间隔1秒就调用一次fsync。最后一种方式一般用于生产环境,在性能和安全之间做一个平衡。所以,aof可能丢失的就是1秒的数据
实际操作代码如下:
cd /etc
vim redis.conf
修改如下配置
appendonly yes
# The name of the append only file (default: "appendonly.aof")
appendfilename "appendonly.aof"
往下面看,有三种刷盘方式,我们选择每秒刷一次
# appendfsync always
appendfsync everysec # 一秒调用一次
# appendfsync no
...
很后面有一行,这个是redis文件的配置
dir /var/lib/redis
运行几个命令
set a 1
incr a
set b 2
如此...
接着去到/var/lib/redis文件夹,可以看到appendonly.aof
文件已经生成,使用less命令进行查看,就会有如下命令
*2
$6
SELECT
$1
0
*3
$3
SET
$1
c
$1
2
*3
$3
SET
$1
v
$1
1
*3
$3
SET
$1
a
$1
1
接下来尝试另外一个命令,bgrewriteaof
对日志进行瘦身
dbsize
6
//日志显示的文件大小
[root@VM_75_157_centos redis]# ll
total 20
-rw-r--r-- 1 root root 347 Jun 20 22:37 appendonly.aof
然后执行bgrewriteaof命令:
127.0.0.1:6379> bgrewriteaof
Background append only file rewriting started
redis开启了子进程进行瘦身
[root@VM_75_157_centos redis]# ll
total 20
-rw-r--r-- 1 root root 267 Jun 20 22:38 appendonly.aof
文件大小从347降低到了267
- redis的RBD和AOF方式都有优缺点。我们究竟采取何种方式呢?在redis4.0之前,我们是很少使用rbd来重启服务器的,这样会丢失大量数据。通常使用的是aof重放,但是这样启动时间就很长。好在redis4.0带来了一个新的持久化方式,混合持久化。将rbd文件的内容和aof的日志文件放在一起。这时的aof不再是全量的日志,而是
自持久化开始到持久化结束结束的时间发生的增量aof日志
。通常aof这部分的日志很小。然后,在进行重启的时候,先加载rbd文件的内容,然后重放aof日志。这样,重启效率就大大提升了。4.0版本的混合持久化默认关闭的,通过aof-use-rdb-preamble配置参数控制,yes则表示开启,no表示禁用,默认是禁用的,可通过config set修改。
管道
- redis客户端提供了管道技术,可以批量处理命令,效率有提高。为何会这样呢?一般来说,我们发送一条命令给redis服务器,它就返回了一个结果,这样就是一个网络数据包来回的时间。write->read的过程。管道是怎么回事呢?管道调整了指令的执行方式,将多个write命令先缓存起来,然后批量发送。比如发送两个指令,顺序就是write->read->write->read,消耗两个数据包时间。使用了管道以后,执行顺序就会变成了write->write->read->read,这时就只是花费了一个网络来回时间。
public class PiplineTest {
private static int count = 10000;
public static void main(String[] args){
useNormal();
usePipeline();
}
public static void usePipeline(){
ShardedJedis jedis = getShardedJedis();
ShardedJedisPipeline pipeline = jedis.pipelined();
long begin = System.currentTimeMillis();
for(int i = 0;i<count;i++){
pipeline.set("key_"+i,"value_"+i);
}
pipeline.sync();
jedis.close();
System.out.println("usePipeline total time:" + (System.currentTimeMillis() - begin));
}
public static void useNormal(){
ShardedJedis jedis = getShardedJedis();
long begin = System.currentTimeMillis();
for(int i = 0;i<count;i++){
jedis.set("key_"+i,"value_"+i);
}
jedis.close();
System.out.println("useNormal total time:" + (System.currentTimeMillis() - begin));
}
public static ShardedJedis getShardedJedis(){
JedisPoolConfig poolConfig = new JedisPoolConfig();
poolConfig.setMaxTotal(2);
poolConfig.setMaxIdle(1);
poolConfig.setMaxWaitMillis(2000);
poolConfig.setTestOnBorrow(false);
poolConfig.setTestOnReturn(false);
JedisShardInfo info1 = new JedisShardInfo("127.0.0.1",6379);
JedisShardInfo info2 = new JedisShardInfo("127.0.0.1",6379);
ShardedJedisPool pool = new ShardedJedisPool(poolConfig, Arrays.asList(info1,info2));
return pool.getResource();
}
}
消耗时间
useNormal total time:772
usePipeline total time:112
- 使用管道的确是节省了时间。这种情况何时使用呢?对于可以允许少量失败的批量写入程序可以使用。比如信息群发,漏掉一两条无所谓,使用定时任务去补就好了。
管道的本质:网络交互的简略流程如下
- 客户端进程调用write将消息写到操作系统为套接字分配的缓冲区中
- 客户端操作系统将缓冲区的内容发送出去
- 服务器进程将数据放在操作系统为套接字分配的缓冲区中
- 服务器调用write将响应消息写到套接字分配的缓冲区中
- 服务器将内容发送出去
- 客户端操作系统将接收到的数据放到为套接字分配的缓冲区中
- 客户端进程调用read从缓冲区读取数据返回给上层使用
我们开始以为,客户端的write操作是要等到对方收到消息以后才返回的,实际情况不是这样。实际情况是客户端的write负责把数据写到缓冲区就返回了。剩下的发送交给操作系统。但是,如果缓冲区满了,write操作就要等待缓冲区空出空间来,这个才是写操作IO真正的耗时。读取内容也是这么回事,读IO操作的耗时就是等待缓冲区有数据到来。 - 对于单个命令的set a 1这样,写操作几乎没有耗时,读操作就有耗时了,这时就要等待网络消息的到来。
- 对于管道来说,连续的write几乎不耗时,多个write也只是写入到了缓冲区。第一个read会比较耗时,会等到数据回来。但是,当第一个结果已经返回的时候,所有的响应都回到操作系统内核的缓冲区了,后续的read就可以直接拿结果,瞬间返回。
redis事物
普通数据库的事务大致如下:
begin();
try{
//业务逻辑
....
commit();
} catch(Exception e) {
rollback();
}
redis的事务有如下的指令来支持,主要有multi
事务开始,exec
事务执行,discard
事务丢弃。
127.0.0.1:6379> multi
OK
127.0.0.1:6379> incr a
QUEUED
127.0.0.1:6379> incr a
QUEUED
127.0.0.1:6379> exec
1) (integer) 9
2) (integer) 10
如果中途有命令是错误的呢?
[root@VM_75_157_centos ~]# redis-cli
127.0.0.1:6379> multi
OK
127.0.0.1:6379> incre a
(error) ERR unknown command 'incre'
127.0.0.1:6379> incr a
QUEUED
127.0.0.1:6379> incr a
QUEUED
127.0.0.1:6379> exec
(error) EXECABORT Transaction discarded because of previous errors.
这时就会告诉用户,事务被丢弃了
a的值并没有改变。但是,这并没有确保是所有的指令都没有执行,redis的事务不支持原子性
redis事务的执行流程就是所有的执行在exec指令之前,都不会执行,而是缓存在服务器的事务队列当中。服务器一旦接收到exec指令,才开始批量执行队列的指令。之前说过redis是单线程,所以可以保证队列里面的指令可以得到顺序执行,不会被其他指令抢占。保证了一批指令的批量执行。
- 探讨redis事务的原子性
(nil)
127.0.0.1:6379> multi
OK
127.0.0.1:6379> set test test
QUEUED
127.0.0.1:6379> incr test
QUEUED
127.0.0.1:6379> set test2 test2
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 test
"test"
127.0.0.1:6379> get test2
"test2"
上面的事务,在 第二个指令执行的时候失败了。如果有使用mysql的经验,我们可能认为,后续的get命令,得到的会是null值。的确,mysql可以对事务进行回滚。但是,redis后续的指令都被执行了。redis事务不支持回滚的一个原因就是redis是先操作指令,然后再写日志。而mysql是先写日志,再进行操作。所以,发生错误的时候,mysql有可以回滚的日志,而redis没有。通过上述的操作,我们可以知道redis的事务不具备原子性,而是仅仅满足了事务隔离性种的串行化。
对事务的操作,我们是可以进行一定的优化的,使用的方式就是前面提过的管道。之前的这几个命令,一个命令就消耗了一个网络来回,我们可以使用管道进行优化。
watch指令,这个是redis提供的一种乐观锁的实现。如果有用过关系型数据库,乐观锁的实现的话,就是在表里面增加一个version版本号。在对某一行进行修改的时候,先select这一行,获得当前的版本号,然后执行更新的时候可以是
update table set a = ? where id = ? and version = 当前线程select的版本号
乐观锁可以处理Java程序的多线程并发修改。redis的watch也是同样的道理,在事务开启之前,先用watch盯住某个key,然后进行事务操作,如果key在事务执行之前,有被修改过,事务就执行失败。
127.0.0.1:6379> set books java
OK
127.0.0.1:6379> watch books
OK
127.0.0.1:6379> set books redis
OK
127.0.0.1:6379> multi
OK
127.0.0.1:6379> set books golang
QUEUED
127.0.0.1:6379> exec
(nil)
我们事先watch了books变量,但是,在事务之前,books被改变了,所以,后面执行事务的时候,就失败。redis乐观锁的指令顺序是watch->multi->exec。
分布式锁
- 如果redis配置了集群环境,redis的set指令扩展设置的分布式锁就会出现问题,它就变得不是绝对安全了。例如,redis有主从两个节点,线程1在主节点获得了一个锁。这时主节点挂掉了,从节点升为主节点,这时新的主节点并没有那个key,线程2请求加锁的时候,也会获得同一把锁。
- 这个问题的解决,需要引入第三方的library,如redlock.使用redlock算法的话,可以保证加锁成功。它的原理是向大多数节点都发送set(key,value,nx,ex)指令,当半数节点都返回true的时候,才认为加锁成功。del也同样如此。由于要对多个节点进行操作,性能会有一定的下降。
redis key的过期策略
- redis的所有数据结构都可以设置过期时间,时间到了,就可以被自动删除。我之前一直很好奇的就是redis的key到底是怎么过期的。使用定时任务?可是如果同一时间太多key要过期,定时任务处理不过来。
- redis会对设置了过期时间的可以放入一个独立的字典种,定时任务会去变量这个数据结构去删除过期的key。除了定时处理以外,redis还提供了惰性删除的方式。在客户端访问访问key的时候,会对key的过期时间进行检查,如果发现过期,就立即删除。
定时扫描
- redis默认每秒进行10次过期扫描,这里不会检查所有过期的key,采用的是一种贪心挑选的策略。
- 从字典中随机挑选20个key
- 删除这20个key中已经过期的key
- 如果过期的key的比例超过25%,就重复步骤1
扫描策略的时间配置
cd /etc
vim redis.conf
把文件拉到最后,会有一行
hz 10
修改这个值就可以改变定时过期扫描的频率,redis支持1~500,但是超过100的话,就不是一个good idea。
- 这里我们会想到,如果一个redis的key在某一个时间段集中过期会怎么样?会不会导致redis卡顿?如果出现这种情况,redis是会出现卡顿的,但是redis对过期扫描设置了时间上限,默认不会超过25ms。就是说,当客户端的请求到来,如果这时redis正在执行过期,那么客户端请求会等待至少25ms才能返回。这时,就要注意客户端的超时时间设置得短的话,就有可能会超时。
- 避免大量的key集中过期的话,我们可以使用一种随机的策略,将时间分散。
jedis.expire(key, Math.random(86400) + time);
从节点过期策略
- 从节点不会进行过期扫描,这个处理是被动的。主节点在key到期的时候,会在aof文件中增加一个del指令,等到从节点同步aof以后,从节点执行这个del指令来删除相应的key。
- 从节点同步的延迟,会导致数据在主节点已经被删除,但是从节点没有及时同步,已经过期的key还会在从节点查到。之前说的分布式锁在集群环境下会不安全,这个也是一大部分原因。