redis进阶之优化

Author Avatar
Sean Yu 12月 22, 2020
  • 在其它设备中阅读本文章

阻塞

Redis是典型的单线程架构,所有的读写操作都是在一条主线程中完成 的。当Redis用于高并发场景时,这条线程就变成了它的生命线。如果出现 阻塞,哪怕是很短时间,对于我们的应用来说都是噩梦。导致阻塞问题的场 景大致分为内在原因和外在原因:

  • 内在原因包括:不合理地使用API或数据结构、CPU饱和、持久化阻塞 等。
  • 外在原因包括:CPU竞争、内存交换、网络问题等。

内在原因

API或数据结构使用不合理

通常Redis执行命令速度非常快,但也存在例外,如对一个包含上万个 元素的hash结构执行hgetall操作,由于数据量比较大且命令算法复杂度是 O(n),这条命令执行速度必然很慢。这个问题就是典型的不合理使用API 和数据结构。对于高并发的场景我们应该尽量避免在大对象上执行算法复杂度超过O(n)的命令。
发现慢查询后,开发人员需要作出及时调整。可以按照以下两个方向去调整:

  • 1) 修改为低算法度的命令,如hgetall改为hmget等,禁用keys、sort等命 令。
  • 2) 调整大对象:缩减大对象数据或把大对象拆分为多个小对象,防止一次命令操作过多的数据。

CPU饱和

单线程的Redis处理命令时只能使用一个CPU。而CPU饱和是指Redis把 单核CPU使用率跑到接近100%。使用top命令很容易识别出对应Redis进程的 CPU使用率。CPU饱和是非常危险的,将导致Redis无法处理更多的命令,严重影响吞吐量和应用方的稳定性。

持久化阻塞

