RocksDB 在 vivo 消息推送系统中的实践

一、消息推背景
在消息推送系统中,送系业务方服务器通过调用推送接口向 VPUSH 服务发送消息 ,统中请求中会包含一个 registerId,消息推用于标识接收该消息的送系用户设备。当 VPUSH 服务接收到推送请求时 ,统中会使用 registerId 找到对应的消息推用户 ,并将消息推送给其手机 。送系然而,统中在 VPUSH 内部处理消息推送时,消息推需要使用一个内部标识符 ClientId 来标识每个用户的送系设备,可以通过 ClientId 查找到对应的统中设备信息 。
因此 ,免费模板消息推系统中引入了一个映射存储服务 MappingTranformServer(后文简称MT),送系用于处理 registerId 和 ClientId 之间的统中转换 。MT 服务缓存了所有用户的设备标识符 ,使用 RocksDB 作为底层存储引擎 ,RocksDB 可以提供高并发读写能力,以磁盘作为存储介质,节省存储成本。当 VPUSH 服务需要将 registerId 转换为 ClientId 时 ,会向 MT 服务发起查询请求 ,MT 服务根据请求中的 registerId 查找对应的 ClientId 并返回,这样系统下游节点就能够通过 ClientId 找到对应的设备,建站模板并将消息推送到用户手机上了。
系统中除了 registerId 以外 ,还有许多其他的标识符,所以引入了 ClientId 来降低后期维护和开发的成本 。由于regId 比较有代表性,下文中主要会以 regId 进行举例讲解 。

二、RocksDB 原理介绍
在介绍业务场景之前 ,先简单介绍一下RocksDB的基本原理 。RocksDB的前身是LevelDB,由于LevelDB不支持高并发写入,源码库Facebook(Meta)针对于LevelDB的一些痛点进行了改造,便有了RocksDB 。RocksDB相比于LevelDB,其支持高并发读写,优化了SST文件布局 ,提供了多种压缩策略 ,总的来说 ,RocksDB 在继承了 LevelDB 的全部功能的基础之上 ,还针对内存和磁盘数据存储进行了优化,使得 RocksDB 具有更高的吞吐量和更低的延迟 ,源码下载更适合分布式 、高可靠性的存储场景 。行业内也有许多数据库将RocksDB作为底层的存储引擎 ,比如 TiDB 。
2.1 LSM设计思想在介绍RocksDB的架构和原理之前,先来了解一下其设计思想:LSM 。
LSM全称为log-structured merge-tree 。LSM并非一种数据结构 ,而是一种设计思想 ,最根本的目的就是模板下载要规避对磁盘的随机写入问题 ,提升写的效率 。其思路如下:
写入顺序:从内存到磁盘
将数据先写入到内存中。随着内存存储数据越来越多 ,达到内存阈值 ,则会将内存中的数据转移到磁盘中。磁盘中数据也分为多层,其中L0层的数据最热,而最冷的数据分布在Ln层 ,且会定期进行合并操作。LSM 在 RocksDB 设计中的服务器租用体现:
在写入数据的时候,同时记录操作日志 。因为内存具有易失性,当程序崩溃后,内存的数据就丢失了,记录日志用于在程序崩溃或者重启时,内存的数据不会丢失 。磁盘中的数据并非使用了整体索引结构 ,而是使用了有序的文件集合结构 。每次将内存中的数据写入到磁盘中或者将磁盘中的数据进行合并时,都会生成新的文件,这一次生成的文件会作为一个层,磁盘上会划分多层,层与层之间相互隔离 ,并且有序,有序保证了查找数据时可以使用二分查找。磁盘文件层级如下图所示 。数据按照key进行字典序排序。由上述可知 ,数据从内存写入磁盘时 ,会不断生成新的文件 ,所以需要不断对磁盘中的文件进行合并,然而如果数据乱序,便无法做到高效合并且保持有序 。
当然 ,LSM也存在一些读放大 、写放大 、空间放大的问题:
【读放大】:读取的时候需要从内存一直寻找到磁盘中【写放大】:程序写入数据一次 ,系统要写多次(例如 :内存一次 、磁盘一次)【空间放大】:一份数据在系统中多个地方存在 ,占用了更多空间2.2 内部结构了解完LSM后,可以仔细剖析一下 RocksbDB 的内部结构,下图是 RocksDB 的内部结构图。
RocksDB 中会分出 ColumnFamily(列族 ,一系列 kv 组成的数据集,可以理解为就是一个 namespace),所有的读写操作都需指定 ColumnFamily ,每个 ColumnFamily 主要由三部分组成,分别是 memtable/sstfile/wal。
memtable 是内存文件数据 ,新写入的数据会先进入到 memtable 中,当 memtable 内存空间写满后 ,会有一部分老数据被转移到 sstfile 中 。sstfile 便是磁盘中的持久化文件 。所有 ColumnFamily 都会共享 WAL(write-ahead-log) 日志文件。
(1)内存部分
① memtable
也称为active memtable 。热点数据均存在这块内存中,用于快速返回用户的读写请求。一旦memtable被写满之后,就会被转为immutable memtable ,并生成一个新的active memtable来提供服务。memtable支持多种结构:
skipList/vector/hashLinkList 。写入数据时通过对key进行字典序排序 ,保持有序 。跳跃表的查找速度可以简单理解近似二分查找log(n) 。跳表结构如下图所示:

