CDB 关系型数据库研究

Golang 实现的关系型数据库引擎 + ORM。链式 API(GORM 风格),无需 SQL 解析,内置数据关联加载、全盘 AES-256-GCM 加密、WAL 崩溃恢复与事务原子性。 数据可运行在两种存储模式(Config.StorageMode):磁盘模式(默认) 数据常驻磁盘、内存仅留索引,数据集可远超 RAM;内存模式 全量数据驻留进程内存(旧行为),可显式配置。

特性

  • 链式 GORM 风格 APIModel / Where / And / Or / Limit / OrderBy / Find / Create / Update / Delete
  • 无 SQL 解析:条件经内存求值器直接执行,条件树随链式调用累积
  • 数据关联加载Preload 支持 hasOne / hasMany / 多对多(中间表),复合主键外键
  • 全盘加密(已接线):数据路径每条记录 AES-256-GCM 认证加密(保密性 + 完整性/MAC),密钥由 SecretKey 经 HMAC-SHA256 派生,每条独立随机 Nonce;错误密钥打开 ⇒ 认证失败 ⇒ 零数据可读
  • 读写并行:读路径 RLock 共享锁、写路径 Lock 独占锁,多读并发执行、写不阻塞读(TestReadersParallel 验证 8 并发读显著快于串行)
  • 内存预算防护Config.MemoryBudget 设定逻辑驻留量上限(len(key)+len(val),两模式统一 O(1) 记账),写事务入口检查,超限返回 ErrMemoryBudgetExceeded,避免 OOM
  • MVCC 快照隔离:读事务锚定快照锚点 A,同一读会话内行集恒定(Postgres 式非不变索引 + 可见性重检);写事务体与全体读者互不阻塞
  • 查询加速:零子句 Count()/Exists() 免行解码(纯 MVCC 生/灭账本遍历,5 万行 263ms → ~6ms);Sum/Min/Max/Avg/Pluck 带索引谓词走 B+ 树候选而非全表扫描(单行点计 270ms → ~50µs)
  • 非阻塞归档:归档构建持 RLock(读并行)、磁盘模式 Compact 压实 + 内存模式快照原子替换,均带版本守卫,规避「写锁内全量重写」的周期全局停顿
  • 崩溃恢复:重启自动回放主文件 + WAL,非法帧(断电撕裂)安全截断,已提交数据不丢失
  • 事务原子性db.Transaction(fn) 要么整体提交(一次性落 WAL),要么整体回滚(新增撤销 + 修改还原 + 删除还原)
  • 删除持久化:墓碑机制,已提交删除在重启后不复活
  • 精简落盘:行记录 = 空值位图 + LEB128 变长整型偏移序列 + 紧凑二进制载荷
  • 高吞吐写入:变更惰性捕获旧值(O(1)/键),免除全表快照开销
  • 双存储模式Config.StorageMode = disk(默认,值常驻 base+WAL,内存仅 B+ 索引 + key→偏移引用,读走 OS 页缓存随机读,数据集可 > RAM)/ memory(全量驻留,旧行为)
  • 内存预算精确记账:预算按 len(key)+len(val) 逻辑驻留量 O(1) 差量记账,双模式统一口径,超限写事务立即返回 ErrMemoryBudgetExceeded

快速开始

