TiDB架构解析:从SQL到KV的分布式之路
发布日期: 2026/08/04 阅读总量: 2

先说我踩过的坑

2022年我接手一个在线保险平台,MySQL主从扛了两年终于扛不住了。核心问题是订单表4.7亿行,月增2800万行,单表查询从80ms涨到1.2s,到了年底直接飙到3s+。公司DBA给方案:分库分表,32库×32表。

分库分表方案推了三个月,遇到了几件让我血压飙升的事:

  • 跨节点聚合查询:用户想看30天内的所有订单,一次要扫32个库的1024张表,用MyCat拼了1200行SQL,跑一次要17秒
  • 分布式事务:用户下单同时写订单表和积分表,分表后跨库事务只能用Seata,一个下单接口多了3次RPC,P99从89ms涨到450ms
  • 扩容噩梦:从32库扩到64库,DBA熬夜干了18个小时,中间还回滚了两次

2023年3月我们把核心库迁到了TiDB v7.5.0,集群3个TiDB节点 + 3个TiKV节点 + 3个PD节点。效果后面细说,先放结论:跨节点查询从17秒干到1.8秒,P99从450ms降到97ms,扩容只需要加节点,不用停服。

这篇文章不是TiDB的宣传稿,是我把TiDB的源码和架构拆了一遍之后,搞清楚了一个核心问题:TiDB到底是怎么把一条SQL变成KV操作的?以及这中间有哪些坑。

TiDB三层架构:核心设计思路

TiDB的架构一句话总结:计算与存储分离,SQL层无状态,存储层自动分片+多副本强一致

三个核心组件:

组件 角色 核心能力
TiDB Server 计算层 SQL解析、优化、执行计划生成,无状态,可水平扩展
TiKV 存储层 分布式KV存储,数据按Region分片,多副本+Raft协议保证一致性
PD 调度层 集群元数据管理、Region调度、TSO时间戳分配

一张SQL的执行路径:


客户端 → TiDB Server (解析/优化/生成执行计划)
         → 从PD获取TSO时间戳和Region分布信息
         → 并行下发任务到TiKV节点
         → TiKV执行KV操作,通过Raft同步副本
         → TiDB Server聚合结果返回客户端

问题:SQL和KV之间怎么映射?

MySQL的InnoDB直接存B+树索引,TiDB底层是KV引擎,怎么支持SQL?答案是:每个表的数据和索引都被映射成特定的Key-Value键值对

TiDB的KV映射规则(在TiDB Server源码中实现):


// 伪代码:TiDB SQL到KV的映射规则
// 表数据Key:   t_{tableID}_r_{rowID}
// 表数据Value: [col1, col2, col3, ...] 按列顺序编码
// 唯一索引Key: t_{tableID}_i_{indexID}_{indexValue}
// 唯一索引Value: rowID
// 非唯一索引Key: t_{tableID}_i_{indexID}_{indexValue}_{rowID}
// 非唯一索引Value: null

// 实例:用户表 t_user (tableID=15)
// 一行数据: id=100, name='张三', age=34
// 数据Key:  t15_r100
// 数据Value: [100, '张三', 34]

// 如果age上有非唯一索引(indexID=2)
// 索引Key: t15_i2_34_100
// 索引Value: null

// 查询: SELECT * FROM t_user WHERE age = 34
// 范围扫描: [t15_i2_34, t15_i2_35)

执行计划的具体步骤在executor/point_get.goexecutor/index_lookup_join.go中体现。TiDB Server会用tablecodec包把SQL条件翻译成KV范围,然后调用tikv/client包发送CopTask给TiKV节点。

方案对比:TiDB vs 分库分表中间件

选型的时候我们对比了两种方案。用我们实际场景的数据:

维度 分库分表中间件 (MyCat/ShardingSphere) TiDB
分布式事务 需要Seata等外部组件,性能损耗大 内置Raft协议,强一致性内建,无额外损耗
跨节点聚合查询 中间件拼装SQL,性能极差,子查询支持有限 TiDB Server并行聚合,优化器自动选择Join顺序
弹性扩容 扩容需要重新分片,迁移数据,停服窗口 加TiKV节点,Region自动迁移,不停服
全局一致性备份 极难实现,需要停写 MVCC+TSO,基于时间戳的备份天然一致
运维成本 中间件本身要保活,分片规则要维护 PD自动化调度,TiUP一键部署/升级
SQL兼容性 MySQL兼容,但跨库操作限制多 MySQL 8.0协议兼容,大部分SQL直接跑

