《架构师》2023年6月
上QQ阅读APP看本书,新人免费读10天
设备和账号都新为新人

案例研究|Case Study

喜马拉雅KV存储演进之路

作者 喜马拉雅技术团队 董道光 策划 褚杏娟

KV存储喜马拉雅最重要的基础组件之一,每天承载着千亿级的请求量。面对公司成百上千的业务,我们怎样满足不同用户对不同场景下的KV存储需求?我们又是如何做到服务的高可用,为业务的稳定性保驾护航的?下面,我会给大家介绍下喜马拉雅的KV存储演进之路,主要内容包括:

• 喜马拉雅KV存储的演进历史

• 如何用自研+社区的方式做自己的缓存系统

• 该系统的运行原理及未来规划

喜马拉雅KV存储发展历程

Redis主从模式

和早期的大多数互联网公司一样,喜马拉雅刚开始的缓存就是简单的主从架构模式,客户端通过vip连接到master节点,master节点挂掉时,漂移vip到slave机器上来保证高可用。这种架构的优点是部署流程简单、易维护,但缺点也很明显,就是QPS和容量受限,因为单个redis的QPS一般不超过10w,数据量一般需控制在10GB内(数据量大,故障重启比较耗时)。

针对上面的问题,大家肯定都想到了既然一个redis不行,那就用多个redis一起扛,类似mysql的分库分表,于是之后就有了客户端sharding模式的架构,即在客户端对key做hash取模,然后打到后端不同的redis节点上。这种架构的优点是可以解决QPS容量受限的问题,但缺点也很致命:无法做到弹性扩容,并且增加删除节点时,客户端代码也要跟着一起改动,非常不方便。严格意义上来说,这种架构根本不能算是集群解决方案。

那么,针对上述问题,业界有没有比较好的解决方案呢?答案是有的。

集群模式选型

2016年时,redis的集群解决方案还不是很多,我们调研了当时业界比较流行的三种解决方案:

Redis Cluster:优点是官方正版,去中心化,组件少,部署简单;缺点是系统高度耦合,升级困难,缺少大规模生产环境验证(当时cluster刚出来没多久)。

Twemproxy:推特开源,proxy代理,无法平滑扩容缩容,运维不够友好。

Codis Redis:豌豆荚开源,proxy代理,兼容Twemproxy,性能优于Twemproxy。平滑扩容缩容,可视化管理界面。产品成熟,很多公司已经在生产环境使用。

基于上述分析,我们最终选择了Codis Redis作为Redis的集群解决方案。下面就让我们一起来了解下Codis Redis。

Codis Redis

Codis Redis使用Proxy做代理,后端连接多个Redis分片,客户端连接proxy,当proxy接收到命令时,会对key做CRC32取模,然后打到后端不同的Redis分片上。Codis Redis还使用ZooKeeper做服务发现,当集群中新增一个proxy时,会自动注册到ZooKeeper上,Jodis客户端会监听节点新增事件,然后更新proxy列表。Codis Redis自带web管理页面Codis-fe,并且支持对sentinel哨兵(高可用组件)的管理,所以在当时来看还是非常好用的。

那么,Codis Redis如何实现弹性扩容呢?Codis将所有key分配到1024个slot中,每个分片负责一批slot。如下图,当集群只有2个分片时,group-1负责0~511的slot,group-2负责512~1023的slot。

当集群需要从2个分片扩容到4个分片时,codis首先将group-1中256~511的slot数据迁移到group-3上,然后修改proxy的slot映射表,将256~511的slot后端节点修改成group 3,这样就可以做到平滑扩容。

我们看到Codis Redis既解决了单Redis QPS容量受限的问题,又解决了Sharding Redis无法弹性扩容的问题。那么Codis Redis是否就满足了我们所有缓存的使用场景呢?下面,我们看看Codis Redis存在的一些问题:

1. 数据全部存储在内存中,消耗大量内存;

2. 业务数据规模较大时,redis实例较多,运维成本高;

3. 实例重启需要加载数据,故障恢复时间长;

4. 一主多从,主从切换代价大。

结论就是Codis Redis并不适合数据量大、但对延时要求不高的业务。那么,针对上述问题,业界有没有比较好的解决方案呢?答案是有的。

