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.")
}