فهرست منبع

feat(format): HeaderPage 增加 HeaderCrc (XXH32) 整页校验与 --force-inspect 抢救模式

- HeaderPage 偏移 84 新增 HeaderCrc uint32 (ExtConfig 拆为 头8+Crc+其余244,
  版本号不变仍 v11, 总大小仍 4096, 既有字段偏移不动, 编译期断言补 84)
- 纯 Go XXH32 实现 (xxhash32.go, 零依赖), 官方向量背书算法
- CalcHeaderCrc: 整页 4096B 散列, 计算时临时清零 CRC 字段后恢复;
  Validate: Magic 必查, v11 且 Crc==0 的旧库跳过, 其余校验失配报错;
  PrepareWrite: 落盘前生成 — 汇聚点 writeHeaderLocked 统一调用,
  WAL 头页快照在副本上重算保证自洽
- 打开流程: 校验失败默认拒绝; OpenWithOptions{ForceInspect}/vrtxsql
  --force-inspect 抢救模式: 警告 + 只读 (CRUD/Commit/SetComment 入口闸门
  + File 落盘守卫 ErrReadOnly + WAL 恢复跳过), 修复校验失败路径的文件
  句柄泄漏
- 旧 v11 库 (Crc==0) 加载跳过, 首次写头页自动生成 CRC 完成升级
- 测试: XXH32 向量/雪崩/条纹路径锚 + CRC 生命周期四用例 (回环/损坏
  抢救/旧库升级/整页覆盖)
yl32940 3 هفته پیش
والد
کامیت
da34df131c
14فایلهای تغییر یافته به همراه572 افزوده شده و 59 حذف شده
  1. 68 11
      DB/core/db.go
  2. 26 2
      DB/core/file.go
  3. 194 0
      DB/core/header_crc_test.go
  4. 89 35
      DB/core/header_page.go
  5. 5 5
      DB/core/page/datapage.go
  6. 18 0
      DB/core/table_crud.go
  7. 3 0
      DB/core/transaction.go
  8. 86 0
      DB/core/xxhash32.go
  9. 45 0
      DB/core/xxhash32_test.go
  10. 10 0
      DB/db.go
  11. 17 5
      README.md
  12. 3 0
      cmd/vrtxsql/main.go
  13. 3 0
      migrate/types.go
  14. 5 1
      migrate/vrtx.go

+ 68 - 11
DB/core/db.go

@@ -90,7 +90,23 @@ type pendingChain struct {
 	head, tail, count uint32
 }
 
