假设你正在维护一套自动化平台。刚上线时,所有任务都放在一台数据库服务器里:任务触发后写入一条运行记录,执行器不断更新状态,管理后台读取日志。数据不算多,请求也不算密,这套方案简单、直接,而且很好排查问题。
几个月后,平台接入了更多团队。每天的运行记录从几万条涨到几千万条,早上九点的批量任务还会同时启动。你先加内存、换更快的磁盘,后来又发现另一个麻烦:服务器一旦停机,所有自动化流程都会失去状态。此时我们面对的已经不只是“数据库够不够快”,而是三个彼此不同的问题:一台机器装不下、一个节点扛不住所有读写、一个故障点不能拖垮整个系统。
数据分布就是为这些问题服务的。它并不神秘,本质上只做两件事:把不同的数据拆开存,或者把同一份数据多处存。前者叫分片,后者叫复制。两者经常一起出现,却解决着不同的问题。

分布式不是“更高级的单机”。数据一旦跨越网络,延迟、丢包、节点失联、版本先后和故障切换都会进入设计范围。我们应该先说清楚瓶颈和可用性目标,再决定要不要把数据放到多台机器上。
很多团队看到数据库压力上涨,第一反应就是“上分片”。这一步常常太快了。服务器慢可能来自缺少索引、一次查询扫描太多数据、日志文档无限膨胀、连接池配置不当,也可能只是某个报表在高峰期抢走了资源。分片能分散负载,却不会自动修好低效查询;一条原本扫描百万条记录的查询,拆到十个分片后,很可能变成十台机器同时扫描。
我们可以先从四个方向定位问题:
给现有服务器增加 CPU、内存和更快的存储,叫垂直扩展。它的优势是应用几乎不用理解数据去了哪里,事务和查询仍在一个节点内完成,监控与备份也更直接。它的边界则很清楚:单机配置存在上限,更大的机器通常越来越贵,维护时仍可能面对单点停机。
增加服务器,把数据或请求分散出去,叫水平扩展。它能让容量和吞吐随着节点增长,但应用需要接受一个新事实:网络不是本地内存,跨节点协调既慢又可能失败。水平扩展买到的是弹性,付出的成本是路由、迁移、一致性和运维复杂度。
对自动化平台来说,一个务实的演进顺序通常是:先把任务记录的索引、保留周期和日志结构整理好,再评估单机升级;如果读取压力明显高于写入,就先增加副本;如果活跃数据和写入量确实超过单节点上限,再进入分片。
先把两个概念放到同一张表里,你会更容易看清它们的边界。
下面这个小工具把常见症状与策略放在一起。你可以先选一个场景,再观察系统真正需要解决的是什么。
需要特别留意的是,复制不是备份。副本会忠实传播更新,也可能忠实传播误删除;备份则应当保留某个时间点的独立副本,允许你回到事故发生之前。反过来,备份也不能代替热副本,因为从备份恢复往往需要更长时间。高可用与灾难恢复是两条相关但不同的工作线。
现在回到自动化平台。假设我们有三台数据节点,可以让任务编号哈希后落到不同节点:任务 run-1001 去分片一,run-1002 去分片二,run-1003 去分片三。每个节点只保存并处理一部分运行记录,新增节点后再迁移一部分数据,就有机会继续扩大容量和总写入吞吐。
分片并不是把一条记录切成三截。更常见的做法是选定一个分片单位,让需要一起读写的数据尽量留在同一个分片。对任务运行记录来说,一次运行本身就是很自然的聚合:当前状态、开始时间、重试次数与步骤日志经常一起读取,也常在同一次状态推进里更新。
数据库需要一条规则判断数据应该去哪台机器,这条规则依赖分片键。分片键看上去只是一个字段,实际上会同时影响四件事:数据能否均匀散开、写入会不会集中、常用查询能否定向到少数分片、重新分布数据要花多少代价。

