Brak opisu

yl32940 a7d0c72713 feat(driver): VACUUM 语句 — db.Exec("VACUUM") 直接整库重建 3 tygodni temu
.idea 114e4de25c commit 1 miesiąc temu
DB a7d0c72713 feat(driver): VACUUM 语句 — db.Exec("VACUUM") 直接整库重建 3 tygodni temu
analyzer fa9c420a94 feat(analyzer): blob 链可达性 — 修复 44 页孤儿误报 + 取证/复现工具 3 tygodni temu
cmd fa9c420a94 feat(analyzer): blob 链可达性 — 修复 44 页孤儿误报 + 取证/复现工具 3 tygodni temu
demo aa8857b65a feat(driver): database/sql 驱动 (vrtxsql) — ORM 子集翻译器, M1/M2/M3 全量 3 tygodni temu
docs a7d0c72713 feat(driver): VACUUM 语句 — db.Exec("VACUUM") 直接整库重建 3 tygodni temu
gormvrtx c5ccb4f02c fix(gormvrtx): 批量创建切片共用一个预分配主键 — 每行独立 AllocPKID 3 tygodni temu
internal fa9c420a94 feat(analyzer): blob 链可达性 — 修复 44 页孤儿误报 + 取证/复现工具 3 tygodni temu
migrate fa9c420a94 feat(analyzer): blob 链可达性 — 修复 44 页孤儿误报 + 取证/复现工具 3 tygodni temu
.DS_Store b146b7354e 固定大小有问题 1 miesiąc temu
.gitignore 74ebdeb003 chore: demo 运行日志 (45 个 .log) 移出版本库, .gitignore 补规则 3 tygodni temu
Makefile b685a00d0d chore(all): demo 改名 verify_*, slices 现代化, Close 错误聚合, doc.go (D4+D5) 3 tygodni temu
README.md c2cf123995 docs: JOIN 移入支持清单 + GORM 接入章节 (gormvrtx) 3 tygodni temu
go.mod b16bdf59ee chore: 模块路径改为 git.ycl.pub/yl32940/TsDB2, 支持作为库被引用 1 miesiąc temu
go.sum a56b71d5b9 feat(core): blob 列改为 8B 链引用 (schema v10); 一并落地自动 VACUUM 与批量写入/页缓冲池优化 1 miesiąc temu

README.md

TsDB2

TsDB2 是一个纯 Go 的嵌入式数据库,同时服务两类场景:

  • OLTP 主键表:_id 主键 + 聚簇 B+ 树,叶子直接存行数据。
  • 时序表:_ts 主键 + 扁平时间条目链,按时间段裁剪 DataPage。

代码分三层:

层 包 职责
ORM 层 git.ycl.pub/yl32940/TsDB2/DB 用 Go 结构体 + vrtx tag 声明表结构与索引,通过泛型 Table[T] / Query[T] 完成增删改查,无需手写建表 / 建索引语句
core 层 git.ycl.pub/yl32940/TsDB2/DB/core 页式存储、B+ 树 / Hash / 时序索引、事务、WAL、BufferPool 等底层实现
迁移层 git.ycl.pub/yl32940/TsDB2/migrate + cmd/vrtxsql 与 MySQL / SQLite 互转(结构 + 数据 + 索引 + 主键),见 跨库迁移
  • 模块路径:git.ycl.pub/yl32940/TsDB2,Go 版本:go 1.24(以 go.mod 为准)
  • 引擎本体(DB / DB/core)不依赖任何第三方库,只用标准库
  • 只有可选的 migrate / cmd/vrtxsql 需要 github.com/go-sql-driver/mysql 与 modernc.org/sqlite(纯 Go 的 SQLite);不跑迁移就不会链接到它们
  • 数据文件后缀任意(示例用 .db),页式布局见 存储格式

目录


特性

  • 声明式建表:内嵌 DB.BaseModel(或等价的 DB.PrimaryModel)得到 _id 主键,内嵌 DB.TimeSeriesModel 得到 _ts 时序键;两者的键列都能用 vrtx:"pk" 换成表自己的字段。字段类型由 Go 类型自动推导,不写任何 DDL。
  • 对象头部增删改查:内嵌表基类之后,记录自己就带 Insert / Update / Delete / Save / Load / Reload / PK —— 父类(基类)定义方法,每张表自己决定主键样式。
  • 时序表支持「同一时间戳多行」:_ts 只决定一行落在哪个时间段,引擎另给每行分配隐藏行号 pkID(行首 8B,对用户不可见)。对象被 Insert / Find / Select 读出来后自带行号,于是 obj.Update() / Delete() / Reload() 精确作用于这一行,不会误伤同刻的其它行;二级索引回表走的也是同一个行号。
  • Schema 自动演进:UseTable[T] 对已存在的表自动同步结构体字段(新增列 / 删除列),并补建缺失的二级索引;索引分量与模型不符时直接报错而非静默沿用。
  • 多种索引形态:主键聚簇 B+ 树、B+ 二级索引、Hash 桶式二级索引(等值 O(1))、时序索引、复合索引(同名 index:NAME 自动合并;IndexTypeCompose 的处理器的底层就是 SBT 二级 B+ 树——与 BTree 复合键同结构,最左前缀/等值/范围 O(logN),分量不限 8 字节)。
  • 查询能力
    • ORM 层:Where 支持 = != > >= < <= like in,多个 Where 之间为 AND;支持 Select(投影下推)/ OrderBy / Limit / Offset / Count / 流式 Scan / First。
    • IN 走索引:Where("Age","in",vals) 在索引列上为每个值展开一次索引定位(B+ / Hash / 复合前缀均可),不再退化为全表扫描;重复值自动去重,空集合仍恒假(0 行)。
    • ORDER BY + LIMIT 流式 top-k:只保留 limit+offset 行的小顶堆,内存 O(k) 且免全量排序,稳定性与全排序一致。
    • core 层:条件构造器 Eq/Gt/Ge/Lt/Le/Like/ILike/In 与 And/Or/Not 任意组合;Query 另支持 Select/FindOne/Sum/Max/Min(分组聚合 GroupBy/Having 暂未支持)。
  • 事务:BeginTransaction() / Commit() / Rollback();提交走整页 WAL + fsync,回滚通过丢弃缓存页立即生效,BEGIN 自动收敛未落盘的修改。
  • database/sql 驱动:import _ "DB/driver" 后 sql.Open("vrtx", 路径) 即可让 xorm / sqlx / 自研 ORM 直连(内置 ORM 子集 SQL 翻译器,零依赖);支持单表全语句 + INNER/LEFT 等值 JOIN;子查询 / GROUP BY 等明确报错不静默,见 docs/SQL_DRIVER_GUIDE.md。配套 gormvrtx/ GORM Dialector(独立子模块):GORM 的 AutoMigrate / CRUD / Joins / 事务直接可用。
  • 提交策略可调:防抖自动提交、脏页阈值、预设(batch / online / safe / ultra-safe),默认无需手动 Commit。
  • 存储管理:真 LRU BufferPool(读/写命中都刷新热度,容量满淘汰最久未用的干净页;读未命中也进缓存,重复扫描/点查不再每次走磁盘)、空闲页链表回收(DropTable 后页可复用,页删空延迟到提交成功后还链)、动态字段走堆页 + 引用计数自动回收。
  • 可视化分析器:analyzer/ 是独立的网页工具,可打开 .db 文件查看页布局、表 / 索引结构、堆链、空闲链,并做全库一致性审计。

快速开始

作为外部库引用(发布在 git.ycl.pub,公开仓库):

# 域名不在公共 Go proxy 缓存里,首次使用需要一次性声明 GOPRIVATE(走直连拉取)
go env -w GOPRIVATE=git.ycl.pub

go get git.ycl.pub/yl32940/TsDB2@v1.0.0
import tsdb "git.ycl.pub/yl32940/TsDB2/DB"

仓库内开发:直接 go run ./demo/...。

Where / OrderBy 的字段名约定(新用户最常踩的坑):传的是 Go 结构体字段名 (也就是默认列名,如 Age / Dept / Salary),不是 vrtx tag 里自定义的小写列名。 传了不存在的字段名会在执行时(Find / First / Count / Scan)直接报错 ("未知字段"),不再静默不命中 —— 以前 Where("不存在的字段","!=",x) 会静默返回全表, OrderBy 错字段静默不排序,都是危险地静默错数据。示例:

