二级索引
10.1 二级索引作为额外的键
表结构
正如第8章提到的,二级索引本质上是包含主键的额外键值对。每个索引通过B+树中的键前缀来区分。
type TableDef struct {
// 用户定义的部分
Name string
Types []uint32 // 列类型
Cols []string // 列名
Indexes [][]string // 第一个索引是主键
// 自动分配的B+树键前缀,用于不同的表和索引
Prefixes []uint32
}
第一个索引被用作主键,因为它本身也是一个索引。
键值结构
对于二级索引,我们可以将主键存储在B+树的值中,以便通过它找到完整的行数据。然而,与主键不同的是,二级索引通常没有唯一性约束,因此可能会出现重复的B+树键。
为了避免修改B+树以支持重复键,我们可以将主键附加到B+树的键上,使其唯一,并将值留空。
例如:
create table t1 (
k1 string,
k2 int,
v1 string,
v2 string,
primary key (k1, k2),
index idx1 (v1),
index idx2 (v2, k2)
);
对应的KV结构如下:
- 主表
t1
:- 键:
prefix1, k1, k2
- 值:
v1, v2
- 键:
- 索引
idx1
:- 键:
prefix2, v1, k1, k2
- 值:
(empty)
- 键:
- 索引
idx2
:- 键:
prefix3, v2, k2, k1
- 值:
(empty)
- 键:
10.2 使用二级索引
通过匹配列选择索引
为了执行范围查询,我们必须选择一个与查询键匹配的索引。这个信息存储在 Scanner
类型中,以便 Scanner.Deref()
可以使用它。
type Scanner struct {
// 范围,从 Key1 到 Key2
Cmp1 int // CMP_??
Cmp2 int
Key1 Record
Key2 Record
// 内部字段
db *DB
tdef *TableDef
index int // 使用哪个索引?
iter *BIter // 底层的B+树迭代器
keyEnd []byte // 编码后的 Key2
}
索引可以由多列组成。例如,索引 (a, b)
可以服务于查询 (a, b) > (1, 2)
。它也可以服务于查询 a > 1
,因为这等价于 (a, b) > (1, +∞)
。索引的选择仅仅是通过匹配列完成的。
func dbScan(db *DB, tdef *TableDef, req *Scanner) error {
// ...
isCovered := func(index []string) bool {
key := req.Key1.Cols
return len(index) >= len(key) && slices.Equal(index[:len(key)], key)
}
req.index = slices.IndexFunc(tdef.Indexes, isCovered)
// ...
}
将缺失列编码为无穷大
在最后一个例子中,查询 a > 1
使用索引 (a, b)
,但只使用了其中的一列,因此我们需要将剩余的列编码为无穷大。
输入查询 | 使用索引 |
---|---|
a > 1 | (a, b) > (1, +∞) |
a ≤ 1 | (a, b) < (1, +∞) |
a ≥ 1 | (a, b) > (1, -∞) |
a < 1 | (a, b) < (1, -∞) |
这可以通过修改我们的保持顺序编码实现。我们选择 \xff
表示正无穷大(+∞
),而空字符串表示负无穷大(-∞
)。由于没有列会被编码为空字符串,因此在 -∞
的情况下可以直接忽略缺失的列。而在 +∞
的情况下,我们需要在每个编码列前添加一个标签,以确保它们不会以 \xff
开头。
// 保持顺序的编码
func encodeValues(out []byte, vals []Value) []byte {
for _, v := range vals {
out = append(out, byte(v.Type)) // 添加标签:不以 0xff 开头
switch v.Type {
case TYPE_INT64:
var buf [8]byte
u := uint64(v.I64) + (1 << 63)
binary.BigEndian.PutUint64(buf[:], u) // 大端
out = append(out, buf[:]...)
case TYPE_BYTES:
out = append(out, escapeString(v.Str)...)
out = append(out, 0) // 空字符终止
default:
panic("what?")
}
}
return out
}
我们在每个编码列前添加列类型代码作为标签。这不仅方便调试,还可以通过查看十六进制转储直接解码内容。
这是一个小步骤,用于支持前缀列上的范围查询。
// 用于主键和索引
func encodeKey(out []byte, prefix uint32, vals []Value) []byte {
// 4字节的表前缀
var buf [4]byte
binary.BigEndian.PutUint32(buf[:], prefix)
out = append(out, buf[:]...)
// 保持顺序编码的键
out = encodeValues(out, vals)
return out
}
// 用于输入范围,可能是索引键的前缀
func encodeKeyPartial(
out []byte, prefix uint32, vals []Value, cmp int,
) []byte {
out = encodeKey(out, prefix, vals)
if cmp == CMP_GT || cmp == CMP_LE { // 编码缺失列为无穷大
out = append(out, 0xff) // 不可达的 +∞
} // 否则:-∞ 是空字符串
return out
}
10.3 维护二级索引
与主数据同步
更新可能涉及多个带有二级索引的B+树键。当一行被更改时,我们必须删除旧的索引键并插入新的索引键。为此,扩展了B+树接口以返回旧值。
type UpdateReq struct {
tree *BTree
// 输出
Added bool // 添加了新键
Updated bool // 添加了新键或旧键被更改
Old []byte // 更新前的值
// 输入
Key []byte
Val []byte
Mode int
}
使用新的信息:
func dbUpdate(db *DB, tdef *TableDef, rec Record, mode int) (bool, error) {
// ...
// 插入行
req := UpdateReq{Key: key, Val: val, Mode: mode}
if _, err = db.kv.Update(&req); err != nil {
return false, err
}
// 维护二级索引
if req.Updated && !req.Added {
// 使用 `req.Old` 删除旧的索引键...
}
if req.Updated {
// 添加新的索引键...
}
return req.Updated, nil
}
多键更新的原子性
原子性不可组合!即使单个KV操作是原子的,当涉及多个键时,我们也会失去原子性。如果数据库崩溃或在更新二级索引时发生错误,应该回滚到之前的状态。
仅使用 get
, set
, del
实现这一点非常棘手,这就是为什么简单的KV接口有很大的局限性。下一步是引入事务性KV接口,以允许对多个键甚至并发读取进行原子操作。
10.4 表和索引的总结
- 行和列作为KV对。
- 范围查询:
- B+树迭代器。
- 保持顺序的编码。
- 二级索引:
- 索引选择。
- 需要一个事务性接口。
代码仓库地址:database-go