####背景问题
Alluxio管理文件的方式采用了与HDFS类似的Block,每个文件被分为几个Block存储在不同的worker上,job_worker会根据设定的Block副本数来进行Block的复制。如下图所示,从BlockInfo里可以看到某个文件的Block存储在哪些worker的哪些存储介质上。
Alluxio有自己的文件系统,那就也有一套元数据管理的逻辑,如inode信息(包括路径、ACL等等)以及Block信息(Block大小、location信息等等),这些数据在备份元数据时会被打包存储到指定的某个路径下。下面先看一个元数据问题导致数据读取错误的问题:
背景:Alluxio开启了元数据自动备份,每天中午12点进行备份。业务会在半夜进行数据产出,覆写全表数据,白天进行查询操作。Alluxio采用了RocksDB进行元数据存储,有一定概率出现宕机。
问题描述:某日早晨,Alluxio master因为RocksDB的bug发生宕机,发现宕机后利用昨日的元数据备份进行了拉起,当天下午业务查询数据时发生报错,如下所示:
此时执行checkConsistency也无法修复,将文件从Alluxio中手动删除并重新load后可恢复。
问题分析:由此首先怀疑是元数据的问题导致了文件读取的错误,parquet文件读取时会先读取magic number来确定此文件是parquet格式的。从时间线上看,恢复时使用的备份是前一天的,也就是使用了旧的元数据去读取了新一天的数据,而且由于业务是覆盖写入,目录结构和文件名没有变化,这样即使读取了相同的文件,却会读取到错误的Block,在footer中的magic number也就不符合parquet文件的格式从而报错。
这里可以看到,当元数据有错误时,即使worker正确加载了数据,在覆写的场景下也只能通过重新加载文件的方式修复数据读取错误问题。从这个问题出发,我们分析了Alluxio文件读写的代码逻辑,并进行梳理,下面简要进行介绍。
####写入逻辑分析
Alluxio中写入文件主要分为createFile和createDirectory两种方式,分别是写文件和目录,下面以createFile为例进行分析。首先我们看整个写入过程的调用关系:
createFile(BaseFileSystem.java:160)
|—createFile(RetryHandlingFileSystemMasterClient.java:154)
| |—createFile(FileSystemMasterClientServiceGrpc.java:1653)
| |—createFile(DefaultFileSystemMaster.java:1497)(rpc调用)
| |—createFileInternal(DefaultFileSystemMaster.java:1548)
| |—createPath(InodeTree.java:670)
|—AlluxioFileOutStream(AlluxioFileOutStream.java:85)
创建文件并写入的过程主要分为两步:1)元数据写入,2)将文件写入Block。下面看下客户端BaseFileSystem.java中createFile方法的具体实现:
public FileOutStream createFile(AlluxioURI path, CreateFilePOptions options)
throws FileAlreadyExistsException, InvalidPathException, IOException, AlluxioException {
checkUri(path);
return rpc(client -> {
CreateFilePOptions mergedOptions = FileSystemOptions.createFileDefaults(
mFsContext.getPathConf(path)).toBuilder().mergeFrom(options).build();
//先创建元数据
URIStatus status = client.createFile(path, mergedOptions);
LOG.debug("Created file {}, options: {}", path.getPath(), mergedOptions);
OutStreamOptions outStreamOptions =
new OutStreamOptions(mergedOptions, mFsContext.getClientContext(),
mFsContext.getPathConf(path));
outStreamOptions.setUfsPath(status.getUfsPath());
outStreamOptions.setMountId(status.getMountId());
outStreamOptions.setAcl(status.getAcl());
try {
//返回写入Block的对象实例
return new AlluxioFileOutStream(path, outStreamOptions, mFsContext);
} catch (Exception e) {
delete(path);
throw e;
}
});
}
客户端发起的文件写入请求会通过rpc调用的方式在服务端DefaultFileSystemMaster.java的createFile方法中响应。服务端的操作主要包括元数据同步、加锁、权限检查、建立目录、journal写入等,涉及的代码较多,下面以简化的代码进行介绍。
public FileInfo createFile(AlluxioURI path, CreateFileContext context)
throws AccessControlException, InvalidPathException, FileAlreadyExistsException,
BlockInfoException, IOException, FileDoesNotExistException {
syncMetadata();
lockInodePath(lockingScheme)
checkParentPermission(Mode.Bits.WRITE, inodePath);
mMountTable.checkUnderWritableMountPoint(path);
createFileInternal(rpcContext, inodePath, context);
return getFileInfoInternal(inodePath);
}
}
服务端在创建文件时,先进行元数据同步,确保当前目录和UFS是一致的,具体的同步逻辑调用栈如下所示,简要来看就是将当前路径的所有inode加入一个同步队列,一个一个inode地进行同步,同步时按照以下几种情况进行不同处理:
1)如果Alluxio和UFS都存在此inode,但元数据不一致,则分别判断Alluxio与UFS的每个元数据的条目是否相同,如果有不同,则以UFS的为准进行更新;
2)如果UFS中不存在此inode而Alluxio中存在,删除Alluxio中的inode;
3)如果UFS存在此inode但Alluxio不存在,直接从UFS中load此inode;
4)如果此inode还有子inode,将所有子inode放入同步队列;
createFile(DefaultFileSystemMaster.java:1497)
|—syncMetadata(DefaultFileSystemMaster.java:3372)
|—syncMetadata(DefaultFileSystemMaster.java:3398)
|—sync(InodeSyncStream.java:257)
|—processSyncPath(InodeSyncStream.java:410)
|—syncInodeMetadata(InodeSyncStream.java:443)
|—syncExistingInodeMetadata(InodeSyncStream.java:458)
在syncExistingInodeMetadata方法中,Alluxio会根据几种情况的不同参数,通过computeSyncPlan方法生成不同的元数据同步执行计划,并根据执行计划来执行更新、删除、加载等操作。
元数据同步后,由于后面会有journal的写入操作,因此需要对当前路径加锁,然后确定当前用户是否有对Alluxio和UFS中当前路径父目录的写权限,随后调用createFileInternal中的createPath来进行元数据的写入。Alluxio对于文件和目录的创建逻辑均实现在createPath中,通过上下文对象进行文件和目录的区分,下面为createPath简化后的代码:
public List<Inode> createPath(RpcContext rpcContext, LockedInodePath inodePath,
CreatePathContext<?, ?> context) throws FileAlreadyExistsException, BlockInfoException,
InvalidPathException, IOException, FileDoesNotExistException {
...
一些文件状态和参数检查
...
//创建路径上不存在的inode
if(parentInodeNotExist){
createParentInodeIfNotExist();
applyAndJournal();
}
if (context instanceof CreateDirectoryContext) {
//创建目录inode
createDirInode();
} else if (context instanceof CreateFileContext) {
//创建文件inode
createFileInode();
} else {
throw new IllegalStateException(String.format("Unrecognized create options: %s", context));
}
//创建成功,将元数据变动写入journal
applyAndJournal();
return createdInodes;
}
创建inode时涉及的owner、group、acl的确定以及其他初始化操作简化在createxxxInode()中。创建给定目录的过程中,首先判断是否父目录不存在,并创建父目录中不存在的各级inode,随后根据当前创建的是哪种inode进行单独处理,并将所有创建的inode放入一个List中返回。createFileInternal中的元数据相关操作完成后,会通过getFileInfoInternal返回当前路径的信息。getFileInfoInternal的具体实现简要如下所示:
private FileInfo getFileInfoInternal(LockedInodePath inodePath)
throws FileDoesNotExistException, UnavailableException {
// 处理BlockInfo不存在的Block
if (fileInfo.getBlockIds().size() > fileInfo.getFileBlockInfos().size() && inode.isPersisted()) {
List<Long> missingBlockIds = fileInfo.getBlockIds().stream().filter((bId) -> fileInfo.getFileBlockInfo(bId) != null).collect(Collectors.toList());
mBlockMaster.removeBlocks(fileInfo.getBlockIds(), true);
commitBlockInfosForFile(fileInfo.getBlockIds(), fileInfo.getLength(), fileInfo.getBlockSizeBytes());
fileInfo.setFileBlockInfos(getFileBlockInfoListInternal(inodePath));
}
return fileInfo;
}
此方法中有一步针对BlockInfo缺失的操作,对于BlockInfo缺失的文件,Alluxio会将所有Block暂时删除,并重新加载BlockInfo。此时虽然元数据的写入已完成,但有较大的可能还没有完成Block的写入和UFS的写入,因此当前未写入完成的文件会被标记为BlockInfo缺失,经过remove和重新commit Block会使得文件元数据不完整或损坏。此时Hive查询了此文件就会报如下错误:
重新load文件可以暂时解决问题,但重新写入之后仍很有可能出现,因此我们对这部分的逻辑进行了优化,对missingBlock增加了isCompleted判断,同时在日志中增加一个warning,只有Block写入完成并符合其他missingBlock条件的文件才会进入此处的处理逻辑,优化后没有出现过上图的报错,从日志中可以看到有大量的写入完成之前就进行BlockInfo缺失处理操作,由此可见此处的优化很有必要,尤其是针对读写频繁且底层性能较差的场景。
在元数据写入成功后,利用元数据信息实例化一个文件输出流对象,createFile的调用方随后会使用此对象的write方法进行Block的写入。例如下面为一个调用了createFIle的方法,Alluxio先在createFile方法中写入元数据,然后调用write方法写入buf的数据到Block中。
private void writeFile(FileSystem fileSystem)
throws IOException, AlluxioException {
...
FileOutStream os = fileSystem.createFile(mFilePath, mWriteOptions);
os.write(buf.array());
os.close();
}
Block写入过程的调用栈如下所示(以HDFS作为UFS为例),Alluxio实现了一个抽象类OutputStream,对不同的UFS均继承了OutputStream并根据UFS的依赖实现了不同的write方法。
write(AlluxioFileOutStream.java:205)
|—writeInternal(AlluxioFileOutStream.java:220)
|—write(BlockOutStream.java:141)(写入Alluxio)
|—write(HdfsUnderFileOutputstream.java:59)(写入UFS)
|—write(FSDataOutputStream.java:47)(Hdfs方法)
其中writeInternal的具体实现如下所示,除ASYNC_THROUGH外,无论哪种写入模式,Alluxio均会先一个一个Block地写入Alluxio,再调用UFS的写入方法将文件写入底层。ASYNC_THROUGH写入模式下,数据会先写入Alluxio,Block被标记为TO_BE_PERSISTED,然后根据持久化的配置项来定时进行数据持久化。
private void writeInternal(int b) throws IOException {
if (mShouldCacheCurrentBlock) {
try {
if (mCurrentBlockOutStream == null || mCurrentBlockOutStream.remaining() == 0) {
getNextBlock();
}
//写入Alluxio
mCurrentBlockOutStream.write(b);
} catch (IOException e) {
handleCacheWriteException(e);
}
}
if (mUnderStorageType.isSyncPersist()) {
//写入UFS
mUnderStorageOutputStream.write(b);
Metrics.BYTES_WRITTEN_UFS.inc();
}
mBytesWritten++;
}
需要注意的是,在某些场景下,Alluxio可能会读取到在UFS上没有缓存到Alluxio中的文件,若readType没有配置为NO_CACHE,此时也会发生写入,写入逻辑的过程与上面的一致。下图为createFile过程的流程图:
####总结
本文基于代码简要分析了Alluxio写入过程涉及的逻辑,Alluxio写入一个文件时会首先进行Alluxio元数据和journal的写入,然后将文件按Block存储在worker上,最后通过UFS的写入方法来将文件写入底层。当writeType为ASYNC_THROUGH时,写入Alluxio的文件暂时不会直接写入UFS,而是被标记为TO_BE_PERSISTED,再根据持久化配置项配置的persist interval等参数确定何时进行持久化。在UFS存在而Alluxio不存在的文件也会通过文件写入的逻辑缓存到Alluxio中。