Skip to content

Windows Azure Storage ​

标签
分布式/存储
字数
15443 字
阅读时间
60 分钟

WAS(SOSP 2011)是微软的云存储,2008 年 11 月起在生产环境运行。它在这条线上的位置很特殊:前面几篇里"高可用"和"强一致"基本是二选一(Dynamo 选 AP、[Spanner](04-Spanner 与 F1.md) 用时钟设施把强一致买回来),而 WAS 的标题就直接写着 "with Strong Consistency" —— 它靠的是把强一致限定在一个层次里做,把可用性交给另一层。

一个真实负载作为规模锚点:Windows Azure 上的摄入引擎(为 Facebook 与 Twitter 做近实时搜索,是 Bing 管道的一部分,在用户发帖后 15 秒内让内容可被公开搜索到)—— 它在 WAS 里存约 350 TB 数据(复制前),峰值约 40,000 事务/秒,每天 20 到 30 亿次事务。内容走 Blobs、工作流走 Queues、处理结果与状态走 Tables —— 这个组合就是常说的用法模式。

两个层级:stamp 与位置服务 ​

Storage Stamp 是一个集群:

  • N 个机架的存储节点,每个机架作为独立故障域建设(冗余网络与电力);
  • 典型 10 到 20 个机架,每机架 18 个磁盘密集型节点;
  • 第一代 stamp 每个约 2 PB 原始存储,下一代最多 30 PB。

利用率目标值得单独记:stamp 要维持在约 70% 利用率(容量、事务、带宽三个维度),避免超过 80%,因为要留 20% 余量给两件事:

  • 磁盘短行程(short stroking) —— 只用盘的外圈轨道以获得更好的寻道时间与更高吞吐;
  • 机架故障时继续提供容量与可用性。

stamp 达到 70% 时,位置服务用跨 stamp 复制把账号迁到别的 stamp。

Location Service 管什么 ​

Location Service(LS) 管所有 stamp,也管跨 stamp 的账号命名空间:

  • 把账号分配到 stamp,并跨 stamp 管理它们以做灾难恢复与负载均衡;
  • LS 自己分布在两个地理位置做自身的灾难恢复;
  • WAS 在北美、欧洲、亚洲三个地理区域提供存储;每个位置是一个数据中心(含一栋或多栋建筑),每个位置有多个 stamp;
  • 扩容的方式:在目标位置部署新 stamp 并加入 LS → LS 把新账号分给新 stamp,也能把已有账号从旧 stamp 迁到新 stamp;
  • 申请新账号时应用指定位置亲和性(如 US North),LS 用启发式(考虑各 stamp 的满载程度、网络与事务利用率)选一个作为该账号的 primary stamp,把账号元数据存进去,然后更新 DNS 让 https://AccountName.service.core.windows.net/ 路由到该 stamp 的 VIP。

全局命名空间:三段式与“事务范围就是 PartitionName” ​

这套系统的地址格式是三段拼出来的,而每一段各自解决一个规模问题:

   http(s)://AccountName.<service>.core.windows.net/PartitionName/ObjectName
              └────┬────┘                            └─────┬─────┘  └───┬───┘
              AccountName DNS 翻译                     PartitionName   ObjectName
              定位主存储集群与数据中心                 按流量横向扩     分区内定位
              ——所有请求都去这个主位置                 展访问           单个对象

三段的职责分工是这套命名空间的全部意义:AccountName 决定“数据在哪个数据中心、哪个 stamp”(它是 DNS 主机名的一部分,所以能被 DNS 解析定位);PartitionName 决定“这个对象的负载落在哪些存储节点上”(它用于按流量需求横向扩展访问);ObjectName 在分区内标识单个对象(对某些类型的数据它可以省略,因为 PartitionName 已经唯一标识了对象)。

三种抽象各自把哪一段当 PartitionName ​

三种抽象各自把三段映射成了什么,是这一节最有信息量的部分:

抽象哪一段是 PartitionName后果
Blob整个 blob 名就是 PartitionName一个 blob 就是一个分区,没有“同一分区多对象”这个概念
Table每行的主键 = PartitionName + ObjectName应用可以把多行放进同一个 PartitionName,从而对它们做原子事务
Queue队列名是 PartitionName,每条消息有 ObjectName队列天然是事务范围

这张表解释了一句容易被读过去的话:系统支持对“PartitionName 相同的对象”做原子事务。 换句话说 —— 事务范围在 API 层就是 PartitionName。这与 MegaStore 的 entity group 是同一个思路(把“原子性范围”做成应用可配置的),区别在于 WAS 用的是“地址里的一个字段”,而 MegaStore 用的是“schema 里的一个外键”。

两种做法的差别很实际:WAS 的 PartitionName 既是事务范围也是负载均衡的单位(按流量切分与迁移的就是它),而 MegaStore 的 entity group 主要是原子性与复制的单位。把一个概念同时用作“事务边界”与“调度单位”,好处是应用只需要理解一个概念;代价是这两件事被绑在一起 —— 想拆开其中一个,就得同时动另一个。

这一节还有一处值得与事务范围那一节并读的推论。 既然 Blob 的整个名字就是 PartitionName,那么两个 blob 永远不可能在同一个事务里 —— 这来自“一个 blob 就是一个分区”这条定义。想让两个对象一起原子更新,在这套抽象里只有一个办法:把它们放进同一个 PartitionName 范围,也就是做成同一张 Table 里同分区名的两行。

这一条决定了 WAS 上“事务设计”的操作面:Blob 适合“各自独立的不可变对象”,Table 适合“需要一起变的一组行”。选哪种抽象不只是数据结构偏好,它直接决定了你能表达的事务边界。

一个 stamp 内的三层 ​

这三层的分工方式是全篇的核心设计,每层只解决一类问题:

层负责不负责
Stream Layer存盘上的 bit;把数据分布与复制到许多服务器以在 stamp 内保持持久不理解更高层的对象构造或语义
Partition Layer理解 Blob / Table / Queue 三种抽象、提供可扩展命名空间、提供事务排序与强一致性、把数据存在流层之上、缓存数据不直接管 bit 的落盘与复制
Front-End (FE) Layer一组无状态服务器:查 AccountName、认证授权、按 PartitionName 路由不持有状态

关键分工的一句话:数据存在流层,但从分区层访问 —— 分区服务器与流服务器在同一存储节点上 co-located。

分区层怎么扩展 ​

