Skip to content

在Windows上复制文件后,源文件的修改时间没有应用到目标文件。keywords: windows, mtime, modification time, modify time #6390

@pnqx

Description

@pnqx

What happened:

在Windows上复制文件后,源文件的“修改时间”没有应用到目标文件。也就是说,复制文件后,源文件和目标文件的“修改时间”会不同。

What you expected to happen:

复制文件后,源文件和目标文件的“修改时间”应该相同,以便各种备份软件和同步软件能够通过比较文件大小和“修改时间”快速比较文件而不用读取整个文件内容。

How to reproduce it (as minimally and precisely as possible):

在Windows上把juifs挂载到T:盘,然后用Windows资源管理器把某个文件从本地硬盘复制到T:盘,然后查看源文件和目标文件的“修改时间”。

目标文件的“修改时间”会有如下变化:

  1. 先变成当天的日期
  2. 然变成源文件的“修改时间”
  3. 最后变成当天的日期

使用以下挂载命令:

    juicefs mount --no-usage-report ^
        --buffer-size=4G ^
        --max-readahead=3G ^
        --cache-dir=H:\juicefs_cache\%JFS_NAME% ^
        --cache-size=29G ^
        --free-space-ratio=0.00 ^
        --backup-meta=24h ^
        --get-timeout=120s ^
        --put-timeout=120s ^
        --prefetch=2 ^
        --max-uploads=20 ^
        -o debug,volname=中国移动云盘_家庭云 ^
        --debug ^
        --fuse-trace-log=X:\juicefs\%JFS_NAME%_fuse.log ^
        "mysql://juicefs01:@(foo.com:3306)/juicefs01" ^
        T:

Anything else we need to know?

我已经排除的可能引发本问题的原因:

挂载为”网络驱动器“类型

WinFSP提供了2种挂载类型,分别为”本地硬盘“类型和”网络驱动器“类型,juicefs挂载的即为”网络驱动器“类型,我怀疑是这2种类型不同导致文件的“修改时间”没有保留。所以我编译了winfsp/cgofuse官方的示例程序 memfs,然后挂载为”网络驱动器“类型,结果 memfs 正确地保留了源文件的“修改时间”。

memfs的挂载命令为

memfs.exe -d -o volname=jfs03,ExactFileSystemName=JuiceFS,ThreadCount=16,DirInfoTimeout=1000,VolumeInfoTimeout=1000,KeepFileCache,FileInfoTimeout=1000,VolumePrefix=/juicefs/jfs03,debug,volname=中国移动云盘_家庭云,dothidden

其中的winfsp参数我用juicefs --debug打印出来然后复制给memfs用,确保参数尽量相同。

我用的memfs.go略有修改,主要是给内存盘加了1GB的容量,这样才能复制文件进去。

--atime-mode 参数的不同值

不管是 --atime-mode=noatime 还是 --atime-mode=strictatime 问题依旧存在。

我收集的日志

以下日志的收集时间不同,因为进行了多次试验。

winfsp的日志

以下是winfsp的一段连续的日志:

juicefs[TID=6bd4]: FFFFA983986E8800: >>Create [UT----] "\Freakonomics.epub", FILE_CREATE, CreateOptions=44, FileAttributes=20, Security=NULL, AllocationSize=0:81000, AccessToken=0000000000000518[PID=1860], DesiredAccess=17019f, GrantedAccess=0, ShareAccess=0
juicefs[TID=6bd4]: FFFFA983986E8800: <<Create IoStatus=0[2] UserContext=0000000000000000:0000000000124D80, GrantedAccess=17019f, FileInfo={FileAttributes=20, ReparseTag=0, AllocationSize=0:0, FileSize=0:0, CreationTime=2025-09-25T12:34:41.302Z, LastAccessTime=2025-09-25T12:34:41.302Z, LastWriteTime=2025-09-25T12:34:41.302Z, ChangeTime=2025-09-25T12:34:41.315Z, IndexNumber=0:20802}
juicefs[TID=6bd4]: FFFFA983994CBE00: >>SetInformation [EndOfFile] 0000000000000000:0000000000124D80, FileSize = 0:80a08
juicefs[TID=6bd4]: FFFFA983994CBE00: <<SetInformation IoStatus=0[0] FileInfo={FileAttributes=20, ReparseTag=0, AllocationSize=0:81000, FileSize=0:80a08, CreationTime=2025-09-25T12:34:41.302Z, LastAccessTime=2025-09-25T12:34:41.302Z, LastWriteTime=2025-09-25T12:34:41.302Z, ChangeTime=2025-09-25T12:34:41.315Z, IndexNumber=0:20802}
juicefs[TID=6bd4]: FFFFA98397576DE0: >>Write 0000000000000000:0000000000124D80, Address=000000004B750000, Offset=0:80000, Length=2568, Key=0
juicefs[TID=6bd4]: FFFFA98397576DE0: <<Write IoStatus=0[2568] FileInfo={FileAttributes=20, ReparseTag=0, AllocationSize=0:81000, FileSize=0:80a08, CreationTime=2025-09-25T12:34:41.302Z, LastAccessTime=2025-09-25T12:34:41.302Z, LastWriteTime=2025-09-25T12:34:41.331Z, ChangeTime=2025-09-25T12:34:41.331Z, IndexNumber=0:20802}
juicefs[TID=512c]: FFFFA983994CBE00: >>Write 0000000000000000:0000000000124D80, Address=00000000006D0048, Offset=0:0, Length=262144, Key=0
juicefs[TID=512c]: FFFFA983994CBE00: <<Write IoStatus=0[262144] FileInfo={FileAttributes=20, ReparseTag=0, AllocationSize=0:81000, FileSize=0:80a08, CreationTime=2025-09-25T12:34:41.302Z, LastAccessTime=2025-09-25T12:34:41.302Z, LastWriteTime=2025-09-25T12:34:41.331Z, ChangeTime=2025-09-25T12:34:41.331Z, IndexNumber=0:20802}
juicefs[TID=6bd4]: FFFFA983AE5C8DE0: >>Write 0000000000000000:0000000000124D80, Address=00000000006D0048, Offset=0:40000, Length=262144, Key=0
juicefs[TID=6bd4]: FFFFA983AE5C8DE0: <<Write IoStatus=0[262144] FileInfo={FileAttributes=20, ReparseTag=0, AllocationSize=0:81000, FileSize=0:80a08, CreationTime=2025-09-25T12:34:41.302Z, LastAccessTime=2025-09-25T12:34:41.302Z, LastWriteTime=2025-09-25T12:34:41.331Z, ChangeTime=2025-09-25T12:34:41.331Z, IndexNumber=0:20802}
juicefs[TID=6bd4]: FFFFA98399A81DE0: >>SetInformation [Basic] 0000000000000000:0000000000124D80, FileAttributes=ffffffff, CreationTime=0, LastAccessTime=0, LastWriteTime=2021-06-18T03:15:45.000Z
juicefs[TID=6bd4]: FFFFA98399A81DE0: <<SetInformation IoStatus=0[0] FileInfo={FileAttributes=20, ReparseTag=0, AllocationSize=0:81000, FileSize=0:80a08, CreationTime=2025-09-25T12:34:41.302Z, LastAccessTime=2025-09-25T12:34:41.302Z, LastWriteTime=2021-06-18T03:15:45.000Z, ChangeTime=2025-09-25T12:34:41.348Z, IndexNumber=0:20802}
juicefs[TID=6bd4]: FFFFA98399A81DE0: >>Cleanup 0000000000000000:0000000000124D80
juicefs[TID=6bd4]: FFFFA98399A81DE0: <<Cleanup IoStatus=0[0]
juicefs[TID=6bd4]: FFFFA98398DDE7B0: >>Close 0000000000000000:0000000000124D80

我对这段日志的解读是:Windows的资源管理器先调用Create创建了.epub目标文件,然后SetInformation [EndOfFile]设置文件大小为 526856 字节,然后调用Write 写目标文件内容,然后调用SetInformation [Basic] 设置目标文件的“修改时间”为2021年,这样使得源文件的日期和目标文件一致,最后关闭目标文件。

所以问题不在winfsp。

--fuse-trace-log 日志

