searchusermenu
  • 发布文章
  • 消息中心
点赞
收藏
评论
分享
原创

Percolator事务分析

2023-07-31 07:12:08
27
0

目的

  • 支持分布式事务,跨行、跨节点

前提要求

  • 下层存储引擎需要支持单行事务
  • 需要有一个全局授时组件

基本思路

google在用于更新网页索引的时候提出了percolator事务,可以认为是一种优化版的2PC,client充当了部分coodinator的角色,采用了乐观锁的事务模型,检测到冲突则回滚或者重试。它将2PC的coordinator进行了分割,对于发起prepare和commit的指令等无状态操作交给了client来实现,而对于持久化的事务状态则下沉到了存储引擎中。本质上可以认为Percolator是一个用于客户端事务libary,不再需要一个全局的事务管理器。

隔离级别

可以实现RR,tidb称之为RR,但和MySQL的RR有细微区别,本质上是SI。

每一个事务都有一个开始时间戳(ST)和提交时间戳(CT),一个事务只允许看见CT小于自己的ST的事务的数据(既然存在CT也意味着必然事务已经提交)。

                                    time
------------------------------------------------------------------------------>
|---------T1--------·|
       |------------------T2-------------|      
                                                |-------------T3----------|

对于如上的几个事务T2,T2,T3. T2应该看不见T1写入的数据,无论T1是否提交,但是T3应当能够看见T1与T2写入的数据。但是需要注意的是这的ST和CT都是从授时中心获取的单调递增的逻辑时间戳,而非真实的物理时间。

Percolator基于的存储格式

google Percolator 底层存储基于Bigtable,其存储有列族的概念(下面以CF代指),同一个列族的数据存储在一起。每个逻辑上的行分为多个列族,每个列族可以分为多个列,而其中每一列的数据以时间戳倒序排序。

下面为一个典型的Percolator事务模型的行

key data lock write
Bob 6:
5:$10
6:
5:
6:data@5
5:
Joe 6:
5:$2
6:
5:
6:data@5
5:

key为整个行的key,data为该行的数据。而Percolator要求额外的两个CF为:Lock和Write。Lock顾名思义表示该行的锁,而write表示写入这行数据的事务提交的时候时间戳CT。

基本步骤

论文中给出了一个转账的示例来表示事务的基本步骤,假设上面的Bob需要转账7元给Joe.那么首先进入第一阶段:Prewrite

prewrite

1. 对primary行进行加锁

prewrite对应2PC的prepare阶段。首先随机选择一行作为primary,以事务开始间戳为版本,对其进行加锁,写入锁以及Bob转账后的余额。假设选择Bob的账户行为primary:

key data lock write
Bob

7:3

6:

5:10

7:I am Primary
6:
5:
7:
6:data@5
5:
Joe 6:
5:$2
6:
5:
6:data@5
5:

2.对其他行进行加锁

剩余的其他参加事务的行作为secondary行,分别对其进行加锁,这里只有Joe:

key data lock write
Bob

7:3

6:

5:10

7:I am Primary
6:
5:
7:
6:data@5
5:
Joe

7:9

6:

5:2

7:primary@Bob
6:
5:
7:
6:data@5
5:

在这一步之后,转账后的数据其实已经写入了其中(版本时间戳为7的数据),并且每一行都加上了锁。其他行的锁都保存了primary行的信息,以便通过primary行判断事务的状态。

commit

1. 提交prmary行

进行commit的时候,客户端通过Bigtable的单行事务,清楚primary行的锁,并且以提交时间戳在write写入提交标志。

key data lock write
Bob

8:
7:3

6:

5:10

8:
7:
6:
5:
8:data@7
7:
6:data@5
5:
Joe

7:9

6:

5:2

7:primary@Bob
6:
5:
7:
6:data@5
5:

Write CF的写入是整个事务提交的标志,这个操作的完成就意味着事务已经完成提交了。
write中写入的数据指向Bob真正存放余额的地方, 这里看上去写了一整行新数据,但是由于Bigtable列族的特性并没有耗费额外的存储空间。

2. 释放其他的行.

key data lock write
Bob

8:
7:3

6:

5:10

8:
7:
6:
5:
8:data@7
7:
6:data@5
5:
Joe

8:
7:9

6:

5:2

8:
7:
6:
5:
8data@7:
7:
6:data@5
5:

