存储管理的神奇功能(微信相关应用都在用的存储系统究竟是怎么扛住的)
作者:林枫 & 张根兴来源:云加社区背景:两个十亿级的挑战
PaxosStore 是微信内广泛应用的强一致性的分布式存储系统,它广泛支撑了微信的在线应用,峰值过亿TPS,运行在数千台服务器上,在线服务场景下性能强悍。但在软件开发中没有银弹,在面对离线产出、在线只读的数据场景,PaxosStore 面临了两个新的十亿挑战:
10亿 / 秒 的挑战:
· "看一看"团队需要一个存储系统来存放CTR过程需要用到的模型,实现存储和计算分离,使得推荐模型的大小不会受限于单机内存。
· 每次对文章的排序打分,ctrsvr 会从这个存储系统中拉取成千上万个特征,这些特征需要是相同版本的,PaxosStore 的 BatchGet 不保证相同版本。
· 业务方预估,这个存储系统需要支持10亿/秒的QPS,PaxosStore 的副本数是固定的,无法增加只读副本。
· 这个存储系统需要有版本管理和模型管理的功能,支持历史版本回退。
10亿 / 小时 的挑战:
· 微信内部不少团队反馈,他们需要把10亿级(也就是微信用户的数量级)信息,每天定期写到 PaxosStore 中,但 PaxosStore 的写入速度无法满足要求,有时候甚至一天都写不完,写太快还会影响现网的其他业务。
2. 数据路由
· 考虑扩缩容,FeatureKV 会把一个版本的数据切分为 N 份, N 现在是 2400,通过哈希 HashFun(key) % N 来决定 key 属于那份文件。
· KVSvr 加载哪些文件是由一致性哈希决定的,角色相同的 KVSvr 会加载相同一批在扩缩容的时候,数据腾挪的单位是文件。
· 由于这个一致性哈希只有 2400 个节点,当 2400 不能被 sect 内机器数量整除时,会出现比较明显的负载不均衡的情况。所以 FeatureKV 的 sect 内机器数得能够整除2400。还好 2400 是一个幸运数,它 30 以内的因数包括 1,2,3,4,5,6,8,10,12,15,16,20,24,25,30 ,已经可以满足大部分场景了。
· 上图是 N=6 时候的例子,Part_00[0-5] 表示 6 份数据文件。从 RoleNum=2 扩容成 RoleNum=3 的时候,只需要对 Part_003 和 Part_005 这两份文件进行腾挪,Part_005 从 Role_0迁出至 Role_2,Part_003 从 Role_1 迁出至 Role_2。
· 由于现网所用的 N=2400 ,节点数较少,为了减少每次路由的耗时,我们枚举了 RoleNum<100 && 2400%RoleNum==0 的所有情况,打了一个一致性哈希表。
3. 系统扩展性
· FeatureKV 的 FKV_WFS 上存有当前可用版本的所有数据,所以扩容导致的文件腾挪,只需要新角色的机器从 FKV_WFS 拉取相应编号的文件,旧角色机器的丢弃相应编号的文件即可。
· 当 BatchSize 足够大的时候,一次 BatchGet 的 rpc 数量等价于 Role 数量,这些 rpc 都是并行的。当 Role 数量较大时,这些 rpc 出现最少一个长尾请求的概率就越高,而 BatchGet 的耗时是取决于最慢一个 rpc 的。上图展示了单次 rpc 是长尾请求的概率是 0.01% 的情况下,不同 Role 数量情况下的 BatchGet 长尾概率,通过公式 1 - (0.999^N) 计算。
· 增加 Sect(读性能扩容):
· 每个 Sect 都有全量的数据,增加一个 Sect 意味着增加一个只读副本,可以达到读性能扩容的效果。
· 由于一个 BatchGet 只需要发往一个 Sect ,RPC 数量是收敛的,不会因为底下的 KVSvr 有 200 台而发起 200 次 RPC。这种设计可以降低 BatchGet 操作的平均耗时,减少长尾请求出现的概率。
· 增加 Role(存储容量 读性能扩容):
· 假设每台机的存储能力是相等的,增加 Role 的数量便可以增加存储容量。
· 由于整个模块的机器都多了,所以读性能也会增加,整个模块在读吞吐量上的扩容效果等价于增加 Sect。
· 但当 Role 数量较大时,一次 BatchGet 涉及的机器会变多,出现长尾请求概率会增大,所以一般建议 Role 的数量不要超过30。
· 增加 DataSvr(写性能扩容):
· DataSvr 是一个无状态服务,可以做到分钟级的扩容速度。
· 底下的写任务是分布式的跑,一次写会切分为多个并行的 job,增加 DataSvr 的实例数,可以增加整个模块的写性能。
· 数据迁移都是以文件为级别,没有复杂的迁移逻辑,不考虑灰度流程的话,可以在小时级完成,考虑灰度流程一般是一天内。
4. 系统容灾
· KVSvr 侧:
· 每个 Sect 的机器是部署在同一个园区的,只需要部署 2 个 Sect 就可以容忍一个园区的机器故障。
· 具体案例:2019年3月23号,上海南汇园区光缆被挖断,某个 featurekv 有 1/3 的机器在上面,故障期间服务稳定。
· 故障期间部分 RPC 超时,导致长尾请求增加。但是换机重试之后大部分请求都成功了,最终失败出现次数很低。后续全局屏蔽了南汇园区的机器之后,长尾请求和最终失败完全消失。
· DataSvr/WFS 侧:
· 即便这两部分整个挂掉, FeatureKV 的 KVSvr 还是可以提供只读服务,对于大部分 定时批量写、在线只读 的场景,这样已经足够了。
· 具体案例:2019年6月3号,某个分布式文件系统集群故障,不可用9小时。某个 featurekv 的 USER_FS 和 FKV_WFS 都是这个集群。故障期间业务方的输出产出流程也停止了,没有产生写任务。整个故障期间,featurekv 的读服务稳定。
十亿每秒的挑战 在线读服务的具体设计
1. KVSvr 读性能优化
为了提高 KVSvr 的性能,我们采取了下面一些优化手段:
· 高性能哈希表:针对部分数据量较少、读请求很高的数据,FeatureKV 可以用 MemTable 这一个全内存的表结构来提供服务。Memtable 底层实现是一个我们自己实现的只读哈希表,在 16 线程并发访问的时候可以达到 2800w 的 QPS,已经超过了 rpc 框架的性能,不会成为整个系统瓶颈。
· libco aio:针对部分数据量较大、读请求要求较低的数据,FeatureKV 可以用 BlkTable 或 IdxTable 这两种表结构来提供服务,这两表结构会把数据存放在 SSD 中。而 SSD 的读性能需要通过多路并发访问才能完全发挥。在线服务不可能开太多的线程,操作系统的调度是有开销的。这里我们利用了 libco 中对 linux aio 的封装,实现了协程级的多路并发读盘,经过压测在 value_size 是 100Byte 的情况下,TS80A 上 4 块 SSD 盘可以达到 150w /s 的QPS。
· 数据包序列化:在 perf 调优的过程中,我们发现 batch_size 较大的情况下(ctrfeaturekv 的平均 batch_size 是 4k ),rpc 数据包的序列化时耗时会较大,所以这里我们自己做了一层序列化/反序列化,rpc 层的参数是一段二进制 buffer。
· 数据压缩:不同业务对数据压缩的需求是不一样的,在存储模型的场景,value 会是一段浮点数/浮点数数组,表示一些非 0. 特征。这时候如果用 snappy 这类明文压缩算法,效果就不太好了,压缩比不高而且浪费 cpu。针对这类场景,我们引入了半精度浮点数(由 kimmyzhang 的 sage 库提供)来做传输阶段的数据压缩,降低带宽成本。
2. 分布式事务 BatchGet 的实现
· 需求背景:更新分为全量更新和增量更新两种,一次更新包括多条数据,每次更新都会让版本号递增,BatchGet 也会返回 多条数据。业务方希望这些更新都是事务的,BatchGet 的时候如果一个更新没有全部执行完,那就返回上一个版本的数据,不能返回半新半旧的数据。
· RoleNum=1 的情况:
· 数据没有分片,都落在同一台机器上,我们调研后发现有这么两种做法:
· MVCC: 多版本并发控制,具体实现就是 LevelDB 这样的存储引擎,保存多版本的数据,可以通过 snapshot 控制数据的生命周期,以及访问指定版本的数据。这种方案的数据结构需要同时支持读写操作,后台也得有线程通过清理过期的数据,要支持全量更新也是比较复杂。
· COW: 写时复制,具体的实现就是双 Buffer 切换,具体到FeatureKV的场景,增量更新还需要把上一个版本的数据拷贝一份,再加上增量的数据。这种方案的好处是可以设计一个生成后只读的数据结构,只读的数据结构可以有更高的性能,缺点是需要双倍的空间开销。
·
·
· 为了保证在线服务的性能,我们采用了 COW 的方式,设计了 第一部分 中提到了只读哈希表,来做到单机的事务 BatchGet。
· RoleNum>1 的情况:
· 数据分布在不同机器,而不同机器完成数据加载的时间点不一样,从分布式的角度去看,可能没有一个统一的版本。
· 一个直观的想法,就是保存最近N份版本,然后选出每个 Role 都有的、最新的一份版本。
· N 的取值会影响存储资源(内存、磁盘)的开销,最少是2。为了达到这个目的,我们在 DataSvr 侧加入了这么两个限制:
· 单个表的更新是串行的。
· 写任务开始结束之前,加多一步版本对齐的逻辑,即等待所有的 kvsvr 都加载完最新的版本。
· 这样我们就可以在只保留最近 2 个版本的情况下,保证分布式上拥有一个统一的版本。在 COW 的场景下,只要把另外一个 Buffer 的数据延期删除(直到下次更新才删),就可以了保留最近 2 个版本了,内存开销也不会变大。
拥有全局统一的版本之后,事务 BatchGet 应该怎么实现呢?
· 先发一轮 rpc 询问各 role 的版本情况?这样做会让QPS翻倍,并且下一时刻那台机可能就发生数据更新了。
· 数据更新、版本变动其实是很低频的,大部分时刻都是返回最新一个版本就行了,并且可以在回包的时候带上 B-Version (即另外一个 Buffer 的版本),让 client 端在出现版本不一致的时候,可以选出一个全局统一的版本 SyncVersion,再对不是 SyncVersion 的数据进行重试。
· 在数据更新的时候,数据不一致的持续时间可能是分钟级的,这种做法会带来一波波的重试请求,影响系统的稳定性。所以我们还做了一个优化就是缓存下这个 SyncVersion ,每次 BatchGet 的时候,如果有 SyncVersion 缓存,则直接拉取 SyncVersion 这个版本的数据。
3. 版本回退
· 每个表的元数据中有一个回退版本字段,默认是0表示不处于回退状态,当这个字段非0,则表示回退至某个版本。
· 先考虑如何实现版本回退:
· 考虑简单的情况,一个表每次都是全量更新。那么每次让都是让 KVSvr 从 FKV_WFS 拉取指定版本的数据到本地,走正常的全量更新流程就好了。
· 然后,需要考虑增量的情况。如果一个表每次更新都是增量更新,那么回退某个版本 Vi,就需要把 V1 到 Vi 这一段都拉到 KVSvr 本地,进行更新重放,类似于数据库的 binlog,当累计了成千上万的增量版本之后,这是不可能完成的事。
· 我们需要有一个异步的 worker,来把一段连续的增量,以及其前面的全量版本,合并为一个新的全量版本,类似 checkpoint 的概念,这样就可以保证一次回退不会涉及太多的增量版本。这个异步的 worker 的实现在 DataSvr 中。
· 更进一步,这里有一个优化就是如果回退的版本在本地双 Buffer 中,那么只是简单的切换一下双 Buffer 的指针就好,可以做到秒级回退效果。实际上很多回退操作都是回退到最后一个正常版本,很可能是上一个版本,在本地的双 Buffer 中。
· 处于回退状态的表禁止写入数据,防止再次写入错误的数据。
· 再考虑如何解除回退:
· 解除回退就是让某个表,以回退版本的数据继续提供服务,并且以回退版本的数据为基础执行后续的增量更新。
· 直接解除回退状态,现网会先更新为回退前的版本,如果还有流量的话则会读到回退前的异常数据,这里存在一个时间窗口。
· 数据的版本号要保证连续递增,这一点在数据更新的流程中会依赖,所以不能简单粗暴的删除最后一段数据。
· 为了避免这个问题,我们借用了COW的思想,先复制一遍。具体的实现就是把当前回退的版本,写出一个全量的版本,作为最新的数据版本。
· 这一步需要点时间,但在回退的场景下,我们对解除回退的耗时要求并不高。只要回退够快,解除回退是安全的,就可以了。
十亿每小时的挑战 离线写流程的具体设计
1. 背景
· DataSvr 主要的工作是把数据从 USER_FS 写入 FKV_WFS,在写入过程需要做路由切分、数据格式重建等工作,是一个流式处理的过程。
· FeatureKV 中目前有三种表结构,不同的表结构在写流程中有不一样的处理逻辑:
· MemTable: 数据全内存,索引是无序的哈希结构,容量受限于内存,离线写逻辑简单。
· IdxTable: 索引全内存,索引是有序的数组,Key量受限于内存,离线写逻辑较为简单,需要写多一份索引。
· BlkTable: 块索引全内存,索引是有序的数据,记录着磁盘中一个 4KB 数据块的 begin_key 和 end_key,容量没限制,离线写流程复杂,需要对数据文件进行排序。
2. 单机的 DataSvr
· 一开始,我们只有 MemTable,数据都是全内存的。MemTable 的数据最大也就 200 GB,这个数据量并不大,单机处理可以节省分布式协同、结果合并等步骤的开销,所以我们有了上面的架构:
· 一次写任务只由一个 DataSvr 执行。
· Parser 每次处理一个输入文件,解析出 Key-Value 数据,计算路由并把数据投递到对应的 Que。
· 一个 Sender 负责处理一个 Que 的数据,底下会对应多份 FKV_FS 的文件。FKV_FS 上的一个文件只能由一个 Sender 写入。
· 总的设计思想是,让可以并行跑的流程都并行起来,榨干硬件资源。
· 具体的实现,加入了很多批量化的优化,比如对FS的IO都是带 buffer 的,队列数据的入队/出队都是 batch 的等,尽量提高整个系统的吞吐能力。
· 最终,在台 24 核机器上的写入速度可以达到 100MB/s,写入 100GB 的数据只需要 20 分钟左右。
3. 分布式的 DataSvr
· 再往后,FeatureKV 需要处理十亿级Key量、TB级的数据写入,因此我们加入了 IdxTable 和 BlkTable 这两种表结构,这对于写流程的挑战有以下两点:
· 生成的数据需要有序,只有有序的数据才能做到范围索引的效果,让单机的key量不受内存限制。
· TB 级的写速度,100MB/s 是不够用的,写入 1TB 需要接近 3 小时的时间,并且这里是不可扩展的,即便有很多很多机器,也是 3 小时,这里需要变得可以扩展。
· 先考虑数据排序的问题:
· 我们得先把数据切片跑完,才能把一个 Part 的数据都拿出来,对数据进行排序,前面的数据切片类似于 MapReduce 的 Map,后续的排序就是 Reduce,Reduce 中存在着较大的计算资源开销,需要做成分布式的。
· Map 阶段复用上述的单机 DataSvr 逻辑,数据切分后会得到一份临时的全量结果,然后实现一个分布式的 Reduce 逻辑,每个 Reduce 的输入是一份无序的数据,输出一份有序的数据及其索引。
· 这种做法有一次全量写和一次全量读的额外开销。
· 具体的流程如下图所示,DATASVR SORTING 阶段由多台 DataSvr 参与,每个浅蓝色的方框表示一个 DataSvr 实例。
· 再考虑大数据量情况下的扩展性:
· 参考上图,现在 DataSvr 的排序阶段其实已经是分布式的了,唯一一个单点的、无法扩容的是数据切片阶段。
· 实现分布式的数据切片,有两种做法:
i 一是每个 DataSvr 处理部分输入的 User_Part 文件,每个 DataSvr 都会输出 2400 个切片后的文件,那么当一次分布式切片有 K 个 DataSvr 实例参与,就会生成 2400 * K 个切片后的文件,后续需要把相同编号的文件合并,或者直接作为排序阶段的输入。
ii 二是每个 DataSvr 负责生成部分编号的 FKV 文件,每次都读入全量的用户输入,批处理生成一批编号的 FKV 文件。
· 第一种做法如果是处理 MemTable 或者 IdxTable,就需要后接一个 Merging 过程,来把 TMP_i_0, TMP_i_1, TMP_i_2 ... 合并为一个 FKV_i。而处理 BlkTable 的时候,由于其后续是有一个 Sorting 的逻辑的,只需要把 Sorting 的逻辑改为接受多个文件的输入即可。故这种做法的坏处是在数据量较少的时候,MemTable 或者 IdxTable 采用分布式数据切片可能会更慢,Merging 阶段的耗时会比分布式切片减少的耗时更多;
· 第二种做法生成的直接就是 2400 个文件,没有后续 Merging 流程。但它会带来读放大的问题,假设数据被切分成为 T 批,就会有 T-1 次额外的全量读开销。在数据量大的情况下,批数会越多,因为排序的数据需要全部都进内存,只能切得更小;
· 在小数据场景,单机的数据分片已经足够了,所以我们选用了第一种方案。
· 是否分布式切分,是一个可选项,在数据量较小的情况下,可以不走这条路径,回到单机 DataSvr 的处理流程。
· 最终,我们得到了一个可以线性扩展的离线处理流程,面对10亿、1TB数据的数据:
· 在实现 BlkTable 之前,这是一个不可能完成的任务。
· 在实现分布式数据切片之前,这份数据需要 120min 才能完成写入。
· 现在,我们只需要 71min 便可以完成这份数据的写入。
· 上面这一套流程,其实很像 MapReduce,是多个 Map, Reduce 过程拼接在一起的结果。我们自己实现了一遍,主要是基于性能上的考虑,可以把系统优化到极致。
现网运营状况
· FeatureKV 在现在已经部署了 10 个模块,共 270 台机,业务涉及看一看,搜一搜,微信广告,小程序,微信支付,数据中心用户画像,附近的生活,好物圈等各类数业务,解决了离线生成的数据应用于在线服务的痛点问题,支撑着各类数据驱动业务的发展。
· 最大的一个模型存储模块有210台机:
· 11亿特征/s: 日均峰值 BatchGet 次数是29w/s,平均 BatchSize 是 3900,模块压测时达到过 30亿特征/s。
· 15ms: 96.3% 的 BatchGet 请求在 15ms 内完成,99.6% 的 BatchGet 请求在 30ms 内完成。
· 99.999999%:99.999999% 的事务 BatchGet 执行成功。
· 微信广告基于 FeatureKV 实现个性化拉取 个性化广告位置,推荐策略能够及时更新。相比于旧的方案,拉取量和收入都取得了较大的增长,拉取 21.8%,收入 14.3%。
· 微信支付在面对面发券以及支付风控中都有用 FeatureKV,存储了多份十亿级的特征,之前一天无法更新完的数据可以在数小时内完成更新。
总结
一开始,这类定时批量写、在线只读的需求不太普遍,一般业务会用 PaxosStore 或者文件分发来解决。
但随着越来越多的应用/需求都与数据有关,这些数据需要定期大规模输入到在线服务当中,并需要很强的版本管理能力,比如用户画像、机器学习的模型(DNN、LR、FM)、规则字典,甚至正排/倒排索引等,因此我们开发了 FeatureKV 来解决这类痛点问题,并取得了良好的效果。
,
免责声明:本文仅代表文章作者的个人观点,与本站无关。其原创性、真实性以及文中陈述文字和内容未经本站证实,对本文以及其中全部或者部分内容文字的真实性、完整性和原创性本站不作任何保证或承诺,请读者仅作参考,并自行核实相关内容。文章投诉邮箱:anhduc.ph@yahoo.com