2.1.1 Redis & Memcached缓存型数据库
任何一个事物都不会凭空出现,缓存型数据库也不例外。随着互联网技术的普及,静态网页越来越少,大部分动态网站都需要挂载数据库才能完成交互。传统的关系型数据库,经过了轻量级MySQL挑战重量级Oracle/SQL Server的时代,大家认识到,易用、简单的数据库已经足以支撑起自己的站点。甚至,某些站点或某些应用的业务逻辑非常简单,根本不需要复杂的SQL语句,瓶颈往往是峰值流量,而这种场景对MySQL并不友好。很多新兴行业的出现,比如直播、游戏等,大家急需要一种极简、易用的数据库,帮助动态请求交互。这一切,就是以Redis & Memcached为代表的缓存型数据库出现的历史背景。
从历史上说,Memcached出现得更早,大约在2003年就有了首个公开版本,它的开发者是前Google著名的程序员Brad Fitzpatrick,他曾经也是Golang项目组成员之一。Redis的出现则晚一些,2009年,由来自意大利的开发者(网名antirez)开发,现在由Redis Labs维护。
网上关于Redis和Memcached谁更优秀的讨论,比比皆是。Redis与Memcached最重要的区别在于,Redis提供了更丰富的Value类型,并且提供了持久化和数据复制的能力。从结果来看,Redis正在逐步取代Memcached,阿里云甚至开始使用Redis兼容Memcached协议,以保证一些老的应用依然可以使用Memcached服务。
2.1.1.1 Redis单线程模型的实现方式
几乎所有的数据库(包括Memcached)都是使用多进程或多线程的方式,来实现并发处理数据库请求的,但Redis最出名的,就是以单线程模型扛起了数以万计的请求。以阿里云Redis为例,一个Redis节点便能够扛起8万QPS,企业版TairDB更是能扛起10万QPS。为什么Redis的单线程这么厉害?
这其中有两个地方,决定了Redis能够按单线程处理。
第一,Redis的命令,并不像SQL一样有非常长的谓词判断逻辑、表连接逻辑,动辄10行、20行的SQL命令,在Redis命令中最多就是几个option,解析器、优化器的处理难度大幅度降低。而且Redis的存储结构全部是key-value格式的,没有二维表的众多主外键约束、索引冗余空间,在原生设计上就极简。
第二,Redis的类I/O操作全部是异步的。这也决定了执行器的链路被大大缩短,不再需要由主进程跟进存储引擎(这里也包括对内存的I/O)。Redis的类I/O操作全部丢给epoll来处理。Redis 6.0 Beta提出的三线程模型,即TairDB 5.0增强性能版本,都是在保留经典的单工作线程模型的情况下,使用多线程epoll来做好响应和接待的,如图2-1所示。
图2-1 Redis主线程与I/O线程
而在主进程内收到的并发请求命令,会按照时间戳进行拆分,串行地用单线程处理。换句话说,Redis通过拆细时间片,把大量并发请求编排出串行。
这个模型的瓶颈也是显而易见的,即:一旦有任何一个命令处理慢了,比如keys命令或者上锁的命令(如blpop),则会导致主进程卡顿,请求出现排队。所以说Redis的慢请求,其影响程度要远超过多线程模型的数据库。
Redis社区,从Redis 2.8到6.0版本都在不断地迭代,其中一个核心提高点,就是提高慢请求的速度,比如使用hgetall、zrange等命令。甚至,为了避免超大Hash,还推出了Bloom Filter(布隆过滤器)。
因为极简、易用的特点设计,Redis基本不写日志,在server log中只会记录一些关键任务,比如AOF的相关操作、启停等,所以对诊断和排查有较大的挑战。
2.1.1.2 Redis持久化机制
Redis的持久化主要依赖两个方面,即:类似于镜像技术的RDB和类似于逻辑日志的AOF(Apend Only File)。AOF承担了Redis主从复制的主要任务。
我们一般将AOF翻译为“追加式文件”,即Redis会持续地将key的变更操作追加写入文件内。随着时间的推移,这个文件会不断地增长。并且AOF文件用于恢复时,实际上是将文件内记录的key操作顺序重放一遍,当AOF文件中记录的冗余操作非常多(如某个key写入后发生了大量的变更,或者某些key当前已被删除或过期)时,Redis需要将这些冗余的操作“不厌其烦”地重新执行一次,即便单次命令操作得很快(μs级),当需要重放的操作数量级很大时,恢复的整体时间也会超出我们的承受范围。只有尽量减少AOF文件中不必要的冗余操作,降低文件大小,保证其恢复时间可控,AOF持久化才有其存在的意义,AOF ReWrite机制因此被设计出来以解决问题。
AOF ReWrite主要分为ReWrite(阻塞工作线程)和BGReWrite(不阻塞工作线程)两种。由于前者在生产环境中使用率极低,因此这里主要介绍后者的实现细节,如图2-2所示。
图2-2 AOF BGReWrite过程
说明
图中①标注的阶段,由于需要额外的内存区缓存子线程diff_from_parent的增量日志内容,当业务写操作QPS非常高时,这个内存开销会比较大。
图中②标注的阶段,由于需要短暂阻塞业务请求,阻塞时间一般受业务写请求的QPS和磁盘I/O影响,当业务写操作QPS非常高或I/O性能不理想时,可能会对业务造成较明显的影响。
当出现上述两种场景问题时,对Redis Server进行增加内存、使用性能更好的SSD存储等垂直扩展操作,往往较难线性地达到理想的预期效果,此时水平拆分(即Redis集群化拆分)也是一个不错的选择。
2.1.1.3 Redis集群的实现原理
基于前面介绍的Redis线程模型可知,Redis的扩展性主要体现在如下两个方面。
第一,垂直扩展。在单机环境中扩展Redis的内存,使它能存储更多的数据。但存在QPS瓶颈,因为单线程模型有固定的QPS上限。
还有一种思路是读/写分离,即扩展Redis的只读节点。这种读/写分离的场景,虽然不能提高写入的QPS水平,但是能针对热点key,进行热点只读流量的对冲。毕竟选用Redis的场景,应该是多读少写的,这才符合缓存的设计要求。
第二,水平扩展,既能扩展内存,又能扩展计算节点。其中最流行的两种水平扩展方案是社区版Redis Cluster和阿里云选用的Redis Sharding。
社区版Redis Cluster采用的是去中心化的集群,由节点自己去协商。假如请求在A节点上,而数据在其他节点上,则会由A节点去请求路由其他节点。但是其间可能会遇到重新分片(Reshard)的情况,所以在使用上有些麻烦。社区版Redis Cluster架构如图2-3所示。
图2-3 社区版Redis Cluster架构
阿里云采用的是类似于Codis(但不支持codis命令)的Sharding设计,如图2-4所示。数据被Hash计算后,平均分布到各个Shard上,每个Shard上的key的数量近似一致。其带来的好处是学习成本非常低,这个分布对于前台应用完全是透明的,且分散比较均匀,各个节点的压力也比较均衡。
图2-4 阿里云Redis Proxy透明集群结构
但是因为分片的原则是希望节点上的key数量一致,所以如果有大key(即存储空间比较大的key)存在,则会打破这个平衡,导致某个Shard上的内存开销比较大。因此,在分片集群的使用中,需要注意规避大key,把大key拆小。
2.1.1.4 Redis缓存空间管理
Redis本质上是基于内存的缓存存储,这决定了它的空间容量往往有明显的局限性。同时由于缓存具有生命周期短、快速迭代的特性,如何有效地管理缓存的生命周期并建立有效的清理机制,以避免缓存击穿,是内核设计中需要考虑的首要问题。
1. 生命周期管理
Redis提供了EXPIRE(TTL秒级)、PEXPIRE(TTL毫秒级)、EXPIREAT(指定TTL至秒级时间戳)、PEXPIREAT(指定TTL至毫秒级时间戳)等命令,用于设置一个key的生命周期。
2. 过期清理机制
对于超出生命周期的key,一般被称为过期key。对于过期key,常见的清理策略有如下三种。
立即清理:key过期后立即清理,CPU开销较大。
惰性清理:从不主动清理,只有过期key被请求到时才触发清理,内存开销较大。
定时清理:按固定频率扫描并清理,清理效率和资源开销都处于前两种策略之间。
由于Redis单线程的特性,其进程大部分CPU时间都用于处理业务请求,选择立即清理策略会占用较多的CPU时间,对其高并发性能有明显的影响;而Redis的内存空间限制也决定了惰性清理策略不够友好,可见,能够利用较少的CPU时间尽可能多地清理掉过期key的清理机制才是最适合Redis的。Redis内核最终选择了定时清理+惰性清理的组合策略来实现对过期key的清理。
Redis内核会在CPU空闲时随机从数据库内选择一定数量有生命周期的key,并清理掉已过期的key,如果已过期的key占比超过25%,则会再进行一轮key的选择和清理,单次清理动作最多重复4轮;清理动作的触发频率可以通过设置参数hz的值来调整,但不建议超过100。
从定期清理策略可以看出,Redis的过期key一般较难准确地彻底清理。如果内存水位高需要较为彻底的清理,则可以基于惰性清理策略,使用scan等命令分批全量扫描所有key,扫描到的key会被清理掉。
3. 满内存逐出机制
为了避免内存满业务不可用或内存溢出,Redis提供了这样的功能:当内存满时,如果有新的写入操作,则按照一定的策略清理缓存释放内存空间。这个功能可以通过设置参数maxmemory-policy来实现,对应的策略及其说明如表2-1所示。
表1-5 Redis满内存逐出策略及其说明
2.1.1.5 Redis主从复制
一个成熟完备的数据库需要具备高可用的主从复制能力,以应对宕机、灾备等风险场景,Redis同样提供了主从复制能力。
在讲解Redis主从复制过程前,我们需要先了解一下Redis的复制缓冲区(REPL_BACKLOG)。在默认情况下,REPL_BACKLOG是一个1MB大小的先进先出定长队列,在Master节点上增量操作会被顺序记录到这个backlog中,当队列写满时后续记录会逐步推出之前的记录
说明
我们可以形象地将复制缓冲区比喻为羽毛球筒,在一个球筒只能放入10个球且已放满的情况下,塞入新的球,就会把最早放进去的球从另一侧顶出,由此可以理解为repl_backlog_first_byte_offset就是目前球筒里最早放进去的那个球,master_repl_offset是球筒里最后放进去的那个球,repl_backlog_histlen是球筒里放入的球的数量。
Redis主从复制过程如下:
①Redis主从复制环境搭建后,由于Slave节点初始并没有数据,因此是在Master节点上执行bg save命令生成全量RDB备份并传输到Slave节点恢复的,同时记录了初始Offset(偏移位点)。
②Slave节点上的RDB恢复完成后,它拿着初始Offset向Master节点请求后续数据,Master节点检查REPL_BACKLOG,发现这个Offset还存在,于是将下一个Offset的操作发给Slave节点,Slave节点追加完成后更新Offset并继续请求下一个Offset的操作(这个过程就是部分重同步)。如此循环,直至主从数据库同步。
③假如主从数据库之间的连接中断一段时间,恢复后Slave节点会用自身最后一次成功应用的Offset向Master节点请求数据,此时Master节点检查Offset,如果发现它还在REPL_BACKLOG队列中,则按照步骤②循环;如果发现它已经不在队列中了,则新建主从链路,回到步骤①,从RDB开始重新传输。
最后,随着非易失性内存的普及,以及PMem的上线,阿里云已经拥有了AEP机型的Redis,从而解决了之前断电内存数据失效的痛点,Redis的泛用性可见地增强了。