对于开启了持久化功能的Redis节点,需要排查是否是持久化导致的阻 塞。持久化引起主线程阻塞的操作主要有:

  • fork阻塞
    fork操作发生在RDB和AOF重写时,Redis主线程调用fork操作产生共享 内存的子进程,由子进程完成持久化文件重写工作。如果fork操作本身耗时过长,必然会导致主线程的阻塞。优化调整,需避免使用过大的内存实例和规避fork缓慢的操作系统等
  • AOF刷盘阻塞
    当我们开启AOF持久化功能时,文件刷盘的方式一般采用每秒一次,后 台线程每秒对AOF文件做fsync操作。当硬盘压力过大时,fsync操作需要等 待,直到写入完成。如果主线程发现距离上一次的fsync成功超过2秒,为了 数据安全性它会阻塞直到后台线程执行fsync操作完成。这种阻塞行为主要 是硬盘压力引起
  • HugePage写操作阻塞
    子进程在执行重写期间利用Linux写时复制技术降低内存开销,因此只 有写操作时Redis才复制要修改的内存页。对于开启Transparent HugePages的 操作系统,每次写命令引起的复制内存页单位由4K变为2MB,放大了512 倍,会拖慢写操作的执行时间,导致大量写操作慢查询。(Linux 大页内存

外在原因

CPU竞争

  • 进程竞争: Redis是典型的CPU密集型应用,不建议和其他多核CPU密 集型服务部署在一起。当其他进程过度消耗CPU时,将严重影响Redis吞吐 量。可以通过top、sar等命令定位到CPU消耗的时间点和具体进程,这个问 题比较容易发现,需要调整服务之间部署结构。
  • 绑定CPU: 部署Redis时为了充分利用多核CPU,通常一台机器部署多 个实例。常见的一种优化是把Redis进程绑定到CPU上,用于降低CPU频繁上 下文切换的开销。这个优化技巧正常情况下没有问题,但是存在例外情况,当Redis父进程创建子进程进行RDB/AOF重写时,如果做了CPU绑定, 会与父进程共享使用一个CPU。子进程重写时对单核CPU使用率通常在90% 以上,父进程与子进程将产生激烈CPU竞争,极大影响Redis稳定性。因此 对于开启了持久化或参与复制的主节点不建议绑定CPU。

内存交换

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

  • 保证机器充足的可用内存。
  • 确保所有Redis实例设置最大可用内存(maxmemory),防止极端情况下Redis内存不可控的增长。
  • 降低系统使用swap优先级

网络问题

连接拒绝

当出现网络闪断或者连接数溢出时,客户端会出现无法连接Redis的情况。我们需要区分这三种情况:

  • 网络闪断, 一般发生在网络割接或者带宽耗尽的情况,具体 问题定位需要更上层的运维支持,对于重要的Redis服务需要充分考虑部署架构的优化,尽量避免客户端与Redis之间异地跨机房调用。
  • Redis连接拒绝, Redis通过maxclients参数控制客户端最大 连接数,默认10000。当Redis连接数大于maxclients时会拒绝新的连接进入。Redis使用多路复用IO模型可支撑大量连接,但是不代表可以无限连 接。客户端访问Redis时尽量采用NIO长连接或者连接池的方式。
  • 连接溢出。连接溢出。这是指操作系统或者Redis客户端在连接时的 问题。这个问题的原因比较多,下面就分别介绍两种原因:
    • 进程限制, 客户端想成功连接上Redis服务需要操作系统和Redis的限制都通过才可以。由于Linux系统对TCP连 接也定义为一个文件句柄,因此对于支撑大量连接的Redis来说需要增大这 个值,如设置ulimit-n65535,防止Too many open files错误。
    • backlog队列溢出, 系统对于特定端口的TCP连接使用backlog队列保存。Redis默认的长度 为511,通过tcp-backlog参数设置。如果Redis用于高并发场景为了防止缓慢 连接占用,可适当增大这个设置,但必须大于操作系统允许值才能生效。

网络延迟

网络延迟取决于客户端到Redis服务器之间的网络环境。主要包括它们 之间的物理拓扑和带宽占用情况。

网卡软中断

网卡软中断是指由于单个网卡队列只能使用一个CPU,高并发下网卡数据交互都集中在同一个CPU,导致无法充分利用多核CPU的情况。

内存

内存消耗划分

Redis进程内消耗主要包括:自身内存+对象内存+缓冲内存+内存碎片, 其中Redis空进程自身内存消耗非常少,通常used_memory_rss在3MB左右, used_memory在800KB左右,一个空的Redis进程消耗内存可以忽略不计。 Redis主要内存消耗如图8-1所示。

image.png

对象内存

对象内存是Redis内存占用最大的一块,存储着用户所有的数据。Redis 所有的数据都采用key-value数据类型,每次创建键值对时,至少创建两个类 型对象:key对象和value对象。对象内存消耗可以简单理解为sizeof(keys) +sizeof(values)。键对象都是字符串,在使用Redis时很容易忽略键对内存 消耗的影响,应当避免使用过长的键。value对象更复杂些,主要包含5种基本数据类型:字符串、列表、哈希、集合、有序集合。其他数据类型都是建 立在这5种数据结构之上实现的,如:Bitmaps和HyperLogLog使用字符串实 现,GEO使用有序集合实现等。每种value对象类型根据使用规模不同,占 用内存不同。在使用时一定要合理预估并监控value对象占用情况,避免内存溢出。

缓冲内存

缓冲内存主要包括:

  • 客户端缓冲
    客户端缓冲指的是所有接入到Redis服务器TCP连接的输入输出缓冲。 输入缓冲无法控制,最大空间为1G,如果超过将断开连接。
    • 普通客户端: 除了复制和订阅的客户端之外的所有连接,Redis并没有对普通客户端的输 出缓冲区做限制,一般普通客户端的内存消耗可以忽略不计
    • 从客户端: 主节点会为每个从节点单独建立一条连接用于命令复制,当主从节点之间 网络延迟较高或主节点挂载大量从节点时这部分内存消耗将占用很大一部 分,建议主节点挂载的从节点不要多于2个
    • 订阅客户端: 当使用发布订阅功能时,连接客户端使用单独的输出缓冲区,当订阅服务 的消息生产快于消费速度时,输出缓冲区会产生积压造成输出缓冲区空间溢 出。
  • 复制积压缓冲区
    Redis在2.8版本之后提供了一个可重用的固定大小缓 冲区用于实现部分复制功能,默认1MB。对 于复制积压缓冲区整个主节点只有一个,所有的从节点共享此缓冲区,因此 可以设置较大的缓冲区空间,如100MB,这部分内存投入是有价值的,可以 有效避免全量复制
  • AOF缓冲区
    这部分空间用于在Redis重写期间保存最近的写入命令。AOF缓冲区空间消耗用户无法控制,消耗的内存取决于 AOF重写时间和写入命令量,这部分空间占用通常很小。

子进程内存消耗

子进程内存消耗主要指执行AOF/RDB重写时Redis创建的子进程内存消耗。Redis执行fork操作产生的子进程内存占用量对外表现为与父进程相同, 理论上需要一倍的物理内存来完成重写操作。但Linux具有写时复制技术 (copy-on-write),父子进程会共享相同的物理内存页,当父进程处理写请求时会对需要修改的页复制出一份副本完成写操作,而子进程依然读取fork时整个父进程的内存快照。

内存管理

设置内存上限

Redis使用maxmemory参数限制最大可用内存。限制内存的目的主要有:

  • 用于缓存场景,当超出内存上限maxmemory时使用LRU等删除策略释放空间。
  • 防止所用内存超过服务器物理内存。

动态调整内存上限

Redis的内存上限可以通过config set maxmemory进行动态修改,即修改最大可用内存。通过动态修改maxmemory,可以实现在当前服务器下动态伸缩Redis内存的目的。

内存回收策略

Redis的内存回收机制主要体现在以下两个方面:

  • 删除到达过期时间的键对象。
    Redis所有的键都可以设置过期属性,内部保存在过期字典中。由于进 程内保存大量的键,维护每个键精准的过期删除机制会导致消耗大量的 CPU,对于单线程的Redis来说成本过高,因此Redis采用惰性删除和定时任 务删除机制实现过期键的内存回收。
    • 惰性删除: 惰性删除用于当客户端读取带有超时属性的键时,如果已经超过键设置的过期时间,会执行删除操作并返回空,这种策略是出于节省CPU成本考虑,不需要单独维护TTL链表来处理过期键的删除。但是单独用这种方式存在内存泄露的问题,当过期键一直没有访问将无法得到及时删除,从而导致内存不能及时释放。正因为如此,Redis还提供另一种定时任务删除机制作为惰性删除的补充。
    • 定时任务删除: Redis内部维护一个定时任务,默认每秒运行10次(通 过配置hz控制)。定时任务中删除过期键逻辑采用了自适应算法,根据键的过期比例、使用快慢两种速率模式回收键,流程如图8-4所示。
      DgETT1.png
  • 内存使用达到maxmemory上限时触发内存溢出控制策略。当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策略。

内存优化

redisObject对象

高并发写入场景中,在条件允许的情况下,建议字符串长度控制在39字节以内,减少创建redisObject内存分配次数,从而提高性能。

DgZA4x.png

缩减键值对象

降低Redis内存使用最直接的方式就是缩减键(key)和值(value)的长度。

共享对象池

共享对象池是指Redis内部维护[0-9999]的整数对象池。创建大量的整数 类型redisObject存在内存开销,每个redisObject内部结构至少占16字节,甚 至超过了整数自身空间消耗。所以Redis内存维护一个[0-9999]的整数对象 池,用于节约内存。除了整数值对象,其他类型如list、hash、set、zset内部 元素也可以使用整数对象池。因此开发中在满足需求的前提下,尽量使用整 数对象以节省内存。

为什么开启maxmemory和LRU淘汰策略后对象池无效?
LRU算法需要获取对象最后被访问时间,以便淘汰最长未访问数据,每 个对象最后访问时间存储在redisObject对象的lru字段。对象共享意味着多个 引用共享同一个redisObject,这时lru字段也会被共享,导致无法获取每个对 象的最后访问时间。如果没有设置maxmemory,直到内存被用尽Redis也不 会触发内存回收,所以共享对象池可以正常工作。
综上所述,共享对象池与maxmemory+LRU策略冲突,使用时需要注 意。对于ziplist编码的值对象,即使内部数据为整数也无法使用共享对象池,因为ziplist使用压缩且内存连续的结构,对象共享判断成本过高

字符串优化

Redis自身实现的字符串结构有如下特点:

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

字符串之所以采用预分配的方式是防止修改操作需要不断重分配内存和 字节数据拷贝。但同样也会造成内存的浪费。尽量减少字符串频繁修改操作如append、setrange,改为直接使用set修 改字符串,降低预分配带来的内存浪费和内存碎片化。

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

编码优化

DgeFsg.png

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

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

Dge8eJ.png

该数据结构特点如下:

  • 内部表现为数据紧凑排列的一块连续内存数组。
  • 可以模拟双向链表结构,以O(1)时间复杂度入队和出队。
  • 新增删除操作涉及内存重新分配或释放,加大了操作的复杂性。
  • 读写操作涉及复杂的指针移动,最坏时间复杂度为O(n2)。
  • 适合存储小对象和长度有限的数据。

针对性能要求较高的场景使用ziplist,建议长度不要超过1000,每个元 素大小控制在512字节以内。

intset编码
ntset,同样的数据内存占用只有不 到hashtable编码的十分之一。intset数据结构插入命令复杂度为O(n),查询 命令为O(log(n)),由于整数占用空间非常小,所以在集合长度可控的 基础上,写入命令执行速度也会非常快,因此当使用整数集合时尽量使用 intset编码。

控制键的数量

当使用Redis存储大量数据时,通常会存在大量键,过多的键同样会消 耗大量内存。Redis本质是一个数据结构服务器,它为我们提供多种数据结 构,如hash、list、set、zset等。使用Redis时不要进入一个误区,大量使用 get/set这样的API,把Redis当成Memcached使用。对于存储相同的数据内容 利用Redis的数据结构降低外层键的数量,也可以节省大量内存。

通过在客户端预估键规模,把大量键分组映射到多个hash结构中降低键的数量。

DgmKAA.png

hash结构降低键数量分析:

  • 根据键规模在客户端通过分组映射到一组hash对象中,如存在100万个键,可以映射到1000个hash中,每个hash保存1000个元素。
  • hash的field可用于记录原始key字符串,方便哈希查找。
  • hash的value保存原始值对象,确保不要超过hash-max-ziplist-value限制。

使用hash重构后节省内存量效果非常明显,特别对于存储小对象的场 景,内存只有不到原来的1/5。下面分析这种内存优化技巧的关键点:

  • hash类型节省内存的原理是使用ziplist编码,如果使用hashtable编码 方式反而会增加内存消耗。
  • ziplist长度需要控制在1000以内,否则由于存取操作时间复杂度在 O(n)到O(n2)之间,长列表会导致CPU消耗严重,得不偿失。
  • ziplist适合存储小对象,对于大对象不但内存优化效果不明显还会增 加命令操作耗时。
  • 需要预估键的规模,从而确定每个hash结构需要存储的元素数量。
  • 根据hash长度和元素大小,调整hash-max-ziplist-entries和hash-max-ziplist-value参数,确保hash类型使用ziplist编码。

关于hash键和field键的设计:

  • 当键离散度较高时,可以按字符串位截取,把后三位作为哈希的 field,之前部分作为哈希的键。如 key=1948480,哈希key=group:hash: 1948,哈希field=480
  • 当键离散度较低时,可以使用哈希算法打散键,如:使用 crc32(key)&10000函数把所有的键映射到“0-9999”整数范围内,哈希field 存储键的原始值。
  • 尽量减少hash键和field的长度,如使用部分键内容。

使用hash结构控制键的规模虽然可以大幅降低内存,但同样会带来问
题,需要提前做好规避处理。如下所示:

  • 客户端需要预估键的规模并设计hash分组规则,加重客户端开发成本。
  • hash重构后所有的键无法再使用超时(expire)和LRU淘汰机制自动删除,需要手动维护删除。
  • 对于大对象,如1KB以上的对象,使用hash-ziplist结构控制键数量反而得不偿失。