Skip to content

Bigtable ​

标签
分布式/存储
字数
11558 字
阅读时间
46 分钟

Bigtable(OSDI 2006)是 Google 的结构化数据存储,建在 GFS 之上。它被定位成"能可靠扩展到 PB 级数据与数千台机器"的系统,上线时已被 60 多个 Google 产品使用(Analytics、Finance、Orkut、Personalized Search、Writely、Google Earth),集群规模从几台到数千台服务器、存到几百 TB。

它在设计上有一个很明确的态度:不追求完整的关系模型。Bigtable 提供的是一个简单的数据模型,它支持对数据布局与格式的动态控制,并让客户端能推理数据的局部性。

数据模型:一个三维有序 map ​

Bigtable 的定义只有一句:

它是一个稀疏的、分布式的、持久的多维有序 map,索引是 (row key, column key, timestamp),每个值是未解释的字节数组。

(row:string, column:string, time:int64)→string

最能说明意图的例子:存网页的表,row name 是反转的 URL(com.google.www 这种,好让同一域名下的页面聚在一起),contents column family 存页面内容,anchor column family 存引用该页面的锚文本。同一行的不同 column 由冒号分隔的 family:qualifier 指定。

三个设计后果 ​

三个设计后果值得单独指出:

  • 不支持完整的关系数据模型,也没有跨行事务(只有单行事务);
  • 数据当未解释的字符串处理 —— 客户端自己把结构化/半结构化数据序列化进去,Bigtable 不管语义;
  • 客户端可以通过 schema 的精心选择控制数据局部性,schema 参数还能动态控制数据是从内存服务还是从磁盘服务。

两个构件:SSTable 与 Chubby ​

Bigtable 的实现只依赖两个外部构件。

SSTable ​

SSTable 是 Google 内部的存储文件格式:一个持久的、有序的、不可变的 key→value map,key 与 value 都是任意字节串。支持两个操作:查指定 key 的值、在指定 key 范围内迭代。

结构上:

  • 内含一系列 block,典型每块 64 KB(可配置);
  • block index 存在文件末尾,打开 SSTable 时加载进内存;
  • 因此一次查寻只需一次磁盘寻道 —— 先在内存索引里二分定位 block,再从盘读那一块;
  • 可选地把整个 SSTable 映射进内存,于是查寻与扫描完全不碰盘。

Chubby ​

Chubby 是高可用、持久的分布式锁服务(5 个活跃副本,一个选为 master 主动服务;多数副本运行且能互通时服务才活着;Chubby 内部用 Paxos 保持副本一致)。它提供目录与小文件的命名空间,每个文件或目录都能当锁用,读写原子;客户端与服务之间维持会话,会话靠租约续期,续不上就过期并丢失所有锁与打开的句柄;客户端还能在文件/目录上注册变更或会话过期的回调。

Bigtable 用 Chubby 做了五件事,这五件事解释了它为什么离不开 Chubby:

  1. 确保至多一个活跃 master;
  2. 存 Bigtable 数据的引导位置(bootstrap location);
  3. 发现 tablet server,并判定 tablet server 的死亡;
  4. 存 schema 信息(每张表的 column family 信息);
  5. 存访问控制列表。

代价被量化了:Chubby 长时间不可用会让 Bigtable 不可用。跨 11 个 Chubby 实例、14 个 Bigtable 集群的测量显示,因 Chubby 不可用导致数据不可用的平均时间占比是 0.0047%;受影响最大的单集群是 0.0326%。

组件与职责划分 ​

三个组件:链接进每个 client 的库、一个 master、多个 tablet server。

角色职责
master分配 tablet 给 tablet server、检测 tablet server 的加入与过期、平衡负载、垃圾回收 GFS 里的文件、处理 schema 变更(建表 / 建 column family)
tablet server管理一组 tablet(典型 10 到 1000 个)、处理读写、切分过大的 tablet
client 库直接与 tablet server 通信

与 GFS 同构的那条分工 ​

一条与 GFS 同构的分工:client 数据不经过 master,client 直接与 tablet server 读写。因为 client 不依赖 master 获取 tablet 位置信息,多数 client 从不与 master 通信 —— 对应的说法是"master 在实践中负载很轻"。

一个 tablet 内部:commit log + memtable + SSTables ​

tablet 的状态由三层构成:

  • commit log:存 redo 记录,更新先提交到这里;
  • memtable:最近提交的更新存在内存里的一个有序缓冲;
  • 一系列 SSTable:较旧的更新。

三条路径:恢复 / 写 / 读 ​

恢复路径:tablet server 从 METADATA 表读出该 tablet 的元数据(含组成它的 SSTable 列表与一组 redo points —— 指向可能含该 tablet 数据的 commit log 的指针);把 SSTable 的索引读进内存,然后通过重放自 redo points 以来提交的所有更新来重建 memtable。

写路径三步:① 检查格式良好、发送者有权限(权限从 Chubby 文件读允许写者列表,几乎总是 Chubby 客户端缓存命中);② 合法 mutation 写入 commit log,用 group commit 提升大量小 mutation 的吞吐;③ 提交后内容插入 memtable。