实际上,即使在执行这一步前,客户端挂了而没能处理这些行的锁也没有问题。当其他事务读取到这样的行的数据的时候,通过锁可以找出primary行,从而判断出事务的状态,如果已经提交,则可以清除锁写入提交标志。

伪代码步骤

论文中用C++风格的伪代码进行了Percolator事务流程的表达,整个事务被封装成了一个class,先来看其中需要用到的成员:

class Transaction {
   // Write结构体表示一个写入操作,哪个key下的哪一个列,写入什么值
    struct Write { Row row; Column col; string value; };
    // writes 则为在这个事务中缓存的所有写入的集合
    vector<Write> writes ;
    // 事务开始的时间戳ST
    int start ts ;
    
    Transaction() : start ts (oracle.GetTimestamp()) fg
    //下面是各个实现函数,见下文
    ...
}

事务中需要用到的数据结构比较少,只保存了事务开始的时间戳和写入集合。

Percolator作为一个2PC优化版本,本质上依然分为两个阶段Prewrite和Commit,Prewrite的伪代码如下:

bool Prewrite(Write w, Write primary) {
    // 列族名
    Column c = w.col;
    // google的Percolator基于bigtable的单行事务,因此这里用bigtable::Txn表示发起单行事务
    bigtable::Txn T = bigtable::StartRowTransaction(w.row);

    // Abort on writes after our start timestamp . . .
     if (T.Read(w.row, c+"write", [start ts, +Inf])) return false;
     // . . . or locks at any timestamp.
    if (T.Read(w.row, c+"lock", [0, 1])) return false;

    T.Write(w.row, c+"data", start ts , w.value);
    T.Write(w.row, c+"lock", start ts ,
        {primary.row, primary.col}); // The primary’s location.
    return T.Commit();
}

Prewrite首先开启一个单行事务进行冲突检测

  • 如果在自己的开始时间戳ST之后有已经提交的事务写入了该行数据,则认为检测到冲突,回滚当前事务。
  • 如果在自己之前已经有其他事务对这行进行加锁,认为存在冲突,回滚当前事务。

如果不存在冲突,则在bigtable的单行事务中修改该行,将这行数据上锁。注意,这只是对于参与分布式事务的其中某一行的加锁。函数的第二个参数primay表示某一个被选为主行的数据,其他所有行的锁都指向这个primary。

下面再来看整体上的提交流程的伪代码:

bool Commit() {
    Write primary = writes [0];
    vector<Write> secondaries(writes .begin()+1, writes .end());
    // 对所有参与事务的行执行Prewrite
    // 先对随机选出的某一个primary行加锁,再对其他行加锁。
    if (!Prewrite(primary, primary)) return false;
    for (Write w : secondaries)
        if (!Prewrite(w, primary)) return false;
    
    // 提交时间戳CT
    int commit ts = oracle .GetTimestamp();

    // Commit primary first.
    Write p = primary;
    bigtable::Txn T = bigtable::StartRowTransaction(p.row);
    // 失去了锁,可能被别人终止了,事务回滚
    if (!T.Read(p.row, p.col+"lock", [start ts , start ts ])) 
        return false; // aborted while working
    // 向primary行的Write CF写入提交标志,时间戳为CT
    T.Write(p.row, p.col+"write", commit ts,
        start ts ); // Pointer to data written at start ts .
    //擦除primary的锁
    T.Erase(p.row, p.col+"lock", commit ts);
    if (!T.Commit()) return false; // commit point

    // Second phase: write out write records for secondary cells.
    // 在其他行同样进行写入Write CF并且擦除锁
    for (Write w : secondaries) {
        bigtable::Write(w.row, w.col+"write", commit ts, start ts );
        bigtable::Erase(w.row, w.col+"lock", commit ts);
    }
    return true;
}

事务开始的时候会随机选取一行作为primary,这一行的write CF实际上作为了整个事务
commit时的标志,一旦该CF写入,则认为整个事务已经提交了。事务的提交过程依然可以像2PC一样分为两个阶段:

  • 第一阶段:对每一行调用Prewrite进行加锁,其中非primary行的锁有primay行的位置。
  • 第二阶段:检查primay行的锁是否还被自己持有,如果依然持有,就可以进行提交操作。提交操作首先用单行事务向primay行写Writie CF并且擦除primay的锁,一旦这个操作完成,就可以认为事务已经提交了。后续再释放其他行的锁。如果此时有其他事务访问尚擦除锁的非primay行,可以通过锁找到primary行从而得知是否事务已经提交。如过已经提交可以直接清除掉这个锁。

