一文通透讲解Redis高级特性,多线程/持久化/淘汰机制等统统搞定

Redis 是一个开源的,基于内存的可持久化的非关系型数据库存储系统。在实际项目中可以用 Redis 做缓存或消息服务器,Redis 也是目前互联网中使用比较广泛的非关系型数据库,下面就来深入分析Redis的高级特性!

Lua脚本

介绍

Lua是一种轻量级脚本语言,它是C语言编写的,跟数据的存储过程有点类似。官网介绍:redis.io/commands/ev… 。使用Lua脚本来执行Redis命令的好处:

  1. 一次发送多个命令,减少网络开销。
  2. Redis会将整个脚本作为一个整体执行,不会被其他请求打断,保持原子性
  3. 对于复杂的组合命令,我们可以放在文件中,可以实现命令复用。

语法

Lua脚本的语法:

eval lua-script key-num [key1 key2 key3 ...][value1 value2 value3...]
  • eval代表执行Lua语言的命令。
  • lua-script代表Lua语言脚本内容。
  • key-num表示参数中有多少个key,需要注意的是Redis中key是从1开始的,如果没有key的参数,那么写0。
  • [key1 key2 key3 ...]是key作为参数传递给Lua语言,也可以不填,但是需要和key-num的个数对应起来。
  • [value1 value2 value3...]这些参数传递给Lua语言,它们是可填可不填的。

lua-script里面执行redis命令的语法:

redis.call(command,key[param1,param2...])
  • command是命令,包括set、get、del等。
  • key是被操作的键。
  • param1,param2...代表给key的参数。

学习了上面的两个语法,我们来看几个例子。

eval "return 'Hello World'" 0
eval "return redis.call('set','jackxu','shuaige')" 0
eval "return redis.call('set',KEYS[1],ARGV[1])" 1 jackxu shuaige

一文通透讲解Redis高级特性,多线程/持久化/淘汰机制等统统搞定_第1张图片 

  • 第一个是返回一个hello word,没有用到redis命令,所以key的参数是0。
  • 第二个用到了redis命令,set jackxu shuaige,但是这里的key写的固定的,所以传递进去key的个数还是0个。
  • 第三个还是set jackxu shuaige,但是这里key和value是作为参数传递进去的,所以后面跟的1是指key的个数,jackxu是key,shuaige是value。

在Lua脚本比较长的情况下,如果每次调用脚本都需要把整个脚本传给Redis服务端,会产生比较大的网络开销。为了解决这个问题,Redis可以缓存Lua脚本并生成SHA1摘要码,后面可以直接通过摘要码来执行Lua脚本。语法如下:

## 在服务端缓存lua脚本生成一个摘要码
script load "return 'Hello World'"
## 通过摘要码执行缓存的脚本 
evalsha "470877a599ac74fbfda41caa908de682c5fc7d4b" 0

 

Java代码中用法

上面介绍的是在redis的客户端执行的方法,但往往我们是在代码中使用的,下面我们来看下在代码中是如何使用的。

第一个例子比较简单,就是上面的语法在Jedis中的实现,写法是一样的。

    public static void main(String[] args) {
        Jedis jedis = new Jedis("39.103.144.86", 6379);
        jedis.eval("return redis.call('set',KEYS[1],ARGV[1])", 1, "jackxu", "shuaige");
        System.out.println(jedis.get("jackxu"));
    }

第二个例子讲的是限流,X秒内限制访问Y次,我这里参数里面写的是1和200,代表1秒内限制访问200次,其实这个就是QPS200。因为这里的Lua脚本非常的长,每次发给服务端需要耗时,所以使用的是上面第二种写法摘要的方法来实现的。

    /**
     * 限流,X秒内限制访问Y次
     */
    public static void limit() {
        Jedis jedis = new Jedis("39.103.144.86", 6379);
        // 只在第一次对key设置过期时间
        String lua = "local num = redis.call('incr', KEYS[1])\n" +
                "if tonumber(num) == 1 then\n" +
                "\tredis.call('expire', KEYS[1], ARGV[1])\n" +
                "\treturn 1\n" +
                "elseif tonumber(num) > tonumber(ARGV[2]) then\n" +
                "\treturn 0\n" +
                "else \n" +
                "\treturn 1\n" +
                "end\n";
        Object result = jedis.evalsha(jedis.scriptLoad(lua), Arrays.asList("limit.key"), Arrays.asList("1", "200"));
        System.out.println(result);
    }

