TiDB 在轉轉的業務實戰

PingCAP發表於2019-01-17

作者:陳維,轉轉優品技術部 RD。

開篇

世界級的開源分散式資料庫 TiDB 自 2016 年 12 月正式釋出第一個版本以來,業內諸多公司逐步引入使用,並取得廣泛認可。

對於網際網路公司,資料儲存的重要性不言而喻。在 NewSQL 資料庫出現之前,一般採用單機資料庫(比如 MySQL)作為儲存,隨著資料量的增加,“分庫分表”是早晚面臨的問題,即使有諸如 MyCat、ShardingJDBC 等優秀的中介軟體,“分庫分表”還是給 RD 和 DBA 帶來較高的成本;NewSQL 資料庫出現後,由於它不僅有 NoSQL 對海量資料的管理儲存能力、還支援傳統關聯式資料庫的 ACID 和 SQL,所以對業務開發來說,儲存問題已經變得更加簡單友好,進而可以更專注於業務本身。而 TiDB,正是 NewSQL 的一個傑出代表!

站在業務開發的視角,TiDB 最吸引人的幾大特性是:

  1. 支援 MySQL 協議(開發接入成本低);

  2. 100% 支援事務(資料一致性實現簡單、可靠);

  3. 無限水平擴充(不必考慮分庫分表)。

基於這幾大特性,TiDB 在業務開發中是值得推廣和實踐的,但是,它畢竟不是傳統的關係型資料庫,以致我們對關係型資料庫的一些使用經驗和積累,在 TiDB 中是存在差異的,現主要闡述“事務”和“查詢”兩方面的差異。

TiDB 事務和 MySQL 事務的差異

MySQL 事務和 TiDB 事務對比

圖 1

在 TiDB 中執行的事務 b,返回影響條數是 1(認為已經修改成功),但是提交後查詢,status 卻不是事務 b 修改的值,而是事務 a 修改的值。

可見,MySQL 事務和 TiDB 事務存在這樣的差異:

MySQL 事務中,可以通過影響條數,作為寫入(或修改)是否成功的依據;而在 TiDB 中,這卻是不可行的!

作為開發者我們需要考慮下面的問題:

  1. 同步 RPC 呼叫中,如果需要嚴格依賴影響條數以確認返回值,那將如何是好?

  2. 多表操作中,如果需要嚴格依賴某個主表資料更新結果,作為是否更新(或寫入)其他表的判斷依據,那又將如何是好?

原因分析及解決方案

對於 MySQL,當更新某條記錄時,會先獲取該記錄對應的行級鎖(排他鎖),獲取成功則進行後續的事務操作,獲取失敗則阻塞等待。

對於 TiDB,使用 Percolator 事務模型:可以理解為樂觀鎖實現,事務開啟、事務中都不會加鎖,而是在提交時才加鎖。參見 這篇文章(TiDB 事務演算法)。

其簡要流程如下:

圖 2

在事務提交的 PreWrite 階段,當“鎖檢查”失敗時:如果開啟衝突重試,事務提交將會進行重試;如果未開啟衝突重試,將會丟擲寫入衝突異常。

可見,對於 MySQL,由於在寫入操作時加上了排他鎖,變相將並行事務從邏輯上序列化;而對於 TiDB,屬於樂觀鎖模型,在事務提交時才加鎖,並使用事務開啟時獲取的“全域性時間戳”作為“鎖檢查”的依據。

所以,在業務層面避免 TiDB 事務差異的本質在於避免鎖衝突,即,當前事務執行時,不產生別的事務時間戳(無其他事務並行)。處理方式為事務序列化

TiDB 事務序列化

在業務層,可以藉助分散式鎖,實現序列化處理,如下:

圖 3

基於 Spring 和分散式鎖的事務管理器擴充

在 Spring 生態下,spring-tx 中定義了統一的事務管理器介面:PlatformTransactionManager,其中有獲取事務(getTransaction)、提交(commit)、回滾(rollback)三個基本方法;使用裝飾器模式,事務序列化元件可做如下設計:

圖 4

其中,關鍵點有:

  1. 超時時間:為避免死鎖,鎖必須有超時時間;為避免鎖超時導致事務並行,事務必須有超時時間,而且鎖超時時間必須大於事務超時時間(時間差最好在秒級)。

  2. 加鎖時機:TiDB 中“鎖檢查”的依據是事務開啟時獲取的“全域性時間戳”,所以加鎖時機必須在事務開啟前。

事務模板介面設計

隱藏複雜的事務重寫邏輯,暴露簡單友好的 API:

圖 5

圖 6

TiDB 查詢和 MySQL 的差異

在 TiDB 使用過程中,偶爾會有這樣的情況,某幾個欄位建立了索引,但是查詢過程還是很慢,甚至不經過索引檢索。

索引混淆型(舉例)

表結構:

CREATE TABLE `t_test` (
	  `id` bigint(20) NOT NULL DEFAULT '0' COMMENT '主鍵id',
	  `a` int(11) NOT NULL DEFAULT '0' COMMENT 'a',
	  `b` int(11) NOT NULL DEFAULT '0' COMMENT 'b',
	  `c` int(11) NOT NULL DEFAULT '0' COMMENT 'c',
	  PRIMARY KEY (`id`),
	  KEY `idx_a_b` (`a`,`b`),
	  KEY `idx_c` (`c`)
	) ENGINE=InnoDB;
