通过读取USN日志监控文件变动

之前我曾在借助计划任务执行需要管理员权限运行的程序里提过,要用读取USN日志来实现对文件变动的监控。之后实现了初版的监控,并断断续续迭代了几个月,修了不少bug。现在功能算是稳定了,在这记录一下,也分享一下遇到的坑,希望能帮到其他人。

USN日志

简介

官方文档戳这里
简单说就是NTFS格式的磁盘上可以开启USN日志功能,记录所有文件/文件夹的文件变动,包括增加删除移动重命名、属性变化、内容变化等。
USN日志中包含一条一条的USN记录,这些记录可以通过 update sequence number (USN) 进行查找。
不同版本的USN日志中存储的USN记录结构体并不相同(USN_RECORD_V2/USN_RECORD_V3/USN_RECORD_V4)。但都有下列属性:

  • FileReferenceNumber: 文件ID
  • ParentFileReferenceNumber: 父文件ID
  • Usn: USN
  • TimeStamp: 时间戳
  • Reason: 变动原因
  • FileAttributes: 文件属性
  • FileNameLength、FileNameOffset、FileName: 文件名

基础实现

Windows提供了两种方式来读取USN日志记录

  1. FSCTL_ENUM_USN_DATA 枚举两个USN之间的所有USN记录。
  2. FSCTL_READ_USN_JOURNAL 读取指定USN之后满足条件的USN记录

可以参考官方例子walking-a-buffer-of-change-journal-records

监控实现

整体方案

  1. 使用CreateFile获得一个磁盘卷的句柄(该操作需要UAC权限)
  2. FSCTL_CREATE_USN_JOURNAL创建/更新USN日志,得到执行FSCTL_CREATE_USN_JOURNAL时刻T0,当前USN日志第一条记录的USN FirstUsn和下一次记录插入时的USN NextUsn。在已经有USN日志的磁盘卷上执行该操作,会按照传入的参数,重新剪裁日志。
  3. FSCTL_ENUM_USN_DATA枚举FirstUsnNextUsn之间的所有USN记录。这些记录对应了T0时,磁盘卷上存在的文件/文件夹。可以通过USN记录中的文件ID、父文件ID、文件名来构建文件树结构FileTree
  4. 反复执行FSCTL_READ_USN_JOURNAL 获取NextUsn之后的所有USN记录。对每一条记录,根据文件ID/父文件ID,在FileTree中获取完整路径。并根据Reason更新FileTree,并生成对应的文件变动事件。

需要注意的坑

  • 只处理Reason包含USN_REASON_CLOSE时的USN记录。

  • 单条USN记录的Reason可能同时包含USN_REASON_FILE_DELETE / USN_REASON_RENAME_NEW_NAME / USN_REASON_FILE_CREATE。目前我们的实现是优先判断USN_REASON_FILE_DELETE,然后判断USN_REASON_RENAME_NEW_NAME, 最后判断USN_REASON_FILE_CREATE。

  • 理论上可以在执行FSCTL_READ_USN_JOURNAL时带上过滤条件,只获取包含USN_REASON_CLOSE时的USN记录。
    但我们的客户反馈,部分使用Transactional NTFS (TxF)的软件(比如Word的某几个版本)在执行删除A -> B移动到A操作时,FSCTL_READ_USN_JOURNAL获取到记录的顺序为B移动到A -> 删除A。导致上层逻辑认为实际操作为B文件移动到A位置之后被删除了。而对应的Reason为USN_REASON_RENAME_OLD_NAME的记录到达顺序则是准确的。最终我们选择记录Reason为USN_REASON_RENAME_OLD_NAME的记录对应的文件ID,并在更新FileTree和生成文件变动事件时,严格按照USN_REASON_RENAME_OLD_NAME到达顺序来处理USN_REASON_RENAME_NEW_NAME记录。

  • FSCTL_ENUM_USN_DATA在文件量较多、磁盘速度较慢的情况下,需要花费较长的时间执行。这会导致:

    1. 如果每次启动监控都执行一次FSCTL_ENUM_USN_DATA,则需要等待很长的时间才能真正开始工作。

    2. FSCTL_READ_USN_JOURNAL开始读取的一部分USN记录延迟较大。

      目前我们的做法是:

    • 将文件树存入sqlite3 db中缓存起来,并记录对应的USN日志ID和最后一次执行FSCTL_READ_USN_JOURNAL时返回的NextUsn
    • 监控启动时判断USN日志ID是否和db中记录的一致。NextUsn是否大于等于db中记录的值。如果一致,则跳过执行FSCTL_ENUM_USN_DATA,以db中的NextUsn作为执行FSCTL_READ_USN_JOURNAL时传入的初始值。否则清空DB,按照正常流程执行FSCTL_ENUM_USN_DATA。
    • FSCTL_ENUM_USN_DATA执行完成后,先执行FSCTL_READ_USN_JOURNAL,只获取包含USN_REASON_CLOSE时的USN记录,并更新FileTree。直到读取不到USN记录,或者USN记录的触发时间与当前时间的间隔已经小于1秒。之后再执行正常的FSCTL_READ_USN_JOURNAL流程,开始生成文件变动事件。
  • USN_REASON_OBJECT_ID_CHANGE的解释为The object identifier of a file or directory is changed.,但似乎不能作为文件ID变化的依据。

  • NTFS的树状结构中,磁盘卷根目录并不是文件树的根节点。存在部分节点,他们的父文件ID并不在FSCTL_ENUM_USN_DATA构造的文件树中。遇到这种case,认为这些节点在磁盘卷的根目录下即可。

  • 执行FSCTL_READ_USN_JOURNAL过程中如果删除USN日志,则FSCTL_READ_USN_JOURNAL会返回错误ERROR_INVALID_PARAMETERERROR_JOURNAL_ENTRY_DELETED,需要根据对应的业务逻辑,重新执行FSCTL_CREATE_USN_JOURNAL生成新的USN日志,或者结束监控。