将大型目录拆分为子目录

4

我有一个包含大约250万个文件且超过70 GB的目录。

我希望将其拆分为子目录,每个子目录中包含1000个文件。

这是我试过使用的命令:

i=0; for f in *; do d=dir_$(printf %03d $((i/1000+1))); mkdir -p $d; mv "$f" $d; let i++; done

这个命令在小规模下可以工作,但是我可以让它在这个目录下运行数小时,看起来什么也没有发生。

我愿意通过任何方式在命令行上完成这个任务:Perl,Python等。只要哪种方式最快就可以了...


1
你的 * 扩展需要一些时间。也许可以先从更具针对性的文件名子集,如 a* 开始,看看是否能在更合理的时间内返回结果。你还可以考虑使用 'find' 而不是 for 循环。此外,我担心在这个已经太大的目录中创建子目录。你有没有考虑在其他地方创建它们? - LinuxDisciple
我建议处理find命令的结果。 - Cole Tierney
只有编写得非常好的shell才能处理由命令或通配符扩展产生的多MB大小范围内的字符串。 在程序中读取目录是快速且容易的(请参见@ikegami)。 - 如果您想使用shell脚本:通过将“ls”管道传输到while read ... done 来逐个获取文件,从而将问题细分。 - laune
@laune:让*for循环中扩展到250万个文件名本身并不是问题-实际上,for f in *; do ...ls | while read ...要快得多。问题在于调用了250万次外部工具。 - mklement0
@mklement0 您不想为每个文件都使用单个mv。1000的因数将其缩小到可管理的大小。-- 但是,由于最大命令长度为2.088.198(根据xargs --show-limits),我认为扩展*以进行250万次操作可能行不通。-- 所有这些只是表明这项任务对于shell脚本来说有点太多了。Perl已经足够好了,我们都知道用C(甚至Java)也很容易。 - laune
@laune: 的确如此,这就是为什么无论是 for f in * 还是 ls | while read 都不是解决方案的原因。最大命令行长度并没有起作用,因为 * 的扩展结果并没有传递给 _外部实用程序_(并非该限制不适用于 printf 等 _内置命令_,这正是我的答案所依赖的)。同意,一个 Shell 脚本也不是正确的解决方案。 - mklement0
6个回答

11

我怀疑如果你检查一下,你会发现你的程序确实在移动文件,只是速度非常慢。启动一个程序相当耗费资源(至少与系统调用相比),而你每个文件都要启动三到四次!因此,以下方法应该更快:

perl -e'
   my $base_dir_qfn = ".";
   my $i = 0;
   my $dir;
   opendir(my $dh, $base_dir_qfn)
      or die("Can'\''t open dir \"$base_dir_qfn\": $!\n");

   while (defined( my $fn = readdir($dh) )) {
      next if $fn =~ /^(?:\.\.?|dir_\d+)\z/;

      my $qfn = "$base_dir_qfn/$fn";

      if ($i % 1000 == 0) {
         $dir_qfn = sprintf("%s/dir_%03d", $base_dir_qfn, int($i/1000)+1);
         mkdir($dir_qfn)
            or die("Can'\''t make directory \"$dir_qfn\": $!\n");
      }

      rename($qfn, "$dir_qfn/$fn")
         or do {
            warn("Can'\''t move \"$qfn\" into \"$dir_qfn\": $!\n");
            next;
         };

      ++$i;
   }
'

问题是调用多个外部工具的2.5百万次迭代,而不是通过*提前构建所有文件名的内部数组。 在我的笔记本电脑上运行Ubuntu 14.04,对2.5百万个文件进行空循环只需要大约25秒:time for f in *; do :; done 仅添加一个简单的外部实用程序到混合中 - 例如,time for f in *; do date; done >/dev/null - 大大增加了执行时间;我在大约30分钟后终止了该命令。 您的Perl解决方案要快得多,因为所有操作都在进程内完成。 - mklement0

