NTFS文件系统的USN Journal(Update Sequence Number Journal,更新序列号日志)是系统维护的一份记录所有文件和目录变更操作的二进制日志,它会记录文件创建、修改、删除、重命名等事件,每个变更记录都包含唯一的USN序号和文件路径信息。利用USN Journal实现文件同步,不需要遍历整个磁盘的文件,只需要读取上一次同步之后的增量变更记录,就能快速定位需要同步的文件,大幅降低IO开销和同步耗时。

USN Journal核心概念
USN Journal由NTFS卷维护,每个NTFS卷都有独立的USN Journal,其核心属性包括:
- UsnJournalID:日志的唯一标识,当日志被删除重建时该ID会变化
- FirstUsn:日志中第一条记录的USN序号
- NextUsn:下一条将要写入的记录的USN序号,也是当前日志的最大USN
- LowestValidUsn:当前有效的最低USN
每个变更记录包含USN、文件引用号、父目录引用号、变更原因、文件名等关键信息,其中变更原因字段可以标识出文件是创建、修改还是删除。
C#调用系统API准备
读取USN Journal需要调用Windows底层API,主要涉及kernel32.dll中的几个函数,首先需要在C#中声明这些函数的签名和相关的结构体:
using System;
using System.Runtime.InteropServices;
public class UsnJournalHelper
{
// 声明API函数和常量
public const uint GENERIC_READ = 0x80000000;
public const uint FILE_SHARE_READ = 0x00000001;
public const uint FILE_SHARE_WRITE = 0x00000002;
public const uint OPEN_EXISTING = 3;
public const uint FILE_FLAG_BACKUP_SEMANTICS = 0x02000000;
public const uint FSCTL_QUERY_USN_JOURNAL = 0x000900f4;
public const uint FSCTL_READ_USN_JOURNAL = 0x000900b7;
[DllImport("kernel32.dll", SetLastError = true, CharSet = CharSet.Auto)]
public static extern IntPtr CreateFile(
string lpFileName,
uint dwDesiredAccess,
uint dwShareMode,
IntPtr lpSecurityAttributes,
uint dwCreationDisposition,
uint dwFlagsAndAttributes,
IntPtr hTemplateFile
);
[DllImport("kernel32.dll", SetLastError = true)]
public static extern bool DeviceIoControl(
IntPtr hDevice,
uint dwIoControlCode,
IntPtr lpInBuffer,
uint nInBufferSize,
IntPtr lpOutBuffer,
uint nOutBufferSize,
out uint lpBytesReturned,
IntPtr lpOverlapped
);
[DllImport("kernel32.dll", SetLastError = true)]
public static extern bool CloseHandle(IntPtr hObject);
// USN Journal基本信息结构体
[StructLayout(LayoutKind.Sequential)]
public struct USN_JOURNAL_DATA
{
public long UsnJournalID;
public long FirstUsn;
public long NextUsn;
public long LowestValidUsn;
public long MaxUsn;
public long MaximumSize;
public long AllocationDelta;
}
// 读取USN Journal的入参结构体
[StructLayout(LayoutKind.Sequential)]
public struct READ_USN_JOURNAL_DATA
{
public long StartUsn;
public uint ReasonMask;
public uint ReturnOnlyOnClose;
public long Timeout;
public long BytesToWaitFor;
public long UsnJournalID;
}
// USN变更记录结构体
[StructLayout(LayoutKind.Sequential)]
public struct USN_RECORD
{
public uint RecordLength;
public ushort MajorVersion;
public ushort MinorVersion;
public long FileReferenceNumber;
public long ParentFileReferenceNumber;
public long Usn;
public long TimeStamp;
public uint Reason;
public uint SourceInfo;
public uint SecurityId;
public uint FileAttributes;
public ushort FileNameLength;
public ushort FileNameOffset;
}
}
获取USN Journal基本信息
首先需要打开目标NTFS卷,获取该卷的USN Journal基本信息,确认日志存在且可用:
public static USN_JOURNAL_DATA? GetUsnJournalData(string driveLetter)
{
// 打开卷,格式为 \.C: 对应C盘
string volumePath = $"\\.\{driveLetter}:";
IntPtr hVolume = UsnJournalHelper.CreateFile(
volumePath,
UsnJournalHelper.GENERIC_READ,
UsnJournalHelper.FILE_SHARE_READ | UsnJournalHelper.FILE_SHARE_WRITE,
IntPtr.Zero,
UsnJournalHelper.OPEN_EXISTING,
UsnJournalHelper.FILE_FLAG_BACKUP_SEMANTICS,
IntPtr.Zero
);
if (hVolume == IntPtr.Zero || hVolume.ToInt64() == -1)
{
Console.WriteLine($"打开卷失败,错误码:{Marshal.GetLastWin32Error()}");
return null;
}
try
{
// 查询USN Journal信息
uint bytesReturned;
USN_JOURNAL_DATA journalData;
IntPtr inBuffer = IntPtr.Zero;
int outBufferSize = Marshal.SizeOf(typeof(USN_JOURNAL_DATA));
IntPtr outBuffer = Marshal.AllocHGlobal(outBufferSize);
bool success = UsnJournalHelper.DeviceIoControl(
hVolume,
UsnJournalHelper.FSCTL_QUERY_USN_JOURNAL,
inBuffer,
0,
outBuffer,
(uint)outBufferSize,
out bytesReturned,
IntPtr.Zero
);
if (success)
{
journalData = (USN_JOURNAL_DATA)Marshal.PtrToStructure(outBuffer, typeof(USN_JOURNAL_DATA));
Marshal.FreeHGlobal(outBuffer);
return journalData;
}
else
{
Console.WriteLine($"查询USN Journal失败,错误码:{Marshal.GetLastWin32Error()}");
Marshal.FreeHGlobal(outBuffer);
return null;
}
}
finally
{
UsnJournalHelper.CloseHandle(hVolume);
}
}
读取增量变更记录
获取到基础信息后,可以指定上次同步的USN作为起始点,读取之后的所有变更记录,解析出需要同步的文件信息:
public static List<string> ReadUsnRecords(string driveLetter, long startUsn, long journalId)
{
List<string> changedFiles = new List<string>();
string volumePath = $"\\.\{driveLetter}:";
IntPtr hVolume = UsnJournalHelper.CreateFile(
volumePath,
UsnJournalHelper.GENERIC_READ,
UsnJournalHelper.FILE_SHARE_READ | UsnJournalHelper.FILE_SHARE_WRITE,
IntPtr.Zero,
UsnJournalHelper.OPEN_EXISTING,
UsnJournalHelper.FILE_FLAG_BACKUP_SEMANTICS,
IntPtr.Zero
);
if (hVolume == IntPtr.Zero || hVolume.ToInt64() == -1)
{
Console.WriteLine($"打开卷失败,错误码:{Marshal.GetLastWin32Error()}");
return changedFiles;
}
try
{
// 构造读取参数,ReasonMask设为0xFFFFFFFF表示获取所有变更类型
READ_USN_JOURNAL_DATA readData = new READ_USN_JOURNAL_DATA
{
StartUsn = startUsn,
ReasonMask = 0xFFFFFFFF,
ReturnOnlyOnClose = 0,
Timeout = 0,
BytesToWaitFor = 0,
UsnJournalID = journalId
};
int readDataSize = Marshal.SizeOf(typeof(READ_USN_JOURNAL_DATA));
IntPtr inBuffer = Marshal.AllocHGlobal(readDataSize);
Marshal.StructureToPtr(readData, inBuffer, false);
// 缓冲区大小设为64KB,可根据实际情况调整
int bufferSize = 64 * 1024;
IntPtr outBuffer = Marshal.AllocHGlobal(bufferSize);
uint bytesReturned;
bool success = UsnJournalHelper.DeviceIoControl(
hVolume,
UsnJournalHelper.FSCTL_READ_USN_JOURNAL,
inBuffer,
(uint)readDataSize,
outBuffer,
(uint)bufferSize,
out bytesReturned,
IntPtr.Zero
);
if (success)
{
IntPtr currentPtr = outBuffer;
// 跳过前8字节的USN,后面是变更记录数组
currentPtr = IntPtr.Add(currentPtr, 8);
int remainingBytes = (int)bytesReturned - 8;
while (remainingBytes > 0)
{
USN_RECORD record = (USN_RECORD)Marshal.PtrToStructure(currentPtr, typeof(USN_RECORD));
// 解析文件名
IntPtr fileNamePtr = IntPtr.Add(currentPtr, record.FileNameOffset);
string fileName = Marshal.PtrToStringUni(fileNamePtr, record.FileNameLength / 2);
// 拼接完整文件路径
string fullPath = $"{driveLetter}:\{fileName}";
changedFiles.Add(fullPath);
// 移动到下一条记录
currentPtr = IntPtr.Add(currentPtr, (int)record.RecordLength);
remainingBytes -= (int)record.RecordLength;
}
}
else
{
Console.WriteLine($"读取USN记录失败,错误码:{Marshal.GetLastWin32Error()}");
}
Marshal.FreeHGlobal(inBuffer);
Marshal.FreeHGlobal(outBuffer);
}
finally
{
UsnJournalHelper.CloseHandle(hVolume);
}
return changedFiles;
}
实现增量文件同步
结合上述方法,就可以实现完整的增量同步逻辑,核心步骤如下:
- 首次同步时记录当前卷的NextUsn作为下次同步的起始点
- 后续同步时传入上次记录的起始USN,读取增量变更记录
- 根据变更记录的文件路径和变更类型,执行对应的同步操作
- 同步完成后更新记录的起始USN为当前最大USN
以下是简单的同步示例:
class Program
{
static void Main(string[] args)
{
string driveLetter = "C";
// 假设上次同步的USN是0,首次同步会获取所有变更记录
long lastSyncUsn = 0;
// 获取USN Journal基本信息
var journalData = GetUsnJournalData(driveLetter);
if (journalData == null)
{
Console.WriteLine("无法获取USN Journal信息,请确认目标盘是NTFS格式");
return;
}
// 如果是首次同步,从FirstUsn开始读取
if (lastSyncUsn == 0)
{
lastSyncUsn = journalData.Value.FirstUsn;
}
// 读取增量变更文件
var changedFiles = ReadUsnRecords(driveLetter, lastSyncUsn, journalData.Value.UsnJournalID);
Console.WriteLine($"共发现{changedFiles.Count}个变更文件:");
foreach (var file in changedFiles)
{
Console.WriteLine(file);
// 这里可以添加实际的同步逻辑,比如复制到目标目录、上传到服务器等
}
// 更新下次同步的起始USN
Console.WriteLine($"下次同步起始USN:{journalData.Value.NextUsn}");
}
}
注意事项
- 只有NTFS格式的卷才支持USN Journal,FAT32、exFAT等格式无法使用
- USN Journal有大小限制,当日志写满后旧记录会被覆盖,因此同步间隔不能太长,否则可能丢失变更记录
- 读取USN Journal需要管理员权限,否则会打开卷失败
- 文件引用号是文件的唯一标识,即使文件重命名,引用号也不会变化,可以通过引用号关联同一个文件的不同变更记录
C#NTFS_Change_JournalUSN_Journal文件同步修改时间:2026-06-10 04:06:53