slava项目(6):实现数据库的事务功能(含乐观锁的详解和实现)

slava是作者参与的一个github开源项目,项目的主要的工作是用Go语言构建一个高性能、K-V云数据库。 slava项目的连接

本文,作者将实现数据库的事务的功能,slava数据库的功能包括Multi(提交事务)、Exec(提交事务)、Discard(撤销或者回滚事务)、Watch(乐观锁机制,监控某个key值)等

事务的流程图:

执行事务流程图

1.在实现数据库的事务的功能之前,我们先要实现分数据库的实体:

type DB struct {
	index      int       // 表示第几个分数据库
	data       dict.Dict // 分数据库的数据存放
	ttlMap     dict.Dict // key所对应的过期时间
	versionMap dict.Dict // key所对应的版本(uint32)
	// dict.Dict将确保其方法的并发安全
	// 仅对复杂的命令使用该互斥锁,如,rpush,incr,msetnx...
	locker *lock.Locks
	AddAof func(Cmdline)
}

// redis命令的执行函数
// args 中并不包括cmd命令行
type ExecFunc func(db *DB, args [][]byte) slava.Reply
// CmdLine表示命令行
type Cmdline = [][]byte
// PreFun 将在”multi“命令中使用,返回相关的写键和读键
// 该函数在ExecFunc前执行,负责分析命令行读写了哪些key便于进行加锁
type PreFunc func(args [][]byte) ([]string, []string)
//UndoFunc返回给定命令行的撤消日志,仅在事务中使用,负责undo logs以备事务执行过程中遇到错误需要回滚
//撤消时从头到尾执行
type UndoFunc func(db *DB, args [][]byte) []Cmdline

定义了一个结构体来表示分数据库,结构体中的字段:

(1)结构体的第一个字段index表示分数据库的索引;

(2)data字段为该数据库中存放的所有k-v数据,data的底层是一个可并发读写的map;

(3)ttlMap存储的是key的对应的过期时间

(4)versionMap存储key对应的版本号,版本号用于事务提交的时候判断被监控的key是否被修改。

(5)locker在执行复杂的命令使用该互斥锁,复杂命令指得是msetnx、rpush等

(6)AddAof 对命令(修改和写入的命令)进行Aof操作

2.基于乐观锁的Watch

2.1乐观锁: 总是假设最好的情况,在拿去共享数据的时候,认为没人会修改该共享数据,所以不会在访问数据的时候进行加锁。乐观锁适用于多读写少的应用场景,这样可以提高吞吐量。
2.2悲观锁: 总是假设是最坏的情况,再对共享数据的操作的时候,需要上锁,同一个时间只能有一个线程访问该共享数据,其他的线程被阻塞。

2.3使用场景:

  • 当竞争不激烈,出现冲突的概率小的时候,乐观锁更有优势,因为悲观锁会锁住临界资源,导致其他线程无法访问,被阻塞,而且加锁和解锁都需要消耗额外的系统资源
  • 当竞争激烈,出现冲突概率较大时,悲观锁更加有优势,因为乐观锁更新的时候频繁失败,需要不断重试,浪费CPU资源

2.4乐观锁实现的两种方式:

2.4.1CAS算法
即Compare And Swap(比较与交换),是一种无锁的算法。CAS包含三个操作数,一个是需要读写的内存位置(V),一个是进行比较的预期原值(A),另一个是拟写入的目标值(B)。在进行对数据修改前,先查看内存位置(V)的值是等于预期原值(A),如果等于,说明在修改期间没有别的线程修改数据,如果不等于说明数据被别的线程修改过了,则此次不进行操作。

2.4.2版本号机制(本项目实现的Watch方法就是基于这个方法)
一般在数据表中加上一个数据版本号version字段,表示数据被修改的次数,当数据被修改的时候,version会加一。当某个线程要修改一个数据的时候,先获取这个数据的版本号,再进行提交修改之前,先判断当前这个数据的版本号是否是之前的版本号,若是,则表明线程再执行修改期间,并没有别的线程对该数据进行修改,所以这时候该线程可以对该数据继续修改。如果版本号发生改变,则该线程的修改更新失败。

2.5 CAS有哪些缺点(这也是为什么本项目的Watch不选用CAS实现乐观锁的原因):

2.5.1 ABA问题

假设有两个线程——线程1和线程2,两个线程按照顺序进行以下操作:
(1)线程1读取内存中数据为A;
(2)线程2将该数据修改为B;
(3)线程2将该数据修改为A;
(4)线程1对数据进行CAS操作
在第(4)步中,由于内存中数据仍然为A,因此CAS操作成功,但实际上该数据已经被线程2修改过了。这就是ABA问题。

2.5.2高并发下的开销问题

在并发冲突概率较大的高并发环境中,如果CAS一直失败,就会一直重试,CPU的开销较大。针对这个问题,可以引入退出机制,如果重试的次数超过一定的阈值后,则退出。但是,根本的还是要在冲突多的场景避免使用乐观锁

2.5.3功能受限

CAS的功能比较受限,只能保证单个变量操作的原子性,CAS无法保证多个变量的原子性。

2.6利用版本号机制实现Watch命令

