Redis(三) 阻塞与内存

Posted by ZhouJ000 on November 3, 2018
最后更新于:2018-11-04

Redis(一) 基础与api
Redis(二) 小功能
Redis(三) 阻塞与内存
Redis(四) 缓存设计
Redis(五) 客户端调用
Redis(六) 持久化与复制
Redis(七) 哨兵
Redis(八) 集群

阻塞

redis是典型的单线程架构,所有读写操作在一条主线程中完成。如果出现阻塞,哪怕很短时间,也会造成很大影响

导致阻塞问题的场景大致分为内在原因和外在原因:
内在原因: 不合理地使用API或数据结构、CPU饱和、持久化阻塞等
外在原因: CPU竞争、内存交换、网络问题等

当redis阻塞时,线上应用服务器应该最先感知到,应用方会收到大量redis超时异常,比如Jedis客户端或抛出JedisConnectionException异常。常见做法就是加入异常统计并进行报警。应用使用redis集群,可以修改Jedis的Connection类下的connect、sendCommand、readProtocolWithCheckingBroken方法专门捕获连接、发送命令、协议读取事件的异常。由于客户端类库都会保存ip和port信息,当异常发生时很容易打印出对应节点的ip和port信息,辅助快速定位问题节点

内在原因

在定位到具体redis节点异常后,应该先排查是否是redis自身原因导致

API或数据结构使用不合理

通常redis执行命令速度很快。但比如对一个包含上万个元素的hash结构执行hgetall操作,由于数据量比较大且命令算法复杂度是O(n),这条命令执行速度必然很慢。折旧是典型的不合理利用API和数据结构。对于高并发场景应该尽量避免在大对象上执行算法复杂度超过O(n)的命令

1、如何发现慢查询
redis原生提供的慢查询统计功能,执行slowlog get {n}命令可以获取最近n条慢查询命令。发现慢查询后可以按照下面两个方向去调整:
1)修改为低算法度的命令,如hgetall改为hmget等,禁用keys、sort等命令
2)调整大对象,缩减大对象数据或者把大对象拆分为多个对象,防止一次命令操作过多的数据。大对象拆分过程需要视具体的业务决定

2、如何发现大对象
使用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使用情况,该命令每秒输出一行统计信息

持久化阻塞

1、fork阻塞
fork操作发生在RDB和AOF重写时,redis主线程调用fork操作产生共享内存的子进程,由子进程完成持久化文件重写工作。如果fork操作本身耗时过长,必然会导致主线程的阻塞。可以使用info stats命令获取到latest_fork_usec指标,表示redis最近一次fork操作耗时,做出优化调整

2、AOF刷盘阻塞
当开启AOF持久化功能时,文件刷盘的方式一般采用每秒一次,后台线程每秒对AOF文件做fsync操作。当硬盘压力过大时,fsync操作需要等待,直到写入完成。如果主线程发现距离上一次fsync成功超过2秒,为了数据安全性它会阻塞直到后台线程执行fsync操作完成。这种阻塞行为主要是硬盘压力引起,可以查看redis日志分析。硬盘压力可能是redis进程引起的,也可能是其他进程引起的,可以使用iotop查看具体哪个进程消耗过多的硬盘资源

3、HugePage写操作阻塞
子进程在执行重写期间利用linux写时复制技术降低内存开销,因此只有写操作时redis才会复制要修改的内存页。对于开启Transparent HugePage的操作系统,每次写命令引起的复制内存页单位由4K变为2MB,放大了512倍,会拖慢写操作的执行时间,导致大量写操作慢查询

外在原因

CPU竞争

主要有:
1、进程竞争,由于redis是典型的CPU密集型应用,不建议与其他多核CPU密集型服务部署在一起
2、绑定CPU,部署redis为了充分利用多核CPU,通常一台部署多个实例,把redis进程绑定到CPU上以降低CPU频繁上下文切换开销。但是当redis父进程创建子进程进行RDB/AOF重写时,如果做了CPU绑定,会与父进程共享使用一个CPU,可能出现互相竞争。因此对于开启了持久化或参与复制的主节点不建议绑定CPU

内存交换

内存交换(swap)对于redis来说是非常致命的。redis保证高性能的一个重要前提就是所有数据存在内存中。如果操作系统把redis使用的部分内存换出到硬盘,由于内存和硬盘读写速度之间差距几个数量级,会导致发生交换后的redis性能急剧下降

网络问题