再来看看 jucefs --fuse-trace-log得到的日志,删掉了其中我知道的只读的请求,但保留了操作的顺序:

2025/09/25 22:24:03 trace.go:131: [uid=197609,gid=197121,pid=6240]: github.com/juicedata/juicefs/pkg/winfsp.(*juice).Create("/Freakonomics.epub", 1282, 0x1c0) = (0, 0x1ce)
2025/09/25 22:24:03 trace.go:131: [uid=197609,gid=197121,pid=6240]: github.com/juicedata/juicefs/pkg/winfsp.(*juice).Chflags("/Freakonomics.epub", 0x800) = 0
2025/09/25 22:24:03 trace.go:131: [uid=4294967295,gid=4294967295,pid=-1]: github.com/juicedata/juicefs/pkg/winfsp.(*juice).Truncate("/Freakonomics.epub", 526856, 0x1ce) = 0
2025/09/25 22:24:03 trace.go:131: [uid=4294967295,gid=4294967295,pid=-1]: github.com/juicedata/juicefs/pkg/winfsp.(*juice).Write("/Freakonomics.epub", 262144, 0, 0x1ce) = 262144
2025/09/25 22:24:03 trace.go:131: [uid=4294967295,gid=4294967295,pid=-1]: github.com/juicedata/juicefs/pkg/winfsp.(*juice).Write("/Freakonomics.epub", 262144, 262144, 0x1ce) = 262144
2025/09/25 22:24:03 trace.go:131: [uid=4294967295,gid=4294967295,pid=-1]: github.com/juicedata/juicefs/pkg/winfsp.(*juice).Write("/Freakonomics.epub", 2568, 524288, 0x1ce) = 2568
2025/09/25 22:24:03 trace.go:131: [uid=4294967295,gid=4294967295,pid=-1]: github.com/juicedata/juicefs/pkg/winfsp.(*juice).Utimens("/Freakonomics.epub", []fuse.Timespec{fuse.Timespec{Sec:1758810243, Nsec:875592000}, fuse.Timespec{Sec:1623986145, Nsec:0}}) = 0
2025/09/25 22:24:05 trace.go:131: [uid=197609,gid=197121,pid=4208]: github.com/juicedata/juicefs/pkg/winfsp.(*juice).OpenEx("/Freakonomics.epub", 0) = (0, 0x1e7)
2025/09/25 22:24:05 trace.go:131: [uid=4294967295,gid=4294967295,pid=-1]: github.com/juicedata/juicefs/pkg/winfsp.(*juice).Flush("/Freakonomics.epub", 0x1e7) = 0
2025/09/25 22:24:05 trace.go:131: [uid=197609,gid=197121,pid=4208]: github.com/juicedata/juicefs/pkg/winfsp.(*juice).OpenEx("/Freakonomics.epub", 0) = (0, 0x1e9)
2025/09/25 22:24:05 trace.go:131: [uid=4294967295,gid=4294967295,pid=-1]: github.com/juicedata/juicefs/pkg/winfsp.(*juice).Flush("/Freakonomics.epub", 0x1e9) = 0
2025/09/25 22:24:14 trace.go:131: [uid=4294967295,gid=4294967295,pid=-1]: github.com/juicedata/juicefs/pkg/winfsp.(*juice).Flush("/Freakonomics.epub", 0x1ce) = 0

从中可以看到 Juicefs在收到了write请求之后,收到了Utimens请求把文件的“修改时间”设置成1623986145(即2021-06-18 11:15:45)。如果flush操作不会改变文件的“修改时间”的话,那么这个日志也没问题。

MariaDB数据库(元数据引擎)的日志

以下是修改mtime字段相关的SQL日志,保留了各SQL语句在日志中原有的顺序。

日志里各字段的解释:例如 182行 247 Query START TRANSACTION 表示该日志行第182行,连接ID为247。我还试图找出执行该SQL语句的go语言源代码(在pkg\meta\sql.go源文件里)。

182247 Query	START TRANSACTION

