先说我踩过的坑
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.go和executor/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中经过的环节:
- 协议层:接收MySQL协议包解析出SQL文本,
server/conn.go - Parser层:词法/语法解析生成AST,
parser/parser.y - Logical Plan:AST转逻辑执行计划,包括逻辑优化(谓词下推、列裁剪),
planner/core - Physical Plan:选索引、确定Join顺序、分配内存限制,
planner/core/exhaust_physical_plans.go - Executor:执行物理计划,算子包括TableReader、IndexReader、IndexLookUp,
executor/builder.go - DistSQL:将执行计划拆成CopTask下发到TiKV,
distsql/distsql.go - 结果汇总: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列入候选清单。它的架构设计值得做一次技术预研,不会浪费你的时间。