深入理解分布式事务Percolator(二)

转载请附本文链接:https://blog.csdn.net/maxlovezyy/article/details/99702091

思考篇

本篇为前一篇入门分布式事务之后,再对Percolator的设计细节做一个深究,整体文档采用QA的方式来组织。下一篇再对实现细节进行思考,包括对paper中未提到的GC以及一些工程上需要考虑的点做说明。

  • Q1 Percolator的读一行数据的时候,如果该行数据有锁,是需要等待的,而不能直接把小于等于其start_ts的无锁数据返回。简单来看,直接返回小于start_ts的无锁的数据,视图上看起来还是一致的啊,为什么不可以呢?
    A1 其实这个问题在第一篇的读流程里已经说过了,这里再说一下,以保证本文档的完整性。假设有一个读事务txn-r,一个写事务txn-w,按照时间轴来描述如下,

    [txn-r] 
      t5 :  get start_ts
      t6 :  select * from table_a where id > 100;
      t7 :  do sth.
      t9 :  select * from table_a where id > 100;  // 不可重复度id > 100,幻读 id = 300
      t10:  commit
      
    [txn-w] 
      t1 :  start_ts  // prewrite include lock
      t2 :  update table_a set income = 1000 where id > 100;  // 导致不可重复度
            insert into table_a (id, income) values (300, 1000);   // 导致幻读
      t3 :  get commit_ts
      t8 :  commit  // 这是一个stale的commit,会提交时间戳为t3的数据,导致start_ts为t5的读事务看得见
    

    根据上述描述,read需要等小于其start_ts的锁都不存在了,才可以开始读。而对于大于start_ts的锁不需要等待,因为其commit_ts也必定大于read txn的start_ts,压根读不到。那么这就引出另一个问题,如果lock这个行的事务挂了,它的锁没人清理,后续的所有读事务就都hang死了,写事务都得cancel掉,这是不允许的,所以Percolator引入了清锁的设计,见下一个QA。

  • Q2 Read等到一定时间就可以清理掉锁,这样为什么不会影响数据一致性?
    A2 清锁的方式是先清理主锁,之后再清理从锁,因为主锁其实是保证原子的基本barrier。而一致性需要从两个方面看,一方面是站在数据服务一侧的视角,对于强一致的数据库,需要满足ACID,Percolator的事务是具有原子性的,由于从锁(secondary lock)和主锁(primary lock)的trick设计,无论你释放的锁处于什么状态,总能通过分析找出某一个write怎么处理能保证原子性。比如对于从锁来说,如果主锁存在,则说明事务还未提交,不能清除自身,想resolve从锁,需要先resolve主锁;如果主锁不存在,则先查询主锁的write列是否有write是指向主锁事务start_ts的data,如果有说明事务提交了,如果没有则说明事务没有被提交,也可能write列有一个rollback的指向了主锁事务start_ts的data,这更显式地说明了事务被cancel了。从锁根据主锁的情况一致地处理自身就行。所以在另一方面是站在客户端一侧的视角,由于当前同时存在读写两个事务,说明客户端使用的时候要么并发了,要么之前有一个请求返回了unknown error(分布式的情况下响应分两种,certain和unknown,certain是服务端给的确定性的结果,无论成功、失败或其他错误,unknown是整个请求链有网络原因导致的未知错误),这种情况下用户没有等到得到一个确定性的结果之前,就又执行了一个请求,导致了并行。这两种情况无论哪一种对于用户侧都是未知的行为(因为有并行行为),所以这里清锁(resolve lock)是没有任何客户端视角的一致性问题的,因为本来结果就是一个不确定的未知。综上,不会影响一致性。当然这里隐含了一个问题,那就是写事务是一个长事务,这时候读清理了写事务的锁会导致可用性问题,这可以通过对锁保持心跳更新其deadline阻止清理,这在第三篇实现篇也有说明。

  • Q3 两个写事务,会不会有死锁问题?会不会有互相导致对方cancel的情况进而没有一个能成功,出现活性问题?
    A3 死锁: 对于事务层面的死锁,Percolator是不存在死锁问题的,因为发现冲突的会直接取消自身,相当于try-lock,not wait。但对于具体工程实现上的某个服务节点,是可能存在死锁的,因为一旦你支持批量的prewrite,就可能存在两个并发对多个row进行prewrite的可能,就可能出现死锁(Batch1: lock A; lock B; Batch2: lock B; lock A;)其实这是一个普遍的问题,解决办法很简单,在具体的加锁节点采取2PL的方式,即统一的加锁阶段和解锁阶段,中间才是操作阶段,并且对于所以需要加锁的节点进行相同规则的排序,这样就不会死锁了。其证明也很简单,这里就不介绍了,读者自己反证法思考下就ok了。活性: 假设两个事务都写A和B,txn_w1先lock A,txn_w2先lock B,下一步各自lock B和A的时候,就都会因为冲突取消自己,如果这样无限循环,活性就有问题了。解决办法也很简答,依然是排序,对依赖进行排序就没问题了,上锁的顺序就都是先A后B,那么后上锁的事务就会取消自己,先上锁的事务不会被取消,这样就不会相互无限取消,满足了活性。
    但是其实排序的方式解决死锁问题是有局限性的,因为你的数据量可能是非常大的,很难在commit事务之前全部buffer下来进行排序,这种情况下如果采用全排序,性能损耗太大,不过如果局部排序,又可能出现死锁。

  • Q4 为什么写写事务不能像读等锁那样等,而是在冲突(有lock或比当前事务start_ts大的write)的时候需要取消一个?比如有两个写事务txn-w1和txn-w2的相关row有overlap,假设txn-w1的start_ts是t1,txn-w2的start_ts是t2,而txn-w2先抢到了lock,执行了起来,之后在t3时间txn-w2 commit了,这种情况下为什么txn-w1一定要取消自己,而不是等到t3时间之后再commit?要知道能出现两个写事务冲突了说明有了并发,发生原因和上一个A2中说的原因一样,这种情况下无论哪一个事务最后提交都是ok的,因为并行了。
    A4 假设Q4的做法成立,t4时间txn-w1提交了,笔者想到了4点原因:

  1. 保证事务的原子性和隔离性。比如事务txn-2读b判断后写a,事务txn-1是读a判断后写a。由于percolator mvcc读不加锁,所以可能存在事务txn-2的写在txn-1之前上锁写了a。假设写是等锁而非取消自己的实现,那事务txn-1之后的写就会破坏了事务txn-1和txn-2的原子性和隔离性,即txn的读-判断-写不是原子的了。产生这个问题的本质原因是percolator的mvcc机制设计上读不加锁,进而没办法保证原子性。背后其实还隐藏着write skew的问题,都是因为读不加锁产生的。下面回到本话题描述下可能的时序:

     [txn-1]
     t1: start_ts
     t3: read lock
     t7: if 0 == lock ->  lock = 1
     t8: commit
     
     [txn-2]
     t2: start_ts
     t4: read lock
     t5: if 0 == lock -> lock = 2
     t6: commit
    

    假如a初始值为0,则期望两个事务执行后的结果是有一个事务认为自己上锁成功了。但是假如写写不冲突则两个都会认为自己上锁成功且后者覆盖了前者的值。

    或者像下面这样,lost update了:
    假设一个变量X=100,txn-1读X,加1之后写回,txn-2读X,加2之后写回。假设在串行化下,在两个事务执行完之后X必然等于103。但假如写写不冲突,则会lost update如下。

    [txn-1]
    t1 : start_ts
    t2 : read x (x=100)
    t8 : x+=1   (x=101)
    t9 : write x
    t10: commit
    
    [txn-2]
    t3 : start_ts
    t4 : read x (x=100)
    t5 : x+=2   (x=102)  // lost
    t6 : write x
    t7 : commit
    

    事务2的更新丢失了。

  2. 可能会导致某一个读事务在重复读的时候出现由于设计原因导致的latency不同,比如如下执行时序:

    [txn-w1]
    t1  : start_ts
    t2  : update table_a set x = 1 where id = 100;
    t5  : commit
    
    [txn-w2]
    t3  : start_ts
    t4  : wait lock
    t6  : get start_ts
    t10 : updateinsert   // get lock,lantency的来源
    t100: commit
    
    [txn-r]
    t7  : get start_ts
    t8  : select * from table_a where id > 100;  // 正常读
    t9  : do sth.
    t11 : select * from table_a where id > 100;  // 等txn-w2加的lock,有了非物理因素的预期外lantency
    t101: read back
    t111: commit
    
  3. 设计成写写冲突还有一个好处,那就是可以在用户的写事务出现unknown error的时候,用户可以明确地、方便地得到一个确定的结果。由于时间戳是单调递增的,假设用户串行执行事务请求,一旦后续有一个写事务成功了,那么在它之前的stale的请求必定失败,因为后续的写事务时间戳一定大于之前unknown那个stale的。

  4. 正如Q2中说的死锁,ww冲突不是等而是取消一个,那就相当于try-lock的形式,无论什么情况下都不会出现死锁问题,而活性问题可以外围全排序解决。

  • 0
    点赞
  • 0
    收藏
    觉得还不错? 一键收藏
  • 0
    评论

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值