文章目录
前言
Rocksdb 作为单机存储引擎,已经非常成熟得应用在了许多分布式存储(CEPH, TiKV),以及十分通用的数据库之上(mysql, mongodb, Drango等),所以Rocksdb本身需要能够实现ACID属性,尤其是其中的不同的隔离级别才能够作为一个公共的存储组件。本节,结合rocksdb6.4.6代码以及官网wiki来梳理一下rocksdb的事务管理以及隔离性的实现。
1. 隔离性
ACID中的隔离性意味着 同时执行的事务之间是互不影响的。这个时候,在一些同时执行事务的场景下,就需要有针对事务的隔离级别,来满足客户端针对存储系统的要求。
图1.1 两个客户之间的竞争状态同时递增计数器
如上图1.1,user1和user2对数据库的访问
- user1先从数据库中get,得到了42。完成get事务之后拿着get的结果+1,将43set到数据库中
- user1下发set的同时user2从数据库中get,同样得到了42,也进行42+1 的操作
- 两者的事务都是各自隔离的,且是串行执行互不影响(user2的get并无法同时访问user1 set的结果),保证了结果对用户的正确性
图1.2 违反了隔离性:一个事务读取了另一个事务执行的结果
如上图中,user2将user1的insert过程中的 hello 作为了自己的输入,即一个事务能够读取另一个事务未被执行状态。这个过程被称作脏读
2. Rocksdb实现的隔离级别
2.1 常见的四种隔离级别
ReadUncommited
读取未提交内容,所有事务都可以看到其他未提交事务的执行结果,存在脏读ReadCommited
读取已提交内容,事务只能看到其他已提交事务的更新内容,多次读的时候可能读到其他事务更新的内容RepeatableRead
可重复读,确保事务读取数据时,多次操作会看到同样的数据行(innodb引擎使用快照隔离来实现)。Serializability
可串行化,强制事务之间的执行是有序的,不会互相冲突。
2.2 Rocksdb 支持的隔离级别及基本实现
2.2.1 ReadComitted 隔离级别的测试
Rocksdb支持ReadCommited
的隔离级别,它能够提供两个保障
- 从数据库读时,只能看到已提交的数据(没有脏读(dirty reads):不同事务之间能够读到对方未提交的内容)
- 写入数据库时,只会覆盖已经写入的数据(没有脏写(dirty writes):不同事务之间的写在提交之前能够相互覆盖)
先看一下简单的测试代码:
//支持事务的方式打开rocksdb
Status s = TransactionDB::Open(options, txn_db_options, kDBPath, &txn_db);
// 开启事务操作,定义当前事务为t1
Transaction* txn = txn_db->BeginTransaction(write_options);
assert(txn);
// 先下发一个t1的读操作
s = txn->Get(read_options, "abc", &value);
assert(s.IsNotFound());
// 再下发一个t1的写操作(注意此时是在同一个事务t1内部,现在只是不同的操作)
s = txn->Put("abc", "def");
assert(s.ok());
// 在当前事务外部下发一个t2读操作,确认是否存在脏读(txn_db->Get是一个不同于当前事务的独立事务,t2)
s = txn_db->Get(read_options, "abc", &value);
std::cout << "t2 Get result " << s.ToString() << std::endl;
// 在当前事务外部下发一个t3写操作,这里更新的是不同的key,如果更新相同的key。则t1事务commit的时候会报错
//s = txn_db->Put(write_options, "xyz", "zzz");
s = txn_db->Put(write_options, "abc", "zzz");
std::cout << "t3 Put result " << s.ToString() << std::endl;
// 提交t1事务
s = txn->Commit();
assert(s.ok());
//提交之后再get一次
s = txn_db->Get(read_options, "abc", &value);
std::cout << "t4 Get result after commit: " << value << std::endl;
delete txn;
输出如下:
# 两个事务Get时不可见对方未提交内容,不存在脏读
t2 Get result NotFound:
# 在提交之后能够发现Set的结果也并未生效,不存在脏写,切Put相同的key发现加锁超时
t3 Put result Operation timed out: Timeout waiting to lock key
# t4在t1提交之后get t1的结果的时候能够看到t1的结果生效
t4 Get result after commit def
通过这个简单的测试代码以及对应的输出结果,我们能够看出当前Rocksdb已经能够支持ReadCommited
的隔离级别,不存在脏读,同时脏写实现看起来像是通过加锁来避免的。
2.2.2 ReadCommitted的实现
简单描述一下该隔离特性,Rocksdb的一个事务操作是通过Rocksdb内部WriteBatch实现的,针对不同事务Rocksdb会为其分配对应的WriteBatch,由WriteBatch来处理具体的写入。同时针对同一个事务的读操作,会优先从当前事务的WriteBatch中读,来保证能够读到当前写操作之前未提交的更新。提交的时候则依次写入WAL和memtable之中,保证ACID的原子性和一致性。
大体的流程如下2.1图
图2.1 通过WriteBatch实现 ReadCommitted
以上过程结合我们的测试代码,可以有两种方式来进行
- 显式得通过事务的方式写入,提交
Transaction* txn = txn_db->BeginTransaction(write_options); txn->Get(read_option,"abc",&value); txn->Put("abc","value1"); txn->commit();
- 直接通过TransactionDB生成一个auto transaction,transactionDB会将这个单独的操作封装成事务,并自动commit。
txn_db->Get(read_options, "abc", &value); txn_db->Put(write_options, "abc", "zzz");
一种transactionDB这里没有锁的冲突检查,而我们使用transaction的方式进行Put,实验代码中也能看到有锁的超时检查.
2.2.3 RepeatableRead的实现
可重复读是指Rocksdb重复多次读取数据的时候,能够访问到预期的数值,而不会被其他事务的更新操作影响。
这里的可重复读其实在SQL指定标准之前是用快照隔离来描述的,通用的关系型数据库都使用MVCC机制来进行多版本管理,多版本的访问也就是通过快照来进行的。
Rocksdb这里的实现是通过为每一个写入的key-value请求添加一个LSN(Log Sequence Number),最初是0,每次写入+1,达到全局递增的目的。同时当实现快照隔离时,通过Snapshot设置其与一个lsn绑定,则该snapshot能够访问到小于等于当前lsn的k-v数据,而大于该lsn的key-value是不可见的。
相关代码在snapshot_impl.h
之中
class SnapshotImpl : public Snapshot {
public:
//lsn number
SequenceNumber number_;
......
SnapshotImpl* prev_;
SnapshotImpl* next_;
SnapshotList* list_; // 链表头指针
int64_t unix_time_; //时间戳
// 用于写冲突的检查
bool is_write_conflict_boundary_;
};
snapshot可以有多个,它的创建和删除是通过操作一个全局的双向链表来进行,天然得根据创建的时间来进行排序SetSnapShot()函数创建一个快照。
快照隔离的测试代码如下:
// 通过设置set_snapshot=true,来在BeginTransaction的时候就设置一个快照
value = "def";
txn_options.set_snapshot = true;
txn = txn_db->BeginTransaction(write_options, txn_options);
//读取一个快照
const Snapshot* snapshot = txn->GetSnapshot();
// 重新生成一个写入事务
db->Put(write_options, "abc", "xyz");
// 通过读取的snapshot,来访问指定的key
read_options.snapshot = snapshot;
// 通过GetForUpdate来进行读操作,这个函数锁定多个事务操作,即也会让之前的Put加入到WriteBatch中。
s = txn->GetForUpdate(read_options, "abc", &value);
assert(value == "def");
// 提交事务
s = txn->Commit();
// 新生成的事务可能与读操作冲突,不过这里用了GetForUpdate就不会产生冲突了
assert(s.IsBusy());
delete txn;
// 释放snapshot
read_options.snapshot = nullptr;
snapshot = nullptr;
其中用到了GetForUpdate函数,区别于Get接口,GetForUpdate对读记录加独占写锁,保证后续对该记录的写操作是排他的。保证了多个事务的操作都能够被GetForUpdate锁定,而不是一个GetForUpdate成功,其他的失败。
2.2.4 事务并发处理
通过对以上事务的隔离性的分析,能够总结出以下几种事务并发时Rocksdb的处理方式。
- 如果事务都是读操作,不论操作之间是否有交集,都不会触发锁定
- 如果事务冲包含读、写操作
- 所有的读事务都不会触发锁定,读的结果与snapshot请求相关
- 写事务之间不存在交集,则不会锁定
- 写事务之间存在交集,如果此时设置了snapshot,则会串行提交;如果没有设置snapshot,则只执行第一个写操作,其他的操作都会失败。
3. 一些总结
本文通过探索Rocksdb的事务机制 以及描述了事务的基本实现,读提交以及可重复读的特性基本能够让其作为单机存储引擎底座,来适配分布式存储中的ACID特性。
同时还有一些更加细粒度的实现需要探索:
- 像针对写事务的交集如何进行冲突检测以及如何通过锁机制解决冲突。
- 默认使用的悲观锁以及可以显式调用的乐观锁 在隔离性的几个级别中是如何生效的。
- 还有2PC(Two-Pharse-Commit)的实现机制,以及2PC上层的应用场景
不得不说一个公共的存储底座实现是真的不容易,后续将尝试手写一些隔离级别,来加深对分布式锁的理解。