Redis(九)Redis优化

782 阅读18分钟

Redis 设计优化

估算Redis内存使用量

要估算redis中的数据占据的内存大小,需要对redis的内存模型有比较全面的了解,要估算redis中的数据占据的内存大小,需要对redis的内存模型有比较全面的了解

假设有90000个键值对,每个key的长度是12个字节,每个value的长度也是12个字节(且key和 value都不是整数)

微信截图_20210817205033.png

下面来估算这90000个键值对所占用的空间。在估算占据空间之前,首先可以判定字符串类型使用的编码方式:embstr

90000个键值对占据的内存空间主要可以分为两部分:一部分是90000个dictEntry占据的空间;一部分是键值对所需要的bucket空间

每个dictEntry占据的空间包括

  • 一个dictEntry结构,24字节,jemalloc会分配32字节的内存块( 64位操作系统下,一个指针8字节,一个dictEntry由三个指针组成)

  • 一个key,12字节,所以SDS(key)需要12+4=16 个字节([SDS的长度=4+字符串长度),jemalloc会分配16字节的内存块

  • 一个redisObject,16字节,jemalloc会分配16字节的内存块( 4bit+4bit+24bit+4Byte+8Byte=16Byte )

  • 一个value,12字节,所以SDS(value)需要12+4=16个字节([SDS的长度=4+字符串长度),jemalloc会分配16字节的内存块

  • 综上,一个dictEntry所占据的空间需要32+16+16+16=80个字节

bucket数组的大小为大于90000的最小的2^n,是131072;每个bucket元素(bucket中存储的都是指针元素)为8字节(因为64位系统中指针大小为8字节)

因此,可以估算出这90000个键值对占据的内存大小为:9000080 + 1310728 = 8248576

作为对比将key和value的长度由12字节增加到13字节,则对应的SDS变为17个字节,jemalloc会分配 32个字节,因此每个dictEntry占用的字节数也由80字节变为112字节。此时估算这90000个键值对占据 内存大小为:90000112 + 1310728 = 11128576

优化内存占用

了解redis的内存模型,对优化redis内存占用有很大帮助。下面介绍几种优化场景和方式

  • 利用jemalloc特性进行优化

    由于jemalloc分配内存时数值是不连续的,因此key/value字符串变化一个字节,可能会引起占用内存很大的变动;在设计时可以利用这一点

例如,如果key的长度如果是13个字节,则SDS为17字节,jemalloc分配32字节;此时将key长度缩减为12个字节,则SDS为16字节,jemalloc分配16字节;则每个key所占用的空间都可以缩小一半

  • 使用整型/长整型

    如果是整型/长整型,Redis会使用int类型(8字节)存储来代替字符串,可以节省更多空间。因此在可以使用长整型/整型代替字符串的场景下,尽量使用长整型/整型

  • 共享对象

    利用共享对象,可以减少对象的创建(同时减少了redisObject的创建),节省内存空间。目前redis中的共享对象只包括10000个整数(0-9999);可以通过调整OBJ_SHARED_INTEGERS 参数提高共享对象的个数

例如将OBJ_SHARED_INTEGERS 调整到20000,则0-19999之间的对象都可以共享。论坛网站在redis中存储了每个帖子的浏览数,而这些浏览数绝大多数分布在0-20000之间,这时候通过适当增大OBJ_SHARED_INTEGERS 参数,便可以利用共享对象节省内存空间

  • 缩短键值对的存储长度

键值对的长度是和性能成反比的,比如我们来做一组写入数据的性能测试,执行结果如下

微信截图_20210818093438.png

从以上数据可以看出,在 key 不变的情况下,value 值越大操作效率越慢,因为 Redis 对于同一种数据类型会使用不同的内部编码进行存储,比如字符串的内部编码就有三种:int(整数编码)、raw(优化内存分配的字符串编码)、embstr(动态字符串编码),这是因为 Redis 的作者是想通过不同编码实现效率和空间的平衡,然而数据量越大使用的内部编码就越复杂,而越是复杂的内部编码存储的性能就越低

这还只是写入时的速度,当键值对内容较大时,还会带来另外几个问题

  • 内容越大需要的持久化时间就越长,需要挂起的时间越长,Redis 的性能就会越低

  • 内容越大在网络上传输的内容就越多,需要的时间就越长,整体的运行速度就越低

  • 内容越大占用的内存就越多,就会更频繁的触发内存淘汰机制,从而给 Redis 带来了更多的运行负担

因此在保证完整语义的同时,我们要尽量的缩短键值对的存储长度,必要时要对数据进行序列化和压缩再存储,以 Java 为例,序列化我们可以使用 protostuff 或 kryo,压缩我们可以使用 snappy

查看Redis内存统计

 info memory 
 # Memory 
 #Redis分配的内存总量
 used_memory:853464 
 #占操作系统的内存,不包括虚拟内存(字节)used_memory是Redis使用的内存总量,它包含了实际
 缓存占用的内存和Redis自身运行所占用的内存(如元数据、lua)。它是由Redis使用内存分配器分
 配的内存,所以这个数据并没有把内存碎片浪费掉的内存给统计进去。
 used_memory_rss:12247040 
 #内存碎片比例 如果小于1说明使用了虚拟内存
 mem_fragmentation_ratio:15.07 
 #内存碎片字节数
 mem_fragmentation_bytes
 #Redis使用的内存分配器
 mem_allocator:jemlloc-5.1.0
  • used_memory

    used_memory 字段数据表示的是:由Redis分配器分配的内存总量,以字节(byte)为单位

    used_memory_human只是显示更加人性化

  • used_memory_rss

    记录的是由 操作系统分配 的 Redis进程内存 和Redis内存中无法再被jemalloc分配的 内存碎片 (单位是字节)

used_memory和used_memory_rss的区别:

前者是从Redis角度得到的量,后者是从操作系统角度得到的量。二者之所以有所不同,一方面是因为内存碎片和Redis进程运行需要占用内存,使得前者可能比后者小

由于在实际应用中,Redis的数据量会比较大,此时进程运行占用的内存与Redis数据量和内存碎片相比,都会小得多;因此used_memory_rss和used_memory的比例,便成了衡量Redis内存碎片率的参数;这个参数就是mem_fragmentation_ratio

  • mem_fragmentation_ratio

    内存碎片比率 ,该值是used_memory_rss / used_memory的比值

    mem_fragmentation_ratio一般大于1,且该值越大,内存碎片比例越大

    mem_fragmentation_ratio<1

    mem_fragmentation_ratio值计算为进程的内存驻留集大小(RSS,由OS测量)与Redis使用分配器分配的总字节数之间的比率。 现在,如果使用libc分配更多内存(与jemalloc,tcmalloc相比),或者在基准测试期间系统上的某些其他进程使用了更多内存,则可以通过操作系统交换Redis内存。它会减少RSS(因为Redis内存的一部分不再存在于主内存中)。由此产生的碎裂率将小于1。 换句话说,只有当您确定操作系统没有交换Redis内存时,此比率才有意义(如果不是这样,那么无论如何都会出现性能问题)

    一般来说,mem_fragmentation_ratio在1.03左右是比较健康的状态(对于jemalloc来说);刚开始的mem_fragmentation_ratio值很大,是因为还没有向Redis中存入数据,Redis进程本身运行的内存使得used_memory_rss 比used_memory大得多

  • mem_allocator

    Redis使用的内存分配器,在编译时指定;可以是 libc 、jemalloc或者tcmalloc,默认是jemalloc

Redis 性能优化

设置键值的过期时间

我们应该根据实际的业务情况,对键值设置合理的过期时间,这样 Redis 会帮你自动清除过期的键值对,以节约对内存的占用,以避免键值过多的堆积,频繁的触发内存淘汰策略

Redis 有四个不同的命令可以用于设置键的生存时间(键可以存在多久)或过期时间(键什么时候会被删除)

  • EXPlRE 命令用于将键key 的生存时间设置为ttl 秒

  • PEXPIRE 命令用于将键key 的生存时间设置为ttl 毫秒

  • EXPIREAT < timestamp> 命令用于将键key 的过期时间设置为timestamp所指定的秒数时间戳

  • PEXPIREAT < timestamp > 命令用于将键key 的过期时间设置为timestamp所指定的毫秒数时间戳

使用 lazy free 特性(惰性删除)

lazy free 特性是 Redis 4.0 新增的一个非常使用的功能,它可以理解为惰性删除或延迟删除。意思是在删除的时候提供异步延时释放键值的功能,把键值释放操作放在 BIO(Background I/O) 单独的子线程处理中,以减少删除删除对 Redis 主线程的阻塞,可以有效地避免删除 big key 时带来的性能和可用性问题

lazy free 对应了 4 种场景,默认都是关闭的

lazyfree-lazy-eviction no 
lazyfree-lazy-expire no 
lazyfree-lazy-server-del no 
slave-lazy-flush no

代表的含义如下

  • lazyfree-lazy-eviction:表示当 Redis 运行内存超过最大内存时,是否开启 lazy free 机制删除

  • lazyfree-lazy-expire:表示设置了过期时间的键值,当过期之后是否开启 lazy free 机制删除

  • lazyfree-lazy-server-del:有些指令在处理已存在的键时,会带有一个隐式的 del 键的操作,比如 rename 命令,当目标键已存在,Redis 会先删除目标键,如果这些目标键是一个 big key,就会造成阻塞删除的问题,此配置表示在这种场景中是否开启 lazy free 机制删除

  • slave-lazy-flush:针对 slave(从节点) 进行全量数据同步,slave 在加载 master 的 RDB 文件前,会运行 flushall 来清理自己的数据,它表示此时是否开启 lazy free 机制删除

建议开启其中的 lazyfree-lazy-eviction、lazyfree-lazy-expire、lazyfree-lazy-server-del 等配置,这样就可以有效的提高主线程的执行效率

限制 Redis 内存大小,设置内存淘汰策略

最大缓存

 maxmemory 1048576 
 maxmemory 1048576B 
 maxmemory 1000KB 
 maxmemory 100MB 
 maxmemory 1GB

没有指定最大缓存,如果有新的数据添加,超过最大内存,则32位会使redis崩溃,所以一定要设置。最佳设置是物理内存的75% ,写操作比较多 60%

LRU原理

LRU(Least recently used,最近最少使用)算法根据数据的历史访问记录来进行淘汰数据,其核心思想是“如果数据最近被访问过,那么将来被访问的几率也更高”

微信截图_20210818100534.png

LFU原理

LFU,全称是:Least Frequently Used,最不经常使用策略,在一段时间内,数据被使用频次最少的,优先被淘汰。* 最少使用* (* LFU* )是一种用于管理计算机内存的缓存算法。主要是记录和追踪内存块的使用次数,当缓存已满并且需要 更多空间时,系统将以最低内存块使用频率清除内存.采用LFU算法的最简单方法是为每个加载到缓存的块分配一个计数 器。每次引用该块时,计数器将增加一。当缓存达到容量并有一个新的内存块等待插入时,系统将搜索计数器最低的块并 将其从缓存中删除

微信截图_20210818100703.png

LRU和LFU侧重点不同,LRU主要体现在对元素的使用时间上,而LFU主要体现在对元素的使用频次上。LFU的缺陷是:在短期的时间内,对某些缓存的访问频次很高,这些缓存会立刻晋升为热点数据,而保证不会淘汰,这样会驻留在系统内存里面。而实际上,这部分数据只是短暂的高频率访问,之后将会长期不访问,瞬时的高频访问将会造成这部分数据的引用频率加快,而一些新加入的缓存很容易被快速删除,因为它们的引用频率很低

Redis缓存淘汰策略

redis 内存数据集大小上升到一定大小的时候,就会实行数据淘汰策略

maxmemory-policy voltile-lru,支持热配置 内存淘汰策略在 Redis 4.0 之后有 8 种

  • noeviction 不淘汰任何数据,当内存不足时,新增操作会报错,Redis 默认内存淘汰策略

  • allkeys-lru 淘汰整个键值中最久未使用的键值

  • allkeys-random 随机淘汰任意键值

  • volatile-lru 淘汰所有设置了过期时间的键值中最久未使用的键值

  • volatile-random 随机淘汰设置了过期时间的任意键值

  • volatile-ttl 优先淘汰更早过期的键值

在 Redis 4.0 版本中又新增了 2 种淘汰策略

  • volatile-lfu :淘汰所有设置了过期时间的键值中,最少使用的键值

  • allkeys-lfu :淘汰整个键值中最少使用的键值

其中 allkeys-xxx 表示从所有的键值中淘汰数据,而 volatile-xxx 表示从设置了过期键的键值中淘 汰数据

禁用长耗时的查询命令

Redis 绝大多数读写命令的时间复杂度都在 O(1) 到 O(N) 之间

其中 O(1) 表示可以安全使用的,而 O(N) 就应该当心了,N 表示不确定,数据越大查询的速度可能会越慢。因为 Redis 只用一个线程来做数据查询,如果这些指令耗时很长,就会阻塞 Redis,造成大量延时

要避免 O(N) 命令对 Redis 造成的影响,可以从以下几个方面入手改造

  • 决定禁止使用 keys 命令

  • 避免一次查询所有的成员,要使用 scan 命令进行分批的,游标式的遍历

  • 通过机制严格控制 Hash、Set、Sorted Set 等结构的数据大小

  • 将排序、并集、交集等操作放在客户端执行,以减少 Redis 服务器运行压力

  • 删除 (del) 一个大数据的时候,可能会需要很长时间,所以建议用异步删除的方式 unlink ,它会启动一个新的线程来删除目标数据,而不阻塞 Redis 的主线程

Redis6.0 引入了多线程

为什么 Redis 一开始选择单线程模型

  • IO多路复用

    Redis顶层设计

    微信截图_20210818102245.png

    FD是一个文件描述符,意思是表示当前文件处于可读、可写还是异常状态。使用 I/O 多路复用机制同时监听多个文件描述符的可读和可写状态

    一旦受到网络请求就会在内存中快速处理,由于绝大多数的操作都是纯内存的,所以处理的速度会非常地快

    也就是说在单线程模式下,即使连接的网络处理很多,因为有IO多路复用,依然可以在高速的内存处理中得到忽略

  • 可维护性高

    多线程模型虽然在某些方面表现优异,但是它却引入了程序执行顺序的不确定性,带来了并发读写的一系列问题。单线程模式下,可以方便地进行调试和测试

  • 基于内存,单线程状态下效率依然高

    多线程能够充分利用CPU的资源,但对于Redis来说,由于基于内存速度那是相当的高,能达到在一秒内处理10万个用户请求,如果一秒十万还不能满足,那我们就可以使用Redis分片的技术来交给不同的Redis服务器。这样的做法避免了在同一个 Redis 服务中引入大量的多线程操作

    而且基于内存,除非是要进行AOF备份,否则基本上不会涉及任何的 I/O 操作。这些数据的读写由于只发生在内存中,所以处理速度是非常快的;用多线程模型处理全部的外部请求可能不是一个好的方案

总结成两句话,基于内存而且使用多路复用技术,单线程速度很快,又保证了多线程的特点。因为没有必要使用多线程

为什么 Redis 在 6.0 之后加入了多线程(在某些情况下,单线程出现了缺点,多线程可以解决)

因为读写网络的read/write系统调用在Redis执行期间占用了大部分CPU时间,如果把网络读写做成多线程的方式对性能会有很大提升

Redis可以使用del命令删除一个元素,如果这个元素非常大,可能占据了几十兆或者是几百兆,那么在 短时间内是不能完成的,这样一来就需要多线程的异步支持

微信截图_20210818102921.png 使用多线程删除工作可以在后台

总结:Redis 选择使用单线程模型处理客户端的请求主要还是因为 CPU 不是 Redis 服务器的瓶颈,所以使用多线程 模型带来的性能提升并不能抵消它带来的开发成本和维护成本,系统的性能瓶颈也主要在网络 I/O 操作上;而 Redis 引入多线程操作也是出于性能上的考虑,对于一些大键值对的删除操作,通过多线程非阻塞地释放内存空间也能减少对 Redis 主线程阻塞的时间,提高执行的效率

4.0 开始已经有异步线程了

使用 slowlog 优化耗时命令

可以使用 slowlog 功能找出最耗时的 Redis 命令进行相关的优化,以提升 Redis 的运行 速度,慢查询有两个重要的配置项

  • slowlog-log-slower-than :用于设置慢查询的评定时间,也就是说超过此配置项的命令,将会被当成慢操作记录在慢查询日志中,它执行单位是微秒 (1 秒等于 1000000 微秒)

  • slowlog-max-len :用来配置慢查询日志的最大记录数

可以根据实际的业务情况进行相应的配置,其中慢日志是按照插入的顺序倒序存入慢查询日志中,我们可以使用 slowlog get n 来获取相关的慢查询日志,再找到这些慢查询对应的业务进行相关的优化

避免大量数据同时失效

Redis 过期键值删除使用的是贪心策略,它每秒会进行 10 次过期扫描,此配置可在 redis.conf 进行配置,默认值是 hz 10,Redis 会随机抽取 20 个值,删除这 20 个键中过期的键,如果过期 key 的比例超过 25% ,重复执行此流程

如果在大型系统中有大量缓存在同一时间同时过期,那么会导致 Redis 循环多次持续扫描删除过期字典,直到过期字典中过期键值被删除的比较稀疏为止,而在整个执行过程会导致 Redis 的读写出现明显的卡顿,卡顿的另一种原因是内存管理器需要频繁回收内存页,因此也会消耗一定的 CPU。

为了避免这种卡顿现象的产生,我们需要预防大量的缓存在同一时刻一起过期,就简单的解决方案就是在过期时间的基础上添加一个指定范围的随机数

使用 Pipeline 批量操作数据

Pipeline (管道技术) 是客户端提供的一种批处理技术

可以批量执行一组指令,一次性返回全部结果,可以减少频繁的请求应答

微信截图_20210818105438.png

客户端使用优化

在客户端的使用上我们除了要尽量使用 Pipeline 的技术外,还需要注意要尽量使用 Redis 连接池,而不是频繁创建销毁 Redis 连接,这样就可以减少网络传输次数和减少了非必要调用指令

  import redis.clients.jedis.JedisPool; 
  import redis.clients.jedis.JedisPoolConfig;

使用分布式架构来增加读写速度

Redis 分布式架构有重要的手段

  • 主从同步

  • 哨兵模式

  • Redis Cluster 集群

使用主从同步功能我们可以把写入放到主库上执行,把读功能转移到从服务上,因此就可以在单位时间内处理更多的请求,从而提升的 Redis 整体的运行速度

而哨兵模式是对于主从功能的升级,但当主节点奔溃之后,无需人工干预就能自动恢复 Redis 的正常使用

Redis Cluster 是 Redis 3.0 正式推出的,Redis 集群是通过将数据库分散存储到多个节点上来平衡各个节点的负载压力

禁用 THP 特性

Linux kernel 在 2.6.38 内核增加了 Transparent Huge Pages (THP) 特性 ,支持大内存页 2MB 分配,默认开启

当开启了 THP 时,fork 的速度会变慢,fork 之后每个内存页从原来 4KB 变为 2MB,会大幅增加重写期间父进程内存消耗。同时每次写命令引起的复制内存页单位放大了 512 倍,会拖慢写操作的执行时间,导致大量写操作慢查询。例如简单的 incr 命令也会出现在慢查询中,因此 Redis 建议将此特性进行禁用,禁用方法如下

echo never > /sys/kernel/mm/transparent_hugepage/enabled

为了使机器重启后 THP 配置依然生效,可以在 /etc/rc.local 中追加 echo never > /sys/kernel/mm/transparent_hugepage/enabled