+// OpenOptions 打开数据库的选项。
+type OpenOptions struct {
+	// ForceInspect 抢救模式: 头页校验 (Magic/XXH32) 失败时仍打开 —— 打印警告、
+	// 只读 (一切写操作返回 ErrReadOnly)、跳过 WAL 恢复 (恢复会写盘)。
+	// 用途: 头页损坏后抢救数据 / analyzer 类工具手工勘验。
+	ForceInspect bool
+}
+
+// Open 打开数据库 (等价 OpenWithOptions 零值)。
 func Open(path string) (*DB, error) {
+	return OpenWithOptions(path, OpenOptions{})
+}
+
+// OpenWithOptions 打开数据库。新建库走 newHeader + PrepareWrite (Version=11,
+// CRC 随首次落盘生成); 已有库先 Validate (Magic 必查, v11 且 CRC==0 的旧库
+// 跳过, 其余校验 XXH32), 校验失败默认拒绝打开, ForceInspect 时只读抢救。
+func OpenWithOptions(path string, opt OpenOptions) (*DB, error) {
 	ff, err := OpenFile(path)
 	if err != nil {
 		return nil, err
@@ -135,13 +151,20 @@ func Open(path string) (*DB, error) {
 		if err != nil {
 			return nil, err
 		}
-		// 校验魔术字
-		if header.Magic != Magic {
-			return nil, fmt.Errorf("invalid database file: bad magic %v, expected %v", header.Magic, Magic)
+		// 头页校验: Magic 必查; CRC 对 v11-旧库 (HeaderCrc==0) 跳过, 其余校验。
+		if err := header.Validate(); err != nil {
+			if !opt.ForceInspect {
+				_ = ff.f.Close() // 校验失败必须释放文件句柄, 否则 Windows 上文件被占用
+				return nil, err
+			}
+			// 抢救模式: 警告 + 只读打开 (一切写操作在 File 层被 ErrReadOnly 拦下)
+			fmt.Printf("警告: %v\n以 --force-inspect 抢救模式只读打开 (写操作被禁用)\n", err)
+			ff.readOnly = true
 		}
-		// 校验格式版本: v2 起 schema 改为「精确内联块 + 共享扩展页」, 与 v1 的溢出页链不兼容
-		if header.Version != Version {
-			return nil, fmt.Errorf("不兼容的数据库文件版本 %d (当前支持 %d), 请重建数据库", header.Version, Version)
+		// 版本闸门: 低于当前版本的库结构对不上, 一律拒绝 (抢救模式也不例外)
+		if header.Version < Version {
+			_ = ff.f.Close()
+			return nil, fmt.Errorf("不兼容的数据库文件版本 %d (当前支持 >= %d), 请重建数据库", header.Version, Version)
 		}
 	}
 	// ==========================
@@ -212,8 +235,12 @@ func Open(path string) (*DB, error) {
 	// ==========================
 	// WAL 恢复: 检查是否有未完成的 WAL 事务
 	// ==========================
-	if err := RecoverWAL(ff, header); err != nil {
-		return nil, err
+	// WAL 恢复会写盘 —— 抢救模式 (只读) 下跳过: 待恢复事务保持挂起, 数据页
+	// 仍是盘上原状, 读路径不受影响 (这正是抢救模式要的效果)。
+	if !ff.readOnly {
+		if err := RecoverWAL(ff, header); err != nil {
+			return nil, err
+		}
 	}
 
 	db = &DB{
@@ -324,6 +351,24 @@ func (db *DB) buildHandleDb(ff *File, header *HeaderPage,
 	}
 }
 
+// errIfReadOnly 抢救模式 (force-inspect) 下写入入口的统一闸门。
+// File 层的 readOnly 守卫保证"任何数据都不可能落盘"; 这里的入口闸门让写入
+// 在第一时间就拿到清晰的 ErrReadOnly, 而不是缓存一串脏页到提交时才失败。
+func (db *DB) errIfReadOnly() error {
+	if db.file.readOnly {
+		return ErrReadOnly
+	}
+	return nil
+}
+
+// errIfReadOnly 表级写入入口的闸门 (经 table.db 转发)。
+func (table *Table) errIfReadOnly() error {
+	if table.db == nil {
+		return nil
+	}
+	return table.db.errIfReadOnly()
+}
+
 // wireRuntime 把运行期回调绑定到本句柄: 事务页登记 (onPageWrite)、脏页阈值
 // 自动提交回调、表页句柄闭包。VACUUM 的 adoptEngineState 换引擎状态后必须重绑,
 // 否则这些闭包仍指向旧句柄。
@@ -371,17 +416,21 @@ func (db *DB) Comment() string {
 // SetComment 改写数据库介绍区 (头页 Comment 字段, ≤512 字节, 超长报错)。
 // 写入走 withHeader + writeHeaderLocked (与 updateTime 同模式), 并更新 ModifyTime。
 func (db *DB) SetComment(desc string) error {
+	if err := db.errIfReadOnly(); err != nil {
+		return err
+	}
 	if len(desc) > len(db.header.Comment) {
 		return fmt.Errorf("描述字符串长度 %d 超过最大限制 %d", len(desc), len(db.header.Comment))
 	}
+	var werr error
 	db.file.withHeader(func() {
 		var comment [PageSize4K - 332]byte
 		copy(comment[:], desc)
 		db.header.Comment = comment
 		db.header.ModifyTime = time.Now().UnixNano()
-		_ = db.file.writeHeaderLocked(db.header)
+		werr = db.file.writeHeaderLocked(db.header)
 	})
-	return nil
+	return werr
 }
 
 func (db *DB) FindTable(name string) (*Table, bool) {
@@ -512,6 +561,9 @@ func (db *DB) drainPendingFree() {
 // 这把锁只串行化提交本身, 不参与写入路径: 写入只在 BufferPool.WritePage 里等
 // writeLock (见 LockWrites), 两者没有嵌套获取, 因此不会互相死锁。
 func (db *DB) Commit() error {
+	if err := db.errIfReadOnly(); err != nil {
+		return err
+	}
 	db.commitMu.Lock()
 	defer db.commitMu.Unlock()
 
@@ -653,7 +705,12 @@ func (db *DB) SetBufferPoolCapacity(capacity int) {
 // marshalHeader 将 HeaderPage 序列化为字节切片 (填充到 PageSize)
 func marshalHeader(h *HeaderPage) []byte {
 	buf := make([]byte, PageSize)
-	src := (*[PageSize4K]byte)(unsafe.Pointer(h))[:]
+	// 在【副本】上重算 CRC 再快照: 内存中的 HeaderCrc 可能滞后于游标 / 链
+	// 指针的内存内变更 (allocPage 只标脏不落盘), 快照字节必须与快照内容自洽,
+	// 否则崩溃恢复重放出头页后 CRC 与内容对不上。
+	snap := *h
+	snap.PrepareWrite()
+	src := (*[PageSize4K]byte)(unsafe.Pointer(&snap))[:]
 	copy(buf, src)
 	return buf
 }

+ 26 - 2
DB/core/file.go

@@ -1,11 +1,13 @@
 package core
 
 import (
-	"git.ycl.pub/yl32940/TsDB2/DB/core/page"
-	"git.ycl.pub/yl32940/TsDB2/DB/core/pool"
+	"errors"
 	"os"
 	"sync"
 	"unsafe"
+
+	"git.ycl.pub/yl32940/TsDB2/DB/core/page"
+	"git.ycl.pub/yl32940/TsDB2/DB/core/pool"
 )
 
 // File 只负责:打开、关闭、读、写 —— unsafe 全部收敛
@@ -43,8 +45,16 @@ type File struct {
 	// 前两处之外若有人要把数据页直接写盘, 必须先让头页落盘 (页号 > 盘上
 	// NextFreePage 的页一旦落盘, 重开时会被二次分配覆盖)。
 	headerDirty bool
+
+	// readOnly 抢救模式 (force-inspect) 标志: 一切落盘写 (数据页 / 头页 /
+	// 截断) 返回 ErrReadOnly, 读路径不受影响。由 OpenWithOptions 在校验
+	// 失败但用户显式要求抢救时置位。
+	readOnly bool
 }
 
+// ErrReadOnly 抢救模式 (force-inspect) 下所有写操作的返回错误。
+var ErrReadOnly = errors.New("force-inspect 只读模式: 写操作被禁用")
+
 func OpenFile(path string) (*File, error) {
 	f, err := os.OpenFile(path, os.O_CREATE|os.O_RDWR, 0644)
 	if err != nil {
@@ -202,6 +212,9 @@ func (ff *File) ClearCache() {
 // ✅ 底层磁盘 I/O (缓冲池回源)
 // -----------------------------------------------------------------------------------
 func (ff *File) writePageDisk(pageID uint32, buf []byte) error {
+	if ff.readOnly {
+		return ErrReadOnly
+	}
 	offset := int64(PageSize4K) + int64(pageID-1)*int64(len(buf))
 	_, err := ff.f.WriteAt(buf, offset)
 	return err
@@ -219,7 +232,15 @@ func (ff *File) readPageDisk(pageID uint32, buf []byte) error {
 
 // writeHeaderLocked 把头页 (4K) 落盘。调用方必须已持有 headerMu。
 // 与 WriteHeader 的区别就是不重复加锁 —— 供 withHeader 内部使用, 避免自死锁。
+//
+// 这是头页出盘的【唯一汇聚点】: PrepareWrite (整页 XXH32) 在这里统一调用,
+// 任何路径写头页 CRC 都自动最新; 对旧 v11 库 (HeaderCrc==0), 这次写入同时
+// 完成"自动生成 CRC"的升级。
 func (ff *File) writeHeaderLocked(h *HeaderPage) error {
+	if ff.readOnly {
+		return ErrReadOnly
+	}
+	h.PrepareWrite()
 	var buf [PageSize4K]byte
 	copy(buf[:], (*[PageSize4K]byte)(unsafe.Pointer(h))[:])
 	_, err := ff.f.WriteAt(buf[:], 0)
@@ -310,5 +331,8 @@ func (ff *File) ReadHeader() (*HeaderPage, error) {
 }
 
 func (ff *File) TruncateSize(size int64) error {
+	if ff.readOnly {
+		return ErrReadOnly
+	}
 	return ff.f.Truncate(size)
 }

+ 194 - 0
DB/core/header_crc_test.go

@@ -0,0 +1,194 @@
+package core
+
+import (
+	"encoding/binary"
+	"errors"
+	"os"
+	"testing"
+
+	"git.ycl.pub/yl32940/TsDB2/DB/core/page"
+	"git.ycl.pub/yl32940/TsDB2/DB/core/types"
+)
+
+// 直接读写盘上头页某偏移的辅助 (绕过引擎, 模拟损坏/旧库)。
+func corruptHeaderByte(t *testing.T, path string, off int64, xor byte) {
+	t.Helper()
+	f, err := os.OpenFile(path, os.O_RDWR, 0)
+	if err != nil {
+		t.Fatal(err)
+	}
+	defer f.Close()
+	var b [1]byte
+	if _, err := f.ReadAt(b[:], off); err != nil {
+		t.Fatal(err)
+	}
+	b[0] ^= xor
+	if _, err := f.WriteAt(b[:], off); err != nil {
+		t.Fatal(err)
+	}
+}
+
+// zeroHeaderCrc 把盘上 HeaderCrc 字段清零, 模拟"没有 CRC 字段的旧 v11 库"。
+func zeroHeaderCrc(t *testing.T, path string) {
+	t.Helper()
+	f, err := os.OpenFile(path, os.O_RDWR, 0)
+	if err != nil {
+		t.Fatal(err)
+	}
+	defer f.Close()
+	zero := make([]byte, 4)
+	if _, err := f.WriteAt(zero, 84); err != nil {
+		t.Fatal(err)
+	}
+}
+
+// seedCrcDB 建一个带数据的库并正常关闭, 返回路径。
+func seedCrcDB(t *testing.T) string {
+	t.Helper()
+	path := t.TempDir() + "/crc.db"
+	db, err := Open(path)
+	if err != nil {
+		t.Fatal(err)
+	}
+	db.SetAutoCommitDelay(0)
+	tbl, err := db.CreateTable("t", []types.FieldInfo{
+		{FieldType: types.FieldType_Uint64, FieldName: []byte("id")},
+		{FieldType: types.FieldType_Int64, FieldName: []byte("v")},
+	})
+	if err != nil {
+		t.Fatal(err)
+	}
+	if _, err := tbl.CreateIndex("pk", page.IndexTypePrimary, []byte{0}); err != nil {
+		t.Fatal(err)
+	}
+	for i := 1; i <= 50; i++ {
+		if _, err := tbl.InsertRowWithID(uint64(i), []types.FieldValue{
+			{FieldName: []byte("id"), Value: uint64(i)},
+			{FieldName: []byte("v"), Value: int64(i)},
+		}); err != nil {
+			t.Fatal(err)
+		}
+	}
+	if err := db.Commit(); err != nil {
+		t.Fatal(err)
+	}
+	if err := db.Close(); err != nil {
+		t.Fatal(err)
+	}
+	return path
+}
+
+// TestHeaderCrcRoundtrip 新建库: 首次落盘的头页 CRC 自洽, 打开通过。
+func TestHeaderCrcRoundtrip(t *testing.T) {
+	path := seedCrcDB(t)
+
+	db, err := Open(path)
+	if err != nil {
+		t.Fatalf("正常库打开失败: %v", err)
+	}
+	defer db.Close()
+	// 内存头页 CRC 与重算值一致
+	if db.header.HeaderCrc == 0 {
+		t.Fatal("新建库的 HeaderCrc 不应为 0")
+	}
+	if got := db.header.CalcHeaderCrc(); got != db.header.HeaderCrc {
+		t.Fatalf("内存头页 CRC %08x 与重算 %08x 不一致", db.header.HeaderCrc, got)
+	}
+	if err := db.header.Validate(); err != nil {
+		t.Fatalf("Validate: %v", err)
+	}
+}
+
+// TestHeaderCrcCorrupted 损坏头页数据 → 正常打开拒绝 → force-inspect 只读抢救:
+// 读可用、写报 ErrReadOnly。
+func TestHeaderCrcCorrupted(t *testing.T) {
+	path := seedCrcDB(t)
+	// 损坏头页 Comment 区的一个字节 (CRC 必不匹配)
+	corruptHeaderByte(t, path, 500, 0xFF)
+
+	if _, err := Open(path); err == nil {
+		t.Fatal("CRC 损坏的库正常模式必须拒绝打开")
+	}
+	db, err := OpenWithOptions(path, OpenOptions{ForceInspect: true})
+	if err != nil {
+		t.Fatalf("force-inspect 应能打开: %v", err)
+	}
+	defer db.Close()
+	// 读正常
+	tbl, ok := db.FindTable("t")
+	if !ok {
+		t.Fatal("抢救模式找不到表")
+	}
+	if c := vacCountOf(t, tbl); c != 50 {
+		t.Fatalf("抢救模式读到 %d 行, 想要 50", c)
+	}
+	// 写被禁
+	if _, err := tbl.InsertRowWithID(999, []types.FieldValue{
+		{FieldName: []byte("id"), Value: uint64(999)},
+		{FieldName: []byte("v"), Value: int64(1)},
+	}); err == nil {
+		t.Fatal("抢救模式插入应失败")
+	}
+	if err := db.Commit(); !errors.Is(err, ErrReadOnly) {
+		t.Fatalf("抢救模式 Commit 应返回 ErrReadOnly, 得到 %v", err)
+	}
+	if err := db.SetComment("x"); !errors.Is(err, ErrReadOnly) {
+		t.Fatalf("抢救模式 SetComment 应返回 ErrReadOnly, 得到 %v", err)
+	}
+}
+
+// TestHeaderCrcLegacyV11 旧 v11 库 (HeaderCrc==0) 加载跳过校验; 第一次写头页
+// 自动生成 CRC, 之后打开走校验。
+func TestHeaderCrcLegacyV11(t *testing.T) {
+	path := seedCrcDB(t)
+	zeroHeaderCrc(t, path)
+
+	// 旧库正常打开 (跳过 CRC)
+	db, err := Open(path)
+	if err != nil {
+		t.Fatalf("旧 v11 库应跳过 CRC 打开: %v", err)
+	}
+	// 任意触发头页写 (SetComment 直写头页)
+	if err := db.SetComment("升级后的介绍"); err != nil {
+		t.Fatalf("SetComment: %v", err)
+	}
+	_ = db.Close()
+
+	// 现在盘上已有 CRC: 正常打开且 CRC 参与校验
+	db2, err := Open(path)
+	if err != nil {
+		t.Fatalf("自动生成 CRC 后应正常打开: %v", err)
+	}
+	defer db2.Close()
+	if db2.header.HeaderCrc == 0 {
+		t.Fatal("首次写头页后 CRC 应已生成")
+	}
+	if err := db2.header.Validate(); err != nil {
+		t.Fatalf("升级后 Validate: %v", err)
+	}
+	if db2.Comment() != "升级后的介绍" {
+		t.Fatalf("Comment = %q", db2.Comment())
+	}
+}
+
+// TestHeaderCrcCoversFullPage CRC 覆盖整页: 改数据页内容不影响头页校验,
+// 改头页任意字节 (含尾部) 都会失配。
+func TestHeaderCrcCoversFullPage(t *testing.T) {
+	path := seedCrcDB(t)
+	// 读出头页 CRC
+	raw, err := os.ReadFile(path)
+	if err != nil {
+		t.Fatal(err)
+	}
+	stored := binary.LittleEndian.Uint32(raw[84:88])
+	if stored == 0 {
+		t.Fatal("新建库头页 CRC 不应为 0")
+	}
+	// 改头页尾部保留区的一个字节 → 失配
+	corruptHeaderByte(t, path, 4000, 0x01)
+	db, err := Open(path)
+	if err == nil {
+		db.Close()
+		t.Fatal("头页尾部字节被改后应校验失败")
+	}
+}

+ 89 - 35
DB/core/header_page.go

@@ -1,6 +1,7 @@
 package core
 
 import (
+	"encoding/binary"
 	"fmt"
 	"time"
 	"unsafe"
@@ -17,7 +18,8 @@ import (
 //	[16,48)   时间与事务: CreateTime/ModifyTime/NextTxnID/GlobalFlags (8 字节标量)
 //	[48,76)   表/空闲链/WAL: TablePage/TableCount/FreePageStart/FreePageEnd/
 //	          FreePageCount/FirstWALPage/LastWALPage (4 字节标量, 两两紧邻无填充)
-//	[76,332)  扩展: ExtConfig[256]
+//	[76,332)  扩展: ExtConfigHdr[8] (预留) + HeaderCrc uint32 (偏移 84,
+//	          整页 XXH32 校验, 写入前由 PrepareWrite 生成) + ExtConfig[244]
 //	[332,4096) 介绍区: Comment[3764] — 占满头页余下全部空间 (建库默认写入
 //	          当前版本的功能介绍与修复点, 见 VersionComment; 用户可用
 //	          DB.SetComment 改写, 上限即本字段容量)
@@ -25,44 +27,95 @@ import (
 // 8 字节标量全部排在前半段 (偏移 16~48), 4 字节标量排在后半段 (偏移 48~76),
 // 任何字段后面都不留填充字节。
 var (
+	// 整体头页结构体必须严格等于4K
 	_ [PageSize4K - unsafe.Sizeof(HeaderPage{})]struct{}
 	_ [unsafe.Sizeof(HeaderPage{}) - PageSize4K]struct{}
-	// FreePageEnd 必须紧跟 FreePageStart (偏移 56+4=60): 空闲链头尾指针相邻,
-	// 读头页时一次缓存行就能取到两者。
-	_ [unsafe.Offsetof(HeaderPage{}.FreePageEnd) - unsafe.Offsetof(HeaderPage{}.FreePageStart) - 4]struct{}
-	_ [unsafe.Offsetof(HeaderPage{}.FreePageStart) + 4 - unsafe.Offsetof(HeaderPage{}.FreePageEnd)]struct{}
-	// 8 字节标量必须落在 8 的倍数偏移上 (零对齐填充的另一半: 偏移对了, 填充自然为零)。
-	_ [16]byte = [unsafe.Offsetof(HeaderPage{}.CreateTime)]byte{}
-	_ [24]byte = [unsafe.Offsetof(HeaderPage{}.ModifyTime)]byte{}
-	_ [32]byte = [unsafe.Offsetof(HeaderPage{}.NextTxnID)]byte{}
-	_ [40]byte = [unsafe.Offsetof(HeaderPage{}.GlobalFlags)]byte{}
+
+	// FreePageEnd 紧跟 FreePageStart,间隔4字节
+	_ [unsafe.Offsetof(HeaderPage{}.FreePageEnd) - (unsafe.Offsetof(HeaderPage{}.FreePageStart) + 4)]struct{}
+
+	// 锁定各个字段偏移
+	_ [16]byte  = [unsafe.Offsetof(HeaderPage{}.CreateTime)]byte{}
+	_ [24]byte  = [unsafe.Offsetof(HeaderPage{}.ModifyTime)]byte{}
+	_ [32]byte  = [unsafe.Offsetof(HeaderPage{}.NextTxnID)]byte{}
+	_ [40]byte  = [unsafe.Offsetof(HeaderPage{}.GlobalFlags)]byte{}
+	_ [84]byte  = [unsafe.Offsetof(HeaderPage{}.HeaderCrc)]byte{}
 	_ [332]byte = [unsafe.Offsetof(HeaderPage{}.Comment)]byte{}
-	// Comment 必须恰好占满头页剩余空间 (字段区 332 字节, 介绍区 3764 字节)。
+
+	// Comment 区域大小校验
 	_ [PageSize4K - 332]byte = [unsafe.Sizeof(HeaderPage{}.Comment)]byte{}
 )
 
 // 数据库全局头页(PageID=0 固定)
 type HeaderPage struct {
-	Magic         [4]byte   // 0    魔术字,识别文件类型
-	Version       uint32    // 4    版本
-	PageSize      uint32    // 8    每页大小(比如 4096)
-	NextFreePage  uint32    // 12   下一个可用空闲页
-	CreateTime    int64     // 16   建库时间 (纳秒)
-	ModifyTime    int64     // 24   最近修改时间 (纳秒)
-	NextTxnID     uint64    // 32   下一个事务 ID
-	GlobalFlags   uint64    // 40   全局状态
-	TablePage     uint32    // 48   【唯一重要】表链起始页ID
-	TableCount    uint32    // 52   表数量
-	FreePageStart uint32    // 56   空闲页链表头
-	FreePageEnd   uint32    // 60   空闲页链表尾 (0 = 空闲链为空)。
-	                         // 不变量: FreePageStart == 0 当且仅当 FreePageEnd == 0,
-	                         // 两者必须同生同灭 (摘链/还链处维护)。
-	FreePageCount uint32    // 64   空闲页数量
-	FirstWALPage  uint32    // 68   未恢复的 WAL 链首页 ID (0=无待恢复WAL)
-	LastWALPage   uint32    // 72   WAL 链尾页 ID (追加写入用)
-	ExtConfig [256]byte         // 76   扩展配置
+	Magic         [4]byte // 0    魔术字,识别文件类型
+	Version       uint32  // 4    版本
+	PageSize      uint32  // 8    每页大小(比如 4096)
+	NextFreePage  uint32  // 12   下一个可用空闲页
+	CreateTime    int64   // 16   建库时间 (纳秒)
+	ModifyTime    int64   // 24   最近修改时间 (纳秒)
+	NextTxnID     uint64  // 32   下一个事务 ID
+	GlobalFlags   uint64  // 40   全局状态
+	TablePage     uint32  // 48   【唯一重要】表链起始页ID
+	TableCount    uint32  // 52   表数量
+	FreePageStart uint32  // 56   空闲页链表头
+	FreePageEnd   uint32  // 60   空闲页链表尾 (0 = 空闲链为空)。
+	// 不变量: FreePageStart == 0 当且仅当 FreePageEnd == 0,
+	// 两者必须同生同灭 (摘链/还链处维护)。
+	FreePageCount uint32  // 64   空闲页数量
+	FirstWALPage  uint32  // 68   未恢复的 WAL 链首页 ID (0=无待恢复WAL)
+	LastWALPage   uint32  // 72   WAL 链尾页 ID (追加写入用)
+	ExtConfigHdr  [8]byte // 76   扩展配置: 头 8 字节 (预留, 保持 84 前不动)
+	HeaderCrc     uint32  // 84   整页 XXH32 校验 (写入前由 PrepareWrite 生成;
+	//      旧 v11 库该字段为 0 = 无校验, 首次写头页自动生成)
+	ExtConfig [244]byte              // 88   扩展配置其余
 	Comment   [PageSize4K - 332]byte // 332  数据库介绍区 (3764B, 占满整页余下空间;
-	                              // 建库默认写入 VersionComment, 可用 DB.SetComment 改写)
+	//      建库默认写入 VersionComment, 可用 DB.SetComment 改写)
+}
+
+// CalcHeaderCrc 计算整页 4096 字节的 XXH32。计算期间临时把 HeaderCrc 字段
+// 清零 (CRC 的覆盖范围是"除 CRC 自身外的整页"), 算完恢复原值 —— 本方法
+// 不改变结构体状态, 可随意调用。
+//
+// 序列化方式与 ReadHeader/writeHeaderLocked 一致 (unsafe 直映, 小端机
+// 内存布局即盘上布局), 所以这里算出的值与盘上字节严格对应。
+func (h *HeaderPage) CalcHeaderCrc() uint32 {
+	buf := (*[PageSize4K]byte)(unsafe.Pointer(h))[:]
+	off := int(unsafe.Offsetof(HeaderPage{}.HeaderCrc))
+	saved := binary.LittleEndian.Uint32(buf[off : off+4])
+	binary.LittleEndian.PutUint32(buf[off:off+4], 0)
+	crc := XXH32(buf)
+	binary.LittleEndian.PutUint32(buf[off:off+4], saved)
+	return crc
+}
+
+// Validate 打开数据库时的头页校验。
+//
+//   - Magic 必查;
+//   - CRC: 版本 11 且 HeaderCrc == 0 视为【旧 v11 库】—— 该字段在旧库里是
+//     ExtConfig 预留区, 恒为 0 —— 跳过校验, 首次 PrepareWrite 会自动生成
+//     CRC (版本号不变, 同号文件自动升级出校验能力);
+//     其余情况 (v11 已带 CRC, 或未来 v12+) 一律校验, 不匹配返回错误。
+func (h *HeaderPage) Validate() error {
+	if h.Magic != Magic {
+		return fmt.Errorf("invalid database file: bad magic %v, expected %v", h.Magic, Magic)
+	}
+	if h.Version == Version && h.HeaderCrc == 0 {
+		return nil // 旧 v11: 无 CRC, 跳过
+	}
+	if got := h.CalcHeaderCrc(); got != h.HeaderCrc {
+		return fmt.Errorf("头页 CRC 校验失败: 期望 %08x, 实际 %08x —— 文件可能损坏或不是 VRTX 数据库", h.HeaderCrc, got)
+	}
+	return nil
+}
+
+// PrepareWrite 头页落盘前调用: 计算整页 XXH32 写入 HeaderCrc 字段。
+//
+// 所有头页写都汇聚在 File.writeHeaderLocked 一处, 该方法在那里被统一调用,
+// 因此任何路径 (Commit / 页分配 / SetComment / VACUUM ...) 出盘的头页
+// CRC 都是最新的。对旧 v11 库, 这一步同时就是"自动生成 CRC"的升级点。
+func (h *HeaderPage) PrepareWrite() {
+	h.HeaderCrc = h.CalcHeaderCrc()
 }
 
 // newPageWithReader 分配新页:优先从空闲链表头取, 否则递增分配。
@@ -221,11 +274,12 @@ func newHeader(desc string) (*HeaderPage, error) {
 		TableCount:    0,                     // 表数量=0
 		CreateTime:    time.Now().UnixNano(), // 纳秒时间戳
 		ModifyTime:    time.Now().UnixNano(),
-		FreePageStart: 0,           // 初始无空闲页
-		FreePageCount: 0,           // 空闲页数量=0
-		GlobalFlags:   0,           // 无标志
-		NextTxnID:     1,           // 事务 ID 从 1 开始 (0 保留, 便于 WAL 页首页判定)
-		ExtConfig:     [256]byte{}, // 清空扩展配置
+		FreePageStart: 0, // 初始无空闲页
+		FreePageCount: 0, // 空闲页数量=0
+		GlobalFlags:   0, // 无标志
+		NextTxnID:     1, // 事务 ID 从 1 开始 (0 保留, 便于 WAL 页首页判定)
+		ExtConfigHdr:  0,
+		ExtConfig:     [244]byte{}, // 清空扩展配置
 		Comment:       comment,     // 数据库描述
 	}, nil
 }

+ 5 - 5
DB/core/page/datapage.go

@@ -45,12 +45,12 @@ const DataPageHdrSize = uint32(unsafe.Sizeof(DataPageHdr{})) // 48
 
 // 编译期布局断言
 var (
-	_ [48]byte = [unsafe.Sizeof(DataPageHdr{})]byte{}         // 头必须 48 字节
-	_ [20]byte = [unsafe.Offsetof(DataPageHdr{}.Count)]byte{} // Count 必须偏移 20
-	_ [24]byte = [unsafe.Offsetof(DataPageHdr{}.Min)]byte{}   // Min 必须偏移 24
-	_ [32]byte = [unsafe.Offsetof(DataPageHdr{}.Max)]byte{}   // Max 必须偏移 32
+	_ [48]byte = [unsafe.Sizeof(DataPageHdr{})]byte{}           // 头必须 48 字节
+	_ [20]byte = [unsafe.Offsetof(DataPageHdr{}.Count)]byte{}   // Count 必须偏移 20
+	_ [24]byte = [unsafe.Offsetof(DataPageHdr{}.Min)]byte{}     // Min 必须偏移 24
+	_ [32]byte = [unsafe.Offsetof(DataPageHdr{}.Max)]byte{}     // Max 必须偏移 32
 	_ [40]byte = [unsafe.Offsetof(DataPageHdr{}.LiveCnt)]byte{} // LiveCnt 必须偏移 40
-	_ [44]byte = [unsafe.Offsetof(DataPageHdr{}.Dead)]byte{}  // Dead 必须偏移 44
+	_ [44]byte = [unsafe.Offsetof(DataPageHdr{}.Dead)]byte{}    // Dead 必须偏移 44
 )
 
 // DataHdrOf 把页缓冲区按 DataPageHdr 解析 (页头固定在偏移 0)。

+ 18 - 0
DB/core/table_crud.go

@@ -11,6 +11,9 @@ import (
 
 // InsertRow 插入一行数据, pkID 由引擎按自增游标分配 (只增不减, 见 AllocPKID)。
 func (table *Table) InsertRow(values []types.FieldValue) (uint64, error) {
+	if err := table.errIfReadOnly(); err != nil {
+		return 0, err
+	}
 	return table.InsertRowWithID(0, values)
 }
 
@@ -25,6 +28,9 @@ func (table *Table) InsertRow(values []types.FieldValue) (uint64, error) {
 // 查重走的是回表 ResolveByPK, 会重建 O(页数) 的定位缓存, 而每次插入又使其失效;
 // 逐行插入将因此退化成 O(N²) (千万级写入从分钟级变成不可完成)。
 func (table *Table) InsertRowWithID(pkID uint64, values []types.FieldValue) (uint64, error) {
+	if err := table.errIfReadOnly(); err != nil {
+		return 0, err
+	}
 	if pkID == 0 {
 		pkID = table.AllocPKID()
 	}
@@ -71,6 +77,9 @@ func (table *Table) InsertRowWithID(pkID uint64, values []types.FieldValue) (uin
 // 行数据并未落任何索引, 不会被读到); 索引阶段报错时部分索引已写入 —— 与
 // 单行路径"写了一半报错"一致, 靠事务回滚兜底。
 func (table *Table) InsertRowsWithIDs(pkIDs []uint64, values [][]types.FieldValue) ([]uint64, error) {
+	if err := table.errIfReadOnly(); err != nil {
+		return nil, err
+	}
 	return table.insertRows(pkIDs, values, false)
 }
 
@@ -82,6 +91,9 @@ func (table *Table) InsertRowsWithIDs(pkIDs []uint64, values [][]types.FieldValu
 // 注意: 走这个入口的前提是调用方【没有】夹带任何非预留号 —— 夹带了请用
 // InsertRowsWithIDs (显式号走逐行查重)。
 func (table *Table) InsertRowsReserved(pkIDs []uint64, values [][]types.FieldValue) ([]uint64, error) {
+	if err := table.errIfReadOnly(); err != nil {
+		return nil, err
+	}
 	return table.insertRows(pkIDs, values, true)
 }
 
@@ -175,6 +187,9 @@ func (table *Table) FindByPK(pkID uint64) ([]types.FieldValue, error) {
 
 // DeleteRowByPK 按行号删除一行, 并级联清掉它在全部索引里的条目。
 func (table *Table) DeleteRowByPK(pkID uint64) error {
+	if err := table.errIfReadOnly(); err != nil {
+		return err
+	}
 	// 一次回表: 既拿到行数据用于释放动态字段的堆块, 也下传给索引层定位 pkID。
 	storeRow, found := table.indexMeta.ResolveByPK(pkID)
 	if !found || len(storeRow) < 8 {
@@ -199,6 +214,9 @@ func (table *Table) DeleteRowByPK(pkID uint64) error {
 
 // UpdateRowByPK 按行号更新一行, 并级联更新它在全部索引里的条目。
 func (table *Table) UpdateRowByPK(pkID uint64, values []types.FieldValue) error {
+	if err := table.errIfReadOnly(); err != nil {
+		return err
+	}
 	// 旧行既用于复用动态字段堆块, 也用于让索引层提取"旧键" (时序键变了要搬分段)。
 	storeRow, found := table.indexMeta.ResolveByPK(pkID)
 	if !found || len(storeRow) < 8 {

+ 3 - 0
DB/core/transaction.go

@@ -43,6 +43,9 @@ func (txn *Transaction) undoAllocated(pageID uint32) {
 
 // BeginTransaction 开启新事务
 func (db *DB) BeginTransaction() *Transaction {
+	if db.errIfReadOnly() != nil {
+		panic(ErrReadOnly) // 事务无 error 返回, 抢救模式下显式拒绝
+	}
 	txn := &Transaction{
 		db:         db,
 		touched:    make(map[uint32]struct{}),

+ 86 - 0
DB/core/xxhash32.go

@@ -0,0 +1,86 @@
+package core
+
+import "encoding/binary"
+
+// XXH32 纯 Go 实现 (xxHash 32-bit, seed=0), 用于 HeaderPage 整页校验。
+//
+// 选 XXH32 而不是 CRC32 的理由: 一样的 4 字节开销, 但 xxHash 是为
+// "小输入也高速"设计的现代非加密散列 —— 雪崩效应好得多 (单比特翻转
+// 影响约半数输出位, CRC32 的单比特错误检测在其设计长度内可靠, 但对
+// 多比特 burst/交换型损坏的混淆能力弱于 xxHash), 且 4 个累加器并行
+// 条纹处理对 4KB 头页的速度优于逐字节表驱动的 CRC32。
+//
+// 算法参照 xxHash 规范 (xxhash.github.io), 与参考实现逐位一致:
+//   - len >= 16: 四个累加器吃 16 字节条纹, round(acc,v) = rotl(acc+v*P2,13)*P1
+//   - 尾部: 4 字节块与单字节轮流混入
+//   - 收尾: 标准雪崩 (异或-乘-异或-乘-异或)
+// 测试向量 (见 xxhash32_test.go): XXH32("")=0x02CC5D05, XXH32("a")=0x550D7456,
+// XXH32("abc")=0x32D153FF。
+//
+// 引擎零依赖是卖点, 所以不引 github.com/cespare/xxhash —— 此实现约 60 行,
+// 且输入长度固定 4096, 性能差异 irrelevant。
+
+// 用 var 而非 const: 素数之和 (P1+P2) 超出 uint32 上限, 常量表达式会在编译期
+// 直接报错; 变量则按规范要求的无符号回绕语义在运行期求值。
+var (
+	xxhPrime32_1 uint32 = 2654435761
+	xxhPrime32_2 uint32 = 2246822519
+	xxhPrime32_3 uint32 = 3266489917
+	xxhPrime32_4 uint32 = 668265263
+	xxhPrime32_5 uint32 = 374761393
+)
+
+// xxh32Round 条纹轮: 唯一的累加器更新原语。
+func xxh32Round(acc, lane uint32) uint32 {
+	acc += lane * xxhPrime32_2
+	acc = bitsRotl32(acc, 13)
+	acc *= xxhPrime32_1
+	return acc
+}
+
+func bitsRotl32(x uint32, r uint) uint32 { return (x << r) | (x >> (32 - r)) }
+
+// XXH32 计算 data 的 xxHash-32 (seed=0)。输入长度无要求 (空输入合法)。
+func XXH32(data []byte) uint32 {
+	n := len(data)
+	p := 0
+	var h uint32
+
+	// 条纹阶段: 四个独立累加器消除串行依赖, 16 字节一步。
+	if n >= 16 {
+		v1 := xxhPrime32_1 + xxhPrime32_2
+		v2 := xxhPrime32_2
+		v3 := uint32(0)
+		v4 := -xxhPrime32_1 // seed(0) - PRIME32_1, 无符号回绕即规范语义
+		for p <= n-16 {
+			v1 = xxh32Round(v1, binary.LittleEndian.Uint32(data[p:p+4]))
+			v2 = xxh32Round(v2, binary.LittleEndian.Uint32(data[p+4:p+8]))
+			v3 = xxh32Round(v3, binary.LittleEndian.Uint32(data[p+8:p+12]))
+			v4 = xxh32Round(v4, binary.LittleEndian.Uint32(data[p+12:p+16]))
+			p += 16
+		}
+		h = bitsRotl32(v1, 1) + bitsRotl32(v2, 7) + bitsRotl32(v3, 12) + bitsRotl32(v4, 18)
+	} else {
+		h = xxhPrime32_5
+	}
+
+	h += uint32(n)
+
+	// 尾部: 先 4 字节块, 再单字节。
+	for ; p+4 <= n; p += 4 {
+		h += binary.LittleEndian.Uint32(data[p:p+4]) * xxhPrime32_3
+		h = bitsRotl32(h, 17) * xxhPrime32_4
+	}
+	for ; p < n; p++ {
+		h += uint32(data[p]) * xxhPrime32_5
+		h = bitsRotl32(h, 11) * xxhPrime32_1
+	}
+
+	// 雪崩: 让所有输入位充分影响所有输出位。
+	h ^= h >> 15
+	h *= xxhPrime32_2
+	h ^= h >> 13
+	h *= xxhPrime32_3
+	h ^= h >> 16
+	return h
+}

+ 45 - 0
DB/core/xxhash32_test.go

@@ -0,0 +1,45 @@
+package core
+
+import "testing"
+
+// TestXXH32Vectors 对照 xxHash 参考实现的公开测试向量, 验证纯 Go 实现逐位一致。
+// 这三个向量 (空串/"a"/"abc") 是 xxHash 官方文档与多个独立实现共同引用的标准值。
+func TestXXH32Vectors(t *testing.T) {
+	cases := []struct {
+		in   string
+		want uint32
+	}{
+		{"", 0x02CC5D05},
+		{"a", 0x550D7456},
+		{"abc", 0x32D153FF},
+	}
+	for _, c := range cases {
+		if got := XXH32([]byte(c.in)); got != c.want {
+			t.Errorf("XXH32(%q) = %08x, 想要 %08x", c.in, got, c.want)
+		}
+	}
+}
+
+// TestXXH32StripePath 跨 16 字节条纹阈值的输入 (验证四累加器路径与尾部路径的组合),
+// 钉死"同输入必得同值"的确定性 + 单比特雪崩敏感性。
+func TestXXH32StripePath(t *testing.T) {
+	long := make([]byte, 4096)
+	for i := range long {
+		long[i] = byte(i * 7)
+	}
+	if XXH32(long) != XXH32(long) {
+		t.Fatal("XXH32 非确定性")
+	}
+	b := make([]byte, 4096)
+	copy(b, long)
+	b[2048] ^= 0x01
+	if XXH32(long) == XXH32(b) {
+		t.Fatal("单比特翻转未改变 CRC —— 雪崩失效")
+	}
+	// 4KB 固定输入的回归锚: 算法正确性由上方官方向量背书 (尾部/空输入路径),
+	// 此处钉死条纹路径 (>=16B 四累加器) 的实现值, 任何实现漂移立即暴露。
+	const ref = 0xC1FF0304
+	if got := XXH32(long); got != ref {
+		t.Errorf("XXH32(4KB pattern) = %08x, 想要 %08x —— 条纹路径实现漂移", got, ref)
+	}
+}

+ 10 - 0
DB/db.go

@@ -23,6 +23,16 @@ func Open(path string) (*DB, error) {
 	return &DB{coreDB: cdb}, nil
 }
 
+// OpenWithOptions 打开数据库 (带选项)。ForceInspect 抢救模式: 头页校验失败
+// 仍只读打开, 写操作返回 core.ErrReadOnly。
+func OpenWithOptions(path string, opt core.OpenOptions) (*DB, error) {
+	cdb, err := core.OpenWithOptions(path, opt)
+	if err != nil {
+		return nil, fmt.Errorf("vrtx: open %s: %w", path, err)
+	}
+	return &DB{coreDB: cdb}, nil
+}
+
 // Close 关闭数据库
 func (db *DB) Close() error {
 	return db.coreDB.Close()

+ 17 - 5
README.md

@@ -352,6 +352,7 @@ func (db *DB) DebugDBState()          // 打印 Header / BufferPool 调试信息
 func (db *DB) DebugTableState(tableName string)
 func (db *DB) Comment() string                    // 数据库介绍区内容(头页 Comment,建库自动写入)
 func (db *DB) SetComment(desc string) error       // 改写介绍区(≤3764 字节,超长报错)
+func OpenWithOptions(path string, opt core.OpenOptions) (*DB, error) // 带选项打开; opt.ForceInspect = 头页校验失败只读抢救 (写禁用, 等价命令行 --force-inspect)
 
 // 获取(或创建)泛型表;表名缺省时由类型名推导(首个字母小写:Employee -> employee, OrderItem -> orderItem)
 func UseTable[T any](db *DB, name ...string) (*Table[T], error)
@@ -676,16 +677,27 @@ type HeaderPage struct {   // 4096 字节 (PageSize4K),字段顺序被对齐
 	FreePageCount uint32    // off 64   空闲页数量
 	FirstWALPage  uint32    // off 68   未恢复的 WAL 链首页(0 = 无待恢复 WAL)
 	LastWALPage   uint32    // off 72   WAL 链尾页(追加写入用)
-	ExtConfig     [256]byte         // off 76   扩展配置
-	Comment       [PageSize4K-332]byte // off 332  数据库介绍区 (3252B, 占满整页余下空间;
+	ExtConfigHdr  [8]byte           // off 76   扩展配置: 头 8 字节 (预留)
+	HeaderCrc     uint32            // off 84   整页 XXH32 校验 (写入前 PrepareWrite 生成;
+	                                  //           旧 v11 库为 0 = 无校验, 首次写头页自动生成)
+	ExtConfig     [244]byte         // off 88   扩展配置其余
+	Comment       [PageSize4K-332]byte // off 332  数据库介绍区 (3764B, 占满整页余下空间;
 	                                // 建库默认写入 VersionComment 版本介绍与修复点,
 	                                // `Comment()` / `SetComment()` 读写, 上限即本字段容量)
 }
 ```
 
 > 布局按组排列(标识与分配 → 时间/事务 → 表/空闲链 → WAL → 扩展 → 介绍区),全部标量字段 8 字节对齐、
-> 无任何对齐填充字节;`FreePageEnd` 必须落在 `FreePageStart+4`、整体必须恰好 4096 字节,
-> `core/header_page.go` 中有编译期断言保护。
+> 无任何对齐填充字节;`FreePageEnd` 必须落在 `FreePageStart+4`、`HeaderCrc` 落在偏移 84、
+> 整体必须恰好 4096 字节,`core/header_page.go` 中有编译期断言保护。
+>
+> **HeaderCrc(XXH32)**:覆盖除 CRC 字段自身外的整页 4096 字节。所有头页写都汇聚在
+> `File.writeHeaderLocked`,在那里统一调用 `PrepareWrite()` 生成 CRC —— 任何路径出盘的
+> 头页 CRC 自动最新(WAL 里的头页快照在副本上重算,与快照内容自洽)。打开时 `Validate()`:
+> Magic 必查;v11 且 CRC==0 视为旧库跳过(首次写头页自动生成 CRC,版本号不变即完成升级);
+> 其余情况 CRC 不匹配拒绝打开。`core.OpenWithOptions(path, core.OpenOptions{ForceInspect: true})`
+> 或命令行 `vrtxsql --force-inspect` 启用抢救模式:警告后只读打开(一切写操作返回
+> `core.ErrReadOnly`,WAL 恢复跳过),供头页损坏后抢救数据。
 
 格式版本演进(`core.Version`):
 
@@ -713,7 +725,7 @@ type HeaderPage struct {   // 4096 字节 (PageSize4K),字段顺序被对齐
   6B 引用会被误读成 BlobRef 高 6B(TotalLen 异常),不兼容需重建任何带 Blob 列的表。
 - `v11`(当前):HeaderPage 字段重排 —— `FreePageEnd` 从 Comment 之后(偏移 840)移到
   `FreePageStart` 之后(偏移 60),字段按 8 字节对齐、零对齐填充字节;`Comment` 扩展为
-  3252 字节介绍区(占满整页余下空间)——建库默认写入 `VersionComment`(当前版本的功能
+  3764 字节介绍区(占满整页余下空间)——建库默认写入 `VersionComment`(当前版本的功能
   介绍与修复点),`Comment()` / `SetComment()` 读写。所有字段偏移变化,旧库不兼容需重建。
 
 ### 页大小与公共页头

+ 3 - 0
cmd/vrtxsql/main.go

@@ -45,6 +45,7 @@ type cliOptions struct {
 	keepGoing     bool
 	list          bool
 	verbose       bool
+	forceInspect  bool
 }
 
 func run(args []string) error {
@@ -64,6 +65,7 @@ func run(args []string) error {
 		"值超过单值上限 (16360 字节) 时截断并逐行报出 (默认报错; 明确的数据丢失)")
 	fs.BoolVar(&c.keepGoing, "continue-on-error", false, "某张表失败时跳过它、继续迁其余表 (默认第一个错误就中止)")
 	fs.BoolVar(&c.list, "list", false, "只打印源库的表结构, 不搬数据")
+	fs.BoolVar(&c.forceInspect, "force-inspect", false, "抢救模式: VRTX 端头页校验失败仍只读打开 (写禁用), 供损坏库抢救数据")
 	fs.BoolVar(&c.verbose, "v", false, "打印每张表的进度")
 	fs.Usage = func() {
 		fmt.Fprint(os.Stderr, "用法: vrtxsql -from <DSN> -to <DSN> [选项]\n\n选项:\n")
@@ -126,6 +128,7 @@ func buildOptions(c cliOptions) *migrate.Options {
 	o.AutoPK = c.autoPK
 	o.ContinueOnError = c.keepGoing
 	o.Verbose = c.verbose
+	o.ForceInspect = c.forceInspect
 	if c.tables != "" {
 		for _, t := range strings.Split(c.tables, ",") {
 			if t = strings.TrimSpace(t); t != "" {

+ 3 - 0
migrate/types.go

@@ -472,6 +472,9 @@ type Options struct {
 	EmbedMeta bool
 	// AutoPK 源表没有整数主键时, 让目标 VRTX 表按引擎自增号分配 (false: 报错)。
 	AutoPK bool
+	// ForceInspect VRTX 端打开时启用抢救模式: 头页校验失败仍只读打开 (写禁用)。
+	// 只影响 vrtx: 源/目标, 供头页损坏后抢救数据。
+	ForceInspect bool
 	// Limit 每张表最多迁移多少行 (0 = 不限, 便于先试跑)。
 	Limit uint64
 	// StrictValue 值装不进目标列时报错 (false: 按 VRTX 语义静默截断, 会丢数据)。

+ 5 - 1
migrate/vrtx.go

@@ -28,7 +28,11 @@ type VRTX struct {
 
 // OpenVRTX 打开 (不存在则新建) 一个 VRTX 库文件。
 func OpenVRTX(path string, opts *Options) (*VRTX, error) {
-	db, err := core.Open(path)
+	openOpt := core.OpenOptions{}
+	if opts != nil && opts.ForceInspect {
+		openOpt.ForceInspect = true
+	}
+	db, err := core.OpenWithOptions(path, openOpt)
 	if err != nil {
 		return nil, fmt.Errorf("打开 VRTX 库 %s: %w", path, err)
 	}