表格存储中的事务
1. 背景
分布式表格存储系统在业界拥有广泛的应用场景。Google 先后发布了 Bigtable 和 Spanner 两代分布式表格存储系统,承接了其公司内部和外部云服务中的所有表格存储需求,其中 Bigtable 的开源实现 HBase 在国内外公司中都得到了广泛的使用。
在字节跳动,随着头条全网搜索项目等业务的启动和发展,业务需要一个全局有序、容量巨大同时性能高效的表格存储系统以存储整个互联网中所有链接和网页,并保证互联网上发生的所有变更都被能实时的更新到表格存储系统中。我们团队最初使用 HBase 提供服务,比如搜索场景在全网链接关系的实时更新需求下需要提供足够高的可用性和足够低的延时,由于其数据量极其庞大所以会创建极多的数据分片,集群的整体尾延时和可用性会随着数据分片实例数的增多而造成指数级别的恶化,因此对每一个分片实例的延时和可用性提出了更高的要求。但由于 HBase 存在尾延时较高和可用性较低的问题,并不能满足我们的需求,于是我们团队自研了第一代基于 Bigtable 数据模型的分布式表格存储系统 Bytable。
类似于 Google Percolator 中提到的问题,我们的业务也有跨分区数据一致性的需求,于是调研、设计并实现了我们的第一代分布式事务。本文介绍了系统的关键设计点以及一些原创优化。在第 2 节中介绍了事务相关的一些基础概念以及我们的选择。第 3 节介绍了数据模型和架构。第 4 节介绍了系统的设计和优化。第 5 节展望了下未来演进的方向。第 6 节加入了一些 FAQ,方便读者深入理解设计细节。总体上来讲干货多多,相信读者一定会有一个非常好的体验。
2. 事务简介
2.1 模型
目前常见的分布式事务模型有大致 4 种:
- 2PC(Prepare-Commit/Abort)
在分布式的情况下,没有办法一次性地使得分布式系统中牵涉的不同实例之间达到原子的状态。试想假设发起者所在的服务试图一次性一阶段执行涉及 s1 和 s2 两个服务的修改操作 op1 和 op2,那么在这一次性执行的过程中是没有办法保证 op1 和 op2 的原子性的,很可能或者 s1 或者 s2 由于各种原因失败了,这是分布式下 1PC 必然做不到的事情。那么 2PC(两阶段提交)怎么做到?
两阶段提交执行分为两个阶段,第一阶段 prepare,预写数据,第二阶段 commit,提交可见。具体的牵涉节点有两种角色,一个是协调者,一个是参与者。协调者负责整个事务的生命周期管理,调度管理整个事务的执行,参与者为相应的事务相关的操作节点。
a. 选出一个事务协调者,负责整个事务的调度和执行,协调者选出一个状态决议服务(可能是他自己,也可能全局一个不需要选)维护当前事务的状态,比如 TransactionManager™。
b. 第一阶段:协调者先向参与者发送 prepare 请求,其中不仅有相应的 operation,还携带了事务状态决议服务的信息。把要做的修改预先写下但不对外生效,并记录状态决议的必要信息(比如 TM 信息以及事务 id)。
c. 第二阶段:协调者收集参与者的 prepare 执行结果,如果成功则向 TM 提交事务,并向参与者异步(同步)发送提交请求,如果失败则向 TM 取消事务。
[外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-whOGU99P-1596444733970)(https://upload-images.jianshu.io/upload_images/19116566-28e1da8d2767a838?imageMogr2/auto-orient/strip%7CimageView2/2/w/1240)]
为什么 2PC 能保证原子性?因为 prepare 的时候不仅持久化记录下了操作,还记录了事务状态决议的必要信息。即便在过程中有参与者或者协调者挂了,我们都可以最终根据事务状态信息决议自己该提交还是回滚。而分布式的情况下高可靠的系统一般实例都是有多个副本,能保证高可用,所以基本没有单点问题。
- 3PC
3PC 相比 2PC 增加了超时机制以及资源锁定预留阶段,其虽然解决了标准 2PC 的阻塞等问题,但是增加了 latency,并且依然存在数据不一致问题,而且其超时自动提交机制很鸡肋,数据不一致的来源之一,分布式下有多副本机制基本上没什么应用的地方。对于 2PC 和 3PC 存在的宕机等问题导致出问题的场景,根本上还是要靠分布式去保证,比如逻辑上通过 Quorum/Raft/Paxos 构造多副本,物理上通过 disk、server、rack、AZ(DC)、region 等做隔离。
- TCC(Try-Confirm-Cancel)
TCC 其实就是 2PC 的一种特化实现,Percolator 一样也是 2PC 的一种特化实现。TCC 的 try 阶段相当于 2PC 的 prepare 阶段,confirm 相当于 2PC 的 commit,cancel 相当于 2PC 的 abort。2PC 的实现会与具体系统关联性较强,也就是对内高内聚,对外的表现更加透明、低耦合。而 TCC 设计上讲究的是让业务做更多,业务可以做决策,TCC 本身只是一个框架,资源层不同的参与事务的子系统只要实现 TCC 的接口即可,业务基于 TCC 框架调用接口就能实现子系统间的事务。一般 TCC 的子系统接口会具有幂等性或者支持事务,可以方便业务实现。由于 TCC 把 2PC 的决策过程放到了业务层中,而资源层子系统又会暴露较多信息给业务,这样业务其实就可以更好地跟踪事务状态以作出决策。比如对于 commit 超时这一结果的判定,2PC 的话只能通过业务读取结果去判定事务状态,而 TCC 业务可以直接通过查看事务 id 或者查看相应事务 id 在各个子系统中的状态去判定事务状态。
- 消息表
基于 MQ 的方式就是消息表的一种具体特化实现,这种机制的事务显而易见就是异步的,遵循的不是 ACID 而是 BASE 理论,讲究最终一致性,某些 case 下的弱一致性。通过 MQ 去做解偶,并利用 MQ 的可靠性等机制简化实现。
2.2 一致性
上面基于 BASE 理论的属于柔性事务,其对应的就是刚性事务。刚性事务(如单数据库)完全遵循 ACID 规范,即数据库事务正确执行的四个基本要素:
- 原子性(Atomicity)
- 一致性(Consistency)
- 隔离性(Isolation)
- 持久性(Durability)
柔性事务(如分布式事务)为了满足可用性、性能与降级服务的需要,降低一致性(Consistency)与隔离性(Isolation)的要求,遵循 BASE 理论:
- 基本可用性(Basic Availability)
- 柔性状态(Soft state)
- 最终一致性(Eventual consistency)
同样的,柔性事务也部分遵循 ACID 规范:
- 原子性:严格遵循
- 一致性:事务完成后的一致性严格遵循;事务过程中的一致性可适当放宽
- 隔离性:并行事务间不可影响;事务中间结果可见性允许适当放宽
- 持久性:严格遵循
2.3 我们的选择
对于 2PC 和 TCC,它们的可柔可刚,都看具体实现。一般 TCC 的实现偏柔性 2PC 偏刚性,而消息表基本都是柔性的,基于 MQ 做刚性事务不太现实也不太合适。
Bytable 作为基础的表格存储服务,业务上一般对一致性的要求比较高,这里如果造成数据不一致上层业务很难做,也会有较大的放大效应,综合来看我们选择了基于 2PC 的 Percolator 模型来做 Bytable 的分布式事务。
Percolator
Percolator 是一个 2PC 的事务模型,主要通过 lock 保证事务的原子性,通过 MVCC 保证隔离性。通过对事务涉及的数据扩展两个列来实现 lock 和 MVCC。通过下面 Percolator paper 中的数据模型图可以有一个直观的印象:
其中 data 列是数据列;lock 列为事务状态列;write 列为 commit 标志列,其 value 有一个指向 data 列的时间戳来表达对应提交的版本。
对于事务中的写,Percolator 以事务发起的 client 作为 coodinator,client 首先缓存下所有的 writes,直到用户发起 commit 之后再执行具体的 2PC。
Client 先进入 prepare 阶段,先选出任意一个 write 作为 primary,不仅本身是一个冲突安全保证手段,同时也是决议事务状态的标志。先对 primary 执行 prewrite 动作,就是在 data 列写入数据,且在 lock 列写入状态信息,如果没有版本和状态冲突,则 prewrite 成功。如果 primary 成功了则开始对 secondaries(其他的)进行 prewrite(可并行),如果都 prewrite 成功了,则进入 commit 阶段,否则任何一个失败则回滚。这里需要注意的是 secondary row 的 lock 的 value 记录了 primary row 的信息,也就是说通过其 lock 能找到 primary 来进行状态决议。
Commit 阶段先对 primary 进行 commit,就是在去掉 lock,在 write 列写入一条记录,如果成功了则异步对 secondaries 进行 commit(可并行,为什么可以异步可以自己思考一下),如果 primary commit 失败了则回滚。
理解了 Percolator 的模型后,大家就知道实际上,Percolator 是没有传统上的 TransactionManager 来管理事务的,将原本 2PC 中 TransactionManager 管理的集中化的事务状态信息分散在每一行的数据中(每个事务的 primary row 里),对于未决的情况通过 lock 的信息就能找到 primary row,进而就能确定这个事务的状态。所以可以看出其实整个表格存储服务相当于一个大的事务管理器。
3. 数据模型和架构
上面介绍了事务的基本概念以及 Percolator 的原理,这里介绍一下数据模型和架构细节。Bytable 是有多版本的能力的,用户可以自己指定版本号,所以这里采用版本号来存储数据的时间。下表每一个列对应数据层的 3 个列,这里 value 相对于 Percolator 做了细化,主要是针对异常以及 MVCC 做的设计。
3.1 列的逻辑数据模型
- lock value
flag 触发上锁的动作,类型为 put、delete、lock;“pk"即 Percolator 中的 primary key,目前 pk 是包含了表信息的以支持跨表事务;“deadline"是 lock 的生命线,用于清锁超时等判定;min_commit_ts 用于优化读事务,写不阻塞读,committing 用于配合防止数据比较热不停 commit 失败的情况;“has_short_value"是针对 short_value 为 empty 时的效率优化(load prewrite 的时候不需要额外 load data 列来判断 data 是否为空);“short_value"是针对小 value 的优化,不写 data 列,直接放到 write 里。
- write value
flag 类型为 put、delete、lock、rollback;short_value 参考 lock value。
3.2 逻辑模型与 Table 的映射
1. Timestamp(ts)的映射
Bytable 的 cell 数据是有 ts 的,所以上面列的逻辑数据模型 key 编码时的 ts 是不存在的,直接对应到 Bytable cell 数据的 ts。
2. 列的映射
Bytable 数据模型和 Bigtable/HBase 是一致的,有 ColumnFamily(cf)和 ColumnQualifier(cq)的概念,每一个 cq 是一个独立的维度,可以理解为关系型数据库的列,因此这里需要为每一个用户创建的 cq 扩展出 data、write。对于 lock,我们考虑其主要作用是记录活动事务的状态,有效数据量不大,为了提高扫 lock 的性能,单独放到一个 lock cf 中。
3.3 架构
- TSO(Timestamp Oracle)
全局单调递增的时钟管理服务,Raft 多副本高可用。
- TransactionMaster
负责事务的 admin 管理,统计,同时也负责 GC task 的生成和分配。
- TransactionProxy
无状态分布式事务代理服务,负责事务逻辑处理(包括 2PC),同时也负责执行具体的 GC task。
- BytableMaster:
管理服务,可以获取到分区信息。
- BytableTabletServer
数据存储服务。
3.4 主要流程
a. Application 通过 TransactionProxy API 创建事务。
b. TransactionProxy 创建事务,建立 session,获取 start_ts。
c. Application 发起读、写。
d. TransactionProxy 收到读请求后直接处理,收到写请求后缓存下来(也有后台线程刷)。
e. Application 提交事务。
f. TransactionProxy 开始执行 2PC,如果中途失败了执行 rollback,返回 Application 结果。
4. 设计和优化
Percolator 论文本身只介绍了其基于 Bigtable 实现 2PC 分布式事务的核心算法,并没有提及一些优化点以及 GC 相关的设计。我们这里介绍了相关的概念和我们所做的核心优化以达到追求极致的目的,这也是我们和其他基于 Percolator 实现分布式事务的最大不同。
4.1 死锁/活锁
本设计可以极大地提升事务的性能,特别是对于大事务,由串行化 prewrite 变成了完全并行化进行。下面进行介绍。
不同事务之间可能出现相互依赖的死锁情况。比如两个事务 t1 和 t2 都依赖 a 和 b 两个资源,t1 先拿到了 a 的锁,同时 t2 拿到了 b 的锁,这样就陷入了死锁。
这种情况对于本事务模型存在吗?显然是不存在的。Percolator 对于并行事务的写冲突的处理办法是遵循 First-Writer-Wins(FWW)原则来防止 lost-update 异常,又因为 Percolator 都是事务接口访问存储层的锁(当前 bytable-txn 的设计类似,txn-proxy 通过 CAS 接口访问 bytable),会通过这种类似乐观的方式发现锁冲突并取消自己,所以事实上 Percolator 是不存在死锁的情况的。但是却存在活锁的情况,就是 t1 和 t2 相互冲突来回取消。
对于上面提到的活锁,一个 naive 的解决办法就是每一个事务都对其所有 buffer 的 row 进行全排序且按序 prewrite,这样 first prewrite 的 txn 就会 win。但存在的问题一个是 one by one 的方式性能太差,一个是存在大事务 buffer 过大的资源限制和排序性能损耗。
本设计采用的办法是参考了常用的解决死锁的两个方法 Wait-Die 和 Wound-Wait,采用的是 **Wound-Die **的方式。即对于任意一个事务,如果在 prewrite 的时候遇到比它年轻的(新的,出生时间大的)事务加了 lock,则尝试直接强制 resolve lock(不等);反之则 abort 自己。可以看出,解决的原理就是对并行事务进行优先级的排序。通过这样的设计,我们做到了一个事务的 prewrite 阶段可以所有 rows 全并行进行,降低了事务的 latency。为什么不采用 Wait-Die 或者 Wound-Wait?因为 Percolator 为了通过 FWW 的方式解决 lost-update 异常,所以 wait 的事务后续必然会冲突,没有任何 wait 的意义。
4.2 写不阻塞读
Percolator 的设计下,当一个读事务遇到 lock 的时候需要等待 resolve lock 之后才能进行读取,否则可能会破坏快照一致性语义,因为有可能读的时候有其他已经获取了 commit_ts 且小于这个读事务的 start_ts,但是请求还没有发起或者还在网络上。但是读等待 resolve lock 这样做带来的问题就是如果读遇到的 lock 是由于相应事务的 coordinator 挂了残留的 lock,那么这就会影响事务服务的可用性,而且如果是一个牵涉写的大事务,也一样会有这样了情况。在这方面业界的做法都差不多,但我们又为了避免活锁引入了一个额外的优化,具体做法如下:
通过给 lock 列加一个 min_commit_ts 解决这个问题。当读遇到 lock 的时候,就把 min_commit_ts 改成读事务的 start_ts + 1。当写事务 commit 的时候遇到由于 commit_ts expire 错误时修改 commit_ts 为 min_commit_ts 再进行 commit。这里可能存在的一个问题就是对于热读可能导致 commit 频繁失败重来,可以在 lock 列增加一个 committing 状态位,当 commit 发起失败是因为 commit_ts expire 导致的,则改 committing 状态为 true,如果处于 committing 了则不可以更改 min_commit_ts 了。
4.3 Prewrite 优化
有了 5.2 中提到的优化,为了追求极致的性能,我们对 prewrite 的流程进行了优化。标准做法是先把写入 buffer 下来,当 client 发起 commit 之后再进行 2PC,先进行 prewrite,成功之后再进行 commit。我们这里增加了异步 prewrite 的流程,prewrite 不需要再等待 client commit 才开始,而是有了 buffer 就可以直接做。特别地对 primary 进行了优化,即第一个写入的 row 就立即成为 primary 且立即进行 prewrite。这样的话事务的整个 prewrite 过程就不是 client commit 的时候再先 prewrite primary,之后再并行 primary secondaries,而是大概率在 client 发起 commit 之前 primary 就已经 prewrite 完成了,进一步优化了单个事务的 latency。
4.4 进程 crash/宕机优化
在实际的生产环境中,服务的可用性是非常重要的。服务器可能会由各种原因宕机,程序也有可能由于 bug 而 crash。由于有 5.3 中的优化,所以 client 在 commit 之前如果有写入,其宕机了或者挂了也会残留 prewrite 的 lock,如果协调者在 prewrite 过程中或者 commit primary 之前宕机了,同样会残留事务的 prewrite 的 lock。
对于 proxy(coordinator)宕机的场景,其实 client 可以有一个明确可决议的网络不可达或者超时错误,client 可以直接回滚,而不需要等待 lifetime 到期再 resolve lock。我们对此进行了优化,增加了一个 rollback 接口,同时会把 primary 返回给 client,当 client 判定 proxy 宕机时,会直接调用 rollback 强制回滚事务。
对于 client 宕机的场景,我们通过对事务的 session 提供 client lease 的机制来保证。Client 对其所有的事务通过向 proxy 发送心跳保活,一旦失联了 proxy 就会把相应的事务直接回滚。
4.5 分布式 GC
由于 MVCC 机制,随着服务的运行,会有越来越多冗余的旧版本数据存在,必须有垃圾回收机制对旧版本的无用数据进行清除。GC 设计的关键是找一个 safepoint,之后开始清除全局的所有 key 对应的 write 列 ts 小于 safepoint 的数据。
对于单条记录,GC 的过程主要分两个阶段:
- 准备阶段
事务的提交是 2PC,在 commit 的过程中是可能出现某个 secondary commit 过程中崩溃把 lock 和 data 留下的情况,这种情况是需要读请求去 resolve 的。在 GC 过程中如果遇到了 lock 时间戳在 safepoint 之前的这种锁,就必须进行 resolve,所有涉及 GC 的 row 都 resolve 之后才可以进入执行阶段。因为如果不 resolve,存在其 primary 的 write 记录被 GC 掉了导致这个未决议的 secondary 无法决议,出现数据不一致的情况。
- 执行阶段
GC 遇到类型为 put 的记录不能直接删除,因为有可能这是唯一一个记录,删除了数据就丢失了。GC 遇到类型为 delete 的记录也不能直接删除,需要删除比其 ts 小的其他记录后再删除本记录,因为如果其后面跟着一条类型为 put 的记录,这时候如果先删除当前 delete 记录,则后面的 put 就是可见的了,这是错误的。
目前我们采用的是分布式的 GC。主要有 3 点:
- GCMaster 会生成 GCTask 并分发给 GCWorker。但这里有一个问题就是我们对 GC 是有时效性的需求的,有的业务写吞吐很大,同一个 row 的更新频率非常大。如果 GC 时效性不好会严重影响后续的读性能,所以如何生成 GCTask 就非常重要了。我们通过自适应的算法来让系统自学习,根据初始 GCTask 统计信息来学习和优化 GCTask 的生成。
- GCMaster 负责生成 resolving_point 即下一个候选的 safepoint,GCWorker 负责 resolve resolving_point 之前的 lock,如果 resolve 完成,则更新 safepoint 为 resolving_point。
- GCWorker 通过和 GCMaster 心跳来同步信息,每个 GCWorker 会负责一些 partitions 的 GC 任务,GCWorker 每个任务会实时地采用最新的 safepoint 来做 GC。
4.6 共享事务
业务上有多个不同 client 共享一个事务的需求,所以我们在请求的 context 中加入了事务 id,事务 id 是全局唯一的,每一个事务 id 是 bind 到一个 proxy 实例,业务的不同 client 可以通过将同一事务的请求发送到同一个 proxy 实例做到共享事务,proxy 在收到请求之后会根据事务 id 来维护其上下文,同一个事务 id 的请求在一个上下文之内。
4.7 大事务的优化
大事务/长事务的主要特点就是事务牵涉的行数比较多,大概率会执行比较长的时间,由于标准 Percolator 的情况下写会阻塞读,可以看出大事务会显著影响性能。这里我们对大事务有几个优化,有一些优化在前面已经提到了,这里为了保证完整性,也会将它们列出来。
- 对于大事务 proxy 的内存是可能撑不住的,另一方面条目较多的情况下用户 commit 时 prewrite 阻塞时间较久。 目前我们的优化是异步分批次 prewrite,不需要等用户 commit,内部会根据情况异步 prewrite 数据,而不是都等到用户 commit 的时候再开始 2PC。
- 每一个事务根据大小都有一个默认的 ttl 为了防止事务的 coordinator hang 或者 crash。对于大事务会有 lifetime 大于初始 deadline,导致事务中途大概率会被 resolve 的情况。 通过 keep-alive 的方式解决这个问题。Coordinator 会启动一个异步线程对 primary 的 ttl 进行续约,同时也会结合 GCMaster 把 safepoint 统一起来,以防止因此带来的数据不一致和可用性问题。
- 大事务会影响读的可用性。因为 Percolator 的设计上目前读遇到锁是需要等待或 lock 超时进行 resolve 的,是不可以直接返回最新数据的,会出现幻读、不可重复读问题。 通过给 lock 列加一个 min_commit_ts 解决这个问题。当读遇到 lock 的时候,就把 min_commit_ts 改成读事务的 start_ts + 1。当写事务 commit 的时候遇到由于 commit_ts expire 错误时修改 commit_ts 为 min_commit_ts 再进行 commit。这里可能存在的一个问题就是对于热读可能导致 commit 频繁失败重来。可以在 lock 列增加一个 committing 状态位,当 commit 发起失败是因为 commit_ts expire 导致的,则改 committing 状态为 true,如果处于 committing 了则不可以更改 min_commit_ts 了。
- 大事务会影响 GC 的回收时效性。因为朴素的实现方式下 GC 需要先 resolve 所有在 safepoint 时间点之前的 lock 之后才可以进行 GC,否则有可能把 primary 的 commit(Percolator write 列) ROLLBACK/COMMIT 记录删掉了但 secondary 的 lock 还在进而无法决议了的问题。 我们目前的优化方案是每一个事务都要向 gc master 申请一个指定 ts 的 gc lock,gc master 的 safepoint 不可以大于所有 gc lock 的最小时间,当事务进入到 committing 状态时或者调用了 FreezeRead 接口(不会再有读了)就解锁。业务可以通过 FreezeRead 来显著地通过逻辑调整优化混杂大事务下的服务性能。
4.8 事务安全性
在 MVCC 下,需要有 GC 机制回收历史版本。这就引入了一个问题,比如当前有一个事务开始时间戳是 t2,它访问数据 rx。假设当前有 rx 的 2 个版本,一个版本是 t1 时刻的,一个版本是 t3 时刻的。这个事务的快照包含 t1 时刻的 rx,所以事务可能先读到了 t1:rx,之后 GC 执行了,safepoint 是 t3,就会把 t1:rx 回收掉,因为已经有 t3:rx 了,之后事务如果再次读取 rx 就读不到了,出现了不可重复读。
对于这个问题有两个解决办法,普遍的做法给事务加一个 TTL,事务的生命周期不可以超过这个 TTL,safepoint 选择当前时间减去事务 max TTL,这样就能保证所有超过 TTL 都会出错,而没有超过 TTL 的事务也不会出现上面的问题,但这个做法有一个问题就是始终偏移会有概率导致时间的判定出问题,但其实发生的概率是极低的。
不过我们支持了一个新的可选的方式可以对事务的安全性做严格的保证。当事务提交的时候,先去 master 拿一下当前的 safepoint,如果 safepoint 大于事务的 commit_ts 则认为事务可能有安全性问题,返回错误。更进一步,如果事务调用了 FreezeRead,则比较 safepoint 和 read_frozen_ts 即可。可以看出这种方式能够做到严格的保证 SI 隔离级别,但是带来了一次额外的 RPC 开销。
5. 未来展望
5.1 悲观锁
有一些业务会有冲突比较大的场景,乐观事务难以应对这种场景,需要通过先上锁占上资源的方式来悲观地处理,我们需要有对悲观事务支持。
5.2 Serializable Snapshot Isolation(SSI)
SI 隔离级别有着 WriteSkew 这样异常,这给一些需要严格 ACID 能力的业务场景带来了麻烦。不过一般业务上都可以通过一些技术比如通过分析执行流程类似于 TPC-C benchmark 来避免异常,或者事务里写一个 dummy row 来避免这个问题,或者锁表,为什么还需要 SSI 的实现呢?对于 dummy 这种方式的临界区太大了,对于吞吐是非常不友好的;而并行场景下分析流程保证在 SI 下安全是非常困难的,很难证明它是符合串行化语义的,所以我们需要实现 SSI 隔离级别的高性能事务。
5.3 TrueTime/HLC
目前的 TSO 分配时间戳的方式存在两个问题:一个是跨地域甚至跨洲的情况下延迟较大,一个是单点的 QPS 有上限。我们后面可以考虑加入 TrueTime/HLC 的方式来应对这样的场景。
5.4 逻辑模型优化
如同 ShortValue 的设计一样,彻底去掉 data 列,全部写入到 Write 列之中以提升读取的性能。问题就是 Lock 列增加了读的开销,可以将 Lock 列优化成大内存甚至存内存来优化。
5.5 SQL
目前对于业务暴露的都是通过 API 的接口,一方面表达能力弱,一方面还没有引入计算下推,很难做极致的优化。我们需要 SQL 的能力,做一套 NewSQL 的数据库服务。
5.6 TCC
有的业务需要在业务的层面统筹它所以来的多个外部服务的数据一致性,需要联动的事务。对于这种场景,为了追求极致的性能,我们需要支持 TCC,提供一些支持性的能力。
6. FAQ
Q1:为什么需要持久化对数据的 lock 到 lock 列,而不是仅仅在内存中?
A:分布式的情况下,同一个事务的数据分布在不同的服务器上,如果 lock 仅仅在内存中,那么一旦某个 secondary row 所在机器挂了,lock 信息就丢了,原子性也就没了。而且 Percolator 的 lock 列设计 巧妙,lock 中包含了 primary 信息,能把同一个事务中的数据串起来,这个串起来的信息也是必须持久化才能保证一致性(比如如果不持久化,假设 primary 已经 commit,未提交的 secondary 挂了重启之 后不知道自己的状态了)、原子性的。
Q2:为什么 write 列需要写 rollback 标志的数据?
A:可防止处理迟到的无用 prewrite。当然也可以不写 rollback 标记,有 resolve 逻辑不会影响正确性,而且目前 Wound 的方式 resolve 的时候就是不打 rollback 标记的。
Q3:在 lock 的实现中,为什么 write 列需要写 lock 类型的数据,而不是 commit 的时候直接清除 lock 列类型为 lock 的数据?
A:由于 Percolator 的 mutation 是 buffer 在 client 端的,一次性 prewrite 获取锁,整体上等于是乐观锁模型,所以没办法在事务中遇到 select … for update 就先在资源层 lock 住,只能造一个 write type 为 lock 类型的记录,在 txn commit 的时候根据 prewrite 的这个 lock mutation 做一个类似 CAS 的 check,失败了说明这个 txn 开始的 select … for update 失败了。至于 commit 时为什么要真正把这个 lock 写到 write 里而不是遇到 lock 列的类型为 lock 直接简单删除,原因是如果不在 write 列写入一个 commit,可能有 stale 的 txn 提交数据。而且有了这个标志还能保证幂等性,commit 之后再 commit 不会出错。对于可以防止 stale txn 提交数据的情况,可参考如下例子(站在 client 使用的视角):
1. Txn1
t2: create
t3: select a … for update
t5: if(a.exist) update b.pid = a
t6: commit.prewrite(client commit 事务之后 proxy 的第一阶段)
t7: commit.commit(client commit 事务之后 proxy 的第二阶段)
2. Txn2
t1: create
t4: select a … for update
t8: if(a.child == empty) delete a
t9: commit.prewrite
t10: commit.commit
如果 txn1 commit 的时候不在 write 写入 lock 类型的 a,那么 txn2 这个 start_ts 小于 txn1 start_ts 的事务是可以提交成功的。这样 txn1 和 txn2 就依然有 write skew 的问题,b.pid 指向的 parent a 还是会被删除掉而 b 成了孤儿。所以 lock 类型的 mutation 提交的时候必须在 write 列写入类型为 lock 的记录。
可能有的人会想语义上来看是没必要必须失败一个事务的啊,假设是 Mysql 非 SI 隔离级别比如 RR 级下这样做结果会是两个事务都成功,而本事务模型为什么出现这种 case 就必须失败一个?其实本质原因就是本文设计的事务是 lazy 的 CAS check 形式的锁,select … for update 一行读取到的数据在当行及以后的使用中并没有处于受保护的临界区之中,而 Mysql 非 SI 隔离级别下则不同,其会是直接先 lock 住 for update 的 row,之后的处理都处于 lock 的保护之中,不会出现隔离和一致性问题,所以都会成功,而本文涉及的事务模型就必须失败一个才能保证一致性。
Q4:Coordinator 挂掉了怎么办?大事务会阻塞非常多的行,直到 resolve。
A:目前 txn 的 context 中会返回 primary row,遇到 NetError 等错误的时候 client 可以通过调用 rollback 来强制回滚。