196247 Execute	INSERT INTO `jfs_node` (`inode`,`type`,`flags`,`mode`,`uid`,`gid`,`atime`,`mtime`,`ctime`,`atimensec`,`mtimensec`,`ctimensec`,`nlink`,`length`,`rdev`,`parent`,`access_acl_id`,`default_acl_id`) VALUES (139266,1,16,448,197609,197121,1758814156829584,1758814156829584,1758814156829584,0,0,0,1,0,0,1,0,0)

201247 Query	COMMIT

208247 Query	START TRANSACTION

213247 Execute	UPDATE `jfs_node` SET `flags` = 16, `mode` = 448, `uid` = 197609, `gid` = 197121, `atime` = 1758814156829584, `mtime` = 1758814156829584, `ctime` = 1758814156839529, `atimensec` = 0, `mtimensec` = 0, `ctimensec` = 300, `access_acl_id` = 0, `default_acl_id` = 0 WHERE `inode`=139266 其中 1758814156829584=2025-09-25T15:29:16.829Z ,1758814156839529=2025-09-25T15:29:16.839Z 可能对应源码 doSetAttr 函数的 s.Cols("flags", "mode", "uid", "gid", "atime", "mtime", "ctime",
			"atimensec", "mtimensec", "ctimensec", "access_acl_id", "default_acl_id").
			Update(&dirtyNode, &node{Inode: inode})

215247 Query	COMMIT

234247 Query	START TRANSACTION

242247 Execute	INSERT INTO `jfs_chunk` (`inode`,`indx`,`slices`) VALUES (139266,0,'\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\n')

245247 Execute	UPDATE `jfs_node` SET `mtime` = 1758814156858580, `ctime` = 1758814156858580, `mtimensec` = 200, `ctimensec` = 200, `length` = 526856 WHERE `inode`=139266 其中 1758814156858580=2025-09-25T15:29:16.858Z ,526856 是文件大小,可能对应源码 doTruncate 函数的 s.Cols("length", "mtime", "ctime", "mtimensec", "ctimensec").Update(&nodeAttr, &node{Inode: nodeAttr.Inode}) 或者 doFallocate 或 doWrite 函数的 s.Cols("length", "mtime", "ctime", "mtimensec", "ctimensec").Update(&nodeAttr, &node{Inode: inode}) 或者 CopyFileRange 函数的s.Cols("length", "mtime", "ctime", "mtimensec", "ctimensec").Update(&nout, &node{Inode: fout})

247247 Query	COMMIT

271244 Query	START TRANSACTION

276行写入了正确的“修改时间” 244 Execute	UPDATE `jfs_node` SET `flags` = 16, `mode` = 448, `uid` = 197609, `gid` = 197121, `atime` = 1758814156829584, `mtime` = 1623986145000000, `ctime` = 1758814156878765, `atimensec` = 0, `mtimensec` = 0, `ctimensec` = 0, `access_acl_id` = 0, `default_acl_id` = 0 WHERE `inode`=139266 其中 1623986145000000=2021-06-18T03:15:45.000Z ,1758814156878765=2025-09-25T15:29:16.878Z

278244 Query	COMMIT

1352250925 23:29:02	   247 Query	START TRANSACTION

1360247 Execute	INSERT INTO jfs_chunk (inode, indx, slices) VALUES (139266, 0, '\0\0\0\0\0\0\0\0\0�0�\0\n\0\0\0\0\0\n') ON DUPLICATE KEY UPDATE slices=concat(slices, '\0\0\0\0\0\0\0\0\0�0�\0\n\0\0\0\0\0\n')

1369247 Execute	UPDATE `jfs_node` SET `mtime` = 1758814156875250, `ctime` = 1758814166887447, `mtimensec` = 300, `ctimensec` = 400, `length` = 526856 WHERE `inode`=139266 其中 1758814156875250=2025-09-25T15:29:16.875Z ,1758814166887447=2025-09-25T15:29:26.887Z ,526856 等于文件大小,可能对应源码 doWrite 函数的s.Cols("length", "mtime", "ctime", "mtimensec", "ctimensec").Update(&nodeAttr, &node{Inode: inode}) ;不对应 doFallocate 函数,因为 doFallocate 函数把Mtime和Ctime设为相同: nodeAttr.setMtime(now);nodeAttr.setCtime(now)

1374247 Query	COMMIT