网络问题经常是引起redis阻塞的问题点,常见有:

  1. 连接拒绝
    • 网络闪断。一般发生在网络割接或带宽耗尽的情况
    • redis连接拒绝。redis通过maxclients参数控制客户端最大连接数。客户端访问redis时尽量采用NIO长连接或连接池方式
    • 连接溢出
      • 进程线程。操作系统会对进程使用的资源做限制,其中一项是对进程可打开最大文件数控制,默认1024
      • backlog队列溢出。系统对于特定端口的TCP连接使用backlog队列保存,redis默认长度为511
  2. 网络延迟。取决于客户端到服务端的网络环境,主要包括物理拓扑和带宽占用情况
  3. 网卡软中断。指由于单个网卡队列只能使用一个CPU,高并发下网卡数据交互都集中在同一个CPU,导致无法充分利用多核CPU的情况

内存

内存消耗

可以通过info memory命令获取内存相关指标。需要重点关注的指标有 used_memory_rss和used_memory以及它们的比值mem_fragmentation_ratio。当比值大于1时,说明两者间多出的部分没有用于数据存储,而是被内存碎片所消耗,如果两者差距很大,说明碎片率严重。当比值小于1时,这种情况一般出现在操作系统把redis内存交换到硬盘所致,这时需要格外注意,硬盘速度远远慢于内存,redis性能会变差甚至僵死

redis进程内消耗主要包括: 自身内存 + 对象内存 + 缓冲内存 + 内存碎片
1、自身内存:一个空的redis进程消耗内存可以忽略不计
2、对象内存:是redis内存占用最大的一块,存储着用户所有的数据。redis所有数据都采用key-value数据类型,所以内存消耗可以简单理解为sizeof(keys) + sizeof(values)。键对象是字符串,要避免使用过长的键;value对象有5种基本数据类型,要根据使用规模不同,合理预估并监控,防止内存溢出
3、缓冲内存:主要包括客户端缓冲、复制积压缓冲区、AOF缓冲区
4、内存碎片:redis默认的内存分配器采用jemalloc。内存分配器为了更好管理和重复利用内存,分配内存策略一般采用固定范围的内存块进行分配。在频繁做更新操作、大量过期键删除时会出现高内存碎片问题。一般常见解决方法为数据对齐、安全重启

子进程内存消耗:
主要指执行AOF/RDB重写时redis创建的子进程内存消耗

内存管理

设置内存上限

redis使用maxmemory参数限制最大可用内存。限制内存的主要目的有:
1、用于缓存场景,当超过内存上限maxmemory时使用LRU等删除策略释放空间
2、防止所有内存超过服务器物理内存
注意:maxmemory限制的是redis实际使用的内存量,也就是used_memory统计项对应内存。由于内存碎片率的存在,实际消耗内存会更大,所以要小心这部分内存溢出

redis的内存上限可以通过config set maxmemory进行动态修改

redis默认无限使用服务器内存,为防止极端情况下导致系统资源耗尽,建议所有redis进程都要设置maxmemory

内存回收策略

主要体现在两方面:
1、删除到达过期时间的键对象
2、内存使用达到maxmemory上限时触发内存溢出控制策略

删除过期键对象

redis所有键都可以设置过期属性,内部保存在过期字典中。由于内存保存大量的键,维护每个键的精准过期删除机制会导致消耗大量CPU,对于单线程redis来说成本过高,因此redis采用惰性删除和定时任务删除机制实现过期键的内存回收

1、惰性删除:用于客户端读取带有超时属性的键时,如果已经超过过期时间,会执行删除操作并返回空
2、定时任务删除:redis内部维护一个定时任务,默认每秒运行10次。定时任务中删除过期键逻辑采用了自适应算法,根据键的过期比例,使用快慢两种速率模式回收键。

内存溢出控制策略

当redis所用内存打到maxmemory上限时触发相应的溢出控制策略。具体策略受maxmemory-policy参数控制,支持6种策略:
1、noeviction:默认策略,不会删除任何数据,拒绝所有写入操作并返回客户端错误信息,此时redis只响应读操作
2、volatile-lru:根据LRU算法删除设置了超时属性(expire)的键,直到腾出足够的空间为止。如果没有可删除对象,回退到noeviction策略
3、allkeys-lru:根据LRU算法删除键,不管数据有没有设置超时属性,直到腾出足够的空间为止
4、allkeys-random:随机删除所有键,直到腾出足够的空间为止
5、volatile-random:随机删除过期键,直到腾出足够的空间为止
6、volatile-ttl:根据键值对象的ttl属性,删除最近将要过期的数据。如果没有,回退到noeviction策略

内存溢出控制策略可以通过采用config set maxmemory-policy {policy}动态配置

频繁回收内存成本很高,主要包括查找可回收键和删除键的开销,如果当前redis有从节点,回收内存操作对应的删除命令会同步到从节点,导致写放大的问题。建议线上redis内存工作在maxmemory大于used_memory状态下,避免频繁内存回收开销

