为何要有VLL?VLL旨在解决什么问题?
在数据库系统中,锁是广泛使用的并发控制机制。然而对于内存数据库系统,锁管理器却成为了性能瓶颈所在。
一项研究说明内存数据库中有16%~25%的时间用于与锁管理器的交互
在传统的锁管理器中,最常见的实现方式是采用以数据主键为id,请求该数据的锁请求链表为值的hash表,锁请求链表还存在锁头追踪该项的锁状态,以及支持互斥操作。
对于与磁盘相关的数据库,这些hash表查找、锁获取和链表操作都是可以忽略不计的,可对于内存数据库来说,这些额外的内存访问、cache未命中、cpu周期、以及临界区就可能接近或者超过执行实际事务逻辑的成本
VLL在上述锁管理器上做出了两个主要变化
- 将锁信息从中心hash表中移出到原始数据中
- 从锁数据结构中删除哪些事务对特定锁未完成请求的信息,取而代之的是原始数据包含未完成的写、读请求的数量(CX记录未完成的写请求数量、CS记录未完成的读请求数量)
在事务获取锁时,会一次请求所有锁,并按照请求锁的顺序对事务排序,按照这种全局事务顺序来确定哪个事务应该先解除堵塞并执行
可是VLL也并非银弹,在高争用负载下,可能会导致并发减少和CPU利用率差。为解决这个问题,又引入了选择性竞争优化(SCA),在需要的时候高效计算最有用的竞争信息子集
VLL算法介绍
锁结构
VLL锁管理结构由
- 储存到每个原始数据的的未完成锁写请求数量CX和未完成锁读请求数量CS
- 中心事务请求全局队列TxnQueue
组成
流程
具体流程如下
-
当事务到达分区时,会尝试对该分区所有在其生命周期内访问的记录加锁
每个锁请求都采取相应记录的CX或CS加1的形式
-
如果请求后的CX=1且CS=0,则认为独占锁被请求事务抢占
如果请求后的CX=0,则认为请求事务获取了共享锁
-
当事务获取锁之后就被添加到TxnQueue
请求锁和向队列添加事务都发生在同一临界区中
-
在离开临界区时,若事务在请求时获取到所有锁,则该事务为free,否则为堵塞。那么只有free事务才能够立即执行
问题
考虑以下问题
- 单线程VLL在需要其他节点协作的事务请求中,如果一直等待,会导致资源的浪费
- 在没有中心锁管理结构情况下,如何释放等待中的堵塞事务执行呢?
- 如果在CX=0的情况下才能获取锁,而写请求到达之后都能递增CX,那么在存在多个写请求事务堵塞情况下,CX会永远大于0
- 随着TxnQueue大小增长,新事务能立刻获取锁的概率大大降低
为解决这些问题
- 引入waiting状态,标识事务正在进行中,但需要获取搭配远程结果才能继续执行。因此在进入waiting状态之后就可以挂起,不会导致额外的资源浪费
- 让后台线程检查TxnQueue中每个堵塞事务的请求数据CX、CS值,若CX下降到1,CS为0,则说明该事务获取到互斥锁;CX为0,CS大于0,则说明获取到共享锁
- 对于队列头的堵塞事务直接解除堵塞并执行。这是因为事务位于队列头,说明在此之前请求锁的事务都已经完结了
- 为TxnQueue设立阈值,当超过后,将会停止处理新事务,从TxnQueue中寻找可以被解除堵塞的事务。而在阈值内则会优先处理新请求事务
代码及流程展示
以下是代码展示
func beginTransaction(t *TxnItem) {
// 开始时,事务状态为Free
t.TxnStatus = Free
// 遍历本地读集合,检查是否存在冲突,若存在冲突,则事务状态为Blocked
for _, readKey := range t.ReadSet {
if inLocal(readKey) {
data[readKey].cs++
if data[readKey].cx > 1 {
t.TxnStatus = Blocked
}
}
}
// 遍历本地写集合,检查是否存在冲突,若存在冲突,则事务状态为Blocked
for _, writeKey := range t.WriteSet {
if inLocal(writeKey) {
data[writeKey].cx++
if data[writeKey].cs > 0 || data[writeKey].cx > 1 {
t.TxnStatus = Blocked
}
}
}
// 若事务状态为Free,且事务为分布式事务,则事务状态为Waiting
if t.TxnType == TxnTypeDistributed && t.TxnStatus == Free {
t.TxnStatus = Waiting
}
}
func commitTransaction(t *TxnItem) {
// 提交事务时,释放读集合的锁
for _, readKey := range t.ReadSet {
if inLocal(readKey) {
data[readKey].cs--
}
}
// 提交事务时,释放写集合的锁
for _, writeKey := range t.WriteSet {
if inLocal(writeKey) {
data[writeKey].cx--
}
}
}
func vll() {
for {
// 若接收到远程事务消息,则处理该事务的消息,并执行事务、提交事务以及从队列中移除该事务
t, ok, message := selectReceivedMessageTx()
if ok {
handleReadResult(t, message)
if isReadyToExecute(t) {
execute(t)
commitTransaction(t)
txnQueue.dequeue(t)
}
continue
}
// 若队列头事务堵塞,在分布式事务时,设置状态为等待,并给其他参与节点发送事务请求
// 否则,设置状态为空闲,执行事务、提交事务以及从队列中移除该事务
if txnQueue.front().TxnStatus == Blocked {
t = txnQueue.front()
if t.TxnType == TxnTypeDistributed {
t.TxnStatus = Waiting
// 给其他参与节点发送事务请求
} else {
// 注意!此处,对于队列头的堵塞事务直接解除堵塞并执行
// 这是因为事务位于队列头,说明在此之前请求锁的事务都已经完结了。并且考虑到存在多个事务增加同数据CX值的情况,使得CX总是会大于1,导致死锁
t.TxnStatus = Free
execute(t)
commitTransaction(t)
txnQueue.dequeue(t)
}
} else if !txnQueue.isFull() {
// 若队列未满,则获取新事务请求
// 新事务状态为Free,执行事务、提交事务
// 若事务状态为Waiting,给其他参与节点发送事务请求,并入队
// 若事务状态为Blocked,入队
// 注意!检查未满的缘故在于平衡新旧事务的处理。在阈值内优先处理新事务请求,阈值之外优先处理waiting的事务
t = getNewTxnRequest()
beginTransaction(t)
switch t.TxnStatus {
case Free:
execute(t)
commitTransaction(t)
case Waiting:
// 给其他参与节点发送事务请求
txnQueue.enqueue(t)
case Blocked:
txnQueue.enqueue(t)
}
}
}
}
考虑下面场景下的演变,表中展示的是各事务请求的数据
txn | read set | write set |
---|---|---|
A | x, y | x |
B | x, y | x |
C | x | 0 |
D | y | z |
E | 0 | y |
- 最开始,所有数据的CX、CS都是0,TxnQueue为空
- 事务A发起读取x、y,并写入x请求,此时数据x的CX递增,数据y的CS递增,并且事务A作为free事务加入TxnQueue
- 事务B发起x、y读,x写的请求。当由于x.CX>0,所以B无法获取到x的写锁,因此作为堵塞事务加入到TxnQueue
- 当A完成执行后,释放锁。此时x.CX–、y.CS–,并将A从TxnQueue移除,B而后标记为free,获取锁开始执行
- 事务C发起读取x的请求,因此x.CS++,由于x.CX>0,因此事务C的读请求无法保证,作为堵塞事务加入到TxnQueue
- 事务D发起对y读请求,对z写请求。那么y.CS++,z.CX=1,由于y.CX和z.CX此前均为0,事务D所有锁获取成功,并作为free事务加入TxnQueue
- 事务E发起对y的写请求,由于y.CS>0,所以E请求失败,作为堵塞事务加入到TxnQueue
- 事务B执行完毕后,释放x写锁、y读锁,并从TxnQueue移除,C此时位于TxnQueue队列最前面,因此标记为free,成功获取到x读锁并执行
- 最后事务E也能够安全执行,因为不与TxnQueue中在其之前的任何事务冲突。但又由于VLL算法仅允许TxnQueue最前面事务执行,所以还是不能够执行。SCA将解除该限制
array VLL
array VLL采用向量或数组的数据结构来连续储存锁信息,这会使得数据和锁信息是在两次单独请求中进行的,但在单独线程中,由于在核心缓存中,所以也能在性能上有着较好的发挥
一次性获取所有锁的障碍
- 在运行事务前,事务读写集可能是未知的
- 由于每个节点都有TxnQueue,并且临界区在本地,那么不同的节点可能处理顺序并不一致,这可能会导致死锁
为解决第一个问题,允许事务在进入临界区前,能给执行所需要的任何读取
为解决节点事务顺序不一致引发的死锁问题,有两种解决方法
- 允许产生死锁,通过使用死锁检测协议取消死锁事务
- 协调节点,使各节点事务顺序一致
VLL采用协调节点的方式,可以在增加协调开销的情况下,仍然产生改进的性能
SCA——提升在高争用下的性能
对于高竞争和高百分比的多分区工作负载,VLL引入了SCA,进行模拟标准锁管理器去检测哪些事务应该继承已释放锁的能力
SCA只在真正需要的时候才会激活,比如在CPU处理空闲时。从队列头往尾部进行扫描,分析在事务之前的事务是否存在冲突
func sca(q TxnQueue) *TxnItem {
// 初始化冲突检测数组
dx := make([]int8, 100<<10)
ds := make([]int8, 100<<10)
for _, item := range q {
// 如果事务被阻塞
if item.TxnStatus == Blocked {
success := true
// 检查读集合,若读集合中的数据已经被写占用,则事务失败
for _, read := range item.ReadSet {
key := hash(read)
if dx[key] == 1 {
success = false
}
// 读集合中的数据标记为已占用
ds[key] = 1
}
// 检查写集合,若写集合中的数据已经被读、写占用,则事务失败
for _, write := range item.WriteSet {
key := hash(write)
if ds[key] == 1 || dx[key] == 1 {
success = false
}
// 写集合中的数据标记为已占用
dx[key] = 1
}
// 若事务成功,则返回该事务
if success {
return item
}
}else {
// 事务未被阻塞,仅标记读写集合数据被占用
for _, read := range item.ReadSet {
key := hash(read)
ds[key] = 1
}
for _, write := range item.WriteSet {
key := hash(write)
dx[key] = 1
}
}
}
return nil
}
VLLR——支持范围的VLL
对于频繁读取、写入、删除同一事务中的许多连续行,锁定行范围比锁定多个单独行更有用,可以避免幻读以及在删除一个数据行共享其主键的公共前缀的实体的常见用例中特别有用
VLLR通过锁定位串前缀进行范围锁定。当锁定一个范围主键时,将该范围表示为集合R,然后生成集合P,使得R中任何一个元素都能在P中找到前缀。最简单生成P的方式时选择R中最小和最大位串的最长公共前缀
在得到集合P之后,VLLR采用分层锁的方式进行。即先从数据库获取意图锁,而后从表获取意图锁,接着在页获取意图锁,最后获取行锁
此外,为了更好管理锁状态,VLLR在CX、CS的基础上,引入了P集合的LX、LS计数器,也就是如果当前请求属于集合P的写请求,则LX递增,属于集合P的读请求,则LS递增
func requestLock(item *TxnItem, rg *txnRange, exclusive bool) bool {
// 事务加入队列
txnQueue.enqueue(item)
// 获取锁范围的前缀
pfs := rg.toPrefix()
// 请求前缀锁
success := true
for _, pf := range pfs {
success = success && requestPrefixLock(pf, exclusive)
}
return success
}
func requestPrefixLock(key string, exclusive bool) bool {
success := true
k := hash(key)
// 检查当前key数据锁是否不可获取
if exclusive {
success = success && (cs[k] == 0)
}
success = success && (cx[k] == 0)
// 检查key数据所在前缀锁是否不可获取
if exclusive {
success = success && (ls[k] == 0)
}
success = success && (lx[k] == 0)
// 检查key所有前缀的数据锁是否不可获取
for i := 1; i < len(key); i++ {
h := hash(key[:i])
success = success && (cx[h] == 0)
if exclusive {
success = success && (cs[h] == 0)
}
}
// 标记key数据锁
if exclusive {
cx[k]++
} else {
cs[k]++
}
// 标记key数据所有前缀的前缀锁
for i := 1; i < len(key); i++ {
h := hash(key[:i])
if exclusive {
lx[h]++
} else {
ls[h]++
}
}
return success
}
func releaseLock(item *TxnItem, rg *txnRange, exclusive bool) {
// 释放锁范围的前缀
pfs := rg.toPrefix()
for _, pf := range pfs {
releasePrefixLock(pf, exclusive)
}
// 事务出队列
txnQueue.dequeue(item)
}
func releasePrefixLock(key string, exclusive bool) {
// 释放key数据锁
k := hash(key)
if exclusive {
cx[k]--
} else {
cs[k]--
}
// 释放key数据所有前缀的前缀锁
for i := 1; i < len(key); i++ {
h := hash(key[:i])
if exclusive {
lx[h]--
} else {
ls[h]--
}
}
}
Ref
- https://www.cs.umd.edu/~abadi/papers/vldbj-vll.pdf