前言
在HDFS中,当每次客户端用户往某个文件中写入数据的时候,为了保持数据的一致性,此时其它客户端程序是不允许向此文件同时写入数据的。那么HDFS是如何做到这一点的呢?答案是租约(Lease)。换句话说,租约是HDFS给予客户端的一个写文件操作的临时许可证,无此证件者将不被允许操作此文件。本文我们将要深入分析HDFS内部的租约机制,包括租约的添加、移除、管理操作等等。
HDFS租约的概念
HDFS租约可能很多使用HDFS的人都或多或少都知道一些,大致的理解一般如下:“客户端在每次读写HDFS文件的时候获取租约对文件进行读写,文件读取完毕了,然后再释放此租约”。但是是否有人仔细研究过这个租约内部到底包含了什么信息呢,它与租约持有者也就是客户端用户是一种怎样的逻辑关系呢?首先我们需要了解的就是这个问题。下面是HDFS租约的相关定义:
- 每个客户端用户持有一个租约。
- 每个租约内部包含有一个租约持有者信息,还有此租约对应的文件Id列表,表示当前租约持有者正在写这些文件Id对应的文件。
- 每个租约内包含有一个最新近更新时间,最近更新时间将会决定此租约是否已过期。过期的租约会导致租约持有者无法继续执行写数据到文件中,除非进行租约的更新。
综合上述3点,租约的结构关系如图1-1所示。
图 1-1 租约内部结构
租约类代码中的定义如下:
class Lease {
// 租约持有者
private final String holder;
// 最近更新时间
private long lastUpdate;
// 当前租约持有者打开的文件
private final HashSet<Long> files = new HashSet<>();
...
每次当客户端用户新写入一个文件的时候,它会将此文件Id加入到它所对应的租约中,同时更新lastUpdateTime值。讲述完租约的概念,下面我们在来看租约的管理。
HDFS租约的管理
在HDFS中,每天会有许许多多的应用程序在读写文件,于是就会有各个租约的生成。那么这些租约是如何管理的呢?如果有些客户端用户写某文件后未及时关闭此文件,导致此租约一直未释放,从而造成其他用户无法对此文件进行写操作,面对这种情况,HDFS中的做法是怎样的呢?以上提到的问题就是租约管理的内容了。
LeaseManager租约管理器
HDFS租约管理的操作集中在一个类上:LeaseManager。它与CacheManager(缓存管理类),SnapshotManager(快照管理类)类似,是一个中心管理类,运行在Active NameNode的服务中。租约类的定义就是在LeaseManager中的。在LeaseManager租约管理器中,它所做的事情主要归纳为两类。
第一个,维护HDFS内部当前所有的租约,并以多种映射关系进行保存。保存的映射关系分为如下3种:
- 租约持有者对租约的映射关系。
- 文件Id对租约的映射关系。
- 按照时间排序进行租约集合的保存,此关系并不是一种映射关系。
以上3种关系的代码定义如下:
public class LeaseManager {
...
// 租约持有者对租约的映射图
private final SortedMap<String, Lease> leases = new TreeMap<>();
// 按照时间进行排序的租约队列
private final PriorityQueue<Lease> sortedLeases = new PriorityQueue<>(512,
new Comparator<Lease>() {
@Override
public int compare(Lease o1, Lease o2) {
return Long.signum(o1.getLastUpdate() - o2.getLastUpdate());
}
});
// 文件Id对租约的映射图
private final HashMap<Long, Lease> leasesById = new HashMap<>();
...
图形展示效果如图1-2所示。
图 1-2 HDFS内部保存的租约映射关系
HDFS保存多种映射关系是为了方便租约的多维度查询,至少目前来看,按照租约持有者,正在写的文件Id都可以直接查到对应的租约对象。
在LeaseManager租约管理器中,还有一件重要的事情是定期释放过期的租约对象。这个操作可以避免文件租约长期不释放导致其他客户端文件无法写文件的问题。
因为在某些异常情况下,客户端程序可能在写完文件后,没有正常关闭文件,导致文件始终处于正在写的状态中,此文件在对应的租约中没有被真正的移除掉。
LeaseManager中的解决办法是启动一个定时的监控线程,来释放过期的租约。周期检测线程主方法如下:
public void run() {
for(; shouldRunMonitor && fsnamesystem.isRunning(); ) {
boolean needSync = false;
try {
fsnamesystem.writeLockInterruptibly();
try {
// 如果当前NameNode已经离开安全模式
if (!fsnamesystem.isInSafeMode()) {
// 则进行租约进行检测操作
needSync = checkLeases();
}
} finally {
...
}
// 进行租约间隔检测时间的睡眠,默认2秒
Thread.sleep(fsnamesystem.getLeaseRecheckIntervalMs());
} catch(InterruptedException ie) {
if (LOG.isDebugEnabled()) {
LOG.debug(name + " is interrupted", ie);
}
} catch(Throwable e) {
LOG.warn("Unexpected throwable: ", e);
}
}
}
从上面的代码我们可以看出,这是一个持续运行的操作,我们进入checkLease方法,先来看checkLease的头几行代码的执行逻辑,
synchronized boolean checkLeases() {
boolean needSync = false;
assert fsnamesystem.hasWriteLock();
// 获取租约检测的起始时间
long start = monotonicNow();
// 满足一下3个条件,则进入租约释放操作:
// 1.如果租约队列不为空
// 2.租约队列中最老的租约已经出现了超时
// 3.没到租约检测的最大时间期限
while(!sortedLeases.isEmpty() && sortedLeases.peek().expiredHardLimit()
&& !isMaxLockHoldToReleaseLease(start)) {
// 获取更新时间最老的租约,同样也是已过期的租约时间
Lease leaseToCheck = sortedLeases.peek();
LOG.info(leaseToCheck + " has expired hard limit");
...
因为sortedLeases租约队列已经是按最近更新时间值排序好的,所以取出的Lease对象就是最旧的一个租约。在这里还要介绍上面的第3个条件的意思,也就是下面这行代码的意思:
isMaxLockHoldToReleaseLease(start)
因为HDFS为了避免每次租约检测花费过长的时间,在此进行租约检测时间的判断,如果时间超过了,则终止当前的操作,等待下一次的checkLease操作。
我们继续来看while循环内下半部分的代码:
...
final List<Long> removing = new ArrayList<>();
// 获取待释放租约中包含的文件Id
Collection<Long> files = leaseToCheck.getFiles();
Long[] leaseINodeIds = files.toArray(new Long[files.size()]);
FSDirectory fsd = fsnamesystem.getFSDirectory();
String p = null;
// 遍历这些文件Id
for(Long id : leaseINodeIds) {
try {
// 获取这些文件Id对应的INode path对象
INodesInPath iip = INodesInPath.fromINode(fsd.getInode(id));
p = iip.getPath();
// Sanity check to make sure the path is correct
if (!p.startsWith("/")) {
throw new IOException("Invalid path in the lease " + p);
}
// 进行文件的关闭,在此过程中,此文件Id将从此租约中移除
boolean completed = fsnamesystem.internalReleaseLease(
leaseToCheck, p, iip,
HdfsServerConstants.NAMENODE_LEASE_HOLDER);
...
} catch (IOException e) {
LOG.error("Cannot release the path " + p + " in the lease "
+ leaseToCheck, e);
// 如果在关闭文件的过程中发生异常,则将文件Id加入到移除列表中
removing.add(id);
}
// 如果发现租约检测时间到了,则终止当前操作
if (isMaxLockHoldToReleaseLease(start)) {
LOG.debug("Breaking out of checkLeases after " +
fsnamesystem.getMaxLockHoldToReleaseLeaseMs() + "ms.");
break;
}
}
// 从租约中移除异常文件Id
for(Long id : removing) {
// 如果此租约中已无文件Id,则此租约将从HDFS中彻底移除
removeLease(leaseToCheck, id);
}
}
return needSync;
}
通过上述代码,租约检测的操作可以归纳为如下步骤:
- 第1步,获取最老的已过期的租约。
- 第2步,得到此租约中保存的文件Id。
- 第3步,关闭这些文件Id对应的文件,并将这些文件Id从此租约中移除。
- 第4步,如果此租约中已经没有打开的文件Id,则将此租约从系统中进行移除。
租约检测过程如图1-3所示。
图 1-3 HDFS租约的周期性检测过程
LeaseRenewer租约更新器
LeaseRenewer对象的作用在于定时更新DFSClient用户所持有的租约。每个用户对应一个LeaseRenewer更新器对象,而每个LeaseRenewer对象内部会维护一个DFSClient客户端列表。在LeaseRenewer的主方法中,会定期的执行DFSClient客户端对应租约的renew操作。当DFSClient端所操作的文件都被关闭了,此DFSClient将从LeaseRenewer的客户端列表中进行移除,这就意味着此DFSClient所对应的租约将不再被更新,最后将会被LeaseManager进行过期移除操作。
HDFS租约的添加、检测、释放
讲述完租约的概念以及管理之后,我们来分析租约的添加到释放的过程。以我们对于租约的一个传统概念应该是这样一个过程:首先在进行文件写操作时,进行租约的添加,然后操作结束之后,进行租约的释放。但是猜想归猜想,事实上究竟是否如此呢?下面我们从HDFS代码层面对此进行分析。
首先是HDFS租约的添加,租约的添加的确是在每次HDFS写文件操作的时候进行的,以追加写操作为例:
static LocatedBlock prepareFileForAppend(final FSNamesystem fsn,
final INodesInPath iip, final String leaseHolder,
final String clientMachine, final boolean newBlock,
final boolean writeToEditLog, final boolean logRetryCache)
throws IOException {
assert fsn.hasWriteLock();
final INodeFile file = iip.getLastINode().asFile();
final QuotaCounts delta = verifyQuotaForUCBlock(fsn, file, iip);
file.recordModification(iip.getLatestSnapshotId());
file.toUnderConstruction(leaseHolder, clientMachine);
// 在追加写操作之前进行租约的添加
fsn.getLeaseManager().addLease(
file.getFileUnderConstructionFeature().getClientName(), file.getId());
...
类似的方法还有FSDirWriteFileOp的startFile方法。对于租约的移除,本人在查阅相关代码时,并没有明显发现在关闭文件操作的时候进行租约的移除动作。所以租约的移除并不是一个简单的过程,此过程的移除还是依赖于LeaseManager的租约过期移除操作。文件在关闭的过程中,会将自身从相应的DFSClient客户端对象中进行移除,继而使得此DFSClient从LeaseRenewer对象中移除,最后让它的租约不再更新。此过程原理见上小节LeaseRenewer对象的原理介绍。在DFSClient端的写文件操作方法中,会执行LeaseRenewer的添加动作,代码如下:
private DFSOutputStream append(String src, int buffersize,
EnumSet<CreateFlag> flag, String[] favoredNodes, Progressable progress)
throws IOException {
checkOpen();
final DFSOutputStream result = callAppend(src, flag, progress,
favoredNodes);
// 将当前文件Id加入,
beginFileLease(result.getFileId(), result);
return result;
}
这里的beginFileLease操作的意思不是添加新租约的意思,而是说开始对此文件所属的租约开启定时更新操作,执行的更新操作是LeaseRenewer的run方法。
最后我们来看租约的检查,我们看看HDFS如何利用租约来保证只有一个客户端程序可以写数据到某个文件的。HDFS租约的检查方法为FSNamesystem的checkLease方法。此方法在getAdditionalDatanode和fsync方法中被调用,这表明了租约检查发生以下在两个时候:
- 第一个,为新写的block选择目标存储节点时,进行租约的检查。
- 第二个,进行数据同步到磁盘的时候,又一次进行租约的检查。
这里我们以getAdditionalDatanode方法为例:
LocatedBlock getAdditionalDatanode(String src, long fileId,
final ExtendedBlock blk, final DatanodeInfo[] existings,
final String[] storageIDs,
final Set<Node> excludes,
final int numAdditionalNodes, final String clientName
) throws IOException {
...
readLock();
try {
...
//进行租约的检查
final INodeFile file = checkLease(iip, clientName, fileId);
...
最后进入checkLease租约检查方法,
INodeFile checkLease(INodesInPath iip, String holder, long fileId)
throws LeaseExpiredException, FileNotFoundException {
String src = iip.getPath();
INode inode = iip.getLastINode();
assert hasReadLock();
if (inode == null) {
throw new FileNotFoundException("File does not exist: "
+ leaseExceptionString(src, fileId, holder));
}
...
// 获取当前文件的操作者即租约持有者
final String owner = file.getFileUnderConstructionFeature().getClientName();
// 如果当前操作者不是租约持有者,则抛出异常
if (holder != null && !owner.equals(holder)) {
throw new LeaseExpiredException("Client (=" + holder
+ ") is not the lease owner (=" + owner + ": "
+ leaseExceptionString(src, fileId, holder));
}
return file;
}
所以当我们在HDFS的日志中看到诸如“Client (=xxx) is not the lease owner…”这种错误的时候,就表明当前有多个客户端程序同时在写某个文件。
OK,以上就是本文所要讲述的HDFS租约的相关内容了,希望本文能让大家对HDFS租约有一个更深入的了解。个人感觉HDFS的整个租约逻辑还是有一定复杂度的,还需要大家进行反复地阅读,理解。