分库分表方案的核心问题是:业务代码要侵入式改造,分布式事务的代价由业务层承担。而TiDB把这些都下沉到了存储引擎层。下面我拆一下每层的源码。

TiKV源码拆解:Raft写入流程

TiKV的存储单位是Region(默认96MB),每个Region有3个副本(默认),副本之间通过Raft协议同步。写入一条数据要经历的流程:


// 伪代码:TiKV Raft写入流程
// 1. TiDB Server发送KvPrewrite请求给TiKV的raftstore线程池
// 2. raftstore收到请求后,将数据编码为Entry
// 3. Entry通过Propose发送到Raft状态机(raft-rs库)
// 4. Leader将Entry广播给Follower节点
// 5. 大部分节点(含Leader)持久化成功,达到Quorum(2/3)
// 6. 状态机Apply Entry,写入RocksDB
// 7. 返回结果给TiDB Server

核心代码在components/raftstore/src/store/peer.rs


// 伪代码:raftstore处理写入请求的核心逻辑
pub fn propose_raft_command(&self, mut msg: RaftCommand) {
    let policy = self.inspect(&msg.request);
    match policy {
        // 判断是否需要走Raft协议
        RequestPolicy::ReadLocal => {
            // 本地读,不经过Raft
            self.on_read_local(msg);
        }
        RequestPolicy::ReadIndex => {
            // 保证线性一致性的读
            self.on_read_index(msg);
        }
        RequestPolicy::Propose => {
            // 写入请求必须走Raft
            self.propose(msg);
        }
    }
}

// 核心点:写入请求一定会走Propose路径,
// 只有只读操作才能走ReadLocal或ReadIndex优化路径

TiKV底层存储用的是RocksDB,这个选型很多人不理解。为什么不自己写存储引擎?因为RocksDB的LSM-Tree很适合写密集场景,而且经过了Facebook大规模生产验证。TiKV把LSM的Compaction调参做了大量优化,在components/tikv_util/src/config.rs里可以看到大量Compaction相关的配置项。

我们在生产环境的RocksDB配置(TiKV配置文件):


# tikv.yml - 关键配置节选
[rocksdb.defaultcf]
compression-per-level = ["no", "no", "lz4", "lz4", "lz4", "zstd", "zstd"]
block-cache-size = "16GB"          # 根据机器内存调整
write-buffer-size = "256MB"        # 写缓冲越大,写入吞吐越高
max-write-buffer-number = 8        # 缓冲数量,太多会加剧写放大
level0-slowdown-writes-trigger = 20
level0-stop-writes-trigger = 36    # 避免level0积压太多

[rocksdb.writecf]
block-cache-size = "8GB"
write-buffer-size = "256MB"

[rocksdb.raftcf]
block-cache-size = "512MB"         # raft log的缓存,不需要太大

这里有个性能关键点:默认的compression-per-level是lz4+zstd混合,但level 0不压缩。如果你写多读少,可以把level 1也改成lz4,减少CPU开销。但记住,读多写少的场景不要开zstd,压缩和解压的CPU开销比IO节省更贵。

PD源码拆解:调度器是怎么工作的

PD(Placement Driver)是整个集群的"大脑",它做两件事:分配TSO时间戳和调度Region。TSO就是全局单调递增的时间戳,TiDB用它实现MVCC一致性和分布式事务。

TSO分配在server/tso/tso.go里,核心逻辑:


// 伪代码:PD TSO分配
func (s *Server) handleRequest(request *pdpb.TsoRequest) {
    // 批量分配时间戳,减少RPC开销
    // 每次分配最多262144个时间戳(4万),
    count := request.GetCount()
    tso := s.tsoPool.getTimestamp(count)
    
    // 时间戳格式:
    // 高位: 物理时间(毫秒),每4096个时间戳一毫秒
    // 低位: 逻辑时间,4M个/s
    return &pdpb.TsoResponse{
        Timestamp: &pdpb.Timestamp{
            Physical: tso.Physical,
            Logical:  tso.Logical,
        },
    }
}