Partition Layer 的规模手段:所有对象都有 PartitionName,按 PartitionName 值切成不相交的范围,由不同分区服务器服务;这一层管哪个分区服务器服务哪些范围,并提供跨分区服务器的自动负载均衡。

FE 的规模手段:无状态 → 可以随意加。它靠系统维护的 Partition Map 知道 PartitionName 范围与分区服务器的对应关系。

流层:append-only 的文件系统 ​

流层只被分区层使用,提供类似文件系统的命名空间,但所有写都是 append-only。四个数据概念:

概念定义与参数
Block读写的最小单位,最大 N 字节(例如 4 MB);不要求等大,由客户端控制大小;读时必须读整个 block —— 因为校验和是 block 级的、每块一个;此外系统里所有 block 每隔几天会做一次校验和验证
Extent流层复制的单位,默认在一个 stamp 内保留三份副本;存在一个 NTFS 文件里,由一串 block 组成;分区层使用的目标大小是 1 GB
Streamextent 指针的有序列表,由 Stream Manager 维护;对分区层来说像一个大文件;可追加、可随机读;只有最后一个 extent 可追加,之前的全部不可变
Sealextent 填到目标大小后在一个 block 边界上被封,之后不可追加。对冷 extent 会做纠删码编码

大小对象为什么不能一刀切 ​

大小对象的两种处理(这一条说明了 1 GB 这个目标值为什么不能一刀切):

  • 小对象:分区层把多个追加到同一个 extent、甚至同一个 block;
  • TB 级大对象(Blob):分区层把它拆到许多 extent 上。

分区层在自己的索引里记录对象存在哪些 stream、extent 与 extent 内的字节偏移。

用拼接代替复制 ​

一个很快的操作:用拼接现有 stream 的 extent 来构造新 stream —— 因为只是更新一张指针列表。

Stream Manager 与那些"不做"的事 ​

Stream Manager(SM)本身是一个标准 Paxos 集群,且在客户端请求的关键路径之外。它维护 stream 命名空间、extent 状态与 extent 在 Extent Node(EN)上的分配,职责六条:监控 EN 健康、创建并分配 extent、惰性再复制丢失的副本、垃圾回收不再被引用的 extent、按策略调度纠删码编码。

SM 的边界交代得很清楚,这些"不做"正是它能扩展的原因:

  • SM 不知道 block,只知道 stream 与 extent;
  • 它不跟踪每一次 block 追加 —— 因为 block 总数可能极大,SM 无法扩展到跟踪它们;
  • 它周期性轮询(sync)EN 的状态;发现某 extent 的副本数少于期望时,惰性地重建再复制;
  • 副本放置靠随机:SM 在不同故障域里随机选 EN,使副本不会因电力、网络或同机架而相关失效。

复制流程:为什么这里不需要 lease ​

这是与前面几篇一个明显的对照:

  1. 创建 stream 时,SM 为第一个 extent 分配三个副本(一 primary、两 secondary)到三个 EN,节点由 SM 随机选以分散故障域与升级域(同时考虑 EN 使用率做负载均衡);
  2. SM 还决定哪个副本是 primary;
  3. 写总是从客户端到 primary EN,primary EN 负责协调写往两个 secondary EN;
  4. primary 与副本位置为什么能固定 ​

extent 在被追加期间(未 seal 时),primary 与三个副本的位置永不改变。

由第 4 点直接推出一条结论:

不需要用 lease 来表示 extent 的 primary —— 因为 extent 未 seal 时 primary 总是固定的。

对比一下 GFS:那里必须用租约来选 primary,因为同一个 chunk 会被反复修改;而这里 extent 是 append-only 的,"谁是 primary"在 extent 生成时就定死了,直到它被 seal。把可变性从数据结构里去掉,就省掉了一整套租约机制。

多块追加(multi-block append)的契约值得单独记:它允许把一次大量顺序数据作为单个原子操作写入,而最小读单位是单个 block —— 两者合起来就实现了"一次性写大量顺序数据、之后做小读"。代价是一份契约:

若客户端因故障没收到回复,应当重试请求(或 seal 该 extent)。 这意味着客户端必须预期同一个 block 可能被追加多次,并正确处理重复记录。

分区层处理重复记录的两种方式(按数据类型分):

  • 元数据与 commit log stream:所有写入的事务都有序列号,重复记录的序列号相同;
  • 行数据与 blob 数据 stream:重复写时只有最后一次写会被 RangePartition 的数据结构指向,之前的重复写没有引用,会被后续垃圾回收。

流层给分区层的两条保证 ​

这是三层设计能成立的枢纽 —— 流层与分区层是共同设计的(co-designed):

分区层提供强一致的正确性建立在流层这两条保证之上:

  1. 一旦一条记录被追加并向客户端确认,任何后续从任何副本的读都会看到相同数据(数据不可变);
  2. 一旦 extent 被 seal,从任何 sealed 副本的任何读都总是看到该 extent 相同的内容。

这两条保证管的是不可变性 ​

注意这两条都是"不可变性"保证,不是"最新性"保证。 流层只承诺"写下去的东西不会变",谁是最新的、写入顺序怎么定,全部交给分区层。这就是"把强一致限定在一层里做"的确切含义。

威胁边界也划得很清楚:恶意对手由数据中心、Fabric Controller 与 WAS 的安全机制负责,流复制不处理这类威胁;而它处理的故障范围是从磁盘与节点错误到断电、网络问题、位翻转、随机硬件故障,以及软件 bug —— 这些都会造成数据损坏,用校验和检测。

强一致是怎么实现的:PM、PS 与那把租约 ​

原稿把“强一致限定在分区层”讲清楚了,但没讲分区层靠什么把它做出来。答案只有三个组件加一把租约:

   Front End / 客户端
        │  1. 查 Partition Map Table,找到该 RangePartition 的分区服务器
        ▼
   Partition Server (PS)          ← 真正服务读写的那个
        │  2. 持有一把租约,才有资格服务这个 RangePartition
        ▼
   Lock Service(Paxos 集群)      ← 选 PM 的 leader,并发放 PS 的租约
        ▲
        │  3. 续租、监控租约状态
   Partition Manager (PM)          ← 负责切分 OT、把 RangePartition 分配给 PS