② immutable memtable
是由于memtable写满后 ,转换而来 ,只提供读 ,不能做修改 。当系统中触发flush时,就会将同一个ColumnFamily中的immutable memtable进行合并 ,生成一个sst file放入磁盘中 ,位于磁盘的L0层 。
(2)磁盘部分
① sst,全称为sorted sequence table
是存储在磁盘中的持久化数据。sst中也有多种格式 ,默认设置为BlockBasedTable。其是根据data block来进行归类存储的。block中还分为data block数据块 ,meta block元数据块 ,footer块尾 。每块的k-v都是有序的。data block也有缓存 ,名为block cache。顾名思义用于缓存SST文件中的热点数据到内存中,提供高速的读服务,所有ColumnFamily中都共用一块block cache 。block cache可以设置两种数据结构:LRU cache和Clock cache 。
② WAL ,全称为write ahead log。
WAL会把所有写操作保存到磁盘中,当程序发生崩溃时 ,可以利用WAL重新构建memtable。如果容忍一定数量数据丢失,也可以关闭WAL来提升写入的性能 。
③ Manifest
该文件主要用于持久化整个LSM的信息。RocksDB需要将LSM树的信息保存于内存中,以便快速进行查找或者对sst进行compaction压缩合并。而RocksDB也会将这些信息持久化到磁盘中,这个就是Manifest文件 。其主要内容便是事务性相关日志以及RocksDB状态的变化。当RocksDB崩溃后重启时 ,就会先读取Manifest文件对LSM进行重建,再根据WAL对内存memtable进行恢复 。
2.3 写入数据流程了解完RocksDB的内部结构,我们来分析一下 RocksDB 的写入流程如下 :

写入流程:
将数据写入 memtable 的同时也会写 WAL(write-ahead-log)当 memtable 达到一定阈值后,会将数据迁移到 immutable memtable,其中,immutable 中的数据只能读不能写之后 flush 线程会负责将 immutable 中的数据持久化到磁盘中 ,即 SST file(L0层)compaction 线程会触发 compaction 操作将 L0 的 sst file 合并到 L1-Ln 层中。所有的 sst file 都是只读不写2.4 读取数据流程同样地 ,还有读取流程 ,RocksDB 的读取流程如下:

简而言之 ,读流程基于内存到磁盘的顺序 ,逐层进行查找。
下图为读取过程中所经历的一些数据对象:
列族指针ColumnFamilyHandle指向了列族对象ColumnFamily ,列族对象中存放有列族相关的数据:ColumnFamilyData 。ColumnFamilyData 中关键的数据为 SuperVersion ,SuperVersion 为当前最新版本的数据集 ,内部维护了内存的memtable和immutable memtable的指针以及磁盘数据的指针。
读取细节如下图所示 :
数据读取的入口为DBImpl的Get方法 ,通过该入口,先在内存中的MemTable进行遍历。图中的MemTableResp为MemTable的具体实现。当在MemTable中没有读取到数据时,便会到MemTableListVersion中进行读取,MemTableListVersion 内部存放着多个 immutable memtable。当内存中读取不到数据时 ,便会到磁盘中读取,也就是Version类。Version中FilePicker逐层读取文件 ,每次读取到文件时 ,先查看TableCache,TableCache维护了SST读取器的信息 ,方便快速查找 。如果在TableCache中没找到相关的信息 ,便会执行FindTable,并将读取到读取器放入到TableCache中 ,方便下次查找。最后 ,通过读取器对SST进行遍历查找 。
RocksDB 通过在写入数据时先存入内存来保证写入高性能 ,内存写满后便会将内存的数据转移到磁盘,写入磁盘时保持 key 有序来提升磁盘查询的效率(类似于二分查找) ,并且对磁盘中的数据进行分层,热点数据所在的层级越低 ,冷数据存储的层级越高 。
三、业务场景介绍
简单了解了 RocksDB 后,来看下具体的一些业务实践场景 。
目前 ,registerId 与 clientId 的映射数量约为数百亿,每个应用为每个用户分配一个 registerId ,但每个用户只有一个 clientId ,因此,registerId 到 clientId 的映射是多对一的关系。这些数据都存储在RocksDB中 。

为了做到服务的高并发、高可用,每个应用的缓存以多副本的形式分散在多台 MT 服务器中,形成多对多的关系 。例如,MT1 、MT2 和 MT3 中均缓存了app1的全量数据 ,app2 的全量数据则存放于 MT2 和 MT4 中 ,如下图所示 :
图片
消息推送时,MT的上游服务会根据推送请求内的appId寻址到MT服务器完成映射的转换。
此时 ,读者可能会想到 ,不少系统使用 Redis 作为缓存服务,它似乎也可以完成这样的任务,为什么还需要开发一个专门的映射服务 ?
实际上 ,主要有以下几个原因 :
成本问题:作为一种磁盘键值(KV)存储引擎 ,RocksDB 相比 Redis 更具有成本优势 ,可以有效降低存储成本。容灾问题