ソースを参照

feat(orm): blob 列流式载体 types.Blob 全生命周期打通

建模: 放行 types.Blob 载体 (自动推断为 blob 列), []byte 保留为一次性载体;
core: DB.BlobCommit 无事务入口 (ORM 逐行写 blob 不再每行开 tx, 避免
  1500 倍性能回退); 修 pageHdrNextPage 大端误读 —— 多页链删行后链页
  永不归还的泄漏;
convert: 修 blobFieldToRef 判空顺序 (读回镜像曾被误判空 blob, Update
  一发整条链被抹掉); 新增 backfillBlobRefs —— Insert/Update/BatchInsert
  成功后回填 ref 并清掉已消费的 Source;
Table: 新增 BlobReader() 供流式载体 Open;

测试: blob_orm_tx_test (回滚/NULL/删行归还), blob_stream_orm_test
  (流式载体回填/保链/重开); demo/blob_orm 六场景全景。
yl32940 1 ヶ月 前
親
コミット
43aac6bab5

+ 25 - 36
DB/Table.go

@@ -47,9 +47,11 @@ func (t *Table[T]) SchemaColumns() []string {
 // Update / Delete / Reload (见 model.go); 同时把引擎分配的行号带回对象, 之后
 // obj.Update() / obj.Delete() 才能精确到"这一行"。
 //
-// 自动开 tx: Blob 列必须在 tx 上下文里调用 tx.BlobCommit 落链, ORM 自动接管这一
-// 边界 —— 调用方无需手动 tx.Begin/Commit; 没有 Blob 列时 tx 仍是 no-op (只是
-// Begin/Commit 两次锁开销, 测得 ~50ns/调用, 不值得专门判断).
+// Blob 列的落链由 t.db.coreDB (无事务版 BlobCommitter) 承接: 用户不需要手动开事务,
+// 写入的内容照样自动落链; 用户若已显式开事务, 链页会经 DB.NewPage 登记进该事务,
+// Rollback 整链回收。这里【不】自动 begin-tx + 立即 Commit —— tx.Commit 只要缓冲池
+// 里有脏页 (单行插入必然有) 就走完整 WAL + fsync (~37ms/行), 逐行插入会比防抖提交
+// 慢三个数量级, 且 SetCommitPreset 的批量策略被整体绕过 (a56b71d 的性能回退根因)。
 func (t *Table[T]) Insert(obj *T) error {
 	t.bindModel(obj)
 
@@ -92,23 +94,18 @@ func (t *Table[T]) Insert(obj *T) error {
 		}
 	}
 
-	tx := t.db.BeginTransaction()
-	fvs, err := structToFieldValues(obj, t.model, t.activeColumns, tx)
+	fvs, err := structToFieldValues(obj, t.model, t.activeColumns, t.db.coreDB)
 	if err != nil {
-		_ = tx.Rollback()
 		return fmt.Errorf("tsdb: insert: %w", err)
 	}
 
 	// pkID == 0 (结构体没有主键字段) 时由 core 自行分配
 	assigned, err := t.coreTable.InsertRowWithID(pkID, fvs)
 	if err != nil {
-		_ = tx.Rollback()
 		return fmt.Errorf("tsdb: insert: %w", err)
 	}
 	setRowIDOf(obj, assigned)
-	if cerr := tx.Commit(); cerr != nil {
-		return fmt.Errorf("tsdb: insert: tx commit: %w", cerr)
-	}
+	backfillBlobRefs(obj, t.model, fvs)
 	return nil
 }
 
@@ -186,6 +183,7 @@ func (t *Table[T]) BatchInsert(objs []*T) error {
 	}
 	for i, obj := range objs {
 		setRowIDOf(obj, assigned[i])
+		backfillBlobRefs(obj, t.model, fvs[i])
 	}
 	return nil
 }
