引子:培训课上一个让人费解的概念
前一阵子参加 TiDB 的培训,在介绍事务实现时,讲师提到了一个关键概念——Column Family(CF)。
为了帮助大家理解,讲师把 CF 比喻成一个"分区",更准确一点应该是“逻辑分区-独立的存储区域”。
作为一个长期和数据库打交道的人,脑子里马上冒出了几个问题:
- CF 到底是什么?和 HBase 里的 CF 是同一个东西吗?
- 为什么 TiKV 里面会出现 default、lock、write 三个 CF?
- 数据为什么不能简单放在一起?
带着这些疑问,我重新翻了一下 Google 的 Bigtable、Percolator 两篇经典论文,又顺着 TiKV 和 RocksDB 的实现一路往下深入。
最后发现:
Column Family 这个名字一直没有变,但它在不同系统里的职责发生了变化。
在 Bigtable 中,它是连接数据模型和存储管理的一种组织方式;
到了 HBase,它成为用户可以直接设计的数据模型;
而到了 TiKV,它借助 RocksDB 的 Column Family 机制,成为存储引擎内部隔离不同类型数据的工具。
还是那个“抽屉”,只是以前抽屉是用户整理数据的工具,到了 TiKV,它成了存储引擎自己管理数据的工具。
不过,在深入之前,需要先澄清一个很多人都会产生的误解:
TiKV 并不是把一张用户表直接拆成三个 Column Family。
真正发生的是:
TiDB 先把表编码成 Key-Value,再按照 Percolator 的事务模型,把不同类型的数据分别写入 default、lock、write 三个 CF。
也就是说,拆分发生在事务存储层,而不是用户表层。
理解这一点,后面很多设计就顺理成章了。
一、追根溯源:Bigtable 为什么提出 Column Family?
要理解 Column Family,需要回到 2006 年 Google 发布的经典论文:
Bigtable: A Distributed Storage System for Structured Data
在 Bigtable 中,一条数据由四个维度共同定位:
Row Key + Column Family + Column Qualifier + Timestamp
其中:
- Row Key:行键
- Column Family:列族
- Column Qualifier:列名
- Timestamp:版本号
很多人第一次看到 Column Family,都容易把它理解成"列的文件夹"。
其实,它更准确的定位是:
Column Family 是一组列的命名空间(Namespace)。真正的列由「Column Family + Column Qualifier」共同确定。
例如:
Row Key |
Column Family |
Column Qualifier |
|---|---|---|
user_001 |
info | name |
user_001 |
info | age |
user_001 |
address | country |
user_001 |
address | city |
这里,info 和 address 就是两个 Column Family,而 name、age、country、city 则是对应的 Column Qualifier。
也就是说,真正的列名其实是:
info:nameinfo:ageaddress:countryaddress:city
那么,Bigtable 为什么没有直接使用普通列,而是引入 Column Family?
原因主要有三个。
1. 逻辑分组
同一个 Column Family 中的列,通常具有相似的数据类型和访问模式。
例如,用户基本信息(info:name、info:age)和用户地址信息(address:country、address:city)天然属于不同类别。
2. 管理单元
Column Family 同时也是资源管理的基本单位。
例如:
- 访问控制(ACL);
- 内存统计;
- 磁盘统计;
- 压缩策略。
也就是说,它不仅是一个逻辑概念,也在告诉存储系统:这些数据应该作为一个整体进行管理。
3. 数据局部性
不同类型的数据,访问模式往往完全不同。
例如,用户资料(user_profile)经常被扫描,而用户日志(user_log)更多是持续追加写入。
如果把两类数据混在一起:
- 扫描会读入大量无关数据;
- 缓存容易互相挤占;
- 压缩策略也难以兼顾。
因此,Bigtable 提出了 Column Family,让不同类型的数据拥有不同的存储策略。
这也是 Column Family 最初的设计初衷:把逻辑上的数据组织和物理上的存储管理结合起来。
二、HBase:Bigtable 的开源实现
提到 Column Family,大多数人首先想到 HBase。
原因很简单:HBase 几乎完整继承了 Bigtable 的设计理念。
在 HBase 中:
- 建表时需要定义 Column Family;
- 每个 CF 对应独立的 Store;
- 每个 Store 管理自己的 StoreFile;
- 每个 CF 可以配置不同的 TTL、压缩算法、Bloom Filter 等参数。
需要注意的是,Column Family 是固定的,但是 Family 下有哪些列(Qualifier),可以动态增加。
例如,info:name、info:age、info:email 可以在写入时直接新增。
不过,这种设计也不是没有代价。
每增加一个 Column Family,就意味着需要维护更多:
- MemStore;
- StoreFile;
- Flush;
- Compaction。
因此,HBase 社区一直建议:不要为一张表创建过多 Column Family。
直到这里,Column Family 仍然属于用户的数据模型。
三、到了 TiKV,Column Family 的角色变了
TiKV 同样使用了 Column Family。
但这里的 CF,已经不是 HBase 那个意义上的 CF。
特性 |
HBase |
TiKV |
|---|---|---|
| 用户是否可见 | 是 |
否 |
| 定义方式 | 建表时定义 |
存储引擎内部创建 |
| 用途 | 组织用户数据 |
隔离不同类型的数据 |
一句话总结就是:
HBase 的 Column Family 面向数据建模;TiKV 的 Column Family 面向存储实现。
不过,它们仍然保留了 Bigtable 最重要的一点:每个 Column Family 都是一个独立的数据存储单元。
到了 RocksDB,这句话可以进一步具体化。
在 RocksDB 的实现中,一个 Column Family 基本可以看成一棵独立的 LSM Tree。
为什么?
因为每个 CF 都维护着自己的一整套状态:
- MemTable;
- Immutable MemTable;
- SST 文件;
- Version;
- Compaction。
而多个 CF 则共享同一个 RocksDB 实例,例如:
- WAL(Write-Ahead Log);
- 后台线程池;
- (可选共享的)Block Cache。
因此,TiKV 的三个 CF,并不是三个数据库,而是同一个 RocksDB 实例中的三棵 LSM Tree。
四、为什么 TiKV 要设计三个 CF?
终于来到本文最核心的问题。
为什么不是一个 CF,而是 default、lock、write 三个?
答案来自 Google 的另一篇经典论文:
Percolator: Large-scale Incremental Processing Using Distributed Transactions and Notifications
需要注意的是:Percolator 并没有定义三个 Column Family,它定义的是三类逻辑数据:
- data
- lock
- write
TiKV 借助 RocksDB 的 Column Family,把这三类数据映射到了三个独立的 CF 中。
Percolator 逻辑数据 |
TiKV CF |
存储内容 |
|---|---|---|
| data | default | 真正的业务数据 |
| lock | lock | 事务锁信息 |
| write | write | 提交记录 |
所以,default、lock、write 并不是 Percolator 的设计,而是 TiKV 在 RocksDB 上的一种工程实现。
为什么不能放在一起?
因为它们的数据特征完全不同。
CF |
数据大小 |
生命周期(访问特点) |
|---|---|---|
| lock | 极小 |
毫秒级(延迟极其敏感) |
| write | 小(几十字节) |
较长(高频写入、高频查询) |
| default | 可达 KB~MB |
最长(保存真实业务数据) |
对于存储引擎来说,有一个非常重要的原则:
生命周期相近、访问模式相似的数据,尽量放在同一棵 LSM Tree 中。
如果三类数据混在一起:
- 生命周期不同的数据会频繁参与同一次 Compaction,造成大量无意义的数据重写,增加写入放大(Write Amplification);
- 大 Value 的整理会拖慢小记录的 Compaction;
- 大对象会不断挤占 Block Cache,影响热点小数据的访问效率。
因此,TiKV 选择把它们拆分到三个独立的 Column Family。
最终,一个 RocksDB 实例内部,同时维护着三棵独立的 LSM Tree:
- default CF:保存真正的数据;
- write CF:保存版本记录;
- lock CF:保存事务锁。
每棵树都有自己的 MemTable、SST 文件、Version 和 Compaction 逻辑。
它们在 LSM 管理层面彼此独立,因此:
default中的大对象整理,不会拖慢lock的访问;write中高频的小记录写入,也不会增加default的 Compaction 负担。
当然,它们仍然共享 WAL、线程池等公共资源,因此这里说的"隔离",主要指的是 LSM Tree 层面的隔离。
五、从源码看:为什么说 CF 就是一棵 LSM Tree?
如果继续往 RocksDB 源码里追,很快就会遇到一个核心对象:
ColumnFamilyData
它并不仅仅保存一个 CF 的名字。
实际上,它负责维护一个 Column Family 从创建到运行的整个生命周期,其中包括:
- MemTable;
- Immutable MemTable;
- Version;
- SST 文件;
- Compaction 状态。
也就是说,在 RocksDB 内部,并不是"一个 LSM Tree 下面分三个目录",而是:
一个 RocksDB 实例下面,维护着多个彼此独立的 LSM Tree。
这也是为什么很多 RocksDB 开发者都会直接说:
一个 Column Family,就是一棵独立的 LSM Tree。
从这个角度再回头看 TiKV 的设计,也就不难理解了:
创建一个新的 CF,本质上就是在同一个 RocksDB 实例中新增一棵 LSM Tree。相比启动一个新的数据库实例,它成本更低;相比把所有数据混在一起,它又提供了足够好的物理隔离能力。
这正是 TiKV 选择这种实现方式的重要原因。
六、Column Family 的角色,一直在演进
回顾整个发展过程,可以发现:
- Bigtable 提出了 Column Family,把数据组织和物理存储管理结合起来;
- HBase 继承了 Bigtable,把 Column Family 变成了用户可见的数据模型;
- TiKV 借助 RocksDB 的 Column Family 机制,将 Column Family 用作存储引擎内部的数据隔离单元,用来承载 Percolator 的 data、lock、write 三类逻辑数据。。
核心思想没有变,但服务对象发生了变化。。
Bigtable、HBase 的 Column Family,解决的是数据如何组织的问题;TiKV 的 Column Family,解决的是数据如何高效存储的问题。
结语:三个抽屉,其实是三棵 LSM Tree
现在再回头那句话:
"Column Family 就是一个独立的存储区域。"
其实没有说错,只是少说了一半。对于 TiKV 来说,更准确的说法应该是:
一个 Column Family,不只是一个独立的存储区域,更是一棵独立管理的 LSM Tree。
- default:保存真实业务数据;
- write:保存版本信息;
- lock:保存事务锁。
三者各司其职,又共同支撑起了 TiDB 的 MVCC 和分布式事务。所以,下次再看到 TiKV 的三个 CF 时,不妨把它们想象成三个分工明确的"抽屉"。不过,它们装的不只是不同类型的数据——它们背后,其实是三棵彼此独立、又协同工作的 LSM Tree。