我们评估候选分片键时,可以逐项问:
分片规则常见三类,没有哪一类在所有场景里都最好。
范围分片把相邻的键放在一起,例如把一月至三月的数据放到分片一,四月至六月放到分片二。范围查询很自然,但单调增长的写入容易涌向最新范围。某个范围特别热门时,负载也会随数据一起集中。
哈希分片先把分片键变成较均匀的哈希值,再按哈希空间分配。它擅长打散连续编号与写入流量,却会破坏原始顺序。想查询一个月内的所有记录时,数据可能散在许多分片上,需要并行查询后汇总。
目录路由维护一张“键或范围属于哪个分片”的映射表。它能支持更灵活的放置策略,例如指定某个租户留在特定区域,但路由元数据必须高可用且保持正确,映射变更也要和数据迁移配合。
下面是一段简化的哈希路由代码。真实系统会使用稳定的哈希算法、虚拟槽位和版本化的路由表,这里只用它展示“同一个任务要稳定落到同一个位置”。
const shardNames = ['分片一', '分片二', '分片三']
function simpleHash(text) {
let value = 0
for (const char of text) {
value = (value * 31 + char.codePointAt(0)) >>> 0
}
return value
}
代码里用节点数量直接取模,只适合解释概念。节点从三台变成四台时,大量键都会改变结果,数据迁移范围太大。生产系统常在“数据键”和“物理节点”之间增加稳定槽位:先把键映射到槽位,再把少量槽位从旧节点移动到新节点,这样扩容时不必搬动几乎全部数据。
下面的实验把三种写入模式压到三个分片上。切换分片方式与流量模式,看看“数据条数差不多”为什么不等于“请求负载也差不多”。
如果查询里带有完整分片键,路由层通常能把请求直接交给目标分片,这叫定向查询。比如按 runId 读取一次运行的详情,路由代价很低。
如果后台要查询“最近十分钟内所有失败任务”,而分片键只有 runId,路由层无法从时间和状态推导目标,只能询问所有分片,再做排序、去重和分页。这类广播查询不是绝对错误,低频报表完全可以接受;问题在于把它放进高频在线接口后,节点越多,参与一次请求的节点也越多,尾部延迟与失败概率都会上升。
常用处理办法有三种:让高频查询带上租户或任务等路由条件;为搜索和分析维护独立的读模型;把低频全局报表放到异步分析链路。不要为了让一个数据模型满足所有查询,把在线写入路径拖进无休止的跨分片聚合。
扩容、缩容或修复热点时,系统要把一部分数据从旧节点迁到新节点。迁移会读取旧数据、通过网络传输、写入新副本,还要追上迁移期间产生的新变更。它与正常业务争抢磁盘、网络和 CPU,因此单节点已经接近满载时才开始分片,风险尤其高。
先用真实请求回放验证候选分片键,记录每个键值的写入频率、查询路由范围与最大租户占比,而不是只看数据总量。
再在有余量的环境里演练数据迁移,限制搬迁速率,并持续观察在线请求的高分位延迟、复制积压和磁盘空间。
接着安排双写、变更日志追赶或短暂停写的切换方案,明确旧路由与新路由同时存在时由谁判定版本。
最后准备回退路径。只有当数据校验、流量切换和故障注入都通过后,才逐步扩大生产流量。
不要等到旧节点的磁盘、网络和写入队列都接近极限时才启动迁移。再分片本身就需要资源,越接近饱和,迁移越慢;迁移越慢,期间新增的数据越多,最后容易形成一个自我加剧的循环。
分片解决了“不同数据放哪里”,复制关注的是“同一份数据有几份”。最容易理解的复制结构是主节点—副本节点:写入先交给主节点,主节点记录变更,再把变更传播给一个或多个副本节点。副本可以承担只读查询,也可以在主节点故障后被提升为新的主节点。

这套结构很适合读多写少的场景。比如自动化平台的模板配置,发布修改并不频繁,但大量执行器会读取模板。我们可以让写入集中在主节点,再把读取分散给副本。不过,所有写入仍然经过一个主节点,所以复制不会凭空带来写入水平扩展。
写入完成到底指什么?至少有三种可能:主节点已经放进内存;主节点已经写入持久化日志;主节点与足够多的副本都确认收到。确认条件越严格,节点故障时丢失已确认写入的概率越低,但每次写入等待的网络往返也越多。
异步复制让主节点先向客户端返回成功,再把变更传播给副本。它通常延迟更低,却留下一个时间窗口:主节点已经确认的最新写入尚未到达副本;如果此时主节点永久损坏并切换,新主节点可能缺少这部分数据。
同步复制或多数派确认会缩小这个窗口,但不代表“永远不会失败”。磁盘损坏、软件缺陷、误操作、多个故障域同时失效仍要由备份、校验和恢复演练兜底。工程上应当把目标写清楚,例如“单节点故障时不丢已确认的任务状态”,再选择对应的确认级别,而不是笼统地写“强一致”。
用户刚刚暂停一个任务,写入在主节点成功;页面刷新时,读请求却被负载均衡到尚未追上的副本,于是页面仍显示“运行中”。这不是缓存一定出错,也不代表写入消失了,而是读取发生在复制传播完成之前。
常见处理方式包括:写入后的短时间内固定读主节点;让客户端携带刚才写入的版本位置,只从已经追到该位置的副本读取;对状态推进等关键读取使用更严格的一致性级别;对统计面板等可容忍旧值的页面继续使用副本。关键在于按业务语义区分,不必让所有读取都付出最高协调成本。
主节点失联时,集群需要判断它是真的故障,还是只是暂时和一部分节点断开。如果两个网络分区都以为自己有主节点,就可能同时接受互相冲突的写入。成熟的选主机制会使用任期、法定票数与旧主节点隔离等手段,保证旧主在失去多数派后不能继续以有效主节点身份提交写入。
新的主节点上线后,客户端还要发现它并重连;旧主恢复后要以副本身份追赶;未进入新主历史的写入可能回滚。因此故障切换测试不能只看“能否选出新主”,还要检查连接重试是否幂等、旧主是否被正确隔离、任务状态是否出现倒退或重复执行。
主节点结构简单,但主节点仍是写入入口。对等复制让多个节点地位接近,客户端可以把读写交给任一协调节点,再由它把数据发送给负责该键的多个副本。这种结构能让不同区域就近接入,也能分散协调工作,不过冲突与一致性判断会更显眼。

