目录
1.管道
1.1 RESP
1.2 通过管道交互
2 事务操作
3.脚本
4. 发布/订阅模式
5.持久化
5.1 RDB
5.2 AOF
Redis的协议称为RESP,它将协议数据分为不同类型,根据数据的首字符决定,不过所有类型的数据均以CRLF(即“\r\n”)结束。
1)简单字符串
首字符为“+”,后续为字符串内容,字符串内容不能包括'\r'或'\n'。例如“+OK\r\n”,经过客户端转换就变成了“OK”。由于简单字符串没有转义的情况,所以反序列化就是直接把“+”和“\r\n”之间的部分复制出去。
2)块字符串
如果发送的字符串本身包含了'\r'或'\n',就不能使用简单字符串,此时有两种解决方案:一是加入转义机制,这种方式效率比较低;二是在字符串前/后加上一个数字,表示整个字符串的长度。Redis使用了后者。块字符串的首字符是'$',后面跟着整个字符串的长度,以“\r\n”分隔后,跟上字符串内容,最后以“\r\n”结尾。例如:“$12\r\nhello\r\nworld\r\n”转换后就是:
hello
world
如果字符串长度为0,说明是空字符串,如果为-1("$-1\r\n"),则说明是null
3)错误字符串
正常的字符串是“+”开头,所以错误字符串就是“-”开头,剩下的和简单字符串一样
4)数字
以“:”开头,紧跟着数字本身,以“\r\n”结尾
5)数组
以“*”开头,紧跟着数组长度,类似于块字符串,长度为0代表空数组,为-1代表null,数组内容夹在两个“\r\n”中间,每一个数组元素都是这五种类型的数据,例如:"*2\r\n+hello\r\n+world\r\n"代表["hello","world"],数组内元素类型可以不一样,也支持嵌套数组。
客户端在向服务端发送数据时,参数以块字符串的形式发送,命令本身因为不属于以上任何类型,所以以原型发送
服务器向客户端发送消息时,有两种情况:第一种是对客户端的命令返回响应,可以返回以上五种类型;第二种是对Pub/Sub模式的订阅者推送消息,此时以数组类型发送。
在Linux中,管道是进程间通信的重要手段,可以让前一条命令的结果传递给下一条命令作为参数,但是Redis的管道和Linux的不太一样,它的作用是将一批命令序列化地发送到服务端,执行后再将结果集序列化发送回客户端,从而节约网络I/O的时间。
Redis是单线程架构,即一次只能处理一条命令,在串行实现中,客户端命令的执行有如下四个步骤:
该实现的优点在于简单,缺点则是效率低,因为每一条命令必须等待前一条命令完全执行后才会执行,同样地,从客户端角度来看,每一条命令的发送都需要前一条命令的响应被接收,导致吞吐量很低,尤其是内存速度比网络I/O快太多,会导致客户端空等待。
为了解决这个问题,Redis提供了两种方案:
第一种就是如mget、mset这样的批量操作,这种操作具有很多优点,如原子执行、原生支持等,缺点也很明显,并不是所有操作都有批量版本;
第二种就是管道机制,Redis管道可以将一批命令一次性发送出去,和原生批量操作的区别在于:首先,管道不是原子操作;其次,批量操作是对多个key执行相同操作,而管道可以将不同的操作封装成一批;最后,管道的实现需要客户端和服务端共同完成。
对于Redis自带的客户端,即redis-cli,在启动时加上--pipe选项即可使用pipe机制,例如:
root@Yhc-Surface:~# echo -en '*3\r\n$3\r\nset\r\n$5\r\nhello\r\n$5\r\nworld\r\n*2\r\n$4\r\nincr\r\n$9\r\ntestcount\r\
n' | redis-cli --pipe
All data transferred. Waiting for the last reply...
Last reply received from server.
errors: 0, replies: 2
该命令将 set hello world 和 incr testcount 两个命令合并,通过管道发送给服务端,美中不足的是看不到服务端响应内容,下面的用法可以看到服务端响应:
root@Yhc-Surface:~# printf '*3\r\n$3\r\nset\r\n$5\r\nhello\r\n$5\r\nworld\r\n*2\r\n$4\r\nincr\r\n$9\r\ntestcount\r\n'
| nc localhost 6379
+OK
:2
可以看到,服务端返回了一条简单字符串和一个数字。
现在计算pipeline的性能提升幅度:
对于串行模式,每一条命令都有两倍的网络延迟RTT(一收一发),还要加上客户端和服务端对命令的处理时间,分别设为m和n,则其吞吐量公式为:
对于管道模式,设一批执行了k条命令,仍然只有两倍的网络延迟,不过客户端和服务端的处理时间变成了k*m、k*n,即:
当k足够大时,可以忽略网络延迟,即TPS=1/(m+n)。如果假设网络I/O所需时间是命令处理时间的10倍,那么使用管道最大可以将性能提升10倍。
事务是数据库的重要特性,可以保证操作全部或全不执行,从而保障写安全。Redis也支持事务操作,以下是一个例子:
127.0.0.1:6379> multi
OK
127.0.0.1:6379> set hello world
QUEUED
127.0.0.1:6379> incr testcount
QUEUED
127.0.0.1:6379> exec
1) OK
2) (integer) 1
可以看到,事务中的命令在调用了exec之后才全部执行。且命令成功进入事务队列中时,会输出QUEUED。
Redis事务由五个命令组成:
watch的一个例子如下:
操作 | 客户端1 | 客户端2 |
1 | set user:tom:age 5 | |
2 | watch user:tom:age | |
3 | multi | |
4 | incr user:tom:age | |
5 | incr user:tom:age | |
6 | exec |
执行以上命令最终的结果如下:
127.0.0.1:6379> set user:tom:age 5
OK
127.0.0.1:6379> watch user:tom:age
OK
127.0.0.1:6379> multi
OK
127.0.0.1:6379> incr user:tom:age
QUEUED
127.0.0.1:6379> exec
(nil)
//另一个客户端
127.0.0.1:6379> incr user:tom:age
(integer) 6
watch的实质是一个乐观锁。
与关系型数据库,如MySQL不同的是,Redis事务不支持回滚。根据官方的解释,如果在事务中出现错误,那肯定是因为出现编程错误,例如:命令拼写错误、数据类型错误等,这些错误一般不会发生在生产环境中,而且即便可以回滚,还会再次出现这个错误,那么回滚意义不大,相反地,没有回滚机制还能简化Redis的实现、加快Redis运行。
如果需要防止脏读、读未提交、不可重复读等问题,可以利用watch实现。
在上一节我们提到,管道中的命令相互独立,互不影响,但如果以事务的形式发出命令,就可以做到原子执行:
root@Yhc-Surface:~# printf 'multi\r\n*3\r\n$3\r\nset\r\n$5\r\nhello\r\n$5\r\nworld\r\n*2\r\n$4\r\nincr\r\n$9\r\ntestcount\r\nexec\r\n' | nc localhost 6379
+OK
+QUEUED
+QUEUED
*2
+OK
:3
可以看到,两条指令在exec之后一起执行,即事务对管道起作用了。
事务的实现依靠multiState结构体(位于server.h):
typedef struct multiState {
multiCmd *commands; /* Array of MULTI commands */
int count; /* Total number of MULTI commands */
int cmd_flags; /* The accumulated command flags OR-ed together.
So if at least a command has a given flag, it
will be set in this field. */
int minreplicas; /* MINREPLICAS for synchronous replication */
time_t minreplicas_timeout; /* MINREPLICAS timeout as unixtime. */
} multiState;
commands数组存放事务中执行的命令,watch则位于redisDb结构体:
typedef struct redisDb {
dict *dict; /* The keyspace for this DB */
dict *expires; /* Timeout of keys with a timeout set */
dict *blocking_keys; /* Keys with clients waiting for data (BLPOP)*/
dict *ready_keys; /* Blocked keys that received a PUSH */
dict *watched_keys; /* WATCHED keys for MULTI/EXEC CAS */
int id; /* Database ID */
long long avg_ttl; /* Average TTL, just for stats */
list *defrag_later; /* List of key names to attempt to defrag one by one, gradually. */
} redisDb;
watched_keys是dict类型,键为被watch的key,值则是这些key的redisClient结构体指针组成的链表(multiState实例存放在redisClient结构体中),如果redis执行写命令时,被写入的key在watched_keys中,则将其对应的redisClient的flags字段置为REDIS_DIRTY_CAS,当客户端exec时,如果发现了flags字段被标记,则拒绝执行。
而unwatch就不难理解了:读取watched_keys,从key对应的链表中,去除目标redisClient即可:
void unwatchAllKeys(client *c) {
listIter li;
listNode *ln;
if (listLength(c->watched_keys) == 0) return;
listRewind(c->watched_keys,&li);
while((ln = listNext(&li))) {
list *clients;
watchedKey *wk;
/* Lookup the watched key -> clients list and remove the client
* from the list */
wk = listNodeValue(ln);
clients = dictFetchValue(wk->db->watched_keys, wk->key);
serverAssertWithInfo(c,NULL,clients != NULL);
listDelNode(clients,listSearchKey(clients,c));
/* Kill the entry at all if this was the only client */
if (listLength(clients) == 0)
dictDelete(wk->db->watched_keys, wk->key);
/* Remove this watched key from the client->watched list */
listDelNode(c->watched_keys,ln);
decrRefCount(wk->key);
zfree(wk);
}
}
Redis支持执行Lua脚本。该机制也是事务性的,即它是原子操作。
脚本的执行有三种方式:
1)eval script num_of_keys key ... arg ...:其中script就是Lua脚本的内容
2)script load script 配合 evalsha sha1 num_of_keys key ... arg ...:script还是Lua脚本的内容,不过可借助cat命令读取文件,例如:script load "${cat ~/test.lua}",script load命令会返回脚本的sha1值,将此作为参数传递给evalsha命令即可调用脚本
3)redis-cli --eval lua:这里的lua是指脚本文件的全路径
Redis提供的脚本管理命令有:
下面是一个eval的例子,注意这里的KEYS必须大写,如果要访问传入的参数,可以使用ARGV:
127.0.0.1:6379> eval 'return KEYS[1]' 1 hello
"hello"
这个例子非常简单,如果想调用Redis API,可以使用redis.call()和redis.pcall(),两者的区别是,call方法遇到错误后会退出,pcall会继续执行:
127.0.0.1:6379> eval "return redis.call('get',KEYS[1])" 1 hello
"world"
需要注意的是,Lua和Redis的数据格式不是完全相同的,不过可以自动转换:
此外,成功和失败信息还可以通过redis.status_reply()和redis.error_reply()函数返回,以下两个写法是等价的:
return {err="Error"}
return redis.error_reply("Error")
众所周知,Lua脚本的一大特性就是可以利用C、C++进行扩展,Redis基于C编写,也因此为Lua提供了丰富的库,官方介绍有如下库:
详细介绍可参见:https://redis.io/commands/eval
在缓存学习(六):Redis的数据结构及基本命令中提到了Redis 5新加入的streams数据结构,它引入了Producer、Consumer、Consumer Group的概念,实现了类似于Kafka这样的 生产/消费 模式,但是在Redis 5之前,Redis提供了另一种数据交互模式,即 发布/订阅(Pub/Sub)模式。
和其他交互模式中客户端触发,服务端接收不同,发布订阅模式是客户端触发,其他客户端接收,服务端仅作为中转。涉及的命令如下:
这里可以看出,频道有两种:
匹配支持三种符号:?代表任意一个字符,*代表零到多个任意字符,[...]表示中括号内的某个字符出现一次
该机制在pubsub.c中实现,依赖了server.h中的redisServer结构体:
struct redisServer {
...
dict *pubsub_channels; /* channels a client is interested in (SUBSCRIBE) */
list *pubsub_patterns; /* patterns a client is interested in (SUBSCRIBE) */
...
}
typedef struct pubsubPattern {
client *client;
robj *pattern;
} pubsubPattern;
pubsub_channels的键是频道名称,值是其订阅者链表,pubsub_patterns维护了模式频道和订阅者的关系,内容就是pubsubPattern结构体实例,包含了模式和订阅它的客户端指针。
Redis本身是一个内存数据库,虽然一般用作缓存,但是本质上还是有持久化数据的需求,因此Redis提供了RDB(全量)和AOF(增量)两种持久化模式。
RDB在持久化触发时,将节点所有数据库的所有数据完全保存下来,形成快照。
它有如下优点:
其缺点如下:
RDB的触发主要有三种方式:
由于RDB持久化都需要fork子进程,所以bgsave其实也会阻塞一段时间,但是很短,影响不大。
RDB持久化的流程如下:
由于bgsave以异步方式备份,执行后立即返回“Background saving started”,如果要查询bgsave是否成功备份,可以用lastsave命令,检查在bgsave执行前后返回值是否改变。
RDB的自动备份时间点、文件名、存储位置都可以通过redis.conf配置,也可以通过config set dir、config set dbfilename在线修改。
如果RDB文件损坏,可以尝试使用redis-check-rdb工具进行检查。
RDB最大的问题就是无法实时备份,有数据丢失的风险,AOF可以记录服务器接受的写入操作(读取操作没有影响不必记录),在服务器启动时重新执行,即可实现状态的恢复。
AOF的原理是将写入操作记录到aof_buf中,然后根据指定的fsync策略定期刷写到磁盘中。
AOF的优点:
AOF的缺点主要是相同数据量下,AOF文件要比RDB大。
处于性能和效率的考量,一般将AOF和RDB结合使用,如果两者同时启用,会优先使用AOF。从server.c的main函数中,可以看到调用了loadDataFromDisk函数,它主要就是两个分支:
void loadDataFromDisk(void) {
if (server.aof_state == AOF_ON) {
...
} else {
...
}
}
可见AOF启用后,Redis就不会主动加载RDB。原因很简单,一般而言AOF的数据都比RDB完整,兼容性和容错性也更好。
AOF重写的函数为bgrewriteaof,不过也会根据配置自动重写。
下面是一个破坏AOF文件并修复的例子:
首先开启AOF,然后进行一次set操作:
127.0.0.1:6379> config set appendonly yes
OK
127.0.0.1:6379> set 20190426 Friday
OK
然后使用kill命令强制关闭Redis,并打开appendonly.aof文件(默认在~目录下),可以发现,它实际就是在RDB后面追加写日志,我们将set^M到Friday^M的部分删掉,再次启动Redis,可以发现,不但刚刚新增的键没了,之前加入的数据也没了:
127.0.0.1:6379> keys *
(empty list or set)
再次关闭Redis,使用redis-check-aof工具检查aof文件,输出如下:
root@Yhc-Surface:~# redis-check-aof ~/appendonly.aof
The AOF appears to start with an RDB preamble.
Checking the RDB preamble to start:
[offset 0] Checking RDB file /root/appendonly.aof
[offset 26] AUX FIELD redis-ver = '5.0.4'
[offset 40] AUX FIELD redis-bits = '64'
[offset 52] AUX FIELD ctime = '1556263650'
[offset 67] AUX FIELD used-mem = '914520'
[offset 83] AUX FIELD aof-preamble = '1'
[offset 85] Selecting DB ID 0
[offset 842] Checksum OK
[offset 842] \o/ RDB looks OK! \o/
[info] 19 keys read
[info] 0 expires
[info] 0 already expired
RDB preamble is OK, proceeding with AOF tail...
0x 369: Expected to read 5 bytes, got 1 bytes
AOF analyzed: size=874, ok_up_to=865, diff=9
AOF is not valid. Use the --fix option to try fixing it.
可见由于刚刚的修改使得aof文件中记录的日志长度和实际长度不符,于是没有进行数据恢复,我们按照提示使用--fix选项进行修复并启动Redis,此时发现以前的数据都回来了:
127.0.0.1:6379> keys *
1) "key1"
2) "key2"
3) "key3"
... //后略