README.md 77 KB

TsDB2

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

  • OLTP 主键表:_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() 精确作用于这一行,不会误伤同刻的其它行;二级索引回表走的也是同一个行号。
  • Schema 自动演进:UseTable[T] 对已存在的表自动同步结构体字段(新增列 / 删除列),并补建缺失的二级索引;索引分量与模型不符时直接报错而非静默沿用。
  • 多种索引形态:主键聚簇 B+ 树、B+ 二级索引、Hash 桶式二级索引(等值 O(1))、时序索引、复合索引(同名 index:NAME 自动合并,支持最左前缀)。
  • 查询能力
    • ORM 层:Where 支持 = != > >= < <= like,多个 Where 之间为 AND;支持 OrderBy / Limit / Offset / Count / 流式 Scan / First。
    • core 层:条件构造器 Eq/Gt/Ge/Lt/Le/Like/ILike/In 与 And/Or/Not 任意组合;Query 另支持 Select/GroupBy/Having/FindOne/Sum/Max/Min。
  • 事务:BeginTransaction() / Commit() / Rollback();提交走整页 WAL + fsync,回滚通过丢弃缓存页立即生效。
  • 提交策略可调:防抖自动提交、脏页阈值、预设(batch / online / safe / ultra-safe),默认无需手动 Commit。
  • 存储管理:LRU BufferPool 页缓存、空闲页链表回收(DropTable 后页可复用)、动态字段走堆页 + 引用计数自动回收。
  • 可视化分析器: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 层最小示例(时序索引)

数据模型与 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" 自行指定:

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" 非上述关键字的部分视为列名

要点:

  • 列名默认就是 Go 字段名本身(不做大小写 / 下划线转换),如 Age 列名即 Age;Where("Age", ...) 用列名匹配。
  • 自定义主键必须是 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 存储)

浮点列不复用整数列(旧版本把 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)。

API 文档

ORM 层(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 层条件构造器。

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、归还本事务新分配的页,立即生效。

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 层)

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() 为真,不分配任何页。

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

blob 列(ORM + core)

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)

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 数据页溢出页(已废弃,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)

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(FirstWALPage != 0),先执行恢复(wal_recovery.go),恢复完成后整条 WAL 链一次性回收。
  • 页回收遵循「先 WAL 后回写」:事务中途不直写盘地还链,而是登记为 pending;提交成功后才真正还链,回滚则整体丢弃。
  • 页分配只有 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 的实际输出。

10 万行端到端(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

100 万行完整性(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)

50 万 + 10 万行压力(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

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/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 ./... 亦全部通过)。

综合 / ORM

命令 内容 实测结果
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 等值查询 / 主键回表 / 全匹配延迟基准 见 性能实测

事务 / WAL / 提交策略

命令 内容 实测结果
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+ 树、堆链、空闲页链、按主键定位,并执行全库一致性审计(页泄漏 / 空闲链自检 / 索引校验)。


跨库迁移:VRTX ↔ MySQL ↔ SQLite

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 打印每张表的进度

库内 API

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_meta

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

  • 每列的定长档位(1/2/4/8/16/32/64/128/256 / Float64 / Int8/16/32/64 / 变长)——否则 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)才会把刚分配的行号回填进 _ts,让这行至少落在某个明确时刻上(回填的是编号,不是真实时间戳)。
  7. LockWrites 是「阻塞」而非「拒绝」,锁的是「页落盘」:它挡住的是 BufferPool.WritePage(正在写盘的 goroutine 阻塞等待),Insert / Update / Delete 本身照常改内存中的页并标记脏,不会返回任何错误 —— 引擎里没有"写入被拒绝"这种错误码。因此锁定期写入既不报错也不丢,只是落盘被推迟到 UnlockWrites(或 Commit 内部临时加锁结束)之后。demo/lock_table_test 按这套语义断言:锁定期写入不报错且一条不丢、写入过程中脏页阈值触发提交不丢行、BufferPool 容量满时淘汰干净页而不是死锁、关闭重开后数据仍在。
  8. 单进程嵌入式,且是「单写入者」模型:只面向单进程,同一时刻只允许一个 goroutine 写 —— Insert / Update / Delete / Commit 都应由同一个写入者依次调用;不提供多进程写并发控制与分布式复制。多 goroutine 的正确用法是「一个写 + 只读」,但引擎对并发读没有一致性保证:一次写入要分多页依次落到缓存,读可能撞上写入的中间态(读到"新叶子 + 旧父节点"这种半成品)。要做到读取端严格一致,请把读写也串起来(或者写完之后再读)。两个 goroutine 同时写、或一个 goroutine 写的同时另一个 goroutine 调 Commit 会崩:CollectDirty 把脏页移出缓冲池之后、回写磁盘之前,这一页既不在缓存里也还不在盘上,并发的读会从盘上读到全零页(实测稳定 panic:btPID: slice bounds out of range [-4:],页头 EntryLen 读到 0)。引擎内部没有写入串行化(DB/core/pool/buffer_pool.go 里关于 capacityWait 的注释写的就是「单写入者模型」),需要多写入者请在调用方自己加锁。写入与提交的「穿插」是支持的 —— 脏页阈值 / 防抖计时器触发的提交由引擎在写入者的 goroutine 里同步发起,不存在重叠。
  9. 批量删除开销较高:Deleter 走「重建主键 + 二级索引」路径以保证正确性,峰值内存 / IO 高于单行删除(单行删除走 B+ 树借位 / 合并)。
  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. 文件格式版本 v7:v6 及更早的旧格式(24 字节表名 / IndexMeta 84 字节且索引名与表名共用 48 字节上限 / 有符号整数混用无符号档)不兼容,需要重建数据库文件。
  15. 没有 RENAME COLUMN / RENAME TABLE API:改列名只能 DROP 旧列 + ADD 新列,老行原值不会迁移到新列名(数据仍在行内,只是新列名下不可达),需要业务侧自行回填;改表名请新建表后搬迁。
  16. DROP COLUMN 是软删除:字段类型最高位置 0x80 标记删除,行内数据依然保留;之后 ADD 一个同名同类型的列会「复活」老行里的旧值。因此删列不能用来清除敏感数据,需要重建表 / 覆写行才能彻底擦除。
  17. ORM 重开表会按 struct 强制同步 schema:UseTable[T] 会把库里存在、但 struct 里没有的列自动 DropField(即上面的软删除)。纯 core API 手工 AddField 出来的列,一旦被 ORM UseTable 打开就会「消失」(数据仍在行内,把字段加回 struct 即可复活)。因此同一张表不要混用 ORM 与手工 DDL;以 ORM 为唯一入口,或把手工列也声明进 struct。
  18. 迁移会把 VARCHAR 一律当定长:SQL 侧读到的变长字符串列(VARCHAR / TEXT)在 VRTX 里按 VARCHAR(n) 的 n 就近取定长档位;TEXT / 无长度上限的类型超出 256 字节则落 Dynamicbyte。超过 256 字节的字符串值在 VRTX 里是变长列,不能再建索引。
  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 存储。

相关文档


许可证

MIT License