背景
公司一年的部分业务数据放在redis服务器上,但数据量比较大,单纯的string类型数据一年就将近32G,而且是经过压缩后的。
所以我在想能否通过获取string数据的时间改为保存list数据类型,或者将数据持久化到硬盘上,或者放在不同库上,解决未来数据过大导致down机的问题。
相关知识点
- string数据类型
- 数据持久化
- 数据加载
Redis的字符串(string)的实现原理
Redis是由C语言编写的,以高效和轻量著称。
比如一个简单的字符串”hello world”,其实是一个如下的字符的数组:
[‘h’, ‘e’, ‘l’, ‘l’, ‘o’, ‘ ‘, ‘w’, ‘o’, ‘r’, ‘l’, ‘d’, ‘\0’]
最后的一个’\0’是空字符,表示字符串的结尾。
Redis由于各种原因,并没有直接使用了C语言的字符串结构,而是对其做了一些封装,得到了自己的简单动态字符串(simple dynamic string, SDS)的抽象类型。
Redis中,默认以SDS作为自己的字符串表示。只有在一些字符串不可能出现变化的地方使用C字符串。
SDS定义中有三个参数:
buf是一块可用的内存空间,通常大小会大于等于需要存储的字符串的大小
len表示字符串的长度,也表示buf中已经被使用的空间的大小
free表示buf中没有被使用的空间的大小。
要注意的是,buf的大小等于len+free+1,其中多余的1个字节是用来存储’\0’的
那么这么封装到底有什么好处呢?
1.常数复杂度获取字符串长度
在C语言中的字符串只是简单的字符的数组,当使用strlen获取字符串长度的时候,C语言内部其实是直接顺序遍历数组的内容,找到对应的’\0’对应的字符,从而计算出字符串的长度。显然这个算法复杂度和字符串的长度成正比,即O(N)。而对于SDS来说,只需要访问SDS的len属性就能得到字符串的长度,复杂度为O(1)。这样,获取字符串长度的操作就不会成为Redis的瓶颈。
2.杜绝缓冲区溢出
C++里面的字符串使用了STL的string类型,我们开发者不太需要关注内存的分配和释放的过程。但是Redis是C语言编写的,并没有这么方便的数据类型。对于字符串的拼接、复制等操作,C语言开发者必须确保目标字符串的空间足够大,不然就会出现溢出的情况。
char a[10]="hello";
strcar(a,"world");
strcpy(a,"hello world");
上面的三句代码,就是C语言的字符串拼接和复制的使用,但是明显出现了缓冲区溢出的问题。字符数组a的长度是10,而”hello world”字符串的长度为11,则需要12个字节的空间来存储(不要忘记了’\0’)。
然而当使用SDS的API对字符串进行修改的时候,API内部第一步会检测字符串的大小是否满足。
如果空间已经满足要求,那么就像C语言一样操作即可。
如果不满足,则拓展buf的空间,使得满足操作的需求,之后再进行操作。
每次操作之后,len和free的值会做相应的修改。
这就是SDS的全部的高明之处了吗?当然不!
当API发现SDS的buf的容量不够的时候,并不是简单申请正好适合的大小,而是额外申请了一倍的空间!我们以sds的API sdscat函数为例,该函数实现了sds的拼接的功能。
3.减少修改字符串时带来的内存重新分配次数
c语言底层是一个N+1长的字符数组,长度的变化都会引起内存的重新分配。而对于SDS则通过一些策略去解决这些问题:
空间预分配
这种方式用于处理字符串长度增加的问题。
如果对字符串的修改使得字符串的长度增加,API首先会判断buf的空间大小是否满足,如果满足则直接操作,如果不满足,则进行如下操作:
如果对SDS进行修改之后的,SDS的长度(即len的值)小于1MB。程序将额外分配和len一样大小的未使用空间。以上面的”hello” + ” world”的操作为例。
在这个例子中”hello”的len是5(不考虑’\0′),修改之后的字符串”hello world”长度为11,那么新的SDS的buf的容量就是11*2+1。其中len和free都是11,多余的1字节用来存储’\0’。
惰性空间释放
当执行字符串长度缩短的操作的时候,SDS并不直接重新分配多出来的字节,而是修改len和free的值(len相应减小,free相应增大,buf的空间大小不变化)。通过惰性空间释放,可以很好的避免缩短字符串需要的内存重分配的情况。而且多余的空间也可以为将来可能有的字符串增长的操作做优化。
当然,SDS也提供直接释放未使用空间的API,在需要的时候,也能真正的释放掉多余的空间。
数据持久化
- filesnapshotting(快照)
- Append-only(aof)
filesnapshotting
默认redis是会以快照的形式将数据持久化到磁盘的(一个二进 制文件,xx.rdb)
在配置文件中的格式是:save N M表示在N秒之内,redis至少发生M次修改则redis抓快照到磁盘。
当然我们也可以手动执行save或者bgsave(异步)做快照。
工作原理简单介绍:当redis需要做持久化时,redis会fork一个子进程;子进程将数据写到磁盘上一个临时RDB文件中;当子进程完成写临时文件后,将原来的RDB替换掉,这样的好处就是可以copy-on-write
缺点:filesnapshotting方法在redis异常死掉时, 最近的数据会丢失(丢失数据的多少视你save策略的配置),所以这是它最大的缺点,当业务量很大时,丢失的数据是很多的
Appened-only
可以做到全部数据不丢失,但redis的性能就要差些。AOF就可以做到全程持久化,只需要在配置文件中开启(默认是no),appendonly yes开启AOF之后,可以选择三种不同的策略,都会把它添加到aof文件中,当redis重启时,将会读取AOF文件进行“重放”以恢复到 redis关闭前的最后时刻。
AOF的三种策略
appendfsync :appendfsync always每提交一个修改命令都调用fsync刷新到AOF文件,非常非常慢,但也非常安全;
appendfsync everysec每秒钟都调用fsync刷新到AOF文件,很快,但可能会丢失一秒以内的数据;
appendfsync no依靠OS进行刷新,redis不主动刷新AOF,这样最快,但安全性就差。默认并推荐每秒刷新,这样在速度和安全上都做到了兼顾。
LOG Rewriting随着修改数据的执行AOF文件会越来越大,其中很多内容记录某一个key的变化情况。
因此redis有了一种比较有意思的特性:在后台重建AOF文件,而不会影响client端操作。在任何时候执行BGREWRITEAOF命令,都会把当前内存中最短序列的命令写到磁盘,这些命令可以完全构建当前的数据情况,而不会存在多余的变化情况(比如状态变化,计数器变化等),缩小的AOF文件的大小。
所以当使用AOF时,redis推荐同时使用BGREWRITEAOF。
LOG Rewrite的工作原理:同样用到了copy-on-write:首先redis会fork一个子进程;子进程将最新的AOF写入一个临时文件;父进程 增量的把内存中的最新执行的修改写入(这时仍写入旧的AOF,rewrite如果失败也是安全的);当子进程完成rewrite临时文件后,父进程会收到 一个信号,并把之前内存中增量的修改写入临时文件末尾;这时redis将旧AOF文件重命名,临时文件重命名,开始向新的AOF中写入。
Redis启动加载过程
1. 初始化全局服务器配置
2. 加载配置文件(如果指定了配置文件,否则使用默认配置)
3. 初始化服务器
4. 加载数据库
5. 网络监听
下面对上面这些步骤进行介绍
初始化全局服务器配置
初始化全局服务器配置通过initServerConfig()函数完成,主要是初始化server变量
初始化的内容包括下面几个方面:
1. 网络监听相关,如绑定地址,TCP端口等
2. 虚拟内存相关,如swap文件、page大小等
3. 保存机制,多长时间内有多少次更新才进行保存
4. 复制相关,如是否是slave,master地址、端口
5. Hash相关设置
6. 初始化命令表
如其中的保存机制中,服务器初始化策略为:
// 1小时内1次更新
appendServerSaveParams(*,); // 5分钟内100次更新
appendServerSaveParams(,); // 1分钟内10000次更新
appendServerSaveParams(,);
如果在启动服务器时,指定了配置文件,则会在下面的“加载配置文件”步骤中,根据配置文件内容,更改其中的某些服务器配置。
加载配置文件
如果指定了配置文件,Redis使用loadServerConfig()函数加载配置文件,使用标准I/O库打开配置文件,循环读取每一行然后覆盖上一步进行的默认配置。
需要注意的是,下载Redis后代码包中有一个默认配置文件,如果启动Redis服务器时,不指定配置文件,Redis不会使用这个默认文件的配置,而是使用上一步“初始化全局服务器配置”中的配置。在默认配置文件中提供的配置项与上一步默认初始化的配置有些事不一样的,所以如果没有指定配置文件,千万不能认为Redis的行为会按照默认配置文件进行,最典型的一个例子,在默认配置文件中的数据保存策略是:
# 15分钟内1次更新
save # 5分钟内100次更新
save # 1分钟内10000次更新
save
而默认初始化的全局配置中数据保存策略:appendServerSaveParams的配置项
初始化服务器
初始化服务器的工作在initServer()函数中,主要是完成前面未完成的工作,继续对server变量初始化,如设置信号处理、创建clients、slaves列表,创建Pub/Sub通道列表,同时还会创建共享对象:
shared.crlf = createObject(REDIS_STRING,sdsnew("\r\n"));
shared.ok = createObject(REDIS_STRING,sdsnew("+OK\r\n"));
shared.err = createObject(REDIS_STRING,sdsnew("-ERR\r\n"));
shared.emptybulk = createObject(REDIS_STRING,sdsnew("$0\r\n\r\n"));
最后,如果启用了虚拟内存机制,还需要初始化虚拟内存相关,如Thread I/O等。
加载数据库
在完成了上面的所有的初始化工作之后,Redis开始加载数据到内存中,如果启用了appendonly了,则Redis从appendfile加载数据,否则就从dbfile加载数据。
1. 从appendfile中加载数据:loadAppendOnlyFile()函数
在此之前,我们先来看一下appendfile里面保存了什么,如我执行了下面两条命令(记得在配置文件中开启appendonly):
redis> SET mykey001 myvalue001
OK
redis> GET mykey001
"myvalue001"
使用cat命令查看appendonly.aof的内容:
$ cat appendonly.aof
*
$
SELECT
$ *
$
SET
$
mykey001
$
myvalue001
在appendonly.aof文件中保存的正是从客户端发过来的请求命令,还可以看到对于GET命令,并没有保存。
既然appendonly.aof中保存了所有写入数据的请求命令,那么在加载数据的时候只要重新执行一遍这些命令即可。
事实上Redis也正是这么做的,在开始加载之前暂时关闭appendonly,然后Redis创建一个假的Redis客户端。
然后读取appendonly.aof文件中的命令,在假的Redis客户端上下文中执行,同时服务器也不对该客户端做任何应答。
如果加载过程中物理内存不够用,并且Redis开启了VM,则还需要处理swap操作,最后加载完成后重新设置appendonly标志。
2. 从dbfile中加载数据:rdbLoad()函数
如果Redis没有开启appendonly,就需要从数据库文件中加载数据到内存,基本步骤如下:
a. 处理SELECT命令,即选择数据库
b. 读取key
c. 读取value
d. 检测key是否过期
e. 添加新的对象到哈希表
f. 设置过期时间(如果需要)
g. 如果开启了VM,处理swap操作
网络监听
在完成了初始化配置和数据加载后,Redis启动监听。Redis的网络库没有使用libevent或者libev,而是作者自己实现的一个非常轻量级的库(主要实现在ae.c文件中)
然而回到我的问题上,如果数据库中数据已经放入了32G的数据,在启动redis时加载数据库这部分必定会相当相当慢,而且涉及到宕机的问题(物理内存到达峰值)
这时可能单台redis很难解决这个问题,而应该考虑集群。