Codis Pika

什么是Pika?Pika是360开源的一款类Redis的持久化KV存储系统,完全兼容Redis协议,兼容string、hash、list、zset、set的绝大多数接口。最重要的一点是,Pika使用磁盘存储数据,突破了Redis的内存容量限制,非常适合业务数据量大,但对延时要求不是很高的业务。

我们在Pika上定制了类似Codis Redis的数据迁移接口,这样就以最小的代价支持了Pika的集群模式。如下图:

但是,我们在使用Codis Pika上也遇到了很多的问题:

1. 数据量较大时,读磁盘经常出现延时抖动;

2. 底层数据compact时,IO高导致延时毛刺;

3. 基于key的数据迁移,扩缩容太慢;

4. 数据存在磁盘,机器内存利用率低;

5. 监控不够完善,定位问题困难。

那么,针对上述问题,业界有没有比较好的解决方案呢?抱歉,这次还真没有。

我们知道,随着公司的快速发展,业务场景的复杂度也会越来越高,很难再有合适的开源产品能很好满足我们的需求。所以,我们最终决定通过自研+社区的方式开发自己的缓存系统:XCache。

我们对XCache定位就是要实现容量大、高吞吐、低延时、高可用、运维完善的目标。下面就让我们一起来了解下XCache是如何实现这些目标的。

XCache架构和实践

冷热数据分离

为什么要做冷热数据分离?

Pika底层使用的是RocksDB,数据都是存储在磁盘上,这导致机器内存有很大的浪费。另外,Pika的复杂数据类型性能比较差,读命令经常会出现延时抖动,尤其是range查询。

那么该如何更好的利用机器内存来提升Pika的性能呢?我们想到的方案就是做冷热数据分离:将热数据缓存在内存中,冷数据存储在磁盘上,业务热数据直接查内存返回,大大降低命令响应时间。

可能有的小伙伴会问,RocksDB本身不是已经用block cache来提高读性能了吗,为什么还要用Redis再做一层缓存,是不是多此一举?我想说的是,RocksDB自带的缓存粒度相对来说比较粗糙,使用Redis可以对热数据做更精细化的管控。

如何做冷热数据分离?

那么,热数据缓存该如何加?我们当时有两种解决方案。第一种方案是在Pika的上层再加一层Redis,如下图。这样的好处就是开发比较简单,对Pika几乎没有侵入性改动,但缺点也很明显,就是组件太多。Pika是多线程的,我们测试发现,如果要把单个Pika性能打满,前面必须要挂多个Redis,这就导致了运维成本的增加。另外,多了一层网络传输,就会有一定的性能损耗。

于是我们想到了第二种解决方案,如下图,就是将Redis以lib库的方式嵌入到Pika当中,这需要移植Redis代码,并且对Pika做深度定制,开发量比较大。但优点也很明显,就是Pika的热数据缓存在外界看来就是完全透明的,并且集群架构也不需要做任何改动。所以我们最终选择了该方案。

我们在做性能测试时发现,缓存命中的情况下,读的吞吐量相比Pika可以提升1倍,复杂数据类型的TP100延时降低了90%以上(Pika的复杂数据类型性能比较差)。

KV分离存储

为什么要做KV分离存储?

RocksDB的存储引擎采用的是LSM-tree架构,这种存储引擎有个缺点,就是存在写放大的问题。写放大就是RocksDB为了控制每层数据大小以及删除过期数据,会进行compact操作,因此导致大量的key和value被多次重写,当value很大时,写放大的问题会更加明显。写放大会通常会带来以下问题:

1. compact时磁盘IO过高,读写命令产生延时,极端情况下,会导致flush速度变慢。当写入速度大于flush速度时,有可能触发rocksdb的Write Stall,甚至Write Stop,产生秒级别的延时。

2. 大大缩短SSD的使用寿命。因为SSD不支持覆盖写,必须先擦除再写入,而每个SSD block(block是SSD擦除操作的基本单位)的平均擦除次数是有限的。

如何做KV分离存储?