从SQL日志可以看到,juicefs有2条连接,一条ID是247,另一条是244。

从中可以看到问题了:276行连接ID为244的SQL写入了正确的“修改时间”(2021年),但是被1369行 连接ID为247的SQL覆盖了,导致文件最终的“修改时间”被改成了2025年。

我猜测问题的原因

juicefs的实现里,“修改时间”的操作是同步的,所以当收到操作系统要修改文件日期的请求,juicefs直接就把日期写到了元数据引擎的mtime字段;而write操作和flush操作是异步的,并且这2个操作会改变元数据引擎里的mtime字段。

“修改时间”的操作是同步的,juicefs源码里的调用关系如下:

//github.com/winfsp/cgofuse/fuse包下的fuse\fsop.go文件
type FileSystemInterface interface {
	// Utimens changes the access and modification times of a file.
	Utimens(path string, tmsp []Timespec) int
}

//pkg\winfsp\winfs.go
func (j *juice) Utimens(path string, tmsp []fuse.Timespec) (e int) {
    e = errorconv(f.Utime2(ctx, tmsp[0].Sec, tmsp[0].Nsec, tmsp[1].Sec, tmsp[1].Nsec))
}

//pkg\fs\fs.go
func (f *File) Utime2(ctx meta.Context, atimeSec, atimeNSec, mtimeSec, mtimeNsec int64) (err syscall.Errno) {
    err = f.fs.m.SetAttr(ctx, f.inode, flag, 0, &attr)
}

//pkg\meta\base.go
func (m *baseMeta) SetAttr(ctx Context, inode Ino, set uint16, sugidclearmode uint8, attr *Attr) syscall.Errno {
    err := m.en.doSetAttr(ctx, inode, set, sugidclearmode, attr, &oldAttr)
}

//pkg\meta\sql.go
func (m *dbMeta) doSetAttr(ctx Context, inode Ino, set uint16, sugidclearmode uint8, attr *Attr, oldAttr *Attr) syscall.Errno {
    _, err = s.Cols("flags", "mode", "uid", "gid", "atime", "mtime", "ctime",
                "atimensec", "mtimensec", "ctimensec", "access_acl_id", "default_acl_id").
                Update(&dirtyNode, &node{Inode: inode})
}

原因1

juicefs的flush类型的函数时会改变文件的“修改时间”。这类函数我没有详细看源码,所以不确定。

原因2

write操作会引发slices上传,slices上传成功后,文件的“修改时间”会改成触发上传的write操作的时间。这部分在juicefs源码里的调用关系如下:

// pkg\fuse\fuse.go
func (fs *fileSystem) Write(cancel <-chan struct{}, in *fuse.WriteIn, data []byte) (written uint32, code fuse.Status) {
    err := fs.v.Write(ctx, Ino(in.NodeId), data, in.Offset, in.Fh)
}

//pkg\vfs\vfs.go
func (v *VFS) Write(ctx Context, ino Ino, buf []byte, off, fh uint64) (err syscall.Errno) {
    err = h.writer.Write(ctx, off, buf)
}

//pkg\vfs\writer.go
func (f *fileWriter) Write(ctx meta.Context, off uint64, data []byte) syscall.Errno {
    if st := f.writeChunk(ctx, indx, pos, data[:n]); st != 0 {
        return st
    }
}
func (f *fileWriter) writeChunk(ctx meta.Context, indx uint32, off uint32, data []byte) syscall.Errno {
    go c.commitThread() // 异步上传和提交元数据引擎
}
func (c *chunkWriter) commitThread() {
    err = f.w.m.Write(meta.Background(), f.inode, c.indx, s.off, ss, s.lastMod)
}

//pkg\meta\base.go
func (m *baseMeta) Write(ctx Context, inode Ino, indx uint32, off uint32, slice Slice, mtime time.Time) syscall.Errno {
    st := m.en.doWrite(ctx, inode, indx, off, slice, mtime, &numSlices, &delta, &attr)
}

//pkg\meta\sql.go
func (m *dbMeta) doWrite(ctx Context, inode Ino, indx uint32, off uint32, slice Slice, mtime time.Time, numSlices *int, delta *dirStat, attr *Attr) syscall.Errno {
    _, err = s.Cols("length", "mtime", "ctime", "mtimensec", "ctimensec").Update(&nodeAttr, &node{Inode: inode})
}