脚本超时

Redis的指令执行本身是单线程的,这个线程如果正在执行Lua脚本,这时候Lua脚本执行超时或者陷入了死循环,这会导致其他的命令进入等待状态。如下命令

eval 'while(true) do end' 0

redis config中有个配置

lua-time-limit 5000

脚本执行有个超时时间,默认5秒,超过了5秒,其他客户端不会等待,而是直接会返回"BUSY"错误。但是这样也不行,就像上面的while循环卡住了,这样别的命令一个都执行不了。这时候我们就要把Lua脚本给终止掉。

终止有两个命令,script kill 和 shutdown nosave。像上面的命令用script kill即可终止,但是下面的命令终止不了。

eval "redis.call('set','jack','aaa') while true do end" 0

如果当前执行的Lua脚本对Redis进行了数据修改(set、del等),用script kill是终止不了的,会返回UNKILLABLE错误。因为脚本的运行要保证原子性,如果脚本执行了一部分被终止,那就违背了脚本原子性的目标。遇到这种情况就要用shutdown nosave命令来终止,正常关闭reids是shutdown命令,shutdown nosave的区别在于不会进行持久化操作,意味着发生在上一次快照后的数据库修改都会丢失。

应用

最后介绍了这么多Lua的知识,我们知道Lua脚本非常的重要,我们在实现分布式锁的时候一定要用Lua脚本来保证原子性。

另外还可以用Redis incr来实现限流,通过一个自定义注解+切面放在方法上面即可。

Reids为什么这么快

首先我们来测试一下Redis到底有多快,redis自带了一个测试工具,redis.io/topics/benc… ,接下来我们来测试一下

cd /jackxu/redis-6.2.2/src
redis-benchmark -t set,lpush -n 100000 -q

set和lpush分别是9.4W和9W,和官网号称的10W QPS相差不大,我这台是1核2G的云服务器,如果配置和网络好的话还会更高。

为什么能够抗住这么大的QPS,总结起来有三点:

  1. 纯内存结构
  2. 请求处理单线程
  3. 多路复用机制

下面我们来分别看下。

内存

大家知道redis是存储在内存的,内存就是快快快,时间复杂度是O(1)。

单线程

我们经常说Redis是单线程的,其实这是不严谨的。Redis 单线程指的是网络请求模块使用了一个线程,即一个线程处理所有网络请求, 其他模块该使用多线程,仍会使用了多个线程。从4.0的版本之后,还引入了一些线程处理其他的事情,比如请求脏数据、无用连接的释放、大Key的删除。

把处理请求的主线程设置成单线程有什么好处呢?

  1. 没有创建线程、销毁线程带来的消耗
  2. 避免了上下文切换导致的CPU消耗
  3. 避免了线程之间带来的竞争问题,例如加锁释放锁死锁等等

官方给出的解释是,在Redis中单线程已经够用了,CPU不是Redis的瓶颈。Redis的瓶颈最有可能是机器内存或者网络带宽。既然单线程容易实现,又不需要处理线程并发的问题,那就顺理成章地采用单线程方案了。

注意,因为处理请求是单线程的,不要在生产环境运行长命令,比如keys、flushall、flushdb,否则会导致请求被阻塞。

多路复用

首先要弄清楚产生问题的原因?

