当前位置: 首页 > news >正文

数据库的并发控制

并发控制

12.1 并发级别

问题:交错的读写

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

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

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

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

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

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

读-复制-更新(RCU)

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

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

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

乐观并发控制

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

SeqTX1:
read a
write b := a
commitSeqTX2:
write a := 1
commit

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

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

SeqTX1:
write a := 1
commitSeqTX2:
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.roottx.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:], truecase ok && val[0] == FLAG_DELETED: // 在此事务中删除return nil, falsecase !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时释放锁来进一步优化,以允许其他事务进入,并让只读事务退出。提交步骤仍然需要通过另一个锁与其他提交步骤序列化。这一点留作练习。

相关文章:

  • USB资料摘录for后期,bus hound使用
  • 小白学习java第16天(下):javaweb
  • 凸性(Convexity)
  • Python小酷库系列:bidict,可以双向查询的dict
  • 2025年5月5日星期一的摸鱼大冒险
  • 音视频作品:AI生成音乐、短视频的邻接权保护
  • 基于yolov11的灭火器检测系统python源码+pytorch模型+评估指标曲线+精美GUI界面
  • NV287NV291美光固态闪存NV293NV294
  • Kubernetes排错(七)-节点排错
  • 用 Python 分析 IRIS 数据集:概率论与数理统计实战教程
  • 嵌入式开发学习日志Day13
  • 2025年最新Linux的Redis主从集群搭建
  • [C++] 小游戏 决战苍穹
  • 深度学习系统学习系列【4】之反向传播(BP)四个基本公式推导
  • 12. 多线程(9) --- 案例:定时器
  • DeepWiki 是什么,怎么使用
  • SIFT算法详细原理与应用
  • 力扣第448场周赛
  • Android学习总结之GetX库篇(场景运用)
  • 蓝桥杯单片机备战笔记
  • 繁荣活跃!“五一”假期全国重点零售和餐饮企业销售额同比增长6.3%
  • 山大齐鲁医院通报“子宫肌瘤论文现男性患者”:存在学术不端
  • 美权威人士批“特朗普对进口电影征关税”:将杀死美电影产业
  • 一周文化讲座|那些年的年青人
  • 海港负国安主场两连败,五强争冠卫冕冠军开始掉队
  • 格桑花盛放上海,萨迦艺术团襄阳公园跳起藏族舞