锁服务在这里承担两件事,而且都是它该干的事:一是给 Partition Manager 做 leader 选举(Paxos 锁服务);二是给每个 PS 发一把租约,PS 凭这把租约才有资格服务某些 RangePartition。有了租约,同一个 RangePartition 在任一时刻只有一个 PS 能写 —— 这就是“强一致与事务顺序”在分区层的落点。

它与 Chubby 的关系是直接的:这套 PM 选举加 PS 租约的做法,与 Chubby 里那套概念同源。

故障处理也只有一步:PS 挂掉时,它服务的那 N 个 RangePartition 由 PM 重新分配给可用的 PS —— PM 按各服务器负载挑选 N 个(或更少)目标,逐个分配,然后更新 Partition Map Table 里“哪个 RangePartition 由哪个 PS 服务”这一列。于是下一批请求被 FE 路由到新的 PS 上。

一条运维口径:一个 PS 可以同时服务来自不同 OT 的多个 RangePartition,在生产部署里平均一个 PS 服务约十个 RangePartition。这个数字是“租约的粒度”的量化 —— 租约是每个 RangePartition 一把,而不是每台机器一把,一台机器上同时持有多把。

commit length:不可变性怎么变成副本间的一致 ​

原稿里的两条保证(“追加并确认后任何副本读到相同数据”、“seal 后任何 sealed 副本读到相同内容”)是结论,而达成它的机制是 commit length:

   一次 append 到某个 extent
        │
        ▼
   primary EN 决策:这次追加放在哪个 offset
        │  (offset 由 primary 选,从不假手他人)
        ├─ 并行写 3 个副本(自己 + 两个 secondary)
        ▼
   三个副本都成功 ⇒ primary 才向客户端确认成功
        │
        ▼
   每个副本上,“按顺序提交的最后那个位置” = 该副本的 commit length

bits 在三副本之间相同,靠的是四条性质同时成立:

  1. extent 的 primary EN 永不变更(在它被 seal 之前);
  2. offset 永远由 primary 选;
  3. append 按顺序提交(所以“最后一个位置”是良定义的);
  4. 失败时 extent 立即被 seal。

sealing 的动作本身就是一致性机制的一部分。 SM 协调 sealing:它向三个 EN 各要一次当前长度,然后按能联系到的 EN 取最小的 commit length 作为 seal 点。取最小不会丢数据 —— 因为 primary EN 只在三个副本都成功后才向客户端确认过成功,所以任何一个“客户端以为写成了”的位置,必然在三个副本上都不小于它。

一条性能数字:sealing 加分配新 extent 平均在 20 ms 内完成;而且客户端在新 extent 一分配好就可以继续追加,不需要等某个具体节点恢复。

这套机制与 GFS 有一处明确分歧:GFS 里 primary 只要有一个副本成功就可以确认;这里的 primary 要等三个副本都成功。这就是“强一致”在流层的具体价格 —— 它把“最慢的那个副本”从可选变成了必须。

两种复制引擎:关键路径上的与后台的 ​

WAS 有两套复制,而且它们被放在不同的层、解决不同的问题。这个分工本身就是设计的主线之一。

   intra-stamp 复制(流层,在关键路径上)
   ─────────────────────────────────────
   把一份数据在同一个 stamp 内的不同故障域里放够副本
   挡的是:磁盘故障、节点故障、机架故障
   由流层完成;成功之后才能向客户确认

   inter-stamp 复制(分区层,在后台)
   ─────────────────────────────────────
   把对象(或最近的变化量)复制到别的 stamp
   挡的是:地理级灾难;同时兼做账号迁移
   由位置服务配置、分区层执行;完全不进关键路径

为什么必须拆成两套,给出的理由有四条,值得逐条记:

  • 故障频率不同:intra-stamp 挡的是硬件故障,而硬件故障在大规模系统里频繁发生;inter-stamp 挡的是地理灾难,而那是罕见的。用同一套机制去挡两种频率差几个数量级的故障,必然要给高频的那种付低频的价钱。
  • 延迟要求不同:intra-stamp 必须在关键路径上且低延迟;inter-stamp 的目标是在可接受的复制延迟下把 stamp 间的网络带宽用到最优。一个优化延迟,一个优化带宽,方向相反。
  • 复制的单位不同:intra-stamp 复制的是“构成对象的磁盘块”,inter-stamp 复制的是“对象以及作用在对象上的事务” —— 前者不认识对象,后者认识。
  • 命名空间不同:两套复制各自面对一套命名空间。

最值得记的是第三条:它把“分层”这件事的意义讲清楚了。 inter-stamp 复制之所以能做“按对象复制、甚至只复制最近的变化量”,正是因为它跑在认识对象的那一层;而流层不认识对象,所以它只能按块复制。同一份数据,在两个层次上是两种不同的东西。

由此也能推出一条结论:这套系统的持久性保证是分级的 —— intra-stamp 给的是“硬件故障不丢”,inter-stamp 给的是“地理灾难可在某个延迟内恢复”。两个级别对应两个 RPO,而不是一个。

journal drive:为什么加一层盘反而快 5 倍 ​

原稿记了那个数字(30 ms → 6 ms),但没记为什么。机制在 extent node 上:

   一次 stream append 到达某个 EN
        │
        ├─ (a) 把这次追加的全部数据写进 journal drive
        │      ——每个 EN 拿出一整块盘或 SSD 专做这件事,
        │        只写一条顺序日志,因此能跑满设备写吞吐
        │
        └─ (b) 同时把这次追加排进队列,等着写进 extent 文件
              所在的 data disk
        │
        ▼
   (a) 与 (b) 谁先成功,就可以返回成功
        │
        ├─ journal 先成功 ⇒ 数据同时缓存在内存里,
        │                    ▶ 这段时间内的读由内存服务,
        │                      直到它落到 data disk 为止
        │
        ▼
   成功后:连续写被合并成更大的写落到 data disk,
           并发读写得到更好的调度

四条因果,逐条都能对上那个 5 倍:

  1. journal 不跟读抢设备:追加不必与去 data disk 的读争用就能把结果提交回客户端。这是“顺序日志”这个形态最直接的收益。
  2. 路径上多了一次写,但它不在关键路径上:官方把这笔账说得直白 —— 用多一次写(在关键路径之外)换低延迟。
  3. 它把抖动抹平:journal 先成功时数据从内存服务读,读延迟不再受 data disk 的调度影响。这就是“方差显著下降”的来源。
  4. 它让 data disk 看到更好的负载形状:连续写被合并成更大的写,读写调度都更容易做好。