读取的伪代码:

bool Get(Row row, Column c, string* value) f
    while (true) {
        bigtable::Txn T = bigtable::StartRowTransaction(row);
        // Check for locks that signal concurrent writes.
        if (T.Read(row, c+"lock", [0, start ts ])) {
            // There is a pending lock; try to clean it and wait
            BackoffAndMaybeCleanupLock(row, c);
        continue;
    }

   // Find the latest write below our start timestamp.
   latest write = T.Read(row, c+"write", [0, start ts ]);
   if (!latest write.found()) return false; // no data
   int data ts = latest write.start timestamp();
   *value = T.Read(row, c+"data", [data ts, data ts]);
   return true;
}

读取操作,先看是否有尚未提交的锁,如果有的话,需要等待提交后。或者达到满足认为应该终止这个事务的条件,将未提交事务终止。然后根据自己的ST去寻找可见的数据行。这里有个认为应该终止某些事务的条件,在原论文中google更新网页索引时的事务会在chubby上注册临时节点,因此可以知道事务是否存活。但是tidb中并没有这个,官方文档中称 TiKV 在存储节点本地添加了一个简单的 Scheduler 层,在 2PC 读写遇到锁的时候并不是粗暴的直接回滚返回,而是尝试在本地排队等一下 ,如果超时或者其他异常,再返回客户端重试,具体实现需要再研究。

一些想法

Percolator事务是一种乐观锁机制,所有的修改操作都缓存在客户端,最后commit的时候再一次性写入,会受到能缓存的数据量的制约。同时,按文档描述事务提交存在超时机制,如果超时,已经持有的锁也可能被其他事务清除,因此大事务提交时间过长也会有问题。从模型上来讲tidb不应该用于大事务(比如在一个事务中删除两亿条数据之类的)。另外,乐观锁机制在发生冲突的时候会产生事务回滚,代价比较大,因此应该避免过多的热点竞争。例如存在一个全局计数器,每一个事务都会在执行过程中对这个计数器进行+1操作,这种场景下冲突会极其激烈,造成大量回滚重试,效率较低。

0条评论
作者已关闭评论
温****鎏
5文章数
0粉丝数
温****鎏
5 文章 | 0 粉丝
原创

Percolator事务分析

2023-07-31 07:12:08
27
0

目的

  • 支持分布式事务,跨行、跨节点

前提要求

  • 下层存储引擎需要支持单行事务
  • 需要有一个全局授时组件

基本思路

google在用于更新网页索引的时候提出了percolator事务,可以认为是一种优化版的2PC,client充当了部分coodinator的角色,采用了乐观锁的事务模型,检测到冲突则回滚或者重试。它将2PC的coordinator进行了分割,对于发起prepare和commit的指令等无状态操作交给了client来实现,而对于持久化的事务状态则下沉到了存储引擎中。本质上可以认为Percolator是一个用于客户端事务libary,不再需要一个全局的事务管理器。

隔离级别

可以实现RR,tidb称之为RR,但和MySQL的RR有细微区别,本质上是SI。

每一个事务都有一个开始时间戳(ST)和提交时间戳(CT),一个事务只允许看见CT小于自己的ST的事务的数据(既然存在CT也意味着必然事务已经提交)。

                                    time
------------------------------------------------------------------------------>
|---------T1--------·|
       |------------------T2-------------|      
                                                |-------------T3----------|

对于如上的几个事务T2,T2,T3. T2应该看不见T1写入的数据,无论T1是否提交,但是T3应当能够看见T1与T2写入的数据。但是需要注意的是这的ST和CT都是从授时中心获取的单调递增的逻辑时间戳,而非真实的物理时间。

Percolator基于的存储格式

google Percolator 底层存储基于Bigtable,其存储有列族的概念(下面以CF代指),同一个列族的数据存储在一起。每个逻辑上的行分为多个列族,每个列族可以分为多个列,而其中每一列的数据以时间戳倒序排序。

下面为一个典型的Percolator事务模型的行

key data lock write
Bob 6:
5:$10
6:
5:
6:data@5
5:
Joe 6:
5:$2
6:
5:
6:data@5
5:

key为整个行的key,data为该行的数据。而Percolator要求额外的两个CF为:Lock和Write。Lock顾名思义表示该行的锁,而write表示写入这行数据的事务提交的时候时间戳CT。

基本步骤

