编者按:高可用架构分享及传播在架构领域具有典型意义的文章,本文由毛剑在高可用架构群分享。转载请注明来自高可用架构公众号「 ArchNotes 」。
毛剑,就职于 Bilibili(B 站),成为了一名二次元同志(哔哩哔哩)。负责 UGC 平台,基础架构相关工作。Golang 践行者。bfs 是为了在 B 站遇到的一个急需解决的小问题开发的项目。
自主研发 bfs 的背景
B 站从一个比较小众的社区,慢慢发展到今天,技术一开始的实现必然是求快为主,所以前不久发生过一次事故,自建的 CDN 因为一些故障导致图片、静态资源大量回源打到源站,源站 MongoDB 扛不住挂了。当时第一反应是:
MongoDB 不合适存储小文件图片?GridFS 场景更像是为大文件存储实现?
于是尝试了 FastDFS,搭建 TFS 等进行升级和替换。第一版用了 FastDFS,其线上的稳定性、故障情况以及扩展来说表现不佳;而 TFS 编译复杂,运维相对比较困难。后续有其他扩展需求还不知道是否好实现,因为行业特殊不太合适用公有云,所以选了一个最差解决方案:自己造轮子。
当时想到了参看 Facebook 这种海量图片的公司是怎么实现小文件存储,于是找到了 Haystack 的论文一探究竟。
到底什么样存储系统适合 B 站呢?需求大概是:
写非常多、读相对多(访问长尾效应)、从不修改、很少删除
非常稳定、高可用、可扩容、可运维部署
为图片定制优化的系统
整体架构
图片静态资源外围必然会加上 CDN,让大部分 “热” 图片尽可能在 CDN 边缘节点命中,最快的返回给用户。源站核心要解决就是 CDN 穿透以后回源的压力,所以设想的架构是:
把请求从上往下看
OSPF LVS:解决四层的负载均衡。
Tengine:7 层负载、作为 L1 cacheproxy_cache,缓存热图。
Image Server:当 L1 cache miss 时,IS 进一步作为 L2 cache。因为上一层使用了 URI 一致性 hash,同一张图片一定会命中具体某一台 Server。如果运气再不好,请求就会打到请求真正的原图。考虑到移动端、PC 端各种尺寸要求的图片都有,因此使用实时缩略,进一步减少原图存储多版本浪费空间。我们用 Lua opencv C binding 的方式结合 nginx,实现了一整套 Image Server。
bfs 作为最终请求结束节点,也就是对象存储。
考虑到 CDN 可能的故障,最终还在 CDN 回源增加了一个二级缓存节点,一方面还避免暴露核心机房的 IP 给第三方 CDN,另外二级缓存节点可以大带宽,比核心机房便宜。
这样看来,整个请求的走向是 CDN -> L2 源站 -> L1 源站 -> 源站内部 L1、2 缓存。这样设计后,系统比以前的版本会更稳定。
bfs 模块
bfs 架构包含四个模块,分别是 proxy、directory、store、pitchfork。
bfs 依赖 ZooKeeper 存储 store 的元数据信息,依赖 HBase 存储用户自定义数据。
大体的数据请求走向都是从 proxy 开始,proxy 负责屏蔽内部协议的复杂性,后续也可以在 proxy 做一些异步任务。经过 proxy 后,请求会转发给各个模块。
总的设计目标来讲,希望 bfs 架构非常简单,这样在扩展、维护、bug 查找和编码都会有很多优势,为了这个目标也需要牺牲一些特性,后面会提到。
bfs 架构如下:
将请求从下往上看
Store:负责所有图片数据存储。
每个用户的图片可能会分布在任何一个 store 节点,store 是核心存储,代码和架构必须要非常谨慎。
和其他基于 Haystack 论文实现的开源存储不一样,bfs 的 store 模块只有简单的增、删、读操作,复制、健康检查、节点心跳一概没有,甚至可以只使用单个 store 直接提供服务,没有任何依赖。
为了能和集群通讯,唯一依赖一个外部设施就是 ZooKeeper,但是也非常简单,在初始化阶段本地的 volume(卷轴,后面会详细介绍这个术语,类似盘符)索引(volume 对应的文件路径)和 ZooKeeper 完成一次双向同步。
Pitchfork:心跳检测模块。
Pitchfork 会监控所有 store 节点,定期做 probe 探针检查。比如尝试读取一个文件,当出现异常就会去对应的 ZooKeeper 上 store 节点的路径更新状态。还会定时上报一些信息给 ZooKeeper ,方便其他依赖模块使用。监控还会从各个 store 模块内部收集平均延迟 delay、剩余空间、写入 IOPS 等。
因为需要监控的 store 节点可能非常多,为了分摊任务,依赖 ZooKeeper 把工作的 pitchfork 节点分组,然后分别负责不同的 store 模块监控。
Directory
读请求可以根据特定业务的 bucket 以及用户自定义的文件名先从 HBase 查询一次,获取到内部的 store 节点集和具体的文件信息,这样就能提供正常的读服务了。
写请求会根据 pitchfork 定期反馈的节点状态信息,做一次打分计算,按照空间、延迟、IOPS 等计算出 score,然后在 directory 内存中进行一次调度,返回 store 集就完成了。
所以 directory 担任的就是一个元信息查找定位、存储调度的模块,因为信息都存储在 directory 内存中,所有 directory 节点都是一致的,也就是说 directory 是无状态的,因此很容易扩充。
Proxy:代理模块。
顾名思义,负责屏蔽内部 directory 查找、store 读取和写入等具体的细节逻辑,对外暴露面向资源的 RESTful API,之前也有考虑直接在 nginx 的 Lua 实现,目前暂时搁置了。
虽然 proxy 结构会让系统内部多一次 HTTP 请求,但是整体逻辑可维护性变高了,也方便之后在 proxy 中完成更多逻辑。
不过如果对性能要求非常极致也可以考虑 Lua 脚本来实现。
bfs 存储模块 store
背景
由于目录文件多了非常慢,所以根据文件名 hash 头几位,建立 00-FF 好多目录,来打散这些文件的存储。
目录定位本身也是需要 IO 成本,早期的 ext2 是线性查找,ext3 做了一些优化线性查找 Btree 搜索,包括到后面 ext4 / xfs 等基于 extent 查找等,本质还是需要 IO 操作的,目录越多,cache 命中率就会下去,IO 成本就会变高。
小文件本身的 inode 也是占用上百字节,浪费空间。
打开文件时需要一次系统调用 open 操作,也是一次 IO,可以缓存所有的打开 fd,这样就少几次 IO。但是上亿文件情况下,不可能缓存所有的 fd,如果缓存用 LRU 策略,长尾问题还是没解决,因为存在一定的 cache miss。
方案
既然文件数量多,先考虑小文件合并,引入一个概念叫 superblock 超级大块。
每个小文件叫 needle(类似针头,指针),通过顺序写,以及文件追加的方式集合所有小文件变成大文件。
这样有两个好处:
小文件数量得到了控制。
顺序追加写可以利用机械硬盘的特性提高 IOPS 能力。
(图:facebook Haystack 设计)
Superblock
读取文件:needle 中包含了一个 key,保存图片时,会分配一个唯一的 int64 作为图片 ID,最终只需要维护 key 到 Superblock 偏移(store 的内存中)就可以很方便的找到文件了。
这样设计后,IO 成本只需要一次 ReadAt 操作就以完成图片读取,因此最少只需要一次 IO。内存中要保存所有 key 到 offset 的 hashmap,需要提前打开好这个 Block 的 fd 保存起来。
需要提一点,bfs 考虑到长尾读取或者是任何读取行为以后,立马会 cache 到上层的 Nginx 或者是 CDN,所以直接关闭预读的,这个可以通过 fadvise 告诉内核。
还有个比较特殊的字段叫做 cookie,相当于一个密码,需要经过 directory 授权分配 cookie,匹配上 needle 的 cookie 才认为是合法请求,所以不能直接访问 store。
写入文件:上传图片非常简单,因为是 append-only,直接追加到文件尾部就可以了,然后更新内存中的映射,新增一个 key 到 offset 的键值对。
因为是追加,所以如果是修改行为就没办法了,比如同一个 key 的文件需要更新内容,内容变大或者变小就不太好支持了,所以这里还是继续 append,然后更新内存中新的 offset 过去,“残留”的 needle,就是一个孤立文件,需要考虑其他手段来删除。
考虑到需要 hashmap 维护非常海量的映射,key 定为 int64,value offset 本来应该是 int64,这里用一个非常巧妙的方法改成了 uint32,空间占用少了一倍。uint32 的寻址空间是 2 ^ 32 - 1 = 4GB,也就是说单个 block 只允许 4GB。
怎么样用 uint32 实现寻址空间 32GB?
如果是 10T 硬盘,那就有非常多 block,为了尽可能少一些文件,可以考虑对齐的做法,不管文件多大,最终都会以一个值来对齐。bfs 默认用 8 字节来对齐,所以用 uint32 可以寻址空间变成了 uint32 * 8 = 32GB,单个 block 文件的大小就变成了 32GB,比如整个 needle Size = 8 的话,内部 offset = 8 / 8 = 1,也就是偏移 = 1。
还有一个点需要提一下,写入文件用的是 buffered I/O,也就是会先写内核 page cache,之后通过 bfs 参数,有点类似于 MySQL sync_binlog 决定什么时候来刷盘。
刚写入文件以后,一般来说会立马读,所以用 fadvise 告诉内核 page cache 不需要保留,每当 fsync,用新内核的 sync_file_range 来指定 offset 刷新,然后再 drop 掉 page cache,避免不必要的内存占用。
考虑到 DirectIO 需要按照 sector (一般是512字节)或者是 page cache(4KB)来对齐有点大也很麻烦,所以还是用 buffered I/O。Block 文件一开始就预分配,falloc 让 extent 连续,避免磁盘碎片,还有一个好处可以提前占领空间,不让其他人用。
删除文件:store 删除文件用的是 flag,flag 是一字节的标志位,只需要更新 needle 的 flag = deleted,就认为是删除了,这里也是使用 WriteAt 来更新,另外 hashmap 为了节省空间,把默认 0 认为是删除。删除文件的回收下文会提到。
索引文件:当 store 意外重启时,需要重新构造内存中的 hashmap。可以直接使用 Superblock 来还原整个内存映射,但是意味着需要扫描整个 block,这非常吃 IO。所以进行了一个加快启动过程,就是建立映射本身的索引。
(图:facebook Haystack 的索引设计)
整个 block index 就变成了一个 volume,给每个 volume 分配一个 ID,类似像盘符 C 盘、D 盘。
索引文件写入本身是异步,用了 Golang 的 channel 来处理索引写入(批量打包一次写入),这样写入操作可以快速返回,同样删除操作并不会更新索引,而是使用 “ Lazy Read ” 的方法,读 needle 时候如果 flag 为删除,再来更新 hashmap 的内存数据。
细致的读者可能发现,索引是异步,会不会还有数据没来得及更新,导致 needle 丢失?所以还需要做一步操作,根据最后索引 offset 去原始 block 做一次补齐操作还原这些 needle,并且重新更新到索引中去,那么整个恢复就好了。
压缩:压缩操作是直接在线执行的,它能回收已删除、重复的 needle 所占据的空间。store 机器压缩卷文件的方式是,逐个复制 needle 到一个新的卷文件,并跳过任何重复项、已删除项。在压缩时如果接收到删除操作,两个卷文件都需处理(代码里面会根据 compact 状态,对删除操作进行记录)。一旦复制过程执行到卷文件末尾,所有对此卷的修改操作将被阻塞,新卷文件和新内存中映射将对前任执行原子替换(变量替换是原子的),随后恢复正常工作。
故障恢复:store 可能会因为 N 多原因导致出错,这时候 pitchfork 模块会检查出来,更新 ZooKeeper 的状态为只读(为了运维方便,这个机器所涉及到的所有 volume 都会只读)之后再人工确认问题,一旦确诊,可以快速修复,并且更新状态,如果是数据错误,修复失败以后,可能更严厉的一步就是强制 sync,从其他节点完整的复制整个 block 进行还原,目前这个成本还是很高,大家有更好的意见也可以提。
批量操作:B 站投稿这种业务,后台功能或者是视频特定时间点的缩略图属于批量写操作,可以把零散的写请求打包变成连续的一次大请求,效率更好。
meta 信息:一台机器一般一个 store 节点,一个 store 节点可能有 N 个 32GB 的 volume,机器一般还会按照机架来区分,方便分组(一般跨机架分为一组,避免机架内机器集体断电,比如本机架风扇或者空调坏了),所以 ZooKeeper 的结构为:其中 volume 最终保存了自己关于统计、管理、API 接口的地址,以及 ID、状态(读写等)。
运维模块 OPS
运维模块我认为是最核心,开源项目中最大支持最有用的一块,很多开源项目都忽视了这点。我正在拉前端同事用 Vue 配合提供的接口来构建一整套运维体系,说起来运维系统其实也有不少流程和细节,这里大概讲一下 bfs 是如何分配 store。
Group
bfs 的核心是镜像(而非 EC,私有云有钱任性),考虑故障隔离时,方便运维也方便代码的做法是,按照机器大粒度来做故障隔离,ZooKeeper 的设计:
把机器做一次分组,比如机架 A 的 store1 和机架 B 的 store2,成为一个分组 group1,那么 store1 下面所有的 volume 任何一个出现故障,整个 group 都是只读的,这时候通知故障转移,依赖 ZooKeeper 的 directory 就会使用其他的 group。
所以有个大前提,group 最好多几个,避免挂一个分组就没可用的资源了。这就是牺牲了一定的可用性来完成架构的简单化,如果按照 volume 级别来做高可用,代码复杂度高挺多的,有兴趣的可以思考下这个问题。
目前 OPS 模块还是 Python 脚本来实现,当时考虑的方案是,按照目前所有资源,比如机架分布、存储分布、随机性、副本等 N 个参数,根据 ops 平台输入计算一个合理的分配完成一个分组,这块目前在 TODO,这样当一批机器入库资产以后,可以根据自由选择套餐分配可用的 bfs 资源组。
Volume
做好完分组以后,就是分配卷(volume)了,由 ops 平台触发,调用各自分组需要分配资源的 store 机器,创建 volume,id 是通过 ops 平台指定的,这里代码简单的自增分配了 volume id,然后创建好另外一个 ZooKeeper 节点信息,方便后面的 pitchfork 和 directory 获取统计信息写入。
其中处理了多少写请求,写延迟,剩余空间都是 pitchfork 来定期维护更新到 ZooKeeper,方便 directory 根据规则来调度到不同的 store 集合。这个结构和上文 store 部门提供的 ZooKeeper 结构区别在于,store 关注的是 store 本身有哪些 volume,而这个关注的是 volume 有哪些 store。比如 store-1 可能有 volume1,2,3,而 volume1 可能需要 store-1 和 store-2 两组机器来构成一个卷,所以 volume id 对于 store 单个节点是唯一的,但是对于不同的 store,volume id 如果有重复,表示他们两个其实是一组。
心跳模块 pitchfork
pitchfork 启动时,会根据整个 ZooKeeper rack 进行枚举,找到所有的 store 的集合,然后根据当前 pitchfork 的节点数量,进行任务划分。这样当 store 非常多时,任务就在各个节点之间进行了任务拆分,这个特质和 kafka 的 consumer group 实现几乎是一致的。看看 ZooKeeper 的节点:
使用 ZooKeeper 的顺序和临时节点,当 pitchfork 启动时候,创建一个临时、顺序的节点,如果 pitchfork 挂掉,节点会消失,因此只需要把 pitchfork 监听 pitchfork,watch 这个 parent 就可以获取整个 pitchfork 节点的新增和删除,然后重新进行分配。
把所有 store 排序以后,除以所有可用的节点数量来进行任务分配,这样即使是分布在不同机器的 pitchfork 都可以得到一直计算结果,进行任务分配。
任务分配完毕以后,内部会并行进行 Probe 探针,定期检查 store 下所有 volume 的健康状况。很巧妙的使用了 Golang map 每次 range 具有随机性的特点进行探测。当然 store 本身内部的 IO 错误,会直接设置 error 标志位,这样探测也会失败,当探测失败,就会更新整个 store 的状态为只读或者不可用,所以故障是机器级别的。
同样使用 Golang goroutine 并行异步的同步 store 中每个 volume 的内部统计,比如 IOPS、delay、freespace 等,方便 directory 调度。
目录模块 directory
目录服务根据上文的 Volume ZK meta,就可以非常方便的获取到各种进行然后进行打分。
目前规则比较简单粗暴,先获取所有 group,然后根据 group下的所有 store,然后根据所有 store 下的 volume,给 group 计算一个总分,根据总分对 group 进行权重分配,然后 rand 命中一个 group,就是它来负责写操作 了。
如果某个 group 有任意一个 store 只读或者故障,就放弃这个 group,最终调度单元就是 group 为粒度了。group 选中以后,再 rand 具体 store中的某个 volume,就开始写了(volume 级别没做那么细了,直接用的随机分布),因此副本数其实是由 ops 平台设置 group 决定的。
高可用
因为有 pitchfork 模块来更新 ZooKeeper 通知 group 和 volume 的信息,方便重新进行打分或者调度或者故障转移,所以整个高可用都是基于 directory 的调度来实现的,对 store 代码几乎是没有任何侵入的。
这样设计的好处是,可以非常方便更新 directory 代码升级策略,或者修复 bug,而不是 store 节点之间做复制,因为 store 重启的成本很大,而且存储引擎层面的东西最好最少的修改,而是单纯当作存储来用。
从 ops 的副本策略来看,属于 CAP 中的 CP 系统,牺牲了可用性。因为当副本数过多的情况下,响应会变慢,因为要求所有 volume下的 store 全部写入成功才会返回的,所以是强一致的。任意一个 store 挂掉以后,因为可以提供只读服务,所以网络分区的问题也可以得到解决。
代理模块 proxy
目前代理模块非常简单,包括封装 bucket(区分权限),做一些内部协议的处理。比如从 directory 获取可写的 store 节点,然后一一写入(也可以并行),然后再返回给调用者。
很关键的,还可以使用代理处理比如 store 的 needle checksum 失败的节点,进行异步的修复逻辑处理。更进一步的,是不是可以用 proxy 发成功写入请求的操作记录到队列中,通知到另外的机房来进行拉取镜像同步,然后就可以实现跨机房备份了,只需要在二级节点的源站用 nginx 的 upstream fallback 就搞定了。
总结
bfs 从开发到上线大概 3-4 个月时间,是非常迅速的,我一直比较倾向简单的架构,牺牲一些比较不常用的功能,比如存储之间的 rebalance,更多的我们倾向把写比重不同来实现 rebalance 。
项目参考地址:
开发人员:毛剑(review)、查普余。
也希望更多的同志给我们提供文档和建议,我们运维平台在内部测试通过以后会尽快同步到外网。
Q & A
1. bfs 有什么优缺点?跟 mfs、hdfs 等等比较?
毛剑:分以下几点:
bfs 的彻底故障恢复是比较麻烦的,需要从其他镜像的备份去还原,可想而知那个时候内网带宽是很吃紧的。
另外就是基于镜像实现,会存在一定浪费。不是专用的大文件存储(HDFS 这种按照小快拆到各个节点),比如视频这种大文件存在 bfs 就不合适。因为经常会看动作片,快进啥的,这个时候都 seek 到同一个 store,压力很大的。如果是大文件存储实现,可能会像拉链一样分到不同的存储节点,我是这样认为的。
2. 为何用 Tengine,有什么优势?
毛剑:Tengine 对运维更爽。 REUSE_PORT 新版本很早就支持了,upstream 的主动健康检查,过载保护,动态加载 dso 模块,upstream 重试次数可以配置,日志支持各种 syslog 啥的,总之太多了。Nginx 都是后来追这些特性的。
3. 为何选 ZooKeeper,不选 consul?
毛剑:ZooKeeper 我最熟悉,而且各种现成的开源项目在使用,我没激进使用新的方案,比较保守。
4. B 站的视频资源大多是大文件?bfs 适合存储大文件还是小文件?
毛剑:B 站视频都是大文件居多,bfs 更适合图片存储,小文件存储。大文件的话过阵子说不定可以等我分享 bbfs,大文件是另外一种优化实现。(小编:非常期待)
5. FastDFS 扩展能力是很强的,稳定性也不差,感觉很多公司都在用?
毛剑:我扫过一点 FastDFS 源码,并不认为他的稳定性很高,比如要自定义文件名就非常麻烦,要改代码,要么就是基于二次开发,FastDFS 为什么没在阿里内部使用,可能还是有原因的,只不过没有更好的小文件存储实现。bfs 的好处就是,我相信,任何一个人都可以读懂我的代码,而且扩展及其容易,另外 Golang 非常好学!
6. 分享中没有提到,bfs 应该是不支持集群同步吧?
毛剑:集群同步,是依赖 proxy 调用 directory 接口获取到 store 集合(具体的一个 group 下所有 store 节点),然后写入多份的,所以本质是有 N 份数据,集群同步不知道你是指的机房之间同步吗,如果是机房之间同步,目前在 proxy 准备依赖消息队列发送消息给其他机房,然后来拉取图片进行同步,牺牲一定的一致性。
7. 不知道 B 站的小文件规模有多大,看起来很大?
毛剑:小规模主要是稿件封面,用户头像,各种视频截图缩略图,各种运营 banner,用户创作中心,画站,总之啥都有。
8. 有没有对比过 seaweedfs,貌似这个也是 Hystack 的 Golang 实现?
毛剑:其实对比过 seaweedfs 的,先是看过他的代码以后决定重写的。
我感觉 seaweedfs 有点和论文的意图有出入。比如,我认为调度应该在上层代理实现,而作者似乎是在 store 模块完成的,另外 hashmap 的优化,以及 GC 优化,还有各种 syscall 的优化做的比较少,比如 fallocate,fadvise,fast recovery,sync_file_range 等优化,以及 page cache 的处理令我不太满意,所以决定重写,顺便学习一下。
9. Golang 的坑能否分享下?或者说 bfs 设计过程中踩到了什么坑?
毛剑:这个问题非常的好,做 goim(我另外一个开源项目)核心关注的是 GC 问题,那么做 bfs 的时候 GC 并不是那么大的问题,因为瓶颈在 IO 上,而非 CPU、内存啥的。
1. 不支持 Writev,当批量上传的时候,零散的内存,我不得不 memcpy 到一个大 buffer,Writev 要支持很麻烦,所以暂时放弃了。
2. syscall 要兼容跨平台也很麻烦,好难写,各种 posix 的调用,我机器 mac 的,要去 centos 调啥的,烦死人了。
3. 比较大的坑,是我自己的代码规范,因为图快,当时把一个重要的 Buffer 对象暴露给外部,让外部调用者自用使用,结果导致内存出错,影响到 block 的写入(数据错误),这个问题非常严重。后来我推翻了自己的代码,严格按照对象内部处理的,绝对不暴露,不使用黑科技。
4. 另外存储需要非常严谨的逻辑和代码,任何修改一定要完整的测试,所以我的单元测试写的非常的多,另外同事写的各种 test case 要全部覆盖到,这个有一定的难度的。
5. 一定要注意 page cache 、文件系统有一定的了解(我开始做的时候是门外汉)
6. 做 Compact 压缩 block 时,代码之前写复杂了,后来用原子变量替换 COW 搞定了(block 在 map 中,处理很麻烦)一个并发的争用问题。所以我建议脑袋里面也要经常模拟 debug 各种可能的情况,写多日志。
本文策划邓启明,编辑王杰,想讨论更多架构设计,请关注公众号获取进群机会。转载请注明来自高可用架构「ArchNotes」微信公众号及包含以下二维码。
高可用架构
改变互联网的构建方式
长按二维码 订阅「高可用架构」公众号
欢迎技术文章投稿,投稿请订阅高可用架构后回复 投稿