package main<
>>
import (
    "fmt"
    "keven/cdb/pkg/borm"
)
// 数据模型(borm 标签声明主键与关联)
type User struct {
    ID    uint64 `borm:"pk"`
    Name  string
    Email string
    Age   int
}
func main() {
    // 打开(或创建)加密数据库
    db, err := borm.Open("app.db", &borm.Config{
        SecretKey:      []byte("0123456789abcdef0123456789abcdef"),
        BufferCapacity: 100,
        WALMaxSize:     1 << 20,
        MemoryBudget:   1 << 30, // 可选:内存驻留上限(0=不限)
        // StorageMode: borm.StorageModeDisk,  // 默认:磁盘数据 + 内存索引,数据集可远超 RAM
        // StorageMode: borm.StorageModeMemory, // 全量内存驻留(旧行为,内存受限场景慎选)
    })
    if err != nil {
        panic(err)
    }
    defer db.Close()
    // ---------- 写入(写事务,原子提交) ----------
    err = db.Transaction(func(tx *borm.Tx) error {
        for i := 1; i <= 100; i++ {
            u := User{ID: uint64(i), Name: fmt.Sprintf("user_%d", i),
                Email: fmt.Sprintf("user%d@a.com", i), Age: 18 + i%50}
            if err := tx.Model(&User{}).Create(&u); err != nil {
                return err
            }
        }
        return nil
    })
    if err != nil {
        panic(err)
    }
    // ---------- 查询(读会话) ----------
    // 条件 + 排序 + 分页
    var users []User
    err = borm.Model(db, &User{}).
        Where("Age", ">=", 30).
        Where("Name", "in", []any{"user_1", "user_2"}).
        OrderBy("Age", false).
        Offset(5).
        Limit(10).
        Find(&users)
    if err != nil {
        panic(err)
    }
    // 聚合
    count, _ := borm.Model(db, &User{}).Count()
    maxAge, _ := borm.Model(db, &User{}).Max("Age")
    // 单条(dest 传结构体指针 或 切片指针,未命中返回 ErrRecordNotFound)
    var one User
    _ = borm.Model(db, &User{}).Where("ID", "==", 1).First(&one)
    // ---------- 更新 / 删除(写事务) ----------
    _ = db.Transaction(func(tx *borm.Tx) error {
        if err := tx.Model(&User{}).Where("ID", "==", 1).
            Updates(map[string]any{"Age": 99, "Name": "admin"}); err != nil {
            return err
        }
        return tx.Model(&User{}).Where("ID", "<", 10).Delete()
    })
    _ = count
    _ = maxAge
    fmt.Println("ok")
}

数据关联(Preload)

type User struct {
    ID     uint64             `borm:"pk"`
    Name   string
    Profile *Profile          `borm:"rel:hasOne,through:Profile.UserID"`
    Posts  []*Post            `borm:"rel:hasMany,through:Post.UserID"`
    Tags   []*Tag             `borm:"rel:manyToMany,through:UserTag"`
}
type Profile struct {
    ID     uint64 `borm:"pk"`
    UserID uint64 // FK → User.ID(与标签 through 字段对应)
    Email  string
}
var users []User
err := borm.Model(db, &User{}).
    Where("ID", "<", 10).
    Preload("Profile", "Posts", "Tags").  // 加载 hasOne / hasMany / 多对多
    Find(&users)

API 总览

顶层

API说明borm.Open(path, cfg) (*DB, error)打开/创建加密数据库,自动崩溃恢复db.Close()刷盘并关闭db.Transaction(fn) error写事务;fn 返回 nil 原子提交,否则整体回滚borm.Model(db, &T{}) *Tx创建查询会话(GORM 风格链式入口)

链式条件

