-
Notifications
You must be signed in to change notification settings - Fork 1.1k
Description
What happened:
在Windows上复制文件后,源文件的“修改时间”没有应用到目标文件。也就是说,复制文件后,源文件和目标文件的“修改时间”会不同。
What you expected to happen:
复制文件后,源文件和目标文件的“修改时间”应该相同,以便各种备份软件和同步软件能够通过比较文件大小和“修改时间”快速比较文件而不用读取整个文件内容。
How to reproduce it (as minimally and precisely as possible):
在Windows上把juifs挂载到T:盘,然后用Windows资源管理器把某个文件从本地硬盘复制到T:盘,然后查看源文件和目标文件的“修改时间”。
目标文件的“修改时间”会有如下变化:
- 先变成当天的日期
- 然变成源文件的“修改时间”
- 最后变成当天的日期
使用以下挂载命令:
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源文件里)。
182行 247 Query START TRANSACTION
196行 247 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)
201行 247 Query COMMIT
208行 247 Query START TRANSACTION
213行 247 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})
215行 247 Query COMMIT
234行 247 Query START TRANSACTION
242行 247 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�')
245行 247 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})
247行 247 Query COMMIT
271行 244 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
278行 244 Query COMMIT
1352行 250925 23:29:02 247 Query START TRANSACTION
1360行 247 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�')
1369行 247 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)
1374行 247 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
,此时ltime
和mtime
相等。
这类似于现在juicefs文件长度字段的机制。本机总是能看见本机所作的最新修改,只有重新打开文件才能看到其他机器的最新修改。
Environment:
- JuiceFS version (use
juicefs --version
) or Hadoop Java SDK version: 我用2个版本的juicefs源码试验过,都存在本文所述的问题:git提交ID为d2eb4dd和v1.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之间。