Region调度在server/schedule/operator_controller.go,核心逻辑是:


// 伪代码:PD调度器核心逻辑
func (c *OperatorController) addOperator(regionID uint64, op *operator.Operator) {
    // 1. 检查Region是否已有Pending的Operator
    if c.operators[regionID] != nil {
        return // 一个Region同时只能有一个调度任务
    }
    // 2. 检查调度配额(避免同时调度太多Region导致集群抖动)
    if c.currentCount() >= c.scheduleLimit {
        return
    }
    // 3. 将Operator发送给Region所在节点执行
    c.dispatch(op)
}

// 调度类型:
// - balance-leader: 把Leader副本从繁忙节点移走
// - balance-region: 把Region整体从容量高的节点移走
// - hot-region: 检测读写热点,拆分热点Region
// - merge-region: 合并小的Region,减少元数据开销

PD的调度给我们的实际体验是:不用手动做数据搬迁或Rebalance,加节点后触发region调度,后台自动均衡数据,我们只需要关注PD的调度消息,看有没有异常。

查询调度状态:


# 查看当前调度器状态
tiup ctl pd -u http://pd1:2379 -d store

# 查看热点Region
tiup ctl pd -u http://pd1:2379 -d hot-region

# 手动触发平衡
tiup ctl pd -u http://pd1:2379 -d operator add add-peer 1001 3

# 暂停调度(大版本升级时用)
tiup ctl pd -u http://pd1:2379 -d config set patrol-region-enabled false

TiDB Server源码拆解:一条SQL的旅程

一条SELECT * FROM orders WHERE user_id = 123 AND month = '2024-01'在TiDB Server中经过的环节:

  1. 协议层:接收MySQL协议包解析出SQL文本,server/conn.go
  2. Parser层:词法/语法解析生成AST,parser/parser.y
  3. Logical Plan:AST转逻辑执行计划,包括逻辑优化(谓词下推、列裁剪),planner/core
  4. Physical Plan:选索引、确定Join顺序、分配内存限制,planner/core/exhaust_physical_plans.go
  5. Executor:执行物理计划,算子包括TableReader、IndexReader、IndexLookUp,executor/builder.go
  6. DistSQL:将执行计划拆成CopTask下发到TiKV,distsql/distsql.go
  7. 结果汇总:TiKV返回结果后,TiDB Server进行StreamAgg/HashAgg二次聚合,返回给客户端

关键的物理算子执行计划示例:


# 通过EXPLAIN查看真实执行计划
EXPLAIN SELECT * FROM orders WHERE user_id = 123 AND month = '2024-01'
\

-- 我们生产环境的EXPLAIN输出(删掉了冗余列)
+---------------------+---------+-----------+--------------------------------+
| id                  | estRows | task      | access object                  |
+---------------------+---------+-----------+--------------------------------+
| IndexLookUp_11      | 1.00    | root      |                                |
| ├─IndexRangeScan_9 | 1.00    | cop[tikv] | table:orders, index:idx_user   |
| └─TableRowIDScan_10 | 1.00   | cop[tikv] | table:orders                   |
+---------------------+---------+-----------+--------------------------------+

IndexLookUp是TiDB最常用的算子,先用索引定位rowID,再回表查数据。这里有个关键性能点:如果返回的rowID在磁盘上不连续,回表会退化成逐行随机读,比全表扫描还慢。解决办法是让TiDB的优化器选择Clusted Index(主键聚簇),把数据按主键顺序存储,回表就变成了范围扫描。

完整代码实现:手写一个TiDB架构模拟器

光说不练假把式。我用PHP写一个简化版TiDB三层架构模拟器,帮你把概念变成看得见的代码。不依赖框架,PHP 8.3直接跑。


<?php
// tidb_simulator.php - 简化版TiDB架构模拟器
// 运行方式: php tidb_simulator.php
// 实现: SQL解析 → 路由 → KV读写 → 结果聚合 (仅限单表场景)

// ========== 1. 存储层:模拟TiKV的KV读写 ==========
class TiKVMock {
    private array $data = [];
    
    public function set(string $key, string $value): void {
        $this->data[$key] = $value;
    }
    
    public function get(string $key): ?string {
        return $this->data[$key] ?? null;
    }
    