由于进程的执行过程是线性的(也就是顺序执行),当我们调用低速系统I/O(read,write,accept等等),进程可能阻塞,此时进程就阻塞在这个调用上,不能执行其他操作。阻塞是很正常的,当客户端和服务端在通信的时候,服务器端要read(sockfd1,bud,bufsize),此时客户端进程没有发送数据,那么read(阻塞调用)将阻塞直到客户端write(sockfd,but,size)发来数据。在一个客户和服务器通信时这没什么问题,当多个客户与服务器通信时,若服务器阻塞于其中一个客户sockfd1,当另一个客户的数据到达套接字sockfd2时,服务器仍不能处理,仍然阻塞在read(sockfd1,...)上。此时问题就出现了,不能及时处理另一个客户的服务,这时候就要用I/O多路复用来解决!

当有多个客户连接的时候,sockfd1、sockfd2、sockfd3..sockfdn同时监听这n个客户,当其中有一个发来消息时就从select的阻塞中返回,然后就调用read读取收到消息的sockfd,然后又循环回select阻塞;这样就不会因为阻塞在其中一个上而不能处理另一个客户的消息。更详细地讲解请参考我写的《浅谈Linux五种IO模型》文章。

下面是Redis中的模型图:

Redis基于Reactor模式开发了自己的网络事件处理器,称之为文件事件处理器(File Event Hanlder)。文件事件处理器由Socket、IO多路复用程序、文件事件分派器(dispather),事件处理器(handler)四部分组成。

IO多路复用程序会同时监听多个socket,当被监听的socket准备好执行accept、read、write、close等操作时,与这些操作相对应的文件事件就会产生。IO多路复用程序会把所有产生事件的socket压入一个队列中,然后有序地每次仅一个socket的方式传送给文件事件分派器,文件事件分派器接收到socket之后会根据socket产生的事件类型调用对应的事件处理器进行处理。一文通透讲解Redis高级特性,多线程/持久化/淘汰机制等统统搞定_第2张图片

Redis中的I/O多路复用的所有功能通过包装常见的select、epoll、evport和kqueue,这些I/O多路复用函数库来实现的。

  • evport是Solaris系统内核提供支持的;
  • epoll是linux系统内核提供支持的;
  • kqueue是Mac系统内核提供支持的;
  • select是POSIX提供的,一般的操作系统都有支持(保底方案);

事件处理器又分为三种:

  • 连接应答处理器:用于处理客户端的连接请求;
  • 命令请求处理器:用于执行客户端传递过来的命令,比如常见的set、lpush等;
  • 命令恢复处理器:用于返回客户端命令的执行结果,比如set、get等命令的结果;

多线程I/O

我们知道Redis 6.0版本引入了多线程,这里所说的多线程,其实就是将Redis单线程中做的这两件事情从客户端读取数据、回写数据给客户端(也可以称为网络I/O),处理成多线程的方式,但是执行Redis命令还是在主线程中串行执行,所以不存在线程并发安全问题。

下面是单线程I/O和多线程I/O的区别:一文通透讲解Redis高级特性,多线程/持久化/淘汰机制等统统搞定_第3张图片

一文通透讲解Redis高级特性,多线程/持久化/淘汰机制等统统搞定_第4张图片 

过期策略

我们知道Redis的内存是有限的,不可能让你一直往里面set,总有一天会满的,这时候我们需要把已经过期的key给删掉,来腾出空间。过期策略有三种:

立即过期(主动淘汰)

每个设置过期时间的Key都需要创建一个定时器,到过期时间就会立即清除。该策略可以立即清除过期的数据,对内存很友好;但是会占用大量的CPU资源去处理过期的数据,从而影响缓存的响应时间和吞吐量。

惰性过期(被动淘汰)

只有当访问一个Key时,才会判断该Key是否已过期,过期则清除。该策略可以最大化地节省CPU资源,却对内存非常不友好。极端情况可能出现大量的过期Key没有再次被访问,从而不会被清除,占用大量内存。

所有的查询都会调用expireIfNeeded判断是否过期:db.c 1299行

