TiKV 源碼分析之 PointGet
一、背景介紹
TiDB是一款具有HTAP能力(同時支持在線事務處理與在線分析處理 )的融合型分布式數(shù)據(jù)庫產品,具備水平擴容或者縮容等重要特性。TiDB 采用多副本+Multi-Raft 算法的方式將數(shù)據(jù)調度到不同的機器節(jié)點上,具備較高的可靠性和容災能力。TiDB中的存儲層TiKV組件,能夠獨立于TiDB作為一款分布式KV數(shù)據(jù)庫使用,目前已經捐贈給CNCF并于2020年正式畢業(yè)。目前vivo公司內部的磁盤KV產品采用了開源的TiKV作為存儲層實現(xiàn), 目前已經在公司的不同業(yè)務產品中有深度實踐。
TiKV作為一款KV數(shù)據(jù)庫產品,同時提供了RawAPI和TxnAPI兩套接口:
- RawAPI僅支持最基本的針對單Key操作的Set/Get/Del及Scan語義
- TxnAPI提供了基于ACID事務標準的接口,支持多Key寫入的原子性
TxnAPI采用了分布式事務來保證多Key寫入的原子性,其適用的業(yè)務場景與RawAPI相比來說更為廣泛。本文后續(xù)內容將重點對PointGet在TiKV側的執(zhí)行流程進行分析,其內容涉及到storage和txn模塊。閱讀本文后,讀者將會深入了解TiKV源碼中Get流程的實現(xiàn)細節(jié),包括如何處理讀請求、如何進行數(shù)據(jù)定位和讀取、如何實現(xiàn)事務隔離級別等方面,并且能夠更好地理解TiKV的內部工作原理和性能優(yōu)化。
二、PointGet介紹
2.1 TiDB視角中的PointGet
PointGet顧名思義即"點查", 它是TiDB中最為基本的幾種算子之一,以下列舉了兩個常見的PointGet算子的使用場景:
根據(jù)主鍵Id查詢
MySQL [test]> explain select * from user where id = 1024;
+-------------+---------+------+-------------------------------+---------------+
| id | estRows | task | access object | operator info |
+-------------+---------+------+-------------------------------+---------------+
| Point_Get_1 | 1.00 | root | table:user, index:PRIMARY(id) | |
+-------------+---------+------+-------------------------------+---------------+
根據(jù)唯一索引查詢
MySQL [test]> explain select * from users where name = "test";
+-------------+---------+------+-------------------------------+---------------+
| id | estRows | task | access object | operator info |
+-------------+---------+------+-------------------------------+---------------+
| Point_Get_1 | 1.00 | root | table:users, index:name(name) | |
+-------------+---------+------+-------------------------------+---------------+
2.2 純KV用戶視角中的PointGet
部分業(yè)務沒有完整地使用TiDB組件,而是使用官方提供的client-go/client-rust直接訪問PD和TiKV。
func testGet(k []byte) (error) {
txn, err := client.Begin()
if err != nil {
return err
}
v, err := txn.Get(context.TODO(), k)
if err != nil {
return err
}
fmt.Printf("value of key is: %+v", v)
return nil
}
三、PointGet在TiDB中的實現(xiàn)
TiDB層為計算層,其主要職能為MySQL協(xié)議的實現(xiàn)以及SQL優(yōu)化器和執(zhí)行器的構建。客戶端發(fā)起的所有SQL, 都會經過以下生命周期流程:
- Lexer/Parser解析后得到AST,并轉換為執(zhí)行計劃。
- 執(zhí)行計劃經過RBO/CBO后得到優(yōu)化過后的執(zhí)行計劃。
- 基于執(zhí)行計劃構建執(zhí)行器,其本質是不同的算子"套娃",整體構成一個樹型結構。
TiDB的執(zhí)行器基于"火山模型"構建,不同的操作算子具有不同的Executor實現(xiàn):
type Executor interface {
base() *baseExecutor
Open(context.Context) error
Next(ctx context.Context, req *chunk.Chunk) error
Close() error
Schema() *expression.Schema
}
Executor中最為核心的是三個函數(shù)分別是Open/Next/Close,分別對應算子的初始化、迭代以及收尾邏輯。本文涉及的PointGet算子由PointGetExector實現(xiàn),其核心的查詢邏輯位于PointGetExector::Next()函數(shù)中。由于相關邏輯耦合了悲觀事務,以及tikv/client-go中部分Percolator的實現(xiàn),且不屬于本文重點分析的主要內容,這里不展開描述,感興趣的讀者可以自行閱讀。
四、PointGet在TiKV中的實現(xiàn)
4.1 PointGet接口定義
TiKV和TiDB使用gRPC進行通信,其接口契約定義采用了protobuf,我們可以在pingcap/kvproto項目中找到與PointGet相關的接口定義KvGet如下:
// Key/value store API for TiKV.
service Tikv {
// Commands using a transactional interface.
rpc KvGet(kvrpcpb.GetRequest) returns (kvrpcpb.GetResponse) {}
// ... other api definations ...
}
其中入?yún)etRequest定義如下代碼片段,我們可以看到,TiKV的點查接口除了key之外,還額外需要一個名為version的參數(shù),即當前事務的start_ts(事務開始時間戳),這個時間戳是由TiDB在啟動事務時從Pd組件申請而來。與很多數(shù)據(jù)庫類似,TiKV也采用了MVCC機制,即同一個key在底層的存儲中在不同時刻擁有不同的值,因此要想進行查詢,除了key之外,還需要帶上版本。
// A transactional get command. Lookup a value for `key` in the transaction with
// starting timestamp = `version`.
message GetRequest {
Context context = 1;
bytes key = 2;
uint64 version = 3;
}
4.2 TiKV側調用堆棧
TiKV作為gRPC的Server端,提供了KvGet接口的實現(xiàn),相關調用堆棧為:
+TiKV::kv_get (grpc-poll-thread)
+future_get
+Storage::get
+Storage::snapshot (readpool-thread)
+SnapshotStore::get
+PointGetterBuilder::build
+PointGetter::get
在一次KvGet調用中,函數(shù)執(zhí)行流程會在grpc-poll-thread和readpool-thread中切換,其中前者為gRPC的poll thread,請求在被路由到Storage層后,會根據(jù)讀寫屬性路由到不同的線程池中,只讀語義的Get/Scan請求都會被路由到ReadPool中執(zhí)行,這是一個特定用于處理只讀請求的線程池。
4.3 Read through locks介紹
在分析后續(xù)邏輯之前,我們需要對Read through locks機制先做個簡單介紹。TiKV使用Percoaltor模型來實現(xiàn)分布式事務,同時也引入了MVCC機制。然而其實現(xiàn)和傳統(tǒng)的MVCC實現(xiàn)略有差異:TiKV的讀取過程中若遇到其他事務提交時寫入的Lock, 則需要等待或者嘗試解鎖,這會阻塞讀取直到事務狀態(tài)確定,一定程度上會損失并發(fā)性能。
然而在一些場景(如SecondaryLocks),在Key對應的鎖仍然存在的情況下,我們已經知道相關事務的最終狀態(tài)(提交或回滾)。如果我們將這些事務的最終狀態(tài)與查詢請求一起發(fā)送給TiKV, 那么TiKV可以根據(jù)這些事務狀態(tài)來確定能否在有Lock的情況下安全讀取,避免不必要的等待, 即本小節(jié)提到的Read through lock機制。
Context是所有的TiKV請求都會攜帶的上下文信息,為了實現(xiàn)Read through lock,
https://github.com/pingcap/kvproto/pull/833 這個PR在Context中添加了如下字段:
message Context {
// Read requests can ignore locks belonging to these transactions because either
// these transactions are rolled back or theirs commit_ts > read request's start_ts.
repeated uint64 resolved_locks = 13;
// Read request should read through locks belonging to these transactions because these
// transactions are committed and theirs commit_ts <= read request's start_ts.
repeated uint64 committed_locks = 22;
}
其中resolved_locks用于記錄讀取時可以忽略的鎖,這些鎖對應的事務可能已被回滾,或者已成功提交但CommitTS大于當前的讀StartTS,直接忽略這些鎖也不影響快照一致性。
其中committed_locks則用于記錄邏輯上已被正確提交但物理上Lock還未被清理的、且CommitTS小于當前讀取使用的StartTS的事務。由于事務本質上已經被提交,因此讀取時可以不需要返回等待,只需要通過Lock查詢DefaultCF中的數(shù)據(jù)即可。
通過Read through lock機制,TiKV可以在一些Lock尚未被清理的情況下直接返回正確的結果,避免了客戶端層面的Wait和ResolveLock,其具體實現(xiàn)在后續(xù)小節(jié)會涉及到。
4.4 Storage::get流程分析
下方代碼塊是經過精簡過后的偽代碼,主要標注了get流程中一些比較關鍵的步驟。
pub fn get(&self, mut ctx: Context, key: Key, start_ts: TimeStamp) -> impl Future<Output = ... >> {
self.read_pool.spawn_handle(async move {
// 1. 創(chuàng)建創(chuàng)建快照需要的上下文
let snap_ctx = prepare_snap_ctx(...);
// 2. 申請一個快照
let snapshot = Self::with_tls_engine(|engine| Self::snapshot(engine, snap_ctx)).await?;
// 3. 創(chuàng)建SnapshotStore對象并執(zhí)行查詢
let snap_store = SnapshotStore::new(...);
let result = snap_store.get(key);
// 4. 更新Metrics和Stats統(tǒng)計信息
});
}
4.4.1 準備快照上下文
prepare_snap_ctx顧名思義即準備用于創(chuàng)建快照所需要的上下文對象,即SnapContext對象,其完整定義如下:
pub struct SnapContext<'a> {
pub pb_ctx: &'a Context,
pub read_id: Option<ThreadReadId>,
// When start_ts is None and `stale_read` is true, it means acquire a snapshot without any
// consistency guarantee.
pub start_ts: Option<TimeStamp>,
// `key_ranges` is used in replica read. It will send to
// the leader via raft "read index" to check memory locks.
pub key_ranges: Vec<KeyRange>,
// Marks that this snapshot request is allowed in the flashback state.
pub allowed_in_flashback: bool,
}
fn prepare_snap_ctx<'a>(...) -> Result<SnapContext<'a>> {
if !pb_ctx.get_stale_read() {
concurrency_manager.update_max_ts(start_ts);
}
if need_check_locks(isolation_level) {
concurrency_manager.read_key_check(...)
}
let mut snap_ctx = SnapContext {...};
if need_check_locks_in_replica_read(pb_ctx) {
snap_ctx.key_ranges = ...
}
}
prepare_snap_ctx只需要創(chuàng)建一個SnapContext對象,但目前實現(xiàn)中多出了如下判斷或操作,絕大部分都源于TiKV5.0中的AsyncCommit特性所需。
1.當本次讀取非StaleRead時,需要將當前讀取請求的start_ts與CurrencyManager中的max_ts進行比較,并將二者中的最大值更新為全局max_ts。這一操作用于保證異步提交事務計算出來的MinCommitTs不會破壞快照一致性。
2. 若當前的隔離級別是SnapshotIsolation或者RcCheckTs時, 則需要額外檢查CurrencyManager中的內存鎖。如果存在鎖且當前start_ts大于鎖中的MinCommitTs,TiKV會直接拒絕本次讀取請求。其原因在于AsyncCommit事務Prewrite結束之前需要暫時阻止使用更新的start_ts發(fā)起的快照讀,否則會導致正在異步提交的事務計算出的MinCommitTS無法滿足快照一致性。
4.4.2 向Engine申請Snapshot
Engine是TiKV中對上層存儲組件的一次抽象,所有實現(xiàn)了Engine Trait的具體實現(xiàn)都可以作為TiKV中的存儲層組件。目前TiKV中已經實現(xiàn)了BTreeEngine/MockEngine/RocksEngine/RaftKV等多個實現(xiàn)。
pub trait Engine: Send + Clone + 'static {
// 獲取用于查詢的快照
fn async_snapshot(&mut self, ctx: SnapContext<'_>) -> Self::SnapshotRes;
// 提交寫入的Mutation
fn async_write(&self,ctx: &Context,batch: WriteData,subscribed: u8, on_applied: Option<OnAppliedCb>) -> Self::WriteRes;
// 其他接口...
}
Engine的接口定義中與讀寫相關的接口分別是async_snapshot和async_write。目前TiKV中的默認Engine實現(xiàn)為RaftKV,即一個基于Raftstore的實現(xiàn)。在RaftKV中,所有的寫入都會通過Raft狀態(tài)機進行propose/commit/apply流程,用戶可以基于訂閱機制獲得這3個事件的通知從而做出不同處理,默認情況下,TiKV會在一次寫入請求被RaftLeader apply成功后返回用戶。而讀取操作則需要遵循先行一致性讀取,在早期版本中,一次讀取需要通過Raft狀態(tài)機進行一次ReadIndex才能進行,在新版中TiKV實現(xiàn)了基于租約的LeaseRead, 簡化了讀取流程。本次介紹的PointGet讀取流程中,會涉及到使用async_snapshot獲取一個Engine在當前時刻的快照,并基于快照進行讀取。
TiKV按照KeyRange將Key拆分為不同的Region, 每個Region都是一個RaftGroup,且擁有獨立的狀態(tài)機推進運轉。因此,RaftKV-Engine中async_snapshot返回的是一個名為RegionSnapshot的對象,其定義如下:
pub struct RegionSnapshot<S: Snapshot> {
snap: Arc<S>,
region: Arc<Region>,
apply_index: Arc<AtomicU64>,
pub term: Option<NonZeroU64>,
pub txn_extra_op: TxnExtraOp,
// `None` means the snapshot does not provide peer related transaction extensions.
pub txn_ext: Option<Arc<TxnExt>>,
pub bucket_meta: Option<Arc<BucketMeta>>,
}
RegionSnapshot本質是對底層的KV引擎RocksDB層面的快照的封裝,其邏輯視圖如下:
4.4.3 MVCC實現(xiàn)和快照隔離級別實現(xiàn)
前文提到的Engine::async_snapshot接口返回的快照本質是Engine在當下時刻的快照,并不等于事務層面的MVCC快照,因此在具體查詢時,需要配合StartTS進行使用。TiKV中封裝了一個SnapshotStore用于輔助MVCC層面的查詢。其定義如下:
pub struct SnapshotStore<S: Snapshot> {
snapshot: S,
start_ts: TimeStamp,
isolation_level: IsolationLevel,
fill_cache: bool,
bypass_locks: TsSet,
access_locks: TsSet,
check_has_newer_ts_data: bool,
point_getter_cache: Option<PointGetter<S>>,
}
SnapshotStore中集合了從Engine獲取的快照和客戶端請求附帶的StartTS, 因此可以被認為是一個MVCC層面的快照。用戶對SnapshotStore發(fā)起的點查會被委托給內部的PointGetter。
// PointGetter::get
pub fn get(&mut self, user_key: &Key) -> Result<Option<Value>> {
fail_point!("point_getter_get");
// 根據(jù)當前請求使用的隔離級別判定是否需要檢查鎖
if need_check_locks(self.isolation_level) {
// 如果需要檢查鎖且鎖存在,則需要根據(jù)判定鎖
if let Some(lock) = self.load_and_check_lock(user_key)? {
return self.load_data_from_lock(user_key, lock);
}
}
// Percoaltor正常讀取流程:從WriteCF中找到<=start_ts中最大的commit_ts,并基于其存儲的start_ts到DefaultCF中讀取
self.load_data(user_key)
}
在執(zhí)行查詢前,TiKV需要根據(jù)當前請求的隔離級別判定是否需要檢查鎖。
pub fn need_check_locks(iso_level: IsolationLevel) -> bool {
matches!(iso_level, IsolationLevel::Si | IsolationLevel::RcCheckTs)
}
TiKV支持SnapshotIsolation/ReadCommitted/ReadCommittedCheckTs三種隔離級別,其中前兩種需要檢查鎖。其原因在于LockCf中的鎖是由于事務在2PC的第一階段提交階段寫入的,事務的最終狀態(tài)無法確定,如果不檢查鎖直接讀取,那么可能導致快照讀取被破壞。
fn load_and_check_lock(&mut self, user_key: &Key) -> Result<Option<Lock>> {
// 從LockCf查詢該Key的鎖信息
let lock_value = self.snapshot.get_cf(CF_LOCK, user_key)?;
if let Some(ref lock_value) = lock_value {
let lock = Lock::parse(lock_value)?;
// 如果存在鎖則檢查鎖是否沖突
if let Err(e) = Lock::check_ts_conflict(
Cow::Borrowed(&lock),
user_key,
self.ts,
&self.bypass_locks,
self.isolation_level,
)
// ...
}
其中Lock::check_ts_conflict的實現(xiàn)中會根據(jù)當前的事務隔離級別進行判定,不同的隔離級別的判定邏輯略有差異。由于本文篇幅有限,這里只分析我們常用的快照隔離級別的實現(xiàn)。
fn check_ts_conflict_si(lock: Cow<'_, Self>, key: &Key, ts: TimeStamp, bypass_locks: &TsSet ) -> Result<()> {
if lock.ts > ts
|| lock.lock_type == LockType::Lock
|| lock.lock_type == LockType::Pessimistic
{
return Ok(());
}
if lock.min_commit_ts > ts {
// Ignore lock when min_commit_ts > ts
return Ok(());
}
if bypass_locks.contains(lock.ts) {
return Ok(());
}
let raw_key = key.to_raw()?;
if ts == TimeStamp::max() && raw_key == lock.primary && !lock.use_async_commit {
// When `ts == TimeStamp::max()` (which means to get latest committed version
// for primary key), and current key is the primary key, we ignore
// this lock.
return Ok(());
}
// There is a pending lock. Client should wait or clean it.
Err(Error::from(ErrorInner::KeyIsLocked(
lock.into_owned().into_lock_info(raw_key),
)))
}
- 當lock.ts > ts時,當前查詢請求可以直接忽略這個鎖。其原因在于當前的lock是由具有更高start_ts的事務寫入,因此即便這個事務后續(xù)被提交,其commit_ts一定大于當前的start_ts,其新寫入的數(shù)據(jù)是不可見的,不會破壞快照一致性。
- 當lock_type==Lock時,也可以直接忽略這個鎖突, 其原因在于LockType::Lock是由于創(chuàng)建索引產生,它只用于指示被鎖定但不會修改數(shù)據(jù),因此也可以直接被忽略。
- 當lock_type==Pessistics時,也可以直接忽略這個鎖突,LockType::Pessistics是由于悲觀事務執(zhí)行DML時寫入,并未進行到事務提交階段,即使這個事務很快被提交,由于其commit_ts也一定大于當前讀取的start_ts, 直接忽略并不會影響快照一致性。
- 當lock.min_commit_ts > ts時,也可以直接忽略這個鎖,其原因在于它能保證這個AsyncCommit事務的最終計算出的commit_ts一定大于ts,即使這個事務會被提交,也不會破壞快照一致性。
- 當bypass_locks中包含了當前鎖的start_ts時, 也可以直接忽略這個鎖。bypass_locks即前面Read through locks小節(jié)中提到了resloved_locks,這些鎖雖然存在,但它們對應事務要么已經被回滾,要么使用了大于當前讀取start_ts的commit_ts進行提交,無論是哪種情況都不會破壞快照一致性。
- 其他情況則需要返回KeyIsLocked錯誤給客戶端,客戶端收到這個錯誤后則會檢查這個鎖的過期時間,如果鎖尚未過期則需要做wait,否則會嘗試進行解鎖恢復這個事務的狀態(tài)。
若check_ts_conflict_si返回KeyIsLocked或其他錯誤后,TiKV會額外檢查access_locks里是否包含該鎖,如果該鎖存在,則KeyIsLocked錯誤則會被忽略,同時鎖會被直接返回,外層函數(shù)可以通過鎖找到start_ts從而直接讀取DefaultCF中的數(shù)據(jù)。這里的access_locks即Read through locks中的committed_locks,即已經知曉被提交的且commit_ts小于當前快照讀start_ts的事務,在這種情況下,直接讀取DefaultCF是一個超前但安全的操作,原因在在于一旦這個Lock被Resolve,用戶通過新的commit_ts可以定位到同一個start_ts。
if let Err(e) = Lock::check_ts_conflict(Cow::Borrowed(&lock),user_key,self.ts,&self.bypass_locks,self.isolation_level) {
if self.access_locks.contains(lock.ts) {
return Ok(Some(lock));
}
Err(e.into())
}
在不存在Key被鎖定或沖突,且沒有使用Read through locks讀取后,TiKV則會進行正常的Percolator讀取流程,即從WriteCF中找到<=start_ts中最大的commit_ts,并基于其存儲的start_ts到DefaultCF中讀取。
4.4.4 RegionSnapshot的Get實現(xiàn)
RegionSnapshot::get的實現(xiàn)相對比較簡單,邏輯如下:
fn get_value_cf_opt(&self, opts: &ReadOptions, cf: &str, key: &[u8]) -> EngineResult<Option<Self::DbVector>> {
// 1. 檢查查詢的key是否在Region的范圍內, 如果不在則直接返回錯誤。
check_key_in_range(key,self.region.get_id(),self.region.get_start_key(),self.region.get_end_key()).map_err(|e| EngineError::Other(box_err!(e)))?;
// 2. 基于查詢的key拼接出raftstore層面的DataKey (raftstore在寫入時會給用戶key前添加一個前綴'z')。
let data_key = keys::data_key(key);
// 3. 使用內部的RocksSnapshot查詢RocksDB獲取key對應的值。
self.snap.get_value_cf_opt(opts, cf, &data_key).map_err(|e| self.handle_get_value_error(e, cf, key))
}
4.4.5 RocksDB/Titan的Get實現(xiàn)
TiKV使用rust-rocksdb庫使用FFI實現(xiàn)與RocksDB C-API的交互,RocksSnapshot::get會通過crocksdb_get_pinned_cf將查詢接口委托給底層的RocksDB。值得注意的是,TiKV使用的并不是官方的RocksDB,而是自行維護的一個整合了Titan插件的版本。Titan是一個受WiscKey論文啟發(fā)而創(chuàng)建的項目,其主要目的是將存入RocksDB的大Value從LSM-Tree中分離出來,存儲到額外的Blob文件中,從而達到減小寫放大的目的。
本小節(jié)我們著重分析一下TitanDB中一次查詢的實現(xiàn)過程(做過大量精簡):
Status TitanDBImpl::GetImpl(const ReadOptions& options,
ColumnFamilyHandle* handle, const Slice& key,
PinnableSlice* value) {
// 先查詢RocksDB
s = db_impl_->GetImpl(options, key, gopts);
// 如果Key的Value不存在或者不是BlobIndex, 則直接返回
if (!s.ok() || !is_blob_index) return s;
// Value是BlobIndex,說明這是一個索引,還需要額外查詢BlobStorage
BlobIndex index;
s = index.DecodeFrom(value);
assert(s.ok());
if (!s.ok()) return s;
BlobRecord record;
PinnableSlice buffer;
mutex_.Lock();
// 根據(jù)索引查詢BlobStorage
auto storage = blob_file_set_->GetBlobStorage(handle->GetID()).lock();
mutex_.Unlock();
if (s.ok()) {
value->Reset();
value->PinSelf(record.value);
}
return s;
}
五、總結
- TiKV對數(shù)據(jù)存儲層的職能進行了非常合理的抽象,通過Engine/Snapshot/Iterator等trait定義實現(xiàn)了存儲層與上層的解耦。
- TiKV在RocksDB提供的多列族原子性寫入能力之上實現(xiàn)了Percolator模型,提供了分布式事務和MVCC等能力,并實現(xiàn)了AsyncCommit和1PC等改善了事務提交延遲。
- TiKV實現(xiàn)了一個基于RocksDB的KV分離插件titan, 借鑒了Wisckey的思想將大Value從LSM-Tree中分離,在大Value的業(yè)務場景下能夠通過降低寫放大改善性能。
- 從PointGet的實現(xiàn)我們可以看到在使用了MVCC的情況下,查詢時遇到前一事務Prewrite產生的Lock仍然需要等待Resolve,因此在AsyncCommit開啟的前提下,業(yè)務開發(fā)需要盡量避免設計事務提交后即刻發(fā)起查詢的場景,此外也要盡量避免由于大事務提交延遲高影響相關的查詢。
參考資料: