浏览代码

fix(engine): recoverHeader 裸 WriteAt 加 bp.UpdatePage + Close panic-safe unlock + VACUUM 异步 GC

1. CRITICAL: wal_recovery.go recoverHeader — 补上了和 writeHeaderLocked 同性质的 bug: 裸 WriteAt(0) 绕过 BufferPool, Close 时 bp.Flush 会把陈旧页 0 缓存覆盖 recoverHeader 刚落盘的头页

2. HIGH: file.go Close — 加 defer recover, 防止 bp.Close()/diskWrite panic 导致 unlock 跳过 → Windows LockFileEx 锁残留 → 下次 Open 永久 ErrDatabaseLocked

3. MEDIUM: deleter.go VACUUM — 两处同步 debug.FreeOSMemory() 改成后台 goroutine, 大表 VACUUM 时 GC 峰值数秒不再阻塞主流程
yl32940 2 周之前
父节点
当前提交
0fb009b135
共有 3 个文件被更改,包括 49 次插入 和 12 次删除
  1. 20 8
      DB/core/deleter.go
  2. 16 1
      DB/core/file.go
  3. 13 3
      DB/core/wal_recovery.go

+ 20 - 8
DB/core/deleter.go

@@ -1,18 +1,30 @@
 package core
 
 import (
+	"cmp"
+	"encoding/binary"
+	"slices"
+	"sync"
+
 	"git.ycl.pub/yl32940/TsDB2/DB/core/index"
 	"git.ycl.pub/yl32940/TsDB2/DB/core/index/btree"
 	"git.ycl.pub/yl32940/TsDB2/DB/core/index/datapage"
 	"git.ycl.pub/yl32940/TsDB2/DB/core/page"
 	"git.ycl.pub/yl32940/TsDB2/DB/core/types"
-	"cmp"
-	"encoding/binary"
-	"runtime/debug"
-	"slices"
-	"sync"
 )
 
+// asyncFreeMemory 把 FreeOSMemory() 丢到后台 goroutine 执行, 不阻塞 VACUUM 主流程。
+//
+// 之前每 rebuild 完一个索引就同步 asyncFreeMemory() —— 大表 (百万行 + 多索引)
+// VACUUM 时 GC 峰值可达数秒, 用户侧直接超时。后台化后 VACUUM 主线程立即返回,
+// GC 由 Go scheduler 在空闲时调度; 若未来需要可加参数开关禁用。
+func asyncFreeMemory() {
+	go func() {
+		defer func() { _ = recover() }()
+		asyncFreeMemory()
+	}()
+}
+
 // Deleter 删除操作对象
 type Deleter struct {
 	table *Table
@@ -34,7 +46,7 @@ func (d *Deleter) Where(cond Condition) *Deleter {
 // SQLite 风格优化: 先软删除 DataPage 行 (极快), 再重建索引 (流式, 低内存)
 // 避免逐行级联删除 B+ 树的高昂开销
 func (d *Deleter) Exec() (int64, error) {
-	defer d.table.db.lockOp()() // 批量删除全程串行; 内部 scanWithPKID/重建索引同 goroutine 重入
+	defer d.table.db.lockOp()()  // 批量删除全程串行; 内部 scanWithPKID/重建索引同 goroutine 重入
 	defer d.table.db.beginOp()() // 软删 + 全索引重建是一个操作, 中途阈值提交会切成半成品
 	// 找到主键索引
 	var pkMeta *IndexMeta
@@ -230,7 +242,7 @@ func (d *Deleter) rebuildIndexes(pkMeta *IndexMeta, dpIDs []uint32) error {
 	// 必须晚于建树: 否则这些页会先被 NewPage 拿去分给新树, 而这里还要按旧树结构遍历。
 	d.releaseOldTree(pkMeta, oldPKRoot)
 	leafEntries = nil // 释放内存
-	debug.FreeOSMemory()
+	asyncFreeMemory()
 
 	// ===== 重建每个二级索引: 收集 → 排序 → 批量构建 =====
 	for i := range d.table.indexMeta.metas {
@@ -292,7 +304,7 @@ func (d *Deleter) rebuildIndexes(pkMeta *IndexMeta, dpIDs []uint32) error {
 		// 同上: 新结构就位后归还旧结构的全部页。
 		d.releaseOldTree(m, oldSecRoot)
 		secEntries = nil
-		debug.FreeOSMemory()
+		asyncFreeMemory()
 	}
 
 	return nil

+ 16 - 1
DB/core/file.go

@@ -89,10 +89,25 @@ func OpenFileReadOnly(path string) (*File, error) {
 }
 
 func (ff *File) Close() error {
+	// defer recover: 防止 bp.Close() / bp.Flush() 里的 diskWrite panic 把 unlock 跳过 ——
+	// Windows LockFileEx 是 OS 级锁, 进程崩了句柄会被系统回收, 但同一进程里 panic
+	// 只会 defer 触发, 不会释放 OS 资源, 所以必须 recover 保证 unlock 执行。
+	defer func() {
+		if r := recover(); r != nil {
+			ff.unlock()
+			ff.f.Close()
+			panic(r) // 让上层知道 Close 中间炸过, 不要静默
+		}
+	}()
+
 	if ff.bp != nil {
 		ff.bp.Close() // 先刷盘再关文件
 	}
-	ff.unlock() // 显式释放跨进程文件锁 (若持有); 句柄关闭也会自动释放, 这里兜底
+	// 显式释放跨进程文件锁 (若持有); 句柄关闭也会自动释放, 这里兜底。
+	// 放在 bp.Close() 之后: bp.Flush 里的 writePageDisk 可能让 EOF 改变,
+	// unlock 必须用 tryLock 时保存的原始 lockOffset —— 顺序对 unlock 本身无影响,
+	// 但语义上"释放锁"应该在"写完所有页"之后。
+	ff.unlock()
 	return ff.f.Close()
 }
 

+ 13 - 3
DB/core/wal_recovery.go

@@ -286,10 +286,20 @@ func readNextWalPage(buf []byte) uint32 {
 	return page.HdrOf(buf).NextPage
 }
 
-// recoverHeader 恢复 HeaderPage
+// recoverHeader 恢复 HeaderPage —— WAL 整页镜像重放阶段最后一步, 把头页写回磁盘。
+//
+// 关键: 写盘后必须同步 BufferPool 页 0 缓存。本函数用裸 WriteAt(0) 绕过 BufferPool,
+// 如果不调 bp.UpdatePage(0, ...), Close 时 bp.Flush 会把 BufferPool 里陈旧的头页
+// 缓存写回去 —— 刚好覆盖掉 recoverHeader 刚落盘的干净头页 (和 writeHeaderLocked 之前
+// 犯的是同一个 bug, 之前 grep 只扫到了 file.go 的两个 WriteAt(0), 漏了这里)。
 func recoverHeader(ff *File, data []byte) error {
-	// data 是完整的 16KB 页镜像, 但 HeaderPage 只有 4KB
-	// 取前 4KB 写入文件头
+	if ff.bp != nil {
+		defer func() {
+			if len(data) >= PageSize4K {
+				ff.bp.UpdatePage(0, data[:PageSize4K])
+			}
+		}()
+	}
 	if len(data) < PageSize4K {
 		_, err := ff.f.WriteAt(data, 0)
 		return err