读路径:同样先做格式与权限检查,然后在 SSTable 序列与 memtable 的合并视图上执行 —— 两者都是字典序有序结构,所以合并视图可以用一次归并得到。

读写可以在 tablet 切分与合并进行时继续。

三层状态与读写路径放在一起看:

        ┌───────────── tablet 的三层状态 ──────────────┐
        │                                              │
 写 ──▶ │  ① commit log    (redo 记录,写先到这里)    │
        │        │                                     │
        │        ▼                                     │
        │  ② memtable      (最近提交,内存有序缓冲)    │
        │        │                                     │
        │        ▼                                     │
        │  ③ 一组 SSTable  (较旧的更新,落在 GFS 上)   │
        └──────────────────────────────────────────────┘
                       ▲
   读 ──▶ 在「SSTable 序列 + memtable」的合并视图上执行
          (两者都是字典序有序结构 ⇒ 一次归并就能得到视图)

   恢复 ──▶ 从 METADATA 读出该 tablet 的 SSTable 列表与 redo points
              └─▶ 把 SSTable 的索引读进内存
                    └─▶ 重放 redo points 之后提交的所有更新,重建 memtable

三档 compaction ​

memtable 会一直增长,所以有一整套后台整理机制:

档触发与动作目标
minor compactionmemtable 达到阈值 → 冻结它、建新 memtable、把冻结的转成 SSTable 写入 GFS① 降低 tablet server 内存占用;② 减少 server 挂掉时从 commit log 恢复要读的数据量
merging compaction后台定期把若干 SSTable 与 memtable 合成一个新 SSTable,输入完成后即可丢弃限制 SSTable 的数量 —— 否则读操作可能要在任意多个 SSTable 上合并更新
major compaction把所有 SSTable 重写成恰好一个回收已删数据占用的资源,并保证已删数据及时消失

三档的层次与各自的目标:

   memtable(内存里的有序缓冲)
        │  达到阈值 ⇒ minor compaction
        │      目标:① 降 tablet server 的内存占用
        │            ② 减少 server 挂掉时从 commit log 恢复要读的数据量
        ▼
   若干 SSTable ─── 后台定期 ⇒ merging compaction ───▶ 合成一个新 SSTable
        │              目标:限制 SSTable 的数量
        │                    (否则一次读可能要在任意多个 SSTable 上合并更新)
        ▼
   全部 SSTable ─── 定期 ⇒ major compaction ───────▶ 恰好一个 SSTable
                       目标:回收已删数据占用的资源,
                             并保证已删数据及时消失

后两档的关键区别在删除 ​

后两档的关键区别在于删除:非 major compaction 产出的 SSTable 里可能含特殊删除条目,用来抑制仍存活的旧 SSTable 里的已删数据;而 major compaction 产出的 SSTable 不含任何删除信息或已删数据。Bigtable 轮转所有 tablet 并定期对它们做 major compaction —— 这条对存敏感数据的服务很重要。

底层依赖:两个构件与它们的边界 ​

正文说"只依赖两个外部构件",但这两处的依赖形态不同,值得分开看:

构件依赖的形态出问题时的表现
GFS数据落点 —— SSTable 与 commit log 都写在这里数据面:读写延迟与吞吐下降
Chubby控制面 —— 五件事全部关于"谁是谁、谁能做"元数据面:master 选举、位置、schema、ACL 全断

两者掉的时候,这套系统的表现不一样:GFS 慢 → 读写变慢(可感知的降级);Chubby 不可用 → 整套不可用,因为它连"谁是 master、数据在哪"都答不出来。所以 Chubby 的依赖被单独量化了:跨 11 个 Chubby 实例、14 个 Bigtable 集群的测量里,因 Chubby 不可用导致数据不可用的平均时间占比 0.0047%,最差单集群 0.0326%。 这两个数说明控制面依赖才是那条真正的单点,而它的实测影响被压在了万分之几。

第三条依赖其实被拆散在正文各处:SSTable 这个文件格式。 它不是外部系统,但它决定了上面很多东西:

性质它决定了什么
不可变compaction 的全部设计;以及"删除只能用特殊条目抑制"
有序block index 二分 ⇒ 一次查寻一次寻道;读路径能"一次归并"
以 block 为单位压缩按 block 做(牺牲压缩率换"只解压一小块");block index 能装进内存

"不可变"这条的分量最大:它让"追加 + 后台合并"这条路走得通 —— 数据写下就不再改写,于是并发控制被推到"合并"这一步,而合并是后台做的。本栏好几篇(GFS 的追加语义、LSM-Tree、Haystack)都属这一族,各自只是选了不同的落点。

一条与 GFS 那篇的对照:GFS 的三层让步(网络、文件系统、数据完整性)在 Bigtable 这一层被复用,但它自己又加了一层"协调服务" —— Chubby 提供的"至多一个 master"比 GFS 的单点更强(GFS 的 master 是单点但没有选举)。这也解释了为什么"垃圾回收 GFS 里的文件"这类动作能交给 master 独占做 —— 它假设了同一时刻只有一个 master。

