redis学习(六) 阻塞和内存
阻塞
Redis是典型的单线程架构,所有的读写操作都是在一条主线程中完成的。当Redis用于高并发场景时,这条线程就变成了它的生命线。如果出现阻塞,哪怕是很短时间,对于我们的应用来说都是噩梦。
内在原因包括:不合理地使用API或数据结构、CPU饱和、持久化阻塞等。
外在原因包括:CPU竞争、内存交换、网络问题等。
发现阻塞
Jedis客户端会抛出JedisConnectionException异常。
常见的做法是在应用方加入异常统计并通过邮件/短信/微信报警,以便及时发现通知问题。开发人员需要处理如何统计异常以及触发报警的时机。
何时触发报警一般根据应用的并发量决定,如1分钟内超过10个异常触发报警。
当异常发生时,异常信息最终会被日志系统收集到Appender(输出目的地),默认的Appender一般是具体的日志文件,开发人员可以自定义一个Appender,用于专门统计异常和触发报警逻辑。
应用方加入异常监控之后还存在一个问题,当开发人员接到异常报警后,通常会去线上服务器查看错误日志细节。这时如果应用操作的是多个Redis节点(比如使用Redis集群),如何决定是哪一个节点超时还是所有的节点都有超时呢?这是线上很常见的需求,但绝大多数的客户端类库并没有在异常信息中打印ip和port信息,导致无法快速定位是哪个Redis节点超时。
不过修改Redis客户端成本很低,比如Jedis只需要修改Connection类下的connect、sendCommand、readProtocolWithCheckingBroken方法专门捕获连接,发送命令,协议读取事件的异常。由于客户端类库都会保存ip和port信息,当异常发生时很容易打印出对应节点的ip和port,辅助我们快速定位问题节点。
内在原因
API或者数据结构使用不合理
如何发现慢查询?
修改为低算法度的命令,如hgetall改为hmget等,禁用keys、sort等命令。
调整大对象:缩减大对象数据或把大对象拆分为多个小对象,防止一次命令操作过多的数据。
如何发现大对象?
Redis本身提供发现大对象的工具,对应命令:redis-cli-h{ip}-p{port} --bigkeys。
内部原理采用分段进行scan操作,把历史扫描过的最大对象统计出来便于分析优化。
CPU饱和
单线程的Redis处理命令时只能使用一个CPU。
而CPU饱和是指Redis把单核CPU使用率跑到接近100%。
使用top命令很容易识别出对应Redis进程的CPU使用率。
CPU饱和是非常危险的,将导致Redis无法处理更多的命令,严重影响吞吐量和应用方的稳定性。
对于这种情况,首先判断当前Redis的并发量是否达到极限,建议使用统计命令redis-cli-h{ip}-p{port}--stat
获取当前Redis使用情况。
有可能使用了高算法复杂度的命令。
还有一种情况是过度的内存优化,这种情况有些隐蔽,需要我们根据info command stats统计信息分析出命令不合理开销时间。
持久化阻塞
持久化引起主线程阻塞的操作主要有:fork阻塞、AOF刷盘阻塞、HugePage写操作阻塞。
内存
内存消耗分析
内存使用分析,使用info memory获取相关信息
重点关注
used_memory_rss 从操作系统角度显示redis进程占用的物理内存总量
used_memory redis内部数据的内存占用量
mem_fragmentation_ratio =内存碎片率=used_memory_rss/used_memory
- 当mem_fragmentation_ratio>1时,说明used_memory_rss-used_memory多出的部分内存并没有用于数据存储,而是被内存碎片所消耗,如果两者相差很大,说明碎片率严重。
- 当mem_fragmentation_ratio<1时,这种情况一般出现在操作系统把Redis内存交换(Swap)到硬盘导致,出现这种情况时要格外关注,由于硬盘速度远远慢于内存,Redis性能会变得很差,甚至僵死。
内存消耗划分
Redis进程内消耗主要包括:自身内存+对象内存+缓冲内存+内存碎片
其中Redis空进程自身内存消耗非常少,通常used_memory_rss在3MB左右,used_memory在800KB左右,一个空的Redis进程消耗内存可以忽略不计。
used_memory:自身内存,对象内存,缓存内存
used_memory_rss-used_memory :内存碎片
对象内存是Redis内存占用最大的一块,存储着用户所有的数据。Redis所有的数据都采用key-value数据类型,每次创建键值对时,至少创建两个类型对象:key对象和value对象。
键对象都是字符串,在使用Redis时很容易忽略键对内存消耗的影响,应当避免使用过长的键。value对象更复杂些,主要包含5种基本数据类型:字符串、列表、哈希、集合、有序集合。
在使用时一定要合理预估并监控value对象占用情况,避免内存溢出。
缓冲内存主要包括:客户端缓冲、复制积压缓冲区、AOF缓冲区。
AOF缓冲区:这部分空间用于在Redis重写期间保存最近的写入命令,用户无法控制,空间占用通常很小。
复制挤压缓冲区:Redis在2.8版本之后提供了一个可重用的固定大小缓冲区用于实现部分复制功能,根据repl-backlog-size参数控制,默认1MB。对于复制积压缓冲区整个主节点只有一个,所有的从节点共享此缓冲区,因此可以设置较大的缓冲区空间,如100MB,这部分内存投入是有价值的,可以有效避免全量复制。
客户端缓冲指的是所有接入到Redis服务器TCP连接的输入输出缓冲。输入缓冲无法控制,最大空间为1G,如果超过将断开连接。输出缓冲通过参数client-output-buffer-limit控制。
内存碎片
Redis默认的内存分配器采用jemalloc,可选的分配器还有:glibc、tcmalloc。
内存分配器为了更好地管理和重复利用内存,分配内存策略一般采用固定范围的内存块进行分配。
例如jemalloc在64位系统中将内存空间划分为:小、大、巨大三个范围。每个范围内又划分为多个小的内存块单位。
小:[8byte],[16byte,32byte,48byte,...,128byte],[192byte,256byte,...,512byte],[768byte,1024byte,...,3840byte]
大:[4KB,8KB,12KB,...,4072KB]
巨大:[4MB,8MB,12MB,...]
比如当保存5KB对象时jemalloc可能会采用8KB的块存储,而剩下的3KB空间变为了内存碎片不能再分配给其他对象存储。
内存碎片问题虽然是所有内存服务的通病,但是jemalloc针对碎片化问题专门做了优化,一般不会存在过度碎片化的问题,正常的碎片率(mem_fragmentation_ratio)在1.03左右。
容易出现高内存碎片问题:
频繁做更新操作
大量过期键删除
出现高内存碎片问题时常见的解决方式如下:数据对齐,数据的值长度一致;安全重启。
重启节点可以做到内存碎片重新整理。
子进程消耗内存
子进程内存消耗主要指执行AOF/RDB重写时Redis创建的子进程内存消耗。
管理内存的原理和方法
Redis主要通过控制内存上限和回收策略实现内存管理。
设置内存上限
Redis使用maxmemory参数限制最大可用内存。限制内存的目的主要有:
用于缓存场景,当超出内存上限maxmemory时使用LRU等删除策略释放
空间。
防止所用内存超过服务器物理内存。
需要注意,maxmemory限制的是Redis实际使用的内存量,也就是used_memory统计项对应的内存。但是由于内存碎片,实际上消耗的内存要不maxmemory要大。
动态调整内存上限
Redis的内存上限可以通过config set maxmemory进行动态修改,即修改最大可用内存。
内存回收策略
Redis的内存回收机制主要体现在以下两个方面:
- 删除到达过期时间的键对象。
- 内存使用达到maxmemory上限时触发内存溢出控制策略。
删除过期的键
Redis所有的键都可以设置过期属性,内部保存在过期字典中。
由于进程内保存大量的键,维护每个键精准的过期删除机制会导致消耗大量的CPU,对于单线程的Redis来说成本过高,因此Redis采用惰性删除和定时任务删除机制实现过期键的内存回收。
惰性删除:惰性删除用于当客户端读取带有超时属性的键时,如果已经超过键设置的过期时间,会执行删除操作并返回空,这种策略是出于节省CPU成本考虑,不需要单独维护TTL链表来处理过期键的删除。但是单独用这种方式存在内存泄露的问题,当过期键一直没有访问将无法得到及时删除,从而导致内存不能及时释放。
定时任务删除:Redis内部维护一个定时任务,默认每秒运行10次(通过配置hz控制)。定时任务中删除过期键逻辑采用了自适应算法,根据键的过期比例、使用快慢两种速率模式回收键。
内存溢出控制策略
当Redis所用内存达到maxmemory上限时会触发相应的溢出控制策略。
具体策略受maxmemory-policy参数控制,Redis支持6种策略
- noeviction:默认策略,不会删除任何数据,拒绝所有写入操作并返回客户端错误信息(error)OOM command not allowed when used memory,此时Redis只响应读操作。
- volatile-lru:根据LRU算法删除设置了超时属性(expire)的键,直到腾出足够空间为止。如果没有可删除的键对象,回退到noeviction策略。
- allkeys-lru:根据LRU算法删除键,不管数据有没有设置超时属性,直到腾出足够空间为止。
- allkeys-random:随机删除所有键,直到腾出足够空间为止。
- volatile-random:随机删除过期键,直到腾出足够空间为止。
- volatile-ttl:根据键值对象的ttl属性,删除最近将要过期数据。如果没有,回退到noeviction策略
内存溢出控制策略可以采用config set maxmemory-policy {policy}动态配置。
建议线上Redis内存工作在maxmemory>used_memory状态下,避免频繁内存回收开销。
对于需要收缩Redis内存的场景,可以通过调小maxmemory来实现快速回收。
内存优化
Redis存储的所有值对象在内部定义为redisObject结构体。
前面学到的type key可以查看键的类型,object enoding key查看内部编码
lru字段:记录对象最后一次被访问的时间,当配置了maxmemory和maxmemory-policy=volatile-lru或者allkeys-lru时,用于辅助LRU算法删除键数据。可以使用object idletime{key}命令在不更新lru字段情况下查看当前键的空闲时间。
refcount字段:记录当前对象被引用的次数,用于通过引用次数回收内存,当refcount=0时,可以安全回收当前对象空间。使用object refcount{key}获取当前对象引用。
*ptr字段:与对象的数据内容相关,如果是整数,直接存储数据;否则表示指向数据的指针。
提醒:object commond arg[arg] object指的是redisObject结构体
降低Redis内存使用最直接的方式就是缩减键(key)和值(value)的长度。
共享对象池
共享对象池是指Redis内部维护[0-9999]的整数对象池。创建大量的整数类型redisObject存在内存开销,每个redisObject内部结构至少占16字节,甚至超过了整数自身空间消耗。
所以Redis内存维护一个[0-9999]的整数对象池,用于节约内存。除了整数值对象,其他类型如list、hash、set、zset内部元素也可以使用整数对象池。因此开发中在满足需求的前提下,尽量使用整数对象以节省内存。
整数对象池在Redis中通过变量REDIS_SHARED_INTEGERS定义,不能通过配置修改。
可以通过object refcount命令查看对象引用数验证是否启用整数对象池技术。
为什么只有整数对象池?
首先整数对象池复用的几率最大,其次对象共享的一个关键操作就是判断相等性,Redis之所以只有整数对象池,是因为整数比较算法时间复杂度为O(1),只保留一万个整数为了防止对象池浪费。如果是字符串判断相
等性,时间复杂度变为O(n),特别是长字符串更消耗性能(浮点数在Redis内部使用字符串存储)。
字符串对象是Redis内部最常用的数据类型。
Redis没有采用原生C语言的字符串类型而是自己实现了字符串结构,内部简单动态字符串(simple dynamic string,SDS)。
struct SDS{
int len 已用字节长度
int free 未用字节长度
char buf[] 字节数组
}
因为字符串(SDS)存在预分配机制,日常开发中要小心预分配带来的内存浪费。
尽量减少字符串频繁修改操作如append、setrange,改为直接使用set修改字符串,降低预分配带来的内存浪费和内存碎片化。
编码优化
不同编码实现效率和空间的平衡。
编码类型转换在Redis写入数据时自动完成,这个转换过程是不可逆的,转换规则只能从小内存编码向大内存编码转换。
类型 | 编码 | 绝对条件 |
---|---|---|
hash | ziplist | value最大空间(字节)<=hash-max-ziplist-value field个数<=hash-max-ziplist-entries |
hashtable | value最大空间(字节)>hash-max-ziplist-value field个数>hash-max-ziplist-entries |
|
list | ziplist | value最大空间(字节)<=list-max-ziplist-value 链表长度<=list-max-ziplist-entries (老版本能用) |
linkedlist | value最大空间(字节)>list-max-ziplist-value 链表长度>list-max-ziplist-entries(老版本能用) |
|
quicklist | list-max-ziplist-size:表示最大压缩空间或长度 list-compress-depth: 表示最大压缩深度,默认不压缩 |
|
set | intset | 元素是整数,集合长度<=set-max-intset-entries |
hashtable | 元素是非整数,集合长度>hash-max-ziplist-entries | |
zset | ziplist | value最大空间字节<=zset-max-ziplist-value 有序集合长度<=zset-max-ziplist-entries |
skiplist | value最大空间字节>zset-max-ziplist-value 有序集合长度>zset-max-ziplist-entries |
以上的配置项可以用 config set parameter arg
进行设置
127.0.0.1:6379> sadd set:1 1 2 3 4 5
(integer) 5
127.0.0.1:6379> object encoding set:1
"intset"
127.0.0.1:6379> config set set-max-intset-entries 5
OK
127.0.0.1:6379> sadd set:1 7 8
(integer) 2
127.0.0.1:6379> object encoding set:1
"hashtable"
下面是一个编码类型的流程示例:
控制键的数量
Redis本质是一个数据结构服务器,它为我们提供多种数据结构,如hash、list、set、zset等。
使用Redis时不要进入一个误区,大量使用get/set这样的API,把Redis当成Memcached使用。
对于存储相同的数据内容利用Redis的数据结构降低外层键的数量,也可以节省大量内存。
通过在客户端预估键规模,把大量键分组映射到多个hash结构中降低键的数量。
keys---> 客户端哈希函数 --- >存储到redis服务端
其中客户端哈希可以把相同哈希值的方法存入一个哈希表中。