CockroachDB事务流水线

本文内容参考Up Distributed SQL Transactions以及阅读CockroachDB源代码(V19.1)整理而成。
传统的分布式事务 对于一个采用两阶段提交的事务,传统的做法(这里不去特别讨论事务失败的处理)一般是这样的:
step 1. 由事务协调者向所有参与事务的节点发送prepare请求,这个过程是串行的,如果所有的的事务参与者均返回OK,即事务可以提交,一般情况下这个阶段事务参与者需要持久化事务的redo log和undo log(这里指的逻辑特性)。然后进入第二阶段
step 2. 由事务协调者向所有参与事务的节点发送commit请求(如果由节点prepare阶段失败,则发送abort请求,取消事务),各个节点提交本地的事务。
至此,事务结束。
我们假设存在N个节点参与事务,一次RPC请求耗时R_T,记录存储耗时W_T,因此节点都是多副本备份,采用raft复制协议,假设raft复制/提交的RPC延时是C_T(raft复制协议可以理解为一个两阶段提交,因此一个提案从发起到被应用需要两次网络交互raft日志复制和提交都是并行化的,因此多个副本和两个副本的耗时可以理解为一样的),raft log存储的耗时是L_T,同时考虑到协调者的高可用,因此协调者的信息也需要存储,因此一次事务的总耗时(我们假设是三副本)TxnTotal=2*(R_T+W_T+L_T+C_T+L_T) + N*2*(R_T+W_T+L_T+C_T+L_T)
这显然不可接受,并且考虑到CockroachDB支持SSI隔离级别,因此整个系统的吞吐无法满足生产需要。
第一阶段优化-并行提交 事实上无论是prepare还是commit,各个节点之间没有相关性,因此可以并行处理,这样事务的总耗时TxnTotal=2*(R_T+W_T+L_T+C_T+L_T) + 2*(R_T+W_T+L_T+C_T+L_T)
现在看起来好多了,但是还是太慢,可以继续优化
第二阶段优化-事务记录优化 如果事务协调者可以和其中一个事务参与者存储在同一个节点(更加本质的说法应该是同一个range),那么就可以进一步降低事务时延,CockroachDB就是这样做的,它把事务记录存储在事务的第一个参与者(range)上,这样优化之后事务的总耗时Txn_Total=2*(R_T+W_T+L_T+C_T+L_T)
第三阶段优化-本地读写 这里我简单说一下raft的工作机制,以便我们明白接下来的优化方向。raft leader拿到一个提案之后,首先会在本地持久化raft log,然后并行复制给其他的follower,并且等待follower返回复制确认,如果leader收到超过法定个数的成员返回复制确认,那么就认为复制完成,通知所有follower可以应用这个提案了,同时leader自己也会应用这个提案,两者是并行的。
如果我们可以在本地先应用提案,然后再通过raft复制给其他的副本,只要可以复制成功即可说明这个提案已经生效(因为提前应用了,这里不去讨论leader切换带来的问题的解决办法)。这样做对于写的优化提升不大,但是对于事务中的一致性读意义重大,这样读就可以不通过raft 提交来实现了(即使raft作者提供的一致性读,在绝对在乎一致性的场景下仍然需要一条额外的raft log开销)。
【CockroachDB事务流水线】另一个优化是one PC commit。CockroachDB如果检测到所有的提交都在同一个range上,那么并不需要两阶段提交了,直接batch提交,利用raft log保证原子性。这种在table刚刚创建的时候以及没有二级索引的insert/update/delete 语句效果特别明显,会有几倍的性能提升。
注意:前面第一阶段和第二阶段的优化,我并没有讨论事务中包含读的场景。
第四阶段优化-事务流水线 我们还可以进一步优化事务中的写吗?答案是肯定的。仔细分析raft的提交过程,在第三阶段的基础上(第三阶段本地读写的优化非常重要),如果我们在本地应用之后就假定写操作成功,即假定后面复制给其他的follower也会成功,那么这个时延只有R_T + W_T了,因为我们只有在commit的时候成功才认为事务成功,这一点和传统的mysql 事务不太一样,很多NewSQL都是需要检查commit的结果,由此确定事务是否成功。只要我们在提交事务之前确保之前写的数据都确定复制成功,那么这样我们事务的延时就可以继续减少,因此最后事务的总耗时Txn_Total=2*(R_T+W_T)(这是理想情况,实际会比这个数据多一点儿)。CockroachDB具体是怎么保证在这种场景下的事务正确定呢?
事务第一阶段prepare的时候,写操作提前应用,并且提交提案给raft之后就返回了,并不等待raft复制,我们称之为outstanding wrties。而事务的协调者在事务第二阶段commit的时候,首先检查所有outstanding writes是否存在,也会检查是否复制成功。如果lease holder发生了转移,只要查看新的lease holder是否有outstanding writes即可证明是否复制成功,如果lease holder没有发生变化,这里cockroachDB设计的很巧妙,任何写操作都需要首先申请一个latch,这个latch本质上是一个锁,对于写是独占的,这锁的释放是发生在lease holder应用raft log的时候(对于本地副本,因为已经提前应用了,会跳过),因此只要后面同样的时间戳可以上锁成功,即证明之前的写操作已经复制成功了。
这个过程并不会额外增加RPC,这个检查是随着commit请求一起发送到对应的range,只是这些检查先执行,执行没有问题后才会执行commit请求。因此事务流水线可以极大的提升事务的吞吐。
事务流水线对于大事务的性能提升非常明显。
cockroachDB对于事务流水线有一些限制:事务流水线限制事务过程一次写入的大小,最大一次允许128条目,最大256KB(并不是事务中row的大小,而是存储的key的大小,客户端不方便控制)。这里限制并不是对整个事务的限制,而是事务过程中prepare的限制。
其他方案 CockroachDB曾经评估过一个方案,就是所有的写都缓存在协调者的内存中,等到commit的时候再提交。不过这个方案存在三个问题(顾虑)
协调者节点需要缓存所有的写在内存中,因此对于协调者需要关注事务的大小,这客观上限制了事务的使用(大事务需要业务拆分)
事务持有锁的时间太长,增加了事务读写冲突的概率。
事务的读和写使用不同的时间戳,降低了事务的隔离级别,在SSI隔离级别中要求读写必须使用相同的时间戳。

    推荐阅读