假设某条任务记录有三个副本。一次写入要求两个副本确认,记作写入确认数 W = 2;一次读取询问两个副本,记作读取确认数 R = 2;副本总数是 N = 3。因为 W + R > N,读集合和最近一次成功写集合至少有一个重叠副本,系统就有机会看到较新的版本并修复落后的副本。
“有机会”三个字很重要。多数派交集只是推理的一部分,最终语义还取决于版本比较、并发写入处理、失败重试、时钟与数据库实现。不能只把三个数字配成 2 + 2 > 3,就宣布所有业务都获得了线性一致性。
如果两个节点在失联期间都接受了对同一字段的修改,就会出现两个并发版本。例如调度节点甲把任务状态从“运行中”改为“成功”,节点乙因为超时把同一任务改为“等待重试”。网络恢复后,系统必须决定保留哪一个,或者用业务规则合并。
简单的“最后写入获胜”依赖时间戳,容易被机器时钟偏差和消息延迟误导;保存多个版本再交给业务合并更准确,却把复杂度推给了应用;可交换的数据结构能自动合并计数、集合等特定操作,但并不适合所有状态机。对自动化任务状态而言,最稳妥的办法往往是使用单调版本号与合法状态迁移规则:版本旧的更新不能覆盖版本新的记录,“成功”也不能被一个迟到的“运行中”改回去。
function advanceRun(current, command) {
if (command.expectedVersion !== current.version) {
return { ok: false, reason: '版本已经变化,请读取后重试' }
}
const allowed = {
等待: ['运行中', '已取消'],
运行中: ['成功', '失败', '等待重试'],
等待重试: ['运行中', '已取消'],
}
拖动复制延迟,再切换读取位置。你会看到“写入已经成功”和“所有副本已经可见”并不是同一个时刻。下方的多数派区域则帮助你判断读写集合是否必然重叠。
网络分区指节点之间无法可靠通信,但各自仍可能对客户端提供服务。此时,如果我们要求所有客户端都只能看到单一、最新的状态,就必须拒绝某些无法完成协调的请求;如果我们坚持每个区域都继续接受请求,就要允许短时间内出现不同版本,之后再解决冲突。
所以 CAP 的取舍发生在分区已经存在的时刻,不是让你给数据库永久贴上“只要一致性”或“只要可用性”的标签。网络正常时,系统依然可以同时提供一致的结果和正常响应;网络异常时,甚至可以按操作区分策略:任务扣费、唯一锁和状态终结拒绝冒险写入,浏览历史与可合并计数则继续接收。
“最终会一致”不是冲突处理方案。你仍要回答哪个版本胜出、如何识别并发、合并是否会破坏业务约束、多久必须收敛,以及用户在收敛前会看到什么。
只做分片时,每个分片仍可能只有一份数据。分片二所在节点故障,分片一和分片三也许还能服务,但落在分片二的任务仍会不可用。只做复制时,每个节点都保存完整数据,容量与主写入入口的上限依然存在。
生产集群常见的做法是先把数据分成多个分片,再为每个分片维护多个副本。路由层先判断请求属于哪个分片,再把写入交给该分片的主副本或协调副本。这样,分片负责容量和总吞吐,复制负责每个分片内部的可用性与故障恢复。

如果有三个分片,每片三个副本,逻辑上是三份数据范围,物理上则是九个副本角色。实际部署时还要把同一分片的副本放到不同故障域:不要让它们共享同一台宿主机、同一个机架电源,或者只存在于同一个可用区域。副本数量只有和故障独立性放在一起看,才有意义。
组合之后也会产生新的联动问题:
分片数量、节点数量和副本数量不是同一个概念。一个节点可以承载多个分片副本,一个分片也会分布到多个节点。讨论架构时把这三个词分开,很多容量与容灾误解会自然消失。
现在把前面的原则放回同一个业务。我们的平台需要保存任务运行记录,每次运行包含当前状态、触发方式、重试次数和步骤日志。常见访问路径是:按运行编号读取详情;按租户查看最近运行;执行器用运行编号推进状态;运营人员查询某个时间范围内的失败任务。

先看一个简化的运行聚合:
{
"tenantId": "tenant-021",
"runId": "run-20260811-00042",
"workflowId": "flow-invoice-check",
"status": "等待重试",
"version": 7,
"retryCount": 2,
"startedAt": "2026-08-11T09:15:00+08:00",
"steps": [
{ "name": "读取发票", "status": "成功", "attempt"
把 status 作为分片键看似方便查失败任务,实际风险很大:取值太少,运行中的任务会集中写入同一位置;任务状态每次变化还可能跨分片搬迁。只用 tenantId 可以让租户查询定向,但超级租户会形成热点。只用 runId 哈希分布最均匀,却会让租户列表查询散到许多分片。
一个可行折中是用“租户桶 + 运行编号哈希前缀”作为路由键。普通租户分配一个桶,超大租户分配多个可计算的桶;运行详情通过运行编号中的桶信息直接定位;租户最近运行查询并行读取该租户有限数量的桶再合并。桶数增加了查询扇出,却换来了更均匀的写入,这是有意识的权衡,不是免费优化。
失败任务的全局分析不必强迫在线存储承担。运行状态变更可以同时发布一条事件,由独立的搜索或分析读模型按时间、状态建立索引。这样,执行器的高频状态推进保持单分片,运营查询则使用适合筛选与聚合的结构。数据模型围绕访问模式拆开后,跨分片交易会少很多。
分布式系统里,客户端超时并不等于写入失败。执行器提交“步骤成功”后连接中断,它无法确定数据库是否已经落盘,只能重试。若更新操作不是幂等的,重试可能让计数加两次、重复安排后续任务。
我们可以给每条命令分配唯一的 commandId,在运行聚合内记录已经应用的命令;同时用 expectedVersion 做条件更新。重复命令直接返回上次结果,过期版本则要求调用方重新读取。这样即使网络重试、故障切换或消息重复投递,状态机也不会轻易走出非法路径。
写出前三到五个最重要的访问路径,并标出每条路径携带的路由字段、可接受延迟、是否允许读到旧值,以及失败后能否安全重试。
用生产流量分布评估候选分片键,特别观察最大租户、最新时间范围、少数状态值和突发批任务,不要只用均匀生成的测试数据。
明确复制因子、写入确认条件和读取位置,让任务状态、统计面板、配置读取分别选择符合业务风险的一致性级别。
注入单节点故障、网络延迟、网络分区与磁盘逼近满载等场景,验证选主、重连、幂等重试、回滚检测和副本重建。
只有能独立路由到不同分片的请求才容易并行扩展。广播查询、全局排序、热点键和跨片更新都会形成协调瓶颈。节点更多还会带来更多心跳、元数据与故障组合,容量规划不能只做简单乘法。
三个副本可能同时位于同一故障域,也可能通过异步复制留下未传播窗口,还可能一起复制误删除。我们需要同时说明副本放置、确认条件、持久化方式和独立备份,单独一个“副本数”不能代表数据安全。
数据库可以自动路由和搬迁,却不知道哪个租户会突然变热、哪些查询必须低延迟、哪些字段会单调增长。自动化减少的是执行工作,不会替你定义业务访问模式。
跨节点原子提交需要更多网络往返与故障处理,参与节点越多,尾部延迟越容易被最慢节点决定。偶发的管理操作可以接受,不应让每次任务状态推进都依赖跨片事务。
选出新主只是第一步。客户端是否重连、旧主是否被隔离、重试是否重复执行、最新写入是否回滚、容量是否扛得住副本重建,都会决定用户看到的是短暂抖动还是长时间事故。
学完这一章,你应该先记住一句最朴素的话:分片把不同数据拆开,复制把同一数据多处保存。 分片主要扩展容量与写入吞吐,复制主要提高可用性、容错和读取吞吐。两者组合后,每个分片都有可接管的副本,系统才同时具备扩展与局部故障恢复能力。
真正困难的地方不在画出几台服务器,而在把业务语义落到选择上:用什么键路由,热点怎样识别,哪些查询允许广播,写入要等几个副本,用户能否接受旧读,网络断开时哪些操作宁可拒绝,冲突发生后又由谁解决。
如果我们从访问模式和故障目标出发,先保留单机的简单,再逐步引入复制和分片,分布式架构就不再是一堆抽象名词。它会变成一组可以测量、演练和解释的工程取舍。
最后演练扩容、缩容与整库恢复。能自动切换不等于能从误删除或数据损坏中恢复,备份校验必须单独通过。