Bigtable
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 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:
- 确保至多一个活跃 master;
- 存 Bigtable 数据的引导位置(bootstrap location);
- 发现 tablet server,并判定 tablet server 的死亡;
- 存 schema 信息(每张表的 column family 信息);
- 存访问控制列表。
代价被量化了: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 compaction | memtable 达到阈值 → 冻结它、建新 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 参数控制)。代价是牺牲一点空间,收益是可以只解压一小块而不必解压整个文件。
很多客户端用两遍自定义压缩:
- 第一遍用 Bentley-McIlroy 方案,在大窗口内压缩长的公共字符串;
- 第二遍用快速算法,在 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 条目按
排序。排序输出里,某个 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.filesize | 10737418240(10 GB) | tablet 切分阈值 |
hbase.hregion.memstore.flush.size | 134217728(128 MB) | memtable → SSTable 的阈值 |
hbase.hregion.memstore.block.multiplier | 4 | 到 4× 阈值就阻塞更新 |
hbase.regionserver.global.memstore.size | 代码逻辑 0.4 | 全局 memstore 上限(占堆) |
hfile.block.cache.size | 0.4 | block cache 占堆比例 |
hbase.hstore.blockingStoreFiles | 16 | StoreFile 超过 16 就阻塞更新 |
hbase.hstore.compaction.min / .max | 代码逻辑 3 / 10 | minor compaction 的入选下限与上限 |
hbase.hstore.compaction.ratio | 1.2F | 较大的 StoreFile 是否入选 |
hbase.hregion.majorcompaction | 604800000(7 天) | major compaction 周期 |
hbase.hregion.majorcompaction.jitter | 0.50 | 在周期两侧抖动,避免同时开跑 |
hbase.client.write.buffer | 2097152(2 MB) | 客户端写缓冲 |
hbase.regionserver.handler.count | 30 | RPC 处理线程数 |
zookeeper.session.timeout | 90000(90 秒) | 协调服务会话超时 |
hbase.rpc.timeout | 60000(60 秒) | 客户端 RPC 超时 |
hbase.client.retries.number | 15 | 最大重试次数 |
hbase.regionserver.region.split.policy | SteppingSplitPolicy | 什么时候切 region |
hbase.table.max.rowsize | 1073741824(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 Cassandra | Bigtable 的模型 + Dynamo 的去中心方案 —— 就是本栏 05 篇那条路 | |
| Apache Accumulo | NSA | 在模型之上加了单元格级访问控制 |
| 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 数暴涨 |
三条判读原则:
- 先分清"被拒绝"与"变慢"。 这套系统里有多个主动阻塞的机制(memstore 到倍数就挡更新、StoreFile 太多就挡更新)。"写不进去"的第一判据是 CPU 与磁盘忙不忙 —— 都不忙就是被挡住了,这时该调阈值,而不是加资源。
- 读放大来自"一个 tablet 由多少 SSTable 组成"。 这个数是读性能的直接决定量,而它由 compaction 策略控制。看到读变慢,先数 SSTable 个数,而不是先看磁盘带宽。
- 删除的可见性由 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, |
| 支持跨行事务吗 | 不支持,只有单行事务 |
| 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 的组更大 |
| 合流后恢复怎么不变慢 | 按 |
再补几行(覆盖本轮补进来的内容):
| 问题 | 答案 |
|---|---|
| 它真正的外部依赖有几处 | 两处外部系统(GFS 管数据、Chubby 管控制面)+ 一处格式约定(SSTable) |
| 哪一处依赖更致命 | Chubby —— 它挂了连"谁是 master、数据在哪"都答不出来;实测影响被压在万分之几 |
| 云版本的规模锚点 | 2024 年 4 月:超过 10 EB 数据、每秒 70 亿次以上请求 |
| 云版本与内部版的关系 | 同一份代码;对外还提供 HBase 兼容 API 作为迁移入口 |
| 底层存储换过吗 | 换过 —— 正文里是 GFS,云版本的 tablet 存在 Colossus 上 |
| 被开源继承得最多的是什么 | 数据模型与 SSTable 格式,而不是架构(HBase 换了文件系统与协调服务,Cassandra 连架构都换了) |
| 单一索引的代价是什么 | 改键等于全量重写 —— schema 设计成为使用者的全部工作 |
| 压缩率为什么能到 10:1 | 行布局让相似数据相邻,Bentley-McIlroy 因而能识别出同主机页面间大量共享样板 |
这张表的用法:它的每一行都是"某个设计决定在后来被检验的结果" —— 依赖形态、规模锚点、底层换代、开源分支各换了什么、单一索引的代价、压缩率的来源。读这类系统的资料时,"后来被验证了什么"比"当时怎么设计的"更能说明设计的分量。
最后三条结构性判断(决定会变,这三条最稳):
- 把两个外部构件划清 —— 数据面(分布式文件系统)与控制面(协调服务)分开,各自可以独立换代。GFS → Colossus 换掉了前者,而整套设计没动,就是这条划清的价值;
- 把语义全部留给客户端 —— 值是不可解释的字节、索引只有 row key。代价与收益出自同一处:存储层不必理解数据,因此可以极简;
- 把并发控制推到后台的合并上 —— 靠"数据不可变"这条性质。它决定了这套系统能持续追加写入,而不是原地修改。
这三条合起来构成本专栏里"追加 + 后台合并"这一族的共同骨架 —— 本栏 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 月的规模锚点)
YJ