瀏覽代碼

固定大小有问题

yl32940 1 月之前
父節點
當前提交
6ef1c034cd
共有 2 個文件被更改,包括 39 次插入 和 22 次删除
  1. 21 12
      README.md
  2. 18 10
      docs/OPERATION_TUTORIAL.md

+ 21 - 12
README.md

@@ -123,7 +123,8 @@ func main() {
 		panic(err)
 	}
 
-	// 增:ID 为 0 时由 ORM 自动分配(= 当前行序号 + 1),并回写到结构体
+	// 增:ID 为 0 时由 ORM 向引擎要一个只增不减的自增号(从当前最大存活
+	// pkID 之后继续,删除行不会让号回退),并回写到结构体
 	_ = emp.Insert(&Employee{Name: "Alice", Age: 25, Score: 90, Dept: "D1"})
 
 	// 查:主键点查
@@ -219,7 +220,7 @@ u.Delete()                          // 删
 | 写法 | 含义 |
 |------|------|
 | `vrtx:"pk"` | 该字段是本表**主键**(列名就是字段名),即**覆盖**所内嵌基类默认的键列(`_id` / `_ts` 不再建列)。写 `vrtx:"_id"` / `vrtx:"_ts"` 等价。注意它**不能替代基类**:只标 `pk` 而不内嵌基类报 `ErrNotModel` |
-| `vrtx:"maxlen:32"` | 字符串按 32 字节定长存储(≤32/64/128/256 就近取档,>256 转动态列);`N` 的上界是 **16360 字节**,超了 `ParseModel` 直接报错 |
+| `vrtx:"maxlen:32"` | 字符串定长存储(16~2048 共 24 档就近**向上**取整,约 1.25 倍递增,如 20→24B、200→224B);`N` 超过 2048 转动态列,上界 **16360 字节**,超了 `ParseModel` 直接报错 |
 | `vrtx:"index"` | 建**默认 B+ 二级索引**,索引名自动为 `idx_<表名>_<列名>` |
 | `vrtx:"index:idx_age"` | 建 B+ 二级索引并指定索引名 |
 | `vrtx:"index:idx_age,type:hash"` | 建 Hash 二级索引 |
@@ -356,6 +357,9 @@ func (db *DB) SetCommitPreset(preset string)       // "batch" | "online" | "safe
 func (db *DB) SetBufferPoolCapacity(capacity int)  // BufferPool 容量(页)
 func (db *DB) LockWrites()                         // 后续页面写入阻塞等待
 func (db *DB) UnlockWrites()
+func (db *DB) VACUUM(opts ...core.VACUUMOptions) (*core.VACUUMResult, error) // 整库重写:真正缩小文件并归还空闲页给 OS(页内墓碑回收走自动 compact,文件大小不变;VACUUM 任何阶段失败则旧库原样保留)
+func (db *DB) SetAutoVACUUM(interval time.Duration, opts ...core.VACUUMOptions) // 周期自动 VACUUM(0 = 关闭)
+func (db *DB) LastVACUUM() time.Time               // 上次(自动)VACUUM 的时间
 
 // 事务
 func (db *DB) BeginTransaction() *core.Transaction
@@ -444,7 +448,7 @@ func (table *Table) InsertRow(values []types.FieldValue) (uint64, error)
 func (table *Table) SearchByIndex(idxName string, key []byte) ([]types.FieldValue, error)
 func (table *Table) UpdateRow(idxName string, key []byte, values []types.FieldValue) error
 func (table *Table) DeleteRow(idxName string, key []byte) error
-func (table *Table) CreateIndex(idxName string, typ page.IndexType, fields []byte) (*IndexMeta, error)
+func (table *Table) CreateIndex(idxName string, typ page.IndexType, fields []byte, unique ...bool) (*IndexMeta, error)
 func (table *Table) DropIndex(idx string) error
 func (table *Table) BuildIndexKey(idxName string, values ...interface{}) ([]byte, error)
 func (table *Table) TotalRows() uint64