// Watch 监控key
func Watch(db *DB, conn slava.Connection, args [][]byte) slava.Reply {
	watching := conn.GetWatching()
	for _, bkey := range args {
		key := string(bkey)
		watching[key] = db.GetVersion(key)
	}
	return protocol.MakeOkReply()
}

// 判断某个key是否在事务过程中被别的线程修改
func isWatchingChanged(db *DB, watching map[string]uint32) bool {
	for key, val := range watching {
		currentVersion := db.GetVersion(key)
		if currentVersion != val { // 说明修改过
			return true
		}
	}
	// 如果全部的key都没有修改过则返回false
	return false
}

// GetVersion返回某个给定key的版本号,用在watch中,在执行exec操作的时候用来对比监控的key值是否发生改变
func (db *DB) GetVersion(key string) uint32 {
	entity, ok := db.versionMap.Get(key)
	if !ok { // 没取到值
		return 0
	}
	return entity.(uint32)
}

3.实现开启事务、撤销事务以及提交事务

// StartMulti 开启事务
func StartMulti(conn slava.Connection) slava.Reply {
	if conn.InMultiState() {
		return protocol.MakeErrReply("ERR MULTI calls can not be nested") // multi不能嵌套
	}
	conn.SetMultiState(true)
	return protocol.MakeOkReply()
}

// DiscardMulti 撤销事务,删除事务中的命令
func DiscardMulti(conn slava.Connection) slava.Reply {
	if !conn.InMultiState() {
		return protocol.MakeErrReply("ERR DISCARD without MULTI")
	}
	// 清除multi中的命令
	conn.ClearQueuedCmds()
	// 表示退出事务
	conn.SetMultiState(false)
	return protocol.MakeOkReply()
}

// exec提交事务
func execMulti(db *DB, conn slava.Connection) slava.Reply {
	if !conn.InMultiState() {
		return protocol.MakeErrReply("ERR EXEC without Multi")
	}
	defer conn.SetMultiState(false)
	if len(conn.GetTxErrors()) > 0 {
		return protocol.MakeErrReply("EXECABORT Transaction discarded because of previous errors.")
	}
	cmdLines := conn.GetQueuedCmdLine()
	return db.ExecMulti(conn, conn.GetWatching(), cmdLines)
}

// ExecMulti 以原子性和隔离性方式执行多命令事务
func (db *DB) ExecMulti(conn slava.Connection, watching map[string]uint32, cmdLines []Cmdline) slava.Reply {
	// prepare
	writeKeys := make([]string, 0, len(cmdLines)) // 提升性能,预分配内存
	readKeys := make([]string, 0, len(cmdLines))
	for _, cmdLine := range cmdLines {
		cmdName := strings.ToLower(string(cmdLine[0]))
		cmd := cmdTable[cmdName]
		prepare := cmd.prepare
		write, read := prepare(cmdLine[1:])
		writeKeys = append(writeKeys, write...)
		readKeys = append(readKeys, read...)
	}
	// set Watch
	watchingKeys := make([]string, 0, len(watching))
	for key := range watching {
		watchingKeys = append(watchingKeys, key)
	}
	// 将监控的key加入到readKeys的集合中,对writekeys和readkeys进行加锁,他们可能会存在相同的key
	readKeys = append(readKeys, watchingKeys...)
	db.locker.RWLocks(writeKeys, readKeys)
	defer db.locker.RWUnLocks(writeKeys, readKeys)
	// 如果watch keys 有发生改变,则回滚事务,并退出
	if isWatchingChanged(db, watching) { // 说明监控的key有别的线程修改过,则回滚
		return protocol.MakeEmptyMultiBulkReply()
	}
	// 如果监控的key没有被别的线程修改则执行其中的事务
	results := make([]slava.Reply, 0, len(cmdLines)) // 记录所有事务所有指令的返回结果
	aborted := false                                 // 取消事务,开始先默认false
	undoCmdLines := make([][]Cmdline, 0, len(cmdLines))
	for _, cmdLine := range cmdLines {
		undoCmdLines = append(undoCmdLines, db.GetUndoLogs(cmdLine))
		result := db.execWithLock(cmdLine) // 调用分数据库中的执行命令的函数
		if protocol.IsErrorReply(result) {
			aborted = true // 如果事务执行出现错误,则aborted变成true
			// 执行出错的命令,不回滚
			undoCmdLines = undoCmdLines[:len(undoCmdLines)-1]
			break // 只要在事务中有一个命令行出错则停止循环
		}
		// 没有出错则将result加入到结果的数组中
		results = append(results, result)
	}
	// 如果中止标志部位true则返回结果,如果中止标志为true,则要进行回滚
	if !aborted {
		db.addVersion(writeKeys...)
		return protocol.MakeMultiRawReply(results)
	}
	// 如果中止条件aborted为true则要进行回滚
	size := len(undoCmdLines)
	for i := size - 1; i >= 0; i-- {
		curCmdLines := undoCmdLines[i]
		if len(curCmdLines) == 0 {
			continue
		}
		for _, cmdLine := range curCmdLines {
			db.execWithLock(cmdLine)
		}
	}
	return protocol.MakeErrReply("EXECABORT Transaction discarded because of previous errors.")
}
评论 1
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

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

余额充值