数据库的并发控制
并发控制
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
一种解决冲突的方法是在检测到冲突时中止事务:
- 事务开始。
- 读取基于快照,但写入缓冲在本地。
- 提交前验证与已提交事务是否有冲突。
- 事务结束:
- 如果有冲突,中止并回滚。
- 否则,将缓冲的写入转移到数据库。
验证和提交是一个原子步骤。这种方法称为乐观并发控制,因为它假设冲突很少发生,因此不采取任何措施预防冲突。
悲观并发控制(替代方法)
在乐观并发控制中,事务在冲突时无法继续,这对应用程序来说并不友好,因为它们只能不断重试。另一种解决冲突的方法是通过锁定来预防冲突。事务会为其依赖项获取锁,潜在冲突的事务会彼此等待。
这种方法听起来更好,尤其是在最后一个示例中,写/删操作可以无问题地进行。然而,这仍然不能保证进展,因为事务可能会因死锁而失败。
死锁是指两个参与者彼此等待对方释放自己持有的锁。如果依赖图中存在循环,超过两个参与者也可能发生死锁。在并发编程中,锁应按预定义顺序获取以避免循环。但对于数据库而言,客户端可以以任意顺序获取锁,因此数据库必须检测并解决死锁,这是一个图问题。
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)并阻塞线程。
到目前为止,只有Begin
、Commit
和Abort
是序列化的。但考虑到Commit
涉及I/O操作,我们可以通过在等待I/O时释放锁来进一步优化,以允许其他事务进入,并让只读事务退出。提交步骤仍然需要通过另一个锁与其他提交步骤序列化。这一点留作练习。