写点什么

喜马拉雅 KV 存储演进之路

  • 2023-05-19
    北京
  • 本文字数:6114 字

    阅读完需:约 20 分钟

喜马拉雅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 与缓存的发展相结合。

 

2023-05-19 18:575591

评论 4 条评论

发布
用户头像
如何保证持久性

如果 value 大于设定的阈值,则将 value 写到 blob 文件中,然后再将 key 和 value 在 blob 文件中的索引写到 sst 文件中

2023-05-22 12:00 · 湖北
回复
在做kv分离之前,数据还是会先写WAL,再写memtable,通过WAL来持久化内存中数据,这个逻辑和原来rocksdb一致。flush或compact时,如果需要分离,则先将value写到blob文件中做value的持久化,再将key和value对应的索引写到sst文件中做key的持久化
2023-05-22 22:37 · 上海
回复
用户头像
LSM-tree架构下为啥不直接用机械硬盘

大大缩短 SSD 的使用寿命。因为 SSD 不支持覆盖写

2023-05-22 11:56 · 湖北
回复
主要还是考虑性能问题,机械硬盘的读写速度比NVMe还是差很多的,xcache是当缓存用的,业务对QPS和时延要求都非常高
2023-05-22 22:39 · 上海
回复
没有更多了
发现更多内容

产品经理训练营学习总结

新盛

2021团体程序设计天梯赛总结

玄兴梦影

算法 总结 比赛

亲爱的开发者,您收到一个启动智能世界的魔方

脑极体

30亿参数,华为云发布全球最大预训练模型,开启工业化AI开发新模式

华为云开发者联盟

AI nlp 华为云 盘古 预训练模型

区块链电子发票的多维创新与变革效应

CECBC

电子税务

小白也能看懂的操作系统之内存

程序猿阿星

操作系统 内存 内存管理 内存优化

第八次课程总结

小匚

产品经理训练营

换一个角度,看华为云的变化,云产业的更迭

脑极体

kafka 可视化工具_6个重要维度 | 帮你快速了解这9款免费etl调度工具的应用

敏捷调度TASKCTL

大数据 kafka kettle 调度式分布 ETL

使用 Go 实现一个简单的 k-v 数据库

roseduan

数据库 Go 语言 KV存储引擎

区块链链接能源:到底是乌托邦愿景还是未来蓝图?

CECBC

能源

Spring优缺点

风翱

spring 4月日更

HashMap 源码分析

小方

Java HashMap底层原理

Python爬虫:BeatifulSoap解析HTML报文的三个实用技巧

老猿Python

Python 爬虫 编程语言 BeatifulSoap Html报文解析

图神经网络在生化医疗方面的相关应用

博文视点Broadview

Golang 常见架构模式

escray

学习 极客时间 Go 语言 4月日更

网络协议学习笔记 Day4

穿过生命散发芬芳

网络协议 4月日更

ConcurrentHashMap 源码分析

小方

ConcurrentHashMap

“拼多多”值得我们学习

小天同学

思考 拼多多 自我感悟 4月日更

业务需求与系统功能,你分清楚了吗?

BY林子

测试用例 业务需求 测试设计 业务价值

如何运用“区块链”,让档案数据管理更安全

CECBC

数据安全

产品文档和原型怎么弄?——课堂笔记

Deborah

Python OOP-3

若尘

oop Python编程

实战来了!Spring Boot+Redis 分布式锁模拟抢单!

Java小咖秀

redis 分布式 分布式锁 springboot 抢单

前端架构演进 - 从单体到微前端(理论篇)

Teobler

大前端 架构演进

话题讨论|华为云再报大动作,云厂商未来战场在哪里?

程序员架构进阶

话题讨论 28天写作 4月日更

MySQL角色(role)功能介绍

Simon

MySQL

6大新品重磅发布,华为云全栈云原生技术能力持续创新升级

华为云开发者联盟

华为云 CloudIDE GaussDB(for openGauss) 沃土云创计划 可信智能计算服务TICS

多场景实时音视频通信激增背后,RTC 技术大爆发

融云 RongCloud

Spring 实战:通过 BeanPostProcessor 动态注入 ID 生成器

看山

Spring实战

工作三年,小胖连 Redis 持久化都不知道?真丢人!

一个优秀的废人

redis 持久化 aof rdb

喜马拉雅KV存储演进之路_文化 & 方法_喜马拉雅技术团队_InfoQ精选文章