三处依赖各自的"失效半径"不一样,摆在一起看很清楚:

依赖失效半径系统还能做什么
GFS 变慢读写变慢元数据操作照常;已缓存与在内存里的数据照常服务
GFS 不可用读写全停已进 memtable 的数据还在内存里,但新的持久化与 compaction 都做不了
Chubby 不可用整套不可用连"谁是 master、数据在哪"都答不出来 —— 没有降级形态
SSTable 格式变触发全量重写这是"格式即契约"的代价:格式一改,存量数据都要过一遍

第二行与第三行的差别是这套设计的一条重要性质:数据面与控制面的失效半径不对称。 数据面慢下来还能降级服务,控制面断了没有降级可言 —— 所以"把控制面做小、做稳"是这套架构的基本取向:Chubby 里存的是很小的元数据(引导位置、schema、ACL),大块的数据一概不进去。这个分工在本专栏反复出现:把"少量但关乎全局"的东西单独放进一个极稳的组件里。

第四行是"格式即契约"的代价:SSTable 本身不可变,但格式要演进 —— 一旦格式变了,存量 SSTable 只能靠读时兼容或后台重写来处理。这与 GFS 那篇里"不提供 POSIX API 换来自定义语义"属同一类取舍:接口越少,演进时的束缚越少;而格式一旦被大量数据采用,它就变成了接口。

最后一条依赖在"格式"这一侧,值得单列:SSTable 的 block 大小(典型 64 KB)同时决定三件事 —— 一次查寻要读多少数据、压缩时能利用多大的局部性、以及 block index 要占多少内存。它是这套系统里少数"一个数字牵动三处"的参数:调大 → 局部性更好、压缩率更高,但每次随机读要多读;调小 → 随机读更精细,但索引更大、压缩率下降。这类"一处改动换三处"的参数,是判断系统耦合程度的好指标 —— 耦合越紧,单个参数越难独立调优。

一处容易忽略的依赖方向:locality group 是"让客户端参与布局"的接口。它允许客户端把"不一起访问"的 column family 隔到不同 SSTable,甚至声明某一组常驻内存。也就是说,这套系统把"数据在物理上怎么分组"的一部分决定权交给了客户端 —— 与"把 schema 设计交给客户端"是同一个取向的延伸。代价与应用侧一致:布局选得好不好直接体现在读放大与内存占用上,而系统不做判断。

五处性能改进 ​

"refinements"那一节是整篇最实用的部分。

局部性组(locality group) ​

客户端可以把多个 column family 分组成一个 locality group,每个 tablet 为每个 locality group 生成一个独立的 SSTable。把通常不一起访问的 family 隔开,读就更省 —— 例子:Webtable 里页面元数据(语言、校验和)一组,页面内容另一组,只想读元数据的应用不必读穿全部页面内容。

locality group 还能声明为 in-memory:它的 SSTable 被惰性加载进 tablet server 内存,加载后读该组的列完全不碰盘。这个特性用于"小而频繁访问的数据",Bigtable 自己拿它放 METADATA 表的 location column family。

压缩:两遍方案与实测压缩率 ​

压缩是按 SSTable block 为单位做的(block 大小也由 locality group 参数控制)。代价是牺牲一点空间,收益是可以只解压一小块而不必解压整个文件。

很多客户端用两遍自定义压缩:

  1. 第一遍用 Bentley-McIlroy 方案,在大窗口内压缩长的公共字符串;
  2. 第二遍用快速算法,在 16 KB 小窗口里找重复。

速度数字:两遍都很快,编码 100–200 MB/s,解码 400–1000 MB/s。

选算法时重速度而轻压缩率,但结果超出预期:Webtable 里存网页内容的实验达到 10:1 的空间压缩,远好于 HTML 页面上 Gzip 典型的 3:1 到 4:1。原因在于行的布局——同一主机的所有页面存在相邻位置,Bentley-McIlroy 因此能识别出同主机页面之间大量的共享样板。

一条可迁移的经验:很多应用都把 row name 设计成让相似数据聚在一起,因此能拿到很好的压缩率;而存同一值的多个版本时压缩率还会更好。

两级缓存 ​

缓存缓存什么对哪类负载有用
Scan Cache(上层)SSTable 接口返回给 tablet server 代码的键值对反复读同一数据
Block Cache(下层)从 GFS 读来的 SSTable block读与刚读过的数据相邻(顺序读,或热行内同一 locality group 的不同列的随机读)

Bloom filter ​

一次读要读组成该 tablet 状态的全部 SSTable,它们不在内存时会造成大量磁盘访问。客户端可以指定对某个 locality group 的 SSTable 建 Bloom filter,用来问"该 SSTable 是否可能含有指定 row/column 对的数据"。

效果是两条:用少量 tablet server 内存大幅减少读的磁盘寻道;以及对不存在行/列的查询大多完全不必碰盘。

