yl32940 1 tháng trước cách đây
mục cha
commit
078bcdc20c

+ 11 - 2
DB/convert.go

@@ -108,7 +108,7 @@ func normalizeValue(fv reflect.Value, fd FieldDef) interface{} {
 
 	switch fd.FieldType {
 	case types.FieldType_1byte:
-		return toInt8(iface)
+		return uint8(toUint64(iface))
 	case types.FieldType_2byte:
 		return toInt16(iface)
 	case types.FieldType_4byte:
@@ -142,6 +142,8 @@ func setFieldFromValue(target reflect.Value, val interface{}, fd FieldDef) error
 		setIntField(target, int64(v))
 	case int8:
 		setIntField(target, int64(v))
+	case uint8:
+		setIntField(target, int64(v))
 	case float64:
 		setFloatField(target, v)
 	case float32:
@@ -166,6 +168,8 @@ func setIntField(target reflect.Value, val int64) {
 		target.SetUint(uint64(val))
 	case reflect.Float32, reflect.Float64:
 		target.SetFloat(float64(val))
+	case reflect.Bool:
+		target.SetBool(val != 0)
 	}
 }
 
@@ -225,6 +229,11 @@ func toUint64(v interface{}) uint64 {
 		return uint64(val)
 	case float32:
 		return uint64(val)
+	case bool:
+		if val {
+			return 1
+		}
+		return 0
 	default:
 		rv := reflect.ValueOf(v)
 		if rv.CanUint() {
@@ -308,7 +317,7 @@ func setPKValue(obj interface{}, model *ModelInfo, pk uint64) {
 func zeroValue(fd FieldDef) interface{} {
 	switch fd.FieldType {
 	case types.FieldType_1byte:
-		return int8(0)
+		return uint8(0)
 	case types.FieldType_2byte:
 		return int16(0)
 	case types.FieldType_4byte:

+ 1 - 1
DB/query.go

@@ -174,7 +174,7 @@ func normalizeCondValue(v interface{}, ft types.FieldType) interface{} {
 	case types.FieldType_2byte:
 		return int16(toUint64(v))
 	case types.FieldType_1byte:
-		return int8(toUint64(v))
+		return uint8(toUint64(v))
 	default:
 		// 定长字符串类型: 转换为 []byte (core 的 encodeIndexKey 需要)
 		if s, ok := v.(string); ok {

+ 19 - 0
demo/bench_10m/full_run.err

@@ -0,0 +1,19 @@
+fatal error: all goroutines are asleep - deadlock!
+
+goroutine 1 [chan receive]:
+TsDB2/DB/core/pool.(*BufferPool).WritePage(0xc0000aa0c0, 0x2b348, {0xc002ca2000, 0x4000, 0xc000098001?})
+	D:/GOPATH/src/TsDB2/DB/core/pool/buffer_pool.go:294 +0x151
+TsDB2/DB/core.(*File).WritePage(0xc004bbb108?, 0xa31fedc9?, {0xc002ca2000?, 0x2b348a32cf0a0?, 0xc002ca2000?})
+	D:/GOPATH/src/TsDB2/DB/core/File.go:112 +0x25
+TsDB2/DB/core/index/btree.sbtSave(...)
+	D:/GOPATH/src/TsDB2/DB/core/index/btree/secondary.go:48
+TsDB2/DB/core/index/btree.SBTBuildFromSorted({0xc005e6e000?, 0xc3500, 0x7ff6a3459d01?}, {0xc00009c160, 0xc000008078, 0xc000008090, 0xc00002e0e0, 0xc00002e0d0, 0xc000008060, 0xc0000080a8, ...})
+	D:/GOPATH/src/TsDB2/DB/core/index/btree/secondary.go:1170 +0x25d
+TsDB2/DB/core.(*Deleter).rebuildIndexes(0xc002539d18, 0xc0001fa1a0, {0xc0021f8000, 0xcaf, 0xc00002e0d0?})
+	D:/GOPATH/src/TsDB2/DB/core/deleter.go:282 +0x545
+TsDB2/DB/core.(*Deleter).Exec(0xc002539d18)
+	D:/GOPATH/src/TsDB2/DB/core/deleter.go:119 +0x431
+main.phase6Delete(0xc000098028, 0xc00002a180, 0xc00002a360, 0xc000114140, 0xc00010a330)
+	D:/GOPATH/src/TsDB2/demo/bench_10m/main.go:990 +0x6c5
+main.main()
+	D:/GOPATH/src/TsDB2/demo/bench_10m/main.go:1731 +0x3d7

+ 135 - 0
demo/bench_10m/full_run.log

@@ -0,0 +1,135 @@
+
+========================================================================================================
+P0 环境与参数
+========================================================================================================
+  时间        : 2026-09-12 01:49:20
+  Go          : go1.25.0 | CPU 32 核 (GOMAXPROCS=32) | windows/amd64
+  规模        : 主表 10000000 行 | 订单 1000000 行 | 部门 100 行 | 单行更新 2000 次 | 逐行删除 100000 行
+  参数        : Age=20+No%50 | Score=No%1000 | Dept=Dept(No%100) | Salary=No%100000
+  索引        : pk_emp + idx_emp_no/BTree + idx_emp_age/BTree + idx_emp_dept/BTree
+  提交策略    : preset=batch(脏页阈值 500 页), 自动提交防抖禁用, 每 100 万行显式 Commit
+  主库        : D:\GOPATH\src\TsDB2\demo\bench_10m\bench_10m.db
+  探针库      : D:\GOPATH\src\TsDB2\demo\bench_10m\bench_10m_probe.db
+  GoAlloc=0MB Sys=6MB HeapInuse=1MB NumGC=0
+
+  清理同前缀旧库文件...
+
+========================================================================================================
+P1 建表 / 打开表 + 索引
+========================================================================================================
+  UseTable × 3 (含建表/建索引)                               行数=3         耗时=1ms         5.81k/s
+  emp 活跃列 : [_id No Name Age Score Dept Salary Active]
+  emp 字段数 : 总 8 / 活跃 8 | TotalRows=0
+  索引 pk_emp         type=2  分量=1 fieldIDs=[0]
+  索引 idx_emp_no     type=5  分量=1 fieldIDs=[1]
+  索引 idx_emp_age    type=5  分量=1 fieldIDs=[3]
+  索引 idx_emp_dept   type=5  分量=1 fieldIDs=[5]
+
+========================================================================================================
+P2 千万级写入: 单行 Insert × 10000000 (含进度 / 分档 Commit)
+========================================================================================================
+  [   200000 行] 累计   10.4s | 瞬时   19.22k/s | 磁盘    58.8MB | ETA  8m30s | GoAlloc=6MB Sys=69MB HeapInuse=7MB NumGC=11703
+  [   400000 行] 累计   21.2s | 瞬时   18.54k/s | 磁盘   111.6MB | ETA  8m38s | GoAlloc=5MB Sys=69MB HeapInuse=5MB NumGC=22971
+  [   600000 行] 累计   33.1s | 瞬时   16.86k/s | 磁盘   163.5MB | ETA  9m17s | GoAlloc=8MB Sys=81MB HeapInuse=8MB NumGC=34686
+  [   800000 行] 累计   45.8s | 瞬时   15.66k/s | 磁盘   215.1MB | ETA  9m48s | GoAlloc=12MB Sys=81MB HeapInuse=12MB NumGC=47075
+  [  1000000 行] 累计   58.9s | 瞬时   15.33k/s | 磁盘   265.6MB | ETA  9m47s | GoAlloc=17MB Sys=81MB HeapInuse=17MB NumGC=59253
+  [  1000000 行] 显式 Commit 83ms | 磁盘 269.0MB
+  [  1200000 行] 累计   72.0s | 瞬时   15.27k/s | 磁盘   319.7MB | ETA  9m36s | GoAlloc=12MB Sys=81MB HeapInuse=12MB NumGC=71929
+  [  1400000 行] 累计   85.4s | 瞬时   14.93k/s | 磁盘   373.2MB | ETA  9m36s | GoAlloc=8MB Sys=81MB HeapInuse=8MB NumGC=84673
+  [  1600000 行] 累计   98.7s | 瞬时   14.99k/s | 磁盘   425.2MB | ETA  9m21s | GoAlloc=10MB Sys=81MB HeapInuse=11MB NumGC=96935
+  [  1800000 行] 累计  112.1s | 瞬时   14.94k/s | 磁盘   480.1MB | ETA   9m9s | GoAlloc=7MB Sys=81MB HeapInuse=8MB NumGC=109291
+  [  2000000 行] 累计  126.6s | 瞬时   13.76k/s | 磁盘   532.2MB | ETA  9m41s | GoAlloc=5MB Sys=81MB HeapInuse=6MB NumGC=122263
+  [  2000000 行] 显式 Commit 20ms | 磁盘 532.2MB
+  [  2200000 行] 累计  141.9s | 瞬时   13.09k/s | 磁盘   583.9MB | ETA  9m56s | GoAlloc=7MB Sys=81MB HeapInuse=8MB NumGC=135902
+  [  2400000 行] 累计  156.9s | 瞬时   13.29k/s | 磁盘   634.8MB | ETA  9m32s | GoAlloc=14MB Sys=81MB HeapInuse=14MB NumGC=148898
+  [  2600000 行] 累计  175.0s | 瞬时   11.07k/s | 磁盘   688.7MB | ETA  11m8s | GoAlloc=8MB Sys=81MB HeapInuse=9MB NumGC=162234
+  [  2800000 行] 累计  190.9s | 瞬时   12.57k/s | 磁盘   742.3MB | ETA  9m33s | GoAlloc=6MB Sys=81MB HeapInuse=7MB NumGC=175220
+  [  3000000 行] 累计  206.8s | 瞬时   12.60k/s | 磁盘   793.9MB | ETA  9m16s | GoAlloc=10MB Sys=81MB HeapInuse=11MB NumGC=188839
+  [  3000000 行] 显式 Commit 80ms | 磁盘 793.9MB
+  [  3200000 行] 累计  222.7s | 瞬时   12.55k/s | 磁盘   847.1MB | ETA   9m2s | GoAlloc=5MB Sys=81MB HeapInuse=5MB NumGC=202580
+  [  3400000 行] 累计  238.9s | 瞬时   12.38k/s | 磁盘   898.4MB | ETA  8m53s | GoAlloc=10MB Sys=81MB HeapInuse=10MB NumGC=216304
+  [  3600000 行] 累计  255.4s | 瞬时   12.14k/s | 磁盘   950.4MB | ETA  8m47s | GoAlloc=13MB Sys=81MB HeapInuse=13MB NumGC=229671
+  [  3800000 行] 累计  271.9s | 瞬时   12.08k/s | 磁盘  1004.9MB | ETA  8m33s | GoAlloc=7MB Sys=81MB HeapInuse=8MB NumGC=243280
+  [  4000000 行] 累计  288.0s | 瞬时   12.43k/s | 磁盘  1056.2MB | ETA   8m3s | GoAlloc=9MB Sys=81MB HeapInuse=10MB NumGC=256821
+  [  4000000 行] 显式 Commit 45ms | 磁盘 1056.2MB
+  [  4200000 行] 累计  303.9s | 瞬时   12.62k/s | 磁盘  1109.3MB | ETA  7m40s | GoAlloc=9MB Sys=81MB HeapInuse=9MB NumGC=270387
+  [  4400000 行] 累计  318.9s | 瞬时   13.25k/s | 磁盘  1163.4MB | ETA   7m3s | GoAlloc=6MB Sys=81MB HeapInuse=6MB NumGC=283779
+  [  4600000 行] 累计  334.0s | 瞬时   13.31k/s | 磁盘  1213.9MB | ETA  6m46s | GoAlloc=11MB Sys=81MB HeapInuse=11MB NumGC=297419
+  [  4800000 行] 累计  349.1s | 瞬时   13.18k/s | 磁盘  1267.3MB | ETA  6m34s | GoAlloc=10MB Sys=81MB HeapInuse=11MB NumGC=310946
+  [  5000000 行] 累计  364.6s | 瞬时   12.92k/s | 磁盘  1319.8MB | ETA  6m27s | GoAlloc=9MB Sys=81MB HeapInuse=9MB NumGC=324717
+  [  5000000 行] 显式 Commit 72ms | 磁盘 1319.8MB
+  [  5200000 行] 累计  380.3s | 瞬时   12.79k/s | 磁盘  1373.2MB | ETA  6m15s | GoAlloc=4MB Sys=81MB HeapInuse=5MB NumGC=338381
+  [  5400000 行] 累计  395.8s | 瞬时   12.84k/s | 磁盘  1425.2MB | ETA  5m58s | GoAlloc=9MB Sys=81MB HeapInuse=10MB NumGC=351938
+  [  5600000 行] 累计  411.5s | 瞬时   12.79k/s | 磁盘  1477.2MB | ETA  5m44s | GoAlloc=11MB Sys=81MB HeapInuse=11MB NumGC=365736
+  [  5800000 行] 累计  426.9s | 瞬时   13.00k/s | 磁盘  1529.6MB | ETA  5m23s | GoAlloc=9MB Sys=81MB HeapInuse=9MB NumGC=379237
+  [  6000000 行] 累计  442.2s | 瞬时   13.04k/s | 磁盘  1581.5MB | ETA   5m7s | GoAlloc=11MB Sys=81MB HeapInuse=12MB NumGC=393058
+  [  6000000 行] 显式 Commit 68ms | 磁盘 1581.5MB
+  [  6200000 行] 累计  457.6s | 瞬时   12.95k/s | 磁盘  1635.5MB | ETA  4m53s | GoAlloc=6MB Sys=81MB HeapInuse=7MB NumGC=406784
+  [  6400000 行] 累计  473.1s | 瞬时   12.96k/s | 磁盘  1687.2MB | ETA  4m38s | GoAlloc=8MB Sys=81MB HeapInuse=8MB NumGC=420396
+  [  6600000 行] 累计  488.9s | 瞬时   12.59k/s | 磁盘  1739.4MB | ETA  4m30s | GoAlloc=11MB Sys=81MB HeapInuse=12MB NumGC=434091
+  [  6800000 行] 累计  504.8s | 瞬时   12.61k/s | 磁盘  1791.8MB | ETA  4m14s | GoAlloc=14MB Sys=81MB HeapInuse=15MB NumGC=447634
+  [  7000000 行] 累计  520.6s | 瞬时   12.66k/s | 磁盘  1844.0MB | ETA  3m57s | GoAlloc=14MB Sys=81MB HeapInuse=14MB NumGC=461424
+  [  7000000 行] 显式 Commit 59ms | 磁盘 1845.6MB
+  [  7200000 行] 累计  537.5s | 瞬时   11.86k/s | 磁盘  1898.6MB | ETA  3m56s | GoAlloc=6MB Sys=81MB HeapInuse=6MB NumGC=475065
+  [  7400000 行] 累计  553.4s | 瞬时   12.57k/s | 磁盘  1950.2MB | ETA  3m27s | GoAlloc=8MB Sys=81MB HeapInuse=9MB NumGC=488851
+  [  7600000 行] 累计  568.9s | 瞬时   12.91k/s | 磁盘  2002.2MB | ETA   3m6s | GoAlloc=7MB Sys=81MB HeapInuse=7MB NumGC=502511
+  [  7800000 行] 累计  584.5s | 瞬时   12.82k/s | 磁盘  2057.0MB | ETA  2m52s | GoAlloc=5MB Sys=81MB HeapInuse=5MB NumGC=515785
+  [  8000000 行] 累计  600.5s | 瞬时   12.48k/s | 磁盘  2109.2MB | ETA  2m40s | GoAlloc=6MB Sys=81MB HeapInuse=6MB NumGC=529721
+  [  8000000 行] 显式 Commit 30ms | 磁盘 2109.2MB
+  [  8200000 行] 累计  616.8s | 瞬时   12.26k/s | 磁盘  2158.0MB | ETA  2m27s | GoAlloc=14MB Sys=81MB HeapInuse=14MB NumGC=542914
+  [  8400000 行] 累计  633.6s | 瞬时   11.88k/s | 磁盘  2213.4MB | ETA  2m15s | GoAlloc=8MB Sys=81MB HeapInuse=9MB NumGC=556861
+  [  8600000 行] 累计  649.6s | 瞬时   12.54k/s | 磁盘  2265.1MB | ETA  1m52s | GoAlloc=7MB Sys=81MB HeapInuse=7MB NumGC=570239
+  [  8800000 行] 累计  665.7s | 瞬时   12.37k/s | 磁盘  2319.2MB | ETA  1m37s | GoAlloc=9MB Sys=81MB HeapInuse=9MB NumGC=583636
+  [  9000000 行] 累计  682.0s | 瞬时   12.30k/s | 磁盘  2373.4MB | ETA  1m21s | GoAlloc=3MB Sys=81MB HeapInuse=4MB NumGC=597474
+  [  9000000 行] 显式 Commit 25ms | 磁盘 2373.4MB
+  [  9200000 行] 累计  697.9s | 瞬时   12.61k/s | 磁盘  2425.1MB | ETA   1m3s | GoAlloc=7MB Sys=81MB HeapInuse=7MB NumGC=611244
+  [  9400000 行] 累计  714.7s | 瞬时   11.87k/s | 磁盘  2475.5MB | ETA    51s | GoAlloc=12MB Sys=81MB HeapInuse=12MB NumGC=625416
+  [  9600000 行] 累计  731.8s | 瞬时   11.72k/s | 磁盘  2530.0MB | ETA    34s | GoAlloc=7MB Sys=81MB HeapInuse=8MB NumGC=639224
+  [  9800000 行] 累计  748.3s | 瞬时   12.11k/s | 磁盘  2581.1MB | ETA    17s | GoAlloc=13MB Sys=81MB HeapInuse=13MB NumGC=653049
+  [ 10000000 行] 累计  764.1s | 瞬时   12.64k/s | 磁盘  2635.2MB | ETA     0s | GoAlloc=10MB Sys=89MB HeapInuse=10MB NumGC=666283
+  [ 10000000 行] 显式 Commit 62ms | 磁盘 2635.2MB
+  单行 Insert × 10000000 + 分档 Commit                     行数=10000000  耗时=12m44.175s  13.09k/s
+  最终 Commit 0s | 磁盘 2635.2MB | 文件 2.57GB | GoAlloc=18MB Sys=89MB HeapInuse=18MB NumGC=666286
+  全表扫描 写入后全量校验(插入态)                                    行数=10000000  耗时=21.609s     462.77k/s
+
+========================================================================================================
+P3 查询性能 (主键点查 / 二级索引等值 / 范围 / 分页 / 排序 / 投影 / 聚合)
+========================================================================================================
+  主键点查 Find(pkID) × 20000                              行数=20000     耗时=615ms       32.53k/s
+  二级索引等值 Where(No=..) × 20000                          行数=20000     耗时=1.714s      11.67k/s
+  索引等值 Count(Age=..) × 20 (每次 200000 行)                行数=4000000   耗时=26.317s     151.99k/s
+  索引范围 Count(Age 30..34, 1000000 行)                    行数=1000000   耗时=6.346s      157.58k/s
+  字符串索引 Count(Dept=Dept007, 100000 行)                  行数=100000    耗时=752ms       133.00k/s
+  无索引条件 Count(Score=123, 10000 行) 全表扫描                 行数=10000000  耗时=1.904s      5.25M/s
+  Limit(10).Offset(500)                                行数=10        耗时=17ms        606/s
+  OrderBy(索引列 Age) + Limit(10) 流式取前 N                  行数=10        耗时=46ms        217/s
+  OrderBy(非索引列 Score) 100000 行参与内存排序                   行数=100000    耗时=944ms       105.93k/s
+  Select(No,Age) 首行返回: No=1 Age=21
+  Sum(Salary) 全表                                       行数=10000000  耗时=7.696s      1.30M/s
+  Max(No)                                              行数=10000000  耗时=7.574s      1.32M/s
+
+========================================================================================================
+P4 多表: 部门维度表 + 订单表 + 索引关联计数校验
+========================================================================================================
+  维度表 dept_dim 写入 100 行                                行数=100       耗时=5ms         19.88k/s
+  [订单   200000 行] 累计   6.3s |   31.97k/s | 磁盘 2659.3MB
+  [订单   400000 行] 累计  13.2s |   30.35k/s | 磁盘 2677.1MB
+  [订单   600000 行] 累计  20.0s |   29.93k/s | 磁盘 2701.5MB
+  [订单   800000 行] 累计  28.2s |   28.34k/s | 磁盘 2727.5MB
+  [订单  1000000 行] 累计  36.6s |   27.33k/s | 磁盘 2749.7MB
+  订单表 orders 写入 1000000 行                              行数=1000000   耗时=36.665s     27.27k/s
+  关联计数: 订单表按 EmpNo 索引 × 20                             行数=20        耗时=42ms        479/s
+  关联计数: 员工表按 Dept 索引 × 100 (每次 100000 行)               行数=10000000  耗时=1m10.755s   141.33k/s
+
+========================================================================================================
+P5 更新 (单行整行重写 / core 批量更新 / 索引列更新后索引是否同步)
+========================================================================================================
+  单行更新 Update(整行重写) × 2000                             行数=2000      耗时=119ms       16.80k/s
+  core 批量更新 Updater.Exec (Score=500 → Age=99)          行数=10000     耗时=22.192s     451/s
+  索引列更新后 Count(Age=99)                                 行数=10000     耗时=99ms        100.56k/s
+  core 批量更新字符串索引列 (Score=321 → Dept=Dept099)           行数=10000     耗时=27.832s     359/s
+
+========================================================================================================
+P6 删除 (逐行删 100000 行 / 批量删除全表重建) + 全量一致性校验
+========================================================================================================
+  逐行 Delete(pkID) × 100000                             行数=100000    耗时=17.959s     5.57k/s

+ 1746 - 0
demo/bench_10m/main.go

@@ -0,0 +1,1746 @@
+// ============================================================================
+// TsDB2 千万级功能 + 性能测试
+//
+// 覆盖:
+//   P2  千万级写入 (分档进度 / 分档 Commit / 磁盘与内存曲线)
+//   P3  查询性能 (主键点查 / 二级索引等值 / 范围 / 分页 / 排序 / 投影 / 聚合)
+//   P4  多表      (部门维度表 + 订单表 + 索引关联计数校验)
+//   P5  更新      (单行全列重写 / core 批量更新 / 索引列更新后索引同步)
+//   P6  删除      (逐行删 10 万行 / 批量删除全表重建) + 全量一致性校验
+//   P7  Schema 变更 (ADD COLUMN / DROP COLUMN / 改列名) + 数据正确性校验
+//   P8  引擎语义探针 (独立库, 不污染主库)
+//   P9  关库重开后的持久化与 schema 校验
+//   P10 汇总 (性能总表 / 校验总表 / 数据问题清单)
+//
+// 规模可用环境变量调整 (默认千万级):
+//   TSDB_BENCH_N       主表行数    默认 10000000 (向下取整到 1000 的倍数)
+//   TSDB_BENCH_ORDERS  订单表行数  默认 1000000
+//   TSDB_BENCH_DB      主库文件名  默认 bench_10m.db (相对当前工作目录)
+//
+// 运行: cd demo/bench_10m; go run .
+// 注意: 启动时会删除同前缀旧库文件; 运行结束后库文件保留, 可交给 analyzer 审计。
+// ============================================================================
+package main
+
+import (
+	"encoding/binary"
+	"fmt"
+	"os"
+	"path/filepath"
+	"runtime"
+	"strconv"
+	"strings"
+	"time"
+
+	DB "TsDB2/DB"
+	core "TsDB2/DB/core"
+	"TsDB2/DB/core/page"
+	"TsDB2/DB/core/types"
+)
+
+// ============================== 规模与派生参数 ==============================
+const (
+	deptCount  = 100
+	scoreMod   = 1000
+	ageBase    = 20
+	ageMod     = 50
+	bulkAgeAt  = 500
+	bulkAgeTo  = 99
+	bulkDeptAt = 321
+)
+
+const bulkDeptTo = "Dept099"
+
+var (
+	nEmp      int64
+	nOrder    int64
+	nJoinMod  int64
+	delCount  int64
+	rowUpdCnt int64
+	rowUpdAt  int64
+	delFrom   int64
+	delTo     int64
+	sampleNo  int64 // P7/P9 抽样行号, 随规模派生, 保证存在且不落在更新/删除区间
+	extraRows int64 = 3
+
+	dbPath    string
+	probePath string
+)
+
+var (
+	perfs    []perfRow
+	checks   []checkRow
+	problems []problemRow
+)
+
+// ============================== 数据公式 ==============================
+func empName(no int64) string { return fmt.Sprintf("Emp%08d", no) }
+
+func expName(no int64) string {
+	if no >= rowUpdAt && no < rowUpdAt+rowUpdCnt {
+		return "UP" + empName(no)
+	}
+	return empName(no)
+}
+
+func expAge(no int64) int64 {
+	if no%scoreMod == bulkAgeAt {
+		return bulkAgeTo
+	}
+	return ageBase + no%ageMod
+}
+
+func expScore(no int64) int64 { return no % scoreMod }
+
+func expDept(no int64) string {
+	if no%scoreMod == bulkDeptAt {
+		return bulkDeptTo
+	}
+	return fmt.Sprintf("Dept%03d", no%deptCount)
+}
+
+func expSalary(no int64) float64 { return float64(no % 100000) }
+
+func expActive(no int64) bool { return no%2 == 0 }
+
+func expActiveByte(no int64) byte {
+	if expActive(no) {
+		return 1
+	}
+	return 0
+}
+
+func pkIDOf(no int64) uint64 {
+	if no <= nEmp {
+		return uint64(no)
+	}
+	return uint64(nEmp-delCount + (no - nEmp))
+}
+
+func isDeletedNo(no int64) bool { return no >= delFrom && no <= delTo }
+
+func expectRows() int64 { return nEmp - delCount + extraRows }
+
+func expectSalarySum(n int64) float64 {
+	full := n / 100000
+	rem := n % 100000
+	return float64(full)*4999950000.0 + float64(rem)*float64(rem+1)/2
+}
+
+// ============================== 通用小工具 ==============================
+func must(err error) {
+	if err != nil {
+		fmt.Printf("\n[FATAL] %v\n", err)
+		os.Exit(1)
+	}
+}
+
+// softMust 供探针使用: 出错时记录为数据问题并返回 true, 不终止整个进程。
+// 探针的目的是"发现并报告引擎语义/缺陷", 单步失败不应让整场测试无结果。
+func softMust(stage string, err error) bool {
+	if err == nil {
+		return false
+	}
+	fmt.Printf("    [探针错误] %s → %v\n", stage, err)
+	problem("探针", stage, fmt.Sprintf("%v", err))
+	return true
+}
+
+func phase(title string) {
+	fmt.Printf("\n%s\n%s\n%s\n", strings.Repeat("=", 104), title, strings.Repeat("=", 104))
+}
+
+func sub(title string) { fmt.Printf("\n--- %s ---\n", title) }
+
+func envInt64(key string, def int64) int64 {
+	if s := os.Getenv(key); s != "" {
+		if v, err := strconv.ParseInt(s, 10, 64); err == nil && v > 0 {
+			return v
+		}
+	}
+	return def
+}
+
+func human(v float64) string {
+	switch {
+	case v >= 1e6:
+		return fmt.Sprintf("%.2fM", v/1e6)
+	case v >= 1e3:
+		return fmt.Sprintf("%.2fk", v/1e3)
+	default:
+		return fmt.Sprintf("%.0f", v)
+	}
+}
+
+func mbOf(v uint64) float64 { return float64(v) / (1 << 20) }
+
+func memLine() string {
+	var m runtime.MemStats
+	runtime.ReadMemStats(&m)
+	return fmt.Sprintf("GoAlloc=%.0fMB Sys=%.0fMB HeapInuse=%.0fMB NumGC=%d",
+		mbOf(m.Alloc), mbOf(m.Sys), mbOf(m.HeapInuse), m.NumGC)
+}
+
+func dbSize(path string) int64 {
+	dir := filepath.Dir(path)
+	stem := strings.TrimSuffix(filepath.Base(path), filepath.Ext(path))
+	entries, err := os.ReadDir(dir)
+	if err != nil {
+		return 0
+	}
+	var total int64
+	for _, e := range entries {
+		if e.IsDir() || !strings.HasPrefix(e.Name(), stem) {
+			continue
+		}
+		if fi, err := e.Info(); err == nil {
+			total += fi.Size()
+		}
+	}
+	return total
+}
+
+func cleanOld(path string) {
+	dir := filepath.Dir(path)
+	stem := strings.TrimSuffix(filepath.Base(path), filepath.Ext(path))
+	entries, err := os.ReadDir(dir)
+	if err != nil {
+		return
+	}
+	for _, e := range entries {
+		if !e.IsDir() && strings.HasPrefix(e.Name(), stem) {
+			p := filepath.Join(dir, e.Name())
+			if err := os.Remove(p); err == nil {
+				fmt.Printf("  [清理旧库] %s\n", p)
+			}
+		}
+	}
+}
+
+func pkKey(id uint64) []byte {
+	b := make([]byte, 8)
+	binary.BigEndian.PutUint64(b, id)
+	return b
+}
+
+func contains(xs []string, s string) bool {
+	for _, x := range xs {
+		if x == s {
+			return true
+		}
+	}
+	return false
+}
+
+func dumpVal(v interface{}) string {
+	if b, ok := v.([]byte); ok {
+		return strings.TrimRight(string(b), "\x00")
+	}
+	return fmt.Sprint(v)
+}
+
+func dumpRow(row []types.FieldValue) string {
+	parts := make([]string, 0, len(row))
+	for _, fv := range row {
+		parts = append(parts, fmt.Sprintf("%s=%s", string(fv.FieldName), dumpVal(fv.Value)))
+	}
+	return strings.Join(parts, " ")
+}
+
+func rowMap(row []types.FieldValue) map[string]interface{} {
+	m := make(map[string]interface{}, len(row))
+	for _, fv := range row {
+		m[string(fv.FieldName)] = fv.Value
+	}
+	return m
+}
+
+func rawEqual(got, want map[string]interface{}) (bool, string) {
+	for k, wv := range want {
+		gv, ok := got[k]
+		if !ok {
+			return false, "缺少列 " + k
+		}
+		if dumpVal(gv) != dumpVal(wv) {
+			return false, fmt.Sprintf("%s 期望=%s 实际=%s", k, dumpVal(wv), dumpVal(gv))
+		}
+	}
+	return true, ""
+}
+
+func fieldIndexByName(t *core.Table, name string) int {
+	for i, f := range t.GetFields() {
+		if string(f.FieldName) == name {
+			return i
+		}
+	}
+	return -1
+}
+
+func fixedBytes(t *core.Table, col, s string) []byte {
+	ft, ok := t.GetActiveFieldType(col)
+	if !ok {
+		return []byte(s)
+	}
+	b := make([]byte, int(types.GetSize(ft)))
+	copy(b, s)
+	return b
+}
+
+func coreRow(t *core.Table, pkIdx string, pk uint64) map[string]interface{} {
+	row, err := t.SearchByIndex(pkIdx, pkKey(pk))
+	if err != nil {
+		return nil
+	}
+	return rowMap(row)
+}
+
+func sampleIDs(count, max int64) []uint64 {
+	ids := make([]uint64, 0, count)
+	x := uint64(0x9E3779B97F4A7C15)
+	for int64(len(ids)) < count {
+		x ^= x << 13
+		x ^= x >> 7
+		x ^= x << 17
+		ids = append(ids, uint64(x%uint64(max))+1)
+	}
+	return ids
+}
+
+// ============================== 报告设施 ==============================
+type perfRow struct {
+	Phase, Op string
+	Rows      int64
+	Cost      time.Duration
+	Note      string
+}
+
+type checkRow struct {
+	Name, Expect, Actual string
+	Pass                 bool
+}
+
+type problemRow struct {
+	Level string
+	Title string
+	Detail string
+}
+
+func perf(ph, op string, rows int64, d time.Duration) {
+	perfs = append(perfs, perfRow{ph, op, rows, d, ""})
+	rate := "-"
+	if d > 0 && rows > 0 {
+		rate = human(float64(rows)/d.Seconds()) + "/s"
+	}
+	fmt.Printf("  %-52s 行数=%-9d 耗时=%-11s %s\n", op, rows, d.Round(time.Millisecond), rate)
+}
+
+func chk(name string, pass bool, expect, actual string) bool {
+	checks = append(checks, checkRow{name, expect, actual, pass})
+	if !pass {
+		fmt.Printf("  [FAIL] %s | 期望=%s 实际=%s\n", name, expect, actual)
+	}
+	return pass
+}
+
+func problem(level, title, detail string) {
+	problems = append(problems, problemRow{level, title, detail})
+	fmt.Printf("  [%s] %s: %s\n", level, title, detail)
+}
+
+// ============================== 表结构 ==============================
+type Emp struct {
+	DB.BaseModel
+	No     int64   `vrtx:"index:idx_emp_no"`
+	Name   string  `vrtx:"maxlen:24"`
+	Age    int64   `vrtx:"index:idx_emp_age"`
+	Score  int64   // 故意不建索引: 用于对比"无索引条件 = 全表扫描"
+	Dept   string  `vrtx:"maxlen:12,index:idx_emp_dept"`
+	Salary float64 // 注意: ORM 把 float64 映射成 8byte 整数列, 小数会被截断
+	Active bool
+}
+
+type DeptDim struct {
+	DB.BaseModel
+	Code  string `vrtx:"maxlen:12,index:idx_dept_code"`
+	Title string `vrtx:"maxlen:32"`
+	Level int64
+}
+
+type Order struct {
+	DB.BaseModel
+	OrderNo int64 `vrtx:"index:idx_order_no"`
+	EmpNo   int64 `vrtx:"index:idx_order_empno"`
+	Amount  int64
+	Status  int64
+}
+
+func deptCode(i int64) string { return fmt.Sprintf("Dept%03d", i) }
+
+// ============================== P0 环境 ==============================
+func phase0() {
+	phase("P0 环境与参数")
+	fmt.Printf("  时间        : %s\n", time.Now().Format("2006-01-02 15:04:05"))
+	fmt.Printf("  Go          : %s | CPU %d 核 (GOMAXPROCS=%d) | %s/%s\n",
+		runtime.Version(), runtime.NumCPU(), runtime.GOMAXPROCS(0), runtime.GOOS, runtime.GOARCH)
+	fmt.Printf("  规模        : 主表 %d 行 | 订单 %d 行 | 部门 %d 行 | 单行更新 %d 次 | 逐行删除 %d 行\n",
+		nEmp, nOrder, deptCount, rowUpdCnt, delCount)
+	fmt.Printf("  参数        : Age=20+No%%50 | Score=No%%1000 | Dept=Dept(No%%100) | Salary=No%%100000\n")
+	fmt.Printf("  索引        : pk_emp + idx_emp_no/BTree + idx_emp_age/BTree + idx_emp_dept/BTree\n")
+	fmt.Printf("  提交策略    : preset=batch(脏页阈值 500 页), 自动提交防抖禁用, 每 100 万行显式 Commit\n")
+	fmt.Printf("  主库        : %s\n", dbPath)
+	fmt.Printf("  探针库      : %s\n", probePath)
+	fmt.Printf("  %s\n", memLine())
+}
+
+// ============================== P1 建表 ==============================
+func phase1(db *DB.DB) (*DB.Table[Emp], *DB.Table[DeptDim], *DB.Table[Order], *core.Table) {
+	phase("P1 建表 / 打开表 + 索引")
+	t := time.Now()
+	empT, err := DB.UseTable[Emp](db, "emp")
+	must(err)
+	deptT, err := DB.UseTable[DeptDim](db, "dept_dim")
+	must(err)
+	orderT, err := DB.UseTable[Order](db, "orders")
+	must(err)
+	empCore, ok := db.CoreDB().FindTable("emp")
+	if !ok {
+		must(fmt.Errorf("core 层找不到 emp 表"))
+	}
+	perf("P1 建表", "UseTable × 3 (含建表/建索引)", 3, time.Since(t))
+
+	fmt.Printf("  emp 活跃列 : %v\n", empCore.GetActiveFieldNames())
+	fmt.Printf("  emp 字段数 : 总 %d / 活跃 %d | TotalRows=%d\n",
+		len(empCore.GetFields()), len(empCore.GetActiveFieldNames()), empCore.TotalRows())
+	for _, nm := range []string{"pk_emp", "idx_emp_no", "idx_emp_age", "idx_emp_dept"} {
+		if m, ok := empCore.IndexMeta().FindIndex(nm); ok {
+			ids := make([]string, 0, m.FieldCnt)
+			for k := 0; k < int(m.FieldCnt); k++ {
+				ids = append(ids, strconv.Itoa(int(m.FieldIDs[k])))
+			}
+			fmt.Printf("  索引 %-14s type=%-2d 分量=%d fieldIDs=[%s]\n",
+				nm, m.IndexType, m.FieldCnt, strings.Join(ids, ","))
+		} else {
+			fmt.Printf("  索引 %-14s 缺失!\n", nm)
+		}
+	}
+	return empT, deptT, orderT, empCore
+}
+
+// ============================== P2 千万级写入 ==============================
+func phase2InsertEmp(db *DB.DB, empT *DB.Table[Emp], empCore *core.Table) {
+	phase(fmt.Sprintf("P2 千万级写入: 单行 Insert × %d (含进度 / 分档 Commit)", nEmp))
+	t0 := time.Now()
+	last, lastN := t0, int64(0)
+	e := &Emp{}
+	for i := int64(1); i <= nEmp; i++ {
+		*e = Emp{
+			BaseModel: DB.BaseModel{ID: 0},
+			No:        i,
+			Name:      empName(i),
+			Age:       int64(ageBase + i%ageMod),
+			Score:     expScore(i),
+			Dept:      fmt.Sprintf("Dept%03d", i%deptCount),
+			Salary:    expSalary(i),
+			Active:    expActive(i),
+		}
+		if err := empT.Insert(e); err != nil {
+			must(fmt.Errorf("插入第 %d 行失败: %w", i, err))
+		}
+		if i%200000 == 0 {
+			now := time.Now()
+			rate := float64(i-lastN) / now.Sub(last).Seconds()
+			eta := time.Duration(float64(nEmp-i) / rate * float64(time.Second))
+			fmt.Printf("  [%9d 行] 累计%7.1fs | 瞬时 %8s/s | 磁盘 %7.1fMB | ETA %6s | %s\n",
+				i, now.Sub(t0).Seconds(), human(rate), float64(dbSize(dbPath))/(1<<20),
+				eta.Round(time.Second), memLine())
+			last, lastN = now, i
+		}
+		if i%1000000 == 0 {
+			ct := time.Now()
+			must(db.Commit())
+			fmt.Printf("  [%9d 行] 显式 Commit %s | 磁盘 %.1fMB\n",
+				i, time.Since(ct).Round(time.Millisecond), float64(dbSize(dbPath))/(1<<20))
+		}
+	}
+	ct := time.Now()
+	must(db.Commit())
+	total := time.Since(t0)
+	perf("P2 写入", fmt.Sprintf("单行 Insert × %d + 分档 Commit", nEmp), nEmp, total)
+	fmt.Printf("  最终 Commit %s | 磁盘 %.1fMB | 文件 %.2fGB | %s\n",
+		time.Since(ct).Round(time.Millisecond), float64(dbSize(dbPath))/(1<<20),
+		float64(dbSize(dbPath))/(1<<30), memLine())
+
+	chk("2.1 TotalRows == N", empCore.TotalRows() == uint64(nEmp),
+		strconv.FormatInt(nEmp, 10), strconv.FormatUint(empCore.TotalRows(), 10))
+	scanned, bad := verifyEmpInserted(empT, "写入后全量校验(插入态)", nEmp)
+	chk("2.2 全表扫描行数 == N", scanned == nEmp, strconv.FormatInt(nEmp, 10), strconv.FormatInt(scanned, 10))
+	chk("2.3 全表逐行公式校验 0 错", bad == 0, "0", strconv.FormatInt(bad, 10))
+}
+
+// verifyEmpORM 用 ORM 映射全表扫描并按公式逐行校验 (跳过已删除区间)
+func verifyEmpORM(empT *DB.Table[Emp], label string, want int64) (int64, int64) {
+	t := time.Now()
+	var scanned, bad int64
+	var first string
+	err := empT.Select(func(e *Emp) bool {
+		scanned++
+		no := e.No
+		if isDeletedNo(no) {
+			bad++
+			if first == "" {
+				first = fmt.Sprintf("已删除行仍存活 No=%d", no)
+			}
+			return true
+		}
+		badCols := make([]string, 0, 4)
+		if e.Name != expName(no) {
+			badCols = append(badCols, "Name")
+		}
+		if e.Age != expAge(no) {
+			badCols = append(badCols, "Age")
+		}
+		if e.Score != expScore(no) {
+			badCols = append(badCols, "Score")
+		}
+		if e.Dept != expDept(no) {
+			badCols = append(badCols, "Dept")
+		}
+		if e.Salary != expSalary(no) {
+			badCols = append(badCols, "Salary")
+		}
+		if e.Active != expActive(no) {
+			badCols = append(badCols, "Active")
+		}
+		if e.ID != pkIDOf(no) {
+			badCols = append(badCols, fmt.Sprintf("_id(%d!=%d)", e.ID, pkIDOf(no)))
+		}
+		if len(badCols) > 0 {
+			bad++
+			if first == "" {
+				first = fmt.Sprintf("No=%d 列不符: %s", no, strings.Join(badCols, ","))
+			}
+		}
+		return true
+	})
+	must(err)
+	perf("校验", "全表扫描 "+label, scanned, time.Since(t))
+	if first != "" {
+		fmt.Printf("    首个错行: %s\n", first)
+	}
+	if want >= 0 {
+		chk("全表扫描 "+label+" 行数", scanned == want,
+			strconv.FormatInt(want, 10), strconv.FormatInt(scanned, 10))
+	}
+	return scanned, bad
+}
+
+// verifyEmpInserted P2 专用: 只校验"刚插入时应有的原始值", 不含后续 P5 更新 / P6 删除的最终态。
+// (verifyEmpORM 的公式里带有更新覆盖与删除区间, 在 P2 阶段尚不成立)
+func verifyEmpInserted(empT *DB.Table[Emp], label string, want int64) (int64, int64) {
+	t := time.Now()
+	var scanned, bad int64
+	var first string
+	err := empT.Select(func(e *Emp) bool {
+		scanned++
+		no := e.No
+		badCols := make([]string, 0, 4)
+		if e.Name != empName(no) {
+			badCols = append(badCols, "Name")
+		}
+		if e.Age != ageBase+no%ageMod {
+			badCols = append(badCols, "Age")
+		}
+		if e.Score != no%scoreMod {
+			badCols = append(badCols, "Score")
+		}
+		if e.Dept != fmt.Sprintf("Dept%03d", no%deptCount) {
+			badCols = append(badCols, "Dept")
+		}
+		if e.Salary != float64(no%100000) {
+			badCols = append(badCols, "Salary")
+		}
+		if e.Active != (no%2 == 0) {
+			badCols = append(badCols, "Active")
+		}
+		if e.ID != uint64(no) {
+			badCols = append(badCols, fmt.Sprintf("_id(%d!=%d)", e.ID, no))
+		}
+		if len(badCols) > 0 {
+			bad++
+			if first == "" {
+				first = fmt.Sprintf("No=%d 列不符: %s", no, strings.Join(badCols, ","))
+			}
+		}
+		return true
+	})
+	must(err)
+	perf("校验", "全表扫描 "+label, scanned, time.Since(t))
+	if first != "" {
+		fmt.Printf("    首个错行: %s\n", first)
+	}
+	if want >= 0 {
+		chk("全表扫描 "+label+" 行数", scanned == want,
+			strconv.FormatInt(want, 10), strconv.FormatInt(scanned, 10))
+	}
+	return scanned, bad
+}
+
+// ============================== P3 查询 ==============================
+func phase3Queries(empT *DB.Table[Emp], empCore *core.Table) {
+	phase("P3 查询性能 (主键点查 / 二级索引等值 / 范围 / 分页 / 排序 / 投影 / 聚合)")
+	const qn = 20000
+
+	// 3.1 主键点查
+	{
+		ids := sampleIDs(qn, nEmp)
+		bad := int64(0)
+		t := time.Now()
+		for _, id := range ids {
+			e, err := empT.Find(id)
+			if err != nil || e.ID != id || e.No != int64(id) || e.Name != empName(int64(id)) {
+				bad++
+			}
+		}
+		perf("P3 查询", fmt.Sprintf("主键点查 Find(pkID) × %d", qn), qn, time.Since(t))
+		chk("3.1 主键点查结果全对", bad == 0, "0 错", fmt.Sprintf("%d 错", bad))
+	}
+	// 3.2 二级索引等值
+	{
+		ids := sampleIDs(qn, nEmp)
+		bad := int64(0)
+		t := time.Now()
+		for _, id := range ids {
+			no := int64(id)
+			e, err := empT.Where("No", "=", no).First()
+			if err != nil || e.No != no || e.ID != uint64(no) {
+				bad++
+			}
+		}
+		perf("P3 查询", fmt.Sprintf("二级索引等值 Where(No=..) × %d", qn), qn, time.Since(t))
+		chk("3.2 索引等值结果全对", bad == 0, "0 错", fmt.Sprintf("%d 错", bad))
+	}
+	// 3.3 索引等值 Count (Age)
+	{
+		const times = 20
+		want := nEmp / ageMod
+		bad := int64(0)
+		t := time.Now()
+		for k := int64(0); k < times; k++ {
+			c, err := empT.Where("Age", "=", ageBase+k).Count()
+			if err != nil || c != want {
+				bad++
+			}
+		}
+		perf("P3 查询", fmt.Sprintf("索引等值 Count(Age=..) × %d (每次 %d 行)", times, want), want*times, time.Since(t))
+		chk("3.3 索引等值计数正确", bad == 0, "0 错", fmt.Sprintf("%d 错", bad))
+	}
+	// 3.4 索引范围 Count
+	{
+		want := nEmp * 5 / ageMod
+		t := time.Now()
+		c, err := empT.Where("Age", ">=", 30).Where("Age", "<=", 34).Count()
+		perf("P3 查询", fmt.Sprintf("索引范围 Count(Age 30..34, %d 行)", want), want, time.Since(t))
+		chk("3.4 索引范围计数正确", err == nil && c == want,
+			strconv.FormatInt(want, 10), fmt.Sprintf("%d err=%v", c, err))
+	}
+	// 3.5 字符串索引等值 Count
+	{
+		d := deptCode(7)
+		want := nEmp / deptCount
+		t := time.Now()
+		c, err := empT.Where("Dept", "=", d).Count()
+		perf("P3 查询", fmt.Sprintf("字符串索引 Count(Dept=%s, %d 行)", d, want), want, time.Since(t))
+		chk("3.5 字符串索引计数正确", err == nil && c == want,
+			strconv.FormatInt(want, 10), fmt.Sprintf("%d err=%v", c, err))
+	}
+	// 3.6 无索引列条件 → 全表扫描
+	{
+		want := nEmp / scoreMod
+		t := time.Now()
+		c, err := empT.Where("Score", "=", 123).Count()
+		perf("P3 查询", fmt.Sprintf("无索引条件 Count(Score=123, %d 行) 全表扫描", want), nEmp, time.Since(t))
+		chk("3.6 无索引条件计数正确", err == nil && c == want,
+			strconv.FormatInt(want, 10), fmt.Sprintf("%d err=%v", c, err))
+	}
+	// 3.7 分页
+	{
+		d := deptCode(7)
+		rows, err := empT.Where("Dept", "=", d).Limit(20).Find()
+		bad := 0
+		for _, e := range rows {
+			if e.Dept != d {
+				bad++
+			}
+		}
+		chk("3.7 Limit 分页", err == nil && len(rows) == 20 && bad == 0,
+			"20 行且全为 "+d, fmt.Sprintf("%d 行 %d 不符 err=%v", len(rows), bad, err))
+
+		// Offset 分页: 偏移量随规模派生, 避免小规模下 dept 总行数 < 偏移量导致取空
+		deptSize := nEmp / deptCount
+		off := int64(500)
+		if deptSize > 0 && off > deptSize/2 {
+			off = deptSize / 4
+		}
+		if off < 0 {
+			off = 0
+		}
+		wantFull := int(off) + 10
+		t := time.Now()
+		rows2, err2 := empT.Where("Dept", "=", d).Limit(10).Offset(int(off)).Find()
+		perf("P3 查询", fmt.Sprintf("Limit(10).Offset(%d)", off), 10, time.Since(t))
+		full, _ := empT.Where("Dept", "=", d).Limit(wantFull).Find()
+		match := len(rows2) == 10 && len(full) == wantFull
+		if match {
+			for k := 0; k < 10; k++ {
+				if full[int(off)+k].No != rows2[k].No {
+					match = false
+					break
+				}
+			}
+		}
+		chk("3.7 Offset 分页与全量前缀一致", match && err2 == nil, "一致",
+			fmt.Sprintf("一致=%v off=%d full=%d rows2=%d err=%v", match, off, len(full), len(rows2), err2))
+	}
+	// 3.8 排序
+	{
+		t := time.Now()
+		rows, err := empT.Where("Age", ">=", 30).Where("Age", "<=", 34).OrderBy("Age").Limit(10).Find()
+		perf("P3 查询", "OrderBy(索引列 Age) + Limit(10) 流式取前 N", 10, time.Since(t))
+		ordered := true
+		for i := 1; i < len(rows); i++ {
+			if rows[i].Age < rows[i-1].Age {
+				ordered = false
+			}
+		}
+		chk("3.8 索引序排序正确", err == nil && len(rows) == 10 && ordered,
+			"10 行且 Age 非降", fmt.Sprintf("%d 行 ordered=%v err=%v", len(rows), ordered, err))
+
+		part := nEmp / deptCount
+		t = time.Now()
+		rows2, err2 := empT.Where("Dept", "=", deptCode(7)).OrderBy("Score").Limit(10).Find()
+		perf("P3 查询", fmt.Sprintf("OrderBy(非索引列 Score) %d 行参与内存排序", part), part, time.Since(t))
+		ordered2 := true
+		for i := 1; i < len(rows2); i++ {
+			if rows2[i].Score < rows2[i-1].Score {
+				ordered2 = false
+			}
+		}
+		chk("3.8 非索引列排序正确", err2 == nil && len(rows2) == 10 && ordered2,
+			"10 行且 Score 非降", fmt.Sprintf("%d 行 ordered=%v err=%v", len(rows2), ordered2, err2))
+	}
+	// 3.9 投影与 FindOne
+	{
+		rows, err := empCore.Query().Select("No", "Age").Limit(5).Find()
+		ok := err == nil && len(rows) == 5
+		for _, r := range rows {
+			if len(r) < 2 {
+				ok = false
+			}
+		}
+		if len(rows) > 0 {
+			fmt.Printf("  Select(No,Age) 首行返回: %s\n", dumpRow(rows[0]))
+		}
+		chk("3.9 Select 投影返回指定列", ok, "5 行且含 No/Age",
+			fmt.Sprintf("%d 行 err=%v", len(rows), err))
+
+		one, err2 := empCore.Query().Where(core.Eq("No", uint64(1), types.FieldType_8byte)).FindOne()
+		chk("3.9 FindOne 命中", err2 == nil && len(one) > 0 && dumpVal(rowMap(one)["No"]) == "1",
+			"No=1", fmt.Sprintf("%v err=%v", rowMap(one)["No"], err2))
+	}
+	// 3.10 聚合
+	{
+		t := time.Now()
+		sum, err := empCore.Query().Sum("Salary")
+		perf("P3 聚合", "Sum(Salary) 全表", nEmp, time.Since(t))
+		want := expectSalarySum(nEmp)
+		chk("3.10 Sum 正确", err == nil && sum == want,
+			fmt.Sprintf("%.0f", want), fmt.Sprintf("%.0f err=%v", sum, err))
+
+		t = time.Now()
+		mx, err2 := empCore.Query().Max("No")
+		perf("P3 聚合", "Max(No)", nEmp, time.Since(t))
+		chk("3.10 Max 正确", err2 == nil && dumpVal(mx) == strconv.FormatInt(nEmp, 10),
+			strconv.FormatInt(nEmp, 10), fmt.Sprintf("%v err=%v", mx, err2))
+
+		mn, err3 := empCore.Query().Min("No")
+		chk("3.10 Min 正确", err3 == nil && dumpVal(mn) == "1", "1", fmt.Sprintf("%v err=%v", mn, err3))
+	}
+	_ = coreRow
+}
+
+// ============================== P4 多表 ==============================
+func phase4Multi(db *DB.DB, empT *DB.Table[Emp], deptT *DB.Table[DeptDim], orderT *DB.Table[Order],
+	empCore *core.Table) (map[int64]int64, map[int64]int64) {
+	phase("P4 多表: 部门维度表 + 订单表 + 索引关联计数校验")
+
+	// 4.1 维度表
+	t := time.Now()
+	for i := int64(0); i < deptCount; i++ {
+		d := &DeptDim{Code: deptCode(i), Title: "部门-" + deptCode(i), Level: i % 5}
+		must(deptT.Insert(d))
+	}
+	must(db.Commit())
+	perf("P4 多表", fmt.Sprintf("维度表 dept_dim 写入 %d 行", deptCount), deptCount, time.Since(t))
+	c, err := deptT.Count()
+	chk("4.1 维度表行数", err == nil && c == deptCount, strconv.Itoa(deptCount), fmt.Sprintf("%d err=%v", c, err))
+	d7, err := deptT.Where("Code", "=", deptCode(7)).First()
+	chk("4.1 维度表按 Code 索引查询", err == nil && d7 != nil && d7.Title == "部门-"+deptCode(7),
+		"部门-"+deptCode(7), fmt.Sprintf("%v err=%v", d7, err))
+
+	// 4.2 订单表
+	orderCore, _ := db.CoreDB().FindTable("orders")
+	t0 := time.Now()
+	o := &Order{}
+	for i := int64(1); i <= nOrder; i++ {
+		*o = Order{OrderNo: i, EmpNo: 1 + i%nJoinMod, Amount: 100 + i%9900, Status: i % 5}
+		if err := orderT.Insert(o); err != nil {
+			must(fmt.Errorf("插入第 %d 条订单失败: %w", i, err))
+		}
+		if i%(nOrder/5) == 0 {
+			rate := float64(i) / time.Since(t0).Seconds()
+			fmt.Printf("  [订单 %8d 行] 累计%6.1fs | %8s/s | 磁盘 %.1fMB\n",
+				i, time.Since(t0).Seconds(), human(rate), float64(dbSize(dbPath))/(1<<20))
+		}
+	}
+	must(db.Commit())
+	perf("P4 多表", fmt.Sprintf("订单表 orders 写入 %d 行", nOrder), nOrder, time.Since(t0))
+	chk("4.2 订单表行数", orderCore.TotalRows() == uint64(nOrder),
+		strconv.FormatInt(nOrder, 10), strconv.FormatUint(orderCore.TotalRows(), 10))
+
+	// 4.3 关联期望 (公式推导: EmpNo = 1 + OrderNo%nJoinMod)
+	orderExpect := map[int64]int64{}
+	orderAfterDel := map[int64]int64{}
+	for i := int64(1); i <= nOrder; i++ {
+		e := 1 + i%nJoinMod
+		orderExpect[e]++
+		if i%5 != 0 { // P6 会删掉 Status==0 (i%5==0) 的订单
+			orderAfterDel[e]++
+		}
+	}
+
+	// 4.4 员工 → 订单 (走 idx_order_empno)
+	sample := make([]int64, 0, 20)
+	for k := int64(0); k < 20; k++ {
+		if k%2 == 0 {
+			sample = append(sample, 1+k*nJoinMod/40) // 落在 [1, nJoinMod] 内
+		} else {
+			sample = append(sample, nJoinMod+1+k) // 落在 (nJoinMod, nEmp] 内
+		}
+	}
+	bad := int64(0)
+	t = time.Now()
+	for _, e := range sample {
+		c, err := orderT.Where("EmpNo", "=", e).Count()
+		if err != nil || c != orderExpect[e] {
+			bad++
+		}
+	}
+	perf("P4 多表", "关联计数: 订单表按 EmpNo 索引 × 20", 20, time.Since(t))
+	chk("4.4 员工→订单 关联计数正确", bad == 0, "0 错", fmt.Sprintf("%d 错", bad))
+
+	// 4.5 部门 → 员工 (走 idx_emp_dept, 100 个部门全查)
+	want := nEmp / deptCount
+	bad = 0
+	t = time.Now()
+	for i := int64(0); i < deptCount; i++ {
+		c, err := empT.Where("Dept", "=", deptCode(i)).Count()
+		if err != nil || c != want {
+			bad++
+			fmt.Printf("    [部门计数异常] %s 期望=%d 实际=%d err=%v\n", deptCode(i), want, c, err)
+		}
+	}
+	perf("P4 多表", fmt.Sprintf("关联计数: 员工表按 Dept 索引 × %d (每次 %d 行)", deptCount, want),
+		want*deptCount, time.Since(t))
+	chk("4.5 部门→员工 关联计数全对", bad == 0, "0 错", fmt.Sprintf("%d 错", bad))
+	chk("4.5 部门计数之和 == 全表行数", want*deptCount == nEmp,
+		strconv.FormatInt(nEmp, 10), strconv.FormatInt(want*deptCount, 10))
+	return orderExpect, orderAfterDel
+}
+
+// empSample 员工抽样: 一半落在 [1, nJoinMod] (有订单), 一半落在其后 (无订单)
+func empSample() []int64 {
+	s := make([]int64, 0, 20)
+	for k := int64(0); k < 20; k++ {
+		if k%2 == 0 {
+			s = append(s, 1+k*nJoinMod/40)
+		} else {
+			s = append(s, nJoinMod+1+k)
+		}
+	}
+	return s
+}
+
+// ============================== P5 更新 ==============================
+func phase5Update(db *DB.DB, empT *DB.Table[Emp], empCore *core.Table) {
+	phase("P5 更新 (单行整行重写 / core 批量更新 / 索引列更新后索引是否同步)")
+
+	// 5.1 单行更新
+	{
+		bad := int64(0)
+		t := time.Now()
+		for k := int64(0); k < rowUpdCnt; k++ {
+			no := rowUpdAt + k
+			e, err := empT.Find(uint64(no))
+			if err != nil {
+				bad++
+				continue
+			}
+			e.Name = "UP" + empName(no)
+			if err := empT.Update(e); err != nil {
+				bad++
+			}
+		}
+		must(db.Commit())
+		perf("P5 更新", fmt.Sprintf("单行更新 Update(整行重写) × %d", rowUpdCnt), rowUpdCnt, time.Since(t))
+		chk("5.1 单行更新全部成功", bad == 0, "0 错", fmt.Sprintf("%d 错", bad))
+
+		e, err := empT.Find(uint64(rowUpdAt))
+		chk("5.1 更新后读回正确", err == nil && e.Name == "UP"+empName(rowUpdAt) && e.No == rowUpdAt &&
+			e.Age == expAge(rowUpdAt) && e.Dept == expDept(rowUpdAt) && e.Score == expScore(rowUpdAt),
+			"UP"+empName(rowUpdAt),
+			fmt.Sprintf("Name=%s No=%d Age=%d Dept=%s Score=%d err=%v", e.Name, e.No, e.Age, e.Dept, e.Score, err))
+	}
+	// 5.2 core 批量更新 (整数索引列 Age)
+	{
+		want := nEmp / scoreMod
+		t := time.Now()
+		cnt, err := empCore.Update().Set("Age", uint64(bulkAgeTo)).
+			Where(core.Eq("Score", uint64(bulkAgeAt), types.FieldType_8byte)).Exec()
+		perf("P5 更新", fmt.Sprintf("core 批量更新 Updater.Exec (Score=%d → Age=%d)", bulkAgeAt, bulkAgeTo),
+			cnt, time.Since(t))
+		chk("5.2 批量更新影响行数", err == nil && cnt == want,
+			strconv.FormatInt(want, 10), fmt.Sprintf("%d err=%v", cnt, err))
+
+		t = time.Now()
+		c99, err2 := empT.Where("Age", "=", int64(bulkAgeTo)).Count()
+		perf("P5 校验", "索引列更新后 Count(Age=99)", want, time.Since(t))
+		chk("5.2 索引列更新后索引已同步", err2 == nil && c99 == want,
+			strconv.FormatInt(want, 10), fmt.Sprintf("%d err=%v", c99, err2))
+
+		want20 := nEmp/ageMod - nEmp/scoreMod
+		c20, err3 := empT.Where("Age", "=", int64(ageBase)).Count()
+		chk("5.2 原 Age 桶计数同步减少", err3 == nil && c20 == want20,
+			strconv.FormatInt(want20, 10), fmt.Sprintf("%d err=%v", c20, err3))
+	}
+	// 5.3 core 批量更新 (字符串索引列 Dept)
+	{
+		want := nEmp / scoreMod
+		t := time.Now()
+		cnt, err := empCore.Update().Set("Dept", fixedBytes(empCore, "Dept", bulkDeptTo)).
+			Where(core.Eq("Score", uint64(bulkDeptAt), types.FieldType_8byte)).Exec()
+		perf("P5 更新", fmt.Sprintf("core 批量更新字符串索引列 (Score=%d → Dept=%s)", bulkDeptAt, bulkDeptTo),
+			cnt, time.Since(t))
+		chk("5.3 字符串列批量更新影响行数", err == nil && cnt == want,
+			strconv.FormatInt(want, 10), fmt.Sprintf("%d err=%v", cnt, err))
+
+		want99 := nEmp/deptCount + nEmp/scoreMod
+		c99, err2 := empT.Where("Dept", "=", bulkDeptTo).Count()
+		chk("5.3 字符串索引同步 (并入 "+bulkDeptTo+")", err2 == nil && c99 == want99,
+			strconv.FormatInt(want99, 10), fmt.Sprintf("%d err=%v", c99, err2))
+
+		want21 := nEmp/deptCount - nEmp/scoreMod
+		c21, err3 := empT.Where("Dept", "=", deptCode(21)).Count()
+		chk("5.3 原字符串桶计数同步减少", err3 == nil && c21 == want21,
+			strconv.FormatInt(want21, 10), fmt.Sprintf("%d err=%v", c21, err3))
+	}
+	// 5.4 负数测试: 更新不存在的主键
+	{
+		ghost := &Emp{BaseModel: DB.BaseModel{ID: 1 << 60}, No: 1, Name: "ghost"}
+		err := empT.Update(ghost)
+		chk("5.4 更新不存在的主键应报错", err != nil, "error", fmt.Sprintf("%v", err))
+	}
+}
+
+// ============================== P6 删除 + 一致性校验 ==============================
+func phase6Delete(db *DB.DB, empT *DB.Table[Emp], orderT *DB.Table[Order], empCore *core.Table,
+	orderAfterDel map[int64]int64) {
+	phase(fmt.Sprintf("P6 删除 (逐行删 %d 行 / 批量删除全表重建) + 全量一致性校验", delCount))
+
+	// 6.1 逐行删除
+	{
+		t := time.Now()
+		ok := int64(0)
+		for id := uint64(delFrom); id <= uint64(delTo); id++ {
+			if err := empT.Delete(id); err != nil {
+				fmt.Printf("    [删除失败] pkID=%d err=%v\n", id, err)
+			} else {
+				ok++
+			}
+		}
+		perf("P6 删除", fmt.Sprintf("逐行 Delete(pkID) × %d", delCount), ok, time.Since(t))
+		chk("6.1 逐行删除全部成功", ok == delCount,
+			strconv.FormatInt(delCount, 10), strconv.FormatInt(ok, 10))
+		chk("6.1 TotalRows 已减少", empCore.TotalRows() == uint64(nEmp-delCount),
+			strconv.FormatInt(nEmp-delCount, 10), strconv.FormatUint(empCore.TotalRows(), 10))
+
+		_, err := empT.Find(uint64(delFrom))
+		chk("6.1 已删除行 Find 应 not found", err != nil, "error", fmt.Sprintf("%v", err))
+
+		alive := delFrom - 1
+		e, err2 := empT.Find(uint64(alive))
+		chk("6.1 边界外一行仍存活", err2 == nil && e.No == alive,
+			fmt.Sprintf("No=%d", alive), fmt.Sprintf("%v err=%v", e, err2))
+	}
+	// 6.2 批量删除 orders (Status==0 无索引列 → 收集后全表重建)
+	{
+		orderCore, _ := db.CoreDB().FindTable("orders")
+		want := nOrder / 5
+		t := time.Now()
+		cnt, err := orderCore.Delete().Where(core.Eq("Status", uint64(0), types.FieldType_8byte)).Exec()
+		perf("P6 删除", fmt.Sprintf("批量删除 orders (Status=0, %d 行) 触发全表重建", want), want, time.Since(t))
+		chk("6.2 批量删除影响行数", err == nil && cnt == want,
+			strconv.FormatInt(want, 10), fmt.Sprintf("%d err=%v", cnt, err))
+		chk("6.2 订单表行数已减少", orderCore.TotalRows() == uint64(nOrder-want),
+			strconv.FormatInt(nOrder-want, 10), strconv.FormatUint(orderCore.TotalRows(), 10))
+
+		bad := 0
+		t2 := time.Now()
+		for _, e := range empSample() {
+			c, err := orderT.Where("EmpNo", "=", e).Count()
+			if err != nil || c != orderAfterDel[e] {
+				bad++
+			}
+		}
+		perf("P6 校验", "批量删除后 员工→订单 关联计数 × 20", 20, time.Since(t2))
+		chk("6.2 批量删除后关联计数正确", bad == 0, "0 错", fmt.Sprintf("%d 错", bad))
+	}
+	// 6.3 全量一致性校验 (期望值全部由公式推导)
+	{
+		sub("6.3 一致性校验 (更新 + 删除之后, 期望值由公式推导)")
+		scanned, bad := verifyEmpORM(empT, "删除后全量校验(公式)", nEmp-delCount)
+		chk("6.3 全表逐行公式校验 0 错", bad == 0, "0", strconv.FormatInt(bad, 10))
+
+		deptWant := map[string]int64{}
+		ageWant := map[int64]int64{}
+		for i := int64(1); i <= nEmp; i++ {
+			if isDeletedNo(i) {
+				continue
+			}
+			deptWant[expDept(i)]++
+			ageWant[expAge(i)]++
+		}
+		badD := 0
+		t := time.Now()
+		for i := int64(0); i < deptCount; i++ {
+			c, err := empT.Where("Dept", "=", deptCode(i)).Count()
+			if err != nil || c != deptWant[deptCode(i)] {
+				badD++
+				fmt.Printf("    [部门计数异常] %s 期望=%d 实际=%d err=%v\n", deptCode(i), deptWant[deptCode(i)], c, err)
+			}
+		}
+		perf("P6 校验", fmt.Sprintf("索引计数复核 Dept × %d", deptCount), nEmp-delCount, time.Since(t))
+		chk("6.3 全部部门索引计数正确", badD == 0, "0 错", fmt.Sprintf("%d 错", badD))
+
+		badA := 0
+		for _, a := range []int64{int64(bulkAgeTo), int64(ageBase), int64(ageBase) + 1, 40, 55} {
+			c, err := empT.Where("Age", "=", a).Count()
+			if err != nil || c != ageWant[a] {
+				badA++
+				fmt.Printf("    [Age 计数异常] %d 期望=%d 实际=%d err=%v\n", a, ageWant[a], c, err)
+			}
+		}
+		chk("6.3 抽样 Age 索引计数正确", badA == 0, "0 错", fmt.Sprintf("%d 错", badA))
+
+		sum := int64(0)
+		for _, v := range deptWant {
+			sum += v
+		}
+		chk("6.3 部门计数之和 == 存活行数", sum == nEmp-delCount,
+			strconv.FormatInt(nEmp-delCount, 10), strconv.FormatInt(sum, 10))
+		_ = scanned
+	}
+}
+
+// ============================== P7 Schema 变更 ==============================
+func expectEmpRaw(no int64) map[string]interface{} {
+	return map[string]interface{}{
+		"_id":    pkIDOf(no),
+		"No":     uint64(no),
+		"Name":   []byte(expName(no)),
+		"Age":    uint64(expAge(no)),
+		"Score":  uint64(expScore(no)),
+		"Dept":   []byte(expDept(no)),
+		"Salary": uint64(expSalary(no)),
+		"Active": expActiveByte(no),
+	}
+}
+
+func expectEmpVals(no int64, pk, level uint64) []types.FieldValue {
+	return []types.FieldValue{
+		{FieldName: []byte("_id"), Value: pk},
+		{FieldName: []byte("No"), Value: uint64(no)},
+		{FieldName: []byte("Name"), Value: []byte(expName(no))},
+		{FieldName: []byte("Age"), Value: uint64(expAge(no))},
+		{FieldName: []byte("Score"), Value: uint64(expScore(no))},
+		{FieldName: []byte("Dept"), Value: []byte(expDept(no))},
+		{FieldName: []byte("Salary"), Value: uint64(expSalary(no))},
+		{FieldName: []byte("Active"), Value: expActiveByte(no)},
+		{FieldName: []byte("Level"), Value: level},
+	}
+}
+
+func calcDeptWant(nExtra int64) map[string]int64 {
+	m := map[string]int64{}
+	for i := int64(1); i <= nEmp; i++ {
+		if isDeletedNo(i) {
+			continue
+		}
+		m[expDept(i)]++
+	}
+	for k := int64(1); k <= nExtra; k++ {
+		m[expDept(nEmp+k)]++
+	}
+	return m
+}
+
+// verifyEmpCoreScan 纯 core 视角全表扫描校验 (不经过 ORM 结构体映射)
+func verifyEmpCoreScan(empCore *core.Table, label string, wantRows int64, wantDept bool) (int64, int64) {
+	t := time.Now()
+	var scanned, bad int64
+	var first string
+	must(empCore.Query().Scan(func(r []types.FieldValue) bool {
+		scanned++
+		m := rowMap(r)
+		no, _ := m["No"].(uint64)
+		ino := int64(no)
+		if isDeletedNo(ino) {
+			bad++
+			if first == "" {
+				first = fmt.Sprintf("已删除行仍存活 No=%d", ino)
+			}
+			return true
+		}
+		cols := make([]string, 0, 7)
+		if dumpVal(m["Name"]) != expName(ino) {
+			cols = append(cols, "Name")
+		}
+		if dumpVal(m["Age"]) != strconv.FormatInt(expAge(ino), 10) {
+			cols = append(cols, "Age")
+		}
+		if dumpVal(m["Score"]) != strconv.FormatInt(expScore(ino), 10) {
+			cols = append(cols, "Score")
+		}
+		if wantDept && dumpVal(m["Dept"]) != expDept(ino) {
+			cols = append(cols, "Dept")
+		}
+		if dumpVal(m["Salary"]) != strconv.FormatUint(uint64(expSalary(ino)), 10) {
+			cols = append(cols, "Salary")
+		}
+		if dumpVal(m["Active"]) != strconv.Itoa(int(expActiveByte(ino))) {
+			cols = append(cols, "Active")
+		}
+		if dumpVal(m["_id"]) != strconv.FormatUint(pkIDOf(ino), 10) {
+			cols = append(cols, "_id")
+		}
+		if len(cols) > 0 {
+			bad++
+			if first == "" {
+				first = fmt.Sprintf("No=%d 列不符: %s", ino, strings.Join(cols, ","))
+			}
+		}
+		return true
+	}))
+	perf("校验", "core 全表扫描 "+label, scanned, time.Since(t))
+	if first != "" {
+		fmt.Printf("    首个错行: %s\n", first)
+	}
+	chk("core 全表扫描 "+label+" 行数", scanned == wantRows,
+		strconv.FormatInt(wantRows, 10), strconv.FormatInt(scanned, 10))
+	return scanned, bad
+}
+
+func phase7Schema(db *DB.DB, empT *DB.Table[Emp], empCore *core.Table) {
+	phase("P7 Schema 变更: 增列 / 删列 / 改列名 (含数据正确性校验)")
+
+	rowsBefore := empCore.TotalRows()
+	sub("7.0 变更前基线")
+	fmt.Printf("  活跃列: %v\n", empCore.GetActiveFieldNames())
+	fmt.Printf("  TotalRows=%d | 字段数 总%d/活跃%d\n",
+		rowsBefore, len(empCore.GetFields()), len(empCore.GetActiveFieldNames()))
+	row0 := coreRow(empCore, "pk_emp", uint64(sampleNo))
+	fmt.Printf("  基线行 No=%d → %s\n", sampleNo, dumpRow2(row0))
+
+	// ---- 7.1 ADD COLUMN ----
+	sub("7.1 ADD COLUMN Level (8 字节新列)")
+	must(empCore.AddField(types.FieldInfo{FieldType: types.FieldType_8byte, FieldName: []byte("Level")}))
+	cols := empCore.GetActiveFieldNames()
+	chk("7.1 活跃列含 Level", contains(cols, "Level"), "含 Level", fmt.Sprintf("%v", cols))
+
+	row := coreRow(empCore, "pk_emp", uint64(sampleNo))
+	_, hasLevel := row["Level"]
+	chk("7.1 老行不返回新增列(缺失而非零值)", !hasLevel, "无 Level", fmt.Sprintf("有=%v", hasLevel))
+	eq, diff := rawEqual(row, expectEmpRaw(sampleNo))
+	chk("7.1 老行其它列不受加列影响", eq, "全等", diff)
+
+	sampled, missLevel := int64(0), int64(0)
+	must(empCore.Query().Limit(2000).Scan(func(r []types.FieldValue) bool {
+		sampled++
+		if _, ok := rowMap(r)["Level"]; !ok {
+			missLevel++
+		}
+		return true
+	}))
+	chk("7.1 抽样老行均不含 Level", missLevel == sampled,
+		fmt.Sprintf("%d/%d", sampled, sampled), fmt.Sprintf("%d/%d", missLevel, sampled))
+
+	bad := int64(0)
+	newPKs := make([]uint64, 0, extraRows)
+	for k := int64(1); k <= extraRows; k++ {
+		no := nEmp + k
+		pk := empCore.TotalRows() + 1
+		got, err := empCore.InsertRow(expectEmpVals(no, pk, 777))
+		if err != nil || got != pk {
+			bad++
+			fmt.Printf("    [新行插入异常] No=%d pk=%d err=%v\n", no, pk, err)
+			continue
+		}
+		newPKs = append(newPKs, pk)
+	}
+	chk("7.1 加列后新行插入成功", bad == 0, "0 错", fmt.Sprintf("%d 错", bad))
+	r1 := coreRow(empCore, "pk_emp", newPKs[0])
+	lv, okLv := r1["Level"]
+	chk("7.1 新行返回 Level=777", okLv && dumpVal(lv) == "777", "777", dumpVal(lv))
+	eq1, diff1 := rawEqual(r1, expectEmpRaw(nEmp+1))
+	chk("7.1 新行其它列值正确", eq1, "全等", diff1)
+
+	// ---- 7.2 在新增列上建索引 ----
+	sub("7.2 在新增列上建索引")
+	lvIdx := fieldIndexByName(empCore, "Level")
+	if lvIdx < 0 {
+		must(fmt.Errorf("找不到 Level 字段的 master 索引"))
+	}
+	_, err := empCore.CreateIndex("idx_emp_level", page.IndexTypeBTree, []byte{byte(lvIdx)})
+	must(err)
+	c7, err7 := empCore.Query().Where(core.Eq("Level", uint64(777), types.FieldType_8byte)).Count()
+	perf("P7 校验", "新增列索引 Count(Level=777)", extraRows, time.Millisecond)
+	chk("7.2 新增列索引能查到新行", err7 == nil && c7 == extraRows,
+		strconv.FormatInt(extraRows, 10), fmt.Sprintf("%d err=%v", c7, err7))
+	if err7 == nil && c7 == 0 {
+		problem("重要限制", "CreateIndex 不回填存量行",
+			"在已有数据的表上新建二级索引, 索引中不含任何存量行: 本例新行(3 行 Level=777)在 CreateIndex 之前已插入, 建好后 Count(Level=777) 仍为 0。只有建索引之后新插入的行才进索引; 引擎未提供公开的『重建全部二级索引』接口。")
+	}
+	c0, err0 := empCore.Query().Where(core.Eq("Level", uint64(0), types.FieldType_8byte)).Count()
+	chk("7.2 老行在新增列上视为无值(不落索引)", err0 == nil && c0 == 0,
+		"0", fmt.Sprintf("%d err=%v", c0, err0))
+
+	// ---- 7.3 DROP COLUMN ----
+	sub("7.3 DROP COLUMN Level (先 DropIndex 再 DropField)")
+	must(empCore.DropIndex("idx_emp_level"))
+	must(empCore.DropField("Level"))
+	cols = empCore.GetActiveFieldNames()
+	chk("7.3 活跃列不含 Level", !contains(cols, "Level"), "不含 Level", fmt.Sprintf("%v", cols))
+	_, stillThere := empCore.IndexMeta().FindIndex("idx_emp_level")
+	chk("7.3 索引已删除", !stillThere, "不存在", fmt.Sprintf("仍存在=%v", stillThere))
+
+	row = coreRow(empCore, "pk_emp", uint64(sampleNo))
+	eq2, diff2 := rawEqual(row, expectEmpRaw(sampleNo))
+	chk("7.3 删列后老行其它列完好", eq2, "全等", diff2)
+	_, hasAfterDrop := row["Level"]
+	chk("7.3 删列后老行不再有 Level", !hasAfterDrop, "无 Level", fmt.Sprintf("有=%v", hasAfterDrop))
+
+	r1 = coreRow(empCore, "pk_emp", newPKs[0])
+	_, has := r1["Level"]
+	eq3, diff3 := rawEqual(r1, expectEmpRaw(nEmp+1))
+	chk("7.3 新行的 Level 列已不可见", !has, "无 Level", fmt.Sprintf("有=%v", has))
+	chk("7.3 新行其它列完好", eq3, "全等", diff3)
+	chk("7.3 删列不影响行数", empCore.TotalRows() == uint64(int64(rowsBefore)+extraRows),
+		strconv.FormatInt(int64(rowsBefore)+extraRows, 10), strconv.FormatUint(empCore.TotalRows(), 10))
+
+	// ---- 7.4 改列名 ----
+	sub("7.4 改列名 (引擎无 RENAME API, 只能 DROP 旧列 + ADD 新列)")
+	oldDept := dumpVal(row0["Dept"])
+	must(empCore.DropIndex("idx_emp_dept"))
+	must(empCore.DropField("Dept"))
+	must(empCore.AddField(types.FieldInfo{FieldType: types.FieldType_32byte, FieldName: []byte("DeptName")}))
+	r := coreRow(empCore, "pk_emp", uint64(sampleNo))
+	_, hasOld := r["Dept"]
+	_, hasNew := r["DeptName"]
+	chk("7.4 改名后老列名不可读", !hasOld, "无 Dept", fmt.Sprintf("有=%v", hasOld))
+	chk("7.4 改名后新列名在老行上无值", !hasNew, "无 DeptName", fmt.Sprintf("有=%v", hasNew))
+	fmt.Printf("  证据: 老行 Dept 原值=%q, 改名后既读不到 Dept 也读不到 DeptName\n", oldDept)
+	problem("设计约束", "引擎没有 RENAME COLUMN / RENAME TABLE API",
+		"改列名只能 DROP 旧列 + ADD 新列; 老行原值不会被迁移到新列名(数据仍在行内, 只是列名不可达), 需要业务侧自行回填")
+
+	// ---- 7.5 复活同名列 ----
+	sub("7.5 重新加回同名 Dept 列 (DropField 实为软删除)")
+	must(empCore.AddField(types.FieldInfo{FieldType: types.FieldType_32byte, FieldName: []byte("Dept")}))
+	r = coreRow(empCore, "pk_emp", uint64(sampleNo))
+	got, okGot := r["Dept"]
+	chk("7.5 同名列复活后老行数据可见", okGot, "可见", fmt.Sprintf("可见=%v", okGot))
+	chk("7.5 复活值与删列前完全一致", dumpVal(got) == oldDept, oldDept, dumpVal(got))
+	eq4, diff4 := rawEqual(r, expectEmpRaw(sampleNo))
+	chk("7.5 复活后老行全列正确", eq4, "全等", diff4)
+	problem("重要语义", "DROP COLUMN 是软删除, 数据仍留在行内; 重新 ADD 同名列会\"复活\"老行数据",
+		"这解释了为什么改列名(删+加新名)后数据看似丢失, 但加回旧列名后又能读到 → 敏感数据无法靠删列来清除, 需要重建表/覆写")
+
+	// ---- 7.6 重建索引 + 全表校验 ----
+	sub("7.6 重建 Dept 索引 + core 层全表校验")
+	dIdx := fieldIndexByName(empCore, "Dept")
+	_, ciErr := empCore.CreateIndex("idx_emp_dept", page.IndexTypeBTree, []byte{byte(dIdx)})
+	must(ciErr)
+	want := calcDeptWant(extraRows)
+	badD := 0
+	t := time.Now()
+	for i := int64(0); i < deptCount; i++ {
+		c, err := empT.Where("Dept", "=", deptCode(i)).Count()
+		if err != nil || c != want[deptCode(i)] {
+			badD++
+			fmt.Printf("    [部门计数异常] %s 期望=%d 实际=%d err=%v\n", deptCode(i), want[deptCode(i)], c, err)
+		}
+	}
+	perf("P7 校验", fmt.Sprintf("重建索引后 索引计数复核 Dept × %d", deptCount), expectRows(), time.Since(t))
+	chk("7.6 重建索引后部门计数全对", badD == 0, "0 错", fmt.Sprintf("%d 错", badD))
+	if badD == int(deptCount) {
+		problem("重要限制", "DROP 后重建的二级索引同样不回填存量行",
+			"idx_emp_dept 删除后重新 CreateIndex, 对全部存量行按索引计数均为 0; 而紧随其后的 core 逐行全表扫描校验 0 错 → 数据本身完好, 只是索引不含存量行。")
+	}
+
+	_, badRows := verifyEmpCoreScan(empCore, "P7 变更后全量校验", expectRows(), true)
+	chk("7.6 P7 变更后逐行公式校验 0 错", badRows == 0, "0", strconv.FormatInt(badRows, 10))
+	_ = db
+}
+
+func dumpRow2(m map[string]interface{}) string {
+	keys := make([]string, 0, len(m))
+	for k := range m {
+		keys = append(keys, k)
+	}
+	// 固定顺序输出, 便于对比
+	order := []string{"_id", "No", "Name", "Age", "Score", "Dept", "Salary", "Active", "Level", "DeptName"}
+	parts := make([]string, 0, len(keys))
+	for _, k := range order {
+		if v, ok := m[k]; ok {
+			parts = append(parts, fmt.Sprintf("%s=%s", k, dumpVal(v)))
+		}
+	}
+	return strings.Join(parts, " ")
+}
+
+// ============================== P8 引擎语义探针 ==============================
+func coreRows(pdb *DB.DB, name string) uint64 {
+	if t, ok := pdb.CoreDB().FindTable(name); ok {
+		return t.TotalRows()
+	}
+	return 0
+}
+
+func phase8Probes() {
+	phase("P8 引擎语义探针 (独立库 " + filepath.Base(probePath) + ", 不影响主库)")
+	pdb, err := DB.Open(probePath)
+	must(err)
+	defer func() { must(pdb.Close()) }()
+	probeLayoutAssumption(pdb)
+	probeDanglingIndex(pdb)
+	probeExplicitPK(pdb)
+	probePKReuse(pdb)
+	probeFloatTrunc(pdb)
+}
+
+func probeLayoutAssumption(pdb *DB.DB) {
+	sub("T1 core 建表(无 _id 列) 时, Delete/Update 如何确定 pkID")
+	ct, err := pdb.CoreDB().CreateTable("probe_layout", []types.FieldInfo{
+		{FieldType: types.FieldType_8byte, FieldName: []byte("No")},
+		{FieldType: types.FieldType_32byte, FieldName: []byte("Label")},
+	})
+	if softMust("CreateTable(probe_layout)", err) {
+		return
+	}
+	_, err = ct.CreateIndex("pk_probe_layout", page.IndexTypePrimary, []byte{0})
+	if softMust("CreateIndex(pk_probe_layout)", err) {
+		return
+	}
+	for i, no := range []uint64{5, 5, 5, 6, 7} {
+		_, err := ct.InsertRow([]types.FieldValue{
+			{FieldName: []byte("No"), Value: no},
+			{FieldName: []byte("Label"), Value: []byte(fmt.Sprintf("L%d", i+1))},
+		})
+		if softMust(fmt.Sprintf("InsertRow(No=%d)", no), err) {
+			return
+		}
+	}
+	one, err := ct.SearchByIndex("pk_probe_layout", pkKey(1))
+	if softMust("SearchByIndex(pkID=1)", err) {
+		one = nil
+	}
+	fmt.Printf("  表 = [No(8B), Label(32B)], 无 _id 列; 引擎反序列化出的行: %s\n", dumpRow(one))
+	fmt.Printf("  数据: pkID 1→No5, 2→No5, 3→No5, 4→No6, 5→No7\n")
+
+	cnt, errD := ct.Delete().Where(core.Eq("No", uint64(5), types.FieldType_8byte)).Exec()
+	alive := map[uint64]uint64{}
+	for pk := uint64(1); pk <= 5; pk++ {
+		if r, err := ct.SearchByIndex("pk_probe_layout", pkKey(pk)); err == nil {
+			if v, ok := rowMap(r)["No"].(uint64); ok {
+				alive[pk] = v
+			}
+		}
+	}
+	fmt.Printf("  Delete(No==5) → cnt=%d err=%v | 剩余: ", cnt, errD)
+	for pk := uint64(1); pk <= 5; pk++ {
+		if v, ok := alive[pk]; ok {
+			fmt.Printf("pkID%d(No=%d) ", pk, v)
+		}
+	}
+	fmt.Printf("| TotalRows=%d\n", ct.TotalRows())
+
+	if len(alive) == 0 && ct.TotalRows() > 0 {
+		problem("严重", "core 主键索引建在非 _id 列上时, 行按 pkID 不可达",
+			"表 [No(8B), Label(32B)] 建 IndexTypePrimary(field 0=No) 并插入 5 行: TotalRows=5、Query().Count()=5 数据可见, 但 SearchByIndex(pk_..., pkKey(1..5)) 全部 key not found, 只有 1 个键能命中 → 主键 B+ 树未按 pkID 建立完整条目。纯 core 表应把 _id 作为首列主键。")
+	}
+
+	aliveNo5 := int64(0)
+	for _, v := range alive {
+		if v == 5 {
+			aliveNo5++
+		}
+	}
+	chk("T1 批量删除影响行数应等于匹配行数 3", cnt == 3, "3", strconv.FormatInt(cnt, 10))
+	chk("T1 被匹配的行(No=5)应全部删除", aliveNo5 == 0, "0 行", fmt.Sprintf("%d 行", aliveNo5))
+	_, keep5 := alive[5]
+	chk("T1 未被匹配的行(pkID=5,No=7)不应被删", keep5, "存活", fmt.Sprintf("存活=%v", keep5))
+	if cnt != 3 || aliveNo5 != 0 || !keep5 {
+		problem("严重", "core 层 Deleter.Exec 用\"行内第一个 uint64 列的值\"当 pkID",
+			fmt.Sprintf("条件 No==5 匹配 pkID 1/2/3 三行, 但 Deleter 把匹配行第一个 uint64 列的值 5 当成 pkID, 于是删掉了 pkID=5 的行(其 No=7, 未匹配), 真正匹配的 3 行一行未删 (cnt=%d)", cnt))
+	}
+
+	cnt2, errU := ct.Update().Set("Label", []byte("X")).
+		Where(core.Eq("No", uint64(6), types.FieldType_8byte)).Exec()
+	fmt.Printf("  Update(No==6 → Label=X) → cnt=%d err=%v\n", cnt2, errU)
+	if errU != nil {
+		problem("严重", "同一假设影响 core 层 Updater.Exec",
+			fmt.Sprintf("匹配行的第一个 uint64 值(6)被当成 pkID 去 UpdateRow → err=%v", errU))
+	} else if cnt2 != 1 {
+		problem("严重", "同一假设影响 core 层 Updater.Exec",
+			fmt.Sprintf("影响行数 %d != 匹配数 1", cnt2))
+	}
+	fmt.Printf("  对照: ORM 表的行首列是 _id(=pkID), 因此 ORM 路径不受影响; 纯 core 表需自行保证首列语义\n")
+}
+
+func probeDanglingIndex(pdb *DB.DB) {
+	sub("T2 只删列不删索引: 残留索引的行为")
+	ct, err := pdb.CoreDB().CreateTable("probe_dangling", []types.FieldInfo{
+		{FieldType: types.FieldType_8byte, FieldName: []byte("No")},
+		{FieldType: types.FieldType_8byte, FieldName: []byte("Tag")},
+	})
+	if softMust("CreateTable(probe_dangling)", err) {
+		return
+	}
+	_, err = ct.CreateIndex("pk_probe_dangling", page.IndexTypePrimary, []byte{0})
+	if softMust("CreateIndex(pk_probe_dangling)", err) {
+		return
+	}
+	_, err = ct.CreateIndex("idx_probe_tag", page.IndexTypeBTree, []byte{1})
+	if softMust("CreateIndex(idx_probe_tag)", err) {
+		return
+	}
+	for i := uint64(1); i <= 5; i++ {
+		_, err := ct.InsertRow([]types.FieldValue{
+			{FieldName: []byte("No"), Value: i},
+			{FieldName: []byte("Tag"), Value: i * 10},
+		})
+		if softMust(fmt.Sprintf("InsertRow(No=%d)", i), err) {
+			return
+		}
+	}
+	c0, err0 := ct.Query().Where(core.Eq("Tag", uint64(20), types.FieldType_8byte)).Count()
+	fmt.Printf("  删列前 Count(Tag==20)=%d err=%v\n", c0, err0)
+	if softMust("DropField(Tag)", ct.DropField("Tag")) {
+		return
+	}
+	fmt.Printf("  删列后活跃列=%v\n", ct.GetActiveFieldNames())
+	_, dangling := ct.IndexMeta().FindIndex("idx_probe_tag")
+	fmt.Printf("  残留索引 idx_probe_tag 仍存在=%v\n", dangling)
+	_, errQ := ct.Query().Where(core.Eq("Tag", uint64(20), types.FieldType_8byte)).Count()
+	fmt.Printf("  删列后按残留索引查询 Tag==20 → err=%v\n", errQ)
+	_, errI := ct.InsertRow([]types.FieldValue{{FieldName: []byte("No"), Value: uint64(6)}})
+	fmt.Printf("  删列后插入不含 Tag 的新行 → err=%v TotalRows=%d\n", errI, ct.TotalRows())
+	r6, err6 := ct.SearchByIndex("pk_probe_dangling", pkKey(6))
+	fmt.Printf("  读回新行: %s err=%v\n", dumpRow(r6), err6)
+	chk("T2 残留索引不影响插入(缺失列应被跳过)", errI == nil, "nil", fmt.Sprintf("%v", errI))
+	if errQ != nil {
+		problem("使用约束", "DropField 不会自动删除引用该列的索引",
+			fmt.Sprintf("残留索引会导致按该列查询直接报错(err=%v); 正确顺序是先 DropIndex 再 DropField", errQ))
+	}
+}
+
+func probeExplicitPK(pdb *DB.DB) {
+	sub("T3 ORM 显式指定 _id ≠ 引擎 pkID")
+	pt, err := DB.UseTable[Emp](pdb, "probe_emp")
+	if softMust("UseTable(probe_emp)", err) {
+		return
+	}
+	e := &Emp{BaseModel: DB.BaseModel{ID: 999999}, No: 1, Name: "P1", Age: 30, Score: 1, Dept: "Dept000", Salary: 1, Active: true}
+	errI := pt.Insert(e)
+	fmt.Printf("  Insert(ID=999999) → err=%v, 对象 ID=%d\n", errI, e.ID)
+	_, errF := pt.Find(999999)
+	fmt.Printf("  Find(999999) → err=%v\n", errF)
+	real, errR := pt.Find(1)
+	if errR == nil {
+		fmt.Printf("  Find(1) → ID=%d No=%d (说明引擎分配的 pkID 是 1, 与 _id 列 999999 不一致)\n", real.ID, real.No)
+	} else {
+		fmt.Printf("  Find(1) → err=%v\n", errR)
+	}
+	chk("T3 按指定的 _id 应能查到该行", errF == nil, "查得到", fmt.Sprintf("err=%v", errF))
+	if errF != nil {
+		problem("风险", "ORM 显式指定的主键值会被静默忽略, 产生\"插入成功但按主键查不到\"的行",
+			fmt.Sprintf("引擎 pkID 恒为 TotalRows+1; 写 ID=999999 后 Find(999999) 失败, 只有 Find(1) 能查到, 且该行 _id 列值仍是 999999 → _id 与真实 pkID 失配, 之后 Delete/批量删除会按 _id 操作到错误的行 (err=%v)", errF))
+	}
+}
+
+func probePKReuse(pdb *DB.DB) {
+	sub("T4 删除中间 pkID 后再插入: pkID 复用与碰撞")
+	pr, err := DB.UseTable[Emp](pdb, "probe_reuse")
+	if softMust("UseTable(probe_reuse)", err) {
+		return
+	}
+	for i := int64(1); i <= 3; i++ {
+		if softMust(fmt.Sprintf("Insert(No=%d)", i), pr.Insert(&Emp{No: i, Name: empName(i), Age: 20 + i, Score: i, Dept: "Dept000", Salary: float64(i), Active: true})) {
+			return
+		}
+	}
+	if softMust("Delete(pkID=2)", pr.Delete(2)) {
+		return
+	}
+	cnt, _ := pr.Count()
+	fmt.Printf("  插 3 行(pkID 1,2,3) → 删 pkID=2 → Count=%d TotalRows=%d\n", cnt, coreRows(pdb, "probe_reuse"))
+	if softMust("Insert(No=99)", pr.Insert(&Emp{No: 99, Name: empName(99), Age: 99, Score: 9, Dept: "Dept000", Salary: 9, Active: true})) {
+		return
+	}
+	type kv struct {
+		ID uint64
+		No int64
+	}
+	rows := make([]kv, 0, 4)
+	if softMust("Select(probe_reuse)", pr.Select(func(e *Emp) bool { rows = append(rows, kv{e.ID, e.No}); return true })) {
+		return
+	}
+	fmt.Printf("  再插 1 行(No=99) 后存活: ")
+	for _, r := range rows {
+		fmt.Printf("{_id=%d,No=%d} ", r.ID, r.No)
+	}
+	fmt.Printf("| Count=%d TotalRows=%d\n", len(rows), coreRows(pdb, "probe_reuse"))
+	hasNo3 := false
+	for _, r := range rows {
+		if r.No == 3 {
+			hasNo3 = true
+		}
+	}
+	chk("T4 新插入不应覆盖既有存活行(No=3 应仍在, 共 3 行)", hasNo3 && len(rows) == 3,
+		"3 行且含 No=3", fmt.Sprintf("%d 行 含No3=%v", len(rows), hasNo3))
+	if !hasNo3 || len(rows) != 3 {
+		problem("严重", "pkID = TotalRows+1 且删除会回退 TotalRows → 删中间行后再插入会复用已占用的 pkID",
+			fmt.Sprintf("删 pkID=2 后 TotalRows 回退为 2, 下一条插入分到 pkID=3, 与仍存活的 pkID=3 冲突, 实测存活 %d 行(期望 3, 含 No=3)", len(rows)))
+	}
+}
+
+func probeFloatTrunc(pdb *DB.DB) {
+	sub("T5 float64 列的小数精度")
+	pt, err := DB.UseTable[Emp](pdb, "probe_float")
+	if softMust("UseTable(probe_float)", err) {
+		return
+	}
+	softMust("Insert(Salary=123.75)", pt.Insert(&Emp{No: 1, Name: "F", Age: 1, Score: 1, Dept: "Dept000", Salary: 123.75, Active: true}))
+	got, err2 := pt.Find(1)
+	if err2 == nil {
+		fmt.Printf("  写入 Salary=123.75 → 读回 %v\n", got.Salary)
+	}
+	chk("T5 float64 的小数部分应保留", err2 == nil && got.Salary == 123.75,
+		"123.75", fmt.Sprintf("%v err=%v", got, err2))
+	if got != nil && got.Salary != 123.75 {
+		problem("风险", "ORM 把 float64 映射成 8 字节整数列, 小数被静默截断",
+			fmt.Sprintf("写入 123.75 读回 %v; 金额/比例等小数数据不能直接存 float64 列(需业务侧放大成整数)", got.Salary))
+	}
+}
+
+// ============================== P9 关库重开 ==============================
+func phase9Reopen(path string) {
+	phase("P9 关库重开: 持久化 / schema / 索引 校验")
+	db2, err := DB.Open(path)
+	must(err)
+	defer func() { must(db2.Close()) }()
+	empCore, ok := db2.CoreDB().FindTable("emp")
+	if !ok {
+		must(fmt.Errorf("重开后找不到 emp 表"))
+	}
+	cols := empCore.GetActiveFieldNames()
+	fmt.Printf("  重开后 emp 活跃列: %v\n", cols)
+	fmt.Printf("  TotalRows=%d (期望 %d) | 磁盘 %.1fMB\n",
+		empCore.TotalRows(), expectRows(), float64(dbSize(path))/(1<<20))
+	chk("9.1 重开后 TotalRows 一致", empCore.TotalRows() == uint64(expectRows()),
+		strconv.FormatInt(expectRows(), 10), strconv.FormatUint(empCore.TotalRows(), 10))
+	chk("9.1 重开后 schema 保留 P7 结果", contains(cols, "Dept") && contains(cols, "DeptName"),
+		"含 Dept 与 DeptName", fmt.Sprintf("%v", cols))
+
+	_, bad := verifyEmpCoreScan(empCore, "重开后 core 全量校验", expectRows(), true)
+	chk("9.1 重开后逐行公式校验 0 错", bad == 0, "0", strconv.FormatInt(bad, 10))
+
+	idxOK := 0
+	for _, nm := range []string{"pk_emp", "idx_emp_no", "idx_emp_age", "idx_emp_dept"} {
+		if _, ok := empCore.IndexMeta().FindIndex(nm); ok {
+			idxOK++
+		}
+	}
+	chk("9.1 主键+三个二级索引均存在", idxOK == 4, "4", strconv.Itoa(idxOK))
+
+	sub("9.2 ORM 重开表 (UseTable 会按 struct 同步 schema)")
+	empT2, err := DB.UseTable[Emp](db2, "emp")
+	must(err)
+	cols2 := empCore.GetActiveFieldNames()
+	fmt.Printf("  UseTable[Emp] 之后活跃列: %v\n", cols2)
+	if contains(cols, "DeptName") && !contains(cols2, "DeptName") {
+		problem("风险", "ORM 重开表会按 struct 强制同步 schema, 静默删除 struct 里没有的列",
+			fmt.Sprintf("纯 core 视角活跃列=%v 含 DeptName, 调用 UseTable[Emp] 后活跃列=%v, DeptName 被自动 DropField; 手工加过列的表被 ORM 打开就会\"丢列\"(数据仍在行内, 再加回同名列可复活)", cols, cols2))
+	}
+	scanned, bad2 := verifyEmpORM(empT2, "重开后 ORM 全量校验", expectRows())
+	chk("9.2 ORM 重开后可读全表", scanned == expectRows() && bad2 == 0,
+		fmt.Sprintf("%d 行 0 错", expectRows()), fmt.Sprintf("%d 行 %d 错", scanned, bad2))
+
+	e, errF := empT2.Find(1)
+	chk("9.2 重开后主键点查可用", errF == nil && e.No == 1, "No=1", fmt.Sprintf("%v err=%v", e, errF))
+	c, errC := empT2.Where("No", "=", int64(sampleNo)).Count()
+	chk("9.2 重开后索引等值查询可用", errC == nil && c == 1, "1", fmt.Sprintf("%d err=%v", c, errC))
+
+	want := int64(0)
+	for i := int64(1); i <= nEmp; i++ {
+		if !isDeletedNo(i) && expAge(i) == int64(bulkAgeTo) {
+			want++
+		}
+	}
+	for k := int64(1); k <= extraRows; k++ {
+		if expAge(nEmp+k) == int64(bulkAgeTo) {
+			want++
+		}
+	}
+	c2, errC2 := empT2.Where("Age", "=", int64(bulkAgeTo)).Count()
+	chk("9.2 重开后 Age 索引计数正确", errC2 == nil && c2 == want,
+		strconv.FormatInt(want, 10), fmt.Sprintf("%d err=%v", c2, errC2))
+}
+
+// ============================== P10 汇总 ==============================
+func report(start time.Time) {
+	phase("P10 汇总报告")
+	fmt.Printf("\n【性能总表】共 %d 项\n", len(perfs))
+	fmt.Printf("%-10s %-52s %12s %13s %14s\n", "阶段", "操作", "行数", "耗时", "速率")
+	fmt.Printf("%s\n", strings.Repeat("-", 108))
+	for _, p := range perfs {
+		rate := "-"
+		if p.Cost > 0 && p.Rows > 0 {
+			rate = human(float64(p.Rows)/p.Cost.Seconds()) + "/s"
+		}
+		fmt.Printf("%-10s %-52s %12d %13s %14s\n", p.Phase, p.Op, p.Rows, p.Cost.Round(time.Millisecond), rate)
+	}
+
+	pass, fail := 0, 0
+	for _, c := range checks {
+		if c.Pass {
+			pass++
+		} else {
+			fail++
+		}
+	}
+	fmt.Printf("\n【校验总表】共 %d 项: 通过 %d, 失败 %d\n", len(checks), pass, fail)
+	if fail > 0 {
+		fmt.Printf("%-66s %-24s %s\n", "失败检查项", "期望", "实际")
+		for _, c := range checks {
+			if !c.Pass {
+				fmt.Printf("%-66s %-24s %s\n", c.Name, c.Expect, c.Actual)
+			}
+		}
+	}
+
+	fmt.Printf("\n【数据问题清单】共 %d 项\n", len(problems))
+	for i, p := range problems {
+		fmt.Printf("  %d. [%s] %s\n     %s\n", i+1, p.Level, p.Title, p.Detail)
+	}
+	fmt.Printf("\n【结论】总耗时 %s | 校验 %d/%d 通过 | 数据问题 %d 项\n",
+		time.Since(start).Round(time.Second), pass, len(checks), len(problems))
+	fmt.Printf("  主库保留: %s (%.1fMB) | 探针库: %s (%.1fMB)\n",
+		dbPath, float64(dbSize(dbPath))/(1<<20), probePath, float64(dbSize(probePath))/(1<<20))
+	fmt.Printf("  可将上述 .db 交给 analyzer 审计\n")
+}
+
+// ============================== main ==============================
+func deriveScale() {
+	delCount = nEmp / 100
+	if delCount > 100000 {
+		delCount = 100000
+	}
+	if delCount < 100 {
+		delCount = 100
+	}
+	delCount = delCount / 100 * 100
+	delFrom, delTo = nEmp-delCount+1, nEmp
+
+	rowUpdCnt = nEmp / 5000
+	if rowUpdCnt > 2000 {
+		rowUpdCnt = 2000
+	}
+	if rowUpdCnt < 10 {
+		rowUpdCnt = 10
+	}
+	rowUpdAt = nEmp/4 + 1
+
+	// 抽样行号: 取 nEmp/3, 保证存在且不落在 rowUpd 区间(仅 10 行)或尾部删除区间内
+	sampleNo = nEmp / 3
+	if sampleNo < 1 {
+		sampleNo = 1
+	}
+
+	nJoinMod = nOrder / 5
+	if nJoinMod > 200000 {
+		nJoinMod = 200000
+	}
+	if nJoinMod < 5 {
+		nJoinMod = 5
+	}
+}
+
+func main() {
+	start := time.Now()
+	nEmp = envInt64("TSDB_BENCH_N", 10_000_000)
+	nEmp = nEmp / 1000 * 1000
+	if nEmp < 1000 {
+		nEmp = 1000
+	}
+	nOrder = envInt64("TSDB_BENCH_ORDERS", 1_000_000)
+	name := os.Getenv("TSDB_BENCH_DB")
+	if name == "" {
+		name = "bench_10m.db"
+	}
+	abs, err := filepath.Abs(name)
+	must(err)
+	dbPath = abs
+	probePath = filepath.Join(filepath.Dir(abs), strings.TrimSuffix(filepath.Base(abs), ".db")+"_probe.db")
+	deriveScale()
+
+	phase0()
+	fmt.Printf("\n  清理同前缀旧库文件...\n")
+	cleanOld(dbPath)
+	cleanOld(probePath)
+
+	db, err := DB.Open(dbPath)
+	must(err)
+	db.SetCommitPreset("batch")
+	db.SetAutoCommitDelay(0)
+
+	empT, deptT, orderT, empCore := phase1(db)
+	phase2InsertEmp(db, empT, empCore)
+	phase3Queries(empT, empCore)
+	_, orderAfterDel := phase4Multi(db, empT, deptT, orderT, empCore)
+	phase5Update(db, empT, empCore)
+	phase6Delete(db, empT, orderT, empCore, orderAfterDel)
+	phase7Schema(db, empT, empCore)
+
+	must(db.Commit())
+	must(db.Close())
+	fmt.Printf("\n  [已关库] 磁盘 %.1fMB | %s\n", float64(dbSize(dbPath))/(1<<20), memLine())
+
+	phase8Probes()
+	phase9Reopen(dbPath)
+	report(start)
+}
+
+
+
+
+

BIN
demo/bench_10m/smoke_full.log


+ 3 - 3
demo/commit_preset_test/_out.log

@@ -6,9 +6,9 @@
 
   预设类型 |   行数  |  耗时  | 文件大小
   ---------+--------+--------+----------
-  batch    |  10000行 |  0.34s |   0.0MB
-  online   |  10000行 |  0.35s |   0.0MB
-  safe     |  10000行 |  0.34s |   0.9MB
+  batch    |  10000行 |  0.26s |   0.0MB
+  online   |  10000行 |  0.26s |   0.0MB
+  safe     |  10000行 |  0.27s |   0.9MB
 
 说明:
   - batch:  高吞吐,适合离线导入 (断电风险: ~10,000行)

+ 5 - 5
demo/dirty_threshold_test/_out.log

@@ -4,11 +4,11 @@
 
 测试场景: 插入 50000 行数据(禁用防抖,仅靠脏页阈值)
 
-  DirtyThreshold=  20页 (  0.3MB) | 插入  50000行 | 耗时:   1.73s | 文件:   4.1MB
-  DirtyThreshold= 100页 (  1.6MB) | 插入  50000行 | 耗时:   1.83s | 文件:   4.7MB
-  DirtyThreshold= 500页 (  7.8MB) | 插入  50000行 | 耗时:   1.68s | 文件:   0.0MB
-  DirtyThreshold=2000页 ( 31.2MB) | 插入  50000行 | 耗时:   1.66s | 文件:   0.0MB
-  DirtyThreshold=5000页 ( 78.1MB) | 插入  50000行 | 耗时:   1.73s | 文件:   0.0MB
+  DirtyThreshold=  20页 (  0.3MB) | 插入  50000行 | 耗时:   1.44s | 文件:   4.1MB
+  DirtyThreshold= 100页 (  1.6MB) | 插入  50000行 | 耗时:   1.48s | 文件:   4.7MB
+  DirtyThreshold= 500页 (  7.8MB) | 插入  50000行 | 耗时:   1.36s | 文件:   0.0MB
+  DirtyThreshold=2000页 ( 31.2MB) | 插入  50000行 | 耗时:   1.32s | 文件:   0.0MB
+  DirtyThreshold=5000页 ( 78.1MB) | 插入  50000行 | 耗时:   1.31s | 文件:   0.0MB
 
 说明:
   - 阈值越小 → Commit 越频繁 → 磁盘 I/O 越多 → 性能越差

+ 15 - 15
demo/full_test/_out.log

@@ -5,13 +5,13 @@
   索引: pk_order, idx_status
 
 ========== 2. 批量插入 100万行 ==========
-  已插入 20000 行 (1.0s)
-  已插入 40000 行 (2.1s)
-  已插入 60000 行 (3.1s)
-  已插入 80000 行 (4.1s)
-  已插入 100000 行 (5.2s)
+  已插入 20000 行 (0.9s)
+  已插入 40000 行 (1.7s)
+  已插入 60000 行 (2.5s)
+  已插入 80000 行 (3.4s)
+  已插入 100000 行 (4.3s)
   插入完成, 开始 Commit...
-  ✅ 插入 100000 行 + Commit 完成, 耗时 5.3s (Commit 0.0s)
+  ✅ 插入 100000 行 + Commit 完成, 耗时 4.3s (Commit 0.0s)
 
 ========== 3. 主键查询 ==========
   ✅ PK 精确查找 id=1
@@ -20,31 +20,31 @@
 
 ========== 4. 条件查询 ==========
   ✅ WHERE Age=25
-    返回 384 行, 耗时 0.011s
+    返回 2000 行, 耗时 0.026s
   ✅ WHERE Age>60
-    返回 16919 行, 耗时 0.180s
+    返回 18000 行, 耗时 0.165s
   ✅ WHERE Age>=20 AND Age<=25
-    返回 10467 行, 耗时 0.103s
+    返回 12000 行, 耗时 0.110s
   ✅ WHERE Score=999 (全表扫描)
     返回 100 行, 耗时 0.018s
 
 ========== 5. 排序 + 分页 ==========
   ✅ ORDER BY Age ASC LIMIT 10
   ✅   排序正确 (前2行 Age 递增)
-    耗时 0.211s
+    耗时 0.176s
   ✅ ORDER BY Age DESC LIMIT 5
   ✅   排序正确 (前2行 Age 递减)
   ✅ ORDER BY Age OFFSET 100 LIMIT 5
 
 ========== 6. 聚合查询 ==========
   ✅ COUNT(*)
-    COUNT=100000, 耗时 0.062s
+    COUNT=100000, 耗时 0.055s
 
 ========== 7. 第二张表: 插入 + 关联查询 ==========
   已插入 5000 行 (0.2s)
   已插入 10000 行 (0.4s)
   ✅ 插入 10000 行 Order + Commit, 耗时 0.4s
-  Order WHERE Status=0: 967 行
+  Order WHERE Status=0: 2500 行
   ✅ Order WHERE Status=0
 
 ========== 8. 更新操作 ==========
@@ -56,7 +56,7 @@
   ✅ DELETE id=2
   ✅ DELETE 后验证 (应找不到)
   ✅ DELETE 11行后 COUNT
-    删除+Commit 耗时 0.094s
+    删除+Commit 耗时 0.098s
 
 ========== 10. 关闭 + 重开验证持久化 ==========
   ✅ 重开后 employee COUNT
@@ -69,9 +69,9 @@
   ✅ 防抖自动提交后重开 COUNT
 
 ========== 12. 删除表 + 空闲页回收 ==========
-  删除表前 FreePageCount=400
+  删除表前 FreePageCount=398
   ✅ DropTable order
-  删除表后 FreePageCount=458 (回收 58 页)
+  删除表后 FreePageCount=456 (回收 58 页)
   ✅ 删除表后空闲页增加
   ✅ order 表已不存在
   ✅ employee 表不受影响

+ 15 - 15
demo/hash_bench/_out.log

@@ -18,32 +18,32 @@
   吞吐总览
 ============================================================
 数据量               插入(行/秒)       提交(ms)        更新(行/秒)        删除(行/秒)     文件(MB)
-10000               17171         23.5          11861           9226       3.21
-50000               21646         93.8          14176          11713      14.66
-100000              21690         48.7          15112          11706      20.35
-200000              21074         77.6          12279          10809      34.44
-400000              13432         61.9           3292           5772      60.99
+10000               23448         22.1          14685          12928       3.13
+50000               27748         89.5          17448          15357      14.66
+100000              28458         44.0          20396          16041      20.25
+200000              28231         69.7          16451          14374      34.27
+400000              17392         55.8           3632           6923      61.02
 
 ============================================================
   查询延迟 (单位: 微秒 µs / 毫秒 ms)
 ============================================================
 数据量                   哈希等值查询(Limit=1)                 主键回表(Find)       哈希全匹配(Count)
                   avg      p50      p99        avg      p50      p99    avg(ms)  p99(ms)
-10000            35.0     35.0     38.0       21.0     21.0     32.0       0.10     0.17
-50000            46.0     46.0     53.0       27.0     28.0     30.0       0.54     0.64
-100000           51.0     51.0     60.0       29.0     29.0     30.0       1.11     1.23
-200000           59.0     58.0     77.0       29.0     29.0     31.0       2.30     2.65
-400000           70.0     69.0     99.0       31.0     31.0     39.0       4.58     5.44
+10000            29.0     29.0     36.0       16.0     16.0     18.0       0.08     0.12
+50000            37.0     37.0     41.0       22.0     23.0     24.0       0.43     0.47
+100000           44.0     43.0     50.0       24.0     24.0     30.0       0.89     0.95
+200000           51.0     51.0     64.0       27.0     27.0     36.0       1.97     2.20
+400000           59.0     58.0     84.0       23.0     23.0     25.0       3.56     3.78
 
 ============================================================
   查询吞吐 (QPS = 1000/avg µs, 按均摊延迟换算)
 ============================================================
 数据量                    哈希等值查询               主键回表              哈希全匹配
-10000                   28571              47619              10000
-50000                   21739              37037               1852
-100000                  19608              34483                904
-200000                  16949              34483                435
-400000                  14286              32258                219
+10000                   34483              62500              12500
+50000                   27027              45455               2336
+100000                  22727              41667               1129
+200000                  19608              37037                507
+400000                  16949              43478                281
 
 注: 每档数据量均执行 插入 N 行 / 2万次哈希查询 / 500次全匹配 /
     2万次主键查询 / 2万次更新 / 2万次删除。删除后剩余行数校验一致。

+ 0 - 0
demo/hash_index_test/_err.log


+ 49 - 0
demo/hash_index_test/_out.log

@@ -0,0 +1,49 @@
+============================================================
+  TsDB2 Hash 桶式索引 — 完整测试
+  覆盖: CRUD/分页/桶分裂(Split)/桶合并(Merge)/持久化/性能
+============================================================
+
+========== 1. 建表 + 自动索引
+  ✅ UseTable[Record] 建表 (自动创建索引)
+  ✅ idx_hash_age (Hash) 已自动创建
+
+========== 2. 大批量插入 (3000 行, 触发多页)
+  ✅ 插入 3000 行 耗时 0.14s (21873 rows/s)
+  ✅ Commit 持久化
+  ✅ Hash 查找: 100/100 个 Age 值可查
+  ✅ PK 查找 ID=100
+  ✅ 行数: 3000 (应=3000)
+
+========== 3. 桶分裂测试 (Split)
+  集中插入 1000 行 Age=25 (全部 hash 到同一桶, 阈值=800)...
+  ✅ 集中插入 1000 行 耗时 0.06s
+  ✅ 分裂后 Hash 查找 Age=25
+  ✅ 分裂后 Hash 查找: 100/100 个 Age 值可查
+  ✅ 分裂后 PK 查找
+  ✅ 分裂后行数: 4000 (应=4000)
+
+========== 4. Hash 索引删除测试
+  ✅ 删除 500 行 (ID 1~500)
+  ✅ 删除后 PK 查找 ID=1 (应不存在)
+  ✅ 删除后 PK 查找 ID=501 (应存在)
+  ✅ 删除后 Hash 查找 Age=25
+
+========== 5. 桶合并测试 (Merge)
+  ✅ 删除 3500 行
+  ✅ 剩余行数: 0 (已删除全部 4000 行)
+  ✅ 合并后 Hash 索引不崩溃
+
+========== 6. 持久化 + 重开验证
+  ✅ 关闭数据库
+  ✅ 重开 UseTable[Record]
+  ✅ 重开后行数: 0 (应=0)
+
+========== 7. 性能对比: Hash vs B+树
+  插入 5000 行: 0.30s (16615 rows/s)
+  Hash  查找 1000 次: 0.021s (47193 qps)
+  B+树 查找 1000 次: 0.027s (37297 qps)
+  ✅ Hash/B+树 性能比: 1.27x
+
+========== 测试总结
+  总计: 23 项测试, ✅ 通过: 23, ❌ 失败: 0
+  🎉 全部通过!Hash 桶式索引工作正常!

+ 0 - 0
demo/hash_verify/_err.log


+ 35 - 0
demo/hash_verify/_out.log

@@ -0,0 +1,35 @@
+============================================================
+  Hash 索引专项验证 (Linear Hashing)
+============================================================
+
+========== 1. 插入 30000 行 (必然触发多次桶分裂)
+  [PASS] 插入 30000 行 耗时 1.22s, Count=30000
+
+========== 2. 索引精确性 (每个 Age 值 + 抽样主键)
+  [PASS] 全部 200 个不同 Age 值的行数完全一致
+  [PASS] SearchByIndex 覆盖全部 200 个 Age 值
+  [PASS] 抽样 811 个主键回表一致
+
+========== 3. RangeScan (键过滤 + 排序, 哈希不保序)
+  [PASS] 范围 [30,40] Count=1100 (期望 1100)
+  [PASS] 范围 [30,40] OrderBy Age 升序, 取回 50 行
+  [PASS] 范围 [30,40] OrderBy Age 降序, 取回 50 行
+
+========== 4. 大量删除 (触发桶合并/收缩)
+  [PASS] 删除 29500 行 耗时 2.27s (0 失败)
+  [PASS] 删除后 Count=500 (期望 500)
+  [PASS] 删除后 Age=25 Count=500 (期望 500)
+  [PASS] 删除后 Age>=30 Count=0 (期望 0)
+
+========== 5. 关闭重开 (持久化)
+  [PASS] 重开后 Count=500 (期望 500)
+  [PASS] 重开后 Age=25 Count=500 (期望 500)
+  [PASS] 重开后 500 个保留主键全部可回表
+  [PASS] 重开后全量扫描取回 500 行
+[DEBUG] Header: TablePage=2 NextFreePage=573 FreePageStart=35 FirstWAL=0 LastWAL=0 NextTxn=3
+[DEBUG] BufferPool: total=0 dirty=0
+
+========== 6. 空间占用
+  [PASS] DB 文件 8.94 MB (313 字节/行)
+
+========== 总结: 通过 16, 失败 0

+ 0 - 0
demo/hash_widekey_test/_err.log


+ 43 - 0
demo/hash_widekey_test/_out.log

@@ -0,0 +1,43 @@
+============================================================
+  Hash 宽键索引测试 — 分裂阈值随桶页容量自适应
+============================================================
+
+========== 1. 插入 3000 行 (同表两个哈希索引, 仅键宽不同)
+  [PASS] 插入 3000 行, Count=3000, 耗时 0.18s
+
+========== 2. 分裂进度: 宽键已分裂, 窄键未分裂
+  [info] idx_tag EntryPage=8 level=4 split=2 total=3000 keyLen=64 目录页=1
+  [info] idx_grp EntryPage=11 level=4 split=0 total=3000 keyLen=8 目录页=1
+  [PASS] idx_tag keyLen=64 → EntryLen=72 → 桶页容量=227 (期望 64/72/227)
+  [PASS] idx_grp keyLen=8 → EntryLen=16 → 桶页容量=1022 (期望 8/16/1022)
+  [PASS] idx_tag 分裂阈值=170 (min(400, 227*3/4)=170), 合并阈值=42
+  [PASS] idx_grp 分裂阈值=400 (1022*3/4=766 > 400, 上限生效), 合并阈值=100
+  [PASS] idx_tag 3000 行 → level=4 splitPointer=2 桶数=18 (期望 4/2/18)
+  [PASS] idx_grp 3000 行 → level=4 splitPointer=0 桶数=16 (期望 4/0/16)
+  [PASS] 两个索引活跃条目数均为 3000
+  [PASS] idx_tag 桶目录页数=1 (18 个槽位应仍在一页内)
+
+  [说明] 若阈值仍是固定 400: idx_tag 要 16*400=6400 行才分裂,
+         本次 3000 行时 splitPointer 会是 0 —— 上面那条断言即为回归护栏
+
+========== 3. 宽键哈希索引正确性 (全量点查 / 分组计数 / 范围扫描)
+  [PASS] 3000 个 64B 键全部点查命中且内容一致 (丢失 0)
+  [PASS] 50 个 Grp 分组的计数全部 = 60
+  [PASS] Grp 范围 [10,19] Count=600 (期望 600; 哈希不保序, 靠全桶扫描+键过滤)
+
+========== 4. 删除 2500 行 → 桶合并收缩
+  [PASS] 删除 2500 行 (失败 0), 剩余 Count=500
+  [info] 删除后 idx_tag level=4 split=0 total=500 桶数=16
+  [PASS] 删除后 idx_tag total=500 splitPointer=0 桶数=16 (期望 500/0/16)
+
+========== 5. 关闭重开 (持久化)
+  [PASS] 关库后磁盘上 idx_tag splitPointer=0 total=500, 与关库前一致
+  [PASS] 重开后 idx_tag EntryPage=8 (关闭前 8)
+  [PASS] 重开后 level=4 splitPointer=0 total=500 与关闭前一致
+  [PASS] 重开后 Count=500 (期望 500)
+  [PASS] 重开后 500 个存活行的宽键点查全部命中 (丢失 0)
+  [PASS] 重开后已删除的 TAG00001 查不到
+
+========== 测试总结
+  总计 20 项, 通过 20, 失败 0
+  [OK] 宽键哈希索引工作正常, 分裂阈值确实随桶页容量收敛

+ 0 - 0
demo/integrity_test/_err.log


+ 63 - 0
demo/integrity_test/_out.log

@@ -0,0 +1,63 @@
+==============================================
+  TsDB2 完整性测试 (100万行)
+==============================================
+
+========== Phase 1: 打开数据库 ==========
+  [初始状态] 磁盘:     0.00 MB | Go堆:    0.8 MB | Sys:    6.3 MB | Goroutines: 1
+
+========== Phase 2: 批量插入 ==========
+  [插入 100000 行] 磁盘:    15.66 MB | Go堆:    6.5 MB | Sys:   64.5 MB | Goroutines: 1
+  [插入 200000 行] 磁盘:    23.41 MB | Go堆:   13.5 MB | Sys:   64.5 MB | Goroutines: 1
+  [插入 300000 行] 磁盘:    38.89 MB | Go堆:    4.5 MB | Sys:   64.5 MB | Goroutines: 1
+  [插入 400000 行] 磁盘:    46.64 MB | Go堆:   11.8 MB | Sys:   64.7 MB | Goroutines: 1
+  [插入 500000 行] 磁盘:    62.14 MB | Go堆:    3.4 MB | Sys:   64.7 MB | Goroutines: 1
+  [插入 600000 行] 磁盘:    69.89 MB | Go堆:    4.4 MB | Sys:   64.7 MB | Goroutines: 1
+  [插入 700000 行] 磁盘:    77.64 MB | Go堆:   13.8 MB | Sys:   64.7 MB | Goroutines: 1
+  [插入 800000 行] 磁盘:    93.13 MB | Go堆:    3.7 MB | Sys:   64.7 MB | Goroutines: 1
+  [插入 900000 行] 磁盘:   100.88 MB | Go堆:    8.9 MB | Sys:   64.7 MB | Goroutines: 1
+  [插入 1000000 行] 磁盘:   116.38 MB | Go堆:    3.5 MB | Sys:   64.7 MB | Goroutines: 1
+  插入 1000000 行耗时: 24.95s (40078 rows/s)
+
+========== Phase 3: Commit ==========
+  [Commit 后] 磁盘:   116.38 MB | Go堆:    1.9 MB | Sys:   64.7 MB | Goroutines: 1
+  Commit 耗时: 0.00s
+
+========== Phase 4: 主键精确查找 ==========
+  查找 8 个样本: 8 成功, 0 失败, 耗时 0.001s
+
+========== Phase 5: 全量扫描验证 ==========
+  扫描行数: 1000000
+  Name 错误: 0
+  Age 错误: 0
+  全量扫描耗时: 1.53s (655062 rows/s)
+
+========== Phase 6: 条件查询 ==========
+  WHERE Age=25: 20000 行, 耗时 0.204s
+  WHERE Score=500: 1000 行, 耗时 0.173s
+
+========== Phase 7: 更新验证 ==========
+  Update id=500: OK (Dept=UPDATED)
+  更新+Commit 耗时: 0.003s
+
+========== Phase 8: 删除验证 ==========
+  Delete id=999: OK (已找不到)
+  删除+Commit 耗时: 0.003s
+  删除后 Count: 999999 (应为 999999)
+
+========== Phase 9: 关闭重开验证 ==========
+  [Close 后] 磁盘:   116.38 MB | Go堆:    1.4 MB | Sys:   64.7 MB | Goroutines: 1
+  重开后 Count: 999999
+  重开后 Find(1): OK (Name=Emp0000001)
+  重开后 Find(1000000): OK (Name=Emp1000000)
+
+========== Phase 10: 最终统计 ==========
+  数据库文件大小: 116.38 MB
+  每行平均占用: 122 bytes
+  理论最小行大小: ~72 bytes (32+8+8+16+8)
+  空间放大系数: 1.7x
+  Go 堆内存: 3.0 MB
+  总分配内存: 161743.5 MB
+
+==============================================
+  ✅ 完整性测试全部通过!
+==============================================

+ 0 - 0
demo/lock_table_test/_err.log


+ 26 - 0
demo/lock_table_test/_out.log

@@ -0,0 +1,26 @@
+==============================================
+  锁表机制测试
+==============================================
+
+测试1: 手动 LockWrites/UnlockWrites
+  ✅ 已锁定写入
+  ⏳ 插入操作正在等待解锁...
+  ✅ 已解锁写入
+  ❌ 锁定期不应允许插入
+  ❌ 测试失败: 锁定期插入了数据
+
+测试2: Commit 期间自动锁表
+  已插入 100 行
+  执行 Commit...
+  ✅ Commit 完成
+  ⚠️  未检测到阻塞(可能 Commit 太快)
+
+测试3: BufferPool 满时阻塞等待
+  已插入 10 行(BufferPool 已满)
+  执行 Commit 释放空间...
+  ✅ Commit 完成,空间已释放
+  ⚠️  未检测到阻塞(可能 Commit 太快)
+
+==============================================
+  所有测试完成!
+==============================================

+ 0 - 0
demo/mem_test/_err.log


+ 57 - 0
demo/mem_test/_out.log

@@ -0,0 +1,57 @@
+========== 1. 打开数据库 ==========
+  表创建/打开成功
+
+========== 2. 插入 200000 行 (禁用防抖, 仅脏页阈值触发) ==========
+[DEBUG] Header: TablePage=2 NextFreePage=147 FreePageStart=0 FirstWAL=0 LastWAL=0 NextTxn=1
+[DEBUG] BufferPool: total=145 dirty=145
+  [ 20000行] Alloc=5.3MB Total=3132.5MB 耗时=0.6s
+[DEBUG] Header: TablePage=2 NextFreePage=286 FreePageStart=0 FirstWAL=0 LastWAL=0 NextTxn=1
+[DEBUG] BufferPool: total=284 dirty=284
+  [ 40000行] Alloc=9.1MB Total=6304.1MB 耗时=1.0s
+[DEBUG] Header: TablePage=2 NextFreePage=425 FreePageStart=0 FirstWAL=0 LastWAL=0 NextTxn=1
+[DEBUG] BufferPool: total=423 dirty=423
+  [ 60000行] Alloc=12.8MB Total=9474.5MB 耗时=1.5s
+[DEBUG] Header: TablePage=2 NextFreePage=1003 FreePageStart=563 FirstWAL=0 LastWAL=0 NextTxn=2
+[DEBUG] BufferPool: total=65 dirty=65
+  [ 80000行] Alloc=3.2MB Total=12678.7MB 耗时=2.1s
+[DEBUG] Header: TablePage=2 NextFreePage=1003 FreePageStart=703 FirstWAL=0 LastWAL=0 NextTxn=2
+[DEBUG] BufferPool: total=205 dirty=205
+  [100000行] Alloc=7.0MB Total=15853.9MB 耗时=2.6s
+[DEBUG] Header: TablePage=2 NextFreePage=1003 FreePageStart=842 FirstWAL=0 LastWAL=0 NextTxn=2
+[DEBUG] BufferPool: total=344 dirty=344
+  [120000行] Alloc=13.5MB Total=19027.1MB 耗时=3.1s
+[DEBUG] Header: TablePage=2 NextFreePage=1003 FreePageStart=980 FirstWAL=0 LastWAL=0 NextTxn=2
+[DEBUG] BufferPool: total=482 dirty=482
+  [140000行] Alloc=15.2MB Total=22199.3MB 耗时=3.5s
+[DEBUG] Header: TablePage=2 NextFreePage=1499 FreePageStart=1119 FirstWAL=0 LastWAL=0 NextTxn=3
+[DEBUG] BufferPool: total=125 dirty=125
+  [160000行] Alloc=5.0MB Total=25407.0MB 耗时=4.2s
+[DEBUG] Header: TablePage=2 NextFreePage=1499 FreePageStart=1259 FirstWAL=0 LastWAL=0 NextTxn=3
+[DEBUG] BufferPool: total=265 dirty=265
+  [180000行] Alloc=10.7MB Total=28581.2MB 耗时=4.7s
+[DEBUG] Header: TablePage=2 NextFreePage=1499 FreePageStart=1397 FirstWAL=0 LastWAL=0 NextTxn=3
+[DEBUG] BufferPool: total=403 dirty=403
+  [200000行] Alloc=15.3MB Total=31753.8MB 耗时=5.1s
+
+  插入完成: 耗时=5.1s, Alloc=7.4MB
+
+========== 3. 手动 Commit ==========
+  Commit 耗时: 0.1s
+
+========== 4. 验证行数 ==========
+  当前行数: 200000 (期望 200000)
+  ✅ 行数正确
+
+========== 5. 关闭 → 重开验证 ==========
+  重开后行数: 200000 (期望 200000)
+  ✅ 数据完整持久化
+
+========== 6. 抽查数据 ==========
+  Row#1: Name=user_0 Age=20 Score=0 Dept=dept_0
+  Row#200000: Name=user_199999 Age=69 Score=999 Dept=dept_9
+
+========== 总结 ==========
+  最终 Alloc=1.9MB, TotalAlloc=32012.8MB
+  插入耗时: 5.1s
+  脏页阈值: 10000 页 (≈156MB)
+  ✅ 内存控制良好 (< 200MB), 脏页阈值生效!

+ 0 - 0
demo/no_commit_test/_err.log


+ 48 - 0
demo/no_commit_test/_out.log

@@ -0,0 +1,48 @@
+==============================================
+  TsDB2 不手动 Commit 场景测试
+==============================================
+
+========== 场景 1: Insert → Close (无手动 Commit) ==========
+  插入 10000 行 (未手动 Commit)
+  Close 后文件大小: 2.41 MB
+  重开后 Count: 10000, Find(1).Name: Emp0000001
+  ✅ 场景 1 通过: Close 自动提交, 数据持久化
+
+========== 场景 2: Insert → 等待 2 秒 (自动提交) ==========
+  插入 5000 行 (未手动 Commit)
+  等待 2 秒让自动提交触发...
+  等待后文件大小: 1.32 MB
+  重开后 Count: 5000
+  ✅ 场景 2 通过: 防抖自动提交生效
+
+========== 场景 3: 禁用自动提交 → Insert → Close ==========
+  插入 5000 行 (禁用自动提交, 未手动 Commit)
+  Close 后文件大小: 1.29 MB
+  重开后 Count: 5000
+  ✅ 场景 3 通过: Close 兜底提交生效
+
+========== 场景 4: 大量 Insert 触发脏页阈值自动提交 ==========
+  [20000 行] 文件大小: 0.00 MB
+  [40000 行] 文件大小: 0.00 MB
+  [60000 行] 文件大小: 0.00 MB
+  [80000 行] 文件大小: 15.66 MB
+  [100000 行] 文件大小: 15.66 MB
+  插入 100000 行 (仅靠脏页阈值自动提交)
+  Close 后文件大小: 15.66 MB
+  重开后 Count: 100000, Find(50000).Name: Emp0050000
+  ✅ 场景 4 通过: 脏页阈值 + Close 兜底
+
+========== 场景 5: 100 万行, 不手动 Commit ==========
+  [200000 行] 文件: 23.4 MB, 耗时: 5.2s
+  [400000 行] 文件: 46.6 MB, 耗时: 10.5s
+  [600000 行] 文件: 69.9 MB, 耗时: 15.8s
+  [800000 行] 文件: 93.1 MB, 耗时: 21.0s
+  [1000000 行] 文件: 116.4 MB, 耗时: 26.2s
+  插入完成, 耗时: 26.16s
+  Close 后文件: 116.38 MB
+  重开后 Count: 1000000, Find(1): Emp0000001, Find(1000000): Emp1000000
+  ✅ 场景 5 通过: 100 万行无手动 Commit, 数据完整!
+
+==============================================
+  测试完成!
+==============================================

+ 0 - 0
demo/orm_full/_err.log


+ 102 - 0
demo/orm_full/_out.log

@@ -0,0 +1,102 @@
+==================================================================
+  TsDB2 ORM 全量特性验证
+  主键表(_id) / Hash 二级 / B+ 二级 / 时序表(_ts)
+==================================================================
+
+==================================================================
+【0. 建表与索引清单 (索引类型由 vrtx tag 自动决定)】
+  employee.pk_employee   IndexType=2  EntryPage=0
+  employee.idx_age       IndexType=6  EntryPage=0
+  employee.idx_score     IndexType=5  EntryPage=0
+  metric.pk_metric       IndexType=1  EntryPage=0
+  [OK]   Age 字段使用 Hash 索引 (IndexType=6)
+  [OK]   Score 字段使用 B+ 树索引 (IndexType=5)
+  [OK]   主键 _id 使用 Primary 索引 (IndexType=2)
+  [OK]   时序主键 _ts 使用 Time 索引 (IndexType=1)
+
+==================================================================
+【1. 主键表 CRUD (Employee: _id + Hash(Age) + B+(Score))】
+  [OK]   Insert 20000 行 (0.98s, 20410 行/s), Count=20000
+  [OK]   主键 Find(12345) 命中且字段正确
+  [OK]   Hash 等值查询 Age=25 → 10000 行 (期望 10000), 均摊 43.384183ms
+  [OK]   Hash 范围查询 Age∈[60,70] → 2000 行 (期望 2000), 均摊 18.16533ms (全桶扫描)
+  [OK]   B+ 范围查询 Score∈[200,299] → 2000 行, 有序=true, 均摊 21.993054ms
+  → 延迟对比: Hash 等值 43.384183ms  |  Hash 范围 18.16533ms  |  B+ 范围 21.993054ms
+  [OK]   OrderBy(Score desc).Limit(5) 返回 5 行且分数降序
+  [OK]   Offset(10).Limit(5) 分页返回 5 行
+  [OK]   Where(Dept=D1).Count()=4000 (期望 4000, 全表扫描无索引)
+  [OK]   First() 返回单条
+  [OK]   Scan 流式遍历 Limit(100) → 100 行
+  [OK]   FindAll() → 20000 行
+  [INFO] Update 只传 Score 后: Name="" Age=0 Score=777 Dept="" (改前 Name="Emp001001")
+  [OK]   Update 为整行覆盖语义 (未赋值字段被清零)
+  [OK]   改 Age(25→123) 100 行后: Age=25 → 9900 (期望 9900), Age=123 → 100 (期望 100)
+  [OK]   改 Score(1→5000): 旧值计数=18, 新值计数=1
+  [OK]   Delete 200 行: Count 20000→19800 (失败 0), Find(1)/(200) 失败, Find(201) 成功
+  [OK]   删除后 Hash 索引级联清理: Age=123 → 0 (期望 0); B+ 索引级联清理: Score=5000 → 0 (期望 0)
+
+==================================================================
+【2. 时序表 CRUD (Metric: _ts + 时序索引)】
+  [OK]   顺序写入 20000 个时间点 (0.31s, 64148 点/s), Count=20000
+  [OK]   时序主键 Find(ts) 精确命中
+  [OK]   时间范围 [_ts∈3.0s~3.999s] → 1000 行 (期望 1000), 有序=true, 在界内=true, 均摊 1.042896ms
+  [OK]   OrderBy(_ts desc).Limit(10) 取到最新 10 个点且降序
+  [OK]   Where(Host=h2).Count()=5000 (期望 5000, 无索引全扫描)
+  [OK]   时序行 Update → Find 校验 Value=9999
+  [OK]   时序行 Delete(ts) 后: Find 失败, Count=19999 (期望 19999)
+
+==================================================================
+【3. 时序写入的两个关键特征 (乱序 / 缺省时间戳)】
+  [OK]   LateMetric 顺序写入 1000 点, Count=1000
+  [INFO] 乱序补写 3 个中间时间点: 插入错误=0, 主键 Find 命中=3/3, 时间范围扫描回读=1003 行 (期望 1003)
+  [OK]   乱序补写的点可被主键 Find 命中
+  [OK]   时间范围扫描包含乱序补写的点 (1003 行)
+  [INFO] 不设置 _ts 插入后, 结构体回写得到的 TS=1004
+  [OK]   缺省 _ts 时由 ORM 自动分配 (非真实时间戳, 等于行序号)
+
+==================================================================
+【4. 事务 (ORM 增删改 + 提交/回滚)】
+  [OK]   事务内插入 10 行并提交 → Where(Dept=TX)=10
+  [OK]   事务内插入后回滚 → Where(Dept=RB)=0 (期望 0)
+
+==================================================================
+【5. 关闭重开 (持久化验证)】
+  [OK]   重开后 employee Count 19810 → 19810
+  [OK]   重开后 metric Count 19999 → 19999
+  [OK]   重开后主键 Find(201) 命中
+  [OK]   重开后时序 Find(ts) 命中
+  [OK]   重开后 Hash 索引可用: Age=25 → 9900
+  [OK]   重开后 B+ 索引可用: Score∈[300,399] → 2010 行
+
+  数据库文件: 6.61 MB
+
+==================================================================
+【6. 四种索引形态特性对比】
+  主键 _id
+      IndexType    = 2 Primary
+      底层结构     = B+ 树
+      叶子/条目    = 叶子直接存行数据(DataPage)
+      查询复杂度   = 等值/范围 O(logN), 天然按 pk 有序
+      ORM 声明     = 内嵌 DB.BaseModel
+  Hash 二级
+      IndexType    = 6 Hash
+      底层结构     = 桶目录 + 溢出链
+      叶子/条目    = 叶子存 pkID, 需回主键取行
+      查询复杂度   = 等值 O(1); 范围=全桶扫描 O(N)
+      ORM 声明     = vrtx:"index:x,type:hash"
+  B+ 二级
+      IndexType    = 5 BTree
+      底层结构     = B+ 树
+      叶子/条目    = 叶子存 pkID, 需回主键取行
+      查询复杂度   = 等值/范围/有序 均 O(logN)
+      ORM 声明     = vrtx:"index:x" (默认)
+  时序 _ts
+      IndexType    = 1 Time
+      底层结构     = 扁平时间条目链
+      叶子/条目    = [Begin,Last,DataPageID] → 分段 DataPage
+      查询复杂度   = 时间范围只加载命中分段; 等值需扫段内行
+      ORM 声明     = 内嵌 DB.TimeSeriesModel
+
+==================================================================
+  总计: 通过 39, 失败 0
+==================================================================

+ 0 - 0
demo/stress_extreme_config/_err.log


+ 19 - 0
demo/stress_extreme_config/_out.log

@@ -0,0 +1,19 @@
+==============================================
+  极限小配置压力测试
+==============================================
+
+测试场景: 插入 2000 行数据(极端小配置)
+
+BP(页)    DT(页)    耗时         结果         内存占用
+-------- -------- ---------- ---------- -------------------
+10       5        0.06s      ✅          总=0.23MB
+10       10       0.04s      ✅          总=0.31MB
+20       10       0.05s      ✅          总=0.47MB
+20       20       0.04s      ✅          总=0.62MB
+50       10       0.05s      ✅          总=0.94MB
+50       20       0.04s      ✅          总=1.09MB
+50       50       0.04s      ✅          总=1.56MB
+
+结果: 7 通过, 0 失败
+
+✅ 极限配置也能工作!

+ 0 - 0
demo/stress_small_config/_err.log


+ 24 - 0
demo/stress_small_config/_out.log

@@ -0,0 +1,24 @@
+==============================================
+  小配置压力测试
+==============================================
+
+测试场景: 插入 5000 行数据
+
+配置组合测试:
+  BP=BufferPool容量, DT=DirtyThreshold
+
+BP(页)    DT(页)    耗时         结果         说明
+-------- -------- ---------- ---------- -------------------
+100      10       0.14s      ✅ PASS     BP=1.6MB, DT=0.2MB
+100      50       0.12s      ✅ PASS     BP=1.6MB, DT=0.8MB
+100      100      0.13s      ✅ PASS     BP=1.6MB, DT=1.6MB
+200      50       0.14s      ✅ PASS     BP=3.1MB, DT=0.8MB
+200      100      0.12s      ✅ PASS     BP=3.1MB, DT=1.6MB
+500      100      0.12s      ✅ PASS     BP=7.8MB, DT=1.6MB
+500      200      0.12s      ✅ PASS     BP=7.8MB, DT=3.1MB
+1000     200      0.12s      ✅ PASS     BP=15.6MB, DT=3.1MB
+1000     500      0.12s      ✅ PASS     BP=15.6MB, DT=7.8MB
+
+结果: 9 通过, 0 失败
+
+✅ 所有配置都正常工作!

+ 0 - 0
demo/stress_test/_err.log


+ 87 - 0
demo/stress_test/_out.log

@@ -0,0 +1,87 @@
+============================================================
+  TsDB2 大数据压力测试 (50万行 Employee + 10万行 Order)
+  覆盖: 批量插入/多索引/事务/WAL/条件查询/排序/聚合/持久化
+============================================================
+
+================================================== 1. 打开数据库
+  ✅ OpenDB
+  配置: BufferPool=2000页(32MB), DirtyThreshold=500页
+  [初始状态] 磁盘: 0.0MB | Go堆=1.0MB Sys=6.3MB
+
+================================================== 2. 建表 + 多索引
+  ✅ UseTable[Employee]
+  ✅ 索引: pk_employee + idx_age + idx_dept + idx_score (标签自动创建)
+  ✅ UseTable[Order]
+  ✅ 索引: pk_order + idx_status + idx_userid (标签自动创建)
+
+================================================== 3. 批量插入 Employee (50万行)
+  [100000行] 耗时: 5.0s | 磁盘: 30.7MB | Go堆=5.6MB Sys=73.2MB
+  [200000行] 耗时: 10.9s | 磁盘: 54.0MB | Go堆=11.5MB Sys=81.2MB
+  [300000行] 耗时: 22.7s | 磁盘: 79.6MB | Go堆=13.8MB Sys=81.5MB
+  [400000行] 耗时: 41.4s | 磁盘: 101.9MB | Go堆=12.5MB Sys=81.5MB
+  [500000行] 耗时: 61.0s | 磁盘: 124.1MB | Go堆=13.4MB Sys=81.7MB
+  ✅ 插入 500000 行耗时: 61.00s (8196 rows/s)
+
+================================================== 4. Commit (WAL 持久化)
+  ✅ DB.Commit()
+  Commit 耗时: 0.071s | 磁盘: 124.1MB | Go堆=29.8MB Sys=81.7MB
+
+================================================== 5. 批量插入 Order (10万行)
+  ✅ 插入 100000 行 Order 耗时: 3.87s (25845 rows/s)
+  ✅ Order Commit
+  磁盘: 132.1MB | Go堆=12.5MB Sys=81.7MB
+
+================================================== 6. 主键精确查找 (性能)
+  ✅ 6 次主键查找 耗时: 0.0000s (+Inf finds/s)
+
+================================================== 7. 条件查询 (二级索引)
+  ✅ WHERE Age=25: 10000 行, 耗时: 0.121s
+  ✅ WHERE Dept='Engineering': 62500 行, 耗时: 0.508s
+  ✅ WHERE Score>900: 49500 行, 耗时: 0.611s
+  ✅ WHERE Age>=30 AND Age<=40: 110000 行, 耗时: 0.962s
+
+================================================== 8. 排序 + 分页
+  ✅ ORDER BY Score DESC LIMIT 10, 耗时: 1.088s
+    Top3: Score=999, 999, 999
+  ✅ ORDER BY Age ASC OFFSET 10000 LIMIT 100, 耗时: 0.898s
+
+================================================== 9. 聚合查询
+  ✅ COUNT(*): 500000, 耗时: 0.289s
+
+================================================== 10. 事务: 批量插入 + 提交
+  ✅ 事务插入 10000 行 + Commit, 耗时: 0.71s
+  ✅ 验证: ID=500001 存在 (Name=Txn0500001)
+
+================================================== 11. 事务: 回滚
+  事务内插入 5000 行
+  ✅ Transaction.Rollback()
+
+================================================== 12. 更新 + 删除 (大批量)
+  ✅ 更新 Score>950 → 9999: 25480 行, 耗时: 7.592s
+  ✅ 删除 Dept='Legal' 前100行: 100 行, 耗时: 3.534s
+  ✅ 验证 Delete(7) 成功
+  ✅ 更新+删除后 Commit
+
+================================================== 13. 关闭 + 重开验证持久化
+  关闭前磁盘: 143.1MB
+  ✅ DB.Close()
+  ✅ 重新打开数据库
+  ✅ 重开后 COUNT: 509900 (期望 509900, 事务已持久化)
+  ✅ 重开后 Find(951): Score=9999 (更新持久化)
+  ✅ 重开后回滚数据不存在
+  重开后磁盘: 143.1MB | Go堆=1.3MB Sys=326.8MB
+
+================================================== 14. 全量扫描验证
+  ✅ 全量扫描: 509900 行, 耗时: 0.69s (737905 rows/s)
+
+================================================== 15. Order 表验证
+  ✅ Order COUNT: 100000
+  ✅ Order WHERE Status=0: 20000 行
+
+============================================================
+  测试结果: 32 通过, 0 失败
+  数据量: Employee=500000 + Order=100000
+  磁盘大小: 143.1MB
+  Go堆=9.1MB Sys=326.8MB
+  🎉 全部通过!
+============================================================

+ 0 - 0
demo/txn_test/_err.log


+ 21 - 0
demo/txn_test/_out.log

@@ -0,0 +1,21 @@
+==============================================
+  TsDB2 事务功能测试
+==============================================
+
+========== 测试1: 正常事务提交 ==========
+✅ 插入 5 行并提交,当前行数: 5
+
+========== 测试2: 事务回滚 ==========
+回滚前行数: 5
+插入 5 行后(未提交): 10
+⚠️  已调用 Rollback()(简化版不立即清除 BufferPool)
+   实际回滚依赖 WAL 恢复机制
+
+========== 测试3: 验证数据持久化 ==========
+重开后行数: 5(应为 5,回滚的 5 行不应存在)
+Row#1: Name=User001, Age=21
+✅ Row#6 不存在(已回滚)
+
+==============================================
+  测试完成!
+==============================================

+ 0 - 0
demo/wal_rowlevel_test/_err.log


+ 86 - 0
demo/wal_rowlevel_test/_out.log

@@ -0,0 +1,86 @@
+===== 测试 1: 普通 CRUD + WAL Commit =====
+
+--- 插入 10 行 ---
+插入完成, TotalRows=10
+
+--- 更新 ID=3, ID=5 ---
+Update(3) OK
+Update(5) OK
+
+--- 删除 ID=7, ID=9 ---
+Delete(7) OK
+Delete(9) OK
+
+操作后 TotalRows=8
+
+--- 显式 Commit (WAL) ---
+Commit OK
+
+--- 验证查询 ---
+  ✅ ID=3: 找到 -> ID=3 Name=User_3_Updated Age=99 
+  ✅ ID=5: 找到 -> ID=5 Name=User_5_Updated Age=88 
+  ✅ ID=7: 正确不存在
+  ✅ ID=9: 正确不存在
+
+数据库已关闭 (不模拟崩溃, 正常关闭)
+
+===== 测试 1 验证: 重新打开数据库 =====
+TotalRows=8 (期望: 8)
+
+--- 验证查询 ---
+  ✅ ID=1: 找到 -> ID=1 Name=User_1 Age=21 
+  ✅ ID=3: 找到 -> ID=3 Name=User_3_Updated Age=99 
+  ✅ ID=5: 找到 -> ID=5 Name=User_5_Updated Age=88 
+  ✅ ID=7: 正确不存在
+  ✅ ID=9: 正确不存在
+  ✅ ID=10: 找到 -> ID=10 Name=User_10 Age=30 
+
+✅ 测试 1 通过: 普通 CRUD + WAL Commit 数据持久化成功
+
+===== 测试 2: 事务 CRUD + WAL =====
+
+--- 非事务插入 5 行 ---
+基础数据 TotalRows=5
+
+--- 开启事务 ---
+--- 事务内插入 5 行 (ID=6~10) ---
+--- 事务内更新 ID=2 ---
+--- 事务内删除 ID=4 ---
+
+事务内操作后 TotalRows=9 (期望: 9, 5+5-1)
+
+--- 事务 Commit ---
+事务 Commit OK
+数据库已关闭
+
+===== 测试 2 验证: 重新打开数据库 =====
+TotalRows=9 (期望: 9)
+  ✅ ID=1: 找到 -> ID=1 Name=Base_1 Age=21 
+  ✅ ID=2: 找到 -> ID=2 Name=Base_2_TxnUpdated Age=77 
+  ✅ ID=4: 正确不存在
+  ✅ ID=6: 找到 -> ID=6 Name=Txn_6 Age=36 
+  ✅ ID=10: 找到 -> ID=10 Name=Txn_10 Age=40 
+
+✅ 测试 2 通过: 事务 CRUD + WAL 数据持久化成功
+
+
+===== 测试 3: 事务 Rollback =====
+--- 插入 5 行基础数据 ---
+基础数据 TotalRows=5
+
+--- 开启事务, 插入 5 行 (ID=6~10) ---
+事务内 TotalRows=10 (期望: 10)
+
+--- 事务 Rollback ---
+Rollback OK
+回滚后 TotalRows=5 (期望: 5)
+  ✅ ID=1: 找到 -> ID=1 Name=Base_1 
+  ✅ ID=6: 正确不存在
+  ✅ ID=10: 正确不存在
+
+--- 重新打开数据库验证 ---
+重开后 TotalRows=5 (期望: 5)
+  ✅ ID=1: 找到 -> ID=1 Name=Base_1 
+  ✅ ID=6: 正确不存在
+
+✅ 测试 3 通过: 事务 Rollback 正确