“一次 append 只跟最慢的那个 EN 一样快”这句是理解那个数字的关键。 commit log stream 的追加要等所有副本都成功,所以它的延迟由最慢的 EN 决定;而没有 journal 时,最慢的 EN 又被它的读负载拖住。加 journal 等于把“最慢的那个”从“受读干扰的 data disk”换成了“独占的顺序日志盘”。

这条经验的可迁移之处:写延迟的瓶颈往往在“写与读抢同一个设备”上,而不在写本身。 给写一条独立的顺序通道,比优化写路径的算法更直接。

分区层:Object Table 与 RangePartition ​

Object Table(OT) 是分区层的内部数据结构,可以增长到数 PB,按流量负载动态切分成 RangePartition 散布到各个分区服务器上。

一个 RangePartition 是 OT 中从 low-key 到 high-key 的一段连续行。同一 OT 的所有 RangePartition 互不重叠,且每一行都在某个 RangePartition 里。

六张 OT 各有明确职责:

OT存什么
Account Table分配到该 stamp 的每个存储账号的元数据与配置
Blob Table该 stamp 里所有账号的所有 blob 对象
Entity Table所有账号的所有 entity 行(公开的 Windows Azure Table 抽象)
Message Table所有账号队列的所有消息
Schema Table所有 OT 的 schema
Partition Map Table所有 OT 当前的 RangePartition 与对应的分区服务器 —— FE 靠它路由请求

主键设计:Blob / Entity / Message 表的主键都是三个属性 —— AccountName、PartitionName、ObjectName,它们同时提供索引与排序顺序。

一个顺带发现的性能数字 ​

commit log 那里有一组很说明问题的对比:不带 journaling 的 commit log stream 平均端到端 append 延迟 30 ms;带 journaling 时平均 append 延迟 6 ms,而且延迟方差显著下降。

加一层日志反而快了 5 倍 —— 这条数据本身就是"为什么所有存储系统最后都会在写路径上加一层缓冲日志"的最好注脚,值得和 Aurora 那篇的"日志即数据库"并读。

底层依赖:Fabric Controller、锁服务与数据中心的网络形态 ​

这套系统压在三件外部设施上,其中一件是它与“物理世界”的全部接口。

依赖一:Fabric Controller —— 它是 WAS 与物理集群之间唯一的接口。

Fabric Controller 是 Windows Azure 平台的资源供给与管理层,为 WAW 提供节点管理、网络配置、健康监控、服务实例的启停、服务部署;WAS 从它那里取网络拓扑信息、集群的物理布局、硬件信息。换句话说:WAS 不需要自己发现硬件,也不需要自己决定“哪台机器在哪” —— 这些事实由平台层提供。

这条依赖的形态值得记:它把“数据中心事实”从存储系统里剥离出去了。 前面几篇里,GFS 要自己管 chunkserver 与机架,Dynamo 要靠 gossip 发现成员;WAS 把这两件事都交给了平台层,于是它自己能专注在“分层 + 复制”上。代价是它无法脱离这个平台运行。

依赖二:一个 Paxos 锁服务。

它被用在两处:给 Partition Manager 做 leader 选举,以及给每个 Partition Server 发租约(PS 凭租约才有资格服务某些 RangePartition)。这套概念与 Chubby 同源,而它的作用范围很窄 —— 只服务分区层的所有权判定,不承载数据、不承载元数据。

这与 MegaStore 里 Chubby 的用法几乎一模一样(那里也只用它做 coordinator 的故障检测与锁)。两套系统独立地得出了同一个结论:协调服务只应该出现在“判定谁拥有什么”这一处,不该出现在数据路径上。

依赖三:数据中心的网络形态 —— 而且它被当作一个可调的设计变量。

设计里有一条明确的路线图判断:因为“计算与存储分离”是早期就定下的决定,目标从一开始就是“让计算能以高带宽访问存储,而数据不在同一节点、甚至不在同一个机架”。为此正在迁移到下一代的网络架构:扁平化数据中心拓扑,并在计算与存储之间提供全双工带宽。

这句话把一件常常被忽略的事说清了:一层架构的上限往往不在软件里,而在它脚下的网络形态里。 “数据放在别的机架上”这个前提一旦成立,“访问它的带宽有多大”就成了软件之外的决定因素 —— 而它只能靠改网络拓扑来解决,不是靠改协议。

四处依赖排成一张表:

依赖承担什么换得掉吗
Fabric Controller节点/网络/部署/健康,以及集群物理布局难换 —— 换掉它就要自己实现硬件发现与部署
Paxos 锁服务PM 的 leader 选举、PS 的租约换得掉(换成别的锁服务),影响面只到所有权判定
数据中心的网络拓扑计算与存储之间的带宽换得掉,但只能改硬件与拓扑,改不了协议
本地文件系统extent 落成一个 NTFS 文件,journal 独占一块盘换得掉,代价是改 extent 的存储形态

这张表里最值得记的一条是“extent 就是一个 NTFS 文件”。 它意味着这套系统的复制单位、seal 单位、纠删码单位,最终都落在一个普通文件系统的文件上 —— 分布式协议的最底层,是一个单机文件系统。

这份依赖表里最该单独记的是“平台层替它挡掉了什么”。 在别的系统里要靠系统自己解决的问题,这里都由 Fabric Controller 承担了:节点在哪、集群的物理布局、硬件信息、部署与升级、健康监控 —— 这五件事在 GFS 与 Dynamo 里是系统自身逻辑的一部分(master 管位置、gossip 发现成员),在这里是平台提供的输入。

这条差别的后果很实际:WAS 的设计里没有“成员管理”这一章 —— 它不需要一套 gossip 或一份成员表,因为它脚下有一个已经知道答案的平台层。代价是这套设计无法脱离该平台存在,也解释了为什么它的公开材料里“分层与复制”讲得极细,而“集群怎么组建”几乎不提。

还有一处依赖关系值得点明:这套设计里的“故障域”是由平台给出的。 副本要分散到不同故障域,而“哪些节点属于同一个故障域”这个事实不来自 WAS 自己 —— 它来自 Fabric Controller 提供的物理布局。换句话说,流层那个“随机选 EN 以避开相关失效”的策略,其有效性完全取决于平台给出的故障域划分是不是真的隔离。 把副本“随机”撒到同一路电、同一交换机的机器上,随机就成了装饰。