3
注意:ikegami基于Perl的解决方案是更优的选择——它在单个进程中执行整个操作,因此比下面的Bash+标准工具解决方案快得多。
为了使bash解决方案能够合理地运行,需要避免循环调用外部实用程序。
您自己的解决方案在每个循环迭代时调用两个外部实用程序并创建一个子shell,这意味着您最终将创建大约750万个进程(!)
以下解决方案避免了循环,但是由于输入文件数量极大,仍然需要相当长的时间才能完成(您最终将为每1000个输入文件创建4个进程,即总共约1万个进程)。
printf '%s\0' * | xargs -0 -n 1000 bash -O nullglob -c '
  dirs=( dir_*/ )
  dir=dir_$(printf %04s $(( 1 + ${#dirs[@]} )))
  mkdir "$dir"; mv "$@" "$dir"' -
  • printf '%s\0' * 打印出该目录下所有文件名,文件名之间以 NUL 字符分隔。
    • 请注意,由于 printf 是 Bash 的内置命令而不是外部实用程序,因此由 getconf ARG_MAX 报告的最大命令行长度不适用。
  • xargs -0 -n 1000 使用 1000 个输入文件名的一小块调用指定命令。

    • 请注意,xargs -0 是非标准的,但在 Linux 和 BSD / OSX 上都受支持。
    • 使用以 NUL 分隔的输入可靠地传递文件名,而不必担心意外地将它们拆分为多个部分,并且甚至可以使用包含换行符的文件名(尽管这种文件名非常罕见)。
  • bash -O nullglob -c 执行带有选项 nullglob 的指定命令字符串,这意味着与没有匹配项的 glob 模式扩展为空字符串。

    • 该命令字符串计算到目前为止创建的输出目录数,以确定具有下一个更高索引的下一个输出目录的名称,创建下一个输出目录,并将当前的一批(最多)1000 个文件移动到那里。

你可以在目录上循环(但不是文件)。 - laune
@laune:是的,但是没有简单的方法可以从xargs直接向while循环稳健地传递1000个文件名的批处理(您的方法无法处理嵌入空格和/或恰好是有效通配符的文件名)。 通过让xargs将每个1000个文件名的批处理作为单独的参数(不涉及shell)传递给bash,它们被正确地保留。 - mklement0

2
如果目录未被使用,我建议采取以下措施。
find . -maxdepth 1 -type f | split -l 1000 -d -a 5 

这将创建n个文件,文件名为x00000 - x02500(为确保5位数字,尽管4位也可以)。然后,您可以将每个文件中列出的1000个文件移动到相应的目录中。
如果存在名称冲突,则可以使用set -o noclobber来消除覆盖风险。
要移动文件,最好使用大括号扩展来迭代文件名。
for c in x{00000..02500}; 
do d="d$c"; 
   mkdir $d; 
   cat $c | xargs -I f mv f $d; 
done 

++,但是(a)使用_GNU_ find,生成的文件名列表不会像glob *一样排序,(b)假设OP的xargs支持-0,则使用tr'\n''\0'<"$c"|xargs -0 -J f mv f"$d"将更快;(c)正如我在(b)中的代码片段中所做的那样,我建议双引号引用变量以促进良好习惯,即使在这里不是严格必要的。 - mklement0
是的,文件不会被排序,但我不确定这是否是一个要求。 - karakfa
虽然这不是一个要求,但鉴于 OP 基于 * 来实现自己的方法,这种差异值得一提。我刚刚意识到 -JBSDxargs 选项;因此,在使用 GNU 工具时,更有效的命令应该被声明为 tr '\n' '\0' < "$c" | xargs -0 mv -t "$d";并且只是为了明确:你的 -I f 方法会将每个文件单独移动。 - mklement0
有点讽刺的是,你目前唯一使用双引号的地方是它们从来不需要(尽管它们并不会有害):d="d$c"无论$c的值如何,都不需要双引号。 - mklement0

0

移动文件始终是一个挑战。在我看来,到目前为止提出的所有解决方案都存在破坏文件的风险。这可能是因为这个挑战听起来很简单,但在实现时需要考虑和测试的内容很多。

我们也不能低估解决方案的效率,因为我们可能要处理(非常)大量的文件。

这里是一个经过仔细和深入测试自己文件的脚本。但当然,使用时需自行承担风险!

这个解决方案:

  • 对包含空格的文件名是安全的。
  • 不使用 xargs -L,因为这很容易导致“参数列表过长”错误。
  • 基于 Bash 4,不依赖于 awksedtr 等工具。
  • 随着要移动的文件数量的增加而扩展得很好。

以下是代码:

if [[ "${BASH_VERSINFO[0]}" -lt 4 ]]; then
  echo "$(basename "$0") requires Bash 4+"
  exit -1
fi >&2

opt_dir=${1:-.}
opt_max=1000

readarray files <<< "$(find "$opt_dir" -maxdepth 1 -mindepth 1 -type f)"
moved=0 dirnum=0 dirname=''

for ((i=0; i < ${#files[@]}; ++i))
do
  if [[ $((i % opt_max)) == 0 ]]; then
    ((dirnum++))
    dirname="$opt_dir/$(printf "%02d" $dirnum)"
  fi
  # chops the LF printed by "find"
  file=${files[$i]::-1}
  if [[ -n $file ]]; then
    [[ -d $dirname ]] || mkdir -v "$dirname" || exit
    mv "$file" "$dirname" || exit
    ((moved++))
  fi
done

echo "moved $moved file(s)"

例如,将此保存为split_directory.sh。现在假设您有2001个文件在some/dir中:

 $ split_directory.sh some/dir
mkdir: created directory some/dir/01
mkdir: created directory some/dir/02
mkdir: created directory some/dir/03
moved 2001 file(s)

现在的新情况如下:

  • some/dir 包含 3 个目录和 0 个文件
  • some/dir/01 包含 1000 个文件
  • some/dir/02 包含 1000 个文件
  • some/dir/03 包含 1 个文件

再次对同一目录调用脚本是安全的,并几乎立即返回结果:

 $ split_directory.sh some/dir
moved 0 file(s)

最后,让我们来看一下特殊情况,即在生成的目录之一上调用脚本的情况:
 $ time split_directory.sh some/dir/01
mkdir: created directory 'some/dir/01/01'
moved 1000 file(s)

real    0m19.265s
user    0m4.462s
sys     0m11.184s
 $ time split_directory.sh some/dir/01
moved 0 file(s)

real    0m0.140s
user    0m0.015s
sys     0m0.123s

请注意,此测试是在一台相对较慢的老旧计算机上运行的。
祝你好运 :-)

-1

这可能比 Perl 程序慢(10,000 个文件需要 1 分钟),但它应该可以在任何符合 POSIX 标准的 shell 中运行。

#! /bin/sh
nd=0
nf=0
/bin/ls | \
while read file;
do
  case $(expr $nf % 10) in
  0)
    nd=$(/usr/bin/expr $nd + 1)
    dir=$(printf "dir_%04d" $nd)
    mkdir $dir
    ;;
  esac
  mv "$file" "$dir/$file"
  nf=$(/usr/bin/expr $nf + 1)

完成

使用bash,您可以使用算术扩展$((...))。

当然,通过使用xargs,这个想法可以得到改进 - 对于250万个文件,不应该需要超过45秒的时间。

nd=0
ls | xargs -L 1000 echo | \
while read cmd;
do
  nd=$((nd+1))
  dir=$(printf "dir_%04d" $nd)
  mkdir $dir
  mv $cmd $dir
done

请为您的变量引用加上双引号,以保护它们免受意外的单词拆分和全局匹配。为了健壮性,您的read命令应该以IFS =为前缀,并使用-rIFS = read -r ...。尽管罕见,但可能会有前导或尾随空格或嵌入的\字符的文件名。 - mklement0
你的前两个命令是单独处理每个文件,这也是 OP 自己尝试过但不成功的方法。 调用多个外部工具 250 万次需要很长时间。 - mklement0
你的 xargs 解决方案不够健壮:它在文件名中包含空格时会出现问题。 - mklement0

-2
我会从命令行使用以下内容:
find . -maxdepth 1 -type f |split -l 1000
for i in `ls x*`
do 
   mkdir dir$i
   mv `cat $i` dir$i& 2>/dev/null
done

关键在于"&",它将每个mv语句串联起来。

感谢karakfa提出的分割思路。


1
虽然在ls x*情况下使用for可能可以行得通,因为文件名恰好没有嵌入空格或globbing元字符,但这在一般情况下是不好的想法。此外,\cat $i\会导致生成的单词受到单词分割的影响,一方面您正在依赖它,但是对于包含嵌入空格的文件名,这会导致失败。此外,使用该技术可能导致生成的命令行过长。鉴于您已经有一个名为$i的文件,您无法创建相同名称的目录。 - mklement0

网页内容由stack overflow 提供, 点击上面的
可以查看英文原文,
原文链接