expireIfNeeded(redisDb *db,robj *key)

定期过期

每隔一定的时间,会扫描一定数量的数据库的expires字典中一定数量的key,并清除其中已过期的key。该策略是前两种方案的折中,通过调整定时扫描的时间间隔和每次扫描的限定耗时,可以在不同情况下使用CPU和内存资源达到最优的平衡效果。

总结:Redis中同时使用了惰性过期和定期过期两种过期策略,并不是实时地清除过期的Key。

淘汰策略

如果Key都没有设置过期时间,Redis快要达到最大内存极限时,需要使用淘汰策略来决定清理掉哪些数据,以保证新数据的存入。

最大内存的设置,在redis.conf配置中,

# maxmemory 

如果不设置maxmemory或者设置为0,32位系统最多使用3GB内存,64位系统不限制内存。

# 动态修改
redis>config set maxmemory 2GB

淘汰策略一共有八种

  • volatile-lru:首先从设置了过期时间的键集合中驱逐最久没有使用的键,如果没有可删除的键,回退到noeviction策略。
  • allkeys-lru:通过LRU算法驱逐最久没有使用的键。
  • volatile-lfu:从所有配置了过期时间的键中驱逐使用频率最少的键。
  • allkeys-lfu:从所有键中驱逐使用频率最少的键。
  • volatile-random:从过期键的集合中随机驱逐。
  • allkeys-random:从所有key随机删除。
  • volatile-ttl:从配置了过期时间的键中驱逐马上就要过期的键,如果没有,回退到noeviction策略。
  • noeviction:当内存使用超过配置的时候会返回错误,不会驱逐任何键,此时redis只响应读操作。

如果没有设置ttl或者没有符合前提条件的key被淘汰,那么volatile-lru,volatile-random、volatile-ttl相当于noeviction(不做内存回收)。

配置也是在redis.conf

# maxmemory-policy noeviction

或者动态修改

redis>config set maxmemory-policy volatile-lru

建议使用volatile-lru,在保证正常服务的情况下,优先删除最近最少使用的key。

redis中的LRU算法没有使用传统的哈希链表,因为需要额外的数据结构存储,消耗内存。Redis中通过随机采样来调整算法的精度,通过配置maxmemory_samples(默认是5个),随机从数据库中选择m个key,淘汰其中热度最低的key对应的缓存数据。所以采样参数m配置的数值越大,就越能精确地查找到待淘汰的缓存数据,但是也消耗更多的CPU计算,执行效率降低。

redis.io/topics/lru-… ,在sample为10的情况下,已经能够接近传统的LRU算法了。一文通透讲解Redis高级特性,多线程/持久化/淘汰机制等统统搞定_第5张图片

持久化机制

我们知道Redis这么快就是因为数据存储在内存的,但是存在内存会有一个风险,就是断电或者宕机,都会导致内存中的数据丢失。为了实现重启后数据不丢失,Redis提供了两种持久化的方案,一种是RDB快照(Redis DataBase),一种是AOF(Append Only File)。持久化是Redis跟Memcache的主要区别之一。

RDB

RDB是Redis默认的持久化方案(如果开启了AOF,优先用AOF)。当满足一定条件的时候,会把内存中的数据写入到磁盘,生产一个快照文件dump.rdb。Redis重启会通过加载dump.rdb文件恢复数据。

触发方式分为自动触发手动触发

自动触发:

a)、配置触发规则 在redis.conf文件中

# rule
save 900 1 #900秒内至少有一个key被修改(包括添加)
save 300 10 #300秒内至少有10个key被修改
save 60 10000 #60秒内至少有10000个key被修改
# 文件路径
dir ./
# 文件名称
dbfilename dump.rdb
# 是否以LZF压缩rdb文件
rdbcompression yes
#开启数据校验
rdbchecksum yes

