为 nutsdb 添加文件锁的过程记录及 gofrs/flock 详解

最近在 nutsdb v0.12.5 的发布计划中,有要实现 file lock 这一条,来保证只让开启 DB 的这个进程访问数据库文件夹,而其它的进程无法对该文件夹进行操作。

Elliot Chen 的建议下接下了这个任务,一开始觉得可能是要自己实现一个 file lock,但是在考察了其它的项目实现文件锁的方式后,发现了一个库:gofrs/flock,虽然 star 并不是很多,只有 400+,并且已经好几年没有更新过了,但是看了一眼有足足将近 1.2w 的使用者后,当即决定直接用这个库实现。

在 nutsdb 中使用 gofrs/flock

接下来我会详细介绍一下在 nutsdb 中是如何使用 gofrs/flock 的。

gofrs/flock 的接口介绍

这个库提供的接口相当简单易懂。 首先,通过flock包下的New函数创建一个*Flock,注意传进去的是需要上锁的文件路径,并且这个文件一定要是存在的,否则就会报错。

然后,可以调用LockTryLock方法进行上锁,在TryLock的注释中有些到:

TryLock is the preferred function for taking an exclusive file lock.

TryLock方法是一个非阻塞方法,如果获取锁失败了会返回 false 而不会一直等待 file lock 被释放,所以我采用了TryLock来获取锁。

实际代码实现

在 nutsdb 打开的时候,获取文件锁即可。

1
2
3
4
5
6
7
8
flock := flock.New(filepath.Join(opt.Dir, FLockName))
if ok, err := flock.TryLock(); err != nil {
return nil, err
} else if !ok {
return nil, ErrDirLocked
}

db.flock = flock

释放锁也只需要在 nutsdb 关闭的时候调用 Unlock就可以了。

1
2
3
4
5
6
7
8
if !db.flock.Locked() {
return ErrDirUnlocked
}

err = db.flock.Unlock()
if err != nil {
return err
}

借助这个库来实现 file lock 还是很简单的,但是中间遇到了一些小问题。在 v0.12.4 的发布计划中提到了 nutsdb 当前的所有测试用例都是公用一个 DB 实例,所以有一些测试用例在打开 DB 实例后并没有调用关闭方法,所以会导致下一个测试用例执行失败,因为无法再次获取到文件锁了。

暂时是通过给所有测试用例都调用关闭方法来解决的,后续要通过重构测试用例彻底解决这个问题。

gofrs/flock 源码解析

仅仅会调接口当然是不行的 🤣,于是我详细看了一下源码来了解实现原理。这个库的代码非常精简,兼容了多个平台也就 1200+ 行代码,看起来也是不怎么费劲。

Windows 系统上的实现

在 Windows 上,gofrs/flock 使用了 kernel32.dll 中的 LockFileExUnlockFileEx 函数来实现文件锁定:

1
2
3
4
5
var (
kernel32, _ = syscall.LoadLibrary("kernel32.dll")
procLockFileEx, _ = syscall.GetProcAddress(kernel32, "LockFileEx")
procUnlockFileEx, _ = syscall.GetProcAddress(kernel32, "UnlockFileEx")
)

procLockFileExprocUnlockFileEx这两个变量是 LockFileExUnlockFileEx 系统调用函数的地址,这两个地址要通过使用syscall.GetProcAddresskernel32.dll中获取,因为在 Windows 系统上,LockFileExUnlockFileEx并不能直接通过syscall包下定义的函数来调用,而要先获取到地址,再使用syscall.syscall6来调用(这个 6 指的是系统调用的参数个数,不过类似于这样的函数已经全部过时了,应该统一使用 syscall.syscallN 来调用)。

那么在 Windows 系统上,是如何实现的共享锁和独占锁呢?是通过一些标志来确定的,在procLockFileEx函数的参数中需要填入这个文件锁对应的锁类型标志,这些标志也定义在flock_winapi.go中:

1
2
3
4
5
const (
winLockfileFailImmediately = 0x00000001
winLockfileExclusiveLock = 0x00000002
winLockfileSharedLock = 0x00000000
)

winLockfileExclusiveLockwinLockfileSharedLock分别表示要获取共享文件锁还是独占文件锁。只需要在获取锁时根据需要的类型将对应的flag传入系统调用中就可以了。

解锁也是一样,只需要调用procUnlockFileEx系统调用,只是少传一个flag参数,其余的使用跟procLockFileEx都是一样的。

gofrs/flock 分别为这两个系统调用封装成了lockFileExunlockFileEx函数来方便的加锁解锁。

类 Unix 系统上的实现

在 Unix 系统上,file lock 的实现就相对更加简单一点了,因为是可以直接通过syscall包下定义的Flock函数,根据给这个函数传入的第二个参数flag,来决定这个系统调用的具体行为:

1
syscall.Flock(int(f.fh.Fd()), flag)

使用到了这些flag

1
2
3
syscall.LOCK_EX // 获取独占锁
syscall.LOCK_SH // 获取共享锁
syscall.LOCK_UN // 解锁

需要注意的是,在某些类 UNIX 操作系统上,使用Lock方法可能会自动将共享锁替换为独占锁。在使用独占锁(Exclusive Locks)和共享锁(RLock())时要小心,因为调用 Unlock() 可能会意外释放曾经是共享锁的独占锁。

TryLock 的实现

gofrs/flock 有一个非常好的功能就是可以实现非阻塞的获取锁,一旦获取锁失败了就直接返回,那么这是怎么实现的呢?

在 Windows 上,try的实现要稍微简单一些,在调用lockFileEx方法的时候,多添加了一个之前列出来但没提到的标志:winLockfileFailImmediately

1
_, errNo := lockFileEx(syscall.Handle(f.fh.Fd()), flag|winLockfileFailImmediately, 0, 1, 0, &syscall.Overlapped{})

有了这个标志就意味着非阻塞的尝试,如果 lockFileEx 返回特定的错误码,如 ErrorLockViolationsyscall.ERROR_IO_PENDING,则表示无法立即获得锁定,函数会立即返回,并返回 false 表示尝试失败。

1
2
3
4
5
6
7
if errNo > 0 {
if errNo == ErrorLockViolation || errNo == syscall.ERROR_IO_PENDING {
return false, nil
}

return false, errNo
}

而在类 UNIX 系统上,在调用FLock时,加上一个标志位syscall.LOCK_NB,如果 syscall.Flock 返回 syscall.EWOULDBLOCK 错误,则表示无法立即获得锁定,函数会立即返回,并返回 false 表示尝试失败。

1
err := syscall.Flock(int(f.fh.Fd()), flag|syscall.LOCK_NB)

不过在类 Unix 系统上还实现了一个reopenFDOnError方法,如果Flock返回的错误是syscall.EIOsyscall.EBADF,那么该方法会尝试重新打开文件描述符,并再次进行尝试。这是因为在某些情况下,文件描述符可能会在错误发生后被关闭,所以我们尝试重新打开文件描述符,确保可以继续尝试获取锁定。

这样 gofrs/flock 就实现了非阻塞的文件锁定。

总结

gofrs/flock 是一个功能强大且易于使用的 Go 语言库,用于实现文件锁定。它通过封装底层的系统调用,在不同的操作系统上提供了一致的接口,使得开发者可以轻松地确保多个进程对同一个文件的并发访问是安全的。无论是在 Linux/Unix 还是 Windows 等操作系统上,gofrs/flock 都为文件锁定提供了可靠的解决方案。

虽然不是自己从头开发的🤣,但也是为 nutsdb 完成了一个小目标,希望 nutsdb 可以越来越完善。