commit log 的合流与排序恢复 ​

这一条最能体现"工程取舍是多米诺式的"。

先看问题:如果每个 tablet 一个独立日志文件,GFS 里会并发写极大量文件,可能造成大量磁盘寻道;而且日志独立会削弱 group commit(组会更小)。

修法:每个 tablet server 只追加到一个 commit log,把不同 tablet 的 mutation 混在同一个物理日志文件里。

于是恢复变复杂:tablet server 挂掉后,它服务的 tablet 会被迁到很多其他 server 上,每个新 server 要从原 server 的日志里重放属于自己那个 tablet 的 mutation,而这些 mutation 是混在一起的。

朴素解法的代价可以算:如果 100 台机器各分到一个 tablet,那个日志文件会被读 100 遍。

解法:先把 commit log 条目按

⟨table, row name, log sequence number⟩

排序。排序输出里,某个 tablet 的所有 mutation 变成连续的,于是一次寻道加一次顺序读就能读完。为了并行化,日志文件被切成 64 MB 段,在不同 tablet server 上并行排序;整个过程由 master 协调,在某个 tablet server 表示需要从 commit log 恢复时发起。

参数与可调项 ​

模型层的旋钮(从正文里抽出来):

旋钮取值换什么
tablet 大小每个 tablet server 管 10–1000 个负载均衡粒度 ↔ 元数据开销
SSTable block典型 64 KB压缩率 ↔ 随机读要解压多少
memtable 阈值触发 minor compaction内存占用 ↔ 恢复要读的日志量
SSTable 数量由 merging compaction 限制读放大 ↔ 后台 I/O
major compaction轮转所有 tablet 并定期做已删数据消失的及时性 ↔ I/O 峰值
locality group每组一个独立 SSTable,可标 in-memory读放大 ↔ 元数据与内存
Bloom filter按 locality group 指定内存 ↔ 读的寻道数
commit log 段切成 64 MB 段并行排序恢复并行度 ↔ 协调开销

它的开源直系实现是 HBase,默认值都在官方 hbase-default.xml 里。挑与上面这些对应的 19 条:

参数默认值对应什么
hbase.hregion.max.filesize10737418240(10 GB)tablet 切分阈值
hbase.hregion.memstore.flush.size134217728(128 MB)memtable → SSTable 的阈值
hbase.hregion.memstore.block.multiplier4到 4× 阈值就阻塞更新
hbase.regionserver.global.memstore.size代码逻辑 0.4全局 memstore 上限(占堆)
hfile.block.cache.size0.4block cache 占堆比例
hbase.hstore.blockingStoreFiles16StoreFile 超过 16 就阻塞更新
hbase.hstore.compaction.min / .max代码逻辑 3 / 10minor compaction 的入选下限与上限
hbase.hstore.compaction.ratio1.2F较大的 StoreFile 是否入选
hbase.hregion.majorcompaction604800000(7 天)major compaction 周期
hbase.hregion.majorcompaction.jitter0.50在周期两侧抖动,避免同时开跑
hbase.client.write.buffer2097152(2 MB)客户端写缓冲
hbase.regionserver.handler.count30RPC 处理线程数
zookeeper.session.timeout90000(90 秒)协调服务会话超时
hbase.rpc.timeout60000(60 秒)客户端 RPC 超时
hbase.client.retries.number15最大重试次数
hbase.regionserver.region.split.policySteppingSplitPolicy什么时候切 region
hbase.table.max.rowsize1073741824(1 GB)单行大小上限

按六要素摊开三处。

hbase.hregion.max.filesize(默认 10 GB) —— 语义是"一个 region 的 HFile 总和超过它就把 region 切成两个"。它把正文里"切分过大的 tablet"这句落成了一个具体数值。 什么时候该改?region 太大 → 切分与恢复的粒度太粗(一次迁移或 compaction 要搬更多数据);太小 → region 数暴涨,元数据与 master 负载上升。联动的是 hbase.regionserver.region.split.policy(默认 SteppingSplitPolicy)—— 一个决定"什么时候切",这个决定"切到什么程度"。 失败模式是region 数增长到元数据吃紧,而症状看起来像"master 变慢"。

hbase.hregion.memstore.flush.size(默认 128 MB)与 .memstore.block.multiplier(默认 4) —— 前者是 memtable 转 SSTable 的阈值,也就是 minor compaction 的触发点;后者更值得说:memstore 涨到 4 × flush.size 时,更新会被直接阻塞。 官方给的理由是"防止更新流量尖峰时 memstore 失控 —— 没有上界的话,flush 出来的文件要花很久才能 compact 或 split,更糟的是会 OOME"。这是一个"宁可拒绝写入也不让内存爆掉"的旋钮,它把背压做在了写入路径上。 失败模式:尖峰流量下写请求超时或报错,而 CPU 与磁盘都不忙 —— 因为瓶颈是"被主动拒绝"。

