数据库的并发控制

并发控制

12.1 并发级别

问题:交错的读写

并发客户端可以随意进入和退出事务,并在中途请求读取和写入。为了简化分析,假设enter/exit/read/write是原子步骤,因此并发事务只是这些步骤的交错。

我们还将区分只读事务和读写事务:

  • 并发读者并发写者更容易处理。
  • 许多应用程序是读密集型的,因此读性能更为重要。
读者-写者锁(RWLock)

如果不了解如何实现并发控制,可以简单地添加一个互斥锁(mutex)来序列化数据访问。为了提升读性能,可以使用读者-写者锁(RWLock),它允许多个并发读者,但只有一个写者。

  • 如果没有写者,数据不会改变,因此并发读者是可以接受的。
  • 当写者想要进入时,它会等待所有读者离开。
  • 写者会阻塞读者,但读者之间不会相互阻塞。

这种方法的用处有限,因为写者之间没有并发能力,且长时间运行的事务会导致读者和写者互相阻塞。

读-复制-更新(RCU)

为了防止读者和写者互相阻塞,可以让读者和写者各自操作自己的数据版本:

  • 数据有一个指向不可变数据的指针,读者只需抓取快照。
  • 单个写者更新自己的副本,然后翻转指针。

由于我们采用的是写时复制(copy-on-write),这种并发级别是免费获得的。然而,单个写者仍然不足,因为事务的生命周期由客户端控制,可能任意延长。

乐观并发控制

并发写者会导致冲突,例如:

SeqTX1:
read a
write b := a
commit

SeqTX2:
write a := 1
commit

TX1依赖于TX2修改的同一键,因此它们不能同时成功。

有些看似“仅写”的操作实际上有读依赖性。例如,我们的更新/删除接口会报告键是否被更新或删除,这取决于键的先前状态。因此以下场景也是冲突:

SeqTX1:
write a := 1
commit

SeqTX2:
delete a
commit

一种解决冲突的方法是在检测到冲突时中止事务:

  1. 事务开始。
  2. 读取基于快照,但写入缓冲在本地。
  3. 提交前验证与已提交事务是否有冲突。
  4. 事务结束:
    • 如果有冲突,中止并回滚。
    • 否则,将缓冲的写入转移到数据库。

验证和提交是一个原子步骤。这种方法称为乐观并发控制,因为它假设冲突很少发生,因此不采取任何措施预防冲突。

悲观并发控制(替代方法)

在乐观并发控制中,事务在冲突时无法继续,这对应用程序来说并不友好,因为它们只能不断重试。另一种解决冲突的方法是通过锁定来预防冲突。事务会为其依赖项获取锁,潜在冲突的事务会彼此等待。

这种方法听起来更好,尤其是在最后一个示例中,写/删操作可以无问题地进行。然而,这仍然不能保证进展,因为事务可能会因死锁而失败。

死锁是指两个参与者彼此等待对方释放自己持有的锁。如果依赖图中存在循环,超过两个参与者也可能发生死锁。在并发编程中,锁应按预定义顺序获取以避免循环。但对于数据库而言,客户端可以以任意顺序获取锁,因此数据库必须检测并解决死锁,这是一个图问题。


12.2 为读者实现快照隔离

隔离级别指的是事务如何看待其他事务的更改。对于写时复制来说这不是问题,因为每个事务都操作B+树的快照。

捕获本地更新

事务保持数据库的快照和本地更新:

  • 快照只是一个根指针(写时复制)。
  • 本地更新保存在一个内存中的B+树中。
type KVTX struct {
    snapshot BTree // 只读快照
    pending  BTree // 捕获的KV更新,值通过1字节标志表示删除的键
}

快照和本地更新都在事务开始时初始化:

func (kv *KV) Begin(tx *KVTX) {
    tx.snapshot.root = kv.tree.root
    tx.snapshot.get = ... // 从mmap页面读取
    pages := [][]byte(nil)
    tx.pending.get = func(ptr uint64) []byte { return pages[ptr-1] }
    tx.pending.new = func(node []byte) uint64 {
        pages = append(pages, node)
        return uint64(len(pages))
    }
    tx.pending.del = func(uint64) {}
}
读取自己的写入

在事务内,客户端应该能够读取刚刚写入的内容,即使尚未提交。查询应先检查KVTX.pending再检查KVTX.snapshot。这就是为什么写入保存在B+树中而不是列表中。

func (tx *KVTX) Get(key []byte) ([]byte, bool) {
    val, ok := tx.pending.Get(key)
    switch {
    case ok && val[0] == FLAG_UPDATED: // 在此事务中更新
        return val[1:], true
    case ok && val[0] == FLAG_DELETED: // 在此事务中删除
        return nil, false
    case !ok: // 从快照中读取
        return tx.snapshot.Get(key)
    default:
        panic("unreachable")
    }
}

对于范围查询,新增了一个迭代器类型来合并两棵树。

空闲列表中的版本号

由于读者可能持有旧版本的数据库,空闲列表不能释放这些版本中的页面。我们通过为每个版本分配单调递增的版本号(也称为时间戳)来解决这个问题。

type FreeList struct {
    maxVer uint64 // 最老的读者版本
    curVer uint64 // 提交时的版本号
}

12.3 处理写者的冲突

检测历史冲突

所有读取都被添加到KVTX.reads中(点查询和范围查询)。

type KVTX struct {
    reads []KeyRange
}

每次成功提交都会添加到KV.history中。

type KV struct {
    history []CommittedTX
}

冲突检测通过检查依赖关系和历史记录之间的重叠来进行:

func detectConflicts(kv *KV, tx *KVTX) bool {
    for i := len(kv.history) - 1; i >= 0; i-- {
        if !versionBefore(tx.version, kv.history[i].version) {
            break
        }
        if rangesOverlap(tx.reads, kv.history[i].writes) {
            return true
        }
    }
    return false
}

代码仓库地址:database-go

序列化内部数据结构

在分析中,事务被简化为交错的步骤。在现实中,这些步骤可以并行运行,因此需要序列化共享的KV结构。

type KV struct {
    mutex sync.Mutex
}

尽管可以通过锁保护所有KVTX方法,但也可以减少锁定。例如:

  • 写入仅操作KVTX.pending,不会触及KV。
  • 读取仅触及KV.mmap.chunks,这是mmap返回的切片。

提交步骤可能会修改KV.mmap.chunks,因此每个事务使用一个本地副本。

通过这种方式,读写方法不需要加锁,并且可以并行运行。这很好,因为读取操作可能会触发页面错误(page faults)并阻塞线程。

到目前为止,只有BeginCommitAbort是序列化的。但考虑到Commit涉及I/O操作,我们可以通过在等待I/O时释放锁来进一步优化,以允许其他事务进入,并让只读事务退出。提交步骤仍然需要通过另一个锁与其他提交步骤序列化。这一点留作练习。

评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包

打赏作者

Achilles.Wang

你的鼓励将是我创作的最大动力

¥1 ¥2 ¥4 ¥6 ¥10 ¥20
扫码支付:¥1
获取中
扫码支付

您的余额不足,请更换扫码支付或充值

打赏作者

实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值