Golang 实现 Redis(8): TCC分布式事务
本文是使用 golang 实现 redis 系列的第八篇, 将介绍如何在分布式缓存中使用 Try-Commit-Catch 方式来解决分布式一致性问题。
godis 集群的源码在Github:Godis/cluster
在上一篇文章中我们使用一致性 hash 算法将缓存中的 key 分散到不同的服务器节点中,从而实现了分布式缓存。随之而来的问题是:一条指令(比如 MSET)可能需要多个节点同时执行,可能有些节点成功而另一部分节点失败。
对于使用者而言这种部分成功部分失败的情况非常难以处理,所以我们需要保证 MSET 操作要么全部成功要么全部失败。
MSET 命令在集群模式下的问题
于是问题来了 DEL、MSET 等命令所涉及的 key 可能分布在不同的节点中,在集群模式下实现这类涉及多个 key 的命令最简单的方式当然是 For-Each 遍历 key 并向它们所在的节点发送相应的操作指令。 以 MGET 命令的实现为例:
func MGet(cluster *Cluster, c redis.Connection, args [][]byte) redis.Reply {
if len(args) < 2 {
return reply.MakeErrReply("ERR wrong number of arguments for 'mget' command")
}
// 从参数列表中取出要读取的 key
keys := make([]string, len(args)-1)
for i := 1; i < len(args); i++ {
keys[i-1] = string(args[i])
}
resultMap := make(map[string][]byte)
// 计算每个 key 所在的节点,并按照节点分组
groupMap := cluster.groupBy(keys)
// groupMap 的类型为 map[string][]string,key 是节点的地址,value 是 keys 中属于该节点的 key 列表
for peer, group := range groupMap {
// 向每个节点发送 mget 指令,读取分布在它上面的 key
resp := cluster.Relay(peer, c, makeArgs("MGET", group...))
if reply.IsErrorReply(resp) {
errReply := resp.(reply.ErrorReply)
return reply.MakeErrReply(fmt.Sprintf("ERR during get %s occurs: %v", group[0], errReply.Error()))
}
arrReply, _ := resp.(*reply.MultiBulkReply)
// 将每个节点上的结果 merge 到 map 中
for i, v := range arrReply.Args {
key := group[i]
resultMap[key] = v
}
}
result := make([][]byte, len(keys))
for i, k := range keys {
result[i] = resultMap[k]
}
return reply.MakeMultiBulkReply(result)
}
// 计算 key 所属的节点,并按节点分组
func (cluster *Cluster) groupBy(keys []string) map[string][]string {
result := make(map[string][]string)
for _, key := range keys {
// 使用一致性 hash 计算所属节点
peer := cluster.peerPicker.Get(key)
// 将 key 加入到相应节点的分组中
group, ok := result[peer]
if !ok {
group = make([]string, 0)
}
group = append(group, key)
result[peer] = group
}
return result
}
那么 MSET 命令的实现能否如法炮制呢?答案是否定的。在上面的代码中我们注意到,在向各个节点发送指令时若某个节点读取失败则会直接退出整个 MGET 执行过程。
若在执行 MSET 指令时遇到部分节点失败或超时,则会出现部分 key 设置成功而另一份设置失败的情况。对于缓存使用者而言这种部分成功部分失败的情况非常难以处理,所以我们需要保证 MSET 操作要么全部成功要么全部失败。
两阶段提交
两阶段提交(2-Phase Commit, 2PC)算法是解决我们遇到的一致性问题最简单的算法。在 2PC 算法中写操作被分为两个阶段来执行:
- Prepare 阶段
- 协调者向所有参与者发送事务内容,询问是否可以执行事务操作。在 Godis 中收到客户端 MSET 命令的节点是事务的协调者,所有持有相关 key 的节点都要参与事务。
- 各参与者锁定事务相关 key 防止被其它操作修改。各参与者写 undo log 准备在事务失败后进行回滚。
- 参与者回复协调者可以提交。若协调者收到所有参与者的YES回复,则准备进行事务提交。若有参与者回复NO或者超时,则准备回滚事务
- Commit 阶段
- 协调者向所有参与者发送提交请求
- 参与者正式提交事务,并在完成后释放相关 key 的锁。
- 参与者协调者回复ACK,协调者收到所有参与者的ACK后认为事务提交成功。
- Rollback 阶段
- 在事务请求阶段若有参与者回复NO或者超时,协调者向所有参与者发出回滚请求
- 各参与者执行事务回滚,并在完成后释放相关资源。
- 参与者协调者回复ACK,协调者收到所有参与者的ACK后认为事务回滚成功。
2PC是一种简单的一致性协议,它存在一些问题:
- 单点服务: 若协调者突然崩溃则事务流程无法继续进行或者造成状态不一致
- 无法保证一致性: 若协调者第二阶段发送提交请求时崩溃,可能部分参与者受到COMMIT请求提交了事务,而另一部分参与者未受到请求而放弃事务造成不一致现象。
- 阻塞: 为了保证事务完成提交,各参与者在完成第一阶段事务执行后必须锁定相关资源直到正式提交,影响系统的吞吐量。
首先我们定义事务的描述结构:
type Transaction struct {
id string // 事务 ID, 由 snowflake 算法生成
args [][]byte // 命令参数
cluster *Cluster
conn redis.Connection
keys []string // 事务中涉及的 key
undoLog map[string][]byte // 每个 key 在事务执行前的值,用于回滚事务
}
Prepare 阶段
先看事务参与者 prepare 阶段的操作:
// prepare 命令的格式是: PrepareMSet TxID key1, key2 ...
// TxID 是事务 ID,由协调者决定
func PrepareMSet(cluster *Cluster, c redis.Connection, args [][]byte) redis.Reply {
if len(args) < 3 {
return reply.MakeErrReply("ERR wrong number of arguments for 'preparemset' command")
}
txId := string(args[1])
size := (len(args) - 2) / 2
keys := make([]string, size)
for i := 0; i < size; i++ {
keys[i] = string(args[2*i+2])
}
txArgs := [][]byte{
[]byte("MSet"),
} // actual args for cluster.db
txArgs = append(txArgs, args[2:]...)
tx := NewTransaction(cluster, c, txId, txArgs, keys) // 创建新事务
cluster.transactions.Put(txId, tx) // 存储到节点的事务列表中
err := tx.prepare() // 准备事务
if err != nil {
return reply.MakeErrReply(err.Error())
}
return &reply.OkReply{}
}
实际的准备操作在 tx.prepare() 中:
func (tx *Transaction) prepare() error {
// 锁定相关 key
tx.cluster.db.Locks(tx.keys...)
// 准备 undo log
tx.undoLog = make(map[string][]byte)
for _, key := range tx.keys {
entity, ok := tx.cluster.db.Get(key)
if ok {
blob, err := gob.Marshal(entity) // 将修改之前的状态序列化之后存储作为 undo log
if err != nil {
return err
}
tx.undoLog[key] = blob
} else {
// 若事务执行前 key 是空的,在回滚时应删除它
tx.undoLog[key] = []byte{}
}
}
tx.status = PreparedStatus
return nil
}
看看协调者在做什么:
func MSet(cluster *Cluster, c redis.Connection, args [][]byte) redis.Reply {
// 解析参数
argCount := len(args) - 1
if argCount%2 != 0 || argCount < 1 {
return reply.MakeErrReply("ERR wrong number of arguments for 'mset' command")
}
size := argCount / 2
keys := make([]string, size)
valueMap := make(map[string]string)
for i := 0; i < size; i++ {
keys[i] = string(args[2*i+1])
valueMap[keys[i]] = string(args[2*i+2])
}
// 找到所属的节点
groupMap := cluster.groupBy(keys)
if len(groupMap) == 1 { // do fast
// 若所有的 key 都在同一个节点直接执行,不使用较慢的 2pc 算法
for peer := range groupMap {
return cluster.Relay(peer, c, args)
}
}
// 开始准备阶段
var errReply redis.Reply
txId := cluster.idGenerator.NextId() // 使用 snowflake 算法决定事务 ID
txIdStr := strconv.FormatInt(txId, 10)
rollback := false
// 向所有参与者发送 prepare 请求
for peer, group := range groupMap {
peerArgs := []string{txIdStr}
for _, k := range group {
peerArgs = append(peerArgs, k, valueMap[k])
}
var resp redis.Reply
if peer == cluster.self {
resp = PrepareMSet(cluster, c, makeArgs("PrepareMSet", peerArgs...))
} else {
resp = cluster.Relay(peer, c, makeArgs("PrepareMSet", peerArgs...))
}
if reply.IsErrorReply(resp) {
errReply = resp
rollback = true
break
}
}
if rollback {
// 若 prepare 过程出错则执行回滚
RequestRollback(cluster, c, txId, groupMap)
} else {
_, errReply = RequestCommit(cluster, c, txId, groupMap)
rollback = errReply != nil
}
if !rollback {
return &reply.OkReply{}
}
return errReply
}
Commit 阶段
事务参与者提交本地事务:
func Commit(cluster *Cluster, c redis.Connection, args [][]byte) redis.Reply {
if len(args) != 2 {
return reply.MakeErrReply("ERR wrong number of arguments for 'commit' command")
}
// 读取事务信息
txId := string(args[1])
raw, ok := cluster.transactions.Get(txId)
if !ok {
return reply.MakeIntReply(0)
}
tx, _ := raw.(*Transaction)
// 在提交成功后解锁 key
defer func() {
cluster.db.UnLocks(tx.keys...)
tx.status = CommitedStatus
//cluster.transactions.Remove(tx.id) // cannot remove, may rollback after commit
}()
cmd := strings.ToLower(string(tx.args[0]))
var result redis.Reply
if cmd == "del" {
result = CommitDel(cluster, c, tx)
} else if cmd == "mset" {
result = CommitMSet(cluster, c, tx)
}
// 提交失败
if reply.IsErrorReply(result) {
err2 := tx.rollback()
return reply.MakeErrReply(fmt.Sprintf("err occurs when rollback: %v, origin err: %s", err2, result))
}
return result
}
// 执行操作
func CommitMSet(cluster *Cluster, c redis.Connection, tx *Transaction) redis.Reply {
size := len(tx.args) / 2
keys := make([]string, size)
values := make([][]byte, size)
for i := 0; i < size; i++ {
keys[i] = string(tx.args[2*i+1])
values[i] = tx.args[2*i+2]
}
for i, key := range keys {
value := values[i]
cluster.db.Put(key, &db.DataEntity{Data: value})
}
cluster.db.AddAof(reply.MakeMultiBulkReply(tx.args))
return &reply.OkReply{}
}
协调者的逻辑也很简单:
func RequestCommit(cluster *Cluster, c redis.Connection, txId int64, peers map[string][]string) ([]redis.Reply, reply.ErrorReply) {
var errReply reply.ErrorReply
txIdStr := strconv.FormatInt(txId, 10)
respList := make([]redis.Reply, 0, len(peers))
for peer := range peers {
var resp redis.Reply
if peer == cluster.self {
resp = Commit(cluster, c, makeArgs("commit", txIdStr))
} else {
resp = cluster.Relay(peer, c, makeArgs("commit", txIdStr))
}
if reply.IsErrorReply(resp) {
errReply = resp.(reply.ErrorReply)
break
}
respList = append(respList, resp)
}
if errReply != nil {
RequestRollback(cluster, c, txId, peers)
return nil, errReply
}
return respList, nil
}
Rollback
回滚本地事务:
func Rollback(cluster *Cluster, c redis.Connection, args [][]byte) redis.Reply {
if len(args) != 2 {
return reply.MakeErrReply("ERR wrong number of arguments for 'rollback' command")
}
txId := string(args[1])
raw, ok := cluster.transactions.Get(txId)
if !ok {
return reply.MakeIntReply(0)
}
tx, _ := raw.(*Transaction)
err := tx.rollback()
if err != nil {
return reply.MakeErrReply(err.Error())
}
return reply.MakeIntReply(1)
}
func (tx *Transaction) rollback() error {
for key, blob := range tx.undoLog {
if len(blob) > 0 {
entity := &db.DataEntity{}
err := gob.UnMarshal(blob, entity) // 反序列化事务前的快照
if err != nil {
return err
}
tx.cluster.db.Put(key, entity) // 写入事务前的数据
} else {
tx.cluster.db.Remove(key) // 若事务开始之前 key 不存在则将其删除
}
}
if tx.status != CommitedStatus {
tx.cluster.db.UnLocks(tx.keys...)
}
tx.status = RollbackedStatus
return nil
}
协调者的逻辑与 commit 类似:
func RequestRollback(cluster *Cluster, c redis.Connection, txId int64, peers map[string][]string) {
txIdStr := strconv.FormatInt(txId, 10)
for peer := range peers {
if peer == cluster.self {
Rollback(cluster, c, makeArgs("rollback", txIdStr))
} else {
cluster.Relay(peer, c, makeArgs("rollback", txIdStr))
}
}
}
Golang 实现 Redis(8): TCC分布式事务的更多相关文章
- 终于有人把“TCC分布式事务”实现原理讲明白了!
之前网上看到很多写分布式事务的文章,不过大多都是将分布式事务各种技术方案简单介绍一下.很多朋友看了还是不知道分布式事务到底怎么回事,在项目里到底如何使用. 所以这篇文章,就用大白话+手工绘图,并结合一 ...
- TCC分布式事务的实现原理(转载 石杉的架构笔记)
拜托,面试请不要再问我TCC分布式事务的实现原理![石杉的架构笔记] 原创: 中华石杉 目录 一.写在前面 二.业务场景介绍 三.进一步思考 四.落地实现TCC分布式事务 (1)TCC实现阶段一:Tr ...
- 拜托,面试请不要再问我TCC分布式事务的实现原理!(转)
一.写在前面 之前网上看到很多写分布式事务的文章,不过大多都是将分布式事务各种技术方案简单介绍一下.很多朋友看了不少文章,还是不知道分布式事务到底怎么回事,在项目里到底如何使用. 所以咱们这篇文章,就 ...
- 【转载】终于有人把“TCC分布式事务”的实现原理讲明白了
之前网上看到很多写分布式事务的文章,不过大多都是将分布式事务各种技术方案简单介绍一下.很多朋友看了还是不知道分布式事务到底怎么回事,在项目里到底如何使用. 所以这篇文章,就用大白话+手工绘图,并结合一 ...
- 终于有人把“TCC分布式事务”实现原理讲明白了
所以这篇文章,就用大白话+手工绘图,并结合一个电商系统的案例实践,来给大家讲清楚到底什么是 TCC 分布式事务. 首先说一下,这里可能会牵扯到一些 Spring Cloud 的原理,如果有不太清楚的同 ...
- TCC分布式事务的实现原理
目录 一.写在前面 二.业务场景介绍 三.进一步思考 四.落地实现TCC分布式事务 (1)TCC实现阶段一:Try (2)TCC实现阶段二:Confirm (3)TCC实现阶段三:Cancel 五.总 ...
- tcc分布式事务框架解析
前言碎语 楼主之前推荐过2pc的分布式事务框架LCN.今天来详细聊聊TCC事务协议. 2pc实现:https://github.com/codingapi/tx-lcn tcc实现:https://g ...
- 聊一聊如何用C#轻松完成一个TCC分布式事务
背景 银行跨行转账业务是一个典型分布式事务场景,假设 A 需要跨行转账给 B,那么就涉及两个银行的数据,无法通过一个数据库的本地事务保证转账的 ACID ,只能够通过分布式事务来解决. 在 聊一聊如何 ...
- Golang 实现 Redis(10): 本地原子性事务
为了支持多个命令的原子性执行 Redis 提供了事务机制. Redis 官方文档中称事务带有以下两个重要的保证: 事务是一个单独的隔离操作:事务中的所有命令都会序列化.按顺序地执行.事务在执行的过程中 ...
随机推荐
- 关于python递归函数,这样写就对了
大家好我是致力于让每个人都能够轻松学会编程的小梁,在这条路上任重道远,关注我,每天让您获取来自编程的乐趣. 关注公众号"轻松学编程".了解更多. 今天就给大家分享一下关于使用递归函 ...
- [Luogu P4777] 【模板】扩展中国剩余定理(EXCRT) (扩展中国剩余定理)
题面 传送门:洛咕 Solution 真*扩展中国剩余定理模板题.我怎么老是在做模板题啊 但是这题与之前不同的是不得不写龟速乘了. 还有两个重点 我们在求LCM的时候,记得先/gcd再去乘另外那个数, ...
- 解决pl/sql developer中数据库插入数据乱码问题
最近学习SSM项目开发,用到oracle数据库, 使用管理软件PL/sql developer往数据库表中插入数据时记录乱码. 结果如下: 可以看到中文数据都乱码成了???????问号, 看了网上各 ...
- Dreamweaver是怎么把图片转换成代码 简单五步骤即可解决
Dreamweaver图片转换代码图文介绍 1.打开需要转换的Photoshop作品: 2.保存为web格式,得到一个文件夹和一个html格式文件: 3.在html格式文件上单击右键,选择打开方式为D ...
- HTML生成横向的PDF
HTML生成PDF请参照:https://www.cnblogs.com/yunfeiyang-88/p/10984740.html 如要生成横向的PDF:在html模板的style标签里面加入@pa ...
- CCF-201512-消除类游戏
问题描述 试题编号: 201512-2 试题名称: 消除类游戏 时间限制: 1.0s 内存限制: 256.0MB 问题描述: 问题描述 消除类游戏是深受大众欢迎的一种游戏,游戏在一个包含有n行m列的游 ...
- Linux下PSSH的安装
python实现的集群批量命令工具,非常方便集群管理.同时其还带有pscp等功能 在合适的目录下,这里本机为/soft 输入命令 wget https://pypi.python.org/packag ...
- php读取Excel文件数据
首先先下载 PHPExcel 脚本之家下载地址:https://www.jb51.net/codes/194070.html 然后把文件丢到php脚本同级目录里面 在php文件里面写下以下代码即可 & ...
- C#设计模式-外观模式(Facade Pattern)
引言 在软件测试中,一般都是在功能测试稳定的情况下再进行UI自动化测试.或者进行性能测试.如果一个一个进行太麻烦,此时可以使用对外提供一个简单接口,通过这个接口可以访问内部一群接口.例如进行UI自动化 ...
- 攻克弹唱第九课(如何运用好G大调和弦)
在本期文章中,笔者将使用guitar pro7软件与大家分享如何运用好G大调音阶的经验. 众所周知,在我们学习吉他的过程中,先从C大调开始,再以G大调为深入,然后才走过入门的阶段.很多朋友都觉得自己对 ...