1. 残缺标题背后的完整需求本质上是一趟SMB文件搬家我猜很多人在搜索引擎里敲下“Java 复制远程共享文件夹到”这半句话时后面想接的往往是“本地”“服务器目录”或者“另一个共享目录”。虽然标题看着缺了半截但需求其实非常明确你需要用Java程序把一台远程主机上通过SMB/CIFS协议共享出来的文件夹完整复制到另一台机器上。这个场景在真实项目里出现频率相当高。举几个我实际接触过的例子某个数据归集系统需要定时把分散在各办公室Windows机器上的共享文件夹汇总到中央服务器但客户不允许在每台机器上装Agent只能通过共享路径读取。某个运维平台要批量备份远程目录源目录挂在NAS上通过UNC路径访问目标端是Linux机器上的普通磁盘路径。某个项目需要做跨网段的文件迁移源端是Windows共享目录目标端是对象存储网关挂载出的目录中间程序跑在Linux服务器上。你会发现这些场景共同的特点是源路径不是本地路径也不是HTTP接口而是标准的SMB共享地址比如\\192.168.1.10\shared\data。Java标准库里的java.io.File和java.nio虽然很强大但面对SMB协议却束手无策——它们只懂本地文件系统不懂UNC路径更不懂NTLM认证。所以第一步不是写代码而是搞清楚SMB共享访问这件事在JVM世界里到底该怎么落地。如果你对SMB协议不太熟简单说一句SMB是微软网络文件共享的核心协议我们现在用的Windows共享文件夹就是这玩意儿。访问一个共享文件夹时路径通常写成\\hostname\sharename\subdir在Linux下用工具挂载后则是/mnt/xxx。Java要直接读这种远程路径常规方案只有两条要么先在操作系统层面把共享挂载成本地盘再走普通文件IO要么直接用Java库去跟SMB服务端对话。后者更灵活不依赖系统挂载、不限操作系统也正是本文要重点讲的路线。安全性方面多说一句通过SMB复制文件本质上是网络传输行为如果共享文件夹所在的服务端支持SMB 3.0以上协议传输过程可以进行加密密码走的是NTLM或Kerberos认证不会明文暴露在网络上。这一点在选型库的时候要特别注意后面我会专门展开讲。2. 方案选型JCIFS和SMBJ之间我踩过的坑很多人一搜“Java SMB”就会看到老牌开源库JCIFSjcifs我之前在早期项目里也用过。这库确实历史悠久、资料多但它最大的问题是只实现了SMB1协议。SMB1是什么概念它是上世纪80年代的技术CVE漏洞多到数不清现在Windows 10/11系统默认都是禁用SMB1的很多新的NAS设备也干脆不支持。你要是拿JCIFS去连一台现代Windows共享大概率直接报Unexpected packet received之类的错误因为服务端根本不跟你协商SMB1。这不只是理论问题我自己就遇到过某次对接一台新采购的NAS设备文档里明确写着“本设备仅支持SMB 2.0及以上协议”JCIFS程序跑起来始终连不上抓包都抓不到会话建立最后换了SMBJ问题才消失。2.1 为什么最终选定SMBJSMBJcom.hierynomus:smbj是目前Java生态里比较活跃的SMB客户端库核心优势有三个支持SMB 2.0/2.1/3.0/3.1.1协议能跟现代Windows、Samba、商业NAS正常对话。内置NTLM认证用户名/密码、域账户、匿名访问都能处理。底层是Netty网络框架超时控制、连接管理比JCIFS更可控。当然它也不是没有代价API设计跟java.io.File风格完全不同目录列举返回的是对象数组而Files.walk是流式初次上手需要一点学习成本。但从长期维护和兼容性来看这代价值得付。下面是Maven依赖版本号用你当前最新的稳定版即可dependency groupIdcom.hierynomus/groupId artifactIdsmbj/artifactId version0.13.0/version /dependency如果你用的是Gradle对应写法是implementation com.hierynomus:smbj:0.13.02.2 SMBJ连接参数里容易被忽略的细节SMBJ连接远程共享的方式跟我最早想象的不太一样。它不是直接“打开一个URI”而是要经过两步先跟目标主机的445端口建立会话再用会话去连接具体的共享名。看一段最基础的连接代码SMBClient client new SMBClient(); try (Connection connection client.connect(192.168.1.10)) { AuthenticationContext auth new AuthenticationContext( username, password.toCharArray(), DOMAIN // 域参数单机环境填空串即可 ); Session session connection.authenticate(auth); try (Share share session.connectShare(shared)) { // 到这里就拿到了共享对象后续操作都基于它 } }这里面有个关键点client.connect(host)并不需要指定端口默认就是445端口。如果远程主机改过端口SMBJ也提供了connect(String host, int port)重载方法可以自定义。认证这一行有几个容易踩的坑域参数用大写还是小写SMBJ不强制但要跟目标主机的实际配置一致。单机共享通常传空串就行。密码是char[]不是String很多人抄老代码时容易写错。连接对象和会话对象都实现了AutoCloseable一定要用try-with-resources否则连接泄漏会让你后面的批量任务突然卡死。我早期写生产代码时把所有连接放在一个静态工具类里只创建一次SMBClient实例复用它去连接所有主机。这里要注意SMBClient本身是线程安全的吗严格说单连接操作时多线程共享同一个Connection并不安全官方建议是每个线程持有独立的Connection。后面讲并行复制时我会给出一套安全方案。3. 核心代码递归复制目录树的完整实现连接通了剩下就是目录遍历和文件复制的逻辑。这个环节最容易翻车的是细节而不是框架。我写第一版的时候想当然地以为拿到Share之后可以把路径拼成字符串直接创建文件结果折腾了半天才明白SMBJ里目录和文件的真正句柄是DiskEntry列举目录要用Directory对象而且遍历时拿到的每一项都只有文件名entry.getFileName()完整路径需要自己拼接。3.1 从共享路径到SRC根目录的定位假设远程共享名是data你要复制的是共享里reports/2025/09这个子目录。那么第一步不是直接以reports/2025/09为参数去打开目录而是要一层层打开String[] pathParts reports/2025/09.split(/); DiskShare diskShare (DiskShare) share; Directory currentDir null; for (String part : pathParts) { if (currentDir null) { currentDir diskShare.openDirectory(part); } else { currentDir currentDir.openDirectory(part); } }这段代码的问题在于openDirectory打开的是一个目录句柄用完后要关闭而且在循环里打开深层层级时如果中间某个目录不存在会抛出SmbException。所以正常情况下我会先写一个辅助方法来判断目标目录是否存在或者干脆让调用方保证路径有效。实际业务中更稳妥的写法是一次性用openDirectory打开整个相对路径因为SMBJ的openDirectory(String path)是支持多级路径的Directory rootDir diskShare.openDirectory(reports/2025/09);它内部会按照分隔符逐级向下解析不用你自己循环。只有在需要逐级处理权限、断点定位等特殊场景下才需要一级级打开。3.2 复制单个文件流式传输而不是一次性读入内存复制文件的核心逻辑很简单把远程文件打开成输入流把本地目标文件打开成输出流然后以缓冲区循环搬运。缓冲区大小我用64KB既不显得碎片也不会占用太多堆内存。private static void copyFile(DiskShare share, String remotePath, Path localTarget) throws IOException { try (InputStream in share.openInputStream(remotePath)) { // 目标目录必须事先创建好 Files.createDirectories(localTarget.getParent()); try (OutputStream out new FileOutputStream(localTarget.toFile())) { byte[] buffer new byte[65536]; int len; long total 0; while ((len in.read(buffer)) 0) { out.write(buffer, 0, len); total len; } System.out.println(已复制: remotePath - localTarget ( total bytes)); } } }注意几点share.openInputStream(remotePath)要求传入的是共享根目录下的相对路径不允许以/开头。这是SMBJ的约定配合DiskShare.getBasePath()的输出就能比较好地理解它的路径语义。输出流用的是普通的java.nio/java.io因为目标端通常是本地目录。如果目标端也是共享那就两端口都用SMBJ对称处理。进度日志在批量复制时非常有用尤其是复制的文件都比较大时能看到卡在哪一个文件。3.3 递归遍历目录小心死循环和句柄泄漏目录遍历用递归是最直观的。但有两个必须注意的问题符号链接SMB共享的目录里可能有符号链接指向上层目录盲目递归会形成环。遍历时可以通过entry.getFileInformation()判断是否为符号链接遇到就跳过或单独记录。句柄泄漏每打开一个Directory都必须在用完后关闭否则长期运行会句柄耗尽。用try-with-resources是最简单可靠的姿势。给一个完整的递归复制实现private static void copyDirectoryRecursively(DiskShare share, String remoteDir, Path localDir) throws IOException { Files.createDirectories(localDir); try (Directory directory share.openDirectory(remoteDir)) { ListFileIdBothDirectoryInformation entries directory.list(); // 列举所有条目 for (FileIdBothDirectoryInformation entry : entries) { String fileName entry.getFileName(); // 注意list()会返回 . 和 ..必须跳过 if (..equals(fileName) || ...equals(fileName)) { continue; } String childRemote remoteDir / fileName; Path childLocal localDir.resolve(fileName); if (entry.getFileAttributes().isDirectory()) { copyDirectoryRecursively(share, childRemote, childLocal); } else { // 普通文件 copyFile(share, childRemote, childLocal); } } } }这里有个很重要的细节entry.getFileAttributes().isDirectory()判断的是远程文件属性而不是本地路径是否存在。不要用Files.isDirectory(childLocal)去判断因为目标文件可能还不存在。还有一点directory.list()返回的列表一次性全部加载到内存。如果某个共享目录下有几十万个文件这个列表会占不少堆内存。面对海量小文件的场景可以考虑用list(FileFilter)过滤掉隐藏文件或者用分页列举的方式分批处理避免内存压力过大。后面第5章我会专门讲并行和增量的优化。4. 我在生产环境踩过的坑连接、编码还有那些隐藏文件这一部分要分享的东西都是我在真实项目里碰到、调试、最后解决掉的不是从文档里抄出来的。你知道文档不会告诉你什么文档不会告诉你“明明用户名密码都对但就报拒绝访问”文档不会告诉你“Windows共享的隐藏目录列举出来却看不到”文档更不会告诉你“程序跑得好好的第二天突然连接超时”。4.1 连接数耗尽SMBConnectionException的漫长排查第一次遇到这个问题是跑一个每天凌晨同步共享文件夹的定时任务。前两周一切正常第三周某天突然报错日志里全是SMBConnectionException底层是Connection reset by peer。我当时的直觉是网络问题或者目标主机的防火墙做了限制。排查了大半天最后才想到是共享连接没关干净。原因很简单早期代码里我在copyFile方法内部又创建了一个新的SMBClient实例去连接同一个共享但只关了输出流和输入流没有关Session和Connection。远程Windows主机对每个会话有并发连接数限制超了就拒绝新的连接请求。修复方法很粗暴也很有用统一在一个连接生命周期内完成所有复制操作不要让单个文件复制方法去创建新连接。也就是说连接创建放在最外层方法里递归复制始终复用同一个Connection和Session只按需开关Directory和InputStream。这样做之后连接数从“文件数×2”降到了“任务数×1”问题彻底消失。4.2 中文文件名乱码一次让人抓狂的编码问题共享文件夹里有大量中文文件名比如“报告-2025-09.xlsx”。程序跑完一看本地文件名变成了“??-2025-09.xlsx”或者“鎶ュ憡-2025-09.xlsx”标准的乱码。我一开始以为是代码里用了错误的字符集做转换检查了半天发现根本没有做任何编码转换——SMBJ读取文件名返回的是Java的String内部是UTF-16。那问题出在哪最后用一个最小复现程序做实验发现直接用SMBJ列举文件名、打印到控制台是完全正常的只是写到Windows本地目录时变了样。真正的原因程序运行在Linux服务器上本地文件系统默认字符集是UTF-8这没有问题但代码里创建Path对象时如果用了系统默认字符集而某些历史遗留目录的文件名实际上是GBK编码在磁盘上两边对不上。说白了这是源端共享里的文件名本身就是GBK字节序列SMBJ按UTF-8解析出来就成了乱码。问题的本质不在SMBJ而在源端文件的原始编码。处理办法如果文件名的编码不是UTF-8需要在获取文件名后用new String(oldName.getBytes(StandardCharsets.UTF_8), Charset.forName(GBK))这类方式做一次字节级重新解释。但说实话现代Windows共享的文件名基本都是Unicode存储这种情况越来越少见。真遇到了先确认源端文件系统是不是老旧的FAT分区或者是不是从Linux的Samba共享里用错误的挂载参数挂出来的。4.3 隐藏的符号链接差点把整个根目录复制了一遍这是我印象最深的一次事故。某客户共享目录下有个“快捷方式”式的目录链接指向共享根目录本身。程序一运行递归函数在遇到这个符号链接时getFileAttributes().isDirectory()返回的是true于是傻乎乎地继续往里钻一次复制任务把共享根目录下的所有内容按照死循环方式反复复制目标磁盘差点被塞满。SMBJ的FileDirectoryInformation.getFileAttributes()里有一个FILE_ATTRIBUTE_REPARSE_POINT标记对应Java层面可以通过entry.getFileAttributes().isReparsePoint()判断。在处理递归时凡遇到isReparsePoint() true的条目一律跳过并记录到一个日志文件里宁可少复制也不要跑出死循环。修正后的递归逻辑加了一行判断if (entry.getFileAttributes().isReparsePoint()) { System.out.println(跳过符号链接: childRemote); continue; }这行代码看似不起眼但我在后来的所有SMB遍历代码里都默认带上已经成了不可省略的防御性写法。4.4 文件被占用InputStream只有机会读一半Windows共享文件的另一个特点是可能被其他用户正在打开甚至正在写入。当你尝试用SMBJ打开一个被独占锁定的文件时有概率会收到SmbException但也有概率能正常打开却只读到一部分字节。最保险的校验手段复制完成后对比远程文件大小与本地文件大小不一致就重试或记录为失败。远程文件大小可以从列举条目时的entry.getEndOfFile()拿到一次列举出来就一并记录避免额外请求。我把这个校验逻辑封装成一个简单工具方法private static boolean isCopyComplete(long remoteSize, Path localFile) throws IOException { return Files.exists(localFile) Files.size(localFile) remoteSize; }在批量复制结束后把所有失败项写到failures.txt里下次同步任务可以读取该文件做增量重试。这个物美价廉的“幂等”设计救过我好几次。5. 更进一步并行复制、增量同步与断点续传如果你只是复制几个文件前面那套递归代码够用了。但真实业务里动不动就是几百GB、几十万个文件的共享目录单线程复制速度很难让人满意。这一节我分享几个经过生产验证的增强方案。5.1 并行复制的安全和性能平衡并行最容易想到的做法是引入线程池把copyFile做成任务丢进ExecutorService。但要小心SMBJ的DiskShare对象不是线程安全的同一个DiskShare同时被多个线程调用文件读取可能触发“channel closed”异常。我的做法是每个工作线程持有自己的Connection和Share实例线程池只负责调度任务任务内部自行建立并管理SMB连接。示例逻辑如下ExecutorService pool Executors.newFixedThreadPool(config.threadCount()); ListRemoteFileInfo files collectAllFiles(share, rootDir); for (RemoteFileInfo file : files) { pool.submit(() - { // 这里每个任务创建自己的SMBClient, Connection, Share try (SMBClient client new SMBClient(); Connection connection client.connect(host)) { // ... copyFileWithRetry(connection, auth, file); } }); }线程数我不是拍脑袋定的而是调试后总结的经验CPU密集的复制任务线程数设为2到4比较稳妥。原因在于SMB传输瓶颈通常是网络带宽不是CPU过大的线程数只会增加连接占用和协议握手开销。假设网络带宽是1Gbps单线程实测复制速度约200MB/s已经很快了此时再加线程并不会线性提速反而会导致共享端会话争抢。如果网络是100Mbps两个线程就足够填满带宽了。5.2 增量同步比较修改时间与文件大小对定时同步类任务来说全量复制每次都扫一遍几十万文件既慢又浪费流量。增量同步的思想很简单本地已有文件的尺寸和修改时间跟远程一致就跳过。SMBJ的FileIdBothDirectoryInformation里有getEndOfFile()和getChangeTime()/getLastWriteTime()字段这两个数据足够做增量判断。代码如下private static boolean needCopy(FileIdBothDirectoryInformation remoteEntry, Path localFile) { if (!Files.exists(localFile)) { return true; } try { long remoteSize remoteEntry.getEndOfFile(); long localSize Files.size(localFile); if (remoteSize ! localSize) { return true; } long remoteMTime remoteEntry.getLastWriteTime().toEpochMillis(); long localMTime Files.getLastModifiedTime(localFile).toMillis(); // 给一个两秒的容差避免一些文件系统时间精度差异导致误判 return Math.abs(remoteMTime - localMTime) 2000; } catch (IOException e) { return true; // 获取失败时保守处理重新复制 } }这段代码在执行时要比一次性传输几万个小文件快上十倍甚至几十倍。唯一要注意的是不同文件系统的时间精度不一样NTFS精确到100纳秒Ext4精确到纳秒但Java读取FAT32挂载的本地目录时可能精度只到2秒所以容差必须留。5.3 断点续传先写临时文件再改名复制几十GB的大文件中途网络闪断是常有的事。全量重来会让人崩溃。断点续传的一个经典做法先在目标目录写一个带后缀.part的临时文件记录已复制的字节数下次任务开始时查看临时文件的大小用SeekableByteChannel跳过去继续写。由于SMBJ的InputStream本身支持从任意位置开始读取吗不一定openInputStream方法不提供偏移量参数。但SMBJ还有一个更底层的openFile方法可以返回一个RandomAccessFile风格的File对象或者使用DiskShare.openFile(path, AccessMask, Attributes, ShareAccess)配合FileRange读取。具体做法是打开文件后先调用file.read(buffer, offset)方式按偏移量读取。Java代码里最通用的做法还是本地临时文件用RandomAccessFile打开远程文件用share.openInputStream打开先从远程流里跳过已传输的字节数再继续写。大文件场景下跳过的操作虽然会消耗一些带宽但比重传整个文件快得多。为了不过度复杂化我实际生产里用的是“伪断点”默认每次都从源头复制但在本地用Files.move(..., ATOMIC_MOVE)把.part文件改名为正式文件。这样即使复制中断下次任务最多重新复制一个完整的文件而不是面临“两个半截文件如何合并”的难题。5.4 失败重试与退避策略分布式环境里SMB连接出现瞬时故障非常正常。简单的重试机制就能把失败率大幅降低。我的重试模板是对每个文件最多重试3次每隔1s、2s、4s递增等待时间也就是指数退避避免集中重试把共享端打挂。private static void copyFileWithRetry(...) throws IOException { int maxRetry 3; int delayMs 1000; for (int i 1; i maxRetry; i) { try { copyFile(...); return; } catch (SmbException e) { if (i maxRetry) { throw e; } System.out.println(第 i 次复制失败等待 delayMs ms后重试); Thread.sleep(delayMs); delayMs * 2; } } }注意Thread.sleep被中断时要恢复中断状态不要在循环里吞掉中断信号。这个模板贵在简单没有引入额外的重试框架但解决了我大量的偶发问题。6. 跨环境细节Windows、Linux、NAS之间的路径差异同样是“复制远程共享文件夹”运行环境不一样容易踩的坑也不一样。这一节算是我在各个环境间切换时的经验汇总按几种常见组合列个对照表源端共享类型目标端类型需要注意的点Windows共享Linux本地目录Linux目标路径大小写敏感文件名里的中文编码要统一确认Windows共享Windows本地目录目标路径用Path对象没问题注意当前用户是否有目标目录写权限Samba/NAS共享Linux本地目录Samba默认可能使用UTF-8NAS型号不同会有差异确认共享协议版本Windows共享另一个Windows共享两端都走SMBJ对称处理连接管理要格外小心两边都要关闭Linux挂载的CIFS共享Linux本地目录此时源端已变成本地路径可以用纯java.nio处理不经过SMBJ还有一个经常被忽略的问题SMBJ运行在Windows上时本地路径分隔符是反斜杠\但远程相对路径按规范必须用正斜杠/拼接。如果你把remotePath用File.separator拼Windows上会拼成reports\2025\09SMBJ极有可能解析不了。这是我的血泪教训因此所有涉及SMBJ的路径拼接我统一使用String.join(/, parts)。如果你是在Linux服务器上跑Java进程目标端写本地文件时Files.createDirectories和Files.newOutputStream传Path即可完全不需要考虑反斜杠问题。但要注意Linux文件系统的权限位SMB共享复制过来的文件默认权限是rw-r--r--如果你的后续程序需要写这些文件记得在创建时指定权限例如Files.setPosixFilePermissions(path, EnumSet.of(OWNER_READ, OWNER_WRITE, GROUP_READ))。另外目标磁盘空间不足也是生产环境的高频事故。我会在复制前先获取共享目录的总大小遍历所有条目累加getEndOfFile()再和目标路径的剩余空间对比空间不足时直接终止复制并报警。这比复制到一半报“磁盘已满”友好得多。7. 一个打包好的完整同步工具类拿来就能改着用前面讲了不少碎片化片段最后我把自己常用的一套代码结构整理出来。它不是最优美的设计但胜在直白、可复制、可改。整个流程只有三步连共享、遍历收集文件列表、逐批复制。public class SharedFolderSync { private final String host; private final String username; private final String password; private final String domain; private final String shareName; public SharedFolderSync(String host, String username, String password, String domain, String shareName) { this.host host; this.username username; this.password password; this.domain domain; this.shareName shareName; } public void sync(String remoteRoot, Path localRoot, int threadCount) throws IOException { ListRemoteFileInfo files new ArrayList(); collectFiles(remoteRoot, , files); ExecutorService pool Executors.newFixedThreadPool(threadCount); try { for (RemoteFileInfo info : files) { pool.submit(() - { try (SMBClient client new SMBClient(); Connection conn client.connect(host)) { Session session conn.authenticate( new AuthenticationContext(username, password.toCharArray(), domain)); try (Share share session.connectShare(shareName)) { DiskShare diskShare (DiskShare) share; Path target localRoot.resolve(info.relativePath); copyFileWithRetry(diskShare, info.remotePath, target); } } catch (Exception e) { System.err.println(复制失败: info.remotePath - e.getMessage()); } }); } } finally { pool.shutdown(); } } private void collectFiles(String root, String parent, ListRemoteFileInfo out) throws IOException { try (SMBClient client new SMBClient(); Connection conn client.connect(host)) { Session session conn.authenticate(new AuthenticationContext(username, password.toCharArray(), domain)); try (Share share session.connectShare(shareName)) { DiskShare diskShare (DiskShare) share; walk(diskShare, root, parent, out); } } } private void walk(DiskShare share, String currentDir, String relativeParent, ListRemoteFileInfo out) throws IOException { try (Directory dir share.openDirectory(currentDir)) { for (FileIdBothDirectoryInformation entry : dir.list()) { String name entry.getFileName(); if (..equals(name) || ...equals(name)) { continue; } if (entry.getFileAttributes().isReparsePoint()) { continue; } String relative relativeParent.isEmpty() ? name : relativeParent / name; String remotePath currentDir / name; if (entry.getFileAttributes().isDirectory()) { walk(share, remotePath, relative, out); } else { out.add(new RemoteFileInfo(remotePath, relative, entry.getEndOfFile())); } } } } private void copyFileWithRetry(DiskShare share, String remotePath, Path localTarget) throws IOException { int maxRetry 3; int delayMs 1000; for (int attempt 1; ; attempt) { try { Files.createDirectories(localTarget.getParent()); copyFile(share, remotePath, localTarget); return; } catch (IOException e) { if (attempt maxRetry) { throw e; } Thread.sleep(delayMs); delayMs * 2; } } } private void copyFile(DiskShare share, String remotePath, Path localTarget) throws IOException { try (InputStream in share.openInputStream(remotePath); OutputStream out Files.newOutputStream(localTarget)) { byte[] buffer new byte[65536]; int len; while ((len in.read(buffer)) 0) { out.write(buffer, 0, len); } } } static class RemoteFileInfo { final String remotePath; final String relativePath; final long size; RemoteFileInfo(String remotePath, String relativePath, long size) { this.remotePath remotePath; this.relativePath relativePath; this.size size; } } }这套代码的基础版足够跑通绝大多数简单场景。你拿到手之后最需要调整的是两个地方一是collectFiles阶段目前会单独建立一次垃圾连接文件量大时会多占一些时间可以改成从主连接里传DiskShare下去去掉重复认证二是异常处理目前只是打印日志生产环境建议接SLF4J并统计失败文件数方便后续告警。我自己在实际项目里用的版本会在RemoteFileInfo里多存一个lastWriteTime然后在copyFile之前先做增量判断这样每天定时同步时速度会快非常多。增量判断的代码在前面已经给过直接插到copyFileWithRetry的入口处即可。最后再分享一个我个人的操作习惯写完这类批量复制工具后不要急着全量跑先拿一个小目录做“试运行”打印出文件总数和总字节数确认路径拼写、权限、符号链接都没有问题再放开跑。数据迁移这种事谨慎永远不会过时。
阅读完成 · 觉得有帮助?