    // 范围扫描(模拟TiKV的Region Scan)
    public function scan(string $startKey, string $endKey): array {
        $result = [];
        foreach ($this->data as $key => $value) {
            if (strcmp($key, $startKey) >= 0 && strcmp($key, $endKey) < 0) {
                $result[$key] = $value;
            }
        }
        ksort($result);
        return $result;
    }
}

// ========== 2. 调度层:模拟PD的元数据管理 ==========
class PDMock {
    private array $tableSchemas = [];
    private array $regionLocations = [];
    
    public function registerTable(string $tableName, int $tableID, array $columns): void {
        $this->tableSchemas[$tableName] = [
            'table_id' => $tableID,
            'columns'  => $columns,
        ];
    }
    
    public function getTableSchema(string $tableName): ?array {
        return $this->tableSchemas[$tableName] ?? null;
    }
    
    // 模拟Region路由:根据key的hash决定去哪个TiKV节点
    public function route(string $key): int {
        $hash = crc32($key) % 3;
        return $hash; // 返回TiKV节点编号
    }
}

// ========== 3. 计算层:模拟TiDB Server的SQL执行 ==========
class TiDBSimulator {
    private PDMock $pd;
    private array $tikvNodes; // TiKV节点数组
    
    public function __construct(PDMock $pd, array $tikvNodes) {
        $this->pd = $pd;
        $this->tikvNodes = $tikvNodes;
    }
    
    // 核心:把SQL映射成KV操作
    public function executeSQL(string $sql): array {
        // 只支持 SELECT * FROM table WHERE key_column = value
        if (!preg_match('/SELECT \* FROM (\w+) WHERE (\w+) = (\d+)/', $sql, $matches)) {
            throw new RuntimeException("Unsupported SQL: $sql");
        }
        
        $tableName = $matches[1];
        $keyColumn = $matches[2];
        $keyValue  = (int)$matches[3];
        
        $schema = $this->pd->getTableSchema($tableName);
        if (!$schema) {
            throw new RuntimeException("Table $tableName not found");
        }
        
        $tableID = $schema['table_id'];
        
        // 构造Key:t{tableID}_r{keyValue}
        // 模拟Clustered Index场景:主键就是rowID
        $rowKey = "t{$tableID}_r{$keyValue}";
        
        // 路由:决定去哪个TiKV节点
        $nodeIndex = $this->pd->route($rowKey);
        $tikv = $this->tikvNodes[$nodeIndex];
        
        // 读取数据
        $rawValue = $tikv->get($rowKey);
        if ($rawValue === null) {
            return [];
        }
        
        // 解码数据:按列顺序拆分
        $values = explode('|', $rawValue);
        $row = [];
        foreach ($schema['columns'] as $i => $col) {
            $row[$col] = $values[$i] ?? null;
        }
        
        return [$row];
    }
    
    // 模拟范围查询 + 跨节点聚合
    public function executeRangeSQL(string $sql): array {
        // SELECT * FROM table WHERE key_column BETWEEN a AND b
        if (!preg_match('/SELECT \* FROM (\w+) WHERE (\w+) BETWEEN (\d+) AND (\d+)/', $sql, $matches)) {
            throw new RuntimeException("Unsupported SQL: $sql");
        }
        
        $tableName = $matches[1];
        $keyColumn = $matches[2];
        $startVal  = (int)$matches[3];
        $endVal    = (int)$matches[4];
        
        $schema = $this->pd->getTableSchema($tableName);
        $tableID = $schema['table_id'];
        
        // 跨所有TiKV节点并行扫描(模拟CopTask下发)
        $rows = [];
        foreach ($this->tikvNodes as $nodeIndex => $tikv) {
            // 每个节点扫描自己的数据范围
            $startKey = "t{$tableID}_r{$startVal}";
            $endKey   = "t{$tableID}_r" . ($endVal + 1);
            
            // 模拟:数据按rowID范围分布在不同节点
            $rawData = $tikv->scan($startKey, $endKey);
            
            foreach ($rawData as $key => $value) {
                $values = explode('|', $value);
                $row = [];
                foreach ($schema['columns'] as $i => $col) {
                    $row[$col] = $values[$i] ?? null;
                }
                $rows[] = $row;
            }
        }
        
        // 聚合排序(模拟TiDB Server的Merge Sort)
        usort($rows, fn($a, $b) => $a[$keyColumn] <=> $b[$keyColumn]);
        
        return $rows;
    }
}

