资讯动态

分布式系统设计入门:一致性、多数派、分片与容错实践

发布时间:2026/10/27 6:56:56 来源:尧图企业网站定制
第一次被问“你们这套系统怎么保证数据不丢”的时候我脑子里只有“加机器”三个字。后来做容量规划、做故障演练、做数据修复才慢慢意识到分布式系统这门功课真正的门槛不在“会不会用某个中间件”而在于你能不能在脑子里面建起一张图请求从入口进来经过几次网络跳转数据落在哪几个副本上其中某一段断了以后系统会退化成什么样。这张图画不出来看再多架构图也只是背单词。这篇是我在系统设计学习路线里整理的第一部分专门聊分布式系统。它适合两类人一是刚开始准备系统设计相关交流、想从零建立知识框架的同学二是已经在写业务代码但对自己负责的服务“为什么这么设计”说不清楚的开发者。我会把单机到分布式的动机、一致性语义的拆解、多数派与选主的推导、分片路由的取舍、超时重试这套组合拳以及一条能落地的学习路径按我理解的顺序铺开。1. 从一台机器到一堆机器分布式到底在解决什么很多人学分布式是从“CAP 定理”开始的我觉得这个顺序有点问题。定理是结论结论脱离动机就容易变成口号。更自然的入口是先搞清楚一台机器到底在哪几个具体时刻撑不住了以及拆成多台之后我们付出了什么。1.1 单机到顶的五个信号比“性能不够”具体得多“性能不够”这个说法太模糊落到实操上我一般会盯五个具体信号。第一个是入口连接的并发数不是 QPS 高就一定要拆很多时候是长连接把文件句柄耗尽了单机默认的文件句柄上限是个硬约束调参能解决一部分但连接数增长是线性的早晚还是会撞墙。第二个是单表数据量当一张表涨到几千万行、索引层数变深之后即使命中索引随机 IO 的次数也会上升查询延迟的 P99 会先于平均值恶化这个信号比平均值敏感得多。第三个是写入放大日志、索引、副本带来的多份写入会让磁盘带宽先于容量见底。第四个是发布窗口单机部署意味着每次上线都得停服或者做很别扭的热加载业务一旦要求 7×24 小时不间断发布这件事本身就成了拆分的驱动力——这一点常被忽略但在我经历过的几次拆分里它是真实的主因之一。第五个是故障半径单机宕机等于全站不可用这个风险不是性能问题是可用性问题两类问题经常被混在一起讨论导致方案选型跑偏。把信号区分清楚的价值在于如果只是连接数问题优化参数或者加一层连接复用就够如果是故障半径问题那你需要的不是“更快”而是“冗余”。这两个方向对应的架构改动完全不同一个是纵向加深一个是横向铺开。1.2 拆分不是免费的一份常被低估的代价清单我见过太多讨论只讲收益不讲成本。把单体拆开之后你立刻会面对一组新的问题而且这些问题没有一个是“配一下就好”的。代价项具体表现为什么麻烦网络不可靠调用可能超时、乱序、重复、只成功一半单机里函数调用要么成功要么抛异常边界清晰部分失败A 服务成功、B 服务失败整体状态处于中间态没有全局事务就很难回滚干净时钟不同步两台机器的本地时间可能差几百毫秒用时间戳排序事件会出现“因果倒置”调试成本一次请求跨 5 个服务日志分散定位问题需要链路追踪不能靠单机断点运维复杂度部署单元从 1 个变成 N 个配置项翻倍环境一致性、灰度策略都要重新设计我最想强调的是部分失败这一条。单机程序里“写了一半”这种情况通常是进程崩溃才会出现而进程崩溃后整个上下文都没了重启后状态是干净的。分布式里不一样写请求发出去对方处理成功了但你这边没收到响应你不知道该重试还是该放弃。这种“不确定态”是分布式系统里一切复杂度的源头后面讲到的幂等、重试、对账本质上都是在处理它。还有一条隐性成本认知负担。单机时代一个新人接手项目读代码就能大致明白数据流向拆成微服务之后他得先搞清楚服务拓扑、调用链、每个服务的职责边界才能看懂一次业务请求。所以我一直主张拆分的粒度应该由团队的运维能力和组织边界决定而不是由技术时髦度决定。三个人的团队维护十五个服务收益大概率是负的。2. 把“一致性”这个词拆开看三种经常被混用的语义“一致性”是我见过被滥用最严重的词。开会时有人说“我们这里要保证一致性”你得先追问一句你说的是哪一种因为至少有三个不同的东西都叫这个名字而它们的实现方式和代价差了一个数量级。2.1 副本一致性、事务一致性、线性一致性别混着说副本一致性讲的是同一份数据存在多个副本时各个副本之间的数据差异。这里有个关键指标叫复制延迟主库写完从库可能 50 毫秒后才看到。如果你的业务是“下单后立刻跳转到订单详情页”用户可能在详情页看不到刚下的单这就是典型的复制延迟暴露到了业务层。解决办法通常不是“消除延迟”而是读己之写把该用户的读请求路由到主库或者在一段时间内粘住同一个副本。事务一致性讲的是多个数据项之间的约束是否同时生效比如转账时 A 扣钱和 B 加钱必须一起成功。这个在单库靠事务保证跨库就得靠两阶段提交、补偿事务或者最终一致的方案。这里我踩过一个坑早期我以为“用了消息队列就能保证最终一致”后来发现如果本地事务提交成功但消息发送失败或者消息发成功了但消费方处理失败两端状态就对不上。必须要有本地消息表这类机制把“业务写入”和“消息记录”放在同一个本地事务里再靠后台任务补发。线性一致性是最强的一种它要求整个系统对外表现得像一台单机任何时刻所有客户端看到的写顺序是一致的而且读操作一定能读到之前的写。这个语义的实现代价非常高需要用共识协议或者单一主节点串行化所有写操作。很多系统号称“强一致”实际上是“读主库”在单副本读的情况下确实接近线性一致但一旦涉及多副本读、跨区域部署就要打问号。我判断一个系统的一致性水平一般会问三个问题写操作在几个副本上确认才算成功读操作从哪个副本读跨区域的复制是同步还是异步这三个答案基本能定位它在一致性和延迟之间的位置。2.2 CAP 被讲烂之后真正该记住的是权衡维度CAP 定理说在发生网络分区时一致性和可用性只能二选一。这话没错但它的实用价值被高估了。原因是真正的分区不是“有或没有”而是“持续多久、影响多大”。一个 200 毫秒的丢包和一个 20 分钟的网络隔离是完全不同的两件事。我更愿意用另外两个维度来思考延迟和可用性。当你想让写操作在多数副本上确认时你就把单次写的延迟拉到了最慢那个副本的水平当你想让读操作不落后太多时你就得同步复制代价是写入吞吐下降。这些取舍在稳定期也一直存在不必等到分区发生。一个具体例子三副本的存储系统写成功需要 2 个副本确认。如果三个副本分布在同一机房的三个机架上跨机架的网络往返可能是 0.5 毫秒写延迟就是基线加 0.5 毫秒几乎无感。如果三个副本分布在三个城市往返 30 毫秒写延迟就变成基线加 30 毫秒对高并发写入场景是灾难。所以“同城三机房”和“异地多活”不是同一类问题把它们放在一张架构图里讨论的人往往没算过这笔账。另外还有一条常被忘记的CAP 里的 C 指的是线性一致性不是“不丢数据”。如果你的业务诉求只是“数据最终别丢”那用异步复制加分片日志就能满足不需要付出线性一致的代价。需求识别错了方案就会过度设计。3. 共识与选主多数派为什么能救命讲到副本就绕不开“多数派”。这个词在教材里出现得太轻描淡写但它背后的推导其实很有意思理解了推导你就知道为什么副本数推荐奇数为什么 5 个节点能容忍 2 个故障而不是 3 个。3.1 从“一半以上”推出容错能力设定总节点数为 N写操作需要 W 个节点确认读操作需要 R 个节点响应。要保证“读到的一定是最新写入”必须满足 W R N。这个不等式是多数派机制的核心。取最小值让 W R ⌊N/2⌋ 1也就是“半数以上”。为什么是这个值因为任何两个“半数以上的集合”必然有交集交集里的那个节点同时知道最新的写和最新的读一致性就成立。再推容错能力假设最多有 f 个节点同时故障宕机或者失联为了保证剩余节点还能凑出多数派需要 N - f ≥ ⌊N/2⌋ 1解出来就是 N ≥ 2f 1反过来 f ≤ (N-1)/2。代入具体数字就清楚了节点数 N写/读多数派可容忍故障数 f说明220任一节点故障即无法达成多数派无容错321最常见的入门配置431比 3 节点多花一台机器却不多容错不划算532跨可用区部署的常见选择743容错更高但写延迟受最慢节点影响更明显这张表解释了为什么生产环境推荐 3 或者 5 而不是 4 和 6。偶数的第 N 台机器不增加容错只是在故障时多一个“陪跑”的。我在一次容量评审里就见过这样的方案四副本部署运维成本高了 33%容错能力和三副本一样方案被推翻重做。顺带说一个容易混淆的点多数派不保证不丢数据只保证在满足条件时能达成一致。如果 W 1写只落在一个节点上那台机器一挂数据就没了虽然它仍然可能满足 W R N取决于 R 取多大。所以选择 W 和 R 的时候先想清楚你的核心诉求是“读快”还是“写稳”。3.2 脑裂、租约和那些必须调的超时参数脑裂是分区场景下的经典问题网络断开后两个分区的节点都以为对方挂了各自选出主节点于是出现两个主。多数派机制天然能防住这个——只有拿到多数派投票的节点才能成为主而两个分区不可能同时拥有多数派因为多数派集合必须相交。这就是为什么“法定人数”这个概念在选主里这么关键。但多数派只解决“谁是主”不解决“旧主还活着并且还在接受写请求”的问题。旧主可能在分区里继续对外服务直到它的租约过期。租约机制就是给主节点一个有期限的授权期限之内它认为自己合法超过期限就自动降级。这里的核心参数是租约时长它受限于两台机器之间的时钟漂移上界。我在实践中总结的一组参考值仅供起步必须按实际网络压测调整心跳间隔50~100 毫秒选举超时心跳间隔的 3~5 倍且必须加随机抖动比如 300 毫秒上下浮动 ±30%租约时长选举超时的 2 倍以上时钟漂移预算留出至少 1.5 倍的实测漂移量作为安全余量随机抖动这一条特别重要。如果所有从节点用完全相同的超时时间主节点一挂它们会在同一时刻集体发起选举票数被瓜分谁也拿不到多数派然后一起等下一个超时系统出现周期性的“选举风暴”。加随机化能把这个同步性打散。还有一个我吃过亏的地方超时设得太小比设得太大更危险。曾经因为一次跨机房网络抖动把某个服务的超时从 2 秒调到 200 毫秒结果抖动期间大量请求超时触发重试重试流量把下游压垮雪崩。后来复盘正确的做法是先把重试关掉观察真实延迟分布再定超时。4. 数据分片与路由一致性哈希不是万能药单机放不下就要分片分片就要解决“一条数据该落在哪个节点”和“节点变动时怎么迁移”这两个问题。很多人一上来就说“用一致性哈希”但一致性哈希解决的主要是第二个问题第一个问题它并不比取模更好。4.1 取模、哈希环、预分桶三条路各自的代价取模最简单hash(key) % N。它的优点是路由计算 O(1)数据分布均匀。缺点是 N 一变几乎全部 key 的归属都会改变。算一下从 10 个节点扩到 11 个只有那些hash % 10 hash % 11的 key 会留在原节点比例大约是 1/11 ≈ 9%也就是说 91% 的数据需要搬迁。这个量级在生产上基本等于“全量重刷”非常痛。一致性哈希把节点和 key 都映射到一个环上key 顺时针找到第一个节点。节点增加时只有落在新节点和它前一个节点之间的那段环上的 key 需要迁移。10 个节点扩到 11 个理论迁移量是 1/11 ≈ 9%。这就是它的价值。但它有两个副作用一是需要虚拟节点来保证均衡一个物理节点通常要映射成几十到几百个虚拟节点否则环上的分布会非常不均匀二是路由需要维护一份环的元数据客户端要么本地缓存有一致性风险要么每次查询有延迟成本。预分桶也叫虚拟槽是把哈希空间预先切成固定的份数比如 1024 个槽然后把槽分配给节点。10 个节点时每节点 102 槽扩到 11 个节点时从每个老节点匀出几个槽给新节点迁移量可以精确控制到槽的粒度。这个方案的优点是迁移量可计算、可分批、可暂停运维友好度最高。代价是槽数量固定如果未来节点数可能增长到接近槽数就要提前规划一个更大的槽空间。方案扩容迁移量10→11 节点是否需要虚拟节点迁移可控性适用场景取模约 91%不需要差节点数基本固定的小规模场景一致性哈希约 9%必须中节点动态变化、缓存类场景预分桶1024 槽约 9%但可按槽分批不需要好存储类、需要精细运维的场景我的选择倾向是如果是缓存这类“丢了还能重建”的数据一致性哈希够用简单如果是持久化存储选预分桶因为你需要“迁移到一半出问题能停下来”这个能力。4.2 迁移过程中的双写、校验与回滚分片方案定下来只是开始真正难的是迁移过程。我参与过的一次迁移方案本身没问题问题出在执行细节上。第一件事是双写窗口。在数据真正搬到新节点之前需要让写请求同时写到老位置和新位置这样才能保证迁移期间不丢更新。双写的代价是写延迟上升、失败率翻倍两个地方都可能失败。我一般会把双写窗口压得很短并且在这段时间内把非核心的批量任务停掉避免写入量叠加。第二件事是存量数据的搬移与校验。搬完之后不能只看“搬了多少条”必须做内容校验。常见的做法是对每批数据算一个摘要比如按主键排序后对字段做哈希源端和目标端各算一次比对不一致的批次。我见过只比对条数的迁移结果有几百条数据字段错位条数一模一样问题在业务侧暴露了两周之后才被发现。第三件事是读切换的顺序。安全的顺序是先双写再迁存量再校验然后按小比例把读流量切到新位置观察错误率和延迟逐步放大最后停止老位置写入。中间任何一步出问题都能通过“把读切回去”来兜底因为双写期间两边数据是一致的。第四件事是回滚预案。回滚不是“把开关拨回去”这么简单如果双写已经停掉、老位置的数据已经落后切回去就会丢数据。所以我的原则是双写必须在观察期结束之后才能停观察期至少覆盖一个完整的业务周期比如包含一次日终处理。5. 通信与失败处理超时、重试、幂等这套组合拳跨进程调用跟函数调用最大的区别就是你永远无法确定对方到底有没有执行。这个不确定性没法消除只能通过工程手段收敛。超时、重试、幂等这三件事必须一起设计单独看任何一个都会出问题。5.1 超时时间不是拍脑袋定的是算出来的超时时间的下限由什么决定由下游的真实延迟分布决定。假设下游的 P99 是 200 毫秒P999 是 800 毫秒如果你把超时设成 300 毫秒那大约 1% 的请求会超时。对电商的下单接口1% 的超时率是完全不可接受的。我的做法是先测分布再定值大致公式是超时时间 下游 P999 网络抖动余量 重试预留其中“重试预留”取决于你打算重试几次。如果上游的总超时预算是 1 秒你打算给这个下游最多 2 次尝试那单次超时最多设 400 毫秒左右还要给编排逻辑留一点时间。这条“预算倒推”的规则很关键否则每个下游都按自己的 P999 设超时串起来的总延迟会失控。另一个坑是超时级联。A 调用 B超时 500 毫秒B 调用 C超时 500 毫秒如果 B 在 490 毫秒时才收到 C 的超时它自己已经快超时了根本没机会做降级。正确的做法是逐层递减A 给 B 的超时是 500 毫秒B 给 C 的超时应该是 300 毫秒左右留出处理余量。这个比例我一般按每层留 30%~40%。5.2 重试放大、退避抖动与幂等键重试最容易被忽略的风险是流量放大。假设一个服务有 3 层调用链每层失败重试 3 次最坏情况下一次用户请求会变成 27 次底层调用。如果底层已经很吃力这个放大效应会直接把它打死。这就是“重试风暴”。控制手段有三条只在最外层重试。内层失败向上抛由统一的入口层决定要不要重试避免每层都重试。指数退避加随机抖动。等待时间是 100 毫秒、200 毫秒、400 毫秒这样递增并乘一个 0.5~1.5 的随机因子防止大量请求在同一时刻再度涌向已经恢复的下游。设置重试预算。给每个下游分配一个“重试额度”比如正常流量的 10%超出就不再重试直接快速失败。重试必须配幂等否则“超时了重试一次”会导致重复扣款、重复下单。幂等的实现方式我一般用幂等键客户端在发起请求时生成一个全局唯一的键比如业务流水号加时间戳服务端在本地事务里记录这个键重复请求直接返回第一次的结果。关键是这个“记录”和“业务写入”必须在同一个本地事务里否则又会出现“键写了业务没写”或者反过来。这里有个细节要注意幂等记录表会一直增长必须有清理策略。我一般按业务容忍窗口来定比如支付场景保留 24 小时超过就归档。窗口期太短跨天的重试就防不住太长表会撑爆。这个值必须和业务方确认不能由技术单方面拍。6. 学习路径怎么把概念变成能画出来的方案概念看懂了不等于会用。我自己走过一段“书上看明白了、面试能说两句、真动手还是懵”的弯路后来发现症结在于缺一个“把方案写出来并接受质疑”的环节。下面这套安排是我调整之后觉得比较有效的四周一轮。6.1 一份四周可执行的学习安排周次主题具体动作产出物第一周建立失败模型把网络延迟、丢包、节点宕机、时钟漂移四类故障各写一段模拟描述说明业务会变成什么样一页故障影响清单第二周副本与共识手写一个三节点的多数派读写模拟内存版即可验证 WRN能跑通的模拟代码第三周分片与迁移用 1024 个槽、10 个节点做一次扩容到 11 个节点的迁移演练统计实际搬迁比例迁移量与耗时的实测表格第四周端到端设计选一个自己熟悉的业务比如订单、库存、消息画出完整的数据流与失败分支一张能讲 15 分钟的架构图第一周容易被跳过但我觉得它最重要。因为分布式系统的绝大多数设计决策本质上都是对某类故障的回应。你说不出要防什么故障就选不出合适方案。我会要求自己把“如果这台机器现在拔电会发生什么”这个问题对每个组件都回答一遍。第二周的模拟代码不需要复杂用一个字典模拟三个节点写操作随机选两个节点写入读操作随机选两个节点返回最新的值然后人为让其中一个节点“失联”观察读写是否还能达成一致。跑一遍这个程序你对“多数派”的理解会比看十篇文章都深。第三周的重点在统计实际搬迁比例。理论值是 9%但实际会因为数据分布不均、槽分配策略、迁移顺序而偏离。我做过一次演练实测搬迁量是 11%原因是某些槽的数据量明显偏大。这个偏差必须提前知道否则容量规划会算错。6.2 验证环节画图、压测和“故意搞坏”方案写出来之后必须有一个验证环节。我的三步法是画图、压测、搞坏。画图不是画漂亮的架构图而是画数据流图只标数据从哪来到哪去、在哪几个地方落地、每个落地点的副本数是多少。画的过程中经常发现自己说不清某条链路那就是知识盲区。我一般要求自己能在白纸上不看资料画出核心链路画不出来就说明没真懂。压测的重点不是峰值 QPS而是延迟分布和故障注入下的表现。我会重点看三个数P50、P99、P999。P50 好看不代表系统健康P999 才暴露长尾问题而长尾往往就是那些“刚好碰上副本切换”“刚好触发了一次主从同步”的请求。“搞坏”是最有意思的一步手动杀掉一个副本、手动制造 200 毫秒的网络延迟、手动把某台机器的时钟往前拨几秒。观察系统的反应请求失败率上升多少多久恢复有没有数据不一致这一步能暴露大量纸面上看不出来的问题。我印象最深的一次是拨快时钟结果基于时间戳的幂等判断直接失效重复请求被当成了新请求。这个问题在正常环境下永远测不出来。最后分享一个我自己的判断标准如果你能对着一个没参与过这个项目的同行用十分钟讲清楚“这套系统在什么情况下会出问题、出了问题怎么兜底”那你对这个系统的理解就到位了。讲不清楚的部分就是你下一步该补的部分。

读完文章,也想定制专属网站?

尧图设计师 24 小时内与您沟通定制方案

免费获取报价 →
↑