我是如何基于二阶段递交及悲观锁实现分布式事务的

  由于框架一开始的定位就是需要支持强一致性分布式存储,所以如何实现分布式事务成为一个大挑战。作者学习了CockroachDB及TiDB等数据库的实现方式后,决定参考TiDB的实现方式,但不同于使用乐观方式而是采用悲观锁方式,遇到事务冲突采用排队的方式而不是重启事务。

一、二阶段(2PC)递交流程:

参考下图举例说明一下流程:

  1. 业务服务开始事务,其所在的节点作为事务协调者新建一个事务实例(使用HLC作为事务开始时间戳);
  2. 协调者将命令1加入事务命令列表(如果是第一个命令则作为事务主记录),同时向表1的RaftLeader发送命令,表1状态机处理命令时上锁成功后返回(包含当前节点的HLC);
  3. 同步骤2,但记录事务主记录是哪个,用于检测事务状态;
  4. 业务服务处理完后通知协调者递交事务,协调者选取所有事务参与者节点最大的HLC作为事务递交时间戳;
  5. 协调者发送事务递交命令至事务主记录所在表1的RaftLeader,在表1RaftGroup持久化事务主记录状态后通知协调者立即向业务服务返回事务是否成功递交;
  6. 事务主记录所在的RaftLeader向其他参与者的RaftLeader异步通知事务递交。

每个RaftGroup’s Leader都有定时器进行事务状态检测:

  • 如果检测到挂起的命令是事务主记录,则与协调者通信检测其是否存活;
  • 如果检测到挂起的命令非事务主记录,则与事务主记录所在的RaftLeader通信检测事务状态。

二、上锁及冲突检测流程:

  简单说明一下每个Raft节点的状态机的处理流程:

  1. Apply事务命令的RaftLog时,先检测当前锁列表是否存在冲突,如果没有冲突上锁成功持久化锁信息后返回;如果存在冲突则排入已上锁队列并持久化锁信息,等待上级锁事务递交后再返回;
  2. Apply事务递交命令时,从当前锁列表内找到对应的命令,持久化写入命令对应的KV数据至底层的RocksDB内,如果当前锁有等待队列,则依次将队列重新尝试上锁;
  3. 接收到读命令时,如果与当前锁冲突,则根据事务开始时间戳判断是通知冲突事务向后更新递交时间戳,还是忽略该冲突。

如果Raft节点意外崩溃后重新启动时,会先从存储加载锁信息恢复当前锁列表。

三、性能测试:

  根据作者的经验,锁并不是影响并发性能的原因,冲突才是,所以做了个简单的并发测试。

1. 并发更新同一条记录(冲突激烈):

虚拟机(I74C8G) wrk -t2 -c120 测试约3900tps

对比参照(MacbookPro13 I74C16G):

  • PostgreSql: 64线程: 调用640000次共耗时: 179295毫秒 平均每秒调用: 3569
  • Cockroach: 64线程: 调用6400次共耗时: 90784毫秒 平均每秒调用: 70

2. 事务插入两条记录(无冲突):

64线程: 调用64000次共耗时: 4008毫秒 平均每秒调用: 15968

四、小结:

  本篇介绍了框架集成的分布式存储引擎是如何实现分布式事务的,当然还有很多优化待做(如单分区事务递交优化等)。如果您有问题或Bug报告,请留言或在[GitHub]提交Issue,另外您的关注与点赞将是作者最大的动力。