Golang 实现 Redis(10): 本地原子性事务
为了支持多个命令的原子性执行 Redis 提供了事务机制。 Redis 官方文档中称事务带有以下两个重要的保证:
- 事务是一个单独的隔离操作:事务中的所有命令都会序列化、按顺序地执行。事务在执行的过程中,不会被其他客户端发送来的命令请求所打断。
- 事务是一个原子操作:事务中的命令要么全部被执行,要么全部都不执行
我们在使用事务的过程中可能会遇到两类错误:
- 在命令入队过程中出现语法错误
- 在命令执行过程中出现运行时错误,比如对 string 类型的 key 进行 lpush 操作
在遇到语法错误时 Redis 会中止命令入队并丢弃事务。在遇到运行时错误时 Redis 仅会报错然后继续执行事务中剩下的命令,不会像大多数数据库那样回滚事务。对此,Redis 官方的解释是:
Redis 命令只会因为错误的语法而失败(并且这些问题不能在入队时发现),或是命令用在了错误类型的键上面:这也就是说,从实用性的角度来说,失败的命令是由编程错误造成的,而这些错误应该在开发的过程中被发现,而不应该出现在生产环境中。
因为不需要对回滚进行支持,所以 Redis 的内部可以保持简单且快速。
有种观点认为 Redis 处理事务的做法会产生 bug , 然而需要注意的是, 在通常情况下, 回滚并不能解决编程错误带来的问题。 举个例子, 如果你本来想通过 INCR 命令将键的值加上 1 , 却不小心加上了 2 , 又或者对错误类型的键执行了 INCR , 回滚是没有办法处理这些情况的。鉴于没有任何机制能避免程序员自己造成的错误, 并且这类错误通常不会在生产环境中出现, 所以 Redis 选择了更简单、更快速的无回滚方式来处理事务。
emmmm, 接下来我们尝试在 Godis 中实现具有原子性、隔离性的事务吧。
事务的原子性具有两个特点:1. 事务执行过程不可被其它事务(线程)插入 2. 事务要么完全成功要么完全不执行,不存在部分成功的状态
事务的隔离性是指事务中操作的结果是否对其它并发事务可见。由于KV数据库不存在幻读问题,因此我们需要避免脏读和不可重复度问题。
事务机制浅析
锁
与 Redis 的单线程引擎不同 godis 的存储引擎是并行的,因此需要设计锁机制来保证执行多条命令执行时的原子性和隔离性。
我们在实现内存数据库一文中提到:
实现一个常规命令需要提供3个函数:
- ExecFunc 是实际执行命令的函数
- PrepareFunc 在 ExecFunc 前执行,负责分析命令行读写了哪些 key 便于进行加锁
- UndoFunc 仅在事务中被使用,负责准备 undo logs 以备事务执行过程中遇到错误需要回滚。
其中的 PrepareFunc 会分析命令行返回要读写的 key, 以 prepareMSet 为例:
// return writtenKeys, readKeys
func prepareMSet(args [][]byte) ([]string, []string) {
	size := len(args) / 2
	keys := make([]string, size)
	for i := 0; i < size; i++ {
		keys[i] = string(args[2*i])
	}
	return keys, nil
}
结合实现内存数据库 中提到的 LockMap 即可完成加锁。由于其它协程无法获得相关 key 的锁所以不可能插入到事务中,所以我们实现了原子性中不可被插入的特性。
事务需要把所有 key 一次性完成加锁, 只有在事务提交或回滚时才能解锁。不能用到一个 key 就加一次锁用完就解锁,这种方法可能导致脏读:
| 时间 | 事务1 | 事务2 | 
|---|---|---|
| t1 | 锁定key A | |
| t2 | 修改key A | |
| t3 | 解锁key A | |
| t4 | 锁定key A | |
| t4 | 读取key A | |
| t5 | 解锁key A | |
| t6 | 提交 | 
如上图所示 t4 时刻, 事务 2 读到了事务 1未提交的数据,出现了脏读异常。
回滚
为了在遇到运行时错误时事务可以回滚(原子性),可用的回滚方式有两种:
- 保存修改前的value, 在回滚时用修改前的value进行覆盖
- 使用回滚命令来撤销原命令的影响。举例来说:键A原值为1,调用了Incr A之后变为了2,我们可以再执行一次Set A 1命令来撤销 incr 命令。
出于节省内存的考虑我们最终选择了第二种方案。比如 HSet 命令只需要另一条 HSet 将 field 改回原值即可,若采用保存 value 的方法我们则需要保存整个 HashMap。类似情况的还有 LPushRPop 等命令。
有一些命令可能需要多条命令来回滚,比如回滚 Del 时不仅需要恢复对应的 key-value 还需要恢复 TTL 数据。或者 Del 命令删除了多个 key 时,也需要多条命令进行回滚。综上我们给出 UndoFunc 的定义:
// UndoFunc returns undo logs for the given command line
// execute from head to tail when undo
type UndoFunc func(db *DB, args [][]byte) []CmdLine
我们以可以回滚任意操作的rollbackGivenKeys为例进行说明,当然使用rollbackGivenKeys的成本较高,在可能的情况下尽量实现针对性的 undo log.
func rollbackGivenKeys(db *DB, keys ...string) []CmdLine {
	var undoCmdLines [][][]byte
	for _, key := range keys {
		entity, ok := db.GetEntity(key)
		if !ok {
			// 原来不存在 key 删掉
			undoCmdLines = append(undoCmdLines,
				utils.ToCmdLine("DEL", key),
			)
		} else {
			undoCmdLines = append(undoCmdLines,
				utils.ToCmdLine("DEL", key), // 先把新 key 删除掉
				aof.EntityToCmd(key, entity).Args, // 把 DataEntity 序列化成命令行
				toTTLCmd(db, key).Args,
			)
		}
	}
	return undoCmdLines
}
接下来看一下 EntityToCmd, 非常简单易懂:
func EntityToCmd(key string, entity *database.DataEntity) *protocol.MultiBulkReply {
	if entity == nil {
		return nil
	}
	var cmd *protocol.MultiBulkReply
	switch val := entity.Data.(type) {
	case []byte:
		cmd = stringToCmd(key, val)
	case *List.LinkedList:
		cmd = listToCmd(key, val)
	case *set.Set:
		cmd = setToCmd(key, val)
	case dict.Dict:
		cmd = hashToCmd(key, val)
	case *SortedSet.SortedSet:
		cmd = zSetToCmd(key, val)
	}
	return cmd
}
var hMSetCmd = []byte("HMSET")
func hashToCmd(key string, hash dict.Dict) *protocol.MultiBulkReply {
	args := make([][]byte, 2+hash.Len()*2)
	args[0] = hMSetCmd
	args[1] = []byte(key)
	i := 0
	hash.ForEach(func(field string, val interface{}) bool {
		bytes, _ := val.([]byte)
		args[2+i*2] = []byte(field)
		args[3+i*2] = bytes
		i++
		return true
	})
	return protocol.MakeMultiBulkReply(args)
}
Watch
Redis Watch 命令用于监视一个(或多个) key ,如果在事务执行之前这个(或这些) key 被其他命令所改动,那么事务将被放弃。
实现 Watch 命令的核心是发现 key 是否被改动,我们使用简单可靠的版本号方案:为每个 key 存储一个版本号,版本号变化说明 key 被修改了:
// database/single_db.go
func (db *DB) GetVersion(key string) uint32 {
	entity, ok := db.versionMap.Get(key)
	if !ok {
		return 0
	}
	return entity.(uint32)
}
// database/transaciton.go
func Watch(db *DB, conn redis.Connection, args [][]byte) redis.Reply {
	watching := conn.GetWatching()
	for _, bkey := range args {
		key := string(bkey)
		watching[key] = db.GetVersion(key) // 将当前版本号存在 conn 对象中
	}
	return protocol.MakeOkReply()
}
在执行事务前比较版本号:
// database/transaciton.go
func isWatchingChanged(db *DB, watching map[string]uint32) bool {
	for key, ver := range watching {
		currentVersion := db.GetVersion(key)
		if ver != currentVersion {
			return true
		}
	}
	return false
}
源码导读
在了解事务相关机制后,我们可以来看一下事务执行的核心代码ExecMulti
func (db *DB) ExecMulti(conn redis.Connection, watching map[string]uint32, cmdLines []CmdLine) redis.Reply {
	// 准备阶段
	// 使用 prepareFunc 获取事务要读写的 key
	writeKeys := make([]string, 0) // may contains duplicate
	readKeys := make([]string, 0)
	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...)
	}
	watchingKeys := make([]string, 0, len(watching))
	for key := range watching {
		watchingKeys = append(watchingKeys, key)
	}
	readKeys = append(readKeys, watchingKeys...)
	// 将要读写的 key 和被 watch 的 key 一起加锁
	db.RWLocks(writeKeys, readKeys)
	defer db.RWUnLocks(writeKeys, readKeys)
	// 检查被 watch 的 key 是否发生了改变
	if isWatchingChanged(db, watching) { // watching keys changed, abort
		return protocol.MakeEmptyMultiBulkReply()
	}
	// 执行阶段
	results := make([]redis.Reply, 0, len(cmdLines))
	aborted := false
	undoCmdLines := make([][]CmdLine, 0, len(cmdLines))
	for _, cmdLine := range cmdLines {
		// 在命令执行前再准备 undo log, 这样才能保证例如用 decr 回滚 incr 命令的实现可以正常工作
		undoCmdLines = append(undoCmdLines, db.GetUndoLogs(cmdLine))
		result := db.execWithLock(cmdLine)
		if protocol.IsErrorReply(result) {
			aborted = true
			// don't rollback failed commands
			undoCmdLines = undoCmdLines[:len(undoCmdLines)-1]
			break
		}
		results = append(results, result)
	}
	// 执行成功
	if !aborted {
		db.addVersion(writeKeys...)
		return protocol.MakeMultiRawReply(results)
	}
	// 事务失败进行回滚
	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.")
}
Golang 实现 Redis(10): 本地原子性事务的更多相关文章
- Golang 实现 Redis(8): TCC分布式事务
		本文是使用 golang 实现 redis 系列的第八篇, 将介绍如何在分布式缓存中使用 Try-Commit-Catch 方式来解决分布式一致性问题. godis 集群的源码在Github:Godi ... 