@@ -649,7 +653,7 @@ type File struct {
 ```go
 type HeaderPage struct {
 	Magic         [4]byte   // 魔术字 "VRTX"
-	Version       uint32    // 格式版本,当前 = 7(core.Version)
+	Version       uint32    // 格式版本,当前 = 10(core.Version)
 	PageSize      uint32    // 数据页大小 (16384)
 	NextFreePage  uint32    // 下一个未分配页(0 头页 / 1 建库预留,故从 2 开始)
 	TablePage     uint32    // 表链首页 ID
@@ -685,10 +689,13 @@ type HeaderPage struct {
 - `v8`:DataPage 引入槽位目录(行号稳定),主键 B+ 树叶条目由 `[key(8)+dpID(4)]` 12 字节扩为
   `[key(8)+dpID(4)+slot(2)]` 14 字节 —— 命中叶即可按 `(dpID, slot)` O(1) 取行。旧库按 12 字节解析会读错
   `dpID`,不兼容需重建。
-- `v9`(当前):字段类型枚举整体重排并改用语义名 —— `1/2/4/8byte` → `Uint8/16/32/64`,定长字符串的
+- `v9`:字段类型枚举整体重排并改用语义名 —— `1/2/4/8byte` → `Uint8/16/32/64`,定长字符串的
   `16/32/64/128/256byte` 五档合并为单一 `String`(行内宽度全交 Flag 低 5 位的宽度码)。类型值编号与 v8
   完全不同(如 `Uint8` 由 1 变 2、`Int8` 由 12 变 3),旧库按 v8 解析会把类型字节当成另一种类型:行内宽度
   与解码语义同时错位,不兼容需重建。主键列 / 时序键 `_ts` 仍留在 `FieldType_Uint64`(无符号、补码)。
+- `v10`(当前):`FieldType_Blob` 行内由 6B 堆引用改为 8B `BlobRef` 直引链页(流式多页链),内容上限
+  从单堆页 ~16KB 扩到链页总 ≈4GB;Update / Delete / DropTable 自动释放旧 blob 链。旧库 blob 列的
+  6B 引用会被误读成 BlobRef 高 6B(TotalLen 异常),不兼容需重建任何带 Blob 列的表。
 
 ### 页大小与公共页头
 
@@ -972,10 +979,10 @@ TsDB2/
 │
 ├── cmd/vrtxsql/                         # 命令行迁移工具(见「跨库迁移」)
 │
-├── demo/                                # 20 个可运行示例 / 验证程序(见下节)
+├── demo/                                # 27 个可运行示例 / 验证程序(见下节)
 ├── docs/                                # 设计与操作文档
 ├── Makefile                             # build / test / fmt / lint / demo-analyzer
-├── go.mod                               # module git.ycl.pub/yl32940/TsDB2(go 1.25;引擎零依赖,迁移层带 sqlite/mysql 驱动)
+├── go.mod                               # module git.ycl.pub/yl32940/TsDB2(go 1.24;引擎零依赖,迁移层带 sqlite/mysql 驱动)
 └── README.md
 ```
 
@@ -992,7 +999,7 @@ TsDB2/
 | `go run ./demo/orm_full` | 4 种索引形态 CRUD + 事务 + 持久化 + 索引特性对比 + 头部增删改查 / 自定义主键 / 拒绝无基类模型 | **47 通过 / 0 失败** |
 | `go run ./demo/full_test` | 10 万行端到端:CRUD + 条件 + 排序 + 聚合 + 持久化 + 页回收 | **28 通过 / 0 失败** |
 | `go run ./demo/feature_demo` | 特性清单逐项验证(含防抖提交、批量插入、FindAll、表基类约定) | **64 通过 / 0 失败** |
-| `go run .` | core 层最小示例:建表 + 时序索引 + Inserter/Updater/Deleter | 正常输出 |
+| `go run ./demo/manual_test` | core 层最小示例:建表 + 时序索引 + Inserter/Updater/Deleter | 正常输出 |
 
 ### 索引专项
 
@@ -1031,6 +1038,7 @@ TsDB2/
 | 命令 | 内容 | 实测结果 |
 |------|------|----------|
 | `go run ./demo/blob_stream` | Blob 流式存储 5 场景:空流 / 8KB 不压缩 / 1MB Snappy / 网络中断回滚 / 100MB 随机流 | **全部通过**(链式 blob,单值上限 ~4GB,可选 Snappy) |
+| `go run ./demo/blob_orm` | ORM 层 Blob 6 场景:32MB 流式写 / 流式读 CRC / []byte 对照 / 改名 Update 保链 / 重开持久化 / 删行归还链页 | **全部通过** |
 | `go run ./demo/unique_index_test` | 唯一索引正确性 / 跨二级索引冲突 / 重建 / 持久化 | 通过 |
 | `go run ./demo/unique_orm_test` | ORM 层面 `vrtx:"unique"` 与 `index:NAME` 复合唯一键 | 通过 |
 | `go run ./demo/null_bitmap_test` | NULL 位图:可空列 + 多列组合 + 持久化 + 索引对 NULL 的语义 | 通过 |
@@ -1104,6 +1112,7 @@ DSN 前缀决定库的种类,冒号后面整段就是它的标识(路径 / 
 | `-auto-pk` | 源表没有整数主键时自动补 `_id` 列(默认报错) |
 | `-continue-on-error` | 某张表失败时跳过它、继续迁其余表(默认第一个错误就中止) |
 | `-loose` | 值装不进目标列时按引擎语义截断(默认报错,免得静默丢数据) |
+| `-truncate-oversize` | 行超过引擎整行上限(8171B)时截断而非报错——`INTO VRTX` 导入超大行时用 |
 | `-no-meta` | 不写 `__vrtx_meta`,放弃定长档位 / 索引类型的还原 |
 | `-v` | 打印每张表的进度 |
 
@@ -1127,7 +1136,7 @@ fmt.Println(rep.String())                 // 每张表的行数 / 列数 / 索
 
 SQL 表达不了 VRTX 的这些细节,所以导出时会在 SQL 侧额外建一张隐藏表 `__vrtx_meta` 记着:
 
-- 每列的**定长档位**(1/2/4/8/16/32/64/128/256 / Float64 / Int8/16/32/64 / 变长)——否则 `INT` 回来时无法区分 4 字节还是 8 字节,也无从分辨有符号档与无符号档;
+- 每列的**定长档位**(`FieldType` 原始字节:覆盖 `Bool`/有符号与无符号各档整数/`Float32`/`Float64`/`Int128`/`UUID`/`Decimal`/`String`(16~2048 宽度码)/变长/Blob 全部语义类型)——否则 `INT` 回来时无法区分 4 字节还是 8 字节,也无从分辨有符号档与无符号档;
 - 每列的**可空性**(`NULL` / `NOT NULL`)与**列级唯一声明**——SQL 的列级 `UNIQUE` 在读回时只剩一条自动索引,列上"声明过唯一"这半信息只有这里记得住;
 - 行存储索引的**类型**(primary / time)与**名字**(`pk_<表名>`);
 - 二级索引的**类型**(B+ 树 / Hash / 复合)、分量列顺序与**唯一标记**(`UNIQUE INDEX` ⇄ `IndexFlagUnique`)。
@@ -1163,7 +1172,7 @@ SQL 表达不了 VRTX 的这些细节,所以导出时会在 SQL 侧额外建
 3. **动态列不能建索引**:`[]byte`、`maxlen>2048` 的字符串映射为动态列,行内只存 6B 堆引用,`UseTable` 会拒绝为其建索引。
 4. **Hash 索引不保序**:`type:hash` 的等值查询 O(1),但范围 / 排序查询退化为全桶扫描;需要范围 / 有序时请用默认 B+ 二级索引。
 5. **索引分量变更需显式处理**:表已存在时,若同名索引在模型中的分量列表与库中不一致,`UseTable` 会**报错**(避免静默使用少一列的索引),需先删索引再重建。
-6. **主键自动分配是「只增不减」的游标**:结构体主键为 0 时向引擎要一个自增号(首次使用时从当前最大存活 pkID 之后开始),**删除不会让编号回退**,不会与存活行撞号;结构体主键非 0 时按该值插入(`_id` 列与引擎 pkID 保持一致),重复报 `ErrDuplicatePK`。自动号不是真实时间戳。**时序表是唯一的例外**:时间键不是行号,所以无论 `_ts` 是否给了值,引擎都会分配一个新行号 —— 给了 `_ts` 就原样使用、不写回、也不当行号(同刻多行因此能共存);完全没给(`_ts == 0`)才会把刚分配的行号回填进 `_ts`,让这行至少落在某个明确时刻上(回填的是编号,不是真实时间戳)。
+6. **主键自动分配是「只增不减」的游标**:结构体主键为 0 时向引擎要一个自增号(首次使用时从当前最大存活 pkID 之后开始),**删除不会让编号回退**,不会与存活行撞号;结构体主键非 0 时按该值插入(`_id` 列与引擎 pkID 保持一致),重复报 `ErrDuplicatePK`。自动号不是真实时间戳。**时序表是唯一的例外**:时间键不是行号,所以无论 `_ts` 是否给了值,引擎都会分配一个新行号 —— 给了 `_ts` 就原样使用、不写回、也不当行号(同刻多行因此能共存);完全没给(`_ts == 0`)才会补上**写入这一刻的纳秒时间戳**(与显式传入的时间戳同量纲;同一纳秒内连插多行允许,同刻多行由行号区分)。
 7. **`LockWrites` 是「阻塞」而非「拒绝」,锁的是「页落盘」**:它挡住的是 `BufferPool.WritePage`(正在写盘的 goroutine 阻塞等待),`Insert` / `Update` / `Delete` 本身照常改内存中的页并标记脏,**不会返回任何错误** —— 引擎里没有"写入被拒绝"这种错误码。因此锁定期写入既不报错也不丢,只是落盘被推迟到 `UnlockWrites`(或 `Commit` 内部临时加锁结束)之后。`demo/lock_table_test` 按这套语义断言:锁定期写入不报错且一条不丢、写入过程中脏页阈值触发提交不丢行、`BufferPool` 容量满时淘汰干净页而不是死锁、关闭重开后数据仍在。
 8. **单进程嵌入式,且是「单写入者」模型**:只面向单进程,**同一时刻只允许一个 goroutine 写** —— `Insert` / `Update` / `Delete` / `Commit` 都应由同一个写入者依次调用;不提供多进程写并发控制与分布式复制。多 goroutine 的正确用法是「一个写 + 只读」,但引擎对并发**读**没有一致性保证:一次写入要分多页依次落到缓存,读可能撞上写入的中间态(读到"新叶子 + 旧父节点"这种半成品)。要做到读取端严格一致,请把读写也串起来(或者写完之后再读)。**两个 goroutine 同时写、或一个 goroutine 写的同时另一个 goroutine 调 `Commit` 会崩**:`CollectDirty` 把脏页移出缓冲池之后、回写磁盘之前,这一页既不在缓存里也还不在盘上,并发的读会从盘上读到全零页(实测稳定 panic:`btPID: slice bounds out of range [-4:]`,页头 `EntryLen` 读到 0)。引擎内部没有写入串行化(`DB/core/pool/buffer_pool.go` 里关于 `capacityWait` 的注释写的就是「单写入者模型」),需要多写入者请在调用方自己加锁。写入与提交的「穿插」是支持的 —— 脏页阈值 / 防抖计时器触发的提交由引擎在写入者的 goroutine 里同步发起,不存在重叠。
 9. **批量删除开销较高**:`Deleter` 走「重建主键 + 二级索引」路径以保证正确性,峰值内存 / IO 高于单行删除(单行删除走 B+ 树借位 / 合并)。
@@ -1174,11 +1183,11 @@ SQL 表达不了 VRTX 的这些细节,所以导出时会在 SQL 侧额外建
     - 表名:**≤ 48 字节**(`TableInfo.Name`);`pk_` 前缀算在索引名的 64 里,不再侵占表名额度;
     - 自动命名的二级索引 `idx_<表名>_<首列名>`:**≤ 64 字节**,即表名 ≤ `59 - len(首列名)`。
     `UseTable` 在建表前先算这笔账(`checkNameBudget`),返回带具体字节数的 `ErrTableNameTooLong` / `ErrIndexNameTooLong`;名字还不够长就用 `vrtx:"index:<名字>"` 显式命名二级索引。core 层 `CreateIndex` 同样会拒绝超长名,而不是 `copy` 静默截断成另一个名字。
-14. **文件格式版本 `v7`**:v6 及更早的旧格式(24 字节表名 / `IndexMeta` 84 字节且索引名与表名共用 48 字节上限 / 有符号整数混用无符号档)不兼容,需要重建数据库文件。
+14. **文件格式版本 `v10`**:v9 及更早的旧格式(类型枚举旧编号 / blob 列行内 6B 堆引用 / 主键叶条目 12 字节 / 24 字节表名等,见「存储格式」的版本演进)不兼容,需要重建数据库文件。
 15. **没有 `RENAME COLUMN` / `RENAME TABLE` API**:改列名只能 `DROP` 旧列 + `ADD` 新列,**老行原值不会迁移到新列名**(数据仍在行内,只是新列名下不可达),需要业务侧自行回填;改表名请新建表后搬迁。
 16. **`DROP COLUMN` 是软删除**:字段类型最高位置 `0x80` 标记删除,**行内数据依然保留**;之后 `ADD` 一个同名同类型的列会「复活」老行里的旧值。因此**删列不能用来清除敏感数据**,需要重建表 / 覆写行才能彻底擦除。
 17. **ORM 重开表会按 struct 强制同步 schema**:`UseTable[T]` 会把库里存在、但 struct 里没有的列自动 `DropField`(即上面的软删除)。纯 core API 手工 `AddField` 出来的列,一旦被 ORM `UseTable` 打开就会「消失」(数据仍在行内,把字段加回 struct 即可复活)。因此**同一张表不要混用 ORM 与手工 DDL**;以 ORM 为唯一入口,或把手工列也声明进 struct。
-18. **迁移会把 VARCHAR 一律当定长**:SQL 侧读到的变长字符串列(`VARCHAR` / `TEXT`)在 VRTX 里按 `VARCHAR(n)` 的 `n` 就近取定长档位;`TEXT` / 无长度上限的类型超出 256 字节则落 `Dynamicbyte`。超过 256 字节的字符串值在 VRTX 里是变长列,**不能再建索引**。
+18. **迁移会把 VARCHAR 一律当定长**:SQL 侧读到的变长字符串列(`VARCHAR` / `TEXT`)在 VRTX 里按 `VARCHAR(n)` 的 `n` 就近取定长档位(16~2048);`TEXT` / 无长度上限的类型一律落 `Dynamicbyte`。声明超过 2048 字节的字符串在 VRTX 里是动态列,**不能再建索引**。
 19. **迁移不搬 `AUTO_INCREMENT` / 序列**:SQL 的自增属性不还原(VRTX 的主键号由引擎自己的游标分配);`-recreate` 之外的重复迁移是**追加**语义,同一张表迁两次会插两遍 / 撞主键报错,续传请用 `-tables` 只点名没迁完的表。
 20. **迁移期间的并发写不做保证**:`migrate.Run` 是"读源 → 写目标"的单向快照,不锁源库、也不是一致性快照;边写边迁只能拿到某一时刻的近似结果。
 21. **时序表的行号不保证一致**:VRTX 时序索引的 `pkID` 由索引内部维护(只用来在库内区分同一时间戳下的多行),跨库没有意义,因此往返只保证 `_ts` 与各列数据一致,不保证行号一致。

+ 18 - 10
docs/OPERATION_TUTORIAL.md

@@ -65,8 +65,8 @@ type Order struct {
 
 **字段声明规则:**
 - **基类(必须)**:每个表结构体都要内嵌 `DB.BaseModel` / `DB.PrimaryModel`(主键表,`_id`)或 `DB.TimeSeriesModel`(时序表,`_ts`)。基类既是"这是一张本框架的表"的标记,也提供对象头部的增删改查(`Insert / Update / Delete / Save / Load / Reload / PK`)。没有内嵌基类的结构体不是本框架的表:`ParseModel` / `UseTable` 直接报 `DB.ErrNotModel`(校验在建表之前,库里不会留下半成品表)。
-- **数值类型**:无需 tag,按 Go 类型自动映射。有符号整数单独占一档(`Int8`/`Int16`/`Int32`/`Int64`,盘上用保序编码),无符号整数走无符号档(`1`/`2`/`4`/`8byte`):`int8` → `Int8`、`int16` → `Int16`、`int32` → `Int32`、`int64`/`int` → `Int64`;`uint8`/`bool` → `1byte`、`uint16` → `2byte`、`uint32` → `4byte`、`uint64` → `8byte`。两档盘上字节不同、不可混用,否则范围查询 / 排序会出错。
-- **字符串**:用 `vrtx:"maxlen:N"` 指定最大字节数,映射到最接近的定长类型(≤32→32byte,≤64→64byte,≤128→128byte,≤256→256byte,>256→变长)。`N` 的上界是 **16360 字节**:超过 2048 字节的列转动态列(内容整体存进一个堆页),声明超过这个数的列在 `ParseModel` 阶段直接报错 —— 那种容量兑不了现,与其等写入时才失败,不如建模时就拒。
+- **数值类型**:无需 tag,按 Go 类型自动映射。有符号整数单独占一档(`Int8`/`Int16`/`Int32`/`Int64`,盘上用保序编码),无符号整数走无符号档(`Uint8`/`Uint16`/`Uint32`/`Uint64`,v9 起的语义名):`int8` → `Int8`、`int16` → `Int16`、`int32` → `Int32`、`int64`/`int` → `Int64`;`uint8` → `Uint8`、`uint16` → `Uint16`、`uint32` → `Uint32`、`uint64` → `Uint64`。两档盘上字节不同、不可混用,否则范围查询 / 排序会出错。
+- **字符串**:用 `vrtx:"maxlen:N"` 指定最大字节数,映射到最接近的定长档位(16~2048 共 24 档,约 1.25 倍递增就近向上取整,如 20→24B、200→224B;>2048 转动态列)。`N` 的上界是 **16360 字节**:超过 2048 字节的列转动态列(内容整体存进一个堆页),声明超过这个数的列在 `ParseModel` 阶段直接报错 —— 那种容量兑不了现,与其等写入时才失败,不如建模时就拒。
 - **索引**:在 tag 中追加 `index:名称`(默认 B+ 树)或 `index:名称,type:hash`(Hash 桶式索引)。
 - **主键**:`vrtx:"pk"` 把基类默认的键列(`_id` / `_ts`)**覆盖**成自己的字段(`_id` / `_ts` 不再建列),字段类型必须是 `int` / `uint` / `int64` / `uint64`;字符串、浮点、`int32` 等窄整数会在 `ParseModel` 时报 `DB.ErrInvalidPrimaryKey`。注意 `pk` 标签不能替代基类 —— 只写 `pk` 而不内嵌基类同样报 `DB.ErrNotModel`。
 - 一个 tag 内多个声明用逗号分隔,例如 `vrtx:"maxlen:32,index:idx_name"`。
@@ -243,7 +243,7 @@ for _, emp := range rows {
 
 ```go
 // 全表扫描
-allEmps, err := empTbl.Find()  // 无条件,返回所有记录
+allEmps, err := empTbl.FindAll()  // 无条件,返回所有记录
 if err != nil {
     panic(err)
 }
@@ -418,17 +418,25 @@ fmt.Printf("符合条件的员工: %d 人\n", len(rows))
 
 ### 7.6 OR 条件
 
+ORM 层的 `Where` 链只表达 AND;**OR / NOT / 嵌套组合要到 core 层**用条件构造器(`core.Or` / `core.And` / `core.Not`),把组合条件交给 core `Query`:
+
 ```go
-// 使用 OrCondition
-import "git.ycl.pub/yl32940/TsDB2/DB/core"
+import (
+    "git.ycl.pub/yl32940/TsDB2/DB/core"
+    "git.ycl.pub/yl32940/TsDB2/DB/core/types"
+)
 
-cond := core.OrCondition{
-    core.EqCondition{Field: "Dept", Value: "工程部"},
-    core.EqCondition{Field: "Dept", Value: "技术部"},
-}
-rows, _ := empTbl.WhereCondition(cond).Find()
+// WHERE Dept = "工程部" OR Dept = "技术部" —— 等价于 ORM 的 Where("Dept","in",...)
+empCore, _ := db.CoreDB().FindTable("employee")
+cond := core.Or(
+    core.Eq("Dept", "工程部", types.FieldType(0)),
+    core.Eq("Dept", "技术部", types.FieldType(0)),
+)
+n, err := empCore.Query().Where(cond).Count()
 ```
 
+> `core.Eq` 的第三个参数是列类型提示,`types.FieldType(0)` 表示自动推断;条件构造器还有 `Gt/Ge/Lt/Le/Like/ILike/In`,可继续用 `core.And` / `core.Or` / `core.Not` 任意嵌套。
+
 ---
 
 ## 8. 排序与分页