Arthur tiancaiamao@gmail.com atom egg for Chicken www.zenlife.tk Copyright (c) 2015, Arthur Mao 伟大的野心家,实践家 Arthur的博客 2005-07-31T12:29:29Z Arthur http://www.zenlife.tk tiancaiamao@gmail.com <p>把公司项目迁移到 Go1.11 的 module 了,这个过程中对于版本控制,依赖管理之类的事情有了更加深入的了解。 本来懒得写东西,有同事说起,“我们从 glide 到 dep,一直到现在切换到 module 了,为什么项目的依赖还是很难用”,所以我觉得还是有必要写一写。让更多的人理解 go module 以及语义版本,还是有意义的。</p> <p>包是一个固定的路径,而包的代码是会动态的变化的。如果今天 import 一个包可以编译过,明天那个包升级一下,项目就挂了,肯定是不能忍的。过去的一个做法,我们把所有的依赖都放进 vendor 里面,就不受外部包的升级之类的影响了。</p> <p>仅仅是代码拷到 vendor 不行,需要知道使用的是哪个版本的包,要有工具帮忙做这个事情,glide 到 dep 都是这样的工具。这些工具会有一个描述文件,一个 lock 文件,以及 vendor 代码。 描述文件里面,可以写很多灵活的规则,指定一个包使用哪个版本,比如使用 1.1.1,或者版本大于多少且小于多少,使用某个分支,又或者是某个 commit hash。lock 文件是工具根据描述文件的说明生成出来的,每个包使用具体到哪个 commit 都是确定的。</p> <p>主流的依赖管理工具都是这么干的,无论是在 rust 还是 javascript 或者其它语言里面。一开始在 Go 做 dep 的时候,也准备就按主流搞法做,然而 russ cox 对这个方向越做越觉得不对。</p> <p>使用 commit hash 是一个非常糟糕的行为,它让项目之间无法合作。比如项目 A 描述它要依赖 C 的 commit hash 5,项目 B 描述它依赖项目 C 的 commit hash 10,这也没问题。然后当 B 要引用 A 的时候,工具就懵逼了:你让我到底是选择 C 的 commit hash 5,还是 commit hash 10 呢? 于是工具只能把错误抛出来。</p> <p><strong>在描述文件里面指定依赖,是把所有的心智负担交给做依赖管理的人了。那个人当然会觉得难用!</strong></p> <p>假设我是项目 A 的维护者,我肯定要骂项目 B 的作者是傻逼。但是他听不到,也不知道自己很傻逼,我没法控制项目 B 的修改,于是只能默默去改自己的项目 A 的依赖,将 C 的版本依赖,改成一个既能兼容 commit hash 5,又能兼容 commit hash 10 的版本,好,我指定了一个大于版本 0.8 吧。然后再赞自己一句,真是太 TMD 机智了。</p> <p>过了几天,项目 C 升级了,它做了一个不兼容的改动,然后我的项目 A 想 update 一下自己的依赖,结果就跪了。于是又得重新默默处理 A 的依赖,顺便再问候 C 的女性长辈。</p> <p>一个投机取巧的处理方式是,把同一个包的不同版本给编译多份。A 依赖 C 的 commit hash 5,B 依赖 C 的 commit hash 10,A 依赖 B,那么,把 B 的版本 5 和 版本 10 分别都编译进 binary,问题就解决了。包的不同的版本,其实是不同的包。初看这个想法挺机智的,直到有一天遇到了这个问题:</p> <pre><code>func init() { http.Registe(&quot;/debug/requests&quot;) } </code></pre> <p>这是一个只能做一次的代码,但是 C 在版本 5 和 版本 10 里面分别都做了一次,然后就 panic 了。</p> <p><strong>这所有的一切都是不可协作的方式,每个人写自己的包的时候,没有考虑别人</strong>。提供灵活的规则去指定版本是不管用的。在描述文件里面指定依赖,是把所有的心智负担交给做依赖管理的人了。那个人当然会觉得难用! 嗯,重要的话重复几遍。所以 Go 觉得这个方式不可行。靠谱的方式应该是,由写包的人要遵循某个约定,剩下的版本选择交给工具去做。这个约定就是 -- 语义版本。</p> <p>语义版本才是 Go module 的核心。写包的人必须遵循语义版本,他写出来的包才能跟其它人协作。这是重要的开发哲学,<strong>心智负担从维护项目的人身上,转移到了各个包的开发者身上</strong>。我们看一下语义版本是什么。</p> <p><a href="https://semver.org/">https://semver.org/</a></p> <p>版本号是 MAJOR.MINOR.PATCH 的形式。当做了不兼容的 API 改动的时候,MAJOR 版本号就需要变。当做了能后向兼容的改动的时候,MINOR 版本号要变。对于前后完全兼容的版本,则只需要更改 PATCH 版本。</p> <p>最小版本选择,这也是 Go module 一个很有特点的地方。在主流工具做法里面,所有的包会指定自己的依赖,然后求解得到满足依赖的结果,它们会倾向使用最新的版本。然而 Go 不一样,它是选择满足条件的最小的版本。也就是没事不要瞎升级。选择最小的版本,会让约束更松,从而可选择而更多,兼容性更好。Go 没有像普通工具多做一个 lock 文件,然后锁定版本。这些都是在 Go 的工具链里面整个的。只有用户决定要升级某个包的时候,才会去升级。升级方式是这样:</p> <pre><code>GO111MODULE=on go get -u github.com/repo/pkg@version </code></pre> <p>一行命令它会自动更新相应的依赖。可以是 @version,也可以是 branch。如果是 branch 会默认使用该 branch 上面的最新的版本,没打版本,则是最新的那次提交。</p> <p>Go 是这样处理语义版本的,它假设 1.0 之后,是稳定的版本。如果没有打版本,它还是会把同一个包的不同版本,当前不同的包处理。打了版本之后,它会寻找一个兼容的版本。比如之前的例子,</p> <ul> <li>项目依赖 的 commit hash 5 跟 commmit hash 10,那么这个包的两个版本都会被编译进去</li> <li>如果依赖写成 v1.0.5 和 v1.0.10,这两版本是兼容的,则选择最小版本依赖 v1.0.5</li> <li>如果同时依赖是 v1.5.0 和 v1.10.0,最小的兼容版本是 v1.10.0。</li></ul> <p>注意,如果包不遵循语义版本会怎么样? 如果没有打版本,还是使用 commit hash,那么 Go module 也帮不上忙,又回到了 dep 那种时代,于是维护的人还是会觉得很痛苦。</p> <p>如果一个项目,明明是不兼容的改动,然而它的版本依然是 v1.1.1 写成 v1.1.2,那么造成的结果是升级就跪了。比如加了一个 API,那就应该 v1.1.1 就到 v1.2.0。这对所有暴露的 API 都需要慎重考虑, Go 语言在一开始就是遵循标准流程的。</p> <p>处理开发分支,有一个 replace 命令:</p> <pre><code>GO111MODULE=on go mod edit -replace github.com/repo/pkg=github.com/your-fork/pkg@version </code></pre> <p>它可以让原本依赖的 github.com/repo/pkg 包,实际使用 github.com/your-fork/pkg@version。</p> <p>实际使用过程中,说一个比较恶心的地方是 repo 间的相互引用成环。比如 github.com/pingcap/tidb-tools 要引用 github.com/pingcap/tidb,而 tidb 又要引用 tidb-tools/tidb-binlog/pump_client 就很麻烦,至今没想到好办法。</p> <p>Go module 并不是万能药。万能药是靠语义版本来保证的。语义版本的理念非常的好,如果大家都照做了,天下大同,相互协作就简单了。如果不遵循,则依然是混乱。关键还是要写代码的人对自己的严格要求。</p> www.zenlife.tk/go-module-semantic-version.md 2018-11-18T13:06:42Z 关于 Go1.11 module 和语义版本 2018-11-18T13:06:42Z Arthur http://www.zenlife.tk tiancaiamao@gmail.com <p>今天有同事问起,如何并发执行任务,同时保持让结果有序。</p> <p>我记得以前写过一段代码,想起来很精妙,应该 share 出来。</p> <pre><code>type task struct { sync.WaitGroup } func main() { for t := range &lt;-task { sendToWorker(ch, t) sendToKeepOrder(fifo, t) } for t := range &lt;-fifo { t.wait() } } func worker(ch) { for t := range ch { t.Done() } } </code></pre> <p>同时把任务往两个队列里面扔,一个用于实现并发,另一个用于实现先进先出。同时用一个 WaitGroup 来保序。</p> <p>最早这段代码出现在这里</p> <p>https://github.com/pingcap/tidb/pull/6323#discussion_r193763230</p> <p>更早的启发应该来源于这里的一个场景,往各个 region 发请求需要并行,而结果又需要是按发送顺序返回。</p> <p>https://github.com/pingcap/tidb/pull/2804/files#diff-c27388ffb48c6f6eaeefe07dd3243530R406</p> www.zenlife.tk/concurrency-with-keep-order.md 2018-12-25T20:34:42Z 并发同时保序 2018-12-25T20:34:42Z Arthur http://www.zenlife.tk tiancaiamao@gmail.com <p>再过几天就正好是 30 岁了。30 岁的自己,似乎还没有许多人 20 岁的沉稳,但确实在成长。清明回了一趟家,还是很触动的。看着父母一天一天地老去,有些自己避不开的责任。</p> <p>往些年写总结,时而抑郁,时而迷茫。如果用一个词语来总结今年,我想那应该是:<strong>懂事</strong>。这种感觉好像,读了许多的书,去过了不少地方,经历太多的事情。这一切在潜移默化中改变自己。只是积累到某个时间点,整个人突然开窍了。</p> <p>或者用知乎上看到的一个描述特别贴切:</p> <ol> <li>情绪稳定性</li> <li>控制自己情绪的能力</li> <li>目标的把控以及坚定的执行能力</li></ol> <p>今年整体的情绪都比较稳定,波澜不惊。要说执行上面,最大的区别之一是现在做事情更讲究方法了。为了一个目标,完成它需要哪些步骤,而自己能拿到哪些资源,找到当前的瓶颈在哪里,如何克服。</p> <p>如果也用一个关键词总结工作方面,那应该是:<strong>职业化</strong>。</p> <p>交待员工一个事情,他能够办好,就会有更多的事情过来,有更大的责任需要承担,直到他无法胜任当前的任务的时候才会停下。彼得原理。逼着自己成长到能够承担更大的任务,大概是职业化过程中的必然经历。有的人成长快,有的人成长慢。有时候遇到一个瓶颈阶段后难以突破到下一个。</p> <p>在职业化的道路上,个人感悟,三种瓶颈:技术瓶颈,工作量瓶颈,管理瓶颈。</p> <p>技术瓶颈就是,纯粹的技术方面问题搞不定了。以前觉得自己纯粹技术方面,应该不会有什么搞不定的事情。后来明白,瓶颈总是会遇到的。比如一个典型的事情, shen-go 项目,我不想走 jit,又希望性能方面能够进一步突破。就发现搞不定了。再比如当遇到一个 bug 死活找不出来时,这也是事情 block 在技术瓶颈上面。技术类型瓶颈的典型特征,瓶颈就落在那某一个人身上,他不突破,整个项目都无法突破。</p> <p>工作量瓶颈,知道一个事情该如何一步一步的做,技术角度也没有特别难的东西。这不代表这件事情就容易做成,有时可能需要投入特别多的精力,太多简单粗暴的琐事,自己一个人根本搞不定。处理这种瓶颈的办法,就是加人,以及争取更多的资源帮自己干活。要把控握整个事情的进展,有计划,有条不紊。当然,说起来都懂,但是把控起来并不容易,做事情非常要讲究方法。尤其在不断被打断,高上下文切换地情况下。</p> <p>管理瓶颈,推动一个事情的过程中,需要的资源越多,越是需要更强大的管理的能力。期间会遇到各种阻力,没人愿意配合,没人帮忙干活。可能需要一些坑蒙拐骗的手段,最终还是得想办法把活儿干完。沟通交流,理解别人的想法,影响力,这些技术之外的软实力开始突显重要性。积极主动去推动一个事情,否则瓶颈不会自动解决。以前遇到不在依赖在别人的时候就停下了,导致事情会 block 在自己这里。独自做一件没啥依赖的事情,比如 coding,只是做事情的非常基础的阶段。能让别人参与进来一直推进一个东西,远比独自战斗要复杂。更高的境界,是激发别人的使命感,让人觉得这也是他应该实现的目标,最后这个目标完成了,对方也成长了,才是管理要做的。</p> <p>另一个要说的依然是<strong>时间</strong>。经济学里面衡量一个物品的价值,不是用金钱,而是获得它需要拿什么作为交换。时间可以换取无限的可能性,然而却没有什么能购买时间。</p> <p>时间真的是一个很贵很贵的东西。收入水平越高,越是觉得,最最最买不起的东西,就是自己的时间。 以前穷,总是学生心态,时间不值钱。可以大把时间用来干许多的好玩的事情。越长大越是忙碌,时间的齿轮则转动的越来越快。 心态上一个很大的变化就是,如果一件事情需要投入时间,而回报很低,我会宁愿多花点钱。举个简单例子,大学时会找各种破解下载而不是购买正版,其实有些场景浪费的时间是很不划算的。再举个例子,不是自己电脑,如果说重装系统丢给电脑城十块钱二十块钱就能解决,投入时间明显是不值得的,如果不是因为,嗯你懂的。</p> <p>为了在极快的节奏下,维持生活的有条不紊,再次尝试了 GTD 方法,也算时间管理相关的。我设计了一个文本格式,就类似于 org-mode 那种。使用一阵之后我发现,全部被 @work 占满了。周末放个假,都觉得堆积了好多事情要做。现在静下心读一本书,真的算是很奢侈的事情了。今年读的有一本书值得推荐,叫做 《The Deciding Decades》,讲 <a href="https://zhuanlan.zhihu.com/p/36287548">20 岁到 30 岁一些重要的道理</a>。20 岁是开始是真正适应社会的时期,而到 30 岁之后很多事情都定型了。有点后悔读得太晚。anyway, it's better late than never。也或许是猛然发现时间流逝得太快吧,比我们所有人预想的,都还要快。尤其是有一个明确的时间节点时候,才会让人这么着急...</p> <p>想想 30 岁之前应该完成哪些事情。那种紧迫感就让人非常压抑。</p> <p>说说好玩的事情。</p> <p>世界语刷一点点就落下了,没能坚持。强行找借口背单词占用我时间过多。同时维持多项学习计划还是非常耗费精力的。今年至少是把 GRE 的单词给背完了。另外,背单词软件可以随时抓碎片时间,但世界语似乎就没那么容易利用上碎片时间,又找到一个好借口。世界语算是少数号称自学就能掌握的语言,至少还是把 duolingo 刷通关吧,这项要加到 19 年计划里。</p> <p>摄影也是玩票性质。大部分时间相机放着吃灰,人像镜头买完都还没用过(囧)。年初倒是挺热情的,报了摄影团零下二十度的地方去拍风景。追着日出日落跑,看着极美的风景,拍摄的往往都特别苦逼。</p> <p>今年都没怎么骑行,改玩<strong>徒步</strong>了。买了帐篷自从之前爬过泰山之后,终于再次用起来。一日线的走了香山;走了大觉寺—阳台山—妙峰山的三峰连穿。海坨山扎营算是正经的一次徒步,雨过天晴,感觉非常的好。走<a href="https://zhuanlan.zhihu.com/p/43589747">五台山</a>的那次运气就有点差,几乎遇到最恶劣的天气,九月飞雪,一天 30km 走崩溃了。</p> <p>十一的假期,去体验了人生的第一次雪山,徒步走了洛克线到稻城亚丁,海拔 4700,结果高反下撤没走完全程。我们走了一个叫做蓝月山谷的地方,下撤也算是一番别样的体验。稻城很美,&quot;最后的香格里拉&quot;并非浪得虚名。另外,S217 稻城到理塘,经过海子山的那一段,真的是我人生中见过的最美丽的地方之一。如果将来有机会,无论骑行或者自驾游,很想再去一次。</p> <p>记得刚来北京的时候,羽毛球完全没怎么接触。公司每周会组织打羽毛球,日积月累下来,自己球技进步了挺多。让人感觉时间投入在哪里,收获就会在哪里呢。</p> <p>重新回到了<strong>远程工作</strong>的状态,开始新的挑战。一晃已经是在北京的第三个年头。来北京的第一天,就几乎预料在未来的某天,终将离开这座城市。只不曾想离开其实也只是很平凡的一天。 remote work 并不是件容易的事情,对自律的要求,对工作生活的平衡,心态的调整。曾经失败过一次。现在是从哪里跌倒了,再从哪里爬起来,只是这一次,我相信自己成长很多了。</p> <p>最后把朋友圈关掉了,不会什么时候像心被揪了一下,陷入回忆。看着记忆一条一条的消失,如下雨冲洗过一样,不曾留下痕迹。2019,新的开始,新的生活。也是告别过去:</p> <p>So long, and thanks for all the fish.</p> www.zenlife.tk/2018.md 2018-12-30T01:00:42Z 2018年终总结 2018-12-30T01:00:42Z Arthur http://www.zenlife.tk tiancaiamao@gmail.com <p>这里说的错误处理是指分布式场景里面的错误处理。分布式场景下,结果是有三态的,成功,失败,不可知。 失败和不可知很容易被混一谈,它们都被当作错误,但其实是完全不同的:失败的结果是确定的,操作没成功,并且不会产生副作用。不可知的结果不是确定的,有可能操作成功了。这时如果重试,就有可能导致数据被写了多次。</p> <p>先说几个场景</p> <p>proxy 的自动重试。假设 proxy 有两个功能,一个是负载均衡,另一个是自动重连。假设 proxy 作用在业务层转发,则自动重连在有些场景下面可能会出问题。比如说 proxy 接的是一个分布式存储,一条写操作下去,该节点可能执行完之后 down 掉了。此时结果是不可知,有可能是成功。那 proxy 换一个节点重连,客户端把写操作再执行一遍,就可能重复插数据了。</p> <p>closing 状态的错误处理。这是一个需要特别小心的状态。一个后台服务,收到服务关闭信号,跟断开网络监听端口,可能不是处于同一个原子操作内。这时会发生的事情是,从服务收到关闭信号到完全关闭期间,处于一个 closing 状态。这个状态下可能仍然会收到请求,这些 inflight 的请求,是有可能被执行成功的。对于客户端来说,它会收到一条服务器关闭的错误,这个错误其实是不可知,而不是失败。假设客户端重试这条请求,就有可能出问题。这就意味着,错误处理是客户端和服务端共同的事情,客户端的错误处理必须能区分失败和不可知。很遗憾,对于 SQL 协议,这种场景客户端无法区分。</p> <p>如何设计一个可重试的协议呢?我们可以在服务端记一个 id 号,将这个 id 号返回客户端。当错误类型是不可知的时候,协议要约定让客户端拿这个 id 去服务端查询状态,是成功或失败。 tcp 是一个典型的例子,它就自动重试了更底层的错误。</p> <p>tcp 本质上是一个只有 2 个 peer 参与的分布式共识协议。send 和 receiver 之间有三次握手,四次分手,并且对包的组装,重传,流控,等等达成共识,是一个有状态的协议。</p> <p>我们得出在不可知状态下错误处理,重试的条件:</p> <ul> <li>操作本身是幂等操作。即使被执行多次,结果也是一样的。</li> <li>另一种是通讯的两端都记状态并对比。</li></ul> <p>第一点很好理解,比如说读操作就是幂等的。再比如说 kv 存储,把同一个 &quot;key = value&quot; 写操作执行多遍也是幂等的,当然这里涉及到一个时序问题,前提是有 MVCC 条件下。 第二点其实就是业务感知,通过协议层的约定,把不可知状态给干掉了。</p> <p>分层</p> <p>分布式存储就不能 proxy 么?当然不是的。假设提供服务是在 SQL 层,而 proxy 在 tcp 层,比如多台 tidb + lvs。因为分层了,下一层里面发生的错误,对上层透明。</p> <p>如果我们把 tidb 的网络消息栈从上到下看一遍,它也是一个分层的:</p> <pre><code>transaction mvcc multi-raft grpc ... tcp </code></pre> <p>tcp 提供了一个可靠的基于字节流的协议。grpc 在底层网络协议之上,构建了一个可靠的基于请求包的协议。</p> <p><strong>不同的分层应该有各自的抽象。上层不应该去 assert 很下层的实现细节</strong>,合理分层之后,我们说每一层的错误处理。</p> <p>比如事务冲突,这是属于业务层关心的。</p> <p>rpc 是更下一层。这一层应该屏蔽非业务的细节。比如 tikv 挂了,正在关闭,这都是它能处理的。消息队列满了,server is busy,也丢在 rpc 层处理了。如果要拆细,其实属于更上面一点点,就像网络模型可以拆四层或者七层。</p> <p>multi-raft 这里,要处理 region 分裂合并,请求应该路由到哪个 region,重试 region 分裂之类造成的失败。</p> <p>就像 tcp 的报文格式一样,在设计 grpc 的消息包时,也要分层,要考虑每一层的错误,并且不要跨层处理,尤其是不要混。比如说把一个 raft 层的错误设置成 other error,一直上抛到 transaction 层,那 transaction 层对这个 error 是懵逼的。</p> <p>2PC 的 commit 阶段,如果发生了网络错误,这是一个 undetermine 的状态。但是 rpc 层并不知道,也不应该知道业务逻辑执行的是不是 2PC 的 commit。 multi raft 也不知道事务是否执行的 2PC 的 commit 操作,所以这种错误应该由 transaction 层处理。</p> <p>这里暴露了一个矛盾的地方,这个矛盾大概是错误处理麻烦的根源:</p> <pre><code>下层 callee 并不知道它自己是否应该被 retry,因为它不知道它被调用的语境 上层 caller 并不知道被 callee 会返回什么错误,错误类型太多了,assert 每一种错误不现实 </code></pre> <p>第一个问题,我们可以约定:callee 返回错误,caller 根据语境决定是否 retry。</p> <p>第二个问题,我们合理分层,不要把太下层的错误抛到上层</p> <p>错误并不描述它是否 retryable,因为 callee 并不知道错误是否 retryable 的。 closing 状态错误也是一个 undetermine。</p> <p>总结一下:</p> <ul> <li>分布式环境下,遇到结果不可知的场景不能轻易重试</li> <li>合理分层,每一层处理自己可处理错误,并且向上层屏蔽细节</li> <li>caller 和 callee 之间约定好错误处理方式</li></ul> www.zenlife.tk/error-retry.md 2019-01-18T12:55:42Z 错误处理与自动重试 2019-01-18T12:55:42Z Arthur http://www.zenlife.tk tiancaiamao@gmail.com <p><a href="https://www.cockroachlabs.com/blog/transaction-pipelining/">这篇文章</a>很有意思,简单写几句。</p> <p>早期 cockroachdb 在 begin ... commit 之间,每执行一条 SQL 语句都是把 write intents 完全写到存储之后,才返回给客户端。这样导致一个问题,整个的事务执行时间是跟 statement 数量正相关。performance 上不去,为此他们进行了优化。</p> <p>他们曾经评估过的一种方案是,所有事务的所有写入先缓存在本地内存,直到 commit 的时候,才真正去执行提交。这正好是 tidb 使用的模型,然而他们并没有采用该方案。理由有两点,一个是他们发现这个方案,事务会持有写锁产生大量读写冲突,导致读操作失败。尤其是在一些 workload 比如 TPCC 下面,影响很大。另一个理由是,读写使用不同的 timestamp 导致隔离级别会弱一些,比如 snapshot isolation。</p> <p>对于冲突问题,我的评价是:fair and objective,哈哈。继续往下说,他们没有采用这套方案,那他们是怎么改进的呢?</p> <p>他们把事务里面执行里面 statements 的过程流水线化了。即先返回结果,同时异步去写 write intents。只要保证最后 commit 的时候,所有 statement 的异步 write intents 都是完成了的,正确性就没问题。</p> <p>异步写 write intents,这个时候没执行结果呢,拿什么返回给客户端?他们观察到,其实写操作返回的结果只需要成功失败,以及影响了多少行。而在 range 的 lease holder 中拥有所有必需的信息。</p> <p>于是可以不做复制就返回结果。相当于在内存里面操作完,就把结果返回去。之后异步地同步状态。分布式共识说白了就是状态同步过程:改状态,状态通过复制,达成共识,之后返回客户端。lease holder 说自己状态是多少,然后把这个状态同步到其它 peer 上面。</p> <p>这当然是可能失败的。比如 lease holder 切换了,各种故障的场景等。于是流水线之后会出现一种情况,statement 返回客户端操作成功,实际操作是失败的。直到 commit 会抛出错误来。他们很鸡贼地利用了,只有 commit 的成功才决定整个事务的成功。就好比 CPU 的指令流水线,先预取,预测执行,失败后无非将结果丢弃重来,打破流水线,而预测成功则可以带来巨大的性能提升。</p> <p>做完这个优化之后会造成一个问题,会跟 MySQL 一些基于锁的行为,语义上不太一致。有些业务程序或者框架就假定使用 MySQL 的行为,会无法兼容。</p> www.zenlife.tk/cockroach-transaction-pipeline.md 2019-01-26T16:58:42Z cockroachdb 的事务流水线 2019-01-26T16:58:42Z Arthur http://www.zenlife.tk tiancaiamao@gmail.com <p>在 TiDB 里面,事务的所有操作都会先临时缓存在本地内存,直到事务提交的时候,通过 2PC 将事务的所有修改整体提交。</p> <p>这样会遇到一个问题,后面的操作需要 “看见” 前面的操作的修改。比如插入 a,再读取 a,这时需要读到刚刚插入的 a。由于前面的插入并没有真正写下去,我们读的时候需要做一个融合:优先读本地缓存,读不到的情况下,再穿透缓存去读下面的存储。把这些东西封装起来,让调用者只管当它是一个 store,我们把这个封装的代码叫 union store。</p> <p>union store 概念上尽管很简单,但是实际的代码还是挺乱的。中间穿插着 dirty table,binlog,事务状态等等东西,维护起来容易出错。我前面写过一篇<a href="write-safe-code.md">编写安全的代码</a>,是思考理想的情况下,应该怎么样让代码更健壮。但是现实与理想相去甚远。这里很容易弄坏数据,出现数据不一致,对于数据库,数据错了就是灾难。然而没人能保证代码永无 bug。所以这次换一个角度:如何让 bug 不产生破坏性影响,让系统更健壮。</p> <p>这应该算工程手段吧。可以先解释所谓的工程手段。</p> <p>做个类比,我要写很多数据到磁盘,也不确定会不会中间有数据出错。于是可以写数据的时候,生成一份检验信息,把检验信息和数据一起写出去,然后验证。该过程并消灭掉中间写数据出错的可能性,但是捕获到出错的情况了。</p> <p>再做个类比,就像写一个内存分配器,想保证代码完全没有 bug 并不容易。那么让定位 bug 更容易一些呢?可以把每次分配操作,在哪分配,分配了多少空间,全部记录下来,然后去重放操作。能复现就好查问题了。</p> <p>回到正题,先分析下导致数据写坏的 bug 都是如何产生,比如写了数据没写索引;比如该缓存没有刷,上一次操作的缓存留到下一次了;或者错误处理没做好,有一半成功一半失败,导致部分残留了;比如更新的时候,读出错了,接着用读到的错误数据去写...</p> <p>union store 看到的东西,都不是真实的,而是缓存融合真实存储后的一个视图。缓存融合的过程中,可能 bug 导致数据有问题。</p> <p>约束检查是个这样的想法:所有操作过程中,都会产生一些假设。 我们把操作的假设 (contract) 全记录下来,提交缓存数据时,把 contract 一起提交下去验证。 就相当于,代码无 bug 的情况下,这些 contract 应该是满足的。 如果代码有 bug,就有可能捕获了。</p> <p>举个具体点的例子,更新一条记录,会先读后写,更新数据和更新索引。那么,这个操作的 contract 就包含,老的索引数据存在,老的数据存在。如果是唯一索引,那么生成的 contract 还包括索引数据是不存在的。</p> www.zenlife.tk/unionstore-contract.md 2019-01-26T17:31:42Z union store 数据一致性以及约束检查 2019-01-26T17:31:42Z Arthur http://www.zenlife.tk tiancaiamao@gmail.com <p>《Calvin: Fast Distributed Transactions for Partitioned Database Systems》</p> <h1>Abstract</h1> <blockquote> <p>Calvin is a practical transaction scheduling and data replication layer that uses a deterministic ordering guarantee to significantly reduce the normally prohibitive contention costs associated with distributed transactions.</p></blockquote> <p>事务调度和数据副本层,通过确定 order 的保障,极大的减少了分布式事务存在的竞争问题。</p> <p>线性扩展,无单点问题</p> <blockquote> <p>By replicating transaction inputs rather than effects, Calvin is also able to support multiple consistency levels—including Paxosbased strong consistency across geog</p></blockquote> <p>replicate 的时事务的输入,而不是事务的 effect。这个 effect 怎么理解呢,就是说,复制的是操作,而不是状态。</p> <h1>1. Background and introduction</h1> <p>前面正确的废话都跳过了</p> <blockquote> <p>Calvin is designed to run alongside a non-transactional storage system, transforming it into a shared-nothing (near-)linearly scalable database system that provides high availability1 and full ACID transactions.</p></blockquote> <p>calvin 是设计成运行在不支持事务的存储层之上的。它是存储层之上,提供了一层事务调度层。</p> <blockquote> <p>The key technical feature that allows for scalability in the face of distributed transactions is a deterministic locking mechanism that enables the elimination of distributed commit protocols.</p></blockquote> <p>最关键的技术是,使用确定的锁机制,消除了分布式提交协议。这里面所谓分布式提交协议,其实指的就是 2PC。</p> <h2>1.1 The cost of distributed transactions</h2> <p>2PC 要求所有的成员参与,走多轮网络。这个协议的设计,整个过程其实是锁住的。如果有 popular record 的情况,这会极大的降低系统吞吐。想想这里是不是?</p> <blockquote> <p>We refer to the total duration that a transaction holds its locks which includes the duration of any required commit protocol as the transaction’s contention footprint</p></blockquote> <p>事务持有锁的时间,这里定义了一个 contention footprint。本文的假设是悲观协议,所以算的是锁的时间。假设是乐观并发协议,一旦事务 abort,影响其实是严重的,</p> <h2>1.2 Consistent replication</h2> <p>一致性复制协议这里就特指 paxos 了,就像前面分布式提交协议其实说的 2PC 一样。</p> <h2>1.3 Achieving agreement without increasing contention</h2> <blockquote> <p>when multiple machines need to agree on how to handle a particular transaction, they do it outside of transactional boundaries</p></blockquote> <p>这句废话是说,假设多个机器,它们已经对于某个事务如何处理,达成共识了,那就没有了加锁呀,并发控制呀,这些事情了。</p> <p>必须完全严格地按 plan 描述的去执行。node 挂了这类问题,并不会使得事务 abort。挂了恢复,继续执行,只要事务的执行是确定的。</p> <p>就是说,只要把所有事务定好序,把它们将要如何执行事先确定下来,后面就严格地执行就好了,过程就跟重放 log 其实是一样的。而 log 有 paxos 来保证了。</p> <p>所以事情就变成如何保证 determinism 了。</p> <blockquote> <p>Calvin uses a deterministic locking protocol based on one we introduced in previous work [28]</p></blockquote> <p>calvin 使用了一个确定的锁协议,在之前的论文里面讲过。</p> <p>calvin 这种方式,完全地干掉了 2PC 的过程。(关键点:calvin 是干掉了事务提交阶段的 2PC 开销,但是它干掉 2PC 的代价是引入事务的定序过程,而给并发事务定序这一步是有额外开销的)</p> <p>主要贡献:</p> <ul> <li>设计了一个事务调度和复制层,可以将不支持事务的存储系统,变成一个水平扩展,高可用,强一致,完全支持 ACID 事务的系统</li> <li>一个靠谱的 deterministic 的并发控制协议的实现,比之前的方法更 scalable,并且无单点故障</li> <li>快速 checkpoint 模式,跟 determinism 保证加在一起,可以完全干掉 REDO log 以及它相应的开销</li></ul> <h1>2. Deterministic database systems</h1> <p>需要分布式提交协议,是因为事务需要一个原子性保证,并且是持久化的:要么都成功,要么都失败。</p> <blockquote> <p>Events that preven a node from committing its local changes fall into two categories: nondeterministic events and deterministic events</p></blockquote> <p>导致某个节点提交它本地 change 失败的事件可以分为两类:一类是不确定事件,比如节点挂了。另一类是确定事件,事务的逻辑比如遇到锁。事务在 nondeterministic 事件里面,是不应该 abort 的。</p> <p>假设因为 nondeterministic 事件让整个系统卡住,这设计是很傻逼的。题外话,读到这里,我想举个很傻逼的例子,2PC 阶段,coordinator 把请求发给所有的 worker,所有 worker 都回复 yes 了,然后 coordinator 挂掉了。整个系统就卡住了:所有 worker 只能等待 coordinator 节点恢复过来。</p> <p>如果协议设计的是 deterministic 的,那么遇上 nondeterministic 事件时,replica 起来继续执行就 ok 了。</p> <blockquote> <p>The only problem is that replicas need to be going through the same sequence of database states in order for a replica to immediately replace a failed node in the middle of a transaction.</p></blockquote> <p>关键点只需要顺序是确定的,在事务过程中挂掉,让 replica 替换掉原 node,继续执行就是了。</p> <blockquote> <p>Synchronously replicating every database state change would have far too high of an overhead to be feasible. Instead deterministic database systems synchronously replicate batches of transaction requests.</p></blockquote> <p>复制所有数据库的状态变化,overhead 太重了。只复制 batch 的事务请求。</p> <p>传统数据库里,这样复制事务请求是不行的,因为会有事务并发问题:事务并发执行顺序,需要跟某个串行执行顺序一致。然而,假设先走一个加锁排序的操作,后面的执行顺序就可以保证 deterministic 了。</p> <p>顺序定了之后,replica node 只需要同步就行了。同样的操作会得到相同的结果。并且遇到故障恢复也无所谓,事务并不 abort。在事务处理的最后,不需要执行一个分布式提交。</p> <h1>3. SYSTEM ARCHITECTURE</h1> <blockquote> <p>Calvin organizes the partitioning of data across the storage systems on each node, and orchestrates all network communication that must occur betwee nodes in the course of transaction execution.</p></blockquote> <p>calvin 将数据 partition 到存储系统的各个节点上面,并且编排事务执行时的节点间网络通信。</p> <p>calvin 分成三层的不同子系统:</p> <ul> <li>sequencing 层: 将收到的事务请求进行全局输入排序</li> <li>scheduler 层:事务编排,使得事务可以并行执行,而执行结果又是等价于某种串行执行顺序的</li> <li>storage 层:处理物理的数据分布</li></ul> <blockquote> <p>Each node in a Calvin deployment typically runs one partition of each layer</p></blockquote> <p>这三层都是水平扩展的,分布在整集群的 share-nothing 节点上面。(这里有个疑问,如此分层,并且各层分布式,会使各层之间的网络通信开销很高。就像上一层的某个 node,要跟下一层的另一个 node 通信,而不是在同 node 上面)</p> <blockquote> <p>By separating the replication mechanism, transactional functionality and concurrency control (in the sequencing and scheduling layers) from the storage system, the design of Calvin deviates significantly from traditional database design which is highly monolithic, with physical access methods, buffer manager, lock manager, and log manager highly integrated and cross-reliant.</p></blockquote> <p>事务功能,并发控制,这些是跟存储系统分离的。这样子 calvin 跟传统的数据库很不一样:在物理访问,缓存管理,锁管理,日志管理等方面。</p> <h2>3.1 Sequencer and replication</h2> <p>一个最简单的 sequencer 就是把所有请求都发到一个 node 上面,log 下来,然后按 timestamp 顺序 forward 到后面节点上去。但是这样问题是,一个有单点故障,另一个集群负载的上限,就是单个节点处理能力。</p> <blockquote> <p>Calvin’s sequencing layer is distributed across all system replicas, and also partitioned across every machine within each replica.</p></blockquote> <p>calvin 里面这个东西是分布式的。</p> <blockquote> <p>Calvin divides time into 10-millisecond epochs during which every machine’s sequencer component collects transaction requests from clients. At the end of each epoch, all requests that have arrived at a sequencer node are compiled into a batch.</p></blockquote> <p>sequencer 组件以 10ms 为单位,收集 client 请求。每个 epoch 结束的时候,到达一个 node 的 sequencer 上面的请求形成一个 batch。</p> <p>每个 batch 写副本成功以后,向 scheduler 发消息,包括以下信息:</p> <p>1) sequencer 的唯一节点 ID</p> <p>2) epoch number (即每 10ms 一次的这个)</p> <p>3) 所有需要多个 recipient 参与的事务输入</p> <p>这样子每个 scheduler 就可以将该 epoch 来自所有 sequencer 的交错的事务的 batch 汇集到一起。</p> <h3>3.1.1 Synchronous and asynchronous replication</h3> <p>calvin 支持异步复制和 基于 paxos 的同步复制两种模式。两种模式下,节点都是分成了 replication groups。每个 replication group 都包含一个分片的所有副本。</p> <p>异步复制模式下,是设计成主从。优点是极低延迟,缺点是在遇到错误时,容错处理的复杂度显著增加。(不想细节了,在我看来,异步模式绝逼不靠谱的)。然后就是 paxos 同步模式。</p> <blockquote> <p>since this synchronization step does not extend contention footprints, transactional throughput is completely unaffected by this preprocessing step</p></blockquote> <p>注意,这一步的同步过程,是不会对事务的竞争导致的延时,产生半毛钱的影响的。也就是顶请求多影响请求的响应延迟,不会影响事务吞吐。 作者在这里丢了一个图表,同数据中心,ping 延迟 1ms 跟 amazon 的 Northern California 到 Ireland 数据中心,ping 延迟 100-170ms 的情况,总体事务吞吐没受影响。</p> <h2>3.2 Scheduler and concurrency control</h2> <p>一旦走到存储层,所有事情都必须是确定的了。</p> <blockquote> <p>Both the logging and concurrency protocols have to be completely logical, referring only to record keys rather than physical data structures.</p></blockquote> <p>logging 只能够是 logical 的 logging。由于数据库的状态可以完全由输入确定,逻辑 logging 很简单。在 sequencing 层 logging,并且定期的在 storage 层做 checkpoint。</p> <p>这里又有一个细节,阻止 phantom updates 一般需要锁住一个 range 的 keys,这个操作发生在物理数据上面的上锁,而 scheduler 只能访问到(从 sequencer 汇总过来的)逻辑 record 信息,作者没细讲怎么处理,丢了另外一篇 paper:</p> <blockquote> <p>To handle this case, Calvin could use an approach proposed recently for another unbundled database system by creating virtual resources that can be logically locked in the transactional layer [20]</p></blockquote> <p>calvin 的 deterministic 锁管理器,是 partition 到整个 scheduler 层的。每个 node 的 scheduler 只负责该 node 的存储组件上面的 lock。</p> <blockquote> <p>each node’s scheduler is only responsible for locking records that are stored at that node’s storage component even for transactions that access records stored on other nodes.</p></blockquote> <p>锁协议类似于严格的两阶段锁,加了一点点约束。</p> <ul> <li>事务 A 和 B,都需要在资源 R 上面加排它锁,如果事务 A 先于 B,则 A 必须先于 B 在资源 R 上加锁。calvin 把这个丢到单线程做了。</li></ul> <blockquote> <p>All transactions are therefore required to declare their full read/write sets in advance</p></blockquote> <p><strong>每个事务都必须提前声明,它所有的读/写的请求访问的集合</strong> (我认为这是一个非常大的约束)</p> <ul> <li>锁管理器 grant 每个锁给事务的顺序,必须严格的按照事务请求锁的顺序</li></ul> <blockquote> <p>Once a transaction has acquired all of its locks under this protocol (and can therefore be safely executed in its entirety) it is handed off to a worker thread to be executed.</p></blockquote> <p>一旦一个事务拿到它所有需要的锁之后,就可以丢给后台 worker 去执行了。</p> <p>worker 线程执行事务分为五个阶段:</p> <ol> <li>读/写 集合分析</li> <li>执行本地读</li> <li>远程读</li> <li>收集远程读结果</li> <li>执行事务逻辑并且 write</li></ol> <h3>3.2.1 Dependent transactions</h3> <blockquote> <p>Calvin’s deterministic locking protocol requires advance knowledge of all transactions’ read/write sets before transaction execution can begin</p></blockquote> <p>由于这个限制,对于需要先读,才能知道完整 读/写 集合的事务,也就是有依赖的事务,calvin 是不支持的。这一节里面写了一点点特例,说 calvin 能支持的情况。</p> <blockquote> <p>The idea is for dependent transactions to be preceded by an inexpensive, low-isolation, unreplicated, read-only reconnaissance query that performs all the necessary reads to discover the transaction’s full read/write set</p></blockquote> <p>其实是对前面的读 query 先执行了一遍,得到读/写集合之后,再去构建。这里面会存在好几个问题,一个是每一遍预读的操作,一定需要是一个非常轻量,所以论文说要求 inexpensive, low-isolation, unreplicated</p> <p>另外一个是,如果构建读/写集合期间,有其它的事务去修改了数据,那读到的就是失效了,构造的读/写集合也就是失效的。如果这样子,就会违反之前说的,事务写 storage 层之前 deterministic 的要求。所以呢,这里又一个约束是要求 read-only reconnaissance query</p> <p>它居然还花了一段文字,说次级索引的例子,是满足它要求的一个特例:次级索引很少被修改。有一类依赖事务就是,读次级索引,再根据读到次级索引的结果,决定读/写集。</p> <p>对此我持否定意见,并认为这一节的内容完全很扯...</p> <h1>4. Calvin with disk-based storage</h1> <p>calvin 之前的工作,都是基于内存做的。原因是传统的 traditional nondeterministic 数据库,它的保证事务最终顺序可以等价于任何一种串行顺序,而在 calvin 里面,最终顺序一定是 sequencer 选择的顺序。 并行的几个事务的执行,比如写盘花了 10ms,在传统数据库那边,(只要没有锁冲突) 会产生不同的结果,但是不管哪种都算做是对的。而 calvin 里面,只能出来一种结果,就是等这 10ms。</p> <p>(简单说,它就是不能乱序写了。另外,保序和并行,又是一对天敌)</p> <blockquote> <p>Calvin avoids this disadvantage of determinism in the context of disk-based databases by following its guiding design principle: move as much as possible of the heavy lifting to earlier in the transactio processing pipeline, before locks are acquired.</p></blockquote> <p>处理方式,尽可能把重的操作,提到前面,在获取锁之前。这里面有一处优化是,sequencer 组件,收到请求后,若发现立刻执行该操作会导致卡在磁盘上,那它就故意 delay 一点点,先别转发到 scheduler 层。同时,通知 storage 组件去“预热”一下数据,把事务将要访问的数据准备到内存。这个优化可事务吞吐受到磁盘延迟的影响比较小。</p> <p>这个优化实际会有两个困难:一个是需要比较精确的预测磁盘上面的延迟,这样事务发过去,数据正好准备好。另一个是,sequencer 需要精确追踪所有 storage 节点,哪些 key 是在内存里面的,这样它才能处理 data prefetch。</p> <h2>4.1 Disk I/O latency prediction</h2> <p>磁盘 IO 延迟其实很难预测。因为影响因素太多了。典型的:</p> <ul> <li>物理的磁头旋转的距离</li> <li>之前已排队的磁盘 IO 操作数量</li> <li>remote read 的情况下的网络 latency</li> <li>存储介质挂了,failover 的情况</li> <li>数据结构比如 B+ 树,需要多次 IO 的次数</li></ul> <blockquote> <p>It is therefore impossible to predict latency perfectly, and any heuristic used will sometimes result in underestimates and sometimes in overestimates.</p></blockquote> <p>纯属瞎扯了,就直说无法预测,才是正经的。</p> <p>这个地方估高了有问题,平白无故多出一些等待时间。估低了也有问题,请求发过去还是要 block 在磁盘那里,并且这里是在执行过程中,是挂锁等待的,导致竞争升高,会影响整体吞吐。</p> <p>又是一个需要 tuning 的活。</p> <blockquote> <p>A more exhaustive exploration of this particular latency contention tradeoff would be an interesting avenue for future research, particularly as we experiment further with hooking Calvi up to various commercially available storage engines.</p></blockquote> <p>嗯,这是将来很好的一个研究方向,呵呵哒!</p> <h2>4.2 Globally tracking hot records</h2> <blockquote> <p>.. for the sequencer to accurately determine which transactions to delay scheduling while their read sets are warmed up ...</p></blockquote> <p>为了让 sequencer 精确决定 transaction 应该 delay 多久,每个 node 的 sequencer 组件需要 track 整个系统当前哪些数据是在内存的。这并不是 scalable 的。</p> <blockquote> <p>If global lists of hot keys are not tracked at every sequencer, one solution is to delay all transactions from being scheduled until adequate time for prefetching has been allowed.</p></blockquote> <p>在无法知道的情况下,就无脑 delay 呗。这样会导致所有事务 latency 增加。或者是让 scheduler 来决定 delay。总之,这也并不是一个解决了的问题。</p> <h1>5. Checkpointing</h1> <p>calvin 有个好处是不用写物理的 REDO log。只要重放事务的输入的 history 就可以恢复到当前状态。当然,重放整个所有的 history 是很 ridiculous 的,所以有 check point。</p> <p>一种 check point 模式是,replica freeze 并生成一个所有版本的 snapshot。这个只会每次一个 snapshot,并且是在 replica 上面,client 端不受影响。failover 的时候还是很有影响的,如果又要恢复,还要追数据的话。</p> <p>另一种 check point 模式是 Cao etal.’s Zig-Zag algorithm [10] ,没细看。</p> <p>最后一种是,如果存储引擎支持 mvcc 的情况。这种就没啥问题了。</p> <h1>6. PERFORMANCE AND SCALABILITY</h1> <p>略</p> <h1>7. Related work</h1> <p>Calvin 的关键点就是它用的 deterministic 的方式处理事务,各个副本就不会有差异。之前有些文章也往这个方向做,但是限制是单机,单节点。一方面是吞吐会受限,另一个是单点故障的恢复。</p> www.zenlife.tk/calvin.md 2019-01-27T20:29:42Z calvin paper reading 2019-01-27T20:29:42Z Arthur http://www.zenlife.tk tiancaiamao@gmail.com <p>JVM 的 bytecode 指令集特别非常有规律,很容易记下来。</p> <h2>分类</h2> <p>JVM 是一个基于栈的虚拟机,所以 bytecode 指令集设计也是围绕着栈虚拟机相关的的操作。</p> <p>首先是对指令集进行一下分类。设计一个指令集,肯定各类不同指令会有不同目的的用途,比如:</p> <p>栈操作 控制指令 运算指令 对象操作 类型转换 常量 函数调用</p> <p>其中运算指令,就有算术运算,逻辑运算,位运算,大小比较等。Java 是面向对象的语言,对象操作相关的指令也是必须的,另外,还一些围绕数组相关操作的指令。</p> <h2>记关键词</h2> <p>分类完毕之后,接下来是记忆关键词。</p> <p>栈操作: push pop load store dup swap</p> <p>控制指令: return ret goto if_cmp if nop jsr lookupswitch tableswitch throw</p> <p>数组操作: newarray arraylength load store multianewarray</p> <p>算术运算: add sub mul div rem neg</p> <p>逻辑运算: and or</p> <p>位运算: shl shr xor</p> <p>比较运算: cmp</p> <p>类型转换的: checkcast x2y wide</p> <p>对象操作:new getfield putfield getstatic putstatic instanceof</p> <p>函数调用:invokedynamic invokeinterface invokespecial invokestatic invokevirtual</p> <p>常量: const ldc</p> <h2>找规律</h2> <ol> <li>类型规律</li></ol> <p>不少指令是加了一个 a 开头的。比如load 不是 load,而是 aload,从局部变量中 load 一个值到栈上。store 也是 astore,将栈上的值存储到局部变量。还有 areturn。</p> <p>类型变换的,都是 x2y 形式,比如 double 转 float,就是 d2f。int 转 char,就是 i2c... 只要把类型记下来,专换操作非常好记。类型有 byte short int float double char。</p> <p>不同类型操作以各类符号开头。比如 load 就是 fload dload 分别用于 float 和 double 类型。布尔是 b 开头,array 是 a 开头,所以很容易想到,baload 是从 array 里面 load 一个 bool。int 是以 i 开头,相应的指令 istore iload ior iand irem 等等。</p> <ol> <li>缩写规律</li></ol> <p>有些指令可以把操作数跟指令连写,简化成一条指令。形式都是 <code>xx_&lt;n&gt;</code>。比如 <code>aload_1</code> <code>dload_2</code> <code>iconst_i</code> 这样子。</p> <p>函数调用的指令都是以 invoke 开头,不同的调用方法对应到不同的指令。类的静态方法是 invokestatic,接口调用是 invokeinterface。实例方法是 invokespecial,它也处理基类构造,实例初始化。</p> <h2>抓重点</h2> <p>对于最常用的,优先记忆。像栈操作的,都很频繁。然后是控制指令,if goto return 这种。然后是 invoke。</p> <p>剩下就是边边角角的特殊指令了,用的时候查也没关系,像 lookupswitch,monitorenter</p> <p>最后是<a href="https://docs.oracle.com/javase/specs/jvms/se7/html/jvms-6.html#jvms-6.5">指令集手册</a>,对照着看。</p> www.zenlife.tk/jvm-instructions.md 2019-02-09T13:26:42Z JVM 指令集快速记忆 2019-02-09T13:26:42Z Arthur http://www.zenlife.tk tiancaiamao@gmail.com <p>Amazon Aurora: Design Considerations for High Throughput Cloud-Native Relational Databases</p> <h2>Abstract</h2> <p>aurora 是一个 OLTP 的关系型数据库。这篇 paper 里面写了架构和设计时的考量。高吞吐的数据处理瓶颈,已经从计算和存储,转移到了网络。这是 paper 一个重要观点。</p> <blockquote> <p>We believe the central constraint in high throughput data processing has moved from compute and storage to the network.</p></blockquote> <p>aurora 提出了一个新颖的架构,将 redo processing 推到一个多租户,scale-out 的存储服务里面。这样做不仅节省了网络带宽,而且可以快速故障恢复。</p> <p>划几个重点</p> <ul> <li>它目标其实是解决多租户 scale-out 问题</li> <li>认为网络是瓶颈,这个架构可以减少网络的代价</li> <li>share storage,出故障了直接从副本拉起来,上层无状态</li> <li>log as database</li></ul> <h2>1. Introduction</h2> <p>IT 基础设施现在移到云上去了。现代的分布式云服务,通过计算存储分离,通过存储 replicate 到多个节点上面,以获得弹性和扩展性。</p> <blockquote> <p>In modern distributed cloud services, resilience and scalability are increasingly achieved by decoupling compute from storage and by replicating storage across multiple nodes.</p></blockquote> <p>在多机群的环境下,IO 要跨很多节点和很多 disk,导致传统数据库在这种场景下会遇到 IO 瓶颈(它这里指的应该是在云服务上面的虚拟存储)。</p> <p>database 操作之间,需要同步,导致 stall 和上下文切换。然后会有缓存的伪共享(false sharing)问题。</p> <blockquote> <p>In this paper, we describe Amazon Aurora, a new database service that addresses the above issues by more aggressively leveraging the redo log across a highly-distributed cloud environment.</p></blockquote> <p>redo log 分离是 aurora 架构设计的关键改动。下层是一个虚拟分片的 redo log 存储服务,上层是多租户 database 实例,两者之间松散耦合。</p> <p>每个 instance 保留传统数据库的 kernel 部分,包括 query processor,transactions,locking,buffer cache,access 和 undo 管理。挪到存储服务里面的是 redo logging,持久化存储,crash recovery,以及备份和恢复。</p> <p>相对传统数据库的优势:</p> <ol> <li>存储层是独立的能容错且自愈的服务,并且跟上层是分离的,因此数据库不会受到相关问题影响,下层对上层屏蔽了这类错误</li> <li>只写 redo log,可以将网络 IO 减少一个数量级</li> <li>将备份和 redo 恢复等功能变成了持续的异步写整个分布式存储(这些在传统数据库里面至关重要,往往是同步且 expensive 的)</li></ol> <blockquote> <p>Second, by only writing redo log records to storage, we are able to reduce network IOPS by an order of magnitude.</p></blockquote> <h2>2. DURABILITY AT SCALE</h2> <p>数据写进去了,必须是持久化的。</p> <h3>2.1 Replication and Correlated Failures</h3> <p>instance 可以随时添加减少,而 storage 其实不应该随 instance 的生命期动态变化。否则,storage 的 fail 就必须通过副本做容错。在一个系统里面,每一层都有自己的错误风险率,节点,磁盘,网络等等。分离存储是让风险降低的。</p> <p>用 quorum 做,要保证一致性,必须满足 <code>R+W &gt; N</code> 。这样读到的数据中一定会包含最新的版本。要避免写冲突,写必须写入超过半数 <code>W &gt; N/2</code>。常见是 3 副本,写 2 份读 2 份。</p> <p>论文里面认为,3 副本不够。跟 amazon 内部的AZ (availability zone) 配置环境相关。他们是 6 副本的,写 4 份读 3 份。</p> <h3>2.2 Segmented Storage</h3> <blockquote> <p>We instead focus on reducing MTTR to shrink the window of vulnerability to a double fault.</p></blockquote> <p>关注的是平均修复时间 MTTR(Mean Time to Repair)。为了获得较低的 平均修复时间,将 database volume 切成小的固定大小的 segment,当前是 10G 每个 segment。6 副本形成一个 PG(Protection Group),所以每个 PG 是 6 个 10G 的 segment。分散到 3 个 AZ 里面,每个 AZ 2 个 segment。storage volume 是一系列 PG 串起来,物理上丢到存储节点集群中。PG 是随着 volume 的增长而分配,目前支持到 64TB。</p> <p>segment 是独立的失败和修复的单元。10G 的 segment 可以在 10s 恢复。</p> <blockquote> <p>A 10GB segment can be repaired in 10 seconds on a 10Gbps network link.</p></blockquote> <p>只有在同一个 10s 窗口内发生了两次故障,并且再外加一次 AZ 故障才会导致 quorum 数量不够。这个发生率是极其低的。</p> <p>划重点,这一小节主要内容是说,将数据分割成小块,每个小块又有副本,同时恢复速度极快,以此来达到容错。</p> <h3>2.3 Operational Advantages of Resilience</h3> <p>只要系统的容错可以容忍长时的故障,那么它也是可以容忍更短时的故障的。aurora 的容错能力特别强,利用它来做一些维护的事情,可以简化问题。 比如说热点管理,直接把热点的 segment 标记为 bad,然后快速修复就可以自动迁移到其它节点上面。甚至连操作系统打补丁,软件升级之类的,都可以这么干。滚动升级,一次处理一个 AZ。</p> <h2>3 THE LOG IS THE DATABASE</h2> <p>这一节解释为什么传统数据库,在分片 + 副本 的存储系统里面,会造成网络 IO 问题以及同步写阻塞。然后解释 aurora 为什么把 log 拿出去做进存储服务里面,以及这么做为什么能极大的减少网络 IO。</p> <p>理解这一块,也就能理解他们为啥要开发 aurora 了。</p> <h3>3.1 The Burden of Amplified Writes</h3> <p>segment 6 副本,并且要写 4/6 份,可以获得极强的容错弹性。但是如果像传统数据库那么搞,每次写操作会生成非常多的 IO,放大特别严重。 并且 IO 的同步会造成 stall 以及高延迟。chain 的方式 replication 可以减少网络开销,但是同步的 stall 和 latency 会更高。</p> <p>传统数据库,同时要写数据页 (btree等),同时还要写 redo log(WAL)。redo log 记录 page 在修改前后的差别,在 page 的 before-image 上重放 log,就可以得到 after-image。</p> <p>为了高可用,还会有主从同步,主从同步又会把整个开销放大一倍。论文这里有张图特别形象,写 log,写 binlog,写 data,还要写元数据文件。 在 EBS 还有写 EBS mirror。</p> <p>写的步骤中好些还是要串行,同步写的,延迟很高。有很多不同类型的写入,他们其实是把重复的信息做了多遍。</p> <h3>3.2 Offloading Redo Processing to Storage</h3> <p>传统数据库里面,是先写 redo log,再将 log apply 到内存中的 page。事务提交 log 必须是写入的,不过数据页的修改可以推后。 在 aurora 中,跨网络唯一要写的只有 redo log。不会写 data 页等其它东西。log 会推到存储层,后台可以用 log 生成 database page。</p> <blockquote> <p>In Aurora, the only writes that cross the network are redo log records.</p></blockquote> <p>如果要等页生成完才返回,这个是比较耗时的。因此,是在后台持续地做物化(materialize)。后台物化过程只是可选的... 其实 the log is the database ... 存储层的物化过程,只是把 log 应用后生成一层 cache。</p> <p>物化跟 checkpoint 不同的是,checkpoint 是针对所有的 log chain,而 aurora 的 page 物化只针对该 page。</p> <blockquote> <p>Checkpointing is governed by the length of the entire redo log chain. Aurora page materialization is governe by the length of the chain for a given page.</p></blockquote> <p>(这里有一个疑问,如果只针对 page,不同 page 之间 log apply 到的位置不一样,那么它如何保证当前 snapshot 的全局一致性?)</p> <p>相比于 mysql 模式搭主从,在 aurora 模式下面只有主的 log 写入到存储服务,只要写够了副本数,比如 4/6 就算是 durable 了。从就可以直接用 redo log 去 apply 并更新缓存。</p> <p>在 30 分钟的测试表明,mysql 的写放大是 aurora 的 6 倍以上。这个时间内完成的 transaction 是 mysql 的 35 倍。</p> <p>将存储分离之后,将 checkpoint,data page 写入和备份等等后台处理工作都一并干掉了。crash recovery 时间缩短,可用性提高。</p> <blockquote> <p>In Aurora, durable redo record application happens at the storage tier, continuously, asynchronously, and distributed acros the fleet.</p></blockquote> <h3>3.3 Storage Service Design Points</h3> <p>存储服务的设计重心之一,是尽量减少前端写请求的延迟。大部分存储处理都是在 background 的。</p> <p>trade CPU for disk。存储节点如果前台的请求比较忙的时候,就不要 GC 了,除非是磁盘容量快满。当前台处理越重时,后台处理就减少,这跟传统数据库里面是不一样的。</p> <p>存储节点里面的各种活动,涉及以下步骤:</p> <ol> <li>接收 log 记录,加到内存队列</li> <li>持久化 disk 并且 ack</li> <li>组织 record 并判断 log 的 gap 有多久</li> <li>gossip 协议让 peers 去 fill gap</li> <li>将 log record 合并成新的 data page</li> <li>周期性地将 log 和新 pages 存到 S3</li> <li>周期性地 GC 旧版本</li> <li>周期性地校验 pages 的 CRC</li></ol> <p>只有 1 和 2 会影响 latency</p> <h2>4 THE LOG MARCHES FORWARD</h2> <p>这一节讲如何从 database engine 生成 log,使得持久化状态,运行时状态,以及副本都是一致的。并且如何不通过 2PC 来实现。</p> <h3>4.1 Solution sketch: Asynchronous Processing</h3> <p>每条 log record 都关联了一个逻辑序列号 Log Sequence Number(LSN),单调递增。维护 consistency 和 durability 两个位置,并且在收到 ack 之后会持续地将这两个位置往前推进。由于有些节点可能会 miss 一个或者多个 log record,所以它们之间需要 gossip 交换信息,并且补齐 gap。只要不发生 recovery 异常,直接可以根据 runtime state 读单节点,而不需要读 quorum。</p> <blockquote> <p>The runtime state maintained by the database lets us use single segment reads rather than quorum reads except on recovery when the state is lost and has to be rebuilt.</p></blockquote> <p>(疑问,这里的一致性是最终一致性么? 各个 instance 上面的 runtime state 并不是同步的呀!)</p> <p>数据库可能会有多个进行中的相互 isolated 的事务,最后的完成顺序可能跟初始顺序不一样。假设数据库 crash 了或者 reboot 了,各个事务是否 rollback 是相互独立的。track 哪些事务完成了一部分,需要 undo 的逻辑,还是留在数据库引擎里面,就像是写盘一样。但是重启时,在数据库能提供服务之前,存储服务需要确保,恢复不是按 user-level 事务,而是保证提供一个 uniform view。</p> <p>存储服务需要确定最高的 LSN,在它之前的 log 都是可用的。这个最高的 LSN 叫 Volume Complete LSN(VCL)。恢复的时候,比 VCL 更大的 LSN 都丢弃。</p> <blockquote> <p>During storage recovery, every log record with an LSN larger than the VCL must be truncated.</p></blockquote> <p>(这里肯定有一致性相关的细节没展开)</p> <ul> <li>VCL (Volume Complete LSN): 由存储服务决定的最大的 LSN,在 VCL 之前的 log record 都是可用的。</li> <li>CPL (Consistency Point LSN): 由 database 给 log record 打标签决定</li> <li>VDL (Volume Durable LSN): CPL 中最小的,小于等于 VCL。</li></ul> <p>这几个概念无非就是 log 同步到哪个位置了,以及 log apply 到哪个位置了,理解并不难。</p> <p>database 与存储之间的交互如下:</p> <ol> <li>每个 database-level 事务,会拆分成多个 mini-transaction(MTRs),必须有序并且是原子的</li> <li>每个 mini-transaction 由多段连续的 log records 构成</li> <li>mini-transaction 中的最后的 log records 是一个 CPL</li></ol> <p>(这个地方是切分要保证不会有事务成功一半)</p> <p>recovery 的时候,database 会问 storage service 确定每个 PG 的 duration point。并且会用它确定 VDL,大于 VDL 的 log record 需要 truncate 掉。</p> <p>(也就是事务写入一半时没有回滚,在这里会把多写入的给 truncate 掉)</p> <blockquote> <p>On recovery, the database talks to the storage service to establish the durable point of each PG and uses that to establish the VDL and then issue commands to truncate the log records above VDL.</p></blockquote> <h3>4.2 Normal Operation</h3> <h4>4.2.1 Writes</h4> <blockquote> <p>In Aurora, the database continuously interacts with the storage service and maintains state to establish quorum, advance volume durability, and register transactions as committed.</p></blockquote> <p>database 会持久地跟 storage service 交互,维持状态确定 quorum,往前推进 durability 位置,并将之标记为 committed。</p> <p>database 会为 log record 分配唯一,有序的 LSN,新分配的 LSN 大于当前的 VDL,并小于最大 LSN 限制 LSN Allocation Limit (LAL)。</p> <p>注意每个 PG 的每个 segment 里面只有一个 log records 子集,只影响 segment 里面的 pages。每个 log record 里面包含了一个后向链接,识别该 PG 的前一个 log record。这个 backlink 可以确认 segment 的完备性,作为写满一个 segment 的标记。存储节点会 gossip 会用到这个信息,来确定缺失的 log records。</p> <h4>4.2.2 Commits</h4> <p>在 aurora 里面,事务 commit 完成是异步的。收到客户端的 commit 后,处理线程把相应的 commit LSN 记录下来,等 VDL 大于等于这个位置的时候,就是完成了。worker 线程并不会同步地等等 commit 完成,而是继续处理其它的。</p> <h4>4.2.3 Reads</h4> <p>读的时候有 page 缓存,只有当缓存不中,才会到 storage IO 请求。buffer 满的时候就要替换旧的。在传统数据库里面,如果 page 是 dirty 的,就需要刷到磁盘里面。而在 aurora 里面不是在刷新 dirty page 的时候写入,它强制要求 buffer cache 必须一直是最新的。 这个很简单,就是保证 page LSN 是大于等于 VDL 的。</p> <p>正常情况下,database 并不需要保证读 quorum。读的时候要确认一个 read-point,代表请求发起的时候的 VDL。(说白了,这里就是历史读)</p> <blockquote> <p>When reading a page from disk, the database establishes a read-point, representing the VDL at the time the request was issued.</p></blockquote> <p>注意这里读的时候有 MTR 切割,事务必须看到的是完整的。</p> <p>database 跟底下 storage node 交互过程中,它是知道哪些 segment 中可以读到它要的数据的(read-point 位置跟 SCL 对比)。</p> <p>database 能知道哪些读操作当前正在执行中,所以它可以对每个 PG 计算最小的 read-point。相互之前 gossip 之后,就可以知道一个整体最小的 read-point。那么 GC 就可以用这个信息做物化。</p> <p>并发控制跟传统数据库里面是一样的。</p> <h4>4.2.4 Replicas</h4> <p>在 aurora 中,可以单个 writer,15 个 read replicas mount 到单个共享存储上面。加读副本是没有额外开销的,既不会增加存储量,也不会增加磁盘写入。</p> <p>为了减少延迟,writer 生成的 log stream 除了发到存储节点,还会发给 read replicas。</p> <p>replica 在应用这些 log 的时候要注意,log records 的 LSN 必须大于等于 VDL 才能 apply;注意一个事务产生的多条 log 在应用时的原子性。</p> <h3>4.3 Recovery</h3> <p>传统数据库里面,用 checkpoint + WAL 来做 recovery。page buffer 是只是 cache,要么多数据,要么差数据。必须在 checkpoint 基础上重放 WAL 才得到正确的 page。多做 checkpoint 可以缩短恢复过程,但是做 checkpoint 又会对前台业务产生影响。</p> <p>在 aurora 里面,redo log 的 apply 是丢到了存储节点里面,并且是在后台时刻运行。database 恢复的时候,就是直接从存储服务上面恢复,速度非常快。</p> <p>database 刚起来恢复运行时状态的时候,要读 quorum,这样来满足一致性。计算 VDL,并且 truncate 掉做到一半的事务。</p> <blockquote> <p>The database still needs to perform undo recovery to unwind the operations of in-flight transactions at the time of the crash.</p></blockquote> <p>database 可以在 online 之后,再去 undo recovery.</p> <h2>5 PUTTING IT ALL TOGETHER</h2> <p>这里给了一个整个的图。图里面非常清楚了,这一节废话比较多。</p> <p>aurora 是在社区版 mysql 上面改的。先用了一段介绍了一下 mysql innodb 的执行流程。可以略过。</p> <p>在 aurora 中,redo log records 组织成 batches,batches 会按每个 log record 所属的 PGs 进行 shard。 redo log records 表示的是每个 MTR 中必须被原子执行的修改。读副本从 writer 获取事务的 start 和 commit 信息,并用这些信息支持快照隔离。并发控制是完全在 database engine 实现的,不影响存储服务。存储服务提供了一个统一的视图,就跟从 innodb 里面读数据一模一样。</p> <p>每个集群的 database instance 包括一个 writer,零到多个 reader。</p> <h2>6 PERFORMANCE RESULTS</h2> <p>sysbench 测试,使用的实例越牛X 性能就越牛X。</p> <p>在 database size 为 100GB 时,写入的 tps 是 mysql 的 67 倍。</p> <p>当数据库大小增加时, aurora 写入性能降低了...这个为啥?</p> <h2>7 LESSONS LEARNED</h2> <p>写了一些用户经验的东西,或者说叫客户需求的东西。</p> <h2>9 CONCLUSION</h2> <blockquote> <p>The big idea was to move away from the monolithic architecture of traditional databases and decouple storage from compute.</p></blockquote> <p>存储计算分离。</p> <blockquote> <p>With all I/Os written over the network, our fundamental constraint is now the network.</p></blockquote> <p>网络是瓶颈。</p> <p>使用 quorum 模型做容错,log processing 降低 IO 负担, asynchronous consensus 消除多阶段的同步协议的开销,离线的 crash recovery,checkpoint 做在分布式存储里面。</p> <p>最终,这个方法简单了架构,降低复杂性,scale 很好。</p> www.zenlife.tk/aurora.md 2019-02-10T20:04:42Z aurora paper reading 2019-02-10T20:04:42Z Arthur http://www.zenlife.tk tiancaiamao@gmail.com <p>公司内部在做 TiDB Internal 系列培训,弄完 PPT 发现,写篇博客也挺有意思。就事务这个话题讲一讲。</p> <h2>第一部分 MVCC</h2> <p>为什么要基于 MVCC 呢?因为 MVCC 的粒度比锁要好。普通的锁是全部操作都阻塞的;读写锁中,读不阻塞读,不过读会阻塞写;有了 MVCC 之后,大家写不同的版本,相互之间不干扰。读旧版本,不会阻塞写入,这就比读写锁的冲突粒度更低了。</p> <p>然后说说 CAS。CAS 这个东西,就是一个原子操作。即使并发执行,也只会有其中一个成功。比如 <code>CAS(v0 -&gt; v1)</code> <code>CAS(v0 -&gt; v2)</code> 同时执行,最后的结果要么是 v1 要么是 v2。</p> <p>那么 MVCC + CAS 一起使用,会有什么启发呢? 我们可以发现事务的本质!普通的 CAS 概念只会改一个 variable,而事务会涉及许多个修改。一旦我们把 MVCC 概念也加进来,所有修改之前的值 MVCC 版本叫 v0,修改之后叫 v1。那么事务就是 CAS(v0 -> v1)。</p> <p>TiDB 事务模型是按 percolator 实现的。事务启动要拿一个 start ts,等到提交要拿一个 commit ts。其实分别对应着 v0 和 v1。也就是用 start ts 拿到一个 snapshot,这个就是 MVCC 的版本 v0。然后在 snapshot 的基础上修改,修改之后其实是 MVCC 的版本 v1, 用 commit ts 提交时,做的事情就是 CAS(v0 -> v1)。确认 start ts 到 commit ts 之间,没有事务冲突,否则这个 CAS 就会失败了。</p> <pre><code>Get snapshot at start ts Check no modification at commit ts Reject the operation if CAS fail (transaction conflict) </code></pre> <p>所以我们可以得出:<strong>这个事务模型的本质,其实就是 MVCC + CAS</strong></p> <p>接下来,是如何实现这个 <code>CAS(v0 Snapshot =&gt; in-memory modification =&gt; v1)</code> 操作。也就是一个事务在 start ts 和 commit ts 之间,没有同时被其它事务修改过。这个理解很容易,在 start ts 和 commit ts 之前划一条线,没有 overlap 就可以了:</p> <p>这种是不行的:</p> <pre><code>T1 start ts -----------------------------&gt; commit ts T2 start ts ----&gt; commit ts </code></pre> <p>同样,这种也是不行的:</p> <pre><code>T1 start ts --------------------&gt; commit ts T2 start ts -------------------------&gt; commit ts </code></pre> <p>都有重叠了。</p> <p>实现这个检测,就是在写操作的时候,对数据上锁。所谓数据上锁,就是在数据的值上加一个特殊的标记就行了,有这个标记,表示有人正在写,锁住了这个值。读操作是不给数据上锁的,因为读不会发生在数据上加标记这个动作(否则读就要写东西了)。</p> <ul> <li>写写冲突:后面的写操作,会发现前面写操作留下的锁</li> <li>写读冲突:后面的读操作,会发现前面写操作留下的锁</li> <li>读读操作:读读不冲突,没锁也没关系</li> <li>读写冲突:这是最麻烦的。因为读的人不会在数据上加锁,所以写的人不知道有人在读</li></ul> <p>看这个时序:</p> <pre><code>T1 start ts ----------&gt; t1 write ----&gt; t1 commit T2 start ts -------&gt; </code></pre> <p>T2 在 start ts 的时候读,读到 T1 开始之前的值(T1 write 还没执行,即使执行了, T1 的改动也还在本地内存未提交)</p> <p>T1 在 write 的时候,不知道 T2 有读过(读不会上锁,检查不到)</p> <p>这种读写冲突是在 commit 的时候去检查:</p> <pre><code>T1 start ts --------------------&gt; commit ts T2 start ts -------------------------&gt; commit ts </code></pre> <p>T2 在 commit 的时候,它会发现 T1 已经 commit 过了(数据版本变了),所以它会 abort 掉。</p> <p>最后一个问题,是 tidb 事务是乐观锁机制。所以 start ts 到 commit ts 之间发生的 modification,都是记录在内存,直到提交才写到 tikv。那么 lazy commit 的正确性呢? 在 commit 时检查也仍然是没问题的。</p> <h2>第二部分 2PC</h2> <p>为什么需要 2PC 呢? 为了保证原子性。前面说到了,TiDB 事务本质是是一个 MVCC + CAS。到了 CAS 这一步(其实也就是事务 commit),TiDB 一个事务的改动,是涉及到多个 region (raft group) 数据的。 假设修改其中一个 region 成功了,修改另一个 region 失败了,那就麻烦了。所以必须保证多个 region 同时能成功,原子性破坏,所以需要 2PC。</p> <p>2PC 的第一个阶段是 Prewrite 阶段,预先把要改的数据,写到各个 region 上面。每个 region 就是 participant 的概念。如果所有的 region 写入都能成功,那么就进入到第二阶段,Commit 阶段。</p> <p>Prewrite 已经把数据都写好了,但是有一个标签,说这些数据还不是可见的,走到 Commit 阶段就是说,所有 participant 选票都 OK, 那么提交吧。提交也就是把数据标签变成可见。</p> <p>但是这里同样有一个问题:把所有标签变成可见这一步,也需要是原子的步骤。怎么实现?这时我们就有了 primary key 的概念。每个事务,指定一个 primary key 作为事务的代表。如果 primary key 是提交成功的,事务就成功。如果 primary key 失败了,事务就失败。</p> <p>primary key 只有一个 key,所以只落在一个 region 上面,不会出现一半成功一半失败的问题,这个是由 raft 来保证的。</p> <p>每个 region 是多副本的,这里其实也涉及到副本间的一致问题,raft 也是一个很大的话题,就不在这里单独展开了。总结一下,就是</p> <ul> <li>2PC 保证一个事务分散在多个 region 上的数据写入原子性,对事务选取一个 primary key,primary key 不会涉及多 region 问题</li> <li>raft 保证同一个 region 上面多副本的一致性</li></ul> <p>注意,percolator 里面是没有 coordinator 的。这里会涉及到 primay key 提交成功了,事务的其它修改还没成功,或者是 primary key 提交失败了,需要清理的工作一类。</p> <h2>第三部分 TSO</h2> <p>略</p> <p><a href="https://docs.google.com/presentation/d/19ARcGdA0majRipJvEPfaES8eUyM-lunS_mPwipic8e4/edit?usp=sharing">PPT link</a></p> www.zenlife.tk/tidb-transaction-internals.md 2019-04-06T11:35:42Z TiDB Transaction Internals 2019-04-06T11:35:42Z