hbase.hstore.compaction.ratio(默认 1.2F) —— 语义是"判定某个较大的 StoreFile 是否参与 minor compaction 的比例"。它的官方描述直接点了 Bigtable 的名字:取一个很低的值(比如 0.25)会产生与 BigTable 的 compaction 算法相似的行为、产出四个 StoreFile。这条描述本身就是一份血统证据 —— HBase 把 Bigtable 的 compaction 策略当成了可比较的参照点。取舍也写全了:调高 → 写成本上升(compaction 动的文件更大);调低 → 要压住"读时碰多少个 StoreFile",就得靠 Bloom filter。 联动 .compaction.min(3)与 .max(10)。

一处结构性观察:HBase 的默认值里有一组防护型参数(blockingStoreFiles 16、memstore.block.multiplier 4、table.max.rowsize 1 GB、majorcompaction.jitter 0.50)—— 它们不为提升性能,而是在异常输入或流量尖峰下保住系统。 Bigtable 的正文里没有这一类旋钮;它们出现在开源实现里的原因,与 GFS→HDFS 多出运维参数的原因相同:外部部署要面对不可控的写入模式。

把这张默认值表和正文对照,能看出一条分界:正文里那些取值(64 KB block、64 MB 日志段)是内部的经验值,而 HBase 把它们变成了用户可调的参数 —— 并且加上了 Bigtable 没有的防护型旋钮(阻塞阈值、行大小上限、compaction 抖动)。

这条分界说明"框架"与"平台"的差别不在功能多少,而在"谁承担调参的责任"。 内部系统里参数由少数了解全局的人定,改一次要权衡所有使用方;开源实现面对的是不可控的部署与写入模式,于是把每个阈值都暴露出来,并把"超过阈值就暂停"做成默认行为。代价是调参成了使用者的活 —— 材料里那种"参数难调"的抱怨,根源不在这里:是责任被转移了,而不在参数设计得差

版本演进:从内部系统到云服务与三条开源支线 ​

第一条:同一个系统,后来变成了云服务。 Bigtable 2006 年公开,2015 年 5 月 6 日以 Cloud Bigtable 的形式对外提供,而且用的是与 Google 内部版本相同的代码。规模锚点:截至 2024 年 4 月,Bigtable 管理超过 10 EB 数据、每秒服务 70 亿次以上请求。后来的增量方向是 SQL 支持、增量物化视图、全局二级索引、自动扩缩。

这里有一处结构性变化值得单独说:存储与计算被分开了 —— 节点只负责服务请求,数据存在 Colossus 上,所以"扩吞吐"就是改节点数、数据不用搬。而对外接口里有一档是 HBase 兼容 API,也就是说它把当年被开源模仿的那套接口,反过来当成了迁移入口。两处数字给出了适用边界:SSD 集群典型稳态约每节点每秒 1 万次查询、延迟 6 毫秒;新节点要大约 20 分钟才能被充分利用 —— 后者说明"弹性"不是瞬时的。

第二条:三条开源支线,各自换了不同的东西。

系统出自从 Bigtable 拿了什么、换了什么
Apache HBase依公开的设计说明实现,成为 Hadoop 的主数据库照搬数据模型与 SSTable;换掉底层(跑在 HDFS 上,协调用 ZooKeeper 而不是 Chubby)
Apache CassandraFacebookBigtable 的模型 + Dynamo 的去中心方案 —— 就是本栏 05 篇那条路
Apache AccumuloNSA在模型之上加了单元格级访问控制
Hypertable独立开源项目走同一条路,2016 年停止开发

这条对照说明一件事:Bigtable 被继承得最多的部分是它的数据模型与 SSTable 格式,而不在它的架构 —— HBase 换掉了分布式文件系统与协调服务,Cassandra 连架构都换了。能跨系统活下来的往往是"数据长什么样",而不是"系统怎么搭"。

第三条:底层从 GFS 换到了 Colossus。 正文里 SSTable 与 commit log 都写在 GFS 上,而 Cloud Bigtable 的 tablet 存在 Colossus 上的 SSTable 文件里。这与 GFS 那篇的演进线合上了 —— 存储层换代而上面这层设计几乎没动,正是因为当初就把它当成了"两个外部构件之一"。

一条判断:这套设计最长寿的部分是"只有一个索引"这个决定。 它换来了排序带来的局部性与压缩收益,代价是把"schema 设计"变成了使用者的全部工作。后来的增量(二级索引、物化视图)都在这条约束之外补,而没有把它改掉 —— 说明这条约束就是这套系统的身份,而不是权宜之计。

三处演进合起来,能看出这套设计的"可变部分"与"不可变部分"分得很清楚:

可变的不可变的
底层存储(GFS → Colossus)数据模型(三维有序 map)
协调服务的归属与部署形态单一索引(row key)
上层的补充(SQL、二级索引、物化视图)值的不可解释性
是否对外提供服务追加 + 后台合并的写路径

左列变了四样,右列一样没动。 这个格局回答了一个常被问到的问题:"这套设计算成功还是过时?" —— 它的接口层面(能查什么、怎么写)一直有效,而实现层面被换掉过。 本专栏好几个系统都呈这个形状,而 Bigtable 是其中分界最干净的一个:它把"不该动的东西"收缩得极窄(一个索引、一个字节串模型),于是能在下面换掉两个外部系统而不影响使用者。

