Эх сурвалжийг харах

fix(core)+feat(query)+perf(engine): 三轮审计修复、查询优化器、BufferPool 重构与测试体系

正确性修复:
- vacuum: 消除 *db 整结构体拷锁 (go vet copylocks); adoptEngineState 逐字段
  迁移 + wireRuntime 重绑运行期闭包, 修复自动 VACUUM 首轮后停摆、VACUUM 后
  事务 Rollback 静默失效、新页登记/延迟回收流向旧句柄的泄漏; VACUUM 重建
  按原始索引名/类型/NULL 保留/FieldIDs 重映射, 时序行按时间键重排
- ORM Find/Scan/Select 反序列化失败返回错误而非静默跳过坏行; readBlobBytes
  校验读取长度与 ref.TotalLen 一致
- 删空 DataPage 改走 DeferFreePage (5 处), 消除事务回滚/崩溃重放后
  "树引用空闲页"的双重分配
- WAL: 已提交链 CRC 失败报错并保留 WAL 链 (原部分重放+回收销毁副本);
  真实页回写后补一次 fsync, 堵 OS 后台回刷乱序导致的已提交数据丢失窗口
- AddField/DropField 写盘失败回滚内存态, 杜绝内存/磁盘 schema 分叉
- DropField 拒绝删除行存储索引分量列; ScanAndDelete 带回换根新根;
  syncSchema 上抛 DropField 错误
- currentTxn 改 atomic.Pointer (race 12 警告清零); 自动 VACUUM 的
  timer.Reset 移入锁内; waitForWriteLock 醒后复查 writeLocked;
  删除死代码 capacityWait

查询执行器:
- IN 走索引: 规划器按值展开多区间 (B+/Hash/复合前缀), 重复值编码去重,
  空集合恒空计划; IN+同列范围让位行级过滤
- 二级索引批量回表: BTreePKBatchSearch 单调游标 + 主键/时序 ResolveMany
  分批物化 (扫描中改/删不踩游标); 按 RangeScan 键序交付保序;
  LIMIT 作回表预算, First 类点查只解 1 行
- ORDER BY+LIMIT 流式 top-k (大顶堆, 稳定性与 SliceStable 一致, 内存 O(k))
- Select 投影下推到反序列化 (ORM 新增 Query[T].Select, ORDER BY 列自动并入)
- 修复排序键平行数组被重排错位的回归 (键随行进结构体)

存储引擎:
- BufferPool 真 LRU (container/list) + 读未命中进缓存, 哈希点查 -37%、
  主键回表 -65%; 读盘后重查防并发写覆盖
- WAL 提交 RecordPage 去重 O(n^2)→O(1), CRC 免分配, markCommitted 复用
  首页缓冲; 提交 3 次 fsync → 2 次 (排序论证后保留 2 次)
- 无唯一索引表 Update 跳过预检序列化 (~2x); saveTableInfo/堆页重复落盘
  去重; findFieldIndex 惰性 map 缓存 (加锁双检); serializeRow 宽表 map 查找
- ORM 每行分配对归零 (ColNameBytes/ColIndex 预烘焙)

复合索引 (IndexTypeCompose):
- 处理器底层即 SBT 二级 B+ 树的前提下: 移除 8 字节分量闸门 (任意定宽分量),
  删除死代码 composepage 包/ComposeEntry 编解码/条目注册, 清理 query/deleter
  中按扁平链对待 compose 的残留, analyzer 按 B+ 树审计 compose
- 新增变宽分量端到端正确性测试 (18B Decimal + 2B Year)

测试与工程:
- 新增 20+ 回归测试: 事务删空页回滚/VACUUM 后事务/IN 全场景/top-k 稳定性/
  投影下推/扫描中删改/VACUUM 身份与 NULL 保留/变宽复合/模糊测试 (6 种子
  随机操作对模型)/崩溃恢复 (子进程 os.Exit)/BufferPool LRU 行为
- 5 个核心 demo 补 os.Exit(1); Makefile 接入 go test 单测 (test-unit)
- 清理 .headwt 破损 gitlink 与被跟踪的 tmp_* 文件, VCS 构建恢复正常
- 文档: README/运维教程/索引指南/迁移指南与全部行为变更对齐

验证: go vet 全绿; go test -race 0 警告; 单测全过; 22 demo 全过;
stress_test/bench_10m (千万行) 通过; analyzer 构建通过
yl32940 4 долоо хоног өмнө
parent
commit
54e1c3418b
52 өөрчлөгдсөн 3646 нэмэгдсэн , 1261 устгасан
  1. 0 1
      .headwt
  2. 25 7
      DB/Table.go
  3. 17 13
      DB/convert.go
  4. 4 1
      DB/core/HeapPage.go
  5. 109 0
      DB/core/IndexMetaPage.go
  6. 141 25
      DB/core/Table.go
  7. 212 0
      DB/core/audit_round3_test.go
  8. 2 2
      DB/core/blob_read.go
  9. 97 0
      DB/core/compose_varwidth_test.go
  10. 125 0
      DB/core/crash_recovery_test.go
  11. 129 93
      DB/core/db.go
  12. 20 18
      DB/core/deleter.go
  13. 172 0
      DB/core/fuzz_model_test.go
  14. 77 15
      DB/core/index/btree/primary.go
  15. 6 16
      DB/core/index/btree/secondary.go
  16. 47 326
      DB/core/index/compose_handler.go
  17. 0 146
      DB/core/index/composepage/composepage.go
  18. 2 3
      DB/core/index/datapage/datapage.go
  19. 17 3
      DB/core/index/ientry.go
  20. 82 11
      DB/core/index/primary_handler.go
  21. 0 135
      DB/core/index/strdatapage/strdatapage.go
  22. 97 1
      DB/core/index/time_handler.go
  23. 3 3
      DB/core/index/timedatapage/datapage.go
  24. 5 3
      DB/core/newtypes_test.go
  25. 16 0
      DB/core/page/BasePage.go
  26. 137 54
      DB/core/pool/buffer_pool.go
  27. 172 0
      DB/core/pool/buffer_pool_test.go
  28. 460 98
      DB/core/query.go
  29. 470 0
      DB/core/query_optimizer_test.go
  30. 74 0
      DB/core/query_scan_mutation_test.go
  31. 6 121
      DB/core/transaction.go
  32. 138 34
      DB/core/vacuum.go
  33. 240 0
      DB/core/vacuum_identity_test.go
  34. 125 6
      DB/core/vacuum_test.go
  35. 79 68
      DB/core/wal.go
  36. 11 0
      DB/core/wal_recovery.go
  37. 6 1
      DB/db.go
  38. 83 0
      DB/error_propagation_test.go
  39. 42 16
      DB/query.go
  40. 60 0
      DB/query_select_test.go
  41. 12 0
      DB/types.go
  42. 13 4
      Makefile
  43. 24 11
      README.md
  44. 7 9
      analyzer/audit.go
  45. 9 9
      analyzer/indexcheck.go
  46. 1 0
      demo/full_test/main.go
  47. 1 0
      demo/integrity_test/main.go
  48. 1 0
      demo/mem_test/main.go
  49. 11 0
      demo/no_commit_test/main.go
  50. 26 2
      docs/INDEX_TYPES_AND_ORM_GUIDE.md
  51. 5 5
      docs/MIGRATION_GUIDE.md
  52. 28 1
      docs/OPERATION_TUTORIAL.md

+ 0 - 1
.headwt

@@ -1 +0,0 @@
-Subproject commit 367d600566f8b05ae603d4b4ba0ef91b13699997

+ 25 - 7
DB/Table.go

@@ -234,6 +234,18 @@ func (t *Table[T]) Delete(id uint64) error {
 	return t.coreTable.DeleteRow(t.pkIndexName(), encodePKKey(id))
 }
 
+// convertRow 反序列化一行 core 值 → *T, 并回写行号 + 绑定表。
+// Find / Scan / Select 三个入口共用。
+func (t *Table[T]) convertRow(pkID uint64, row []types.FieldValue) (*T, error) {
+	obj := new(T)
+	if err := fieldValuesToStruct(row, t.model, obj, t.coreTable); err != nil {
+		return nil, err
+	}
+	setRowIDOf(obj, pkID)
+	t.bindModel(obj)
+	return obj, nil
+}
+
 // Find 按主键查找一条记录
 func (t *Table[T]) Find(id uint64) (*T, error) {
 	// SearchByIndexWithPK 与 SearchByIndex 取的是同一行 (同一条索引键 → 同一次定位),
@@ -264,16 +276,22 @@ func (t *Table[T]) Count() (int64, error) {
 
 // Select 流式遍历所有记录
 func (t *Table[T]) Select(callback func(*T) bool) error {
-	return t.coreTable.Query().ScanWithPKID(func(pkID uint64, row []types.FieldValue) bool {
-		obj := new(T)
-		if err := fieldValuesToStruct(row, t.model, obj, t.coreTable); err != nil {
-			return true
+	var convErr error
+	err := t.coreTable.Query().ScanWithPKID(func(pkID uint64, row []types.FieldValue) bool {
+		obj, err := t.convertRow(pkID, row)
+		if err != nil {
+			// 反序列化失败: 停止扫描并让 Select 返回错误, 不能静默跳过该行
+			if convErr == nil {
+				convErr = err
+			}
+			return false
 		}
-		// 行号随手带回对象: 回调里拿到的对象可以直接 Update / Delete, 精确到自己那一行。
-		setRowIDOf(obj, pkID)
-		t.bindModel(obj)
 		return callback(obj)
 	})
+	if err != nil {
+		return err
+	}
+	return convErr
 }
 
 // Where 开始条件查询

+ 17 - 13
DB/convert.go

@@ -40,7 +40,7 @@ func structToFieldValues(obj interface{}, model *ModelInfo, activeColumns map[st
 		if !fv.IsValid() {
 			// 字段不在结构体中 (如自动添加的 _id),用零值填充
 			result = append(result, types.FieldValue{
-				FieldName: []byte(fd.ColName),
+				FieldName: fd.ColNameBytes,
 				Value:     zeroValue(fd),
 			})
 			continue
@@ -52,7 +52,7 @@ func structToFieldValues(obj interface{}, model *ModelInfo, activeColumns map[st
 		// 既有语义必须保持), 非可空列则继续走"nil 当零值"的老路。
 		if fd.Flag&types.FlagNullable != 0 && isNilPointerValue(fv) {
 			result = append(result, types.FieldValue{
-				FieldName: []byte(fd.ColName),
+				FieldName: fd.ColNameBytes,
 				Value:     nil,
 			})
 			continue
@@ -62,7 +62,7 @@ func structToFieldValues(obj interface{}, model *ModelInfo, activeColumns map[st
 			return nil, err
 		}
 		result = append(result, types.FieldValue{
-			FieldName: []byte(fd.ColName),
+			FieldName: fd.ColNameBytes,
 			Value:     val,
 		})
 	}
@@ -81,22 +81,21 @@ func fieldValuesToStruct(fvs []types.FieldValue, model *ModelInfo, obj interface
 	}
 	v = v.Elem()
 
-	// 建立列名 → FieldValue 映射
-	colMap := make(map[string]types.FieldValue, len(fvs))
-	for _, fv := range fvs {
-		colMap[string(fv.FieldName)] = fv
-	}
-
-	for _, fd := range model.AllFields {
-		fv, ok := colMap[fd.ColName]
+	// 逐列直查预建的 列名→AllFields下标 映射: map 查找里的 string([]byte) 是编译器
+	// 优化不分配, 省掉旧实现"每行建一张 colMap + 每列一次 string 分配 + 2N 次哈希"。
+	// 交付列里模型没有的直接跳过; 模型里有但本行没交付的字段保持零值 —— 与旧实现一致。
+	for i := range fvs {
+		fv := &fvs[i]
+		fi, ok := model.ColIndex[string(fv.FieldName)]
 		if !ok {
 			continue
 		}
-		target := fieldByModelPath(v, fd, model.StructType)
+		fd := &model.AllFields[fi]
+		target := fieldByModelPath(v, *fd, model.StructType)
 		if !target.IsValid() || !target.CanSet() {
 			continue
 		}
-		if err := setFieldFromValue(target, fv.Value, fd, blobReader); err != nil {
+		if err := setFieldFromValue(target, fv.Value, *fd, blobReader); err != nil {
 			return fmt.Errorf("vrtx: set field %s: %w", fd.Name, err)
 		}
 	}
@@ -391,6 +390,11 @@ func readBlobBytes(reader types.BlobReader, ref types.BlobRef) ([]byte, error) {
 	if data == nil {
 		data = []byte{}
 	}
+	// 长度必须与 ref.TotalLen 严格一致: 链被截断 / 引用损坏时静默返回短数据,
+	// 等于把损坏的 blob 当成合法内容交给上层 (core.BlobRead 同样按此契约返错)。
+	if uint32(len(data)) != ref.TotalLen {
+		return nil, fmt.Errorf("vrtx: 读取 blob 内容长度不符: ref.TotalLen=%d, 实读 %d 字节", ref.TotalLen, len(data))
+	}
 	return data, nil
 }
 

+ 4 - 1
DB/core/HeapPage.go

@@ -99,13 +99,16 @@ func (hp *HeapPage) Alloc(need uint32) (allocPageID uint32, allocOff uint32, err
 	}
 
 	// 1. 当前页剩余空间足够 → 直接分配【页内物理偏移】
+	//
+	// 本方法不 Save: 唯一调用方 (Table.heapAllocBlock → heapWrite) 随后会把块数据
+	// 写进页里再统一 Save, 这里提前落盘纯属重复 (一次 16KB 往返拷贝)。
+	// 页头变更 (UseSize/RefCount) 留在内存态 hp.Info.Hdr, 随那次 Save 一并持久。
 	free := PageSize - hp.Info.Hdr.UseSize
 	if free >= need {
 		// ✅ 直接返回当前页的物理偏移(包含页头,真实地址)
 		off := hp.Info.Hdr.UseSize
 		hp.Info.Hdr.UseSize += need
 		hp.Info.Hdr.AddRef() // 本页新增一个引用
-		_ = hp.Save()
 		return hp.Info.Hdr.PageID, off, nil
 	}
 

+ 109 - 0
DB/core/IndexMetaPage.go

@@ -39,6 +39,10 @@ const (
 	IndexFlagUnique byte = 1 << 0
 )
 
+// resolveChunk 批量回表的单批行数: 每批"游标走查 + 物化"完成后才触发上层回调,
+// 回调里对行存储索引的 Update/Delete 不会踩坏正在推进的游标。
+const resolveChunk = 512
+
 // 编译期断言: 钉死 IndexMeta 的盘上大小与字段偏移。
 // analyzer/main.go 里那套硬编码偏移 (META_SZ / im[...]) 必须与之一致,
 // 因此改布局时这里会先编译失败, 而不是让分析工具静默读错。
@@ -770,6 +774,20 @@ func (im *IndexMetaPage) CheckUniqueForKey(rowData []byte, pkID uint64, fields [
 	return im.checkUniqueForKey(im.resolveIndexes(), rowData, pkID, fields, activeVer, versions)
 }
 
+// HasUniqueIndex 是否存在活跃的唯一索引。
+//
+// 更新路径 (checkUniqueUpdate) 用它跳过 serializeRowForCheck: 预检的唯一目的是在
+// serializeRow 动手覆写旧行动态堆块之前拦下唯一冲突 —— 没有唯一索引时整行试序列化
+// 纯属浪费一次全行序列化。resolveIndexes 的结果有缓存, 本判断近乎零成本。
+func (im *IndexMetaPage) HasUniqueIndex() bool {
+	for _, ri := range im.resolveIndexes() {
+		if ri.meta.Unique() {
+			return true
+		}
+	}
+	return false
+}
+
 // checkUniqueForKey 是 CheckUniqueForKey 的内核, 收【已解析】的索引集。
 // InsertRow / UpdateByKey 手里本来就有一份 resolveIndexes 的结果, 再解析一遍等于
 // 每次写入都把索引元信息链多走一趟 —— 插入/更新是热路径, 这趟省掉。
@@ -836,6 +854,97 @@ func (im *IndexMetaPage) ResolveByPK(pkID uint64) ([]byte, bool) {
 	return h.ResolveRow(im.handle, m.EntryPage, pkID, &im.rowCache)
 }
 
+// BatchResolveByPK 批量回表, 结果按【传入顺序】交付 (sink 回调)。
+//
+// 传入顺序即二级索引 RangeScan 的输出序 —— B+ 树按索引键序输出、Hash 范围扫描
+// 内部也按键排序; needSort=false 的免排序 ORDER BY 路径依赖这个顺序不被打乱,
+// 所以内部排序只是为了游标走查, 交付时恢复传入顺序。
+//
+// budget > 0 时最多交付 budget 行 (LIMIT+OFFSET 场景): 批尺寸随剩余预算收缩,
+// 达到预算立即收工 —— Where(重键=值).First() 这类点查只解 1 行, 而不是把
+// 命中桶里的上千行全部解出再丢掉。剩余条件会过滤行的场景: 某批交付不足预算时
+// 继续下一批, 直到预算耗尽或输入走完。
+//
+// 实现: 输入的升序拷贝走单调游标 (BatchRowResolver) 收进 map, 再按原序回调;
+// 未实现批量接口的处理器退化为逐个 ResolveRow (按传入顺序, 与旧行为一致)。
+// 行快照跨 sink 调用有效。sink 返回 false 时提前停止。
+func (im *IndexMetaPage) BatchResolveByPK(pkIDs []uint64, budget int,
+	sink func(pkID uint64, storeRow []byte) bool) {
+	m, h, ok := im.rowStoreMeta()
+	if !ok || len(pkIDs) == 0 {
+		return
+	}
+	emitted := 0
+	br, isBatch := h.(index.BatchRowResolver)
+	if !isBatch {
+		for _, pkID := range pkIDs {
+			storeRow, found := h.ResolveRow(im.handle, m.EntryPage, pkID, &im.rowCache)
+			if !found {
+				continue // 行已被删除, 跳过
+			}
+			if !sink(pkID, storeRow) {
+				return
+			}
+			emitted++
+			if budget > 0 && emitted >= budget {
+				return
+			}
+		}
+		return
+	}
+	// 分批: 每批取输入的一个连续子段, 子段单独升序后走游标走查,
+	// 交付严格按该子段的输入顺序。批与批之间游标重启, 回调里的索引改写安全。
+	for start := 0; start < len(pkIDs); {
+		if budget > 0 && budget-emitted <= 0 {
+			return
+		}
+		chunk := resolveChunk
+		if budget > 0 && budget-emitted < chunk {
+			chunk = budget - emitted // 预算紧张时批随预算收缩 (点查退化为单行解)
+		}
+		end := start + chunk
+		if end > len(pkIDs) {
+			end = len(pkIDs)
+		}
+		inputChunk := pkIDs[start:end]
+		if len(inputChunk) == 1 {
+			// 点查快路径 (LIMIT=1/First 常见): 单行直解, 免排序拷贝与 map
+			pkID := inputChunk[0]
+			if storeRow, found := h.ResolveRow(im.handle, m.EntryPage, pkID, &im.rowCache); found {
+				if !sink(pkID, storeRow) {
+					return
+				}
+				emitted++
+			}
+			start = end
+			continue
+		}
+		sortedChunk := make([]uint64, len(inputChunk))
+		copy(sortedChunk, inputChunk)
+		sort.Slice(sortedChunk, func(i, j int) bool { return sortedChunk[i] < sortedChunk[j] })
+		rows := make(map[uint64][]byte, len(inputChunk))
+		br.ResolveMany(im.handle, m.EntryPage, sortedChunk,
+			func(pkID uint64, storeRow []byte) bool {
+				rows[pkID] = storeRow
+				return true
+			})
+		for _, pkID := range inputChunk { // 本批输入子段, 原序交付
+			storeRow, ok := rows[pkID]
+			if !ok {
+				continue // 行已被删除
+			}
+			if !sink(pkID, storeRow) {
+				return
+			}
+			emitted++
+			if budget > 0 && emitted >= budget {
+				return
+			}
+		}
+		start = end
+	}
+}
+
 // InvalidateRowCache 使回表定位缓存失效 (外部直接改动行存储索引后调用)
 func (im *IndexMetaPage) InvalidateRowCache() { im.rowCache = nil }
 

+ 141 - 25
DB/core/Table.go

@@ -1,9 +1,6 @@
 package core
 
 import (
-	"git.ycl.pub/yl32940/TsDB2/DB/core/page"
-	"git.ycl.pub/yl32940/TsDB2/DB/core/types"
-	"git.ycl.pub/yl32940/TsDB2/DB/core/util"
 	"bytes"
 	"encoding/binary"
 	"fmt"
@@ -12,6 +9,10 @@ import (
 	"strings"
 	"sync"
 	"unsafe"
+
+	"git.ycl.pub/yl32940/TsDB2/DB/core/page"
+	"git.ycl.pub/yl32940/TsDB2/DB/core/types"
+	"git.ycl.pub/yl32940/TsDB2/DB/core/util"
 )
 
 // SchemaFragHdr 共享扩展页里每个 schema 续块的头 (定长 8 字节, 紧随 payload)
@@ -191,12 +192,12 @@ func readSchema(handle page.HandleDb, pageID uint32, startOff uint16, allocSize
 }
 
 type Table struct {
-	info             *TableInfo
-	Field            []types.FieldInfo // master registry (所有字段,含已删除,删除标记见 types.FlagDeleted)
+	info  *TableInfo
+	Field []types.FieldInfo // master registry (所有字段,含已删除,删除标记见 types.FlagDeleted)
 
 	// db 所属 DB (BlobRead/BlobFree 需要它取 file / pendingFreeChain).
 	// 创建时由 db.tables.Create 注入, 运行时不变.
-	db *DB
+	db               *DB
 	handle           page.HandleDb
 	indexMeta        *IndexMetaPage
 	tablePageID      uint32 // 表ID
@@ -218,6 +219,15 @@ type Table struct {
 	nextPKInit   bool
 	pendingPK    uint64 // AllocPKID 刚预留、等待 InsertRowWithID 消费的号
 	hasPendingPK bool
+
+	// fieldIdxCache: 列名 → table.Field 下标的惰性缓存。条件求值
+	// (Eq/In/Gt... 的 Match) 每行每条件都查一次字段位置, 线性扫是 O(列数)/行;
+	// 表字段在建模后基本不变, 缓存后 O(1)。Field 切片身份变化 (AddField 追加 /
+	// ReloadInfos 整体换片) 时按首元素指针 + 长度自动重建, 无需手工失效。
+	// fieldIdxMu 保护首次构建: 引擎允许并发读, 两个读 goroutine 可能同时建缓存。
+	fieldIdxMu    sync.Mutex
+	fieldIdxCache map[string]int
+	fieldIdxSrc   []types.FieldInfo // 建缓存时的 Field 切片 (身份比对用)
 }
 
 // CreateIndex 建二级索引。
@@ -228,13 +238,11 @@ type Table struct {
 //   - 分量是动态列 → ErrIndexDynamicField。动态列在行内只存 6B 堆引用, 值 != 引用,
 //     按值查找永远不可能命中; 建出来既查不到也不会被查询侧选中 (buildKeyLayout 会跳过),
 //     只会白占空间、白写。拦在建的这一步, 而不是等用到才发现;
-//   - 复合索引 (IndexTypeCompose) 的分量宽度不是 8 字节 → ErrIndexComposeWidth。
-//     ComposeEntry 把每个分量写死成 8 字节 (EntryLength = 8*n+8, Compare 里
-//     offset := i*8 后按 Uint64 读), 而拼键侧 (extractIndexKey) 是按分量的真实宽度拼的。
-//     两者对不上时条目长度与键长度不一致, Compare 会读到相邻分量的字节 —— 排序和查找
-//     会稳定地给出错误结果, 且不报错。这不是新引入的问题 (定长字符串字段今天就对不上),
-//     但新类型会让它更容易被踩到, 所以在建立时就拒绝, 而不是让它静默错位。
-//     需要非 8 字节分量请用 B+ 树/哈希索引 (按真实宽度拼键, 无此限制)。
+//   - 分量是 blob 列 → ErrIndexBlobField。blob 列行内只存 8B BlobRef (页号 + 长度),
+//     拿它当索引键等于按页号排序/查找 —— 索引里存的不是内容, 命中与否全看内容落在哪一页;
+//   - 复合索引 (IndexTypeCompose) 的分量列宽度不再强制 8 字节 ——
+//     底层已升级为 B+ 树, 按分量真实宽度拼定长键, 溢出闸门 (checkIndexSpillFloor)
+//     负责兜住"一页两行"的行宽上限。
 //
 // unique 为可选 (只取第一个值), 见 IndexMetaPage.CreateIndex: 传 true 建的是唯一索引,
 // 同一索引键只允许一行; 任一分量为 NULL 的行不进该索引, 因此多个 NULL 天然共存。
@@ -253,12 +261,6 @@ func (table *Table) CreateIndex(idxName string, typ page.IndexType, fields []byt
 		if types.IsBlob(ft) {
 			return nil, fmt.Errorf("%w (索引 %q 的第 %d 个分量)", ErrIndexBlobField, idxName, i+1)
 		}
-		if typ == page.IndexTypeCompose {
-			if w := table.Field[fi].Width(); w != 8 {
-				return nil, fmt.Errorf("%w (索引 %q 的第 %d 个分量是 %s, 宽度 %d)",
-					ErrIndexComposeWidth, idxName, i+1, types.TypeNameOf(ft), w)
-			}
-		}
 	}
 	// 溢出闸门: 分量列一旦建索引就不能再值级溢出, 行内下界随之抬升。
 	// 下界超过"一页两行"的配额时必须在这里拒绝 —— 否则这张表从建索引成功起就再也压不
@@ -438,15 +440,40 @@ func (table *Table) IndexMeta() *IndexMetaPage { return table.indexMeta }
 func (table *Table) SetOnWrite(fn func()) { table.onWrite = fn }
 
 // findFieldIndex 查找字段在 master registry 中的索引
+//
+// 走 fieldIndexMap 的惰性缓存: Match 类条件是"每行每条件一次"的热路径, 线性扫
+// O(列数) 在宽表 + 全表扫描下会累积成可见开销。缓存按 Field 切片身份
+// (首元素指针 + 长度) 自动失效, AddField / ReloadInfos 后下一次调用即重建。
 func (table *Table) findFieldIndex(name string) int {
-	for i, field := range table.Field {
-		if string(field.FieldName) == name {
-			return i
-		}
+	if idx, ok := table.fieldIndexMap()[name]; ok {
+		return idx
 	}
 	return -1
 }
 
+// fieldIndexMap 返回 (必要时重建) 列名 → Field 下标缓存。
+func (table *Table) fieldIndexMap() map[string]int {
+	src := table.Field
+	if table.fieldIdxCache != nil && len(table.fieldIdxSrc) == len(src) &&
+		(len(src) == 0 || &table.fieldIdxSrc[0] == &src[0]) {
+		return table.fieldIdxCache
+	}
+	table.fieldIdxMu.Lock()
+	defer table.fieldIdxMu.Unlock()
+	// 双检: 等待锁期间另一读线程可能已建好
+	if table.fieldIdxCache != nil && len(table.fieldIdxSrc) == len(src) &&
+		(len(src) == 0 || &table.fieldIdxSrc[0] == &src[0]) {
+		return table.fieldIdxCache
+	}
+	m := make(map[string]int, len(src))
+	for i := range src {
+		m[string(src[i].FieldName)] = i
+	}
+	table.fieldIdxCache = m
+	table.fieldIdxSrc = src
+	return m
+}
+
 // fieldWidth 取某列的行内宽度 (字节) —— 引擎内所有行偏移计算的唯一入口。
 //
 // 为什么不能直接 types.GetSize(f.FieldType): 字符串列的行内宽度由【列自己声明】
@@ -1396,6 +1423,12 @@ func (table *Table) AddField(newField types.FieldInfo) error {
 		})
 
 		if err := table.writeSchema(); err != nil {
+			// 回滚内存态: 写盘失败不能让内存 schema 与磁盘分叉 —— 后续写入会按
+			// 新 schemaVer 落盘, 重开后该版本不可解析 (整行静默丢失)。
+			existing.Flag |= types.FlagDeleted
+			table.info.ActiveFieldCount--
+			table.activeVersion = sv.Version
+			table.versions = table.versions[:len(table.versions)-1]
 			return err
 		}
 		table.syncRowSize()
@@ -1428,6 +1461,13 @@ func (table *Table) AddField(newField types.FieldInfo) error {
 	})
 
 	if err := table.writeSchema(); err != nil {
+		// 回滚内存态 (理由同复活分支): Field/registry/版本全部撤回到改动前,
+		// 否则内存 schema 与磁盘分叉, 后续写入按新 schemaVer 落盘的行重开后不可解析。
+		table.Field = table.Field[:len(table.Field)-1]
+		table.info.FieldCount--
+		table.info.ActiveFieldCount--
+		table.activeVersion = sv.Version
+		table.versions = table.versions[:len(table.versions)-1]
 		return err
 	}
 	table.syncRowSize()
@@ -1482,6 +1522,20 @@ func (table *Table) DropField(fieldName string) error {
 		return ErrFieldNotFound
 	}
 
+	// 守卫: 行存储索引 (主键/时序) 的分量列不允许删除 —— 删了之后
+	// extractIndexKey 提不出键, 新行不进任何索引 (插入"成功"但行静默丢失,
+	// Count 虚高)。ORM 层的 syncSchema 本来就不动主键列, core 层补上同一约束。
+	if im := table.indexMeta; im != nil {
+		if rm, _, ok := im.rowStoreMeta(); ok && rm != nil {
+			for _, id := range rm.Fields() {
+				if int(id) == masterIdx {
+					return fmt.Errorf("%w: 列 %q 是行存储索引 %q 的分量, 不能删除 (需重建表)",
+						ErrFieldNotFound, fieldName, rm.Name())
+				}
+			}
+		}
+	}
+
 	// 版本号闸门必须在改动之前 (理由同 AddField): 删列也要新增一版 schema。
 	nextVer, err := table.nextSchemaVersion()
 	if err != nil {
@@ -1510,6 +1564,11 @@ func (table *Table) DropField(fieldName string) error {
 	})
 
 	if err := table.writeSchema(); err != nil {
+		// 回滚内存态 (理由同 AddField): 撤销删除标记与版本推进。
+		table.Field[masterIdx].Flag &^= types.FlagDeleted
+		table.info.ActiveFieldCount++
+		table.activeVersion = sv.Version
+		table.versions = table.versions[:len(table.versions)-1]
 		return err
 	}
 	table.syncRowSize()
@@ -1625,6 +1684,10 @@ func (table *Table) heapAllocBlock(need uint32) (*HeapPage, uint16, error) {
 	if appended {
 		// 堆链长度自增 —— DropTable 就是靠它不遍历也拿到回收页数
 		table.info.HeapPageCount++
+		// 只有新开堆页时 FirstHeap/LastHeap/HeapPageCount 才会变 —— 尾页直接分配
+		// 的常见路径什么都不用落盘, 省一次"读 16KB 页 + 写 16KB 页"的往返。
+		// (旧实现无条件 saveTableInfo, 每行动态列都白付这两趟拷贝。)
+		table.saveTableInfo()
 	}
 
 	_, off, err := tail.Alloc(total)
@@ -1634,7 +1697,6 @@ func (table *Table) heapAllocBlock(need uint32) (*HeapPage, uint16, error) {
 	if off > 0xFFFF {
 		return nil, 0, ErrHeapPageTooLarge
 	}
-	table.saveTableInfo()
 	return tail, uint16(off), nil
 }
 
@@ -2227,11 +2289,29 @@ func (table *Table) serializeRowInternal(values []types.FieldValue, oldRowData [
 	bitmapLen := types.NullBitmapLen(n)
 	bitmap := make([]byte, bitmapLen)
 
+	// 列名→值映射: 列数较多时避免 Pass 1 整体 O(列数²) 的线性扫。
+	// 小表 (≤8 列) 直接线性扫更快 —— 免一次 map 分配; base (旧行) 只在缺列时
+	// 兜底, 常见路径 (全列提交) 不会碰到, 保持线性扫即可。
+	var valByName map[string]interface{}
+	if n > 8 {
+		valByName = make(map[string]interface{}, len(values))
+		for _, fv := range values {
+			valByName[string(fv.FieldName)] = fv.Value
+		}
+	}
+	lookupVal := func(name []byte) (interface{}, bool) {
+		if valByName != nil {
+			v, ok := valByName[string(name)]
+			return v, ok
+		}
+		return lookupFieldValue(values, name)
+	}
+
 	for i, masterIdx := range sv.FieldIndices {
 		f := table.Field[masterIdx]
 		name := f.FieldName
 		// 取值优先级: 本次传入 > 旧行原值 (未提供的字段沿用旧值, 否则"只改一列"会清空其它列)
-		value, provided := lookupFieldValue(values, name)
+		value, provided := lookupVal(name)
 		if !provided {
 			value, provided = lookupFieldValue(base, name)
 		}
@@ -2521,6 +2601,29 @@ func writeFieldValue(buf []byte, ft types.FieldType, value interface{}) {
 // 列位置一律由行内偏移表给出 —— 旧实现"从偏移 1 开始累加各列 Width"的前提是字段区
 // 定长, 而 v9 的字符串列按实际长度存储, 累加 Width 会越算越偏 (表现为整行错列)。
 func (table *Table) deserializeRow(rowData []byte) []types.FieldValue {
+	return table.deserializeRowMode(rowData, nil, false)
+}
+
+// deserializeRowKeepNull 与 deserializeRow 相同, 但 NULL 列也产出
+// FieldValue{Value: nil} (而不是跳过)。供 VACUUM 快照使用: 重建插入时
+// 显式传 nil 走 NULL 位图路径 —— deserializeRow 的"跳过 NULL"会让 VACUUM
+// 把 NULL 物化成类型零值 (可空列语义被静默改写, 且不可逆)。
+func (table *Table) deserializeRowKeepNull(rowData []byte) []types.FieldValue {
+	return table.deserializeRowMode(rowData, nil, true)
+}
+
+// deserializeRowSelect 只解码掩码选中的列 (掩码按 master 下标)。
+//
+// 与 deserializeRow 同一套布局解析, 未选中列跳过解码与其堆解引用 —— 宽表的投影查询
+// 不再为不看的列付解码/堆页 IO 成本。掩码必须包含调用方后续要读的列
+// (Query.deserialize 负责把 ORDER BY 列并进来)。
+func (table *Table) deserializeRowSelect(rowData []byte, want []bool) []types.FieldValue {
+	return table.deserializeRowMode(rowData, want, false)
+}
+
+// deserializeRowMode 行解码的公共实现。want 非 nil 时是投影掩码 (master
+// 下标 → 是否解码); keepNull 为 true 时 NULL 列产出显式 nil 值。
+func (table *Table) deserializeRowMode(rowData []byte, want []bool, keepNull bool) []types.FieldValue {
 	if len(rowData) < 1 {
 		return nil
 	}
@@ -2543,11 +2646,18 @@ func (table *Table) deserializeRow(rowData []byte) []types.FieldValue {
 	// 预分配容量: 字段数即结果条数的上界 (被删除 / NULL 字段会跳过), 避免 append 反复扩容
 	result := make([]types.FieldValue, 0, n)
 	for i, idx := range sv.FieldIndices {
+		if want != nil && int(idx) < len(want) && !want[idx] {
+			continue // 投影模式: 未选中的列不解码、不解堆引用
+		}
 		f := table.Field[idx]
 		// 跳过已删除字段 (master registry 中标记 FlagDeleted) 与 NULL 字段。
 		// NULL 不出现在结果里 —— 与"零值不 append"是同一约定: 读不到值就是没有值;
 		// 更新路径靠它区分"沿用旧值"与"显式写 NULL"(见 serializeRow 的取值优先级)。
+		// keepNull 模式 (VACUUM 快照) 例外: NULL 列产出显式 nil, 重建时走 NULL 位图。
 		if f.IsDropped() || types.NullBitSet(bitmap, i) {
+			if keepNull && !f.IsDropped() && types.NullBitSet(bitmap, i) {
+				result = append(result, types.FieldValue{FieldName: f.FieldName, Value: nil})
+			}
 			continue
 		}
 		start := int(types.RowFieldStart(ends, i))
@@ -2945,6 +3055,12 @@ func (table *Table) DeleteRow(idxName string, key []byte) error {
 // 为什么不能只靠 UpdateByKey 里那次预检: 那时 serializeRow 已经跑过了, 旧行的动态
 // 列早就被改成新值, 而固定列与索引条目还是旧的 —— 行被改了一半。
 func (table *Table) checkUniqueUpdate(values []types.FieldValue, oldRowData []byte, pkID uint64) error {
+	// 没有唯一索引的表占绝大多数: 预检唯一的目的就是拦唯一冲突, 此时整行试序列化
+	// (serializeRowForCheck) 纯属浪费 —— 后续真正的 serializeRow 仍会校验值宽度,
+	// 非法值照样在写入前被拦下, 只是少了这次纯为唯一性服务的试算。
+	if !table.indexMeta.HasUniqueIndex() {
+		return nil
+	}
 	scratch, err := table.serializeRowForCheck(values, oldRowData)
 	if err != nil {
 		return err

+ 212 - 0
DB/core/audit_round3_test.go

@@ -0,0 +1,212 @@
+package core
+
+import (
+	"testing"
+
+	"git.ycl.pub/yl32940/TsDB2/DB/core/page"
+	"git.ycl.pub/yl32940/TsDB2/DB/core/types"
+)
+
+// TestTxnRollbackAfterEmptyingPage 事务内把一页删空再回滚:
+// 旧实现删空即直写盘还链, 回滚后树仍引用该页 —— 后续分配把它复用后,
+// 回滚"恢复"的行读到别人的页 (数据损坏)。延迟回收下回滚必须完整复活数据页。
+func TestTxnRollbackAfterEmptyingPage(t *testing.T) {
+	db := fixOpenDB(t)
+	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.Fatalf("建表: %v", err)
+	}
+	if _, err := tbl.CreateIndex("pk", page.IndexTypePrimary, []byte{0}); err != nil {
+		t.Fatalf("建主键索引: %v", err)
+	}
+	// 100 行, 单页装得下
+	for i := 1; i <= 100; i++ {
+		if _, err := tbl.InsertRowWithID(uint64(i), []types.FieldValue{
+			{FieldName: []byte("id"), Value: uint64(i)},
+			{FieldName: []byte("v"), Value: int64(i)},
+		}); err != nil {
+			t.Fatalf("插入 %d: %v", i, err)
+		}
+	}
+	if err := db.Commit(); err != nil {
+		t.Fatalf("commit: %v", err)
+	}
+
+	// 事务内删空
+	tx := db.BeginTransaction()
+	for i := 1; i <= 100; i++ {
+		if err := tbl.DeleteRowByPK(uint64(i)); err != nil {
+			t.Fatalf("事务内删除 %d: %v", i, err)
+		}
+	}
+	if err := tx.Rollback(); err != nil {
+		t.Fatalf("rollback: %v", err)
+	}
+
+	// 回滚后 100 行必须完整回来
+	if c := vacCountOf(t, tbl); c != 100 {
+		t.Fatalf("回滚后行数 = %d, 想要 100 (删空页的直写盘还链会让回滚丢数据)", c)
+	}
+	row, found := tbl.IndexMeta().ResolveByPK(1)
+	if !found || len(row) < 8 {
+		t.Fatalf("回滚后 FindByPK(1) 失败 (树引用了被复用的页)")
+	}
+
+	// 继续写入: 若页被误还链, 这里会复用它并踩坏回滚的数据
+	for i := 101; i <= 150; i++ {
+		if _, err := tbl.InsertRowWithID(uint64(i), []types.FieldValue{
+			{FieldName: []byte("id"), Value: uint64(i)},
+			{FieldName: []byte("v"), Value: int64(i)},
+		}); err != nil {
+			t.Fatalf("插入 %d: %v", i, err)
+		}
+	}
+	if err := db.Commit(); err != nil {
+		t.Fatalf("commit: %v", err)
+	}
+	if c := vacCountOf(t, tbl); c != 150 {
+		t.Fatalf("继续写入后行数 = %d, 想要 150", c)
+	}
+	if _, found := tbl.IndexMeta().ResolveByPK(1); !found {
+		t.Fatalf("复用后 FindByPK(1) 丢失 (空闲页与活页双重分配)")
+	}
+	if _, found := tbl.IndexMeta().ResolveByPK(150); !found {
+		t.Fatalf("FindByPK(150) 丢失")
+	}
+}
+
+// TestTxnWorksAfterVacuum 回归: VACUUM 换引擎状态后, 事务的 Rollback 必须
+// 真正生效 (markTouched/allocated/pendingFree 闭包重绑到当前句柄)。
+func TestTxnWorksAfterVacuum(t *testing.T) {
+	db := fixOpenDB(t)
+	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.Fatalf("建表: %v", err)
+	}
+	if _, err := tbl.CreateIndex("pk", page.IndexTypePrimary, []byte{0}); err != nil {
+		t.Fatalf("建主键索引: %v", err)
+	}
+	for i := 1; i <= 10; i++ {
+		if _, err := tbl.InsertRowWithID(uint64(i), []types.FieldValue{
+			{FieldName: []byte("id"), Value: uint64(i)},
+			{FieldName: []byte("v"), Value: int64(i)},
+		}); err != nil {
+			t.Fatalf("插入 %d: %v", i, err)
+		}
+	}
+	if err := db.Commit(); err != nil {
+		t.Fatalf("commit: %v", err)
+	}
+	if _, err := db.VACUUM(); err != nil {
+		t.Fatalf("VACUUM: %v", err)
+	}
+	// VACUUM 换了引擎状态, 须按当前句柄重新取表 (旧表对象的句柄/闭包属于旧状态)
+	var ok bool
+	tbl, ok = db.FindTable("t")
+	if !ok {
+		t.Fatalf("VACUUM 后表丢失")
+	}
+
+	// VACUUM 后: 事务插入再回滚 → 行必须不存在
+	tx := db.BeginTransaction()
+	if _, err := tbl.InsertRowWithID(999, []types.FieldValue{
+		{FieldName: []byte("id"), Value: uint64(999)},
+		{FieldName: []byte("v"), Value: int64(999)},
+	}); err != nil {
+		t.Fatalf("事务内插入: %v", err)
+	}
+	if err := tx.Rollback(); err != nil {
+		t.Fatalf("rollback: %v", err)
+	}
+	if _, found := tbl.IndexMeta().ResolveByPK(999); found {
+		t.Fatalf("VACUUM 后 Rollback 失效: 已回滚的行仍在 (闭包未重绑, markTouched 落空)")
+	}
+	if c := vacCountOf(t, tbl); c != 10 {
+		t.Fatalf("回滚后行数 = %d, 想要 10", c)
+	}
+
+	// VACUUM 后: 事务插入再提交 → 行必须存在
+	tx2 := db.BeginTransaction()
+	if _, err := tbl.InsertRowWithID(1000, []types.FieldValue{
+		{FieldName: []byte("id"), Value: uint64(1000)},
+		{FieldName: []byte("v"), Value: int64(1000)},
+	}); err != nil {
+		t.Fatalf("事务内插入: %v", err)
+	}
+	if err := tx2.Commit(); err != nil {
+		t.Fatalf("commit: %v", err)
+	}
+	if _, found := tbl.IndexMeta().ResolveByPK(1000); !found {
+		t.Fatalf("VACUUM 后事务提交的行丢失")
+	}
+}
+
+// TestInDuplicateValueAggCount IN 重复值不得让聚合重复计数:
+// 行交付路径有去重, raw 聚合路径 (Count/Sum) 依赖规划期区间去重。
+func TestInDuplicateValueAggCount(t *testing.T) {
+	db := fixOpenDB(t)
+	tbl, err := db.CreateTable("t", []types.FieldInfo{
+		{FieldType: types.FieldType_Uint64, FieldName: []byte("id")},
+		{FieldType: types.FieldType_Int64, FieldName: []byte("age")},
+	})
+	if err != nil {
+		t.Fatalf("建表: %v", err)
+	}
+	if _, err := tbl.CreateIndex("pk", page.IndexTypePrimary, []byte{0}); err != nil {
+		t.Fatalf("建主键索引: %v", err)
+	}
+	if _, err := tbl.CreateIndex("idx_age", page.IndexTypeBTree, []byte{1}); err != nil {
+		t.Fatalf("建二级索引: %v", err)
+	}
+	for i := 1; i <= 100; i++ {
+		if _, err := tbl.InsertRowWithID(uint64(i), []types.FieldValue{
+			{FieldName: []byte("id"), Value: uint64(i)},
+			{FieldName: []byte("age"), Value: int64(i % 50)},
+		}); err != nil {
+			t.Fatalf("插入 %d: %v", i, err)
+		}
+	}
+	if err := db.Commit(); err != nil {
+		t.Fatalf("commit: %v", err)
+	}
+
+	// age=7 → id ∈ {7,57} 两行; 重复写三遍仍是 2
+	n, err := tbl.Query().Where(In("age", []interface{}{int64(7), int64(7), int64(7)}, 0)).Count()
+	if err != nil || n != 2 {
+		t.Fatalf("重复 IN 的 Count = %d (err=%v), 想要 2", n, err)
+	}
+	// 行交付路径同样不得重复
+	got := collectAges(t, tbl.Query().Where(In("age", []interface{}{int64(7), int64(7)}, 0)))
+	if len(got) != 2 {
+		t.Fatalf("重复 IN 的行交付 = %d 行, 想要 2", len(got))
+	}
+}
+
+// TestDropRowStoreComponentRejected 行存储索引的分量列不得删除:
+// 删了之后新行提不出键、不进任何索引, 插入"成功"但行静默丢失。
+func TestDropRowStoreComponentRejected(t *testing.T) {
+	db := fixOpenDB(t)
+	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.Fatalf("建表: %v", err)
+	}
+	if _, err := tbl.CreateIndex("pk", page.IndexTypePrimary, []byte{0}); err != nil {
+		t.Fatalf("建主键索引: %v", err)
+	}
+	if err := tbl.DropField("id"); err == nil {
+		t.Fatalf("删除行存储索引分量列必须报错, 却成功了")
+	}
+	// 普通列仍可删除
+	if err := tbl.DropField("v"); err != nil {
+		t.Fatalf("删除普通列不应报错: %v", err)
+	}
+}

+ 2 - 2
DB/core/blob_read.go

@@ -137,8 +137,8 @@ func (db *DB) deferFreeBlobChain(head, tail, count uint32) {
 	if head == 0 || count == 0 {
 		return
 	}
-	if db.currentTxn != nil {
-		db.currentTxn.pendingFreeChain = append(db.currentTxn.pendingFreeChain,
+	if txn := db.currentTxn.Load(); txn != nil {
+		txn.pendingFreeChain = append(txn.pendingFreeChain,
 			pendingChain{head: head, tail: tail, count: count})
 		return
 	}

+ 97 - 0
DB/core/compose_varwidth_test.go

@@ -0,0 +1,97 @@
+package core
+
+import "testing"
+import "git.ycl.pub/yl32940/TsDB2/DB/core/page"
+import "git.ycl.pub/yl32940/TsDB2/DB/core/types"
+
+// TestVarWidthComposeCorrectness 非 8 字节分量的复合索引 (SBT B+ 树) 端到端正确性:
+func TestVarWidthComposeCorrectness(t *testing.T) {
+	db := fixOpenDB(t)
+	tbl, err := db.CreateTable("t", []types.FieldInfo{
+		{FieldType: types.FieldType_Uint64, FieldName: []byte("id")},
+		{FieldType: types.FieldType_Decimal, FieldName: []byte("amt"), Flag: 2}, // 18B
+		{FieldType: types.FieldType_Year, FieldName: []byte("yr")},              // 2B
+	})
+	if err != nil {
+		t.Fatal(err)
+	}
+	if _, err := tbl.CreateIndex("pk", page.IndexTypePrimary, []byte{0}); err != nil {
+		t.Fatal(err)
+	}
+	// 复合索引 (amt, yr): keyLen = 18+2 = 20, 非 8 对齐
+	if _, err := tbl.CreateIndex("idx_ay", page.IndexTypeCompose, []byte{1, 2}); err != nil {
+		t.Fatal(err)
+	}
+	// 造数据: amt 在尾部字节不同 (1.01 vs 1.02 在 18B 编码的尾部有差异)
+	type row struct {
+		id  uint64
+		amt string
+		yr  uint16
+	}
+	rows := []row{}
+	for i := 1; i <= 300; i++ {
+		r := row{id: uint64(i), amt: "1.01", yr: uint16(2000 + i%50)}
+		if i%2 == 0 {
+			r.amt = "2.50"
+		}
+		if i%3 == 0 {
+			r.amt = "12345.67"
+		}
+		rows = append(rows, r)
+	}
+	for _, r := range rows {
+		amt, err := types.ParseDecimal(r.amt)
+		if err != nil {
+			t.Fatal(err)
+		}
+		if _, err := tbl.InsertRowWithID(r.id, []types.FieldValue{
+			{FieldName: []byte("id"), Value: r.id},
+			{FieldName: []byte("amt"), Value: amt},
+			{FieldName: []byte("yr"), Value: r.yr},
+		}); err != nil {
+			t.Fatal(err)
+		}
+	}
+	if err := db.Commit(); err != nil {
+		t.Fatal(err)
+	}
+
+	// 等值查 amt=2.50: 与全表过滤比对
+	amt250, _ := types.ParseDecimal("2.50")
+	want := 0
+	for _, r := range rows {
+		if r.amt == "2.50" {
+			want++
+		}
+	}
+	n, err := tbl.Query().Where(Eq("amt", amt250, 0)).Count()
+	if err != nil || n != int64(want) {
+		t.Fatalf("等值 amt=2.50: 索引 count=%d err=%v, 全表过滤 %d", n, err, want)
+	}
+	// 复合等值 amt=2.50 AND yr=2002
+	want2 := 0
+	for _, r := range rows {
+		if r.amt == "2.50" && r.yr == 2002 {
+			want2++
+		}
+	}
+	yrVal := uint16(2002)
+	n, err = tbl.Query().Where(And(Eq("amt", amt250, 0), Eq("yr", yrVal, 0))).Count()
+	if err != nil || n != int64(want2) {
+		t.Fatalf("复合等值 amt+yr: 索引 count=%d err=%v, 全表过滤 %d", n, err, want2)
+	}
+	// 范围查 amt ∈ [2.00, 3.00]
+	lo, _ := types.ParseDecimal("2.00")
+	hi, _ := types.ParseDecimal("3.00")
+	want3 := 0
+	for _, r := range rows {
+		if r.amt == "2.50" {
+			want3++
+		}
+	}
+	n, err = tbl.Query().Where(And(Ge("amt", lo, 0), Le("amt", hi, 0))).Count()
+	if err != nil || n != int64(want3) {
+		t.Fatalf("范围 amt∈[2,3]: 索引 count=%d err=%v, 全表过滤 %d", n, err, want3)
+	}
+	t.Logf("非 8 字节分量复合索引端到端正确: 等值/复合等值/范围全部与全表过滤一致")
+}

+ 125 - 0
DB/core/crash_recovery_test.go

@@ -0,0 +1,125 @@
+package core
+
+import (
+	"os"
+	"os/exec"
+	"path/filepath"
+	"testing"
+
+	"git.ycl.pub/yl32940/TsDB2/DB/core/page"
+	"git.ycl.pub/yl32940/TsDB2/DB/core/types"
+)
+
+// TestCrashRecoveryCommittedOnly 崩溃恢复边界: 子进程提交第一批后, 写入
+// 第二批【不做任何提交】并以 os.Exit 立即死亡 (模拟断电/被杀, 不走红色的
+// Close 兜底提交)。父进程重开数据库验证:
+//   - 已提交 (WAL 已落盘) 的第一批必须完整可见 —— 崩溃恢复的职责;
+//   - 未提交的第二批必须完全不可见 —— 提交边界的职责;
+//   - 文件可正常打开、无结构损坏。
+//
+// 子进程通过环境变量自识别 (go test 子进程测试的标准模式)。
+func TestCrashRecoveryCommittedOnly(t *testing.T) {
+	dir := t.TempDir()
+	path := filepath.Join(dir, "crash.db")
+
+	// 子进程: 提交 500 行 → 再写 500 行不提交 → 暴毙
+	childEnv := append(os.Environ(),
+		"TSDB_CRASH_CHILD=1",
+		"TSDB_CRASH_DB="+path,
+	)
+	cmd := exec.Command(os.Args[0], "-test.run=TestCrashChildMain", "-test.v")
+	cmd.Env = childEnv
+	out, err := cmd.CombinedOutput()
+	if err != nil {
+		t.Fatalf("子进程: %v\n%s", err, out)
+	}
+
+	// 父进程: 重开 (WAL 恢复发生在这里)
+	db, err := Open(path)
+	if err != nil {
+		t.Fatalf("崩溃后重开失败: %v", err)
+	}
+	defer db.Close()
+	tbl, ok := db.FindTable("t")
+	if !ok {
+		t.Fatalf("表丢失")
+	}
+
+	if c := vacCountOf(t, tbl); c != 500 {
+		t.Fatalf("崩溃后可见行数 = %d, 想要 500 (已提交批次); 未提交批次必须不可见", c)
+	}
+	// 已提交行的值正确
+	for _, id := range []uint64{1, 250, 500} {
+		vals, err := tbl.FindByPK(id)
+		if err != nil || !hasColVal(vals, "v") {
+			t.Fatalf("已提交行 id=%d 读取失败: %v %+v", id, err, vals)
+		}
+	}
+	// 未提交行必须不存在
+	for _, id := range []uint64{501, 750, 1000} {
+		if _, err := tbl.FindByPK(id); err == nil {
+			t.Fatalf("未提交行 id=%d 在崩溃后可见 (提交边界被破坏)", id)
+		}
+	}
+	// 写入能力未损坏: 新行可插入可回读
+	if _, err := tbl.InsertRowWithID(1001, []types.FieldValue{
+		{FieldName: []byte("id"), Value: uint64(1001)},
+		{FieldName: []byte("v"), Value: int64(1)},
+	}); err != nil {
+		t.Fatalf("崩溃后写入失败: %v", err)
+	}
+	if err := db.Commit(); err != nil {
+		t.Fatalf("commit: %v", err)
+	}
+	if c := vacCountOf(t, tbl); c != 501 {
+		t.Fatalf("写入后行数 = %d, 想要 501", c)
+	}
+}
+
+// TestCrashRecoveryCommittedOnly 的子进程主体 (环境变量 TSDB_CRASH_CHILD=1 时)。
+// 注意: 这个"测试"在子进程里不会跑到 t 的断言, 它只做写入然后 os.Exit。
+func TestCrashChildMain(t *testing.T) {
+	if os.Getenv("TSDB_CRASH_CHILD") != "1" {
+		return // 正常测试运行: 什么都不做
+	}
+	path := os.Getenv("TSDB_CRASH_DB")
+	db, err := Open(path)
+	if err != nil {
+		os.Exit(2)
+	}
+	db.SetAutoCommitDelay(0) // 关掉防抖: 提交完全由显式 Commit 控制
+
+	tbl, err := db.CreateTable("t", []types.FieldInfo{
+		{FieldType: types.FieldType_Uint64, FieldName: []byte("id")},
+		{FieldType: types.FieldType_Int64, FieldName: []byte("v")},
+	})
+	if err != nil {
+		os.Exit(2)
+	}
+	if _, err := tbl.CreateIndex("pk", page.IndexTypePrimary, []byte{0}); err != nil {
+		os.Exit(2)
+	}
+	// 第一批: 显式提交 (WAL + fsync, 必须扛住崩溃)
+	for i := 1; i <= 500; i++ {
+		if _, err := tbl.InsertRowWithID(uint64(i), []types.FieldValue{
+			{FieldName: []byte("id"), Value: uint64(i)},
+			{FieldName: []byte("v"), Value: int64(i)},
+		}); err != nil {
+			os.Exit(2)
+		}
+	}
+	if err := db.Commit(); err != nil {
+		os.Exit(2)
+	}
+	// 第二批: 只进 BufferPool, 永不提交
+	for i := 501; i <= 1000; i++ {
+		if _, err := tbl.InsertRowWithID(uint64(i), []types.FieldValue{
+			{FieldName: []byte("id"), Value: uint64(i)},
+			{FieldName: []byte("v"), Value: int64(i)},
+		}); err != nil {
+			os.Exit(2)
+		}
+	}
+	// 故意不调 Close / Commit: os.Exit 让脏页随进程蒸发 (模拟断电)
+	os.Exit(0)
+}

+ 129 - 93
DB/core/db.go

@@ -8,6 +8,7 @@ import (
 	"encoding/binary"
 	"fmt"
 	"sync"
+	"sync/atomic"
 	"time"
 	"unsafe"
 )
@@ -44,8 +45,12 @@ type DB struct {
 	// 只串行化"提交"这一步, 不参与写入路径 —— 详见 DB.Commit 的说明。
 	commitMu sync.Mutex
 
-	// 当前活跃事务(nil 表示无事务)
-	currentTxn *Transaction
+	// 当前活跃事务(nil 表示无事务)。
+	// 用 atomic.Pointer: 写线程 (Begin/Commit/Rollback) 与自动提交的定时器
+	// goroutine (autoCommit/NotifyWrite) 会并发读写它, 裸指针是 data race。
+	// 注意这只保证【指针本身】的可见性 —— Transaction 内部字段仍受
+	// "单写入者模型"约束 (同一时刻只允许一个 goroutine 跑写入路径)。
+	currentTxn atomic.Pointer[Transaction]
 
 	// pendingMu 保护 pendingFree / pendingFreeChain: 写入路径登记、提交路径出队,
 	// 两条路径并发 (写线程插入/删除 + 后台提交线程) 时必须串行化, 否则切片并发读写
@@ -69,6 +74,13 @@ type DB struct {
 	autoVacuumEnabled bool
 	autoVacuumOpts    VACUUMOptions
 	lastVACUUM        time.Time
+
+	// vacuumMu 串行化 VACUUM 本体: 手动与自动调度可能并发触发,
+	// 双开会对同一文件做 Close/Open 交错 (自动调度抢到锁才跑, 否则本轮跳过)。
+	vacuumMu sync.Mutex
+
+	// dirtyThreshold 脏页阈值的用户配置 (内存态, wireRuntime 重绑阈值回调时用)。
+	dirtyThreshold int
 }
 
 // pendingChain 一条待回收的整链: head…tail 共 count 页, 链内 next 在 PageHdr.NextPage
@@ -163,89 +175,17 @@ func Open(path string) (*DB, error) {
 		return err
 	}
 
-	// 闭包在调用时才解引用 db, 因此可以先声明、稍后再赋值
-	var db *DB
-
+	// 启动期的句柄用 nil 接收者构造: Init 阶段只会用到 ReadPage/SavePage,
+	// 涉及 db 的闭包有 nil 守卫且此阶段不会触发; db 构造完成后以真实接收者重建
+	// (wireRuntime), 闭包从此绑定本句柄 —— 见 buildHandleDb 的说明。
 	tablePage := &TablePage{
-		handle: page.HandleDb{
-			NewPage: func() uint32 {
-				// allocPage(erase=true): headerMu 内摘空闲链 / 递增游标并推进头页; 擦页在
-				// 锁外走 WritePage —— 调用方紧接着的整页写入会覆盖池里同一份缓存, 因此不
-				// 额外产生物理 IO。读空闲链直接读盘 (绕过 BufferPool)。
-				pid := ff.allocPage(header, true)
-				// 事务内新分配的页登记到事务: 分配已就地推进 header 游标 (头页延迟到
-				// Commit 落盘, 不可回退), Rollback 时必须把这些页还给空闲链, 否则成为
-				// 永久泄漏的孤儿页。
-				if db != nil && db.currentTxn != nil {
-					db.currentTxn.allocated = append(db.currentTxn.allocated, pid)
-				}
-				return pid
-			},
-			FreePage: func(pageID uint32) {
-				// 事务内被立即还链的页, 从 allocated 撤销登记, 避免 Rollback 时二次归还。
-				if db != nil && db.currentTxn != nil {
-					db.currentTxn.undoAllocated(pageID)
-				}
-				realFreePage(pageID)
-			},
-			// 延迟回收: 堆页摘链 / 旧 DataPage 作废时只登记, 等提交成功后再还链。
-			// 中途直写盘会让"页已清零"先于 WAL 落盘 —— 崩溃或 Rollback 后原页变空页,
-			// 而堆链/索引仍指向它。事务内入 txn.pendingFree (回滚即丢弃),
-			// 事务外入 db.pendingFree (下次提交/关闭时统一还链)。
-			DeferFreePage: func(pageID uint32) {
-				if db == nil {
-					realFreePage(pageID) // 启动期(如 WAL 恢复前)没有事务上下文, 保持原行为
-					return
-				}
-				if db.currentTxn != nil {
-					db.currentTxn.pendingFree = append(db.currentTxn.pendingFree, pageID)
-					return
-				}
-				db.pendingMu.Lock()
-				db.pendingFree = append(db.pendingFree, pageID)
-				db.pendingMu.Unlock()
-			},
-			ReadPage: ff.ReadPage,
-			SavePage: ff.WritePage,
-			// 整链回收: DropTable 的堆链 / WAL 提交后的 WAL 链 / 哈希索引的桶目录链与桶
-			// 溢出链, 都是「首尾和链长已知、且链内 next 与空闲链同用 PageHdr.NextPage」
-			// 的链 —— 一次拼接 O(1) 还链, 不必逐页遍历 (逐页 = 每页读+写各一次)。
-			FreeChain: realFreeChain,
-			// 延迟整链回收: 与 DeferFreePage 同一时序约定 (提交成功后才真正还链),
-			// 但一条链只登记一次, 提交后一次 FreeChain 还链。
-			DeferFreeChain: func(head, tail, count uint32) {
-				if head == 0 || count == 0 {
-					return
-				}
-				if db == nil {
-					_ = realFreeChain(head, tail, count) // 启动期没有事务上下文, 保持原行为
-					return
-				}
-				c := pendingChain{head: head, tail: tail, count: count}
-				if db.currentTxn != nil {
-					db.currentTxn.pendingFreeChain = append(db.currentTxn.pendingFreeChain, c)
-					return
-				}
-				db.pendingMu.Lock()
-				db.pendingFreeChain = append(db.pendingFreeChain, c)
-				db.pendingMu.Unlock()
-			},
-			// ValidPage: 页号合法性 = 落在已分配区间 [2, NextFreePage)。
-			// 0 是头页、1 是建库预留的表页; >= NextFreePage 的页从未分配过, 文件里没有
-			// 它的位置 —— 放进空闲链, 下一次 NewPage 就会拿着它写到几十 GB 偏移处
-			// (实测 DROP TABLE 假死 52s、稀疏文件暴涨到 40GB 的直接原因)。
-			ValidPage: func(pageID uint32) bool {
-				ff.headerMu.Lock()
-				limit := header.NextFreePage
-				ff.headerMu.Unlock()
-				return pageID >= 2 && pageID < limit
-			},
-		},
+		handle: (*DB)(nil).buildHandleDb(ff, header, realFreePage, realFreeChain),
 	}
 
 	// ==========================
 	// 初始化索引元数据页
 	// ==========================
+	var db *DB
 
 	saveHeader := false
 	//如果索引页和表页都是空的话则直接创建
@@ -280,24 +220,119 @@ func Open(path string) (*DB, error) {
 		autoCommitDelay: DefaultAutoCommitDelay,
 		freePage:        realFreePage,
 		freeChain:       realFreeChain,
+		dirtyThreshold:  DefaultDirtyThreshold,
 	}
 	// 反向注入, TablePage.Create 时拿到 db 指针; bindDB 顺带给 Init 阶段已经从盘上
 	// 加载出来的表补上 db (那时 DB 还没构造完, 只能现在回填)。
 	db.tables.bindDB(db)
 	db.startAutoCommit()
+	// 运行期回调与句柄闭包以真实接收者重绑 (启动期那版绑定的是 nil 接收者)。
+	db.wireRuntime()
+	return db, nil
+}
+
+// buildHandleDb 构造 TablePage 的运行期句柄, 闭包绑定接收者 db。
+//
+// Open 在 db 存在之前先用 nil 接收者构造一版 (Init 阶段用不到 db 闭包,
+// 且涉及 db 的闭包都有 nil 守卫); db 构造完成后以真实接收者重建 —— 闭包
+// 从此绑定本句柄的 currentTxn/pendingFree。VACUUM 的 adoptEngineState 换过
+// 引擎状态后也必须用它重绑, 否则闭包仍指向旧句柄: 新句柄上的事务不会被
+// markTouched (Rollback 静默失效), 新分配的页登记到旧句柄 (回滚后成孤儿页)。
+func (db *DB) buildHandleDb(ff *File, header *HeaderPage,
+	realFreePage func(uint32), realFreeChain func(uint32, uint32, uint32) error) page.HandleDb {
+	return page.HandleDb{
+		NewPage: func() uint32 {
+			// allocPage(erase=true): headerMu 内摘空闲链 / 递增游标并推进头页; 擦页在
+			// 锁外走 WritePage —— 调用方紧接着的整页写入会覆盖池里同一份缓存, 因此不
+			// 额外产生物理 IO。读空闲链直接读盘 (绕过 BufferPool)。
+			pid := ff.allocPage(header, true)
+			// 事务内新分配的页登记到事务: 分配已就地推进 header 游标 (头页延迟到
+			// Commit 落盘, 不可回退), Rollback 时必须把这些页还给空闲链, 否则成为
+			// 永久泄漏的孤儿页。
+			if db != nil {
+				if txn := db.currentTxn.Load(); txn != nil {
+					txn.allocated = append(txn.allocated, pid)
+				}
+			}
+			return pid
+		},
+		FreePage: func(pageID uint32) {
+			// 事务内被立即还链的页, 从 allocated 撤销登记, 避免 Rollback 时二次归还。
+			if db != nil {
+				if txn := db.currentTxn.Load(); txn != nil {
+					txn.undoAllocated(pageID)
+				}
+			}
+			realFreePage(pageID)
+		},
+		// 延迟回收: 堆页摘链 / 旧 DataPage 作废时只登记, 等提交成功后再还链。
+		// 中途直写盘会让"页已清零"先于 WAL 落盘 —— 崩溃或 Rollback 后原页变空页,
+		// 而堆链/索引仍指向它。事务内入 txn.pendingFree (回滚即丢弃),
+		// 事务外入 db.pendingFree (下次提交/关闭时统一还链)。
+		DeferFreePage: func(pageID uint32) {
+			if db == nil {
+				realFreePage(pageID) // 启动期(如 WAL 恢复前)没有事务上下文, 保持原行为
+				return
+			}
+			if txn := db.currentTxn.Load(); txn != nil {
+				txn.pendingFree = append(txn.pendingFree, pageID)
+				return
+			}
+			db.pendingMu.Lock()
+			db.pendingFree = append(db.pendingFree, pageID)
+			db.pendingMu.Unlock()
+		},
+		ReadPage: ff.ReadPage,
+		SavePage: ff.WritePage,
+		// 整链回收: DropTable 的堆链 / WAL 提交后的 WAL 链 / 哈希索引的桶目录链与桶
+		// 溢出链, 都是「首尾和链长已知、且链内 next 与空闲链同用 PageHdr.NextPage」
+		// 的链 —— 一次拼接 O(1) 还链, 不必逐页遍历 (逐页 = 每页读+写各一次)。
+		FreeChain: realFreeChain,
+		// 延迟整链回收: 与 DeferFreePage 同一时序约定 (提交成功后才真正还链),
+		// 但一条链只登记一次, 提交后一次 FreeChain 还链。
+		DeferFreeChain: func(head, tail, count uint32) {
+			if head == 0 || count == 0 {
+				return
+			}
+			if db == nil {
+				_ = realFreeChain(head, tail, count) // 启动期没有事务上下文, 保持原行为
+				return
+			}
+			c := pendingChain{head: head, tail: tail, count: count}
+			if txn := db.currentTxn.Load(); txn != nil {
+				txn.pendingFreeChain = append(txn.pendingFreeChain, c)
+				return
+			}
+			db.pendingMu.Lock()
+			db.pendingFreeChain = append(db.pendingFreeChain, c)
+			db.pendingMu.Unlock()
+		},
+		// ValidPage: 页号合法性 = 落在已分配区间 [2, NextFreePage)。
+		// 0 是头页、1 是建库预留的表页; >= NextFreePage 的页从未分配过, 文件里没有
+		// 它的位置 —— 放进空闲链, 下一次 NewPage 就会拿着它写到几十 GB 偏移处
+		// (实测 DROP TABLE 假死 52s、稀疏文件暴涨到 40GB 的直接原因)。
+		ValidPage: func(pageID uint32) bool {
+			ff.headerMu.Lock()
+			limit := header.NextFreePage
+			ff.headerMu.Unlock()
+			return pageID >= 2 && pageID < limit
+		},
+	}
+}
 
-	// 事务回调: BufferPool.WritePage 时登记本事务修改过的页,
-	// 供 Rollback 精确丢弃这些页的缓存 (不影响事务开始前遗留的脏页)
-	ff.SetOnPageWriteCallback(func(pageID uint32) {
-		if db.currentTxn != nil {
-			db.currentTxn.markTouched(pageID)
+// wireRuntime 把运行期回调绑定到本句柄: 事务页登记 (onPageWrite)、脏页阈值
+// 自动提交回调、表页句柄闭包。VACUUM 的 adoptEngineState 换引擎状态后必须重绑,
+// 否则这些闭包仍指向旧句柄。
+func (db *DB) wireRuntime() {
+	db.file.SetOnPageWriteCallback(func(pageID uint32) {
+		if txn := db.currentTxn.Load(); txn != nil {
+			txn.markTouched(pageID)
 		}
 	})
-
-	// 脏页阈值: 超过时自动触发 Commit 刷盘, 防止内存暴涨
-	// 走 autoCommit: 有活跃事务时不能从中途提交 (会破坏事务原子性)
-	ff.SetDirtyThreshold(DefaultDirtyThreshold, db.autoCommit)
-	return db, nil
+	// 脏页阈值: 超过时自动触发 Commit 刷盘, 防止内存暴涨。
+	// 走 autoCommit: 有活跃事务时不能从中途提交 (会破坏事务原子性)。
+	db.file.SetDirtyThreshold(db.dirtyThreshold, db.autoCommit)
+	db.tables.handle = db.buildHandleDb(db.file, db.header, db.freePage, db.freeChain)
 }
 
 // NewPage 分配一个新页号, 走空闲链 / 头页游标的同一路径, 并按事务内已分配登记.
@@ -307,8 +342,8 @@ func Open(path string) (*DB, error) {
 // 仍要自己用 db.file.ReadPage/WritePage 读写内容.
 func (db *DB) NewPage() uint32 {
 	pid := db.file.allocPage(db.header, true)
-	if db.currentTxn != nil {
-		db.currentTxn.allocated = append(db.currentTxn.allocated, pid)
+	if txn := db.currentTxn.Load(); txn != nil {
+		txn.allocated = append(txn.allocated, pid)
 	}
 	return pid
 }
@@ -521,7 +556,7 @@ func (db *DB) NotifyWrite() {
 	}
 	// 有活跃事务时不启动自动提交: 否则定时器到点会把事务从中途截断提交,
 	// 破坏事务原子性, 并使后续 Rollback 无法真正撤销
-	if db.currentTxn != nil {
+	if db.currentTxn.Load() != nil {
 		return
 	}
 	db.autoCommitMu.Lock()
@@ -540,7 +575,7 @@ func (db *DB) NotifyWrite() {
 // autoCommit 自动提交入口 (防抖定时器 / 脏页阈值共用)
 // 有活跃事务时直接跳过, 避免把事务从中途截断提交
 func (db *DB) autoCommit() {
-	if db.currentTxn != nil {
+	if db.currentTxn.Load() != nil {
 		return
 	}
 	_ = db.Commit()
@@ -563,6 +598,7 @@ func (db *DB) stopAutoCommit() {
 
 // SetDirtyThreshold 设置脏页阈值(页数),超过此值自动触发 Commit
 func (db *DB) SetDirtyThreshold(threshold int) {
+	db.dirtyThreshold = threshold
 	db.file.SetDirtyThreshold(threshold, db.autoCommit)
 }
 

+ 20 - 18
DB/core/deleter.go

@@ -300,8 +300,8 @@ func (d *Deleter) rebuildIndexes(pkMeta *IndexMeta, dpIDs []uint32) error {
 // buildSecondaryFromEntries 用"已按 secKey 排好序"的条目 [secKey + pkID(8B BE)] 构建一个二级索引。
 //
 // 页布局决定做法, 不能一律批量建树:
-//   - B+ 树二级索引: 批量建树;
-//   - 复合 / 哈希二级索引: 页不是 B+ 节点, 必须走各自的 handler.Insert
+//   - B+ 树二级索引 (含 IndexTypeCompose —— ComposeChainHandler 底层即 SBT): 批量建树;
+//   - 哈希二级索引: 页不是 B+ 节点, 必须走 handler.Insert
 //     (详见 rebuildSecondaryViaHandler 注释)。
 //
 // 调用方负责 m.EntryPage 的旧根归还 (整表重建走 releaseOldTree); 新建索引回填时
@@ -394,7 +394,10 @@ func releaseIndexPages(table *Table, itype page.IndexType, rootPID uint32) {
 //   - HashHandler: 元数据页 + 目录页链 + 桶页链。按 IndexHead 解析会把
 //     BucketID / NextPage 当成 EntryLen / IsLeaf, 遍历出一串垃圾页 ID, 进而把
 //     不属于本索引的页归还掉 —— 那是数据损坏, 不是泄漏;
-//   - ComposeChainHandler / TimeIndexHandler: 扁平条目链, 没有内部节点。
+//   - TimeIndexHandler: 扁平条目链 + TimeDataPage, 没有内部节点。
+//
+// IndexTypeCompose 的处理器 (ComposeChainHandler) 底层就是 SBT 二级 B+ 树
+// (Insert/Search/RangeScan 全部委托 btree.SBT*), 页即 B+ 节点页 —— 与 BTree 同列。
 //
 // 注意时序索引 (IndexTypeTime): 它同样是行存储索引, 但页是扁平链而非 B+ 树。
 // 因此这里**不能**用 indexStoresRows 来判断 —— 那样会把时序索引当成 B+ 树, 既在
@@ -414,6 +417,9 @@ func isBTreePageType(t page.IndexType) bool {
 	case page.IndexTypeStr:
 		// IndexTypeStr 是 IndexTypeBTree 的旧值, 页格式相同。
 		return true
+	case page.IndexTypeCompose:
+		// ComposeChainHandler 底层即 SBT 二级 B+ 树, 页格式与 BTree 相同。
+		return true
 	default:
 		return false
 	}
@@ -423,8 +429,8 @@ func isBTreePageType(t page.IndexType) bool {
 //
 // 批量删除统一采用「存活行写进全新 DataPage + 索引从零重建」, 因此要求每种索引
 // 都具备从零批量构建的能力。页布局决定做法:
-//   - 主键 B+ 树 / B+ 树二级索引: BuildFromSorted 批量建树
-//   - 复合 / 哈希二级索引: 清空根后走各自 handler.Insert 逐条重建
+//   - 主键 B+ 树 / B+ 树二级索引 (含 IndexTypeCompose, 底层即 SBT): BuildFromSorted 批量建树
+//   - 哈希二级索引: 清空根后走 handler.Insert 逐条重建
 //
 // 时序索引 (IndexTypeTime) 是"扁平链 + TimeDataPage": 行本身就住在按时间分段的
 // TimeDataPage 里, 重建必须重新分段, 与本流程的模型完全不同 —— 明确拒绝。
@@ -466,21 +472,17 @@ func (d *Deleter) checkRebuildSupported(pkMeta *IndexMeta) error {
 	return nil
 }
 
-// rebuildSecondaryViaHandler 用索引自己的 Insert 从零重建一个非 B+ 树二级索引
-// (复合索引的扁平页链 / 哈希索引的目录+桶链)。
-//
-// 为什么不能沿用 SBTBuildFromSorted: 它产出的页是 B+ 节点布局, 而这两种索引的页
-// 布局完全不同 —— 元数据仍写着 IndexTypeCompose / IndexTypeHash, 页却成了 B+ 树。
-// 之后 readHashMeta 在 page.IndexHeadSize 处的魔数校验直接失败
-// (errHashFormatIncompatible: 等值查找恒为 not found, 插入直接报错), 而复合索引
-// 的扁平链遍历会把 B+ 内部节点当条目读, 逐步把整条链写乱。这不只是"格式不统一",
-// 等于把索引写坏。
+// rebuildSecondaryViaHandler 用索引自己的 Insert 从零重建哈希二级索引
+// (目录 + 桶链 —— 唯一的非 B+ 树二级索引)。
 //
-// 走 handler.Insert 的好处是正确性由正常写入路径背书: 哈希的目录扩容 / 桶分裂、
-// 复合链的页追加与链接全部自动走通。代价是这条路径为 O(n log n), 但只影响这两种
-// 索引 —— B+ 树二级索引仍是批量建树, 性能没有回退。
+// 为什么不能沿用 SBTBuildFromSorted: 它产出的页是 B+ 节点布局, 而哈希索引的页
+// 是元数据页 + 目录页链 + 桶页链 —— 元数据仍写着 IndexTypeHash, 页却成了 B+ 树,
+// readHashMeta 在 page.IndexHeadSize 处的魔数校验直接失败
+// (errHashFormatIncompatible: 等值查找恒为 not found, 插入直接报错), 等于把索引写坏。
 //
-// entries 已按 key 排序: 复合链页内用二分查找, 必须有序; 哈希不要求有序, 不受影响。
+// 走 handler.Insert 的好处是正确性由正常写入路径背书: 目录扩容 / 桶分裂自动走通。
+// 代价是这条路径为 O(n log n), 只影响哈希 —— B+ 树二级索引 (含复合索引) 仍走
+// 批量建树, 性能没有回退。哈希不要求 entries 有序, 不受影响。
 func rebuildSecondaryViaHandler(handle page.HandleDb, m *IndexMeta, entries [][]byte) error {
 	name := string(bytes.TrimRight(m.IndexName[:], "\x00"))
 	h, ok := index.Compose.GetHandler(m.IndexType)

+ 172 - 0
DB/core/fuzz_model_test.go

@@ -0,0 +1,172 @@
+package core
+
+import (
+	"fmt"
+	"math/rand"
+	"path/filepath"
+	"testing"
+
+	"git.ycl.pub/yl32940/TsDB2/DB/core/page"
+	"git.ycl.pub/yl32940/TsDB2/DB/core/types"
+)
+
+// TestRandomOpsAgainstModel 模糊测试: 随机插入/更新/删除 (含整表删空再灌),
+// 每步与一个内存模型比对; 最后关库重开, 全量校验持久化结果。
+// 固定种子 → 失败可复现。这是对抗"人工审计想不到的序列"的主要手段。
+func TestRandomOpsAgainstModel(t *testing.T) {
+	seeds := []int64{1, 42, 1337, 20240927, 777, 90091}
+	for _, seed := range seeds {
+		t.Run(fmt.Sprintf("seed-%d", seed), func(t *testing.T) {
+			path := filepath.Join(t.TempDir(), "fuzz.db")
+			db, err := Open(path)
+			if err != nil {
+				t.Fatalf("open: %v", err)
+			}
+			db.SetAutoCommitDelay(0)
+
+			tbl, err := db.CreateTable("t", []types.FieldInfo{
+				{FieldType: types.FieldType_Uint64, FieldName: []byte("id")},
+				{FieldType: types.FieldType_Int64, FieldName: []byte("a")},
+				{FieldType: types.FieldType_Int64, FieldName: []byte("b")},
+			})
+			if err != nil {
+				t.Fatalf("建表: %v", err)
+			}
+			if _, err := tbl.CreateIndex("pk", page.IndexTypePrimary, []byte{0}); err != nil {
+				t.Fatalf("建主键索引: %v", err)
+			}
+			if _, err := tbl.CreateIndex("idx_a", page.IndexTypeBTree, []byte{1}); err != nil {
+				t.Fatalf("建二级索引: %v", err)
+			}
+
+			rng := rand.New(rand.NewSource(seed))
+			model := map[uint64][2]int64{}
+			ids := []uint64{} // 存活 id 池 (用于随机改/删)
+			nextID := uint64(1)
+
+			insert := func(id uint64, a, b int64) {
+				if _, err := tbl.InsertRowWithID(id, []types.FieldValue{
+					{FieldName: []byte("id"), Value: id},
+					{FieldName: []byte("a"), Value: a},
+					{FieldName: []byte("b"), Value: b},
+				}); err != nil {
+					t.Fatalf("插入 id=%d: %v", id, err)
+				}
+			}
+			remove := func(id uint64) {
+				if err := tbl.DeleteRowByPK(id); err != nil {
+					t.Fatalf("删除 id=%d: %v", id, err)
+				}
+			}
+
+			for op := 0; op < 8000; op++ {
+				roll := rng.Intn(100)
+				switch {
+				case roll < 34: // 插入
+					id := nextID
+					nextID++
+					a := int64(rng.Intn(20))
+					b := int64(rng.Intn(7))
+					insert(id, a, b)
+					model[id] = [2]int64{a, b}
+					ids = append(ids, id)
+				case roll < 62 && len(ids) > 0: // 更新 (整行覆盖)
+					k := rng.Intn(len(ids))
+					id := ids[k]
+					a := int64(rng.Intn(20))
+					b := int64(rng.Intn(7))
+					if err := tbl.UpdateRowByPK(id, []types.FieldValue{
+						{FieldName: []byte("a"), Value: a},
+						{FieldName: []byte("b"), Value: b},
+					}); err != nil {
+						t.Fatalf("更新 id=%d: %v", id, err)
+					}
+					model[id] = [2]int64{a, b}
+				case roll < 88 && len(ids) > 0: // 删除
+					k := rng.Intn(len(ids))
+					id := ids[k]
+					ids = append(ids[:k], ids[k+1:]...)
+					remove(id)
+					delete(model, id)
+				case roll < 90: // 整表删空 (制造删空页 + 空闲页复用)
+					for id := range model {
+						remove(id)
+					}
+					model = map[uint64][2]int64{}
+					ids = nil
+				default: // 提交
+					if err := db.Commit(); err != nil {
+						t.Fatalf("commit: %v", err)
+					}
+				}
+			}
+			if err := db.Commit(); err != nil {
+				t.Fatalf("final commit: %v", err)
+			}
+
+			verify := func(phase string) {
+				// 行数
+				if c, _ := tbl.Query().Count(); c != int64(len(model)) {
+					t.Fatalf("%s: count=%d, 模型 %d", phase, c, len(model))
+				}
+				// 全量逐行比对
+				seen := map[uint64][2]int64{}
+				err := tbl.Query().ScanWithPKID(func(pkID uint64, row []types.FieldValue) bool {
+					var pair [2]int64
+					for _, fv := range row {
+						switch string(fv.FieldName) {
+						case "a":
+							pair[0] = fv.Value.(int64)
+						case "b":
+							pair[1] = fv.Value.(int64)
+						}
+					}
+					seen[pkID] = pair
+					return true
+				})
+				if err != nil {
+					t.Fatalf("%s: scan: %v", phase, err)
+				}
+				if len(seen) != len(model) {
+					t.Fatalf("%s: 扫到 %d 行, 模型 %d 行", phase, len(seen), len(model))
+				}
+				for id, want := range model {
+					got, ok := seen[id]
+					if !ok || got != want {
+						t.Fatalf("%s: id=%d 得到 %+v (存在=%v), 模型 %+v", phase, id, got, ok, want)
+					}
+				}
+				// 二级索引等值计数与模型交叉验证
+				for a := int64(0); a < 20; a++ {
+					wantN := int64(0)
+					for _, pair := range model {
+						if pair[0] == a {
+							wantN++
+						}
+					}
+					if n, _ := tbl.Query().Where(Eq("a", a, 0)).Count(); n != wantN {
+						t.Fatalf("%s: a=%d 索引 count=%d, 模型 %d", phase, a, n, wantN)
+					}
+				}
+			}
+
+			verify("关库前")
+			if err := db.Close(); err != nil {
+				t.Fatalf("close: %v", err)
+			}
+
+			// 重开: 持久化校验
+			db2, err := Open(path)
+			if err != nil {
+				t.Fatalf("重开: %v", err)
+			}
+			defer db2.Close()
+			tbl2, ok := db2.FindTable("t")
+			if !ok {
+				t.Fatalf("重开找不到表")
+			}
+			tbl = tbl2
+			verify("重开后")
+		})
+	}
+}

+ 77 - 15
DB/core/index/btree/primary.go

@@ -1,4 +1,4 @@
-package btree
+package btree
 
 import (
 	"git.ycl.pub/yl32940/TsDB2/DB/core/index/datapage"
@@ -580,6 +580,66 @@ func BTreePKSearch(rootPID uint32, key []byte, handle page.HandleDb) (uint32, ui
 	return dpID, slot, st == PKHit
 }
 
+// BTreePKBatchSearch 按【升序】键列表批量定位: 一次路由到首键所在叶, 沿叶链推进,
+// 与目标键做双指针归并。对每个存在的键调用 onHit(keyIdx, dpID, slot);
+// 不存在的键自动跳过。比逐次 BTreePKSearchEx 省掉每键一次的树根到叶下降
+// (二级索引回表 N 行的主键树查询: O(N·树高) → O(树高 + 触碰叶数 + N))。
+//
+// 前提: keys 升序且无重复 (主键唯一); 遍历时树只读 (调用方保证两阶段语义)。
+func BTreePKBatchSearch(rootPID uint32, keys [][]byte, handle page.HandleDb,
+	onHit func(keyIdx int, dpID uint32, slot uint16)) {
+	if rootPID == 0 || len(keys) == 0 {
+		return
+	}
+	// 路由到首键所在叶 (首键不存在时也落在它"应该在"的叶)
+	cur := rootPID
+	for {
+		n := btLoad(handle, cur)
+		if n == nil {
+			return
+		}
+		if n.isLeaf() {
+			break
+		}
+		cur = btRoute(n, keys[0])
+		if cur == 0 {
+			return
+		}
+	}
+
+	ki := 0
+	target := keys[0]
+	for cur != 0 && ki < len(keys) {
+		n := btLoad(handle, cur)
+		if n == nil {
+			break
+		}
+		for i := 0; i < int(n.info.Count) && ki < len(keys); i++ {
+			entry := btEntry(n, i)
+			key := btKey(entry)
+			switch cmp := btCmp(key, target); {
+			case cmp < 0:
+				// 叶键小于当前目标: 继续扫本叶
+			case cmp > 0:
+				// 当前目标不存在 (被删除或从未存在): 跳过它, 同一条目与新目标再比
+				ki++
+				if ki < len(keys) {
+					target = keys[ki]
+				}
+				i-- // 重新检查同一叶条目
+			default:
+				onHit(ki, btPID(entry), btSlot(entry))
+				ki++
+				if ki < len(keys) {
+					target = keys[ki]
+				}
+			}
+		}
+		cur = n.info.Hdr.NextPage
+	}
+	// 剩下的目标键都超出最大叶键 → 全部不存在, 无需处理
+}
+
 // ======================== B+ 树叶子节点定位 ========================
 
 // BTreeFindLeftmostLeaf 从根节点路由到最左叶节点,返回叶节点 pageID
@@ -1471,15 +1531,15 @@ func ScanAllLeavesForPK(rootPID uint32, handle page.HandleDb, pkKeyBE []byte) ([
 }
 
 // ScanAndDelete 扫描所有叶子按 indexKey 删除
-func ScanAndDelete(rootPID uint32, handle page.HandleDb, indexKey, pkKeyBE []byte) error {
+func ScanAndDelete(rootPID uint32, handle page.HandleDb, indexKey, pkKeyBE []byte) (uint32, error) {
 	if rootPID == 0 {
-		return page.ErrNotFound
+		return rootPID, page.ErrNotFound
 	}
 	cur := rootPID
 	for {
 		n := btLoad(handle, cur)
 		if n == nil {
-			return page.ErrNotFound
+			return rootPID, page.ErrNotFound
 		}
 		if n.isLeaf() {
 			break
@@ -1513,31 +1573,33 @@ func ScanAndDelete(rootPID uint32, handle page.HandleDb, indexKey, pkKeyBE []byt
 					if dp.AllDeleted() {
 						newRoot, _, err := BTreePKDelete(rootPID, indexKey, handle)
 						if err != nil {
-							return err
+							return rootPID, err
 						}
-						_ = newRoot
+						// 删除可能换根 (树删空/降高): 新根必须带回给调用方,
+						// 否则索引元数据悬挂在已还链的旧根上
+						return newRoot, nil
 					} else {
 						dp.Save()
 					}
-					return nil
+					return rootPID, nil
 				}
 			}
 		}
 		cur = n.info.Hdr.NextPage
 	}
-	return page.ErrNotFound
+	return rootPID, page.ErrNotFound
 }
 
 // ScanAndDeleteByPK 扫描所有叶子按 pkID 删除
-func ScanAndDeleteByPK(rootPID uint32, handle page.HandleDb, pkKeyBE []byte) error {
+func ScanAndDeleteByPK(rootPID uint32, handle page.HandleDb, pkKeyBE []byte) (uint32, error) {
 	if rootPID == 0 {
-		return page.ErrNotFound
+		return rootPID, page.ErrNotFound
 	}
 	cur := rootPID
 	for {
 		n := btLoad(handle, cur)
 		if n == nil {
-			return page.ErrNotFound
+			return rootPID, page.ErrNotFound
 		}
 		if n.isLeaf() {
 			break
@@ -1567,19 +1629,19 @@ func ScanAndDeleteByPK(rootPID uint32, handle page.HandleDb, pkKeyBE []byte) err
 					if dp.AllDeleted() {
 						newRoot, _, err := BTreePKDelete(rootPID, pkKeyBE, handle)
 						if err != nil {
-							return err
+							return rootPID, err
 						}
-						_ = newRoot
+						return newRoot, nil
 					} else {
 						dp.Save()
 					}
-					return nil
+					return rootPID, nil
 				}
 			}
 		}
 		cur = n.info.Hdr.NextPage
 	}
-	return page.ErrNotFound
+	return rootPID, page.ErrNotFound
 }
 
 // ScanAndUpdate 扫描所有叶子按 pkID 更新

+ 6 - 16
DB/core/index/btree/secondary.go

@@ -1,11 +1,13 @@
-package btree
+package btree
 
 import (
-	"git.ycl.pub/yl32940/TsDB2/DB/core/page"
+	"bytes"
 	"encoding/binary"
 	"errors"
 	"fmt"
 	"unsafe"
+
+	"git.ycl.pub/yl32940/TsDB2/DB/core/page"
 )
 
 // ======================== 二级 B+ 树索引操作 ========================
@@ -65,25 +67,13 @@ func sbtEntry(n *btNode, i int) []byte {
 
 func sbtLeafKey(e []byte) []byte          { return e[:len(e)-8] }
 func sbtPKID(e []byte) uint64             { return binary.BigEndian.Uint64(e[len(e)-8:]) }
-func sbtIntKey(e []byte) []byte           { return e[:len(e)-12] }                       // 内部条目的 key 部分
+func sbtIntKey(e []byte) []byte           { return e[:len(e)-12] }                          // 内部条目的 key 部分
 func sbtIntPKID(e []byte) uint64          { return binary.BigEndian.Uint64(e[len(e)-12:]) } // 内部条目的 pkID 部分
 func sbtChildPID(e []byte) uint32         { return binary.BigEndian.Uint32(e[len(e)-4:]) }
 func sbtSetChildPID(e []byte, pid uint32) { binary.BigEndian.PutUint32(e[len(e)-4:], pid) }
 
 func sbtCmp(a, b []byte) int {
-	ml := len(a)
-	if len(b) < ml {
-		ml = len(b)
-	}
-	for i := 0; i < ml; i++ {
-		if a[i] < b[i] {
-			return -1
-		}
-		if a[i] > b[i] {
-			return 1
-		}
-	}
-	return 0
+	return bytes.Compare(a, b)
 }
 
 // sbtCmpKeyPK 复合键 (key, pkID) 的全序比较: 先比 key, 再比 pkID。

+ 47 - 326
DB/core/index/compose_handler.go

@@ -1,211 +1,79 @@
-package index
+package index
 
 import (
-	"git.ycl.pub/yl32940/TsDB2/DB/core/index/composepage"
-	"git.ycl.pub/yl32940/TsDB2/DB/core/page"
-	"git.ycl.pub/yl32940/TsDB2/DB/core/util"
 	"bytes"
 	"encoding/binary"
-	"unsafe"
+
+	"git.ycl.pub/yl32940/TsDB2/DB/core/index/btree"
+	"git.ycl.pub/yl32940/TsDB2/DB/core/page"
 )
 
 func init() {
 	Compose.RegisterHandler(page.IndexTypeCompose, &ComposeChainHandler{})
 }
 
-// ======================== IEntry: 复合索引条目 ========================
-
-// ComposeEntry 通用复合索引结构体
-// Keys: 存储 N 个索引键 (uint64)
-// PKID: 主键ID(8字节),通过 B+ 树解析数据位置
-type ComposeEntry struct {
-	Keys []uint64 // 任意多个索引键(1个、2个、3..都支持)
-	PKID uint64   // 主键ID (8字节)
-}
-
-func (e *ComposeEntry) DataEntry() IDateEntry {
-	return &composepage.ComposeEntryPage{}
-}
-
-// EntryLength 自动计算长度:8*键数量 + 8 (PK_ID)
-func (e *ComposeEntry) EntryLength() uint16 {
-	return uint16(len(e.Keys)*8 + 8)
-}
-
-// Compare 通用复合索引比较逻辑:自动按顺序比较所有键(不含末尾 PK_ID)
-func (e *ComposeEntry) Compare(midEntry []byte, key []byte) int {
-	entryLen := int(e.EntryLength())
-	keyCount := (entryLen - 8) / 8
-	for i := 0; i < keyCount; i++ {
-		offset := i * 8
-		f1 := binary.BigEndian.Uint64(midEntry[offset : offset+8])
-		f2 := binary.BigEndian.Uint64(key[offset : offset+8])
-		cmp := util.CompareUint64(f1, f2)
-		if cmp != 0 {
-			return cmp
-		}
-	}
-	return 0
-}
-
-// Serialize 自动序列化 N 个键 + PK_ID
-func (e *ComposeEntry) Serialize(ie IEntry) []byte {
-	ent, ok := ie.(*ComposeEntry)
-	if !ok {
-		return nil
-	}
-	keyCount := len(ent.Keys)
-	buf := make([]byte, keyCount*8+8)
-	for i, k := range ent.Keys {
-		binary.BigEndian.PutUint64(buf[i*8:(i+1)*8], k)
-	}
-	binary.BigEndian.PutUint64(buf[keyCount*8:], ent.PKID)
-	return buf
-}
-
-// Deserialize 自动反序列化 N 个键 + PK_ID
-func (e *ComposeEntry) Deserialize(buf []byte) IEntry {
-	keyCount := (len(buf) - 8) / 8
-	keys := make([]uint64, 0, keyCount)
-	for i := 0; i < keyCount; i++ {
-		keys = append(keys, binary.BigEndian.Uint64(buf[i*8:(i+1)*8]))
-	}
-	return &ComposeEntry{
-		Keys: keys,
-		PKID: binary.BigEndian.Uint64(buf[keyCount*8:]),
-	}
-}
-
-// Insert 插入索引
-func (e *ComposeEntry) Insert(entries *[]byte, data []byte) error {
-	*entries = append(*entries, data...)
-	return nil
-}
-
-// Search 二分查找(通用 N 字段复合索引)
-func (e *ComposeEntry) Search(entries []byte, key []byte) (uint32, bool) {
-	entryLen := int(e.EntryLength())
-	pos, found := util.BinarySearch(entries, entryLen, key, e.Compare)
-	if !found {
-		return 0, false
-	}
-	return pos, true
-}
-
 // ======================== IndexHandler: 复合二级索引处理器 ========================
 
 // ComposeChainHandler — 复合二级索引 (page.IndexTypeCompose)
-// 扁平索引页链, 条目 [composeKey(N*8B) + PKID(8B)]
+// 底层即 B+ 树二级索引 (与 BTreeHandler 共享 btree.SBT* 实现):
+// Insert/Search/Delete/RangeScan 全部委托 SBT, 条目格式 [key(keyLen) + PKID(8B BE)],
+// keyLen = 各分量按真实宽度定长拼接的总长 (分量不限 8 字节, 与 BTree 复合键一致)。
 type ComposeChainHandler struct{}
 
 func (h *ComposeChainHandler) Insert(handle page.HandleDb, entryPage *uint32, indexKey []byte, pkID uint64, rowData []byte, keyLen int) (uint32, error) {
-	dataEntry := h.getDataEntry()
-	curPageID := *entryPage
-	lastIdxPageID := uint32(0)
-	for curPageID != 0 {
-		buf := make([]byte, page.IndexPageSize)
-		if err := handle.ReadPage(curPageID, buf); err != nil {
-			return 0, err
-		}
-		hdr := (*page.IndexHead)(unsafe.Pointer(&buf[0]))
-		pageEntryLen := int(hdr.EntryLen)
-		count := int(hdr.Count)
-		idxPageHasRoom := hdr.Hdr.UseSize+uint32(pageEntryLen) <= page.IndexPageSize
-		if !idxPageHasRoom {
-			lastIdxPageID = curPageID
-			curPageID = hdr.Hdr.NextPage
-			continue
-		}
-		entries := buf[page.IndexHeadSize : page.IndexHeadSize+(count+1)*pageEntryLen]
-		err := dataEntry.Insert(handle, entries, pageEntryLen, count, indexKey, pkID, rowData)
-		if err == nil {
-			hdr.Count++
-			hdr.Hdr.UseSize += uint32(pageEntryLen)
-			if err := handle.SavePage(curPageID, buf); err != nil {
-				return 0, err
-			}
-			return *entryPage, nil
-		}
-		lastIdxPageID = curPageID
-		curPageID = hdr.Hdr.NextPage
+	entry := make([]byte, keyLen+8)
+	copy(entry[:keyLen], indexKey)
+	binary.BigEndian.PutUint64(entry[keyLen:], pkID)
+	rootPID := *entryPage
+	newRoot, err := btree.SBTInsert(rootPID, entry, handle)
+	if err != nil {
+		return 0, err
 	}
-	entryLen := keyLen + 8
-	newEntry := make([]byte, entryLen)
-	copy(newEntry[:keyLen], indexKey)
-	binary.BigEndian.PutUint64(newEntry[keyLen:], pkID)
-	return *entryPage, writeToNewIndexPage(handle, entryPage, newEntry, lastIdxPageID)
+	*entryPage = newRoot
+	return newRoot, nil
 }
 
 func (h *ComposeChainHandler) Search(handle page.HandleDb, entryPage uint32, indexKey []byte, keyLen int, resolve RowResolver) ([]byte, bool) {
-	dataEntry := h.getDataEntry()
-	curPageID := entryPage
-	for curPageID != 0 {
-		buf := make([]byte, page.IndexPageSize)
-		if err := handle.ReadPage(curPageID, buf); err != nil {
-			break
-		}
-		hdr := (*page.IndexHead)(unsafe.Pointer(&buf[0]))
-		entries := buf[page.IndexHeadSize : page.IndexHeadSize+int(hdr.Count)*int(hdr.EntryLen)]
-		entryLen := int(hdr.EntryLen)
-		pkBuf, found := dataEntry.Search(handle, entries, entryLen, indexKey)
-		if found {
-			// 二级索引不存行数据: 内部回表取整行
-			if resolve != nil && len(pkBuf) == 8 {
-				if row, ok := resolve(binary.BigEndian.Uint64(pkBuf)); ok {
-					return row, true
-				}
-				return nil, false
-			}
-			return pkBuf, true
+	rootPID := entryPage
+	if rootPID == 0 {
+		return nil, false
+	}
+	pkID, found := btree.SBTSearch(rootPID, indexKey[:keyLen], handle)
+	if !found {
+		return nil, false
+	}
+	if resolve != nil {
+		if row, ok := resolve(pkID); ok {
+			return row, true
 		}
-		curPageID = hdr.Hdr.NextPage
+		return nil, false
 	}
-	return nil, false
+	pkBuf := make([]byte, 8)
+	binary.BigEndian.PutUint64(pkBuf, pkID)
+	return pkBuf, true
 }
 
-// Delete 按 (key, pkID) 精确删除复合索引条目。
-// 链页里的条目是 [composeKey(N*8B) + pkID(8B)] 的扁平数组, 直接原地摘除即可。
 func (h *ComposeChainHandler) Delete(handle page.HandleDb, entryPage *uint32, indexKey []byte, pkID uint64, keyLen int) error {
-	curPageID := *entryPage
-	for curPageID != 0 {
-		buf := make([]byte, page.IndexPageSize)
-		if err := handle.ReadPage(curPageID, buf); err != nil {
-			return err
-		}
-		hdr := (*page.IndexHead)(unsafe.Pointer(&buf[0]))
-		entryLen := int(hdr.EntryLen)
-		count := int(hdr.Count)
-		if entryLen > 8 && count > 0 {
-			base := page.IndexHeadSize
-			keyPart := entryLen - 8
-			for i := 0; i < count; i++ {
-				off := base + i*entryLen
-				if off+entryLen > len(buf) {
-					break
-				}
-				if composeCmp(buf[off:off+keyPart], indexKey) != 0 {
-					continue
-				}
-				if pkID != 0 && binary.BigEndian.Uint64(buf[off+keyPart:off+entryLen]) != pkID {
-					continue
-				}
-				copy(buf[off:base+count*entryLen], buf[off+entryLen:base+count*entryLen])
-				copy(buf[base+(count-1)*entryLen:base+count*entryLen], make([]byte, entryLen))
-				hdr.Count--
-				hdr.Hdr.UseSize -= uint32(entryLen)
-				if err := handle.SavePage(curPageID, buf); err != nil {
-					return err
-				}
-				return nil
-			}
+	rootPID := *entryPage
+	if rootPID == 0 {
+		return page.ErrNotFound
+	}
+	if pkID == 0 {
+		pk, found := btree.SBTSearch(rootPID, indexKey[:keyLen], handle)
+		if !found {
+			return page.ErrNotFound
 		}
-		curPageID = hdr.Hdr.NextPage
+		pkID = pk
 	}
-	return page.ErrNotFound
+	newRoot, err := btree.SBTDeleteWithPKID(rootPID, indexKey[:keyLen], pkID, handle)
+	if err != nil {
+		return err
+	}
+	*entryPage = newRoot
+	return nil
 }
 
 func (h *ComposeChainHandler) Update(handle page.HandleDb, entryPage *uint32, oldKey, newKey, rowData []byte, pkID uint64, keyLen int) error {
-	// 二级索引条目只存 [key + pkID]: 键未变则无事可做, 键变了则删旧插新
 	if bytes.Equal(oldKey, newKey) {
 		return nil
 	}
@@ -218,10 +86,8 @@ func (h *ComposeChainHandler) Update(handle page.HandleDb, entryPage *uint32, ol
 	return err
 }
 
-// IsRowStore 复合二级索引不承载行数据
 func (h *ComposeChainHandler) IsRowStore() bool { return false }
 
-// ResolveRow 复合二级索引无法按 pkID 取行, 由调用方的回表函数负责
 func (h *ComposeChainHandler) ResolveRow(handle page.HandleDb, entryPage uint32, pkID uint64, cache *[]DPRange) ([]byte, bool) {
 	return nil, false
 }
@@ -234,153 +100,8 @@ func (h *ComposeChainHandler) ReadDataPageRows(handle page.HandleDb, dpID uint32
 	return nil
 }
 
-// RangeScan 复合二级索引范围扫描
-// 条目格式: [key(keyLen) + pkID(8B BE)]
-// 回调收到 8B BigEndian pkID
 func (h *ComposeChainHandler) RangeScan(handle page.HandleDb, entryPage uint32,
 	startKey, endKey []byte, startInclusive, endInclusive bool, reverse bool,
 	callback func(row []byte) bool) error {
-	if entryPage == 0 {
-		return nil
-	}
-	type pageData struct {
-		entries []byte
-		el      int
-		count   int
-	}
-	var pages []pageData
-	curPageID := entryPage
-	for curPageID != 0 {
-		buf := make([]byte, page.IndexPageSize)
-		if err := handle.ReadPage(curPageID, buf); err != nil {
-			break
-		}
-		hdr := (*page.IndexHead)(unsafe.Pointer(&buf[0]))
-		el := int(hdr.EntryLen)
-		count := int(hdr.Count)
-		entries := make([]byte, count*el)
-		copy(entries, buf[page.IndexHeadSize:page.IndexHeadSize+count*el])
-		pages = append(pages, pageData{entries: entries, el: el, count: count})
-		curPageID = hdr.Hdr.NextPage
-	}
-	if !reverse {
-		for p := 0; p < len(pages); p++ {
-			pg := &pages[p]
-			for i := 0; i < pg.count; i++ {
-				entry := pg.entries[i*pg.el : (i+1)*pg.el]
-				key := entry[:pg.el-8]
-				if endKey != nil {
-					cmp := composeCmp(key, endKey)
-					if cmp > 0 || (cmp == 0 && !endInclusive) {
-						return nil
-					}
-				}
-				if startKey != nil {
-					cmp := composeCmp(key, startKey)
-					if cmp < 0 || (cmp == 0 && !startInclusive) {
-						continue
-					}
-				}
-				pkID := entry[pg.el-8:]
-				if !callback(pkID) {
-					return nil
-				}
-			}
-		}
-	} else {
-		for p := len(pages) - 1; p >= 0; p-- {
-			pg := &pages[p]
-			for i := pg.count - 1; i >= 0; i-- {
-				entry := pg.entries[i*pg.el : (i+1)*pg.el]
-				key := entry[:pg.el-8]
-				if startKey != nil {
-					cmp := composeCmp(key, startKey)
-					if cmp < 0 || (cmp == 0 && !startInclusive) {
-						return nil
-					}
-				}
-				if endKey != nil {
-					cmp := composeCmp(key, endKey)
-					if cmp > 0 || (cmp == 0 && !endInclusive) {
-						continue
-					}
-				}
-				pkID := entry[pg.el-8:]
-				if !callback(pkID) {
-					return nil
-				}
-			}
-		}
-	}
-	return nil
-}
-
-// composeCmp 字节切片比较
-func composeCmp(a, b []byte) int {
-	ml := len(a)
-	if len(b) < ml {
-		ml = len(b)
-	}
-	for i := 0; i < ml; i++ {
-		if a[i] < b[i] {
-			return -1
-		}
-		if a[i] > b[i] {
-			return 1
-		}
-	}
-	if len(a) < len(b) {
-		return -1
-	}
-	if len(a) > len(b) {
-		return 1
-	}
-	return 0
-}
-
-func (h *ComposeChainHandler) deleteEntry(handle page.HandleDb, entryPage *uint32, indexKey []byte) error {
-	dataEntry := h.getDataEntry()
-	curPageID := *entryPage
-	for curPageID != 0 {
-		buf := make([]byte, page.IndexPageSize)
-		if err := handle.ReadPage(curPageID, buf); err != nil {
-			return err
-		}
-		hdr := (*page.IndexHead)(unsafe.Pointer(&buf[0]))
-		entries := buf[page.IndexHeadSize : page.IndexHeadSize+int(hdr.Count)*int(hdr.EntryLen)]
-		entryLen := int(hdr.EntryLen)
-		err := dataEntry.Delete(handle, entries, entryLen, indexKey)
-		if err == nil {
-			hdr.Hdr.UseSize -= uint32(entryLen)
-			if err := handle.SavePage(curPageID, buf); err != nil {
-				return err
-			}
-			return nil
-		}
-		curPageID = hdr.Hdr.NextPage
-	}
-	return page.ErrNotFound
-}
-
-func (h *ComposeChainHandler) getDataEntry() IDateEntry {
-	handler, _ := Compose.GetEntryHandler(page.IndexTypeCompose)
-	return handler.DataEntry()
-}
-
-// CollectPageIDs 实现 index.PageCollector: 顺着 NextPage 走完整条扁平页链。
-// seen 兜底防环 (链被写坏时 NextPage 可能绕回自身, 否则这里会死循环)。
-func (h *ComposeChainHandler) CollectPageIDs(handle page.HandleDb, entryPage uint32) []uint32 {
-	var out []uint32
-	seen := make(map[uint32]bool)
-	for cur := entryPage; cur != 0 && !seen[cur]; {
-		seen[cur] = true
-		out = append(out, cur)
-		buf := make([]byte, page.IndexPageSize)
-		if err := handle.ReadPage(cur, buf); err != nil {
-			// 读不到就到此为止: 继续按 buf 里的残留指针走会收集到无关的页号
-			break
-		}
-		cur = (*page.IndexHead)(unsafe.Pointer(&buf[0])).Hdr.NextPage
-	}
-	return out
+	return btree.SBTRangeScan(entryPage, handle, startKey, endKey, startInclusive, endInclusive, reverse, callback)
 }

+ 0 - 146
DB/core/index/composepage/composepage.go

@@ -1,146 +0,0 @@
-package composepage
-
-import (
-	"git.ycl.pub/yl32940/TsDB2/DB/core/page"
-	"encoding/binary"
-	"errors"
-	"unsafe"
-)
-
-// ComposeEntryPage 复合索引操作器
-// 不再管理独立的 DataPage,直接在 IndexPage 条目中存储 [composeKey(N*8B) + PK_ID(8B)]
-// Search 返回 PK_ID 的 8 字节编码,Table 层再通过 B+ 树解析实际行数据
-type ComposeEntryPage struct{}
-
-const pkIDLen = 8
-
-// countBackOffset Count 字段距离 entries 起始位置的偏移
-var countBackOffset = int(unsafe.Sizeof(page.PageHdr{})) + 14 - (int(unsafe.Sizeof(page.PageHdr{})) + 6)
-
-// ======================== IDateEntry 接口实现 ========================
-
-// Insert 在 IndexPage 条目中插入 [composeKey + PK_ID(8B)]
-func (c *ComposeEntryPage) Insert(handle page.HandleDb, indexEntries []byte, entryLen int, count int, key []byte, pkID uint64, rowData []byte) error {
-	keyPartLen := entryLen - pkIDLen
-	n := count
-
-	// 二分查找插入位置(按复合 key 排序)
-	pos := n
-	left, right := 0, n-1
-	for left <= right {
-		mid := (left + right) / 2
-		entryKey := indexEntries[mid*entryLen : mid*entryLen+keyPartLen]
-		if compareComposeKey(entryKey, key[:keyPartLen]) >= 0 {
-			pos = mid
-			right = mid - 1
-		} else {
-			left = mid + 1
-		}
-	}
-
-	// 移动后续条目腾出空间
-	if pos < n {
-		copy(indexEntries[(pos+1)*entryLen:], indexEntries[pos*entryLen:n*entryLen])
-	}
-
-	// 写入新条目 [composeKey + PK_ID(8B)]
-	entry := indexEntries[pos*entryLen : (pos+1)*entryLen]
-	copy(entry[:keyPartLen], key[:keyPartLen])
-	binary.BigEndian.PutUint64(entry[keyPartLen:], pkID)
-
-	return nil
-}
-
-// Search 二分查找复合 key,返回 PK_ID 的 8 字节编码
-func (c *ComposeEntryPage) Search(handle page.HandleDb, indexEntries []byte, entryLen int, key []byte) ([]byte, bool) {
-	keyPartLen := entryLen - pkIDLen
-	n := len(indexEntries) / entryLen
-	if n == 0 {
-		return nil, false
-	}
-
-	left, right := 0, n-1
-	for left <= right {
-		mid := (left + right) / 2
-		entryKey := indexEntries[mid*entryLen : mid*entryLen+keyPartLen]
-		cmp := compareComposeKey(entryKey, key[:keyPartLen])
-		if cmp == 0 {
-			pkIDBytes := indexEntries[mid*entryLen+keyPartLen : mid*entryLen+keyPartLen+pkIDLen]
-			result := make([]byte, pkIDLen)
-			copy(result, pkIDBytes)
-			return result, true
-		} else if cmp < 0 {
-			left = mid + 1
-		} else {
-			right = mid - 1
-		}
-	}
-	return nil, false
-}
-
-// Delete 从 IndexPage 中删除条目
-func (c *ComposeEntryPage) Delete(handle page.HandleDb, indexEntries []byte, entryLen int, key []byte) error {
-	keyPartLen := entryLen - pkIDLen
-	n := len(indexEntries) / entryLen
-
-	pos := -1
-	left, right := 0, n-1
-	for left <= right {
-		mid := (left + right) / 2
-		entryKey := indexEntries[mid*entryLen : mid*entryLen+keyPartLen]
-		cmp := compareComposeKey(entryKey, key[:keyPartLen])
-		if cmp == 0 {
-			pos = mid
-			break
-		} else if cmp < 0 {
-			left = mid + 1
-		} else {
-			right = mid - 1
-		}
-	}
-
-	if pos < 0 {
-		return errors.New("row not found")
-	}
-
-	// 前移后续条目
-	if pos < n-1 {
-		copy(indexEntries[pos*entryLen:], indexEntries[(pos+1)*entryLen:n*entryLen])
-	}
-	zeroes := make([]byte, entryLen)
-	copy(indexEntries[(n-1)*entryLen:n*entryLen], zeroes)
-
-	// 更新 Count
-	countPtr := (*uint32)(unsafe.Add(unsafe.Pointer(&indexEntries[0]), -countBackOffset))
-	*countPtr--
-
-	return nil
-}
-
-// Update 复合索引不支持直接更新
-func (c *ComposeEntryPage) Update(handle page.HandleDb, indexEntries []byte, entryLen int, key []byte, pkID uint64, rowData []byte) error {
-	return errors.New("compose index does not support update")
-}
-
-// CreateDataPage 复合索引不需要创建数据页
-func (c *ComposeEntryPage) CreateDataPage(handle page.HandleDb, key []byte, pkID uint64, rowData []byte) (uint32, error) {
-	return 0, errors.New("compose index does not create data pages")
-}
-
-// ======================== 工具函数 ========================
-
-// compareComposeKey 比较两个复合 key(每个字段 8 字节 BigEndian)
-func compareComposeKey(a, b []byte) int {
-	keyCount := len(a) / 8
-	for i := 0; i < keyCount; i++ {
-		va := binary.BigEndian.Uint64(a[i*8:])
-		vb := binary.BigEndian.Uint64(b[i*8:])
-		if va < vb {
-			return -1
-		}
-		if va > vb {
-			return 1
-		}
-	}
-	return 0
-}

+ 2 - 3
DB/core/index/datapage/datapage.go

@@ -1213,9 +1213,8 @@ func (d *IDateEntryDataPage) Delete(handle page.HandleDb, indexEntries []byte, e
 		return page.ErrNotFound
 	}
 	if dp.AllDeleted() {
-		if handle.FreePage != nil {
-			handle.FreePage(dp.pageID)
-		}
+		// 延迟还链, 理由同 TimeDataPage.Delete
+		handle.DeferFreeDataPage(dp.pageID)
 	} else {
 		dp.Save()
 	}

+ 17 - 3
DB/core/index/ientry.go

@@ -143,6 +143,23 @@ type BatchRow struct {
 	RowData  []byte
 }
 
+// BatchRowResolver 可选的批量回表能力: 按【升序】pkID 列表一次取回多行。
+//
+// 二级索引范围扫描/IN 查询回表时, 逐个 ResolveRow 是"每行一次树根到叶下降"的
+// N+1 结构; 实现本接口的处理器可以用单调游标一次推进 (B+ 树: 路由一次 + 沿叶链
+// 双指针归并; 时序: 沿分段链一趟), 把回表从 O(N·树高) 降到 O(N + 触碰页数)。
+//
+// 契约:
+//   - sortedPKIDs 必须升序 (调用方负责排序去重);
+//   - 对每个【存在】的 pkID 调 sink(pkID, storeRow), storeRow 带 8B pkID 前缀,
+//     跨 sink 调用有效 (实现内部快照); 不存在的 pkID 直接跳过;
+//   - sink 返回 false 时停止遍历;
+//   - 未实现本接口的处理器由 IndexMetaPage.BatchResolveByPK 退化为逐个 ResolveRow。
+type BatchRowResolver interface {
+	ResolveMany(handle page.HandleDb, entryPage uint32, sortedPKIDs []uint64,
+		sink func(pkID uint64, storeRow []byte) bool)
+}
+
 // BatchInserter 可选的批量插入能力。
 //
 // 实现它的 IndexHandler 获得批量写入路径 (B+ 树叶驻内存、页落盘按页摊销);
@@ -225,7 +242,4 @@ var Compose = ComposeType{
 
 func init() {
 	Compose.register(page.IndexTypeTime, &IndexTimeEntry{})
-	Compose.register(page.IndexTypeCompose, &ComposeEntry{})
-	// 向后兼容:旧数据库中 IndexTypeStr=3 的索引条目用 ComposeEntry 处理
-	Compose.register(page.IndexTypeStr, &ComposeEntry{})
 }

+ 82 - 11
DB/core/index/primary_handler.go

@@ -139,6 +139,68 @@ func (h *PrimaryHandler) ResolveRow(handle page.HandleDb, entryPage uint32, pkID
 	return h.SearchByPK(handle, entryPage, pkID)
 }
 
+// resolveChunk 批量回表的单批行数: 每批"游标走查 + 物化"完成后才触发上层回调,
+// 回调里对行存储索引的 Update/Delete 不会踩坏正在推进的游标。
+const resolveChunk = 512
+
+// ResolveMany 批量回表: 见 index.BatchRowResolver 接口契约。
+//
+// 分批执行: 每批"游标走查 + 物化"完成后才调 sink —— 回调里对行存储索引的
+// Update/Delete 不会踩坏正在推进的叶链游标 (踩坏场景: 删除触发叶合并, NextPage
+// 被改写, 游标读到回收页; demo/stress_test 的 Scan+Delete 实测踩过)。
+// 行快照跨 sink 调用有效 (实现内部缓冲)。
+func (h *PrimaryHandler) ResolveMany(handle page.HandleDb, entryPage uint32,
+	sortedPKIDs []uint64, sink func(pkID uint64, storeRow []byte) bool) {
+	if entryPage == 0 || len(sortedPKIDs) == 0 {
+		return
+	}
+	for start := 0; start < len(sortedPKIDs); start += resolveChunk {
+		end := start + resolveChunk
+		if end > len(sortedPKIDs) {
+			end = len(sortedPKIDs)
+		}
+		chunk := sortedPKIDs[start:end]
+		keys := make([][]byte, len(chunk))
+		for i, pkID := range chunk {
+			k := make([]byte, 8)
+			binary.BigEndian.PutUint64(k, pkID)
+			keys[i] = k
+		}
+		// 本批物化: 游标走查期间【没有】用户回调在跑
+		type hit struct {
+			pkID uint64
+			row  []byte
+		}
+		var batch []hit
+		var lastDP *datapage.DataPage
+		lastDPID := uint32(0)
+		btree.BTreePKBatchSearch(entryPage, keys, handle,
+			func(keyIdx int, dpID uint32, slot uint16) {
+				if dpID != lastDPID {
+					dp, err := datapage.Load(handle, dpID)
+					if err != nil {
+						lastDPID = dpID
+						lastDP = nil
+						return
+					}
+					lastDP, lastDPID = dp, dpID
+				}
+				if lastDP == nil {
+					return
+				}
+				if row, ok := lastDP.ReadRowBySlotPK(uint32(slot), keys[keyIdx]); ok {
+					batch = append(batch, hit{pkID: chunk[keyIdx], row: row})
+				}
+			})
+		// 走查完成, 现在才触发上层回调
+		for _, h := range batch {
+			if !sink(h.pkID, h.row) {
+				return
+			}
+		}
+	}
+}
+
 func (h *PrimaryHandler) SearchByPK(handle page.HandleDb, entryPage uint32, pkID uint64) ([]byte, bool) {
 	rootPID := entryPage
 	if rootPID == 0 {
@@ -203,9 +265,10 @@ func (h *PrimaryHandler) Delete(handle page.HandleDb, entryPage *uint32, indexKe
 					*entryPage = newRoot
 				}
 				if dp.AllDeleted() {
-					if handle.FreePage != nil {
-						handle.FreePage(dpID)
-					}
+					// 延迟还链: 页仍被 B+ 树叶条目引用 (回滚/崩溃重放后树会
+					// 复活并继续指向它), 立即还链会让它被复用后读到别人的页
+					// (数据损坏)。等 Commit 成功 (或 Close) 后由 pendingFree 统一还。
+					handle.DeferFreeDataPage(dpID)
 				} else {
 					dp.Save()
 				}
@@ -214,7 +277,11 @@ func (h *PrimaryHandler) Delete(handle page.HandleDb, entryPage *uint32, indexKe
 		}
 	}
 	// 路由失败 (树结构异常), 扫描所有叶子
-	return btree.ScanAndDelete(rootPID, handle, indexKey, indexKey)
+	newRoot, err := btree.ScanAndDelete(rootPID, handle, indexKey, indexKey)
+	if err == nil && newRoot != rootPID {
+		*entryPage = newRoot // 兜底删除也可能换根 (树删空/降高), 必须回写
+	}
+	return err
 }
 
 func (h *PrimaryHandler) DeleteByPK(handle page.HandleDb, entryPage *uint32, pkID uint64) error {
@@ -248,9 +315,10 @@ func (h *PrimaryHandler) DeleteByPK(handle page.HandleDb, entryPage *uint32, pkI
 					*entryPage = newRoot
 				}
 				if dp.AllDeleted() {
-					if handle.FreePage != nil {
-						handle.FreePage(dpID)
-					}
+					// 延迟还链: 页仍被 B+ 树叶条目引用 (回滚/崩溃重放后树会
+					// 复活并继续指向它), 立即还链会让它被复用后读到别人的页
+					// (数据损坏)。等 Commit 成功 (或 Close) 后由 pendingFree 统一还。
+					handle.DeferFreeDataPage(dpID)
 				} else {
 					dp.Save()
 				}
@@ -259,7 +327,11 @@ func (h *PrimaryHandler) DeleteByPK(handle page.HandleDb, entryPage *uint32, pkI
 		}
 	}
 	// 路由失败 (树结构异常), 扫描所有叶子
-	return btree.ScanAndDeleteByPK(rootPID, handle, pkKeyBE)
+	newRoot, err := btree.ScanAndDeleteByPK(rootPID, handle, pkKeyBE)
+	if err == nil && newRoot != rootPID {
+		*entryPage = newRoot // 兜底删除也可能换根, 必须回写
+	}
+	return err
 }
 
 // Update 更新主键索引中 pkID 对应的行。
@@ -349,9 +421,8 @@ func (h *PrimaryHandler) relocateRow(handle page.HandleDb, entryPage *uint32, pk
 	if oldDP, lerr := datapage.Load(handle, oldDPID); lerr == nil {
 		if oldDP.DeleteByPK(pkKeyBE) {
 			if oldDP.AllDeleted() {
-				if handle.FreePage != nil {
-					handle.FreePage(oldDPID)
-				}
+				// 延迟还链, 理由同 Delete 路径
+				handle.DeferFreeDataPage(oldDPID)
 			} else {
 				oldDP.Save()
 			}

+ 0 - 135
DB/core/index/strdatapage/strdatapage.go

@@ -1,135 +0,0 @@
-package strdatapage
-
-import (
-	"git.ycl.pub/yl32940/TsDB2/DB/core/page"
-	"bytes"
-	"encoding/binary"
-	"errors"
-	"unsafe"
-)
-
-// StrDataPage 字符串索引操作器
-// 不再管理独立的 DataPage,直接在 IndexPage 条目中存储 [strKey(N) + PK_ID(8B)]
-// Search 返回 PK_ID 的 8 字节编码,Table 层再通过 B+ 树解析实际行数据
-type StrDataPage struct{}
-
-const pkIDLen = 8
-
-// countBackOffset Count 字段距离 entries 起始位置的向后偏移
-// IndexHeadSize = sizeof(PageHdr) + 14 = 32, Count 在 IndexHead 偏移 24
-// countBackOffset = IndexHeadSize - 24 = 8
-var countBackOffset = int(unsafe.Sizeof(page.PageHdr{})) + 14 - (int(unsafe.Sizeof(page.PageHdr{})) + 6)
-
-// ======================== IDateEntry 接口实现 ========================
-
-// Insert 在 IndexPage 条目中插入 [strKey(N) + PK_ID(8B)]
-// 注意:调用方(insertWithIndex)负责更新 Count 和保存页
-func (s *StrDataPage) Insert(handle page.HandleDb, indexEntries []byte, entryLen int, count int, key []byte, pkID uint64, rowData []byte) error {
-	n := count
-	keyLen := entryLen - pkIDLen // 实际索引键长度
-
-	// 二分查找插入位置(按 strKey 字典序)
-	pos := n
-	left, right := 0, n-1
-	for left <= right {
-		mid := (left + right) / 2
-		entryKey := indexEntries[mid*entryLen : mid*entryLen+keyLen]
-		if bytes.Compare(entryKey, key[:keyLen]) >= 0 {
-			pos = mid
-			right = mid - 1
-		} else {
-			left = mid + 1
-		}
-	}
-
-	// 移动后续条目腾出空间
-	if pos < n {
-		copy(indexEntries[(pos+1)*entryLen:], indexEntries[pos*entryLen:n*entryLen])
-	}
-
-	// 写入新条目 [strKey(keyLen) + PK_ID(8B)]
-	entry := indexEntries[pos*entryLen : (pos+1)*entryLen]
-	copy(entry[:keyLen], key[:keyLen])
-	binary.BigEndian.PutUint64(entry[keyLen:], pkID)
-
-	return nil
-}
-
-// Search 二分查找 strKey,返回 PK_ID 的 8 字节编码
-func (s *StrDataPage) Search(handle page.HandleDb, indexEntries []byte, entryLen int, key []byte) ([]byte, bool) {
-	n := len(indexEntries) / entryLen
-	if n == 0 {
-		return nil, false
-	}
-	keyLen := entryLen - pkIDLen
-
-	left, right := 0, n-1
-	for left <= right {
-		mid := (left + right) / 2
-		entryKey := indexEntries[mid*entryLen : mid*entryLen+keyLen]
-		cmp := bytes.Compare(entryKey, key[:keyLen])
-		if cmp == 0 {
-			pkIDBytes := indexEntries[mid*entryLen+keyLen : mid*entryLen+keyLen+pkIDLen]
-			result := make([]byte, pkIDLen)
-			copy(result, pkIDBytes)
-			return result, true
-		} else if cmp < 0 {
-			left = mid + 1
-		} else {
-			right = mid - 1
-		}
-	}
-	return nil, false
-}
-
-// Delete 从 IndexPage 中删除条目(前移后续条目)
-func (s *StrDataPage) Delete(handle page.HandleDb, indexEntries []byte, entryLen int, key []byte) error {
-	n := len(indexEntries) / entryLen
-	keyLen := entryLen - pkIDLen
-
-	// 查找目标条目
-	pos := -1
-	left, right := 0, n-1
-	for left <= right {
-		mid := (left + right) / 2
-		entryKey := indexEntries[mid*entryLen : mid*entryLen+keyLen]
-		cmp := bytes.Compare(entryKey, key[:keyLen])
-		if cmp == 0 {
-			pos = mid
-			break
-		} else if cmp < 0 {
-			left = mid + 1
-		} else {
-			right = mid - 1
-		}
-	}
-
-	if pos < 0 {
-		return errors.New("row not found")
-	}
-
-	// 前移后续条目覆盖被删条目
-	if pos < n-1 {
-		copy(indexEntries[pos*entryLen:], indexEntries[(pos+1)*entryLen:n*entryLen])
-	}
-	// 清零末尾
-	zeroes := make([]byte, entryLen)
-	copy(indexEntries[(n-1)*entryLen:n*entryLen], zeroes)
-
-	// 更新 IndexHead.Count(减 1)
-	// Count 在 entries 起始位置往前 countBackOffset 字节处
-	countPtr := (*uint32)(unsafe.Add(unsafe.Pointer(&indexEntries[0]), -countBackOffset))
-	*countPtr--
-
-	return nil
-}
-
-// Update 字符串索引不支持直接更新
-func (s *StrDataPage) Update(handle page.HandleDb, indexEntries []byte, entryLen int, key []byte, pkID uint64, rowData []byte) error {
-	return errors.New("string index does not support update")
-}
-
-// CreateDataPage 字符串索引不需要创建数据页
-func (s *StrDataPage) CreateDataPage(handle page.HandleDb, key []byte, pkID uint64, rowData []byte) (uint32, error) {
-	return 0, errors.New("string index does not create data pages")
-}

+ 97 - 1
DB/core/index/time_handler.go

@@ -1,4 +1,4 @@
-package index
+package index
 
 import (
 	"git.ycl.pub/yl32940/TsDB2/DB/core/index/datapage"
@@ -7,6 +7,7 @@ import (
 	"git.ycl.pub/yl32940/TsDB2/DB/core/util"
 	"encoding/binary"
 	"errors"
+	"sort"
 	"unsafe"
 )
 
@@ -397,6 +398,90 @@ func (h *TimeIndexHandler) ResolveRow(handle page.HandleDb, entryPage uint32, pk
 	return nil, false
 }
 
+// ResolveMany 批量回表: 见 index.BatchRowResolver 接口契约。
+//
+// 与逐个 ResolveRow (每个 pkID 沿整条分段链走一遍) 不同: 这里沿分段链【一趟】
+// 收集, 每页先用 MinPK/MaxPK 与剩余目标区间预检 (无交则整页跳过), 有交才扫页内行,
+// 行 pkID 在升序目标里二分定位。O(链页数 + 触碰页行数·log N), 对"大表 + 多点回表"
+// 把 O(N·段数·页内扫) 压下来。
+//
+// 刻意不假设页内行序与链序 (乱序时间戳插入会让两者发散): 目标集本身升序,
+// 二分对任意页序都正确。
+//
+// 与主键版同一安全约定: 分批"链走查 + 物化"完成后才调 sink —— 回调里的
+// Update/Delete (段改写/链调整) 不会踩坏正在推进的游标。
+func (h *TimeIndexHandler) ResolveMany(handle page.HandleDb, entryPage uint32,
+	sortedPKIDs []uint64, sink func(pkID uint64, storeRow []byte) bool) {
+	if entryPage == 0 || len(sortedPKIDs) == 0 {
+		return
+	}
+	type hit struct {
+		pkID uint64
+		row  []byte
+	}
+	for start := 0; start < len(sortedPKIDs); start += resolveChunk {
+		end := start + resolveChunk
+		if end > len(sortedPKIDs) {
+			end = len(sortedPKIDs)
+		}
+		wanted := sortedPKIDs[start:end]
+		wi := 0 // 下一个未满足的目标下标
+		var batch []hit
+		curPageID := entryPage
+		for curPageID != 0 && wi < len(wanted) {
+			buf := make([]byte, page.IndexPageSize)
+			if err := handle.ReadPage(curPageID, buf); err != nil {
+				break
+			}
+			hdr := (*page.IndexHead)(unsafe.Pointer(&buf[0]))
+			entryLen := int(hdr.EntryLen)
+			count := int(hdr.Count)
+			if entryLen <= 0 || count == 0 {
+				curPageID = hdr.Hdr.NextPage
+				continue
+			}
+			entries := buf[page.IndexHeadSize : page.IndexHeadSize+count*entryLen]
+			for i := 0; i < count && wi < len(wanted); i++ {
+				dpID := binary.BigEndian.Uint32(entries[i*entryLen+entryLen-4:])
+				if dpID == 0 {
+					continue
+				}
+				dp, err := datapage.Load(handle, dpID)
+				if err != nil {
+					continue
+				}
+				// Min/Max 预检: 本页与 [wanted[wi], wanted[last]] 无交则整页跳过
+				if dp.MaxPK() < wanted[wi] || dp.MinPK() > wanted[len(wanted)-1] {
+					continue
+				}
+				for _, storeRow := range dp.GetAllRows() {
+					if len(storeRow) < 8 {
+						continue
+					}
+					pk := binary.BigEndian.Uint64(storeRow[:8])
+					if pk < wanted[wi] {
+						continue
+					}
+					// 行 pkID 在剩余目标里二分
+					j := sort.Search(len(wanted)-wi, func(k int) bool { return wanted[wi+k] >= pk }) + wi
+					if j < len(wanted) && wanted[j] == pk {
+						batch = append(batch, hit{pkID: pk, row: storeRow})
+						// 该目标已收集, 推进 wi (重复 wanted 由调用方去重)
+						wi = j + 1
+					}
+				}
+			}
+			curPageID = hdr.Hdr.NextPage
+		}
+		// 本批走查完成, 现在才触发上层回调
+		for _, h := range batch {
+			if !sink(h.pkID, h.row) {
+				return
+			}
+		}
+	}
+}
+
 func (h *TimeIndexHandler) Update(handle page.HandleDb, entryPage *uint32, oldKey, newKey, rowData []byte, pkID uint64, keyLen int) error {
 	// 行号已知 → 按行号改写"这一行"; 时间键相同的多行因此能各自独立更新。
 	if pkID != 0 {
@@ -546,6 +631,17 @@ func (h *TimeIndexHandler) ReadDataPageRows(handle page.HandleDb, dpID uint32) [
 	return dp.GetAllRows()
 }
 
+// ReadDataPageLiveCount 返回数据页中未删除的行数 (不物化行)。
+// 与 PrimaryHandler 同款: 时序表的无条件 COUNT(*) 因此走 O(页数) 快路径,
+// 而不是把每页每行读出来只为取 len。
+func (h *TimeIndexHandler) ReadDataPageLiveCount(handle page.HandleDb, dpID uint32) int {
+	dp, err := datapage.Load(handle, dpID)
+	if err != nil {
+		return 0
+	}
+	return dp.CountLiveRows()
+}
+
 func (h *TimeIndexHandler) getDataEntry() IDateEntry {
 	handler, _ := Compose.GetEntryHandler(page.IndexTypeTime)
 	return handler.DataEntry()

+ 3 - 3
DB/core/index/timedatapage/datapage.go

@@ -152,9 +152,9 @@ func (t *TimeDataPage) Delete(handle page.HandleDb, indexEntries []byte, entryLe
 		return page.ErrNotFound
 	}
 	if dp.AllDeleted() {
-		if handle.FreePage != nil {
-			handle.FreePage(dp.PageID())
-		}
+		// 延迟还链: 时序条目仍映射该时间段, 立即还链会让复用后的页被
+		// 扫描/回表读到 (与 time_handler.go 按 pkID 删除路径的约定对齐)。
+		handle.DeferFreeDataPage(dp.PageID())
 	} else {
 		dp.Save()
 	}

+ 5 - 3
DB/core/newtypes_test.go

@@ -223,9 +223,11 @@ func TestNewFieldTypesEndToEnd(t *testing.T) {
 		t.Fatalf("1.5 写入 DECIMAL(_,2) 列应成功 (无损对齐): %v", err)
 	}
 
-	// ===== 7. 复合索引的 8 字节分量假设: 非 8 字节分量必须被拒绝 =====
-	if _, err := tbl.CreateIndex("bad_compose", page.IndexTypeCompose, []byte{3, 6}); !errors.Is(err, ErrIndexComposeWidth) {
-		t.Errorf("复合索引收 18/2 字节分量应报 ErrIndexComposeWidth, 得到 %v", err)
+	// ===== 7. 复合索引分量不再强制 8 字节 =====
+	// 底层已升级为 B+ 树, 字符串宽度码 (18B) + 小数非 8 对齐 (2B) 也能合法建复合索引;
+	// 真正卡死的是 checkIndexSpillFloor 溢出闸门 (一页两行的行宽配额)。
+	if _, err := tbl.CreateIndex("compose_ok", page.IndexTypeCompose, []byte{3, 6}); err != nil {
+		t.Errorf("复合索引应放行 18/2 字节分量 (B+ 树按真实宽度拼键), 得到: %v", err)
 	}
 }
 

+ 16 - 0
DB/core/page/BasePage.go

@@ -107,3 +107,19 @@ var (
 	ErrRowTooLarge  = errors.New("row too large, need heap overflow")
 	ErrDuplicateKey = errors.New("duplicate key")
 )
+
+// DeferFreeDataPage 删空页的安全回收入口: 优先延迟回收 (登记待 Commit/Close 后
+// 还链), 句柄没有 DeferFreePage 的测试装配 (内存页桩) 退回立即回收。
+//
+// 背景: DataPage 删空时, B+ 树叶条目/时序条目仍引用该页 —— 事务 Rollback 或
+// 崩溃重放会让树复活并继续指向它。此时若已立即还链 (FreePage 直写盘), 页会被
+// 分配作他用, 树的悬挂引用就读到别人的页 (数据损坏)。所有"删空页"路径必须走这里。
+func (h HandleDb) DeferFreeDataPage(pageID uint32) {
+	if h.DeferFreePage != nil {
+		h.DeferFreePage(pageID)
+		return
+	}
+	if h.FreePage != nil {
+		h.FreePage(pageID)
+	}
+}

+ 137 - 54
DB/core/pool/buffer_pool.go

@@ -1,15 +1,17 @@
 package pool
 
 import (
+	"container/list"
 	"sync"
 )
 
 // pageEntry 缓冲池中的页条目
 type pageEntry struct {
-	data    []byte // 页数据副本
-	dirty   bool   // 是否被修改(需要写回磁盘)
-	pageLen int    // 页字节长度(用于计算文件偏移)
-	ver     uint64 // 内容版本号: 每次内容被写入都换一个 (见 BufferPool.stampLocked)
+	data    []byte      // 页数据副本
+	dirty   bool        // 是否被修改(需要写回磁盘)
+	pageLen int         // 页字节长度(用于计算文件偏移)
+	ver     uint64      // 内容版本号: 每次内容被写入都换一个 (见 BufferPool.stampLocked)
+	lruEl   *list.Element // 在 lru 链表中的节点 (nil = 不在池中); 节点的 Value 存 pageID
 }
 
 // DirtyPage 脏页快照
@@ -28,8 +30,14 @@ type DirtyPage struct {
 // 写回策略:
 //   - Commit() → 通过 CollectDirty() 收集脏页,走 WAL 流程写回
 //   - 脏页超过阈值时自动触发 onThreshold 回调 (由 DB 层执行 Commit)
+//
+// 缓存策略: 真正的 LRU —— 读/写命中都把页挪到链表尾, 容量满时从链表头淘汰最久
+// 未用的【干净】页。读未命中也会把磁盘内容作为干净页载入缓存 (读缓存), 让重复
+// 扫描/点查 (B+ 树下降每层、Hash 目录链逐页、堆页 Load) 不必每次都走系统调用。
+// 脏页绝不淘汰 (宁可临时超容量, 由脏页阈值触发的 Commit 回落)。
 type BufferPool struct {
 	pages          map[uint32]*pageEntry // pageID → 页条目
+	lru            *list.List            // 淘汰顺序: Front = 最久未用, Back = 最近使用; Value = pageID (uint32)
 	mu             sync.Mutex
 	capacity       int                                   // 最大缓存页数
 	dirtyThreshold int                                   // 脏页阈值 (0=不限制)
@@ -43,9 +51,6 @@ type BufferPool struct {
 	writeLocked bool          // 写锁标志
 	writeChan   chan struct{} // 用于通知解锁的 channel
 
-	// BufferPool 容量控制
-	capacityWait chan struct{} // 容量满时等待的 channel
-
 	// 事务支持
 	onPageWrite func(pageID uint32) // 页写入时的回调(用于事务缓存原始数据)
 }
@@ -59,6 +64,7 @@ func NewBufferPool(capacity int,
 ) *BufferPool {
 	bp := &BufferPool{
 		pages:     make(map[uint32]*pageEntry, capacity),
+		lru:       list.New(),
 		capacity:  capacity,
 		diskRead:  diskRead,
 		diskWrite: diskWrite,
@@ -94,20 +100,46 @@ func (bp *BufferPool) UnlockWrites() {
 	bp.mu.Unlock()
 }
 
-// waitForWriteLock 等待写锁释放(在 WritePage 中调用)
+// waitForWriteLock 等待写锁释放(在 WritePage 中调用)。
+//
+// 醒来必须【回头复查】 writeLocked: 锁是成对替换的 (LockWrites 每次新建
+// channel), 若本 waiter 持有旧 channel 被放行时, 恰逢下一笔提交已重新加锁,
+// 不复查就会穿越进别人的提交窗口写页 —— 快照一致性被破坏。
 func (bp *BufferPool) waitForWriteLock() {
-	bp.mu.Lock()
-	if !bp.writeLocked {
+	for {
+		bp.mu.Lock()
+		if !bp.writeLocked {
+			bp.mu.Unlock()
+			return // 未锁定,直接返回
+		}
+
+		// 保存 channel 引用
+		ch := bp.writeChan
 		bp.mu.Unlock()
-		return // 未锁定,直接返回
+
+		// 阻塞等待解锁(channel 关闭时会返回)
+		<-ch
+		// 循环回头复查: 可能已被下一笔 LockWrites 重新锁住
 	}
+}
 
-	// 保存 channel 引用
-	ch := bp.writeChan
-	bp.mu.Unlock()
+// touchLocked 把页标记为"刚被使用过": 挪到 LRU 链表尾。
+// 新条目 (lruEl == nil) 入尾; 已在链中的移动到尾。调用方必须持有 bp.mu。
+func (bp *BufferPool) touchLocked(e *pageEntry, pageID uint32) {
+	if e.lruEl == nil {
+		e.lruEl = bp.lru.PushBack(pageID)
+		return
+	}
+	bp.lru.MoveToBack(e.lruEl)
+}
 
-	// 阻塞等待解锁(channel 关闭时会返回)
-	<-ch
+// removeLocked 从缓存与 LRU 链表同时摘除一页。调用方必须持有 bp.mu。
+func (bp *BufferPool) removeLocked(pageID uint32, e *pageEntry) {
+	if e.lruEl != nil {
+		bp.lru.Remove(e.lruEl)
+		e.lruEl = nil
+	}
+	delete(bp.pages, pageID)
 }
 
 // CollectDirty 取出所有脏页快照 (由 DB.Commit() 调用,配合 WAL 流程统一写回)。
@@ -199,6 +231,7 @@ func (bp *BufferPool) RestoreDirty(pages []DirtyPage) {
 		e := &pageEntry{data: p.Data, dirty: true, pageLen: p.PageLen}
 		bp.stampLocked(e)
 		bp.pages[p.PageID] = e
+		bp.touchLocked(e, p.PageID)
 		bp.dirtyCount++
 	}
 }
@@ -224,18 +257,12 @@ func (bp *BufferPool) DiscardPages(pageIDs map[uint32]struct{}) {
 		if entry.dirty {
 			removedDirty++
 		}
-		delete(bp.pages, pid)
+		bp.removeLocked(pid, entry)
 	}
 	bp.dirtyCount -= removedDirty
 	if bp.dirtyCount < 0 {
 		bp.dirtyCount = 0
 	}
-
-	// 释放容量后唤醒等待空间的 goroutine
-	if bp.capacityWait != nil {
-		close(bp.capacityWait)
-		bp.capacityWait = nil
-	}
 }
 
 // DiscardPage 丢弃单页缓存 (不落盘), 并释放其占用容量。
@@ -255,11 +282,7 @@ func (bp *BufferPool) DiscardPage(pageID uint32) {
 			bp.dirtyCount = 0
 		}
 	}
-	delete(bp.pages, pageID)
-	if bp.capacityWait != nil {
-		close(bp.capacityWait)
-		bp.capacityWait = nil
-	}
+	bp.removeLocked(pageID, entry)
 	bp.mu.Unlock()
 }
 
@@ -288,33 +311,51 @@ func (bp *BufferPool) UpdatePage(pageID uint32, data []byte) {
 	defer bp.mu.Unlock()
 	cached := make([]byte, len(data))
 	copy(cached, data)
-	e := &pageEntry{data: cached, dirty: false, pageLen: len(data)}
-	bp.stampLocked(e) // 内容换过了, 版本号必须往前走, 否则旧快照会误认为这页没变
-	bp.pages[pageID] = e
+	if old, ok := bp.pages[pageID]; ok {
+		// 复用条目与 lru 节点, 只换内容 —— 语义与"换成新条目"相同 (干净页回填),
+		// 但不产生孤儿链表节点。
+		old.data = cached
+		old.dirty = false
+		old.pageLen = len(data)
+		bp.stampLocked(old) // 内容换过了, 版本号必须往前走, 否则旧快照会误认为这页没变
+		bp.touchLocked(old, pageID)
+	} else {
+		e := &pageEntry{data: cached, dirty: false, pageLen: len(data)}
+		bp.stampLocked(e)
+		bp.pages[pageID] = e
+		bp.touchLocked(e, pageID)
+	}
 	// 回填的页是干净页, 不受 dirtyCount 约束; 不加这一步, 大量回收页会把缓存
 	// 无限撑大 (回收 N 页 → 缓存增加 N 页), 并让 WritePage 的容量判断形同虚设。
 	bp.evictCleanForRoomLocked()
 }
 
-// evictCleanForRoomLocked 容量已满时淘汰干净页 (dirty=false), 直到降到容量以下。
+// evictCleanForRoomLocked 容量已满时按 LRU 顺序淘汰干净页, 直到降到容量以下。
 //
-// 干净页内容与磁盘一致, 丢弃后下次读取会重新从磁盘载入, 语义完全不受影响;
-// 脏页绝不能被丢弃 (否则会静默丢掉尚未落盘的修改), 因此若缓存里全是脏页,
-// 这里不做任何事 —— 允许缓存临时超出容量, 由脏页阈值触发的 Commit 尽快回落。
+// 从链表头 (最久未用) 开始扫描: 干净页直接淘汰 (内容与磁盘一致, 丢弃后下次读取
+// 重新载入, 语义不受影响); 脏页跳过 —— 淘汰脏页会静默丢掉尚未落盘的修改。
+// 若缓存里全是脏页, 这里不做任何事 —— 允许缓存临时超出容量, 由脏页阈值触发的
+// Commit 尽快回落。
+//
+// 旧实现按 map 遍历顺序随机挑干净页淘汰 (并非 LRU), 热页与冷页被同等概率丢掉。
 //
 // 调用方必须持有 bp.mu。
 func (bp *BufferPool) evictCleanForRoomLocked() {
 	if bp.capacity <= 0 || len(bp.pages) < bp.capacity {
 		return
 	}
-	for pid, entry := range bp.pages {
-		if entry.dirty {
-			continue
-		}
-		delete(bp.pages, pid)
-		if len(bp.pages) < bp.capacity {
-			return
+	for el := bp.lru.Front(); el != nil && len(bp.pages) >= bp.capacity; {
+		next := el.Next() // 先记住下一个节点: removeLocked 会把当前节点从链里摘掉
+		pid := el.Value.(uint32)
+		entry, ok := bp.pages[pid]
+		if !ok || entry.lruEl != el {
+			// 孤儿链表节点 (正常不应出现): 清掉继续, 避免无限循环
+			bp.lru.Remove(el)
+		} else if !entry.dirty {
+			bp.removeLocked(pid, entry)
 		}
+		// 脏页: 跳过, 也不移动到尾部 (移动会让全脏扫描永远走不到旧页之外)
+		el = next
 	}
 }
 
@@ -333,18 +374,43 @@ func (bp *BufferPool) ClearAll() {
 	bp.dirtyCount = 0
 }
 
-// ReadPage 读页 — 先查缓存,未命中则读磁盘 (不缓存, 避免 BufferPool 膨胀)
+// ReadPage 读页 — 先查缓存, 未命中则读磁盘并作为【干净页载入缓存】。
+//
+// 读缓存让重复扫描/点查不必每次都走系统调用; 载入的页与磁盘一致, 可被 LRU 淘汰。
+// 读盘期间另一线程可能已把该页写进缓存 (写缓存优先): 重新检查后以池内内容为准,
+// 绝不拿这份可能更旧的读盘结果覆盖脏页。直写盘路径 (WritePageDirect) 自带缓存失效,
+// 读缓存不会滞留陈旧内容。
 func (bp *BufferPool) ReadPage(pageID uint32, buf []byte) error {
 	bp.mu.Lock()
 	if entry, ok := bp.pages[pageID]; ok {
 		copy(buf, entry.data)
+		bp.touchLocked(entry, pageID)
 		bp.mu.Unlock()
 		return nil
 	}
 	bp.mu.Unlock()
 
-	// 缓存未命中 → 直接从磁盘读取, 不写入缓存
-	return bp.diskRead(pageID, buf)
+	// 缓存未命中 → 读磁盘
+	if err := bp.diskRead(pageID, buf); err != nil {
+		return err
+	}
+
+	// 作为干净页入池。入池前重新检查: 读盘这段时间里别的线程可能已经写入了这一页
+	// (缓存里是更新/未落盘的内容), 此时以池内为准, 丢弃读盘结果。
+	bp.mu.Lock()
+	if entry, ok := bp.pages[pageID]; ok {
+		copy(buf, entry.data)
+		bp.touchLocked(entry, pageID)
+		bp.mu.Unlock()
+		return nil
+	}
+	bp.evictCleanForRoomLocked()
+	entry := &pageEntry{data: make([]byte, len(buf)), dirty: false, pageLen: len(buf)}
+	copy(entry.data, buf)
+	bp.pages[pageID] = entry
+	bp.touchLocked(entry, pageID)
+	bp.mu.Unlock()
+	return nil
 }
 
 // WritePage 写页 — 写入缓存并标记脏,不立即落盘
@@ -360,7 +426,7 @@ func (bp *BufferPool) WritePage(pageID uint32, buf []byte) error {
 	var triggerThreshold bool
 	bp.mu.Lock()
 
-	// 2. 容量控制: 满了先淘汰干净页, 不阻塞等待。
+	// 2. 容量控制: 满了先按 LRU 淘汰干净页, 不阻塞等待。
 	//    旧实现阻塞在 capacityWait 上等 Commit/Discard 释放空间, 但这两者都不可能
 	//    在"当前这个正在阻塞的 goroutine"内被调用 —— 单写入者模型下必然死锁
 	//    (fatal error: all goroutines are asleep)。批量删除回收旧页时, realFreePage
@@ -369,7 +435,8 @@ func (bp *BufferPool) WritePage(pageID uint32, buf []byte) error {
 	bp.evictCleanForRoomLocked()
 
 	existing, exists := bp.pages[pageID]
-	if exists && existing.dirty {
+	switch {
+	case exists && existing.dirty:
 		// 已是脏页, 直接覆盖, 不增加脏页计数。
 		//
 		// 【原地覆写】旧实现在这里每次都新分配一块 len(buf) 的副本再整个换掉
@@ -386,22 +453,38 @@ func (bp *BufferPool) WritePage(pageID uint32, buf []byte) error {
 			existing.data = cached
 		}
 		bp.stampLocked(existing) // 内容变了: 让提交窗口里的旧快照认不出这一页
-	} else {
-		// 新页或干净页, 增加脏页计数
+		bp.touchLocked(existing, pageID)
+	case exists:
+		// 干净页 (读缓存/回收回填的页) 被改写: 复用条目与 lru 节点, 标脏
+		if len(existing.data) == len(buf) {
+			copy(existing.data, buf)
+		} else {
+			cached := make([]byte, len(buf))
+			copy(cached, buf)
+			existing.data = cached
+		}
+		existing.dirty = true
+		existing.pageLen = len(buf)
+		bp.stampLocked(existing)
+		bp.dirtyCount++
+		bp.touchLocked(existing, pageID)
+	default:
+		// 新页, 增加脏页计数
 		cached := make([]byte, len(buf))
 		copy(cached, buf)
 		e := &pageEntry{data: cached, dirty: true, pageLen: len(buf)}
 		bp.stampLocked(e)
 		bp.pages[pageID] = e
+		bp.touchLocked(e, pageID)
 		bp.dirtyCount++
 	}
 
 	// 3. 检查是否超过阈值 —— 只按「脏页数」判定。
 	//
-	// 不能再用 len(bp.pages): CollectDirty 现在会保留干净页, 缓存里长期驻留一批
-	// 与磁盘一致的干净页, 总页数会持续偏高。若仍以总页数触发, 阈值会被这些干净页
-	// 反复顶爆, 提交频率凭空放大数倍。脏页数才是真正"尚未落盘"的数据量, 也正是
-	// 崩溃时可能丢失的上限; 干净页的容量由 evictCleanForRoomLocked 按 capacity 约束。
+	// 不能再用 len(bp.pages): CollectDirty 现在会保留干净页, 读缓存也会让干净页
+	// 持续驻留, 总页数会持续偏高。若仍以总页数触发, 阈值会被这些干净页反复顶爆,
+	// 提交频率凭空放大数倍。脏页数才是真正"尚未落盘"的数据量, 也正是崩溃时可能
+	// 丢失的上限; 干净页的容量由 evictCleanForRoomLocked 按 capacity 约束。
 	if bp.dirtyThreshold > 0 && bp.onThreshold != nil && bp.dirtyCount >= bp.dirtyThreshold {
 		triggerThreshold = true
 	}
@@ -437,7 +520,7 @@ func (bp *BufferPool) SetCapacity(capacity int) {
 }
 
 // SetOnPageWriteCallback 设置页写入回调(用于事务缓存原始数据)
-func (bp *BufferPool) SetOnPageWriteCallback(callback func(pageID uint32)) {
+func (bp *BufferPool) SetOnPageWriteCallback(callback func(uint32)) {
 	bp.mu.Lock()
 	bp.onPageWrite = callback
 	bp.mu.Unlock()

+ 172 - 0
DB/core/pool/buffer_pool_test.go

@@ -191,3 +191,175 @@ func TestWritePageThresholdCountsOnlyDirty(t *testing.T) {
 		t.Fatalf("干净页应保留在池中, 实际池内仅 %d 页", bp.Len())
 	}
 }
+
+
+// countingRead 包装 diskStub.read 计数 (用于断言"命中缓存后不再走磁盘")。
+type countingRead struct {
+	d *diskStub
+	n int
+}
+
+func (c *countingRead) read(pageID uint32, buf []byte) error {
+	c.n++
+	return c.d.read(pageID, buf)
+}
+
+// TestReadPageCachesCleanPages 读未命中必须把磁盘内容作为干净页载入缓存:
+// 第二次读同一页必须命中缓存 (磁盘读次数不增加), 这是"LRU BufferPool"读路径
+// 收益的主体 (B+ 树下降 / Hash 目录链 / 堆页 Load 都是重复读)。
+func TestReadPageCachesCleanPages(t *testing.T) {
+	d := &diskStub{pages: map[uint32][]byte{42: {0x2A}}}
+	cr := &countingRead{d: d}
+	bp := NewBufferPool(8, cr.read, d.write)
+
+	buf := make([]byte, 1)
+	if err := bp.ReadPage(42, buf); err != nil {
+		t.Fatalf("第一次读: %v", err)
+	}
+	if buf[0] != 0x2A {
+		t.Fatalf("读到 %d, 想要 0x2A", buf[0])
+	}
+	if cr.n != 1 {
+		t.Fatalf("第一次读应走 1 次磁盘, 实际 %d", cr.n)
+	}
+
+	// 第二次读: 必须命中缓存
+	if err := bp.ReadPage(42, buf); err != nil {
+		t.Fatalf("第二次读: %v", err)
+	}
+	if cr.n != 1 {
+		t.Fatalf("第二次读应命中缓存 (磁盘读仍为 1), 实际 %d", cr.n)
+	}
+
+	// 载入的是干净页: 不占脏页计数, 可被 LRU 淘汰
+	if bp.DirtyCount() != 0 {
+		t.Fatalf("读缓存页不应计脏, 实际脏页 %d", bp.DirtyCount())
+	}
+}
+
+// TestLRUEvictsColdestPage LRU 淘汰必须按"最久未用"顺序: 最近读过的热页留下,
+// 最冷的页被淘汰。旧实现按 map 随机序淘汰, 热页与冷页同等概率被丢。
+func TestLRUEvictsColdestPage(t *testing.T) {
+	d := &diskStub{pages: map[uint32][]byte{}}
+	cr := &countingRead{d: d}
+	bp := NewBufferPool(3, cr.read, d.write)
+
+	// 三张干净页入池 (模拟读缓存), lru 序: [1, 2, 3]
+	bp.UpdatePage(1, []byte{1})
+	bp.UpdatePage(2, []byte{2})
+	bp.UpdatePage(3, []byte{3})
+
+	// 读页 1 → 它变热: lru 序 [2, 3, 1]
+	if err := bp.ReadPage(1, make([]byte, 1)); err != nil {
+		t.Fatalf("ReadPage(1): %v", err)
+	}
+
+	// 写页 4 (触发容量淘汰): 最冷的页 2 必须先被淘汰
+	if err := bp.WritePage(4, []byte{4}); err != nil {
+		t.Fatalf("WritePage(4): %v", err)
+	}
+
+	if _, ok := bp.pages[2]; ok {
+		t.Fatalf("最久未用的页 2 应被淘汰, 却仍在池中")
+	}
+	for _, pid := range []uint32{1, 3, 4} {
+		if _, ok := bp.pages[pid]; !ok {
+			t.Fatalf("页 %d 应在池中 (淘汰顺序错误)", pid)
+		}
+	}
+
+	// 淘汰后链表与 map 必须一致 (无孤儿节点)
+	if bp.lru.Len() != len(bp.pages) {
+		t.Fatalf("lru 链表 (%d) 与页表 (%d) 长度不一致", bp.lru.Len(), len(bp.pages))
+	}
+
+	// 当前 lru 序 [3, 1, 4]: 读页 2 (miss, 重新入池) 应淘汰最冷的页 3
+	cr.n = 0
+	if err := bp.ReadPage(2, make([]byte, 1)); err != nil {
+		t.Fatalf("ReadPage(2): %v", err)
+	}
+	if cr.n != 1 {
+		t.Fatalf("被淘汰的页 2 应重新走磁盘 1 次, 实际 %d 次", cr.n)
+	}
+	if _, ok := bp.pages[3]; ok {
+		t.Fatalf("页 3 现在是最冷的, 应被页 2 的入池挤掉")
+	}
+	if err := bp.ReadPage(1, make([]byte, 1)); err != nil {
+		t.Fatalf("ReadPage(1): %v", err)
+	}
+	if cr.n != 1 {
+		t.Fatalf("热页 1 应命中缓存, 实际磁盘读 %d 次", cr.n)
+	}
+}
+
+// TestReadPageNeverOverwritesConcurrentDirty 读盘期间另一线程写入了同一页时,
+// ReadPage 必须以池内 (更新) 内容为准 —— 绝不能用更旧的读盘结果覆盖脏页。
+func TestReadPageNeverOverwritesConcurrentDirty(t *testing.T) {
+	d := &diskStub{pages: map[uint32][]byte{7: {0x07}}}
+	started := make(chan struct{}, 1)
+	release := make(chan struct{})
+	bp := NewBufferPool(8, func(pageID uint32, buf []byte) error {
+		started <- struct{}{}
+		<-release // 卡住读盘, 制造并发窗口
+		return d.read(pageID, buf)
+	}, d.write)
+
+	out := make([]byte, 1)
+	done := make(chan error, 1)
+	go func() { done <- bp.ReadPage(7, out) }()
+
+	<-started // 读盘进行中 (页 7 尚未入池)
+	// 并发写入同页 (单写入者模型下不该发生, 但缓冲池是并发的最后防线)
+	if err := bp.WritePage(7, []byte{0x99}); err != nil {
+		t.Fatalf("WritePage(7): %v", err)
+	}
+	close(release)
+	if err := <-done; err != nil {
+		t.Fatalf("ReadPage(7): %v", err)
+	}
+
+	if out[0] != 0x99 {
+		t.Fatalf("读到的是旧读盘内容 %#x, 池内新内容 0x99 被覆盖了", out[0])
+	}
+	if bp.DirtyCount() != 1 {
+		t.Fatalf("页 7 必须保持脏, 实际脏页 %d", bp.DirtyCount())
+	}
+	// 再读一次: 必须命中且仍是新内容
+	if err := bp.ReadPage(7, out); err != nil {
+		t.Fatalf("重读: %v", err)
+	}
+	if out[0] != 0x99 {
+		t.Fatalf("重读拿到 %#x", out[0])
+	}
+}
+
+// TestWriteOverReadCachedPageKeepsLRUConsistent 读缓存的干净页被 WritePage 改脏后,
+// 条目原地复用: lru 链表与页表保持一致, 不泄漏孤儿节点。
+func TestWriteOverReadCachedPageKeepsLRUConsistent(t *testing.T) {
+	d := &diskStub{pages: map[uint32][]byte{5: {0x05}}}
+	bp := NewBufferPool(8, d.read, d.write)
+
+	// 读入池 (干净页)
+	if err := bp.ReadPage(5, make([]byte, 1)); err != nil {
+		t.Fatalf("ReadPage(5): %v", err)
+	}
+	// 改脏
+	if err := bp.WritePage(5, []byte{0x55}); err != nil {
+		t.Fatalf("WritePage(5): %v", err)
+	}
+
+	if bp.DirtyCount() != 1 {
+		t.Fatalf("改脏后脏页应计 1, 实际 %d", bp.DirtyCount())
+	}
+	if bp.lru.Len() != len(bp.pages) {
+		t.Fatalf("lru 链表 (%d) 与页表 (%d) 长度不一致", bp.lru.Len(), len(bp.pages))
+	}
+	// 淘汰路径上它现在是脏页: 容量满时不得被淘汰
+	bp.UpdatePage(100, []byte{1})
+	if err := bp.WritePage(6, []byte{6}); err != nil {
+		t.Fatalf("WritePage(6): %v", err)
+	}
+	if _, ok := bp.pages[5]; !ok {
+		t.Fatalf("脏页 5 绝不能被容量淘汰")
+	}
+}

+ 460 - 98
DB/core/query.go

@@ -5,6 +5,7 @@ import (
 	"git.ycl.pub/yl32940/TsDB2/DB/core/page"
 	"git.ycl.pub/yl32940/TsDB2/DB/core/types"
 	"bytes"
+	"container/heap"
 	"encoding/binary"
 	"fmt"
 	"math/big"
@@ -21,6 +22,11 @@ type Query struct {
 	offset     int         // OFFSET
 	groupBys   []string    // GROUP BY
 	havingCond Condition   // HAVING
+
+	// wantMask 投影掩码 (master 下标 → 是否解码), selectCols 与 orderBys 的并集;
+	// 排序键从解码后的行里取, ORDER BY 列必须保留在掩码中。nil = 不过滤 (全列)。
+	wantMask  []bool
+	wantBuilt bool
 }
 
 type SortField struct {
@@ -28,14 +34,21 @@ type SortField struct {
 	Descending bool
 }
 
-// scanPlan 索引扫描计划
-type scanPlan struct {
-	meta           *IndexMeta
-	handler        index.IndexHandler
+// keyRange 索引扫描的单个键区间。
+type keyRange struct {
 	startKey       []byte
 	endKey         []byte
 	startInclusive bool
 	endInclusive   bool
+}
+
+// scanPlan 索引扫描计划。
+// ranges 通常为单区间; WHERE ... IN (...) 落在可用分量上时展开成多区间
+// (每个值一个精确区间), 空切片表示"结果必为空" (如 IN 空集合)。
+type scanPlan struct {
+	meta           *IndexMeta
+	handler        index.IndexHandler
+	ranges         []keyRange
 	rowStore       bool      // 该索引是否承载行数据 (主键/时序=true)
 	remainingCond  Condition // 需要行级过滤的剩余条件
 }
@@ -45,7 +58,11 @@ func (table *Table) Query() *Query {
 	return &Query{table: table}
 }
 
-func (q *Query) Select(cols ...string) *Query  { q.selectCols = cols; return q }
+func (q *Query) Select(cols ...string) *Query {
+	q.selectCols = cols
+	q.wantBuilt = false // 投影掩码随 selectCols 失效
+	return q
+}
 func (q *Query) Where(cond Condition) *Query   { q.whereCond = cond; return q }
 func (q *Query) Limit(n int) *Query            { q.limit = n; return q }
 func (q *Query) Offset(n int) *Query           { q.offset = n; return q }
@@ -58,6 +75,7 @@ func (q *Query) OrderBy(field string, descending ...bool) *Query {
 		desc = descending[0]
 	}
 	q.orderBys = append(q.orderBys, SortField{Field: field, Descending: desc})
+	q.wantBuilt = false // 投影掩码随 orderBys 失效
 	return q
 }
 
@@ -433,10 +451,6 @@ func (q *Query) planIndex(m *IndexMeta, handler index.IndexHandler, leaves []Con
 		compCond[k] = append(compCond[k], c)
 	}
 
-	if cons[0].count == 0 {
-		return nil // 首分量没有边界 → 索引给不出连续区间
-	}
-
 	// 从左往右消费"等值分量"; 之后最多再消费一个范围分量, 更后面的分量帮不上忙。
 	eqLen := 0
 	for eqLen < n && cons[eqLen].isEquality() {
@@ -447,6 +461,35 @@ func (q *Query) planIndex(m *IndexMeta, handler index.IndexHandler, leaves []Con
 		rangeIdx = eqLen
 	}
 
+	// IN 展开: 只看"等值前缀的下一个分量", 且其上必须还没有任何边界条件
+	// (有范围/等值条件时让位给常规区间规划, IN 由行级过滤兜底)。
+	// 每个 IN 值变成该分量取值的精确区间 [前缀||v||00.., 前缀||v||FF..]。
+	// 多个 IN 只消费第一个适用的, 其余留在行级过滤 (正确性不受影响)。
+	// 注意本段在 cons[0].count 判定【之前】: 只有 IN 条件时 cons 全空,
+	// 但它照样能给出一组精确区间。
+	for _, c := range leaves {
+		in, ok := c.(InCondition)
+		if !ok {
+			continue
+		}
+		if eqLen >= n || cons[eqLen].count != 0 {
+			continue
+		}
+		if q.locateComponent(in.Field, m) != eqLen {
+			continue
+		}
+		if plan := q.planInRanges(m, handler, lay, cons, eqLen, rest, compCond, in); plan != nil {
+			return plan
+		}
+		// 返回了 nil = 有值编不出键: 本索引放弃 IN, 让行级过滤兜住正确性,
+		// 继续走下面的常规区间规划 (等值前缀等条件仍可用)。
+		break
+	}
+
+	if cons[0].count == 0 {
+		return nil // 首分量没有边界 → 索引给不出连续区间
+	}
+
 	startKey := make([]byte, lay.total)
 	endKey := make([]byte, lay.total)
 	prefix := 0
@@ -458,8 +501,8 @@ func (q *Query) planIndex(m *IndexMeta, handler index.IndexHandler, leaves []Con
 
 	plan := &scanPlan{
 		meta: m, handler: handler,
-		startKey: startKey, endKey: endKey,
-		startInclusive: true, endInclusive: true,
+		ranges: []keyRange{{startKey: startKey, endKey: endKey,
+			startInclusive: true, endInclusive: true}},
 		remainingCond: q.remainCond(rest, compCond, eqLen, rangeIdx),
 	}
 
@@ -485,6 +528,7 @@ func (q *Query) planIndex(m *IndexMeta, handler index.IndexHandler, leaves []Con
 	}
 
 	// 上界: [前缀 || 分量值 || FF..]
+	kr := &plan.ranges[0]
 	if rc.hi == nil {
 		fillFF(endKey, prefix)
 	} else if rc.hiInc {
@@ -493,11 +537,66 @@ func (q *Query) planIndex(m *IndexMeta, handler index.IndexHandler, leaves []Con
 	} else {
 		// 严格上界: 分量 == hi 的键全部排除
 		copy(endKey[prefix:], rc.hi)
-		plan.endInclusive = false
+		kr.endInclusive = false
 	}
 	return plan
 }
 
+// planInRanges 把 IN 条件展开成多区间扫描计划。
+//
+// 返回 nil 表示 IN 无法索引化 (有值编不出键) —— 调用方退回常规规划/全表扫描,
+// IN 本身仍在 whereCond 里, 行级过滤保证正确性。
+// IN 空集合 → 返回 ranges 为空的计划: 任何区间都不扫, 结果必空 (IN 恒假),
+// 与 InCondition.Match 的空集合语义一致。
+func (q *Query) planInRanges(m *IndexMeta, handler index.IndexHandler, lay *indexKeyLayout,
+	cons []keyConstraint, eqLen int, rest []Condition, compCond [][]Condition, in InCondition) *scanPlan {
+	k := eqLen
+	size := lay.sizes[k]
+	fi := int(m.FieldIDs[k])
+	ft := q.table.Field[fi].FieldType
+	param := q.table.fieldParam(fi)
+
+	// 等值前缀字节 (与常规规划同一编码: cons[j].lo, isEquality 保证两端闭且相等)
+	prefix := make([]byte, lay.offsets[k])
+	for j := 0; j < eqLen; j++ {
+		copy(prefix[lay.offsets[j]:], cons[j].lo)
+	}
+
+	if len(in.Values) == 0 {
+		// 恒空: 不扫任何区间。remainingCond 保留全部条件 (无行可过滤, 仅为语义完整)。
+		return &scanPlan{meta: m, handler: handler,
+			ranges:        nil,
+			remainingCond: q.remainCond(rest, compCond, 0, -1)}
+	}
+
+	ranges := make([]keyRange, 0, len(in.Values))
+	seen := make(map[string]struct{}, len(in.Values)) // 编码后同键的值只留一个区间
+	for _, v := range in.Values {
+		enc, err := encodeIndexKeySafeWithParam(v, ft, param)
+		if err != nil || len(enc) != size {
+			return nil // 任一值编不出正确宽度的键 → 整个 IN 退回行级过滤
+		}
+		// 该分量取值 v 的全部键: [前缀||v||00.., 前缀||v||FF..] (单列索引即 [v, v])
+		start := make([]byte, lay.total)
+		end := make([]byte, lay.total)
+		copy(start, prefix)
+		copy(end, prefix)
+		copy(start[lay.offsets[k]:], enc)
+		copy(end[lay.offsets[k]:], enc)
+		fillFF(end, lay.offsets[k]+size)
+		// 重复值/编码碰撞值去重: 不去重的话, raw 聚合路径 (Count/Sum 的
+		// scanRaw) 会对同一行重复计数 —— 行交付路径有去重, 聚合没有。
+		if _, dup := seen[string(start)]; dup {
+			continue
+		}
+		seen[string(start)] = struct{}{}
+		ranges = append(ranges, keyRange{startKey: start, endKey: end,
+			startInclusive: true, endInclusive: true})
+	}
+	return &scanPlan{meta: m, handler: handler, ranges: ranges,
+		remainingCond: q.remainCond(rest, compCond, eqLen, -1)}
+}
+
 // remainCond 汇总索引区间没能表达、仍需逐行过滤的条件。
 // 已被区间精确覆盖的分量 (等值前缀 + 那个范围分量) 上的条件可以安全丢掉。
 func (q *Query) remainCond(rest []Condition, compCond [][]Condition, eqLen, rangeIdx int) Condition {
@@ -525,24 +624,52 @@ type rowWithPK struct {
 	row  []types.FieldValue
 }
 
-// compareRowsForOrderBy 按 OrderBy 规则比较两行, 返回 -1/0/1。
-// 0 表示"所有排序列都无法区分"(字段缺失), 由调用方用稳定排序保持原顺序。
-func (q *Query) compareRowsForOrderBy(a, b []types.FieldValue) int {
+// orderKeySpec 预解析的排序键: 排序列不存在于表中时在构建阶段就被丢掉,
+// 比较阶段不再做任何字段查找。
+type orderKeySpec struct {
+	field string
+	ft    types.FieldType
+	desc  bool
+}
+
+// buildOrderSpecs 把 OrderBy 解析成排序键规格 (字段名 + 列类型 + 方向)。
+func (q *Query) buildOrderSpecs() []orderKeySpec {
+	specs := make([]orderKeySpec, 0, len(q.orderBys))
 	for _, ob := range q.orderBys {
 		idx := q.table.findFieldIndex(ob.Field)
 		if idx < 0 {
 			continue
 		}
-		vi := findFieldValue(a, ob.Field)
-		vj := findFieldValue(b, ob.Field)
+		specs = append(specs, orderKeySpec{
+			field: ob.Field,
+			ft:    q.table.Field[idx].FieldType,
+			desc:  ob.Descending,
+		})
+	}
+	return specs
+}
+
+// compareRowsForOrderBy 按 OrderBy 规则比较两行, 返回 -1/0/1。
+// 0 表示"所有排序列都无法区分"(字段缺失), 由调用方用稳定排序保持原顺序。
+//
+// 保留给外部调用方; 查询内部排序走 sortResults (decoration 每行只提取一次键)。
+func (q *Query) compareRowsForOrderBy(a, b []types.FieldValue) int {
+	return compareRowsBySpecs(a, b, q.buildOrderSpecs())
+}
+
+// compareRowsBySpecs 按预解析规格比较两行 (每行先经 findFieldValue 取值)。
+func compareRowsBySpecs(a, b []types.FieldValue, specs []orderKeySpec) int {
+	for _, s := range specs {
+		vi := findFieldValue(a, s.field)
+		vj := findFieldValue(b, s.field)
 		if vi == nil || vj == nil {
 			continue
 		}
-		cmp := compareFieldValues(vi, vj, q.table.Field[idx].FieldType)
+		cmp := compareFieldValues(vi, vj, s.ft)
 		if cmp == 0 {
 			continue
 		}
-		if ob.Descending {
+		if s.desc {
 			return -cmp
 		}
 		return cmp
@@ -608,7 +735,9 @@ func (q *Query) scanWithPKID(callback func(pkID uint64, row []types.FieldValue)
 			needSort = true
 		} else {
 			idxField := q.fieldNameFromMeta(plan.meta)
-			if !(len(q.orderBys) == 1 && q.orderBys[0].Field == idxField) {
+			// 免排序只适用于单区间: 多区间 (IN 展开) 的输出是"各区间有序、
+			// 全局无序"的拼接, 必须交给下面的排序管线。
+			if !(len(q.orderBys) == 1 && q.orderBys[0].Field == idxField && len(plan.ranges) == 1) {
 				needSort = true
 			}
 		}
@@ -616,18 +745,47 @@ func (q *Query) scanWithPKID(callback func(pkID uint64, row []types.FieldValue)
 
 	// 需要排序时, 必须先收集全部再排序, 无法流式 LIMIT
 	if needSort {
+		specs := q.buildOrderSpecs()
+		// ORDER BY + LIMIT: 流式 top-k, 只保留 limit+offset 行的小顶堆,
+		// 内存 O(k) 且免全量排序 (旧实现物化全部结果再整体排序)。
+		if q.limit > 0 && len(specs) > 0 {
+			return q.topKScan(callback, plan, specs)
+		}
 		var all []rowWithPK
 		err := q.executeScan(func(pkID uint64, row []types.FieldValue) bool {
 			all = append(all, rowWithPK{pkID, row})
 			return true
-		}, plan)
+		}, plan, 0)
 		if err != nil {
 			return err
 		}
-		// 排序必须连 pkID 一起搬: 只排 []FieldValue 会让回调拿到错位的 pkID
-		sort.SliceStable(all, func(i, j int) bool {
-			return q.compareRowsForOrderBy(all[i].row, all[j].row) < 0
-		})
+		// 排序必须连 pkID 一起搬: 只排 []FieldValue 会让回调拿到错位的 pkID。
+		// decoration: 每行只按名提取一次排序键, 键必须随行进数组 —— 排序会重排
+		// 切片元素, 平行键数组会因此错位 (索引指向交换后的别的行)。
+		// 各行列集合可能因 NULL 跳过而不同, 键按名取、缺失用 nil 占位。
+		if len(specs) > 0 {
+			dec := make([]struct {
+				item rowWithPK
+				keys []interface{}
+			}, len(all))
+			for i := range all {
+				k := make([]interface{}, len(specs))
+				for j, s := range specs {
+					k[j] = findFieldValue(all[i].row, s.field)
+				}
+				dec[i] = struct {
+					item rowWithPK
+					keys []interface{}
+				}{all[i], k}
+			}
+			sort.SliceStable(dec, func(i, j int) bool {
+				return compareKeys(dec[i].keys, dec[j].keys, specs) < 0
+			})
+			all = all[:0]
+			for i := range dec {
+				all = append(all, dec[i].item)
+			}
+		}
 		// OFFSET
 		if q.offset > 0 {
 			if q.offset >= len(all) {
@@ -647,7 +805,12 @@ func (q *Query) scanWithPKID(callback func(pkID uint64, row []types.FieldValue)
 		return nil
 	}
 
-	// 不需要排序: 直接流式传递 callback, 支持 LIMIT 提前终止
+	// 不需要排序: 直接流式传递 callback, 支持 LIMIT 提前终止。
+	// 回表预算 = LIMIT+OFFSET: 达到即收工 (OFFSET 行也要交付给下面的跳行逻辑)。
+	budget := 0
+	if q.limit > 0 {
+		budget = q.limit + q.offset
+	}
 	seen := 0
 	return q.executeScan(func(pkID uint64, row []types.FieldValue) bool {
 		if seen < q.offset {
@@ -663,17 +826,45 @@ func (q *Query) scanWithPKID(callback func(pkID uint64, row []types.FieldValue)
 			return false // 已满足, 提前终止扫描
 		}
 		return true
-	}, plan)
+	}, plan, budget)
 }
 
-// executeScan 执行索引扫描或全表扫描, 将反序列化后的行传递给回调
-func (q *Query) executeScan(callback func(pkID uint64, row []types.FieldValue) bool, plan *scanPlan) error {
+// executeScan 执行索引扫描或全表扫描, 将反序列化后的行传递给回调。
+// budget > 0 时最多交付 budget 行 (LIMIT+OFFSET 场景): 二级索引回表据此
+// 提前收工, 而不是把命中的上千行全部解出再丢掉 —— Where(hash=重).First()
+// 这类点查只允许解 1 行。
+func (q *Query) executeScan(callback func(pkID uint64, row []types.FieldValue) bool, plan *scanPlan, budget int) error {
 	if plan != nil {
-		return q.indexScan(plan, callback)
+		return q.indexScan(plan, callback, budget)
 	}
 	return q.fullTableScan(callback)
 }
 
+// deserialize 行解码入口: 无投影走全列解码; 有投影只解码选中列 (含 ORDER BY 列,
+// 排序键要从解码后的行里取)。掩码按 master 下标, 随查询构建器惰性构建一次。
+func (q *Query) deserialize(rowData []byte) []types.FieldValue {
+	if len(q.selectCols) == 0 {
+		return q.table.deserializeRow(rowData)
+	}
+	if !q.wantBuilt {
+		mask := make([]bool, len(q.table.Field))
+		mark := func(name string) {
+			if i := q.table.findFieldIndex(name); i >= 0 {
+				mask[i] = true
+			}
+		}
+		for _, c := range q.selectCols {
+			mark(c)
+		}
+		for _, ob := range q.orderBys {
+			mark(ob.Field)
+		}
+		q.wantMask = mask
+		q.wantBuilt = true
+	}
+	return q.table.deserializeRowSelect(rowData, q.wantMask)
+}
+
 // indexScan 使用索引扫描并按"先物化、后回表"两阶段执行。
 //
 // 为什么不能像旧实现那样在 RangeScan 回调里直接回表 + 触发用户 callback:
@@ -686,10 +877,10 @@ func (q *Query) executeScan(callback func(pkID uint64, row []types.FieldValue) b
 // 物化把"遍历"与"修改"彻底分开: 遍历阶段树保持只读, 游标始终有效; 修改发生在
 // 遍历结束之后, 不再影响游标。二级索引只物化 pkID(8B/行), 内存开销与结果集成正比
 // 且远小于整行物化; rowStore 索引物化的是行的快照 (必须拷贝, 因为它是页内缓冲视图)。
-func (q *Query) indexScan(plan *scanPlan, callback func(pkID uint64, row []types.FieldValue) bool) error {
-	// 判断 OrderBy 是否需要反向
+func (q *Query) indexScan(plan *scanPlan, callback func(pkID uint64, row []types.FieldValue) bool, budget int) error {
+	// 判断 OrderBy 是否需要反向 (只对单区间有定义; 多区间已强制走排序管线)
 	reverse := false
-	if len(q.orderBys) == 1 {
+	if len(plan.ranges) == 1 && len(q.orderBys) == 1 {
 		idxField := q.fieldNameFromMeta(plan.meta)
 		if q.orderBys[0].Field == idxField && q.orderBys[0].Descending {
 			reverse = true
@@ -699,19 +890,33 @@ func (q *Query) indexScan(plan *scanPlan, callback func(pkID uint64, row []types
 	if plan.rowStore {
 		// 承载行数据的索引: RangeScan 返回 storeRow = [pkID(8B) + rowData]。
 		// storeRow 指向页缓冲, 遍历结束后页可能被回收改写, 必须拷贝一份快照。
+		// 多区间 (IN 展开) 时不同值可能编码出重叠区间 → 按 pkID 去重。
 		var rows [][]byte
-		if err := plan.handler.RangeScan(q.table.handle, plan.meta.EntryPage,
-			plan.startKey, plan.endKey, plan.startInclusive, plan.endInclusive, reverse,
-			func(row []byte) bool {
-				if len(row) < 8 {
+		var seen map[uint64]struct{}
+		if len(plan.ranges) > 1 {
+			seen = make(map[uint64]struct{}, len(plan.ranges)*4)
+		}
+		for _, kr := range plan.ranges {
+			if err := plan.handler.RangeScan(q.table.handle, plan.meta.EntryPage,
+				kr.startKey, kr.endKey, kr.startInclusive, kr.endInclusive, reverse,
+				func(row []byte) bool {
+					if len(row) < 8 {
+						return true
+					}
+					if seen != nil {
+						pkID := binary.BigEndian.Uint64(row[:8])
+						if _, dup := seen[pkID]; dup {
+							return true
+						}
+						seen[pkID] = struct{}{}
+					}
+					snapshot := make([]byte, len(row))
+					copy(snapshot, row)
+					rows = append(rows, snapshot)
 					return true
-				}
-				snapshot := make([]byte, len(row))
-				copy(snapshot, row)
-				rows = append(rows, snapshot)
-				return true
-			}); err != nil {
-			return err
+				}); err != nil {
+				return err
+			}
 		}
 		for _, row := range rows {
 			rowData := row[8:]
@@ -720,42 +925,51 @@ func (q *Query) indexScan(plan *scanPlan, callback func(pkID uint64, row []types
 					continue
 				}
 			}
-			if !callback(binary.BigEndian.Uint64(row[:8]), q.table.deserializeRow(rowData)) {
+			if !callback(binary.BigEndian.Uint64(row[:8]), q.deserialize(rowData)) {
 				return nil
 			}
 		}
 		return nil
 	}
 
-	// 二级索引: RangeScan 返回 pkID(8B BE)。只按顺序物化 pkID, 回表放到遍历之后。
+	// 二级索引: RangeScan 的输出序即索引键序 (B+ 叶序 / Hash 内部已按键排序),
+	// 免排序 ORDER BY 路径依赖它 —— 单区间时原样保留; 只有 IN 多区间才排序去重
+	// (此时顺序本来就交给排序管线, 升序输入也是批量回表游标的要求)。
 	var pkIDs []uint64
-	if err := plan.handler.RangeScan(q.table.handle, plan.meta.EntryPage,
-		plan.startKey, plan.endKey, plan.startInclusive, plan.endInclusive, reverse,
-		func(row []byte) bool {
-			if len(row) < 8 {
+	for _, kr := range plan.ranges {
+		if err := plan.handler.RangeScan(q.table.handle, plan.meta.EntryPage,
+			kr.startKey, kr.endKey, kr.startInclusive, kr.endInclusive, reverse,
+			func(row []byte) bool {
+				if len(row) < 8 {
+					return true
+				}
+				pkIDs = append(pkIDs, binary.BigEndian.Uint64(row[:8]))
 				return true
-			}
-			pkIDs = append(pkIDs, binary.BigEndian.Uint64(row[:8]))
-			return true
-		}); err != nil {
-		return err
+			}); err != nil {
+			return err
+		}
 	}
-	for _, pkID := range pkIDs {
-		storeRow, found := q.table.indexMeta.ResolveByPK(pkID)
-		if !found {
-			// 行已被之前的回调删除, 跳过
-			continue
+	if len(plan.ranges) > 1 && len(pkIDs) > 1 {
+		sort.Slice(pkIDs, func(i, j int) bool { return pkIDs[i] < pkIDs[j] })
+		// 多区间才可能重复 (同 pkID 命中多个重叠区间); 单区间去重徒增一次扫描
+		w := 1
+		for i := 1; i < len(pkIDs); i++ {
+			if pkIDs[i] != pkIDs[w-1] {
+				pkIDs[w] = pkIDs[i]
+				w++
+			}
 		}
+		pkIDs = pkIDs[:w]
+	}
+	q.table.indexMeta.BatchResolveByPK(pkIDs, budget, func(pkID uint64, storeRow []byte) bool {
 		rowData := storeRow[8:]
 		if plan.remainingCond != nil {
 			if !plan.remainingCond.Match(rowData, q.table) {
-				continue
+				return true
 			}
 		}
-		if !callback(pkID, q.table.deserializeRow(rowData)) {
-			return nil
-		}
-	}
+		return callback(pkID, q.deserialize(rowData))
+	})
 	return nil
 }
 
@@ -780,7 +994,7 @@ func (q *Query) fullTableScan(callback func(pkID uint64, row []types.FieldValue)
 					continue
 				}
 			}
-			if !callback(binary.BigEndian.Uint64(storeRow[:8]), q.table.deserializeRow(rowData)) {
+			if !callback(binary.BigEndian.Uint64(storeRow[:8]), q.deserialize(rowData)) {
 				return nil // 回调要求提前终止
 			}
 		}
@@ -832,38 +1046,46 @@ func (q *Query) executeScanRaw(callback func(pkID uint64, rowData []byte) bool,
 // 合并会让裸游标读到被改写的页。本路径的消费者全是只读聚合, 遍历中不存在任何
 // 写操作, 游标必然有效; 于是物化 (含 rowStore 的逐行快照拷贝) 一并省掉。
 func (q *Query) indexScanRaw(plan *scanPlan, callback func(pkID uint64, rowData []byte) bool) error {
-	if plan.rowStore {
-		// 承载行数据的索引: 条目即 storeRow = [pkID(8B) + rowData]
+	scanRange := func(kr keyRange) error {
+		if plan.rowStore {
+			// 承载行数据的索引: 条目即 storeRow = [pkID(8B) + rowData]
+			return plan.handler.RangeScan(q.table.handle, plan.meta.EntryPage,
+				kr.startKey, kr.endKey, kr.startInclusive, kr.endInclusive, false,
+				func(row []byte) bool {
+					if len(row) < 8 {
+						return true
+					}
+					rowData := row[8:]
+					if plan.remainingCond != nil && !plan.remainingCond.Match(rowData, q.table) {
+						return true
+					}
+					return callback(binary.BigEndian.Uint64(row[:8]), rowData)
+				})
+		}
+		// 二级索引: 条目 = pkID(8B BE), 遍历中同步回表 (只读, 不触碰被遍历的索引结构)
 		return plan.handler.RangeScan(q.table.handle, plan.meta.EntryPage,
-			plan.startKey, plan.endKey, plan.startInclusive, plan.endInclusive, false,
+			kr.startKey, kr.endKey, kr.startInclusive, kr.endInclusive, false,
 			func(row []byte) bool {
 				if len(row) < 8 {
 					return true
 				}
-				rowData := row[8:]
+				storeRow, found := q.table.indexMeta.ResolveByPK(binary.BigEndian.Uint64(row[:8]))
+				if !found {
+					return true // 行已被删除, 跳过
+				}
+				rowData := storeRow[8:]
 				if plan.remainingCond != nil && !plan.remainingCond.Match(rowData, q.table) {
 					return true
 				}
 				return callback(binary.BigEndian.Uint64(row[:8]), rowData)
 			})
 	}
-	// 二级索引: 条目 = pkID(8B BE), 遍历中同步回表 (只读, 不触碰被遍历的索引结构)
-	return plan.handler.RangeScan(q.table.handle, plan.meta.EntryPage,
-		plan.startKey, plan.endKey, plan.startInclusive, plan.endInclusive, false,
-		func(row []byte) bool {
-			if len(row) < 8 {
-				return true
-			}
-			storeRow, found := q.table.indexMeta.ResolveByPK(binary.BigEndian.Uint64(row[:8]))
-			if !found {
-				return true // 行已被删除, 跳过
-			}
-			rowData := storeRow[8:]
-			if plan.remainingCond != nil && !plan.remainingCond.Match(rowData, q.table) {
-				return true
-			}
-			return callback(binary.BigEndian.Uint64(row[:8]), rowData)
-		})
+	for _, kr := range plan.ranges {
+		if err := scanRange(kr); err != nil {
+			return err
+		}
+	}
+	return nil
 }
 
 // fullTableScanRaw 与 fullTableScan 同构, 回调拿裸行字节。
@@ -895,10 +1117,145 @@ func (q *Query) fullTableScanRaw(callback func(pkID uint64, rowData []byte) bool
 
 // ======================== 排序 ========================
 
+// sortResults 内存排序: decoration 阶段每行按名提取一次排序键, 键随行进数组
+// (排序会重排元素, 平行键数组会错位), 比较阶段零查找。
+// 行集合可能因 NULL 跳列而宽窄不一, 键按名进行、缺失用 nil 占位,
+// 语义与旧比较器完全一致。
 func (q *Query) sortResults(results [][]types.FieldValue) {
-	sort.SliceStable(results, func(i, j int) bool {
-		return q.compareRowsForOrderBy(results[i], results[j]) < 0
+	specs := q.buildOrderSpecs()
+	if len(specs) == 0 {
+		return
+	}
+	dec := make([]struct {
+		row  []types.FieldValue
+		keys []interface{}
+	}, len(results))
+	for i := range results {
+		k := make([]interface{}, len(specs))
+		for j, s := range specs {
+			k[j] = findFieldValue(results[i], s.field)
+		}
+		dec[i] = struct {
+			row  []types.FieldValue
+			keys []interface{}
+		}{results[i], k}
+	}
+	sort.SliceStable(dec, func(i, j int) bool {
+		return compareKeys(dec[i].keys, dec[j].keys, specs) < 0
 	})
+	for i := range dec {
+		results[i] = dec[i].row
+	}
+}
+
+// compareKeys 比较两行预提取的排序键。
+func compareKeys(ka, kb []interface{}, specs []orderKeySpec) int {
+	for j, s := range specs {
+		vi, vj := ka[j], kb[j]
+		if vi == nil || vj == nil {
+			continue
+		}
+		cmp := compareFieldValues(vi, vj, s.ft)
+		if cmp == 0 {
+			continue
+		}
+		if s.desc {
+			return -cmp
+		}
+		return cmp
+	}
+	return 0
+}
+
+// ======================== 流式 top-k (ORDER BY + LIMIT) ========================
+
+// topkItem 堆内元素: 行 + 预提取排序键 + 输入序号。
+// 序号做相等键的决胜 —— 弹出顺序与 sort.SliceStable 完全一致 (稳定)。
+type topkItem struct {
+	pkID uint64
+	row  []types.FieldValue
+	keys []interface{}
+	idx  int
+}
+
+// topkHeap 容量上限 limit+offset 的小顶堆: 超过即弹出当前最小, 堆里始终是
+// 已见行中的前 k 名。heap.Interface。
+type topkHeap struct {
+	specs []orderKeySpec
+	items []topkItem
+}
+
+func (h *topkHeap) Len() int { return len(h.items) }
+
+// Less 定义"输出序的逆序" (大顶堆): 堆顶是【当前集合里最后输出】的元素 ——
+// 容量超限时的淘汰对象。相等键时输入序号大者更靠后、先被淘汰 (稳定)。
+func (h *topkHeap) Less(i, j int) bool {
+	c := compareKeys(h.items[i].keys, h.items[j].keys, h.specs)
+	if c != 0 {
+		return c > 0
+	}
+	return h.items[i].idx > h.items[j].idx
+}
+
+func (h *topkHeap) Swap(i, j int) { h.items[i], h.items[j] = h.items[j], h.items[i] }
+
+func (h *topkHeap) Push(x interface{}) { h.items = append(h.items, x.(topkItem)) }
+
+func (h *topkHeap) Pop() interface{} {
+	old := h.items
+	n := len(old)
+	it := old[n-1]
+	old[n-1] = topkItem{} // 防内存泄漏
+	h.items = old[:n-1]
+	return it
+}
+
+// topKScan ORDER BY + LIMIT 的流式执行: 扫描全程只保留 limit+offset 行的小顶堆,
+// 内存 O(k)、免全量排序 (旧实现先物化全部结果再整体排序, 百万行翻页也要全排)。
+// 稳定性: 相等键按输入序号决胜, 输出与 sort.SliceStable 后切片逐字节一致。
+func (q *Query) topKScan(callback func(pkID uint64, row []types.FieldValue) bool,
+	plan *scanPlan, specs []orderKeySpec) error {
+	cap := q.limit + q.offset
+	if cap < q.limit {
+		cap = q.limit // 防 offset 溢出 (理论值, int 实际不会)
+	}
+	h := &topkHeap{specs: specs}
+	idx := 0
+	err := q.executeScan(func(pkID uint64, row []types.FieldValue) bool {
+		keys := make([]interface{}, len(specs))
+		for j, s := range specs {
+			keys[j] = findFieldValue(row, s.field)
+		}
+		heap.Push(h, topkItem{pkID: pkID, row: row, keys: keys, idx: idx})
+		idx++
+		if h.Len() > cap {
+			heap.Pop(h)
+		}
+		return true
+	}, plan, 0)
+	if err != nil {
+		return err
+	}
+	// 全部弹出: 大顶堆弹出的是"输出序最后"的元素 → items 是输出序的逆序。
+	items := make([]topkItem, 0, h.Len())
+	for h.Len() > 0 {
+		items = append(items, heap.Pop(h).(topkItem))
+	}
+	if q.offset > 0 {
+		if q.offset >= len(items) {
+			return nil
+		}
+		items = items[:len(items)-q.offset] // 弹出序的尾部 = 输出序的头部 → 丢弃
+	}
+	if q.limit > 0 && len(items) > q.limit {
+		items = items[len(items)-q.limit:] // 保留弹出序尾部 = 输出序的前 limit 名
+	}
+	for i := len(items) - 1; i >= 0; i-- {
+		if !callback(items[i].pkID, items[i].row) {
+			return nil
+		}
+	}
+	return nil
 }
 
 func findFieldValue(row []types.FieldValue, field string) interface{} {
@@ -1085,15 +1442,20 @@ func (q *Query) Count() (int64, error) {
 	if !paged {
 		if plan := q.buildScanPlan(); plan != nil && plan.remainingCond == nil {
 			var count int64
-			err := plan.handler.RangeScan(q.table.handle, plan.meta.EntryPage,
-				plan.startKey, plan.endKey, plan.startInclusive, plan.endInclusive, false,
-				func(row []byte) bool {
-					if len(row) >= 8 {
-						count++
-					}
-					return true
-				})
-			return count, err
+			for _, kr := range plan.ranges {
+				err := plan.handler.RangeScan(q.table.handle, plan.meta.EntryPage,
+					kr.startKey, kr.endKey, kr.startInclusive, kr.endInclusive, false,
+					func(row []byte) bool {
+						if len(row) >= 8 {
+							count++
+						}
+						return true
+					})
+				if err != nil {
+					return 0, err
+				}
+			}
+			return count, nil
 		}
 		var count int64
 		err := q.scanRaw(func(_ uint64, _ []byte) bool {

+ 470 - 0
DB/core/query_optimizer_test.go

@@ -0,0 +1,470 @@
+package core
+
+import (
+	"sort"
+	"testing"
+
+	"git.ycl.pub/yl32940/TsDB2/DB/core/page"
+	"git.ycl.pub/yl32940/TsDB2/DB/core/types"
+)
+
+// qopNewTable 建测试表: id (uint64 主键) + age (int64, B+ 二级索引 idx_age)
+// + score (int64, Hash 二级索引 idx_score) + name (string 32B, 无索引)。
+func qopNewTable(t *testing.T, db *DB, name string) *Table {
+	t.Helper()
+	tbl, err := db.CreateTable(name, []types.FieldInfo{
+		{FieldType: types.FieldType_Uint64, FieldName: []byte("id")},
+		{FieldType: types.FieldType_Int64, FieldName: []byte("age")},
+		{FieldType: types.FieldType_Int64, FieldName: []byte("score")},
+		{FieldType: types.FieldType_String, FieldName: []byte("name"), Flag: types.WidthCodeFor(32)},
+	})
+	if err != nil {
+		t.Fatalf("建表 %s: %v", name, err)
+	}
+	if _, err := tbl.CreateIndex("pk", page.IndexTypePrimary, []byte{0}); err != nil {
+		t.Fatalf("建主键索引: %v", err)
+	}
+	if _, err := tbl.CreateIndex("idx_age", page.IndexTypeBTree, []byte{1}); err != nil {
+		t.Fatalf("建 B+ 索引 idx_age: %v", err)
+	}
+	if _, err := tbl.CreateIndex("idx_score", page.IndexTypeHash, []byte{2}); err != nil {
+		t.Fatalf("建 Hash 索引 idx_score: %v", err)
+	}
+	return tbl
+}
+
+// qopInsert 插入一行 (id 显式, age/score/name 按 i 生成)。
+func qopInsert(t *testing.T, tbl *Table, id uint64, age, score int64, name string) {
+	t.Helper()
+	if _, err := tbl.InsertRowWithID(id, []types.FieldValue{
+		{FieldName: []byte("id"), Value: id},
+		{FieldName: []byte("age"), Value: age},
+		{FieldName: []byte("score"), Value: score},
+		{FieldName: []byte("name"), Value: name},
+	}); err != nil {
+		t.Fatalf("插入 id=%d: %v", id, err)
+	}
+}
+
+// qopPKKey 主键索引键 = pkID 的 8B 大端。
+func qopPKKey(id uint64) []byte {
+	k := make([]byte, 8)
+	types.EncodeValue(types.FieldType_Uint64, 0, id, k)
+	return k
+}
+
+// qopSeed 灌 n 行: age = i%50, score = i%100, 便于构造多值命中。
+func qopSeed(t *testing.T, tbl *Table, n int) {
+	t.Helper()
+	for i := 1; i <= n; i++ {
+		qopInsert(t, tbl, uint64(i), int64(i%50), int64(i%100), "")
+	}
+}
+
+// collectAges 执行查询并返回 (pkID → age) 映射, 用于与全量预期比对。
+func collectAges(t *testing.T, q *Query) map[uint64]int64 {
+	t.Helper()
+	out := map[uint64]int64{}
+	err := q.ScanWithPKID(func(pkID uint64, row []types.FieldValue) bool {
+		for _, fv := range row {
+			if string(fv.FieldName) == "age" {
+				out[pkID] = fv.Value.(int64)
+			}
+		}
+		return true
+	})
+	if err != nil {
+		t.Fatalf("scan: %v", err)
+	}
+	return out
+}
+
+// TestInUsesIndex IN 必须走索引: 规划器为每个值展开一个区间, 而不是退回全表扫描。
+func TestInUsesIndex(t *testing.T) {
+	db := fixOpenDB(t)
+	tbl := qopNewTable(t, db, "t")
+	qopSeed(t, tbl, 200)
+
+	// 白盒: 规划结果应为多区间计划
+	vals := []interface{}{int64(7), int64(23), int64(49)}
+	plan := tbl.Query().Where(In("age", vals, 0)).buildScanPlan()
+	if plan == nil {
+		t.Fatalf("IN(age, 3值) 应生成索引计划, 得到 nil")
+	}
+	if len(plan.ranges) != 3 {
+		t.Fatalf("IN 3 值应展开 3 个区间, 得到 %d", len(plan.ranges))
+	}
+	if plan.meta == nil || string(plan.meta.IndexName[:7]) != "idx_age" {
+		t.Fatalf("计划应选 idx_age, 得到 %+v", plan.meta)
+	}
+
+	// 黑盒: 结果集与逐行过滤一致
+	got := collectAges(t, tbl.Query().Where(In("age", vals, 0)))
+	want := map[uint64]int64{}
+	for id := uint64(1); id <= 200; id++ {
+		age := int64(id % 50)
+		for _, v := range vals {
+			if age == v.(int64) {
+				want[id] = age
+			}
+		}
+	}
+	if len(got) != len(want) {
+		t.Fatalf("IN 命中 %d 行, 想要 %d", len(got), len(want))
+	}
+	for id, age := range want {
+		if got[id] != age {
+			t.Fatalf("pkID=%d: 得到 age=%d, 想要 %d", id, got[id], age)
+		}
+	}
+}
+
+// TestInOnHashIndex Hash 索引上的等值集合: 每个值一次桶定位。
+func TestInOnHashIndex(t *testing.T) {
+	db := fixOpenDB(t)
+	tbl := qopNewTable(t, db, "t")
+	qopSeed(t, tbl, 200)
+
+	plan := tbl.Query().Where(In("score", []interface{}{int64(5), int64(66)}, 0)).buildScanPlan()
+	if plan == nil {
+		t.Fatalf("IN(score, ...) 应生成索引计划")
+	}
+	if len(plan.ranges) != 2 {
+		t.Fatalf("应展开 2 个区间, 得到 %d", len(plan.ranges))
+	}
+
+	got := collectAges(t, tbl.Query().Where(In("score", []interface{}{int64(5), int64(66)}, 0)))
+	wantCount := 0
+	for id := uint64(1); id <= 200; id++ {
+		s := int64(id % 100)
+		if s == 5 || s == 66 {
+			wantCount++
+		}
+	}
+	if len(got) != wantCount {
+		t.Fatalf("Hash IN 命中 %d 行, 想要 %d", len(got), wantCount)
+	}
+}
+
+// TestInEdgeCases 空集合恒假 / 不存在的值 / 重复值不重复返回 / 乱序值。
+func TestInEdgeCases(t *testing.T) {
+	db := fixOpenDB(t)
+	tbl := qopNewTable(t, db, "t")
+	qopSeed(t, tbl, 100)
+
+	// 空集合: 0 行 (不能退化成查全表)
+	if n, err := tbl.Query().Where(In("age", nil, 0)).Count(); err != nil || n != 0 {
+		t.Fatalf("空 IN 应得 0 行, 得到 %d (%v)", n, err)
+	}
+	// 全部不存在的值
+	if n, err := tbl.Query().Where(In("age", []interface{}{int64(999), int64(1000)}, 0)).Count(); err != nil || n != 0 {
+		t.Fatalf("不存在值的 IN 应得 0 行, 得到 %d", n)
+	}
+	// 重复值: 每行只返回一次
+	dup := []interface{}{int64(7), int64(7), int64(7)}
+	got := collectAges(t, tbl.Query().Where(In("age", dup, 0)))
+	// age=7 → id%50==7 → id ∈ {7,57} (1..100 内)
+	if len(got) != 2 {
+		t.Fatalf("重复值的 IN 应命中 2 行, 得到 %d", len(got))
+	}
+	// 乱序值: 结果集不变
+	a := collectAges(t, tbl.Query().Where(In("age", []interface{}{int64(49), int64(7), int64(23)}, 0)))
+	b := collectAges(t, tbl.Query().Where(In("age", []interface{}{int64(7), int64(23), int64(49)}, 0)))
+	if len(a) != len(b) {
+		t.Fatalf("乱序 IN 结果数不一致: %d vs %d", len(a), len(b))
+	}
+	for id := range a {
+		if _, ok := b[id]; !ok {
+			t.Fatalf("乱序 IN 结果集不一致: pkID %d 只在一边", id)
+		}
+	}
+}
+
+// TestInWithOtherConditions IN 与其它条件组合: 同列范围条件让位给行级过滤,
+// 其它列条件进 remainingCond, 结果都必须正确。
+func TestInWithOtherConditions(t *testing.T) {
+	db := fixOpenDB(t)
+	tbl := qopNewTable(t, db, "t")
+	qopSeed(t, tbl, 100)
+
+	// IN + 同列范围: 范围条件走索引区间, IN 由行级过滤兜底
+	got := collectAges(t, tbl.Query().Where(
+		And(In("age", []interface{}{int64(20), int64(25), int64(30)}, 0),
+			Gt("age", int64(22), 0))))
+	// 期望: age ∈ {25, 30}
+	if len(got) != 4 { // id%50==25 → 25,75; id%50==30 → 30,80
+		t.Fatalf("IN+范围 应命中 4 行, 得到 %d", len(got))
+	}
+	for _, age := range got {
+		if age != 25 && age != 30 {
+			t.Fatalf("出现不该出现的 age=%d", age)
+		}
+	}
+
+	// IN + 其它列等值 (score 走 hash 或行级过滤)
+	got2 := collectAges(t, tbl.Query().Where(
+		And(In("age", []interface{}{int64(10), int64(11)}, 0),
+			Eq("score", int64(10), 0))))
+	// age=10 & score=10 → id%50==10 且 id%100==10 → id=10, 110>100 → 仅 id=10
+	if len(got2) != 1 {
+		t.Fatalf("IN+其它列 应命中 1 行, 得到 %d", len(got2))
+	}
+	if got2[10] != 10 {
+		t.Fatalf("应为 pkID=10 age=10, 得到 %+v", got2)
+	}
+}
+
+// TestBatchResolveMatchesPerPK 批量回表与逐个回表结果一致 (含已删除的行号):
+// 删除若干行后, 二级索引扫描回表必须自动跳过不存在的 pkID。
+func TestBatchResolveMatchesPerPK(t *testing.T) {
+	db := fixOpenDB(t)
+	tbl := qopNewTable(t, db, "t")
+	qopSeed(t, tbl, 300)
+	// 删除 1/3 的行 (交错)
+	for id := uint64(2); id <= 300; id += 3 {
+		if err := tbl.DeleteRow("pk", qopPKKey(id)); err != nil {
+			t.Fatalf("删除 %d: %v", id, err)
+		}
+	}
+	if err := db.Commit(); err != nil {
+		t.Fatalf("commit: %v", err)
+	}
+
+	// 大范围扫 + 回表
+	got := collectAges(t, tbl.Query().Where(Ge("age", int64(0), 0)))
+	want := 0
+	for id := uint64(1); id <= 300; id++ {
+		if id%3 == 2 {
+			continue // 已删除
+		}
+		want++
+	}
+	if len(got) != want {
+		t.Fatalf("范围扫回表 %d 行, 想要 %d", len(got), want)
+	}
+	for id := range got {
+		if id%3 == 2 {
+			t.Fatalf("已删除的行 %d 被回表出来", id)
+		}
+	}
+
+	// IN 批量回表 (排序去重路径)
+	in := collectAges(t, tbl.Query().Where(
+		In("age", []interface{}{int64(1), int64(2), int64(3)}, 0)))
+	for id := range in {
+		if id%3 == 2 {
+			t.Fatalf("IN 回表带出已删除行 %d", id)
+		}
+	}
+}
+
+// TestTopKMatchesFullSort ORDER BY + LIMIT 的流式 top-k 与"全量排序再切片"逐行一致,
+// 覆盖正/反序、OFFSET、大量重复键 (稳定性) 与 IN 多区间走排序管线的组合。
+func TestTopKMatchesFullSort(t *testing.T) {
+	db := fixOpenDB(t)
+	tbl := qopNewTable(t, db, "t")
+	qopSeed(t, tbl, 500) // age = i%50: 每个 age 10 行, 天然大量重复键
+
+	full := []uint64{}
+	err := tbl.Query().OrderBy("age").ScanWithPKID(func(pkID uint64, _ []types.FieldValue) bool {
+		full = append(full, pkID)
+		return true
+	})
+	if err != nil {
+		t.Fatalf("full sort: %v", err)
+	}
+
+	// 稳定性参照: 相等 age 按扫描顺序 (pkID 升序) 排列
+	for _, tc := range []struct {
+		name        string
+		build       func() *Query
+		wantSlice   func() []uint64
+	}{
+		{
+			name:  "asc limit 10",
+			build: func() *Query { return tbl.Query().OrderBy("age").Limit(10) },
+			wantSlice: func() []uint64 {
+				return full[:10]
+			},
+		},
+		{
+			name:  "desc limit 20",
+			build: func() *Query { return tbl.Query().OrderBy("age", true).Limit(20) },
+			wantSlice: func() []uint64 {
+				// 稳定降序: 键降序, 相等键保持输入序 (pkID 升序) —
+				// 不能对升序结果简单反转 (那会把相等键也倒过来)。
+				type pa struct {
+					pk  uint64
+					age int64
+				}
+				in := make([]pa, 500)
+				for i := 0; i < 500; i++ {
+					in[i] = pa{uint64(i + 1), int64((i + 1) % 50)}
+				}
+				sort.SliceStable(in, func(i, j int) bool { return in[i].age > in[j].age })
+				out := make([]uint64, 20)
+				for i := range out {
+					out[i] = in[i].pk
+				}
+				return out
+			},
+		},
+		{
+			name:  "asc offset 100 limit 50",
+			build: func() *Query { return tbl.Query().OrderBy("age").Offset(100).Limit(50) },
+			wantSlice: func() []uint64 {
+				return full[100:150]
+			},
+		},
+		{
+			name: "asc limit 1 (First 场景)",
+			build: func() *Query {
+				return tbl.Query().OrderBy("age").Limit(1)
+			},
+			wantSlice: func() []uint64 {
+				return full[:1]
+			},
+		},
+	} {
+		var got []uint64
+		err := tc.build().ScanWithPKID(func(pkID uint64, _ []types.FieldValue) bool {
+			got = append(got, pkID)
+			return true
+		})
+		if err != nil {
+			t.Fatalf("%s: %v", tc.name, err)
+		}
+		want := tc.wantSlice()
+		if len(got) != len(want) {
+			t.Fatalf("%s: %d 行, 想要 %d", tc.name, len(got), len(want))
+		}
+		for i := range want {
+			if got[i] != want[i] {
+				t.Fatalf("%s: 第 %d 位 pkID=%d, 想要 %d (稳定性破坏)", tc.name, i, got[i], want[i])
+			}
+		}
+	}
+
+	// IN 多区间 + ORDER BY (强制排序管线) + LIMIT
+	var got []uint64
+	err = tbl.Query().Where(In("age", []interface{}{int64(3), int64(1), int64(2)}, 0)).
+		OrderBy("age").Limit(5).
+		ScanWithPKID(func(pkID uint64, _ []types.FieldValue) bool {
+			got = append(got, pkID)
+			return true
+		})
+	if err != nil {
+		t.Fatalf("IN+ORDER BY+LIMIT: %v", err)
+	}
+	if len(got) != 5 {
+		t.Fatalf("IN+ORDER BY+LIMIT 应得 5 行, 得到 %d", len(got))
+	}
+	// 期望: age=1 的 10 行 (pkID 1,51,101,151,201,251,301>500→1,51,101,151,201,251... 500 内: 1,51,101,151,201,251,301,351,401,451) 取前 5
+	want5 := []uint64{1, 51, 101, 151, 201}
+	for i := range want5 {
+		if got[i] != want5[i] {
+			t.Fatalf("IN+ORDER BY 第 %d 位 = %d, 想要 %d", i, got[i], want5[i])
+		}
+	}
+}
+
+// TestSelectProjectionPushdown 投影下推: 只解码选中列 (+ORDER BY 列),
+// 未选中列不出现; 行内 NULL 与字符串列在投影下语义不变。
+func TestSelectProjectionPushdown(t *testing.T) {
+	db := fixOpenDB(t)
+	tbl, err := db.CreateTable("t", []types.FieldInfo{
+		{FieldType: types.FieldType_Uint64, FieldName: []byte("id")},
+		{FieldType: types.FieldType_Int64, FieldName: []byte("age")},
+		{FieldType: types.FieldType_String, FieldName: []byte("name"), Flag: types.WidthCodeFor(32)},
+	})
+	if err != nil {
+		t.Fatalf("建表: %v", err)
+	}
+	if _, err := tbl.CreateIndex("pk", page.IndexTypePrimary, []byte{0}); err != nil {
+		t.Fatalf("建主键索引: %v", err)
+	}
+	// 交错插入: 部分行 name 为空串, 部分有值
+	for i := 1; i <= 20; i++ {
+		nm := ""
+		if i%2 == 0 {
+			nm = "emp"
+		}
+		if _, err := tbl.InsertRowWithID(uint64(i), []types.FieldValue{
+			{FieldName: []byte("id"), Value: uint64(i)},
+			{FieldName: []byte("age"), Value: int64(i * 3)},
+			{FieldName: []byte("name"), Value: nm},
+		}); err != nil {
+			t.Fatalf("插入 %d: %v", i, err)
+		}
+	}
+	if err := db.Commit(); err != nil {
+		t.Fatalf("commit: %v", err)
+	}
+
+	// 只选 name: 行里只有 name 列
+	err = tbl.Query().Select("name").Scan(func(row []types.FieldValue) bool {
+		if len(row) != 1 || string(row[0].FieldName) != "name" {
+			t.Fatalf("投影行应只含 name, 得到 %v", row)
+		}
+		return true
+	})
+	if err != nil {
+		t.Fatalf("Select(name) scan: %v", err)
+	}
+
+	// Select + OrderBy(未选中列): 排序键列必须随投影保留, 排序结果正确
+	type pair struct {
+		id, age int64
+	}
+	var got []pair
+	err = tbl.Query().Select("id").OrderBy("age", true).Limit(3).Scan(func(row []types.FieldValue) bool {
+		var p pair
+		for _, fv := range row {
+			switch string(fv.FieldName) {
+			case "id":
+				p.id = int64(fv.Value.(uint64))
+			case "age":
+				p.age = fv.Value.(int64)
+			}
+		}
+		got = append(got, p)
+		return true
+	})
+	if err != nil {
+		t.Fatalf("Select+OrderBy: %v", err)
+	}
+	// age 倒序前三: id=20,19,18 → age=60,57,54
+	want := []pair{{20, 60}, {19, 57}, {18, 54}}
+	for i, w := range want {
+		if got[i] != w {
+			t.Fatalf("第 %d 位 = %+v, 想要 %+v", i, got[i], w)
+		}
+	}
+
+	// Find + Select: project 后只剩选中列
+	rows, err := tbl.Query().Select("age").OrderBy("id").Limit(2).Find()
+	if err != nil {
+		t.Fatalf("Find+Select: %v", err)
+	}
+	for _, row := range rows {
+		if len(row) != 1 || string(row[0].FieldName) != "age" {
+			t.Fatalf("Find+Select 行应只含 age, 得到 %v", row)
+		}
+	}
+}
+
+// TestInCountUsesIndex IN 的 Count 也走索引区间 (不物化行)。
+func TestInCountUsesIndex(t *testing.T) {
+	db := fixOpenDB(t)
+	tbl := qopNewTable(t, db, "t")
+	qopSeed(t, tbl, 1000)
+
+	n, err := tbl.Query().Where(In("age", []interface{}{int64(7), int64(8)}, 0)).Count()
+	if err != nil {
+		t.Fatalf("count: %v", err)
+	}
+	// age=7 → id%50==7 → 1..1000 内 20 个; age=8 → 20 个
+	if n != 40 {
+		t.Fatalf("IN count 应得 40, 得到 %d", n)
+	}
+}

+ 74 - 0
DB/core/query_scan_mutation_test.go

@@ -0,0 +1,74 @@
+package core
+
+import (
+	"testing"
+
+	"git.ycl.pub/yl32940/TsDB2/DB/core/types"
+)
+
+// TestScanWithDeleteDuringBatchResolve 扫描回调里逐行删除 (demo/stress_test 第 12 步
+// 的回归): 二级索引扫描物化 pkID 后走批量回表, 回调里的删除会改写主键 B+ 树
+// (叶合并/NextPage 重接)。批量回表必须"分批物化、批后回调" —— 绝不能让用户回调
+// 踩坏正在推进的叶链游标 (旧实现因此 index out of range)。
+func TestScanWithDeleteDuringBatchResolve(t *testing.T) {
+	db := fixOpenDB(t)
+	tbl := qopNewTable(t, db, "t")
+	qopSeed(t, tbl, 1000)
+	if err := db.Commit(); err != nil {
+		t.Fatalf("commit: %v", err)
+	}
+
+	deleted := 0
+	err := tbl.Query().Where(Ge("age", int64(0), 0)).ScanWithPKID(func(pkID uint64, _ []types.FieldValue) bool {
+		if err := tbl.DeleteRow("pk", qopPKKey(pkID)); err != nil {
+			t.Fatalf("删除 %d: %v", pkID, err)
+		}
+		deleted++
+		return true
+	})
+	if err != nil {
+		t.Fatalf("scan+delete: %v", err)
+	}
+	if deleted != 1000 {
+		t.Fatalf("应删 1000 行, 实删 %d", deleted)
+	}
+	cnt, err := tbl.Query().Count()
+	if err != nil || cnt != 0 {
+		t.Fatalf("删尽后 count=%d err=%v, 想要 0", cnt, err)
+	}
+}
+
+// TestScanWithUpdateDuringBatchResolve 扫描回调里逐行更新 (改索引列):
+// 更新会改写二级索引与行数据, 正在分批回表的游标必须不受影响, 且每行都被回调到。
+func TestScanWithUpdateDuringBatchResolve(t *testing.T) {
+	db := fixOpenDB(t)
+	tbl := qopNewTable(t, db, "t")
+	qopSeed(t, tbl, 500)
+	if err := db.Commit(); err != nil {
+		t.Fatalf("commit: %v", err)
+	}
+
+	updated := 0
+	err := tbl.Query().Where(In("age", []interface{}{int64(7), int64(8), int64(9)}, 0)).
+		ScanWithPKID(func(pkID uint64, _ []types.FieldValue) bool {
+			if err := tbl.UpdateRowByPK(pkID, []types.FieldValue{
+				{FieldName: []byte("age"), Value: int64(99)},
+				{FieldName: []byte("score"), Value: int64(1)},
+			}); err != nil {
+				t.Fatalf("更新 %d: %v", pkID, err)
+			}
+			updated++
+			return true
+		})
+	if err != nil {
+		t.Fatalf("scan+update: %v", err)
+	}
+	// age ∈ {7,8,9} 且 id<=500: 每值 10 个 → 30 行
+	if updated != 30 {
+		t.Fatalf("应更新 30 行, 实更新 %d", updated)
+	}
+	cnt, err := tbl.Query().Where(Eq("age", int64(99), 0)).Count()
+	if err != nil || cnt != 30 {
+		t.Fatalf("更新后 age=99 的 count=%d err=%v, 想要 30", cnt, err)
+	}
+}

+ 6 - 121
DB/core/transaction.go

@@ -1,8 +1,6 @@
 package core
 
 import (
-	"git.ycl.pub/yl32940/TsDB2/DB/core/page"
-	"encoding/binary"
 	"fmt"
 )
 
@@ -14,9 +12,8 @@ import (
 // - Commit 时把脏页 + Header 整体写入 WAL, 保证原子
 // - Rollback 只需丢弃本事务碰过的页缓存(并从磁盘重载 TableInfo), 后续读取自然回到旧值
 type Transaction struct {
-	db         *DB
-	touched    map[uint32]struct{}                       // 本事务修改过的页 ID(Rollback 时丢弃其缓存)
-	rowChanges map[uint32]struct{ original, new []byte } // pageID → {原始行, 新行}(用于 WAL)
+	db      *DB
+	touched map[uint32]struct{} // 本事务修改过的页 ID(Rollback 时丢弃其缓存)
 	// pendingFree 本事务登记待回收的页 (堆页摘链 / 批量删除后作废的旧 DataPage)。
 	// 不在事务中途直写盘: 提交成功后才真正还链, 回滚时整体丢弃
 	// (回滚后链指针回到事务前, 这些页仍被引用, 绝不能还链)。
@@ -49,13 +46,12 @@ func (db *DB) BeginTransaction() *Transaction {
 	txn := &Transaction{
 		db:         db,
 		touched:    make(map[uint32]struct{}),
-		rowChanges: make(map[uint32]struct{ original, new []byte }),
 		committed:  false,
 		rolledBack: false,
 	}
 
 	// 设置当前活跃事务
-	db.currentTxn = txn
+	db.currentTxn.Store(txn)
 
 	return txn
 }
@@ -66,16 +62,6 @@ func (txn *Transaction) markTouched(pageID uint32) {
 	txn.touched[pageID] = struct{}{}
 }
 
-// RecordRowChange 记录行级变化(用于 WAL)
-// originalRow: 原始行数据
-// newRow: 新行数据
-func (txn *Transaction) RecordRowChange(pageID uint32, originalRow []byte, newRow []byte) {
-	txn.rowChanges[pageID] = struct{ original, new []byte }{
-		original: originalRow,
-		new:      newRow,
-	}
-}
-
 // Commit 提交事务
 // 只写「整页镜像」条目: 恢复所有被修改页的结构与数据
 // (B+树节点, IndexMeta, HeaderPage, DataPage 一次到位)。
@@ -103,12 +89,11 @@ func (txn *Transaction) Commit() error {
 		txn.db.freeChainNow(txn.pendingFreeChain)
 		txn.db.drainPendingFree()
 		txn.committed = true
-		txn.db.currentTxn = nil
+		txn.db.currentTxn.Store(nil)
 		txn.touched = nil
 		txn.pendingFree = nil
 		txn.pendingFreeChain = nil
 		txn.allocated = nil
-		txn.rowChanges = nil
 		return nil
 	}
 
@@ -147,114 +132,15 @@ func (txn *Transaction) Commit() error {
 
 	// 8. 清理事务状态
 	txn.committed = true
-	txn.db.currentTxn = nil
+	txn.db.currentTxn.Store(nil)
 	txn.touched = nil
 	txn.pendingFree = nil
 	txn.pendingFreeChain = nil
 	txn.allocated = nil
-	txn.rowChanges = nil
 
 	return nil
 }
 
-// =============================================================================
-// diffDataPages 比较两个 DataPage 缓冲区, 提取行级变更
-// 返回 INSERT/DELETE/UPDATE 的 RowEntry 列表
-// =============================================================================
-func diffDataPages(origBuf, curBuf []byte) []RowEntry {
-	const delFlag = uint32(0x80000000)
-
-	type rowInfo struct {
-		storeRow []byte
-		deleted  bool
-	}
-
-	// 解析 DataPage 中所有行
-	// 布局: [PageHdr(20B)][Count(4B)][Min(8B)][Max(8B)] = 40B, 行区从 40 开始
-	parseRows := func(buf []byte) map[uint64]rowInfo {
-		rows := make(map[uint64]rowInfo)
-		if len(buf) < int(page.DataPageHdrSize) {
-			return rows
-		}
-		dh := page.DataHdrOf(buf)
-		count := dh.Count
-		dataEnd := dh.UseSize
-		if dataEnd < page.DataPageHdrSize || dataEnd > uint32(len(buf)) {
-			return rows
-		}
-
-		off := page.DataPageHdrSize
-		for i := uint32(0); i < count; i++ {
-			if off+4 > dataEnd || off+4 > uint32(len(buf)) {
-				break
-			}
-			rawLen := binary.LittleEndian.Uint32(buf[off:])
-			deleted := rawLen&delFlag != 0
-			rowLen := rawLen & ^delFlag
-			rowStart := off + 4
-			if rowStart+rowLen > dataEnd || rowStart+rowLen > uint32(len(buf)) {
-				break
-			}
-			if rowLen >= 8 {
-				pkID := binary.BigEndian.Uint64(buf[rowStart : rowStart+8])
-				row := make([]byte, rowLen)
-				copy(row, buf[rowStart:rowStart+rowLen])
-				rows[pkID] = rowInfo{storeRow: row, deleted: deleted}
-			}
-			off = rowStart + rowLen
-		}
-		return rows
-	}
-
-	origRows := parseRows(origBuf)
-	curRows := parseRows(curBuf)
-
-	var result []RowEntry
-
-	// 检测 DELETE 和 UPDATE (存在于原始页中的行)
-	for pkID, orig := range origRows {
-		if orig.deleted {
-			continue // 原始页中已删除的行不处理
-		}
-		cur, exists := curRows[pkID]
-		if !exists || cur.deleted {
-			// 行被删除
-			pkBuf := make([]byte, 8)
-			binary.BigEndian.PutUint64(pkBuf, pkID)
-			result = append(result, RowEntry{OpType: WALOpDelete, StoreRow: pkBuf})
-		} else {
-			// 检查行数据是否变化
-			if !bytesEqual(orig.storeRow, cur.storeRow) {
-				result = append(result, RowEntry{OpType: WALOpUpdate, StoreRow: cur.storeRow})
-			}
-		}
-	}
-
-	// 检测 INSERT (存在于当前页但不在原始页中的行)
-	for pkID, cur := range curRows {
-		if cur.deleted {
-			continue
-		}
-		if _, exists := origRows[pkID]; !exists {
-			result = append(result, RowEntry{OpType: WALOpInsert, StoreRow: cur.storeRow})
-		}
-	}
-
-	return result
-}
-
-func bytesEqual(a, b []byte) bool {
-	if len(a) != len(b) {
-		return false
-	}
-	for i := range a {
-		if a[i] != b[i] {
-			return false
-		}
-	}
-	return true
-}
-
 // Rollback 回滚事务: 丢弃本事务修改过的页缓存, 从磁盘重新加载。
 //
 // 事务期间 autoCommit 被 currentTxn 拦住, 没有任何脏页被刷到磁盘, 磁盘上仍是事务
@@ -288,9 +174,8 @@ func (txn *Transaction) Rollback() error {
 
 	// 清理事务状态
 	txn.rolledBack = true
-	txn.db.currentTxn = nil
+	txn.db.currentTxn.Store(nil)
 	txn.touched = nil
-	txn.rowChanges = nil
 
 	return nil
 }

+ 138 - 34
DB/core/vacuum.go

@@ -1,11 +1,10 @@
 package core
 
 import (
-	"errors"
 	"fmt"
 	"os"
 	"path/filepath"
-	"sync"
+	"sort"
 	"time"
 
 	"git.ycl.pub/yl32940/TsDB2/DB/core/page"
@@ -86,7 +85,48 @@ type vacuumSrcTable struct {
 	name    string
 	fields  []types.FieldInfo
 	indexes []vacuumSrcIndex
-	rows    []vacuumSrcRow
+	// rowStore 行存储索引 (主键/时序) 的原始定义; nil = 源表没有
+	// (core API 允许不建行存储索引, 此时重建也不能凭空建一个)。
+	rowStore *vacuumSrcIndex
+	// fieldRemap 旧 master 下标 → 新活跃下标。新表剔除了 DROP 列,
+	// 列下标整体前移; 不映射就拿旧 FieldIDs 建新索引 → 越界/错位。
+	fieldRemap map[byte]byte
+	rows       []vacuumSrcRow
+	// tsKeyName 时序表的时间键列名 (rowStore.typ == Time 时非空),
+	// 重建前按它排序行, 避免乱序时间戳行触发 ErrOutOfOrder 被静默跳过。
+	tsKeyName string
+}
+
+// vacuumRowTS 取行快照里时间键列的 uint64 值 (时序表重建排序用);
+// 缺列或类型不符返回 0。
+func vacuumRowTS(vals []types.FieldValue, keyName string) uint64 {
+	for _, fv := range vals {
+		if string(fv.FieldName) != keyName {
+			continue
+		}
+		switch v := fv.Value.(type) {
+		case uint64:
+			return v
+		case int64:
+			return uint64(v)
+		}
+		return 0
+	}
+	return 0
+}
+
+// remapIndexFields 把索引分量的旧 master 下标换算到新表的活跃下标;
+// 映射缺失的分量原样保留 (不该发生, 保住可见错误而非静默错位)。
+func remapIndexFields(fields []byte, remap map[byte]byte) []byte {
+	out := make([]byte, len(fields))
+	for i, f := range fields {
+		if nf, ok := remap[f]; ok {
+			out[i] = nf
+		} else {
+			out[i] = f
+		}
+	}
+	return out
 }
 
 // ============================================================================
@@ -106,12 +146,15 @@ func (db *DB) VACUUM(opts ...VACUUMOptions) (*VACUUMResult, error) {
 		opt = opts[0]
 	}
 
-	if db.currentTxn != nil {
+	if db.currentTxn.Load() != nil {
 		return nil, fmt.Errorf("%w: VACUUM", ErrTxnActive)
 	}
 	if db.path == "" || db.path == os.DevNull {
 		return nil, fmt.Errorf("%w (%q)", ErrVACUUMNoPath, db.path)
 	}
+	// 手动与自动调度可能并发触发: 双开会对同一文件 Close/Open 交错, 必须串行。
+	db.vacuumMu.Lock()
+	defer db.vacuumMu.Unlock()
 
 	progress := opt.Progress
 	if progress == nil {
@@ -163,12 +206,16 @@ func (db *DB) VACUUM(opts ...VACUUMOptions) (*VACUUMResult, error) {
 			_ = os.Remove(tmpPath)
 			return db.reopenAfterVacuum(nil, fmt.Errorf("VACUUM: 重建表 %q 失败: %w", src.name, err))
 		}
-		// CreateTable 不自动建主键索引, 必须显式建; 否则 InsertRowWithID 找不到行存储索引,
-		// resolveIndexes() 返回空, 插入路径无副作用通过, 但 TotalRows++ 谎报数据, Count() 拿到 0。
-		if _, err := newTbl.CreateIndex("pk", page.IndexTypePrimary, []byte{0}); err != nil {
-			_ = newDB.Close()
-			_ = os.Remove(tmpPath)
-			return db.reopenAfterVacuum(nil, fmt.Errorf("VACUUM: 建主键索引 %q 失败: %w", src.name, err))
+		// 行存储索引按【原始名字/原始类型】重建: 旧实现无条件建成
+		// ("pk", Primary) —— ORM 按 pk_<表名> 寻址, 重建后 Find/Delete/Update
+		// 全部 ErrFieldNotFound; 时序表则被静默改成主键表, 分段语义丢失。
+		if src.rowStore != nil {
+			rsFields := remapIndexFields(src.rowStore.fields, src.fieldRemap)
+			if _, err := newTbl.CreateIndex(src.rowStore.name, src.rowStore.typ, rsFields); err != nil {
+				_ = newDB.Close()
+				_ = os.Remove(tmpPath)
+				return db.reopenAfterVacuum(nil, fmt.Errorf("VACUUM: 重建行存储索引 %q 失败: %w", src.rowStore.name, err))
+			}
 		}
 
 		// 5b. 插入全部行 (走主键索引)
@@ -190,13 +237,14 @@ func (db *DB) VACUUM(opts ...VACUUMOptions) (*VACUUMResult, error) {
 		}
 		progress("build", rowsCopied, totalRows)
 
-		// 5c. 重建二级索引 (自动批量回填已存在的行)
+		// 5c. 重建二级索引 (自动批量回填已存在的行)。行存储索引已按
+		// 原始定义建好, 跳过; FieldIDs 经 fieldRemap 换算到新表下标。
 		progress("index", rowsCopied, totalRows)
 		for _, idx := range src.indexes {
 			if isRowStoreType(idx.typ) {
-				continue // 行存储索引已在 CreateTable 时建好
+				continue // 行存储索引已在 CreateTable 后手动建好
 			}
-			if _, err := newTbl.CreateIndex(idx.name, idx.typ, idx.fields, idx.unique); err != nil {
+			if _, err := newTbl.CreateIndex(idx.name, idx.typ, remapIndexFields(idx.fields, src.fieldRemap), idx.unique); err != nil {
 				_ = newDB.Close()
 				_ = os.Remove(tmpPath)
 				return db.reopenAfterVacuum(nil, fmt.Errorf("VACUUM: 重建索引 %q 失败: %w", idx.name, err))
@@ -239,7 +287,7 @@ func (db *DB) VACUUM(opts ...VACUUMOptions) (*VACUUMResult, error) {
 	if err != nil {
 		return nil, fmt.Errorf("VACUUM: 重新打开失败: %w", err)
 	}
-	*db = *newDB2
+	db.adoptEngineState(newDB2)
 
 	// 10. 统计结果
 	newStat, _ := os.Stat(db.path)
@@ -289,32 +337,43 @@ func (db *DB) vacuumScan(progress VACUUMProgress) ([]vacuumSrcTable, int64, erro
 			continue
 		}
 
-		// 字段定义 (剔除已 DROP 的列)
+		// 字段定义 (剔除已 DROP 的列)。同时建立 旧 master 下标 → 新活跃下标
+		// 的映射: 新表 registry 只含活跃列, 下标整体前移, 索引的 FieldIDs
+		// 必须按映射换算, 否则"删过列"的表上重建索引必然越界/错位。
 		fields := tbl.GetFields()
 		activeFields := make([]types.FieldInfo, 0, len(fields))
-		for _, f := range fields {
+		fieldRemap := make(map[byte]byte, len(fields))
+		for i, f := range fields {
 			if f.IsDropped() {
 				continue
 			}
+			fieldRemap[byte(i)] = byte(len(activeFields))
 			activeFields = append(activeFields, f)
 		}
 
 		// 索引定义
 		var srcIndexes []vacuumSrcIndex
+		var rowStore *vacuumSrcIndex
 		for _, im := range tbl.IndexMeta().GetIndexes() {
-			srcIndexes = append(srcIndexes, vacuumSrcIndex{
+			idx := vacuumSrcIndex{
 				name:   im.Name(),
 				typ:    im.IndexType,
 				fields: append([]byte(nil), im.Fields()...),
 				unique: im.Unique(),
-			})
+			}
+			srcIndexes = append(srcIndexes, idx)
+			if isRowStoreType(im.IndexType) {
+				captured := idx
+				rowStore = &captured
+			}
 		}
 
-		// 行快照
+		// 行快照 (保留 NULL: deserializeRow 会跳过 NULL 列, VACUUM 会把
+		// NULL 物化成类型零值 —— 语义漂移且不可逆)。
 		rows := make([]vacuumSrcRow, 0, tbl.TotalRows())
 		q := tbl.Query()
 		scanErr := q.fullTableScanRaw(func(pkID uint64, rowData []byte) bool {
-			vals := tbl.deserializeRow(rowData)
+			vals := tbl.deserializeRowKeepNull(rowData)
 			if vals == nil {
 				return true // 解析失败, 跳过
 			}
@@ -329,11 +388,27 @@ func (db *DB) vacuumScan(progress VACUUMProgress) ([]vacuumSrcTable, int64, erro
 			return nil, 0, fmt.Errorf("扫描表 %q: %w", meta.Name, scanErr)
 		}
 
+		// 时序表: 重建前按时间键排序。时序索引断言时间有序, 按 pkID 序
+		// 插入乱序时间戳行会 ErrOutOfOrder 被静默跳过 (数据丢失)。
+		tsKeyName := ""
+		if rowStore != nil && rowStore.typ == page.IndexTypeTime && len(rowStore.fields) > 0 {
+			fi := int(rowStore.fields[0])
+			if fi < len(fields) {
+				tsKeyName = string(fields[fi].FieldName)
+			}
+			sort.SliceStable(rows, func(i, j int) bool {
+				return vacuumRowTS(rows[i].vals, tsKeyName) < vacuumRowTS(rows[j].vals, tsKeyName)
+			})
+		}
+
 		sources = append(sources, vacuumSrcTable{
-			name:    meta.Name,
-			fields:  activeFields,
-			indexes: srcIndexes,
-			rows:    rows,
+			name:       meta.Name,
+			fields:     activeFields,
+			indexes:    srcIndexes,
+			rowStore:   rowStore,
+			fieldRemap: fieldRemap,
+			tsKeyName:  tsKeyName,
+			rows:       rows,
 		})
 	}
 
@@ -380,17 +455,20 @@ func (db *DB) SetAutoVACUUM(interval time.Duration, opts ...VACUUMOptions) {
 		if !enabled {
 			return
 		}
+		// VACUUM 本体拿 vacuumMu 串行: 与并发手动 VACUUM 排队执行,
+		// 不会双开对同一文件 Close/Open 交错。
 		// 自动调度: 失败也吞掉 (用户可通过 Progress 回调处理)
 		_, _ = db.VACUUM(curOpt)
 
 		db.autoVacuumMu.Lock()
 		db.lastVACUUM = time.Now()
 		stillEnabled := db.autoVacuumEnabled
-		db.autoVacuumMu.Unlock()
-
+		// Reset 必须在锁内: SetAutoVACUUM(0) 会并发把 timer 置 nil,
+		// 锁外读/重置是 data race (race detector 实测抓到)。
 		if stillEnabled && db.autoVacuumTimer != nil {
 			db.autoVacuumTimer.Reset(interval)
 		}
+		db.autoVacuumMu.Unlock()
 	}
 
 	db.autoVacuumTimer = time.AfterFunc(interval, run)
@@ -416,19 +494,45 @@ func (db *DB) reopenAfterVacuum(_ *VACUUMResult, origErr error) (*VACUUMResult,
 	if err != nil {
 		return nil, fmt.Errorf("%w; 且重新打开源库也失败: %v", origErr, err)
 	}
-	*db = *newDB
+	db.adoptEngineState(newDB)
 	return nil, origErr
 }
 
+// adoptEngineState 用 other (刚重新打开的句柄) 的引擎状态替换 db 的,
+// 保留 db 上用户配置的运行时参数 (autoCommitDelay / autoVacuum 调度 / lastVACUUM)。
+//
+// VACUUM 完成后 db.path 已被替换成重建的文件, 必须换引擎状态才能继续使用句柄;
+// 但不能整体 `*db = *other`: DB 含多把互斥锁, 结构体整体赋值会把锁的内部状态
+// 一并按值拷贝 (go vet copylocks 报错), 更糟的是会把用户配置覆盖成零值 ——
+// autoVacuumEnabled 变回 false 导致自动 VACUUM 首轮之后不再重排,
+// autoCommitDelay 被重置成 0 导致防抖自动提交静默失效。
+func (db *DB) adoptEngineState(other *DB) {
+	db.file = other.file
+	db.header = other.header
+	db.tables = other.tables
+	db.currentTxn.Store(nil)
+	db.pendingFree = nil
+	db.pendingFreeChain = nil
+	db.freePage = other.freePage
+	db.freeChain = other.freeChain
+	// other 的表树在 Open 时把内部 db 指针绑到了 other (bindDB 只补 nil, 不会改绑),
+	// 必须强制改绑到本句柄, 否则后续表操作会读到 other 的幽灵状态
+	// (独立的锁、永远为 nil 的事务指针)。
+	db.tables.db = db
+	for _, tbl := range db.tables.tables {
+		tbl.db = db
+	}
+	// 运行期回调与句柄闭包重绑到本句柄: other 的 onPageWrite / autoCommit /
+	// handle 闭包捕获的是 other —— 不重绑的话, VACUUM 之后开事务,
+	// markTouched 查的是 other.currentTxn (恒 nil), Rollback 静默失效;
+	// 新页登记到 other.allocated (回滚后成孤儿页); 延迟回收进 other.pendingFree
+	// (永不 drain, 页永久泄漏)。
+	db.wireRuntime()
+}
+
 // isRowStoreType 索引是否为行存储索引 (主键 / 时序)
 //
 // 行存储索引在 step 5a 已由 VACUUM 自己手动建好, 重建二级索引时跳过, 避免重复。
 func isRowStoreType(typ page.IndexType) bool {
 	return typ == page.IndexTypePrimary || typ == page.IndexTypeTime
-}
-
-// 编译期检查 (用到的 import 别漏)
-var (
-	_ = errors.New
-	_ sync.Mutex
-)
+}

+ 240 - 0
DB/core/vacuum_identity_test.go

@@ -0,0 +1,240 @@
+package core
+
+import (
+	"testing"
+
+	"git.ycl.pub/yl32940/TsDB2/DB/core/page"
+	"git.ycl.pub/yl32940/TsDB2/DB/core/types"
+)
+
+// TestVACUUMPreservesRowStoreIdentity 行存储索引必须按原始名字/类型重建:
+// 旧实现无条件建成 ("pk", Primary) —— ORM 按 pk_<表名> 寻址, VACUUM 后
+// Find/Delete/Update 全部 ErrFieldNotFound; 时序表被静默改成主键表。
+func TestVACUUMPreservesRowStoreIdentity(t *testing.T) {
+	db := fixOpenDB(t)
+
+	// 主键表: 行存储索引用 ORM 风格命名 pk_people
+	tbl, err := db.CreateTable("people", []types.FieldInfo{
+		{FieldType: types.FieldType_Uint64, FieldName: []byte("id")},
+		{FieldType: types.FieldType_Int64, FieldName: []byte("age")},
+	})
+	if err != nil {
+		t.Fatalf("建表: %v", err)
+	}
+	if _, err := tbl.CreateIndex("pk_people", page.IndexTypePrimary, []byte{0}); err != nil {
+		t.Fatalf("建主键索引: %v", err)
+	}
+	for i := 1; i <= 50; i++ {
+		if _, err := tbl.InsertRowWithID(uint64(i), []types.FieldValue{
+			{FieldName: []byte("id"), Value: uint64(i)},
+			{FieldName: []byte("age"), Value: int64(i * 2)},
+		}); err != nil {
+			t.Fatalf("插入 %d: %v", i, err)
+		}
+	}
+	if err := db.Commit(); err != nil {
+		t.Fatalf("commit: %v", err)
+	}
+
+	if _, err := db.VACUUM(); err != nil {
+		t.Fatalf("VACUUM: %v", err)
+	}
+
+	// 行存储索引名字/类型必须原样保留
+	ntbl, ok := db.FindTable("people")
+	if !ok {
+		t.Fatalf("VACUUM 后表丢失")
+	}
+	m, h, ok := ntbl.IndexMeta().rowStoreMeta()
+	if !ok {
+		t.Fatalf("VACUUM 后行存储索引丢失")
+	}
+	if m.Name() != "pk_people" {
+		t.Fatalf("行存储索引名 = %q, 想要 pk_people", m.Name())
+	}
+	if m.IndexType != page.IndexTypePrimary {
+		t.Fatalf("行存储索引类型 = %v, 想要 Primary", m.IndexType)
+	}
+	_ = h
+
+	// 按原索引名寻址 (ORM 的路径) 必须可用
+	row, found := ntbl.IndexMeta().SearchByIndex("pk_people", func() []byte {
+		k := make([]byte, 8)
+		types.EncodeValue(types.FieldType_Uint64, 0, uint64(7), k)
+		return k
+	}())
+	if !found || len(row) < 8 {
+		t.Fatalf("VACUUM 后按 pk_people 查不到 id=7")
+	}
+	if got := row[8:][1:][0]; got != 0 { // rowData[0]=schemaVer
+		_ = got
+	}
+	// 数据完整性
+	if c := vacCountOf(t, ntbl); c != 50 {
+		t.Fatalf("VACUUM 后行数 = %d, 想要 50", c)
+	}
+}
+
+// TestVACUUMPreservesNULL VACUUM 不得把 NULL 物化成零值: 快照保 NULL,
+// 重建后 NULL 位图仍在, IS-NULL 语义不变。
+func TestVACUUMPreservesNULL(t *testing.T) {
+	db := fixOpenDB(t)
+	tbl, err := db.CreateTable("t", []types.FieldInfo{
+		{FieldType: types.FieldType_Uint64, FieldName: []byte("id")},
+		{FieldType: types.FieldType_Int64, FieldName: []byte("v"), Flag: types.FlagNullable},
+	})
+	if err != nil {
+		t.Fatalf("建表: %v", err)
+	}
+	if _, err := tbl.CreateIndex("pk", page.IndexTypePrimary, []byte{0}); err != nil {
+		t.Fatalf("建主键索引: %v", err)
+	}
+	// 3 行: NULL / 有值 / NULL
+	for i := 1; i <= 3; i++ {
+		vals := []types.FieldValue{
+			{FieldName: []byte("id"), Value: uint64(i)},
+		}
+		if i == 2 {
+			vals = append(vals, types.FieldValue{FieldName: []byte("v"), Value: int64(99)})
+		} else {
+			vals = append(vals, types.FieldValue{FieldName: []byte("v"), Value: nil})
+		}
+		if _, err := tbl.InsertRowWithID(uint64(i), vals); err != nil {
+			t.Fatalf("插入 %d: %v", i, err)
+		}
+	}
+	if err := db.Commit(); err != nil {
+		t.Fatalf("commit: %v", err)
+	}
+	if _, err := db.VACUUM(); err != nil {
+		t.Fatalf("VACUUM: %v", err)
+	}
+
+	ntbl, _ := db.FindTable("t")
+	// id=1 的 v 必须仍是 NULL (查不到值), id=2 是 99
+	r1, _ := ntbl.FindByPK(1)
+	r2, _ := ntbl.FindByPK(2)
+	if hasColVal(r1, "v") {
+		t.Fatalf("id=1 的 v 应为 NULL, 得到 %+v", r1)
+	}
+	if !hasColVal(r2, "v") {
+		t.Fatalf("id=2 的 v 应为 99, 得到 %+v", r2)
+	}
+	if c := vacCountOf(t, ntbl); c != 3 {
+		t.Fatalf("行数 = %d, 想要 3", c)
+	}
+}
+
+// TestVACUUMDroppedColumnTable "删过列、且被索引列在被删列之后"的表,
+// VACUUM 必须成功 (FieldIDs 重映射), 且索引仍可用。
+func TestVACUUMDroppedColumnTable(t *testing.T) {
+	db := fixOpenDB(t)
+	tbl, err := db.CreateTable("t", []types.FieldInfo{
+		{FieldType: types.FieldType_Uint64, FieldName: []byte("id")},
+		{FieldType: types.FieldType_Int64, FieldName: []byte("dead")},
+		{FieldType: types.FieldType_Int64, FieldName: []byte("age")},
+	})
+	if err != nil {
+		t.Fatalf("建表: %v", err)
+	}
+	if _, err := tbl.CreateIndex("pk", page.IndexTypePrimary, []byte{0}); err != nil {
+		t.Fatalf("建主键索引: %v", err)
+	}
+	if _, err := tbl.CreateIndex("idx_age", page.IndexTypeBTree, []byte{2}); err != nil {
+		t.Fatalf("建 idx_age: %v", err)
+	}
+	for i := 1; i <= 30; i++ {
+		if _, err := tbl.InsertRowWithID(uint64(i), []types.FieldValue{
+			{FieldName: []byte("id"), Value: uint64(i)},
+			{FieldName: []byte("dead"), Value: int64(i)},
+			{FieldName: []byte("age"), Value: int64(i % 40)},
+		}); err != nil {
+			t.Fatalf("插入 %d: %v", i, err)
+		}
+	}
+	if err := tbl.DropField("dead"); err != nil {
+		t.Fatalf("DropField: %v", err)
+	}
+	if err := db.Commit(); err != nil {
+		t.Fatalf("commit: %v", err)
+	}
+
+	if _, err := db.VACUUM(); err != nil {
+		t.Fatalf("删过列的表 VACUUM 失败: %v", err)
+	}
+
+	ntbl, _ := db.FindTable("t")
+	if c := vacCountOf(t, ntbl); c != 30 {
+		t.Fatalf("VACUUM 后行数 = %d, 想要 30", c)
+	}
+	// 重建的 idx_age 必须仍按新下标工作: age=5 → id ∈ {5, 45} 中 45>30 即仅 id=5
+	n, err := ntbl.Query().Where(Eq("age", int64(5), 0)).Count()
+	if err != nil || n != 1 {
+		t.Fatalf("VACUUM 后 idx_age 等值 count=%d err=%v, 想要 1", n, err)
+	}
+}
+
+// TestVACUUMTimeSeriesKeepsTimeIndex 时序表 VACUUM 后仍是时序索引,
+// 时间范围查询正常。
+func TestVACUUMTimeSeriesKeepsTimeIndex(t *testing.T) {
+	db := fixOpenDB(t)
+	tbl, err := db.CreateTable("tick", []types.FieldInfo{
+		{FieldType: types.FieldType_Uint64, FieldName: []byte("ts")},
+		{FieldType: types.FieldType_Float64, FieldName: []byte("price")},
+	})
+	if err != nil {
+		t.Fatalf("建表: %v", err)
+	}
+	if _, err := tbl.CreateIndex("ts_tick", page.IndexTypeTime, []byte{0}); err != nil {
+		t.Fatalf("建时序索引: %v", err)
+	}
+	for i := 1; i <= 100; i++ {
+		if _, err := tbl.InsertRowWithID(uint64(i), []types.FieldValue{
+			{FieldName: []byte("ts"), Value: uint64(i * 10)},
+			{FieldName: []byte("price"), Value: float64(i) + 0.5},
+		}); err != nil {
+			t.Fatalf("插入 %d: %v", i, err)
+		}
+	}
+	if err := db.Commit(); err != nil {
+		t.Fatalf("commit: %v", err)
+	}
+	if _, err := db.VACUUM(); err != nil {
+		t.Fatalf("VACUUM: %v", err)
+	}
+
+	ntbl, _ := db.FindTable("tick")
+	m, _, ok := ntbl.IndexMeta().rowStoreMeta()
+	if !ok || m.IndexType != page.IndexTypeTime {
+		t.Fatalf("VACUUM 后行存储索引类型 = %+v, 想要 Time", m)
+	}
+	if m.Name() != "ts_tick" {
+		t.Fatalf("VACUUM 后时序索引名 = %q, 想要 ts_tick", m.Name())
+	}
+	// 时间范围查询
+	n, err := ntbl.Query().Where(And(
+		Ge("ts", uint64(300), 0), Le("ts", uint64(500), 0))).Count()
+	if err != nil || n != 21 {
+		t.Fatalf("时序范围 count=%d err=%v, 想要 21 (300..500 步进 10)", n, err)
+	}
+}
+
+// vacCountOf 拿表行数 (测试辅助)。
+func vacCountOf(t *testing.T, tbl *Table) int64 {
+	t.Helper()
+	c, err := tbl.Query().Count()
+	if err != nil {
+		t.Fatalf("count: %v", err)
+	}
+	return c
+}
+
+// hasColVal 行字段值里是否有指定列 (NULL 列不在其中)。
+func hasColVal(vals []types.FieldValue, name string) bool {
+	for _, fv := range vals {
+		if string(fv.FieldName) == name {
+			return true
+		}
+	}
+	return false
+}

+ 125 - 6
DB/core/vacuum_test.go

@@ -4,6 +4,7 @@ import (
 	"fmt"
 	"os"
 	"path/filepath"
+	"sync"
 	"testing"
 	"time"
 
@@ -316,12 +317,20 @@ func TestAutoVACUUM(t *testing.T) {
 	// 启用自动 VACUUM: 间隔 50ms (测试用短间隔)
 	db.SetAutoVACUUM(50 * time.Millisecond)
 
-	// 等 250ms 让定时器触发几次
-	time.Sleep(250 * time.Millisecond)
+	// 轮询等首次自动 VACUUM 完成 (固定 sleep 在 -race 下必然脆弱)
+	before := db.LastVACUUM()
+	deadline := time.Now().Add(2 * time.Second)
+	for time.Now().Before(deadline) && !db.LastVACUUM().After(before) {
+		time.Sleep(10 * time.Millisecond)
+	}
 
-	// 期间加点数据, 用不同的 pkID 范围 (避免与初始 1..50 冲突)
+	// 期间加点数据。自动 VACUUM 可能已换过引擎状态, 必须用【当前句柄】取表。
+	tbl2, ok := db.FindTable("t")
+	if !ok {
+		t.Fatalf("自动 VACUUM 后表丢失")
+	}
 	for i := 100; i < 110; i++ {
-		if _, err := tbl.InsertRowWithID(uint64(i), []types.FieldValue{
+		if _, err := tbl2.InsertRowWithID(uint64(i), []types.FieldValue{
 			{FieldName: []byte("id"), Value: uint64(i)},
 			{FieldName: []byte("v"), Value: uint64(i * 10)},
 		}); err != nil {
@@ -333,14 +342,124 @@ func TestAutoVACUUM(t *testing.T) {
 	// 关闭自动 VACUUM
 	db.SetAutoVACUUM(0)
 
-	// 验证表还在 (至少有 50 行; 自动 VACUUM 后 pkID 1..50 应当保留, 加新的 100..109)
-	cnt, _ := tbl.Query().Count()
+	// 验证表还在 (初始 50 行 + 新插 10 行; 用当前句柄取表再数)
+	tbl3, ok := db.FindTable("t")
+	if !ok {
+		t.Fatalf("取表失败")
+	}
+	cnt, _ := tbl3.Query().Count()
 	if cnt < 50 {
 		t.Errorf("行数应不少于 50, 得到 %d", cnt)
 	}
 	t.Logf("LastVACUUM: %v (当前: %v)", db.LastVACUUM(), time.Now())
 }
 
+// TestAutoVACUUMRepeats 回归测试: 自动 VACUUM 必须周期重排, 而不是首轮之后静默停摆。
+//
+// 旧实现 VACUUM 内部 `*db = *newDB2` 整体结构体拷贝, 把 autoVacuumEnabled 覆盖成
+// false —— 首轮跑完后调度器读到 stillEnabled=false 不再重排定时器, 自动 VACUUM
+// 实际只生效一次 (且该整体拷贝因复制互斥锁被 go vet copylocks 报警)。
+func TestAutoVACUUMRepeats(t *testing.T) {
+	dir := t.TempDir()
+	path := filepath.Join(dir, "test.db")
+
+	db, err := Open(path)
+	if err != nil {
+		t.Fatalf("open: %v", err)
+	}
+	db.SetAutoCommitDelay(0)
+	t.Cleanup(func() {
+		db.SetAutoVACUUM(0)
+		_ = db.Close()
+	})
+
+	tbl := vacNewTable(t, db, "t")
+	vacInsert(t, tbl, 20)
+	if err := db.Commit(); err != nil {
+		t.Fatalf("commit: %v", err)
+	}
+
+	var mu sync.Mutex
+	dones := 0
+	db.SetAutoVACUUM(40*time.Millisecond, VACUUMOptions{
+		Progress: func(phase string, _, _ int64) {
+			if phase == "done" {
+				mu.Lock()
+				dones++
+				mu.Unlock()
+			}
+		},
+	})
+
+	deadline := time.Now().Add(600 * time.Millisecond)
+	for time.Now().Before(deadline) {
+		time.Sleep(20 * time.Millisecond)
+		mu.Lock()
+		n := dones
+		mu.Unlock()
+		if n >= 2 {
+			break
+		}
+	}
+	db.SetAutoVACUUM(0)
+
+	mu.Lock()
+	defer mu.Unlock()
+	if dones < 2 {
+		t.Fatalf("自动 VACUUM 应周期重排, 600ms 内只触发了 %d 次", dones)
+	}
+	t.Logf("600ms 内自动 VACUUM 触发 %d 次", dones)
+}
+
+// TestVACUUMPreservesRuntimeConfig 回归测试: VACUUM 更换引擎状态后,
+// 用户配置 (autoCommitDelay) 必须保留, 句柄必须继续可用。
+//
+// 旧实现整体结构体拷贝会把 autoCommitDelay 重置为默认值 —— 用户设的手动提交模式
+// (0 = 禁用防抖) 被静默改回 1s 防抖。
+func TestVACUUMPreservesRuntimeConfig(t *testing.T) {
+	dir := t.TempDir()
+	path := filepath.Join(dir, "test.db")
+
+	db, err := Open(path)
+	if err != nil {
+		t.Fatalf("open: %v", err)
+	}
+	defer db.Close()
+
+	db.SetAutoCommitDelay(0) // 手动提交模式
+	tbl := vacNewTable(t, db, "t")
+	vacInsert(t, tbl, 10)
+	if err := db.Commit(); err != nil {
+		t.Fatalf("commit: %v", err)
+	}
+
+	if _, err := db.VACUUM(); err != nil {
+		t.Fatalf("VACUUM: %v", err)
+	}
+
+	// 1) 运行时配置保留: 手动提交模式不被静默改回防抖模式
+	if db.autoCommitDelay != 0 {
+		t.Fatalf("VACUUM 后 autoCommitDelay 应保留为 0, 得到 %v", db.autoCommitDelay)
+	}
+
+	// 2) 句柄可用: VACUUM 之后建新表、插入、查询
+	tbl2 := vacNewTable(t, db, "t2")
+	for i := 0; i < 5; i++ {
+		if _, err := tbl2.InsertRowWithID(uint64(i+1), []types.FieldValue{
+			{FieldName: []byte("id"), Value: uint64(i + 1)},
+			{FieldName: []byte("v"), Value: uint64(i * 100)},
+		}); err != nil {
+			t.Fatalf("VACUUM 后插入第 %d 行: %v", i+1, err)
+		}
+	}
+	if err := db.Commit(); err != nil {
+		t.Fatalf("VACUUM 后 commit: %v", err)
+	}
+	if got := vacCount(t, tbl2); got != 5 {
+		t.Fatalf("t2 应有 5 行, 得到 %d", got)
+	}
+}
+
 // ============================================================================
 // 辅助
 // ============================================================================

+ 79 - 68
DB/core/wal.go

@@ -74,9 +74,12 @@ type RowEntry struct {
 type WALWriter struct {
 	txnID   uint64
 	entries []walEntry
-	file    *File
-	header  *HeaderPage
-	state   byte // 当前 WAL 状态 (Mem/Written/Persisted/Committed)
+	// pageIndex: pageID → entries 下标。RecordPage 的"同页只留最新镜像"去重用,
+	// 免掉每次提交 O(条目数²) 的线性扫 (默认阈值 500 页/提交时是 12.5 万次比较)。
+	pageIndex map[uint32]int
+	file      *File
+	header    *HeaderPage
+	state     byte // 当前 WAL 状态 (Mem/Written/Persisted/Committed)
 }
 
 type walEntry struct {
@@ -96,25 +99,26 @@ func (db *DB) BeginTxn() *WALWriter {
 		db.header.NextTxnID++
 	})
 	return &WALWriter{
-		txnID:   txnID,
-		entries: make([]walEntry, 0, 16),
-		file:    db.file,
-		header:  db.header,
-		state:   WALStateMem, // 初始状态: Mem (内存中)
+		txnID:     txnID,
+		entries:   make([]walEntry, 0, 16),
+		pageIndex: make(map[uint32]int, 16),
+		file:      db.file,
+		header:    db.header,
+		state:     WALStateMem, // 初始状态: Mem (内存中)
 	}
 }
 
-// RecordPage 记录一个被修改的页 (同一 pageID 只保留最新镜像)
+// RecordPage 记录一个被修改的页 (同一 pageID 只保留最新镜像)。
+// 去重走 pageIndex 映射, O(1); 旧实现对每条 entry 线性扫全部 entries,
+// 一次 500 页的提交就是 12.5 万次比较。
 func (w *WALWriter) RecordPage(pageID uint32, data []byte) {
 	d := make([]byte, len(data))
 	copy(d, data)
-	// 同一 pageID 只保留最新镜像
-	for i := range w.entries {
-		if w.entries[i].pageID == pageID {
-			w.entries[i].data = d
-			return
-		}
+	if i, ok := w.pageIndex[pageID]; ok {
+		w.entries[i].data = d
+		return
 	}
+	w.pageIndex[pageID] = len(w.entries)
 	w.entries = append(w.entries, walEntry{
 		pageID:     pageID,
 		data:       d,
@@ -122,35 +126,18 @@ func (w *WALWriter) RecordPage(pageID uint32, data []byte) {
 	})
 }
 
-// RecordRowChange 记录行级变更 (带 OpType, 用于崩溃恢复)
-// pageID: 目标 DataPage ID
-// opType: WALOpInsert / WALOpDelete / WALOpUpdate
-// storeRow: INSERT/UPDATE = [pkID(8B BE) + rowData], DELETE = pkID(8B BE)
-func (w *WALWriter) RecordRowChange(pageID uint32, opType byte, storeRow []byte) {
-	data := make([]byte, len(storeRow))
-	copy(data, storeRow)
-	w.entries = append(w.entries, walEntry{
-		pageID:     pageID,
-		isRowLevel: true,
-		opType:     opType,
-		rowEntry: &RowEntry{
-			PageID:   pageID,
-			OpType:   opType,
-			StoreRow: data,
-		},
-	})
-}
-
 // Rollback 回滚: 丢弃所有条目
 func (w *WALWriter) Rollback() {
 	w.entries = nil
+	w.pageIndex = nil
 	w.state = WALStateMem // 重置状态
 }
 
 // Commit 原子提交流程:
 //  1. 将 entries 写入 WAL 页链 (State: Mem → Written)
-//  2. fsync WAL 页 (State: Written → Persisted)
-//  3. 标记 WAL 首页 Committed = 1 并 fsync (State: Persisted → Committed)
+//  2. 标记 WAL 首页 Committed = 1 (State → Committed)
+//  3. fsync 一次: WAL 页与提交标记随这次 fsync 一起落盘
+//     (排序要求的是"标记 durable ⇒ 数据已 durable", 同一次 fsync 前的顺序写出即满足)
 //  4. 回写实际页 (redo; 整页镜像, 重复执行幂等)
 //  5. 回收所有 WAL 页 + 清空 Header 链指针 + fsync
 //
@@ -166,27 +153,25 @@ func (w *WALWriter) Commit() error {
 		return nil
 	}
 
-	// === 步骤 1: 写入 WAL 页链 (State: Mem → Written) ===
-	walPageIDs, err := w.writeWALPages()
+	// === 步骤 1+2+3: 写 WAL 页链 → 标记首页已提交 → 一次 fsync ===
+	// WAL 数据与提交标记在同一次 fsync 前依次写出: fsync 保证"标记 durable ⇒
+	// 数据先于它 durable"的顺序不变 (排序要求的是先后持久, 不是两次 fsync)。
+	// 旧实现这里分两次 fsync (写完数据一次、标记后再一次), 每次提交白付一轮 fsync 延迟。
+	walPageIDs, firstBuf, err := w.writeWALPages()
 	if err != nil {
 		return err
 	}
 
-	// === 步骤 2: fsync WAL 页 (State: Written → Persisted) ===
-	if err := w.file.sync(); err != nil {
-		return err
-	}
-	// 状态转换: Written → Persisted (已落盘)
-	w.state = WALStatePersisted
-
-	// === 步骤 3: 标记 WAL 首页为已提交, 并 fsync (State: Persisted → Committed) ===
+	// === 步骤 3: 标记 WAL 首页为已提交 (State: Written → Committed) ===
 	// 这一步之后, 无论发生什么崩溃, 恢复都能重放整笔事务 (WAL 已完整且被标记)。
-	if err := w.markCommitted(walPageIDs[0]); err != nil {
+	if err := w.markCommitted(walPageIDs[0], firstBuf); err != nil {
 		return err
 	}
+	// 状态转换: Written → Persisted → Committed (随下面这次 fsync 落盘)
 	if err := w.file.sync(); err != nil {
 		return err
 	}
+	w.state = WALStateCommitted
 
 	// === 步骤 4: 回写实际页 (直接写盘, 绕过 BufferPool) ===
 	// 放在提交标记之后: 中途断电可由恢复阶段重做 (整页镜像, 幂等)。
@@ -205,6 +190,13 @@ func (w *WALWriter) Commit() error {
 		}
 	}
 
+	// 步骤 4.5: 回写后立刻 fsync, 钉死"数据页先于头页(步骤 5 清 WAL 指针)落盘"。
+	// 没有这一次 fsync, OS 后台回刷可能把步骤 5 的头页先刷下去、数据页留在缓存 —
+	// 断电后头页说"没有 WAL 可重放", 数据页却是旧值, 已提交数据静默丢失。
+	if err := w.file.sync(); err != nil {
+		return err
+	}
+
 	// === 步骤 5: 回收整条 WAL 链 (O(1) 拼接, 与链长无关) ===
 	// 链内每页的 next 都写在 PageHdr.NextPage, 与空闲链同字段, 所以整条链
 	// head…tail 可以直接挂到空闲链尾, 不必逐页遍历回收。
@@ -226,17 +218,23 @@ func (w *WALWriter) Commit() error {
 	if recycleErr != nil {
 		return recycleErr
 	}
-	// 收尾必须 fsync: 否则断电后磁盘上的 Header 仍指向已提交的 WAL,
-	// 下次打开会重复重放 (行级记录场景还会造成重复插入)
+	// 收尾必须 fsync, 它钉死一个关键的持久化顺序: "忘掉日志"(头页: WAL 链回收
+	// + 指针清零) 不得先于"数据就位"(步骤 4 回写的真实数据页) 落盘。
+	// 没有这次 fsync, 断电时 OS 可能只把头页刷下去、数据页留在缓存里 —— 重开时
+	// 头页说"没有 WAL", 已提交的数据却是旧内容, 且 WAL 页已被标成空闲页即将被
+	// 覆盖, 连重放救回的机会都没有 (静默丢已提交数据)。
+	// 顺带的效果: 头页若仍指着已提交的 WAL, 下次打开会重复重放 —— 整页镜像幂等
+	// 无害, 行级记录则会重复插入。
 	if err := w.file.sync(); err != nil {
 		return err
 	}
 
 	w.entries = nil
+	w.pageIndex = nil
 	return nil
 }
 
-// writeWALPages 将所有 entry 紧凑写入 WAL 页链, 返回分配的页 ID 列表
+// writeWALPages 将所有 entry 紧凑写入 WAL 页链, 返回 (分配的页 ID 列表, 首页缓冲区)。
 // 状态转换: Mem → Written
 //
 // 打包策略: 一条 Entry 占 [13B 元数据 + DataLen]。当前页剩余空间够就继续追加,
@@ -244,8 +242,9 @@ func (w *WALWriter) Commit() error {
 //
 // 所有 WAL 页头部等长 (page.WALPageHdrSize); walEntryPayload 已把单条记录截断到
 // 「一定能放进空页」的长度, 因此开新页后必定写得下, 不会出现空页仍放不下的情况。
-func (w *WALWriter) writeWALPages() ([]uint32, error) {
+func (w *WALWriter) writeWALPages() ([]uint32, []byte, error) {
 	var pageIDs []uint32
+	var firstBuf []byte // 首页缓冲区: markCommitted 直接改它打提交标记, 免一次整页读回
 
 	// 当前正在填充的 WAL 页
 	var curBuf []byte
@@ -253,6 +252,7 @@ func (w *WALWriter) writeWALPages() ([]uint32, error) {
 	var curIsFirst bool
 	var curOff uint32     // 已用字节数 (从页头之后开始)
 	var curEntries uint16 // 已写入的条目数
+	var opTypeBuf [1]byte // CRC 的 opType 段缓冲 (分段 Update 免每条一次临时分配)
 
 	// flushCurrent 定型当前页 (回填 UseSize / EntryCount) 并落盘。
 	// 链指针不在这里填 —— 只有"开新页"时才知道下一页是谁, 由调用处先回填再 flush。
@@ -287,12 +287,18 @@ func (w *WALWriter) writeWALPages() ([]uint32, error) {
 			if curBuf != nil {
 				page.HdrOf(curBuf).NextPage = pid
 				if err := flushCurrent(); err != nil {
-					return nil, err
+					return nil, nil, err
 				}
 			}
 
 			pageIDs = append(pageIDs, pid)
 			curBuf = make([]byte, PageSize)
+			if len(pageIDs) == 1 {
+				// 首页缓冲留一份给 markCommitted: 它的内容在"开第二页回填链指针 +
+				// flush"后就定稿了, 之后不再被本函数改动 —— 提交标记直接改这份内存
+				// 缓冲写回, 免一次"整页读回只为改 1 字节 State"。
+				firstBuf = curBuf
+			}
 			curPID = pid
 			curIsFirst = len(pageIDs) == 1
 			curEntries = 0
@@ -317,13 +323,15 @@ func (w *WALWriter) writeWALPages() ([]uint32, error) {
 		// 追加当前 entry
 		// 统一格式: [TargetPageID(4B)][DataLen(4B)][Checksum(4B)][OpType(1B)][Data]
 		// CRC32 覆盖 OpType + Data, 整页/行级一致, 类型显式存储不再靠首字节猜测。
-		csBuf := make([]byte, 1+dataLen)
-		csBuf[0] = opType
-		copy(csBuf[1:], payload)
+		// crc32.Update 分段计算与"拼成一条再 ChecksumIEEE"完全等价, 省掉每条一次
+		// 1+DataLen 的临时缓冲分配。
+		opTypeBuf[0] = opType
+		crc := crc32.Update(0, crc32.IEEETable, opTypeBuf[:])
+		crc = crc32.Update(crc, crc32.IEEETable, payload)
 
 		binary.LittleEndian.PutUint32(curBuf[curOff:], targetPID)
 		binary.LittleEndian.PutUint32(curBuf[curOff+4:], dataLen)
-		binary.LittleEndian.PutUint32(curBuf[curOff+8:], crc32.ChecksumIEEE(csBuf))
+		binary.LittleEndian.PutUint32(curBuf[curOff+8:], crc)
 		curBuf[curOff+12] = opType
 		curOff += page.WALEntryMeta
 		copy(curBuf[curOff:], payload)
@@ -333,7 +341,7 @@ func (w *WALWriter) writeWALPages() ([]uint32, error) {
 
 	// 定型最后一页
 	if err := flushCurrent(); err != nil {
-		return nil, err
+		return nil, nil, err
 	}
 
 	// 更新 HeaderPage 的 WAL 链指针
@@ -352,7 +360,7 @@ func (w *WALWriter) writeWALPages() ([]uint32, error) {
 	// 状态转换: Mem → Written (已写盘,未 fsync)
 	w.state = WALStateWritten
 
-	return pageIDs, nil
+	return pageIDs, firstBuf, nil
 }
 
 // walEntryPayload 解析一条 entry 的目标页 / OpType / 载荷, 并把载荷截断到
@@ -398,19 +406,22 @@ func (w *WALWriter) walEntryPayload(e walEntry) (byte, uint32, []byte) {
 	return opType, targetPID, payload
 }
 
-// markCommitted 标记 WAL 首页为已提交 (State: Persisted → Committed)
-func (w *WALWriter) markCommitted(firstPageID uint32) error {
-	buf := make([]byte, PageSize)
-	// 必须直接读盘: WAL 页是用 WritePageDirect 写的, 走 BufferPool 可能读到陈旧缓存
-	if err := w.file.readPageDisk(firstPageID, buf); err != nil {
-		return err
+// markCommitted 标记 WAL 首页为已提交 (State → Committed)。
+// firstBuf 是 writeWALPages 手里刚写盘的首页内存缓冲 (内容与盘上逐字节一致),
+// 直接改它的 State 字节整页写回 —— 旧实现为此整页读回 16KB, 只为改 1 字节。
+func (w *WALWriter) markCommitted(firstPageID uint32, firstBuf []byte) error {
+	if len(firstBuf) != int(PageSize) {
+		// 防御: 缓冲缺失/长度不符时退回读盘路径 (正常不会发生)
+		firstBuf = make([]byte, PageSize)
+		// 必须直接读盘: WAL 页是用 WritePageDirect 写的, 走 BufferPool 可能读到陈旧缓存
+		if err := w.file.readPageDisk(firstPageID, firstBuf); err != nil {
+			return err
+		}
 	}
-	page.WalHdrOf(buf).State = WALStateCommitted // State = Committed
-	if err := w.file.WritePageDirect(firstPageID, buf); err != nil {
+	page.WalHdrOf(firstBuf).State = WALStateCommitted // State = Committed
+	if err := w.file.WritePageDirect(firstPageID, firstBuf); err != nil {
 		return err
 	}
-	// 状态转换: Persisted → Committed
-	w.state = WALStateCommitted
 	return nil
 }
 

+ 11 - 0
DB/core/wal_recovery.go

@@ -3,6 +3,7 @@ package core
 import (
 	"git.ycl.pub/yl32940/TsDB2/DB/core/page"
 	"encoding/binary"
+	"fmt"
 	"hash/crc32"
 )
 
@@ -50,6 +51,7 @@ func RecoverWAL(ff *File, header *HeaderPage) error {
 	}
 	var entries []recoveryEntry
 
+	var crcFailed bool // 链上任意一条条目 CRC 损坏即置位
 	pid := header.FirstWALPage
 	remaining := int(totalEntries)
 	walTail := uint32(0) // 已遍历到的最后一页 (链正常读完时即真实链尾)
@@ -93,6 +95,7 @@ func RecoverWAL(ff *File, header *HeaderPage) error {
 			copy(csBuf[1:], payload)
 			if crc32.ChecksumIEEE(csBuf) != checksum {
 				// 校验失败: 后续内容不可信, 停止收集
+				crcFailed = true
 				remaining = 0
 				break
 			}
@@ -120,6 +123,14 @@ func RecoverWAL(ff *File, header *HeaderPage) error {
 		pid = readNextWalPage(buf)
 	}
 
+	// 已提交链出现 CRC 损坏: 不能静默部分重放 (撕裂提交) 或回收整链 (销毁
+	// 唯一完好的副本) —— 报告错误让 Open 失败, WAL 链原样保留, 由用户决定
+	// 修复方式 (从备份恢复 / 手工截断 WAL 链放弃这笔事务)。
+	if crcFailed && committed == WALStateCommitted {
+		return fmt.Errorf("WAL 恢复: 已提交事务 (TxnID=%d) 的条目 CRC 校验失败, "+
+			"拒绝部分重放或回收 —— WAL 链已原样保留, 请修复后重试", header.NextTxnID)
+	}
+
 	// 根据 Committed 标记决定是否重放
 	if committed == WALStateCommitted && len(entries) > 0 {
 		// Phase 1: 先处理整页条目 (恢复页结构: B+树节点, IndexMeta, HeaderPage 等)

+ 6 - 1
DB/db.go

@@ -381,7 +381,12 @@ func syncSchema(coreTbl *core.Table, model *ModelInfo) error {
 	// 2. DB 有但 struct 没有 → 删除列 (dbCols 已排除主键列)
 	for _, name := range dbCols {
 		if _, ok := structCols[name]; !ok {
-			coreTbl.DropField(name)
+			// 错误必须上抛: 静默继续会留下"库里有、struct 没有"的列,
+			// 后续 UseTable 反复尝试删除, 库与模型的不一致也无从排查。
+			// (主键列等删不掉的场景由 DropField 守卫转为明确错误。)
+			if err := coreTbl.DropField(name); err != nil {
+				return fmt.Errorf("同步 schema 删除列 %q: %w", name, err)
+			}
 		}
 	}
 	return nil

+ 83 - 0
DB/error_propagation_test.go

@@ -0,0 +1,83 @@
+package DB
+
+import (
+	"path/filepath"
+	"strings"
+	"testing"
+
+	"git.ycl.pub/yl32940/TsDB2/DB/core/types"
+)
+
+// TestQueryPropagatesRowConversionError 回归测试: 某行反序列化失败时,
+// FindAll / Select / Query.Scan 必须返回错误, 而不是静默跳过该行、
+// 返回"看起来成功"的部分结果。
+//
+// 触发方式: 用 core 层直接写入一行指向不存在链的 blob 引用
+// (FirstPageID=0 且 TotalLen>0 → 读链必失败), 再经 ORM 全表扫描读到它。
+func TestQueryPropagatesRowConversionError(t *testing.T) {
+	type badBlob struct {
+		BaseModel
+		Data []byte `vrtx:"type:blob"`
+	}
+
+	db, err := Open(filepath.Join(t.TempDir(), "test.db"))
+	if err != nil {
+		t.Fatalf("open: %v", err)
+	}
+	defer db.Close()
+
+	tbl, err := UseTable[badBlob](db, "badblob")
+	if err != nil {
+		t.Fatalf("use table: %v", err)
+	}
+
+	// 一行正常数据
+	if err := tbl.Insert(&badBlob{Data: []byte("hello")}); err != nil {
+		t.Fatalf("insert good row: %v", err)
+	}
+
+	// 一行坏数据: 行内 blob ref 指向文件之外的页号 (core 层对 types.BlobRef 原样透传),
+	// 读链必然失败
+	coreTbl, ok := db.CoreDB().FindTable("badblob")
+	if !ok {
+		t.Fatalf("core table not found")
+	}
+	if _, err := coreTbl.InsertRowWithID(999, []types.FieldValue{
+		{FieldName: []byte("Data"), Value: types.BlobRef{TotalLen: 10, FirstPageID: 0x00FFFFFF}},
+	}); err != nil {
+		t.Fatalf("insert bad row: %v", err)
+	}
+	if err := db.Commit(); err != nil {
+		t.Fatalf("commit: %v", err)
+	}
+
+	// FindAll: 旧实现会静默跳过坏行, 只返回 1 行且 err == nil
+	rows, err := tbl.FindAll()
+	if err == nil {
+		t.Fatalf("FindAll 应返回反序列化错误, 却返回了 %d 行 nil error", len(rows))
+	}
+	if !strings.Contains(err.Error(), "blob") {
+		t.Fatalf("错误信息应说明 blob 读取失败, 得到: %v", err)
+	}
+
+	// Select: 同样必须报错而不是跳过
+	selErr := tbl.Select(func(*badBlob) bool { return true })
+	if selErr == nil {
+		t.Fatalf("Select 应返回反序列化错误")
+	}
+
+	// Query.Scan: 同样必须报错而不是跳过
+	scanErr := tbl.Query().Scan(func(*badBlob) bool { return true })
+	if scanErr == nil {
+		t.Fatalf("Scan 应返回反序列化错误")
+	}
+
+	// 坏行之前的正常行不受影响: 主键点查仍应拿到正确内容
+	got, err := tbl.Find(1)
+	if err != nil {
+		t.Fatalf("Find good row: %v", err)
+	}
+	if string(got.Data) != "hello" {
+		t.Fatalf("good row Data = %q, 想要 hello", got.Data)
+	}
+}

+ 42 - 16
DB/query.go

@@ -9,11 +9,12 @@ import (
 
 // Query 泛型查询构建器
 type Query[T any] struct {
-	table  *Table[T]
-	wheres []whereClause
-	orders []orderClause
-	limit  int
-	offset int
+	table      *Table[T]
+	wheres     []whereClause
+	orders     []orderClause
+	selectCols []string
+	limit      int
+	offset     int
 }
 
 type whereClause struct {
@@ -60,6 +61,13 @@ func (q *Query[T]) Offset(n int) *Query[T] {
 	return q
 }
 
+// Select 只取指定列 (Go 字段名)。投影下推到引擎: 未选中的列不做反序列化与
+// 堆页解引用; 读出的对象里未选中的字段保持零值。
+func (q *Query[T]) Select(cols ...string) *Query[T] {
+	q.selectCols = cols
+	return q
+}
+
 // Find 执行查询返回所有结果
 //
 // 用 ScanWithPKID 而不是 Find: 行号不在字段值里, 但它决定了返回的对象能不能被精确
@@ -67,19 +75,25 @@ func (q *Query[T]) Offset(n int) *Query[T] {
 func (q *Query[T]) Find() ([]*T, error) {
 	cq := q.buildCoreQuery()
 	result := make([]*T, 0)
+	var convErr error
 	err := cq.ScanWithPKID(func(pkID uint64, row []types.FieldValue) bool {
-		obj := new(T)
-		if err := fieldValuesToStruct(row, q.table.model, obj, q.table.coreTable); err != nil {
-			return true
+		obj, err := q.table.convertRow(pkID, row)
+		if err != nil {
+			// 反序列化失败: 停止扫描并让 Find 返回错误, 不能静默跳过该行
+			if convErr == nil {
+				convErr = err
+			}
+			return false
 		}
-		setRowIDOf(obj, pkID)
-		q.table.bindModel(obj)
 		result = append(result, obj)
 		return true
 	})
 	if err != nil {
 		return nil, err
 	}
+	if convErr != nil {
+		return nil, convErr
+	}
 	return result, nil
 }
 
@@ -105,15 +119,22 @@ func (q *Query[T]) Count() (int64, error) {
 // Scan 流式遍历
 func (q *Query[T]) Scan(callback func(*T) bool) error {
 	cq := q.buildCoreQuery()
-	return cq.ScanWithPKID(func(pkID uint64, row []types.FieldValue) bool {
-		obj := new(T)
-		if err := fieldValuesToStruct(row, q.table.model, obj, q.table.coreTable); err != nil {
-			return true
+	var convErr error
+	err := cq.ScanWithPKID(func(pkID uint64, row []types.FieldValue) bool {
+		obj, err := q.table.convertRow(pkID, row)
+		if err != nil {
+			// 反序列化失败: 停止扫描并让 Scan 返回错误, 不能静默跳过该行
+			if convErr == nil {
+				convErr = err
+			}
+			return false
 		}
-		setRowIDOf(obj, pkID)
-		q.table.bindModel(obj)
 		return callback(obj)
 	})
+	if err != nil {
+		return err
+	}
+	return convErr
 }
 
 // buildCoreQuery 构建 core 层的 Query
@@ -134,6 +155,11 @@ func (q *Query[T]) buildCoreQuery() *core.Query {
 		cq = cq.OrderBy(o.field, o.desc)
 	}
 
+	// SELECT 投影下推
+	if len(q.selectCols) > 0 {
+		cq = cq.Select(q.selectCols...)
+	}
+
 	// LIMIT / OFFSET
 	if q.limit > 0 {
 		cq = cq.Limit(q.limit)

+ 60 - 0
DB/query_select_test.go

@@ -0,0 +1,60 @@
+package DB
+
+import (
+	"path/filepath"
+	"testing"
+)
+
+// TestQuerySelectProjection ORM 层 Select 投影: 只解码选中列,
+// 未选中字段保持零值; 与 ORDER BY 组合时排序仍正确。
+func TestQuerySelectProjection(t *testing.T) {
+	type Emp struct {
+		BaseModel
+		Name  string `vrtx:"maxlen:32"`
+		Age   int64  `vrtx:"index"`
+		Score int64
+	}
+
+	db, err := Open(filepath.Join(t.TempDir(), "test.db"))
+	if err != nil {
+		t.Fatalf("open: %v", err)
+	}
+	defer db.Close()
+
+	tbl, err := UseTable[Emp](db, "emp")
+	if err != nil {
+		t.Fatalf("use table: %v", err)
+	}
+	for i := 1; i <= 20; i++ {
+		if err := tbl.Insert(&Emp{Name: "n", Age: int64(i), Score: int64(i * 10)}); err != nil {
+			t.Fatalf("insert %d: %v", i, err)
+		}
+	}
+	if err := db.Commit(); err != nil {
+		t.Fatalf("commit: %v", err)
+	}
+
+	// 只选 Age: Name/Score 为零值, Age 正确
+	rows, err := tbl.Query().Select("Age").Where("Age", ">", int64(17)).OrderBy("Age").Find()
+	if err != nil {
+		t.Fatalf("select: %v", err)
+	}
+	if len(rows) != 3 {
+		t.Fatalf("应命中 3 行, 得到 %d", len(rows))
+	}
+	for i, r := range rows {
+		want := int64(18 + i)
+		if r.Age != want || r.Name != "" || r.Score != 0 {
+			t.Fatalf("第 %d 行 = %+v, 想要 Age=%d 其余零值", i, r, want)
+		}
+	}
+
+	// 全列对照: 不限 select 时所有字段都在
+	full, err := tbl.Query().Where("Age", "=", int64(5)).Find()
+	if err != nil {
+		t.Fatalf("full: %v", err)
+	}
+	if len(full) != 1 || full[0].Name != "n" || full[0].Score != 50 {
+		t.Fatalf("全列查询结果 %+v", full[0])
+	}
+}

+ 12 - 0
DB/types.go

@@ -39,6 +39,10 @@ type FieldDef struct {
 	GoPath []int
 	Index  int          // 该字段在 AllFields 中的位置
 	IsPK   bool         // 用户声明的主键字段 (vrtx:"pk" / 显式列名 _id)
+	// ColNameBytes 是 ColName 的 []byte 形态, 建模时预烘焙并与 CoreFields 共享底层数组。
+	// 逐行 structToFieldValues 用它填 FieldValue 的列名, 省掉每行每列一次
+	// string→[]byte 堆分配 (列名在建模后不可变, 天然可复用)。
+	ColNameBytes []byte
 }
 
 // Param 列级类型参数 (Flag 低 5 位): Decimal 读作 scale, FieldType_String 读作宽度码。
@@ -124,6 +128,9 @@ type ModelInfo struct {
 	StructType   reflect.Type      // 结构体类型 (用于反射)
 	PKInStruct   bool              // 主键字段是否在结构体中 (嵌入基类时有)
 	Indexes      []IndexDef        // 从标签解析的二级索引定义
+	// ColIndex 列名 → AllFields 下标, 建模时预建。逐行 fieldValuesToStruct 靠它
+	// 直查字段, 省掉旧实现"每行建一张 colMap + 每列一次 string 分配"。
+	ColIndex map[string]int
 }
 
 // goKindToFieldType Go 类型到数据库字段类型的默认映射
@@ -397,13 +404,18 @@ func (s *modelScanner) modelInfo(t reflect.Type) (*ModelInfo, error) {
 
 	// 构建 CoreFields。Flag 必须一并带过去: Decimal 的列级 scale、可空位与唯一位都存在
 	// 这里, 写入/索引编码都要拿它对齐 (缺失会退化成"按值自带 scale", 破坏同列保序前提)。
+	// 顺带预烘焙 ColNameBytes (与 FieldName 共享数组) 与 ColIndex —— 逐行转换的热路径
+	// (structToFieldValues / fieldValuesToStruct) 直接取用, 零分配。
 	info.CoreFields = make([]types.FieldInfo, len(info.AllFields))
+	info.ColIndex = make(map[string]int, len(info.AllFields))
 	for i, f := range info.AllFields {
 		info.CoreFields[i] = types.FieldInfo{
 			FieldType: f.FieldType,
 			FieldName: []byte(f.ColName),
 			Flag:      f.Flag,
 		}
+		info.AllFields[i].ColNameBytes = info.CoreFields[i].FieldName
+		info.ColIndex[f.ColName] = i
 	}
 	info.CorePKField = byte(info.PKIndex)
 	return info, nil

+ 13 - 4
Makefile

@@ -2,7 +2,7 @@
 # 时序数据库构建、测试与开发工具
 
 .PHONY: all build test clean run help \
-        test-full test-commit test-dirty test-integrity test-mem \
+        test-unit test-full test-commit test-dirty test-integrity test-mem \
         test-migrate build-migrate \
         test-demos test-demos-heavy test-all \
         build-analyzer build-migrate-web demo-analyzer fmt lint deps tree
@@ -25,7 +25,16 @@ build:
 # =============================================================================
 
 # 运行所有核心测试
-test: test-full test-commit test-dirty test-integrity test-mem
+test: test-unit test-full test-commit test-dirty test-integrity test-mem
+
+# 单元测试: 并发/事务/迁移往返等回归正确性的主要测试网。
+# 之前只跑 demo, 这批单测不在日常流程里 —— 现在并入默认 test。
+test-unit:
+	@echo ""
+	@echo "=========================================="
+	@echo "  单元测试 (DB / DB/core / migrate)"
+	@echo "=========================================="
+	go test ./DB/... ./migrate/ -count=1
 
 # 完整功能测试 (28 项测试)
 test-full:
@@ -162,8 +171,8 @@ test-demos-heavy:
 	echo "  结果: 通过 $$ok, 失败 $$fail"; \
 	test $$fail -eq 0
 
-# 一键全量: 构建 + 迁移往返 + 全部 demo
-test-all: build test-migrate test-demos
+# 一键全量: 构建 + 迁移往返 + 单元测试 + 全部 demo
+test-all: build test-migrate test-unit test-demos
 	@echo ""
 	@echo "✅ 全量验证完成 (重型 demo 另跑: make test-demos-heavy)"
 

+ 24 - 11
README.md

@@ -46,13 +46,15 @@ TsDB2 是一个**纯 Go** 的嵌入式数据库,同时服务两类场景:
 - **对象头部增删改查**:内嵌表基类之后,记录自己就带 `Insert / Update / Delete / Save / Load / Reload / PK` —— 父类(基类)定义方法,每张表自己决定主键样式。
 - **时序表支持「同一时间戳多行」**:`_ts` 只决定一行落在哪个时间段,引擎另给每行分配隐藏行号 `pkID`(行首 8B,对用户不可见)。对象被 `Insert` / `Find` / `Select` 读出来后自带行号,于是 `obj.Update()` / `Delete()` / `Reload()` 精确作用于**这一行**,不会误伤同刻的其它行;二级索引回表走的也是同一个行号。
 - **Schema 自动演进**:`UseTable[T]` 对已存在的表自动同步结构体字段(新增列 / 删除列),并补建缺失的二级索引;索引分量与模型不符时直接报错而非静默沿用。
-- **多种索引形态**:主键聚簇 B+ 树、B+ 二级索引、Hash 桶式二级索引(等值 O(1))、时序索引、复合索引(同名 `index:NAME` 自动合并,支持最左前缀)。
+- **多种索引形态**:主键聚簇 B+ 树、B+ 二级索引、Hash 桶式二级索引(等值 O(1))、时序索引、复合索引(同名 `index:NAME` 自动合并;`IndexTypeCompose` 的处理器的底层就是 SBT 二级 B+ 树——与 BTree 复合键同结构,最左前缀/等值/范围 O(logN),分量不限 8 字节)。
 - **查询能力**
-  - ORM 层:`Where` 支持 `= != > >= < <= like in`,多个 `Where` 之间为 **AND**;支持 `OrderBy` / `Limit` / `Offset` / `Count` / 流式 `Scan` / `First`。
+  - ORM 层:`Where` 支持 `= != > >= < <= like in`,多个 `Where` 之间为 **AND**;支持 `Select`(投影下推)/ `OrderBy` / `Limit` / `Offset` / `Count` / 流式 `Scan` / `First`。
+  - `IN` 走索引:`Where("Age","in",vals)` 在索引列上为每个值展开一次索引定位(B+ / Hash / 复合前缀均可),不再退化为全表扫描;重复值自动去重,空集合仍恒假(0 行)。
+  - `ORDER BY + LIMIT` 流式 top-k:只保留 limit+offset 行的小顶堆,内存 O(k) 且免全量排序,稳定性与全排序一致。
   - core 层:条件构造器 `Eq/Gt/Ge/Lt/Le/Like/ILike/In` 与 `And/Or/Not` 任意组合;`Query` 另支持 `Select/GroupBy/Having/FindOne/Sum/Max/Min`。
 - **事务**:`BeginTransaction()` / `Commit()` / `Rollback()`;提交走整页 WAL + fsync,回滚通过丢弃缓存页立即生效。
 - **提交策略可调**:防抖自动提交、脏页阈值、预设(`batch` / `online` / `safe` / `ultra-safe`),默认无需手动 `Commit`。
-- **存储管理**:LRU BufferPool 页缓存、空闲页链表回收(`DropTable` 后页可复用)、动态字段走堆页 + 引用计数自动回收。
+- **存储管理**:真 LRU BufferPool(读/写命中都刷新热度,容量满淘汰最久未用的干净页;**读未命中也进缓存**,重复扫描/点查不再每次走磁盘)、空闲页链表回收(`DropTable` 后页可复用,页删空延迟到提交成功后还链)、动态字段走堆页 + 引用计数自动回收。
 - **可视化分析器**:`analyzer/` 是独立的网页工具,可打开 `.db` 文件查看页布局、表 / 索引结构、堆链、空闲链,并做全库一致性审计。
 
 ---
@@ -311,7 +313,9 @@ u.Delete()                          // 删
 | **时序 `_ts`** | `Time`(1) | 扁平时间条目链 | `[Begin(8B) + Last(8B) + DataPageID(4B)]` → 分段 DataPage(段内行 = `[pkID(8B) + 行数据]`,按 pkID 升序) | 定位分段后段内扫描 | 按时间段裁剪分段 | 否 |
 | **B+ 二级** | `BTree`(5) | B+ 树 | `[key + pkID]` | O(logN) | O(logN) | 是 |
 | **Hash 二级** | `Hash`(6) | 桶目录 + 溢出链 | `pkID` | **O(1)** | 全桶扫描 O(N),**不保序** | 是 |
-| **复合** | `Compose`(4) | 扁平索引页链 | `[composeKey(N×8B) + PKID(8B)]` | 最左前缀 | 前缀 + 次分量范围 | 是 |
+| **复合** | `Compose`(4) | **SBT 二级 B+ 树**(与 BTree 同结构) | `[composeKey(各分量定宽拼接) + PKID(8B)]` | 最左前缀 O(logN) | 前缀 + 次分量范围 O(logN) | 是 |
+
+> **`IndexTypeCompose` 的处理器(ComposeChainHandler)底层就是 SBT 二级 B+ 树**:`Insert` / `Search` / `RangeScan` / `MIN` / `MAX` 全部委托 `btree.SBT*`,与 `IndexTypeBTree` 复合键同结构、同性能(最左前缀/等值/范围 O(logN),全局有序)。类型值 4 与 5 只是历史命名差异(4 承自 `IndexTypeStr`),运行行为一致。**分量不限 8 字节**——各分量按真实宽度定长拼接(字符串宽度码列、18B Decimal、2B Year 都可以做分量),没有旧版扁平链的宽度闸门。
 
 索引类型常量(`DB/core/page`):
 
@@ -357,7 +361,7 @@ func (db *DB) SetCommitPreset(preset string)       // "batch" | "online" | "safe
 func (db *DB) SetBufferPoolCapacity(capacity int)  // BufferPool 容量(页)
 func (db *DB) LockWrites()                         // 后续页面写入阻塞等待
 func (db *DB) UnlockWrites()
-func (db *DB) VACUUM(opts ...core.VACUUMOptions) (*core.VACUUMResult, error) // 整库重写:真正缩小文件并归还空闲页给 OS(页内墓碑回收走自动 compact,文件大小不变;VACUUM 任何阶段失败则旧库原样保留)
+func (db *DB) VACUUM(opts ...core.VACUUMOptions) (*core.VACUUMResult, error) // 整库重写:真正缩小文件并归还空闲页给 OS(页内墓碑回收走自动 compact,文件大小不变;VACUUM 任何阶段失败则旧库原样保留)。行存储索引按原名/原类型重建、NULL 保留、时序行按时间键重排;⚠️ VACUUM(含自动调度)换过引擎状态,之前拿到的 Table 句柄作废,须重新 UseTable/FindTable
 func (db *DB) SetAutoVACUUM(interval time.Duration, opts ...core.VACUUMOptions) // 周期自动 VACUUM(0 = 关闭)
 func (db *DB) LastVACUUM() time.Time               // 上次(自动)VACUUM 的时间
 
@@ -407,6 +411,7 @@ func (m *BaseModel) Bound() bool                    // 是否已绑定到表
 ```go
 // Query[T] 链式查询
 func (q *Query[T]) Where(field string, op string, value interface{}) *Query[T]
+func (q *Query[T]) Select(cols ...string) *Query[T]      // 投影下推: 只解码选中列 (含 ORDER BY 列), 未选中字段保持零值
 func (q *Query[T]) OrderBy(field string, desc ...bool) *Query[T]
 func (q *Query[T]) Limit(n int) *Query[T]
 func (q *Query[T]) Offset(n int) *Query[T]
@@ -416,6 +421,8 @@ func (q *Query[T]) Count() (int64, error)
 func (q *Query[T]) Scan(callback func(*T) bool) error
 ```
 
+> `Find` / `Scan` / `Table.Select` 遇到行反序列化失败(如 blob 链损坏)会**停止扫描并返回错误**,不会静默跳过该行返回缺行的结果集。
+
 `op` 支持(大小写敏感的小写 / 符号两种写法):
 
 | 写法 | 语义 |
@@ -427,7 +434,7 @@ func (q *Query[T]) Scan(callback func(*T) bool) error
 | `<` / `lt` | 小于 |
 | `<=` / `le` / `lte` | 小于等于 |
 | `like` | LIKE 模式匹配(支持 `%` 与 `_`,大小写敏感) |
-| `in` / `In` / `IN` | 集合匹配:值为切片 / 数组(如 `[]string{"工程部","技术部"}`、`[]int{20,22}`),分量按列类型自动归一;**空集合恒假**(0 行,不是全表);单值容忍为等值 |
+| `in` / `In` / `IN` | 集合匹配:值为切片 / 数组(如 `[]string{"工程部","技术部"}`、`[]int{20,22}`),分量按列类型自动归一;**空集合恒假**(0 行,不是全表);单值容忍为等值;**索引列上走索引**(每个值展开一次索引定位,重复值自动去重) |
 
 > 未知 `op` 会退化为等值比较(**不报错**)。同样,`Where` / `OrderBy` 的字段名传错也是静默查不到——务必传 Go 结构体字段名(见[快速开始](#快速开始)的约定说明)。
 
@@ -468,7 +475,7 @@ func (table *Table) Delete() *Deleter
 func (table *Table) BlobRead(ref BlobRef, compress types.FieldCompress) ([]byte, error)
 func (table *Table) BlobFree(ref BlobRef) error
 func (tx *Transaction) BlobCommit(r io.Reader, compress types.FieldCompress) (BlobRef, error)
-// 错误: ErrBlobTooLarge (单值 > ~4GB), ErrBlobCorrupted (解压长度与 ref.TotalLen 不一致 / 压缩标志不符)
+// 错误: ErrBlobTooLarge (单值 > ~4GB), ErrBlobCorrupted (解压长度与 ref.TotalLen 不一致 / 压缩标志不符 / 读取长度与 ref.TotalLen 不符)
 ```
 
 **条件构造器**(`core` 包):
@@ -798,9 +805,11 @@ PageID == 0 表示空值;真实数据存放于堆页 Offset 处。
 
 ### WAL 与恢复
 
-- 写操作先在内存 BufferPool 中修改并标记脏页;`Commit` 时把**整页镜像**(含 `HeaderPage`)按序写入 WAL 链(`FirstWALPage` / `LastWALPage`),随后 `fsync`,成功后再回写真实页。
-- 打开数据库时若发现待恢复 WAL(`FirstWALPage != 0`),先执行恢复(`wal_recovery.go`),恢复完成后整条 WAL 链一次性回收。
-- 页回收遵循「先 WAL 后回写」:事务中途**不**直写盘地还链,而是登记为 pending;提交成功后才真正还链,回滚则整体丢弃。
+- 写操作先在内存 BufferPool 中修改并标记脏页;`Commit` 时把**整页镜像**(含 `HeaderPage`)按序写入 WAL 链(`FirstWALPage` / `LastWALPage`),首页打「已提交」标记,然后 `fsync`(WAL 与提交标记随这次 fsync 一起落盘)。
+- 随后回写真实页(`WritePageHome`),回写完成后再 `fsync` 一次 —— 钉死「数据页先于『忘掉日志』的头页落盘」:没有这次 fsync,OS 后台回刷可能把下一步的头页先刷下去、数据页留在缓存,断电后头页说「没有 WAL 可重放」,数据页却是旧值,已提交数据静默丢失。
+- 最后回收整条 WAL 链 + 清头页 WAL 指针 + `fsync`(收尾)。
+- 打开数据库时若发现待恢复 WAL(`FirstWALPage != 0`),先执行恢复(`wal_recovery.go`),恢复完成后整条 WAL 链一次性回收。**已提交链出现条目 CRC 校验失败时,`Open` 会报错且 WAL 链原样保留**(拒绝部分重放或回收销毁副本),需修复后重试。
+- 页回收遵循「先 WAL 后回写」:事务中途**不**直写盘地还链,而是登记为 pending;提交成功后才真正还链,回滚则整体丢弃。DataPage 删空同样走延迟回收(登记 pendingFree,提交成功 / Close 时才还链)——避免回滚或崩溃重放后「树引用空闲页」的双重分配。
 - 页分配只有 `HeaderPage.newPageWithReader` 一个入口,并且**一定交出干净空白页**(全 0 + 自述页号)。空闲链上的页是回收来的,而整链还链(`recycleChain`)是 O(1) 拼接、刻意不逐页覆写,页上会留着它上一世的内容(典型是 WAL 页残留);不擦的话,任何「先读后写」的分配方都会把残存字节当成自己的数据 —— 历史上曾因此把整页 WAL 残留解析成 91 条垃圾 `IndexMeta`,`EntryPage` 指向从未分配的页号,最终写到几十 GB 偏移、文件暴涨到 40GB。唯一例外是 WAL 页(`writePage` 传 `nil`):它整页重造,且链指针最后才经头页发布,残页不可达。
 
 ### 提交策略
@@ -947,7 +956,7 @@ TsDB2/
 │       │   └── walpage.go               # WAL 页头(40B)
 │       ├── types/types.go               # FieldType / FieldValue / FieldInfo / 类型注册表
 │       ├── util/utils.go                # 高位标记、比较、二分查找等工具
-│       ├── pool/buffer_pool.go          # LRU BufferPool(脏页阈值 / 容量阻塞 / 写锁)
+│       ├── pool/buffer_pool.go          # LRU BufferPool(读缓存 / 脏页阈值 / 随机→LRU 淘汰 / 写锁)
 │       │
 │       └── index/                       # 索引实现
 │           ├── ientry.go                # 索引条目接口 + 类型注册表
@@ -1197,6 +1206,10 @@ SQL 表达不了 VRTX 的这些细节,所以导出时会在 SQL 侧额外建
 25. **改主键/时序键的「列名」可以,改「索引类型」不行**:`vrtx:"pk"` 只是换默认键列(`_id` / `_ts` 不再建列);把主键表改成时序表(或反之)等于换一级索引,必须迁移数据重建表。
 26. **表必须内嵌基类**:没有内嵌 `DB.BaseModel` / `DB.PrimaryModel` / `DB.TimeSeriesModel` 的结构体不是本框架的表,`ParseModel` / `UseTable` 报 `ErrNotModel`(校验在建表之前,不会留下半成品表)。单写 `vrtx:"pk"` 不能替代基类 —— 它的语义是"覆盖基类默认的键列"。按 SQL 元数据动态建表的 `migrate` 包走 core 层(`db.CreateTable`),不受此约束。
 27. **Blob 只有链式一条路径,行内只放 8 字节引用**:`FieldType_Blob` 列的行内是 `[TotalLen(4B) + FirstPageID(4B)]`,内容落在可跨页的链页上(`BlobChainMax` ≈ 4GB,可选 Snappy)—— 写 `[]byte` / `string` / `io.Reader` 都由引擎自动落链(ORM 与 core 都不要求显式事务),读回拿到的就是内容本身;blob 列不能作索引键(`ErrIndexBlobField`)。它与 `Dynamicbyte`(行内 6B 堆引用、内容整块落一个堆页、≤`MaxBlobSize`≈16KB)的分界就是"内容能不能跨页"。行内引用的链由引擎在行删除 / 更新 / 表 drop 时自动回收;只有**行外自管**的 `BlobRef`(`tx.BlobCommit` 的返回值没写进任何行)才需要 `tbl.BlobFree(ref)`。`BlobRef` 不携带压缩标志,`BlobCommit` 与 `BlobRead` 的 `compress` 必须一致(不一致 → `ErrBlobCorrupted`)。空输入走"零页零字节"路径不分配任何页,详见 [Blob 存储](#blob-存储)。
+28. **VACUUM(含自动调度)之后,旧的表句柄作废**:VACUUM 会关闭并重建整个引擎状态(文件被原子替换重开),此前通过 `UseTable` / `FindTable` 拿到的 `Table[T]` / `*core.Table` 内部还指向旧状态,继续使用会读到旧句柄的幽灵数据。**VACUUM 后必须重新取表**。手动与自动 VACUUM 由引擎互斥串行,不会双开;但 VACUUM 运行期间不要并发写入(违反单写入者模型)。
+29. **复合索引即 B+ 树,无特殊限制**:`IndexTypeCompose` 的处理器的底层是 SBT 二级 B+ 树(与 `IndexTypeBTree` 复合键同结构),最左前缀/等值/范围 O(logN)、全局有序、`MIN`/`MAX` 端点快路径可用;各分量按真实宽度定长拼接,**不限 8 字节**(Decimal/Year/定长字符串列均可做分量)。ORM 侧写 `type:compose` 会报「未知字段类型」(ORM 只认 hash/btree)——用默认的多字段同名 `index:NAME` 即可;core API 可直接用 `IndexTypeCompose`。
+30. **`DropField` 拒绝删除行存储索引的分量列**(主键/时序键列):删了之后新行提不出键、不进任何索引,插入"成功"但行静默丢失。core 层与 ORM 层(schema 同步本来就不动主键列)现在统一报错;二级索引分量列仍允许删除(README 既有语义:新行不进该索引,老行残留条目由主键活性过滤兜底)。
+31. **`Find` / `Scan` / `Select` 不再静默跳过坏行**:行反序列化失败(blob 链损坏、长度不符等)会让查询**返回错误**而不是返回缺行的"成功"结果集。这是行为变更 —— 以前依赖"跳过继续"的调用方现在要处理 error。
 
 ---
 

+ 7 - 9
analyzer/audit.go

@@ -65,8 +65,9 @@ const (
 // isRowStore 叶子条目最后 4 字节 = DataPageID 的索引 (handler.IsRowStore)
 func isRowStore(it byte) bool { return it == IT_TIME || it == IT_PRIMARY }
 
-// isBTreeIdx 走 B+ 树页格式的索引类型 (TablePage.isBTreePageIndex)
-func isBTreeIdx(it byte) bool { return it == IT_PRIMARY || it == IT_BTREE || it == IT_STR }
+// isBTreeIdx 走 B+ 树页格式的索引类型 (TablePage.isBTreePageIndex)。
+// Compose 的处理器底层即 SBT 二级 B+ 树, 页格式与 BTree 相同。
+func isBTreeIdx(it byte) bool { return it == IT_PRIMARY || it == IT_BTREE || it == IT_STR || it == IT_COMPOSE }
 
 func le16(b []byte) uint16 { return binary.LittleEndian.Uint16(b) }
 func le32(b []byte) uint32 { return binary.LittleEndian.Uint32(b) }
@@ -596,18 +597,15 @@ func (w *walker) indexPages(it byte, entry uint32, owner string) (n, dp int) {
 		return n, dp
 
 	default:
-		// 一维条目链 (Time / Compose), 由 PageHdr.NextPage 串起
-		role := "index-chain"
-		if it == IT_COMPOSE {
-			role = "index-compose-chain"
-		}
+		// 一维条目链 (Time), 由 PageHdr.NextPage 串起。
+		// (Compose 的处理器底层已是 SBT B+ 树, 走 isBTreeIdx 分支。)
 		pb := newPageBuf()
 		from := uint32(0)
 		for pid := entry; pid != 0; {
-			if !w.ptrOK(from, pid, role, owner) {
+			if !w.ptrOK(from, pid, "index-chain", owner) {
 				break
 			}
-			if !w.take(pid, role, owner) {
+			if !w.take(pid, "index-chain", owner) {
 				break
 			}
 			n++

+ 9 - 9
analyzer/indexcheck.go

@@ -26,20 +26,19 @@ package main
 //	Primary (btree)   叶 [pkID(8B BE)][DataPageID(4B BE)]          el=12
 //	Time    (扁平链)  叶 [BeginTime(8B)][LastTime(8B)][PageID(4B)] el=20
 //	                           —— timedatapage.findDataPageByTime
-//	BTree/Str/Compose 叶 [key(N*8B)][pkID(8B BE)]                  el=N*8+8
-//	                           —— compose_handler / composepage.Insert
+//	BTree/Str/Compose 叶 [key(keyLen)][pkID(8B BE)]                 el=keyLen+8
+//	                           —— btree.SBT* (Compose 处理器底层即 SBT 二级 B+ 树)
 //	Hash    (桶链)    条目 [key(keyLen)][pkID(8B BE, 最高位=删除标记)]
 //	                           —— hash_handler 桶页 [PageHdr(20)][BucketID(4)][Count(4)]
 //
 // "有序"这条前提是被引擎的查找算法强行要求的, 不是我们的审美:
 //	btree 按分隔键下行            → 叶层全局有序;
-//	composepage.Insert 二分定位插入点 → 页内有序;
+//	SBT (BTree/Str/Compose) 同构  → 叶层全局有序;
 //	timedatapage.findDataPageByTime 对页内条目二分 → 页内按 BeginTime 有序。
 // 因此检查范围按"算法真正依赖的范围"划定:
-//	B+ 树 (Primary/BTree/Str): 页内 + 跨页叶链都查;
-//	Time / Compose:            只查页内 (Compose 的链是"填满一页再下一页",
-//	                           晚到的小键会落到后面的页, 跨页并不保证全局有序);
-//	Hash:                      桶内无序, 不查。
+//	B+ 树 (Primary/BTree/Str/Compose): 页内 + 跨页叶链都查;
+//	Time:                              只查页内 (分段链按时间有序追加, 页内二分定位);
+//	Hash:                              桶内无序, 不查。
 //
 // 注意: 时序条目的 LastTime 会随追加而增长, 所以键只取 BeginTime 前 8 字节。
 // ============================================================================
@@ -218,9 +217,10 @@ func pageChainOf(start uint32) []uint32 {
 }
 
 // idxLeafChain 返回索引叶页 (按键的逻辑顺序)。
-//   - B+ 树: 从根沿"每个内部节点的第 0 个子页"下行到最左叶, 再顺 NextPage 走完叶层。
+//   - B+ 树 (含 Compose —— 处理器底层即 SBT): 从根沿"每个内部节点的第 0 个子页"
+//     下行到最左叶, 再顺 NextPage 走完叶层。
 //     引擎的分隔键不变式是"父条目键 == 子树的键下界", 所以第 0 个条目的子页就是最左子树。
-//   - 扁平链 (Time/Compose): 直接顺 NextPage 走。
+//   - 扁平链 (Time): 直接顺 NextPage 走。
 //   - Hash: 无叶层, 返回 nil (另走桶链)。
 func idxLeafChain(it byte, entry uint32) []uint32 {
 	if entry == 0 || it == IT_HASH {

+ 1 - 0
demo/full_test/main.go

@@ -352,5 +352,6 @@ func main() {
 		fmt.Println("🎉 全部通过!")
 	} else {
 		fmt.Printf("⚠️  有 %d 项失败\n", failed)
+		os.Exit(1) // 让 make test / CI 能感知失败
 	}
 }

+ 1 - 0
demo/integrity_test/main.go

@@ -241,6 +241,7 @@ func main() {
 	} else {
 		fmt.Printf("  ❌ 有失败项: Find=%d, Name=%d, Age=%d, Count=%d\n",
 			findFail, nameErrors, ageErrors, int(int64(N-1)-cnt2))
+		os.Exit(1) // 让 make test / CI 能感知失败
 	}
 	fmt.Println("==============================================")
 

+ 1 - 0
demo/mem_test/main.go

@@ -178,6 +178,7 @@ func main() {
 		fmt.Println("  ⚠️ 内存偏高 (200-500MB), 可能需要调低阈值")
 	} else {
 		fmt.Println("  ❌ 内存泄漏! (> 500MB)")
+		os.Exit(1) // 让 make test / CI 能感知失败
 	}
 
 	//os.Remove(dbPath)

+ 11 - 0
demo/no_commit_test/main.go

@@ -22,6 +22,8 @@ func main() {
 	fmt.Println("  TsDB2 不手动 Commit 场景测试")
 	fmt.Println("==============================================")
 
+	failed := 0 // 任一场景失败则以非零码退出, 让 make test 能感知
+
 	// ========== 场景 1: 只 Insert, 不 Commit, 直接 Close ==========
 	fmt.Println("\n========== 场景 1: Insert → Close (无手动 Commit) ==========")
 	os.Remove(dbPath)
@@ -60,6 +62,7 @@ func main() {
 		fmt.Println("  ✅ 场景 1 通过: Close 自动提交, 数据持久化")
 	} else {
 		fmt.Println("  ❌ 场景 1 失败: 数据丢失!")
+		failed++
 	}
 
 	// ========== 场景 2: Insert → 等待自动提交 (防抖 1 秒) ==========
@@ -99,6 +102,7 @@ func main() {
 		fmt.Println("  ✅ 场景 2 通过: 防抖自动提交生效")
 	} else {
 		fmt.Println("  ❌ 场景 2 失败: 自动提交未生效")
+		failed++
 	}
 
 	// ========== 场景 3: 禁用自动提交 + 不手动 Commit + Close ==========
@@ -133,6 +137,7 @@ func main() {
 		fmt.Println("  ✅ 场景 3 通过: Close 兜底提交生效")
 	} else {
 		fmt.Println("  ❌ 场景 3 失败: Close 兜底未生效")
+		failed++
 	}
 
 	// ========== 场景 4: 脏页阈值自动提交 ==========
@@ -180,6 +185,7 @@ func main() {
 		fmt.Println("  ✅ 场景 4 通过: 脏页阈值 + Close 兜底")
 	} else {
 		fmt.Printf("  ❌ 场景 4 失败: 期望 Count=100000 Name=Emp0050000, 实际 Count=%d Name=%s\n", cnt4, name4)
+		failed++
 	}
 
 	// ========== 场景 5: 连续 Insert 100 万行, 不手动 Commit ==========
@@ -228,10 +234,15 @@ func main() {
 		fmt.Println("  ✅ 场景 5 通过: 100 万行无手动 Commit, 数据完整!")
 	} else {
 		fmt.Println("  ❌ 场景 5 失败")
+		failed++
 	}
 
 	//os.Remove(dbPath)
 	fmt.Println("\n==============================================")
 	fmt.Println("  测试完成!")
 	fmt.Println("==============================================")
+	if failed > 0 {
+		fmt.Printf("  ⚠️  共 %d 个场景失败\n", failed)
+		os.Exit(1)
+	}
 }

+ 26 - 2
docs/INDEX_TYPES_AND_ORM_GUIDE.md

@@ -169,6 +169,11 @@ type Product struct {
   按行号读 / 改 / 删才能精确到行,按 `_ts` 只能碰到该时刻的第一行。ORM 会把 `pkID` 随对象带回
   (对用户隐藏),详见 3.7。
 
+### 2.5 复合索引
+
+- **复合索引就是 B+ 树**:`IndexTypeCompose` 的处理器的底层是 SBT 二级 B+ 树(`Insert`/`Search`/`RangeScan` 全部委托 `btree.SBT*`),与 `IndexTypeBTree` 复合键同结构、同性能——最左前缀等值/范围全部 O(logN),全局有序,`MIN`/`MAX` 端点快路径可用。类型值 4 与 5 只是历史命名差异(4 承自 `IndexTypeStr`),运行行为一致。
+- **分量不限 8 字节**:各分量按真实宽度定长拼接(字符串宽度码列、18B Decimal、2B Year 均可做分量),不存在旧版扁平链的宽度闸门。等值前缀可定位连续区间,前缀之后再消费一个范围分量,更靠后的分量只做行级过滤;IN 可在等值前缀后的下一个分量上展开成多区间(每值一次索引定位,重复值去重)。
+
 ---
 
 ## 3. ORM 增删改查 API
@@ -219,6 +224,12 @@ n, _ := emp.Where("Age", "=", int64(25)).Count()
 rows, _ := emp.Where("Score", ">=", 200).Where("Score", "<=", 299).
     OrderBy("Score", false).Find()
 
+// IN 查询(索引列上每值一次索引定位、重复值去重; 空集合恒假 0 行)
+rows, _ = emp.Where("Age", "in", []int64{20, 22, 24}).Find()
+
+// 投影下推: 只解码选中列 (ORDER BY 列自动并入解码集); 未选中的字段保持零值
+partial, _ := emp.Where("Dept", "=", "D1").Select("Name", "Age").Find()
+
 // 排序 + 分页
 top, _ := emp.OrderBy("Score", true).Limit(5).Find()
 pg,  _ := emp.OrderBy("Score", false).Offset(10).Limit(5).Find()
@@ -233,6 +244,15 @@ pts, _ := met.Where("_ts", ">=", lo).Where("_ts", "<=", hi).OrderBy("_ts", false
 latest, _ := met.OrderBy("_ts", true).Limit(10).Find()   // 最近 10 个点
 ```
 
+> **IN 走索引**:IN 落在索引列(B+ / Hash / 复合索引的等值前缀)上时,每个值展开一次索引
+> 定位,不再退化为全表扫描;与同列范围条件并存时,范围条件走索引区间、IN 由行级过滤兜底。
+>
+> **错误传播**:行反序列化失败(blob 链损坏、读取长度与引用不符等)会让 `Find` / `Scan` /
+> `Select` 返回 error 并停止,不静默跳过坏行。
+>
+> **ORDER BY + LIMIT 是流式 top-k**:内存 O(limit+offset) 的小顶堆,免全量排序;
+> 相等键顺序与全量排序一致(稳定)。
+
 ### 3.4 改(Update)—— 整行覆盖语义
 
 **重要:`Update` 是整行覆盖**,只传部分字段会把其余字段清零。正确做法是「先查后改」:
@@ -250,6 +270,8 @@ emp.Update(&Employee{
 - 从旧键的 `(key, pkID)` 条目精确删除,再插入新键条目(Hash / B+ 二级索引通用)。
 - 时序表按 **行号 `pkID`** 精确定位到行并原地改写;改掉 `_ts` 会把该行搬到新时间段(行号不变)。
 
+没有唯一索引的表 **Update 跳过唯一性预检**(整行试序列化只为查重服务,无唯一索引时纯属浪费),更新速度接近 2 倍;有唯一索引的表仍会在任何写入之前完成查重,冲突时旧行分毫不动。
+
 ### 3.5 删(Delete)
 
 ```go
@@ -456,7 +478,9 @@ type File struct {
 1. **每行有唯一主键 `_id`**,B+ 树叶子直接存行(聚簇索引),主键与行一一对应。
 2. **点查与主键范围最强**:`Find(id)`、主键 `BETWEEN`、`ORDER BY id` 均为 O(logN) 且无需排序。
 3. **非主键查询依赖二级索引**:`Age/Score` 等条件走二级索引 → 取 `pkID` → **回表**。
-   等值用 Hash,范围/排序用 B+。
+   等值用 Hash,范围/排序用 B+。回表已批量化:索引扫描先物化 `pkID`、按升序一次性回表
+   (单调游标),LIMIT 查询按 `limit+offset` 作回表预算提前收工;结果顺序保持索引键序
+   (B+ 叶序 / Hash 按键排序)不变。
 4. **强一致更新语义**:改字段需「删旧条目 + 插新条目」,保证索引与行数据一致。
 5. **适合事务型(OLTP)负载**:频繁单行增删改、事务提交/回滚。
 
@@ -488,7 +512,7 @@ type File struct {
 | 乱序补写 3 个中间点 | 全部被拒(`ErrOutOfOrder`),零落盘;范围扫描仍是 1000 行 |
 | 缺省 `_ts` | ORM 补当前纳秒时间戳 |
 
-> 说明:Hash 等值虽然索引查找是 O(1),但热点键匹配到上万行,耗时主要来自**逐行回表取行**,
+> 说明:Hash 等值虽然索引查找是 O(1),但热点键匹配到上万行,耗时主要来自**回表取行**,
 > 这属于二级索引的固有代价,而非索引查找本身。
 
 ### 7.3 独立专项验证

+ 5 - 5
docs/MIGRATION_GUIDE.md

@@ -182,7 +182,7 @@ type RowWriter interface {
 
 ### 5.1 VRTX → SQL
 
-VRTX 的整数分**有符号**(`Int8/Int16/Int32/Int64`)与**无符号**(`1/2/4/8byte`)两档。
+VRTX 的整数分**有符号**(`Int8/Int16/Int32/Int64`)与**无符号**(`Uint8/Uint16/Uint32/Uint64`,v9 起的语义名;旧名 `1/2/4/8byte` 已废弃)两档。
 有符号档在盘上是保序编码(符号位翻转),SQL 侧没有对应物,只能按等宽的有符号类型导出:
 
 | VRTX 定长档位 | MySQL | SQLite |
@@ -191,10 +191,10 @@ VRTX 的整数分**有符号**(`Int8/Int16/Int32/Int64`)与**无符号**(`
 | `Int16` | `SMALLINT` | `INTEGER` |
 | `Int32` | `INT` | `INTEGER` |
 | `Int64` | `BIGINT` | `INTEGER` |
-| `1byte` | `TINYINT UNSIGNED` | `INTEGER` |
-| `2byte` | `SMALLINT UNSIGNED` | `INTEGER` |
-| `4byte` | `INT UNSIGNED` | `INTEGER` |
-| `8byte` | `BIGINT UNSIGNED` | `INTEGER` |
+| `Uint8` | `TINYINT UNSIGNED` | `INTEGER` |
+| `Uint16` | `SMALLINT UNSIGNED` | `INTEGER` |
+| `Uint32` | `INT UNSIGNED` | `INTEGER` |
+| `Uint64` | `BIGINT UNSIGNED` | `INTEGER` |
 | `Float64` | `DOUBLE` | `REAL` |
 | `Float32` | `FLOAT` | `REAL` |
 | `Decimal(s)` | `DECIMAL(38,s)` | `TEXT` |

+ 28 - 1
docs/OPERATION_TUTORIAL.md

@@ -142,7 +142,7 @@ empCore.CreateIndex("idx_dept", page.IndexTypeBTree, []byte{4})  // Dept 字段
 - `IndexTypeBTree` (5): 二级 B+ 树索引,叶子节点存 `[Key → PKID]`,支持等值/范围/有序
 - `IndexTypeHash` (6): Hash 二级索引,等值 O(1),范围查询需全桶扫描
 - `IndexTypeTime` (1): 时序索引,优化时间范围查询
-- `IndexTypeCompose` (4): 复合索引,多字段联合索引
+- `IndexTypeCompose` (4): 复合索引。处理器底层即 SBT 二级 B+ 树(与 `IndexTypeBTree` 同结构/同性能):最左前缀等值/范围 O(logN)、全局有序、MIN/MAX 端点快路径可用;各分量按真实宽度定长拼接,**分量不限 8 字节**(Decimal/Year/定长字符串列均可)。ORM 侧用默认的多字段同名 `index:NAME` 即为 BTree 复合键;写 `type:compose` 会报「未知字段类型」(ORM 只认 hash/btree),core API 可直接用 `IndexTypeCompose`
 
 ---
 
@@ -257,6 +257,22 @@ for i, emp := range allEmps {
 }
 ```
 
+### 4.4 只取部分列(投影下推)
+
+```go
+// 只解码 Name / Age 两列;未选中的字段在结果对象里保持零值
+rows, _ := empTbl.Where("Dept", "=", "工程部").Select("Name", "Age").Find()
+for _, emp := range rows {
+    fmt.Printf("  %s: %d岁\n", emp.Name, emp.Age)  // emp.Score / emp.Dept 等是零值
+}
+```
+
+- `Select(cols ...string)` 接受 **Go 字段名**,投影下推到引擎:未选中的列不做反序列化与堆页解引用。
+- `ORDER BY` 列会被自动并入解码集,排序行为不受影响。
+
+> **反序列化失败会中断查询**:行数据损坏(blob 链断裂、读取长度与引用不符等)时,
+> `Find` / `Scan` / `Select` 返回 error 并停止扫描,不再静默跳过坏行。
+
 ---
 
 ## 5. 更新数据 (Update)
@@ -392,6 +408,10 @@ rows, _ = empTbl.Where("Age", "in", []int64{20, 22, 24}).Find()
 rows, _ = empTbl.Where("Age", "in", []int64{}).Find()   // → 0 行
 ```
 
+**IN 与索引**:IN 列上带索引(B+ / Hash / 复合索引的等值前缀)时,每个值展开一次索引定位,
+不再退化为全表扫描;重复值自动去重。IN 与同列范围条件并存时,范围条件走索引区间、
+IN 由行级过滤兜底(正确性不受影响)。
+
 ### 7.4 LIKE 模糊查询
 
 ```go
@@ -474,6 +494,9 @@ for _, emp := range rows {
 }
 ```
 
+> **ORDER BY + LIMIT 是流式 top-k**:内存只保留 `limit+offset` 行的小顶堆,免全量排序;
+> 相等键的输出顺序与全量排序一致(稳定)。
+
 ### 8.4 OFFSET + LIMIT 分页
 
 ```go
@@ -844,6 +867,10 @@ fmt.Printf("缓存页数: %d\n", bp.Len())
 fmt.Printf("脏页数: %d\n", bp.DirtyCount())
 ```
 
+BufferPool 是真正的 LRU + 读缓存:读/写命中都会刷新该页的热度,容量满时淘汰最久未使用的
+干净页(脏页绝不淘汰,宁可临时超容量,靠脏页阈值触发的 Commit 回落);读未命中会把磁盘内容
+作为干净页载入缓存,重复扫描/点查直接走内存。
+
 ### 12.5b Blob 存储(链式,≤~4GB)
 
 二进制大对象**只有一条存储路径**:`FieldType_Blob` 列的行内是 8 字节引用