这条也解释了为什么“副本放置”在有的系统里是核心算法、在这里只占一段话 —— 当平台已经保证“机架 = 独立故障域(冗余网络与电力)”时,剩下的动作就是在已知的故障域之间随机取,不需要自己推断拓扑。

参数与可调项 ​

这套系统的参数分三层:平台级的目标值、流层的物理参数、分区层的行为选择。

平台级:stamp 的利用率与位置

项取值语义与后果
stamp 利用率目标维持约 70%,不超过 80%三个维度(容量、事务、带宽)各自衡量;留 20% 余量给磁盘短行程(只用外圈轨道)与机架故障时继续服务
账号的主位置建号时按位置亲和性指定(如 US North)位置服务按各 stamp 的满载程度、网络与事务利用率做启发式选择,然后更新 DNS
stamp 规模典型 10–20 个机架 × 18 个磁盘节点第一代约 2 PB 原始存储,下一代最多 30 PB

流层:几个“看起来像常数、其实是取舍”的值

项取值语义与后果
block 上限例如 4 MB(不要求等大)读必须读整块(校验和是 block 级的);客户端控制实际大小
extent 目标大小1 GB(由分区层指定)填满就在 block 边界 seal;extent 不要求等大,可以随时 seal,也可以长得很大
extent 副本数默认 3分布在不同故障域;副本位置由 SM 随机选
journal drive每个 EN 一整块盘或 SSD只写一条顺序日志;这是 30 ms → 6 ms 那笔收益的物理来源
IO 调度阈值预期待处理 IO 超过 100 ms,或有请求已排程但超过 200 ms 未被服务满足任一条件就把新 IO 换到另一个 spindle —— 这是一处真正的运行期旋钮
sealing 耗时平均 20 ms 内完成 seal 与分配新 extent客户端不必等具体节点恢复,新 extent 一分配就可继续追加
校验和验证周期所有 block 每隔几天做一次挡的是位翻转与静默损坏
冷 extent 的纠删码按 stream 策略调度只有 sealed 的(不可变的)extent 才适合做

分区层:三处“选择”而不是“数值”

项取值语义与后果
分区方式范围分区(而不是哈希)换来性能隔离与枚举的便利(同一账号的对象聚在一组 RangePartition 里),代价是顺序写会集中到最后一个 RangePartition
PartitionName 的排布应用可选哈希或分桶这是对上面那条代价的直接对冲手段 —— 官方给的建议就是“用哈希或分桶构造 PartitionName”
限流Sample-Hold 追踪最忙的 N 个 AccountName / PartitionName每个 PS 按请求率历史给每个账号算一个限流概率;流量小的账号不被限流
RangePartition 的粒度按流量动态切分与迁移一个 PS 生产上平均服务约 10 个 RangePartition
inter-stamp 复制由位置服务按账号配置它同时是“地理冗余”与“账号迁移”两个功能的实现手段

这张表里的值还有一个共同点:它们几乎全是“部署形态的换算结果”,而不是“性能参数”。 机架数与每机架节点数决定 stamp 的规模,副本数决定持久性档位,block 上限与 extent 目标大小决定一次 I/O 的粒度,利用率目标决定“什么时候该迁账号”。调其中任何一个,改的都是“这套部署能挡哪种故障、能装多大数据”,而不是“快一点或慢一点”。

一处容易被误读的数值是 block 的“例如 4 MB”。 它同时是三件事的取值:读写的最小单位、校验和的粒度、以及“读必须读整块”这条约束的来源。把它调小会让随机读更省,代价是校验和元数据更多、索引更密;调大则相反。官方用“例如”来写这个值,说明它是按负载定的起点,不是常数。

最后一点与“两个 20 ms 与 6 ms”有关。 表里出现过三个时间量:sealing 加分配新 extent 平均 20 ms、commit log 无 journal 时 append 平均 30 ms、有 journal 时 6 ms。它们分属两层 —— 前者是流层的控制动作,后两者是数据路径的延迟。排查时把它们混在一起看,就分不清“是控制面慢”还是“数据面慢”。

四条读表纪律 ​

四条读这张表的纪律:

  1. 70% 这个利用率目标本身就是一条参数。 它是为两类具体事情留的余量:磁盘短行程要用外圈轨道,机架故障时还要继续服务。把 stamp 塞满,等于同时放弃这两件事。
  2. extent 的 1 GB 只是“分区层指定的目标”,不是硬约束。 官方明确写“extent 不要求等大、可以随时 seal、也可以长得很大” —— 它是给分区层用的一个默认值,不是流层的性质。
  3. 范围分区与哈希分区是二选一,而且各有明确的下行。 选范围,就得接受顺序写热点,并用 PartitionName 的哈希/分桶来对冲;选哈希,就得放弃对象局部性与枚举的便利。官方选范围的理由是“隔离与枚举”,这是一条业务判断,不是技术判断。
  4. 限流是一处容易忽略的“隐性参数”。 它决定的是**“突发流量什么时候被拒绝”。看到请求被限流时,要分清是自己的账号请求率过高**(概率型限流),还是访问模式无法被负载均衡(单 PartitionName 高流量、顺序扫描)—— 两者处置完全不同。

一处已经写进设计的“不可调”:事务范围就是 PartitionName。 它不是参数,是 API 语义 —— 想让两组数据各自事务,就给它们不同的 PartitionName;想对多行做原子事务,就让它们共享同一个 PartitionName。“划事务边界”这件事在这套系统里的操作面,就是构造 PartitionName 的字符串。

“四条读表纪律”之外还有第五条,它管的是这张表里最容易误读的那一类值。 表里有一部分是目标值(70%),一部分是默认值(extent 1 GB、副本 3、block 4 MB),一部分是阈值(100 ms / 200 ms 的 IO 调度)。三者的可调性完全不同:目标值是运维护栏,改它等于改“什么时候触发迁账号”;默认值是起点,extent 的 1 GB 就被明确说成“由分区层指定的目标”;只有阈值是纯运行期的旋钮。

把这三类分开,能避免一类很常见的误操作:把目标值当成性能参数去调。 “把 stamp 利用率目标从 70% 提到 85%” 看起来像在提高利用率,实际是在放弃磁盘短行程与机架故障时的容量余量 —— 它换来的是更早的一次账号迁移,而不是性能。

最后一条与“谁来定义故障域”有关。 表里所有与副本、故障域相关的取值,都建立在“机架 = 独立故障域(冗余网络与电力)”这个由平台层保证的事实上。换句话说,这张表里有一半的值不能独立于部署环境来读 —— 换一个平台、换一种机架定义,副本数与放置策略都要重算。

最后补一处“这张表与运行期的关系”。 这张表里真正能在运行期随手改的只有一类东西:IO 调度的阈值与 extent 的目标大小(而且后者是分区层传给流层的参数,不是流层的性质)。其余全部要么是建 stamp 时定的物理构成(机架数、每机架节点数、副本数、journal 盘的分配),要么是账号级的置位(主 stamp、是否开 inter-stamp 复制)。换句话说:这套系统的可调空间在“部署”与“建号”两个时刻就被用掉了,运行期留给运维的旋钮很少 —— 这恰好与它“运维动作是迁账号而不是调参”的结论一致。

版本演进:三层分工的后续与四处被复盘的初始决定 ​

这条线的时间刻度与规模刻度都很清楚:

   2008-11   在生产环境上线(设计写于 2011,已运行约三年)
     │
   2011      SOSP 那篇:stamp + 三层 + 两种复制引擎
     │        · 第一代 stamp 约 2 PB
     ▼
   同期       正在迁移到下一代数据中心网络
     │        · 扁平化拓扑 + 计算与存储之间的全双工带宽
     ▼
   下一代     stamp 原始存储最多 30 PB

四处被复盘的初始决定,是这一节最值钱的部分 —— 它们解释了这套架构为什么长成这样:

决定理由代价或后续
计算与存储分离计算核与存储可各自独立扩容;多租户下多一层隔离;两套系统各自做负载均衡目标从一开始就是“数据不在同节点甚至不同机架也能高带宽访问”,因此要迁到扁平化网络 + 全双工带宽
范围分区而不是哈希性能隔离更容易(同一账号的对象聚在一组 RangePartition 里),并且枚举方便;还能把可疑账号圈在自己的分区里限流顺序写会集中到 key 范围的最后一个 RangePartition;对冲手段是给 PartitionName 做哈希/分桶
应用级限流与隔离关键难点是“过载时该限谁、且不能误伤安分账号”用 Sample-Hold 只追最忙的 N 个名字;按请求率历史算限流概率
自动负载均衡多租户环境下维持高可用、应对单用户流量洪峰靠切分与迁移 RangePartition 实现 —— 也就是“范围分区”这个选择的配套工程

第二行那条代价与对冲值得单独记,因为它是可迁移的。 范围分区天然把“顺序键写入”变成一个热点(一个例子是依次插入 2011-06-30:12:00:00、…:12:00:02、…:12:00:10,全部落到最后一个 RangePartition)。官方给的缓解办法落在应用侧:给 PartitionName 做哈希或分桶,而不是改分区算法 —— 这与 Cassandra 当年用保序哈希换来范围扫描、后来改回随机哈希是同一类问题的两极:顺序性带来的好处与它造成的热点,通常只能取一边。

还有一条“因为 append-only 而免费拿到”的演进线索。 这份设计明确写了三处收益:① 保留历史状态做快照几乎不额外花钱(于是快照/版本功能容易提供);② 纠删码这类优化变得可行(只有不可变的 sealed extent 才适合编码);③ 诊断与修复变得容易 —— 历史变更被保留下来,工具可以把系统从一个损坏状态恢复到之前某个已知一致的状态。

第三条是运营视角的收益,而它同样是“不可变性”的副产品。 这一点把它与 Bigtable / HBase 那条 LSM 路线连起来了:两者都用“只追加”换取了“旧版本始终可读”,区别是 LSM 用它换读性能,WAS 用它换一致性与可恢复性。

还有一条与“这套分层为什么会被后续沿用”有关的判断。 WAS 的三层分工(无状态的接入层 / 认识对象的索引层 / 不认识对象的存储层)在后来几乎成了云存储的默认形状 —— 它把“可扩展”与“可复制”这两件事分别放到了两层里,于是两层可以各自用最适合自己的手段。

与 MegaStore 对照能看得更清:那里也在 Bigtable 之上补了一层认识对象的语义层,但那层的原子性单位是 schema 里的 entity group,而 WAS 的原子性单位是地址里的一个字段(PartitionName)。两者都在“分层”这个想法上,但一个把分层的接口留在了建模阶段,另一个把它留在了命名阶段。 谁的迁移成本更低,取决于应用能不能自由改自己的命名。

还有一条“这套架构里哪一部分最容易被后来的系统照搬”的判断。 三层分工(无状态接入 / 认识对象的索引 / 不认识对象的存储)被沿用得最多 —— 因为它解决的是通用问题:怎么让接入层无限扩、怎么让语义层有事务、怎么让存储层可以傻到能被复制。而“两种复制引擎”这一条被沿用的少得多,因为它依赖一个前提:你有两个层次的故障域,而且它们的发生频率差几个数量级。

这个前提在只有单个地域部署的系统里就不成立 —— 没有“地理级灾难”这一档,就没有第二套复制的必要。于是同一套架构在不同部署形态下会退化成两层或一层,而这不是缺陷,是它本来就是按故障域的层级设计的。

不适用于什么:范围分区的代价与它换来的东西 ​

成立前提

  • 能接受“事务范围 = PartitionName”。 跨 PartitionName 没有原子事务;要原子性就得让相关行共享一个 PartitionName,而共享得越多,能并行的地方越少。
  • 访问模式能被负载均衡。 顺序写(尤其是“只往 key 范围末尾写”)会集中到单个 RangePartition;系统不会替你解决,要靠给 PartitionName 做哈希或分桶。
  • 能接受突发流量被概率性限流。 过载时系统按请求率历史选择性限流,目标是“不误伤安分账号”,所以高请求率的账号会被更大概率地拒绝。
  • 能接受两级持久性而不是一级。 intra-stamp 挡硬件故障(频繁),inter-stamp 挡地理灾难(罕见)且是异步的 —— 地理级的恢复点取决于异步复制的进度。
  • 能接受“写比读贵”且需要额外一次写。 journal 那笔账很清楚:用关键路径之外的多一次写换低延迟。

前提被违反时的后果

违反的前提后果
顺序键持续写入所有写集中到最后一个 RangePartition,分区与负载均衡都用不上;横向扩展失效
跨 PartitionName 要原子性做不到 —— 这是 API 语义层面的边界,不是配置问题
请求率长期很高被概率性限流;限流按账号算,所以“别的账号很闲”帮不上忙
极高写入吞吐append 要等所有副本成功,所以延迟由最慢的那个 EN 决定;副本多一个就多一个“最慢”的候选
依赖地理级即时恢复inter-stamp 复制异步且面向带宽最优,恢复点是滞后的
需要把数据拷出平台硬依赖 Fabric Controller 与平台网络形态,换平台等于换掉底层设施

与相邻系统的对照

WASGFSMegaStoreDynamo
谁是 primary,怎么定SM 在生成 extent 时定死,不用租约用租约选 primary(chunk 会被反复改)leader + coordinator无 primary
写入确认要几个副本三个都要一个(pipeline 上最近的那个)多数(Paxos)W + R > N
原子性范围PartitionName单行(apply 一次原子)entity group无
定序靠什么PartitionName 的范围 + PS 租约无(追加顺序)日志位置 + Paxos版本向量
二次一致性靠什么两套复制引擎分级无(单层三副本)跨组队列反熵

这张表最值得记的对照是与 GFS 的那一行: 两者都靠“追加 + 固定 primary”回避了大部分协调,但 GFS 需要在“副本写成功一个就够”上让步,WAS 不需要 —— 因为 WAS 把可变性从数据结构里彻底去掉了(extent 未 seal 时只追加),于是“三个副本必须都成功”这件事不会因为并发写而变复杂:没有并发写同一个位置的可能。

代价落在谁身上

代价落在谁身上
构造 PartitionName(含必要时哈希/分桶)应用的数据建模
顺序写热点的对冲应用 —— 系统只负责限流,不负责重排
跨 PartitionName 没有原子性应用的事务设计
概率性限流高请求率的账号
二级持久性的恢复点容灾预期与运维
硬依赖平台与数据中心网络部署选择

这张表的落点与前几篇一致:复杂度被推到“应用怎么给数据命名”这一步。 而它比 MegaStore 更往上一层 —— 那里要划的是 schema 里的外键,这里要划的只是一个字符串(PartitionName)。同一个问题的两种粒度,而粒度的选择决定了迁移成本:改一个字符串比改 schema 便宜,但你能表达的关系也更少。

还有一条与“要不要用范围分区”有关的补充。 范围分区换来的“隔离与枚举”是业务侧的收益,而它付出的“顺序写热点”是技术侧的代价。当两者冲突时,官方的立场是让应用改命名,而不让系统改分区方式 —— 这条立场本身说明了这套系统的定位:它把“数据怎么命名”当成应用的责任,而不是当成系统要自动优化的东西。

于是有一句可执行的判据:如果一个应用的 key 是单调的(时间戳、自增序列),它必须自己决定“要不要给 PartitionName 加哈希” —— 而这个决定没有中间选项:不加哈希就要承担单分区热点,加了哈希就要放弃按 PartitionName 顺序枚举的能力。

排查:从症状到判据 ​

   症状
     │
     ├─ 请求被拒(限流)────────▶ 先看是自己的请求率,还是访问模式
     │                              ├─ 账号请求率长期高 ⇒ 概率型限流,改不了就要分摊到多个账号
     │                              └─ 单个 PartitionName 高流量 / 顺序扫描 ⇒ 无法均衡,只能改命名
     │
     ├─ 写入吞吐上不去,且越来越慢 ▶ 看写入是否集中在 key 范围末尾
     │                              └─ 是 ⇒ 顺序写热点,给 PartitionName 做哈希或分桶
     │
     ├─ 写延迟方差大 ───────────▶ 看是不是“最慢的 EN”在决定延迟
     │                              └─ 看该 EN 的 data disk 是否被读抢占
     │                                 (journal 存在的意义就是这条)
     │
     ├─ extent 切换时出现停顿 ───▶ 看 sealing 与分配新 extent 的耗时
     │                              └─ 平均应约 20 ms;明显偏大 ⇒ 看 SM 与 EN 的连通性
     │
     ├─ 某个 PS 挂后部分请求失败 ▶ 看 PM 是否已重新分配并更新 Partition Map
     │                              └─ 租约到期 + 重新分配需要时间,属预期窗口
     │
     └─ 读到损坏数据 ───────────▶ 看校验和
                                    └─ block 级校验和 + 每隔几天的全量验证

三条判读原则

  1. 先分清“被限流”与“变慢”,两者处置相反。 被限流是系统主动拒绝(保护其他租户),变慢是资源竞争。把限流当性能问题去调,会一路调到“系统在按设计保护别人”。
  2. 写侧的问题先看“键的分布”,再看“副本的响应”。 范围分区把“怎么命名”变成了写吞吐的决定因素 —— 顺序键写入造成的热点是设计层面的,任何参数都救不了;而“最慢副本拖慢整体”是流层面的,journal 就是为它存在的。
  3. 读侧很少是这套系统的瓶颈来源。 读路径上有 block cache、内存中未落盘的 journal 数据、以及 sealed extent 的纠删码优化;真正需要查读的时候,先确认读的是 sealed 还是未 seal 的 extent —— 两者的服务路径不同。

还有一条与“该不该继续扩”有关的判据。 stamp 的利用率目标(70%、不超 80%)与“满了就把账号迁到别的 stamp”这套机制合起来说明:扩容的动作是“迁账号”,不是“加大 stamp”。看到某个 stamp 持续接近 80%,正确的动作是准备迁移,而不是继续往里塞 —— 那 20% 的余量是为短行程与机架故障留的,不是为增长留的。

最后一条与“限流该不该被当成故障”有关。 限流的目的是在多租户下保住其他租户,所以它按账号计算概率、并追踪该账号是否“在被限流后会退让”。这意味着同一个集群上,一个安分账号与一个高请求率账号观察到的行为完全不同 —— 前者几乎感觉不到限流,后者会周期性遇到拒绝。

这也是排查时最需要先确认的前提:看到请求被拒,先确认这个账号的请求率历史,再看是不是访问模式(单 PartitionName 高流量、顺序扫描)导致负载无法被均衡。两种原因的处置完全相反:前者要分摊请求,后者要改命名。

最后一条与“运维动作的粒度”有关。 这套系统的运维主要围绕两个动作:迁账号(stamp 到 70% 时)与限流(PS 过载时)。两者都属于“改变数据的归属”,而不是“重启”或“调参” —— 账号换 stamp、请求按账号被拒绝。