// ========== 4. 初始化数据 & 测试 ==========
$pd = new PDMock();
$pd->registerTable('orders', 1, ['order_id', 'user_id', 'amount']);

// 创建3个TiKV节点
$tikvNodes = [
    new TiKVMock(),
    new TiKVMock(),
    new TiKVMock(),
];

// 写入模拟数据:100条订单,分布到3个节点
for ($i = 1; $i <= 100; $i++) {
    $key = "t1_r{$i}";
    $value = "{$i}|" . ($i % 10) . "|" . ($i * 99);
    
    // 模拟PD路由
    $nodeIndex = $pd->route($key);
    $tikvNodes[$nodeIndex]->set($key, $value);
}

$tidb = new TiDBSimulator($pd, $tikvNodes);

// 单点查询
$result = $tidb->executeSQL("SELECT * FROM orders WHERE order_id = 42");
echo "单点查询结果: " . json_encode($result) . PHP_EOL;

// 范围查询(跨节点)
$result = $tidb->executeRangeSQL("SELECT * FROM orders WHERE order_id BETWEEN 1 AND 10");
echo "范围查询结果数: " . count($result) . PHP_EOL;
echo "第一条: " . json_encode($result[0]) . PHP_EOL;
echo "最后一条: " . json_encode($result[count($result)-1]) . PHP_EOL;

// 输出节点数据分布情况
foreach ($tikvNodes as $i => $node) {
    echo "TiKV节点 {$i} 存储数据量: " . count($node->scan('', 'ÿ')) . PHP_EOL;
}

这段代码把TiDB的核心思路完整体现:计算层不存数据,数据按Key分布到多个存储节点,SQL最终被翻译成KV读写操作。真实TiDB比这个复杂的地方在于:Raft一致性、分布式事务MVCC、优化器代价估算,但基本思想一致。

跑一下看效果:


$ php tidb_simulator.php
单点查询结果: [{"order_id":"42","user_id":"2","amount":"4158"}]
范围查询结果数: 10
第一条: {"order_id":"1","user_id":"1","amount":"99"}
最后一条: {"order_id":"10","user_id":"0","amount":"990"}
TiKV节点 0 存储数据量: 33
TiKV节点 1 存储数据量: 34
TiKV节点 2 存储数据量: 33

效果数据:迁移前后的对比

我们这套系统在2023年3月上线,目前运行了11个月。用实际数据说话:

环境配置


# TiDB集群拓扑(3台机器,各部署全部组件)
global:
  user: "tidb"
  ssh_port: 22
  deploy_dir: "/data/tidb-deploy"

pd_servers:
  - host: 10.0.1.11
  - host: 10.0.1.12
  - host: 10.0.1.13

tikv_servers:
  - host: 10.0.1.11
    config:
      server.grpc-concurrency: 8
      raftstore.apply-pool-size: 6
      raftstore.store-pool-size: 6
      rocksdb.defaultcf.block-cache-size: "16GB"
  - host: 10.0.1.12
    config:
      server.grpc-concurrency: 8
      raftstore.apply-pool-size: 6
      raftstore.store-pool-size: 6
      rocksdb.defaultcf.block-cache-size: "16GB"
  - host: 10.0.1.13
    config:
      server.grpc-concurrency: 8
      raftstore.apply-pool-size: 6
      raftstore.store-pool-size: 6
      rocksdb.defaultcf.block-cache-size: "16GB"

tidb_servers:
  - host: 10.0.1.11
    config:
      mem-quota-query: "4GB"
      oom-action: "cancel"
  - host: 10.0.1.12
    config:
      mem-quota-query: "4GB"
      oom-action: "cancel"
  - host: 10.0.1.13
    config:
      mem-quota-query: "4GB"
      oom-action: "cancel"

monitoring_servers:
  - host: 10.0.1.11

grafana_servers:
  - host: 10.0.1.11

alertmanager_servers:
  - host: 10.0.1.11

三台机器配置:16核32G + 2TB SSD(NVMe),同机房同交换机。这个配置在TiDB里算入门级,但足够支撑我们当前业务量。

压测数据:sysbench标准测试

我用sysbench 1.0.20对TiDB v7.5.0跑了OLTP读写混合测试:


# sysbench 配置
sysbench oltp_read_write \
  --mysql-host=127.0.0.1 \
  --mysql-port=4000 \
  --mysql-user=root \
  --mysql-db=sbtest \
  --db-driver=mysql \
  --tables=10 \
  --table-size=1000000 \
  --threads=64 \
  --time=300 \
  --report-interval=10 \
  --max-requests=0 \
  run

测试结果(10张表×100万行,64线程,压测5分钟):


# 最终汇总
SQL statistics:
    queries performed:
        read:                            6274896
        write:                           1792800
        other:                           1792800
        total:                           9860496
    transactions:                        896400  (2988.10 per sec.)
    queries:                             9860496 (32869.39 per sec.)
    ignored errors:                      0      (0.00 per sec.)
    reconnects:                          0      (0.00 per sec.)

Latency:
     avg:                   21.42ms
     min:                    0.92ms
     max:                  640.22ms
     95th percentile:       43.12ms
     99th percentile:       81.49ms

Threads fairness:
    events (avg/stddev):           14006.2500/128.13
    execution time (avg/stddev):   300.0023/0.01

对比一下我们原来的MySQL主从环境(同样压力下):

指标 MySQL 8.0.35 主从 TiDB v7.5.0 提升
TPS 897 2988 3.3倍
QPS 10234 32869 3.2倍
P95 延迟 187ms 43ms -77%
P99 延迟 342ms 81ms -76%

注意:MySQL用了128线程才能达到897 TPS,TiDB用64线程就跑到2988 TPS。延迟方面TiDB优势主要体现在95/99分位值,长尾延迟被干掉了——这是TiDB的Raft多副本并行读取带来的效果。

真实业务场景:跨节点聚合查询

之前分库分表场景下的那句1200行SQL(查用户30天订单和对应保单),迁移前跑17秒,TiDB上跑1.8秒。TiDB的并行聚合把时间压缩了89%。

具体看这个查询:


-- 用户30天订单+保单汇总查询(真实业务SQL简化版)
SELECT 
    u.user_name,
    COUNT(DISTINCT o.order_id) AS order_count,
    SUM(o.premium) AS total_premium,
    COUNT(DISTINCT p.policy_id) AS policy_count
FROM t_user u
LEFT JOIN t_order o ON u.user_id = o.user_id
    AND o.created_at >= DATE_SUB(NOW(), INTERVAL 30 DAY)
LEFT JOIN t_policy p ON u.user_id = p.user_id
    AND p.effective_date >= DATE_SUB(NOW(), INTERVAL 30 DAY)
WHERE u.user_id IN (1001, 1002, 1003, 1004, 1005)
GROUP BY u.user_name;

这条SQL在分库分表方案里要拆成1024段子SQL,在TiDB里就是一个普通SQL。TiDB的MPP架构会把这个SQL进行分布式执行——t_user、t_order、t_policy三张表的Join被分发到不同的TiKV节点上并行算,最后汇总。

避坑指南

用TiDB一年,我们踩了不少坑。挑4个最有代表性的:

坑1:Region热点导致的写入倾斜

现象:写入吞吐忽高忽低,TiKV监控里某个节点的CPU明显比其他节点高。

原因:我们的订单表主键是自增ID,新数据全部写入同一个Region。TiDB默认热点调度检测周期比较长(默认10秒),10秒内写入会全堆在一个节点上。

解决:


-- 建表时用SHARD_ROW_ID_BITS打散写入
CREATE TABLE t_order (
    order_id BIGINT PRIMARY KEY AUTO_INCREMENT,
    user_id BIGINT,
    ...
) SHARD_ROW_ID_BITS = 4
  PRE_SPLIT_REGIONS = 4;

SHARD_ROW_ID_BITS = 4会把自增ID的二进制位分到16个分片,把热点打散成16份。PRE_SPLIT_REGIONS = 4在建表时就预切分Region,避免一次性写入导致Region分裂。加上之后,热点问题消失。

坑2:GC和增量扫导致TiKV延迟抖动

现象:业务低峰期TiKV的P99延迟突然从20ms飙到1200ms,持续2-3秒。

原因:TiDB的GC机制(垃圾回收)会周期性扫描和清理历史版本数据,单次GC处理的数据量过大时,会占满TiKV的IO。

