免费获取学习方案
ARTICLE DETAIL

资讯详情

深耕编程基础知识与建站技术分享的一线实战洞察。

Curve分布式存储架构深度解析:C++性能优化与Multi-Raft实践

Curve分布式存储架构深度解析:C++性能优化与Multi-Raft实践 如果你的存储系统还停留在“能用但不敢折腾”的状态——数据库压测时延迟偶发飙高、节点故障后恢复时间动辄几小时、扩容要小心翼翼避开业务高峰——那Curve这套网易数帆开源的分布式存储架构值得静下心拆一遍。CPP-Summit-2022上有一场专门讲Curve设计与实现的分享我回看视频之后又对着源码和官方文档刷了两轮今天整理成一篇偏架构和工程侧的笔记内容覆盖Curve的定位、元数据与数据面的全局设计、一次写入请求的完整路径、一致性/快照/恢复的实现思路以及C层面的性能手段和实际运维中踩过的坑。如果你正在做存储选型、分布式系统架构设计或者纯粹对“用C怎么抠性能”感兴趣这篇应该能帮你省下不少自己翻文档的时间。1. Curve是什么以及为什么需要一个“新”的分布式存储1.1 网易内部的存储痛点与Curve的定位先说背景。Curve不是那种为了开源而开源的玩具项目它最开始是网易数帆为了解决内部业务存储痛点做的自研系统。网易的业务形态很杂云资源、游戏、电商、中间件都跑在一套基础设施上底层的数据库、缓存、日志系统对存储的要求差异极大。用传统开源分布式存储软件时团队遇到几个非常现实的问题。第一个是组件的复杂度。像Ceph这样的通用分布式存储功能确实全RBD、CephFS、RGW一套全家桶但生产环境里需要维护的参数非常多OSD、MON、MGR、RGW各自的心跳、内存上限、网络队列、scrub策略都要单独调。一个参数组合不对某个场景下延迟就会莫名其妙恶化。第二个问题是性能的长尾效应。数据库和缓存这类在线业务最怕的不是平均延迟高而是P99延迟偶尔飙上去Ceph在高并发小IO场景下的毛刺是出了名的难搞排查到最后往往是刷盘策略、网络重传或OSD线程调度的问题。第三个问题是故障恢复的节奏不可控一个OSD挂掉rebalance和backfill动起来可能拖垮整个集群的IO。Curve的设计思路就是奔着这三个痛点去的。它的定位非常清晰高性能、易运维、云原生的分布式存储系统。官方把架构收敛成两条线——块存储和文件存储链路短、组件少、可控性强。换句话说它做的不是“我什么都能存”的万能系统而是“我把一类场景做到极致”的专用底座。1.2 CurveBS与CurveFS两条产品线的分工Curve对外主要提供两个产品形态名字起得很直观CurveBS和CurveFS。CurveBS是块存储服务。它向上提供的是标准块设备接口你可以把它理解成一块远端的云硬盘通过iSCSI、NBD或客户端驱动挂载到服务器上。数据库、ES、Kafka这类需要裸设备或格式化文件系统的中间件最适合跑在CurveBS上面。块存储的好处是接口简单、IO路径短不用管POSIX语义、目录、文件锁一大堆事情性能上限更高。CurveFS则是文件系统服务。它实现的是更上层的文件语义多个节点可以同时挂载同一文件系统做文件共享、AI训练数据的统一读取、大数据分析之类的场景更合适。底层数据面其实可以复用同一套存储基础设施差别主要在上层语义和客户端实现上。从个人角度看CurveBS是Curve最值得先学的部分因为块存储的架构比文件系统干净等你理解了Block这条路径上的数据如何流转、如何保证一致性再去看CurveFS会轻松很多。1.3 与Ceph/Rook对比时容易忽略的取舍很多文章一提到Curve就要和Ceph做对比但真正值得关注的不是“谁强谁弱”而是两个系统在设计哲学上的差异。Ceph走的是“统一架构”路线一套底层池子上面同时长RBD、CephFS和RGW数据布局用CRUSH算法客户端可以通过哈希直接计算出数据位置。这套架构的想象力很大但复杂度也同步上涨。不是说Ceph不好而是它的使用和运维门槛系统性地高一般团队根本养不起一个能精准调教Ceph的SRE团队。Curve的路线是做减法。它把元数据管理和数据存储拆成两个角色数据副本组通过Multi-Raft做强一致逻辑卷到物理块的映射由中心化的MDS来调度不需要CRUSH那样复杂的哈希计算。这样做的代价是元数据服务成了必须高可用的组件但收益是整个系统的行为更容易预测、更容易调试。再看Rook。Rook实际上是把Ceph封装成Kubernetes Operator方便在K8s里直接部署和编排底层仍然是Ceph。Curve也有CSI插件可以直接在K8s里提供存储但核心存储引擎是自研的。所以在云原生场景里Curve并不是Rook的替代品而是另一条更专精的技术路线。2. 全局架构拆解元数据管理如何与数据面解耦2.1 三个核心角色Client、MDS、ChunkServerCurve的整体架构一眼看上去很清爽主要角色只有三个客户端Client、元数据服务MDS、数据服务节点ChunkServer。客户端是在业务机器上运行的那一层它向上提供卷设备向下把应用发来的IO转换为远程调用。客户端本地会维护一份路由缓存记录“逻辑块范围对应的物理chunk位置”这样可以减少每次IO到MDS查询的额外开销。客户端还负责IO的合并、重试、超时处理相当于业务和存储集群之间的翻译官。MDS是大脑负责管理元数据、集群拓扑、卷到物理存储的映射关系以及副本组的分配和调度。MDS是中心化组件所以必须是多副本部署通过etcd协调选主避免单点。MDS本身不直接参与数据流的搬运这也是“控制面”和“数据面”分离的核心元数据查询再频繁也不会和真正的数据IO抢带宽。ChunkServer是真正落数据的地方。一台ChunkServer可以挂多块磁盘管理着若干个chunk数据的读写。它是数据副本协议的执行者也就是Raft组里的节点。ChunkServer需要处理来自客户端的写请求、执行日志复制、完成数据落盘同时定期向MDS上报负载状态和心跳。这三个角色的关系其实可以类比成一个仓储系统Client是去仓库取货的公司物流车MDS是总调度台ChunkServer就是实际存放货物的各个仓区。调度台只告诉物流车货在哪不搬货货才是真正在各个仓区之间流转的东西。2.2 从逻辑卷到物理布局Segment与Chunk的映射逻辑一个卷创建出来之后在Curve里并不是一整块连续盘直接对应到某台机器。大文件或大容量卷如果单点放置扩展性直接卡死。Curve的做法是把卷切片。典型的组织方式是一个逻辑卷被切分成多个固定大小的Segment每个Segment作为基本的管理单位Segment里面还可以继续切分成更小的ChunkChunk是客户端实际读写时面对的数据块。Segment和Chunk的具体大小不必死记在部署文档或创建卷参数里能看到关键是理解这种两层切分的目的。为什么要切两层我个人的理解是Segment这一层主要服务于负载均衡和迁移。MDS在调度时可以把不同的Segment分配到不同的ChunkServer上这样单个卷的IO就能同时打到多台机器带宽自然叠加。Chunk这一层则服务于副本组织每个Chunk对应的副本组属于一个Raft Group副本之间通过Raft协议保持数据一致。Segment负责“分而治之”Chunk负责“副本协同”两者一配合整个数据面的布局就立体了。再加上一个逻辑池LogicalPool和物理池PhysicalPool的概念LogicalPool面向业务规定了副本数、故障域范围等策略PhysicalPool则是底层物理资源的抽象。MDS在创建卷时会按照LogicalPool的策略把Segment和Chunk分配到物理池里的合适位置。2.3 MDS的高可用与元数据一致性MDS虽然是中心化设计但它并不是单点。集群里会部署多个MDS进程通过etcd这个协调服务进行leader选举。正常情况下只有一个MDS在真正管理元数据和下发调度指令其他MDS处于standby状态持续同步元数据快照。一旦leader MDS所在机器宕机或网络分区etcd的租约机制会发现它失联standby MDS快速接管变成新的leader客户端通过心跳机制感知到MDS切换重新获取路由信息。由于元数据量相对数据量小很多这种切换的恢复时间通常可以控制在秒级以内。这里有一个很关键的工程细节客户端对MDS的查询不是每次IO都做。Client会在一开始或路由失效时去MDS拉取映射关系之后一直本地缓存。所以MDS切换期间正在进行的普通IO不会全部中断只有那些需要新路由的IO会被阻塞一小段时间。这个设计我非常喜欢——把高频的数据路径和低频的控制路径彻底剥离开系统对控制面故障的容忍度就高很多。3. 一次写入的背后IO路径上的每个关键点3.1 客户端如何快速找到数据位置当应用把数据写进Curve块设备时客户端首先根据卷ID和逻辑偏移量计算出这次IO涉及哪些Segment和Chunk。这步类似把一块地址换算成“第几号楼、第几单元、第几号房”。由于客户端本地有路由缓存绝大多数IO不需要去问MDS直接拿着缓存里的映射找到对应Chunk所在ChunkServer的地址列表。缓存失效或启动初期没有缓存时客户端才回源到MDS查一次映射。查完以后MDS返回一串路由表客户端可以继续使用很长时间。只有当卷扩容、迁移或节点故障导致映射变化时客户端才会收到更新通知或查询失败信号再去刷新缓存。这个机制的收益非常明显元数据服务不用承受每笔IO的查询流量瓶颈被牢牢控制住。客户端缓存对于分布式系统的吞吐提升比很多人想象中大得多。3.2 写流程的完整链路从应用到落盘我把CurveBS一次写请求的完整链路拆成下面这几步1.应用调用write接口写请求进入客户端层。 2.客户端计算目标Chunk所在的Raft Group并向该Group的Leader发送写请求。 3.Leader ChunkServer收到请求后先做两层处理把数据追加到本地的WAL日志同时把这条日志发送给该Group内的其他副本。 4.Follower副本收到日志后也在本地写WAL并返回确认。 5.当Leader收到多数派quorum确认后这条写请求就算“提交”了Leader返回成功给客户端。 6.后台再异步把WAL里的数据应用到实际的数据文件Chunk文件中完成最终落盘。为什么是先写WAL再改数据这和单机文件系统宕机恢复的原理一样日志是顺序追加的写入速度比随机写快得多数据文件是原地覆盖一旦在写入过程中断电可能出现部分更新。先保证日志完整系统重启后就可以根据日志重放或回滚避免数据半新半旧。这种WAL先行的设计在整个分布式存储领域非常常见Curve沿用并把它和Raft日志结合起来。值得注意的一点是Leader必须等待多数派确认后才能返回成功这意味着写请求的时延受最慢的那个quorum副本影响。Curve在设计副本组时会把机器间的网络延迟差异控制到一个合理范围否则单个慢磁盘会无限放大长尾。3.3 刷盘策略与IO合并小IO才是性能杀手生产环境最典型的负载是4K或8K的随机小IO数据库的日志、缓存落盘都是这种模式。如果每个小IO都独立走一次完整的网络往返、WAL、复制、确认链路吞吐会低到没法看。Curve的做法是在多个层次做合并。客户端会把相邻地址的小写请求合并成更大的IO再发出去ChunkServer的Raft层也会把一段时间内到达的多个小日志合并成一个大batch一次性写WAL。这就是所谓的group commit数据库里也在用。合并之后同样是1000个4K写请求网络包数、磁盘写次数可以下降一个数量级吞吐自然就上来了。读路径相对简单客户端拿到路由后向Chunk所在副本组的主副本发读请求即可主副本本地读数据文件返回。遇到读缓存命中时延迟极低但缺页时就要实打实吃一轮磁盘寻道。4. 一致性、快照容量和恢复分布式系统最见功底的三件事4.1 Multi-Raft为什么块存储需要分组一致性Curve副本一致性的基础是Raft协议但它用的不是整个集群跑一个Raft Group而是Multi-Raft。每个Chunk或一组Chunk组成独立的Raft Group各自选举Leader、各自复制日志。Multi-Raft的设计有几个直接好处。第一是故障域变小一个副本挂掉影响的只是它参与的那几个Raft Group而不是整个集群。如果整个集群只有一个Group任何一个节点故障都会导致全局不可写这是不能接受的。第二是并行度提升不同Group的Leader分布在不同的ChunkServer上每个节点都能承担一部分写leader流量避免了单Leader的瓶颈。第三是恢复粒度更细补副本时只需要针对缺失的Group复制数据不用全量重传。读一致性的处理上块存储一般要求读写都从Leader走因为Leader持有最新提交的日志能保证读到的数据一定不比写入旧。如果允许Follower读就需要额外的read index机制或租约机制协议复杂度会增加。Curve在默认路径上倾向于“读写都走Leader”简单、安全换来的是Follower节点的横向扩展能力集中在数据冗余上而不是读负载分担上。4.2 快照体系Copy-on-Write要放在哪一层才能又快又省快照是块存储绕不开的功能数据库备份、容灾演练、测试恢复都靠它。Curve的快照做得比较聪明核心思路是Copy-on-Write字面意思就是“写入时复制”。创建快照时系统不会傻乎乎地把整块卷的数据全部拷贝一份而是只在元数据里记录一个时间点的快照标记。这个操作非常快基本瞬间完成。真正的工作发生在快照之后的新写入当某个Chunk第一次发生写入时ChunkServer会先把这个Chunk的原始数据拷贝一份保存到快照区域然后再覆盖写新的数据。这样一来快照里的数据就是创建那一刻的原始状态而当前卷的数据可以继续变化。COW放在ChunkServer层而不是Client层最大的好处是上层完全无感知不需要业务端配合。坏处是会带来一点写放大一个本来只要写4K的请求在发生COW时可能要额外读和写一个完整的Chunk。快照链越多这种放大越明显。所以Curve会做快照的合并和清理把过期的历史快照定时回收控制COW带来的性能损耗。4.3 故障恢复与数据均衡后台动作如何不拖垮业务节点故障后的恢复是分布式存储最容易翻车的地方。很多系统的思路是“尽快把副本补出来”于是拼命复制数据结果把业务IO全挤爆了。Curve在恢复策略上强调可控。MDS通过心跳发现ChunkServer失联后先标记它离线对上面承载的Raft Group重新计算副本分布有的Group需要选新Leader有的Group需要在新节点上补副本。补副本的数据来源可以是存量副本的在线复制也可以从已有快照恢复。整个补副本过程走后台任务队列有速率限制throttle默认不会抢占业务IO的全部带宽。数据均衡也是一样。MDS会定期收集每个ChunkServer的负载指标比如IOPS、带宽、磁盘水位、CPU用量然后综合计算是否需要做Segment迁移。迁移不是一次搬完会分批、节流、校验每一批都确认数据完整后再更新路由。这种“润物细无声”的玩法和很多一扩容就集群抖动的方案形成了鲜明对比。5. 用C构建高吞吐底座性能是从哪里抠出来的5.1 为什么选C而不是Go或者RustCPP-Summit本身是C的技术活动Curve能在这个峰会上讲架构本身就是它用C作为主力开发语言的一个重要信号。但选C绝不只是“为了快”这么简单。存储这类基础设施生命周期非常长代码可能要维护十年以上。C在这里的优势有几个一是内存控制的确定性没有GC全局暂停的风险延迟可控二是可以直接对接RDMA、SPDK、DPDK这类高性能硬件栈想绕过内核就绕过内核三是在系统调用和内存布局层面可以做非常精细的调优。Go在并发模型和开发效率上确实爽但遇到纳秒级调优、用户态协议栈、无拷贝IO这些硬核需求时原生的C/C生态还是更顺手。Rust在内存安全上很吸引人但它的异步生态和底层存储生态相对年轻对于一个需要长期演进的项目团队风险会更高。C的代价是开发效率低、对工程师要求高、内存管理容易出问题。Curve的应对方式是大量复用成熟的基础库把C易错的部分封装起来比如基于Apache brpc这类高性能RPC框架搭建服务骨架避免从零造网络层的轮子。5.2 并发模型bthread、无锁队列与内存池化Curve底层的网络和RPC框架用到了brpc生态中的核心能力。brpc的bthread是一个用户态M:N协程调度器用少量线程承载海量并发请求。每来一个RPC请求不需要创建一个系统线程而是挂到一个轻量级bthread上逻辑上的阻塞等待被调度器自动让渡给其他bthread执行。这就是“同步写法、异步性能”的效果代码写起来比纯异步回调直观得多又不会因为线程数量过多导致频繁上下文切换。存储节点上对内存的管理也比较讲究。频繁的malloc/free会产生内存碎片积累多了性能会劣化。Curve这类系统通常会对关键路径上的缓冲区做池化预先分配一块大内存用的时候从池里取用完还回去。再配合无锁队列做请求的传递减少锁竞争带来的延迟。这类优化在微观上单个点可能只省几十纳秒但架不住每秒百万级请求的次数多累计起来就是质的差距。此外就是NUMA亲和性的问题。在多路服务器上内存访问跨NUMA节点的延迟远高于本地节点。大盘上的ChunkServer通常会绑定CPU核和内存分配策略让读写线程尽量访问本地内存避免跨节点访问。这些细节普通上层应用根本不关心但存储引擎必须在代码里显式处理。5.3 网络与磁盘IO栈上的优化网络层面Curve可以走RDMA把数据从网卡直接搬进用户态缓冲区跳过内核协议栈的拷贝和中断处理。配合它在客户端和ChunkServer之间的连接复用机制大量小IO请求不会为每次连接付出建连和断连的开销。磁盘IO层面可选SPDK这类用户态驱动方案让应用进程直接在用户态操作NVMe SSD。常规的内核块设备层里有中断、上下文切换和驱动路径上的多次复制SPDK把这些绕开把SSD的队列深度跑满。当然并不是所有部署都一定需要SPDK和RDMA在普通千兆网和SATA盘的环境里率先把合并、批处理、WAL刷盘策略做好收益就已经非常可观了。整个C侧的优化给我的感觉是Curve不是在搞花活而是一层一层把硬件的潜力释放出来——RPC框架管好并发内存池管好分配网络栈管好传输磁盘层管好落盘。每一层都抠一点叠加起来就成了高性能底座。6. 落地实践中的排查笔记部署压测与线上问题6.1 最小化部署先把一条完整链路跑通如果只是想学习Curve我建议先不要贪大最小集群就够了一个etcd、三个MDS、三个ChunkServer。官方提供了Docker镜像和部署脚本也有对应的部署文档跟着文档把基础集群拉起来再用管理工具创建卷。我在本地环境直接跑二进制部署最需要注意的点是机器之间的时间同步Raft协议对时钟漂移比较敏感。另外ChunkServer的数据目录要做足空间规划日志和数据并不总在同一块盘最好把WAL数据目录和数据盘分开。创建卷时顺手把快照策略设好后续验证恢复功能会方便很多。挂载的话CurveBS客户端提供了NBD挂载方式。创建完卷之后在业务机上挂载成/dev/设备格式化一下就能当普通硬盘用。到这一步“一条写入请求完整走完客户端、MDS、ChunkServer”才算真正看到雏形。6.2 fio压测关键参数与输出解读部署好之后用fio压一下最能反映效果。操作系统里装好fio一个典型的随机写测试命令大概长这样fio --namerandwrite \ --ioenginelibaio \ --iodepth32 \ --rwrandwrite \ --bs4k \ --direct1 \ --size10G \ --numjobs4 \ --group_reporting这组参数的含义libaio是Linux原生异步IO引擎iodepth32表示同时挂起的IO数bs4k模拟数据库常见的小块随机写direct1绕过操作系统页缓存。跑完之后重点看三块IOPS数字、平均延迟、P99延迟。不要只盯着IOPS高不高分布式存储里P99/P99.9更能反映业务体感P99如果比平均值高出两三倍甚至更多说明长尾问题没有处理好。调整参数时可以试试改变iodepth和numjobs看IO的并发度对延迟的影响。小IO场景下把客户端侧的IO合并开关打开再观察P99变化通常会有惊喜。我见过有人拿Curve和Ceph在同一批机器上做对比小IO场景下两者都能跑出可观的IOPS但看P99延迟图Curve的毛刺要明显更少、更平稳。6.3 线上问题排查从快照变慢到恢复抖动实际使用中总会遇到一些文档里不常见的问题。我记录过几个典型场景这里挑三个分享。第一个是快照数量过多导致写入变慢。COW机制决定了越靠近当前时间的快照越多新写入触发COW的概率越大。遇到这种情况第一选择是清理过期快照并做快照合并把链深度压下来而不是急着调磁盘参数。第二个是后台恢复抢占业务流量。一个节点损坏后恢复任务会自动启动默认节流值可能挡不住某些高IO业务的叠加。这时候可以把恢复速率上限调低先把业务P99保住让恢复任务在业务低峰期抢带宽。分布式系统里“恢复快”和“业务稳”永远存在权衡关键是提供一个可调的旋钮。第三个是MDS切换后个别客户端路由刷新不及时。客户端路由缓存是一把双刃剑缓存太旧就会在MDS切换后产生一段时间的“瞎寻址”。生产实践里给客户端配一个合理的路由失效时间同时让监控系统盯住MDS切换事件一旦发生切换主动触发相关客户端刷新缓存。这个坑在文档里不太起眼但出问题时会非常难查。最后的一点个人体会分布式存储的架构设计本质上是在一致性、可用性、性能和运维复杂度之间找一个动态平衡点。Curve给我的最大启发不是某个具体协议的巧妙而是它敢于做减法把控制面和数据面拆干净用Multi-Raft收敛副本一致性的复杂度把快照、恢复、均衡这些后台动作全部纳入可控的调度框架。读源码和看分享的最大区别就是你会发现很多设计在PPT上只值一张图落地时却要处理大量边界条件Curve在这方面的工程完成度相当高。如果你时间有限我建议先死磕两块内容一块是客户端路由缓存的失效和刷新机制另一块是ChunkServer上Raft日志从接收到apply的完整状态机流转。这两块吃透了Curve整个系统的骨架基本就装进你脑子里了剩下的都是在这副骨架上的血肉填充。C层面那些高性能手段等技术积累到一定程度再回头看理解会完全不一样。
返回列表