RocksDB写入时先写WAL,然后再写MemTable,当MemTable写满后,会等待后台线程flush到磁盘上,可以在flush的时候做KV分离存储。在flush的过程中检测value的大小,如果小于设定的阈值(比如4KB),就不做分离,将key和value都写到sst文件中。如果value大于设定的阈值,则将value写到blob文件中,然后再将key和value在blob文件中的索引写到sst文件中。当需要读大value时,会先查sst文件,然后再通过索引找到对应的value。如下图:

KV分离存储后,LSM-tree的体积会非常小,因为只存了key和索引。每次compact只需要重写key和索引,索引长度是固定的,key一般来说也都比较小,这样重写的数据量就会大大降低。

但是,KV分离存储需要解决另外一个问题,就是如何清理blob文件中的垃圾数据。sst文件通过compact机制清理,那么blob文件也需要有自己的GC机制。我们当时参考了Tikv的存储引擎Titan的设计思路,在compact时候触发blob的GC。但在线上环境测试中,我们发现还是存在很多问题:

1. GC速度很慢,导致数据量持续上涨。GC任务依赖compact触发,并且当有GC任务正在执行时,其它的compact触发的GC事件都会被丢弃。这就导致compact了很多次,但GC任务却只执行了一次。我们给出的优化方案是添加GC任务队列,每次compact完后,生成一个GC任务push到GC任务队列中。每次GC完后,判断队列中是否还有GC任务,如果有就继续执行GC任务。

2. GC时会涉及到很多文件的读写,因此会产生大量的磁盘IO。在服务请求高峰期时,如果磁盘IO负载过高,会造成读写请求的延时。我们的优化方案是采用RocksDB自带的限速器,GC时对磁盘读写进行限速,避免大量磁盘IO对在线请求造成影响。

快慢命令分离

为什么要做快慢命令分离?

我们发现线上很多执行本应该很快的命令也会经常超时,原因是早期的Pika线程模型是通过Dispatch线程分发客户端连接请求给worker线程,然后worker线程负责同步处理命令请求。这就带来两个问题:

1. 如果一个客户端的命令阻塞,那么这个worker线程上所有客户端发起的命令都会被阻塞。

2. worker线程负载不均衡。假如有多个客户端,但只有一个大流量的客户端发送命令,那么底层也只有一个worker线程处于高负载状态,其它worker线程则都处于低负载状态,发挥不了Pika多线程的优势。

针对上述问题,其实大家很容易想到使用线程池模型,但光线程池模型也不能完全解决问题。举个例子,假如有一个客户端执行的都是比较耗时的命令(如HGETALL),这时候线程池中的线程还是全都会被耗时的命令阻塞,那么那些执行快的命令也会被阻塞。所以,我们想到的解决方案是采用快慢双线程池模型。

如何做快慢命令分离

如下图,创建两个线程池,快慢命令根据不同业务场景可灵活配置,假设一个用户执行的都是get/set比较快的命令,另一个用户执行的都是类似hgetall很慢的命令,那么两个命令会分发到不同的线程执行,即使hgetlall命令导致执行的线程池阻塞,也完全不会影响get/set命令的响应时间。这样就降低了快慢命令之间的互相影响。

集群秒级扩容

有状态的服务扩容往往都伴随着数据迁移,而数据迁移往往又比较耗时。我们线上的Pika实例数据少则几十GB,多则五六百GB,扩容迁移数据的时间成本非常高。我们线上遇到过集群负载过高、业务超时严重的问题,由于集群本身数据量非常大,无法做到快速扩容,导致业务长时间无法恢复,只能降级。

针对上述问题,我们想到了一个秒级扩容的解决方案。线上的集群分片都是主从两个实例,当集群从2个分片扩到4个分片时,直接将group-1的slave实例转移到group-3,group-2的slave实例转移到group-4,然后修改proxy的slot路由信息,中间不需要迁移任何数据,这样就做到了集群的秒级扩容,如下图:

这种扩容方案是很快,但也有缺点,就是扩容后,所有分片都只有一个实例,存在单点的风险,这个需要根据实际场景做权衡。比起服务完全不可用,这种扩容方案在紧急情况下还是可以救命的。

EHash数据类型