@@ -195,63 +193,45 @@ func (t *Table[T]) BatchInsert(objs []*T) error {
 // 优先按对象携带的引擎行号定位 (从表里读出来或插入过的对象都有), 这样同一时间戳
 // 下有多行时改的确实是"自己那一行"; 行号未知时退回按主键列定位 (旧行为)。
 //
-// 自动开 tx: Blob 列更新时 tx.BlobCommit 写新链, 引擎侧 releaseOldBlob 自动释放旧链
-// (ReleaseOld blob ref 与新 ref 不同时); 没有 Blob 列时 tx 是 no-op.
+// Blob 列的落链与旧链回收都由引擎在 UpdateRow 内完成 (新链走 coreDB 的无事务
+// BlobCommitter, 旧链走 deferred free), 与 Insert 同一套边界 —— 不自动开 tx。
 func (t *Table[T]) Update(obj *T) error {
 	t.bindModel(obj)
 
-	tx := t.db.BeginTransaction()
-	fvs, err := structToFieldValues(obj, t.model, t.activeColumns, tx)
+	fvs, err := structToFieldValues(obj, t.model, t.activeColumns, t.db.coreDB)
 	if err != nil {
-		_ = tx.Rollback()
 		return fmt.Errorf("tsdb: update: %w", err)
 	}
 
 	if id := rowIDOf(obj); id != 0 {
 		if uerr := t.coreTable.UpdateRowByPK(id, fvs); uerr != nil {
-			_ = tx.Rollback()
 			return fmt.Errorf("tsdb: update: %w", uerr)
 		}
-		if cerr := tx.Commit(); cerr != nil {
-			return fmt.Errorf("tsdb: update: tx commit: %w", cerr)
-		}
+		backfillBlobRefs(obj, t.model, fvs)
 		return nil
 	}
 
 	pk, err := getPKValue(obj, t.model)
 	if err != nil {
-		_ = tx.Rollback()
 		return fmt.Errorf("tsdb: update: %w", err)
 	}
 	if pk == 0 {
-		_ = tx.Rollback()
 		return fmt.Errorf("tsdb: update: primary key is 0")
 	}
 
 	if uerr := t.coreTable.UpdateRow(t.pkIndexName(), encodePKKey(pk), fvs); uerr != nil {
-		_ = tx.Rollback()
 		return fmt.Errorf("tsdb: update: %w", uerr)
 	}
-	if cerr := tx.Commit(); cerr != nil {
-		return fmt.Errorf("tsdb: update: tx commit: %w", cerr)
-	}
+	backfillBlobRefs(obj, t.model, fvs)
 	return nil
 }
 
 // Delete 按主键删除一条记录
 //
-// 自动开 tx: 删除时引擎走 heapReleaseRow + releaseRowBlobRefs 回收堆块与 blob 链,
-// 都在 tx 的 deferred free 列表中, 与 Insert/Update 边界一致.
+// 引擎走 heapReleaseRow + releaseRowBlobRefs 回收堆块与 blob 链, 登记进 deferred
+// free 队列 (有活跃事务时随 tx, 无事务时由 Commit/Close 收尾) —— 不自动开 tx。
 func (t *Table[T]) Delete(id uint64) error {
-	tx := t.db.BeginTransaction()
-	if err := t.coreTable.DeleteRow(t.pkIndexName(), encodePKKey(id)); err != nil {
-		_ = tx.Rollback()
-		return err
-	}
-	if err := tx.Commit(); err != nil {
-		return fmt.Errorf("tsdb: delete: tx commit: %w", err)
-	}
-	return nil
+	return t.coreTable.DeleteRow(t.pkIndexName(), encodePKKey(id))
 }
 
 // Find 按主键查找一条记录
@@ -329,6 +309,15 @@ func (t *Table[T]) Query() *Query[T] {
 	return &Query[T]{table: t}
 }
 
+// BlobReader 返回本表的 blob 读取入口 (core.Table, 实现了 types.BlobReader)。
+//
+// 用途: 流式载体 types.Blob 的读回 —— row.Body.Open(tbl.BlobReader()) 拿
+// io.ReadCloser, 按流消费 (io.Copy / 逐块), 不把内容整块拉进内存; 适合大对象。
+// []byte 载体不需要它 (ORM 读回时自动解链)。
+func (t *Table[T]) BlobReader() types.BlobReader {
+	return t.coreTable
+}
+
 func (t *Table[T]) pkIndexName() string {
 	return pkIndexPrefix + t.name
 }

+ 157 - 0
DB/blob_orm_tx_test.go

@@ -0,0 +1,157 @@
+package DB
+
+import (
+	"bytes"
+	"path/filepath"
+	"testing"
+
+	"TsDB2/DB/core"
+)
+
+// TestBlobColumnORMTxRollback 显式事务内 ORM Insert 写 blob, 回滚后:
+//  1. 行确实没了 (FindByPK 报 not found);
+//  2. blob 链页整链归还空闲链 (Insert 注释承诺: 用户已开事务时链页经 NewPage
+//     登记进该事务, Rollback 整链回收 —— blob_stream demo 在 core 层验过,
+//     ORM 路径必须同样成立, 否则回滚一次就泄漏一批链页)。
+func TestBlobColumnORMTxRollback(t *testing.T) {
+	dbPath := filepath.Join(t.TempDir(), "blob_tx_rb.db")
+	db, err := Open(dbPath)
+	if err != nil {
+		t.Fatalf("打开: %v", err)
+	}
+	defer func() { _ = db.Close() }()
+	db.SetAutoCommitDelay(0)
+
+	tbl, err := UseTable[blobOrmRow](db, "blobtx")
+	if err != nil {
+		t.Fatalf("建表: %v", err)
+	}
+
+	freeBefore := db.CoreDB().DebugFreePageCount()
+
+	tx := db.BeginTransaction()
+	big := make([]byte, core.MaxBlobSize*3+9) // 跨 3+ 个链页, 回滚归还才有可观测量
+	for i := range big {
+		big[i] = byte(i % 199)
+	}
+	obj := &blobOrmRow{Name: "tx-rb", Body: big}
+	if err := tbl.Insert(obj); err != nil {
+		t.Fatalf("事务内 Insert: %v", err)
+	}
+	if err := tx.Rollback(); err != nil {
+		t.Fatalf("Rollback: %v", err)
+	}
+
+	// 行已不存在。
+	if _, err := tbl.Find(obj.ID); err == nil {
+		t.Fatal("回滚后行仍可读出")
+	}
+
+	// 链页归还: 空闲页数不少于回滚前 (归还的链页挂回空闲链)。
+	freeAfter := db.CoreDB().DebugFreePageCount()
+	if freeAfter < freeBefore {
+		t.Fatalf("回滚后空闲页 %d < 回滚前 %d: blob 链页未归还", freeAfter, freeBefore)
+	}
+	t.Logf("空闲页: 回滚前=%d 回滚后=%d (归还 %d 页)", freeBefore, freeAfter, freeAfter-freeBefore)
+}
+
+// TestBlobColumnORMNullAndDelete
+//   - NULL/nil/空 blob: nil 切片按"空 blob"语义 (见 convert.go: 只认指针/接口,
+//     nil 切片是空值不是 NULL) 存空 ref, 读回必须是空内容而不是报错;
+//   - Delete 行后, 该行 blob 链页必须归还空闲链 —— 只删行不还链是慢性泄漏,
+//     大对象场景下几轮删插就把文件撑爆。
+func TestBlobColumnORMNullAndDelete(t *testing.T) {
+	dbPath := filepath.Join(t.TempDir(), "blob_null_del.db")
+	db, err := Open(dbPath)
+	if err != nil {
+		t.Fatalf("打开: %v", err)
+	}
+	defer func() { _ = db.Close() }()
+	db.SetAutoCommitDelay(0)
+
+	tbl, err := UseTable[blobOrmRow](db, "blobnd")
+	if err != nil {
+		t.Fatalf("建表: %v", err)
+	}
+
+	// nil / 空 blob 正常往返。
+	nul := &blobOrmRow{Name: "nil-body", Body: nil}
+	empty := &blobOrmRow{Name: "empty-body", Body: []byte{}}
+	for _, o := range []*blobOrmRow{nul, empty} {
+		if err := tbl.Insert(o); err != nil {
+			t.Fatalf("Insert(%s): %v", o.Name, err)
+		}
+		got, err := tbl.Find(o.ID)
+		if err != nil {
+			t.Fatalf("Find(%s): %v", o.Name, err)
+		}
+		if len(got.Body) != 0 {
+			t.Fatalf("%s: 读回 %d 字节, 期望空", o.Name, len(got.Body))
+		}
+	}
+	if err := db.Commit(); err != nil {
+		t.Fatalf("提交: %v", err)
+	}
+
+	// Delete 后链页归还: 先插一条大 blob, 记空闲页数, 删行提交, 空闲页应回升。
+	big := make([]byte, core.MaxBlobSize*2+11)
+	for i := range big {
+		big[i] = byte(i % 253)
+	}
+	obj := &blobOrmRow{Name: "to-delete", Body: big}
+	if err := tbl.Insert(obj); err != nil {
+		t.Fatalf("Insert: %v", err)
+	}
+	if err := db.Commit(); err != nil {
+		t.Fatalf("提交: %v", err)
+	}
+	freeBefore := db.CoreDB().DebugFreePageCount()
+
+	if err := tbl.Delete(obj.ID); err != nil {
+		t.Fatalf("Delete: %v", err)
+	}
+	if err := db.Commit(); err != nil {
+		t.Fatalf("提交: %v", err)
+	}
+	if _, err := tbl.Find(obj.ID); err == nil {
+		t.Fatal("删除后行仍可读出")
+	}
+
+	freeAfter := db.CoreDB().DebugFreePageCount()
+	if freeAfter <= freeBefore {
+		t.Fatalf("删除带大 blob 的行后空闲页 %d 未回升 (删除前 %d): blob 链页泄漏",
+			freeAfter, freeBefore)
+	}
+	t.Logf("空闲页: 删前=%d 删后=%d (归还 %d 页)", freeBefore, freeAfter, freeAfter-freeBefore)
+
+	// 其余行不受牵连。
+	got, err := tbl.Find(nul.ID)
+	if err != nil {
+		t.Fatalf("删除牵连 Find(nil-body): %v", err)
+	}
+	if len(got.Body) != 0 || got.Name != "nil-body" {
+		t.Fatalf("删除牵连邻行: %+v", got)
+	}
+
+	// 重开后内容依旧 (链上字节没被误归还污染)。
+	if err := db.Close(); err != nil {
+		t.Fatalf("关库: %v", err)
+	}
+	db2, err := Open(dbPath)
+	if err != nil {
+		t.Fatalf("重开: %v", err)
+	}
+	defer func() { _ = db2.Close() }()
+	db2.SetAutoCommitDelay(0)
+	tbl2, err := UseTable[blobOrmRow](db2, "blobnd")
+	if err != nil {
+		t.Fatalf("重开建表: %v", err)
+	}
+	got2, err := tbl2.Find(empty.ID)
+	if err != nil {
+		t.Fatalf("重开 Find: %v", err)
+	}
+	if !bytes.Equal(got2.Body, []byte{}) || got2.Name != "empty-body" {
+		t.Fatalf("重开后数据异常: %+v", got2)
+	}
+}

+ 146 - 0
DB/blob_stream_orm_test.go

@@ -0,0 +1,146 @@
+package DB
+
+import (
+	"bytes"
+	"hash/crc32"
+	"io"
+	"path/filepath"
+	"testing"
+
+	"TsDB2/DB/core"
+	"TsDB2/DB/core/types"
+)
+
+// blobStreamRow 流式载体: 字段本身就是"持有流的对象" (types.Blob),
+// 不写 type:blob tag 也会被 goTypeToFieldType 自动推断为 blob 列。
+type blobStreamRow struct {
+	BaseModel
+	Name string     `vrtx:"maxlen:32"`
+	Data types.Blob `vrtx:"type:blob"`
+}
+
+// TestBlobStreamCarrierRoundTrip 流式载体 (types.Blob) 的完整生命周期:
+//
+//  1. 写: Data.Source 是 io.Reader, 引擎流式落链 (不整块进内存);
+//  2. 回填: Insert 成功后 ref (FirstPageID/TotalLen) 已回填进对象, Source 清空;
+//  3. 读: Find 只填 ref, Open(table) 按需拿 io.ReadCloser 流式消费;
+//  4. 保链: 只改普通字段的 Update 不抹掉 blob (读回镜像分支);
+//  5. 重开: 关库重开照样流式读得回。
+func TestBlobStreamCarrierRoundTrip(t *testing.T) {
+	dbPath := filepath.Join(t.TempDir(), "blob_stream_orm.db")
+	db, err := Open(dbPath)
+	if err != nil {
+		t.Fatalf("打开: %v", err)
+	}
+	defer func() { _ = db.Close() }()
+	db.SetAutoCommitDelay(0)
+
+	tbl, err := UseTable[blobStreamRow](db, "stream")
+	if err != nil {
+		t.Fatalf("建表: %v", err)
+	}
+
+	content := make([]byte, core.MaxBlobSize*3+7) // 跨 3+ 页, 单页链测不出链指针问题
+	for i := range content {
+		content[i] = byte(i % 251)
+	}
+	wantCRC := crc32.ChecksumIEEE(content)
+
+	row := &blobStreamRow{Name: "stream", Data: types.Blob{Source: bytes.NewReader(content)}}
+	if err := tbl.Insert(row); err != nil {
+		t.Fatalf("Insert: %v", err)
+	}
+	if err := db.Commit(); err != nil {
+		t.Fatalf("Commit: %v", err)
+	}
+
+	// 2. ref 已回填, Source 已清 (再 Update 不会把死流灌一遍)
+	if row.Data.FirstPageID == 0 || row.Data.TotalLen != uint32(len(content)) {
+		t.Fatalf("Insert 后 ref 未回填: %+v", row.Data)
+	}
+	if row.Data.Source != nil {
+		t.Fatalf("Insert 后 Source 应被清掉")
+	}
+
+	// 3. 读回: ref + Open 流式消费
+	got, err := tbl.Find(row.ID)
+	if err != nil {
+		t.Fatalf("Find: %v", err)
+	}
+	rc, err := got.Data.Open(tbl.BlobReader())
+	if err != nil {
+		t.Fatalf("Open: %v", err)
+	}
+	data, err := io.ReadAll(rc)
+	if err != nil {
+		t.Fatalf("流式读: %v", err)
+	}
+	_ = rc.Close()
+	if crc32.ChecksumIEEE(data) != wantCRC {
+		t.Fatalf("流式读内容不一致")
+	}
+
+	// 4. 只改 Name 的 Update: blob 链必须原样保留 (曾因 blobFieldToRef 判空顺序
+	//    把镜像当空 blob, Update 一发整条链被抹掉)
+	freeBefore := db.CoreDB().DebugFreePageCount()
+	got.Name = "renamed"
+	if err := tbl.Update(got); err != nil {
+		t.Fatalf("Update: %v", err)
+	}
+	if err := db.Commit(); err != nil {
+		t.Fatalf("Commit: %v", err)
+	}
+	renamed, err := tbl.Find(got.ID)
+	if err != nil {
+		t.Fatalf("Find: %v", err)
+	}
+	if renamed.Data.TotalLen != uint32(len(content)) {
+		t.Fatalf("改名 Update 后 blob 丢失: TotalLen=%d", renamed.Data.TotalLen)
+	}
+	rc2, err := renamed.Data.Open(tbl.BlobReader())
+	if err != nil {
+		t.Fatalf("Open: %v", err)
+	}
+	again, err := io.ReadAll(rc2)
+	if err != nil {
+		t.Fatalf("流式读: %v", err)
+	}
+	_ = rc2.Close()
+	if crc32.ChecksumIEEE(again) != wantCRC {
+		t.Fatalf("改名 Update 后内容不一致")
+	}
+	// 链页没被回收也没新开 (Update 未触碰 blob)
+	if n := db.CoreDB().DebugFreePageCount(); n != freeBefore {
+		t.Logf("注意: Update 前后空闲页 %d -> %d", freeBefore, n)
+	}
+
+	// 5. 重开库
+	if err := db.Close(); err != nil {
+		t.Fatalf("关库: %v", err)
+	}
+	db2, err := Open(dbPath)
+	if err != nil {
+		t.Fatalf("重开: %v", err)
+	}
+	defer func() { _ = db2.Close() }()
+	tbl2, err := UseTable[blobStreamRow](db2, "stream")
+	if err != nil {
+		t.Fatalf("重开建表: %v", err)
+	}
+	reloaded, err := tbl2.Find(row.ID)
+	if err != nil {
+		t.Fatalf("重开 Find: %v", err)
+	}
+	rc3, err := reloaded.Data.Open(tbl2.BlobReader())
+	if err != nil {
+		t.Fatalf("重开 Open: %v", err)
+	}
+	persist, err := io.ReadAll(rc3)
+	if err != nil {
+		t.Fatalf("重开流式读: %v", err)
+	}
+	_ = rc3.Close()
+	if crc32.ChecksumIEEE(persist) != wantCRC {
+		t.Fatalf("重开后内容不一致")
+	}
+}

+ 57 - 5
DB/convert.go

@@ -144,6 +144,54 @@ func findField(v reflect.Value, name string, t reflect.Type) reflect.Value {
 	return reflect.Value{}
 }
 
+// backfillBlobRefs Insert/Update 成功后把落库拿到的 BlobRef 回填进对象。
+//
+// 只处理流式载体 types.Blob 的字段: 序列化时字段值被换成行内 8B ref, 但对象里的
+// Source/Bytes 原样留着 —— 两个后果都实际伤人:
+//
+//   1. Insert 后对象不知道自己的链在哪 (row.Data.FirstPageID == 0), demo/上层无从
+//      引用;
+//   2. 更致命: structToFieldValues 无脏字段跟踪, 后续 Update 会全列重序列化。
+//      零值 types.Blob 走 blobFieldToRef 得到空 ref —— 用户只是改了 Name 调一次
+//      Update, blob 链就被整体抹成空。
+//
+// 回填即根治: 清掉已消费的 Source/Bytes, 填入 FirstPageID/TotalLen —— 下次 Update
+// 走 blobFieldToRef 的"读回镜像"分支 (FirstPageID != 0 且无内容), 原 ref 原样下传,
+// 链不动。[]byte 载体不需要回填 (它就是内容本身, 每次全量重写是既定语义)。
+func backfillBlobRefs(obj interface{}, model *ModelInfo, fvs []types.FieldValue) {
+	v := reflect.ValueOf(obj)
+	if v.Kind() != reflect.Ptr || v.Elem().Kind() != reflect.Struct {
+		return
+	}
+	v = v.Elem()
+
+	blobType := reflect.TypeOf(types.Blob{})
+	for _, fd := range model.AllFields {
+		if fd.FieldType != types.FieldType_Blob {
+			continue
+		}
+		target := fieldByModelPath(v, fd, model.StructType)
+		if !target.IsValid() || !target.CanSet() || target.Type() != blobType {
+			continue
+		}
+		for _, fv := range fvs {
+			if string(fv.FieldName) != fd.ColName {
+				continue
+			}
+			ref, ok := fv.Value.(types.BlobRef)
+			if !ok {
+				continue
+			}
+			// Source/Bytes 清掉: 流已被 BlobCommit 消费 (reader 位于 EOF),
+			// 留着只会让下一次 Update 把死流再灌一遍或报错。
+			target.Set(reflect.ValueOf(types.Blob{
+				FirstPageID: ref.FirstPageID,
+				TotalLen:    ref.TotalLen,
+			}))
+		}
+	}
+}
+
 // normalizeValue 将反射字段值转换为 core 可接受的值
 //
 // blobc: Blob 列的写入入口 (一般是 *core.Transaction). 传 nil 时要求字段已经自带
@@ -252,14 +300,18 @@ func blobFieldToRef(carrier interface{}, fd FieldDef, blobc types.BlobCommitter)
 	case types.Blob:
 		// Blob 里"已读回"模式 (FirstPageID/TotalLen/Compress 已填) 直接转 ref;
 		// 写侧 (Source/Bytes) 需要 blobc.
+		//
+		// 判定顺序很关键: 必须先看 FirstPageID 再判空。types.Blob.Empty() 的写侧判空
+		// 是 "Source 与 Bytes 都空", 但"读回镜像"(引擎 Find 填的 ref / backfillBlobRefs
+		// 回填的 ref) 恰恰就是 Source/Bytes 全空、FirstPageID 非 0 —— 若先按
+		// "Source==nil && Bytes==空 => 空 blob" 短路, 镜像会被误判成空, Update 一发
+		// 整条链就被抹掉 (曾经正是这么写的, 镜像分支成了死代码)。
+		if v.FirstPageID != 0 && v.Source == nil && len(v.Bytes) == 0 {
+			return types.BlobRef{TotalLen: v.TotalLen, FirstPageID: v.FirstPageID}, nil
+		}
 		if v.Source == nil && len(v.Bytes) == 0 {
 			return types.BlobRef{}, nil
 		}
-		// 兼容: FirstPageID 非 0 且 Source/Bytes 都空 — 用户在 ORM 层手动构造"读回镜像
-		// 后再送" 的场景; 不需要 blobc.
-		if v.FirstPageID != 0 && len(v.Bytes) == 0 && v.Source == nil {
-			return types.BlobRef{TotalLen: v.TotalLen, FirstPageID: v.FirstPageID}, nil
-		}
 		if blobc == nil {
 			return types.BlobRef{}, fmt.Errorf("vrtx: blob 列 %s 需要事务上下文才能写, 请在 ORM 操作内自动 begin-tx (或显式 tx.BeginTransaction)", fd.ColName)
 		}

+ 20 - 0
DB/core/blob_commit.go

@@ -38,6 +38,26 @@ func (tx *Transaction) BlobCommit(r io.Reader, compress types.FieldCompress) (Bl
 	return tx.db.blobCommit(r, compress)
 }
 
+// BlobCommit 无事务上下文版的 BlobCommit —— 让 *DB 直接满足 types.BlobCommitter。
+//
+// 用途: ORM 层 Table.Insert/Update/Delete 把 *core.DB 当 blobc 传给 structToFieldValues,
+// 用户不需要手动开事务也能写 Blob 列 (内容自动落链, 行内只存 8B 引用)。
+//
+// 事务语义: 调用时若恰有活跃事务 (db.currentTxn != nil), 链页经 DB.NewPage 自动登记进
+// 该事务的 allocated 列表, Rollback 一样能整链回收; 无事务时链页与普通脏页同命运,
+// 随 Commit / Close 落盘。因此对调用方而言语义与 tx.BlobCommit 一致。
+//
+// 千万不要为了拿这个入口回到"ORM 每行自动 begin-tx + 立即 Commit"的老路: tx.Commit
+// 只要缓冲池里有脏页 (单行插入必然有) 就走完整 WAL + fsync, ~37ms/行 —— 逐行插入
+// 10000 行要 6 分钟, SetCommitPreset 的防抖/阈值机制被整体绕过 (a56b71d 引入、本次
+// 修复的 1500 倍性能回退就是它)。
+func (db *DB) BlobCommit(r io.Reader, compress types.FieldCompress) (BlobRef, error) {
+	if db == nil {
+		return BlobRef{}, errors.New("BlobCommit: db is nil")
+	}
+	return db.blobCommit(r, compress)
+}
+
 // blobCommit 是 BlobCommit 的无事务版本: 同样的流式写入, 但不要求调用方持有 Transaction.
 //
 // 为什么需要它: FieldType_Blob 列的行内只有 8 字节 BlobRef, 而用户往这一列写的是

+ 9 - 2
DB/core/blob_read.go

@@ -119,10 +119,17 @@ func (t *Table) BlobFree(ref BlobRef) error {
 }
 
 // pageHdrNextPage 读 PageHdr.NextPage, 避免依赖 page 包类型 (本文件独立可读).
+//
+// ★ 必须按小端解: PageHdr 是 unsafe 结构体直映射落盘 (page/hdr.go), NextPage 字段
+// 按本机字节序 (小端) 序列化; 写侧 blob_writer.go 与读侧 blob_reader.go 也都走
+// page.HdrOf 直读写。这里曾按大端解 → 多页链的第二页起 NextPage 被读成 0x0N000000,
+// BlobFree 走链找尾页时 ReadPage 越界报错, 而错误被 releaseRowBlobRefs 的
+// "放任不中断"语义吞掉 —— 行删了、链页却永不归还 (单页链 NextPage=0 不受影响,
+// 所以小 blob 测试全绿, 唯独跨页大 blob 泄漏)。
 func pageHdrNextPage(buf []byte) uint32 {
 	const off = 12 // PageHdr.NextPage 在头内偏移
-	return uint32(buf[off])<<24 | uint32(buf[off+1])<<16 |
-		uint32(buf[off+2])<<8 | uint32(buf[off+3])
+	return uint32(buf[off]) | uint32(buf[off+1])<<8 |
+		uint32(buf[off+2])<<16 | uint32(buf[off+3])<<24
 }
 
 // deferFreeBlobChain 走事务的 pendingFreeChain 路径 (Commit 后真正还链).

+ 13 - 3
DB/types.go

@@ -163,6 +163,10 @@ var goTypeToFieldType = map[reflect.Type]types.FieldType{
 	reflect.TypeOf(types.Decimal{}): types.FieldType_Decimal,
 	reflect.TypeOf(types.UUID{}):    types.FieldType_UUID,
 	reflect.TypeOf(big.Int{}):       types.FieldType_Int128,
+	// types.Blob 载体: "对象里直接持有流" —— 写侧 Source/Bytes 流式落链 (内存峰值
+	// ~128KB, 与内容大小无关), 读侧引擎只填回 8B ref, 调 row.Body.Open(table) 按需
+	// 拿 io.ReadCloser。不声明 tag 即自动推断为 blob 列 (无须 type:blob)。
+	reflect.TypeOf(types.Blob{}): types.FieldType_Blob,
 }
 
 // tagFieldTypes vrtx tag 中 `type:<name>` 可声明的列类型。
@@ -768,16 +772,22 @@ func validateFieldTypeParam(fd FieldDef, sf reflect.StructField) error {
 			return fmt.Errorf("float64 列需要 float32/float64 载体, 不能是 %s", sf.Type)
 		}
 	case types.FieldType_Blob:
-		// Blob 只认 []byte —— 这里【故意不收 string】。
+		// Blob 认 []byte 与 types.Blob 两种载体 —— 后者是"对象里持有流对象"的
+		// 流式载体 (写 Source/Bytes 流式落链, 读回只填 8B ref + Open() 拿流)。
+		// 这里【故意不收 string】。
 		//
 		// 读取路径 setFieldFromValue → setBytesField 对 string 载体是按"截断到第一个
 		// NUL 字节"写的 (那是定长串/文本列的 CHAR(n) 语义)。Blob 装的是二进制,
 		// 内容里出现 0x00 就会被静默截断 —— 写进去 8 字节读回来 3 字节, 且不报错。
 		// 允许 string 等于亲手留一个静默丢数据的后门, 所以直接在建模阶段拒掉。
 		// (要存文本用 Dynamicbyte / maxlen, 那些列的 string 载体语义是明确的。)
-		if carrier.Kind() != reflect.Slice || carrier.Elem().Kind() != reflect.Uint8 {
-			return fmt.Errorf("blob 列需要 []byte 载体, 不能是 %s (二进制里的 0x00 会被 string 载体静默截断)", sf.Type)
+		if carrier.Kind() == reflect.Slice && carrier.Elem().Kind() == reflect.Uint8 {
+			return nil // []byte: 一次性载体, 读回整块拉进内存
 		}
+		if carrier == reflect.TypeOf(types.Blob{}) {
+			return nil // types.Blob: 流式载体
+		}
+		return fmt.Errorf("blob 列需要 []byte / types.Blob 载体, 不能是 %s (二进制里的 0x00 会被 string 载体静默截断)", sf.Type)
 	}
 	return nil
 }