一条与 RDD 那篇呼应的判断:一个抽象的长寿程度,取决于它承诺了多少。 承诺越具体(强一致、多索引、跨行事务),被换掉的成本越高;承诺越窄(一个有序 map),就越能熬过底层换代。 这套设计选的是窄承诺 + 把代价推给 schema 设计 —— 从 2006 年活到 2024 年还在涨规模,说明这笔交换是划算的。

还有一处演进方向值得单独提:云版本的增量(SQL 支持、增量物化视图、全局二级索引)全都落在"补上它原本没有的能力"上,而没有一条去动"只有一个索引"这个前提。 也就是说,二级索引是被"加"上去的,不是被"改"出来的 —— 底下的数据模型仍然只有一个索引,追加的那一层在它之上另行维护。这与"窄承诺换长寿"那条判断一致:改前提会牵动全部使用者,所以补能力的正确位置是在外面加一层。

不适用于什么:Schema 决定一切 ​

这套设计的边界几乎全部落在同一个地方:它只有一个索引。

你想要的能不能给为什么
按主键查或扫能这本来就是它唯一擅长的
跨行事务不能只有单行事务 —— 本栏 08 篇就是在为补这个而存在
二级索引不能要按别的维度查,得自己另建一张表并保持一致
join不能没有跨行语义
把语义交给存储不能值被当作未解释的字节数组,结构由客户端序列化进去
按值类型做压缩不能压缩看重的是行布局带来的相似性,不是字段类型
小数据、短突发不适合云版本的说法很直接:别用几 GB 数据测 30 秒就下结论
写错 row key 之后改代价极高只有一个索引 ⇒ 改键等于全量重写

"改键等于全量重写"这一条最贵,机制值得说清:row key 就是数据的物理排列顺序,它决定了这条数据落在哪个 tablet、和谁相邻,而不只是"一个可以改的字段"。一旦选错,能做的只有"读出来、按新键写进另一张表"。 这也解释了为什么"很多应用都把 row name 设计成让相似数据聚在一起" —— 这个设计动作同时买到了压缩率(相邻行共享样板)与扫描效率(相关的行连续)。

还有一条边界在"谁承担后果"上:值被当作未解释的字节,意味着读写两端必须自己维护编码约定;客户端能推理局部性,意味着局部性的好坏由客户端负责。 这套系统把"数据长什么样"的大部分决定权给出去了,换回来的是"存储层不必理解语义"。 本栏前面几篇里,Dremel 走的是相反的路(存储层理解嵌套结构、自己保 levels)—— 两者在"语义放在哪一层"上正好相反。

一条给选型的判断:如果访问模式事先清楚、而且能用主键表达,这套系统几乎没有对手;如果访问模式会变、或者需要多维度查,它会把代价全部转成"应用侧自己维护副本"。 判断办法很直接:把要做的查询列出来,看有几条能只用 row key 表达 —— 只有一条靠得住的时候,剩下的每一条都要应用自己造一张表。

把边界收成一张判断表,可以照着过一遍:

你的情况结论
查询都能用主键表达合适,而且很难找到对手
有一部分查询要用别的维度要么另建索引表自己维护,要么换系统
需要跨行事务不合适 —— 那是上一层的活(本栏 08 篇)
需要 join不合适
写入尖峰明显可以,但要预期"被阻塞"是设计的一部分,而不是故障
有"删了就必须查不到"的要求可以,但必须显式安排 major compaction(默认 7 天 + 抖动)
数据量只有几 GB、只跑几十秒测不出真实表现 —— 云版本官方明确说过这一点
schema 还没定、访问模式会变风险最高的一类 —— 改键等于全量重写

最后一行是这套设计最主要的失败模式,而且它有一个容易被低估的性质:选错 row key 不会立刻出问题,而是在数据量涨起来之后才暴露 —— 因为"热点集中在一个 tablet""扫描要跨很多 tablet"这类症状,都要等数据分布铺开才明显。所以 schema 设计质量与数据量是耦合的:小规模时看不出差别,规模上来之后改不动。

一条给设计的经验(材料自己暗示的):把"相似的东西放在一起"当成 row key 的第一原则。 它同时买到三样 —— 压缩率(相邻行共享样板,实测 10:1 正是这么来的)、扫描效率(相关的行连续)、以及 tablet 切分的天然边界。而"均匀分布"与"相邻聚集"这两个目标常常冲突(顺序键会均匀得很糟,哈希键会聚集得很糟),取舍点由"主要按范围扫还是按点查"决定 —— 这就是这套系统的 schema 设计没有通用答案的原因。

