从零到一:用 Go 构建一个支持 MVCC 的内存数据库
在这篇文章中,我们将探讨如何使用 Go 语言从零开始构建一个功能强大的内存数据库。这个项目不仅仅是一个简单的键值存储,它还将支持事务、过期时间和并发控制的技术——MVCC(多版本并发控制)。
我们的目标是打造一个在并发场景下既能保证数据一致性,又能提供高性能读写能力的系统。
核心理念与技术选择
在开始编码之前,我们先确定几个核心的设计目标和实现它们的关键技术。
- 内存存储 (In-Memory):所有数据都存储在内存中,以实现极低的读写延迟。
- 并发安全 (Concurrency):多个 goroutine 可以同时安全地读写数据库,而不会导致数据损坏。
- 事务支持 (Transactions):操作可以打包成一个原子单元(事务),要么全部成功,要么全部失败,保证了数据操作的原子性。
- MVCC (Multi-Version Concurrency Control):为了在不使用重量级读锁的情况下实现高并发和事务隔离,我们选择 MVCC。它的核心思想是”写操作不覆盖旧数据,而是创建新版本”,从而实现”读写不冲突”。
架构设计
我们将项目划分为几个模块,每个模块各司其职:
database/db.go
: 数据库的主结构,负责管理数据存储和后台清理任务。database/transaction.go
: 事务的核心实现,包括 Get, Set, Commit, Rollback 等操作。database/transaction_manager.go
: 事务管理器,负责分配全局唯一的事务 ID 和维护事务状态。database/version.go
&version_list.go
: MVCC 的基础,定义了数据的”版本”和如何将一个键的多个版本组织起来。
实现深度剖析
让我们一步步深入代码,看看核心功能是如何实现的。
第一步:版本与数据结构
MVCC 的基础是为每一个键维护一个版本链。因此,我们不能简单地用 map[string]interface{}
来存储数据。
我们定义了 Version
结构来代表一个键的某个版本,以及 VersionList
来存储一个键的所有历史版本。
version.go
:
// Version 代表了 MVCC 中的一个数据版本
type Version struct {
Value interface{} // 真实数据
TxnID uint64 // 创建该版本的事务ID
CreatedAt uint64 // 该版本被创建的时间戳或提交ID
ExpiresAt int64 // 过期时间戳
IsDeleted bool // 是否为删除标记
}
db.go
:
数据库的主结构 DB
持有一个从键到 VersionList
的映射。
// DB 代表了支持 MVCC 的内存数据库
type DB struct {
mu sync.RWMutex // 全局读写锁
data map[string]*VersionList // 存储键的版本列表
txnManager *TransactionManager // 事务管理器
// ... 后台清理相关字段
}
第二步:事务的生命周期
为了管理事务,我们需要一个 TransactionManager
。它的职责很简单:
- 生成唯一的、单调递增的 ID:我们用一个
nextID
计数器同时为新事务和新提交生成 ID。这保证了逻辑时钟的正确性。 - 追踪事务状态:通过一个 map 记录每个事务是 Active, Committed, 还是 Aborted。
// database/transaction_manager.go
func (tm *TransactionManager) GetNextID() uint64 {
tm.mu.Lock()
defer tm.mu.Unlock()
id := tm.nextID
tm.nextID++
return id
}
当用户调用 db.Begin()
时,我们会从管理器获取一个新 ID,并创建一个 Txn
对象,该对象内部维护着一个 writeSet
,用于缓存本次事务的所有写操作。
第三步:MVCC 的魔法 - 可见性检查
Txn.Get(key)
是整个 MVCC 实现中最神奇的部分。当一个事务想要读取一个键时,它必须找到对它而言”可见”的最新版本。
其执行逻辑如下:
- 优先读自己:首先检查当前事务的
writeSet
。如果键在其中,意味着本次事务已经修改或创建了它,直接返回writeSet
中的版本。这保证了”读己之写”。 - 扫描版本链:如果
writeSet
中没有,就去主存储区找到该键的VersionList
。 - 应用可见性规则:从最新到最旧遍历版本链,寻找第一个满足以下所有条件的版本:
- 版本的提交 ID (
CreatedAt
) 必须小于当前事务的 ID (TxnID
)。这确保了我们读到的是事务开始前就已经存在的数据。 - 创建该版本的事务状态必须是
TxnCommitted
。 - 该版本没有被标记为
IsDeleted
。 - 该版本没有过期。
- 版本的提交 ID (
// database/transaction.go - Get 方法简化逻辑
func (txn *Txn) Get(key string) (interface{}, bool) {
// ... 优先检查 writeSet ...
// ... 从主存储获取 versionList ...
// 从后往前遍历,寻找最新可见版本
for i := len(versionList.versions) - 1; i >= 0; i-- {
version := versionList.versions[i]
// 版本可见性核心规则
if version.CreatedAt < txn.TxnID {
// 检查创建该版本的事务是否已提交
txnState, _ := txn.db.txnManager.transactions[version.TxnID]
if txnState == TxnCommitted {
// ... 检查删除和过期 ...
return version.Value, true // 找到了!
}
}
}
return nil, false // 没有找到可见版本
}
第四步:提交与回滚
- Set/Delete:
txn.Set()
和txn.Delete()
并不直接修改全局数据,而是将一个新版本(对于 Delete 则是带IsDeleted
标记的版本)放入事务私有的writeSet
中。 - Commit:
txn.Commit()
是原子性的关键。它会遍历writeSet
,为每个新版本分配一个全局的提交 ID,然后将这些版本追加到对应键的VersionList
中,最后将事务状态更新为Committed
。 - Rollback:
txn.Rollback()
非常简单,只需清空writeSet
并将事务状态更新为Aborted
即可。所有未提交的更改都随风而去。
第五步:后台维护 - 过期与 GC
一个长期运行的数据库必须能清理垃圾数据。我们在 db.go
中启动了一个后台 goroutine,它周期性地执行两个任务:
cleanupExpired()
: 遍历所有版本,移除ExpiresAt
早于当前时间的版本。runVersionGC()
: 这是 MVCC 的垃圾回收。它会从TransactionManager
获取”最老的活跃事务 ID” (oldestActiveTxnID
)。任何提交 ID (CreatedAt
) 小于这个 ID 的旧版本,都可以被安全地删除,因为它们对于现在和未来的任何事务都将是不可见的。
效果演示
main.go