<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Golang实现Redis本地原子性事务</title>
<link href="https://fonts.googleapis.com/icon?family=Material+Icons" rel="stylesheet">
<link href="https://fonts.googleapis.com/css2?family=Fira+Code:wght@400;500&family=Noto+Sans+SC:wght@400;500;700&display=swap" rel="stylesheet">
<style>
/* 独立命名空间样式 */
.redis-transaction-poster {
font-family: 'Noto Sans SC', sans-serif;
line-height: 1.6;
color: #333;
max-width: 720px;
min-height: 960px;
margin: 0 auto;
padding: 40px 20px;
background-color: #f8f9fa;
overflow-y: auto;
box-sizing: border-box;
}
.redis-transaction-poster h1 {
font-size: 32px;
color: #1a73e8;
margin-bottom: 20px;
padding-bottom: 10px;
border-bottom: 2px solid #1a73e8;
}
.redis-transaction-poster h2 {
font-size: 24px;
color: #1a73e8;
margin-top: 30px;
margin-bottom: 15px;
}
.redis-transaction-poster h3 {
font-size: 20px;
color: #1a73e8;
margin-top: 25px;
margin-bottom: 10px;
}
.redis-transaction-poster p {
margin-bottom: 15px;
font-size: 16px;
}
.redis-transaction-poster ul, .redis-transaction-poster ol {
margin-bottom: 15px;
padding-left: 20px;
}
.redis-transaction-poster li {
margin-bottom: 8px;
font-size: 16px;
}
.redis-transaction-poster code {
font-family: 'Fira Code', monospace;
background-color: #e8f0fe;
padding: 2px 4px;
border-radius: 3px;
font-size: 14px;
}
.redis-transaction-poster pre {
background-color: #f1f3f4;
padding: 15px;
border-radius: 5px;
overflow-x: auto;
margin-bottom: 20px;
}
.redis-transaction-poster pre code {
background-color: transparent;
padding: 0;
}
.redis-transaction-poster .code-block {
position: relative;
margin-bottom: 20px;
}
.redis-transaction-poster .code-lang {
position: absolute;
top: 0;
right: 0;
background-color: #1a73e8;
color: white;
padding: 2px 8px;
border-radius: 0 0 0 5px;
font-size: 12px;
}
.redis-transaction-poster .highlight-box {
background-color: #e8f0fe;
border-left: 4px solid #1a73e8;
padding: 15px;
margin: 20px 0;
border-radius: 0 5px 5px 0;
}
.redis-transaction-poster .note {
font-style: italic;
color: #5f6368;
margin-top: 5px;
}
.redis-transaction-poster .material-icons {
vertical-align: middle;
margin-right: 5px;
color: #1a73e8;
}
</style>
</head>
<body>
<div class="redis-transaction-poster">
<h1>Golang实现Redis本地原子性事务</h1>
<h2><i class="material-icons">security</i>Redis事务的两个重要保证</h2>
<p>为了支持多个命令的原子性执行,Redis提供了事务机制。Redis官方文档中称事务带有以下两个重要的保证:</p>
<div class="highlight-box">
<p><strong>事务是一个单独的隔离操作</strong>:事务中的所有命令都会序列化、按顺序地执行。事务在执行的过程中,不会被其他客户端发送来的命令请求所打断。</p>
</div>
<div class="highlight-box">
<p><strong>事务是一个原子操作</strong>:事务中的命令要么全部被执行,要么全部都不执行。</p>
</div>
<h2><i class="material-icons">error_outline</i>Redis事务中的错误处理</h2>
<p>我们在使用事务的过程中可能会遇到两类错误:</p>
<ol>
<li><strong>在命令入队过程中出现语法错误</strong></li>
<li><strong>在命令执行过程中出现运行时错误</strong>,比如对string类型的key进行lpush操作</li>
</ol>
<p>在遇到语法错误时,Redis会中止命令入队并丢弃事务。在遇到运行时错误时,Redis仅会报错然后继续执行事务中剩下的命令,不会像大多数数据库那样回滚事务。</p>
<div class="highlight-box">
<p>对此,Redis官方的解释是:</p>
<p>Redis命令只会因为错误的语法而失败(并且这些问题不能在入队时发现),或是命令用在了错误类型的键上面:这也就是说,从实用性的角度来说,失败的命令是由编程错误造成的,而这些错误应该在开发的过程中被发现,而不应该出现在生产环境中。</p>
<p>因为不需要对回滚进行支持,所以Redis的内部可以保持简单且快速。</p>
</div>
<p>有种观点认为Redis处理事务的做法会产生bug,然而需要注意的是,在通常情况下,回滚并不能解决编程错误带来的问题。举个例子,如果你本来想通过INCR命令将键的值加上1,却不小心加上了2,又或者对错误类型的键执行了INCR,回滚是没有办法处理这些情况的。鉴于没有任何机制能避免程序员自己造成的错误,并且这类错误通常不会在生产环境中出现,所以Redis选择了更简单、更快速的无回滚方式来处理事务。</p>
<h2><i class="material-icons">build</i>在Godis中实现事务的考虑</h2>
<p>接下来我们尝试在Godis中实现具有原子性、隔离性的事务。</p>
<h3>事务的特性</h3>
<p>事务的原子性具有两个特点:</p>
<ol>
<li>事务执行过程不可被其它事务(线程)插入</li>
<li>事务要么完全成功要么完全不执行,不存在部分成功的状态</li>
</ol>
<p>事务的隔离性是指事务中操作的结果是否对其它并发事务可见。由于KV数据库不存在幻读问题,因此我们需要避免脏读和不可重复度问题。</p>
<h3>锁机制设计</h3>
<p>与Redis的单线程引擎不同,godis的存储引擎是并行的,因此需要设计锁机制来保证执行多条命令执行时的原子性和隔离性。</p>
<p>实现一个常规命令需要提供3个函数:</p>
<ul>
<li><strong>ExecFunc</strong>:是实际执行命令的函数</li>
<li><strong>PrepareFunc</strong>:在ExecFunc前执行,负责分析命令行读写了哪些key便于进行加锁</li>
<li><strong>UndoFunc</strong>:仅在事务中被使用,负责准备undo logs以备事务执行过程中遇到错误需要回滚</li>
</ul>
<p>其中的PrepareFunc会分析命令行返回要读写的key,以prepareMSet为例:</p>
<div class="code-block">
<div class="code-lang">go</div>
<pre><code>// 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
}</code></pre>
</div>
<p>结合LockMap即可完成加锁。由于其它协程无法获得相关key的锁所以不可能插入到事务中,所以我们实现了原子性中不可被插入的特性。</p>
<div class="highlight-box">
<p><strong>重要</strong>:事务需要把所有key一次性完成加锁,只有在事务提交或回滚时才能解锁。不能用到一个key就加一次锁用完就解锁,这种方法可能导致脏读。</p>
</div>
<p>例如,以下场景会导致脏读:</p>
<table style="width: 100%; border-collapse: collapse; margin-bottom: 20px;">
<tr style="background-color: #e8f0fe;">
<th style="border: 1px solid #ddd; padding: 8px;">时间</th>
<th style="border: 1px solid #ddd; padding: 8px;">事务1</th>
<th style="border: 1px solid #ddd; padding: 8px;">事务2</th>
</tr>
<tr>
<td style="border: 1px solid #ddd; padding: 8px;">t1</td>
<td style="border: 1px solid #ddd; padding: 8px;">锁定key A</td>
<td style="border: 1px solid #ddd; padding: 8px;"></td>
</tr>
<tr>
<td style="border: 1px solid #ddd; padding: 8px;">t2</td>
<td style="border: 1px solid #ddd; padding: 8px;">修改key A</td>
<td style="border: 1px solid #ddd; padding: 8px;"></td>
</tr>
<tr>
<td style="border: 1px solid #ddd; padding: 8px;">t3</td>
<td style="border: 1px solid #ddd; padding: 8px;">解锁key A</td>
<td style="border: 1px solid #ddd; padding: 8px;"></td>
</tr>
<tr>
<td style="border: 1px solid #ddd; padding: 8px;">t4</td>
<td style="border: 1px solid #ddd; padding: 8px;"></td>
<td style="border: 1px solid #ddd; padding: 8px;">锁定key A</td>
</tr>
<tr>
<td style="border: 1px solid #ddd; padding: 8px;">t5</td>
<td style="border: 1px solid #ddd; padding: 8px;"></td>
<td style="border: 1px solid #ddd; padding: 8px;">读取key A</td>
</tr>
<tr>
<td style="border: 1px solid #ddd; padding: 8px;">t6</td>
<td style="border: 1px solid #ddd; padding: 8px;">提交</td>
<td style="border: 1px solid #ddd; padding: 8px;">解锁key A</td>
</tr>
</table>
<p>如上图所示,t4时刻,事务2读到了事务1未提交的数据,出现了脏读异常。</p>
<h2><i class="material-icons">undo</i>回滚机制的实现</h2>
<p>为了在遇到运行时错误时事务可以回滚(原子性),可用的回滚方式有两种:</p>
<ol>
<li>保存修改前的value,在回滚时用修改前的value进行覆盖</li>
<li>使用回滚命令来撤销原命令的影响。举例来说:键A原值为1,调用了Incr A之后变为了2,我们可以再执行一次Set A 1命令来撤销incr命令</li>
</ol>
<p>出于节省内存的考虑,我们最终选择了第二种方案。比如HSet命令只需要另一条HSet将field改回原值即可,若采用保存value的方法我们则需要保存整个HashMap。类似情况的还有LPushRPop等命令。</p>
<p>有一些命令可能需要多条命令来回滚,比如回滚Del时不仅需要恢复对应的key-value还需要恢复TTL数据。或者Del命令删除了多个key时,也需要多条命令进行回滚。综上我们给出UndoFunc的定义:</p>
<div class="code-block">
<div class="code-lang">go</div>
<pre><code>// UndoFunc returns undo logs for the given command line
// execute from head to tail when undo
type UndoFunc func(db *DB, args [][]byte) []CmdLine</code></pre>
</div>
<p>我们以可以回滚任意操作的rollbackGivenKeys为例进行说明,当然使用rollbackGivenKeys的成本较高,在可能的情况下尽量实现针对性的undo log。</p>
<div class="code-block">
<div class="code-lang">go</div>
<pre><code>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
}</code></pre>
</div>
<p>接下来看一下EntityToCmd,非常简单易懂:</p>
<div class="code-block">
<div class="code-lang">go</div>
<pre><code>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
}</code></pre>
</div>
<div class="code-block">
<div class="code-lang">go</div>
<pre><code>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)
}</code></pre>
</div>
<h2><i class="material-icons">visibility</i>Redis Watch命令的实现</h2>
<p>Redis Watch命令用于监视一个(或多个)key,如果在事务执行之前这个(或这些)key被其他命令所改动,那么事务将被放弃。</p>
<p>实现Watch命令的核心是发现key是否被改动,我们使用简单可靠的版本号方案:为每个key存储一个版本号,版本号变化说明key被修改了。</p>
<div class="highlight-box">
<p><strong>设计思想</strong>:通过版本号机制,我们可以在事务执行前检查被监视的key是否被修改,从而决定是否继续执行事务。这种机制简单高效,适合于Redis这种高性能的键值存储系统。</p>
</div>
<h3>总结</h3>
<p>在Golang中实现Redis的本地原子性事务,需要考虑以下几个关键点:</p>
<ol>
<li><strong>锁机制</strong>:由于godis的存储引擎是并行的,需要设计锁机制来保证执行多条命令执行时的原子性和隔离性</li>
<li><strong>回滚机制</strong>:使用回滚命令来撤销原命令的影响,而不是保存修改前的value,以节省内存</li>
<li><strong>Watch命令</strong>:通过版本号机制实现,用于监视key是否被修改</li>
</ol>
<p>通过以上机制,我们可以在Golang中实现具有原子性、隔离性的Redis事务,保证数据的一致性和完整性。</p>
</div>
</body>
</html>
登录后可参与表态
讨论回复
1 条回复
✨步子哥 (steper)
#1
09-17 08:06
登录后可参与表态