Redis 是一个开源的,基于内存的可持久化的非关系型数据库存储系统。在实际项目中可以用 Redis 做缓存或消息服务器,Redis 也是目前互联网中使用比较广泛的非关系型数据库,下面就来深入分析Redis的高级特性!
Lua是一种轻量级脚本语言,它是C语言编写的,跟数据的存储过程有点类似。官网介绍:redis.io/commands/ev… 。使用Lua脚本来执行Redis命令的好处:
Lua脚本的语法:
eval lua-script key-num [key1 key2 key3 ...][value1 value2 value3...]
lua-script里面执行redis命令的语法:
redis.call(command,key[param1,param2...])
学习了上面的两个语法,我们来看几个例子。
eval "return 'Hello World'" 0eval "return redis.call('set','jackxu','shuaige')" 0eval "return redis.call('set',KEYS[1],ARGV[1])" 1 jackxu shuaige
在Lua脚本比较长的情况下,如果每次调用脚本都需要把整个脚本传给Redis服务端,会产生比较大的网络开销。为了解决这个问题,Redis可以缓存Lua脚本并生成SHA1摘要码,后面可以直接通过摘要码来执行Lua脚本。语法如下:
## 在服务端缓存lua脚本生成一个摘要码script load "return 'Hello World'"## 通过摘要码执行缓存的脚本 evalsha "470877a599ac74fbfda41caa908de682c5fc7d4b" 0
上面介绍的是在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来实现限流,通过一个自定义注解+切面放在方法上面即可。
首先我们来测试一下Redis到底有多快,redis自带了一个测试工具,redis.io/topics/benc… ,接下来我们来测试一下
cd /jackxu/redis-6.2.2/srcredis-benchmark -t set,lpush -n 100000 -q
set和lpush分别是9.4W和9W,和官网号称的10W QPS相差不大,我这台是1核2G的云服务器,如果配置和网络好的话还会更高。
为什么能够抗住这么大的QPS,总结起来有三点:
下面我们来分别看下。
大家知道redis是存储在内存的,内存就是快快快,时间复杂度是O(1)。
我们经常说Redis是单线程的,其实这是不严谨的。Redis 单线程指的是网络请求模块使用了一个线程,即一个线程处理所有网络请求, 其他模块该使用多线程,仍会使用了多个线程。从4.0的版本之后,还引入了一些线程处理其他的事情,比如请求脏数据、无用连接的释放、大Key的删除。
把处理请求的主线程设置成单线程有什么好处呢?
官方给出的解释是,在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中的I/O多路复用的所有功能通过包装常见的select、epoll、evport和kqueue,这些I/O多路复用函数库来实现的。
事件处理器又分为三种:
我们知道Redis 6.0版本引入了多线程,这里所说的多线程,其实就是将Redis单线程中做的这两件事情从客户端读取数据、回写数据给客户端(也可以称为网络I/O),处理成多线程的方式,但是执行Redis命令还是在主线程中串行执行,所以不存在线程并发安全问题。
下面是单线程I/O和多线程I/O的区别:
我们知道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 <bytes>
如果不设置maxmemory或者设置为0,32位系统最多使用3GB内存,64位系统不限制内存。
# 动态修改redis>config set maxmemory 2GB
淘汰策略一共有八种
如果没有设置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这么快就是因为数据存储在内存的,但是存在内存会有一个风险,就是断电或者宕机,都会导致内存中的数据丢失。为了实现重启后数据不丢失,Redis提供了两种持久化的方案,一种是RDB快照(Redis DataBase),一种是AOF(Append Only File)。持久化是Redis跟Memcache的主要区别之一。
RDB是Redis默认的持久化方案(如果开启了AOF,优先用AOF)。当满足一定条件的时候,会把内存中的数据写入到磁盘,生产一个快照文件dump.rdb。Redis重启会通过加载dump.rdb文件恢复数据。
触发方式分为自动触发和手动触发。
自动触发:
a)、配置触发规则 在redis.conf文件中
# rulesave 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采用日志的形式来记录每个写操作,并追加到文件中。开启后,执行更改Redis数据的命令时,就会把命令写入到AOF文件中。Redis重启时会根据日志文件的内容把写指令从前到后执行一次以完成数据的恢复。
AOF的配置:
# 开关appendonly no# 文件名appendfilename "appendonly.aof"# AOF持久化策略appendfsync everysec
由于操作系统的缓存机制,AOF数据并没有真正地写入硬盘,而是进入了系统的硬盘缓存。所以我们需要开启刷缓存策略。
我知道随着执行命令越来越多,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在恢复时,会忽略最后一条可能存在问题的指令,默认值yesaof-load-truncated yes
重写过程中Redis执行了写命令: