4 min read
日常翻车之文件系统目录文件顺序

TL;DR

XFS 在小规模目录下使用 inode 排序,整体文件顺序会相对稳定,而大规模后也会出现顺序不稳定的情况;而 EXT4 在文件数超过一定量的后也会使用 htree 对文件做排序,并且在容器的情况下同一个目录也会因为 seed 不同而带来不同的顺序。

背景

集群升级后,容器内文件系统被默默的从 xfs 换成了 ext4,导致某个项目组的所有 java 服务全部启动 crash,偶有几个能够正常启动。

java.lang.ExceptionInInitializerError
	at org.slf4j.impl.StaticLoggerBinder.<init>(StaticLoggerBinder.java:72)
	at org.slf4j.impl.StaticLoggerBinder.<clinit>(StaticLoggerBinder.java:45)
	at org.slf4j.LoggerFactory.bind(LoggerFactory.java:150)
	at org.slf4j.LoggerFactory.performInitialization(LoggerFactory.java:124)
	at org.slf4j.LoggerFactory.getILoggerFactory(LoggerFactory.java:412)
	at org.slf4j.LoggerFactory.getLogger(LoggerFactory.java:357)
	at org.slf4j.LoggerFactory.getLogger(LoggerFactory.java:383)
	at com.meitu.userprofile.vision.config.VisionBootstrap.<clinit>(VisionBootstrap.java:26)
Caused by: java.lang.IllegalStateException: Detected both log4j-over-slf4j.jar AND bound slf4j-log4j12.jar on the class path, preempting StackOverflowError. See also http://www.slf4j.org/codes.html#log4jDelegationLoop for more details.
	at org.slf4j.impl.Log4jLoggerFactory.<clinit>(Log4jLoggerFactory.java:54)
	... 8 more

crash 的本质是依赖加载顺序的冲突,但本文不对依赖冲突这个问题做讨论。

排查过程

初步定位

从业务的日志来看,是依赖冲突了。不过为啥旧机器可以,但新机器不行,然后想到有业务反馈过 find 结果的顺序变动也命中了他的脚本之前遗留的 bug,然后去找是否 ClassLoader 加载顺序会可能导致这样的结果,得到的答案是肯定的。然后比对下文件系统,惊讶的发现能够正常运行的都是 xfs 的系统,异常的都在 ext4 上(不预期变更,但为什么变更不在本文范围内)。

给业务启动时加 -verbose:class 看了下确实顺序不太一样,然而并不懂 java(头大,一直不太喜欢)

只能拿出 strace 看下业务干了些啥,看跟 classpath 相关的是在做什么,发现

access("/root/user-profile-vision-config/lib/*", F_OK) = -1 ENOENT (No such file or directory)
open("/root/user-profile-vision-config/lib/", O_RDONLY|O_NONBLOCK|O_DIRECTORY|O_CLOEXEC) = 3
fcntl(3, F_GETFD)                       = 0x1 (flags FD_CLOEXEC)
getdents(3, /* 188 entries */, 32768)   = 9224
getdents(3, /* 0 entries */, 32768)     = 0
close(3)                                = 0

有一波读取文件夹的操作,那读完文件夹 java 会再排序吗?不知道诶,先造 syscall open+fcntl+getdents(此处省略一堆代码),发现 getdents 取到的顺序确实是不一样的,在那就看看 JDK 会不会有另外的排序规则吧。

JDK 排查

在排查过程中,一开始认为在传入 classpath 后,应该会在 ClassLoader 中处理 io 的 dir list 于是将 jdk 中的 Launcher 和 ClassLoader 翻了个遍,最后颗粒无收。(吃了不懂 java 的亏,就不赘述一些挖掘的过程了)

然后回过头来看,发现是在最最最前面启动时有一个启动参数转换对 wildcard classpath 做了「翻译」

// jdk/src/share/bin/java.c
static void
TranslateApplicationArgs(int jargc, const char **jargv, int *pargc, char ***pargv)
{
...
            if (IsWildCardEnabled() && arg[1] == 'c'
                && (JLI_StrCmp(arg, "-cp") == 0 ||
                    JLI_StrCmp(arg, "-classpath") == 0)
                && i < argc - 1) {
                *nargv++ = arg;
                *nargv++ = (char *) JLI_WildcardExpandClasspath(argv[i+1]);
                i++;
                continue;
            }
...
}

// jdk/src/share/bin/wildcard.c
const char *
JLI_WildcardExpandClasspath(const char *classpath)
{
    char *expanded;
    FileList fl;

    if (JLI_StrChr(classpath, '*') == NULL)
        return classpath;
    fl = FileList_split(classpath, PATH_SEPARATOR);
    FileList_expandWildcards(fl);
    expanded = FileList_join(fl, PATH_SEPARATOR);
    FileList_free(fl);
    if (getenv(JLDEBUG_ENV_ENTRY) != 0)
        printf("Expanded wildcards:\n"
               "    before: \"%s\"\n"
               "    after : \"%s\"\n",
               classpath, expanded);
    return expanded;
}

而后去调用 wildcardFileList 获取完整的目录列表,最终在 unix 系统会落在

static WildcardIterator
WildcardIterator_for(const char *wildcard)
{
    DIR *dir;
    int wildlen = JLI_StrLen(wildcard);
    if (wildlen < 2) {
        dir = opendir(".");
    } else {
        char *dirname = JLI_StringDup(wildcard);
        dirname[wildlen - 1] = '\0';
        dir = opendir(dirname);
        JLI_MemFree(dirname);
    }
    if (dir == NULL)
        return NULL;
    else {
        WildcardIterator it = NEW_(WildcardIterator);
        it->dir = dir;
        return it;
    }
}

static char *
WildcardIterator_next(WildcardIterator it)
{
    # 重点内容
    struct dirent* dirp = readdir(it->dir);
    return dirp ? dirp->d_name : NULL;
}

以上通过 opendirreaddir 实现读取文件列表最后搓成完整的 jar 路径列表给 classpath,而后在 ClassLoader 中使用的时候便不再处理 wildcard。

至此,我们可以得出一波结论,java classpath 的加载顺序完全取决于 readdir 再深一点就是 getdents

至于文件系统

其实在排查的过程中,其实也发现了 ext4 偶发可以正常启动。那个点当时认为可能是是相关 ext 的 dir_index 的开关(非永远开启),所以暂时先放放。先来看下 xfs 和 ext4 在 getdents 的表现差异的原因。

linux/fs/readdir.c 中 getdents 实际的定义(SYSCALL_DEFINE3(getdents64 或者 SYSCALL_DEFINE3(getdents)去调用 vfs_readdir 做遍历(3.11 后使用各服务注册的 iterate_shared

ext4 readdir

在本文的 case 中 ext4 都启用了 dir_index 也就会走 htree 的方式(如果没启用,则走线性文件目录)

const struct file_operations ext4_dir_operations = {
...
	.readdir	= ext4_readdir,
...
};

根据 linux/fs/ext4/namei.c:L1915 中,如果启用了 dir_index 增加在添加文件的时候向 htree 增加该 entry ext4_dx_add_entry -> ext4_insert_dentry

~linux/fs/ext4/namei.c:L699
...
	hinfo->seed = EXT4_SB(dir->i_sb)->s_hash_seed;
	if (d_name)
		ext4fs_dirhash(d_name->name, d_name->len, hinfo);
	hash = hinfo->hash;
...

而后读取的时候从这颗 htree 中读出,由于 seed(superblock) 不同所以出来的 hash value 其实也各不同(用 debugfs 的 htree 就很清晰了,不赘述),也就能够解释为什么同样开启了 dir_index 下的 ext4 但顺序也不同,然后有时候偶然又能够启动。

xfs readdir

const struct file_operations xfs_dir_file_operations = {
...
	.readdir	= xfs_file_readdir,
...
};

在 XFS 文件系统中会根据 inode 信息的大小分成多种文件夹类型,这块在 xfs_readdir 会被分开处理。如果 inode 记录数超过了目录中的 inode space 则会发生转换。

这块由于当前关注度会比较低,所以暂时不深入代码做整理,后续另外开篇理

详情可见 Chapter 6. Directories

6.1. Shortform Directories 6.2. Block Directories 6.3. Leaf Directories 6.4. Node Directories 6.5. B+tree Directories

Most symbolic links and directory files are small files. XFS allows these files to be stored in inodes for increased performance. XFS also uses delayed writes to wait to gather the entire small file in the buffer cache before writing to disk…

...
	if (dp->i_d.di_format == XFS_DINODE_FMT_LOCAL)
		rval = xfs_dir2_sf_getdents(dp, dirent, offset, filldir);
	else if ((rval = xfs_dir2_isblock(NULL, dp, &v)))
		;
	else if (v)
		rval = xfs_dir2_block_getdents(dp, dirent, offset, filldir);
	else
		rval = xfs_dir2_leaf_getdents(dp, dirent, bufsize, offset,
					      filldir);
...

本文的 case 正好命中了 xfs_dir2_sf_getdents:根据 inode 直接排序,所以在 xfs 中可以稳定运行

refs