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\n3\r\nset\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,采用的是一种贪心挑选的策略。
    1. 从字典中随机挑选20个key
    2. 删除这20个key中已经过期的key
    3. 如果过期的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还会在从节点查到。之前说的分布式锁在集群环境下会不安全,这个也是一大部分原因。
最后编辑于
©著作权归作者所有,转载或内容合作请联系作者
  • 序言:七十年代末,一起剥皮案震惊了整个滨河市,随后出现的几起案子,更是在滨河造成了极大的恐慌,老刑警刘岩,带你破解...
    沈念sama阅读 199,902评论 5 468
  • 序言:滨河连续发生了三起死亡事件,死亡现场离奇诡异,居然都是意外死亡,警方通过查阅死者的电脑和手机,发现死者居然都...
    沈念sama阅读 84,037评论 2 377
  • 文/潘晓璐 我一进店门,熙熙楼的掌柜王于贵愁眉苦脸地迎上来,“玉大人,你说我怎么就摊上这事。” “怎么了?”我有些...
    开封第一讲书人阅读 146,978评论 0 332
  • 文/不坏的土叔 我叫张陵,是天一观的道长。 经常有香客问我,道长,这世上最难降的妖魔是什么? 我笑而不...
    开封第一讲书人阅读 53,867评论 1 272
  • 正文 为了忘掉前任,我火速办了婚礼,结果婚礼上,老公的妹妹穿的比我还像新娘。我一直安慰自己,他们只是感情好,可当我...
    茶点故事阅读 62,763评论 5 360
  • 文/花漫 我一把揭开白布。 她就那样静静地躺着,像睡着了一般。 火红的嫁衣衬着肌肤如雪。 梳的纹丝不乱的头发上,一...
    开封第一讲书人阅读 48,104评论 1 277
  • 那天,我揣着相机与录音,去河边找鬼。 笑死,一个胖子当着我的面吹牛,可吹牛的内容都是我干的。 我是一名探鬼主播,决...
    沈念sama阅读 37,565评论 3 390
  • 文/苍兰香墨 我猛地睁开眼,长吁一口气:“原来是场噩梦啊……” “哼!你这毒妇竟也来了?” 一声冷哼从身侧响起,我...
    开封第一讲书人阅读 36,236评论 0 254
  • 序言:老挝万荣一对情侣失踪,失踪者是张志新(化名)和其女友刘颖,没想到半个月后,有当地人在树林里发现了一具尸体,经...
    沈念sama阅读 40,379评论 1 294
  • 正文 独居荒郊野岭守林人离奇死亡,尸身上长有42处带血的脓包…… 初始之章·张勋 以下内容为张勋视角 年9月15日...
    茶点故事阅读 35,313评论 2 317
  • 正文 我和宋清朗相恋三年,在试婚纱的时候发现自己被绿了。 大学时的朋友给我发了我未婚夫和他白月光在一起吃饭的照片。...
    茶点故事阅读 37,363评论 1 329
  • 序言:一个原本活蹦乱跳的男人离奇死亡,死状恐怖,灵堂内的尸体忽然破棺而出,到底是诈尸还是另有隐情,我是刑警宁泽,带...
    沈念sama阅读 33,034评论 3 315
  • 正文 年R本政府宣布,位于F岛的核电站,受9级特大地震影响,放射性物质发生泄漏。R本人自食恶果不足惜,却给世界环境...
    茶点故事阅读 38,637评论 3 303
  • 文/蒙蒙 一、第九天 我趴在偏房一处隐蔽的房顶上张望。 院中可真热闹,春花似锦、人声如沸。这庄子的主人今日做“春日...
    开封第一讲书人阅读 29,719评论 0 19
  • 文/苍兰香墨 我抬头看了看天上的太阳。三九已至,却和暖如春,着一层夹袄步出监牢的瞬间,已是汗流浃背。 一阵脚步声响...
    开封第一讲书人阅读 30,952评论 1 255
  • 我被黑心中介骗来泰国打工, 没想到刚下飞机就差点儿被人妖公主榨干…… 1. 我叫王不留,地道东北人。 一个月前我还...
    沈念sama阅读 42,371评论 2 346
  • 正文 我出身青楼,却偏偏与公主长得像,于是被迫代替她去往敌国和亲。 传闻我的和亲对象是个残疾皇子,可洞房花烛夜当晚...
    茶点故事阅读 41,948评论 2 341

推荐阅读更多精彩内容

  • 1.1 资料 ,最好的入门小册子,可以先于一切文档之前看,免费。 作者Antirez的博客,Antirez维护的R...
    JefferyLcm阅读 17,028评论 1 51
  • 五种数据结构简介 Redis是使用C编写的,内部实现了一个struct结构体redisObject对象,通过结构体...
    彦帧阅读 6,923评论 0 14
  • 基于内存的NoSQL数据库。提供五种数据结构的存储。字符串、列表、集合、有序集合、散列表。Redis 支持很多特性...
    韩绝交阅读 672评论 0 1
  • 本文摘抄至大鹏的redis教程 redis是一种kv存储系统,value支持五种数据类型: 字符串(strings...
    lintong阅读 64,404评论 0 7
  • 原帖地址:https://www.jianshu.com/p/2f14bc570563 redis概述 Redis...
    onlyHalfSoul阅读 2,148评论 0 28