> emp.Where("Age", "=", int64(25))    // ✅ Go 字段名(默认列名就是它)
> emp.Where("age", "=", int64(25))    // ❌ 小写 / 自定义列名 → 执行时报"未知字段"
> ```
>
> 只有 `vrtx:"col_name"` 显式改过列名的字段,查询时才用那个自定义名。

go package main

import ( "fmt"

tsdb "git.ycl.pub/yl32940/TsDB2/DB" )

// 主键表:_id 主键 + Hash 二级索引(Age) + B+ 二级索引(Score) type Employee struct { tsdb.BaseModel Name string vrtx:"maxlen:32" Age int64 vrtx:"index:idx_age,type:hash" Score int64 vrtx:"index:idx_score" Dept string vrtx:"maxlen:16" }

// 时序表:_ts 主键(时序索引) type Metric struct { tsdb.TimeSeriesModel Host string vrtx:"maxlen:16" Value int64 }

func main() { db, err := tsdb.Open("demo.db") if err != nil { panic(err) } defer db.Close() // Close 会兜底提交未落盘数据

// 建表 + 自动建索引(表不存在时创建,存在时同步 schema / 补建索引) emp, err := tsdb.UseTableEmployee if err != nil { panic(err) }

// 增:ID 为 0 时由 ORM 向引擎要一个只增不减的自增号(从当前最大存活 // pkID 之后继续,删除行不会让号回退),并回写到结构体 _ = emp.Insert(&Employee{Name: "Alice", Age: 25, Score: 90, Dept: "D1"})

// 查:主键点查 row, _ := emp.Find(1) fmt.Println(row.Name)

// 查:二级索引条件 + 排序 + 分页 + 聚合 rows, _ := emp.Where("Age", "=", int64(25)).OrderBy("Score", true).Limit(10).Find() cnt, _ := emp.Where("Score", ">=", int64(60)).Count() fmt.Println(len(rows), cnt)

// 改:按主键更新(整行覆盖语义,未赋值字段会被清零) _ = emp.Update(&Employee{BaseModel: tsdb.BaseModel{ID: 1}, Name: "Alice", Age: 26, Score: 91, Dept: "D2"})

// 删:按主键删除 _ = emp.Delete(2)

// 也可以直接用对象头部的增删改查(由内嵌的 tsdb.BaseModel 提供) u := emp.New() // New:创建并绑定 u.Name, u.Age = "Bob", 30 _ = u.Insert() // 增(自增主键回写 u.ID) u.Age = 31 _ = u.Update() // 改(按 u.ID) _ = u.Reload() // 查(按 u.ID 重读进 u) _ = u.Delete() // 删(按 u.ID)

// 时序表:写入与时间范围查询 met, _ := tsdb.UseTableMetric _ = met.Insert(&Metric{TimeSeriesModel: tsdb.TimeSeriesModel{TS: 1_700_000_000_000}, Host: "h1", Value: 42}) _, _ = met.Where("_ts", ">=", int64(1_700_000_000_000)).OrderBy("_ts", false).Find()

// 也可显式提交(默认已有防抖自动提交,无需手动调用) if err := db.Commit(); err != nil { panic(err) } }


运行:

bash go run ./demo/orm_full # 完整 ORM 特性(4 种索引 CRUD + 事务 + 持久化)


---

## 数据模型与 vrtx 标签

### 基类

| 写法 | 主键列 | 一级(行存储)索引 | 主键是否回写结构体 | 适用场景 |
|------|--------|----------|--------------------|----------|
| 内嵌 `DB.BaseModel` / `DB.PrimaryModel`(等价) | `_id` (uint64) | `IndexTypePrimary`(2) | 是 | 主键 / OLTP 表 |
| 内嵌 `DB.TimeSeriesModel` | `_ts` (uint64) | `IndexTypeTime`(1) | 是 | 时序表 |
| 内嵌基类 + 字段标 `vrtx:"pk"` | **该字段本身**(如 `No`),基类默认的 `_id` / `_ts` 不再建列 | 由所内嵌的基类决定 | 是 | **自定义整数主键 / 时序键** |
| 不内嵌基类 | — | — | — | ❌ `UseTable` 报 `ErrNotModel`:它不一定是本框架的表 |

> **表必须内嵌基类**。基类既是"这是一张本框架的表"的标记,也是对象头部增删改查的来源,所以没有基类的结构体会在建表之前被直接拒绝(`ErrNotModel`,库里不会留下半成品表)。单写一个 `vrtx:"pk"` 不能替代基类 —— 该标签的语义是"覆盖基类默认的键列"。按 SQL 元数据动态建表的 `migrate` 包走的是 core 层(`db.CreateTable`),不受这条约束。

> 插入时若主键值为 `0`,ORM 会向引擎要一个**只增不减**的自增号并回写结构体(回写到 `vrtx:"pk"` 声明的字段或 `BaseModel.ID`)。因此**自动分配的主键值不等于真实时间戳**;删除行不会让号回退复用。主键值由调用方显式给出时(`vrtx:"pk"` / `BaseModel.ID`)直接用该值,重复号报 `core.ErrDuplicatePK`。

**父类定义方法,子类决定主键样式**:三个基类都提供同一套头部方法(`Insert / Update / Delete / Save / Load / Reload / PK`,实现只有一份,在 `DB/model.go`),主键/时序键列则可由每张表用 `vrtx:"pk"` 自行指定:

go type User struct {

DB.BaseModel                    // 内嵌基类 → 拿到头部增删改查
No   int64  `vrtx:"pk"`         // 主键列自己定: _id 不再建列
Name string `vrtx:"maxlen:32"`

}

type Tick struct {

DB.TimeSeriesModel              // 时序表
Stamp int64 `vrtx:"pk"`         // 时序键自己定: _ts 不再建列
Price float64

}

u := userTbl.New() // New: 创建 + 绑定 u.No, u.Name = 7, "a" u.Insert() // 增 u.Name = "b"; u.Update() // 改 u.Reload() // 查(按 u.No 重读进自己) u.Delete() // 删


- 绑定是**自动**的:`Insert` / `Update` / `Find` / `FindAll` / `Select` / `Where(...).Find()` 的结果都会顺带绑定,正常用法不需要手动 `Bind`。
- **一张表只能有一个行存储索引**:主键索引与时序索引都直接存行,二者不能共存 —— 同时内嵌 `BaseModel` 和 `TimeSeriesModel` 报 `DB.ErrInvalidPrimaryKey`,在同一张表上再 `CreateIndex` 另一个行存储索引报 `core.ErrRowStoreExists`。
- 主键/时序键列**固定排在所有列的第一位**(时序索引按固定偏移读第一列当时间键),所以 `vrtx:"pk"` 写在基类之前还是之后都一样。

### 标签选项

标签名为 **`vrtx`**(同时兼容旧的 `tsdb`)。多个选项用逗号分隔:

| 写法 | 含义 |
|------|------|
| `vrtx:"pk"` | 该字段是本表**主键**(列名就是字段名),即**覆盖**所内嵌基类默认的键列(`_id` / `_ts` 不再建列)。写 `vrtx:"_id"` / `vrtx:"_ts"` 等价。注意它**不能替代基类**:只标 `pk` 而不内嵌基类报 `ErrNotModel` |
| `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 二级索引 |
| `vrtx:"index:idx_age,type:btree"` | 显式指定 B+ 二级索引 |
| `vrtx:"unique"` | 建**唯一索引**:该列的值不得重复,重复插入 / 更新报 `core.ErrDuplicatedKey`。`NULL` 不参与(可共存多行 `NULL`)。与 `index:NAME` 连用时约束的是整组列(复合唯一键) |
| `vrtx:"col_name"` | 非上述关键字的部分视为**列名** |

要点:

