作者:陳維,轉轉優品技術部 RD。
開篇
世界級的開源分散式資料庫 TiDB 自 2016 年 12 月正式釋出第一個版本以來,業內諸多公司逐步引入使用,並取得廣泛認可。
對於網際網路公司,資料儲存的重要性不言而喻。在 NewSQL 資料庫出現之前,一般採用單機資料庫(比如 MySQL)作為儲存,隨著資料量的增加,“分庫分表”是早晚面臨的問題,即使有諸如 MyCat、ShardingJDBC 等優秀的中介軟體,“分庫分表”還是給 RD 和 DBA 帶來較高的成本;NewSQL 資料庫出現後,由於它不僅有 NoSQL 對海量資料的管理儲存能力、還支援傳統關聯式資料庫的 ACID 和 SQL,所以對業務開發來說,儲存問題已經變得更加簡單友好,進而可以更專注於業務本身。而 TiDB,正是 NewSQL 的一個傑出代表!
站在業務開發的視角,TiDB 最吸引人的幾大特性是:
-
支援 MySQL 協議(開發接入成本低);
-
100% 支援事務(資料一致性實現簡單、可靠);
-
無限水平擴充(不必考慮分庫分表)。
基於這幾大特性,TiDB 在業務開發中是值得推廣和實踐的,但是,它畢竟不是傳統的關係型資料庫,以致我們對關係型資料庫的一些使用經驗和積累,在 TiDB 中是存在差異的,現主要闡述“事務”和“查詢”兩方面的差異。
TiDB 事務和 MySQL 事務的差異
MySQL 事務和 TiDB 事務對比
在 TiDB 中執行的事務 b,返回影響條數是 1(認為已經修改成功),但是提交後查詢,status 卻不是事務 b 修改的值,而是事務 a 修改的值。
可見,MySQL 事務和 TiDB 事務存在這樣的差異:
MySQL 事務中,可以通過影響條數,作為寫入(或修改)是否成功的依據;而在 TiDB 中,這卻是不可行的!
作為開發者我們需要考慮下面的問題:
-
同步 RPC 呼叫中,如果需要嚴格依賴影響條數以確認返回值,那將如何是好?
-
多表操作中,如果需要嚴格依賴某個主表資料更新結果,作為是否更新(或寫入)其他表的判斷依據,那又將如何是好?
原因分析及解決方案
對於 MySQL,當更新某條記錄時,會先獲取該記錄對應的行級鎖(排他鎖),獲取成功則進行後續的事務操作,獲取失敗則阻塞等待。
對於 TiDB,使用 Percolator 事務模型:可以理解為樂觀鎖實現,事務開啟、事務中都不會加鎖,而是在提交時才加鎖。參見 這篇文章(TiDB 事務演算法)。
其簡要流程如下:
在事務提交的 PreWrite 階段,當“鎖檢查”失敗時:如果開啟衝突重試,事務提交將會進行重試;如果未開啟衝突重試,將會丟擲寫入衝突異常。
可見,對於 MySQL,由於在寫入操作時加上了排他鎖,變相將並行事務從邏輯上序列化;而對於 TiDB,屬於樂觀鎖模型,在事務提交時才加鎖,並使用事務開啟時獲取的“全域性時間戳”作為“鎖檢查”的依據。
所以,在業務層面避免 TiDB 事務差異的本質在於避免鎖衝突,即,當前事務執行時,不產生別的事務時間戳(無其他事務並行)。處理方式為事務序列化。
TiDB 事務序列化
在業務層,可以藉助分散式鎖,實現序列化處理,如下:
基於 Spring 和分散式鎖的事務管理器擴充
在 Spring 生態下,spring-tx 中定義了統一的事務管理器介面:PlatformTransactionManager
,其中有獲取事務(getTransaction)、提交(commit)、回滾(rollback)三個基本方法;使用裝飾器模式,事務序列化元件可做如下設計:
其中,關鍵點有:
-
超時時間:為避免死鎖,鎖必須有超時時間;為避免鎖超時導致事務並行,事務必須有超時時間,而且鎖超時時間必須大於事務超時時間(時間差最好在秒級)。
-
加鎖時機:TiDB 中“鎖檢查”的依據是事務開啟時獲取的“全域性時間戳”,所以加鎖時機必須在事務開啟前。
事務模板介面設計
隱藏複雜的事務重寫邏輯,暴露簡單友好的 API:
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_b
和 idx_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,如下圖:
當從記憶體級的 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 原始碼中,實現了標準的 Statement
和 PreparedStatement
的同時,還有一個ServerPreparedStatement
實現,ServerPreparedStatement
屬於PreparedStatement
的擴充,三者對比如下:
容易發現,PreparedStatement
和 Statement
的區別主要區別在於引數處理,而對於傳送資料包,呼叫服務端的處理邏輯是一樣(或類似)的;經測試,二者速度相當。其實,PreparedStatement
並不是服務端預處理的;ServerPreparedStatement
才是真正的服務端預處理,速度也較 PreparedStatement
快;其使用場景一般是:頻繁的資料庫訪問,sql 數量有限(有快取淘汰策略,使用不宜會導致兩次 IO)。
批處理
對於多條資料寫入,常用 sql 為 insert … values (…),(…)
;而對於多條資料更新,亦可以使用 update … case … when… then… end
來減少 IO 次數。但它們都有一個特點,資料條數越多,sql 越加複雜,sql 解析成本也更高,耗時增長可能高於線性增長。而批處理,可以複用一條簡單 sql,實現批量資料的寫入或更新,為系統帶來更低、更穩定的耗時。
對於批處理,作為客戶端,java.sql.Statement
主要定義了兩個介面方法,addBatch
和 executeBatch
來支援批處理。
批處理的簡要流程說明如下:
經業務中實踐,使用批處理方式的寫入(或更新),比常規 insert … values(…),(…)
(或 update … case … when… then… end
)效能更穩定,耗時也更低。