前言
本文为笔者个人阅读Apache Impala源码时的笔记,仅代表我个人对代码的理解,个人水平有限,文章可能存在理解错误、遗漏或者过时之处。如果有任何错误或者有更好的见解,欢迎指正。
基本信息
Impala在读取HDFS文件前需要先调用hdfsOpenFile()
打开文件来获得文件描述对象hdfsFile
,就像读写本地文件需要先调用Open()
来获取文件对象一样。Impala的IO是多线程进行的,每个线程每次可能只读取某个文件的一小段数据,如果每次读取前都要调用hdfsOpenFile()
打开文件会则增加很多额外的性能开销,因此Impala实现了file handle cache功能来解决这一问题。file handle cache负责创建与销毁文件描述对象,并能缓存已经打开的文件描述对象进行复用。IO线程只需要传递文件名、文件修改时间等参数给file handle cache来申请对应的文件描述对象即可,并不需要关心其他细节。
相关参数
参数名 | 默认值 | 描述 | 备注 |
---|---|---|---|
max_cached_file_handles | 20000 | 最多允许缓存的文件句柄数。如果设置为0则禁用。 | 测试表明,单个文件句柄使用大约6kB的内存。因此,20k个文件句柄将占用大约120MB的内存。文件句柄占用的实际内存量取决于该文件的复制因子或路径名。 |
unused_file_handle_timeout_sec | 21600 | 未使用的文件句柄在文件句柄缓存中保留的最大时间(以秒为单位)。如果设置为0则禁用。 | 清除长期未使用的句柄可以节省内存。此外,缓存的文件句柄包含一个打开的文件描述符,如果对应文件通过HDFS被删除,这个打开的文件描述符可以防止磁盘空间被释放。当元数据发现文件被删除时,该文件句柄将不再被使用,所以清除这个文件句柄可以在适当的时间释放磁盘空间。默认值是6小时,这小于HDFS的fs.trash.interval的常用值,所以缓存将在文件从trash中删除之前清除文件句柄,这意味着文件句柄缓存不会影响HDFS的可用磁盘空间。 |
num_file_handle_cache_partitions | 16 | 文件句柄缓存的分区数。 | 文件句柄缓存被分割成多个独立的分区,每个分区都有自己的锁和结构。大量的分区减少了并发访问的争用,但是由于LRU列表的分离,它也降低了缓存的效率。最优的分区数量默认值还有待进一步测试。 |
cache_remote_file_handles | true | 开启远端HDFS文件句柄缓存。 | 该参数控制是否缓存远程HDFS文件句柄。它不会影响S3、ADLS或ABFS文件句柄。不开启时,只有本地的HDFS文件句柄才会缓存。 |
cache_s3_file_handles | true | 开启S3文件句柄缓存。 | 该参数控制是否缓存S3文件句柄。 |
模块结构
file handle cache功能由FileHandleCache
类实现,该类被定义在be\src\runtime\io\handle-cache.h中。FileHandleCache
主要提供了两个接口,其中GetFileHandle()
用以申请一个文件句柄,句柄使用完毕后可以调用ReleaseFileHandle()
将其返还。
与前几篇文章介绍的DataCache
一样,FileHandleCache
由DiskIoMgr
类创建并初始化,实际由HdfsFileReader
类使用。DiskIoMgr
类是一个管理器类,负责为所有磁盘和远程文件系统上的所有查询进行IO调度。HdfsFileReader
类负责读取HDFS文件,DiskIoMgr
对FileHandleCache
提供的接口进行了进一步的包装供HdfsFileReader
进行使用。
类FileHandleCache
内部包括一个负责打开HDFS文件的HdfsMonitor
对象、一个负责逐出缓存条目的逐出线程eviction_thread_
和一组缓存分区对象FileHandleCachePartition
。
由于hdfsOpenFile()
本身没有提供超时功能,在打开HDFS文件的过程中由于网络故障等问题可能导致执行线程无限期阻塞,因此需要一个实现了超时机制的文件打开方法。HdfsMonitor
类通过一个同步线程池实现了这一功能,调用其接口OpenHdfsFileWithTimeout()
可以进行有时限的文件打开操作,防止执行线程长时间甚至无限期阻塞。
如果缓存中有文件句柄在HDFS上对应的底层文件已被删除,该文件句柄可能会阻止文件在操作系统级别被删除,这会占用磁盘空间并影响正确性。为了避免这种情况,eviction_thread_
线程会定期(1秒)执行EvictHandles()
检查缓存并销毁所有近一段时间(通过unused_file_handle_timeout_sec参数设置)内未被使用的文件句柄。
文件句柄缓存被分割成多个独立的分区,每个分区都有自己的锁和存储结构。缓存分区由结构体FileHandleCachePartition
实现,内部包括一个链表和一个多重映射以实现LRU缓存策略,其采用多重映射而不是哈希表是因为作为缓存键的文件名可以对应多个开启的文件句柄。文件句柄无法同时共用,有多个IO线程在同时读取同一个文件时就需要多个文件句柄。FileHandleCache
根据缓存键的哈希取模值将缓存条目均匀分散存储在各个分区,大量的分区可以减少了并发访问时锁的争用,但是由于LRU列表的分离也降低了缓存的效率,分区数量可以由参数num_file_handle_cache_partitions配置。
工作流程
我们从HdfsFileReader::ReadFromPos()
开始分析file-handle-cache的工作流程,这个函数会被ScanRange
对象调用去读取HDFS文件,下面贴出了部分关键代码和注释说明:
Status HdfsFileReader::ReadFromPos(DiskQueue* queue, int64_t file_offset, uint8_t* buffer,
int64_t bytes_to_read, int64_t* bytes_read, bool* eof) {
...
// 定义文件句柄指针和文件描述符
CachedHdfsFileHandle* borrowed_hdfs_fh = nullptr;
hdfsFile hdfs_file;
...
// 调用DiskIoMgr::GetCachedHdfsFileHandle获取一个文件句柄
RETURN_IF_ERROR(io_mgr->GetCachedHdfsFileHandle(hdfs_fs_,
scan_range_->file_string(),
scan_range_->mtime(), request_context, &borrowed_hdfs_fh));
// 从句柄中获得文件描述符
hdfs_file = borrowed_hdfs_fh->file();
// 设置作用域退出触发器,将一个lambda函数传递给触发器,触发器析构时将调用该函数
// 触发器在ReadFromPos执行完后被析构并调用DiskIoMgr::ReleaseCachedHdfsFileHandle返还文件句柄
auto release_borrowed_hdfs_fh = MakeScopeExitTrigger([this, &borrowed_hdfs_fh]() {
if (borrowed_hdfs_fh != nullptr) {
scan_range_->io_mgr_->ReleaseCachedHdfsFileHandle(scan_range_->file_string(),
borrowed_hdfs_fh);
}
});
...
// 如果读取失败,则重试
if (!status.ok() && borrowed_hdfs_fh != nullptr) {
// 读取失败可能是由于错误的文件句柄,调用DiskIoMgr::ReopenCachedHdfsFileHandle重新打开文件句柄
RETURN_IF_ERROR(io_mgr->ReopenCachedHdfsFileHandle(hdfs_fs_,
scan_range_->file_string(),
scan_range_->mtime(), request_context, &borrowed_hdfs_fh));
hdfs_file = borrowed_hdfs_fh->file();
...
}
...
}
其中GetCachedHdfsFileHandle()
和ReleaseCachedHdfsFileHandle()
是对GetFileHandle()
和ReleaseFileHandle()
的简单包装,只是额外加入了一些metrics的计算,ReopenCachedHdfsFileHandle()
相当于先调用ReleaseFileHandle()
再调用GetFileHandle()
,通过销毁文件句柄并从缓存中获取一个新的文件句柄来重新打开文件句柄。我们继续看GetFileHandle()
的实现:
Status FileHandleCache::GetFileHandle(
const hdfsFS& fs, std::string* fname, int64_t mtime, bool require_new_handle,
CachedHdfsFileHandle** handle_out, bool* cache_hit) {
// 根据缓存键(文件名)的哈希值选择对应的缓存分区p
int index = HashUtil::Hash(fname->data(), fname->size(), 0) % cache_partitions_.size();
FileHandleCachePartition& p = cache_partitions_[index];
// 如果需要新的句柄则直接跳到创建代码,否则在缓存中查找具有相同mtime的未使用条目
// 通过ReopenCachedHdfsFileHandle调用时require_new_handle为true
if (!require_new_handle) {
std::lock_guard<SpinLock> g(p.lock);
// 在对应分区的cache中查找缓存条目,cache是一个multimap,所以一个缓存键可对应多个缓存条目
// 在multimap中可以认为相同缓存键的条目是连续排列的,equal_range能找到这个范围
// equal_range返回的第一个迭代器指向第一个符合条件的条目
// equal_range返回的第二个迭代器指向最后一个符合条件的条目的下一个条目
pair<typename MapType::iterator, typename MapType::iterator> range =
p.cache.equal_range(*fname);
// 然后在范围内选择一个条目返回,在选择时始终遵循multimap的内部顺序,首先选择较早的条目
// 优先使用前部的条目会尽量多地使其他条目不被使用而老化,最终过期被清除以节省内存
while (range.first != range.second) {
// FileHandleEntry是一个简单的结构体,表示缓存条目
// 其成员变量包括文件句柄fh、被使用状态in_use和其在lru_list中的迭代器lru_entry
FileHandleEntry* elem = &range.first->second;
// 当前条目未被使用且其mtime与传入的mtime相等则完成选择
if (!elem->in_use && elem->fh->mtime() == mtime) {
// 从lru_list中删除该元素,并通过重置其迭代器以指向列表的末尾来指定该元素不在lru_list中
p.lru_list.erase(elem->lru_entry);
elem->lru_entry = p.lru_list.end();
// 更新状态并获取该条目的文件句柄
*cache_hit = true;
elem->in_use = true;
*handle_out = elem->fh.get();
return Status::OK();
}
++range.first;
}
}
// 需要新的句柄或者缓存中没有符合条件的句柄时需要创建一个新的
*cache_hit = false;
// 创建一个新的文件句柄,并通过HdfsMonitor为其打开文件描述符
std::unique_ptr<CachedHdfsFileHandle> new_fh;
new_fh.reset(new CachedHdfsFileHandle(fs, fname, mtime));
RETURN_IF_ERROR(new_fh->Init(hdfs_monitor_));
// 用新创建的文件句柄构造缓存条目,并插入到multimap当中
std::lock_guard<SpinLock> g(p.lock);
pair<typename MapType::iterator, typename MapType::iterator> range =
p.cache.equal_range(*fname);
FileHandleEntry entry(std::move(new_fh), p.lru_list);
// 由于条目将被马上使用,所以使用emplace_hint令插入位置尽量靠近范围的头部
typename MapType::iterator new_it = p.cache.emplace_hint(range.first,
*fname, std::move(entry));
++p.size;
// 如果该分区的大小超过了容量,执行EvictHandles逐出多余条目
if (p.size > p.capacity) EvictHandles(p);
FileHandleEntry* new_elem = &new_it->second;
// 更新状态并获取该条目的文件句柄
new_elem->in_use = true;
*handle_out = new_elem->fh.get();
return Status::OK();
}
继续看ReleaseFileHandle()
的实现:
void FileHandleCache::ReleaseFileHandle(std::string* fname,
CachedHdfsFileHandle* fh, bool destroy_handle) {
// 根据缓存键(文件名)的哈希值找到其归属的缓存分区p
int index = HashUtil::Hash(fname->data(), fname->size(), 0) % cache_partitions_.size();
FileHandleCachePartition& p = cache_partitions_[index];
std::lock_guard<SpinLock> g(p.lock);
// 在对应分区的cache中查找文件句柄对应的缓存条目
pair<typename MapType::iterator, typename MapType::iterator> range =
p.cache.equal_range(*fname);
typename MapType::iterator release_it = range.first;
while (release_it != range.second) {
FileHandleEntry* elem = &release_it->second;
if (elem->fh.get() == fh) break;
++release_it;
}
// 重新设置条目的使用状态
FileHandleEntry* release_elem = &release_it->second;
release_elem->in_use = false;
// 如果条目不再使用则销毁,通过ReopenCachedHdfsFileHandle调用时destroy_handle为true
if (destroy_handle) {
--p.size;
p.cache.erase(release_it);
return;
}
// Hdfs可以使用内存进行预读缓存,调用hdfsUnbufferFile会取消缓存,以便hdfs节省内存
// 如果不支持取消缓存,hdfsUnbufferFile()将返回一个非零返回码,此时关闭文件句柄并将其从缓存中删除
if (hdfsUnbufferFile(release_elem->fh->file()) == 0) {
// 调用emplace将其重新添加回lru_list的尾部,并将位置迭代器赋值给lru_entry
release_elem->lru_entry = p.lru_list.emplace(p.lru_list.end(), release_it);
// 如果该分区的大小超过了容量,执行EvictHandles逐出多余条目
if (p.size > p.capacity) EvictHandles(p);
} else {
VLOG_FILE << "FS does not support file handle unbuffering, closing file="
<< fname;
--p.size;
p.cache.erase(release_it);
}
}
至此,file-handle-cache的主要工作流程已经分析完成,最后看一下条目逐出EvictHandles()
的实现:
void FileHandleCache::EvictHandles(
FileHandleCache::FileHandleCachePartition& p) {
// 获取当前时间戳
uint64_t now = MonotonicSeconds();
// 计算过期时间戳,时间戳小于该值的条目均为过期条目,会被逐出销毁
uint64_t oldest_allowed_timestamp =
now > unused_handle_timeout_secs_ ? now - unused_handle_timeout_secs_ : 0;
while (p.lru_list.size() > 0) {
// list的头部节点也就是最旧的节点
LruListEntry oldest_entry = p.lru_list.front();
typename MapType::iterator oldest_entry_map_it = oldest_entry.map_entry;
uint64_t oldest_entry_timestamp = oldest_entry.timestamp_seconds;
// 如果缓存没有超过容量并且最旧的条目也未过期,那么检查完成
if (p.size <= p.capacity && (unused_handle_timeout_secs_ == 0 ||
oldest_entry_timestamp >= oldest_allowed_timestamp)) {
return;
}
// 从multimap和list里逐出最旧的条目
p.cache.erase(oldest_entry_map_it);
p.lru_list.pop_front();
--p.size;
}
}