- go语言之行--golang操作redis、mysql大全
		一.redis 简介 redis(REmote DIctionary Server)是一个由Salvatore Sanfilippo写key-value存储系统,它由C语言编写.遵守BSD协议.支持网 ... 
- python实现redis三种cas事务操作
		cas全称是compare and set,是一种典型的事务操作. 简单的说,事务就是为了存取数据库中同一数据时不破坏操作的隔离性和原子性,从而保证数据的一致性. 一般数据库,比如MySql是如何保证 ... 
- [深入学习Redis]RedisAPI的原子性分析
		在学习Redis的常用操作时,经常看到介绍说,Redis的set.get以及hset等等命令的执行都是原子性的,但是令自己百思不得其解的是,为什么这些操作是原子性的? 原子性 原子性是数据库的事务中的 ... 
- Redis API的原子性分析
		在学习Redis的常用操作时,经常看到介绍说,Redis的set.get以及hset等等命令的执行都是原子性的,但是令自己百思不得其解的是,为什么这些操作是原子性的? 原子性 原子性是数据库的事务中的 ... 
- redis 有没有ACID事务
		看redis官网的介绍: redis确实是有事务的,但是和传统的ACID是否相同呢? 原子性(Atomicity) 原子性是指事务是一个不可分割的工作单位,事务中的操作要么都发生,要么都不发生. 一致 ... 
