Module 3: 数据分区与复制
📖 深度参考手册 — 本模块属于理论参考,非主线必读。 主线学习路径见 README.md。 当你在项目实战中遇到相关问题时,回来查阅。
来源:DDIA Ch5 (Replication), Ch6 (Partitioning)
数据复制和分区是分布式系统的两大基石。复制解决的是”同一份数据放在多个地方”,分区解决的是”不同数据放在不同地方”。两者经常组合使用,共同支撑系统的可用性、性能和可扩展性。
第一部分:数据复制 (Replication)
Section titled “第一部分:数据复制 (Replication)”3.1 为什么要复制
Section titled “3.1 为什么要复制”定义:数据复制是指将同一份数据存储在多个不同的节点(通常是不同的物理机器)上。复制的核心目标有三个:提高可用性(某个节点挂了数据不丢)、降低读取延迟(用户就近读取最近的副本)、提高读吞吐量(多个副本分担读请求)。
为什么重要:在现实世界中,机器故障是常态而非例外。硬盘会坏、网络会断、机房会停电。如果你的数据只有一份,任何故障都意味着服务中断。复制是构建可靠系统的第一道防线。
案例:YouTube的视频元数据(标题、描述、播放量、评论数)需要被全球数十亿用户读取。如果只存在美国的一个数据库里,亚洲用户每次打开视频都要跨太平洋请求,延迟高达200ms以上。通过将元数据复制到全球多个区域的副本,用户可以从最近的副本读取,延迟降至个位数毫秒。
先想一想 🤔 YouTube的视频文件本身(几GB的二进制大文件)也用数据库复制来分发给全球用户吗?如果不是,它用什么方式?
点击查看解析
不是。视频文件这种大体积静态内容用CDN分发,而不是数据库复制。CDN将视频缓存到全球边缘节点,用户从最近的CDN节点拉取。数据库复制适合的是结构化的元数据(标题、播放量等),它们体积小但读取频繁,需要强一致性保障。两者是互补关系:CDN处理大文件分发,数据库复制处理元数据一致性。
3.2 主从复制 (Leader-Follower)
Section titled “3.2 主从复制 (Leader-Follower)”定义:主从复制是最常见的复制架构。系统中有一个主节点(Leader)负责处理所有写请求,一个或多个从节点(Follower)从主节点复制数据变更日志(replication log)。读请求可以发往主节点或任意从节点,写请求只能发往主节点。
为什么重要:主从复制是实现”读写分离”的基础。在大多数系统中,读请求远多于写请求(比如新闻信息流,写入一条帖子可能触发数百万次读取)。通过增加从节点,可以线性扩展读吞吐量,而不需要对写入路径做任何改动。
案例:News Feed系统中,用户发布一条帖子时,写入操作发送到主数据库。用户的数百万粉丝刷信息流时,读请求被分发到多个从数据库。假设有5个从节点,读吞吐量就是单机的5倍。主节点只需承担写入压力,不会被海量读请求压垮。
先想一想 🤔 如果News Feed的主节点挂了,系统该怎么办?从节点能自动”升职”成主节点吗?这个过程有什么风险?
点击查看解析
可以进行"故障转移"(failover):选择一个数据最新的从节点提升为新主节点。但这个过程有几个风险:(1) 如果用异步复制,被提升的从节点可能丢失了主节点最后几秒的写入;(2) 如果有多个从节点同时认为自己应该成为主节点,会出现"脑裂";(3) 正在进行的写入请求可能丢失或重复。所以生产环境通常使用成熟的共识算法(如Raft)来管理主节点选举。
3.3 同步复制 vs 异步复制
Section titled “3.3 同步复制 vs 异步复制”定义:同步复制是指主节点在收到写请求后,等待所有(或指定数量的)从节点确认已收到并写入数据后,才向客户端返回成功。异步复制是指主节点写入本地后立即返回成功,从节点在后台异步拉取数据变更。半同步复制是折中方案:至少等一个从节点确认。
为什么重要:这是分布式系统中最经典的权衡之一——一致性 vs 可用性/延迟。同步复制保证了强一致性(不会丢数据),但代价是写入延迟增加、可用性降低(任何一个从节点慢了或挂了都会阻塞写入)。异步复制写入快、可用性高,但可能丢失最近的写入。
案例:Hotel Reservation系统中,当用户预订最后一间客房时,库存扣减操作必须使用同步复制。原因是:如果用异步复制,主节点扣减了库存并返回”预订成功”,但这个变更还没同步到从节点。此时另一个用户从从节点读到”还有1间房”,又预订了同一间房——超卖了!同步复制确保所有副本都确认库存变更后才返回成功,从根本上杜绝超卖。
先想一想 🤔 如果Hotel Reservation全部使用同步复制,每次写入都要等3个副本确认,一个副本在另一个城市延迟50ms。用户体验会怎样?有没有更好的方案?
点击查看解析
纯同步复制下,每次预订至少要等50ms以上(网络往返),在高并发场景下还可能更久。更好的方案是"半同步复制":主节点加一个同城从节点用同步复制(延迟<1ms),其他跨城从节点用异步复制。这样既保证了至少两份数据一致,又不会因为跨城延迟拖慢用户体验。另外,对于库存这种关键操作,可以在应用层用分布式锁或乐观并发控制来额外保障,不完全依赖复制层。
3.4 复制延迟问题
Section titled “3.4 复制延迟问题”定义:在异步复制架构中,从节点的数据可能落后于主节点。这个时间差就是”复制延迟”(replication lag)。复制延迟会导致三类经典问题:(1) 读己之写(Read Your Own Writes)——用户刚写入的数据,自己刷新后看不到;(2) 单调读(Monotonic Reads)——用户连续两次读取,第二次反而看到更旧的数据;(3) 一致前缀读(Consistent Prefix Reads)——因果相关的写入在读取时顺序颠倒。
为什么重要:复制延迟在正常情况下可能只有几毫秒,用户无感知。但在主节点负载高、网络抖动或从节点故障恢复时,延迟可能飙升到几秒甚至几分钟。这时用户体验会出现诡异的bug,而且很难复现和调试,因为”有时候正常有时候不正常”。
案例:Chat System中,用户Alice发送了一条消息”明天见!“,写入主节点成功。Alice刷新页面,这次读请求被路由到一个复制延迟2秒的从节点——她看不到自己刚发的消息!她以为发送失败,又发了一遍,结果对方Bob收到了两条”明天见!“。解决方案是”读己之写”一致性:在用户写入后的短时间内(比如10秒),将该用户的读请求强制路由到主节点。
先想一想 🤔 “单调读”问题是怎么发生的?在Chat System中会造成什么奇怪的现象?
点击查看解析
单调读问题发生在用户的连续请求被路由到不同从节点时。假设从节点A延迟1秒,从节点B延迟3秒。Alice第一次请求到A,看到了直到1秒前的所有消息;第二次请求到B,看到的是3秒前的状态——一些刚才看到的消息"消失"了。在聊天中这非常诡异:消息先出现后消失,再刷新又出现。解决方案是"会话粘性"(session stickiness):确保同一个用户的所有读请求都路由到同一个从节点。
3.5 多主复制
Section titled “3.5 多主复制”定义:多主复制(Multi-Leader Replication)允许多个节点同时接受写入请求。每个主节点在本地处理写入后,将变更异步复制给其他主节点。典型场景包括:多数据中心部署(每个数据中心有一个主节点)、离线客户端(每个设备是一个”主节点”)、协同编辑。
为什么重要:当你的用户分布在全球、或者需要在离线环境下工作时,单主复制的延迟和可用性都无法接受。多主复制让每个写入都在本地完成,延迟极低,而且某个数据中心挂了不影响其他数据中心的写入能力。代价是必须处理写写冲突。
案例:Google Drive允许用户在多个设备上离线编辑文档。你在飞机上用笔记本编辑了一个文件,同时你的手机在地面上也被同事通过共享链接编辑了。两个设备都在本地完成了写入(各自是一个”主节点”)。等你的笔记本联网后,两端的变更需要同步和合并。这就是多主复制的经典应用场景。
先想一想 🤔 为什么大多数数据库不推荐在同一个数据中心内使用多主复制?
点击查看解析
在同一个数据中心内,网络延迟很低(<1ms),单主复制的写入延迟完全可以接受。多主复制引入了冲突处理的复杂性,而这些复杂性在单数据中心场景下没有任何收益。多主复制的价值在于跨数据中心(跨城、跨国)时避免跨WAN的写入延迟。如果没有跨数据中心的需求,使用多主复制是在"没有收益的情况下增加了巨大的复杂性"。
3.6 冲突解决策略
Section titled “3.6 冲突解决策略”定义:当两个主节点同时修改了同一条数据时,就产生了写写冲突。常见的冲突解决策略包括:(1) Last-Write-Wins(LWW)——按时间戳取最后一个写入,简单但会丢失数据;(2) 合并——尝试自动合并两个写入(比如文本的字符级合并);(3) 自定义解决——把冲突抛给应用层或用户来决定保留哪个版本;(4) CRDT(无冲突复制数据类型)——用特殊的数据结构保证自动合并不冲突。
为什么重要:冲突解决策略的选择直接影响数据正确性和用户体验。LWW最简单但最危险——你的修改可能被静默覆盖而你完全不知道。好的冲突解决策略要么避免数据丢失,要么让用户明确知道冲突存在并选择如何处理。
案例:Google Drive中两人同时编辑同一文件。Alice把标题改成”Q1报告”,Bob把标题改成”2024报告”。如果用LWW,按时间戳Bob的修改晚0.1秒,Alice的修改被丢弃,她完全不知道。Google Docs的实际做法是使用OT(Operational Transformation)或CRDT,将两人的操作在字符级别合并。如果冲突无法自动解决(比如两人改了同一个单词),会提示用户手动选择。
先想一想 🤔 为什么”Last-Write-Wins”的时间戳在分布式系统中不可靠?
点击查看解析
分布式系统中,不同机器的时钟无法完全同步。即使使用NTP同步,误差也可能达到几十毫秒。这意味着"谁先谁后"的判断可能是错误的:Alice实际上先写入,但她的机器时钟比Bob的慢100ms,LWW反而会保留Bob的写入、丢弃Alice的。更极端的情况是时钟回拨(NTP调整时间向前或向后跳变)。所以在需要精确排序的场景中,应该使用逻辑时钟(Lamport时间戳或向量时钟)而非物理时钟。
3.7 无主复制 (Leaderless)
Section titled “3.7 无主复制 (Leaderless)”定义:无主复制(Leaderless Replication)没有主从之分,客户端将写请求发送给多个节点,读请求也从多个节点读取。通过法定人数(Quorum)来保证一致性:如果有N个副本,写入需要W个节点确认,读取需要从R个节点读取。只要W + R > N,就能保证读到最新值。这是Amazon Dynamo论文提出的架构,Cassandra和Riak等数据库采用此模型。
为什么重要:无主复制的最大优势是高可用性和写入容错——没有单点故障,任何一个节点挂了,写入和读取都不受影响(只要法定人数满足)。代价是一致性保证比主从复制弱,需要额外机制(如读修复、反熵)来让副本最终趋于一致。
案例:Gaming Leaderboard场景中,全球多个区服的玩家同时上报分数。使用无主复制(如Cassandra),每个分数写入会发送到3个节点(N=3),需要2个节点确认(W=2)。读取排行榜时也从2个节点读取(R=2),对比后取最新值。即使某个节点短暂宕机,分数上报和排行榜查询都不受影响。
先想一想 🤔 在无主复制中,如果设置W=1, R=1(都只要1个节点),会怎样?如果设置W=N, R=1呢?
点击查看解析
W=1, R=1: 写入和读取都极快(只要1个节点响应),但W+R=2 ≤ N,不满足法定人数条件,可能读到旧数据。适合对一致性要求极低、追求极致性能的场景。W=N, R=1: 写入要所有节点确认(类似同步复制,慢且脆弱),但读取只需1个节点(快)。适合写少读多且需要强一致性的场景。但任何一个节点挂了,写入就完全阻塞——牺牲了无主复制最大的优势:高可用性。
第二部分:数据分区 (Partitioning)
Section titled “第二部分:数据分区 (Partitioning)”3.8 分区策略:按Key范围 vs 按Key哈希
Section titled “3.8 分区策略:按Key范围 vs 按Key哈希”定义:数据分区(也叫分片/Sharding)是将一个大数据集拆分到多个节点上,每个节点只存储一部分数据。两种基本策略:(1) 按Key范围分区——比如A-F在节点1,G-M在节点2,保留了key的排序关系,支持范围查询;(2) 按Key哈希分区——对key做哈希运算,按哈希值分配到节点,数据分布更均匀,但丧失了排序能力。
为什么重要:分区是水平扩展的基础。当单台机器无法存储或处理所有数据时,必须分区。选择哪种分区策略直接影响查询模式:如果你的业务需要大量范围查询(“查询2024年1月到3月的所有订单”),按Key范围分区更高效;如果你更关心负载均匀,按Key哈希分区更安全。
案例:URL Shortener系统中,短链接的shortCode(如”abc123”)是主键。由于shortCode是随机生成的字符串,不存在范围查询需求(不需要”查询abc开头的所有短链接”),但需要均匀分布请求(避免某个节点过热)。所以按shortCode哈希分区是最佳选择——哈希函数天然保证了随机分布,每个分区承担大致相等的请求量。
先想一想 🤔 如果URL Shortener改用按Key范围分区(按shortCode字母序),会出什么问题?
点击查看解析
如果shortCode是顺序生成的(如自增ID转base62),那么最新生成的短链接都集中在最后一个分区,导致写入热点。即使shortCode是随机的,按字母范围分区在某些前缀上可能不均匀(取决于字符集分布)。更关键的是,URL Shortener完全不需要范围查询,所以按范围分区没有任何收益,只有热点风险。按哈希分区是明智的选择。
3.9 热点问题与解决
Section titled “3.9 热点问题与解决”定义:即使使用了哈希分区,某些特定的key可能因为访问量远超其他key而成为热点(Hot Spot)。这就是”名人效应”或”偏斜”(Skew)——少数key占据了大部分流量。常见解决方案包括:(1) 在热点key后追加随机后缀,将一个key的请求分散到多个分区;(2) 在应用层对热点key做特殊缓存;(3) 把热点key的读请求分散到更多副本。
为什么重要:热点问题是分区系统的阿喀琉斯之踵。分区的目的是均匀分摊负载,但热点让某个分区承受了不成比例的压力,这个分区成为整个系统的瓶颈。在极端情况下,一个热点可以打垮整个集群。
案例:News Feed系统中,一个拥有5000万粉丝的大V发了一条帖子。在扇出写(Fan-out on Write)模型下,系统需要将这条帖子写入5000万个粉丝的信息流缓存。如果这个大V的粉丝数据按user_id哈希分区在某几个节点上,这些节点瞬间收到海量写入——这就是”扇出爆炸”。解决方案是对大V采用扇出读(Fan-out on Read):不提前写入粉丝的信息流,而是粉丝刷新时实时拉取大V的最新帖子。混合模式:普通用户用扇出写,大V用扇出读。
先想一想 🤔 如果给热点key(如大V的user_id)加随机后缀来分散写入,读取时会有什么额外成本?
点击查看解析
读取时需要从所有可能的后缀变体中读取数据再合并。比如给大V的user_id后面加00-99的随机后缀,写入时随机选一个后缀写入对应分区。读取时就需要查询100个key(userId_00到userId_99),从所有分区收集结果并合并。这是"写入分散、读取收集"的典型权衡。只应该对真正的热点key这样做,否则会让所有读操作变慢。
3.10 二级索引与分区
Section titled “3.10 二级索引与分区”定义:二级索引(Secondary Index)是在主键之外的字段上建立的索引,用于支持非主键查询。在分区系统中,二级索引有两种实现方式:(1) 本地索引(Local/Document-Partitioned Index)——每个分区维护自己数据的索引,查询时需要”分散-聚集”(scatter-gather)到所有分区;(2) 全局索引(Global/Term-Partitioned Index)——索引本身也被分区,但按索引词(term)分区而非按文档分区,查询只需访问包含目标term的索引分区。
为什么重要:几乎所有真实系统都需要二级索引(按名称搜索、按日期筛选、按标签过滤等)。在分区环境下如何实现二级索引,直接影响查询延迟和写入开销。本地索引写入快(只更新本地索引),但读取慢(需要查所有分区)。全局索引读取快(只查一个分区),但写入慢(一次写入可能需要更新多个索引分区)。
案例:Search Engine是二级索引最典型的应用。倒排索引本质上就是”词 → 包含该词的文档列表”的全局索引。方案一:按文档分区(本地索引),每个分区存储一部分网页及其完整的倒排索引。搜索时需要查询所有分区再合并结果——延迟高。方案二:按词(term)分区(全局索引),“苹果”这个词的所有文档列表在一个分区里。搜索”苹果”只需查一个分区——延迟低。Google等搜索引擎通常采用两级结构:先按term分区(全局索引),每个分区内部再按文档分区,兼顾读写效率。
先想一想 🤔 电商网站的商品数据按商品ID分区,现在要支持”按价格范围搜索”。用本地索引还是全局索引好?
点击查看解析
取决于查询模式。如果按价格范围搜索是高频操作(如用户经常筛选"100-200元"的商品),全局索引更好——价格范围索引按价格区间分区,查询只需访问对应区间的索引分区。如果价格搜索相对低频,而商品更新(改价格)非常频繁,本地索引更好——改价格只需更新商品所在分区的本地索引,不用跨分区更新全局索引。实际电商系统(如淘宝)通常用Elasticsearch这样的搜索引擎作为全局索引层,与主数据库(按商品ID分区)分离。
3.11 分区再平衡
Section titled “3.11 分区再平衡”定义:分区再平衡(Rebalancing)是指在节点增加或减少时,将数据从一些分区迁移到另一些分区,使得负载重新均匀分布。常见策略:(1) 固定数量分区——一开始就创建远多于节点数的分区(如1000个分区分配到10个节点),扩容时只需把一些分区从旧节点迁移到新节点;(2) 动态分区——分区大小超过阈值时自动分裂,低于阈值时合并;(3) 按节点固定分区数——每个节点固定负责一定数量的分区,新节点加入时随机”偷”一些分区。
为什么重要:系统不可能永远不扩容。当数据量增长或请求量增加时,需要添加新节点。再平衡策略决定了扩容的平滑程度——好的策略能最小化数据迁移量和服务中断时间,差的策略可能在扩容期间导致系统性能严重下降甚至不可用。
案例:想象一个快速增长的电商系统(如Hotel Reservation平台),初始用10个节点、1000个分区存储订单数据。旺季来临前需要扩容到15个节点。使用固定数量分区策略,系统只需将约333个分区(1000/3)从原有节点迁移到5个新节点,每个节点最终负责约67个分区。迁移期间,已经在新节点上的分区可以正常服务,还在迁移中的分区继续由旧节点服务,整个过程对用户几乎无感知。
先想一想 🤔 为什么”按key的哈希值取模(hash mod N)“是一个糟糕的分区策略?当N从10变成11时会发生什么?
点击查看解析
当N=10时,hash(key) mod 10 = 3,这个key在节点3上。当N变成11时,hash(key) mod 11 可能等于7——这个key要迁移到节点7。实际上,当N从10变成11时,大约90%的key的 mod 结果都会改变,意味着几乎所有数据都需要迁移!这在数据量大时是灾难性的。这就是为什么要用一致性哈希或固定数量分区——前者在增减节点时只需迁移约1/N的数据,后者甚至不改变分区方案本身,只移动整个分区。
练习一:复制 or 分区?
Section titled “练习一:复制 or 分区?”场景题 🤔 你负责的Hotel Reservation数据库快满了(单机磁盘空间不足),而且读请求响应变慢。你应该选择复制还是分区来解决?能同时用吗?
点击查看解析
分析:
- 磁盘空间不足:复制无法解决这个问题——复制是在多个节点上存储同样的数据,不减少单个节点的数据量。分区可以解决——将数据拆分到多个节点,每个节点只存一部分。
- 读请求慢:两者都能帮助。复制通过增加从节点分担读压力;分区通过减少每个节点的数据量来加速查询(索引更小、缓存命中率更高)。
- 能同时用吗? 当然可以,而且生产环境通常同时使用。比如将数据分成10个分区,每个分区有3个副本。这样既解决了容量问题(10倍),又提高了可用性(3副本容错),还提升了读性能(最多30个节点分担读请求)。
推荐方案:先分区解决容量瓶颈,再对每个分区做主从复制保证可用性。分区键选择:按hotel_id哈希分区(预订查询通常按酒店维度)。
练习二:画出数据流向
Section titled “练习二:画出数据流向”动手题 ✍️ 请画出News Feed系统在主从复制架构下的数据流向图,需要包含以下元素:
- 用户发帖的写入路径(经过哪些组件,最终写到哪里)
- 用户刷信息流的读取路径(从哪里读取)
- 主节点到从节点的复制方向
- 标注哪些地方用同步复制,哪些用异步复制
点击查看参考答案
用户发帖 (写入路径):Client → API Gateway → Write Service → 主数据库 (Leader)│┌──────────────────┼──────────────────┐│ (同步复制) │ (异步复制) │ (异步复制)▼ ▼ ▼从数据库 A 从数据库 B 从数据库 C(同城热备) (同城读副本) (跨城读副本)用户刷信息流 (读取路径):Client → API Gateway → Read Service → 从数据库 A/B/C (负载均衡)特殊情况 - 读己之写:Client → API Gateway → Read Service → 主数据库 (用户刚发帖后的短时间内)关键点:
- 写入只到主节点,保证写入的一致性
- 从节点A用同步复制,确保至少一个副本数据完整(用于failover)
- 从节点B/C用异步复制,减少写入延迟
- 读请求分散到从节点,减轻主节点压力
- “读己之写”场景临时路由到主节点