接手分布式存储系统设计之前,我一直觉得这是大公司才配考虑的事。直到某次业务里,单库数据量过了几个 TB,备份恢复要一整夜,大促期间主库一抖动全链路跟着抖,才意识到分布式存储不是选修课,而是业务长大的必修课。这篇笔记把我从设计、落地到上线后运维一套分布式存储系统的过程,按分片、副本、一致性、元数据、故障演练、扩容迁移这几个核心点拆开讲,说清楚每一步为什么这么做,以及那些文档里不会写的坑。适合正准备把存储从单机改成分布式、或者已经在维护内部存储系统但经常被故障打懵的工程师参考。
1. 先回答一个反直觉的问题:单机上跑得好好的,为什么要拆开?
1.1 单机天花板:不是容量,而是恢复时间
初见分布式存储,很多人第一反应是“数据量大了所以要多台机器”。这个说法不算错,但只说对了一半。真正逼着你拆开的,不是“存不下”,而是“坏不起”。一块单盘写满 10 TB,从备份恢复全量数据要几小时甚至过夜。如果你对外承诺的是分钟级恢复,单机再怎么调优都做不到。
业务量往往以每年几倍的速度增长,硬件却不会按这个速度便宜下去。更麻烦的是,一次磁盘坏道、一次断电、一次误删,所有数据都压在那台唯一的机器上。出问题的时候,恢复流程再标准,也挡不住物理上的带宽和时间。分布式存储的本质,是把数据摊到多台机器上,让任何一台机器挂了都只是整个系统里的小事件,而不是事故。
单机还有一个被忽略的瓶颈:写入带宽和 CPU 是共享的。索引维护、数据校验、后台压缩都会抢占业务请求的计算资源。磁盘顺序写能达到几百兆每秒,但随机写加上随机读一旦打满,延迟会立刻肉眼可见地恶化。所以当你发现一台机器 CPU 不高、磁盘空间还剩不少,但延迟就是不稳的时候,其实已经在触碰单机存储的系统性瓶颈了。
1.2 属于存储的“不可能三角”:你得先选阵营
分布式系统的取舍,最常挂在嘴边的就是一致性、可用性、分区耐受性这三者不可能同时满格。做存储系统的时候,“分区耐受性”其实没得选:机器之间就是会有断网、丢包、宕机。所以真正的设计选择题,是在“网络出现分区时,优先保一致,还是优先保可用”。
如果一个系统用来记账、扣库存、发订单,我会选保一致性:宁可短暂拒绝写入,也不能让两边算出不同结果。如果是内容平台、监控日志这种允许短暂读到旧数据的场景,可以选保可用,牺牲一下强一致性。这个选择要在架构第一天定好,因为后续的副本机制、读路径、故障处理全部围绕它展开。
这里有个常见误区:以为加副本就能既有一致性又有一致可用。实际上加副本只是增加冗余,真出网络分区时,副本之间一样会产生分歧。你只是把“要不要取舍”从单机问题放大成了多机协调问题。所以设计分布式存储时,第一个要写的不是代码,而是一页纸的“故障状态下的行为声明”:分区时谁能访问、谁会被拒、恢复后以谁为准。我见过太多团队在架构评审时高谈阔论,但一到故障演练就连对外的错误码都定义不清楚,上游调用方只能收到一堆超时,整个排查过程惨不忍睹。
2. 分片策略:一把决定系统后半生命运的钥匙
2.1 区间分片和哈希分片,到底该选哪种
分片(sharding)就是把数据按某种规则切到不同节点上。常见的有按范围切和按哈希切。按范围切最直观:比如用户表按用户 ID 0~1万、1万~2万这样切,某个区间的用户一定在对应的分片里。优点是范围查询友好、实现简单,缺点是数据分布天然不均衡:新用户增长快的区间会立刻变热,量大时容易出现“一片撑爆,其他片空闲”的局面。
按哈希切是把键丢进哈希函数,用散列值映射到分片。例如取用户 ID 的 32 位哈希,再模 256 个分片,数据分布会比范围切均匀很多,也能把热点摊开。代价是范围查询需要跨片扫描,而且后续扩容时,取模规则一变,大量数据要搬迁。
工程里没有绝对的优劣,只有匹配不匹配。我做过的模拟项目X里,订单数据用“按卖家ID哈希分片”,因为订单查询基本都带卖家ID,单卖家的事务也可以锁在单个分片内完成;而日志类数据我反而用了按时间范围分片,因为日志需要按时间段批量清理和归档,范围分片让过期数据直接整片下线,不用每条去扫。
| 维度 | 区间分片 | 哈希分片 |
|---|---|---|
| 数据均匀性 | 容易倾斜 | 整体均匀 |
| 范围查询 | 天然支持 | 需要跨片扫描 |
| 扩容迁移 | 可能只需搬部分区间 | 取模法会大量搬家 |
| 典型场景 | 时间序列、日志归档 | 用户、订单、热点散列 |
2.2 一致性哈希和虚拟节点:解决扩容焦虑的常备方案
普通取模哈希最让人头疼的,是节点数从 5 扩到 6,5 的哈希到 6 的哈希,映射关系几乎全变,差不多 80% 的数据需要搬家。一致性哈希的思路是,把哈希空间看成一个环,节点和数据都落到环上,数据归属到顺时针碰到的第一个节点。增加节点时,只在相邻的区间发生数据迁移,大部分数据原地不动。
但一致性哈希有个新问题:节点少时,环上分布可能极不均匀,两个节点可能把环切得畸轻畸重。于是引入虚拟节点,每个物理节点在环上拥有 100~200 个虚拟位置,数据归属落到某个虚拟节点后,再映射回物理节点。这样既能保证节点故障时数据重分配的均匀性,也让新物理节点加进来时压力分散到多段区间,而不是单节点的邻居。
我在实际落地方案里,没有直接用现成的一致性哈希客户端库,而是在路由层维护了一张“分片到物理节点的映射表”,配合虚拟哈希槽位来做。每个逻辑分区相当于一个大的虚拟节点,数据先按 key 哈希到槽位,槽位再对应具体分片。需要扩容时,不是改哈希公式,而是把一部分槽位从老节点挪到新节点,路由表更新完,数据再按槽位搬迁。这套做法的好处是,任何时刻都清楚“哪部分数据归哪个节点管”,排查数据不一致时容易对齐,而不是玄学式地依赖哈希环天然兜底。
2.3 分片键选择:三个让我返工的坑
分片键选错了,后面所有优化都是白搭。我第一次做方案时拍脑袋选了时间戳做哈希,结果某电商业务大促时,所有写入都集中在最近几分钟,时间戳哈希并没有把“同一个商城的热点 key”拆散,相反热点窗口内产生的数据全落到少量分区上,写得慢不说,还拖累读链路。后来改成“固定前缀 + 时间戳”的组合键,比如订单号前几位加时间分桶,才真正把热点打散。
第二个坑是选了唯一标识做分片键,但业务查询大多不带这个标识。比如把日志按某个计数器 ID 哈希分片,可排查问题时总是按时间范围和业务模块过滤,结果跨片查询成为日常,单次查询要扫 20 个分片,延迟完全不可控。选分片键之前,一定要枚举业务的几个主要访问路径,确保最核心的几条查询都能带得上这个键。
第三个坑是忽略冷热访问差异。同一批数据里,最近一天的订单被访问几百次,去年同期的订单可能一个月都不会被碰一次。如果只按哈希把数据均匀摊开,冷热会混在一起,造成热分片的物理 IO 压力。我的做法是在哈希分片之上,再按时间做二级分区,热数据进入高频片,老数据逐步降级到低频片,虽然路由复杂了一点,但换来的是整体延迟的稳定。
3. 副本复制与一致性落地:多数派机制在工程上是怎么玩的
3.1 为什么3副本是默认答案,而不是2副本或4副本
副本数听起来像配置里可以随便调的数字,实际上是数学约束出来的。要避免网络分区下两份数据同时成为“两个老大”,决策时必须形成多数。单副本没有容错能力,出了故障只能等恢复。两副本遇到节点故障时,剩下那一份无法自己形成多数派,要么拒绝写入,要么只能赌一把,容易脑裂。3副本允许挂 1 个节点后仍能正常选出 leader,继续读写;4 副本的容错能力依然是挂 1 个,却多付出一份存储和带宽成本。
| 副本数 | 容错节点数 | 多数派大小 | 主要问题 |
|---|---|---|---|
| 1 | 0 | 1 | 单点,故障即停服 |
| 2 | 0 | 2 | 任一节点故障就无法形成多数派 |
| 3 | 1 | 2 | 工程默认最优解 |
| 4 | 1 | 3 | 容错不变,成本更高 |
所以大多数分布式存储默认 3 副本,不是拍脑袋,是多数派机制下的最小成本解。当然,如果你做的是只有几十 GB 的测试环境,也可以选 2 副本,但生产环境别省这个成本。延迟可以忍受,数据丢了没法忍。
我还遇到过想用 4 副本做双活数据中心的方案,听起来很美好:每个中心各 2 副本。但真发生跨中心网络分区时,每个中心各有一个 2 副本分组,谁也无法形成多数派,整个服务会锁死。后来改成“3+1”组合:主中心 3 副本,容灾中心 1 个只读副本,日常读流量也能打到容灾中心满足就近读的需求,跨中心故障时才提升为主。这样既保住了多数派,也保住了灾备。
3.2 Raft 在存储节点里的落地:从选举到提交的取舍
副本之间不能各写各的,必须有个协议来定“以谁为准”。在工程界使用最广泛的是 Raft 这种基于领导者(leader)和多数派提交的共识算法。集群里每个节点除了存储状态,还要维护一组协议状态:当前任期号、给谁投过票、已提交日志索引、各副本的匹配索引。节点之间靠心跳互相探测,leader 挂了之后,新的选举需要拿到多数节点的票。
具体写流程大致是:客户端把请求发给 leader;leader 先把日志追加到自己的本地存储,并同时把日志发给所有 follower;follower 本地持久化成功后给 leader 回确认;leader 收到过半确认后,把这条日志标记为已提交,再返回客户端成功。这里有个关键点:commitIndex 决定了哪些日志对读真正可见。读请求如果全部走 leader,可以获得线性一致性,但 leader 的 CPU 和磁盘会被读流量占掉不少;有些系统允许读直接走副本,省性能但会有读到旧数据的窗口。
我在工程里选择的是“写全走 leader,读默认走 leader,允许部分即时性要求不高的场景配置成读副本”。同时用租约机制防止 leader 与多数派失联后仍然对外提供写服务:leader 定期从多数派获得续约,一旦续约失败,它就不再接受写入,避免两个节点同时认为自己是 leader 的脑裂场景。
3.3 读写路径设计:一次写入经过的每一站
一个写入请求从入口到成功,大致踩过的流程是:接入层根据 key 计算出槽位,从元数据路由表找到该槽位目前对应的主节点;请求打给主节点内部的存储引擎,先写预写日志(用于崩溃恢复),再更新内存索引,最后异步执行磁盘数据落盘;主节点同时把日志同步给两个副本节点;副本节点各自异步把日志应用到自己的存储引擎,并向主节点返回确认;主节点收到过半确认后,向接入层返回写入成功。
为什么是日志先落、再索引、再返回?因为预写日志是追加写,顺序 IO 很快,而内存索引结构能不能持久化不影响崩溃恢复:只要日志还在,重启后就能重新回放到内存索引。这里有个容易理解的比喻:日志像是记账流水,索引像是分类账本,流水记录在案后哪怕账本着火了,也能照着流水重做。
读路径相比之下简单一些,核心是回答两个问题:在哪读、读到什么程度的旧数据。对于分片内的主节点读,天然能读到最新;对于副本读,要求返回不能落后超过某个阈值,超过就去主节点读。这样既有性能兜底,又不会让用户看到太离谱的旧数据。我在这个环节加过一个很值得做的优化:副本读到数据后,会顺手把该数据块的校验和也验一遍,发现损坏就从主节点重新拉取,避免把坏数据扩散。
4. 元数据服务:路由表怎么做到既正确又不成为瓶颈
4.1 元数据内容与高可用:全局路由表没有便宜的解
分布式存储除了数据分片,还要维护一份“谁在哪”的信息,这就是元数据。它至少要记录每个分片的唯一编号、分片覆盖的键范围、当前的主节点、备节点列表、节点是否在线、路由表版本号等。这份数据如果放在单机里,等于整个分布式系统唯一的单点:路由表一挂,所有数据访问全停。
元数据服务本身也需要复制和高可用。我在设计里就是用一个小的 3 节点元数据集群来存路由表,更新时走多数派确认,读走主节点。因为路由表数据量不大,不会成为瓶颈,关键是要保证它自身的正确性和可用性。即使业务存储节点发生故障、扩容、重建,元数据都是最先稳定的对象。这里我走过的弯路是:一开始把所有分片的最新状态都放进元数据集群,导致每次心跳更新都产生大量写请求,后来改成只存“路由映射 + 分片角色”,把更细的状态放到各个分片节点自报,元数据集群的压力立刻降了一个数量级。
4.2 两级路由设计与缓存降级:不是所有请求都要查全局
如果每个读写请求都到元数据集群查一次路由,元数据集群的 QPS 会伴随业务流量线性增长,迟早撑不住。我的做法是两级路由:接入层启动时先从元数据集群拉取全量路由表,缓存到本地内存;后续请求直接查本地缓存,命中就不找元数据集群。只有当节点状态发生变更时,元数据集群才广播一次路由表版本号变化,各接入层收到后重新拉取。
缓存最怕的是不一致:比如路由表已经在元数据集群更新了,但某个接入层还拿着旧表,把请求发给已经下线的主节点。我的方案是给每个分片数据带一个“主节点任期号”,旧主节点收到任期号过期的请求时,直接返回一个明确的路由重定向错误。接入层收到该错误后立即刷新路由缓存,重试一次。这样虽然会有一次额外的错误往返,但保证不会把请求闷在一个已失效的节点里。
这里我给一个实际变更流程的简化描述:
- 运维或自动调度模块发起节点下线流程,向元数据集群提交“分片 X 主节点迁移到节点 B”的变更。
- 元数据集群走多数派确认后,持久化新路由,并递增全局版本号。
- 接入层在下次心跳或版本检查时发现版本号不一致,重新拉取路由表。
- 迁移期间,旧主节点针对新任期号的写请求返回重定向错误,强制接入层快速刷新。
- 等接入层路由全部更新后,旧节点上的数据再进入回收流程。
为了防止元数据集群被瞬时请求打崩,我还会让接入层本地缓存带上过期时间:正常场景下路由表几乎不变,缓存有效期可以设 5 分钟;但一旦元数据集群广播版本变更,立刻让缓存失效。最极端场景下,如果元数据集群长时间不可用,接入层保留最近一次可用的全量路由表,并拒绝可能涉及路由变更的操作;读操作若分片没变化还能继续,写操作则快速失败返回“服务暂不可用”的明确错误码,而不是让调用方傻等超时。
5. 故障注入与恢复演练:网络分区和磁盘静默损坏哪个更致命
5.1 网络分区:多数派与少数派的真实行为
网络分区不像断电那样直白,它更像一群人被隔成两个房间,两边的喇叭都正常,但互相听不见。对分布式存储来说,判断分区的关键在于节点的心跳探测和租约机制。如果 leader 发现自己无法与多数派通信,它会降级为少数派节点,拒绝新的写入;多数派一侧会发起新的选举,选出新 leader 继续服务。
我在故障演练里最喜欢做的一件事,就是用防火墙规则把节点 B 和 C、D 之间的网络断开,但 B 和 C 仍然互相同步。这时候观察系统行为:新 leader 应该在 C、D 所在多数派中产生;原 leader 所在的少数派必须立刻停止接受写入;当网络恢复后,旧 leader 发现自己已经落后于多数派,从新 leader 同步日志,并把旧的数据回滚覆盖成多数派的数据。所有这一切,设计时就要定好。
如果少数派在分区期间还继续接受写入,灾难就来了:恢复后两份数据都声称自己有效,无法自动调和。所以在代码里,对 leader 的关键要求是:提交任何日志前,必须先确认自己还在多数派中,这个确认通常靠续约完成。续约失败,节点就进入“疑似失联”状态,直接拒绝写入,哪怕它自己觉得一切正常。第一次做演练时,团队有人觉得这太保守,但我认为宁可错杀,不可放过,因为写错数据的后果比短时间拒绝写入大得多。
演练时还要注意,恢复后的数据回填不是瞬间完成的。旧 leader 追上新 leader 的日志需要时间,期间它的读请求如果打到本地,会读到落后的数据。我的做法是给每个副本加一个可读水位:只有副本的日志同步进度超过一个阈值,才允许对外提供读服务。这样既保证副本最终一致,又不至于在恢复窗口期把旧数据暴露给用户。
5.2 磁盘静默损坏:比宕机更隐蔽的数据杀手
宕机是显性故障,监控一响大家都能看到;磁盘静默损坏是隐性故障,数据读出来还是那个扇区,但内容已经不对了,没有任何报错。最常见的原因包括磁盘坏道导致的位翻转、固件 bug 在写入时静默写错、RAID 卡缓存策略错误。如果没有校验,这些坏数据会被当作正常数据复制到副本里,把整条链路都污染掉。
我的第一版存储引擎就没有做端到端校验,直到某次日常巡检,发现某个分片的校验和对不上,把旁边两个副本拉出来比对,发现有一份副本在几个月前就已经写错了。那一刻才真正理解,为什么所有正经设计都要在写入路径上带上校验和,比如 CRC32 或更强的摘要,并且在读路径上校验。写的时候计算并存储校验,读的时候重新计算比对;副本同步完成后也要校验一遍。
更稳的做法是定期做全量巡检:挨个数据块重读一遍、校验一遍,发现错误块后自动从健康的副本修复。这个全量校验的成本听起来高,实际可以做成低优先级后台任务,在 IO 空闲时段跑。我甚至遇到过一块盘在某天温度升高后开始频繁出现静默错误,就是因为巡检发现了异常块数量陡增,及时把数据迁移到新节点,避免了一次大规模数据损坏事故。
6. 扩容缩容、监控指标与上线一年多以后才懂的道理
6.1 扩容的标准流程:先切路由再搬数据是错觉
很多人在扩容上有个直觉:先把新节点挂上去,然后让后台慢慢迁移数据,等迁移完成再切换路由。这个直觉会坑人:如果先切流量,新节点上的数据还没搬完,请求会得到不完整的答案;如果先等搬完再切路由,又会经历漫长的“只搬不提供服务”窗口。
正确的流程应该是:先把新节点加入路由表,但标记为“迁移中”;然后分批把一部分分片的数据复制到新节点,复制期间旧节点继续对外服务。迁移前对单个分片做短暂的只读锁定,把最新数据同步过去并校验完成后,再更新路由表把该分片的主节点切换为新节点,恢复读写。锁定时间通常在几百毫秒到几秒,对业务可以接受。
我实践下来的细节是:一次只迁一小撮分片,比如先迁 64 个槽位,观察新节点负载和延迟稳定后再迁下一批;迁移过程中持续比较源端和目的端的校验和,确保数据一致。这样即使迁移过程中出了问题,也只是影响一小批分片,不会把整个集群拖垮。老节点是不是要立刻删数据?我的建议是保留一段时间的清理窗口,比如 48 小时后确认新节点稳定后再清,防止迁移完才发现问题却已经过了原始数据。
6.2 缩容与热点:教科书很少讲的场景
扩容大家都会紧张地做,缩容反而容易被忽略,但真实业务里缩容需求并不少见:活动结束、业务下线、成本优化,都要求把节点数量降下来。缩容就是扩容的逆过程,但有个额外风险:被迁出的节点可能在迁移期间持续收到写流量。我的做法是先把该节点上的所有分片角色全部降级为只读,等待迁移完成后,再从路由表里移出节点,最后下线。这保证了该节点在迁移期间不会被写入新数据,也就不会有“边迁边变”的一致性问题。
热点问题更麻烦。哈希分片只能保证整体均匀,保证不了某个明星 key 不把流量打爆。比如某个热门商品详情页的 key 被大量读,它所在分片成为热点,延迟升高。常规的缓解手段是把该 key 复制成多个冷副本,放到不同分片,读请求随机选一个,写操作仍然走原分片,代价是读可能短暂读到旧版本。对这种热点 key 的识别,我会在接入层统计请求 QPS 并按 key 聚合,设置阈值告警,而不是等监控面板红了才发现。
6.3 上线后必盯的五组指标与一次性能抖动的复盘
我经历过最典型的一次线上性能抖动,不是源于单台机器崩溃,而是后台数据整理任务和业务高峰叠加引起的 IO 阻塞。复盘时我们把监控指标拆成五类来盯,后来成了团队的固定动作:
第一类是可用性指标,包括请求成功率、错误码分布、P99 延迟,任何一项突跳都要能自动关联到具体的分片。第二类是分片和一致性指标,包括每个分片的主节点任期、日志提交索引、副本之间落后量,落后量持续上涨往往预示着副本要重建。第三类是存储节点资源指标,CPU、磁盘使用率、磁盘队列深度、网络重传率,磁盘队列深度是最容易被忽略的,它比 CPU 更早反映 IO 压力。第四类是元数据集群指标,路由表版本号变更频率、元数据请求错误数。第五类是后台任务指标,数据整理、迁移任务、全量巡检的进度和频率。
那次抖动的原因后来定位很简单:数据整理任务设在凌晨两点统一执行,但恰好业务在做夜间结算任务,把磁盘的随机写打满。我的修复不是改任务时间,而是给数据整理任务加了一个自适应限速:根据当前磁盘队列深度动态调整整理并发度,队列深到阈值就自动暂停,让位给业务请求。从那以后,后台任务和业务流量之间的冲突基本没有再造成明显故障。
我后来还养成了一个习惯,每次架构评审必问一个问题:这个设计在什么情况下会挂?如果回答不上来,说明风险还没想清楚。存储系统永远有故障,设计的目标不是让故障不发生,而是让故障发生后的影响面可控,恢复路径清晰。分布式这条路没有终点,上线的第一天才是真正开始和它相处的日子。