1 minute read

在这篇文章中,我们将探讨如何使用 Go 语言从零开始构建一个功能强大的内存数据库。这个项目不仅仅是一个简单的键值存储,它还将支持事务过期时间和并发控制的技术——MVCC(多版本并发控制)

我们的目标是打造一个在并发场景下既能保证数据一致性,又能提供高性能读写能力的系统。

核心理念与技术选择

在开始编码之前,我们先确定几个核心的设计目标和实现它们的关键技术。

  1. 内存存储 (In-Memory):所有数据都存储在内存中,以实现极低的读写延迟。
  2. 并发安全 (Concurrency):多个 goroutine 可以同时安全地读写数据库,而不会导致数据损坏。
  3. 事务支持 (Transactions):操作可以打包成一个原子单元(事务),要么全部成功,要么全部失败,保证了数据操作的原子性。
  4. 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。它的职责很简单:

  1. 生成唯一的、单调递增的 ID:我们用一个 nextID 计数器同时为新事务和新提交生成 ID。这保证了逻辑时钟的正确性。
  2. 追踪事务状态:通过一个 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 实现中最神奇的部分。当一个事务想要读取一个键时,它必须找到对它而言”可见”的最新版本。

其执行逻辑如下:

  1. 优先读自己:首先检查当前事务的 writeSet。如果键在其中,意味着本次事务已经修改或创建了它,直接返回 writeSet 中的版本。这保证了”读己之写”。
  2. 扫描版本链:如果 writeSet 中没有,就去主存储区找到该键的 VersionList
  3. 应用可见性规则:从最新到最旧遍历版本链,寻找第一个满足以下所有条件的版本:
    • 版本的提交 ID (CreatedAt) 必须小于当前事务的 ID (TxnID)。这确保了我们读到的是事务开始前就已经存在的数据。
    • 创建该版本的事务状态必须是 TxnCommitted
    • 该版本没有被标记为 IsDeleted
    • 该版本没有过期。
// 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,它周期性地执行两个任务:

  1. cleanupExpired(): 遍历所有版本,移除 ExpiresAt 早于当前时间的版本。
  2. runVersionGC(): 这是 MVCC 的垃圾回收。它会从 TransactionManager 获取”最老的活跃事务 ID” (oldestActiveTxnID)。任何提交 ID (CreatedAt) 小于这个 ID 的旧版本,都可以被安全地删除,因为它们对于现在和未来的任何事务都将是不可见的。

效果演示

main.go