複製程式碼

查詢:如果需要查詢 (a=1 且 b=1)或 c=2 的資料,在 MySQL 中,sql 可以寫為:SELECT id from t_test where (a=1 and b=1) or (c=2);,MySQL 做查詢優化時,會檢索到 idx_a_bidx_c 兩個索引;但是在 TiDB(v2.0.8-9)中,這個 sql 會成為一個慢 SQL,需要改寫為:

SELECT id from t_test where (a=1 and b=1) UNION SELECT id from t_test where (c=2);
複製程式碼

小結:導致該問題的原因,可以理解為 TiDB 的 sql 解析還有優化空間。

冷熱資料型(舉例)

表結構:

CREATE TABLE `t_job_record` (
	  `id` bigint(20) NOT NULL AUTO_INCREMENT COMMENT '主鍵id',
	  `job_code` varchar(255) NOT NULL DEFAULT '' COMMENT '任務code',
	  `record_id` bigint(20) NOT NULL DEFAULT '0' COMMENT '記錄id',
	  `status` tinyint(3) NOT NULL DEFAULT '0' COMMENT '執行狀態:0 待處理',
	  `execute_time` bigint(20) NOT NULL DEFAULT '0' COMMENT '執行時間(毫秒)',
	  PRIMARY KEY (`id`),
	  KEY `idx_status_execute_time` (`status`,`execute_time`),
	  KEY `idx_record_id` (`record_id`)
	) ENGINE=InnoDB COMMENT='非同步任務job'
複製程式碼

資料說明

a. 冷資料,status=1 的資料(已經處理過的資料);

b. 熱資料,status=0 並且 execute_time<= 當前時間 的資料。

慢查詢:對於熱資料,資料量一般不大,但是查詢頻度很高,假設當前(毫秒級)時間為:1546361579646,則在 MySQL 中,查詢 sql 為:

SELECT * FROM t_job_record where status=0 and execute_time<= 1546361579646
複製程式碼

這個在 MySQL 中很高效的查詢,在 TiDB 中雖然也可從索引檢索,但其耗時卻不盡人意(百萬級資料量,耗時百毫秒級)。

原因分析:在 TiDB 中,底層索引結構為 LSM-Tree,如下圖:

圖 7

當從記憶體級的 C0 層查詢不到資料時,會逐層掃描硬碟中各層;且 merge 操作為非同步操作,索引資料更新會存在一定的延遲,可能存在無效索引。由於逐層掃描和非同步 merge,使得查詢效率較低。

優化方式:儘可能縮小過濾範圍,比如結合非同步 job 獲取記錄頻率,在保證不遺漏資料的前提下,合理設定 execute_time 篩選區間,例如 1 小時,sql 改寫為:

SELECT * FROM t_job_record  where status=0 and execute_time>1546357979646  and execute_time<= 1546361579646
複製程式碼

優化效果:耗時 10 毫秒級別(以下)。

關於查詢的啟發

在基於 TiDB 的業務開發中,先摒棄傳統關係型資料庫帶來的對 sql 先入為主的理解或經驗,謹慎設計每一個 sql,如 DBA 所提倡:設計 sql 時務必關注執行計劃,必要時請教 DBA。

和 MySQL 相比,TiDB 的底層儲存和結構決定了其特殊性和差異性;但是,TiDB 支援 MySQL 協議,它們也存在一些共同之處,比如在 TiDB 中使用“預編譯”和“批處理”,同樣可以獲得一定的效能提升。

服務端預編譯

在 MySQL 中,可以使用 PREPARE stmt_name FROM preparable_stm 對 sql 語句進行預編譯,然後使用 EXECUTE stmt_name [USING @var_name [, @var_name] ...] 執行預編譯語句。如此,同一 sql 的多次操作,可以獲得比常規 sql 更高的效能。

mysql-jdbc 原始碼中,實現了標準的 StatementPreparedStatement 的同時,還有一個ServerPreparedStatement 實現,ServerPreparedStatement 屬於PreparedStatement的擴充,三者對比如下:

圖8.png

容易發現,PreparedStatementStatement 的區別主要區別在於引數處理,而對於傳送資料包,呼叫服務端的處理邏輯是一樣(或類似)的;經測試,二者速度相當。其實,PreparedStatement 並不是服務端預處理的;ServerPreparedStatement 才是真正的服務端預處理,速度也較 PreparedStatement 快;其使用場景一般是:頻繁的資料庫訪問,sql 數量有限(有快取淘汰策略,使用不宜會導致兩次 IO)。

批處理

對於多條資料寫入,常用 sql 為 insert … values (…),(…);而對於多條資料更新,亦可以使用 update … case … when… then… end 來減少 IO 次數。但它們都有一個特點,資料條數越多,sql 越加複雜,sql 解析成本也更高,耗時增長可能高於線性增長。而批處理,可以複用一條簡單 sql,實現批量資料的寫入或更新,為系統帶來更低、更穩定的耗時。

對於批處理,作為客戶端,java.sql.Statement 主要定義了兩個介面方法,addBatchexecuteBatch 來支援批處理。

批處理的簡要流程說明如下:

圖9.png

經業務中實踐,使用批處理方式的寫入(或更新),比常規 insert … values(…),(…)(或 update … case … when… then… end)效能更穩定,耗時也更低。

TiDB 在轉轉的業務實戰

相關文章