本文系统性地梳理企业级数据库设计的全流程,从需求建模到物理落地,涵盖索引策略、分库分表、读写分离、表结构设计、SQL 编写规范与治理运维,每个知识点附完整 SQL 示例与架构说明。
一、数据库设计流程
数据库设计不是一蹴而就的 DDL 编写,而是一套从业务需求到物理存储的逐层细化方法论,通常遵循 需求分析 → 概念设计 → 逻辑设计 → 物理设计 四个阶段。
1.1 需求分析
目标:理解业务领域,明确系统需要存储哪些数据、数据之间的关系、以及访问模式。
关键活动:
- 与产品、业务方访谈,梳理业务流程
- 识别实体(Entity)与属性(Attribute)
- 摸清数据量级(日增行数、总行数、高峰 QPS)
- 明确一致性要求(强一致 vs 最终一致)
输出:需求文档、数据字典初稿。
实战案例:设计一个电商订单系统,需求分析阶段需要明确:
- 每个用户日均下单量(决定订单表增长速率)
- 高峰时段(大促期间 QPS 可能暴涨 10 倍)
- 订单查询模式(按用户查、按时间查、按状态查)
- 一致性要求(库存扣减必须强一致,订单状态可以最终一致)
- 数据保留策略(已完结订单是否需要长期在线存储)
-- 需求分析阶段产出的数据字典示例
-- 字段名 类型 长度 必填 说明
-- order_id BIGINT 20 Y 订单唯一ID
-- user_id BIGINT 20 Y 下单用户
-- order_no VARCHAR 32 Y 订单号(业务可读)
-- amount DECIMAL 12,2 Y 实付金额
-- status TINYINT 4 Y 订单状态
-- created_at DATETIME 0 Y 下单时间1.2 概念设计(ER 图)
用 实体-关系(Entity-Relationship)模型 描述业务数据。
┌─────────────┐ ┌──────────────┐
│ 用户 (User) │ │ 订单 (Order) │
│─────────────│ │──────────────│
│ user_id PK │──1:N──│ order_id PK │
│ 用户名 │ │ user_id FK │
│ 邮箱 │ │ 金额 │
│ 注册时间 │ │ 状态 │
└─────────────┘ │ 创建时间 │
└──────────────┘
│ 1:N
▼
┌──────────────┐
│ 订单项(Item) │
│──────────────│
│ item_id PK │
│ order_id FK │
│ 商品名称 │
│ 数量 │
└──────────────┘核心关系类型:
| 关系 | 示例 | 实现方式 |
|---|---|---|
| 1:1 | 用户 ↔ 身份证 | 任一方加外键 + UNIQUE |
| 1:N | 用户 ↔ 订单 | "多"方加外键 |
| N:M | 学生 ↔ 课程 | 中间关联表 |
1.3 逻辑设计
将 ER 图转换为关系模型(表、字段、主键、外键、约束)。
-- 用户表
CREATE TABLE `user` (
`user_id` BIGINT NOT NULL AUTO_INCREMENT COMMENT '用户ID',
`username` VARCHAR(32) NOT NULL COMMENT '用户名',
`email` VARCHAR(128) NOT NULL COMMENT '邮箱',
`status` TINYINT NOT NULL DEFAULT 1 COMMENT '状态 1-正常 0-禁用',
`created_at` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
`updated_at` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
PRIMARY KEY (`user_id`),
UNIQUE KEY `uk_email` (`email`),
KEY `idx_created_at` (`created_at`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='用户表';
-- 订单表
CREATE TABLE `order` (
`order_id` BIGINT NOT NULL AUTO_INCREMENT COMMENT '订单ID',
`user_id` BIGINT NOT NULL COMMENT '用户ID',
`amount` DECIMAL(10,2) NOT NULL COMMENT '订单金额',
`status` TINYINT NOT NULL DEFAULT 0 COMMENT '状态 0-待支付 1-已支付 2-已取消',
`created_at` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
PRIMARY KEY (`order_id`),
KEY `idx_user_id` (`user_id`),
KEY `idx_created_at` (`created_at`),
CONSTRAINT `fk_order_user` FOREIGN KEY (`user_id`) REFERENCES `user` (`user_id`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='订单表';1.4 物理设计
决定存储引擎、字符集、分区策略、索引类型、字段类型精炼等。
-- 物理设计阶段的关键决策
-- 1. 选择存储引擎:InnoDB(事务+行锁+外键)
-- 2. 字符集:utf8mb4(支持emoji)
-- 3. 字段类型精炼
CREATE TABLE `order` (
`order_id` BIGINT UNSIGNED NOT NULL AUTO_INCREMENT, -- 无符号扩大范围
`user_id` BIGINT UNSIGNED NOT NULL,
`amount` DECIMAL(12,2) NOT NULL, -- 总金额至多 10^10
`status` TINYINT UNSIGNED NOT NULL DEFAULT 0, -- 小范围枚举用 TINYINT
`pay_time` DATETIME DEFAULT NULL, -- 允许 NULL 表示未支付
`version` INT UNSIGNED NOT NULL DEFAULT 0, -- 乐观锁
PRIMARY KEY (`order_id`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 ROW_FORMAT=DYNAMIC COMMENT='订单表物理设计';1.5 设计原则
| 原则 | 说明 |
|---|---|
| 单一职责 | 一张表只表达一个业务实体 |
| 最小冗余 | 避免非必要的重复存储 |
| 字段原子性 | 字段不可再分(第一范式) |
| 考虑查询 | 以高频查询路径驱动索引与反范式设计 |
1.6 常见设计反模式
| 反模式 | 问题 | 改进 |
|---|---|---|
| 多值字段 | tags VARCHAR 存 "a,b,c" |
拆关联表或 JSON 类型 |
| 万能键 | 全表只有一个自增 ID | 按业务设计复合主键 |
| 过度分表 | 数据量很小却分 1024 表 | 按量级评估,<500万行不拆分 |
| 无视 NULL | 大量字段允许 NULL 导致索引低效 | 用默认值代替 NULL |
| 表内枚举硬编码 | status=1 意义不明 |
用 ENUM 或统一状态码表 |
反模式详解:多值字段的陷阱
-- 反模式:用逗号分隔存储多值
CREATE TABLE `user_tag_bad` (
`user_id` BIGINT PRIMARY KEY,
`tags` VARCHAR(500) COMMENT '标签,逗号分隔' -- "VIP,老用户,付费会员"
);
-- 查询"VIP"用户:全表扫描 + 无法命中索引
SELECT * FROM `user_tag_bad` WHERE FIND_IN_SET('VIP', `tags`) > 0;
-- 正确做法:关联表
CREATE TABLE `user_tag` (
`user_id` BIGINT NOT NULL,
`tag_name` VARCHAR(32) NOT NULL,
`created_at` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
PRIMARY KEY (`user_id`, `tag_name`),
KEY `idx_tag_name` (`tag_name`) -- 按标签检索
);
-- 查询"VIP"用户
SELECT u.* FROM `user` u
JOIN `user_tag` ut ON u.`user_id` = ut.`user_id`
WHERE ut.`tag_name` = 'VIP';反模式详解:过度使用 NULL
-- 反模式:大量字段允许 NULL
CREATE TABLE `user_null_bad` (
`user_id` BIGINT PRIMARY KEY,
`nickname` VARCHAR(32) DEFAULT NULL,
`phone` VARCHAR(20) DEFAULT NULL,
`email` VARCHAR(128) DEFAULT NULL,
`avatar` VARCHAR(500) DEFAULT NULL
);
-- 问题1:索引存储 NULL 值效率低
-- 问题2:查询时需额外处理 IS NULL / IS NOT NULL
-- 问题3:聚合函数 COUNT 会忽略 NULL,容易产生 Bug
-- 正确做法:尽可能用默认值代替 NULL
CREATE TABLE `user_good` (
`user_id` BIGINT PRIMARY KEY,
`nickname` VARCHAR(32) NOT NULL DEFAULT '',
`phone` VARCHAR(20) NOT NULL DEFAULT '',
`email` VARCHAR(128) NOT NULL DEFAULT '',
`avatar` VARCHAR(500) NOT NULL DEFAULT ''
);
-- 业务含义明确时,也可以区分"未设置"和"空"
-- phone = '' 表示未填写手机号
-- phone IS NULL 表示字段不适用此用户
---
## 二、索引设计
索引是数据库性能的基石。设计不当的索引轻则浪费空间,重则拖垮写入性能。
### 2.1 索引选择原则
1. **高选择性列优先** — 选择度(cardinality)越高的列,索引效果越好
2. **WHERE 条件列** — 频繁出现在 WHERE 中的列应加索引
3. **ORDER BY / GROUP BY 列** — 利用索引避免文件排序
4. **JOIN 关联列** — 关联字段必须索引
5. **索引不是越多越好** — 每个索引都影响 INSERT/UPDATE 性能
```sql
-- 检查索引选择度
SELECT
COUNT(DISTINCT column_name) / COUNT(*) AS selectivity
FROM table_name;
-- 选择度越接近 1 越好2.2 联合索引顺序
最左前缀原则:联合索引 (a, b, c) 有效查询条件为 a、a,b、a,b,c,跳过中间列会导致索引失效。
顺序决策规则:
- 等值条件放左边 —
WHERE a=1 AND b=2优于WHERE a>1 AND b=2 - 高频列放左边 — 更高选择度的列优先
- 范围条件放最后 —
WHERE a=1 AND b>10,b 是范围,索引仅匹配到 a
-- 正确的联合索引示例
-- 查询场景:按用户ID查订单,按时间排序
CREATE INDEX `idx_user_time` ON `order` (`user_id`, `created_at`);
-- 能命中 idx_user_time:
EXPLAIN SELECT * FROM `order` WHERE `user_id` = 100 ORDER BY `created_at` DESC;
-- 不能完全命中:
EXPLAIN SELECT * FROM `order` WHERE `created_at` > '2024-01-01'; -- 全表扫描2.3 覆盖索引
覆盖索引指查询所需的所有列都在索引中,无需回表。这是最常见的性能优化手段。
-- 假设索引 (user_id, status, amount)
-- 以下查询只需扫描索引,无需回表
EXPLAIN
SELECT `user_id`, `status`, `amount`
FROM `order`
WHERE `user_id` = 100;
-- Extra 列显示:Using index
-- 若要加 created_at,则需回表
EXPLAIN
SELECT `user_id`, `status`, `amount`, `created_at`
FROM `order`
WHERE `user_id` = 100;
-- Extra 列可能显示:Using index condition (ICP)识别覆盖索引:通过 EXPLAIN 的 Extra 列判断:
Using index— 完全覆盖索引,最优Using index condition— 使用了索引下推(ICP),仍需回表NULL(无提示)— 需回表读取完整行
2.4 冗余索引清理
重复或重叠的索引浪费空间并拖慢写入。
-- 找出冗余索引
-- idx_a_b(a, b) 完全覆盖 idx_a(a),后者可删除
-- idx_b(b) 与 idx_a_b(a, b) 不重叠,不能删
-- MySQL 检查冗余索引
SELECT
table_schema, table_name,
GROUP_CONCAT(index_name ORDER BY seq_in_index) AS indexes
FROM information_schema.statistics
WHERE table_schema = 'your_db'
GROUP BY table_schema, table_name, index_name;清理原则:
(a, b)可以替代(a),但不能替代(b)- 删除从未使用的索引(可通过
performance_schema统计) - 联合索引超过 5 列时,考虑是否有必要
2.5 索引维护
索引并非创建后就一劳永逸,需要持续维护。
-- 查看索引使用情况(MySQL)
SELECT
index_name,
rows_read,
rows_deleted,
rows_updated
FROM performance_schema.table_io_waits_summary_by_index_usage
WHERE object_schema = 'your_db' AND object_name = 'order';
-- 重建碎片化索引
ALTER TABLE `order` ENGINE=InnoDB; -- 重建表(重组聚簇索引)
OPTIMIZE TABLE `order`; -- 整理碎片(InnoDB 下相当于 ALTER)
-- 定期检查索引碎片率
SELECT
table_name,
ROUND(data_length / 1024 / 1024, 2) AS data_mb,
ROUND(index_length / 1024 / 1024, 2) AS index_mb,
ROUND(data_free / 1024 / 1024, 2) AS free_mb
FROM information_schema.tables
WHERE table_schema = 'your_db'
AND data_free > 100 * 1024 * 1024; -- 碎片超过 100MB索引监控与维护频率建议
| 维护操作 | 频率 | 说明 |
|---|---|---|
| 慢查询日志分析 | 每日 | 发现缺失或不当的索引 |
| 索引使用统计 | 每周 | 清理从未使用的索引 |
| 碎片整理 | 每月 | 高写入表的索引碎片整理 |
| 冗余索引清理 | 每季度 | 联合索引覆盖检查 |
| 索引重建 | 大版本升级后 | 配合 DDL 变更 |
三、分库分表
当单库数据量达到千万/亿级别时,分库分表成为必然选择。
3.1 垂直分库(按业务拆分)
将不同业务模块拆分到独立数据库实例。
架构示意:
┌─────────────┐ ┌─────────────┐ ┌─────────────┐
│ 用户库 DB1 │ │ 订单库 DB2 │ │ 商品库 DB3 │
│ user │ │ order │ │ product │
│ user_addr │ │ order_item │ │ category │
│ │ │ payment │ │ inventory │
└─────────────┘ └─────────────┘ └─────────────┘优点:隔离故障、单独扩容、技术栈可异构。
缺点:跨库 JOIN 困难(需应用层聚合或走 CQRS)。
3.2 垂直分表(拆分宽表)
将大字段或不常访问的列拆分到扩展表。
-- 原始宽表
CREATE TABLE `product` (
`product_id` BIGINT PRIMARY KEY,
`name` VARCHAR(100),
`description` TEXT, -- 不常访问的大字段
`spec_json` JSON, -- 不常访问
`cover_image` VARCHAR(500),
`detail_images` JSON, -- 大字段
`price` DECIMAL(10,2),
`stock` INT,
`created_at` DATETIME
);
-- 垂直分表后
CREATE TABLE `product_base` (
`product_id` BIGINT PRIMARY KEY,
`name` VARCHAR(100),
`price` DECIMAL(10,2),
`stock` INT,
`cover_image` VARCHAR(500),
`created_at` DATETIME
) ENGINE=InnoDB COMMENT='商品基础信息';
CREATE TABLE `product_detail` (
`product_id` BIGINT PRIMARY KEY,
`description` TEXT,
`spec_json` JSON,
`detail_images` JSON,
FOREIGN KEY (`product_id`) REFERENCES `product_base`(`product_id`)
) ENGINE=InnoDB COMMENT='商品扩展信息';3.3 水平分表(按 ID/时间分片)
将同一张表的数据按某种规则分散到多张物理表。
-- 订单表按 user_id 取模分 16 表
-- 分片键:user_id
-- 分片规则:user_id % 16
-- 路由逻辑伪代码
function getTableName(userId):
suffix = userId % 16
return "order_" + suffix
-- 每个分片表结构一致
CREATE TABLE `order_0` (
`order_id` BIGINT NOT NULL AUTO_INCREMENT,
`user_id` BIGINT NOT NULL,
`amount` DECIMAL(10,2) NOT NULL,
`status` TINYINT NOT NULL DEFAULT 0,
`created_at` DATETIME NOT NULL,
PRIMARY KEY (`order_id`, `user_id`), -- 复合主键保证分片键在索引中
KEY `idx_user_id` (`user_id`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
-- 其他分片表 order_1 ~ order_15 同上水平分片策略对比
| 策略 | 路由算法 | 优点 | 缺点 | 适用场景 |
|---|---|---|---|---|
| Hash 取模 | key % N |
数据均匀 | 扩缩容困难(翻倍式) | 用户 ID、订单 ID |
| 一致性 Hash | hash(key) 环 |
扩缩容影响小 | 实现复杂 | 缓存、分布式存储 |
| 时间范围 | YYYYMM 分区 |
天然对齐冷热 | 热点集中(月末) | 日志、流水 |
| 范围分片 | ID / step |
范围查询高效 | 数据倾斜 | ID 递增场景 |
时间分片实战:订单按月分表
-- 按月分表:order_202401, order_202402 ...
-- 路由函数
function getTableNameByTime(createTime):
return "order_" + format(createTime, "YYYYMM")
-- 创建当月表
CREATE TABLE `order_202409` (
`order_id` BIGINT NOT NULL AUTO_INCREMENT,
`user_id` BIGINT NOT NULL,
`amount` DECIMAL(10,2) NOT NULL,
`status` TINYINT NOT NULL DEFAULT 0,
`created_at` DATETIME NOT NULL,
PRIMARY KEY (`order_id`),
KEY `idx_user_id` (`user_id`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
-- 跨月查询:应用层需要并行查询多个分片
SELECT * FROM order_202408 WHERE user_id = 100 AND created_at >= '2024-08-01' AND created_at < '2024-09-01'
UNION ALL
SELECT * FROM order_202409 WHERE user_id = 100 AND created_at >= '2024-09-01' AND created_at < '2024-10-01';3.4 分片键选择
分片键(Sharding Key) 是所有分片策略的核心。
| 维度 | 推荐 | 不推荐 |
|---|---|---|
| 高频查询条件 | user_id, order_id | 很少作为 WHERE 条件的列 |
| 数据分布 | 均匀分布(user_id) | 偏斜严重(地区 ID) |
| 不可变 | 创建后不变 | 可变字段(如手机号) |
常见分片算法:
┌─────────────────────────────────────────────┐
│ 分片键 → 分片算法的映射 │
├──────────────┬──────────────────────────────┤
│ 取模分片 │ shard = key % N │
│ 范围分片 │ shard = key / N │
│ 一致性哈希 │ hash(key) on ring │
│ 时间分片 │ shard = YYYYMM │
│ 映射表分片 │ 查路由表获取 shard_id │
└──────────────┴──────────────────────────────┘3.5 分库分表带来的问题
① 跨分片查询
-- 没有分片键的查询 — 需广播到所有分片再合并
SELECT COUNT(*) FROM order_0 WHERE status = 0; -- 查询分片 0
SELECT COUNT(*) FROM order_1 WHERE status = 0; -- 查询分片 1
-- ... 共 16 次查询,应用层汇总
-- 解决方案:
-- 1. 建立倒排索引表(如 ES)
-- 2. 存储汇总数据(日统计表)
-- 3. 中间件聚合(ShardingSphere 支持)② 分布式事务
// 使用 Seata AT 模式实现分布式事务
@GlobalTransactional(name = "create-order", rollbackFor = Exception.class)
public void createOrder(CreateOrderRequest request) {
// 步骤1:扣减库存(商品库)
productService.deductStock(request.getProductId(), request.getQuantity());
// 步骤2:创建订单(订单库)
orderService.create(request);
// 步骤3:扣减余额(用户库)
userService.deductBalance(request.getUserId(), request.getAmount());
// 任一失败则全局回滚
}常用方案:
| 方案 | 一致性 | 性能 | 适用场景 |
|---|---|---|---|
| 两阶段提交(XA) | 强 | 低 | 金融核心 |
| TCC(Try-Confirm-Cancel) | 强 | 中 | 高一致性业务 |
| Seata AT | 最终一致 | 中 | 通用 |
| 本地消息表 | 最终一致 | 高 | 异步非核心 |
| 事务消息(RocketMQ) | 最终一致 | 高 | 异步解耦 |
③ 全局 ID 生成
分片后自增 ID 无法全局唯一,需要全局唯一 ID 生成器。
-- 方案1:雪花算法(Snowflake)
-- 结构:1bit(0) | 41bit(毫秒时间戳) | 10bit(机器ID) | 12bit(序列号)
-- 优点:趋势递增、高性能、无中心
-- 缺点:时钟回拨问题
-- 方案2:号段模式(Leaf)
CREATE TABLE `id_alloc` (
`biz_tag` VARCHAR(64) PRIMARY KEY COMMENT '业务标识',
`max_id` BIGINT NOT NULL DEFAULT 0 COMMENT '当前最大ID',
`step` INT NOT NULL DEFAULT 1000 COMMENT '步长',
`version` INT NOT NULL DEFAULT 0 COMMENT '乐观锁'
) ENGINE=InnoDB;
-- 更新号段
UPDATE `id_alloc`
SET `max_id` = `max_id` + `step`, `version` = `version` + 1
WHERE `biz_tag` = 'order' AND `version` = 0;
-- 应用层获取 [max_id+1, max_id+step] 号段缓存到本地3.6 ShardingSphere 配置示例
# ShardingSphere-JDBC 配置(YAML)
dataSources:
ds0:
url: jdbc:mysql://localhost:3306/order_db0?serverTimezone=UTC
username: root
password: root
ds1:
url: jdbc:mysql://localhost:3306/order_db1?serverTimezone=UTC
username: root
password: root
rules:
- !SHARDING
tables:
order:
actualDataNodes: ds${0..1}.order_${0..15}
tableStrategy:
standard:
shardingColumn: user_id
shardingAlgorithmName: order_inline
keyGenerateStrategy:
column: order_id
keyGeneratorName: snowflake
shardingAlgorithms:
order_inline:
type: INLINE
props:
algorithm-expression: order_${user_id % 16}
keyGenerators:
snowflake:
type: SNOWFLAKE四、读写分离
主库处理写入,从库处理读取,分担负载。
4.1 架构对比
应用层读写分离:
┌──────────┐ 写入 ┌──────────┐
│ 应用服务 │ ──────→ │ 主库 Master │
│ │ └────┬─────┘
│ 数据源路由 │ │ 异步复制
│ (动态切换)│ ▼
│ │ 读取 ┌──────────┐
│ │ ←────── │ 从库 Slave │
└──────────┘ └──────────┘| 维度 | 应用层 | 中间件层(ShardingSphere/MyCat) |
|---|---|---|
| 实现 | 多数据源 + AOP | 透明代理,业务无感 |
| 灵活性 | 高(可细粒度控制) | 中(受限于中间件能力) |
| 运维成本 | 低(无额外组件) | 高(需部署维护代理) |
| 性能损耗 | 几乎无 | 有网络代理开销 |
4.2 主从延迟问题
主从延迟的典型表现:写入后立即读取,从库尚未同步。
延迟原因:
写入主库 → binlog → 网络传输 → 从库 relay log → SQL 线程回放
↑ 这里可能延迟数百 ms解决方案:
-- 方案1:强制读主库(session 级别)
-- 对一致性要求高的请求,强制走主库
SET SESSION /* force_master */;
SELECT * FROM `order` WHERE `order_id` = 12345; -- 走主库
-- 方案2:等待从库追上
-- 检查从库延迟
SHOW SLAVE STATUS\G
-- Seconds_Behind_Master 接近 0 表示已同步
-- 方案3:缓存写后数据
-- 写入时设置本地缓存,读请求优先查缓存
-- 方案4:半同步复制
-- 等待至少一个从库确认后再返回成功
INSTALL PLUGIN rpl_semi_sync_master SONAME 'semisync_master.so';
SET GLOBAL rpl_semi_sync_master_enabled = 1;
SET GLOBAL rpl_semi_sync_master_timeout = 1000; -- 超时回退到异步4.3 强制读主库策略
// 业务代码中标记一致性操作
@Service
public class OrderService {
// 创建订单后立即查询 — 必须走主库
@Transactional
public Order createAndGet(Order order) {
insert(order); // 写入主库
DataSourceContextHolder.set("master"); // 强制走主库
return getById(order.getOrderId()); // 读主库
}
// 列表查询可以走从库
@ReadOnly
public List<Order> listOrders(Long userId) {
return orderMapper.listByUserId(userId); // 走从库
}
}五、表结构设计
5.1 字段选择
字段类型选择是表结构设计的第一步,也是最容易出错的地方。
CREATE TABLE `user` (
-- 整数类型:按需选择
`id` INT UNSIGNED NOT NULL AUTO_INCREMENT, -- 20亿内
`big_id` BIGINT UNSIGNED NOT NULL AUTO_INCREMENT, -- 极大值
-- 字符串类型
`username` VARCHAR(32) NOT NULL, -- 可变短字符串
`intro` VARCHAR(255) DEFAULT '', -- 短文本
`bio` TEXT, -- 长文本(64KB)
`email` VARCHAR(128) NOT NULL,
-- 小数类型:严禁 FLOAT/DOUBLE
`balance` DECIMAL(10,2) NOT NULL DEFAULT 0.00, -- 精确小数
-- 枚举类型
`status` TINYINT UNSIGNED NOT NULL DEFAULT 1, -- 0-禁用 1-正常
`gender` ENUM('male','female','unknown') NOT NULL DEFAULT 'unknown',
-- 时间类型
`created_at` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
`updated_at` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
-- JSON(MySQL 5.7+)
`ext_info` JSON DEFAULT NULL, -- 动态扩展字段
PRIMARY KEY (`id`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;字段类型选择详解
-- TINYINT vs INT vs BIGINT
-- TINYINT UNSIGNED: 0~255 —— 状态码、枚举值
-- SMALLINT UNSIGNED: 0~65535 —— 年龄、积分
-- INT UNSIGNED: 0~42亿 —— 大多数场景
-- BIGINT UNSIGNED: 极大 —— 订单ID、用户ID(分表时)
-- VARCHAR 长度选择
-- VARCHAR(32) vs VARCHAR(255)
-- 虽然可变长,但 MySQL 内部会在内存中分配最大长度
-- 超过 255 时,行格式变化(需额外 2 字节)
-- 建议:VARCHAR(32) 够用绝不设 VARCHAR(255)
-- DECIMAL 精度
-- DECIMAL(10,2) 总长10位,小数2位 → 最大99999999.99
-- DECIMAL(18,4) 总长18位,小数4位 → 适合汇率等精度要求高的场景
-- 严禁 FLOAT/DOUBLE:浮点数不精确,会导致账目不平
-- DATETIME vs TIMESTAMP
-- DATETIME:'1000-01-01' ~ '9999-12-31',占用 5~8 字节
-- TIMESTAMP:'1970-01-01' ~ '2038-01-19',占用 4 字节
-- 建议统一用 DATETIME,避免 2038 年问题字段选择核心规则:
| 原则 | 说明 |
|---|---|
| 选择最小存储类型 | TINYINT 够用不选 INT |
| 小数用 DECIMAL | 绝不使用 FLOAT/DOUBLE |
| VARCHAR 合理化 | 不设 65535,设实际最大长度 |
| 时间统一 DATETIME | TIMESTAMP 有 2038 年问题 |
| 非空约束优先 | NOT NULL DEFAULT x 优于 NULL |
| 统一字符集 | utf8mb4 统一 |
5.2 范式 vs 反范式
三大范式回顾:
1NF:字段不可再分(原子性)
2NF:非主键字段完全依赖于主键(消除部分依赖)
3NF:非主键字段不传递依赖于主键(消除传递依赖)反范式的典型场景:
-- 范式设计:用户表和用户标签分别存储
CREATE TABLE `user` (
`user_id` BIGINT PRIMARY KEY,
`name` VARCHAR(32)
);
CREATE TABLE `user_tag` (
`user_id` BIGINT,
`tag_name` VARCHAR(32),
PRIMARY KEY (`user_id`, `tag_name`)
);
-- 查询用户的标签列表需要 JOIN
SELECT u.*, GROUP_CONCAT(ut.tag_name) AS tags
FROM user u
LEFT JOIN user_tag ut ON u.user_id = ut.user_id
WHERE u.user_id = 100;
-- 反范式设计:用户表冗余标签字段(JSON)
CREATE TABLE `user` (
`user_id` BIGINT PRIMARY KEY,
`name` VARCHAR(32),
`tags` JSON DEFAULT NULL -- 冗余标签 ["VIP","老用户"]
);
-- 直接查询,无需 JOIN
SELECT `user_id`, `name`, JSON_EXTRACT(`tags`, '$') AS tags
FROM `user`
WHERE `user_id` = 100;权衡决策:
范式化(3NF)
├─ 优点:更新一致、无冗余
├─ 缺点:查询需多表 JOIN
└─ 适用:写多读少、一致性要求高的金融场景
反范式
├─ 优点:查询快、无需 JOIN
├─ 缺点:更新不一致风险、冗余占用空间
└─ 适用:读多写少、性能敏感的报表/缓存场景5.3 大字段分离
-- 不要这样做:大字段混在核心表中
CREATE TABLE `article_bad` (
`id` BIGINT PRIMARY KEY,
`title` VARCHAR(200),
`content` LONGTEXT, -- 可能几 MB
`cover_image` VARCHAR(500),
`created_at` DATETIME
);
-- 正确做法:分离大字段
CREATE TABLE `article` (
`id` BIGINT PRIMARY KEY,
`title` VARCHAR(200),
`cover_image` VARCHAR(500),
`summary` VARCHAR(500), -- 摘要单独存
`created_at` DATETIME
);
CREATE TABLE `article_content` (
`article_id` BIGINT PRIMARY KEY,
`content` LONGTEXT,
`version` INT DEFAULT 1,
FOREIGN KEY (`article_id`) REFERENCES `article`(`id`)
);
-- 列表查询只用 article 表,不加载 content
SELECT id, title, created_at FROM article ORDER BY created_at DESC LIMIT 20;
-- 详情页才 JOIN content
SELECT a.*, ac.content
FROM article a
JOIN article_content ac ON a.id = ac.article_id
WHERE a.id = 123;5.4 软删除
用标记位代替物理删除,保留历史数据。
CREATE TABLE `order` (
`order_id` BIGINT PRIMARY KEY,
`amount` DECIMAL(10,2),
`is_deleted` TINYINT NOT NULL DEFAULT 0 COMMENT '0-正常 1-删除',
`deleted_at` DATETIME DEFAULT NULL COMMENT '删除时间',
`created_at` DATETIME NOT NULL
);
-- 软删除操作
UPDATE `order` SET `is_deleted` = 1, `deleted_at` = NOW() WHERE `order_id` = 100;
-- 查询时默认过滤
SELECT * FROM `order` WHERE `is_deleted` = 0;
-- 创建过滤索引(MySQL 8.0+ 支持部分索引)
CREATE INDEX `idx_active` ON `order` (`created_at`) WHERE `is_deleted` = 0;注意:
- 所有查询默认带上
is_deleted = 0 - 唯一约束需要把
is_deleted纳入复合唯一索引 - 定期归档已删除数据到历史表
5.5 版本号与乐观锁
避免并发更新覆盖。
CREATE TABLE `account` (
`account_id` BIGINT PRIMARY KEY,
`balance` DECIMAL(10,2) NOT NULL,
`version` INT UNSIGNED NOT NULL DEFAULT 0 COMMENT '乐观锁版本号'
);
-- 更新时比较版本号
UPDATE `account`
SET `balance` = `balance` - 100, `version` = `version` + 1
WHERE `account_id` = 100 AND `version` = 5;
-- 影响行数为 0 表示版本冲突,需重试5.6 审核日志
所有变更需要可追溯。
CREATE TABLE `audit_log` (
`id` BIGINT PRIMARY KEY AUTO_INCREMENT,
`table_name` VARCHAR(64) NOT NULL COMMENT '操作表',
`record_id` BIGINT NOT NULL COMMENT '记录ID',
`action` VARCHAR(16) NOT NULL COMMENT 'INSERT/UPDATE/DELETE',
`old_value` JSON DEFAULT NULL COMMENT '变更前',
`new_value` JSON DEFAULT NULL COMMENT '变更后',
`operator` VARCHAR(64) NOT NULL COMMENT '操作人',
`operated_at` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
KEY `idx_table_record` (`table_name`, `record_id`),
KEY `idx_operated_at` (`operated_at`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='数据变更审计日志';
-- 配合触发器自动记录
CREATE TRIGGER `trg_order_update` AFTER UPDATE ON `order` FOR EACH ROW
BEGIN
INSERT INTO `audit_log` (`table_name`, `record_id`, `action`, `old_value`, `new_value`, `operator`)
VALUES ('order', OLD.order_id, 'UPDATE',
JSON_OBJECT('status', OLD.status, 'amount', OLD.amount),
JSON_OBJECT('status', NEW.status, 'amount', NEW.amount),
@current_operator);
END;六、SQL 编写规范
6.1 避免 SELECT *
-- 错误:查询所有列,无法利用覆盖索引
SELECT * FROM `order` WHERE `user_id` = 100;
-- 正确:只查询需要的列
SELECT `order_id`, `amount`, `status` FROM `order` WHERE `user_id` = 100;SELECT * 的问题:
- 无法使用覆盖索引
- 返回不必要的数据浪费带宽
- 表结构变更后,应用层可能获取到意外的列
- 增加网络传输与内存消耗
线上案例:某团队在日志表中使用 SELECT *,单行日志包含 5KB 的 content 字段,列表页每次查询拉取 100 行 = 500KB,接口响应时间 3s+。改为只选需要的 5 个字段后,响应降至 50ms。
6.2 避免隐式类型转换
-- order_id 是 VARCHAR 类型
-- 错误:传入整数会导致全表扫描
SELECT * FROM `order` WHERE `order_id` = 12345; -- 隐式转换
-- MySQL 执行:CAST(order_id AS SIGNED) = 12345,索引失效
-- 正确:传入正确的字符串类型
SELECT * FROM `order` WHERE `order_id` = '12345';6.3 避免函数包裹索引列
-- 错误:函数包裹索引列,索引失效
SELECT * FROM `order` WHERE DATE(`created_at`) = '2024-09-16';
-- 全表扫描
-- 正确:使用范围查询,索引生效
SELECT * FROM `order`
WHERE `created_at` >= '2024-09-16 00:00:00'
AND `created_at` < '2024-09-17 00:00:00';常见函数导致索引失效的情况:
| 错误写法 | 正确写法 |
|---|---|
WHERE LEFT(name, 3) = 'abc' |
WHERE name LIKE 'abc%' |
WHERE YEAR(created_at) = 2024 |
WHERE created_at >= '2024-01-01' AND created_at < '2025-01-01' |
WHERE UPPER(email) = 'A@B.COM' |
存的时候用小写 |
6.4 小表驱动大表
-- IN 优于 EXISTS 当外部表小、内部表大时
-- 假设 users 表 100 行,orders 表 1000 万行
-- 推荐:小表 users 驱动大表 orders
SELECT * FROM `user`
WHERE `user_id` IN (SELECT `user_id` FROM `order` WHERE `status` = 1);
-- MySQL 会优化为:先查 order 得到 user_id 集合,再查 user
-- 不推荐:大表 orders 作为子查询驱动力
SELECT * FROM `order`
WHERE EXISTS (SELECT 1 FROM `user` WHERE `user`.`user_id` = `order`.`user_id`);本质:尽量减少大表的扫描次数。
6.5 分页优化
-- 传统分页:OFFSET 越大越慢
SELECT * FROM `order` ORDER BY `id` LIMIT 10 OFFSET 1000000;
-- MySQL 需要读取 1,000,010 行后丢弃前 1,000,000 行
-- 优化方案1:游标分页(推荐)
SELECT * FROM `order`
WHERE `id` > 1000000 -- 上次最后一条的 id
ORDER BY `id`
LIMIT 10;
-- 优化方案2:子查询分页(MySQL)
SELECT * FROM `order`
WHERE `id` >= (
SELECT `id` FROM `order` ORDER BY `id` LIMIT 1 OFFSET 1000000
)
ORDER BY `id`
LIMIT 10;
-- 优化方案3:覆盖索引子查询
SELECT o.*
FROM `order` o
JOIN (
SELECT `id` FROM `order` ORDER BY `id` LIMIT 10 OFFSET 1000000
) tmp ON o.`id` = tmp.`id`
ORDER BY o.`id`;不同分页方案的性能对比
| 方案 | 第1页 | 第100页 | 第10000页 | 适合场景 |
|---|---|---|---|---|
| LIMIT/OFFSET | 0.1ms | 0.5ms | 800ms | 小数据量管理后台 |
| 游标分页 | 0.1ms | 0.1ms | 0.1ms | App 下拉加载 |
| 子查询分页 | 0.1ms | 0.2ms | 10ms | 中间页跳转 |
| 覆盖索引子查询 | 0.1ms | 0.2ms | 8ms | 需要回表的大表 |
注意:游标分页无法支持随机跳页(如"跳到第 100 页"),适合"加载更多"场景。管理后台需要跳页时,建议使用子查询优化或覆盖索引方案。
6.6 WHERE 条件的顺序陷阱
虽然 MySQL 优化器会重新排序 WHERE 条件,但建议将高筛选度的条件放在前面,便于可读性和某些情况下辅助优化器决策。
-- 推荐写法:高选择性条件在前
SELECT * FROM `order`
WHERE `user_id` = 100 -- 高选择性,筛选掉 99.99% 数据
AND `status` = 1 -- 低选择性
AND `amount` > 100; -- 范围条件
-- 不推荐:低选择性条件在前
SELECT * FROM `order`
WHERE `status` = 1 -- 低选择性
AND `amount` > 100
AND `user_id` = 100;6.7 JOIN 优化
-- 原则1:关联字段必须有索引
-- 原则2:小表驱动大表
-- 原则3:避免超过 3 张表的 JOIN
-- 推荐:关联字段索引 + 小表驱动
-- orders 表 1 亿行,users 表 100 万行
-- 用 users 驱动 orders(小表驱动大表)
SELECT u.`username`, o.`order_id`, o.`amount`
FROM `user` u
INNER JOIN `order` o ON u.`user_id` = o.`user_id` -- order.user_id 需有索引
WHERE u.`status` = 1;
-- 不推荐:用子查询模拟 JOIN 但不走索引
SELECT * FROM `order`
WHERE `user_id` IN (SELECT `user_id` FROM `user` WHERE `status` = 1);
-- 如果子查询结果集过大,IN 可能不如 JOIN 高效6.8 批量操作优化
-- 错误:逐条插入(1000 条需 1000 次网络往返)
INSERT INTO `order` (`user_id`, `amount`) VALUES (1, 100);
INSERT INTO `order` (`user_id`, `amount`) VALUES (2, 200);
-- ... 重复 998 次
-- 正确:批量插入(1 次网络往返)
INSERT INTO `order` (`user_id`, `amount`) VALUES
(1, 100),
(2, 200),
-- ... 最多 1000 条一批
(1000, 999);
-- 错误:逐条更新(长事务 + 高锁开销)
-- 正确:批量更新 with CASE WHEN
UPDATE `order` SET `status` = CASE `order_id`
WHEN 1 THEN 2
WHEN 2 THEN 3
WHEN 3 THEN 1
END
WHERE `order_id` IN (1, 2, 3);七、数据库治理
7.1 慢查询治理
-- 1. 开启慢查询日志(MySQL)
SET GLOBAL slow_query_log = 1;
SET GLOBAL long_query_time = 1; -- 超过 1 秒
SET GLOBAL log_queries_not_using_indexes = 1;
-- 2. 分析慢查询
-- 使用 mysqldumpslow
mysqldumpslow -s t -t 10 /var/lib/mysql/slow.log
-- 3. 使用 EXPLAIN 分析执行计划
EXPLAIN SELECT * FROM `order` WHERE `status` = 1 ORDER BY `created_at` DESC;
-- 输出关键指标解读:
-- type: ALL(全表扫描)→ 需加索引
-- possible_keys: 可用的索引
-- key: 实际使用的索引
-- rows: 扫描行数预估
-- Extra: Using filesort(需优化排序)EXPLAIN 输出详解
| 列名 | 常见值 | 含义 | 严重程度 |
|---|---|---|---|
| type | ALL | 全表扫描 | ❌ 必须优化 |
| type | index | 全索引扫描 | ⚠️ 需关注 |
| type | range | 范围扫描 | ✅ 可接受 |
| type | ref/eq_ref | 等值引用 | ✅ 良好 |
| type | const/system | 常量查询 | ✅ 最优 |
| Extra | Using filesort | 文件排序 | ⚠️ 需优化 |
| Extra | Using temporary | 临时表 | ❌ 必须优化 |
| Extra | Using index | 覆盖索引 | ✅ 最优 |
| rows | > 10000 | 扫描行数过多 | ⚠️ 需关注 |
慢查询治理步骤:
发现慢查询(监控告警)
│
▼
记录慢 SQL(慢查询日志)
│
▼
EXPLAIN 分析执行计划
│
▼
定位问题(索引缺失/类型转换/扫描行数多)
│
▼
优化方案(加索引/改 SQL/反范式/分库分表)
│
▼
效果验证(对比执行时间与扫描行数)7.2 大表归档
当表超过一定阈值(如 5000 万行或 50GB),需考虑归档。
-- 策略1:DELETE + 休眠
-- 分批删除,每次 1000 行,避免长事务
DELIMITER $$
CREATE PROCEDURE `archive_old_orders`(IN `before_days` INT)
BEGIN
DECLARE `affected` INT DEFAULT 0;
REPEAT
DELETE FROM `order`
WHERE `created_at` < DATE_SUB(NOW(), INTERVAL before_days DAY)
LIMIT 1000;
SET `affected` = ROW_COUNT();
DO SLEEP(0.1); -- 每批暂停 100ms,释放锁
UNTIL `affected` = 0 END REPEAT;
END$$
DELIMITER ;
CALL archive_old_orders(365);
-- 策略2:分区表实现滚动删除
CREATE TABLE `order_partitioned` (
`order_id` BIGINT NOT NULL,
`user_id` BIGINT NOT NULL,
`created_at` DATETIME NOT NULL,
PRIMARY KEY (`order_id`, `created_at`)
) ENGINE=InnoDB
PARTITION BY RANGE (TO_DAYS(`created_at`)) (
PARTITION p2024_q1 VALUES LESS THAN (TO_DAYS('2024-04-01')),
PARTITION p2024_q2 VALUES LESS THAN (TO_DAYS('2024-07-01')),
PARTITION p2024_q3 VALUES LESS THAN (TO_DAYS('2024-10-01')),
PARTITION p_future VALUES LESS THAN MAXVALUE
);
-- 删除历史分区(极快,不产生 IO 碎片)
ALTER TABLE `order_partitioned` DROP PARTITION p2024_q1;7.3 数据备份策略
┌──────────────────────────────────────┐
│ 备份策略 │
├────────────────┬─────────────────────┤
│ 全量备份 │ 每周日凌晨 3:00 │
│ 增量备份 │ 每天凌晨 3:00 │
│ binlog 备份 │ 实时(每 30 分钟) │
│ 备份保留 │ 近 30 天 │
│ 异地备份 │ 跨机房/跨云 │
│ 恢复演练 │ 每月一次 │
└────────────────┴─────────────────────┘# 全量备份脚本(使用 mysqldump)
#!/bin/bash
BACKUP_DIR="/data/backup/mysql"
DATE=$(date +%Y%m%d_%H%M%S)
DB_USER="backup_user"
DB_PASS="your_password"
DB_NAME="your_database"
mysqldump \
-u${DB_USER} -p${DB_PASS} \
--single-transaction \ # 不加锁
--routines \ # 导出存储过程
--triggers \ # 导出触发器
--master-data=2 \ # 记录 binlog 位置
--flush-logs \ # 刷新日志
${DB_NAME} | gzip > ${BACKUP_DIR}/full_${DATE}.sql.gz
# 只保留最近 30 天备份
find ${BACKUP_DIR} -name "full_*.sql.gz" -mtime +30 -delete# 使用 XtraBackup 进行物理备份(适用于大库)
# 全量备份
xtrabackup --backup --target-dir=/data/backup/xtra/full/ \
--user=root --password=root
# 增量备份
xtrabackup --backup --target-dir=/data/backup/xtra/inc1/ \
--incremental-basedir=/data/backup/xtra/full/ \
--user=root --password=root备份恢复演练清单
| 阶段 | 操作 | 预期耗时 | 验证方式 |
|---|---|---|---|
| 全量备份 | mysqldump --single-transaction |
视数据量 | 检查 SQL 文件完整性 |
| 增量恢复 | 应用 binlog 到指定时间点 | 5~30min | 数据行数比对 |
| 跨机房恢复 | 异地备份导入新实例 | 30~60min | 应用全功能回归 |
| 演练频率 | 每月一次 | — | 恢复时效满足 RTO |
RTO(恢复时间目标):建议 < 1 小时
RPO(恢复点目标):建议 < 5 分钟(依赖 binlog 实时备份)
7.4 监控体系
┌──────────────────┐
│ 告警平台 │
│ (PagerDuty/ │
│ 飞书/钉钉) │
└────────┬─────────┘
│
┌─────────────────────┼─────────────────────┐
│ │ │
▼ ▼ ▼
┌─────────────────┐ ┌─────────────────┐ ┌─────────────────┐
│ Prometheus + │ │ Grafana │ │ ELK/Kibana │
│ MySQL Exporter │ │ 可视化看板 │ │ 慢查询日志分析 │
└────────┬─────────┘ └─────────────────┘ └─────────────────┘
│
▼
┌──────────────────────────────────────┐
│ 监控指标 │
├──────────────────────────────────────┤
│ QPS / TPS │
│ 连接数 / 活跃连接数 │
│ 慢查询数量(每分钟) │
│ 主从延迟(Seconds_Behind_Master) │
│ 磁盘使用率 / 磁盘 IOPS │
│ InnoDB 行锁等待 / 死锁计数 │
│ 缓存命中率(Innodb_buffer_pool_reads)│
│ 临时表创建量 │
└──────────────────────────────────────┘告警阈值推荐
| 指标 | 警告阈值 | 严重阈值 | 说明 |
|---|---|---|---|
| 连接数使用率 | > 70% | > 90% | 防止连接池耗尽 |
| 慢查询数/min | > 10 | > 50 | 需立即分析 |
| 主从延迟 | > 5s | > 30s | 影响读一致性 |
| 磁盘使用率 | > 80% | > 92% | 防止写入失败 |
| InnoDB 死锁数 | > 0 | > 3/h | 需排查业务逻辑 |
| QPS 突降 | < 50% 均值 | < 20% 均值 | 可能数据库不可用 |
| Buffer Pool 命中率 | < 95% | < 90% | 内存不足需扩容 |
7.5 SQL 审核与发布
数据库变更需要经审核才能上线,防止 DDL/DML 失误导致生产故障。
-- DDL 审核清单
-- ✅ 表名、字段名使用小写蛇形命名
-- ✅ 每张表必须有主键
-- ✅ 必须有 created_at / updated_at
-- ✅ 字符集统一 utf8mb4
-- ✅ 字段有 COMMENT 注释
-- ❌ 禁止在业务高峰期执行 DDL
-- ❌ 禁止使用外键约束(应用层保证)
-- ❌ 禁止直接 DROP 表(改为 RENAME + 延迟删除)
-- 安全的 DDL 发布流程
-- 步骤1:备份表
CREATE TABLE `order_bak_20240916` LIKE `order`;
INSERT INTO `order_bak_20240916` SELECT * FROM `order`;
-- 步骤2:使用 pt-online-schema-change 在线变更
pt-online-schema-change h=localhost,D=db,t=order \
--alter "ADD COLUMN remark VARCHAR(500) DEFAULT ''" \
--execute
-- 步骤3:灰度验证
-- 先验证一小部分流量,确认无误后全量
-- 步骤4:清理备份(保留 7 天)
-- DROP TABLE IF EXISTS `order_bak_20240916`;7.6 容量规划
-- 预估单表数据量
-- 行大小 = 各字段字节数之和 + 行开销(约 20 字节)
-- 场景:order 表 1 亿行,单行约 200 字节
-- 数据量 ≈ 1亿 × 200B / 1024³ ≈ 18.6 GB
-- 索引量 ≈ 数据量的 1.5~2 倍 ≈ 30 GB
-- 总空间 ≈ 50 GB(不含备份)
-- 磁盘空间监控
SELECT
table_schema,
ROUND(SUM(data_length + index_length) / 1024 / 1024 / 1024, 2) AS total_gb,
ROUND(SUM(data_free) / 1024 / 1024 / 1024, 2) AS free_gb
FROM information_schema.tables
WHERE table_schema NOT IN ('mysql', 'performance_schema', 'sys')
GROUP BY table_schema;
-- 增长趋势预测
-- 日增 10 万行 × 30 天 = 月增 300 万行
-- 月增 300 万行 × 200B = 月增 600 MB 数据 + 900 MB 索引
-- 留 6 个月余量,预分配存储空间核心监控指标 SQL:
-- 检查 InnoDB 状态
SHOW ENGINE INNODB STATUS\G
-- 查看当前活跃事务
SELECT * FROM information_schema.innodb_trx
WHERE trx_state = 'RUNNING'\G
-- 查看锁等待
SELECT * FROM sys.innodb_lock_waits;
-- 检查表空间使用
SELECT
table_schema AS db,
table_name,
ROUND((data_length + index_length) / 1024 / 1024, 2) AS total_mb,
ROUND(data_length / 1024 / 1024, 2) AS data_mb,
ROUND(index_length / 1024 / 1024, 2) AS index_mb,
ROUND(data_free / 1024 / 1024, 2) AS free_mb,
table_rows
FROM information_schema.tables
WHERE table_schema NOT IN ('mysql', 'performance_schema', 'sys')
ORDER BY total_mb DESC
LIMIT 20;
-- 检查当前 QPS
SHOW GLOBAL STATUS LIKE 'Questions';
-- 等 1 秒后再次执行,差值即 QPS总结
数据库设计不是一次性工作,而是伴随业务演进的持续过程。以下是为企业级数据库设计的核心要点速查:
| 阶段 | 要点 |
|---|---|
| 建模 | 需求驱动,ER 图先行,避免过度设计 |
| 索引 | 高选择性、最左前缀、覆盖索引优先 |
| 分片 | 选好分片键,接受分布式的复杂度 |
| 读写分离 | 注意主从延迟,一致性要求高时强制读主库 |
| 表结构 | 字段精简、大字段分离、软删除 + 版本号 |
| SQL | 避免隐式转换和函数包裹,小表驱动大表 |
| 治理 | 慢 SQL 持续治理,大表归档,备份可恢复 |
设计决策速查表
| 场景 | 推荐做法 |
|---|---|
| 日增 1 万行,总行 500 万 | 单表 + 索引优化,无需分片 |
| 日增 10 万行,总行 1 亿 | 按 user_id 水平分 16~64 表 |
| 日增 100 万行日志 | 按月/日分表 + 自动建表 + 历史分区清理 |
| 读 QPS 1 万,写 QPS 1000 | 1 主 2 从读写分离 |
| 需要 JOIN 3 张以上大表 | 反范式冗余字段或引入 ES 搜索引擎 |
| 强一致性要求(如支付) | 事务 ACID + 强制读主库 + 分布式锁 |
| 最终一致性要求(如通知) | 消息队列 + 本地消息表 + 补偿机制 |
推荐工具链
| 能力 | 推荐工具 |
|---|---|
| ORM/数据访问 | MyBatis-Plus, JPA, Hibernate |
| 分库分表中间件 | ShardingSphere-JDBC, MyCat |
| 分布式事务 | Seata, RocketMQ 事务消息 |
| 数据库迁移 | Flyway, Liquibase |
| 慢查询分析 | pt-query-digest, mysqldumpslow |
| 监控 | Prometheus + MySQL Exporter + Grafana |
| 备份 | XtraBackup, mysqldump |
| SQL 审核 | SQLE, Yearning |
最后的话
数据库设计的最佳实践最终可归纳为一句话:理解业务、尊重数据、持续优化。没有放之四海皆准的设计模板,只有根据业务场景不断权衡和迭代的工程实践。在做出每个设计决策时,问自己三个问题:
- 这个设计能支撑未来 6 个月的数据增长吗?
- 最频繁的查询路径是什么,这个设计是否优化了它?
- 如果出错,数据能恢复吗?
坚持这三个问题,你的数据库设计就不会偏离正确的方向。