如何使用好 Redis 内存数据库
接下来,我们来聊聊如何使用好 Redis 内存数据库。
目前主流的内存数据库是 Redis,它使用 IO 多路复用机制监听多个文件描述符的读写事件,然后使用单线程来处理任务。如下图所示。
虽然能避免线程切换和竞争,但是会话间的操作会相互影响,比如会话二的删除操作阻塞时间过长的话,会影响会话一的写操作,从而影响整个服务的可用性。如下图所示。
另外,Redis 是纯内存型的,但是内存资源成本又比较高,比如说一台物理机能够搭建多少台容器,受限最大的因素就是内存。因此,我们很有必要去了解如何用好 Redis 这个内存数据库,来方便我们优化内存占用和分析解决问题。
程序是由数据结构和算法组成的,但数据量很大的时候,数据结构有着举足轻重的作用,因为我们要先了解清楚 Redis 的对象类型,才有助于实现方案的选择。
Redis 的对象类型主要包括:字符串、列表、哈希、集合和有序集合。那么这些对象类型都有什么特点?它们的实现原理又是怎么样的呢?
字符串内部编码
先来说说字符串的内部编码,Redis 字符串编码格式有这么几种:int 编码、embstr 编码和 raw 编码 。
int 编码:8 个字节的长整型。
embstr 编码:小于 39 字节的字符串。
raw 编码:大于 39 字节的字符串。
当我们对 embstr 只读类型的数据进行修改时,它会自动转换成 raw 类型,并且这个转换过程是不可逆的。因为 Redis 规定只能从小内存编码向大内存编码转换,即使大内存编码最少申请的内存空间远远超过我们实际使用的空间,Redis 也会为了内存对齐浪费掉一部分内存空间,所以,保证 key 的长度在 8 个字节以内还有很必要的,如果 value 值能够压缩或者处理成 8 个字符串的长度,效果会更明显。
列表的内部编码
接下来说一说列表的内部编码,它的内部编码有压缩列表(ziplist)和双端链表(linkedlist)两种。
压缩列表(ziplist):当哈希类型元素个数小于 hash-max-ziplist-entries 配置(默认512个)同时所有值都小于 hash-max-ziplist-value 配置(默认64个字节)时,Redis 会使用 ziplist 作为哈希的内部实现。
简单说,压缩列表是使用时间复杂度换空间存储的一种优化方法,它将 key - value 都按顺序依次摆放到一个长长的字符串来存储,存储空间相比双端链表节省了很多,但是算法复杂度变大了,所以当列表中元素数量小于 512 个并且列表中对象大小都不足 64 字节时,使用压缩列表非常划算。如下图所示:
比如说,我们可以将业务紧密型的一批 key-value 对象,直接使用 ziplist 来存储,也可以通过哈希算法将毫无关联的一批 key - value 对象间接使用 ziplist 来存储,这样就能省下很多的内存空间。
双端链表(linkedlist):当列表类型无法满足ziplist的条件时,Redis 会使用 linkedlist 作为列表的内部实现。
双端链表的好处在插入和删除不需要移动其他元素,但是在插入和删除之前首先要找到插入或删除的位置,在有些场景下还是挺花费时间的。
哈希的内部编码
接下来说一说哈希的内部编码,它的内部编码有两种:ziplist(压缩列表)和 hashtable(哈希表)。
ziplist(压缩列表):当哈希类型元素个数小于 hash-max-ziplist-entries 配置(默认512个),同时所有值都小于hash-max-ziplist-value配置(默认64个字节)时,Redis 会使用 ziplist 作为哈希的内部实现 ziplist 使用更加紧凑的结构实现多个元素的连续存储,所以在节省内存方面比 hashtable 更加优秀。
hashtable(哈希表):当哈希类型无法满足ziplist的条件时,Redis 会使用 hashtable 作为哈希的内部实现。因为此时 ziplist 的读写效率会下降,而 hashtable 的读写时间复杂度为O(1)。
集合的内部编码
接下来说一说集合的内部编码,
intset(整数集合):当集合中的元素都是整数且元素个数小于set-max-intset-entries配置(默认512个)时,Redis 会选用 intset 来作为集合内部实现,从而减少内存的使用。
hashtable(哈希表):当集合类型无法满足 intset 的条件时,Redis 会使用 hashtable 作为集合的内部实现。
有序集合的内部编码
接下来说一说集合的内部编码,它的内部编码有两种:ziplist(压缩列表)和 skiplist(跳跃表)。
ziplist(压缩列表):当有序集合的元素个数小于 zset-max-ziplist-entries配置(默认128个)同时每个元素的值小于 zset-max-ziplist-value 配置(默认64个字节)时,Redis 会用 ziplist 来作为有序集合的内部实现,ziplist 可以有效减少内存使用。
skiplist(跳跃表):当 ziplist 条件不满足时,有序集合会使用skiplist作为内部实现,因为此时zip的读写效率会下降。
前面分享了内部编码的知识以及如何利用好编码结构的特点来合理存储数据。接下来重点分析容易用错的集合。
容易用错的集合
根据我的经验,很多人习惯性将某主体的列表直接存放在单个 key 的集合中,这就很容易产生单 key 操作,一次操作返回的列表数量太大的话,就会导致通信超时或者网络堵塞,反过说,控制好每页元素的个数,游标遍历查询是不会有问题的,但是在新增分片或者分片异常后恢复时,因为需要数据迁移,这个时候大集合就会成为性能瓶颈。
说到这里,细心的朋友可能就要问了,迁移一个大集合对象和迁移很多个小集合对象有什么区别呢。
实际上,Master 节点会在缓冲区中维护 Slave 节点上次同步的偏移量,当缓存区快满时,它会清空之前保存的偏移量,下次增量同步是如果找不到偏移量的话,增量同步就会变成全量同步,而全量同步很容易导致服务阻塞。
到这来,不难发现 Redis 每种对象类型都至少有两种编码结构,这样做的好处是接口和实现分离,可以根据不同的场景切换内部编码,这种技巧在开源框架中非常常见。
比如网络框架 Netty 中就会判断用户使用的 JDK 版本,如果是最新版本,则使用性能更好的实现,从而提高并发能力和锁的速度。
public static LongCounter newLongCounter() {//判断 JDK 是否为 1.8 之后的版本if(javaVersion() >= 8){//使用性能更好的实现return new LongAdderCounter();}else{return new AtomicLongCounter();}}
了解了这些对象类型的内部编码结构和使用技巧后,日常使用基本上不会有太大的问题了。但是大家还是要注意一写非功能性的使用,比如最大连接数的设置、超时时间的设置,最后还要清楚地知道 Redis 常见警告的原因,这样才能万无一失。
Redis 常见告警分析
接下来将对一些常见的警告进行分析
。如果告警表现是入流量过大的话,可能是从主同步在拉取数据,出现这种告警的话还得继续留意在主从同步时延,避免产生大范围的数据不一致。
入流量过大相对的是出流量过大,出流量过大时就危险了,基本上是某个特定的 key 被频繁地访问,也就是大家常说的单分片热 key 现象。
说直白点就是某个连接被刷新了或者某个商品正在进行抢购,这种问题导致集群无法发挥多片抗读的作用。那说到单片热 key 问题,可以使用服务实例的本地缓存来解决,而且这种通用解决方案都会下沉到公司缓存组件中,通过控制台可以轻松启用这个功能,不需要每个业务方重复造轮子。
本地缓存还有一个使用技巧是高并发场景的业务计算中,将计算结果存储在本地缓存,在定期将结果持久化到后端存储中,从而实现数据的最终一致性。
另外一种表现是连接数过高,这种问题很有可能是请求超时然后重试导致的,同时还可能会伴随着 CPU 使用过高的出现。
最后一种常见的告警表现是内存使用率过高,包括只是单个分片的内存使用率过高的情况,碰到这种告警,只能删除数据或者请求助运维扩容了。
为什么会出现只是单个分片内存使用率过高的情况呢?其实这是数据的分区度不够导致的,因为 Redis 会将每一个数据进行哈希映射到槽位上,每个节点都会负责一部分的槽位,这样就能够把数据尽量分配到集群的每一个节点了。
案例
项目中利用缓存是碰到的问题,公司内部有一个在数据库上层的缓存代理,使用它后无需关心缓存和数据库不一致的问题,也不用关心热点缓存的问题,另外他支持大部分原生数据库的操作,但是不支持批量写入,但系统将某个对象的一批数据依次写入时,发现服务的相应时长突然增大了,甚至会出现部分数据丢失的情况。
仔细一想,这个组件肯定是使用了布隆过滤器来避免缓存穿透和保证数据的强一致性,这样就导致相同对象的一批数据需要依次排队处理,超过时间设置过短的话,对尾的数据就有肯能被抛弃掉,所以出现了这样的问题。
总结
先从 Redis 的单线程模型引出了分享,接着介绍了它的对象类型和内部编码,同时给出了利用内部编码特点进行方案设计的例子,最后为了让大家掌握 Redis,分析了四种常见告警问题,包括入流量过大、出流量过大、连接数过高和内存使用率过高,在出流量过大中着重强调了单片热 key 的危害和解决办法。