基于鍵值(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