ClickHouse的存取过程
写入
核心思想:ClickHouse从不直接写入“一行”数据。它会将写入的数据在内存中攒成一个批次,然后将这个批次的数据“拆分”成列,最后将每一列的数据分别写入到磁盘上对应的新文件中。
详细过程举例说明
我们继续使用上一节的 user_profiles 表,并向其中写入一行新数据。
表结构 (DDL):
1 CREATE TABLE user_profiles (
2 UserID UInt64,
3 RegistrationDate Date,
4 Country String,
5 LastLoginIP String
6 ) ENGINE = MergeTree()
7 ORDER BY UserID;
写入请求 (SQL):
客户端(比如一个Java应用)执行了一个标准的 INSERT 语句:
1 INSERT INTO user_profiles VALUES (105, '2023-12-01', 'Germany', '5.5.5.5');
一行数据的写入之旅
第1步:进入内存缓冲区 (In-Memory Buffer)
- 当ClickHouse服务器收到这个INSERT请求时,它不会立即写入磁盘。直接写磁盘效率太低。
- 它会将这行数据 (105, '2023-12-01', 'Germany', '5.5.5.5') 放入一个内存缓冲区中。
- 这个缓冲区会继续等待,以收集更多的INSERT数据。ClickHouse的设计哲学是批量处理。它会等待缓冲区的数据达到一定的大小(由min_insert_block_size_ro
ws等参数控制)或者等待一个短暂的时间,再一次性处理。
为什么要有这一步?
为了实现极高的写入吞吐量。将多次小的写入合并成一次大的写入,可以大大减少磁盘I/O操作的次数和系统开销。
第2步:排序与列式转换 (Sort and Transpose)
假设缓冲区已经攒够了数据(或者超时了),现在准备将这批数据刷入磁盘。
- 排序 (Sort): ClickHouse会将在内存中的这一批数据,严格按照建表时指定的ORDER BY键(这里是UserID)进行排序。这是MergeTree引擎的核心要求。
- 列式转换 (Transpose): 这是最关键的一步。排序完成后,ClickHouse会将这批行式的数据,在内存中进行“转置”,转换成列式的结构。
内存中的变化:
- 之前(行式):
1 Row 1: (105, '2023-12-01', 'Germany', '5.5.5.5')
2 Row 2: (99, '2023-11-20', 'USA', '8.8.8.8')
3 ...
- 排序后:
1 Row 1: (99, '2023-11-20', 'USA', '8.8.8.8')
2 Row 2: (105, '2023-12-01', 'Germany', '5.5.5.5')
3 ...
- 转换后(列式):
1 UserID 列的内存块: [99, 105, ...]
2 RegistrationDate 列的内存块: ['2023-11-20', '2023-12-01', ...]
3 Country 列的内存块: ['USA', 'Germany', ...]
4 LastLoginIP 列的内存块: ['8.8.8.8', '5.5.5.5', ...]
第3步:写入新的数据部分 (Write a New Data Part)
现在,ClickHouse有了按列组织的、排好序的数据块。它会在磁盘上创建一个全新的、独立的目录,我们称之为数据部分(Data Part)。
这个新目录的名字类似 202312_1_1_0(分区ID_最小块编号_最大块编号_层级)。
在这个新目录里,ClickHouse会执行以下操作:
- 将 UserID 列的内存块进行压缩,然后写入到 UserID.bin 文件中。
- 将 RegistrationDate 列的内存块进行压缩,然后写入到 RegistrationDate.bin 文件中。
- 将 Country 列的内存块进行压缩,然后写入到 Country.bin 文件中。
- 将 LastLoginIP 列的内存块进行压缩,然后写入到 LastLoginIP.bin 文件中。
- 同时,为这个新的数据部分创建元数据文件,如 columns.txt(包含列信息)、checksums.txt(用于校验)以及 primary.idx(稀疏主键索引)。
关键点:这个写入过程是原子的。数据部分会先写入一个临时目录,写完后再通过一个rename操作(在文件系统中是原子的)使其对查询可见。这保证了查询要
么看到完整的数据部分,要么完全看不到。
第4th步:后台合并 (Background Merge)
此时,我们的INSERT操作已经完成了。但故事还没结束。
- 现在磁盘上可能有了很多小的、独立的数据部分(比如,之前有一个大的数据部分,现在又多了一个我们刚写入的小的数据部分)。
- ClickHouse的MergeTree引擎有一个后台线程,它会持续监控这些数据部分。
- 当它发现有多个小的数据部分时,它会在后台自动地将它们读取出来,合并成一个更大的、新的数据部分,然后将旧的、小的部分删除。
- 这个合并过程是其性能和健康的保证,它避免了因小文件过多而导致的查询性能下降。
总结对比
| 操作 | ClickHouse (列式) | MySQL (行式, InnoDB) |
|---|---|---|
| 写入一行 | 1. 放入内存缓冲区。 2. 等待成批。 3. 对批次排序。 4. 拆分成列。 5. 分别写入多个列文件,形成新数据块。 |
1.找到对应的B+树数据页。 2. 将整行数据直接写入到数据页的空闲空间中。 3. 写入Redo Log保证事务。 |
| 写入延迟 | 高 (因为需要等待批次形成) | 低 (通常是毫秒级同步返回) |
| 写入吞吐量 | 极高 (批量写入,顺序I/O) | 中等 (受限于B+树的锁和随机I/O) |
| 数据结构 | 不可变的数据部分 (Immutable Parts) | 可变的B+树数据页 (Mutable Pages) |
结论:ClickHouse的写入过程是一种延迟但高速的批量操作。它牺牲了单行写入的低延迟性,换取了大规模数据写入的极高吞吐量。这个设计完全是为了后续的
分析查询性能服务的:数据在写入时就已经被排好序、按列分开并压缩,为闪电般的查询做好了万全的准备。
查询
核心原理:通过位置标记 (Marks) 进行对齐
我们知道,同一列的数据是连续存储在各自的列文件中的。ClickHouse如何知道UserID.bin文件中的第100个值,就和URL.bin文件中的第100个值属于同一行呢?
答案是:通过隐式的行号(或者说,数据在各自列文件中的偏移位置)来对齐。
每一列的数据都是按照相同的顺序(由建表时的ORDER BY子句决定)排列的。因此,第 N 个 UserID、第 N 个 EventTime 和第 N 个 URL 就共同构成了第 N
行的完整数据。
当需要读取一整行时,ClickHouse必须:
- 首先,通过索引定位到这一行数据在所有列文件中的公共位置(行号/偏移量)。
- 然后,分别打开每一列的文件,跳转到这个相同的位置,读取该位置的值。
- 最后,在内存中将从各个列文件中读取到的值“拼装”成一行返回给用户。
详细过程举例说明
我们用一个具体的例子来走一遍完整流程。
场景设定:
假设我们有一个用户画像表 user_profiles。
表结构 (DDL):
1 CREATE TABLE user_profiles (
2 UserID UInt64,
3 RegistrationDate Date,
4 Country String,
5 LastLoginIP String
6 ) ENGINE = MergeTree()
7 ORDER BY UserID; -- 按 UserID 排序,这是关键!
磁盘上的物理文件 (简化版):
- UserID.bin: [101, 102, 103, 104, ...]
- RegistrationDate.bin: ['2023-01-10', '2023-02-15', '2023-05-20', '2023-08-01', ...]
- Country.bin: ['USA', 'USA', 'Canada', 'UK', ...]
- LastLoginIP.bin: ['1.1.1.1', '2.2.2.2', '3.3.3.3', '4.4.4.4', ...]
查询请求 (SQL):
我们要获取 UserID 为 103 的用户的所有信息。
1 SELECT * FROM user_profiles WHERE UserID = 103;
ClickHouse 的执行步骤
第1步:定位行号 (Find the Position)
这是最关键的一步。ClickHouse不会去扫描所有文件。
- 利用稀疏索引: 它首先利用主键 UserID 的稀疏索引,快速定位到包含 UserID=103 的数据块(granule)。
- 读取主键列: 在确定了数据块的范围后,它会读取主键列 UserID.bin 的一小部分数据,来精确地找到 103 这个值在列文件中的位置(行号)。
- 确定位置: 假设它发现 103 是这个文件中的第3个值(0-based index 为 2)。ClickHouse现在就得到了一个魔法数字:2。这个 2
就是我们要找的那一行数据在所有列文件中的公共偏移量。
第2步:按位置读取各列 (Read from Each Column by Position)
现在,ClickHouse知道了目标行号是 2,它会执行以下一系列独立的读取操作:
- 打开 UserID.bin 文件,跳转到位置 2,读取值 103。
- 打开 RegistrationDate.bin 文件,跳转到位置 2,读取值 '2023-05-20'。
- 打开 Country.bin 文件,跳转到位置 2,读取值 'Canada'。
- 打开 LastLoginIP.bin 文件,跳转到位置 2,读取值 '3.3.3.3'。
第3步:在内存中拼装 (Assemble the Row)
ClickHouse的查询处理节点在内存中收集到从各个列文件中读取到的值:
- 103
- '2023-05-20'
- 'Canada'
- '3.3.3.3'
然后,它将这些值组合成一行结果,最终返回给客户端。
性能影响与结论
这个“按位置拼装”的过程,正是ClickHouse不适合OLTP(在线事务处理)场景的根本原因。
- 磁盘寻道成本高: 为了读取一行数据,它需要分别打开并读取多个文件,这会导致大量的磁盘随机I/O(Disk
Seeks)。而像MySQL这样的行式数据库,通常只需要一次磁盘寻道,就能将整行数据从一个数据页中读出。 - 解压开销大: ClickHouse的每一列数据都是被压缩的。为了只读取一个值,它可能需要解压一整个数据块(chunk),这个CPU开销是很大的。
- I/O放大: 即使只读一个值,操作系统也可能会从磁盘预读一个更大的页(e.g., 4KB),导致实际读取的数据量远大于需要的值。
总结对比
| 操作 | ClickHouse (列式) | MySQL (行式) |
|---|---|---|
| 读取一整行 | 1. 用索引找到行号。 2. 分别读取每个列文件的对应行号的值。 3. 在内存中拼装成行。 |
1.用索引找到指向整行数据的指针。 2. 一次性读取包含整行数据的磁盘块。 |
| 性能 | 慢 (多次磁盘寻道,多次解压) | 快 (一次磁盘寻道) |
| 读取一列 | 极快 (只需读取一个文件) | 慢 (需要读取每一行,并丢弃其他列) |
结论:ClickHouse可以读取一整行数据,但这个过程是其性能上的“短板”。它通过“按位置拼装”的方式来实现,这个过程的开销远大于分析查询中按列读取的开
销。因此,应该始终避免在ClickHouse中进行大量、高并发的整行查询,而是把它用在它最擅长的领域:对海量数据的部分列进行大规模的聚合分析。
2016年5月之前的博文发布于51cto,链接地址:shamrock.blog.51cto.com
2016年5月之后博文发布与cnblogs上。
Github地址 https://github.com/umgsai
Keep moving~!!!

浙公网安备 33010602011771号