+ 5 - 1
analyzer/main.go

@@ -28,7 +28,11 @@ const (
 	PGSZ    = 16384
 	HDRSZ   = 4096
 	PHDR    = 20               // PageHdr actual size (with alignment)
-	DP_HDR  = PHDR + 4 + 8 + 8 // DataPage header: PageHdr(20) + Count(4) + Min(8) + Max(8) = 40
+	DP_HDR  = 48               // DataPage header (DB/core/page/datapage.go DataPageHdrSize):
+	                          //   PageHdr(20) + Count(4) + Min(8) + Max(8) + LiveCnt(4) + Dead(4) = 48
+	                          //   v10 在 Count/Min/Max 之后新增了 LiveCnt/Dead 用于 O(1) COUNT 与
+	                          //   失效占比判断; 漏掉这 8B 会让 eachRow 从 LiveCnt/Dead 上读出
+	                          //   假 row header, pkID 全部错位, 进而把整库二级索引条目误判为悬空。
 	IDX_HDR = 32               // PHDR(20) + pad(1) + IndexType(1) + EntryLen(2) + Count(4) + IsLeaf(1) + pad(3)
 	// 下面两个尺寸与 TI_* / IM_* 偏移与 DB/core 的 IndexMeta/TableInfo 布局一一对应。
 	// TableNameSize = 48 且 IndexNameSize = 64 时: IndexMeta 100 字节, TableInfo 104 字节。

+ 130 - 130
demo/bench_10m/full_run.log

@@ -2,7 +2,7 @@
 ========================================================================================================
 P0 环境与参数
 ========================================================================================================
-  时间        : 2026-09-15 09:46:02
+  时间        : 2026-09-15 19:34:15
   Go          : go1.24.5 | CPU 8 核 (GOMAXPROCS=8) | darwin/amd64
   规模        : 主表 10000000 行 | 订单 1000000 行 | 部门 100 行 | 单行更新 2000 次 | 逐行删除 100000 行
   参数        : Age=20+No%50 | Score=No%1000 | Dept=Dept(No%100) | Salary=No%100000
@@ -10,14 +10,14 @@ P0 环境与参数
   提交策略    : preset=batch(脏页阈值 500 页), 自动提交防抖禁用, 每 100 万行显式 Commit
   主库        : /Users/yl32940/Projects/TsDB2/demo/bench_10m/bench_10m.db
   探针库      : /Users/yl32940/Projects/TsDB2/demo/bench_10m/bench_10m_probe.db
-  GoAlloc=0MB Sys=6MB HeapInuse=0MB NumGC=0
+  GoAlloc=0MB Sys=6MB HeapInuse=1MB NumGC=0
 
   清理同前缀旧库文件...
 
 ========================================================================================================
 P1 建表 / 打开表 + 索引
 ========================================================================================================
-  UseTable × 3 (含建表/建索引)                               行数=3         耗时=1ms         5.91k/s
+  UseTable × 3 (含建表/建索引)                               行数=3         耗时=1ms         5.24k/s
   emp 活跃列 : [_id No Name Age Score Dept Salary Active]
   emp 字段数 : 总 8 / 活跃 8 | TotalRows=0
   索引 pk_emp         type=2  分量=1 fieldIDs=[0]
@@ -28,117 +28,117 @@ P1 建表 / 打开表 + 索引
 ========================================================================================================
 P2 千万级写入: 单行 Insert × 10000000 (含进度 / 分档 Commit)
 ========================================================================================================
-  [   200000 行] 累计    8.1s | 瞬时   24.72k/s | 磁盘    44.6MB | ETA  6m37s | GoAlloc=26MB Sys=73MB HeapInuse=26MB NumGC=1124
-  [   400000 行] 累计   16.4s | 瞬时   24.00k/s | 磁盘    87.6MB | ETA  6m40s | GoAlloc=24MB Sys=77MB HeapInuse=25MB NumGC=1791
-  [   600000 行] 累计   25.1s | 瞬时   23.15k/s | 磁盘   130.3MB | ETA  6m46s | GoAlloc=28MB Sys=77MB HeapInuse=29MB NumGC=2461
-  [   800000 行] 累计   34.4s | 瞬时   21.37k/s | 磁盘   172.8MB | ETA  7m11s | GoAlloc=27MB Sys=77MB HeapInuse=27MB NumGC=3133
-  [  1000000 行] 累计   43.8s | 瞬时   21.24k/s | 磁盘   214.8MB | ETA   7m4s | GoAlloc=20MB Sys=77MB HeapInuse=21MB NumGC=3803
+  [   200000 行] 累计    9.0s | 瞬时   22.27k/s | 磁盘    44.7MB | ETA  7m20s | GoAlloc=20MB Sys=72MB HeapInuse=21MB NumGC=1124
+  [   400000 行] 累计   17.4s | 瞬时   23.62k/s | 磁盘    87.7MB | ETA  6m46s | GoAlloc=29MB Sys=72MB HeapInuse=30MB NumGC=1794
+  [   600000 行] 累计   27.1s | 瞬时   20.70k/s | 磁盘   130.2MB | ETA  7m34s | GoAlloc=27MB Sys=72MB HeapInuse=28MB NumGC=2470
+  [   800000 行] 累计   37.4s | 瞬时   19.44k/s | 磁盘   172.8MB | ETA  7m53s | GoAlloc=26MB Sys=72MB HeapInuse=27MB NumGC=3141
+  [  1000000 行] 累计   47.2s | 瞬时   20.47k/s | 磁盘   214.8MB | ETA  7m20s | GoAlloc=28MB Sys=72MB HeapInuse=29MB NumGC=3811
   [  1000000 行] 显式 Commit 30ms | 磁盘 214.8MB
-  [  1200000 行] 累计   53.4s | 瞬时   20.90k/s | 磁盘   253.2MB | ETA   7m1s | GoAlloc=29MB Sys=77MB HeapInuse=30MB NumGC=4468
-  [  1400000 行] 累计   63.0s | 瞬时   20.87k/s | 磁盘   295.0MB | ETA  6m52s | GoAlloc=31MB Sys=77MB HeapInuse=31MB NumGC=5136
-  [  1600000 行] 累计   73.5s | 瞬时   19.09k/s | 磁盘   336.8MB | ETA  7m20s | GoAlloc=29MB Sys=77MB HeapInuse=29MB NumGC=5813
-  [  1800000 行] 累计   84.0s | 瞬时   19.06k/s | 磁盘   377.4MB | ETA  7m10s | GoAlloc=21MB Sys=77MB HeapInuse=21MB NumGC=6684
-  [  2000000 行] 累计   95.2s | 瞬时   17.74k/s | 磁盘   421.7MB | ETA  7m31s | GoAlloc=28MB Sys=77MB HeapInuse=29MB NumGC=7557
-  [  2000000 行] 显式 Commit 38ms | 磁盘 421.7MB
-  [  2200000 行] 累计  106.6s | 瞬时   17.64k/s | 磁盘   463.1MB | ETA  7m22s | GoAlloc=22MB Sys=77MB HeapInuse=23MB NumGC=8429
-  [  2400000 行] 累计  118.2s | 瞬时   17.27k/s | 磁盘   503.6MB | ETA  7m20s | GoAlloc=23MB Sys=77MB HeapInuse=23MB NumGC=9303
-  [  2600000 行] 累计  129.5s | 瞬时   17.64k/s | 磁盘   547.4MB | ETA   7m0s | GoAlloc=31MB Sys=77MB HeapInuse=32MB NumGC=10175
-  [  2800000 行] 累计  140.8s | 瞬时   17.68k/s | 磁盘   588.1MB | ETA  6m47s | GoAlloc=18MB Sys=77MB HeapInuse=19MB NumGC=11048
-  [  3000000 行] 累计  152.1s | 瞬时   17.70k/s | 磁盘   628.5MB | ETA  6m36s | GoAlloc=20MB Sys=77MB HeapInuse=21MB NumGC=11919
-  [  3000000 行] 显式 Commit 38ms | 磁盘 628.5MB
-  [  3200000 行] 累计  163.3s | 瞬时   17.85k/s | 磁盘   670.1MB | ETA  6m21s | GoAlloc=30MB Sys=77MB HeapInuse=31MB NumGC=12792
-  [  3400000 行] 累计  174.4s | 瞬时   18.09k/s | 磁盘   713.0MB | ETA   6m5s | GoAlloc=31MB Sys=77MB HeapInuse=31MB NumGC=13667
-  [  3600000 行] 累计  185.7s | 瞬时   17.59k/s | 磁盘   754.3MB | ETA   6m4s | GoAlloc=17MB Sys=77MB HeapInuse=18MB NumGC=14543
-  [  3800000 行] 累计  197.1s | 瞬时   17.65k/s | 磁盘   795.6MB | ETA  5m51s | GoAlloc=26MB Sys=77MB HeapInuse=26MB NumGC=15418
-  [  4000000 行] 累计  208.5s | 瞬时   17.46k/s | 磁盘   837.6MB | ETA  5m44s | GoAlloc=18MB Sys=77MB HeapInuse=18MB NumGC=16292
-  [  4000000 行] 显式 Commit 40ms | 磁盘 837.6MB
-  [  4200000 行] 累计  220.0s | 瞬时   17.36k/s | 磁盘   880.4MB | ETA  5m34s | GoAlloc=53MB Sys=77MB HeapInuse=54MB NumGC=17166
-  [  4400000 行] 累计  231.6s | 瞬时   17.30k/s | 磁盘   919.0MB | ETA  5m24s | GoAlloc=25MB Sys=77MB HeapInuse=25MB NumGC=18038
-  [  4600000 行] 累计  243.1s | 瞬时   17.43k/s | 磁盘   961.0MB | ETA  5m10s | GoAlloc=32MB Sys=77MB HeapInuse=32MB NumGC=18913
-  [  4800000 行] 累计  254.4s | 瞬时   17.61k/s | 磁盘  1002.0MB | ETA  4m55s | GoAlloc=30MB Sys=77MB HeapInuse=31MB NumGC=19788
-  [  5000000 行] 累计  265.8s | 瞬时   17.56k/s | 磁盘  1043.5MB | ETA  4m45s | GoAlloc=22MB Sys=77MB HeapInuse=23MB NumGC=20662
-  [  5000000 行] 显式 Commit 47ms | 磁盘 1044.0MB
-  [  5200000 行] 累计  277.3s | 瞬时   17.45k/s | 磁盘  1087.1MB | ETA  4m35s | GoAlloc=17MB Sys=77MB HeapInuse=18MB NumGC=21538
-  [  5400000 行] 累计  288.8s | 瞬时   17.38k/s | 磁盘  1128.6MB | ETA  4m25s | GoAlloc=20MB Sys=77MB HeapInuse=21MB NumGC=22412
-  [  5600000 行] 累计  300.3s | 瞬时   17.37k/s | 磁盘  1170.5MB | ETA  4m13s | GoAlloc=16MB Sys=77MB HeapInuse=17MB NumGC=23289
-  [  5800000 行] 累计  311.7s | 瞬时   17.55k/s | 磁盘  1212.1MB | ETA  3m59s | GoAlloc=23MB Sys=77MB HeapInuse=23MB NumGC=24161
-  [  6000000 行] 累计  323.1s | 瞬时   17.62k/s | 磁盘  1253.1MB | ETA  3m47s | GoAlloc=18MB Sys=77MB HeapInuse=19MB NumGC=25034
-  [  6000000 行] 显式 Commit 30ms | 磁盘 1253.1MB
-  [  6200000 行] 累计  334.6s | 瞬时   17.37k/s | 磁盘  1294.7MB | ETA  3m39s | GoAlloc=30MB Sys=77MB HeapInuse=30MB NumGC=25908
-  [  6400000 行] 累计  345.8s | 瞬时   17.85k/s | 磁盘  1335.8MB | ETA  3m22s | GoAlloc=21MB Sys=77MB HeapInuse=22MB NumGC=26782
-  [  6600000 行] 累计  357.0s | 瞬时   17.84k/s | 磁盘  1376.5MB | ETA  3m11s | GoAlloc=21MB Sys=77MB HeapInuse=22MB NumGC=27656
-  [  6800000 行] 累计  368.9s | 瞬时   16.87k/s | 磁盘  1420.0MB | ETA  3m10s | GoAlloc=25MB Sys=77MB HeapInuse=26MB NumGC=28531
-  [  7000000 行] 累计  380.8s | 瞬时   16.72k/s | 磁盘  1460.3MB | ETA  2m59s | GoAlloc=23MB Sys=77MB HeapInuse=23MB NumGC=29406
-  [  7000000 行] 显式 Commit 56ms | 磁盘 1461.9MB
-  [  7200000 行] 累计  392.4s | 瞬时   17.35k/s | 磁盘  1502.2MB | ETA  2m41s | GoAlloc=20MB Sys=77MB HeapInuse=21MB NumGC=30280
-  [  7400000 行] 累计  403.9s | 瞬时   17.38k/s | 磁盘  1544.7MB | ETA  2m30s | GoAlloc=23MB Sys=77MB HeapInuse=24MB NumGC=31155
-  [  7600000 行] 累计  415.7s | 瞬时   16.93k/s | 磁盘  1583.6MB | ETA  2m22s | GoAlloc=19MB Sys=77MB HeapInuse=20MB NumGC=32030
-  [  7800000 行] 累计  427.8s | 瞬时   16.49k/s | 磁盘  1626.1MB | ETA  2m13s | GoAlloc=30MB Sys=77MB HeapInuse=30MB NumGC=32905
-  [  8000000 行] 累计  439.2s | 瞬时   17.57k/s | 磁盘  1668.6MB | ETA  1m54s | GoAlloc=28MB Sys=77MB HeapInuse=29MB NumGC=33780
+  [  1200000 行] 累计   57.3s | 瞬时   19.80k/s | 磁盘   253.2MB | ETA  7m24s | GoAlloc=25MB Sys=72MB HeapInuse=25MB NumGC=4479
+  [  1400000 行] 累计   67.4s | 瞬时   19.66k/s | 磁盘   295.0MB | ETA  7m17s | GoAlloc=24MB Sys=72MB HeapInuse=25MB NumGC=5147
+  [  1600000 行] 累计   77.6s | 瞬时   19.64k/s | 磁盘   336.8MB | ETA   7m8s | GoAlloc=23MB Sys=72MB HeapInuse=23MB NumGC=5825
+  [  1800000 行] 累计   88.8s | 瞬时   17.83k/s | 磁盘   377.4MB | ETA  7m40s | GoAlloc=28MB Sys=72MB HeapInuse=28MB NumGC=6696
+  [  2000000 行] 累计  100.5s | 瞬时   17.10k/s | 磁盘   421.7MB | ETA  7m48s | GoAlloc=25MB Sys=72MB HeapInuse=26MB NumGC=7567
+  [  2000000 行] 显式 Commit 36ms | 磁盘 421.7MB
+  [  2200000 行] 累计  112.4s | 瞬时   16.88k/s | 磁盘   463.1MB | ETA  7m42s | GoAlloc=18MB Sys=73MB HeapInuse=19MB NumGC=8441
+  [  2400000 行] 累计  124.1s | 瞬时   17.09k/s | 磁盘   503.6MB | ETA  7m25s | GoAlloc=28MB Sys=73MB HeapInuse=28MB NumGC=9312
+  [  2600000 行] 累计  135.7s | 瞬时   17.22k/s | 磁盘   547.4MB | ETA  7m10s | GoAlloc=22MB Sys=73MB HeapInuse=23MB NumGC=10185
+  [  2800000 行] 累计  147.4s | 瞬时   17.14k/s | 磁盘   588.1MB | ETA   7m0s | GoAlloc=19MB Sys=73MB HeapInuse=20MB NumGC=11055
+  [  3000000 行] 累计  159.1s | 瞬时   17.09k/s | 磁盘   628.5MB | ETA  6m50s | GoAlloc=29MB Sys=73MB HeapInuse=30MB NumGC=11926
+  [  3000000 行] 显式 Commit 41ms | 磁盘 628.5MB
+  [  3200000 行] 累计  170.6s | 瞬时   17.34k/s | 磁盘   670.1MB | ETA  6m32s | GoAlloc=19MB Sys=73MB HeapInuse=20MB NumGC=12800
+  [  3400000 行] 累计  182.1s | 瞬时   17.34k/s | 磁盘   712.9MB | ETA  6m21s | GoAlloc=31MB Sys=73MB HeapInuse=31MB NumGC=13673
+  [  3600000 行] 累计  194.0s | 瞬时   16.91k/s | 磁盘   754.3MB | ETA  6m18s | GoAlloc=23MB Sys=73MB HeapInuse=24MB NumGC=14548
+  [  3800000 行] 累计  205.5s | 瞬时   17.31k/s | 磁盘   795.6MB | ETA  5m58s | GoAlloc=19MB Sys=73MB HeapInuse=20MB NumGC=15422
+  [  4000000 行] 累计  217.1s | 瞬时   17.35k/s | 磁盘   837.6MB | ETA  5m46s | GoAlloc=26MB Sys=73MB HeapInuse=26MB NumGC=16296
+  [  4000000 行] 显式 Commit 41ms | 磁盘 837.6MB
+  [  4200000 行] 累计  229.0s | 瞬时   16.73k/s | 磁盘   880.4MB | ETA  5m47s | GoAlloc=19MB Sys=73MB HeapInuse=20MB NumGC=17170
+  [  4400000 行] 累计  240.5s | 瞬时   17.34k/s | 磁盘   919.0MB | ETA  5m23s | GoAlloc=25MB Sys=73MB HeapInuse=26MB NumGC=18043
+  [  4600000 行] 累计  252.4s | 瞬时   16.83k/s | 磁盘   960.9MB | ETA  5m21s | GoAlloc=31MB Sys=73MB HeapInuse=32MB NumGC=18917
+  [  4800000 行] 累计  264.2s | 瞬时   16.91k/s | 磁盘  1002.0MB | ETA   5m7s | GoAlloc=26MB Sys=73MB HeapInuse=27MB NumGC=19793
+  [  5000000 行] 累计  275.8s | 瞬时   17.24k/s | 磁盘  1043.5MB | ETA  4m50s | GoAlloc=31MB Sys=73MB HeapInuse=31MB NumGC=20668
+  [  5000000 行] 显式 Commit 46ms | 磁盘 1044.0MB
+  [  5200000 行] 累计  287.7s | 瞬时   16.88k/s | 磁盘  1087.2MB | ETA  4m44s | GoAlloc=24MB Sys=73MB HeapInuse=24MB NumGC=21542
+  [  5400000 行] 累计  299.6s | 瞬时   16.85k/s | 磁盘  1128.6MB | ETA  4m33s | GoAlloc=26MB Sys=73MB HeapInuse=27MB NumGC=22417
+  [  5600000 行] 累计  311.4s | 瞬时   16.83k/s | 磁盘  1170.5MB | ETA  4m21s | GoAlloc=22MB Sys=81MB HeapInuse=23MB NumGC=23289
+  [  5800000 行] 累计  325.6s | 瞬时   14.16k/s | 磁盘  1212.1MB | ETA  4m57s | GoAlloc=20MB Sys=81MB HeapInuse=21MB NumGC=24163
+  [  6000000 行] 累计  339.9s | 瞬时   13.92k/s | 磁盘  1253.1MB | ETA  4m47s | GoAlloc=28MB Sys=81MB HeapInuse=28MB NumGC=25036
+  [  6000000 行] 显式 Commit 37ms | 磁盘 1253.1MB
+  [  6200000 行] 累计  353.5s | 瞬时   14.79k/s | 磁盘  1294.7MB | ETA  4m17s | GoAlloc=31MB Sys=81MB HeapInuse=32MB NumGC=25911
+  [  6400000 行] 累计  367.6s | 瞬时   14.18k/s | 磁盘  1335.8MB | ETA  4m14s | GoAlloc=30MB Sys=81MB HeapInuse=31MB NumGC=26785
+  [  6600000 行] 累计  381.0s | 瞬时   14.92k/s | 磁盘  1376.5MB | ETA  3m48s | GoAlloc=25MB Sys=81MB HeapInuse=26MB NumGC=27658
+  [  6800000 行] 累计  394.5s | 瞬时   14.82k/s | 磁盘  1420.0MB | ETA  3m36s | GoAlloc=31MB Sys=81MB HeapInuse=31MB NumGC=28534
+  [  7000000 行] 累计  408.0s | 瞬时   14.79k/s | 磁盘  1460.3MB | ETA  3m23s | GoAlloc=27MB Sys=81MB HeapInuse=28MB NumGC=29408
+  [  7000000 行] 显式 Commit 55ms | 磁盘 1461.9MB
+  [  7200000 行] 累计  421.7s | 瞬时   14.54k/s | 磁盘  1502.2MB | ETA  3m13s | GoAlloc=25MB Sys=81MB HeapInuse=25MB NumGC=30284
+  [  7400000 行] 累计  439.5s | 瞬时   11.29k/s | 磁盘  1544.7MB | ETA  3m50s | GoAlloc=27MB Sys=81MB HeapInuse=27MB NumGC=31159
+  [  7600000 行] 累计  453.7s | 瞬时   14.06k/s | 磁盘  1583.6MB | ETA  2m51s | GoAlloc=17MB Sys=81MB HeapInuse=18MB NumGC=32034
+  [  7800000 行] 累计  467.5s | 瞬时   14.45k/s | 磁盘  1626.1MB | ETA  2m32s | GoAlloc=18MB Sys=81MB HeapInuse=19MB NumGC=32909
+  [  8000000 行] 累计  481.5s | 瞬时   14.33k/s | 磁盘  1668.6MB | ETA  2m20s | GoAlloc=24MB Sys=81MB HeapInuse=24MB NumGC=33783
   [  8000000 行] 显式 Commit 37ms | 磁盘 1668.6MB
-  [  8200000 行] 累计  450.9s | 瞬时   17.02k/s | 磁盘  1709.3MB | ETA  1m46s | GoAlloc=17MB Sys=77MB HeapInuse=18MB NumGC=34656
-  [  8400000 行] 累计  462.6s | 瞬时   17.10k/s | 磁盘  1751.8MB | ETA  1m34s | GoAlloc=17MB Sys=77MB HeapInuse=17MB NumGC=35532
-  [  8600000 行] 累计  474.3s | 瞬时   17.09k/s | 磁盘  1794.3MB | ETA  1m22s | GoAlloc=23MB Sys=77MB HeapInuse=23MB NumGC=36407
-  [  8800000 行] 累计  485.9s | 瞬时   17.28k/s | 磁盘  1833.5MB | ETA   1m9s | GoAlloc=23MB Sys=77MB HeapInuse=23MB NumGC=37284
-  [  9000000 行] 累计  497.6s | 瞬时   17.16k/s | 磁盘  1876.0MB | ETA    58s | GoAlloc=17MB Sys=77MB HeapInuse=17MB NumGC=38160
-  [  9000000 行] 显式 Commit 45ms | 磁盘 1876.0MB
-  [  9200000 行] 累计  509.2s | 瞬时   17.12k/s | 磁盘  1917.1MB | ETA    47s | GoAlloc=28MB Sys=77MB HeapInuse=28MB NumGC=39036
-  [  9400000 行] 累计  521.0s | 瞬时   17.03k/s | 磁盘  1959.5MB | ETA    35s | GoAlloc=18MB Sys=77MB HeapInuse=19MB NumGC=39914
-  [  9600000 行] 累计  532.6s | 瞬时   17.21k/s | 磁盘  2002.0MB | ETA    23s | GoAlloc=18MB Sys=77MB HeapInuse=19MB NumGC=40787
-  [  9800000 行] 累计  544.2s | 瞬时   17.26k/s | 磁盘  2041.4MB | ETA    12s | GoAlloc=24MB Sys=77MB HeapInuse=24MB NumGC=41660
-  [ 10000000 行] 累计  555.7s | 瞬时   17.43k/s | 磁盘  2083.9MB | ETA     0s | GoAlloc=28MB Sys=77MB HeapInuse=29MB NumGC=42536
-  [ 10000000 行] 显式 Commit 53ms | 磁盘 2085.1MB
-  单行 Insert × 10000000 + 分档 Commit                     行数=10000000  耗时=9m15.718s   17.99k/s
-  最终 Commit 0s | 磁盘 2085.1MB | 文件 2.04GB | GoAlloc=38MB Sys=77MB HeapInuse=38MB NumGC=42538
-  全表扫描 写入后全量校验(插入态)                                    行数=10000000  耗时=23.994s     416.77k/s
+  [  8200000 行] 累计  495.6s | 瞬时   14.18k/s | 磁盘  1709.3MB | ETA   2m7s | GoAlloc=27MB Sys=81MB HeapInuse=27MB NumGC=34657
+  [  8400000 行] 累计  510.3s | 瞬时   13.59k/s | 磁盘  1751.8MB | ETA  1m58s | GoAlloc=19MB Sys=81MB HeapInuse=20MB NumGC=35535
+  [  8600000 行] 累计  525.5s | 瞬时   13.18k/s | 磁盘  1794.3MB | ETA  1m46s | GoAlloc=27MB Sys=81MB HeapInuse=27MB NumGC=36408
+  [  8800000 行] 累计  539.9s | 瞬时   13.90k/s | 磁盘  1833.5MB | ETA  1m26s | GoAlloc=27MB Sys=81MB HeapInuse=28MB NumGC=37281
+  [  9000000 行] 累计  553.9s | 瞬时   14.23k/s | 磁盘  1876.0MB | ETA  1m10s | GoAlloc=19MB Sys=81MB HeapInuse=19MB NumGC=38156
+  [  9000000 行] 显式 Commit 61ms | 磁盘 1876.0MB
+  [  9200000 行] 累计  568.2s | 瞬时   13.98k/s | 磁盘  1917.1MB | ETA    57s | GoAlloc=28MB Sys=81MB HeapInuse=28MB NumGC=39032
+  [  9400000 行] 累计  581.8s | 瞬时   14.75k/s | 磁盘  1959.5MB | ETA    41s | GoAlloc=26MB Sys=81MB HeapInuse=27MB NumGC=39907
+  [  9600000 行] 累计  595.8s | 瞬时   14.31k/s | 磁盘  2002.0MB | ETA    28s | GoAlloc=23MB Sys=81MB HeapInuse=23MB NumGC=40781
+  [  9800000 行] 累计  610.0s | 瞬时   14.01k/s | 磁盘  2041.4MB | ETA    14s | GoAlloc=18MB Sys=81MB HeapInuse=19MB NumGC=41654
+  [ 10000000 行] 累计  624.7s | 瞬时   13.66k/s | 磁盘  2083.9MB | ETA     0s | GoAlloc=20MB Sys=81MB HeapInuse=20MB NumGC=42530
+  [ 10000000 行] 显式 Commit 56ms | 磁盘 2085.1MB
+  单行 Insert × 10000000 + 分档 Commit                     行数=10000000  耗时=10m24.726s  16.01k/s
+  最终 Commit 0s | 磁盘 2085.1MB | 文件 2.04GB | GoAlloc=47MB Sys=81MB HeapInuse=48MB NumGC=42531
+  全表扫描 写入后全量校验(插入态)                                    行数=10000000  耗时=28.129s     355.50k/s
 
 ========================================================================================================
 P3 查询性能 (主键点查 / 二级索引等值 / 范围 / 分页 / 排序 / 投影 / 聚合)
 ========================================================================================================
-  主键点查 Find(pkID) × 20000                              行数=20000     耗时=576ms       34.73k/s
-  二级索引等值 Where(No=..) × 20000                          行数=20000     耗时=1.417s      14.12k/s
-  索引等值 Count(Age=..) × 20 (每次 200000 行)                行数=4000000   耗时=230ms       17.39M/s
-  索引范围 Count(Age 30..34, 1000000 行)                    行数=1000000   耗时=39ms        25.45M/s
-  字符串索引 Count(Dept=Dept007, 100000 行)                  行数=100000    耗时=9ms         10.92M/s
-  无索引条件 Count(Score=123, 10000 行) 全表扫描                 行数=10000000  耗时=5.401s      1.85M/s
-  Limit(10).Offset(500)                                行数=10        耗时=21ms        482/s
-  OrderBy(索引列 Age) + Limit(10) 流式取前 N                  行数=10        耗时=54ms        184/s
-  OrderBy(非索引列 Score) 100000 行参与内存排序                   行数=100000    耗时=2.824s      35.41k/s
+  主键点查 Find(pkID) × 20000                              行数=20000     耗时=609ms       32.83k/s
+  二级索引等值 Where(No=..) × 20000                          行数=20000     耗时=1.782s      11.22k/s
+  索引等值 Count(Age=..) × 20 (每次 200000 行)                行数=4000000   耗时=281ms       14.23M/s
+  索引范围 Count(Age 30..34, 1000000 行)                    行数=1000000   耗时=142ms       7.02M/s
+  字符串索引 Count(Dept=Dept007, 100000 行)                  行数=100000    耗时=10ms        9.56M/s
+  无索引条件 Count(Score=123, 10000 行) 全表扫描                 行数=10000000  耗时=7.39s       1.35M/s
+  Limit(10).Offset(500)                                行数=10        耗时=23ms        433/s
+  OrderBy(索引列 Age) + Limit(10) 流式取前 N                  行数=10        耗时=65ms        153/s
+  OrderBy(非索引列 Score) 100000 行参与内存排序                   行数=100000    耗时=3.451s      28.98k/s
   Select(No,Age) 首行返回: No=1 Age=21
-  Sum(Salary) 全表                                       行数=10000000  耗时=3.147s      3.18M/s
-  Max(No)                                              行数=10000000  耗时=0s          234967.93M/s
+  Sum(Salary) 全表                                       行数=10000000  耗时=4.053s      2.47M/s
+  Max(No)                                              行数=10000000  耗时=0s          156673.51M/s
 
 ========================================================================================================
 P4 多表: 部门维度表 + 订单表 + 索引关联计数校验
 ========================================================================================================
-  维度表 dept_dim 写入 100 行                                行数=100       耗时=19ms        5.39k/s
-  [订单   200000 行] 累计   5.7s |   35.15k/s | 磁盘 2109.3MB
-  [订单   400000 行] 累计  11.9s |   33.47k/s | 磁盘 2134.2MB
-  [订单   600000 行] 累计  18.5s |   32.46k/s | 磁盘 2159.2MB
-  [订单   800000 行] 累计  25.5s |   31.38k/s | 磁盘 2186.3MB
-  [订单  1000000 行] 累计  32.9s |   30.40k/s | 磁盘 2214.5MB
-  订单表 orders 写入 1000000 行                              行数=1000000   耗时=32.943s     30.36k/s
-  关联计数: 订单表按 EmpNo 索引 × 20                             行数=20        耗时=1ms         27.69k/s
-  关联计数: 员工表按 Dept 索引 × 100 (每次 100000 行)               行数=10000000  耗时=877ms       11.40M/s
+  维度表 dept_dim 写入 100 行                                行数=100       耗时=18ms        5.61k/s
+  [订单   200000 行] 累计   6.3s |   31.76k/s | 磁盘 2109.3MB
+  [订单   400000 行] 累计  14.1s |   28.43k/s | 磁盘 2134.2MB
+  [订单   600000 行] 累计  20.5s |   29.26k/s | 磁盘 2159.2MB
+  [订单   800000 行] 累计  28.1s |   28.49k/s | 磁盘 2186.3MB
+  [订单  1000000 行] 累计  36.1s |   27.66k/s | 磁盘 2214.4MB
+  订单表 orders 写入 1000000 行                              行数=1000000   耗时=36.2s       27.62k/s
+  关联计数: 订单表按 EmpNo 索引 × 20                             行数=20        耗时=1ms         28.95k/s
+  关联计数: 员工表按 Dept 索引 × 100 (每次 100000 行)               行数=10000000  耗时=1.066s      9.38M/s
 
 ========================================================================================================
 P5 更新 (单行整行重写 / core 批量更新 / 索引列更新后索引是否同步)
 ========================================================================================================
-  单行更新 Update(整行重写) × 2000                             行数=2000      耗时=150ms       13.32k/s
-  core 批量更新 Updater.Exec (Score=500 → Age=99)          行数=10000     耗时=8.363s      1.20k/s
-  索引列更新后 Count(Age=99)                                 行数=10000     耗时=0s          20.95M/s
-  core 批量更新字符串索引列 (Score=321 → Dept=Dept099)           行数=10000     耗时=8.516s      1.17k/s
+  单行更新 Update(整行重写) × 2000                             行数=2000      耗时=152ms       13.13k/s
+  core 批量更新 Updater.Exec (Score=500 → Age=99)          行数=10000     耗时=8.731s      1.15k/s
+  索引列更新后 Count(Age=99)                                 行数=10000     耗时=1ms         17.89M/s
+  core 批量更新字符串索引列 (Score=321 → Dept=Dept099)           行数=10000     耗时=9.031s      1.11k/s
 
 ========================================================================================================
 P6 删除 (逐行删 100000 行 / 批量删除全表重建) + 全量一致性校验
 ========================================================================================================
-  逐行 Delete(pkID) × 100000                             行数=100000    耗时=22.002s     4.55k/s
-  批量删除 orders (Status=0, 200000 行) 触发全表重建              行数=200000    耗时=5.216s      38.34k/s
-  批量删除后 员工→订单 关联计数 × 20                                行数=20        耗时=1ms         23.49k/s
+  逐行 Delete(pkID) × 100000                             行数=100000    耗时=24.331s     4.11k/s
+  批量删除 orders (Status=0, 200000 行) 触发全表重建              行数=200000    耗时=5.243s      38.14k/s
+  批量删除后 员工→订单 关联计数 × 20                                行数=20        耗时=1ms         22.93k/s
 
 --- 6.3 一致性校验 (更新 + 删除之后, 期望值由公式推导) ---
-  全表扫描 删除后全量校验(公式)                                     行数=9900000   耗时=23.312s     424.67k/s
-  索引计数复核 Dept × 100                                    行数=9900000   耗时=866ms       11.43M/s
+  全表扫描 删除后全量校验(公式)                                     行数=9900000   耗时=23.293s     425.02k/s
+  索引计数复核 Dept × 100                                    行数=9900000   耗时=879ms       11.27M/s
 
 ========================================================================================================
 P7 Schema 变更: 增列 / 删列 / 改列名 (含数据正确性校验)
@@ -164,10 +164,10 @@ P7 Schema 变更: 增列 / 删列 / 改列名 (含数据正确性校验)
   [重要语义] DROP COLUMN 是软删除, 数据仍留在行内; 重新 ADD 同名列会"复活"老行数据: 这解释了为什么改列名(删+加新名)后数据看似丢失, 但加回旧列名后又能读到 → 敏感数据无法靠删列来清除, 需要重建表/覆写
 
 --- 7.6 重建 Dept 索引 + core 层全表校验 ---
-  重建索引后 索引计数复核 Dept × 100                              行数=9900003   耗时=645ms       15.36M/s
-  core 全表扫描 P7 变更后全量校验                                 行数=9900003   耗时=26.129s     378.89k/s
+  重建索引后 索引计数复核 Dept × 100                              行数=9900003   耗时=647ms       15.29M/s
+  core 全表扫描 P7 变更后全量校验                                 行数=9900003   耗时=25.995s     380.85k/s
 
-  [已关库] 磁盘 2256.8MB | GoAlloc=21MB Sys=1218MB HeapInuse=22MB NumGC=53668
+  [已关库] 磁盘 2256.8MB | GoAlloc=28MB Sys=1128MB HeapInuse=28MB NumGC=53678
 
 ========================================================================================================
 P8 引擎语义探针 (独立库 bench_10m_probe.db, 不影响主库)
@@ -205,12 +205,12 @@ P9 关库重开: 持久化 / schema / 索引 校验
 ========================================================================================================
   重开后 emp 活跃列: [_id No Name Age Score Salary Active DeptName Dept]
   TotalRows=9900003 (期望 9900003) | 磁盘 2257.5MB
-  core 全表扫描 重开后 core 全量校验                              行数=9900003   耗时=27.239s     363.45k/s
+  core 全表扫描 重开后 core 全量校验                              行数=9900003   耗时=27.462s     360.50k/s
 
 --- 9.2 ORM 重开表 (UseTable 会按 struct 同步 schema) ---
   UseTable[Emp] 之后活跃列: [_id No Name Age Score Salary Active Dept]
   [风险] ORM 重开表会按 struct 强制同步 schema, 静默删除 struct 里没有的列: 纯 core 视角活跃列=[_id No Name Age Score Salary Active DeptName Dept] 含 DeptName, 调用 UseTable[Emp] 后活跃列=[_id No Name Age Score Salary Active Dept], DeptName 被自动 DropField; 手工加过列的表被 ORM 打开就会"丢列"(数据仍在行内, 再加回同名列可复活)
-  全表扫描 重开后 ORM 全量校验                                    行数=9900003   耗时=24.085s     411.05k/s
+  全表扫描 重开后 ORM 全量校验                                    行数=9900003   耗时=24.458s     404.78k/s
 
 ========================================================================================================
 P10 汇总报告
@@ -219,38 +219,38 @@ P10 汇总报告
 【性能总表】共 32 项
 阶段         操作                                                             行数            耗时             速率
 ------------------------------------------------------------------------------------------------------------
-P1 建表      UseTable × 3 (含建表/建索引)                                          3           1ms        5.91k/s
-P2 写入      单行 Insert × 10000000 + 分档 Commit                         10000000     9m15.718s       17.99k/s
-校验         全表扫描 写入后全量校验(插入态)                                        10000000       23.994s      416.77k/s
-P3 查询      主键点查 Find(pkID) × 20000                                     20000         576ms       34.73k/s
-P3 查询      二级索引等值 Where(No=..) × 20000                                 20000        1.417s       14.12k/s
-P3 查询      索引等值 Count(Age=..) × 20 (每次 200000 行)                     4000000         230ms       17.39M/s
-P3 查询      索引范围 Count(Age 30..34, 1000000 行)                         1000000          39ms       25.45M/s
-P3 查询      字符串索引 Count(Dept=Dept007, 100000 行)                        100000           9ms       10.92M/s
-P3 查询      无索引条件 Count(Score=123, 10000 行) 全表扫描                     10000000        5.401s        1.85M/s
-P3 查询      Limit(10).Offset(500)                                          10          21ms          482/s
-P3 查询      OrderBy(索引列 Age) + Limit(10) 流式取前 N                            10          54ms          184/s
-P3 查询      OrderBy(非索引列 Score) 100000 行参与内存排序                         100000        2.824s       35.41k/s
-P3 聚合      Sum(Salary) 全表                                           10000000        3.147s        3.18M/s
-P3 聚合      Max(No)                                                  10000000            0s   234967.93M/s
-P4 多表      维度表 dept_dim 写入 100 行                                         100          19ms        5.39k/s
-P4 多表      订单表 orders 写入 1000000 行                                   1000000       32.943s       30.36k/s
-P4 多表      关联计数: 订单表按 EmpNo 索引 × 20                                       20           1ms       27.69k/s
-P4 多表      关联计数: 员工表按 Dept 索引 × 100 (每次 100000 行)                   10000000         877ms       11.40M/s
-P5 更新      单行更新 Update(整行重写) × 2000                                     2000         150ms       13.32k/s
-P5 更新      core 批量更新 Updater.Exec (Score=500 → Age=99)                 10000        8.363s        1.20k/s
-P5 校验      索引列更新后 Count(Age=99)                                        10000            0s       20.95M/s
-P5 更新      core 批量更新字符串索引列 (Score=321 → Dept=Dept099)                  10000        8.516s        1.17k/s
-P6 删除      逐行 Delete(pkID) × 100000                                   100000       22.002s        4.55k/s
-P6 删除      批量删除 orders (Status=0, 200000 行) 触发全表重建                    200000        5.216s       38.34k/s
-P6 校验      批量删除后 员工→订单 关联计数 × 20                                          20           1ms       23.49k/s
-校验         全表扫描 删除后全量校验(公式)                                          9900000       23.312s      424.67k/s
-P6 校验      索引计数复核 Dept × 100                                         9900000         866ms       11.43M/s
+P1 建表      UseTable × 3 (含建表/建索引)                                          3           1ms        5.24k/s
+P2 写入      单行 Insert × 10000000 + 分档 Commit                         10000000    10m24.726s       16.01k/s
+校验         全表扫描 写入后全量校验(插入态)                                        10000000       28.129s      355.50k/s
+P3 查询      主键点查 Find(pkID) × 20000                                     20000         609ms       32.83k/s
+P3 查询      二级索引等值 Where(No=..) × 20000                                 20000        1.782s       11.22k/s
+P3 查询      索引等值 Count(Age=..) × 20 (每次 200000 行)                     4000000         281ms       14.23M/s
+P3 查询      索引范围 Count(Age 30..34, 1000000 行)                         1000000         142ms        7.02M/s
+P3 查询      字符串索引 Count(Dept=Dept007, 100000 行)                        100000          10ms        9.56M/s
+P3 查询      无索引条件 Count(Score=123, 10000 行) 全表扫描                     10000000         7.39s        1.35M/s
+P3 查询      Limit(10).Offset(500)                                          10          23ms          433/s
+P3 查询      OrderBy(索引列 Age) + Limit(10) 流式取前 N                            10          65ms          153/s
+P3 查询      OrderBy(非索引列 Score) 100000 行参与内存排序                         100000        3.451s       28.98k/s
+P3 聚合      Sum(Salary) 全表                                           10000000        4.053s        2.47M/s
+P3 聚合      Max(No)                                                  10000000            0s   156673.51M/s
+P4 多表      维度表 dept_dim 写入 100 行                                         100          18ms        5.61k/s
+P4 多表      订单表 orders 写入 1000000 行                                   1000000         36.2s       27.62k/s
+P4 多表      关联计数: 订单表按 EmpNo 索引 × 20                                       20           1ms       28.95k/s
+P4 多表      关联计数: 员工表按 Dept 索引 × 100 (每次 100000 行)                   10000000        1.066s        9.38M/s
+P5 更新      单行更新 Update(整行重写) × 2000                                     2000         152ms       13.13k/s
+P5 更新      core 批量更新 Updater.Exec (Score=500 → Age=99)                 10000        8.731s        1.15k/s
+P5 校验      索引列更新后 Count(Age=99)                                        10000           1ms       17.89M/s
+P5 更新      core 批量更新字符串索引列 (Score=321 → Dept=Dept099)                  10000        9.031s        1.11k/s
+P6 删除      逐行 Delete(pkID) × 100000                                   100000       24.331s        4.11k/s
+P6 删除      批量删除 orders (Status=0, 200000 行) 触发全表重建                    200000        5.243s       38.14k/s
+P6 校验      批量删除后 员工→订单 关联计数 × 20                                          20           1ms       22.93k/s
+校验         全表扫描 删除后全量校验(公式)                                          9900000       23.293s      425.02k/s
+P6 校验      索引计数复核 Dept × 100                                         9900000         879ms       11.27M/s
 P7 校验      新增列索引 Count(Level=777)                                          3           1ms        3.00k/s
-P7 校验      重建索引后 索引计数复核 Dept × 100                                   9900003         645ms       15.36M/s
-校验         core 全表扫描 P7 变更后全量校验                                      9900003       26.129s      378.89k/s
-校验         core 全表扫描 重开后 core 全量校验                                   9900003       27.239s      363.45k/s
-校验         全表扫描 重开后 ORM 全量校验                                         9900003       24.085s      411.05k/s
+P7 校验      重建索引后 索引计数复核 Dept × 100                                   9900003         647ms       15.29M/s
+校验         core 全表扫描 P7 变更后全量校验                                      9900003       25.995s      380.85k/s
+校验         core 全表扫描 重开后 core 全量校验                                   9900003       27.462s      360.50k/s
+校验         全表扫描 重开后 ORM 全量校验                                         9900003       24.458s      404.78k/s
 
 【校验总表】共 87 项: 通过 87, 失败 0
 
@@ -262,6 +262,6 @@ P7 校验      重建索引后 索引计数复核 Dept × 100
   3. [风险] ORM 重开表会按 struct 强制同步 schema, 静默删除 struct 里没有的列
      纯 core 视角活跃列=[_id No Name Age Score Salary Active DeptName Dept] 含 DeptName, 调用 UseTable[Emp] 后活跃列=[_id No Name Age Score Salary Active Dept], DeptName 被自动 DropField; 手工加过列的表被 ORM 打开就会"丢列"(数据仍在行内, 再加回同名列可复活)
 
-【结论】总耗时 13m32s | 校验 87/87 通过 | 数据问题 3 项
+【结论】总耗时 14m56s | 校验 87/87 通过 | 数据问题 3 项
   主库保留: /Users/yl32940/Projects/TsDB2/demo/bench_10m/bench_10m.db (2257.5MB) | 探针库: /Users/yl32940/Projects/TsDB2/demo/bench_10m/bench_10m_probe.db (0.6MB)
   可将上述 .db 交给 analyzer 审计

+ 205 - 0
demo/blob_orm/main.go

@@ -0,0 +1,205 @@
+package main
+
+// ORM blob 全景演示 —— 两种载体, 一张表:
+//
+//   - []byte     一次性载体: 写时整块在内存, 读回整块拉进内存。小内容够用。
+//   - types.Blob 流式载体: 对象里持有"流对象" ——
+//       写侧 Data.Source 是任意 io.Reader (*os.File / 网络流 / bytes.Reader...),
+//       引擎 io.Copy 流式落链, 内存峰值 ~128KB, 与内容大小无关;
+//       读侧引擎只填回 8 字节引用 (TotalLen/FirstPageID), 内容仍躺在链页上,
+//       调 row.Data.Open(tbl.BlobReader()) 按需拿 io.ReadCloser 流式消费。
+//
+// 场景清单:
+//   [1] 流式写: 32MB 文件 → types.Blob{Source} → Insert (内存峰值 < 2MB)
+//   [2] 流式读: Find → Open → io.Copy 流式消费, 校验 CRC (同样不整块进内存)
+//   [3] []byte 载体对照: 小内容直接 Insert/Find
+//   [4] 持久性: 关库重开, 流式读仍成立
+//   [5] 删行归还: Delete 后空闲页回升 (blob 链页回到空闲链)
+
+import (
+	"fmt"
+	"hash/crc32"
+	"io"
+	"os"
+	"path/filepath"
+	"runtime"
+
+	"TsDB2/DB"
+	"TsDB2/DB/core/types"
+)
+
+type FileRow struct {
+	DB.BaseModel
+	Name string `vrtx:"maxlen:64"`
+	// 流式载体: 不写 type:blob 也会自动推断 (goTypeToFieldType 里注册了 types.Blob)。
+	// 这里显式写出来是为了 demo 的自解释。
+	Data types.Blob `vrtx:"type:blob"`
+}
+
+type SmallRow struct {
+	DB.BaseModel
+	Name string `vrtx:"maxlen:32"`
+	Body []byte `vrtx:"type:blob"` // 一次性载体: 读回直接是内容
+}
+
+func must(err error) {
+	if err != nil {
+		panic(err)
+	}
+}
+
+func check(err error) bool {
+	if err != nil {
+		fmt.Println("   !!", err)
+		return false
+	}
+	return true
+}
+
+func memPeakMB() float64 {
+	runtime.GC()
+	var ms runtime.MemStats
+	runtime.ReadMemStats(&ms)
+	return float64(ms.HeapAlloc) / 1024 / 1024
+}
+
+func main() {
+	dir, err := os.MkdirTemp("", "bloborm")
+	must(err)
+	defer os.RemoveAll(dir)
+
+	dbPath := filepath.Join(dir, "bloborm.db")
+	db, err := DB.Open(dbPath)
+	must(err)
+	db.SetAutoCommitDelay(0) // 手动 Commit, 便于对照
+	defer db.Close()
+
+	files, err := DB.UseTable[FileRow](db, "files")
+	must(err)
+	smalls, err := DB.UseTable[SmallRow](db, "smalls")
+	must(err)
+
+	// ------------------------------------------------------------------
+	// [1] 流式写: 32MB 内容从临时文件灌进链页, 内存峰值与内容大小无关
+	// ------------------------------------------------------------------
+	const size = 32 << 20
+	srcPath := filepath.Join(dir, "src.bin")
+	src := make([]byte, 1<<20)
+	for i := range src {
+		src[i] = byte(i % 251)
+	}
+	f, err := os.Create(srcPath)
+	must(err)
+	wantCRC := uint32(0)
+	for i := 0; i < size/len(src); i++ {
+		_, err = f.Write(src)
+		must(err)
+		wantCRC = crc32.Update(wantCRC, crc32.IEEETable, src)
+	}
+	must(f.Close())
+
+	f, err = os.Open(srcPath)
+	must(err)
+	before := memPeakMB()
+	row := &FileRow{Name: "32mb.bin", Data: types.Blob{Source: f}}
+	must(files.Insert(row))
+	must(db.Commit())
+	must(f.Close())
+	after := memPeakMB()
+	fmt.Printf("[1] 流式写 32MB: pk=%d, ref={TotalLen:%d FirstPageID:%d}, 内存峰值 %.1f→%.1f MB\n",
+		row.ID, row.Data.TotalLen, row.Data.FirstPageID, before, after)
+
+	// ------------------------------------------------------------------
+	// [2] 流式读: Open 拿 io.ReadCloser, io.Copy 边读边校验
+	// ------------------------------------------------------------------
+	got, err := files.Find(row.ID)
+	must(err)
+	rc, err := got.Data.Open(files.BlobReader())
+	must(err)
+	gotCRC := uint32(0)
+	buf := make([]byte, 64<<10)
+	for {
+		n, rerr := rc.Read(buf)
+		gotCRC = crc32.Update(gotCRC, crc32.IEEETable, buf[:n])
+		if rerr == io.EOF {
+			break
+		}
+		must(rerr)
+	}
+	must(rc.Close())
+	fmt.Printf("[2] 流式读 32MB: CRC %s (want %08x)\n",
+		cmpCRC(gotCRC, wantCRC), wantCRC)
+
+	// ------------------------------------------------------------------
+	// [3] []byte 载体对照: 小内容一把梭, 读回直接是 []byte
+	// ------------------------------------------------------------------
+	s := &SmallRow{Name: "hello", Body: []byte("hello blob, 一次性载体")}
+	must(smalls.Insert(s))
+	must(db.Commit())
+	back, err := smalls.Find(s.ID)
+	must(err)
+	fmt.Printf("[3] []byte 载体: 写 %q, 读回 %q\n", s.Body, back.Body)
+
+	// ------------------------------------------------------------------
+	// [3.5] Update 不碰 blob 字段 (只改名): 旧链必须原样保留
+	//       (Insert 后 ref 已回填进对象, Update 走"读回镜像"分支, 链不动)
+	// ------------------------------------------------------------------
+	got.Name = "renamed.bin"
+	must(files.Update(got))
+	must(db.Commit())
+	renamed, err := files.Find(got.ID)
+	must(err)
+	rc3, err := renamed.Data.Open(files.BlobReader())
+	must(err)
+	renameCRC, err := crc32Reader(rc3)
+	must(err)
+	must(rc3.Close())
+	fmt.Printf("[3.5] 改名 Update 后 blob: %s (%s, %d 字节)\n",
+		cmpCRC(renameCRC, wantCRC), renamed.Name, renamed.Data.TotalLen)
+
+	// ------------------------------------------------------------------
+	// [4] 持久性: 关库重开, 流式读照样成立
+	// ------------------------------------------------------------------
+	must(db.Close())
+	db2, err := DB.Open(dbPath)
+	must(err)
+	defer db2.Close()
+	files2, err := DB.UseTable[FileRow](db2, "files")
+	must(err)
+	again, err := files2.Find(row.ID)
+	must(err)
+	rc2, err := again.Data.Open(files2.BlobReader())
+	must(err)
+	reopenCRC, err := crc32Reader(rc2)
+	must(err)
+	must(rc2.Close())
+	fmt.Printf("[4] 重开库流式读: CRC %s\n", cmpCRC(reopenCRC, wantCRC))
+
+	// ------------------------------------------------------------------
+	// [5] 删行归还: Delete 后空闲页回升 (堆页 + 整条 blob 链)
+	// ------------------------------------------------------------------
+	core := db2.CoreDB()
+	freeBefore := core.DebugFreePageCount()
+	must(again.Delete())
+	must(db2.Commit())
+	freeAfter := core.DebugFreePageCount()
+	fmt.Printf("[5] 删 32MB 行: 空闲页 %d -> %d (归还 %d 页 ≈ %d KB)\n",
+		freeBefore, freeAfter, freeAfter-freeBefore, (freeAfter-freeBefore)*16)
+
+	fmt.Println("\n=== 全部场景通过 ===")
+}
+
+func cmpCRC(got, want uint32) string {
+	if got == want {
+		return "一致"
+	}
+	return fmt.Sprintf("不一致! got=%08x", got)
+}
+
+func crc32Reader(r io.Reader) (uint32, error) {
+	h := crc32.NewIEEE()
+	if _, err := io.Copy(h, r); err != nil {
+		return 0, err
+	}
+	return h.Sum32(), nil
+}

+ 51 - 33
demo/blob_stream/main.go

@@ -6,6 +6,7 @@ import (
 	"fmt"
 	"io"
 	"os"
+	"strings"
 	"time"
 
 	"TsDB2/DB"
@@ -42,7 +43,16 @@ func must(err error) {
 	}
 }
 
-// fieldValue 从 FindByPK / 读回的行里按列名取值 (找不到返 nil).
+// text 把定长字符串列读回的原样字节转成可打印文本 (定长列会补零到列宽).
+func text(v interface{}) string {
+	b, ok := v.([]byte)
+	if !ok {
+		return fmt.Sprintf("%v", v)
+	}
+	return strings.TrimRight(string(b), "\x00")
+}
+
+// fieldValue 从读回的行里按列名取值 (找不到返 nil).
 func fieldValue(fvs []types.FieldValue, name string) interface{} {
 	for _, fv := range fvs {
 		if string(fv.FieldName) == name {
@@ -66,30 +76,31 @@ func main() {
 	os.Remove(dbPath)
 
 	fmt.Println("=== TsDB2 Blob 流式存储 demo ===")
-	fmt.Println("blob 是【普通的一列】: 与 id/name/size 同在一行, 内容单独走链页。")
-	fmt.Println("写入时给 []byte / io.Reader / types.Blob 都行, 引擎自动流式落链, 不需要手动开事务。")
+	fmt.Println("blob 是【普通的一列】: 与 name/size 同在一行, 内容单独走链页。")
+	fmt.Println("写入给 []byte / io.Reader / types.Blob 都行, 引擎自动流式落链, 不用手动开事务。")
+	fmt.Println("行号 (pk) 由引擎自增分配, InsertRow 的返回值就是它 —— 不用自己造 id 列。")
 	fmt.Println()
 
 	db, err := DB.Open(dbPath)
 	must(err)
 	db.SetAutoCommitDelay(0) // 关掉防抖自动提交: 每步手动 Commit, 便于对照
 
-	// 四列: 三列普通列 + 一列 blob。blob 列在行内只占 8 字节引用, 与其它列平级。
+	// 两列普通列 + 一列 blob。blob 列在行内只占 8 字节引用, 与其它列平级。
 	tbl, err := db.CoreDB().CreateTable("files", []types.FieldInfo{
-		{FieldType: types.FieldType_Uint64, FieldName: []byte("id")},
 		{FieldType: types.FieldType_String, FieldName: []byte("name"), Flag: types.WidthCodeFor(32)},
 		{FieldType: types.FieldType_Int64, FieldName: []byte("size")},
 		{FieldType: types.FieldType_Blob, FieldName: []byte("data"), Flag: types.FlagNullable},
 	})
 	must(err)
+	// 主键 B+ 树的键始终是行号 pkID (与索引声明在哪个字段上无关, 见 PrimaryHandler),
+	// 建了它 FindByPK / UpdateRowByPK / DeleteRowByPK 才能按行号定位。
 	_, err = tbl.CreateIndex("pk", page.IndexTypePrimary, []byte{0})
 	must(err)
 
 	// ------------------------------------------------------------------
-	fmt.Println("[1] 插一行: id + name + size + data([]byte) —— 多列共存")
+	fmt.Println("[1] 插一行: name + size + data([]byte) —— 多列共存, 行号自增")
 	payload := bytes.Repeat([]byte("hello blob "), 512) // ~5.5 KB
-	_, err = tbl.InsertRowWithID(1, []types.FieldValue{
-		{FieldName: []byte("id"), Value: uint64(1)},
+	pk1, err := tbl.InsertRow([]types.FieldValue{
 		{FieldName: []byte("name"), Value: "readme.txt"},
 		{FieldName: []byte("size"), Value: int64(len(payload))},
 		{FieldName: []byte("data"), Value: payload},
@@ -97,20 +108,19 @@ func main() {
 	must(err)
 	must(db.Commit())
 
-	fvs, err := tbl.FindByPK(1)
+	fvs, err := tbl.FindByPK(pk1)
 	must(err)
 	ref1, got1, err := blobRef(tbl, fvs, "data", types.CompressNone)
 	must(err)
-	fmt.Printf("    读回整行: name=%q size=%d data=%d 字节\n",
-		string(fieldValue(fvs, "name").([]byte)), fieldValue(fvs, "size").(int64), len(got1))
+	fmt.Printf("    行号 pk=%d; 读回整行: name=%q size=%d data=%d 字节\n",
+		pk1, text(fieldValue(fvs, "name")), fieldValue(fvs, "size").(int64), len(got1))
 	fmt.Printf("    行内只放 8B 引用: ref=%+v; 内容逐字节一致: %v\n", ref1, bytes.Equal(got1, payload))
 	fmt.Println()
 
 	// ------------------------------------------------------------------
 	fmt.Println("[2] 流式插入: 给 io.Reader (超过 Dynamicbyte 的单块上限, 链能跨页)")
 	streamLen := int(core.MaxBlobSize)*2 + 7
-	_, err = tbl.InsertRowWithID(2, []types.FieldValue{
-		{FieldName: []byte("id"), Value: uint64(2)},
+	pk2, err := tbl.InsertRow([]types.FieldValue{
 		{FieldName: []byte("name"), Value: "random.bin"},
 		{FieldName: []byte("size"), Value: int64(streamLen)},
 		// 流在这里被引擎 io.Copy (32KB buf) 消费完, 内存峰值与 blob 大小无关。
@@ -119,38 +129,38 @@ func main() {
 	must(err)
 	must(db.Commit())
 
-	fvs, err = tbl.FindByPK(2)
+	fvs, err = tbl.FindByPK(pk2)
 	must(err)
 	ref2, got2, err := blobRef(tbl, fvs, "data", types.CompressNone)
 	must(err)
-	fmt.Printf("    写入 %d 字节 (单块堆页上限 %d) -> ref.TotalLen=%d 首页=%d, 读回 %d 字节\n",
-		streamLen, core.MaxBlobSize, ref2.TotalLen, ref2.FirstPageID, len(got2))
+	fmt.Printf("    pk=%d 写入 %d 字节 (单块堆页上限 %d) -> ref.TotalLen=%d 首页=%d, 读回 %d 字节\n",
+		pk2, streamLen, core.MaxBlobSize, ref2.TotalLen, ref2.FirstPageID, len(got2))
 	fmt.Println()
 
 	// ------------------------------------------------------------------
 	fmt.Println("[3] 更新同一行的 blob 列: 换内容时旧链自动归还空闲链")
 	next := []byte("second version, 换了内容")
-	err = tbl.UpdateRowByPK(2, []types.FieldValue{
+	err = tbl.UpdateRowByPK(pk2, []types.FieldValue{
 		// 只传 data: 其它列未提供 = 沿用旧行原值 (不会清空)
 		{FieldName: []byte("data"), Value: next},
 	})
 	must(err)
 	must(db.Commit())
 
-	fvs, err = tbl.FindByPK(2)
+	fvs, err = tbl.FindByPK(pk2)
 	must(err)
 	ref3, got3, err := blobRef(tbl, fvs, "data", types.CompressNone)
 	must(err)
 	fmt.Printf("    链换了: 更新前 ref=%+v / 更新后 ref=%+v\n", ref2, ref3)
 	fmt.Printf("    内容: %q; 其它列仍在: name=%q size=%d\n",
-		string(got3), string(fieldValue(fvs, "name").([]byte)), fieldValue(fvs, "size").(int64))
+		string(got3), text(fieldValue(fvs, "name")), fieldValue(fvs, "size").(int64))
 	fmt.Println()
 
 	// ------------------------------------------------------------------
 	fmt.Println("[4] 流中断: 100KB 流读到 50KB 断 —— 插入失败 + Rollback, 链页归还")
+	before := tbl.TotalRows()
 	tx := db.BeginTransaction()
-	_, err = tbl.InsertRowWithID(3, []types.FieldValue{
-		{FieldName: []byte("id"), Value: uint64(3)},
+	_, err = tbl.InsertRow([]types.FieldValue{
 		{FieldName: []byte("name"), Value: "broken.bin"},
 		{FieldName: []byte("size"), Value: int64(100_000)},
 		{FieldName: []byte("data"), Value: &brokenStream{failAt: 50_000}},
@@ -161,20 +171,28 @@ func main() {
 	}
 	fmt.Printf("    插入失败: %v\n", err)
 	must(tx.Rollback())
-	if _, err := tbl.FindByPK(3); err != nil {
-		fmt.Println("    回滚后 pk=3 查不到 (整行未生效), 已分配的链页随事务归还")
-	} else {
-		fmt.Println("    ERR: 回滚后 pk=3 却存在")
+	fmt.Printf("    回滚后行数仍是 %d (失败的行未生效, 已分配的链页随事务归还)\n", tbl.TotalRows())
+	if tbl.TotalRows() != before {
+		fmt.Println("    ERR: 行数被改动了")
 		os.Exit(1)
 	}
+	if _, err := tbl.InsertRow([]types.FieldValue{
+		{FieldName: []byte("name"), Value: "after-rollback.txt"},
+		{FieldName: []byte("size"), Value: int64(4)},
+		{FieldName: []byte("data"), Value: []byte("ok")},
+	}); err != nil {
+		fmt.Println("    ERR: 回滚后库不可用:", err)
+		os.Exit(1)
+	}
+	must(db.Commit())
+	fmt.Println("    回滚后照常插入新行: 成功")
 	fmt.Println()
 
 	// ------------------------------------------------------------------
 	fmt.Println("[5] 100 MB 随机流 + Snappy: 内存峰值固定 (~128KB, 与 blob 大小无关)")
 	const MB = 1024 * 1024
 	t0 := time.Now()
-	_, err = tbl.InsertRowWithID(4, []types.FieldValue{
-		{FieldName: []byte("id"), Value: uint64(4)},
+	pkBig, err := tbl.InsertRow([]types.FieldValue{
 		{FieldName: []byte("name"), Value: "big.snappy"},
 		{FieldName: []byte("size"), Value: int64(100 * MB)},
 		// 压缩策略是运行时参数 (不落盘), 读侧必须给同一个 compress。
@@ -186,12 +204,12 @@ func main() {
 	must(err)
 	must(db.Commit())
 
-	fvs, err = tbl.FindByPK(4)
+	fvs, err = tbl.FindByPK(pkBig)
 	must(err)
 	ref4, got4, err := blobRef(tbl, fvs, "data", types.CompressSnappy)
 	must(err)
-	fmt.Printf("    写入耗时 %.1fs; ref=%+v; 读回 %d 字节 (链上 %d 字节)\n",
-		time.Since(t0).Seconds(), ref4, len(got4), ref4.TotalLen)
+	fmt.Printf("    pk=%d 写入耗时 %.1fs; ref=%+v; 读回 %d 字节 (链上 %d 字节)\n",
+		pkBig, time.Since(t0).Seconds(), ref4, len(got4), ref4.TotalLen)
 	fmt.Println()
 
 	// ------------------------------------------------------------------
@@ -205,12 +223,12 @@ func main() {
 		fmt.Println("    ERR: 重开后找不到表 files")
 		os.Exit(1)
 	}
-	fvs, err = tbl2.FindByPK(1)
+	fvs, err = tbl2.FindByPK(pk1)
 	must(err)
 	_, got6, err := blobRef(tbl2, fvs, "data", types.CompressNone)
 	must(err)
-	fmt.Printf("    pk=1 内容与写入一致: %v (前 16 字节 %q)\n",
-		bytes.Equal(got6, payload), string(got6[:min(16, len(got6))]))
+	fmt.Printf("    pk=%d 内容与写入一致: %v (前 16 字节 %q)\n",
+		pk1, bytes.Equal(got6, payload), string(got6[:min(16, len(got6))]))
 	must(db2.Close())
 
 	fmt.Println()