CachedReadBufferFromRemoteFS为支持本地Cache封装的一个ReadBuffer。
下面主要介绍CachedReadBufferFromRemoteFS的3个函数,来理解ClickHouse Cache的实现。
预先了解下3种读类型(ReadType):
- CACHED:本地缓存中已存在
- REMOTE_FS_READ_BYPASS_CACHE:在读远端文件时,如果本地缓存不存在,不做任何操作,直接绕过缓存读远端文件
- REMOTE_FS_READ_AND_PUT_IN_CACHE:在读远端文件时,如果本地缓存不存在,读远端文件并写入本地缓存
initialize
熟悉ReadBufferFromS3的应该会比较好理解,每个ReadBufferFromS3都需要初始化一个数据源,供上层读取数据。ReadBufferFromS3的initialize就是通过S3Client携带必要的参数(如bucket、object_name、offset等)请求S3返回一个数据流。CachedReadBufferFromRemoteFS的initialize的作用与其类似,同样是初始化一个数据源,不同的是,它对应的是本地缓存文件(如果本地缓存文件不存在,会对应一个远端文件流)。并且根据丰富的控制参数来实现不同场景的读需求。
代码如下:
void CachedReadBufferFromRemoteFS::initialize(size_t offset, size_t size)
{
if (settings.read_from_filesystem_cache_if_exists_otherwise_bypass_cache)
{
file_segments_holder.emplace(cache->get(cache_key, offset, size));
}
else
{
file_segments_holder.emplace(cache->getOrSet(cache_key, offset, size));
}
/**
* Segments in returned list are ordered in ascending order and represent a full contiguous
* interval (no holes). Each segment in returned list has state: DOWNLOADED, DOWNLOADING or EMPTY.
*/
if (file_segments_holder->file_segments.empty())
throw Exception(ErrorCodes::LOGICAL_ERROR, "List of file segments cannot be empty");
LOG_TEST(log, "Having {} file segments to read", file_segments_holder->file_segments.size());
current_file_segment_it = file_segments_holder->file_segments.begin();
initialized = true;
}
file_segments_holder会持有file_segments列表,每个file_segment就是远端文件被拆分成的一个个文件快(有可能是一个或多个,不会为空),并且列表中所有的file_segment是按照升序排列的,也是为了对应真实远端文件的顺序。
首先介绍下file_segment的状态,一共有5种:
- DOWNLOADED:已经被下载到本地;
- EMPTY:file_segment第一次被创建时的状态,还没有开始下载数据;
- DOWNLOADING:当file_segment被设置downloader时,file_segment会被设置为下载中状态;
- PARTIALLY_DOWNLOADED_NO_CONTINUATION:file_segment只有部分下载成功,读取时会先读已下载的的部分,未下载的部分继续去远端读取文件,不会将未下载的部分补全;
- PARTIALLY_DOWNLOADED:对于PARTIALLY_DOWNLOADED_NO_CONTINUATION,这种状态会被其他downloader将未下载的部分继续补全;
- SKIP_CACHE:由于磁盘空间不足等问题,遇此状态的file_segment直接跳过缓存
在初始化时,设置read_from_filesystem_cache_if_exists_otherwise_bypass_cache(缓存存在就读,不存在就跳过)为true,则通过cache->get()来获取file_segments,如果获取不到,填充一个EMPTY的file_segment到file_segments_holder中,如果能够获取到file_segments,file_segments_holder会持有一个file_segments列表,此时列表中包含的每个file_segment就是已下载到本地的文件块。但此时并不能保证,所需的range中所有file_segment在本地都是存在的,势必会有孔洞存在,如图:
segment{1} segment{2} segment{k}
file_segments: {[----------][________________]...[----______]}
所以在获取到file_segments列表后,这些孔洞会同样会被EMPTY的file_segment进行站位填充,保证返回一个无孔洞的file_segments列表。
如果read_from_filesystem_cache_if_exists_otherwise_bypass_cache为false,则通过cache->getOrSet()来获取file_segments,不同于cache->get()的是,cache->getOrSet()会创建cell(可以简单理解为file_segment的一个wrapper),cell会根据file_offset和total_size所包含的range请求远端文件,并将range进行segment切分,在本地cache路径(cache_base_path)下创建相应cache路径,这里有个大小限制,最大的segment为max_file_segment_size,所以segment的大小以total_size和max_file_segment_size的最小只为准。
注意,这两部分其实都是先构造出需要的file_segment,并没有触发download。
nextImplStep
nextImplStep是CachedReadBufferFromRemoteFS中nextImpl的实现,是填充read_buffer的方法。在CachedReadBufferFromRemoteFS中implementation_buffer是私有的buffer,所有的buffer操作都是针对implementation_buffer来做的,但外部使用的是CachedReadBufferFromRemoteFS,也就是其基类BufferBase中的working_buffer和internal_buffer,所以在操作implementation_buffer的前后都需要做swap操作,将implementation_buffer和base_buffer对齐,外部使用才会正确。
代码中可以看到在implementation_buffer完成构造后和填充数据后都会执行:
swap(*implementation_buffer);
所以该函数作用就是如何构造和填充implementation_buffer。
该函数主要分下面几个过程:
- 初始化file_segments
- 构造implementation_buffer
- 预下载
- implementation_buffer读数据
- 更细file_offset
下面逐点进行分析:
初始化file_segments
if (!initialized)
initialize(file_offset_of_buffer_end, getTotalSizeToRead());
如果CachedReadBufferFromRemoteFS没有初始化,会进行初始化,由于key是CachedReadBufferFromRemoteFS构造时传入的,初始化不需要传参key,只需要offset和size,getTotalSizeToRead是对各种情况做了简单封装,正常会返回read_until_position - file_offset_of_buffer_end,也就是从offset到file_offset+total_size,其余如果有越界之类的情况,getTotalSizeToRead会抛出异常。
构造implementation_buffer
if (implementation_buffer)
{
bool can_read_further = updateImplementationBufferIfNeeded();
if (!can_read_further)
return false;
}
else
{
implementation_buffer = getImplementationBuffer(*current_file_segment_it);
if (read_type == ReadType::CACHED)
(*current_file_segment_it)->incrementHitsCount();
}
assert(!internal_buffer.empty());
swap(*implementation_buffer);
implementation_buffer已被构造
再次进入nextImpl函数,则需要updateImplementationBufferIfNeeded来进行更新,比如上次nextImpl已经将第一个file_segment读完了,新的一轮需要读下一个file_segment,或者上次nextImpl并没有读完当前file_segment,新的一轮可以继续读。上一个file_segment读完,会通过completeFileSegmentAndGetNext函数更换到下一个file_segment,如果上次的nextImpl没读完,updateImplementationBufferIfNeeded函数会根据不同的ReadType,来进行更新。
首先介绍下file_segment,file_segment有5种状态:
- DOWNLOADED:已经被下载到本地;
- EMPTY:file_segment第一次被创建时的状态,还没有开始下载数据;
- DOWNLOADING:当调用getOrSetDownloader时,file_segment会被设置为下载中状态;
- PARTIALLY_DOWNLOADED_NO_CONTINUATION:file_segment只有部分下载成功,读取时会先读已下载的的部分,为下载的部分继续去远端读取文件,不会将为下载的部分补全;
- PARTIALLY_DOWNLOADED:对于PARTIALLY_DOWNLOADED_NO_CONTINUATION,这种状态会被其他downloader将为下载的部分补全;
- SKIP_CACHE:由于磁盘空间不足等问题,遇此状态的file_segment直接跳过缓存
继续看implementation_buffer是如何更新的,这里分为两种情况:
ReadType::CACHED
如果是ReadType::CACHED类型,file_segment的状态为DOWNLOADED,那继续读就好,因为本地已经缓存好了;状态不是DOWNLOADED,那就需要判断已经读到的file_offset_of_buffer_end和download_offset的大小关系来判断,其中download_offset是指file_segment已经下载到的file_offset,如图:
case 1:
segment{k}
cache: [______|___________]
^
download_offset
requested_range: [__________]
^
file_offset_of_buffer_end
downloaded: |___|
need_to_download: |___________|
case 2:
segment{k}
cache: [______|___________]
^
download_offset
requested_range: [__________]
^
file_offset_of_buffer_end
downloaded: |
need_to_download: |___________|
如果file_offset_of_buffer_end < download_offset,说明有一部分数据(downloaded)已经下载好了可以继续读。如果file_offset_of_buffer_end == download_offset,说明这个正在下载的file_segment中已下载的部分(downloaded)已经读完,未下载的部分还处在下载中,所以没办法继续读,需要构造新的implementation_buffer。
ReadType::REMOTE_FS_READ_AND_PUT_IN_CACHE
如果是ReadType::REMOTE_FS_READ_AND_PUT_IN_CACHE类型,说明并没有cache可供读取,需要构造新的implementation_buffer。
implementation_buffer未被构造
通过getImplementationBuffer函数直接构造,构造都是通过getImplementationBuffer函数来完成的,后面再详细说明。
最后要通过swap来使implementation_buffer和base_buffer保持一致。
这里多处涉及到了构造新的implementation_buffer,所以先穿插介绍下用于构造implementation_buffer的函数。
getReadBufferForFileSegment
上面说到过,file_segments_holder会持有file_segments列表,需要的缓存数据都在一个个file_segment中,所以要构造file_segment的read_buffer来获取file_segment中的数据。
在该函数中首先会根据参数read_from_filesystem_cache_if_exists_otherwise_bypass_cache进行快速判断,如果为true,说明缓存存在就读不存在就跳过去直接读远端文件,不会出发下载操作;如果为false,则会继续更复杂的逻辑。
下面的逻辑就和file_segment的状态强相关了:
FileSegment::State::SKIP_CACHE
如果是SKIP_CACHE,则会将read_type设置为REMOTE_FS_READ_BYPASS_CACHE,并返回一个RemoteFSReadBuffer,直接读远端文件
FileSegment::State::EMPTY
如果是EMPTY,首先会去尝试获取或设置downloader,这里还有个caller_id的概念,caller_id是request_id+thread_id构成的,downloader_id就是file_segment在没有downloader_id的情况下首次被设置的caller_id。如果该线程是file_segment的downloader,则会将read_type设置为REMOTE_FS_READ_AND_PUT_IN_CACHE。这里有个小细节,如果file_segment的download_offset和file_offset_of_buffer_end相等,说明刚好要从file_segment的开头开始创建buffer,所以直接返回RemoteFSReadBuffer即可,如果download_offset < file_offset_of_buffer_end,如下图所:
segment{k} cache: [_________________ ^ download_offset requested_range: [__________] ^ file_offset_of_buffer_end bytes_to_predownload: |___|
bytes_to_predownload也就是要预下载的部分,然后再返回RemoteFSReadBuffer。
FileSegment::State::DOWNLOADING
如果是DOWNLOADING,说明file_segment已经在下载中,如果download_offset > file_offset_of_buffer_end,则说明已下载的部分已经满足buffer需求,从已经下载的部分中返回一个CacheReadBuffer即可。
FileSegment::State::DOWNLOADED
如果是DOWNLOADED,说明file_segment已经完整的存在在本地磁盘中,直接返回CacheReadBuffer读取即可。
FileSegment::State::PARTIALLY_DOWNLOADED
如果是PARTIALLY_DOWNLOADED,说明file_segment已经没有downloader了,需要重新设置一个新的downloader来继续下载。如果是部分下载,且download_offset > file_offset_of_buffer_end,同样说明已下载的部分已经满足buffer需求,不需要继续进行下载了,并且将downloader置空。如果download_offset < file_offset_of_buffer_end,说明已下载的部分不足以满足buffer的需求,同样需要预下载bytes_to_predownload部分,再返回RemoteFSReadBuffer。
FileSegment::State::PARTIALLY_DOWNLOADED_NO_CONTINUATION
如果是PARTIALLY_DOWNLOADED_NO_CONTINUATION,说明缓存中有就读,没有直接读远端文件,不存在下载等操作。
最后要注意,这些状态是在while循环里不停切换等,通过上层的next来触发不断的读取。
预下载
如果bytes_to_predownload不为0,则需要预下载,这里的预下载可以理解为是一种lazy seek,不过这里的predownload会将file_segment的前面不需要的部分缓存到本地,然后从需要的位置开始构造implementation_buffer并继续使用。如图:
segment{k}
cache: [--------____________
^
download_offset
requested_range: [__________]
^
file_offset_of_buffer_end
bytes_to_predownload: |___|
虽然在本地查询时bytes_to_predownload并未使用,但是其他查询很有可能就会用到,与其使用lazy seek忽略掉部分数据,不如将其缓存下来供后面查询使用,也可以理解为一种预读。
所以file_segment无论处于什么状态,要么是空,要么就是头部有部分数据,要么就是全部被填满数据,不会出现头部空中间有部分数据的情况。
implementation_buffer读数据
一般的read_buffer在读数据时正常将数据读出到buffer里即可,CachedReadBufferFromRemoteFS会多一个过程,如果该线程是当前file_segment的downloader时,需要额外将buffer中读到的数据写入本地缓存文件块中。
读取数据和写入本地缓存文件块完成后,还要进行一次swap,将implementation_buffer同步到base_buffer。
最后还要将file_segment的downloader置空,downloader的生命周期只在一次next中有效,这样就可以灵活的处理下载和切换downloader。
更新file_offset
将file_offset累加已读取的size,记录read_buffer读到了哪里。
seek
seek也是read_buffer中必须要实现的函数,就是设置buffer的开始读取位置,一般包含SEEK_SET、SEEK_CUR和SEEK_END这3种类型。但是对于CachedReadBufferFromRemoteFS只支持SEEK_SET,并且在CachedReadBufferFromRemoteFS还未初始化时才可以调用。代码如下:
off_t CachedReadBufferFromRemoteFS::seek(off_t offset, int whence)
{
if (initialized)
throw Exception(ErrorCodes::CANNOT_SEEK_THROUGH_FILE, "Seek is allowed only before first read attempt from the buffer");
if (whence != SEEK_SET)
throw Exception(ErrorCodes::CANNOT_SEEK_THROUGH_FILE, "Only SEEK_SET allowed");
first_offset = offset;
file_offset_of_buffer_end = offset;
size_t size = getTotalSizeToRead();
initialize(offset, size);
return offset;
}
该函数主要就是设置file_offset_of_buffer_end,并调用initialize初始化CachedReadBufferFromRemoteFS。
到此CachedReadBufferFromRemoteFS的主要函数就介绍完了,通过这3个方法就可以整体了解了cache的具体执行流程。