于键值(KV)的表
基于键值(KV)的表
将行编码为键值(KVs)
索引查询:点查询和范围查询
在关系型数据库中,数据被建模为由行和列组成的二维表。用户通过SQL表达他们的意图,而数据库则神奇地提供结果。不那么神奇的是,虽然数据库可以执行任意查询,并非所有查询在OLTP工作负载中都是实际可行的(高效且可扩展),并且OLTP总是要求用户通过适当的模式和索引设计来控制查询的执行方式。
一个索引查询的执行归结为两个操作:
- 点查询:根据给定的键查找一行。
- 范围查询:根据一个范围查找多行;以排序顺序迭代结果。
这就是为什么B+树和LSM树被认为是适用的,而哈希表则不然。
主键作为“键”
首先考虑点查询。要找到一行,必须有一种方法唯一标识该行,这就是主键,它是列的一个子集。
create table t1 (k1 string,k2 int,v1 string,v2 string,primary key (k1, k2)
);
表名 | 键 | 值 |
---|---|---|
t1 | k1, k2 | v1, v2 |
作为单独表的辅助索引
除了主键外,表可以通过多种方式进行索引。这是通过额外的间接层解决的:辅助索引。
create table t1 (k1 string,k2 int,v1 string,v2 string,primary key (k1, k2),index idx1 (v1),index idx2 (v2, v1)
);
逻辑上,每个索引就像一个单独的表:
create table idx1 (-- 索引键 (v1)v1 string,-- 主键 (k1, k2)k1 string,k2 int
);create table idx2 (-- 索引键 (v2, v1)v2 string,v1 string,-- 主键 (k1, k2)k1 string,k2 int
);
为找到唯一的主键增加了额外的键。
表名 | 键 | 值 |
---|---|---|
t1 | k1, k2 | v1, v2 |
idx1 | v1 | k1, k2 |
idx2 | v2, v1 | k1, k2 |
主键也是一种索引,但它具有唯一约束。
替代方案:自动生成的行ID
一些数据库使用自动生成的ID作为“真正的”主键,而不是用户选择的主键。在这种情况下,主键和次键之间没有区别;用户主键也是一个间接层。
表名 | 键 | 值 |
---|---|---|
t1 | ID | k1, k2, v1, v2 |
primary key | k1, k2 | ID |
idx1 | v1 | ID |
idx2 | v2, v1 | ID |
优点在于自动生成的ID可以是一个小的、固定宽度的整数,而用户主键则可以任意长。这意味着…
- 对于ID键,内部节点可以存储更多的键(更短的树)。
- 辅助索引更小,因为它们不会重复用户主键。
数据库模式
表前缀
一个数据库可以包含多个表和索引。我们将为键添加一个自动生成的前缀,以便它们能够共享单个B+树。这样做比维护多个树的工作量要少。
以下是将给定内容转换成表格的形式:
key | value | |
---|---|---|
table1 | prefix1 + columns… | columns… |
table2 | prefix2 + columns… | columns… |
index1 | prefix3 + columns… | columns… |
这个表格展示了不同表和索引的键值对结构,其中 key
列表示表或索引的名称,而 value
列则描述了对应的前缀和列信息。
前缀是一个32位自增整数,你也可以使用表名代替,但缺点是它可能会非常长。
数据类型
关系型数据库优于键值存储的一个优点是支持更多的数据类型。为了反映这一点,我们将支持两种数据类型:字符串(string)和整数(integer)。
- 数据类型:与仅能存储简单键值对的键值存储不同,关系型数据库支持更丰富的数据类型。这里提到的支持两种基本的数据类型——字符串和整数,意味着数据库可以存储文本信息和数值信息,从而提供了更高的灵活性和功能。例如,在创建表时,你可以指定列的数据类型为字符串或整数,这有助于确保数据的一致性和正确性。
常量定义
const (TYPE_BYTES = 1 // 字符串(任意字节)TYPE_INT64 = 2 // 整数;64位有符号
)
TYPE_BYTES
表示字符串类型,可以存储任意字节。TYPE_INT64
表示整数类型,使用64位有符号整数。
单元格值结构
// 表单元格
type Value struct {Type uint32 // 类型标记的联合体I64 int64 // 整数值Str []byte // 字符串值
}
Value
是一个带有类型标记的联合体,具体类型由Type
字段决定。- 如果
Type == TYPE_BYTES
,则使用Str
字段存储字符串数据。 - 如果
Type == TYPE_INT64
,则使用I64
字段存储整数数据。
- 如果
表记录结构
// 表行
type Record struct {Cols []string // 列名Vals []Value // 列值
}
Record
表示一行数据,包含列名和对应的列值。- 列名和列值通过数组的形式一一对应。
添加字符串值的方法
func (rec *Record) AddStr(col string, val []byte) *Record {rec.Cols = append(rec.Cols, col)rec.Vals = append(rec.Vals, Value{Type: TYPE_BYTES, Str: val})return rec
}
AddStr
方法用于向记录中添加一个字符串类型的列值。- 参数:
col
:列名。val
:列值(字符串)。
- 返回值:更新后的记录对象。
添加整数值的方法
func (rec *Record) AddInt64(col string, val int64) *Record
AddInt64
方法用于向记录中添加一个整数类型的列值。- 参数:
col
:列名。val
:列值(整数)。
- 返回值:更新后的记录对象。
获取列值的方法
func (rec *Record) Get(col string) *Value
Get
方法根据列名返回对应的列值。- 参数:
col
:列名。
- 返回值:指向列值的指针。
表模式定义
type TableDef struct {// 用户定义的部分Name string // 表名Types []uint32 // 列类型Cols []string // 列名PKeys int // 主键列的数量// 前 `PKeys` 列是主键// 不同表的自动分配的 B 树键前缀Prefix uint32
}
TableDef
定义了表的模式:Name
:表名。Types
:列的数据类型(每个列对应一个类型)。Cols
:列名。PKeys
:主键列的数量,表示前PKeys
列为主键。Prefix
:为不同表自动生成的 B 树键前缀。
内部表
存储表模式的内部表
var TDEF_TABLE = &TableDef{Prefix: 2,Name: "@table",Types: []uint32{TYPE_BYTES, TYPE_BYTES},Cols: []string{"name", "def"},PKeys: 1,
}
TDEF_TABLE
是一个预定义的内部表,用于存储其他表的模式信息。- 结构:
name
:表名。def
:表模式的 JSON 序列化内容。
- 示例:
create table `@table` (`name` string, -- 表名`def` string, -- 模式primary key (`name`)
);
存储元信息的内部表
var TDEF_META = &TableDef{Prefix: 1,Name: "@meta",Types: []uint32{TYPE_BYTES, TYPE_BYTES},Cols: []string{"key", "val"},PKeys: 1,
}
TDEF_META
是另一个预定义的内部表,用于存储额外的元信息。- 结构:
key
:键名。val
:键值。
- 示例:
create table `@meta` (`key` string, -- 键名`val` string, -- 键值primary key (`key`) );
总结
-
核心结构:
Value
:单元格值,支持字符串和整数两种类型。Record
:表的一行数据,包含列名和列值。TableDef
:表的模式定义,包括表名、列名、列类型、主键列数量和 B 树前缀。
-
内部表:
@table
:存储所有表的模式信息。@meta
:存储数据库的元信息,例如表前缀计数器。
这种设计使得数据库能够动态管理表模式和元信息,同时利用 B 树高效地存储和查询数据。
获取、更新、插入、删除和创建操作
点查询和更新接口
以下是用于读取和写入单行数据的接口定义:
func (db *DB) Get(table string, rec *Record) (bool, error)
func (db *DB) Insert(table string, rec Record) (bool, error)
func (db *DB) Update(table string, rec Record) (bool, error)
func (db *DB) Upsert(table string, rec Record) (bool, error)
func (db *DB) Delete(table string, rec Record) (bool, error)
Get
:通过主键获取一行数据。Insert
:仅插入新行(如果主键已存在,则失败)。Update
:仅更新现有行(如果主键不存在,则失败)。Upsert
:插入新行或更新现有行。Delete
:删除指定行。
数据库结构
数据库包装了键值存储(KV):
type DB struct {Path string // 数据库路径kv KV // 键值存储接口
}
按主键查询
函数 dbGet
是按主键查询的核心实现。输入的 rec
参数表示主键,同时也是输出的结果行。
func dbGet(db *DB, tdef *TableDef, rec *Record) (bool, error) {// 1. 根据模式重新排列输入列values, err := checkRecord(tdef, *rec, tdef.PKeys)if err != nil {return false, err}// 2. 编码主键key := encodeKey(nil, tdef.Prefix, values[:tdef.PKeys])// 3. 查询键值存储val, ok := db.kv.Get(key)if !ok {return false, nil}// 4. 解码值到列for i := tdef.PKeys; i < len(tdef.Cols); i++ {values[i].Type = tdef.Types[i]}decodeValues(val, values[tdef.PKeys:])rec.Cols = tdef.Colsrec.Vals = valuesreturn true, nil
}
步骤说明:
- 重新排序列:根据表模式重新排列输入列,并检查是否有缺失列。
- 编码主键:将主键列编码为字节序列。
- 查询键值存储:通过主键从键值存储中获取对应的值。
- 解码值:将存储的值解码为列值,并填充到记录中。
获取表模式
用户接口通过表名引用表,因此需要先获取表模式。
func (db *DB) Get(table string, rec *Record) (bool, error) {tdef := getTableDef(db, table)if tdef == nil {return false, fmt.Errorf("table not found: %s", table)}return dbGet(db, tdef, rec)
}
获取表模式的实现:
func getTableDef(db *DB, name string) *TableDef {rec := (&Record{}).AddStr("name", []byte(name))ok, err := dbGet(db, TDEF_TABLE, rec)assert(err == nil)if !ok {return nil}tdef := &TableDef{}err = json.Unmarshal(rec.Get("def").Str, tdef)assert(err == nil)return tdef
}
- 表模式存储在内部表
@table
中。 - 使用 JSON 序列化和反序列化来处理表模式。
优化:可以将表模式缓存到内存中,以减少查询次数。
插入或更新行
SQL 更新语句有三种不同的行为:
- INSERT:仅添加新行(如果主键已存在,则失败)。
- UPDATE:仅修改现有行(如果主键不存在,则失败)。
- UPSERT:添加新行或修改现有行。
实现方式是扩展 BTree.Insert
方法,增加一个模式标志:
// 更新模式
const (MODE_UPSERT = 0 // 插入或替换MODE_UPDATE_ONLY = 1 // 仅更新现有键MODE_INSERT_ONLY = 2 // 仅添加新键
)type UpdateReq struct {tree *BTree// 输出Added bool // 是否添加了新键// 输入Key []byteVal []byteMode int
}func (tree *BTree) Update(req *UpdateReq)
核心更新逻辑:
func dbUpdate(db *DB, tdef *TableDef, rec Record, mode int) (bool, error) {values, err := checkRecord(tdef, rec, len(tdef.Cols))if err != nil {return false, err}key := encodeKey(nil, tdef.Prefix, values[:tdef.PKeys])val := encodeValues(nil, values[tdef.PKeys:])return db.kv.Update(key, val, mode)
}
- 部分更新(读取-修改-写入)在更高层次(如查询语言)实现。
创建表
创建表的过程包括以下步骤:
- 检查
@table
是否存在重复表名。 - 从
@meta
中读取表前缀计数器。 - 增加并更新表前缀计数器。
- 将表模式插入到
@table
中。
func (db *DB) TableNew(tdef *TableDef) error
- 此过程涉及更新两个键,因此目前缺乏原子性。可以在后续引入事务时修复此问题。
结论:基于键值存储的表
基于键值存储的表与传统关系型数据库并没有根本区别,只是增加了数据序列化和模式管理的额外步骤。
下一步工作:
- 支持范围查询。
- 实现二级索引。
代码仓库地址:database-go