上面三条规则是不冲突的,只要满足任意一个都会触发。如果不需要rdb方案,注释save或者配置成空字符串""。用lastsave命令可以查看最近一次成功生成快照的时间。

b)、shutdown触发,保证服务器正常关闭。

c)、flushall,rdb文件是空的,没有意义。

手动触发:

如果我们需要重启服务或者迁移数据,这个时候就需要手动触发RDB快照保存。Redis提供了两条命令,save和bgsave。

save在生成快照的时候会阻塞当前Redis服务器,Redis不能处理其他命令,如果内存中数据量很大的话,会造成长时间阻塞,为了解决这个问题就要使用bgsave。

bgsave的原理是fork一个子进程,让子进程在后台异步处理进行持久化,主进程还可以响应客户端的请求。它不会记录fork之后产生的数据,阻塞只发生在fork阶段,一般时间很短。

AOF

AOF采用日志的形式来记录每个写操作,并追加到文件中。开启后,执行更改Redis数据的命令时,就会把命令写入到AOF文件中。Redis重启时会根据日志文件的内容把写指令从前到后执行一次以完成数据的恢复。

AOF的配置:

# 开关
appendonly no
# 文件名
appendfilename "appendonly.aof"
# AOF持久化策略
appendfsync everysec

由于操作系统的缓存机制,AOF数据并没有真正地写入硬盘,而是进入了系统的硬盘缓存。所以我们需要开启刷缓存策略。

  • appendfsync everysec:(默认)表示每秒执行一次fsync,可能会导致丢失这1s的数据,但是这样可以兼顾安全性和效率。
  • appendfsync no:表示不执行fsync,由操作系统保证数据同步到磁盘,速度最快,但是不太安全。
  • appendfsync always:表示每次写入都执行fsync,以保证数据同步到磁盘,但是效率很低。

我知道随着执行命令越来越多,AOF文件也会越来越大,Redis增加了重写机制,当AOF文件的大小超过所设定的阈值,Redis就会启动AOF文件的内容压缩,只保留可以恢复数据的最小指令集。可以使用命令bgrewriteaof来重写。AOF文件重写并不是对原文件进行整理,而是直接读取服务器现有的键值对,然后用一条命令去代替之前记录这个键值对的多条命令,生产一个新的文件去替换原来的AOF文件。

# aof文件增长比例,指当前aof文件比上次重写的增长比例大小
auto-aof-rewrite-percentage 100
# aof文件重写最小的文件大小
auto-aof-rewrite-min-size 64mb
# 是否在后台写时同步单写,默认值no(表示需要同步)
no-appendfsync-on-rewrite no
# 指redis在恢复时,会忽略最后一条可能存在问题的指令,默认值yes
aof-load-truncated yes

重写过程中Redis执行了写命令:一文通透讲解Redis高级特性,多线程/持久化/淘汰机制等统统搞定_第6张图片

两种方案比较

  1. Redis 默认开启RDB持久化方式,在指定的时间间隔内,执行指定次数的写操作,则将内存中的数据写入到磁盘中。
  2. RDB 持久化适合大规模的数据恢复但它的数据一致性和完整性较差。
  3. Redis 需要手动开启AOF持久化方式,默认是每秒将写操作日志追加到AOF文件中。
  4. AOF 数据完整性比RDB高,但记录内容多了,会影响数据恢复的效率。
  5. Redis 针对 AOF文件大的问题,提供重写的瘦身机制。
  6. 若只打算用Redis 做缓存,可以关闭持久化。
  7. 若打算使用Redis 的持久化。建议RDB和AOF都开启。 其实RDB更适合做数据的备份,留意后手。AOF出问题了,还有RDB。
  8. 优先载入AOF文件来恢复原始的数据,因为在通常情况下AOF文件保存的数据集比RDB文件保存的要完整。一文通透讲解Redis高级特性,多线程/持久化/淘汰机制等统统搞定_第7张图片

你可能感兴趣的:(编程,Java,程序员,redis,lua,数据库)