当前位置: 首页 > 科技观察

直击Redis持久化磁盘IO痛点,让存储不再有负担!

时间:2023-03-18 22:06:18 科技观察

Redis常用的数据类型Redis中最常用的数据类型主要有以下五种类型:StringHashListSetSortedset描述这些不同的数据类型:首先,Redis内部使用一个redisObject对象来表示所有的键和值。redisObject的主要信息如上图所示。type表示一个值对象的具体数据类型,encoding是Redis中不同数据类型的存储方式,例如:type=string表示该值存储为普通字符串,那么对应的encoding可以是raw或者int,如果是int,则表示实际的Redis内部将这个字符串存储并表示为数值类,当然前提是字符串本身可以用数值来表示,比如:"123""456"这样的字符串。这里需要对vm字段进行说明。只有开启Redis的虚拟内存功能,该字段才会真正分配内存。这个功能默认是关闭的,后面会详细介绍这个功能。从上图我们可以发现Redis使用redisObject来表示所有的key/value数据,这是一种内存浪费。当然,这些内存管理成本主要是为Redis的不同数据类型提供统一的管理接口。实际作者还提供了几种方法来帮助我们最小化内存的使用,后面会详细讨论。我们先来一一分析一下这五种数据类型的使用和内部实现:String常用命令:Set、get、decr、incr、mget等应用场景:String是最常用的数据类型,普通的key/value存储就属于这一类,这里就不多说了。实现方法:String默认以字符串的形式存储在Redis中,由redisObject引用。当遇到incr、decr等操作时,会转化为数值进行计算。此时redisObject的编码字段为int。哈希常用命令:hget、hset、hgetall等。应用场景:下面简单举例说明Hash的应用场景。例如,我们要存储一个用户信息对象数据,其中包含如下信息:用户ID是要查找的key,存储的value用户对象包含姓名、年龄、生日等信息,如果采用普通的key/value结构进行存储,主要有以下两种存储方式:第一种方式以用户ID作为查找key,将其他信息封装到一个对象中序列化存储。缺点是增加了序列化/反序列化的开销,当需要修改其中一条信息时,需要检索整个对象,修改操作需要保护并发,引入了CAS等复杂问题。第二种方法是保存多少个用户信息对象成员的键值对,以用户ID+对应属性名作为唯一标识获取对应属性的值,虽然序列化有开销和并发都省略了问题,但是用户ID被重复存储了。如果这样的数据量很大,内存浪费还是很可观的。那么Redis提供的Hash就很好的解决了这个问题。Redis的Hash实际上将Value内部存储为一个HashMap,并提供了直接访问Map成员的接口,如下图所示:也就是说Key还是用户ID,value是一个Map,这个Map的key是成员的属性名,value是属性值,这样就可以直接通过其内部Map的Key修改和访问数据(在Redis中,内部Map的key叫做field),也就是说可以通过key(用户ID)+field(属性标签)来操作对应的属性数据,不需要重复存储数据,也不会带来序列化和并发修改控制的问题,很好地解决了问题。同时,这里需要注意的是,Redis提供了一个接口(hgetall),可以直接获取所有属性数据,但是如果内部Map的成员很多,则涉及到遍历整个内部Map的操作。由于Redis的单线程模型,这个遍历操作可能比较耗时,其他客户端请求根本没有响应,需要特别注意。实现方法:上面说了,RedisHash其实就是对应Value内部的一个HashMap。其实这里有两种不同的实现方式。当这个Hash的成员比较少的时候,Redis为了节省内存,会采用类似一维数组的方式进行紧凑存储。会采用真正的HashMap结构,对应值redisObject的编码为zipmap。当成员数量增加时,会自动转为真正的HashMap,此时编码为ht。列表常用命令:lpush、rpush、lpop、rpop、lrange等应用场景:Redis列表应用场景非常多,也是Redis最重要的数据结构之一。比如twitter的followlist和fanlist可以用Redislist结构来实现,简单易懂,这里不再赘述。实现方式:Redislist实现为双向链表,可以支持反向查找和遍历,操作起来更方便,但是带来了一些额外的内存开销。Redis内部的很多实现,包括发送缓冲队列等,也是用到了这个数据结构。set常用命令:sadd、spop、smembers、sunion等。应用场景:Redisset提供的功能与list类似,都是列表功能。特殊之处在于set可以自动对重复项进行排序。当你需要存储一个数据列表,又不想有重复的数据时,set是一个很不错的选择,而且set提供了一个重要的接口来判断一个成员是否在一个set集合中,这是list所不能提供的。实现方法:set的内部实现是一个HashMap,其值永远为null。事实上,它计算散列以快速对重复项进行排序。这就是为什么set可以提供一种方法来判断一个成员是否在集合中。Sortedset常用命令:zadd、zrange、zrem、zcard等使用场景:Redissortedset的使用场景和set类似,不同的是set不会自动排序,sortedset可以通过提供对成员进行排序用户附加的优先级(分数)参数,按顺序插入,即自动排序。当你需要一个有序且不重复的集合列表时,你可以选择有序集合数据结构。比如twitter的publictimeline可以存储发布时间作为分数,这样获取到的时候自动按时间排序。实现方法:Redissortedset内部使用HashMap和跳跃列表(SkipList)来保证数据的存储和顺序。HashMap存储成员到分数的映射,而跳跃列表存储所有成员。排序的依据是对于存储在HashMap中的分数,可以采用跳表的结构来获得比较高的查找效率,实现也比较简单。常用的内存优化方法和参数通过上面的一些实现的分析,我们可以看出,Redis实际的内存管理成本是非常高的,即占用内存太多。作者也很清楚这一点,所以他提供了一系列控制和保存内存的参数和手段,我们分开来讨论。首先最重要的一点就是不要开启Redis的VM选项,即虚拟内存功能。这本来是Redis在内存和磁盘中存储超出物理内存的数据的一种持久化策略。但是它的内存管理成本也很高,后面我们会分析这个持久化策略还不成熟,所以要关闭VM功能,请查看你的redis.conf文件中是否有vm-enabled。其次,在redis.conf中设置maxmemory选项。此选项告诉Redis在使用物理内存时拒绝连续写入请求。这个参数可以很好的保护你的Redis。会因为使用过多的物理内存而造成swap,严重影响性能甚至崩溃。此外,Redis还针对不同的数据类型提供了一组参数来控制内存的使用。我们详细分析过,RedisHash是value里面的一个HashMap。如果Map的成员数量比较少,会使用紧凑的一维线性格式来存储Map,这样可以节省大量的指针内存开销,这个参数控制对应redis.conf中的如下两项配置文件:hash-max-zipmap-entries64hash-max-zipmap-value512hash-max-zipmap-entries意思是当valuemap中的成员不超过几个时,会以线性紧凑格式存储。默认为64,即如果value中的成员少于64个,将以线性紧凑格式存储。如果超过这个值,就会自动转为真正的HashMap。hash-max-zipmap-value的含义是当value的Map中每个成员值的长度不超过一个字节数时,会使用线性紧凑存储来节省空间。如果以上两个条件中的任何一个超过了设定值,就会转为真正的HashMap,不再节省内存。该值是否设置得尽可能大?答案当然是否定的。HashMap的优点是搜索和运算的时间复杂度为O(1),而放弃Hash而采用一维存储的时间复杂度为O(n)。成员数量少影响就小,反之则会严重影响性能,所以权衡这个值的设置一般是时间成本和空间成本最根本的权衡。还有类似的参数:list-max-ziplist-entries512说明:list数据类型的节点数以下将使用无指针的紧凑存储格式。list-max-ziplist-value64说明:列表数据类型节点值的大小小于将使用紧凑存储格式的字节数。set-max-intset-entries512注意:如果set数据类型的内部数据全部为数值型,其包含的节点数将以紧凑格式存储。***我想说的是,Redis的内部实现并没有对内存分配做过多的优化,一定程度上会存在内存碎片,但大多数情况下这不会成为Redis的性能瓶颈,但是如果在Redis中存放的数据大部分是数值型的话,Redis内部采用共享整数的方式来节省内存分配的开销,即系统启动时先分配一个从1到n的数,然后将多个数值对象放入池中。如果存储的数据刚好在这个取值范围内,则直接从池中取出对象,通过引用计数进行共享,即使在系统存储大量值的情况下,也能在一定程度上节省内存,提高性能,该参数值n的设置需要修改源码中的一行宏定义REDIS_SHARED_INTEGERS,该值默认为10000,您可以根据自己的需要修改,修改后重新编译。Redis的持久化机制由于Redis支持非常丰富的内存数据结构类型,如何将这些复杂的内存组织方式持久化到磁盘是一个难题,所以Redis的持久化方式与传统数据库有很大的不同。Redis一共支持四种持久化方式,分别是:定时快照方式(snapshot)基于语句追加文件的方式(aof)虚拟内存(vm)Diskstore方式在设计思路上,前两种方式是基于alldataisinmemory,即少量数据下提供磁盘登陆功能,后两种方式是作者尝试存储超过物理内存的数据时,即大容量数据存储数据。到本文为止,后两种持久化方式还处于实验阶段,而vm方式基本上已经被作者抛弃,所以真正能在生产环境中使用的只有前两种。也就是说,Redis只能作为少量数据的存储(所有数据都可以加载到内存中)。这不是Redis擅长的领域。下面分别介绍这几种持久化方式:定时快照方式(snapshot):这种持久化方式其实就是Redis内部的一个定时器事件,定时检查当前数据的变化次数和时间是否满足配置,如果满足持久化的条件trigger被满足,将通过操作系统的fork调用创建一个子进程。默认情况下,此子进程将与父进程共享相同的地址空间。这时候子进程就可以遍历整个内存进行存储操作了,而主进程仍然可以提供服务。当有写入时,操作系统会以内存页(pages)为单位进行copy-on-write,以保证父子进程不会相互影响。这种持久化的主要缺点是定时快照只代表一段时间的内存映像,因此系统重启将丢失上次快照和重启之间的所有数据。Statement-basedappendingmethod(aof):Aof方法其实类似于MySQL的statement-basedbinlog方法,即每条会改变Redis内存数据的命令都会追加到一个日志文件中,也就是说这个日志文件是Redis数据的持久化。aof方法的主要缺点是追加日志文件可能会导致体积过大。系统重启恢复数据时,如果使用aof方式,数据加载会很慢。加载数十GB的数据可能需要几个小时。当然,这会消耗很多时间。并不是因为磁盘文件的读取速度慢,而是因为读取的所有命令都必须在内存中执行一次。另外,因为每条命令都需要写入日志,所以使用aof,Redis的读写性能也会下降。虚拟内存方式:虚拟内存方式是Redis将数据换入换出用户空间的一种策略。这种方法实现效果比较差。主要问题是代码复杂,重启慢,复制慢等,目前已被作者废弃。Diskstore方式:diskstore方式是作者在放弃虚拟内存方式,即传统的B-tree方式后,选择的一种新的实现方式。目前还处于试验阶段,未来能否上线,我们拭目以待。Redis持久化磁盘IO方式及带来的问题。有Redis在线运维经验的人会发现,Redis使用了大量的物理内存,但是当没有超过实际物理内存容量时,就会变得不稳定甚至崩溃,有人认为是基于snapshot方式的persistentforksystemcall导致内存占用翻倍造成的。这种看法是不准确的,因为fork调用的copy-on-write机制是以操作系统page为单位的,也就是说,只会复制已经写入的脏页,但是一般你的系统不会短时间内写完所有页面导致复制,那么是什么原因导致Redis崩溃呢?答案是Redis的持久化是使用BufferIO造成的。所谓BufferIO就是Redis会使用物理内存的PageCache来写入和读取持久化文件,而大多数数据库系统都会使用DirectIO来绕过这层PageCache,自己也维护着一个数据缓存,当Redis的持久化文件过大(尤其是快照文件)对其进行读写,磁盘文件中的数据会作为一个操作加载到物理内存中系统对文件有一层Cache,数据的这层Cache和Redis内存管理的数据其实是重复存储的。虽然内核会在物理内存紧张的时候做PageCache淘汰工作,但是内核可能会认为块PageCache更重要,让你的进程启动Swap,那么你的系统就会开始变得不稳定或者崩溃。我们的经验是,当你的Redis物理内存使用量超过总内存容量的3/5时,就会开始出现危险。下图是Redis读写快照文件dump.rdb后的内存数据图:总结根据业务需要选择合适的数据类型,针对不同的应用场景设置相应的紧凑存储参数。当业务场景不需要数据持久化时,关闭所有持久化方式可以获得最好的性能和最低的内存占用。如果需要持久化,根据能不能容忍重启后丢失部分数据,选择snapshot方式和statementappend方式中的一种,不要使用虚拟内存和diskstore方式。不要让你的Redis机器的物理内存使用超过实际总内存的3/5。