内存优化

redisObject对象

redis存储的所有值对象在内部定义为redisObject结构体,都是用redisObject封装,理解redisObject对内存优化非常有帮助

1、type字段:表示当前对象使用的数据类型,主要支持5种数据类型(string、hash、list、set、zset)。可以使用type {key}命令查看
2、encoding字段:表示redis内部编码类型,代表当前对象内部采用哪种数据结构实现
3、lru字段:记录对象最后一次被访问的时间,用于辅助LRU算法删除键数据(object idletime {key}命令在不更新lru下查看当前键空闲时间;可以使用scan + object idletime命令批量查看那些键长时间未被访问)
4、refcount字段:记录当前对象被引用的次数,用于通过引用次数回收内存,使用object refcouont {key}获取当前引用数
5、*ptr字段:与对象的数据内容相关。如果是整数,直接存储数据;否则表示指向数据的指针。

缩减键值对象

最直接的方法就是缩减 key 和 value(protostuff、kryo等高效序列化工具,或用Snappy压缩json等格式)的长度

共享对象池

共享对象池是redis内部维护[0-9999]的整数对象池。在满足开发需求前提下,尽量使用整数对象以节省资源。整数对象池在redis中通过变量REDIS_SHARED_INTEGERS定义,不能通过配置修改。可以通过object refcount命令查看对象引用验证是否启用整数对象池技术

共享对象池与maxmemory + LRU策略冲突。因为LRU算法需要获取对象最后被访问时间,以便淘汰最长未被访问数据。另外对于ziplist编码的值对象,即使内部数据为整数也无法使用共享内存池,因为ziplist使用压缩且内存连续的结构,对象共享判断成本过高

字符串优化

redis自身实现字符串结构特点:
1、O(1)时间复杂度获取:字符串长度、已用长度、未用长度
2、可用于保存字节数组,支持安全的二进制数据存储
3、内部实现空间预分配机制,降低内存再分配次数
4、惰性删除机制,字符串缩减后的空间不释放,作为预分配空间保留

因为字符串(SDS)存在预分配机制,日常开发要小心预分配带来的内存浪费

字符串重构:指不一定把每份数据作为字符串整体存储,像json这样的数据可以使用hash结构(ziplist),使用二级结构存储也能帮我们节省内存。同时可以使用hmget和hmset命令支持字段的部分读取修改,而不用每次整体存取

编码优化

redis对外提供了多种类型,但是内部针对不同类型存在编码的概念,就是具体使用哪种底层数据结构来实现。编码的不同将直接影响数据的内存占用和读写速率

编码类型的转换再redis写入数据时自动完成,这个转换过程是不可逆的,转换规则只能从小内存编码向大内存编码转换

可以使用config set命令设置编码相关参数来满足使用压缩编码的条件

ziplist编码主要目的是节省内存,因此所有数据都是采用线性连续的内存结构。是应用范围最广的一种,可以分别为hash、list、zset类型的底层数据结构实现

当使用redis存储大量数据时,通常会存在大量的键,过多的键同样会消耗大量内存。redis本质是一个数据结构服务器,提供了多种数据结构。使用redis不要进入一个误区,大量使用get/set这样的API,把redis当做memcached使用。对于存储相同的数据内容利用redis的数据结构节省外层键的数量,也可以节省大量内存

注意:
1、hash类型节省内存的原理是使用ziplist编码,如果使用hashtable编码反而会增加内存消耗
2、hash-ziplist类型比string类型写入耗时,但随着value空间的减小,耗时逐渐减低
3、ziplist长度需要控制在1000以内,否则由于存取操作时间复杂度在O(n)到O(n^2)之间,长列表导致CPU消耗严重,得不偿失
4、ziplist适合存储小对象,对大对象不但内存优化不明显,还会增加命令操作耗时
5、需要预估键的规模,从而确定每个hash结构需要存储的元素数量,设计hash分组规则,也会加重开发成本
6、根据hash长度和元素大小,调整hash-max-ziplist-entries和hash-max-ziplist-value参数,保证hash类型使用ziplist编码
7、当键散列度较高时,可以按字符串位截取,把后3为作为hash的field,之前部分作为hash的键
8、当键散列度较低时,可以使用哈希算法打散键
9、hash重构后所有的键无法在使用超时(expire)和LRU淘汰机制自动删除,需要存储每个对象写入时间,通过定时任务hscan命令扫描数据,找出超时数据手动删除
10、对于大对象,比如1KB以上的对象,使用hash-ziplist结构控制键数量反而得不偿失