一处容易被误解的地方要澄清:"不适合小数据、短突发"说的是"测不出真实表现",而不是"不能用"。 这条界限来自云版本自己的提示 —— 它的性能特征要在长时间、大数据量下才显现,因为它的机制都围绕"数据规模带来的分布"设计:压缩收益来自相邻行共享样板、读放大取决于 SSTable 的数量、tablet 切分要等数据涨起来才发生 —— 这些在小数据上都观察不到。所以这条边界对"测"和"用"是两回事:小规模确实能用,只是别用小规模的数字去推断大规模的表现。

排查:从症状到判据 ​

症状判据先看什么常见归因
读变慢,SSTable 数还在涨一个 tablet 由多少个 SSTable 组成SSTable 数量、compaction 是否在跑merging compaction 跟不上 —— 读要在更多 SSTable 上合并
写突然被阻塞是磁盘慢还是被主动拒绝memstore 占用、StoreFile 数防护型参数生效:memstore 到 4× 阈值、或 StoreFile 数超过 16
恢复时间长commit log 有多大、要重放多少redo points、日志长度minor compaction 太少 ⇒ 要重放的数据多
已删数据仍能被读到major compaction 是否跑过该 tablet 的 compaction 记录只有 major compaction 才真正清干净;非 major 阶段靠删除条目抑制
某个 tablet 明显更热切分是否均匀tablet 分布、master 负载row key 设计造成热点(顺序键会把写集中到最后一个 tablet)
整个集群"同时"不可用协调服务是否可用Chubby 会话与选举控制面依赖,不是数据面的问题
client 报超时但服务端不忙重试与超时参数重试次数、RPC 超时客户端重试策略与超时不匹配;也可能是被主动拒绝(见第二行)
master 变慢region 数与元数据量region 数切分阈值太小 ⇒ region 数暴涨

三条判读原则:

  1. 先分清"被拒绝"与"变慢"。 这套系统里有多个主动阻塞的机制(memstore 到倍数就挡更新、StoreFile 太多就挡更新)。"写不进去"的第一判据是 CPU 与磁盘忙不忙 —— 都不忙就是被挡住了,这时该调阈值,而不是加资源。
  2. 读放大来自"一个 tablet 由多少 SSTable 组成"。 这个数是读性能的直接决定量,而它由 compaction 策略控制。看到读变慢,先数 SSTable 个数,而不是先看磁盘带宽。
  3. 删除的可见性由 major compaction 决定。 如果业务上有"删了就必须查不到"的要求,唯一有效的旋钮是 major compaction 的周期与抖动(默认 7 天 + 0.50 抖动),而不是别的。这一条在敏感数据场景里是硬要求。

最后一条与前文接得上:这套系统的三个状态层(commit log / memtable / SSTable)各自对应一类故障与一类症状 —— commit log 管恢复、memtable 管写入的缓冲与背压、SSTable 管读放大。排查时先定位症状落在哪一层,比按参数表逐个试要快得多。

把这套系统的排查落点收一句:它的症状几乎都能归到四层里的一层。

层症状落在这一层的表现该动什么
commit log恢复慢、重启后重放很久compaction 频率、日志段大小
memtable写入被阻塞、内存占用高flush 阈值、block.multiplier
SSTable读变慢、读放大、删除迟迟不消失compaction 策略与 major 周期
元数据面region 数暴涨、master 变慢、整套不可用切分阈值、协调服务

这张表和前面那张症状表是同一件事的两种切法:一个按"看到什么"查,一个按"东西在哪一层"查。排查时先用后者定位层,再用前者核对具体症状 —— 顺序反过来,容易在参数表里反复试。

最后一条经验与这套系统的取向一致:它的旋钮几乎都作用在"什么时候把内存里的东西搬到盘上"这一件事上 —— flush 阈值、compaction 上下限、major 周期与抖动,全是同一个动作的不同参数。根因是它的写路径就是"先内存、再后台搬",所以性能问题最终都会表现成"搬运的节奏不对":搬得太慢(读放大)、搬得太快(I/O 峰值与写阻塞)、或者根本没搬(删除不消失)。抓住这一点,那张参数表就不再是一堆孤立的数字。

这条取舍的因果链值得画出来,因为它是"多诺米式的":

   问题:每个 tablet 一个独立日志文件
        └─▶ GFS 里并发写极大量小文件(可能造成大量磁盘寻道)
        └─▶ 日志独立会削弱 group commit(组更小)
        │
        ▼
   修法:每个 tablet server 只追加到一个 commit log
        └─ 不同 tablet 的 mutation 混在同一个物理日志里
        │
        ▼
   新问题:server 挂掉后,它的 tablet 会被迁到很多别的 server 上,
           每个新 server 要从原日志里重放「属于自己那个 tablet」的 mutation
        │
        ▼
   朴素解法的代价:100 台机器各分到一个 tablet ⇒ 那个日志文件被读 100 遍
        │
        ▼
   实际解法:先按 ⟨table, row name, log sequence number⟩ 排序
        ├─ 排序输出里,某个 tablet 的所有 mutation 变成连续的
        ├─ ⇒ 一次寻道 + 一次顺序读就能读完
        └─ 日志切成 64 MB 段,在不同 tablet server 上并行排序(由 master 协调)