论文中给出了一个转账的示例来表示事务的基本步骤,假设上面的Bob需要转账7元给Joe.那么首先进入第一阶段:Prewrite

prewrite

1. 对primary行进行加锁

prewrite对应2PC的prepare阶段。首先随机选择一行作为primary,以事务开始间戳为版本,对其进行加锁,写入锁以及Bob转账后的余额。假设选择Bob的账户行为primary:

key data lock write
Bob

7:3

6:

5:10

7:I am Primary
6:
5:
7:
6:data@5
5:
Joe 6:
5:$2
6:
5:
6:data@5
5:

2.对其他行进行加锁

剩余的其他参加事务的行作为secondary行,分别对其进行加锁,这里只有Joe:

key data lock write
Bob

7:3

6:

5:10

7:I am Primary
6:
5:
7:
6:data@5
5:
Joe

7:9

6:

5:2

7:primary@Bob
6:
5:
7:
6:data@5
5:

在这一步之后,转账后的数据其实已经写入了其中(版本时间戳为7的数据),并且每一行都加上了锁。其他行的锁都保存了primary行的信息,以便通过primary行判断事务的状态。

commit

1. 提交prmary行

进行commit的时候,客户端通过Bigtable的单行事务,清楚primary行的锁,并且以提交时间戳在write写入提交标志。

key data lock write
Bob

8:
7:3

6:

5:10

8:
7:
6:
5:
8:data@7
7:
6:data@5
5:
Joe

7:9

6:

5:2

7:primary@Bob
6:
5:
7:
6:data@5
5:

Write CF的写入是整个事务提交的标志,这个操作的完成就意味着事务已经完成提交了。
write中写入的数据指向Bob真正存放余额的地方, 这里看上去写了一整行新数据,但是由于Bigtable列族的特性并没有耗费额外的存储空间。

2. 释放其他的行.

key data lock write
Bob

8:
7:3

6:

5:10

8:
7:
6:
5:
8:data@7
7:
6:data@5
5:
Joe

8:
7:9

6:

5:2

8:
7:
6:
5:
8data@7:
7:
6:data@5
5:

实际上,即使在执行这一步前,客户端挂了而没能处理这些行的锁也没有问题。当其他事务读取到这样的行的数据的时候,通过锁可以找出primary行,从而判断出事务的状态,如果已经提交,则可以清除锁写入提交标志。

伪代码步骤

论文中用C++风格的伪代码进行了Percolator事务流程的表达,整个事务被封装成了一个class,先来看其中需要用到的成员:

class Transaction {
   // Write结构体表示一个写入操作,哪个key下的哪一个列,写入什么值
    struct Write { Row row; Column col; string value; };
    // writes 则为在这个事务中缓存的所有写入的集合
    vector<Write> writes ;
    // 事务开始的时间戳ST
    int start ts ;
    
    Transaction() : start ts (oracle.GetTimestamp()) fg
    //下面是各个实现函数,见下文
    ...
}

事务中需要用到的数据结构比较少,只保存了事务开始的时间戳和写入集合。

Percolator作为一个2PC优化版本,本质上依然分为两个阶段Prewrite和Commit,Prewrite的伪代码如下:

bool Prewrite(Write w, Write primary) {
    // 列族名
    Column c = w.col;
    // google的Percolator基于bigtable的单行事务,因此这里用bigtable::Txn表示发起单行事务
    bigtable::Txn T = bigtable::StartRowTransaction(w.row);

    // Abort on writes after our start timestamp . . .
     if (T.Read(w.row, c+"write", [start ts, +Inf])) return false;
     // . . . or locks at any timestamp.
    if (T.Read(w.row, c+"lock", [0, 1])) return false;

    T.Write(w.row, c+"data", start ts , w.value);
    T.Write(w.row, c+"lock", start ts ,
        {primary.row, primary.col}); // The primary’s location.
    return T.Commit();
}

Prewrite首先开启一个单行事务进行冲突检测

  • 如果在自己的开始时间戳ST之后有已经提交的事务写入了该行数据,则认为检测到冲突,回滚当前事务。
  • 如果在自己之前已经有其他事务对这行进行加锁,认为存在冲突,回滚当前事务。

如果不存在冲突,则在bigtable的单行事务中修改该行,将这行数据上锁。注意,这只是对于参与分布式事务的其中某一行的加锁。函数的第二个参数primay表示某一个被选为主行的数据,其他所有行的锁都指向这个primary。