- **列名默认就是 Go 字段名本身**(不做大小写 / 下划线转换),如 `Age` 列名即 `Age`;`Where("Age", ...)` / `OrderBy("Age", ...)` 传的也是它(即 Go 字段名 = 默认列名,详见[快速开始](#快速开始)里的约定说明——传小写 / 自定义名会在执行时**报"未知字段"错误**,不再静默查不到)。
- **自定义主键必须是 64 位整数**:只接受 `int` / `uint` / `int64` / `uint64`。引擎的行主键号 `pkID` 是 `uint64`,主键表的键列是它的镜像,所以字符串 / 浮点 / 切片 / 窄整数(`int32` 等)都在 `ParseModel` 阶段直接报 **`DB.ErrInvalidPrimaryKey`**(`errors.Is` 可判定);负数主键在 `Insert` / `Update` 时同样报该错,不会写入查不到的行。
- 一个结构体**只能有一个主键**:重复标 `pk`,或同时内嵌两个表基类,都报 `DB.ErrInvalidPrimaryKey`。但**内嵌基类 + 标 `pk` 是合法的** —— 那是"覆盖基类默认键列",正是自定义主键/时序键的用法。
- 主键列**不参与 schema 同步**(不会因结构体改动被 `AddField` / `DropField`),建表时按模型建好,改主键字段名请重建表(换主键列 = 换索引键,需要迁移数据)。
- `maxlen` 只对 `string` 生效;数值类型的宽度由 Go 类型决定,`maxlen` 被忽略。
- **字符串的容量上限是 16360 字节**(一个堆页能装下的载荷):`maxlen` 超过 2048 的列落动态列,内容整体存进一个堆页,所以声明超过 16360 字节的列永远兑现不了 —— 这种声明在 `ParseModel` 阶段就报错(错误里给出上限与写下的值),而不是等某天写入大值时才失败。要存更大的内容请改用 `[]byte`(Blob),或把大列拆到附表。
- **整行占用上限 8171 字节**(= (16KB 页 − 40B 数据页头 − 1) / 2,"一页至少装两行"这条政策的直接后果):每行除字段区还要占 4B 长度前缀 + 8B 行主键号 + 1B schemaVer + NULL 位图,所以**"字段区凑到 8K"其实两行装不进一页**。建表 / `AddField` 按**整行占用**校验,超限直接报错并列出各列宽度(降序);修法是把最宽的几列改成动态列(`maxlen>2048` 或 `[]byte`),或把大列拆到附表。
- 多个字段声明**同一个具名索引** `index:NAME` 时,会自动合并为一个**复合索引**:分量顺序 = 字段在表中的物理顺序,索引类型取首次声明的类型,支持最左前缀(`a=?` 或 `a=? AND b>?`)。
- **动态列(`[]byte`、`maxlen>2048` 的字符串)不能建索引**:`UseTable` 会直接报错(动态列行内只存 6B 堆引用,做键无意义)。

### Go 类型 → 存储类型

**有符号整数与无符号整数分属两档**:SQL 的整数默认有符号(迁移时同样如此),只有显式 `UNSIGNED` 才落到无符号档。两档**盘上字节不同**(有符号档是保序编码),混用会让索引区间扫描与 `ORDER BY` 出错。

| Go 类型 | FieldType | 大小 |
|---------|-----------|------|
| `int8` | `FieldType_Int8`(3) | 1B,保序编码 |
| `int16` | `FieldType_Int16`(5) | 2B,保序编码 |
| `int32` | `FieldType_Int32`(8) | 4B,保序编码 |
| `int64` / `int` | `FieldType_Int64`(11) | 8B,保序编码 |
| `uint8` | `FieldType_Uint8`(2) | 1B |
| `uint16` | `FieldType_Uint16`(4) | 2B |
| `uint32` | `FieldType_Uint32`(7) | 4B |
| `uint64` | `FieldType_Uint64`(10) | 8B |
| `bool` | `FieldType_Bool`(1) | 1B |
| `float32` | `FieldType_Float32`(9) | 4B,保序编码 |
| `float64` | `FieldType_Float64`(12) | 8B,保序编码 |
| `string`(无 maxlen) | `FieldType_String`(16) | 256B(默认宽度) |
| `string` + `maxlen:N` | `FieldType_String`(16) + 宽度码 | 按 N 就近取档;N>2048 转动态 |
| `[]byte` | `FieldType_Dynamicbyte`(17) | 行内 6B 引用 + 堆页 |
| `[]byte` + `vrtx:"type:blob"` | `FieldType_Blob`(18) | 行内 8B `BlobRef`(页号 + 长度),内容落在可跨页的链上(≤~4GB,见 [Blob 存储](#blob-存储)) |

> 浮点列**不复用整数列**(旧版本把 `float64` 映射到无符号整数列,写入 `123.75` 会被静默截断成 `123`),现在各自落专用浮点列并做保序编码。
> `float32` 有独立类型值(4 字节),不再折成 `float64`。
> 主键列 / 时序键 `_ts` 是行号镜像,**强制留在 `FieldType_Uint64`(无符号 8 字节、补码)**,不随字段的 Go 类型走有符号档。

### 字段类型常量(`DB/core/types`)

类型值按**行内宽度分组**排列,名字一律用语义名(不再用字节数当名字):

| 常量 | 值 | 宽度 | 说明 |
|------|----|------|------|
| `FieldType_Bool` | 1 | 1B | 布尔(盘上只允许 0/1) |
| `FieldType_Uint8` | 2 | 1B | 无符号 8 位 |
| `FieldType_Int8` | 3 | 1B | 有符号 8 位,**保序编码** |
| `FieldType_Uint16` | 4 | 2B | 无符号 16 位 |
| `FieldType_Int16` | 5 | 2B | 有符号 16 位,**保序编码** |
| `FieldType_Year` | 6 | 2B | 年份(`uint16` 载体,0..32767) |
| `FieldType_Uint32` | 7 | 4B | 无符号 32 位 |
| `FieldType_Int32` | 8 | 4B | 有符号 32 位,**保序编码** |
| `FieldType_Float32` | 9 | 4B | IEEE754 单精度浮点,**保序编码** |
| `FieldType_Uint64` | 10 | 8B | 无符号 64 位 / 行主键号(`pkID`)/ 时序键 `_ts` |
| `FieldType_Int64` | 11 | 8B | 有符号 64 位,**保序编码** |
| `FieldType_Float64` | 12 | 8B | IEEE754 双精度浮点,**保序编码**后存行内与索引键 |
| `FieldType_Int128` | 13 | 16B | 有符号 128 位,**保序编码** |
| `FieldType_UUID` | 14 | 16B | RFC4122 UUID(16 字节二进制原样) |
| `FieldType_Decimal` | 15 | 18B | 定点小数 `DECIMAL(38,s)` |
| `FieldType_String` | 16 | 变长(默认 256B) | 定长字符串,行内宽度由 Flag 低 5 位**宽度码**决定 |
| `FieldType_Dynamicbyte` | 17 | 6B 引用 | 动态数据,行内存 `[PageID(4B) + Offset(2B)]`,内容存堆页 |
| `FieldType_Blob` | 18 | 8B `BlobRef` | 二进制大对象。行内存 `[TotalLen(4B) + FirstPageID(4B)]`,内容落在可跨页的链页上(单值上限 `BlobChainMax` ≈ 4GB,可选 Snappy)—— 与 `Dynamicbyte` 的分界是"内容能不能跨页",见 [Blob 存储](#blob-存储) |

> **浮点保序编码**:`float64` 以 `Float64ToSortKey`(非负翻转符号位、负值按位取反)写入行内,
> 于是 `uint64` 的字节序 == 浮点数值序,`-Inf < -1 < -0 < +0 < 1 < +Inf`。
> 因为编码是双射(`SortKeyToFloat64` 无损还原),**浮点列可以正常建索引、做等值/范围/排序查询**;
> 若直接存 IEEE754 原始位模式,负数段的字节序会比正数大,范围查询会安静地漏行。
>
> **有符号整数保序编码**:`Int8/16/32/64` 以 `IntNToSortKey`(补码翻转符号位,即 `^ (1<<(N-1))`)
> 写入行内与索引键,于是字节序 == 数值序。若沿用无符号档的补码大端,位数相同的负数会被当作
> 极大的无符号数(`0 < 1 < … < MaxInt < MinInt < … < -1`),等值查得到、**跨 0 的范围扫描与
> `ORDER BY` 却会安静地漏行或混入错误行**。解码走 `SortKeyToIntN`,与编码互逆。

---

## 索引形态

| 形态 | IndexType | 底层结构 | 叶子 / 条目内容 | 等值查询 | 范围 / 有序查询 | 需回表 |
|------|-----------|----------|-----------------|----------|-----------------|--------|
| **主键 `_id`** | `Primary`(2) | 聚簇 B+ 树 | 直接存行(DataPage) | O(logN) | O(logN),天然按 pk 有序 | 否 |
| **时序 `_ts`** | `Time`(1) | 扁平时间条目链 | `[Begin(8B) + Last(8B) + DataPageID(4B)]` → 分段 DataPage(段内行 = `[pkID(8B) + 行数据]`,按 pkID 升序) | 定位分段后段内扫描 | 按时间段裁剪分段 | 否 |
| **B+ 二级** | `BTree`(5) | B+ 树 | `[key + pkID]` | O(logN) | O(logN) | 是 |
| **Hash 二级** | `Hash`(6) | 桶目录 + 溢出链 | `pkID` | **O(1)** | 全桶扫描 O(N),**不保序** | 是 |
| **复合** | `Compose`(4) | **SBT 二级 B+ 树**(与 BTree 同结构) | `[composeKey(各分量定宽拼接) + PKID(8B)]` | 最左前缀 O(logN) | 前缀 + 次分量范围 O(logN) | 是 |

> **`IndexTypeCompose` 的处理器(ComposeChainHandler)底层就是 SBT 二级 B+ 树**:`Insert` / `Search` / `RangeScan` / `MIN` / `MAX` 全部委托 `btree.SBT*`,与 `IndexTypeBTree` 复合键同结构、同性能(最左前缀/等值/范围 O(logN),全局有序)。类型值 4 与 5 只是历史命名差异(4 承自 `IndexTypeStr`),运行行为一致。**分量不限 8 字节**——各分量按真实宽度定长拼接(字符串宽度码列、18B Decimal、2B Year 都可以做分量),没有旧版扁平链的宽度闸门。

索引类型常量(`DB/core/page`):

| 常量 | 值 | 说明 |
|------|----|------|
| `IndexTypeTime` | 1 | 时序索引 |
| `IndexTypePrimary` | 2 | 主键索引(聚簇 B+ 树) |
| `IndexTypeStr` | 3 | **已废弃**,仅为兼容旧数据库文件保留(按复合条目处理) |
| `IndexTypeCompose` | 4 | 复合索引 |
| `IndexTypeBTree` | 5 | B+ 树二级索引(叶子存 `pkID`) |
| `IndexTypeHash` | 6 | Hash 桶式二级索引(等值 O(1)) |

### 更新与删除语义

- **更新**:`Table[T].Update` 为**整行覆盖**——未赋值的字段会被清零;主键索引与所有二级索引条目同步重建。(`demo/orm_full` 中明确验证了这一点。)
- **时序表的定位键是「行号」而不是「时间戳」**:`_ts` 只决定一行落在哪个时间段,**同一个时间戳下允许存在多行**(同一时刻的多个测点 / 多次事件)。引擎给每行分配唯一行号 `pkID`(8B,藏在行首,对用户不可见),对象在 `Insert` / `Find` / `Select` 之后会把它带回内核侧。于是 `obj.Update()` / `obj.Delete()` / `obj.Reload()` 改的、删的、重读的都是**这一行**,不会碰到同刻的其它行。手工构造、还没入过库的对象行号为 0,此时退回按可见主键(`_id` / `_ts`,即该时间点的第一行)定位;行号为 0 且主键也为 0 时 `Delete` 报 `ErrZeroPrimaryKey`。改掉时序键会把该行搬到新时间段,行号不变。
- **删除**:`DeleteRow` 在 DataPage 中先把行标记为**软删除**(长度字段最高位置位);单行删除走 B+ 树删除并借位 / 合并,批量删除(`Deleter`)则**流式重建**主键与二级索引,避免逐行级联。
- **未建索引的列**:条件查询退化为**全表扫描**(例如 `Dept`、`Host`)。

---

## API 文档

### ORM 层(`git.ycl.pub/yl32940/TsDB2/DB`)

go // 打开 / 关闭 / 提交 / 删表 func Open(path string) (*DB, error) func (db *DB) Close() error func (db *DB) Commit() error func (db *DB) DropTable(name string) error func (db *DB) CoreDB() *core.DB // 暴露底层 core,用于高级操作 func (db *DB) DebugDBState() // 打印 Header / BufferPool 调试信息 func (db *DB) DebugTableState(tableName string) func (db *DB) Comment() string // 数据库介绍区内容(头页 Comment,建库自动写入) func (db *DB) SetComment(desc string) error // 改写介绍区(≤3764 字节,超长报错) func OpenWithOptions(path string, opt core.OpenOptions) (*DB, error) // 带选项打开; opt.ForceInspect = 头页校验失败只读抢救 (写禁用, 等价命令行 --force-inspect)

// 获取(或创建)泛型表;表名缺省时由类型名推导(首个字母小写:Employee -> employee, OrderItem -> orderItem) func UseTableT any (*Table[T], error)

// 运行时配置 func (db *DB) SetAutoCommitDelay(d time.Duration) // 0 = 禁用防抖 func (db *DB) SetDirtyThreshold(threshold int) // 脏页数阈值(页) func (db *DB) SetCommitPreset(preset string) // "batch" | "online" | "safe" | "ultra-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 任何阶段失败则旧库原样保留)。行存储索引按原名/原类型重建、NULL 保留、时序行按时间键重排;⚠️ VACUUM(含自动调度)换过引擎状态,之前拿到的 Table 句柄作废,须重新 UseTable/FindTable 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


go // Table[T] 增删改查 func (t *Table[T]) Insert(obj *T) error func (t *Table[T]) BatchInsert(objs []*T) error func (t *Table[T]) Update(obj *T) error // 按主键整行覆盖 func (t *Table[T]) Delete(id uint64) error func (t *Table[T]) Find(id uint64) (*T, error) func (t *Table[T]) FindAll() ([]*T, error) func (t *Table[T]) Count() (int64, error) func (t *Table[T]) Select(callback func(*T) bool) error func (t *Table[T]) SchemaColumns() []string // 当前 DB 中的活跃列名

// 查询入口(均返回 *Query[T]) func (t *Table[T]) Query() *Query[T] func (t *Table[T]) Where(field string, op string, value interface{}) *Query[T] func (t *Table[T]) OrderBy(field string, desc ...bool) *Query[T] func (t *Table[T]) Limit(n int) *Query[T] func (t *Table[T]) Offset(n int) *Query[T] func (t *Table[T]) Bind(obj *T) error // 把对象绑定到本表(Bind 后才有头部方法) func (t *Table[T]) New() *T // 创建并绑定一个新对象(随后可 obj.Load(pk))


go // 对象头部(由内嵌的 DB.BaseModel / DB.PrimaryModel / DB.TimeSeriesModel 提供) // 没有类型参数 → Load 是"填自己";要类型安全的新对象请用 Table[T].Find func (m *BaseModel) Insert() error // 增:键为 0 时取自增号并回写本体 func (m *BaseModel) Update() error // 改:按本体自己的键整行覆盖 func (m *BaseModel) Delete() error // 删:按本体自己的键(键为 0 报 ErrZeroPrimaryKey) func (m *BaseModel) Save() error // 键为 0 → Insert,否则 Update func (m *BaseModel) Load(pk uint64) error // 查:按主键把整行读进本体 func (m *BaseModel) Reload() error // 按当前键重读一遍 func (m *BaseModel) PK() uint64 func (m *BaseModel) PKErr() (uint64, error) func (m *BaseModel) TableName() string func (m *BaseModel) Bound() bool // 是否已绑定到表 // DB.PrimaryModel 是 DB.BaseModel 的别名(方法集完全相同); // DB.TimeSeriesModel 提供同一套方法 + TS uint64 字段


go // Query[T] 链式查询 func (q *Query[T]) Where(field string, op string, value interface{}) *Query[T] func (q *Query[T]) Select(cols ...string) *Query[T] // 投影下推: 只解码选中列 (含 ORDER BY 列), 未选中字段保持零值 func (q *Query[T]) OrderBy(field string, desc ...bool) *Query[T] func (q *Query[T]) Limit(n int) *Query[T] func (q *Query[T]) Offset(n int) *Query[T] func (q *Query[T]) Find() ([]*T, error) func (q *Query[T]) First() (*T, error) func (q *Query[T]) Count() (int64, error) func (q *Query[T]) Scan(callback func(*T) bool) error


> `Find` / `Scan` / `Table.Select` 遇到行反序列化失败(如 blob 链损坏)会**停止扫描并返回错误**,不会静默跳过该行返回缺行的结果集。

`op` 支持(大小写敏感的小写 / 符号两种写法):

| 写法 | 语义 |
|------|------|
| `=` / `==` / `eq` | 等于 |
| `!=` / `<>` | 不等于(实现为 `Not(Eq(...))`) |
| `>` / `gt` | 大于 |
| `>=` / `ge` / `gte` | 大于等于 |
| `<` / `lt` | 小于 |
| `<=` / `le` / `lte` | 小于等于 |
| `like` | LIKE 模式匹配(支持 `%` 与 `_`,大小写敏感) |
| `in` / `In` / `IN` | 集合匹配:值为切片 / 数组(如 `[]string{"工程部","技术部"}`、`[]int{20,22}`),分量按列类型自动归一;**空集合恒假**(0 行,不是全表);单值容忍为等值;**索引列上走索引**(每个值展开一次索引定位,重复值自动去重) |

> 未知 `op` 会退化为等值比较(**不报错**)。但 `Where` / `OrderBy` / `Select` 的字段名传错会**直接报错**("未知字段",执行入口返回 error)——务必传 Go 结构体字段名(见[快速开始](#快速开始)的约定说明)。

### core 层(`git.ycl.pub/yl32940/TsDB2/DB/core`)

go func Open(path string) (*DB, error) func (db *DB) CreateTable(name string, fields []types.FieldInfo) (*Table, error) func (db *DB) FindTable(name string) (*Table, bool) func (db *DB) DropTable(name string) error func (db *DB) GetAllTables() []TableMeta func (db *DB) Commit() error func (db *DB) Close() error func (db *DB) BeginTransaction() *Transaction

// 表级 API 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, 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 func (table *Table) IndexMeta() *IndexMetaPage func (table *Table) GetActiveFieldNames() []string func (table *Table) GetActiveFieldType(name string) (types.FieldType, bool) func (table *Table) AddField(newField types.FieldInfo) error // Schema 演进:加列 func (table *Table) DropField(fieldName string) error // Schema 演进:删列

// 链式构造器 func (table *Table) Query() *Query func (table *Table) Insert() *Inserter func (table *Table) Update() *Updater func (table *Table) Delete() *Deleter

// Blob(链式 / 流式;≤~4GB, 可选 Snappy;详见「Blob 存储」) func (table *Table) BlobRead(ref BlobRef, compress types.FieldCompress) ([]byte, error) func (table *Table) BlobFree(ref BlobRef) error func (tx *Transaction) BlobCommit(r io.Reader, compress types.FieldCompress) (BlobRef, error) // 错误: ErrBlobTooLarge (单值 > ~4GB), ErrBlobCorrupted (解压长度与 ref.TotalLen 不一致 / 压缩标志不符 / 读取长度与 ref.TotalLen 不符)


**条件构造器**(`core` 包):

go core.Eq(field, value, typ) core.Gt(field, value, typ) core.Ge(field, value, typ) core.Lt(field, value, typ) core.Le(field, value, typ) core.Like(field, pattern) core.ILike(field, pattern) core.In(field, values, typ) core.And(conditions...) core.Or(conditions...) core.Not(cond)


**`core.Query`**:`Select` / `Where` / `OrderBy` / `Limit` / `Offset` / `Find` / `FindOne` / `Scan` / `Count` / `Sum` / `Max` / `Min`。(分组聚合 `GroupBy` / `Having` 暂未支持)

**`core.Inserter` / `Updater` / `Deleter`**:

go table.Insert().Set(field, value, typ).Exec() // (*Inserter) Exec() (uint64, error) table.Update().Set(field, value).Where(cond).Exec() // (*Updater) Exec() (int64, error) table.Delete().Where(cond).Exec() // (*Deleter) Exec() (int64, error)


### 事务

go txn := db.BeginTransaction() defer func() { if r := recover(); r != nil { _ = txn.Rollback() panic(r) } }() // ... 写入操作(Insert / Update / Delete)... if err := txn.Commit(); err != nil { /* ... */ }


`Transaction` 提供 `Commit()` / `Rollback()` / `IsCommitted()` / `IsRolledBack()`。

> 事务内所有修改只落在 BufferPool:中途不会刷盘,磁盘始终是事务开始前的状态。
> - `Commit` 把脏页 + Header 整体写入 WAL 并 `fsync`,成功后才回收事务中登记的待释放页。
> - `Rollback` 丢弃本事务碰过的页缓存、从磁盘重载 `TableInfo`、归还本事务新分配的页,**立即生效**。
> - `Begin` 会先把自动提交挂起的未落盘修改提交掉(BEGIN = 此前工作已提交的边界,与 SQL 的 BEGIN 语义一致)。

---

## database/sql 驱动

`DB/driver` 实现了标准库 `database/sql/driver` 接口(驱动名 `vrtx`,内置
SQL 子集翻译器,零第三方依赖),xorm / sqlx / 自研 ORM 可直接对接:

go import ( "database/sql" _ "git.ycl.pub/yl32940/TsDB2/DB/driver" )

db, _ := sql.Open("vrtx", "/path/to/data.db") // DSN = 数据库文件路径 defer db.Close() db.SetMaxOpenConns(1) // 引擎单写者模型,1 路连接即可

db.Exec(INSERT INTO users (id, name, age) VALUES (?, ?, ?), 1, "alice", 30) row := db.QueryRow(SELECT name, age FROM users WHERE id = ?, 1)


支持:SELECT(投影 / WHERE 全条件族含 `IN` `LIKE` `IS NULL` / `ORDER BY` /
`LIMIT` / 聚合 `COUNT SUM MIN MAX AVG`)、INSERT(多行 / 显式主键 / 省略列
=NULL)、UPDATE、DELETE、CREATE TABLE(类型映射 + 主键 / 唯一 / 普通索引)、
CREATE [UNIQUE] INDEX、DROP TABLE、事务。

不支持(明确报错,不静默):子查询、GROUP BY、ALTER TABLE、复合索引 /
复合主键、函数表达式、`BETWEEN`、RIGHT/FULL JOIN 与非等值连接等;CREATE TABLE 的表选项(`ENGINE=`/`CHARSET=` 等)解析后忽略。

语句清单、类型映射表、NULL / 时间语义、连接与并发约定见
[`docs/SQL_DRIVER_GUIDE.md`](docs/SQL_DRIVER_GUIDE.md);可运行示例
`demo/sql_driver`。

---

## Blob 存储

二进制大对象**只有一条存储路径**:`FieldType_Blob` 列的行内是 8 字节 `BlobRef`
(`[TotalLen(4B) + FirstPageID(4B)]`),内容落在**链页**上(`PageType=BlobIndex`,
`PageHdr.NextPage` 串接),单值上限 `core.BlobChainMax` = 256K 页 ≈ 4GB。

| 入口 | 你写什么 | 引擎做什么 | 适用场景 |
|------|----------|-----------|----------|
| ORM `[]byte` + `vrtx:"type:blob"` | 内容本身 | ORM 自动开事务 → 内容落链 → 行内存 8B 引用;读回时按引用把内容整块读回来 | 图片、JSON 片段、文件内容:要与行一起走增删改查的对象 |
| core `Table.InsertRow` / `UpdateRow` | `[]byte` / `string` / `io.Reader` / `types.Blob` | 同上,但**不需要显式事务**(见 `Table.blobFieldRef`) | 迁移、手写 core 写入 |
| core `tx.BlobCommit(r, compress)` → `BlobRef` | `io.Reader`(流式) | 只返回 8 字节引用,不进任何行字段,由你自己保管(存进别的列 / 留在应用内存) | 超大对象、要 Snappy、想自己管生命周期 |

**为什么行内只放引用**:行的布局是固定的(定长列 + 变长列槽位),内容整块塞进行里就必然被
"一个堆页"封顶。把内容挪到链上之后行内宽度恒定 8 字节,内容可以跨任意多页 —— 这也正是
blob 列与 `Dynamicbyte` 的分界(后者行内 6B 堆引用、内容整块落一个堆页,单值上限
`MaxBlobSize`≈16KB):**要装下任意大小的内容就用 blob 列**。

**blob 列不能作为索引键**(与动态列同级拦下):行内是页号而不是内容,拿它建索引等于按页号
排序/查找,`CreateIndex` 直接返 `ErrIndexBlobField`。迁移时源库落在 blob 列上的索引会被跳过
并记一条警告,不会让整张表的迁移失败。

**生命周期由引擎接管**:行删除 / 更新 / 表 drop 时,引擎按行内引用把旧链整条还回空闲链
(`releaseRowBlobRefs` → `BlobFree`);只有"自己 `BlobCommit` 出来、又没写进任何行"的引用
需要你自己 `BlobFree`。

### 链式 blob(core 层)

go tx := db.BeginTransaction() file, _ := os.Open("video.mp4") defer file.Close()

// 流式提交:io.Copy 内部 32KB buf, snappy.NewBufferedWriter ~64KB, // 峰值内存 ≈ 128KB, 与视频大小无关 ref, err := tx.BlobCommit(file, types.CompressSnappy) // ref.TotalLen 原始字节数(解压后) // ref.FirstPageID 链首页; (0,0) 表示空 blob if err != nil { tx.Rollback(); return err } if err := tx.Commit(); err != nil { return err }

// 读回: io.ReadAll → []byte (解压后), 长度 == ref.TotalLen data, err := tbl.BlobRead(ref, types.CompressSnappy)


调用约定:

1. **事务可选,但推荐**:`tx := db.BeginTransaction()` + `tx.Commit()` 时,中途失败可以 `tx.Rollback()` 整链回收(新页都在 `tx.allocated` 里)。不在事务里也走得通(`db.BlobCommit`,或直接往 blob 列写内容),代价是失败时已分配的页成为孤儿——与"无事务下任何写入失败"的语义一致。
2. **`compress` 由调用方选择并保存**:`BlobRef` 不携带压缩标志,调用方必须保证 `BlobCommit` 与 `BlobRead` 的 `compress` 一致(错误 → `ErrBlobCorrupted`)。
3. **中断安全**:流中途 `io.Reader.Read` 返错时函数立即返错,**已分配的链页仍归本事务所有**,`tx.Rollback()` 走 `tx.allocated` 路径整链回收;`tx.Commit()` 之后再丢,则调用方需 `tbl.BlobFree(ref)` 显式还链。
4. **容量上限**:`BlobChainMax = 256 * 1024` 页 ≈ 4GB(链长用 uint32 计数);超限返 `ErrBlobTooLarge`。
5. **生命周期**:`BlobRef` 是 8 字节值,可复制。写进 **blob 列** 的引用由引擎在行删除 / 更新 / 表 drop 时自动还链;只有在**行外**自己保管的引用(`tx.BlobCommit` 的返回值没写进任何行)才需要你调 `tbl.BlobFree(ref)`。
6. **空 blob**:`tx.BlobCommit(io.Reader=nil/EOF)` 返回 `BlobRef{0, 0}`,`Empty()` 为真,**不分配任何页**。

go // 只有在"引用没写进任何行"时, 才需要自己还链 tbl.Delete(id) // 行内引用 (blob 列) 的链由引擎随行回收 tbl.BlobFree(row.BlobRef) // row.BlobRef 是你自己存到某列/内存里的独立句柄


### blob 列(ORM + core)

两种载体,一张表内可混用:

go type File struct {

DB.BaseModel
Name  string `vrtx:"maxlen:64"`
Cover []byte `vrtx:"type:blob"`   // 载体一: []byte —— 一次性, 读回整块拉进内存
Data  types.Blob `vrtx:"type:blob"` // 载体二: 流式 —— 对象里直接持有流对象

} // []byte 载体: 增 / 改 与普通列无异 (Insert(&File{Cover: jpegBytes})), // 读回拿到的是【内容本身】, 不是引用 // // types.Blob 流式载体: 写侧 Data.Source 是任意 io.Reader (*os.File / 网络流...), // 引擎 io.Copy 流式落链, 内存峰值 ~128KB 与内容大小无关; // 读侧引擎只填回 8B 引用 (FirstPageID/TotalLen), 内容仍在链页, // 按需开流消费: // // got, _ := files.Find(id) // rc, _ := got.Data.Open(files.BlobReader()) // io.ReadCloser // io.Copy(dst, rc) // 流式, 不整块进内存 // // // Insert/Update 成功后引用自动回填进对象 (Data.FirstPageID != 0), // // 后续只改普通字段的 Update 不会抹掉 blob 链 // // string 载体不收 (按定长串语义会在第一个 0x00 处截断, 建模阶段就拒掉) // 索引不允许 (与动态列一样, 见 ErrIndexBlobField) // 单值超 ~4GB: 写入报错 ErrBlobTooLarge (含列名与写下的字节数)


完整演示:`demo/blob_stream`(core 层 5 场景:空流 / 8KB 不压缩 / 1MB Snappy / 网络中断回滚 / 100MB 随机流)、`demo/blob_orm`(ORM 层 6 场景:32MB 流式写 / 流式读 CRC / []byte 对照 / 改名 Update 保链 / 重开持久化 / 删行归还链页)。

---

## 架构设计

┌────────────────────────────────────────────────────────────┐ │ ORM 层 (package DB) │ │ Open / UseTable[T] / Table[T] / Query[T] │ │ 结构体 + vrtx tag → 自动推导表结构、字段类型、索引类型 │ │ 表基类:BaseModel / TimeSeriesModel → 对象头部增删改查 │ │ convert.go:reflect 结构体 ⇄ core.FieldValue │ └───────────────────────────┬────────────────────────────────┘

                        │

┌───────────────────────────▼────────────────────────────────┐ │ core 层 (package core) │ │ CreateTable / InsertRow / SearchByIndex / Query │ │ Transaction / WAL / BufferPool / Schema 演进 / 空闲页回收 │ └───────────────────────────┬────────────────────────────────┘

                        │
    ┌───────────────────┼───────────────────┐
    │                   │                   │

┌───────▼────────┐ ┌────────▼────────┐ ┌────────▼────────┐ │ Primary / Time │ │ BTree (二级) │ │ Hash (二级) │ │ 聚簇 B+ / 时间链│ │ 叶子存 pkID │ │ 桶目录 + 溢出链 │ │ Compose (复合) │ │ │ │ │ └───────┬────────┘ └────────┬────────┘ └────────┬────────┘

    │                   │                   │

┌───────▼───────────────────▼───────────────────▼─────────┐ │ DataPage / HeapPage / IndexMetaPage / TablePage / │ │ SchemaExtPage │ └───────────────────────────┬──────────────────────────────┘

                        │

┌───────────────────────────▼──────────────────────────────┐ │ File 层 + WAL + LRU BufferPool │ │ [HeaderPage][TablePage][DataPage][IndexPage][WAL] │ └──────────────────────────────────────────────────────────┘


---

## 存储格式

### 全局头页(PageID = 0)

`PageID = 0` 固定为 `HeaderPage`,固定占 **4096 字节**(`PageSize4K`):

go type HeaderPage struct { // 4096 字节 (PageSize4K),字段顺序被对齐钉死、零对齐填充 Magic [4]byte // off 0 魔术字 "VRTX" Version uint32 // off 4 格式版本,当前 = 11(core.Version) PageSize uint32 // off 8 数据页大小 (16384) NextFreePage uint32 // off 12 下一个未分配页(0 头页 / 1 建库预留,故从 2 开始) CreateTime int64 // off 16 建库时间(纳秒) ModifyTime int64 // off 24 最近修改时间(纳秒) NextTxnID uint64 // off 32 下一个事务 ID(从 1 开始,0 保留) GlobalFlags uint64 // off 40 全局状态 TablePage uint32 // off 48 表链首页 ID TableCount uint32 // off 52 表数量 FreePageStart uint32 // off 56 空闲页链表头 FreePageEnd uint32 // off 60 空闲页链表尾(与 FreePageStart 同生同灭) FreePageCount uint32 // off 64 空闲页数量 FirstWALPage uint32 // off 68 未恢复的 WAL 链首页(0 = 无待恢复 WAL) LastWALPage uint32 // off 72 WAL 链尾页(追加写入用) ExtConfigHdr [8]byte // off 76 扩展配置: 头 8 字节 (预留) HeaderCrc uint32 // off 84 整页 XXH32 校验 (写入前 PrepareWrite 生成; // 旧 v11 库为 0 = 无校验, 首次写头页自动生成) ExtConfig [244]byte // off 88 扩展配置其余 Comment [PageSize4K-332]byte // off 332 数据库介绍区 (3764B, 占满整页余下空间; // 建库默认写入 VersionComment 版本介绍与修复点, // Comment() / SetComment() 读写, 上限即本字段容量) }


> 布局按组排列(标识与分配 → 时间/事务 → 表/空闲链 → WAL → 扩展 → 介绍区),全部标量字段 8 字节对齐、
> 无任何对齐填充字节;`FreePageEnd` 必须落在 `FreePageStart+4`、`HeaderCrc` 落在偏移 84、
> 整体必须恰好 4096 字节,`core/header_page.go` 中有编译期断言保护。
>
> **HeaderCrc(XXH32)**:覆盖除 CRC 字段自身外的整页 4096 字节。所有头页写都汇聚在
> `File.writeHeaderLocked`,在那里统一调用 `PrepareWrite()` 生成 CRC —— 任何路径出盘的
> 头页 CRC 自动最新(WAL 里的头页快照在副本上重算,与快照内容自洽)。打开时 `Validate()`:
> Magic 必查;v11 且 CRC==0 视为旧库跳过(首次写头页自动生成 CRC,版本号不变即完成升级);
> 其余情况 CRC 不匹配拒绝打开。`core.OpenWithOptions(path, core.OpenOptions{ForceInspect: true})`
> 或命令行 `vrtxsql --force-inspect` 启用抢救模式:警告后只读打开(一切写操作返回
> `core.ErrReadOnly`,WAL 恢复跳过),供头页损坏后抢救数据。

格式版本演进(`core.Version`):

- `v2`:schema 改为「精确内联块 + 全库共享 `SchemaExtIndex` 扩展页」,不再使用溢出页链。
- `v3`:空闲页指针从页偏移 0 改到 `PageHdr.NextPage`(偏移 12),不再覆盖 `Type/RefCount`;旧库不兼容。
- `v4`:页头 `RefCount` 由 1 字节扩为 2 字节,空出的保留字节改为 `Flags`;WAL 页头统一为 40 字节。
- `v5`:`TableNameSize` 由 24 扩为 48 —— `TableInfo` 80 → 104 字节,`IndexMeta` 60 → 84 字节,
  表页记录与索引元数据条目整体位移,旧库不兼容。两个结构体的尺寸与关键字段偏移已用编译期断言钉死,
  `analyzer` 的硬编码偏移与之保持同步。
- `v6`:索引名与表名解耦,新增 `IndexNameSize` = 64(表名仍为 48)—— `IndexMeta` 84 → 100 字节,
  `TableInfo` 不变,旧库不兼容需重建。
- `v7`:有符号整数独立成档(`FieldType_Int8/16/32/64`)并改用保序编码(符号位翻转),
  与 `Float64ToSortKey` 同思路;旧库把 ORM 的 `int8/16/32/64` 列存在无符号档位(`1/2/4/8byte`)上,是补码大端,
  对负数不保序(跨 0 的范围扫描 / `ORDER BY` 会漏行或混入错误行),同一列盘上字节不同、索引键需重建,
  旧库不兼容需重建。主键列 / 时序键 `_ts` 仍留在无符号 8 字节档(补码),时序索引与 `pkID` 镜像不受影响。
- `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`,定长字符串的
  `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 列的表。
- `v11`(当前):HeaderPage 字段重排 —— `FreePageEnd` 从 Comment 之后(偏移 840)移到
  `FreePageStart` 之后(偏移 60),字段按 8 字节对齐、零对齐填充字节;`Comment` 扩展为
  3764 字节介绍区(占满整页余下空间)——建库默认写入 `VersionComment`(当前版本的功能
  介绍与修复点),`Comment()` / `SetComment()` 读写。所有字段偏移变化,旧库不兼容需重建。

### 页大小与公共页头

PageSize = 4096 * 4 = 16384 bytes // 数据页 / 索引页 / WAL 页 PageSize4K = 4096 bytes // HeaderPage 等固定元数据页 PageHdrSize = 20 bytes // 所有页统一的头部,位于每页偏移 0


go type PageHdr struct { // 20B,字段顺序被对齐与既有约定钉死 Type PageType // off 0 页类型 Flags byte // off 1 页状态位(保留,见 SetFlag/HasFlag) RefCount uint16 // off 2 引用计数(仅堆页使用,归零 ⟺ 可整页回收) PageID uint32 // off 4 PrePage uint32 // off 8 前驱页 NextPage uint32 // off 12 后继页 —— 堆链/索引链/空闲链/WAL 链共用 UseSize uint32 // off 16 已用字节数(含页头) }


> `NextPage` 必须落在偏移 12、`UseSize` 必须落在偏移 16、整体必须恰好 20 字节,`page/hdr.go` 中有编译期断言保护。

### 页类型

| 常量 | 值 | 说明 |
|------|----|------|
| `TableIndex` | 1 | 表页(表元数据,`TableInfo` 104B 一条,`TableNameSize`=48) |
| `DataIndex` | 2 | 数据页 |
| `HeapIndex` | 3 | 堆页(动态字段溢出存储) |
| `IndexMetaIndex` | 4 | 索引元数据页(每表一条链,条目 100B,`IndexNameSize`=64) |
| `IndexDataIndex` | 5 | 索引数据页(B+ 树节点等) |
| `DataOverflowPage` | 6 | ~~数据页溢出页~~(已废弃,schema 续块改用 `SchemaExtIndex`) |
| `WALIndex` | 7 | WAL 日志页 |
| `SchemaExtIndex` | 8 | 表结构扩展页(全库共享,存各表 schema 续块) |
| `BlobIndex` | 9 | Blob 链页(链式 Blob,每页 raw 载荷 16364B,无块头,`PageHdr.NextPage` 串接;详见「Blob 存储」) |

### DataPage 布局

+-------------------------------------------------------+ | PageHdr (20B) | Count (4B) | Min (8B) | Max (8B) | = 40B 头部 +-------------------------------------------------------+ | [len(4B) + rowData] <- 行数据区,顺序追加 | | [len(4B) + rowData] | | ... | | (空闲空间) | +-------------------------------------------------------+


- `len` 最高位(`0x80000000`)为**软删除标志**。
- `Min` / `Max` 记录各行前 8 字节的最小 / 最大值。

### 索引页头(IndexHead,32B)

go type IndexHead struct { // 32B(含编译器填充) Hdr PageHdr // off 0 20B IndexType IndexType // off 20 1B EntryLen uint16 // off 22 2B(off 21 为对齐填充) Count uint32 // off 24 4B IsLeaf byte // off 28 1B _ [3]byte // off 29 对齐填充 }


条目区紧随其后,共 `Count` 个、每个 `EntryLen` 字节,内容随索引类型不同(见表)。

### WAL 页头(40B)

首页与续页共用同一布局,便于整链用同一结构体解析:

off 0 PageHdr 20B 公共页头 off 20 _ 4B 对齐填充 off 24 TxnID 8B 事务 ID(仅首页有效) off 32 TotalEntries 4B 本事务条目总数(仅首页有效) off 36 State 1B WAL 状态(仅首页有效) off 37 _ 1B 对齐填充 off 38 EntryCount 2B 本页条目数


WAL 条目元数据固定 13B(`TargetPageID(4) + DataLen(4) + Checksum(4) + OpType(1)`)。

### 动态字段引用(行内 6B)

[PageID uint32 BE (4B)][Offset uint16 BE (2B)] PageID == 0 表示空值;真实数据存放于堆页 Offset 处。


### 字节序

- **页头 / HeaderPage / TableInfo 等内存结构**:按 `unsafe` 内存布局直接映射,跟随本机字节序(x86 小端)。
- **索引键、行内主键(pkID)、动态引用**:统一 **BigEndian**,保证跨机器可比较、可排序。

---

## 事务、WAL 与提交策略

### WAL 与恢复

- 写操作先在内存 BufferPool 中修改并标记脏页;`Commit` 时把**整页镜像**(含 `HeaderPage`)按序写入 WAL 链(`FirstWALPage` / `LastWALPage`),首页打「已提交」标记,然后 `fsync`(WAL 与提交标记随这次 fsync 一起落盘)。
- 随后回写真实页(`WritePageHome`),回写完成后再 `fsync` 一次 —— 钉死「数据页先于『忘掉日志』的头页落盘」:没有这次 fsync,OS 后台回刷可能把下一步的头页先刷下去、数据页留在缓存,断电后头页说「没有 WAL 可重放」,数据页却是旧值,已提交数据静默丢失。
- 最后回收整条 WAL 链 + 清头页 WAL 指针 + `fsync`(收尾)。
- 打开数据库时若发现待恢复 WAL(`FirstWALPage != 0`),先执行恢复(`wal_recovery.go`),恢复完成后整条 WAL 链一次性回收。**已提交链出现条目 CRC 校验失败时,`Open` 会报错且 WAL 链原样保留**(拒绝部分重放或回收销毁副本),需修复后重试。
- 页回收遵循「先 WAL 后回写」:事务中途**不**直写盘地还链,而是登记为 pending;提交成功后才真正还链,回滚则整体丢弃。DataPage 删空同样走延迟回收(登记 pendingFree,提交成功 / Close 时才还链)——避免回滚或崩溃重放后「树引用空闲页」的双重分配。
- 页分配只有 `HeaderPage.newPageWithReader` 一个入口,并且**一定交出干净空白页**(全 0 + 自述页号)。空闲链上的页是回收来的,而整链还链(`recycleChain`)是 O(1) 拼接、刻意不逐页覆写,页上会留着它上一世的内容(典型是 WAL 页残留);不擦的话,任何「先读后写」的分配方都会把残存字节当成自己的数据 —— 历史上曾因此把整页 WAL 残留解析成 91 条垃圾 `IndexMeta`,`EntryPage` 指向从未分配的页号,最终写到几十 GB 偏移、文件暴涨到 40GB。唯一例外是 WAL 页(`writePage` 传 `nil`):它整页重造,且链指针最后才经头页发布,残页不可达。

### 提交策略

| 预设 | 脏页阈值 | 防抖延迟 | 断电最多丢失(约) | 适用场景 |
|------|----------|----------|--------------------|----------|
| `batch` | 500 页 (~8MB) | 1s | ~10,000 行 | 离线批量导入,高吞吐 |
| `online` | 100 页 (~1.6MB) | 500ms | ~2,000 行 | 在线业务默认,平衡 |
| `safe` | 20 页 (~320KB) | 100ms | ~400 行 | 金融 / 医疗关键数据 |
| `ultra-safe` | 40 页 (~640KB) | 50ms | ~800 行 | 极致安全,性能最低 |
| 未知字符串 | 500 页 | 1s | — | 回退到默认 |

默认值(`DB/core/const.go`):

| 常量 | 值 | 说明 |
|------|----|------|
| `DefaultBufferPoolCapacity` | 1000 页 (~16MB) | 最小安全值 20 页,推荐 1000–5000 页 |
| `DefaultDirtyThreshold` | 500 页 (~8MB) | BufferPool 中脏页数超过即触发 Commit |
| `DefaultAutoCommitDelay` | 1s | 每次写操作重置计时器,停止写 1s 后自动 Commit |

> `Close()` 会兜底提交;即使既不手动 `Commit` 也不等防抖,`Close` 也不会丢数据。
> `SetAutoCommitDelay(0)` 可完全禁用防抖,仅靠脏页阈值 + `Close` 兜底(见 `demo/verify_no_commit`)。

---

## 性能实测

> 以下为本机(Windows / Go 1.25)实测结果,随机器、索引数量、并发情况而变化,仅作数量级参考。
> 数据来源为仓库内 demo 的实际输出。

### 10 万行端到端(`demo/verify_full`)

`Employee`:`_id` + `Name(32B)` + `Age`(B+ 索引 `idx_age`) + `Score` + `Dept(16B, B+ 索引 `idx_dept`);另有 `Order` 表 1 万行。
数据库文件 `fulltest.db` ≈ **28.35 MB**。

| 操作 | 规模 | 耗时 |
|------|------|------|
| 插入 + Commit | 100,000 行 | **4.2s(~23,800 行/s)** |
| 二级索引等值 `Age=25` | 2,000 行 | 0.023s |
| 二级索引范围 `Age>60` | 18,000 行 | 0.157s |
| 区间 `20<=Age<=25` | 12,000 行 | 0.103s |
| 全表扫描 `Score=999` | 100 行 | 0.018s |
| 排序 + 分页 `ORDER BY Age LIMIT 10` | — | 0.175s |
| 聚合 `COUNT(*)` | 100,000 行 | 0.056s |
| 更新 + Commit | 单行 | 0.005s |
| 删除 11 行 + Commit | — | 0.081s |

### 多索引 + 时序(`demo/orm_full`)

`Employee` 2 万行(Hash 索引 `idx_age` + B+ 索引 `idx_score`),`Metric` 时序 2 万点。文件 ≈ **6.58 MB**。

| 操作 | 规模 | 耗时 / 结果 |
|------|------|-------------|
| 主键表插入 | 20,000 行 | 0.93s(21,437 行/s) |
| Hash 等值 `Age=25` | 10,000 行 | 均摊 41.8ms |
| B+ 范围 `Score∈[200,299]` | 2,000 行 | 均摊 20.2ms |
| 时序写入 | 20,000 点 | 0.30s(67,652 点/s) |
| 时序范围 `_ts∈[3.0s,3.999s]` | 1,000 行 | 均摊 1.05ms |

### 100 万行完整性(`demo/verify_integrity`)

| 指标 | 数值 |
|------|------|
| 插入 1,000,000 行 | **24.42s(40,950 行/s)** |
| 全量扫描 1,000,000 行 | 1.52s(657,071 行/s) |
| 条件查询 `Age=25` | 20,000 行 / 0.206s |
| 文件大小 | **116.41 MB** |
| 每行平均占用 | 122 bytes(空间放大 ≈ 1.7x) |

### 50 万 + 10 万行压力(`demo/verify_stress`)

`BufferPool=2000 页`,`Employee` 带 `idx_age` / `idx_score` / `idx_dept` 三个二级索引。

| 指标 | 数值 |
|------|------|
| 插入 500,000 行 | 59.24s(8,440 行/s) |
| 插入 100,000 行 Order | 3.74s(26,735 行/s) |
| `Age=25` | 10,000 行 / 0.121s |
| `COUNT(*)` | 500,000 / 0.268s |
| 全量扫描 509,900 行 | 0.70s(726,881 行/s) |
| 文件大小 | 143.1 MB |

### Hash 索引点查延迟(`demo/hash_bench`)

| 数据量 | Hash 等值查询 avg | 主键回表 avg | Hash 全匹配 avg |
|--------|-------------------|--------------|-----------------|
| 10,000 | 31 µs | 18 µs | 0.09 ms |
| 100,000 | 46 µs | 26 µs | 0.98 ms |
| 400,000 | 58 µs | 22 µs | 3.73 ms |

### 提交策略对比(`demo/verify_commit_preset`,插入 1 万行)

| 预设 | 耗时 | 文件大小 |
|------|------|----------|
| `batch` | 0.30s | 0.0 MB(未触发落盘阈值) |
| `online` | 0.32s | 0.0 MB |
| `safe` | 0.33s | 0.9 MB |

> 阈值越小 → Commit 越频繁 → 磁盘 I/O 越多;阈值越大 → 内存占用越高。`demo/verify_dirty_threshold` 显示 5 万行在 20–5000 页各种阈值下耗时基本持平(1.6–1.8s),主要影响的是内存占用与断电丢失窗口。

---

## 项目结构

TsDB2/ ├── DB/ # ORM 层(package DB) │ ├── db.go # Open / UseTable[T] / 事务 / 提交策略 / schema 同步 │ ├── Table.go # 泛型 Table[T]:Insert/BatchInsert/Update/Delete/Find/... + Bind/New │ ├── query.go # 泛型 Query[T]:Where/OrderBy/Limit/Offset/Find/First/Count/Scan │ ├── model.go # 表基类:BaseModel / PrimaryModel / TimeSeriesModel(头部增删改查) │ ├── types.go # ParseModel / vrtx tag 解析 / Go 类型映射 / 索引合并 │ ├── convert.go # 结构体 ⇄ core.FieldValue 反射转换 │ ├── _test.go # 批量 / 并发 / blob / 查询等回归单测 │ │ │ └── core/ # 底层引擎(package core) │ ├── db.go # Open/Close/CreateTable/FindTable/DropTable/Commit │ ├── doc.go # 包级文档:职责 / 页布局指针 / 单写入者 / 提交策略 │ ├── table.go # Table 结构与字段定位 / 表页信息 │ ├── table_page.go # 表元数据页链 │ ├── table_schema.go # schema 版本与行布局解析 │ ├── table_crud.go # InsertRow / FindByPK / UpdateRowByPK / DeleteRowByPK │ ├── table_index.go # CreateIndex / DropIndex / SearchByIndex + 存量回填 │ ├── table_heap.go # 堆页读写(动态字段溢出存储) │ ├── table_rowcodec.go # 行序列化 / 动态列 spill / blob 引用 │ ├── table_compare.go # 行内字段比较(条件求值) │ ├── header_page.go # HeaderPage 与空闲页链表 │ ├── heap_page.go # 堆页布局 │ ├── index_meta_page.go # 索引元数据页(建 / 删 / 查索引) │ ├── index_rebuild.go # 索引批量重建与旧树页回收 │ ├── schema_ext.go # 全库共享 schema 扩展页 │ ├── file.go # 文件读写 / BufferPool 接线 / 页缓存同步 │ ├── const.go # Magic / Version / PageSize / 阈值默认值 │ ├── errors.go # 错误定义 │ ├── query.go # 查询对象:Where/OrderBy/Limit/Offset + 物化排序 │ ├── query_plan.go # 扫描计划:索引选择 / 键约束编码 │ ├── query_exec.go # 扫描执行:索引扫描 / 全表扫描 / 流式 top-k │ ├── query_agg.go # 聚合(COUNT/SUM/MIN/MAX/AVG)与排序键比较 │ ├── inserter.go / updater.go / deleter.go │ ├── transaction.go # 事务:Commit / Rollback │ ├── wal.go / wal_recovery.go # WAL 写入与崩溃恢复 │ ├── blob_commit.go / blob_read.go / blob_writer.go / blob_reader.go │ │ # 链式 Blob: tx.BlobCommit / BlobRead / BlobFree (≤~4GB) │ ├── condition.go # Condition 接口 │ ├── condition_types.go # Eq/Gt/Ge/Lt/Le/Like/In/And/Or/Not 各条件类型 │ ├── btree_import.go # B+ 树批量导入 │ ├── vacuum.go # VACUUM 整库重写回收 │ │ │ ├── page/ # 页布局(含编译期断言) │ │ ├── base_page.go # PageType / IndexType / IndexHead / HandleDb │ │ ├── hdr.go # PageHdr(20B) │ │ ├── datapage.go # DataPage 头部(40B) │ │ └── walpage.go # WAL 页头(40B) │ ├── types/types.go # FieldType / FieldValue / FieldInfo / 类型注册表 │ ├── util/utils.go # 高位标记、比较、二分查找等工具 │ ├── pool/buffer_pool.go # LRU BufferPool(读缓存 / 脏页阈值 / 随机→LRU 淘汰 / 写锁) │ │ │ └── index/ # 索引实现(init 注册进全局表 Compose) │ ├── doc.go # 包级文档:注册机制 / 能力接口 │ ├── ientry.go # IndexHandler 接口 + Compose 注册表 │ ├── primary_handler.go # 主键索引处理 │ ├── btree_handler.go # B+ 二级索引处理 │ ├── hash_handler.go # Hash 二级索引处理 │ ├── time_handler.go # 时序索引处理 │ ├── compose_handler.go # 复合索引处理 │ ├── sbt_handler.go # SBT 二级索引共享基座 │ ├── chain_util.go # 链式页工具 │ ├── btree/ # B+ 树核心(primary.go / secondary.go 等) │ ├── datapage/ # 统一 DataPage │ ├── timeindex/ # 时序 DataPage │ ├── strdatapage/ # 字符串 DataPage │ └── composepage/ # 复合索引页 │ ├── analyzer/ # 独立 module(git.ycl.pub/yl32940/TsDB2/analyzer):网页版 .db 分析器 │ ├── main.go / pagemap.go / store.go / assets.go │ ├── indexcheck.go / audit.go # 索引校验与全库一致性审计 │ └── go.mod # replace git.ycl.pub/yl32940/TsDB2 => ../,监听 :8081 │ ├── migrate/ # 跨库迁移(package migrate) │ ├── types.go # Source / Sink / RowWriter 接口、TableSchema 中间表示、Report │ ├── value.go # FieldValue ⇄ SQL 值的编解码(各整数档位、uint64 位模式、定长串去尾 NUL) │ ├── sqltype.go # 定长档位 ⇄ SQL 类型 │ ├── vrtx.go # VRTX 读写端(Schema/Scan/CreateTable/批量写入) │ ├── sql.go # SQL 通用逻辑 + __vrtx_meta 保真表 │ ├── mysql.go / sqlite.go # 两种方言(引号 / 建表 / information_schema / PRAGMA) │ ├── run.go # 编排器 Run:表筛选 / 建表策略 / 分批 / 报告 │ └── roundtriptest.go # 往返一致性测试 │ ├── cmd/vrtxsql/ # 命令行迁移工具(见「跨库迁移」) │ ├── demo/ # 27 个可运行示例;verify 为手工验证程序(见下节) ├── docs/ # 设计与操作文档(archive/ 为历史修复留档) ├── Makefile # build / test / fmt / lint / demo-analyzer ├── go.mod # module git.ycl.pub/yl32940/TsDB2(go 1.24;引擎零依赖,迁移层带 sqlite/mysql 驱动) └── README.md


---

## 运行示例

`demo/` 下每个子目录都是独立的 `main` 包,统一用 `go run ./demo/<名称>` 执行。以下为本仓库全部示例的实测结果(`go test ./...` 亦全部通过)。

### 综合 / ORM

| 命令 | 内容 | 实测结果 |
|------|------|----------|
| `go run ./demo/orm_full` | 4 种索引形态 CRUD + 事务 + 持久化 + 索引特性对比 + 头部增删改查 / 自定义主键 / 拒绝无基类模型 | **47 通过 / 0 失败** |
| `go run ./demo/verify_full` | 10 万行端到端:CRUD + 条件 + 排序 + 聚合 + 持久化 + 页回收 | **28 通过 / 0 失败** |
| `go run ./demo/feature_demo` | 特性清单逐项验证(含防抖提交、批量插入、FindAll、表基类约定) | **64 通过 / 0 失败** |
| `go run ./demo/verify_manual` | core 层最小示例:建表 + 时序索引 + Inserter/Updater/Deleter | 正常输出 |

### 索引专项

| 命令 | 内容 | 实测结果 |
|------|------|----------|
| `go run ./demo/btree_verify` | B+ 树独立校验(内存页存储,绕过 WAL/BufferPool) | 全部检查通过 |
| `go run ./demo/hash_verify` | Hash 索引正确性(3 万行 / 全量删除 / 持久化) | **通过 16 / 失败 0** |
| `go run ./demo/verify_hash_widekey` | 宽键(64B)哈希索引:分裂阈值随桶容量收敛 | **通过 20 / 失败 0** |
| `go run ./demo/verify_hash_index` | Hash 集成:删除 / 桶合并 / 持久化 / Hash vs B+ 性能 | **23 通过 / 0 失败**(Hash 约 1.29× B+) |
| `go run ./demo/hash_bench` | Hash 等值查询 / 主键回表 / 全匹配延迟基准 | 见 [性能实测](#性能实测) |

### 事务 / WAL / 提交策略

| 命令 | 内容 | 实测结果 |
|------|------|----------|
| `go run ./demo/verify_txn` | 事务提交 / 回滚 / 重开验证 | 通过 |
| `go run ./demo/verify_wal_rowlevel` | 事务 CRUD + Rollback + 重开持久化 | **全部通过** |
| `go run ./demo/verify_commit_preset` | `batch` / `online` / `safe` 三预设性能对比 | 见 [性能实测](#性能实测) |
| `go run ./demo/verify_dirty_threshold` | 脏页阈值 20–5000 页对比(5 万行) | 各阈值均通过 |
| `go run ./demo/verify_no_commit` | 无手动 Commit(5 场景,含 100 万行) | 全部通过,Close 兜底 |

### 规模 / 内存 / 配置

| 命令 | 内容 | 实测结果 |
|------|------|----------|
| `go run ./demo/verify_integrity` | 100 万行完整性 + 内存/磁盘统计 | **全部通过** |
| `go run ./demo/verify_mem` | 20 万行内存占用(脏页阈值 10000 页) | 通过,内存 < 200MB |
| `go run ./demo/verify_stress` | 50 万 Employee + 10 万 Order 压力测试 | **32 通过 / 0 失败** |
| `go run ./demo/stress_small_config` | 9 组「小 BufferPool × 小阈值」配置 | **9 通过 / 0 失败** |
| `go run ./demo/stress_extreme_config` | 7 组「极限小配置」(最小 BP=10 页) | **7 通过 / 0 失败** |
| `go run ./demo/bench_10m` | 1000 万行主表 + 100 万行订单表:写入 / 查询 / 更新 / 删除 / Schema 变更 / 重开校验 | **校验 87 通过 / 0 失败** |
| `go run ./demo/verify_lock_table` | 写锁语义(只推迟落盘不拒绝)/ 写入中阈值触发提交不丢行 / 缓存满淘汰干净页 / 关闭重开仍在 | **全部通过** |

### 二进制 / 大对象 / 特殊类型

| 命令 | 内容 | 实测结果 |
|------|------|----------|
| `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/verify_unique_index` | 唯一索引正确性 / 跨二级索引冲突 / 重建 / 持久化 | 通过 |
| `go run ./demo/verify_unique_orm` | ORM 层面 `vrtx:"unique"` 与 `index:NAME` 复合唯一键 | 通过 |
| `go run ./demo/verify_null_bitmap` | NULL 位图:可空列 + 多列组合 + 持久化 + 索引对 NULL 的语义 | 通过 |

### 运维 / 维护

| 命令 | 内容 | 实测结果 |
|------|------|----------|
| `go run ./demo/vacuum_demo` | Vacuum:回收已删除行 / 已释放 blob 链 / 索引碎片 | 通过 |

### 其他

bash go build ./... # 构建 go test ./... # 单元测试 make build / make test / make fmt / make lint


### 数据库可视化分析器(`analyzer/`)

独立 module,启动后在浏览器打开 `http://localhost:8081`,可打开任意 `.db` 文件:

bash cd analyzer && go run .

或: make demo-analyzer


可查看:Header 全字段、表链与表结构(含 schema 版本)、DataPage 行数据、索引页 / B+ 树、堆链、空闲页链、按主键定位,并执行全库一致性审计(页泄漏 / 空闲链自检 / 索引校验)。

---

## 跨库迁移:VRTX ↔ MySQL ↔ SQLite

`git.ycl.pub/yl32940/TsDB2/migrate` + `cmd/vrtxsql` 负责把**表结构、数据、索引、主键**整包搬过去,三个库两两互转:

VRTX ──► MySQL VRTX ──► SQLite MySQL ──► SQLite VRTX ◄── MySQL VRTX ◄── SQLite MySQL ◄── SQLite


`MySQL ↔ SQLite` 没有直连通道,走 `VRTX` 中转(先导入 VRTX 再导出),一条代码路径覆盖全部六个方向。

### 命令行

bash go run ./cmd/vrtxsql -from vrtx:/data/app.db -to sqlite:/data/app.sqlite go run ./cmd/vrtxsql -from sqlite:/data/app.sqlite -to vrtx:/data/app.db go run ./cmd/vrtxsql -from 'mysql:root:pw@tcp(127.0.0.1:3306)/shop' -to vrtx:/data/shop.db go run ./cmd/vrtxsql -from 'mysql:root:pw@tcp(127.0.0.1:3306)/shop' -to sqlite:/data/shop.sqlite go run ./cmd/vrtxsql -from vrtx:/data/app.db -list # 只看结构 go run ./cmd/vrtxsql -from vrtx:/data/app.db -to sqlite:/tmp/snap.sqlite --limit 1000 --dry-run


DSN 前缀决定库的种类,冒号后面整段就是它的标识(路径 / 连接串):

| 前缀 | 后面跟什么 |
|------|-----------|
| `vrtx:` | 数据文件路径,如 `vrtx:/data/app.db` |
| `sqlite:` | 文件路径或 `file:` URI,如 `sqlite:/data/app.sqlite` |
| `mysql:` | go-sql-driver 的 DSN,如 `mysql:user:pw@tcp(host:3306)/db?charset=utf8mb4` |

常用开关(`go run ./cmd/vrtxsql -h` 看全):

| 开关 | 含义 |
|------|------|
| `-tables a,b` | 只迁指定的表 |
| `-recreate` | 目标表已存在时先 `DROP` 重建(默认复用已有表并追加) |
| `-batch N` | 每批写入行数(默认 500) |
| `-limit N` | 每张表最多迁 N 行,用来试跑 |
| `-dry-run` | 只读结构、不写数据 |
| `-auto-pk` | 源表没有整数主键时自动补 `_id` 列(默认报错) |
| `-continue-on-error` | 某张表失败时跳过它、继续迁其余表(默认第一个错误就中止) |
| `-loose` | 值装不进目标列时按引擎语义截断(默认报错,免得静默丢数据) |
| `-truncate-oversize` | 行超过引擎整行上限(8171B)时截断而非报错——`INTO VRTX` 导入超大行时用 |
| `-no-meta` | 不写 `__vrtx_meta`,放弃定长档位 / 索引类型的还原 |
| `-v` | 打印每张表的进度 |

### 库内 API

go import "git.ycl.pub/yl32940/TsDB2/migrate"

opts := migrate.DefaultOptions() // 零值 Options 不推荐直接用 src, _ := migrate.OpenVRTX("/data/app.db", opts) dst, _ := migrate.OpenSQLite("/data/app.sqlite", opts) defer src.Close(); defer dst.Close()

rep, err := migrate.Run(src, dst, opts) // 任意方向都走这一个入口 fmt.Println(rep.String()) // 每张表的行数 / 列数 / 索引数 / 耗时 ```

Source(读)与 Sink(写)是两个小接口,Run 只依赖它们,所以加一种数据库只要再写一个方言实现。MySQL / SQLite 端已经实现好,VRTX 同时是 Source 和 Sink。

保真表 __vrtx_meta

SQL 表达不了 VRTX 的这些细节,所以导出时会在 SQL 侧额外建一张隐藏表 __vrtx_meta 记着:

  • 每列的定长档位(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)。

这样 VRTX → SQLite → VRTX 拿回来的库和原库逐列逐行等价(migrate/roundtrip_test.go 里有符号 / 无符号各档整数 + 浮点 + 变长列 + Hash/B+ 树索引 + uint64 边界值 + 可空列的 NULL 值 + 唯一索引全部比对通过)。

不带 __vrtx_meta 的 SQL 库(外部既有库)也能导入:类型按 SQL 声明就近取档(INTEGER → Int64、VARCHAR(32) → string(32B)、REAL → Float64、BLOB / BINARY / VARBINARY → Blob、TEXT → Dynamicbyte),主键与二级索引照搬(落在 blob 列上的索引会被跳过并记警告,见 Blob 存储)。

  • 不想要这张表就加 -no-meta(代价是往返有损,类型会被就近档位覆盖)。

主键与索引怎么落的

  • VRTX 主键表:行数据就寄存在主键索引里,pkID 是行的真实身份 → 导出成 SQL 的 PRIMARY KEY,回来时用同一个 pkID 插回(InsertRowWithID),编号不漂移。
  • VRTX 主键列不是 8 字节整数(例如定长串主键):补一个 _id 列承载行号当 SQL 主键,原列退化成普通列并补一个 B+ 树索引,保住"按原列查"。
  • VRTX 时序表:行存储索引的键是 _ts(不是行号),所以导出到 SQL 时不设主键,_ts 照常是普通列;索引类型与键列记在 __vrtx_meta 里,导回 VRTX 时原样重建 Time 索引。每行另有索引内部维护的行号(pkID),它只在库内用来区分同一时间戳下的多行,跨引擎没有意义 —— 因此不追求编号一致,只保证每个时间点的数据一字不差。
  • SQL 源表没有主键:默认报错(VRTX 的表必须有行存储索引),加 -auto-pk 自动补 _id;只想跳过它、继续迁其余表就加 -continue-on-error。
  • 二级索引:B+ 树 / Hash / 复合索引按名字、类型、分量列顺序重建;顺序与类型从 __vrtx_meta 还原,没有 meta 时按目标引擎默认(B+ 树)处理。

实现要点

  • 推式扫描:core 的 Query.ScanWithPKID 是回调式的、中途停不下来,所以 Source.Scan 也做成"把行推给回调",而不是 Next() 迭代器。
  • 分批 + 分表提交:每张表迁完调一次目标端的 Commit(VRTX 实现了 Committer),千万行的库不会等最后 Close 才落盘。
  • 列旁路:core 原本没导出"枚举索引"与"按真实 pkID 扫描",IndexMetaPage.GetIndexes()/GetRowStoreIndex() 与 Query.ScanWithPKID 是为迁移补的两个口子。

细节与更多示例见 docs/MIGRATION_GUIDE.md。


注意事项与已知限制

  1. 更新是整行覆盖:Table[T].Update 未赋值的字段会被清零,不是局部字段更新(demo/orm_full 有验证)。
  2. 未建索引即全表扫描:没有 index tag 的列,条件查询退化为全表扫描。
  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)才会补上写入这一刻的纳秒时间戳(与显式传入的时间戳同量纲;同一纳秒内连插多行允许,同刻多行由行号区分)。
  7. LockWrites 是「阻塞」而非「拒绝」,锁的是「页落盘」:它挡住的是 BufferPool.WritePage(正在写盘的 goroutine 阻塞等待),Insert / Update / Delete 本身照常改内存中的页并标记脏,不会返回任何错误 —— 引擎里没有"写入被拒绝"这种错误码。因此锁定期写入既不报错也不丢,只是落盘被推迟到 UnlockWrites(或 Commit 内部临时加锁结束)之后。demo/verify_lock_table 按这套语义断言:锁定期写入不报错且一条不丢、写入过程中脏页阈值触发提交不丢行、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+ 树借位 / 合并)。
  10. 空间放大:文件按 16KB 页预分配,且索引 / 空闲页会带来冗余;实测 100 万行 Employee 约 122 bytes/行、放大 ≈ 1.7x。
  11. unique 由唯一索引执行,且不约束 NULL:约束落在索引上(插入 / 更新前先按键探一次),违反报 core.ErrDuplicatedKey。三个后果值得先知道:库中已有重复值时唯一索引建不出来(同一个错误,需先清理数据);表里已存在同名非唯一索引时 UseTable 直接报错,要先删索引再重建;主键 / 时序这类行存储索引不支持唯一约束(主键本就唯一,时序键允许同刻多行)。可空列的 NULL 不参与唯一性 —— 任一分量为 NULL 的行不进唯一索引,因此可以共存任意多行 NULL(复合唯一键亦然)。
  12. IndexTypeStr(3) 已废弃:仅为兼容旧库文件保留(按复合条目处理),新代码请使用 IndexTypeBTree(5) / IndexTypeHash(6)。
  13. 表名 48 字节、索引名 64 字节,两套独立额度(core.TableNameSize / core.IndexNameSize,UTF-8 下约 16 / 21 个汉字)。索引名单独放宽到 64,是因为自动命名要把「前缀 + 表名 + 列名」拼在一起,和表名共用一个 48 会互相侵占:
    • 表名:≤ 48 字节(TableInfo.Name);pk_ 前缀算在索引名的 64 里,不再侵占表名额度;
    • 自动命名的二级索引 idx_<表名>_<首列名>:≤ 64 字节,即表名 ≤ 59 - len(首列名)。 UseTable 在建表前先算这笔账(checkNameBudget),返回带具体字节数的 ErrTableNameTooLong / ErrIndexNameTooLong;名字还不够长就用 vrtx:"index:<名字>" 显式命名二级索引。core 层 CreateIndex 同样会拒绝超长名,而不是 copy 静默截断成另一个名字。
  14. 文件格式版本 v11:v10 及更早的旧格式(HeaderPage 旧字段布局 / 类型枚举旧编号 / 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 就近取定长档位(16~2048);TEXT / 无长度上限的类型一律落 Dynamicbyte。声明超过 2048 字节的字符串在 VRTX 里是动态列,不能再建索引。
  19. 迁移不搬 AUTO_INCREMENT / 序列:SQL 的自增属性不还原(VRTX 的主键号由引擎自己的游标分配);-recreate 之外的重复迁移是追加语义,同一张表迁两次会插两遍 / 撞主键报错,续传请用 -tables 只点名没迁完的表。
  20. 迁移期间的并发写不做保证:migrate.Run 是"读源 → 写目标"的单向快照,不锁源库、也不是一致性快照;边写边迁只能拿到某一时刻的近似结果。
  21. 时序表的行号不保证一致:VRTX 时序索引的 pkID 由索引内部维护(只用来在库内区分同一时间戳下的多行),跨库没有意义,因此往返只保证 _ts 与各列数据一致,不保证行号一致。
  22. 单进程嵌入式 + 迁移:modernc.org/sqlite 写入端会 SetMaxOpenConns(1),同一进程内对同一个 SQLite 文件的并发迁移会串行化;MySQL 端按 DSN 的连接池走,大批量迁移建议 -batch 调大(如 2000)减少往返。
  23. 一张表只能有一个行存储索引:主键索引(Primary)与时序索引(Time)都直接持有行数据,二者不能共存。同一个结构体同时内嵌 BaseModel 与 TimeSeriesModel 报 DB.ErrInvalidPrimaryKey;在同一张表上再 CreateIndex 另一个行存储索引报 core.ErrRowStoreExists(报错而不是静默生效——否则会得到一个"元数据说自己是行存储、却一行都没有"的索引,之后回表 / 删除 / 重建全都错乱)。
  24. 头部方法没有类型参数:obj.Load(pk) 是"填自己",不会返回新对象(拿不到 T)。要类型安全的新对象请用 Table[T].Find。另外不要浅拷贝已绑定的对象:绑定信息(属于哪张表、本体是谁)存在对象里,dup := *u 之后 dup.Update() 更新的是 u,dup 上的改动会被静默丢弃;需要独立对象请用 Table.New + Load。
  25. 改主键/时序键的「列名」可以,改「索引类型」不行:vrtx:"pk" 只是换默认键列(_id / _ts 不再建列);把主键表改成时序表(或反之)等于换一级索引,必须迁移数据重建表。
  26. 表必须内嵌基类:没有内嵌 DB.BaseModel / DB.PrimaryModel / DB.TimeSeriesModel 的结构体不是本框架的表,ParseModel / UseTable 报 ErrNotModel(校验在建表之前,不会留下半成品表)。单写 vrtx:"pk" 不能替代基类 —— 它的语义是"覆盖基类默认的键列"。按 SQL 元数据动态建表的 migrate 包走 core 层(db.CreateTable),不受此约束。
  27. Blob 只有链式一条路径,行内只放 8 字节引用:FieldType_Blob 列的行内是 [TotalLen(4B) + FirstPageID(4B)],内容落在可跨页的链页上(BlobChainMax ≈ 4GB,可选 Snappy)—— 写 []byte / string / io.Reader 都由引擎自动落链(ORM 与 core 都不要求显式事务),读回拿到的就是内容本身;blob 列不能作索引键(ErrIndexBlobField)。它与 Dynamicbyte(行内 6B 堆引用、内容整块落一个堆页、≤MaxBlobSize≈16KB)的分界就是"内容能不能跨页"。行内引用的链由引擎在行删除 / 更新 / 表 drop 时自动回收;只有行外自管的 BlobRef(tx.BlobCommit 的返回值没写进任何行)才需要 tbl.BlobFree(ref)。BlobRef 不携带压缩标志,BlobCommit 与 BlobRead 的 compress 必须一致(不一致 → ErrBlobCorrupted)。空输入走"零页零字节"路径不分配任何页,详见 Blob 存储。
  28. VACUUM(含自动调度)之后,旧的表句柄作废:VACUUM 会关闭并重建整个引擎状态(文件被原子替换重开),此前通过 UseTable / FindTable 拿到的 Table[T] / *core.Table 内部还指向旧状态,继续使用会读到旧句柄的幽灵数据。VACUUM 后必须重新取表。手动与自动 VACUUM 由引擎互斥串行,不会双开;但 VACUUM 运行期间不要并发写入(违反单写入者模型)。
  29. 复合索引即 B+ 树,无特殊限制:IndexTypeCompose 的处理器的底层是 SBT 二级 B+ 树(与 IndexTypeBTree 复合键同结构),最左前缀/等值/范围 O(logN)、全局有序、MIN/MAX 端点快路径可用;各分量按真实宽度定长拼接,不限 8 字节(Decimal/Year/定长字符串列均可做分量)。ORM 侧写 type:compose 会报「未知字段类型」(ORM 只认 hash/btree)——用默认的多字段同名 index:NAME 即可;core API 可直接用 IndexTypeCompose。
  30. DropField 拒绝删除行存储索引的分量列(主键/时序键列):删了之后新行提不出键、不进任何索引,插入"成功"但行静默丢失。core 层与 ORM 层(schema 同步本来就不动主键列)现在统一报错;二级索引分量列仍允许删除(README 既有语义:新行不进该索引,老行残留条目由主键活性过滤兜底)。
  31. Find / Scan / Select 不再静默跳过坏行:行反序列化失败(blob 链损坏、长度不符等)会让查询返回错误而不是返回缺行的"成功"结果集。这是行为变更 —— 以前依赖"跳过继续"的调用方现在要处理 error。

相关文档


许可证

MIT License