設計與實現:從 Handle 抽象到全鏈路改造)
TiDB 聚簇索引Clustered Index設計與實現從 Handle 抽象到全鏈路改造【免費下載鏈接】tidbTiDB is built for agentic workloads that grow unpredictably, with ACID guarantees and native support for transactions, analytics, and vector search. No data silos. No noisy neighbors. No infrastructure ceiling.項目地址: https://gitcode.com/GitHub_Trending/ti/tidb聚簇索引是 TiDB 將主鍵與行數據存儲位置綁定、省去二次回表的關鍵能力。本文以 TiDB 官方設計文檔 docs/design/2020-05-08-cluster-index.md 為骨架結合當前倉庫源碼系統講解聚簇索引的存儲編碼、讀寫路徑、Planner/Coprocessor/Executor 改造、兼容性策略與落地路線。讀完本文你將理解 TiDB 如何從僅支持單整數列主鍵聚簇演進到支持任意類型主鍵聚簇并能定位到對應的源碼實現。背景為什么聚簇索引只能覆蓋單整數列主鍵在設計文檔提出的時間點2020-05-08TiDB 只支持單整數列主鍵的聚簇索引。其存儲模型是表中每行有一個 row-key 條目存儲整行數據所有二級索引條目指向該 row-key。如果主鍵是單整數列就直接把該列的值當作行 handlerow-key 的一部分實現主鍵即存儲位置否則由系統內部為行分配一個自增 handle并額外建立一條指向 row-key 的索引。當時的 row key 格式t | {table_id} | _r | {handle} // component 1 | 8 | 2 | 8 // byte sizetable_id與handle均為 int64因此 row key 長度恒為 19 字節。非唯一索引 key 格式t | {table_id} | _i | {index_id} | {index_column_values} | encoded_handle // component 1 | 8 | 2 | 8 | size of the values | 9 // byte sizehandle 作為索引 key 的后綴保證同一索引值下不同行按 handle 有序排列。唯一索引 key 格式t | {table_id} | _i | {index_id} | {index_column_values} // component 1 | 8 | 2 | 8 | size of the values // byte size唯一索引的 handle 存放在索引條目的value中而不是 key 中。三種典型操作路徑的差異On Write主鍵為單整數列時直接用主鍵列值作為 handle否則內部分配 handle 并額外寫入一條指向 row key 的索引條目。On Point Select單整數列主鍵時直接用主鍵值構造 row key 做一次 TiKV 查找否則要先構造索引 key 讀 handle再構造 row key 做第二次查找回表。On Range Scan單整數列主鍵時直接構造 row key 范圍做一次掃描否則要掃描索引收集 handle 集合再對每行做 point lookup。可以看到非單整數列主鍵的表在寫入和查詢時都多了一次索引往返這正是聚簇索引要解決的問題。核心抽象Handle 接口與兩種具體實現設計文檔提出將 handle 抽象為接口從而把行的 ID從 int64 推廣到任意主鍵列組合。當前源碼中的定義位于 pkg/kv/key.go接口方法包括package kv // Handle is the ID of a row. type Handle interface { // IsInt returns if the handle type is int64. IsInt() bool // IntValue returns the int64 value if IsInt is true, it panics if IsInt returns false. IntValue() int64 // Next returns the minimum handle that is greater than this handle. Next() Handle // Equal returns if the handle equals to another handle, it panics if the types are different. Equal(h Handle) bool // Compare returns the comparison result of the two handles, it panics if the types are different. Compare(h Handle) int // Encoded returns the encoded bytes. Encoded() []byte // Len returns the length of the encoded bytes. Len() int // NumCols returns the number of columns of the handle. NumCols() int // EncodedCol returns the encoded column value at the given column index. EncodedCol(idx int) []byte // String implements the fmt.Stringer interface. String() string }接口提供兩個具體類型IntHandletype IntHandle int64pkg/kv/key.go對應單整數列主鍵編碼格式與舊實現完全一致零額外開銷。CommonHandlepkg/kv/key.go對應其余所有主鍵類型內部保存encoded字節序列和colEndOffsets []uint16每個主鍵列在編碼后的結束偏移借助EncodedCol(idx)可以切出第 idx 列主鍵的編碼值。從源碼看接口在演進中還補充了Copy、Data、MemUsage、ExtraMemSize等方法用于內存追蹤與深拷貝說明該抽象經受住了后續內存控制等特性的復用。一個容易被忽略的工程細節handle 在很多地方被當作 map key 使用。如果直接用編碼字節作 keyIntHandle 每次都會產生內存分配。設計文檔給出的方案是自定義HandleMap類型內部用兩個 map 分別存放 int64 與字符串 key從而避免 IntHandle 的分配開銷。主鍵類型與 handle 類型的對應關系單列整數主鍵 → IntHandle其他任意主鍵 → CommonHandle。整數 handle 的 row key 與索引 key 格式保持不變改造只需關注 CommonHandle 路徑。編碼方案CommonHandle 的三類存儲格式行 key 格式CommonHandle row keyt | {table_id} | _r | {common_handle} // component 1 | 8 | 2 | len(common_handle) // byte sizecommon_handle與索引 key 中的index_column_values編碼完全一致因此 row key 長度不再固定為 19 字節而是隨主鍵內容變化。非唯一索引 key 格式t | {table_id} | _i | {index_id} | {idx_col_vals} | {common_handle} // component 1 | 8 | 2 | 8 | len(idx_col_vals) | len(common_handle) // byte size非唯一索引的 value 保持原樣common_handle 作為 key 后綴延續了同一索引值下按 handle 排序的性質。唯一索引 value 格式{tailLen} | {common_handle_flag} | {common_handle_len} | {common_handle} // component 1 | 1 | 2 | len(common_handle) // byte size唯一索引的 key 保持不變common_handle 被編碼進 value。tailLen用于描述 handle 之后是否還有附加數據如 TTL 等common_handle_flag用于區分 handle 類型使舊版本 TiKV 也能安全跳過未知 handle。設計文檔強調編碼的關鍵原則是讓 common_handle 與索引列值使用同一套列值編碼codec這樣 Coprocessor 解碼索引列時可以復用同一邏輯也保證了行 key 與索引 key 之間的字典序一致性。讀寫路徑的收益為什么能省掉一次往返聚簇索引的價值集中體現在三類操作上操作非聚簇舊聚簇CommonHandle寫入分配內部 handle 額外索引條目直接用主鍵值做 handle主鍵不再冗余編碼進行值點查索引查 handle → 回表查行用主鍵值直接構造 row key一次點查范圍掃描掃索引收 handle → 多次 point lookup直接掃 row key 范圍單次掃描設計文檔給出的量化動機是TPC-C 負載下把多列主鍵壓縮成單整數主鍵后性能提升約 33%——這 33% 正是聚簇索引期望在多列主鍵表上追回的開銷。需要說明的是這是設計階段的實驗數據不同負載下的實際收益取決于主鍵長度與回表頻率。另外值得注意的是聚簇索引表在寫入時不需要把主鍵列編碼進行 value見下文 Insert 改造進一步壓縮了行存儲。Planner把主鍵訪問當作表路徑而非索引路徑設計文檔對優化器提出了兩點關鍵改造主鍵索引路徑視為表路徑對 CommonHandle 表主鍵索引的訪問路徑應走 TableScan 計劃而非 IndexLookUp 計劃——因為主鍵本身就是行位置不需要索引 → 回表兩步。同時要仔細審查所有隱式假設 TableScan 使用 int64 handle 的邏輯并可能針對聚簇表調整代價模型。IndexScan 輸出 Schema 按需包含主鍵列若查詢用到任一主鍵列或執行器處于 IndexLookUp 中索引掃描的 schema 為{index columns},{primary key columns}且 CommonHandle 表不再額外輸出 handle 列若只用索引列schema 僅為{index columns}。文檔示例create table t (a int, b int, c int, d int, e int, primary key (a, b), index c_d (c, d));c_d索引掃描的 schema 應為c, d, a, b或c, d取決于查詢是否引用主鍵列。若查詢不引用列e則c_d成為覆蓋索引可直接構建 IndexScan 計劃而無需 IndexLookUp。Coprocessor解碼與采樣改造TiKV Coprocessor 側需要配合的改動對應 docs/design/2020-05-08-cluster-index.md 的 Coprocessor 一節IndexScan若掃描 schema 不含主鍵列邏輯不變若含主鍵列則需把 common handle 解碼為主鍵列值編碼后的列值可通過 Handle 的EncodedCol方法直接切出無需重新編碼。Analyze Column多列主鍵時CMSketch 需要插入主鍵的前綴列值這決定了舊版SHOW STATS_BUCKETS/ 多列統計的收集口徑。Fast Analyzehandle 不再是整數無法按給定位置隨機生成采樣 key因此需要新增一種 Coprocessor 請求類型在掃描過程中直接采樣 key/value 對并返回統計結果。Executor受影響的四個主要組件設計文檔明確列出需要改造的執行器Admin Executorsadmin check/recover table/index嚴重依賴 handle 是 int64 的假設需要大改以支持 CommonHandle。PointGet / BatchPointGet需要把 common handle 解碼到 chunk 中。Insert不再需要把主鍵列編碼進行 value主鍵信息已經蘊含在 row key 中。Update若任一主鍵列發生變化需要刪舊行 插新行因為主鍵即存儲位置主鍵變更等于行搬家。SplitTableRegion需要重新設計——舊邏輯根據 region 的 start/end key 取中間 key而 common handle 無法像整數那樣簡單取中值。這些改動在當前倉庫中已有大量落點例如 pkg/ddl/executor.go、pkg/ddl/index_cop.go 中隨處可見IsCommonHandle分支判斷。兼容性與系統變量如何安全上線設計文檔給出的兼容策略是向后兼容、按表啟用、升級需顯式開啟具體由兩處機制承載表結構標記在model.TableInfo中新增字段。當前定義見 pkg/meta/model/table.go// PKIsHandle is true when PK is clustered and a single integer column. PKIsHandle bool json:pk_is_handle // IsCommonHandle is true when PK is clustered and not a single integer column. IsCommonHandle bool json:is_common_handle // CommonHandleVersion is the version of the clustered index. // 0 for the clustered index created 5.0.0 RC. // 1 for the clustered index created 5.0.0 RC. CommonHandleVersion uint16 json:common_handle_version配套提供了HasClusteredIndex()方法pkg/meta/model/table.go統一判斷兩種聚簇形態。所有 CommonHandle 相關邏輯都必須先檢查IsCommonHandle再進入對應代碼塊。全局系統變量tidb_enable_clustered_index定義于 pkg/sessionctx/variable/sysvar.go支持OFF/ON/INT_ONLY三個取值INT_ONLY僅為兼容舊版本保留會輸出棄用警告會話默認值見 pkg/sessionctx/variable/session.go。啟用語義新集群bootstrap 階段直接寫入tidb_enable_clustered_index 1默認開啟聚簇索引舊集群升級需要用戶顯式執行SET GLOBAL tidb_enable_clustered_index 1后才生效避免升級瞬間改變既有表的存儲布局建表時機CREATE TABLE時若全局變量為1則在TableInfo上設置IsCommonHandle true當前 DDL 建表路徑見 pkg/ddl/create_table.go 中相關分支。實施路線與后續工作設計文檔給出的落地順序如下implement the Handle interface for IntHandle and CommonHandle. | refactor int64 handle to the Handle interface in all tidb packages. | - implement the codec. | |- support codec in tikv. | | | - support coprocessor indexScan | | | - support coprocessor fastAnalyze | | | - support coprocessor analyzeColumn | - support common handle in planner. | |- support common handle in executors. | |- support common handle in ddl. | - support common handle in other packages thats need minor change.即先落地 Handle 接口抽象 → 全量重構 int64 handle 為接口 → 實現 codec 與 TiKV 側支持 → 再逐層改造 planner / executors / ddl。從當前倉庫看該路線已基本完成Handle 接口還演進出了Copy、Data、MemUsage等額外能力CommonHandleVersion字段記錄了 5.0.0 RC 前后兩代聚簇索引編碼的差異。設計文檔最后列出的 Open Issues 是TiFlash 與 CDCTiCDC也需要同步更新以支持聚簇索引——即列存副本與增量日志同步必須理解 CommonHandle 編碼否則主鍵變更deleteinsert在復制鏈路上會產生錯誤。小結聚簇索引的本質是主鍵值 行存儲位置。TiDB 通過把 handle 抽象為Handle接口用IntHandle保住單整數列主鍵的零開銷路徑用CommonHandle覆蓋所有其他主鍵類型并以common_handle 與索引列共用同一套列值編碼為支點撬動了從 TiKV codec、Coprocessor、Planner 到 Executor 的全鏈路改造再通過IsCommonHandle表標記與tidb_enable_clustered_index全局變量實現了向后兼容的灰度上線。對于以多列主鍵為主業務模型的用戶這一特性直接消除了索引回表帶來的讀寫放大是理解 TiDB 行存儲與主鍵語義的關鍵一課。【免費下載鏈接】tidbTiDB is built for agentic workloads that grow unpredictably, with ACID guarantees and native support for transactions, analytics, and vector search. No data silos. No noisy neighbors. No infrastructure ceiling.項目地址: https://gitcode.com/GitHub_Trending/ti/tidb創作聲明:本文部分內容由AI輔助生成(AIGC),僅供參考