一、文件同步介绍

作为数据安全的保障手段,RAID属于最底层的方案之一,通过冗余的设备确保数据健壮性(只是提供健壮性,但和数据备份是完全不同的两件事,并且RAID0在单独使用的时候,不仅无法提供健壮性,反而会放大数据损坏的风险)。

在系统运行阶段,数据备份则是数据安全的重要补充手段。

要实现文件同步,需要解决两个问题:触发、传输。触发是指达到某一传输条件,既可以是对被监视文件/目录(源位置)发生的变化(创建、删除、修改)进行监测,也可以在不具备监测条件或者不要求实时性的情况下使用定时策略作为触发条件;传输是指检测到被监视文件/目录(源位置)发生变化后,将这一变化传递到目标位置(目的地)。

备份目标按所处位置可以分为:本地设备的其它存储、局域网其它设备、异地设备,速度最快为本地同步,其次局域网同步,最慢为异地同步。设计备份方案的时候也应当考虑这一点。

当然,也不是所有场景都适用文件直接同步的方案,比如一直变化的大文件(文件同步动作跟不上文件变化)。

二、Windows和Linux之间

Windows向Linux同步可以考虑使用WinSCP这个工具,这个软件提供“保持远端文件夹最新”(本地->远端)功能和“同步”(本地->远端,远端->本地)功能。

1. Windows向Linux同步(实时)

配置很简单,选择Commands菜单下的Keep Remote Directory up to Date菜单项即可打开设置界面,按需配置即可(传输模式、权限设置、用于同步对象筛选的文件掩码等等)。一般而言,在同步选项中,勾上”Delete files”、”Update subdirectories”,其它设置保持默认即可进行实时全目录同步。

在建立同步任务的时候,首先会进行一次完全同步,然后就会根据被监视目录的变化实时同步到远端。

2. Linux向Windows同步(非实时,定时)

简单进行单次文件夹同步(上传或下载)可以使用winscp软件,鼠标一拖就完事。如果希望定时自动进行同步,则需要创建定时任务,定期运行同步命令。如果希望更进一步,实现实时同步就需要使用其它软件进行辅助了(也可以使用共享目录挂载过来,然后监控挂载目录的文件变化,不过这种方案适用场景比较受限)。

传输功能可以借助winscp,也可以借助putty的pscp,稍微新一点的windows10也自带了openssh。试举几例:


#windows 10自带openssh
scp -i %userprofile%\desktop\id_rsa.key -r  root@abc.com:/opt/backup/* C:\lab\backup\opt

#putty的pscp命令
pscp -i %userprofile%\desktop\id_rsa.ppk -r  root@abc.com:/opt/backup/* C:\lab\backup\

#winscp命令,local代表远端到本地,remote代表本地到远端,both代表双向
winscp.exe /console /command "open xin" "synchronize local -delete C:\lab\backup /opt/test" "exit"

三、Linux之间

定时备份可以参考第一节的Linux向Windows同步,实现Linux之间的同步可以借助scp命令(或者rsync命令,选择比较多),把同步命令写到crontab即可,比如每天凌晨进行数据库备份,这就是一种常见场景。

如果希望实时同步,需要结合文件变化监测来作为触发条件,比如Inotify,同时结合rsync,一旦触发即进行文件同步。决定是否可以实时同步最关键的因素就是能否对文件变化进行实时监测(挖个坑,暂时没有Linux下的这个需求,等有空再写吧)。

四、Windows之间

Windows跨机同步最大的问题就是没有像ssh这样的协议支持,所以远程复制一般会挂载远程共享到本地,然后进行本地到本地之间的目录同步(Windows到Linux之间也可以使用这个方法)。

Windows系统可以使用微软的官方工具SyncToy来实现同步或备份需求(由于它依赖.net framework 3.5,所以windows8.1或更新的系统需要启用对应的功能。

安装不提,只需要一直下一步即可。安装完成后,创建一个目录对(源目录、目标目录),选择合适的方向和模式并保存。如果有多个目录需要同步,可以重复添加新的目录对。

SyncToy的同步模式分为三种:Synchronize(双向)、Echo(左到右,同步所有操作,包含删除和重命名)、Contribute(左到右,不同步删除动作)。

SyncToy在创建了目录对以后,除了在GUI中手动执行任务,也可以在命令行中用命令来对指定的目录对进行同步,不过缺点是无法实时监控。

借助WinSCP倒是可以做到实时同步,不过需要额外架设FTP服务,然后利用winscp登录ftp,再按第二节中Windows向Linux自动同步的方案来部署,即可实现windows之间的实时同步。

五、Windows下的文件/目录监测功能开发

这个小节将造一个简陋的轮子,实现Windows之间自动同步功能。

.Net提供了一个名为FileSystemWatcher的类,可以监视指定目录的所有操作或变化。同时,将共享目录挂载到本地,一旦监测到目录发生变化,立即将变化同步到目标文件夹。这样就实现了文件的实时同步。

监视目录设置为本地,操作目录设置为挂载过来的共享文件,可以实现本地->远端方向的同步;如果反过来,则可以实现远端向本地同步。

先来个FileSystemWatcher的测试


const string localDir = @"C:\Wallpapers\Cats";//本地目录
const string remoteDir = @"Y:\wallpapers\cats";//远端共享挂载到本地
FileSystemWatcher fsw = new(localDir)
{
    IncludeSubdirectories = true,
    EnableRaisingEvents = true,
    NotifyFilter =
    NotifyFilters.CreationTime
    | NotifyFilters.DirectoryName
    | NotifyFilters.FileName
    | NotifyFilters.LastWrite
};
fsw.Changed += (s, o) =>
{
    Console.WriteLine($"CHANGED_{o.FullPath}\t{o.ChangeType}");
};
fsw.Created += (s, o) =>
{
    Console.WriteLine($"CREATED_{o.FullPath}\t{o.ChangeType}");
};
fsw.Deleted += (s, o) =>
{
    Console.WriteLine($"DELETED_{o.FullPath}\t{o.ChangeType}");
};
fsw.Renamed += (s, o) =>
{
    Console.WriteLine($"RENAMED_{o.OldFullPath}\t{o.FullPath}\t{o.ChangeType}");
};

关于这个类的属性,NotifyFilter用于配置监控哪些属性(创建时间、修改时间、文件名、目录名、文件大小),IncludeSubdirectories用于设置是否监视子目录。

从输出结果可以看到,小文件在发生变化的时候,只会触发一次Changed,但是文件体积较大的时候会触发两次,所以为了避免同一次修改触发的多次Changed事件,此处应当考虑去重。

因为文件创建的时候有可能会跟着一个甚至数个Changed,所以文件创建动作需要做判断再进行处理,有Changed时需要忽略Created,没有Changed时才可以直接同步。而Renamed和Deleted不存在多次触发的问题。

其次,文件夹在子目录或者子文件发生变化时,同步触发Changed,但是文件夹在触发Changed时并不需要对这个目录做任何同步操作(不需要重命名,不需要删除,无可同步)。

另外,文件夹复制对应的操作,是一整套Created事件的集合:先是根目录,其次是文件,按触发的动作依次同步到目标目录即可,因为每个子文件夹或子文件的动作都能捕获到,所以不需要手工写一遍递归复制。

最后再整理一下我们需要监控的范围:文件创建、修改、重命名、删除,目录创建、修改、重命名。对应的策略如下:

对象操作Action策略
文件删除Deleted直接同步
文件重命名Renamed直接同步
文件修改Changed去重再同步
文件创建Created有Changed则等Changed,否则直接同步
目录删除Deleted直接同步
目录重命名Renamed直接同步
目录修改Changed无可同步,忽略
目录创建Created直接同步

fsw.Changed += (s, o) =>
{
    //如果是文件夹,忽略
    //如果是文件,需要对事件去重
    Console.WriteLine($"CHANGED_{o.FullPath}\t{o.ChangeType}");
};
fsw.Created += (s, o) =>
{
    //如果是文件,需要判断是否有Changed动作
    //如果是目录,直接同步操作
    Console.WriteLine($"CREATED_{o.FullPath}\t{o.ChangeType}");
};
fsw.Deleted += (s, o) =>
{
    //直接同步操作
    Console.WriteLine($"DELETED_{o.FullPath}\t{o.ChangeType}");
};
fsw.Renamed += (s, o) =>
{
    //直接同步操作
    Console.WriteLine($"RENAMED_{o.OldFullPath}\t{o.FullPath}\t{o.ChangeType}");
};

其实只有文件的创建和修改比较麻烦,因为多次触发的原因,需要做去重处理。思路可以粗暴一点:每次触发,把文件名添加到List里,并重置计时,在一个新线程里起一个循环任务,一旦发现计时超过50ms就处理当前List并清空。

最终代码:


const string localDir = @"C:\Wallpapers\Cats";//本地目录
const string remoteDir = @"Y:\wallpapers\cats";//远端共享挂载到本地
if (!Directory.Exists(remoteDir)) Directory.CreateDirectory(remoteDir);
List<string> tasks = new();
/// <summary>
/// 50ms后才做动作
/// </summary>
DateTime Counter = DateTime.Now;
Console.WriteLine("是否全量同步一次?(确认请输入Y,任意其它键跳过)");
var input = Console.ReadKey();
if (input.Key == ConsoleKey.Y)
{
    SyncFolder(localDir);
    Console.WriteLine("全量同步完成");
}
else
{
    Console.WriteLine("跳过");
}
Console.WriteLine($"\r\n开始监控目录{localDir}");
FileSystemWatcher fsw = new(localDir)
{
    IncludeSubdirectories = true,
    EnableRaisingEvents = true,
    NotifyFilter =
    NotifyFilters.CreationTime
    | NotifyFilters.DirectoryName
    | NotifyFilters.FileName
    | NotifyFilters.LastWrite
};
fsw.Changed += (s, o) =>
{
    var local = o.FullPath;
    var remote = RemoteName(o.FullPath);
    //如果是文件夹,忽略
    //如果是文件,需要对事件去重
    if (!IsDirectory(local))
    {
        tasks.Add(local);
        Counter = DateTime.Now;
    }
    Console.WriteLine($"CHANGED_{o.FullPath}\t{o.ChangeType}");
};
fsw.Created += (s, o) =>
{
    var local = o.FullPath;
    var remote = RemoteName(o.FullPath);
    if (IsDirectory(local))
    {
        //如果是目录,直接同步操作
        Directory.CreateDirectory(remote);
    }
    else
    {
        //如果是文件,需要判断是否有Changed动作
        tasks.Add(local);
        Counter = DateTime.Now;
    }
    Console.WriteLine($"CREATED_{o.FullPath}\t{o.ChangeType}");
};
fsw.Deleted += (s, o) =>
{
    //直接同步操作
    var local = o.FullPath;
    var remote = RemoteName(o.FullPath);
    if (IsDirectory(remote))
    {
        //递归删除
        if (Directory.Exists(remote)) Directory.Delete(remote, true);
    }
    else
    {
        if (File.Exists(remote)) File.Delete(remote);
    }
    Console.WriteLine($"DELETED_{o.FullPath}\t{o.ChangeType}");
};
fsw.Renamed += (s, o) =>
{
    //直接同步操作
    var localOld = o.OldFullPath;
    var remoteOld = RemoteName(localOld);
    var localNew = o.FullPath;
    var remoteNew = RemoteName(localNew);

    //如果远端没有OldName,用新名字复制
    if (IsDirectory(localNew))
    {
        if (!Directory.Exists(remoteOld))
        {
            Directory.CreateDirectory(remoteNew);
        }
        else
        {
            Directory.Move(remoteOld, remoteNew);
        }
    }
    else
    {
        if (!File.Exists(remoteOld))
        {
            File.Copy(localNew, remoteNew, true);
        }
        else
        {
            File.Move(remoteOld, remoteNew);
        }
    }
    Console.WriteLine($"RENAMED_{o.OldFullPath}\t{o.FullPath}\t{o.ChangeType}");
};
Task.Run(() =>
{
    while (true)
    {
        if ((DateTime.Now - Counter).Milliseconds > 100)
        {
            if (tasks.Count > 0)
            {//去重
                var files = tasks.Distinct().ToArray();
                tasks.Clear();
                foreach (var file in files)
                {
                    File.Copy(file, RemoteName(file), true);
                }
            }
        }
    }
});
bool quit = false;
while (!quit)
{//Q键退出
    quit = Console.ReadKey().Key == ConsoleKey.Q;
}
static void SyncFolder(string dir)
{
    Console.WriteLine($"\t正在同步\t{dir}");
    //如果远端存在目录,忽略
    //目录不存在,创建
    string remote = dir.Replace(localDir, remoteDir);
    if (!Directory.Exists(remote))
    {
        Directory.CreateDirectory(remote);
    }

    foreach (var file in Directory.EnumerateFiles(dir))
    {
        //如果是文件,直接覆盖
        File.Copy(file, file.Replace(localDir, remoteDir), true);
    }

    //处理子目录
    foreach (var subdir in Directory.EnumerateDirectories(dir))
    {
        SyncFolder(subdir);
    }
}

static bool IsDirectory(string path) => Directory.Exists(path);
static string RemoteName(string path) => path.Replace(localDir, remoteDir);