这也是它与本栏其他系统在运维面上的差别:HBase 的运维是“调参数 + 跑 compaction”,Cassandra 是“调级别 + 跑 repair”,而这里的运维是“决定谁的数据放在哪”。越靠近多租户的系统,运维动作越像调度而不像调参。

判据速查 ​

问题答案
WAS 相对 Dynamo / Spanner 的位置不靠时钟设施也不放弃强一致,靠把强一致限定在分区层、把持久性交给流层
stamp 的规模与构成10–20 个机架、每机架 18 个磁盘节点;第一代约 2 PB,下一代最多 30 PB
为什么利用率目标定 70%、不超 80%留 20% 给磁盘短行程(用外圈轨道)与机架故障时继续服务
为什么 stamp 满了要迁移账号用跨 stamp 复制把账号迁到新 stamp —— 这是 LS 的负载均衡手段
LS 自己怎么容错分布在两个地理位置
三层分工的实质流层管"写下的不会变",分区层管"谁是最新的与写入顺序",FE 无状态
流层的 API 特征所有写都是 append-only
block 的读写与校验读写最小单位;读必须读整块(校验和是 block 级的);所有 block 每隔几天做一次校验和验证
extent 的参数复制单位,默认 3 副本,存在一个 NTFS 文件里,分区层目标大小 1 GB
小对象 / TB 级对象分别怎么放小的多个挤同一 extent 甚至同一 block;大的拆到许多 extent
什么操作很快用拼接现有 extent 构造新 stream(只更新指针列表)
SM 是单点吗不是 —— 它是标准 Paxos 集群,而且在客户端关键路径之外
SM 为什么不管 blockblock 总数可能极大,SM 无法扩展到跟踪它们
副本放置靠什么在不同故障域里随机选 EN,避免电力/网络/同机架的相关失效
为什么这里不用 leaseextent 未 seal 时 primary 与副本位置永不改变 —— 把可变性去掉就省掉了租约
多块追加的契约代价客户端必须预期同一个 block 被追加多次并正确处理重复记录
重复记录怎么处理commit log 用序列号;行/blob 数据靠"只有最后一次写被引用"、旧的被回收
流层给分区层的两条保证① 追加并确认后任何副本读到相同数据;② extent seal 后任何 sealed 副本读到相同内容
这两条保证是"最新性"吗不是 —— 全是不可变性。最新性与顺序由分区层负责
流层处理恶意对手吗不处理(交给数据中心 / Fabric Controller / WAS 的安全机制);它处理的是位翻转、断电、软件 bug 等导致数据损坏的故障,用校验和检测
Object Table 是什么可增长到数 PB 的内部表,按流量切分成 RangePartition
RangePartition 的定义OT 中从 low-key 到 high-key 的一段连续行;同 OT 内互不重叠、覆盖每一行
Blob/Entity/Message 表的主键AccountName + PartitionName + ObjectName
FE 靠什么路由Partition Map Table
加 journaling 的效果commit log 的 append 延迟 30 ms → 6 ms,且方差显著下降

| 强一致靠什么 | PartitionName 的范围 + PS 的租约:同一时刻只有一个 PS 能服务某个 RangePartition | | 租约的粒度 | 每个 RangePartition 一把;一个 PS 生产上平均同时服务约 10 个 | | PS 挂了怎么办 | PM 把它的 RangePartition 按负载重新分配给别的 PS,并更新 Partition Map Table | | commit length 是什么 | 副本上“按顺序提交的最后那个位置”;三副本 bits 相同靠四条性质(primary 不变、offset 由 primary 选、按序提交、失败即 seal) | | sealing 取哪个长度 | 取能联系到的副本中最小的 commit length —— 不会丢数据,因为 primary 只在三个副本都成功后才确认 | | 写确认要几个副本 | 三个都要(与 GFS 的“一个就够”是明确分歧) | | journal 为什么让写更快 | 追加不必与去 data disk 的读争设备就能提交;journal 先成功时读由内存服务,于是方差下降 | | 两套复制各挡什么 | intra-stamp 挡硬件故障(频繁、在关键路径上);inter-stamp 挡地理灾难(罕见、后台) | | inter-stamp 还兼做什么 | 账号迁移 —— 位置服务按账号配置它 | | 事务范围是什么 | PartitionName;三种抽象各自把哪一段当 PartitionName 见 ## 全局命名空间 | | 顺序写为什么会成热点 | 范围分区下,写 key 范围末尾的请求全部落到最后一个 RangePartition | | 顺序写热点的对冲手段 | 给 PartitionName 做哈希或分桶(官方建议,属应用侧动作) | | 限流是怎么算的 | Sample-Hold 追踪最忙的 N 个名字;按请求率历史给每个账号算限流概率 | | 70% 利用率目标是为谁留的 | 磁盘短行程与机架故障时继续服务,不是为增长留的 | | 这份设计自己复盘了哪四处决定 | 计算与存储分离 / 范围分区而非哈希 / 应用级限流隔离 / 自动负载均衡 | | append-only 顺带换来什么 | 快照几乎零成本、纠删码可行、可从损坏状态恢复到之前的已知一致状态 |

相关 ​

  • GFS —— 对照:GFS 必须用租约选 primary(chunk 会被反复修改),WAS 不用(extent 只追加,primary 固定)—— 可变性决定了要不要租约
  • Dynamo / Spanner 与 F1 —— 强一致与高可用的另外两条路线(放弃 / 用时钟设施买)
  • Bigtable —— 同构的分层:那里是 tablet 建在 GFS 上,这里是 RangePartition 建在 stream/extent 上
  • Aurora —— 也提到"加一层日志"的收益;那篇的日志即数据库与本篇的 journaling 数字可以并读

参考 ​

  • B. Calder, J. Wang, A. Ogus, N. Nilakantan, A. Skjolsvold, S. McKelvie, Y. Xu, S. Srivastava, J. Wu, H. Simitci, J. Haridas, C. Uddaraju, H. Khatri, A. Edwards, V. Bedekar, S. Mainali, R. Abbasi, A. Agarwal, M. F. ul Haq, M. I. ul Haq, D. Bhardwaj, S. Dayanand, A. Adusumilli, M. McNett, S. Sankaran, K. Manivannan, L. Rigas. Windows Azure Storage: A Highly Available Cloud Storage Service with Strong Consistency. SOSP 2011.

贡献者 ​

文件历史 ​