方法说明Where(field, op, value)条件子句(AND),操作符大小写不敏感OrWhere(field, op, value)条件子句(OR)And(fn) / Or(fn)闭包逻辑过滤(func(record any) boolWhereIn / WhereNotInIN / NOT INWhereLike / WhereEqLIKE 模糊 / 等值快捷Between(field, lo, hi)范围条件OrderBy(field, desc)排序Limit(n) / Offset(n)分页Distinct()去重


终结操作

方法说明Find(&slice)条件查询First(field, value, &obj)单条查询Pluck(field, &slice)单列查询Payload(&slice, args...)在 Find 后手动触发关联加载(可按 Tag 过滤/加条件)Count() (int, error)计数Exists() (bool, error)存在性判断Sum/Max/Min/Avg(field)聚合FindOrCreate(&obj)查无则插Create(&obj)写入(需写事务)Update(field, val) / Updates(map)更新(需写事务)Delete()删除匹配记录(需写事务,墓碑落盘)Preload("Rel1", "Rel2")关联加载


支持操作符与字段类型

== != > < >= <= in not_in between,以及 Like 模糊匹配(数值/字符串归一化比较)。字符串与 []byte 支持全部比较算子(字节序比较),bool 支持 == != in not_in

支持字段类型:整型(uint8~uint64 / int8~int64)、string、boolfloat32/float64time.Time[]byte;嵌套结构体与除 uint8 以外的切片/指针类型会返回 ErrUnsupportedType


索引(borm:"index"

在结构体字段上加 borm:"index" 标签即为该字段建立 B+ 树索引(主键 borm:"pk" 自动建索引):

type Order struct {
    ID    uint64 `borm:"pk"`     // 主键,自动索引
    User  int    `borm:"index"`  // 二级索引
    Price float64 `borm:"index"` // 二级索引
}

任意「已索引字段」上的  ==   in   >   >=   <   <=   between  查询都会走 B+ 树快路径;  !=   not_in   like 无界、且 B+ 树无剪枝收益,仍走内存全表扫描(语义完全一致)。


存储架构

cdb/
└── pkg/borm/
    ├── api.go            # Open/Close/Transaction/Stats,内存预算 O(1) 守护
    ├── datastore.go      # DataStore 抽象:memDataStore(内存模式)/ diskDataStore(磁盘模式)
    ├── crypto_io.go      # 记录级 AES-256-GCM 加/解密 + 主文件/WAL 加载回路
    ├── archival.go       # 非阻塞后台归档 worker + 版本守卫 + 快照原子替换
    ├── chain.go          # 链式 API:条件拼装 + Create/Find/First/Count
    ├── chain_2.go        # 聚合 + Update/Delete/FindOrCreate
    ├── chain_pk.go       # B+ 树快路径候选收集(主键 / 二级索引字段)
    ├── chain_relation.go # Preload 关联加载(hasOne/hasMany/多对多)
    ├── rel_meta.go       # 关联标签解析(rel:xxx,through:FK)
    ├── evaluator.go      # 内存条件求值器(替代 SQL 解析)
    ├── schema.go         # 结构体反射 + 字段映射缓存
    ├── mvcc.go           # MVCC 生/灭账本 + 快照锚点(可见性判据 aliveAt,零子句 Count/Exists 快路径)
    ├── index.go          # 索引值编码(保序)+ B+ 树索引构建
    ├── metrics.go        # 读写计数 + 慢查询采样日志(SlowQueryThreshold)
    ├── lock.go           # 文件锁(并发 Open 互斥)
    ├── config.go         # 全局配置(含 StorageMode)
    ├── errors.go         # 统一错误定义
    ├── tx/               # 事务视图(读写会话状态)
    ├── storage/          # 磁盘 I/O、页面布局、变长整型、COW 缓冲
    ├── wal/              # WAL 与检查点
    └── crypto/           # AES-256-GCM + KDF

行记录格式(精简二进制)

[NullBitmap(1B)] [FieldCount(1B)] [Varint 偏移序列] [PayloadData(紧凑二进制)]<
>>
  • 空值位图:位 0 = 非空,位 1 = null
  • 数值型全部 LEB128 压缩;字符串存原始字节
  • 抛弃字段名,字段顺序由 schema 注册表决定


双存储模式

Config.StorageMode 选择数据的驻留位置,两种模式 API 完全一致(同一个 DataStore 抽象对外,上层无差别):

模式值数据驻留内存驻留适用场景磁盘(默认)StorageModeDiskbase + WAL(记录级 AES-GCM)仅 B+ 索引 + key→偏移 引用表 + 存活标记数据集可远超 RAM,冷读命中 OS 页缓存内存StorageModeMemorybase + WAL(同格式,供重启恢复)全量 memTable(所有行常驻)低延迟、小数据集(几十 MB 内)

go
db, err := borm.Open("app.db", &borm.Config{
    SecretKey:   []byte("0123456789abcdef0123456789abcdef"),
    StorageMode: borm.StorageModeDisk,   // 默认;改 StorageModeMemory 即内存模式
})

关键点:

  • 磁盘模式读:单条记录随机读(ReadAt),值不在进程中,读走 OS 页缓存;写走两阶段提交(WAL fsync + 引用落定),支持 > RAM 的数据集。
  • 内存模式读:直接从 memTable 取,无磁盘 I/O;写仍落 WAL + 归档,保持与磁盘模式字节级兼容,可无缝切换。
  • 归档一致:磁盘模式归档 = Compact(逐条重写 base + 清空 WAL);内存模式归档 = 快照重写(全量 memTable 加密写新 base + 清空 WAL)。两种模式对外都只暴露 db.CheckpointAndWait / maybeCheckpointBySize 三触发。
  • 预算记账统一Config.MemoryBudget 上限按 len(key)+len(val) 差量记账(keySize 表),双模式口径一致,超限写事务立即返回 ErrMemoryBudgetExceeded
  • 持久化语义:两模式都用同一 WAL 帧格式,base / WAL 文件互换可兼容;Close → Open 语义一致;TestDiskFileBacked 双模式子测试已验证。


事务与持久化

  1. db.Transaction(fn) 持有全局写锁,fn 内变更只更新内存索引(惰性捕获旧值)
  2. fn 返回 nil → 提交:变更集合一次性追加 WAL,随后 fsync 落盘,保证已提交事务断电不丢(ACID D)
  3. fn 返回 error 或写盘失败 → 回滚:新增撤销、修改还原、WAL 截断到提交前
  4. 删除以墓碑哨兵落盘,重启回放时删除主文件中的旧记录,避免"复活"
  5. **归档检查点(Checkpoint)**采用「快照重写」:把当前内存全量重写为主文件、清空 WAL,旧版本与已删行被真正清除。三种触发通路:
  • 体积触发:WAL 达到 WALMaxSize 后,提交时自动归档瘦身
  • 定时触发:每隔 WALCheckpointInterval 后台归档(0 关闭)
  • Close 触发:关闭时强制落盘一次(兵底)
  • 启动时若存在未归档 WAL,Open 合并后立即归档一次


MVCC 快照隔离

  • 快照锚点:读事务建立时锚定 A = 当前 commit-tx(O(1)),会话内行集恒定——锚点后诞生的键不可见,锚点前已存在键恒可见(同一行多次读取结果一致)
  • 生/灭账本:每键记录出生/消亡 commit-tx(全内存 O(1) 判定,无值副本),aliveAt(key, A) 判可见;写提交原子推进 commit-tx,读者锚定经 db.mu 串行,MVCC 可见性原子一致
  • 读写互不阻塞:写事务体持草稿(draft)私有修改,与全体读者并行;提交时一次性发布(WAL + 账本),失败整体回滚
  • 长读稳定性:长读者存续期间,其锚点的行集不会被并发写改变(快照隔离,非可串行化)


索引与查询加速

  • B+ 树索引github.com/google/btree):主键 + 任意 borm:"index" 字段均可建索引, 在其上的 == in > >= < <= between 走 O(log n + 命中行数) 快路径, 跳过全表逐行解码扫描;Find/First/Count/Exists/Sum/Min/Max/Pluck 全部受益。
  • 零子句计数/存在性Count() / Exists() 无任何过滤条件时不读任何行值, 直接遍历生成/消亡账本(同 aliveAt 判据,语义严格等价):5 万行全表 Count 263ms → ~6ms(45x); 写事务体叠加草稿视图(新增键 +1 / 删除的可见键 -1),与慢路径可见行集一致
  • 聚合快路径Sum/Min/Max/Avg/Pluck 带索引谓词(如 WHERE ID==N)走 B+ 树候选行, 单行命中的 Sum WHERE ID==100 270ms → ~50µs;无索引谓词时回退全表扫描(语义一致)
  • 索引编码按字段类型保序(整数 XOR 符号位大端、浮点 IEEE 位翻转、字符串/字节单调编码), 因此范围查询可直接在 B+ 树上区间剪枝,快/慢路径语义完全一致。
  • != not_in like 回退内存条件求值(无界,B+ 树无剪枝收益)。
  • 开放区间/范围查询按候选数上界(非表大小)判回退:候选 ≤ 4096 走树区间(万行表命中 10 行仅 ~100µs),候选超出才回退全表扫描,避免「几乎命中全表」时树遍历反而更慢。

崩溃恢复

  1. 读取主文件 → 重建内存索引
  2. 顺序回放 WAL 帧,逐帧 AES-GCM 解密校验
  3. 首帧校验失败 → 截断该帧及后续(断电撕裂安全)
  4. 提交事务的 TXID 发布,系统就绪


测试

# 核心稳定性(并发读写隔离、崩溃恢复、P0 六项、45000 条 save/load)
go test -count=1 -timeout 120s ./tests/
# DSL 全场景 + 关联加载
go test -count=1 -timeout 120s ./tests/dsl/
# 20 表 x 10 万条基准
go test -count=1 -timeout 600s ./tests/benchmark/
# 长时间混合读写 soak(默认 5 分钟 / 每表 5 万行,全部读写算子覆盖)
cd tests && rm -f soak_long_running.db* && \
  timeout 600 go test -count=1 -timeout 540s -run TestLongRunningSoakMixedOps -v .
# 五维度综合性能评估:写/读/更新/删除/聚合 12 个 Benchmark
cd tests/benchmark && rm -f bm_*.db* && \
  timeout 300 go test -count=1 -timeout 240s -run='^$' \
    -bench 'BenchmarkEval_' -benchmem -benchtime=1000x .
# 综合报告:单库固定数据集,输出 Markdown 对照表 + 关键加速比
cd tests/benchmark && rm -f bm_report.db* && \
  timeout 300 go test -count=1 -timeout 240s -run '^TestEvalReport$' -v .
# 百万行压测报告(单轮约 5 分钟)
cd tests/benchmark && rm -f bm_report1m.db* && \
  timeout 900 go test -count=1 -timeout 840s -run '^TestEvalReport1M$' -v .



© GVGNN 2013-2026