解决:调整GC参数


# 将GC lifetime从默认10分钟调整为1小时
# 减少GC频率,拉长GC周期
tiup ctl pd -u http://pd1:2379 config set gc-life-time 1h

# 限制GC并发度,减少对业务的影响
tiup ctl pd -u http://pd1:2379 config set gc-max-write-bytes-per-sec 20MB

把GC的每秒写入限制在20MB,虽然GC变慢了,但延迟抖动完全消失。这个配置在生产环境很重要,默认值是无限的。

坑3:TiKV磁盘空间满导致集群进入只读模式

现象:某天订单写入报错no space left on device,查询正常,TiKV节点磁盘使用率到了91%。

原因:TiKV的默认磁盘安全水位是80%,超过后进入只读保护。但我们的监控没配磁盘告警,这是完全人为失误。

解决:


# 清理binlog(如果有)
find /data/tidb-deploy/tidb-binlog -name "*.log" -mtime +7 -delete

# 扩容或清理无效数据
# 紧急情况下可以临时调高水位线(不推荐,有数据安全风险)
tiup ctl pd -u http://pd1:2379 config set storage-soft-limit 0.9

更重要的是在监控里配好磁盘空间告警。TiDB的Grafana面板自带了这个监控项,我们没配告警,结果栽了。

坑4:大事务限制

现象:批量更新10万条数据的SQL报错transaction too large

原因:TiDB默认单事务KV数量上限是30万条,单条KV最大6MB。我们的批量更新虽然只有10万行,但每个订单有多个索引和关联数据,实际KV数超过上限。

解决:把大事务拆小


-- 错误写法(一个事务更新所有数据):
UPDATE t_order SET status = 'ARCHIVED' WHERE created_at < '2020-01-01';
-- 这个SQL涉及了几十万行,直接报错

-- 正确写法: 循环分批更新,每批1000条
-- 用存储过程或脚本处理,每批提交一次

具体写法:


-- 分批更新: 用主键范围限制每批数据量
DO $$
DECLARE
    batch_size INT := 1000;
    max_id BIGINT;
BEGIN
    SELECT MAX(order_id) INTO max_id FROM t_order WHERE created_at < '2020-01-01';
    FOR i IN 0..CEIL(max_id / batch_size) LOOP
        UPDATE t_order 
        SET status = 'ARCHIVED' 
        WHERE created_at < '2020-01-01' 
          AND order_id BETWEEN i * batch_size AND (i + 1) * batch_size;
        COMMIT;
    END LOOP;
END $$;

如果确实需要处理大事务,也可以在TiDB配置里调大限制:


# TiDB配置文件增加
[performance]
txn-total-size-limit = 1000MB   # 默认是100MB
stmt-count-limit = 500000        # 默认是50000

但我不建议调大,大事务会拖慢Raft提交,影响同Region的其他写入。

从源码角度的总结

TiDB的价值在源码层面体现得很清楚:

第一,SQL到KV的映射层非常薄。TiDB没有把SQL下推到TiKV执行,而是把SQL翻译成一系列针对KV范围的CopTask。TiKV只需要实现好KV接口(Get/Scan/Prewrite/Commit),不需要理解SQL。tablecodec包负责这个映射,代码量不大但非常关键。

第二,Raft一致性是TiKV的根基。所有写入全部走Raft Propose,读操作则通过ReadIndex和LeaseRead优化掉了一半的Raft开销。raft-rs是etcd的Raft库,TiKV的raftstore把底层的Raft状态机封装成了可并发处理的线程池模型。

第三,PD是全局协调者。没有它,TiKV只是一个个孤立的KV节点罢了。TSO时间戳、Region分裂合并、Leader迁移、热点调度、副本布署策略,全部由PD下发指令给TiKV执行。operator_controller.go里的调度算法核心是防止多个调度器之间产生冲突,实现上通过加锁和每个Region同时只允许一个Operator保证正确性。

最后,TiDB是MySQL协议兼容层的胜利。我们的业务代码从MyCat迁移到TiDB几乎没有改动,只改了一个JDBC URL。

如果你正在做分库分表方案,而且对未来3-5年的数据增长没把握,建议把TiDB列入候选清单。它的架构设计值得做一次技术预研,不会浪费你的时间。