文件的“修改时间”在pkg\vfs\writer.go的func (s *sliceWriter) write(ctx meta.Context, off uint32, data []uint8) syscall.Errno函数赋值为time.Now(),之后再被异步的commitThread()写到元数据引擎。

修复这个bug的修改建议

方法1

当用户程序显式设置文件的“修改时间”时,juicefs阻塞这个操作,等待之前的写入都上传完成。这就保证了“修改时间”不会被异步的commitThread()覆盖。

大多数用户程序应该是写完想写的文件内容之后才会执行显式的“修改时间”操作,之后就不会写入文件内容了。因为大家都知道,写入文件内容会导致“修改时间”变化,很少有用例会显式地设置“修改时间”之后再写入文件内容,所以在实践中执行显式的“修改时间”操作通常放在最后两步(最后一步是close文件)。

这个实现能确保复制文件之后,只要文件大小和“修改时间”相同,源文件和目标文件的内容就一致。

方法2

commitThread()里上传完成后,判断触发本次上传的write操作之后是否有显式的“修改时间”操作

  • 如果有,那么写元数据引擎的mtime字段的值应该是显式设置的“修改时间”而不是s.lastMod
  • 如果没有,按原有逻辑执行

这种“简单”实现的问题是,复制文件之后,即使文件大小和“修改时间”相同,源文件和目标文件的内容可能会不同。可能会引起备份软件和复制软件的误判。

要解决这种误判,需要更复杂的“版本化”地记录“修改时间”:假设用户程序按时间顺序先写了3次文件内容,然后设置“修改时间”,然后写2次文件内容,然后设置“修改时间”,整个操作过程记为w1、w2、w3、m1、w4、w5、m2。那么:

  • 当w1上传成功时,“简单实现”会把mtime设置成m2时刻,而“版本化实现”会设置成w1时刻
  • 当w3上传成功时,“简单实现”会把mtime设置成m2时刻,而“版本化实现”会设置成m1时刻
  • 当w4上传成功时,“简单实现”会把mtime设置成m2时刻,而“版本化实现”会设置成w4时刻
  • 当w5上传成功时,“简单实现”和“版本化实现”都会把mtimet会设置成m2时刻

方法3

让挂载juicefs的机器的内存里(记为ltime)和元数据引擎里各有一个“修改时间”字段(记为ltime)。

当用户程序显式设置“修改时间”时,只修改了ltime,不修改mtime

当用户程序写入文件导致“修改时间”变化时,只修改了ltime,不修改mtime

当用户打开一个文件时,从元数据引擎读取mtime赋值给ltime。此后,写入文件内容和显式设置“修改时间”都只改变ltime。当且仅当用户程序调用flush或close文件时,才把ltime赋值给mtime,此时ltimemtime相等。

这类似于现在juicefs文件长度字段的机制。本机总是能看见本机所作的最新修改,只有重新打开文件才能看到其他机器的最新修改。

Environment:

  • JuiceFS version (use juicefs --version) or Hadoop Java SDK version: 我用2个版本的juicefs源码试验过,都存在本文所述的问题:git提交ID为d2eb4ddv1.3.0
  • Cloud provider or hardware configuration running JuiceFS: alist运行在Alpine系统,juicefs运行在Windows系统,均为64位操作系统 和 x64 CPU
  • OS (e.g cat /etc/os-release): Windows10专业版,版本22H2
  • Object storage (cloud provider and region, or self maintained): webdav(通过alist挂载中国移动云盘然后提供webdav服务)
  • Metadata engine info (version, cloud provider managed or self maintained): MariaDB 12.0.3
  • Network connectivity (JuiceFS to metadata engine, JuiceFS to object storage): juicefs到元数据引擎的延迟为8ms,到网盘的延迟为200-3000ms之间。

Metadata

Metadata

Assignees

Labels

kind/bugSomething isn't working

Type

No type

Projects

No projects

Milestone

No milestone

Relationships

None yet

Development

No branches or pull requests

Issue actions