TiDB:一个“三头六臂”的分布式数据库,如何把 MySQL 拆了又拼起来?🧩🐬
朋友们,想象一下:MySQL 是个老实人,一个人默默扛下所有数据。突然有一天,数据量爆炸增长,MySQL 累趴下了。你说:“分库分表吧!”结果 MySQL 被大卸八块,每个部分都成了“残疾”——不能 JOIN、不能事务、运维要命。😫
这时,TiDB 拍拍胸脯说:“别拆了!让我来组建一个‘复仇者联盟’,每个成员各司其职,还能默契配合!”今天,我们就来揭秘这个“数据库联盟”的内部运作。🚀
一、TiDB 的“人格分裂”:三头六臂的架构 🎭
1. 三个“人格”分工明确
人格1:TiDB Server(计算层) - “聪明的前台接待” 👨💼
- 职责:接 SQL 请求、算执行计划、返回结果
- 特点:无状态,随便扩,不存数据
- 口头禅:“SQL 交给我,数据在哪我帮你找!”
人格2:PD Server(调度层) - “全能的项目经理” 📊
- 职责:管元数据、调度数据分布、分配时间戳
- 特点:集群大脑,奇数个(防脑裂)
- 口头禅:“数据放哪我最清楚,听我调度!”
人格3:TiKV Server(存储层) - “勤劳的仓库管理员” 📦
- 职责:存数据、保证一致性、处理事务
- 特点:有状态,存真实数据,用 Raft 保证一致
- 口头禅:“数据在我这,绝对安全可靠!”
2. 架构全景图
用户请求
↓
┌─────────────────────────────────────┐
│ TiDB Server (计算层) │ ← 无状态,可水平扩展
│ ┌─────────────────────────────┐ │
│ │ 1. SQL解析器 │ │
│ │ 2. 优化器(选最便宜的执行计划)│ │
│ │ 3. 执行器(干活!) │ │
│ └─────────────────────────────┘ │
└───────────────┬────────────────────┘
│ 问PD:数据在哪?
↓
┌─────────────────────────────────────┐
│ PD Server (调度层) │ ← 集群大脑
│ ┌─────────────────────────────┐ │
│ │ 1. 元数据管理(数据分布地图)│ │
│ │ 2. 调度中心(数据搬来搬去) │ │
│ │ 3. 授时中心(分配全局时间戳)│ │
│ └─────────────────────────────┘ │
└───────────────┬────────────────────┘
│ 告诉TiDB:数据在TiKV 3号仓库
↓
┌─────────────────────────────────────┐
│ TiKV Server (存储层) │ ← 有状态,存真实数据
│ ┌─────────────────────────────┐ │
│ │ Region 1 (96MB) │ │
│ │ ┌──────────────────┐ │ │
│ │ │ 数据副本×3 │ │ │
│ │ │ 通过Raft保持一致 │ │ │
│ │ └──────────────────┘ │ │
│ │ Region 2 (96MB) │ │
│ │ ... │ │
│ └─────────────────────────────┘ │
└─────────────────────────────────────┘
二、数据“变形记”:从 SQL 到 Key-Value 🎪
1. MySQL vs TiDB 的数据观
MySQL 视角:
CREATE TABLE users (
id INT PRIMARY KEY,
name VARCHAR(100)
);
-- 数据是"表格",有行列
TiDB 视角:
// 实际上,TiDB 把一切变成 Key-Value
Key: t{表ID}_r{行ID}
Value: {列1: 值1, 列2: 值2, ...}
// 示例:表 users (ID=123),行 id=1, name='张三'
Key: t123_r1
Value: {"id": 1, "name": "张三"}
索引也变成 KV:
// 如果有索引 idx_name(name)
Key: t{表ID}_i{索引ID}_{索引值}_{行ID}
Value: 空
// 示例:在 name 列建索引
Key: t123_i1_张三_1
Value: ""
2. Region:数据分片的“魔法块” ✨
传统分库分表:DBA 手动分区
-- 痛苦的手工活
CREATE TABLE orders (
id INT
) PARTITION BY RANGE (id) (
PARTITION p0 VALUES LESS THAN (1000000),
PARTITION p1 VALUES LESS THAN (2000000),
-- 写到手抽筋...
);
TiDB 自动分片:
一张表 → 自动切成多个 Region
每个 Region → 约 96MB 数据
每个 Region → 3 个副本(通过 Raft)
Region 分布 → PD 自动调度
Region 可视化:
表 users (假设 300MB 数据)
├── Region 1: [Key: t123_r1 → t123_r1000000]
│ ├── Leader副本: TiKV-1
│ ├── Follower副本: TiKV-2
│ └── Follower副本: TiKV-3
├── Region 2: [Key: t123_r1000001 → t123_r2000000]
│ ├── Leader副本: TiKV-2
│ ├── Follower副本: TiKV-3
│ └── Follower副本: TiKV-1
└── Region 3: [Key: t123_r2000001 → t123_r3000000]
├── Leader副本: TiKV-3
├── Follower副本: TiKV-1
└── Follower副本: TiKV-2
三、一条 SQL 的“奇幻漂流” 🚣
让我们跟踪一条简单查询的完整旅程:
-- 用户执行
SELECT * FROM users WHERE id = 100;
阶段1:TiDB 接收请求(计算层)
步骤1:SQL 解析 🧠
// 解析成抽象语法树(AST)
type SelectStmt struct {
Fields []*Field // 字段列表 [*]
Table *Table // 表 users
Where *Expr // WHERE id = 100
// ...
}
步骤2:查询优化 🎯
-- 优化器思考:
-- 方案1:全表扫描(慢,但简单)
-- 方案2:用主键索引(快!)
-- 生成执行计划
EXPLAIN SELECT * FROM users WHERE id = 100;
-- 输出:
-- IndexLookUp_10
-- ├─IndexRangeScan_8 // 用索引找行ID
-- └─TableRowIDScan_9 // 用行ID拿完整数据
步骤3:问 PD:数据在哪? 🗺️
// TiDB 问 PD:"键 t123_r100 在哪个 Region?"
PD 回答:"在 Region 2,Leader 是 TiKV-2"
阶段2:TiKV 处理请求(存储层)
步骤4:Raft 共识读取 ⚡
// TiKV-2 (Leader) 处理读取
1. 检查自己是不是最新的 Leader
2. 读取本地数据(内存或磁盘)
3. 如果读是强一致性,需要确认大多数副本
4. 返回数据
// 如果是 Follower Read(从副本读)
SELECT /*+ READ_FROM_FOLLOWER() */ * FROM users WHERE id = 100;
// 可以从最近的副本读,减少延迟
步骤5:返回数据 📨
{
"id": 100,
"name": "张三",
"age": 25
}
阶段3:TiDB 返回结果(计算层)
步骤6:组装结果 🎁
// TiDB 拿到原始 KV
Key: t123_r100
Value: {"id": 100, "name": "张三", "age": 25}
// 转换成 SQL 结果集
+-----+--------+-----+
| id | name | age |
+-----+--------+-----+
| 100 | 张三 | 25 |
+-----+--------+-----+
步骤7:返回给客户端 ✅
用户:"这么快?!"
TiDB:"基操,勿6~"
四、分布式事务的“时间魔法” ⏰
问题:如何在分布式系统中保证 ACID?
单机 MySQL:用锁 + 日志
分布式 TiDB:用全局时间戳 + Percolator 模型
场景:转账 100 元
BEGIN;
UPDATE accounts SET balance = balance - 100 WHERE id = 1; -- 在 TiKV-1
UPDATE accounts SET balance = balance + 100 WHERE id = 2; -- 在 TiKV-2
COMMIT;
事务流程详解:
阶段1:获取全局时间戳
// TiDB 问 PD:"给我个时间戳!"
PD:"当前全局时间戳是 1631234567890"
// 这个时间戳是事务的"身份证"
start_ts = 1631234567890
阶段2:预写阶段(Prewrite)✍️
// 对每行数据:
// 1. 写锁记录(防止别人改)
Key: t_accounts_r1_lock
Value: {primary: t_accounts_r1, start_ts: 1631234567890}
// 2. 写数据(但标记为未提交)
Key: t_accounts_r1_1631234567890
Value: {balance: 900, 状态: 未提交}
关键技巧:选一行作为 Primary Lock
- 账户1(id=1)作为 Primary
- 其他行(id=2)作为 Secondary
- Primary 提交成功,整个事务才成功
阶段3:提交阶段(Commit)✅
// 1. 获取提交时间戳
commit_ts = 1631234567891 // 比 start_ts 大
// 2. 提交 Primary
Key: t_accounts_r1_1631234567890
Value: {balance: 900, 状态: 已提交, commit_ts: 1631234567891}
// 3. 清理锁
删除 t_accounts_r1_lock
// 4. 异步提交 Secondary
// 事务就算成功了!
阶段4:异步清理 🧹
// 后台清理未提交的数据
// 如果客户端看到 start_ts 之后但 commit_ts 之前的数据
// 会去检查 Primary 是否提交
这个模型的精髓:
- 不用两阶段提交(2PC)协调者,避免单点
- Primary 决定事务生死,简化了分布式提交
- 时间戳全局有序,解决分布式系统的“时钟问题”
五、PD 的“智能调度”:让数据“雨露均沾” 🌧️
PD 的三大超能力:
超能力1:Region 调度(负载均衡)
// PD 监控所有 Region
if Region 太热(访问频繁) {
分裂 Region // 96MB → 48MB + 48MB
}
if Region 太大(>144MB) {
分裂 Region
}
if Region 太小(<24MB) && 邻居Region也小 {
合并 Region
}
if TiKV 节点负载不均 {
迁移 Region 从忙节点到闲节点
}
超能力2:副本管理(高可用)
// 每个 Region 3 副本,分布在不同机器
if 副本宕机 {
PD: "紧急!Region 1 少了个副本!"
在健康机器上补副本
}
if 新增 TiKV 节点 {
PD: "新同学来了,分点活给它!"
迁移部分副本到新节点
}
超能力3:热点调度(解决瓶颈)
-- 热点问题:自增主键导致新数据都写最后一个 Region
CREATE TABLE orders (
id BIGINT AUTO_INCREMENT PRIMARY KEY, -- 热点!
...
);
-- PD 的解决方案:
-- 1. 监控到 orders 表的最后一个 Region 访问频繁
-- 2. 分裂这个 Region
-- 3. 或者建议:用 SHARD_ROW_ID_BITS
CREATE TABLE orders (
id BIGINT AUTO_INCREMENT PRIMARY KEY
) SHARD_ROW_ID_BITS = 4; -- 行ID打散到16个Region
六、TiDB 的“特异功能” 🦸
功能1:HTAP(混合事务/分析处理)
传统架构:MySQL(事务)+ Hadoop(分析)
- ETL 复杂,延迟高
- 两套系统,数据不一致
TiDB HTAP:
同一份数据,两种存储引擎:
┌─────────────────┐
│ 行存 (TiKV) │ ← 高并发事务
│ 列存 (TiFlash) │ ← 实时分析
└─────────────────┘
-- 启用 TiFlash
ALTER TABLE orders SET TIFLASH REPLICA 1;
-- 分析查询自动路由到 TiFlash
SELECT user_id, SUM(amount)
FROM orders -- TiDB 自动选择列存
GROUP BY user_id;
-- 比行存快 10-100 倍!
功能2:在线 DDL(不停机改表结构)
-- MySQL:ALTER TABLE 会锁表
-- TiDB:在线,不锁表!
ALTER TABLE users ADD COLUMN age INT;
-- 可以继续读写 users 表!
原理:
- TiDB 创建新版本的 Schema
- 后台慢慢迁移数据到新结构
- 迁移完成后切换
- 用户无感知
功能3:SQL 兼容性
// 你的 Spring Boot 代码一行不用改!
spring:
datasource:
url: jdbc:mysql://tidb:4000/test
driver-class-name: com.mysql.cj.jdbc.Driver
username: root
password: 123456
// MyBatis、Hibernate、JPA 都能用!
七、故障处理:当“零件”坏掉时 🔧
场景1:一个 TiKV 节点宕机
TiKV-3 宕机!
↓
Region 1(副本在 TiKV-1,2,3)少了个副本
↓
PD 检测到(心跳超时)
↓
选举新 Leader(在 TiKV-1 和 TiKV-2 中选)
↓
在 TiKV-4 上补副本
↓
恢复三副本,集群健康!
用户感受:查询稍微慢了点,但能继续用
场景2:PD Leader 宕机
PD-1(Leader)宕机
↓
PD-2 和 PD-3 检测到
↓
选举新 Leader(PD-2)
↓
新 Leader 接管工作
↓
用户无感知!
关键:PD 用 etcd 的 Raft 实现,自动选主
场景3:网络分区(脑裂风险)
机房A网络断掉
↓
TiKV-1,2 在一个分区
TiKV-3,4,5 在另一个分区
↓
PD 在多数派的分区(有3个节点)继续工作
↓
少数派分区(2个节点)停止服务
↓
避免数据不一致!
八、TiDB 的设计哲学 🧘
1. 存储与计算分离
计算层 (TiDB) ← 可以独立扩展
存储层 (TiKV) ← 可以独立扩展
调度层 (PD) ← 管理两者关系
好处:
- 计算不够?加 TiDB 节点
- 存储不够?加 TiKV 节点
- 互不影响,像乐高积木
2. Shared-Nothing 架构
每个节点自给自足
不共享内存,不共享磁盘
通过网络通信协作
对比 Shared-Storage:
- Shared-Storage(如 AWS Aurora):存储共享,计算分离
- Shared-Nothing(TiDB):什么都不共享,完全分布式
3. 追求在线弹性
传统扩容:停服务 → 迁移数据 → 重启 → 祈祷
TiDB 扩容:加节点 → 自动均衡 → 继续服务
九、TiDB 的“阿喀琉斯之踵” 🦶
弱点1:跨 Region 事务开销
-- 如果事务涉及多个 Region
BEGIN;
UPDATE table1 ...; -- Region 1
UPDATE table2 ...; -- Region 2
UPDATE table3 ...; -- Region 3
COMMIT;
-- 网络开销比单机 MySQL 大
优化:业务设计时,相关数据尽量在同一个 Region
弱点2:二级索引回表
-- 有索引 idx_name(name)
SELECT * FROM users WHERE name = '张三';
-- 1. 用索引找到行ID
-- 2. 用行ID去主键拿完整数据
-- 比主键查询多一次查询
优化:覆盖索引
SELECT id FROM users WHERE name = '张三';
-- 只查索引,不需要回表
弱点3:小表性能
-- 10 行的小表
SELECT * FROM small_table;
-- 可能比 MySQL 慢
-- 因为要走分布式协议
优化:小表不用 TiDB,或者用 TiDB 的缓存
十、总结:TiDB 的“道”与“术” 🎯
TiDB 的“道”(核心理念):
- 兼容性:让用户无缝迁移
- 弹性:像云一样自由伸缩
- 强一致:分布式也要像单机一样可靠
- 实时 HTAP:一套系统解决所有问题
TiDB 的“术”(技术实现):
- 计算存储分离:各司其职,独立扩展
- Raft 共识:数据安全第一
- Percolator 事务:分布式事务的优雅解
- Region 分片:自动分片,自动均衡
- 全局时间戳:解决分布式时钟难题
最后思考:TiDB 不是要替代 MySQL,而是给 MySQL 一个“分布式升级包”。如果你的业务遇到了 MySQL 的天花板(数据量、并发量),又不想重写业务代码,TiDB 是个绝佳选择。
就像把一辆家用轿车(MySQL)改装成了变形金刚(TiDB)——平时还能买菜,关键时刻能拯救世界!🚗➡️🤖
记住:技术选型不是选最好的,是选最合适的。TiDB 适合那些“MySQL 撑不住,但不想大动干戈”的场景。如果你的业务还在用 MySQL 单机,那就好好珍惜,别急着“分布式焦虑”。等技术债务真的来了,再请 TiDB 这位“超级英雄”出马也不迟!💪