- Redis(五)事务
		Redis(五)事务 引用wiki中关于事务处理的定义: Transaction processing is information processing in computer science th ... 
- Golang 实现 Redis(4): AOF 持久化与AOF重写
		本文是使用 golang 实现 redis 系列的第四篇文章,将介绍如何使用 golang 实现 Append Only File 持久化及 AOF 文件重写. 本文完整源代码在作者GithubHDT ... 
- 基于gin的golang web开发:使用数据库事务
		在前文介绍访问数据库时介绍了github.com/jmoiron/sqlx包,本文基于这个包使用数据库事务. defer 在使用数据库事务之前,首先需要了解go语言的defer关键字.defer是go ... 
随机推荐
- SQL语句分为哪几种?
			SQL语句主要可以划分为以下几类: DDL(Data Definition Language):数据定义语言,定义对数据库对象(库.表.列.索引)的操作. 包括:CREATE.DROP.ALTER.R ... 
- Dubbo Monitor 实现原理?
			Consumer 端在发起调用之前会先走 filter 链:provider 端在接收到请求时也是 先走 filter 链,然后才进行真正的业务逻辑处理. 默认情况下,在 consumer 和 pro ... 
- SpringMvc中函数的返回值是什么?
			返回值可以有很多类型,有String, ModelAndView.ModelAndView类把视图和数据都合并的一起的,但一般用String比较好. 
- jQuery--筛选【查找函数】
			查找函数介绍 <A> <B> <C></C> <D></D> <E></E> <F>< ... 
- 面试问题之数据结构与算法:map与unordered_map
			转载于:https://blog.csdn.net/u011475134/article/details/75810085 map map是STL的一个关联容器,它提供一对一数据处理能力.map内部自 ... 
- 说说for循环的两种写法
			for循环 执行多次,条件写在()里,语法形式: 1 2 3 for(计数器变量;条件;计数器增减){ // 将要执行的代码 } 示例: 1 2 3 for (int i = 0; i < 5; ... 
- kafka producer的batch.size和linger.ms
			1.问题 batch.size和linger.ms是对kafka producer性能影响比较大的两个参数.batch.size是producer批量发送的基本单位,默认是16384Bytes,即16 ... 
- springboot项目中的日志输出
			#修改默认输出级别,trace < debug < info < warn < errorlogging.level.com.lagou=trace#控制台输出logging. ... 
- 聊一聊Java8 Optional,让你的代码更加优雅
			码农在囧途 随着时间的推移,曾经我们觉得重要的东西,可能在今天看来是如此的浅薄和无知,同理,今天我们放不下,想不开,觉得重要的东西,多年后我们可能也会觉得也就那样,所以,今天的的所有烦恼,忧愁,想不开 ... 
- Linux 0.11源码阅读笔记-高速缓冲
			高速缓冲 概念 高速缓冲区是内存中的一块内存,在块设备与内核其它程序之间起着一个桥梁作用.内核程序如果需要访问块设备中的数据,都需要经过高速缓冲区来间接的操作. 高速缓冲区结构 高速缓冲区被划分为1k ... 
