|
|
1 月之前 | |
|---|---|---|
| .headwt | 1 月之前 | |
| .idea | 1 月之前 | |
| DB | 1 月之前 | |
| analyzer | 1 月之前 | |
| cmd | 1 月之前 | |
| demo | 1 月之前 | |
| docs | 1 月之前 | |
| migrate | 1 月之前 | |
| .DS_Store | 1 月之前 | |
| .gitignore | 1 月之前 | |
| Makefile | 1 月之前 | |
| README.md | 1 月之前 | |
| go.mod | 1 月之前 | |
| go.sum | 1 月之前 | |
| main.go | 1 月之前 | |
| tmp_migrate_req.json | 1 月之前 | |
| tmp_migrate_req2.json | 1 月之前 | |
| tmp_req3.json | 1 月之前 | |
| tmp_req4.json | 1 月之前 | |
| tmp_runtests.ps1 | 1 月之前 | |
| tmp_runtests2.ps1 | 1 月之前 |
TsDB2 是一个纯 Go 的嵌入式数据库,同时服务两类场景:
_id 主键 + 聚簇 B+ 树,叶子直接存行数据。_ts 主键 + 扁平时间条目链,按时间段裁剪 DataPage。代码分三层:
| 层 | 包 | 职责 |
|---|---|---|
| ORM 层 | TsDB2/DB |
用 Go 结构体 + vrtx tag 声明表结构与索引,通过泛型 Table[T] / Query[T] 完成增删改查,无需手写建表 / 建索引语句 |
| core 层 | TsDB2/DB/core |
页式存储、B+ 树 / Hash / 时序索引、事务、WAL、BufferPool 等底层实现 |
| 迁移层 | TsDB2/migrate + cmd/vrtxsql |
与 MySQL / SQLite 互转(结构 + 数据 + 索引 + 主键),见 跨库迁移 |
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() 精确作用于这一行,不会误伤同刻的其它行;二级索引回表走的也是同一个行号。UseTable[T] 对已存在的表自动同步结构体字段(新增列 / 删除列),并补建缺失的二级索引;索引分量与模型不符时直接报错而非静默沿用。index:NAME 自动合并,支持最左前缀)。Where 支持 = != > >= < <= like,多个 Where 之间为 AND;支持 OrderBy / Limit / Offset / Count / 流式 Scan / First。Eq/Gt/Ge/Lt/Le/Like/ILike/In 与 And/Or/Not 任意组合;Query 另支持 Select/GroupBy/Having/FindOne/Sum/Max/Min。BeginTransaction() / Commit() / Rollback();提交走整页 WAL + fsync,回滚通过丢弃缓存页立即生效。batch / online / safe / ultra-safe),默认无需手动 Commit。DropTable 后页可复用)、动态字段走堆页 + 引用计数自动回收。analyzer/ 是独立的网页工具,可打开 .db 文件查看页布局、表 / 索引结构、堆链、空闲链,并做全库一致性审计。模块路径为本地
TsDB2,直接在仓库内go run ./demo/...。
package main
import (
"fmt"
"TsDB2/DB"
)
// 主键表:_id 主键 + Hash 二级索引(Age) + B+ 二级索引(Score)
type Employee struct {
DB.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 {
DB.TimeSeriesModel
Host string `vrtx:"maxlen:16"`
Value int64
}
func main() {
db, err := DB.Open("demo.db")
if err != nil {
panic(err)
}
defer db.Close() // Close 会兜底提交未落盘数据
// 建表 + 自动建索引(表不存在时创建,存在时同步 schema / 补建索引)
emp, err := DB.UseTable[Employee](db, "employee")
if err != nil {
panic(err)
}
// 增:ID 为 0 时由 ORM 自动分配(= 当前行序号 + 1),并回写到结构体
_ = 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: DB.BaseModel{ID: 1}, Name: "Alice", Age: 26, Score: 91, Dept: "D2"})
// 删:按主键删除
_ = emp.Delete(2)
// 也可以直接用对象头部的增删改查(由内嵌的 DB.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, _ := DB.UseTable[Metric](db, "metric")
_ = met.Insert(&Metric{TimeSeriesModel: DB.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)
}
}
运行:
go run ./demo/orm_full # 完整 ORM 特性(4 种索引 CRUD + 事务 + 持久化)
go run . # core 层最小示例(时序索引)
| 写法 | 主键列 | 一级(行存储)索引 | 主键是否回写结构体 | 适用场景 |
|---|---|---|---|---|
内嵌 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" 自行指定:
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" |
字符串按 32 字节定长存储(≤32/64/128/256 就近取档,>256 转动态列);N 的上界是 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" |
非上述关键字的部分视为列名 |
要点:
Age 列名即 Age;Where("Age", ...) 用列名匹配。int / uint / int64 / uint64。引擎的行主键号 pkID 是 uint64,主键表的键列是它的镜像,所以字符串 / 浮点 / 切片 / 窄整数(int32 等)都在 ParseModel 阶段直接报 DB.ErrInvalidPrimaryKey(errors.Is 可判定);负数主键在 Insert / Update 时同样报该错,不会写入查不到的行。pk,或同时内嵌两个表基类,都报 DB.ErrInvalidPrimaryKey。但内嵌基类 + 标 pk 是合法的 —— 那是"覆盖基类默认键列",正是自定义主键/时序键的用法。AddField / DropField),建表时按模型建好,改主键字段名请重建表(换主键列 = 换索引键,需要迁移数据)。maxlen 只对 string 生效;数值类型的宽度由 Go 类型决定,maxlen 被忽略。maxlen 超过 2048 的列落动态列,内容整体存进一个堆页,所以声明超过 16360 字节的列永远兑现不了 —— 这种声明在 ParseModel 阶段就报错(错误里给出上限与写下的值),而不是等某天写入大值时才失败。要存更大的内容请改用 []byte(Blob),或把大列拆到附表。AddField 按整行占用校验,超限直接报错并列出各列宽度(降序);修法是把最宽的几列改成动态列(maxlen>2048 或 []byte),或把大列拆到附表。index:NAME 时,会自动合并为一个复合索引:分量顺序 = 字段在表中的物理顺序,索引类型取首次声明的类型,支持最左前缀(a=? 或 a=? AND b>?)。[]byte、maxlen>2048 的字符串)不能建索引:UseTable 会直接报错(动态列行内只存 6B 堆引用,做键无意义)。有符号整数与无符号整数分属两档: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 存储) |
浮点列不复用整数列(旧版本把
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 存储 |
浮点保序编码:
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) |
扁平索引页链 | [composeKey(N×8B) + PKID(8B)] |
最左前缀 | 前缀 + 次分量范围 | 是 |
索引类型常量(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)。TsDB2/DB)// 打开 / 关闭 / 提交 / 删表
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)
// 获取(或创建)泛型表;表名缺省时由类型名推导(首个字母小写:Employee -> employee, OrderItem -> orderItem)
func UseTable[T any](db *DB, name ...string) (*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) BeginTransaction() *core.Transaction
// 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))
// 对象头部(由内嵌的 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 字段
// Query[T] 链式查询
func (q *Query[T]) Where(field string, op string, value interface{}) *Query[T]
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
op 支持(大小写敏感的小写 / 符号两种写法):
| 写法 | 语义 |
|---|---|
= / == / eq |
等于 |
!= / <> |
不等于(实现为 Not(Eq(...))) |
> / gt |
大于 |
>= / ge / gte |
大于等于 |
< / lt |
小于 |
<= / le / lte |
小于等于 |
like |
LIKE 模式匹配(支持 % 与 _,大小写敏感) |
未知
op会退化为等值比较。ORM 不支持In,如需In请用 core 层条件构造器。
TsDB2/DB/core)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) (*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 不一致 / 压缩标志不符)
条件构造器(core 包):
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 / GroupBy / Having / Limit / Offset / Find / FindOne / Scan / Count / Sum / Max / Min。
core.Inserter / Updater / Deleter:
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)
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、归还本事务新分配的页,立即生效。
二进制大对象只有一条存储路径: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。
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)
调用约定:
tx := db.BeginTransaction() + tx.Commit() 时,中途失败可以 tx.Rollback() 整链回收(新页都在 tx.allocated 里)。不在事务里也走得通(db.BlobCommit,或直接往 blob 列写内容),代价是失败时已分配的页成为孤儿——与"无事务下任何写入失败"的语义一致。compress 由调用方选择并保存:BlobRef 不携带压缩标志,调用方必须保证 BlobCommit 与 BlobRead 的 compress 一致(错误 → ErrBlobCorrupted)。io.Reader.Read 返错时函数立即返错,已分配的链页仍归本事务所有,tx.Rollback() 走 tx.allocated 路径整链回收;tx.Commit() 之后再丢,则调用方需 tbl.BlobFree(ref) 显式还链。BlobChainMax = 256 * 1024 页 ≈ 4GB(链长用 uint32 计数);超限返 ErrBlobTooLarge。BlobRef 是 8 字节值,可复制。写进 blob 列 的引用由引擎在行删除 / 更新 / 表 drop 时自动还链;只有在行外自己保管的引用(tx.BlobCommit 的返回值没写进任何行)才需要你调 tbl.BlobFree(ref)。空 blob:tx.BlobCommit(io.Reader=nil/EOF) 返回 BlobRef{0, 0},Empty() 为真,不分配任何页。
// 只有在"引用没写进任何行"时, 才需要自己还链
tbl.Delete(id) // 行内引用 (blob 列) 的链由引擎随行回收
tbl.BlobFree(row.BlobRef) // row.BlobRef 是你自己存到某列/内存里的独立句柄
type File struct {
DB.BaseModel
Name string `vrtx:"maxlen:64"`
Cover []byte `vrtx:"type:blob"` // 行内 8B 引用, 内容在链上 (≤~4GB)
}
// 增 / 改 与普通列无异: Insert(&File{Cover: jpegBytes}) —— ORM 自动开事务落链
// 读 / 查 同其它列: 拿到的是【内容本身】([]byte), 不是引用
// 载体只收 []byte (string 载体按定长串语义会在第一个 0x00 处截断, 建模阶段就拒掉)
// 索引不允许 (与动态列一样, 见 ErrIndexBlobField)
// 单值超 ~4GB: 写入报错 ErrBlobTooLarge (含列名与写下的字节数)
完整演示:demo/blob_stream(5 个场景:空流 / 8KB 不压缩 / 1MB Snappy / 网络中断回滚 / 100MB 随机流)。
┌────────────────────────────────────────────────────────────┐
│ 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 固定为 HeaderPage,固定占 4096 字节(PageSize4K):
type HeaderPage struct {
Magic [4]byte // 魔术字 "VRTX"
Version uint32 // 格式版本,当前 = 7(core.Version)
PageSize uint32 // 数据页大小 (16384)
NextFreePage uint32 // 下一个未分配页(0 头页 / 1 建库预留,故从 2 开始)
TablePage uint32 // 表链首页 ID
TableCount uint32 // 表数量
CreateTime int64
ModifyTime int64
FreePageStart uint32 // 空闲页链表头
FreePageCount uint32 // 空闲页数量
GlobalFlags uint64
FirstWALPage uint32 // 未恢复的 WAL 链首页(0 = 无待恢复 WAL)
LastWALPage uint32 // WAL 链尾页(追加写入用)
NextTxnID uint64 // 下一个事务 ID(从 1 开始,0 保留)
ExtConfig [256]byte
Comment [512]byte // 建库描述
FreePageEnd uint32 // 空闲页链表尾(与 FreePageStart 同生同灭)
}
格式版本演进(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(无符号、补码)。PageSize = 4096 * 4 = 16384 bytes // 数据页 / 索引页 / WAL 页
PageSize4K = 4096 bytes // HeaderPage 等固定元数据页
PageHdrSize = 20 bytes // 所有页统一的头部,位于每页偏移 0
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 | SchemaExtIndex) |
WALIndex |
7 | WAL 日志页 |
SchemaExtIndex |
8 | 表结构扩展页(全库共享,存各表 schema 续块) |
BlobIndex |
9 | Blob 链页(链式 Blob,每页 raw 载荷 16364B,无块头,PageHdr.NextPage 串接;详见「Blob 存储」) |
+-------------------------------------------------------+
| PageHdr (20B) | Count (4B) | Min (8B) | Max (8B) | = 40B 头部
+-------------------------------------------------------+
| [len(4B) + rowData] <- 行数据区,顺序追加 |
| [len(4B) + rowData] |
| ... |
| (空闲空间) |
+-------------------------------------------------------+
len 最高位(0x80000000)为软删除标志。Min / Max 记录各行前 8 字节的最小 / 最大值。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 字节,内容随索引类型不同(见表)。
首页与续页共用同一布局,便于整链用同一结构体解析:
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))。
[PageID uint32 BE (4B)][Offset uint16 BE (2B)]
PageID == 0 表示空值;真实数据存放于堆页 Offset 处。
unsafe 内存布局直接映射,跟随本机字节序(x86 小端)。Commit 时把整页镜像(含 HeaderPage)按序写入 WAL 链(FirstWALPage / LastWALPage),随后 fsync,成功后再回写真实页。FirstWALPage != 0),先执行恢复(wal_recovery.go),恢复完成后整条 WAL 链一次性回收。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/no_commit_test)。
以下为本机(Windows / Go 1.25)实测结果,随机器、索引数量、并发情况而变化,仅作数量级参考。 数据来源为仓库内 demo 的实际输出。
demo/full_test)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 |
demo/integrity_test)| 指标 | 数值 |
|---|---|
| 插入 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) |
demo/stress_test)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 |
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/commit_preset_test,插入 1 万行)| 预设 | 耗时 | 文件大小 |
|---|---|---|
batch |
0.30s | 0.0 MB(未触发落盘阈值) |
online |
0.32s | 0.0 MB |
safe |
0.33s | 0.9 MB |
阈值越小 → Commit 越频繁 → 磁盘 I/O 越多;阈值越大 → 内存占用越高。
demo/dirty_threshold_test显示 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 反射转换
│ ├── types_index_test.go # 复合索引 / 动态列建索引约束的单元测试
│ │
│ └── core/ # 底层引擎(package core)
│ ├── db.go # Open/Close/CreateTable/FindTable/DropTable/Commit
│ ├── Table.go # 表操作:InsertRow/SearchByIndex/UpdateRow/DeleteRow/堆页
│ ├── TablePage.go # 表元数据页链
│ ├── HeadPage.go # HeaderPage 与空闲页链表
│ ├── HeapPage.go # 堆页(动态字段溢出存储)
│ ├── IndexMetaPage.go # 索引元数据页(建 / 删 / 查索引)
│ ├── SchemaExt.go # 全库共享 schema 扩展页
│ ├── File.go # 文件读写 / BufferPool 接线 / 页缓存同步
│ ├── const.go # Magic / Version / PageSize / 阈值默认值
│ ├── errors.go # 错误定义
│ ├── query.go # 查询:索引扫描 / 全表扫描 / 排序 / 聚合
│ ├── 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 接口
│ ├── EqCondition.go / GtCondition.go / GeCondition.go
│ ├── LtCondition.go / LeCondition.go
│ ├── LikeCondition.go / InCondition.go
│ ├── AndCondition.go / OrCondition.go / NotCondition.go
│ ├── btree_import.go # B+ 树批量导入
│ │
│ ├── page/ # 页布局(含编译期断言)
│ │ ├── BasePage.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(脏页阈值 / 容量阻塞 / 写锁)
│ │
│ └── index/ # 索引实现
│ ├── ientry.go # 索引条目接口 + 类型注册表
│ ├── primary_handler.go # 主键索引处理
│ ├── btree_handler.go # B+ 二级索引处理
│ ├── hash_handler.go # Hash 二级索引处理
│ ├── time_handler.go # 时序索引处理
│ ├── compose_handler.go # 复合索引处理
│ ├── chain_util.go # 链式页工具
│ ├── btree/ # B+ 树核心(primary.go / secondary.go 等)
│ ├── datapage/ # 统一 DataPage
│ ├── timedatapage/ # 时序 DataPage
│ ├── strdatapage/ # 字符串 DataPage
│ └── composepage/ # 复合索引页
│
├── analyzer/ # 独立 module(TsDB2/analyzer):网页版 .db 分析器
│ ├── main.go / page.go / indexcheck.go / audit.go
│ └── go.mod # replace 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:表筛选 / 建表策略 / 分批 / 报告
│ └── roundtrip_test.go # 往返一致性测试
│
├── cmd/vrtxsql/ # 命令行迁移工具(见「跨库迁移」)
│
├── demo/ # 20 个可运行示例 / 验证程序(见下节)
├── docs/ # 设计与操作文档
├── main.go # core 层最小示例(时序索引)
├── Makefile # build / test / fmt / lint / demo-analyzer
├── go.mod # module TsDB2(go 1.25;引擎零依赖,迁移层带 sqlite/mysql 驱动)
└── README.md
demo/ 下每个子目录都是独立的 main 包,统一用 go run ./demo/<名称> 执行。以下为本仓库全部示例的实测结果(go test ./... 亦全部通过)。
| 命令 | 内容 | 实测结果 |
|---|---|---|
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/btree_verify |
B+ 树独立校验(内存页存储,绕过 WAL/BufferPool) | 全部检查通过 |
go run ./demo/hash_verify |
Hash 索引正确性(3 万行 / 全量删除 / 持久化) | 通过 16 / 失败 0 |
go run ./demo/hash_widekey_test |
宽键(64B)哈希索引:分裂阈值随桶容量收敛 | 通过 20 / 失败 0 |
go run ./demo/hash_index_test |
Hash 集成:删除 / 桶合并 / 持久化 / Hash vs B+ 性能 | 23 通过 / 0 失败(Hash 约 1.29× B+) |
go run ./demo/hash_bench |
Hash 等值查询 / 主键回表 / 全匹配延迟基准 | 见 性能实测 |
| 命令 | 内容 | 实测结果 |
|---|---|---|
go run ./demo/txn_test |
事务提交 / 回滚 / 重开验证 | 通过 |
go run ./demo/wal_rowlevel_test |
事务 CRUD + Rollback + 重开持久化 | 全部通过 |
go run ./demo/commit_preset_test |
batch / online / safe 三预设性能对比 |
见 性能实测 |
go run ./demo/dirty_threshold_test |
脏页阈值 20–5000 页对比(5 万行) | 各阈值均通过 |
go run ./demo/no_commit_test |
无手动 Commit(5 场景,含 100 万行) | 全部通过,Close 兜底 |
| 命令 | 内容 | 实测结果 |
|---|---|---|
go run ./demo/integrity_test |
100 万行完整性 + 内存/磁盘统计 | 全部通过 |
go run ./demo/mem_test |
20 万行内存占用(脏页阈值 10000 页) | 通过,内存 < 200MB |
go run ./demo/stress_test |
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/lock_table_test |
写锁语义(只推迟落盘不拒绝)/ 写入中阈值触发提交不丢行 / 缓存满淘汰干净页 / 关闭重开仍在 | 全部通过 |
| 命令 | 内容 | 实测结果 |
|---|---|---|
go run ./demo/blob_stream |
Blob 流式存储 5 场景:空流 / 8KB 不压缩 / 1MB Snappy / 网络中断回滚 / 100MB 随机流 | 全部通过(链式 blob,单值上限 ~4GB,可选 Snappy) |
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 的语义 | 通过 |
| 命令 | 内容 | 实测结果 |
|---|---|---|
go run ./demo/vacuum_demo |
Vacuum:回收已删除行 / 已释放 blob 链 / 索引碎片 | 通过 |
go build ./... # 构建
go test ./... # 单元测试
make build / make test / make fmt / make lint
analyzer/)独立 module,启动后在浏览器打开 http://localhost:8081,可打开任意 .db 文件:
cd analyzer && go run .
# 或: make demo-analyzer
可查看:Header 全字段、表链与表结构(含 schema 版本)、DataPage 行数据、索引页 / B+ 树、堆链、空闲页链、按主键定位,并执行全库一致性审计(页泄漏 / 空闲链自检 / 索引校验)。
TsDB2/migrate + cmd/vrtxsql 负责把表结构、数据、索引、主键整包搬过去,三个库两两互转:
VRTX ──► MySQL VRTX ──► SQLite MySQL ──► SQLite
VRTX ◄── MySQL VRTX ◄── SQLite MySQL ◄── SQLite
MySQL ↔ SQLite 没有直连通道,走 VRTX 中转(先导入 VRTX 再导出),一条代码路径覆盖全部六个方向。
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 |
值装不进目标列时按引擎语义截断(默认报错,免得静默丢数据) |
-no-meta |
不写 __vrtx_meta,放弃定长档位 / 索引类型的还原 |
-v |
打印每张表的进度 |
import "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_metaSQL 表达不了 VRTX 的这些细节,所以导出时会在 SQL 侧额外建一张隐藏表 __vrtx_meta 记着:
INT 回来时无法区分 4 字节还是 8 字节,也无从分辨有符号档与无符号档;NULL / NOT NULL)与列级唯一声明——SQL 的列级 UNIQUE 在读回时只剩一条自动索引,列上"声明过唯一"这半信息只有这里记得住;pk_<表名>);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(代价是往返有损,类型会被就近档位覆盖)。pkID 是行的真实身份 → 导出成 SQL 的 PRIMARY KEY,回来时用同一个 pkID 插回(InsertRowWithID),编号不漂移。_id 列承载行号当 SQL 主键,原列退化成普通列并补一个 B+ 树索引,保住"按原列查"。_ts(不是行号),所以导出到 SQL 时不设主键,_ts 照常是普通列;索引类型与键列记在 __vrtx_meta 里,导回 VRTX 时原样重建 Time 索引。每行另有索引内部维护的行号(pkID),它只在库内用来区分同一时间戳下的多行,跨引擎没有意义 —— 因此不追求编号一致,只保证每个时间点的数据一字不差。-auto-pk 自动补 _id;只想跳过它、继续迁其余表就加 -continue-on-error。__vrtx_meta 还原,没有 meta 时按目标引擎默认(B+ 树)处理。Query.ScanWithPKID 是回调式的、中途停不下来,所以 Source.Scan 也做成"把行推给回调",而不是 Next() 迭代器。Commit(VRTX 实现了 Committer),千万行的库不会等最后 Close 才落盘。IndexMetaPage.GetIndexes()/GetRowStoreIndex() 与 Query.ScanWithPKID 是为迁移补的两个口子。细节与更多示例见 docs/MIGRATION_GUIDE.md。
Table[T].Update 未赋值的字段会被清零,不是局部字段更新(demo/orm_full 有验证)。index tag 的列,条件查询退化为全表扫描。[]byte、maxlen>2048 的字符串映射为动态列,行内只存 6B 堆引用,UseTable 会拒绝为其建索引。type:hash 的等值查询 O(1),但范围 / 排序查询退化为全桶扫描;需要范围 / 有序时请用默认 B+ 二级索引。UseTable 会报错(避免静默使用少一列的索引),需先删索引再重建。_id 列与引擎 pkID 保持一致),重复报 ErrDuplicatePK。自动号不是真实时间戳。时序表是唯一的例外:时间键不是行号,所以无论 _ts 是否给了值,引擎都会分配一个新行号 —— 给了 _ts 就原样使用、不写回、也不当行号(同刻多行因此能共存);完全没给(_ts == 0)才会把刚分配的行号回填进 _ts,让这行至少落在某个明确时刻上(回填的是编号,不是真实时间戳)。LockWrites 是「阻塞」而非「拒绝」,锁的是「页落盘」:它挡住的是 BufferPool.WritePage(正在写盘的 goroutine 阻塞等待),Insert / Update / Delete 本身照常改内存中的页并标记脏,不会返回任何错误 —— 引擎里没有"写入被拒绝"这种错误码。因此锁定期写入既不报错也不丢,只是落盘被推迟到 UnlockWrites(或 Commit 内部临时加锁结束)之后。demo/lock_table_test 按这套语义断言:锁定期写入不报错且一条不丢、写入过程中脏页阈值触发提交不丢行、BufferPool 容量满时淘汰干净页而不是死锁、关闭重开后数据仍在。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 里同步发起,不存在重叠。Deleter 走「重建主键 + 二级索引」路径以保证正确性,峰值内存 / IO 高于单行删除(单行删除走 B+ 树借位 / 合并)。Employee 约 122 bytes/行、放大 ≈ 1.7x。unique 由唯一索引执行,且不约束 NULL:约束落在索引上(插入 / 更新前先按键探一次),违反报 core.ErrDuplicatedKey。三个后果值得先知道:库中已有重复值时唯一索引建不出来(同一个错误,需先清理数据);表里已存在同名非唯一索引时 UseTable 直接报错,要先删索引再重建;主键 / 时序这类行存储索引不支持唯一约束(主键本就唯一,时序键允许同刻多行)。可空列的 NULL 不参与唯一性 —— 任一分量为 NULL 的行不进唯一索引,因此可以共存任意多行 NULL(复合唯一键亦然)。IndexTypeStr(3) 已废弃:仅为兼容旧库文件保留(按复合条目处理),新代码请使用 IndexTypeBTree(5) / IndexTypeHash(6)。core.TableNameSize / core.IndexNameSize,UTF-8 下约 16 / 21 个汉字)。索引名单独放宽到 64,是因为自动命名要把「前缀 + 表名 + 列名」拼在一起,和表名共用一个 48 会互相侵占:
TableInfo.Name);pk_ 前缀算在索引名的 64 里,不再侵占表名额度;idx_<表名>_<首列名>:≤ 64 字节,即表名 ≤ 59 - len(首列名)。
UseTable 在建表前先算这笔账(checkNameBudget),返回带具体字节数的 ErrTableNameTooLong / ErrIndexNameTooLong;名字还不够长就用 vrtx:"index:<名字>" 显式命名二级索引。core 层 CreateIndex 同样会拒绝超长名,而不是 copy 静默截断成另一个名字。v7:v6 及更早的旧格式(24 字节表名 / IndexMeta 84 字节且索引名与表名共用 48 字节上限 / 有符号整数混用无符号档)不兼容,需要重建数据库文件。RENAME COLUMN / RENAME TABLE API:改列名只能 DROP 旧列 + ADD 新列,老行原值不会迁移到新列名(数据仍在行内,只是新列名下不可达),需要业务侧自行回填;改表名请新建表后搬迁。DROP COLUMN 是软删除:字段类型最高位置 0x80 标记删除,行内数据依然保留;之后 ADD 一个同名同类型的列会「复活」老行里的旧值。因此删列不能用来清除敏感数据,需要重建表 / 覆写行才能彻底擦除。UseTable[T] 会把库里存在、但 struct 里没有的列自动 DropField(即上面的软删除)。纯 core API 手工 AddField 出来的列,一旦被 ORM UseTable 打开就会「消失」(数据仍在行内,把字段加回 struct 即可复活)。因此同一张表不要混用 ORM 与手工 DDL;以 ORM 为唯一入口,或把手工列也声明进 struct。VARCHAR / TEXT)在 VRTX 里按 VARCHAR(n) 的 n 就近取定长档位;TEXT / 无长度上限的类型超出 256 字节则落 Dynamicbyte。超过 256 字节的字符串值在 VRTX 里是变长列,不能再建索引。AUTO_INCREMENT / 序列:SQL 的自增属性不还原(VRTX 的主键号由引擎自己的游标分配);-recreate 之外的重复迁移是追加语义,同一张表迁两次会插两遍 / 撞主键报错,续传请用 -tables 只点名没迁完的表。migrate.Run 是"读源 → 写目标"的单向快照,不锁源库、也不是一致性快照;边写边迁只能拿到某一时刻的近似结果。pkID 由索引内部维护(只用来在库内区分同一时间戳下的多行),跨库没有意义,因此往返只保证 _ts 与各列数据一致,不保证行号一致。modernc.org/sqlite 写入端会 SetMaxOpenConns(1),同一进程内对同一个 SQLite 文件的并发迁移会串行化;MySQL 端按 DSN 的连接池走,大批量迁移建议 -batch 调大(如 2000)减少往返。Primary)与时序索引(Time)都直接持有行数据,二者不能共存。同一个结构体同时内嵌 BaseModel 与 TimeSeriesModel 报 DB.ErrInvalidPrimaryKey;在同一张表上再 CreateIndex 另一个行存储索引报 core.ErrRowStoreExists(报错而不是静默生效——否则会得到一个"元数据说自己是行存储、却一行都没有"的索引,之后回表 / 删除 / 重建全都错乱)。obj.Load(pk) 是"填自己",不会返回新对象(拿不到 T)。要类型安全的新对象请用 Table[T].Find。另外不要浅拷贝已绑定的对象:绑定信息(属于哪张表、本体是谁)存在对象里,dup := *u 之后 dup.Update() 更新的是 u,dup 上的改动会被静默丢弃;需要独立对象请用 Table.New + Load。vrtx:"pk" 只是换默认键列(_id / _ts 不再建列);把主键表改成时序表(或反之)等于换一级索引,必须迁移数据重建表。DB.BaseModel / DB.PrimaryModel / DB.TimeSeriesModel 的结构体不是本框架的表,ParseModel / UseTable 报 ErrNotModel(校验在建表之前,不会留下半成品表)。单写 vrtx:"pk" 不能替代基类 —— 它的语义是"覆盖基类默认的键列"。按 SQL 元数据动态建表的 migrate 包走 core 层(db.CreateTable),不受此约束。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 存储。docs/INDEX_TYPES_AND_ORM_GUIDE.md — 各索引形态详解、时序 vs 主键库对比、ORM CRUD 实测docs/OPERATION_TUTORIAL.md — 使用与运维教程docs/MIGRATION_GUIDE.md — 跨库迁移(VRTX ↔ MySQL ↔ SQLite)完整指南docs/BPLUS_TREE_FIX_PLAN.md / docs/BPLUS_TREE_PROGRESS.md — B+ 树修复计划与进度MIT License