判据速查 ​

问题答案
数据模型一句话稀疏、分布式、持久的多维有序 map,(row,column,timestamp)→bytes
支持跨行事务吗不支持,只有单行事务
SSTable 是什么持久的、有序的、不可变的 key→value map,内部是一串 block(典型 64 KB)
SSTable 一次查寻几次寻道一次(内存里的 block index 二分 + 读一个 block)
Bigtable 对 Chubby 的依赖有几处五处:唯一 master、bootstrap 位置、tablet server 发现与死亡判定、schema、ACL
Chubby 不可用的影响有多大14 个集群实测:数据不可用时间占比平均 0.0047%,最差单集群 0.0326%
client 会打扰 master 吗一般不会 —— client 不依赖 master 拿 tablet 位置,多数 client 从不联系 master
memtable 是什么存最近提交更新的内存有序缓冲
恢复怎么重建 memtable按 METADATA 里的 redo points,重放自那以来提交的所有更新
minor / merging / major compaction 的目标降内存、限 SSTable 数量、彻底清掉已删数据
为什么 non-major compaction 会留删除条目用它抑制仍存活的旧 SSTable 里的已删数据;只有 major compaction 才真正清干净
locality group 解决什么把不一起访问的 column family 隔到不同 SSTable;还可标为 in-memory
压缩为什么要按 block 做牺牲一点压缩率,换"只解压一小块"的能力
实测压缩率Webtable 页面内容 10:1(同主机页面相邻,样板被识别出来)
两级缓存各管什么Scan Cache 管"重复读同一数据",Block Cache 管"读相邻数据"
commit log 为什么合流避免 GFS 里海量并发小文件写入,并让 group commit 的组更大
合流后恢复怎么不变慢按 ⟨table,row,log seq⟩ 排序 → 每个 tablet 的 mutation 连续;切成 64 MB 段并行排序

再补几行(覆盖本轮补进来的内容):

问题答案
它真正的外部依赖有几处两处外部系统(GFS 管数据、Chubby 管控制面)+ 一处格式约定(SSTable)
哪一处依赖更致命Chubby —— 它挂了连"谁是 master、数据在哪"都答不出来;实测影响被压在万分之几
云版本的规模锚点2024 年 4 月:超过 10 EB 数据、每秒 70 亿次以上请求
云版本与内部版的关系同一份代码;对外还提供 HBase 兼容 API 作为迁移入口
底层存储换过吗换过 —— 正文里是 GFS,云版本的 tablet 存在 Colossus 上
被开源继承得最多的是什么数据模型与 SSTable 格式,而不是架构(HBase 换了文件系统与协调服务,Cassandra 连架构都换了)
单一索引的代价是什么改键等于全量重写 —— schema 设计成为使用者的全部工作
压缩率为什么能到 10:1行布局让相似数据相邻,Bentley-McIlroy 因而能识别出同主机页面间大量共享样板

这张表的用法:它的每一行都是"某个设计决定在后来被检验的结果" —— 依赖形态、规模锚点、底层换代、开源分支各换了什么、单一索引的代价、压缩率的来源。读这类系统的资料时,"后来被验证了什么"比"当时怎么设计的"更能说明设计的分量。

最后三条结构性判断(决定会变,这三条最稳):

  1. 把两个外部构件划清 —— 数据面(分布式文件系统)与控制面(协调服务)分开,各自可以独立换代。GFS → Colossus 换掉了前者,而整套设计没动,就是这条划清的价值;
  2. 把语义全部留给客户端 —— 值是不可解释的字节、索引只有 row key。代价与收益出自同一处:存储层不必理解数据,因此可以极简;
  3. 把并发控制推到后台的合并上 —— 靠"数据不可变"这条性质。它决定了这套系统能持续追加写入,而不是原地修改。

这三条合起来构成本专栏里"追加 + 后台合并"这一族的共同骨架 —— 本栏 07 篇(LSM-Tree)与 10 篇(Haystack)各自只是把落点放在了不同的层上。

相关 ​

  • GFS —— Bigtable 的底层存储:SSTable 与 commit log 都写在 GFS 上
  • Zab 与 ZooKeeper —— Chubby 的同类系统;Bigtable 对 Chubby 的五处依赖说明"协调服务"在这一代系统里的位置
  • 03-一致性模型 —— Bigtable 只给单行事务,跨行一致性的缺失是上层(如 MegaStore)要补的

参考 ​

  • F. Chang, J. Dean, S. Ghemawat, W. C. Hsieh, D. A. Wallach, M. Burrows, T. Chandra, A. Fikes, R. E. Gruber. Bigtable: A Distributed Storage System for Structured Data. OSDI 2006.
  • Apache HBase. hbase-default.xml(官方默认值). https://hbase.apache.org/ —— 用于与 Bigtable 的机制逐项对照
  • Google Cloud Bigtable 公开资料(2015 年 5 月对外提供、与内部版同一份代码、Colossus 上存 SSTable、2024 年 4 月的规模锚点)

贡献者 ​

文件历史 ​