下面再来看整体上的提交流程的伪代码:

bool Commit() {
    Write primary = writes [0];
    vector<Write> secondaries(writes .begin()+1, writes .end());
    // 对所有参与事务的行执行Prewrite
    // 先对随机选出的某一个primary行加锁,再对其他行加锁。
    if (!Prewrite(primary, primary)) return false;
    for (Write w : secondaries)
        if (!Prewrite(w, primary)) return false;
    
    // 提交时间戳CT
    int commit ts = oracle .GetTimestamp();

    // Commit primary first.
    Write p = primary;
    bigtable::Txn T = bigtable::StartRowTransaction(p.row);
    // 失去了锁,可能被别人终止了,事务回滚
    if (!T.Read(p.row, p.col+"lock", [start ts , start ts ])) 
        return false; // aborted while working
    // 向primary行的Write CF写入提交标志,时间戳为CT
    T.Write(p.row, p.col+"write", commit ts,
        start ts ); // Pointer to data written at start ts .
    //擦除primary的锁
    T.Erase(p.row, p.col+"lock", commit ts);
    if (!T.Commit()) return false; // commit point

    // Second phase: write out write records for secondary cells.
    // 在其他行同样进行写入Write CF并且擦除锁
    for (Write w : secondaries) {
        bigtable::Write(w.row, w.col+"write", commit ts, start ts );
        bigtable::Erase(w.row, w.col+"lock", commit ts);
    }
    return true;
}

事务开始的时候会随机选取一行作为primary,这一行的write CF实际上作为了整个事务
commit时的标志,一旦该CF写入,则认为整个事务已经提交了。事务的提交过程依然可以像2PC一样分为两个阶段:

  • 第一阶段:对每一行调用Prewrite进行加锁,其中非primary行的锁有primay行的位置。
  • 第二阶段:检查primay行的锁是否还被自己持有,如果依然持有,就可以进行提交操作。提交操作首先用单行事务向primay行写Writie CF并且擦除primay的锁,一旦这个操作完成,就可以认为事务已经提交了。后续再释放其他行的锁。如果此时有其他事务访问尚擦除锁的非primay行,可以通过锁找到primary行从而得知是否事务已经提交。如过已经提交可以直接清除掉这个锁。

读取的伪代码:

bool Get(Row row, Column c, string* value) f
    while (true) {
        bigtable::Txn T = bigtable::StartRowTransaction(row);
        // Check for locks that signal concurrent writes.
        if (T.Read(row, c+"lock", [0, start ts ])) {
            // There is a pending lock; try to clean it and wait
            BackoffAndMaybeCleanupLock(row, c);
        continue;
    }

   // Find the latest write below our start timestamp.
   latest write = T.Read(row, c+"write", [0, start ts ]);
   if (!latest write.found()) return false; // no data
   int data ts = latest write.start timestamp();
   *value = T.Read(row, c+"data", [data ts, data ts]);
   return true;
}

读取操作,先看是否有尚未提交的锁,如果有的话,需要等待提交后。或者达到满足认为应该终止这个事务的条件,将未提交事务终止。然后根据自己的ST去寻找可见的数据行。这里有个认为应该终止某些事务的条件,在原论文中google更新网页索引时的事务会在chubby上注册临时节点,因此可以知道事务是否存活。但是tidb中并没有这个,官方文档中称 TiKV 在存储节点本地添加了一个简单的 Scheduler 层,在 2PC 读写遇到锁的时候并不是粗暴的直接回滚返回,而是尝试在本地排队等一下 ,如果超时或者其他异常,再返回客户端重试,具体实现需要再研究。

一些想法

Percolator事务是一种乐观锁机制,所有的修改操作都缓存在客户端,最后commit的时候再一次性写入,会受到能缓存的数据量的制约。同时,按文档描述事务提交存在超时机制,如果超时,已经持有的锁也可能被其他事务清除,因此大事务提交时间过长也会有问题。从模型上来讲tidb不应该用于大事务(比如在一个事务中删除两亿条数据之类的)。另外,乐观锁机制在发生冲突的时候会产生事务回滚,代价比较大,因此应该避免过多的热点竞争。例如存在一个全局计数器,每一个事务都会在执行过程中对这个计数器进行+1操作,这种场景下冲突会极其激烈,造成大量回滚重试,效率较低。

文章来自个人专栏
技术二三事
5 文章 | 1 订阅
0条评论
作者已关闭评论
作者已关闭评论
0
0