我们有很多在线业务都有个需求就是hash结构的field可以设置过期时间。Pika默认只有key可以设置过期时间,那么如何让filed也支持设置过期时间呢?我们新增了一种ehash数据类型。

我们设计的整体思路是hash对应的每条field记录中加入过期时间,每次获取到field后先判断是否已经过期,如果已经过期则删除该记录并返回空,如果没有过期则返回field对应的value;删除过期field时间时需要将删除操作写入binlog,并传递到slave。下面可以先看下数据结构的存储设计。

原有的hash结构在pika中的存储方式

每个hash表的meta_key和meta_value的落盘方式:

hash表中data_key和data_value的落盘方式:

支持field过期Hash结构的存储

每个hash表的meta_key和meta_value的落盘方式不变:

hash表中data_key和data_value的落盘方式,在原有的data_value前增加过期时间的一个时间戳字段:

这种结构需要注意的一点就是,HLEN命令可能会不精准,因为HLEN命令直接读的meta_key中size,此时有的field可能已经过期了,但meta_key中的size并没有被及时更新。如果要统计出精准的HLEN数量,就只能扫描hash下所有的filed,性能会比较差。

大key大请求检测熔断

大key大请求一直都是导致线上故障的一个顽疾,虽然我们制定了很多缓存使用规范,但还是无法完全避免线上出现大key大请求的情况。针对这种问题,我们的解决方案就是监测+熔断机制。

在proxy层,我们做了大key大请求的检测,在业务请求命令时检测是否存在大key大请求,如string数据类型,业务set或get的value大于monitor_max_value_len则判定为大key;如list数据类型,业务push元素后会返回list的长度,如果大于monitor_max_batchsize也判定为大key;还有像range查询范围大于1000,hgetall查全量的命令则判定为大请求。

当检测到大key大请求后,我们支持配置不同的熔断策略。比如按key熔断,比如,只因为某个大key拖慢了集群,那么可以将这个key添加到黑名单中,后续对该key的访问都会直接返回错误。我们还支持按命令熔断,比如hgetall查全量数据的命令。另外,我们还支持设定熔断比例,比如熔断比例设定为50%,那么业务请求的命令只有一半会被拒绝,这样可以保证集群尽可能在不挂的情况下响应业务请求。

同城多活

随着喜马拉雅的高速发展,我们的业务已经扩大到单个数据中心撑不住、主要机房已经不能再加机器,但业务却不断要求加扩容。所以,我们需要一个方案能够把服务器部署到多个机房。

另外,还有一个更重要的原因是,整个机房级别的故障时有发生,每次都会带来严重的后果。因此,我们需要在发生故障时,能够把一个机房的业务全部迁移到别的机房,保证服务可用。整体架构如下图:

主机房故障切换到备机房流程

我们的架构方案只实现了多活的双读模式,就是主备机房都有读流量,写流量只在主机房,故障时流量切换到备机房也是只保证读流量不受影响,未来我们计划做到同城多活的双写模式。

除了上述讲到的一些特性,XCache还做了很多定制优化,包括性能提升、运维效率提升及高可保障等多个方面,这里就不一一列举了。目前XCache已经在喜马拉雅线上大规模使用,实例数量2500+,数据量120TB+,承载了每天千亿级的业务请求量。

未来发展规划

XCache的未来发展规划主要有以下几个方向:

1. 功能增强。XCache目前还有很多来自业务方以及我们自身的需求,如Lua和事务的支持、数据强一致性、多租户、bulk load离线数据导入、multi get性能优化、热点key检测、静态数据分析等等。

2. 云原生化。随着公司的发展,集群的数量也在持续增长,如果全部依赖人工调度,运维工作也会变得异常繁重。所以我们也想利用云上的弹性调度能力,做到集群的自动化部署,弹性扩缩容。

3. 支持智能化运维。谷歌SRE里有句话我非常认同,就是任何需要人工操作的事情都只会延长恢复时长。所以我们希望未来的运维是以数据驱动,并能根据机器学习和专家经验来自我作出决策,最后根据决策来自动进行任务编排,执行决策。另外,随着ChatGPT的大火,我们也在思考如何将AI与缓存的发展相结合。