Init
This commit is contained in:
@@ -0,0 +1,361 @@
|
||||
---
|
||||
tags: [MySQL, Go, sql.DB, 连接池, 性能调优]
|
||||
create time: 2026-05-16 00:00
|
||||
---
|
||||
|
||||
# Go 连接 MySQL 的工程实践
|
||||
|
||||
## 概述
|
||||
|
||||
Go 的 `database/sql` 提供了数据库无关的连接管理抽象。但与 Redis 不同——Go 标准库的 driver 是**连接池**而非直连,理解它的行为模型对避免生产事故至关重要。
|
||||
|
||||
## sql.DB 的本质
|
||||
|
||||
```mermaid
|
||||
flowchart TB
|
||||
App["应用进程"] --> Pool["sql.DB 连接池"]
|
||||
|
||||
Pool -->|"动态创建"| C1["Connection 1<br/>到 MySQL Server"]
|
||||
Pool --> C2["Connection 2"]
|
||||
Pool --> C3["Connection N..."]
|
||||
|
||||
Pool -.MaxOpenConns 限制.| MaxLimit["最大连接数"]
|
||||
|
||||
C1 --> MySQL["MySQL Server Process"]
|
||||
C2 --> MySQL
|
||||
C3 --> MySQL
|
||||
|
||||
style Pool fill:#00B6BC,color:#fff
|
||||
style MaxLimit fill:#EE5A24,color:#fff
|
||||
```
|
||||
|
||||
> [!WARNING] 常见的误解
|
||||
> `sql.DB` **不是单个连接**,而是一个**连接池**。它代表的是逻辑连接——物理连接是按需创建、空闲回收的。这意味着:
|
||||
> - 多个 goroutine 可以同时使用同一个 `db` 对象
|
||||
> - `db` 是 goroutine-safe 的
|
||||
> - 不要将 `db` 传给每个请求单独维护(会导致连接膨胀)
|
||||
|
||||
## 核心参数调优
|
||||
|
||||
```go
|
||||
import "database/sql"
|
||||
|
||||
db, err := sql.Open("mysql", dsn)
|
||||
if err != nil {
|
||||
log.Fatal(err)
|
||||
}
|
||||
|
||||
// 四大核心参数
|
||||
db.SetMaxOpenConns(50) // 池中最多的打开连接数
|
||||
db.SetMaxIdleConns(10) // 池中最多保持的空闲连接数
|
||||
db.SetConnMaxLifetime(time.Minute * 5) // 连接存活时间
|
||||
db.SetConnMaxIdleTime(time.Minute * 2) // 空闲连接被回收的时间(Go 1.15+)
|
||||
```
|
||||
|
||||
### 参数关系图
|
||||
|
||||
```mermaid
|
||||
flowchart LR
|
||||
A["SetMaxOpenConns = 50<br/>全局上限"] --> B{"当前活跃连接数 < 50?"}
|
||||
B -->|是| C["创建新连接"]
|
||||
B -->|否| D["排队等待可用连接 ⏳"]
|
||||
|
||||
E["SetMaxIdleConns = 10<br/>最少保留 10 个空闲"] --> F["关闭多余的空闲连接"]
|
||||
G["SetConnMaxLifetime = 5m<br/>超过则关闭重建"] --> H["防止 MySQL 8.0 默认 8h wait_timeout 冲突"]
|
||||
I["SetConnMaxIdleTime = 2m<br/>空闲超 2 分钟即关闭"] --> J["减少资源浪费"]
|
||||
|
||||
style D fill:#EE5A24,color:#fff
|
||||
style C fill:#00D866,color:#fff
|
||||
```
|
||||
|
||||
### 参数的依赖关系
|
||||
|
||||
```go
|
||||
// 重要约束
|
||||
// 如果 SetMaxOpenConns <= 0 → 无限制(不推荐!)
|
||||
// 如果 SetMaxIdleConns > SetMaxOpenConns → 自动调整为等于 MaxOpenConns
|
||||
|
||||
// 合理配置示例
|
||||
db.SetMaxOpenConns(100) // 根据压测确定
|
||||
db.SetMaxIdleConns(20) // 至少 1/5 的 MaxOpenConns
|
||||
db.SetConnMaxLifetime(5 * time.Minute) // 短于 MySQL wait_timeout (默认 8h)
|
||||
db.SetConnMaxIdleTime(2 * time.Minute) // 及时释放长期空闲连接
|
||||
```
|
||||
|
||||
### 如何确定 MaxOpenConns 的值
|
||||
|
||||
```mermaid
|
||||
flowchart TD
|
||||
A["压测:单实例 QPS + 平均延迟"] --> B["计算:<br/>MaxOpenConns = QPS × AvgLatency(s)"]
|
||||
B --> C{"是否接近 MySQL max_connections?"}
|
||||
C -->|"是,不够"| D["考虑读写分离<br/>或分库"]
|
||||
C -->|"否,有余量"| E["设为计算值的 80%<br/>留出安全余量"]
|
||||
E --> F["观察 P99 延迟<br/>和连接池等待数"]
|
||||
|
||||
style B fill:#4FC08D,color:#fff
|
||||
style E fill:#EAB308,color:#fff
|
||||
style F fill:#A0AEC0,color:#fff
|
||||
```
|
||||
|
||||
> [!TIP] 经验公式与验证方法
|
||||
> ```
|
||||
> 理论值 = 并发请求数 × 单次查询平均耗时
|
||||
> ```
|
||||
> - **步骤一**:用 `ab` 或 `wrk` 压测,拿到 QPS 和平均响应时间
|
||||
> - **步骤二**:`MaxOpenConns ≈ QPS × AvgLatency(s)`,例如 500 QPS、20ms → `500 × 0.02 = 10`
|
||||
> - **步骤三**:在此基础上乘以 1.5~2 倍的安全系数(考虑突发流量、慢查询兜底)
|
||||
> - **步骤四**:上线后通过 Prometheus 监控 `go_sql_driver_connections` 实际使用量调优
|
||||
>
|
||||
> > [!WARNING] 常见误区
|
||||
> > - **MaxOpenConns 设越大越好** → 连接越多,单连接 CPU 竞争越严重,延迟反而上升
|
||||
> > - **忽略 SetMaxIdleConns** → 默认值为 2,突发流量到来时频繁创建连接导致延迟抖动
|
||||
> > - **MaxIdleConns 应 ≥ 1**(生产建议 CPU 核数或 10~20),确保有预热好的连接可用
|
||||
|
||||
## DSN 格式详解
|
||||
|
||||
```go
|
||||
// user:password@tcp(host:port)/dbname?param1=value1¶m2=value2
|
||||
dsn := "app_user:app_password@tcp(10.0.1.10:3306)/app_db" +
|
||||
"?charset=utf8mb4" +
|
||||
"&parseTime=True" + // 自动将 MySQL DATETIME 解析为 Go time.Time
|
||||
"&loc=Local" + // 时区设置
|
||||
"&timeout=10s" + // dial 超时
|
||||
"&readTimeout=30s" + // 读取超时
|
||||
"&writeTimeout=30s" + // 写入超时
|
||||
"&allowNativePasswords=true" + // MySQL 8.0 认证插件
|
||||
"&tls=false" // TLS 连接
|
||||
```
|
||||
|
||||
> [!TIP] parseTime = true 的价值
|
||||
> ```go
|
||||
> // ❌ parseTime=False (默认): time 字段变成 string
|
||||
> var createdAt string
|
||||
> db.QueryRow("SELECT created_at FROM users WHERE id = ?", id).Scan(&createdAt)
|
||||
> // createdAt = "2026-05-16 10:30:00" — 需要手动解析
|
||||
>
|
||||
> // ✅ parseTime=True: time 字段自动转为 time.Time
|
||||
> var createdAt time.Time
|
||||
> db.QueryRow("SELECT created_at FROM users WHERE id = ?", id).Scan(&createdAt)
|
||||
> // createdAt = 2026-05-16 10:30:00 +0800 CST
|
||||
> ```
|
||||
|
||||
## go-sql-driver/mysql 特有参数
|
||||
|
||||
`github.com/go-sql-driver/mysql` 除了标准 DSN 参数外,还提供了一些有用的驱动级配置:
|
||||
|
||||
| 参数 | 推荐值 | 说明 |
|
||||
|------|--------|------|
|
||||
| `interpolateParams` | `true` | 将占位符替换为实际值后拼接 SQL,减少往返次数 ⚡ |
|
||||
| `loc=Local` | `Local` | 时区设为本地时区(Asia/Shanghai) |
|
||||
| `parseTime` | `true` | MySQL DATETIME/TIMESTAMP 自动转为 Go `time.Time` |
|
||||
| `charset=utf8mb4` | `utf8mb4` | 支持 Emoji 和所有 Unicode 字符 |
|
||||
| `allowNativePasswords` | `true` | MySQL 8.0 `caching_sha2_password` 认证插件 |
|
||||
| `allowAllFiles` | `false` | 禁止 `LOAD DATA LOCAL INFILE` 读取任意文件 🔒 |
|
||||
| `clientFoundRows` | `false` | UPDATE 返回"匹配行数"而非"影响行数" |
|
||||
| `tls=false` / `tls=skip-verify` / `tls=preferred` | 按需选择 | TLS 加密连接(生产环境建议启用) |
|
||||
|
||||
```go
|
||||
dsn := "app_user:app_password@tcp(10.0.1.10:3306)/app_db" +
|
||||
"?charset=utf8mb4&parseTime=True&loc=Local" +
|
||||
"&timeout=10s&readTimeout=30s&writeTimeout=30s" +
|
||||
"&interpolateParams=true" + // 性能优化:减少网络往返
|
||||
"&allowAllFiles=false" + // 安全加固:防止 LOCAL INFILE 注入
|
||||
"&maxAllowedPacket=67108864" // 最大包大小 64MB(大文本/BLOB 必须设)
|
||||
```
|
||||
|
||||
> [!WARNING] interpolateParams 注意事项
|
||||
> - **仅适用于不重复使用的查询**——如果复用 `*sql.Stmt`,会导致 SQL 注入风险
|
||||
> - **对批量插入无效**——prepare + exec 在大批量场景下依然更快
|
||||
> - 适合的场景:单条查询、CRUD 接口中的简单参数绑定
|
||||
|
||||
## 事务最佳实践
|
||||
|
||||
### 模式一:defer tx.Rollback(最常用)
|
||||
|
||||
```go
|
||||
tx, err := db.Begin()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer func() {
|
||||
if p := recover(); p != nil {
|
||||
tx.Rollback() // panic 时确保回滚,防止连接泄漏
|
||||
panic(p) // 重新抛出
|
||||
}
|
||||
}()
|
||||
|
||||
// 执行业务操作
|
||||
_, err = tx.Exec("UPDATE accounts SET balance = balance - ? WHERE id = ?", amount, fromID)
|
||||
if err != nil {
|
||||
return err // defer 触发 Rollback()
|
||||
}
|
||||
_, err = tx.Exec("UPDATE accounts SET balance = balance + ? WHERE id = ?", amount, toID)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
return tx.Commit() // defer 不会触发 Rollback(err == nil)
|
||||
```
|
||||
|
||||
> [!TIP] 核心原则
|
||||
> `defer tx.Rollback()` 在 Commit **之后**调用时是安全的——提交过的事务没有需要回滚的内容。因此可以简化为:
|
||||
> ```go
|
||||
> tx, _ := db.Begin()
|
||||
> defer tx.Rollback() // 简单粗暴,适用于绝大多数场景
|
||||
>
|
||||
> // ... 业务逻辑
|
||||
> return tx.Commit()
|
||||
> ```
|
||||
|
||||
### 模式二:函数式封装(推荐复用)
|
||||
|
||||
```go
|
||||
// 事务包装器:失败自动回滚,成功返回 Commit 的 err
|
||||
func transaction(db *sql.DB, fn func(*sql.Tx) error) error {
|
||||
tx, err := db.Begin()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer func() {
|
||||
if p := recover(); p != nil {
|
||||
tx.Rollback()
|
||||
panic(p)
|
||||
}
|
||||
}()
|
||||
|
||||
if err := fn(tx); err != nil {
|
||||
_ = tx.Rollback() // 忽略 rollback 错误,返回原始业务错误
|
||||
return err
|
||||
}
|
||||
return tx.Commit()
|
||||
}
|
||||
|
||||
// 使用方式
|
||||
err := transaction(db, func(tx *sql.Tx) error {
|
||||
_, err := tx.Exec("UPDATE accounts SET balance = balance - ? WHERE id = ?", amount, fromID)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
_, err = tx.Exec("UPDATE accounts SET balance = balance + ? WHERE id = ?", amount, toID)
|
||||
return err
|
||||
})
|
||||
```
|
||||
|
||||
> [!NOTE] 为什么不建议过度复杂的 panic recover?
|
||||
> | 写法 | 适用场景 | 复杂度 |
|
||||
> |------|---------|--------|
|
||||
> | `defer tx.Rollback()` | 日常开发 ✅ | 低 |
|
||||
> | 函数式封装 | 团队规范、多模块复用 | 中 |
|
||||
> | 手动 Recover+Rollback | 极少数有全局 panic 处理的框架 | 高 |
|
||||
>
|
||||
> 大多数应用不需要担心 commit 后 rollback 的问题——`database/sql` 内部会正确处理,所以一句 `defer tx.Rollback()` 就足够了。
|
||||
|
||||
## 查询模式速查
|
||||
|
||||
```go
|
||||
// 单行查询
|
||||
var id int
|
||||
var name string
|
||||
err := db.QueryRow("SELECT id, name FROM users WHERE email = ?", email).
|
||||
Scan(&id, &name)
|
||||
|
||||
// 多行查询
|
||||
rows, err := db.Query("SELECT id, name FROM users WHERE status = ?", 1)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer rows.Close() // 一定要 Close!
|
||||
|
||||
users := make([]User, 0)
|
||||
for rows.Next() {
|
||||
var u User
|
||||
if err := rows.Scan(&u.ID, &u.Name); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
users = append(users, u)
|
||||
}
|
||||
|
||||
// 批量插入
|
||||
tx, _ := db.Begin()
|
||||
stmt, _ := tx.Prepare("INSERT INTO orders (user_id, amount) VALUES (?, ?)")
|
||||
for _, o := range orders {
|
||||
stmt.Exec(o.UserID, o.Amount)
|
||||
}
|
||||
stmt.Close()
|
||||
tx.Commit()
|
||||
|
||||
// 预处理语句复用(高性能场景)
|
||||
stmt, _ := db.Prepare("SELECT * FROM users WHERE id = ?")
|
||||
defer stmt.Close()
|
||||
// 多次执行时复用 stmt,避免重复解析 SQL
|
||||
```
|
||||
|
||||
## 常见问题排查
|
||||
|
||||
### 连接泄漏
|
||||
|
||||
```go
|
||||
// 症状:应用连接数持续增长,最终达到 MaxOpenConns 上限
|
||||
// 原因:没有 Close() rows 或没有 Commit/Rollback tx
|
||||
|
||||
// 检查方法
|
||||
rows, err := db.Query("SELECT COUNT(*) FROM information_schema.processlist WHERE command != 'Sleep'")
|
||||
// 查看活跃连接数是否异常
|
||||
```
|
||||
|
||||
### 连接风暴
|
||||
|
||||
```go
|
||||
// 症状:短时间内创建大量连接,MySQL 报错 Too many connections
|
||||
// 原因:每次请求都新建 sql.DB 而非复用
|
||||
|
||||
// ✅ 正确做法
|
||||
var db *sql.DB // 全局变量或在初始化函数中创建一次
|
||||
|
||||
// ❌ 错误做法:每个 handler 都 Open
|
||||
func handler(w http.ResponseWriter, r *http.Request) {
|
||||
db, _ := sql.Open("mysql", dsn) // 每个请求都新建池!
|
||||
}
|
||||
```
|
||||
|
||||
### 与 MySQL wait_timeout 的配合
|
||||
|
||||
```ini
|
||||
# MySQL 端配置(my.cnf)
|
||||
wait_timeout = 600 # 10 分钟非交互式超时
|
||||
interactive_timeout = 600
|
||||
```
|
||||
|
||||
```go
|
||||
// Go 端配置必须短于 MySQL 的 wait_timeout
|
||||
db.SetConnMaxLifetime(5 * time.Minute) // 5 分钟 < 10 分钟
|
||||
// 这样 Go 会在连接被 MySQL 强制关闭前主动重建
|
||||
```
|
||||
|
||||
## 思考与拓展
|
||||
|
||||
> [!QUESTION] 为什么 `sql.Open` 不真正建立连接?
|
||||
> `sql.Open` 只验证 DSN 格式并初始化连接池对象,**不会发起真实的 TCP 连接**。第一次实际查询时才会创建物理连接。如果需要启动时即验证连通性:
|
||||
> ```go
|
||||
> err := db.Ping() // 立即发起一次真实连接
|
||||
> if err != nil { log.Fatal(err) }
|
||||
> ```
|
||||
|
||||
> [!QUESTION] 生产环境 SetMaxIdleConns 默认值是 2——这对突发流量有什么影响?
|
||||
> 当空闲连接只有 2 个时,如果 QPS 从 100 突然飙升到 1000,8 个新连接需要逐个建立 TCP 握手(三次握手 + TLS),每次新增连接的延迟可能高达数毫秒。设一个合理的 MaxIdleConns(如 CPU 核数或 10~20)可以维持"热连接池",让突发流量立即命中已有连接。
|
||||
|
||||
> [!QUESTION] go-redis 和 sql.DB 的连接池设计有何异同?
|
||||
> | 维度 | `*sql.DB` | `go-redis.Client` |
|
||||
> |------|-----------|-------------------|
|
||||
> | 最大连接 | `SetMaxOpenConns(n)` | `MaxActive(n)` |
|
||||
> | 空闲连接 | `SetMaxIdleConns(n)` | `MaxIdle(n)` |
|
||||
> | 空闲回收 | `SetConnMaxIdleTime(d)` | `IdleTimeout(d)` |
|
||||
> | 等待超时 | 内部阻塞(无直接 API) | `PoolTimeout(d)` |
|
||||
> | 保活机制 | 需手动设置 Lifetime | `PingPeriod` PING/PONG |
|
||||
>
|
||||
> 共同点:**都是 goroutine-safe 的共享池**,不应每请求新建实例。
|
||||
|
||||
## 关联笔记
|
||||
|
||||
- [[hhs/GORM/01-安装与初始化]] — GORM 底层调用 `*sql.DB` 进行连接池调优
|
||||
- [[hhs/Redis/11-运维与性能调优]] — go-redis 连接池参数对比(MaxActive/MaxIdle vs SetMaxOpenConns/SetMaxIdleConns)
|
||||
@@ -0,0 +1,490 @@
|
||||
---
|
||||
tags: [MySQL, Schema Migration, flyway, goose, migrate]
|
||||
create time: 2026-05-16 00:00
|
||||
---
|
||||
|
||||
# Schema 迁移管理
|
||||
|
||||
## 概述
|
||||
|
||||
生产环境中,数据库表结构不会一蹴而就。随着业务发展,频繁的结构变更需要通过版本化的迁移脚本来管理,而不是直接在数据库中手工 ALTER TABLE。
|
||||
|
||||
> [!QUESTION] 思考一下
|
||||
> 如果你在凌晨三点接到电话——"生产库挂了,因为一个没有回滚脚本的迁移执行了一半就崩溃了",你会怎么做?
|
||||
>
|
||||
> 这个问题的答案涵盖了本章要讨论的全部主题:**版本化**、**可回滚**、**幂等性**以及 **dirty state 恢复**。
|
||||
|
||||
本文档将覆盖从工具选型到生产级实战的完整流程:
|
||||
|
||||
| 阶段 | 核心问题 | 对应章节 |
|
||||
|------|---------|---------|
|
||||
| 选型 | 该选哪个迁移工具? | 主流迁移工具对比 |
|
||||
| 上手 | 怎么写迁移文件?怎么跑? | golang-migrate/migrate 实战 |
|
||||
| 设计 | 怎么写安全的 DDL? | 迁移设计原则 |
|
||||
| 进阶 | 千万级大表怎么改不锁表? | 生产级大表在线 DDL |
|
||||
| 工程化 | 如何在 CI/CD 中安全验证? | CI/CD 集成与测试 |
|
||||
|
||||
## 为什么需要迁移工具?
|
||||
|
||||
```mermaid
|
||||
flowchart TD
|
||||
V1["v1: 初始建表<br/>CREATE TABLE users"] --> V2["v2: 加字段<br/>ALTER TABLE users ADD COLUMN phone"]
|
||||
V2 --> V3["v3: 改列类型<br/>ALTER TABLE users MODIFY COLUMN email VARCHAR(255)"]
|
||||
V3 --> V4["v4: 建新表<br/>CREATE TABLE orders"]
|
||||
|
||||
Dev["开发环境"] -->|"手动执行"| OK1["✅ 没问题"]
|
||||
Staging["测试环境"] -->|"不知道要跑哪些脚本"| FAIL1["❌ 遗漏"]
|
||||
Prod["生产环境"] -->|"怕出错不敢动"| FAIL2["❌ 停滞"]
|
||||
|
||||
Migrate["迁移工具自动化"] --> Auto1["Dev → Staging → Prod 一致性 ✅"]
|
||||
Auto1 --> Auto2["可回滚 ✅"]
|
||||
Auto2 --> Auto3["审计追踪 ✅"]
|
||||
|
||||
style FAIL1 fill:#EE5A24,color:#fff
|
||||
style FAIL2 fill:#EE5A24,color:#fff
|
||||
style Auto1 fill:#00D866,color:#fff
|
||||
```
|
||||
|
||||
## 主流迁移工具对比
|
||||
|
||||
| 工具 | 语言 | 核心特性 | 安装方式 | 适用场景 |
|
||||
|------|------|---------|---------|---------|
|
||||
| **golang-migrate/migrate** | Go | 简洁 API、支持多种 database、按序执行 | `go get -u github.com/golang-migrate/migrate/v4/cmd/migrate@latest` | Go 项目首选 |
|
||||
| **pressly/goose** | Go | CLI + API、`embed.FS` 内嵌迁移文件、自包含二进制 | `go install github.com/pressly/goose/v3/cmd/goose@latest` | 需要将迁移嵌入二进制的团队 |
|
||||
| **Flyway** | Java | 企业级、支持多语言、CI 集成、Schema History Table | Homebrew / Maven / Docker | Java/跨语言团队 |
|
||||
| **Laravel Migrator** | PHP | 框架内置、自动跟踪版本 | 内置于 Laravel 框架 | Laravel 项目 |
|
||||
| **Alembic** | Python | SQLAlchemy 生态、自动生成迁移脚手架 | `pip install alembic` | Python/数据科学 |
|
||||
| **SQLAlchemy 2.0+** | Python | `alembic` 是事实标准 | 同上 | Python Web 服务 |
|
||||
|
||||
> [!TIP] 选型建议
|
||||
> - **Go 项目内部用** → `golang-migrate`(稳定、社区大)或 `goose`(可以 embed 进 binary,部署更方便)
|
||||
> - **团队混合语言(Java/Python/Go)** → Flyway(协议统一)
|
||||
> - **单体项目起步** → 选你们最熟悉的语言的库即可,迁移工具的差异不如"坚持使用它"重要
|
||||
|
||||
## golang-migrate/migrate 实战
|
||||
|
||||
### 项目结构
|
||||
|
||||
```
|
||||
migrations/
|
||||
├── 000001_create_users_table.up.sql
|
||||
├── 000001_create_users_table.down.sql
|
||||
├── 000002_add_phone_to_users.up.sql
|
||||
├── 000002_add_phone_to_users.down.sql
|
||||
└── 000003_create_orders_table.up.sql
|
||||
```
|
||||
|
||||
### 迁移文件示例
|
||||
|
||||
```sql
|
||||
-- 000001_create_users_table.up.sql
|
||||
CREATE TABLE users (
|
||||
id BIGINT UNSIGNED AUTO_INCREMENT PRIMARY KEY,
|
||||
username VARCHAR(50) NOT NULL UNIQUE,
|
||||
email VARCHAR(255) NOT NULL UNIQUE,
|
||||
password_hash VARCHAR(64) NOT NULL,
|
||||
status TINYINT NOT NULL DEFAULT 1,
|
||||
created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
|
||||
INDEX idx_status_created (status, created_at)
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
|
||||
|
||||
-- 000001_create_users_table.down.sql
|
||||
DROP TABLE IF EXISTS users;
|
||||
```
|
||||
|
||||
```sql
|
||||
-- 000002_add_phone_to_users.up.sql
|
||||
-- safe: ADD COLUMN 默认值为 NOT NULL 时不会锁全表(MySQL 8.0.12+)
|
||||
ALTER TABLE users ADD COLUMN phone VARCHAR(20) AFTER email;
|
||||
|
||||
-- 000002_add_phone_to_users.down.sql
|
||||
ALTER TABLE users DROP COLUMN phone;
|
||||
```
|
||||
|
||||
### 命令行操作
|
||||
|
||||
```bash
|
||||
# 创建新的迁移文件
|
||||
migrate create -ext sql -dir migrations -seq create_products_table
|
||||
|
||||
# 执行所有未运行的迁移
|
||||
migrate -path migrations -database "mysql://user:pass@tcp(host:3306)/db" up
|
||||
|
||||
# 向前 migration 指定步数
|
||||
migrate -path migrations -database "mysql://..." up 2
|
||||
|
||||
# 回退指定步数
|
||||
migrate -path migrations -database "mysql://..." down 1
|
||||
|
||||
# 查看当前状态
|
||||
migrate -path migrations -database "mysql://..." version
|
||||
|
||||
# 强制设置版本号(极端情况下的修复手段)
|
||||
migrate -path migrations -database "mysql://..." force 3
|
||||
```
|
||||
|
||||
### Go API 方式
|
||||
|
||||
```go
|
||||
package main
|
||||
|
||||
import (
|
||||
"log"
|
||||
"github.com/golang-migrate/migrate/v4"
|
||||
_ "github.com/golang-migrate/migrate/v4/database/mysql"
|
||||
_ "github.com/golang-migrate/migrate/v4/source/file"
|
||||
)
|
||||
|
||||
func main() {
|
||||
m, err := migrate.New(
|
||||
"file://migrations", // 迁移文件目录
|
||||
"mysql://user:pass@tcp(localhost:3306)/app_db", // 数据库连接
|
||||
)
|
||||
if err != nil {
|
||||
log.Fatal(err)
|
||||
}
|
||||
|
||||
// 运行所有未执行的迁移
|
||||
if err := m.Up(); err != nil && err != migrate.ErrNoChange {
|
||||
log.Fatal(err)
|
||||
}
|
||||
|
||||
// 查看当前版本
|
||||
version, dirty, err := m.Version()
|
||||
if err != nil {
|
||||
log.Fatal(err)
|
||||
}
|
||||
log.Printf("Current migration version: %d (dirty=%v)", version, dirty)
|
||||
}
|
||||
```
|
||||
|
||||
> [!WARNING] Dirty State
|
||||
> 如果迁移过程中途崩溃(如网络中断),migration table 会被标记为 `dirty=true`。下次运行迁移时会拒绝执行直到你手动修复:
|
||||
> ```bash
|
||||
> # 确认迁移确实完成了,清除 dirty 标志
|
||||
> migrate -path migrations -database "mysql://..." force 3
|
||||
> ```
|
||||
>
|
||||
> > [!CAUTION] 为什么不能盲目清 dirty?
|
||||
> > `dirty=true` 意味着上一个迁移可能只执行了一半——表可能被删了但索引还没建完。强制清脏前务必确认:
|
||||
> > 1. 当前 schema 状态与预期版本是否一致
|
||||
> > 2. 如果有不一致,需要手工补跑缺失的 DDL 后再 `force`
|
||||
>
|
||||
> **预防优于修复**:生产环境大表 DDL 建议在低峰期执行,并在预发环境充分验证。
|
||||
|
||||
### goose 的 Embed FS 方式
|
||||
|
||||
相比 `golang-migrate` 需要外部挂载 migrations 目录,goose 支持将 SQL 文件内嵌到二进制中:
|
||||
|
||||
```go
|
||||
import (
|
||||
"embed"
|
||||
"github.com/pressly/goose/v3"
|
||||
)
|
||||
|
||||
//go:embed migrations/*.sql
|
||||
var migrations embed.FS
|
||||
|
||||
func init() {
|
||||
goose.SetBaseDir("./") // go.work 根目录
|
||||
goose.SetDialect("mysql")
|
||||
}
|
||||
|
||||
func RunMigrate(ctx context.Context) error {
|
||||
return goose.ContextualMigrateUp(ctx, migrations)
|
||||
}
|
||||
```
|
||||
|
||||
好处:**不需要在目标服务器维护 migrations 目录**,二进制即一切,适合容器化部署。
|
||||
|
||||
## 常见陷阱与最佳实践
|
||||
|
||||
### 迁移命名规范
|
||||
|
||||
| 约定 | 示例 | 说明 |
|
||||
|------|------|------|
|
||||
| 序号_描述.up.sql | `000003_create_orders_table.up.sql` | 5 位数字确保排序正确 |
|
||||
| 序号_描述.down.sql | `000003_create_orders_table.down.sql` | 每个 up 配 down |
|
||||
| 动词开头 | `add_phone_to_users`, `create_orders` | 可读性强 |
|
||||
| 禁止空格 | ✅ `create_users_table` ❌ `create users table` | 避免 shell 转义问题 |
|
||||
|
||||
### 回滚文件不是必须写的
|
||||
|
||||
> [!EXAMPLE] 什么时候可以省略 .down.sql?
|
||||
> - **DROP TABLE / DROP COLUMN** → down 里怎么写?你不知道之前的 schema 长什么样。此时 down 写 "无法自动回滚" 即可。
|
||||
> - **创建只读表** → down 中可以 DROP,但这种场景本身就应该尽量避免。
|
||||
>
|
||||
> **结论**:down 脚本的价值在于"安全撤销"。如果你不确定怎么回滚,宁可留空并记录原因,也不要写一个会丢失数据的回滚脚本。
|
||||
|
||||
### 禁止在迁移中使用 DML 混合事务
|
||||
|
||||
```sql
|
||||
-- ❌ 危险:DDL + DML 混在同一个迁移里
|
||||
ALTER TABLE users ADD COLUMN bio TEXT;
|
||||
UPDATE users SET bio = 'default' WHERE status = 1;
|
||||
|
||||
-- ✅ 拆成两个迁移
|
||||
-- 000004_add_bio_column.up.sql
|
||||
ALTER TABLE users ADD COLUMN bio TEXT;
|
||||
|
||||
-- 000005_fill_default_bio.up.sql
|
||||
UPDATE users SET bio = 'default' WHERE status = 1 AND bio IS NULL;
|
||||
```
|
||||
|
||||
原因:某些迁移工具对 DDL 和 DML 的事务语义处理不同,混在一起可能导致 **DDL 提交了但 DML 失败**,数据处于半填充状态。
|
||||
|
||||
### 索引迁移的额外关注
|
||||
|
||||
> [!NOTE] 索引创建的锁行为
|
||||
> MySQL 8.0+ InnoDB 支持 Online DDL,但加索引操作依然会:
|
||||
> - 扫描全表构建 B+Tree(读取量 ≈ 表大小 × 列数)
|
||||
> - 期间新写入的数据会被记录在 change buffer 或 redo log 中
|
||||
> - 如果表正在被高频写入,可能会触发多次 rebuild
|
||||
>
|
||||
> **建议**:1000 万行以上的大表加索引,使用 `pt-online-schema-change` 或 `gh-ost`(见下方"生产级在线 DDL")。
|
||||
|
||||
## 迁移设计原则
|
||||
|
||||
### 幂等性
|
||||
|
||||
每个迁移应该是**幂等的**——多次执行不会产生副作用。
|
||||
|
||||
```sql
|
||||
-- ❌ 非幂等:第二次执行会报错 "Column already exists"
|
||||
ALTER TABLE users ADD COLUMN bio TEXT;
|
||||
|
||||
-- ✅ 幂等:IF NOT EXISTS 防止重复创建
|
||||
CREATE TABLE IF NOT EXISTS products (
|
||||
id BIGINT PRIMARY KEY,
|
||||
name VARCHAR(200)
|
||||
);
|
||||
```
|
||||
|
||||
> [!QUESTION] ALTER TABLE ADD COLUMN 如何做到幂等?
|
||||
> MySQL 本身不支持 `ADD COLUMN IF NOT EXISTS`,但有一种间接做法:利用视图或存储过程先检查列是否存在。不过实践中更推荐的做法是——
|
||||
> **让迁移工具保证不重复执行**。golang-migrate/goose/flyway 都会维护一个 schema version table,已执行过的版本不会再跑。所以你只需要在单个文件内避免幂等问题即可。
|
||||
|
||||
### 原子性
|
||||
|
||||
同一事务中多个 DDL 语句不是原子的(MySQL InnoDB 对 DDL 使用隐式提交)。因此 **把互不相关的变更分拆到不同迁移文件**,既是安全策略,也是工程最佳实践。
|
||||
|
||||
```sql
|
||||
-- ❌ 危险:一条 SQL 包含多种变更类型
|
||||
ALTER TABLE orders
|
||||
ADD INDEX idx_status (status),
|
||||
ADD COLUMN source VARCHAR(50),
|
||||
CHANGE COLUMN description detail TEXT;
|
||||
|
||||
-- ✅ 安全:每一步独立迁移文件,单步失败不会污染全局状态
|
||||
-- migration/001_add_source_column.up.sql
|
||||
ALTER TABLE orders ADD COLUMN source VARCHAR(50);
|
||||
|
||||
-- migration/002_add_status_index.up.sql
|
||||
ALTER TABLE orders ADD INDEX idx_status (status);
|
||||
|
||||
-- migration/003_rename_description_to_detail.up.sql
|
||||
ALTER TABLE orders CHANGE COLUMN description detail TEXT;
|
||||
```
|
||||
|
||||
> [!TIP] 分步的额外好处:每步可以单独评估锁时间
|
||||
> - ADD COLUMN → 通常毫秒级(MySQL 8.0.12+ instantDDL)
|
||||
> - ADD INDEX → 可能几分钟到几小时(需要全表扫描排序)
|
||||
> - CHANGE/COLUMN TYPE → 可能需要 rebuild 表,最长
|
||||
>
|
||||
> 分开后你可以决定哪些用普通 `up`、哪些需要在低峰期手动执行。
|
||||
|
||||
### 向前兼容(Three-Phase Deployment)
|
||||
|
||||
> [!WARNING] 最常见的线上事故场景
|
||||
> 代码直接部署了"只读新列"的新逻辑 + 同时执行了迁移,结果新旧实例混跑期间旧实例读到 NULL 值导致业务异常。
|
||||
>
|
||||
> 记住黄金法则:**先部署兼容代码,再改 schema,最后清理旧逻辑**。
|
||||
|
||||
```mermaid
|
||||
sequenceDiagram
|
||||
participant C as Code Deploy
|
||||
participant M as Migration
|
||||
participant D as Database
|
||||
|
||||
Note over C,D: Phase 1: 部署向后兼容代码(最关键的一步)
|
||||
C->>+D: 支持读写新列<br/>空值时回退读旧列
|
||||
C->>D: 同时写入新旧两列
|
||||
D-->>-C: OK — 新旧schema都工作
|
||||
|
||||
Note over C,D: Phase 2: 执行迁移补数据
|
||||
M->>D: ALTER TABLE ADD COLUMN new_col DEFAULT ...
|
||||
Note over M,D: 此时已有代码兜底,即使回滚也安全
|
||||
D-->>M: migration done
|
||||
|
||||
Note over C,D: Phase 3: 清理旧逻辑(可选延后)
|
||||
C->>D: 部署精简代码<br/>只依赖新列
|
||||
D-->>C: OK — 旧列可以 DROP 了
|
||||
```
|
||||
|
||||
> [!NOTE] 渐进式淘汰时间线
|
||||
> | 阶段 | 时间窗口 | 说明 |
|
||||
> |------|---------|------|
|
||||
> | 双写期 | T ~ T+2h | 同时写新旧列,用于灰度验证 |
|
||||
> | 数据填充期 | T+2h ~ T+24h | 通过后台任务把旧数据刷到新列 |
|
||||
> | 切换期 | T+24h~48h | 流量逐渐切到只读新列 |
|
||||
> | 清理期 | 确认无误后 | DROP 旧列 |
|
||||
|
||||
## 生产级大表在线 DDL
|
||||
|
||||
当表超过千万行时,普通的 `ALTER TABLE` 即便支持 Online DDL 也会带来可观的复制延迟和资源消耗。这时需要借助专门的工具来实现**零锁变更**。
|
||||
|
||||
### 方案对比
|
||||
|
||||
```mermaid
|
||||
flowchart LR
|
||||
subgraph Native["MySQL 原生"]
|
||||
N1["Online DDL\nMySQL 5.6+"] --> S1["INPLACE 算法<br/>仍有短暂 LOCK"]
|
||||
N2["INSTANT DDL\nMySQL 8.0.12+"] --> S2["几乎不锁表<br/>但仅限特定操作"]
|
||||
end
|
||||
|
||||
subgraph ThirdParty["第三方工具"]
|
||||
T1["pt-online-schema-change"] --> P1["触发器 + 影子表<br/>Percona Toolkit"]
|
||||
T2["gh-ost"] --> P2["binlog 解析 + 别名切换<br/>GitHub"]
|
||||
end
|
||||
|
||||
S1 -.→|"百万行以下"| OK
|
||||
S2 -.→|"500万行内 ADD/DROP COLUMN"| OK
|
||||
P1 -.→|"超大表 复杂DDL"| BIG
|
||||
P2 -.→|"超大表 要求极低延迟"| BIG
|
||||
|
||||
style S2 fill:#00D866,color:#fff
|
||||
style P2 fill:#FFA500,color:#fff
|
||||
```
|
||||
|
||||
### pt-online-schema-change(OSC)
|
||||
|
||||
Percona Toolkit 提供的经典工具,原理是**创建影子表 → 触发器同步 → 原子替换**。
|
||||
|
||||
```bash
|
||||
# 基本用法:给 orders 表添加索引
|
||||
pt-online-schema-change \
|
||||
--alter="ADD INDEX idx_status (status)" \
|
||||
D=app_db,t=orders \
|
||||
--execute
|
||||
|
||||
# 生产环境推荐参数
|
||||
pt-online-schema-change \
|
||||
--alter="ADD INDEX idx_status (status)" \
|
||||
D=app_db,t=orders \
|
||||
--chunk-time=0.5 # 每批处理 0.5s,控制单批影响
|
||||
--max-lag=1s # 主从延迟超过 1s 自动暂停
|
||||
--check-interval=500 # 每 500ms 检查一次延迟
|
||||
--recursion-method=processlist # 检测连接源的方式
|
||||
--parallel-copy=4 # 多副本并发拷贝(MySQL 8.0+)
|
||||
--no-drop-old-table # 不立即删除旧表,先确认无误后再手工 DROP
|
||||
```
|
||||
|
||||
> [!CAUTION] OSC 的三大限制
|
||||
> 1. **不支持外键** — 有外键约束的表无法使用
|
||||
> 2. **触发器开销** — 每个插入/更新/删除都要额外执行触发器
|
||||
> 3. **内存占用** — 影子表和原表在同一实例上,磁盘空间要预留至少 1x 表大小
|
||||
|
||||
### gh-ost(推荐)
|
||||
|
||||
GitHub 开源的工具,通过解析 binlog 来同步增量数据,最后用**重命名表**实现原子切换。
|
||||
|
||||
```bash
|
||||
# 基本用法
|
||||
./gh-ost \
|
||||
--user="migrator" \
|
||||
--password="pass" \
|
||||
--host="127.0.0.1" \
|
||||
--port=3306 \
|
||||
--database="app_db" \
|
||||
--table="orders" \
|
||||
--alter="ADD INDEX idx_status (status)" \
|
||||
--test-on-replica # 先在副本上测试(强烈建议)
|
||||
--allow-on-master # 最后在主库执行时去掉 --test-on-replica
|
||||
|
||||
# 关键参数
|
||||
--cut-over=atomic # 原子切换(默认),也有 graceful 模式
|
||||
--max-lag-millis=1000 # 容忍的主从延迟阈值
|
||||
--throttle-control-replicas="replica1,replica2" # 多个副本监控点
|
||||
--initially-drop-old-table # 先删旧影子表,避免冲突
|
||||
--initially-drop-ghost-table
|
||||
```
|
||||
|
||||
> [!TIP] 选型决策树
|
||||
> ```
|
||||
> 你的表有多大?
|
||||
> ├─ < 100 万行 → MySQL 原生 Online DDL 就够了
|
||||
> ├─ 100 万 ~ 1000 万 → INSTANT DDL(加/删列用 native,索引用 OSC)
|
||||
> └─ > 1000 万 → gh-ost(首选)或 OSC
|
||||
> ↓
|
||||
> 有没有外键约束?
|
||||
> ├─ 有 → OSC 不适用,只能用 gh-ost
|
||||
> └─ 无 → gh-ost(资源更友好)优于 OSC
|
||||
> ```
|
||||
|
||||
## CI/CD 集成与测试
|
||||
|
||||
迁移脚本如果只在开发环境跑过就上了生产,等于开盲盒。CI 管道必须包含迁移验证环节。
|
||||
|
||||
### Docker 化验证
|
||||
|
||||
```yaml
|
||||
# .github/workflows/migrate-check.yml
|
||||
name: Migration Validation
|
||||
on: pull_request
|
||||
jobs:
|
||||
test-migration:
|
||||
runs-on: ubuntu-latest
|
||||
services:
|
||||
mysql:
|
||||
image: mysql:8.0
|
||||
env:
|
||||
MYSQL_ROOT_PASSWORD: root
|
||||
ports:
|
||||
- 3306:3306
|
||||
options: >-
|
||||
--health-cmd "mysqladmin ping -h localhost"
|
||||
--health-interval 10s
|
||||
--health-timeout 5s
|
||||
--health-retries 5
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- name: Run migrations up
|
||||
run: |
|
||||
migrate -path migrations -database "mysql://root:root@tcp(localhost:3306)/test_db" up
|
||||
- name: Verify schema
|
||||
run: |
|
||||
mysql -h localhost -u root -proot test_db -e "SHOW TABLES;"
|
||||
- name: Rollback and re-apply
|
||||
run: |
|
||||
migrate -path migrations -database "mysql://root:root:root@tcp(localhost:3306)/test_db" down $(migrate -path migrations -database "mysql://..." version)
|
||||
migrate -path migrations -database "mysql://root:root@tcp(localhost:3306)/test_db" up
|
||||
```
|
||||
|
||||
> [!IMPORTANT] 为什么需要回滚再重跑?
|
||||
> 这一步验证了两件事:
|
||||
> 1. **down 脚本可用** — 不是所有迁移都有 down 脚本
|
||||
> 2. **幂等性** — 先 rollback 再 apply 等价于干净状态从头跑,暴露出依赖顺序问题
|
||||
|
||||
### Schema Diff 作为 PR Check
|
||||
|
||||
```bash
|
||||
# 在 PR 中自动比对开发环境 schema 与迁移文件的差异
|
||||
# 安装 schemacatch 或用 flyway validate
|
||||
flyway -url=jdbc:mysql://localhost:3306/app_dev validate
|
||||
```
|
||||
|
||||
输出示例:
|
||||
```
|
||||
VALID: Migrations checksum mismatch is not allowed
|
||||
-> Currently on version: 15/15
|
||||
```
|
||||
|
||||
> [!QUOTE] 经验之谈
|
||||
> "99% 的生产事故不是因为迁移逻辑错了,而是因为本地跑的版本和 CI 检验的版本不一致。"
|
||||
> — 保持 CI、staging、prod 的迁移文件完全一致,任何手动改库的行为都应该被禁止。
|
||||
|
||||
## 关联笔记
|
||||
|
||||
- [[hhs/GORM/17-迁移工具]] — GORM 自带的 AutoMigrate vs 手动迁移工具的权衡
|
||||
- [[hhs/GORM/01-安装与初始化]] — GORM 的表结构创建机制
|
||||
@@ -0,0 +1,563 @@
|
||||
---
|
||||
tags: [MySQL, 分库分表, Sharding, Vitess, ShardingSphere, Snowflake]
|
||||
create time: 2026-05-16 00:00
|
||||
---
|
||||
|
||||
# 分库分表
|
||||
|
||||
## 概述
|
||||
|
||||
想象你开了一家超市,所有商品都放在一个仓库里。当商品只有几千种时,一个仓库绰绰有余;但当商品增长到几十万种,一个仓库就变得拥挤不堪——找货慢、入库堵、仓库还会塌。解决方案很简单:**建多个仓库,按规则分配商品**。这就是分库分表(Sharding)的核心思想。
|
||||
|
||||
当单库单表的数据量超出 MySQL 的舒适区时,分库分表成为必选方案。但分片不仅是技术决策,更是业务架构的改变——它引入了跨分片查询、分布式 ID、数据重平衡等一系列新问题,每一个都是对团队工程能力的考验。
|
||||
|
||||
> [!QUESTION] 你真的需要分库分表吗?
|
||||
>
|
||||
> InnoDB 在单表千万级数据下依然表现优秀。一条合适的联合索引 + 读写分离就能解决 80% 的性能瓶颈。分片引入的复杂度远超收益——跨分片 JOIN、分布式事务、数据倾斜、在线迁移……每一个都是生产环境的深夜警报。**建议仅在以下情况才考虑分片:**
|
||||
>
|
||||
> 1. 单表已突破 **5000 万行**且增长趋势明显
|
||||
> 2. QPS 已接近单机 MySQL 的物理极限(约 1~2 万写入/秒)
|
||||
> 3. SSD 存储成本显著上升,无法继续横向扩展
|
||||
|
||||
## 分片策略总览
|
||||
|
||||
```mermaid
|
||||
flowchart TD
|
||||
subgraph "水平分片 Horizontal"
|
||||
HS["按用户哈希<br/>user_id % N"]
|
||||
HR["按范围 Range<br/>created_at 月份"]
|
||||
HL["按列表 List<br/>country IN ('CN','US')"]
|
||||
end
|
||||
|
||||
subgraph "垂直分片 Vertical"
|
||||
V1["按业务域拆分<br/>订单库 / 用户库 / 商品库"]
|
||||
V2["冷热数据分离<br/>热表 SSD / 冷表 HDD"]
|
||||
end
|
||||
|
||||
style HS fill:#00D866,color:#fff
|
||||
style V1 fill:#00B6BC,color:#fff
|
||||
```
|
||||
|
||||
| 维度 | 类比 | 适用场景 | 优点 | 缺点 |
|
||||
|------|------|---------|------|------|
|
||||
| **Hash 分片** | 按姓名首字母分班 | 均匀写入、分散压力 | 数据分布均匀 | 范围查询需遍历所有分片 |
|
||||
| **Range 分片** | 按年份归档文件 | 时间序列、归档场景 | 天然支持范围查询 | 热点数据倾斜(如双 11) |
|
||||
| **List 分片** | 按部门分配办公区 | 租户隔离、地域隔离 | 边界清晰、易于运维 | 新增分区需提前规划 |
|
||||
| **垂直拆分** | 超市按品类分区 | 多业务混合负载 | 业务解耦、故障隔离 | 跨库 JOIN 变复杂 |
|
||||
|
||||
> [!TIP] 最佳实践:先垂直再水平
|
||||
> 如果不同业务的表耦合在同一库里,先做**垂直拆分**(按业务域拆成多个库),再对单个大表做**水平拆分**。两步都做能最大化收益。
|
||||
|
||||
## 水平分表实战
|
||||
|
||||
### Hash 分片
|
||||
|
||||
```sql
|
||||
-- 假设拆分 orders 表为 4 张子表:orders_0 ~ orders_3
|
||||
-- 路由规则:order_id % 4 = 表后缀
|
||||
-- order_id=1001 → orders_1
|
||||
-- order_id=1004 → orders_0
|
||||
```
|
||||
|
||||
```go
|
||||
// 通过 order_id 计算目标分片
|
||||
func GetShardTableName(orderID int64, shardCount int) string {
|
||||
return fmt.Sprintf("orders_%d", orderID%int64(shardCount))
|
||||
}
|
||||
```
|
||||
|
||||
> ⚠️ Hash 分片的坑:`order_id` 本身由数据库自增生成——如果你还没上分布式 ID,`order_id` 不能用作分片键(因为它是单库内递增的)。此时应改用 `user_id`(雪花算法生成的分布式 ID)。
|
||||
|
||||
### Range 分片
|
||||
|
||||
Range 分片的核心思想是**按连续区间划分**,最典型的场景就是按时间分表——每个月一张表,就像把文件按年份放进不同的文件夹。
|
||||
|
||||
```sql
|
||||
-- 按月分表:审计日志、交易流水等时间序列数据
|
||||
-- 表名:logs_202601, logs_202602, ..., logs_202612
|
||||
|
||||
-- 查询某月数据 → 只需访问一张表
|
||||
SELECT * FROM logs_202603 WHERE level = 'ERROR' ORDER BY created_at DESC LIMIT 100;
|
||||
|
||||
-- 跨月范围查询 → 路由层自动合并多张表
|
||||
SELECT * FROM logs_202603
|
||||
UNION ALL
|
||||
SELECT * FROM logs_202604
|
||||
WHERE created_at BETWEEN '2026-03-01' AND '2026-04-30';
|
||||
```
|
||||
|
||||
```go
|
||||
// 路由层:根据时间范围计算需要查询哪些表
|
||||
func GetLogTableNames(start, end time.Time) []string {
|
||||
var tables []string
|
||||
for t := start; !t.After(end); t = t.AddDate(0, 1, 0) {
|
||||
tables = append(tables, fmt.Sprintf("logs_%s", t.Format("200601")))
|
||||
}
|
||||
return tables
|
||||
}
|
||||
```
|
||||
|
||||
> [!TIP] Range 分片天然适合做数据归档
|
||||
> 历史数据可以直接迁移到低成本存储,操作极其简单:
|
||||
> ```sql
|
||||
> -- 将上月数据归档到 archive 库,腾出 SSD 空间
|
||||
> ALTER TABLE logs_202601 RENAME TO archive.logs_202601_old;
|
||||
> -- 为下月预创建空表
|
||||
> CREATE TABLE logs_202602 LIKE logs_202601;
|
||||
> ```
|
||||
|
||||
> [!QUESTION] Range 分片最大的风险是什么?
|
||||
> 答:**数据倾斜**。如果你按月分表,双 11 当天的写入量可能是平时的 100 倍——所有的写入压力集中在一张表上。解决方案:对热点时段做二次 Hash 分片(见下方"联合分片")。
|
||||
|
||||
### List 分片
|
||||
|
||||
List 分片是**按枚举值分组**,最典型的场景是 SaaS 多租户——每个租户的数据天然隔离,互不干扰。就像办公楼按公司分配楼层,A 公司在 3 楼,B 公司在 5 楼。
|
||||
|
||||
```sql
|
||||
-- SaaS 多租户场景:每个租户的数据在独立的库中
|
||||
-- tenant_A: db_tenant_0.orders, db_tenant_0.users
|
||||
-- tenant_B: db_tenant_1.orders, db_tenant_1.users
|
||||
|
||||
-- 查询时自动路由到对应库
|
||||
-- 应用层根据 tenant_id 选择数据库连接
|
||||
SELECT * FROM orders WHERE status = 'paid' ORDER BY created_at DESC;
|
||||
-- → tenant_A 走 db_tenant_0,tenant_B 走 db_tenant_1
|
||||
```
|
||||
|
||||
```go
|
||||
// 路由层:根据租户 ID 选择目标数据库
|
||||
func GetTenantDB(tenantID string) *sql.DB {
|
||||
// 租户 → 库的映射关系(可存配置中心或 Redis)
|
||||
dbMap := map[string]*sql.DB{
|
||||
"tenant_A": dbTenant0,
|
||||
"tenant_B": dbTenant1,
|
||||
}
|
||||
return dbMap[tenantID]
|
||||
}
|
||||
```
|
||||
|
||||
> [!NOTE] List 分片的额外好处:合规审计
|
||||
> 某些行业(金融、医疗)要求租户数据物理隔离。List 分片天然满足这一需求——不同租户的数据在不同物理库中,审计时只需检查对应库即可。
|
||||
|
||||
### 联合分片
|
||||
|
||||
单一维度的分片总有局限:Hash 分片不支持范围查询,Range 分片会数据倾斜。**联合分片是两种策略的组合**——用两个(或更多)维度共同决定数据归属。
|
||||
|
||||
```go
|
||||
// 联合分片公式:shard_id = (user_id % 16) * 4 + (month % 4)
|
||||
// 第一层 user_id % 16 → 分散写入压力,同一用户的数据集中
|
||||
// 第二层 month % 4 → 按时间归档,热点月份自动分散到 4 张表
|
||||
// 共 16 × 4 = 64 张子表
|
||||
|
||||
func GetShardTableName(userID int64, t time.Time) string {
|
||||
userShard := userID % 16 // 用户维度:16 个桶
|
||||
timeShard := int(t.Month()-1) % 4 // 时间维度:4 个桶
|
||||
shardID := userShard*4 + int64(timeShard)
|
||||
return fmt.Sprintf("orders_%d", shardID)
|
||||
}
|
||||
|
||||
// 举例:
|
||||
// user_id=1001, 2026年3月 → orders_38 (1001%16=9, month=2, 9*4+2=38)
|
||||
// user_id=1001, 2026年5月 → orders_36 (1001%16=9, month=4, 9*4+0=36)
|
||||
// user_id=2002, 2026年3月 → orders_10 (2002%16=2, month=2, 2*4+2=10)
|
||||
// 同一用户不同月份的数据落在相邻分片,方便按用户维度聚合
|
||||
```
|
||||
|
||||
## 分片键选型指南
|
||||
|
||||
分片键(Sharding Key)是分库分表中最关键的决策——**一旦选定,后期几乎无法更换**。选错了分片键,就像把图书馆的书按"书名首字"分区,找"数据库"相关的书要跑遍 20 个书架。
|
||||
|
||||
分片键的核心要求只有一个:**让最常用的查询能精确定位到一个分片**,而不是扫描所有分片。
|
||||
|
||||
```mermaid
|
||||
flowchart TD
|
||||
Start["选定分片键"] --> Q1{"查询是否<br/>总是包含此字段?"}
|
||||
|
||||
Q1 -->|是| Q2{"数据量是否<br/>分布均匀?"}
|
||||
Q1 -->|否| FAIL1["❌ 范围查询需扫描全部分片"]
|
||||
|
||||
Q2 -->|是| RECOMMEND["✅ 推荐作为分片键"]
|
||||
Q2 -->|否| REBALANCE["⚠️ 考虑 List 或 Range 分片"]
|
||||
|
||||
RECOMMEND --> Q3{"是否有业务<br/>级联删除?"}
|
||||
Q3 -->|无| FINAL["🏆 user_id 是最常见的分片键"]
|
||||
Q3 -->|有| WARNING["⚠️ 级联操作会触发多分片写"]
|
||||
|
||||
FAIL1 --> Q4{"是否按时间查询为主?"}
|
||||
Q4 -->|是| TIME["✅ Range 按时间分片"]
|
||||
Q4 -->|否| TRYDIFF["🔄 换一个能覆盖主查询的字段"]
|
||||
```
|
||||
|
||||
> [!EXAMPLE] 经典案例:电商订单的分片键选择
|
||||
> - **错误选择**:`order_id` —— 自增 ID,数据集中在最后一个分片
|
||||
> - **正确选择**:`user_id` —— 分布式 ID,天然分散;且 90% 的订单查询都带 `WHERE user_id = ?`
|
||||
> - **折中方案**:`(user_id, order_id)` 联合主键,`user_id` 作分片键,`order_id` 作二级索引
|
||||
|
||||
### 验证分片键的三个问题
|
||||
|
||||
在决定分片键之前,用这三个问题自检:
|
||||
|
||||
| # | 问题 | 如果答案是否定的 |
|
||||
|---|------|-----------------|
|
||||
| 1 | **核心查询路径是否都能带上这个字段?** | 会出现大量跨分片扫描 |
|
||||
| 2 | **数据分布是否足够随机化?** | 需要加盐(salt)打散 |
|
||||
| 3 | **未来 1~2 年的业务增长是否会改变查询模式?** | 预留可调整空间 |
|
||||
|
||||
> [!NOTE] 数据倾斜检测
|
||||
> ```sql
|
||||
> -- 监控每个分片的数据量差异
|
||||
> SELECT table_name, table_rows
|
||||
> FROM information_schema.tables
|
||||
> WHERE table_schema = 'mydb' AND table_name LIKE 'orders_%'
|
||||
> ORDER BY table_rows DESC;
|
||||
> -- 如果最大分片行数 > 最小分片的 3 倍 → 存在倾斜
|
||||
> ```
|
||||
|
||||
## 分布式 ID 与分片的关系
|
||||
|
||||
分库分表后,**全局唯一 ID** 是第一道门槛。想象有两个班级各自编号:A 班有 1 号、2 号、3 号……B 班也有 1 号、2 号、3 号……当两个班级合并参加运动会时,"1 号"到底是谁?这就是分库后自增 ID 冲突的问题。
|
||||
|
||||
> [!IMPORTANT] 主键策略必须适配分片
|
||||
>
|
||||
> 如果你的主键是 AUTO_INCREMENT,每个分库各自从 1 开始计数——一旦需要合并数据或做跨库查询,ID 冲突不可避免。这就是为什么**要做分片就必须先上分布式 ID**。
|
||||
>
|
||||
> 详细的 ID 生成方案请参考:[[hhs/MySQL/05-表设计/22-主键策略对比]](Snowflake、ULID、UUID_TO_BIN 等方案的深度对比)。
|
||||
|
||||
快速选型参考:
|
||||
|
||||
| 场景 | 推荐方案 | 理由 |
|
||||
|------|---------|------|
|
||||
| 已有 Snowflake Worker | 直接用现有 WorkerID | 保持一致性,无需额外组件 |
|
||||
| 已有独立 ID 服务 | 调用 gRPC/HTTP ID 接口 | 集中管控,可回拨告警 |
|
||||
| 轻量级项目 | MySQL sequence 表 | 简单可靠,但高并发下有瓶颈 |
|
||||
|
||||
## 跨分片查询
|
||||
|
||||
这是分库分表最大的痛点。分片前,一个 `JOIN` 就能搞定的查询,分片后可能需要遍历所有分片再手动合并。
|
||||
|
||||
### 不可行方案
|
||||
|
||||
```sql
|
||||
-- ❌ 跨分片 JOIN:MySQL 的 JOIN 只在同一实例内有效
|
||||
-- orders 表在 sharded_db,users 表在 user_db,两个不同的实例
|
||||
-- 这条 SQL 会直接报错:Table 'user_db.users' doesn't exist
|
||||
SELECT o.*, u.name FROM orders o JOIN users u ON o.user_id = u.id;
|
||||
|
||||
-- ❌ 暴力 UNION ALL:除非你能精确计算分片号,否则必须扫描全部分片
|
||||
-- 4 个分片还好,如果有 64 个分片,每次都扫全量就太浪费了
|
||||
SELECT * FROM orders_0 WHERE user_id = 100
|
||||
UNION ALL SELECT * FROM orders_1 WHERE user_id = 100
|
||||
UNION ALL SELECT * FROM orders_2 WHERE user_id = 100
|
||||
UNION ALL SELECT * FROM orders_3 WHERE user_id = 100;
|
||||
```
|
||||
|
||||
### 可行方案:应用层 JOIN
|
||||
|
||||
```go
|
||||
// Step 1: 获取用户信息
|
||||
userInfo, _ := userService.GetUser(userID)
|
||||
|
||||
// Step 2: 计算订单所在分片
|
||||
shards := []string{fmt.Sprintf("orders_%d", userID%4)}
|
||||
|
||||
// Step 3: 并发查询各分片
|
||||
type OrderResult struct {
|
||||
Orders []Order
|
||||
Err error
|
||||
}
|
||||
|
||||
ch := make(chan OrderResult, len(shards))
|
||||
for _, shard := range shards {
|
||||
go func(s string) {
|
||||
orders := queryOrders(s, userID)
|
||||
ch <- OrderResult{Orders: orders}
|
||||
}(shard)
|
||||
}
|
||||
|
||||
var allOrders []Order
|
||||
for range shards {
|
||||
result := <-ch
|
||||
allOrders = append(allOrders, result.Orders...)
|
||||
}
|
||||
sort.Slice(allOrders, func(i, j int) bool {
|
||||
return allOrders[i].CreatedAt.After(allOrders[j].CreatedAt)
|
||||
})
|
||||
```
|
||||
|
||||
```mermaid
|
||||
flowchart LR
|
||||
Client["客户端请求"] --> GW["应用网关<br/>计算目标分片"]
|
||||
GW -->|"concurrent"| S1["Query orders_0"]
|
||||
GW -->|"concurrent"| S2["Query orders_1"]
|
||||
GW -->|"concurrent"| S3["Query orders_2"]
|
||||
GW -->|"concurrent"| S4["Query orders_3"]
|
||||
S1 & S2 & S3 & S4 --> Merge["合并排序<br/>应用层聚合"]
|
||||
Merge --> Resp["返回最终结果"]
|
||||
style Merge fill:#FF9F43,color:#000
|
||||
```
|
||||
|
||||
### 跨分片分页(难点中的难点)
|
||||
|
||||
```sql
|
||||
-- ❌ LIMIT 深分页在各分片各自生效,合并后分页结果不对
|
||||
-- 分片 0: SELECT * FROM orders_0 ORDER BY id LIMIT 0, 20 → 拿到前 20
|
||||
-- 分片 1: SELECT * FROM orders_1 ORDER BY id LIMIT 0, 20 → 拿到前 20
|
||||
-- 合并取前 20?→ 错了!实际全局可能有 80 条比这些更早的记录
|
||||
|
||||
-- ✅ 正确做法:游标分页 + 应用层归并
|
||||
-- 每个分片取更大窗口:ORDER BY created_at LIMIT 0, (N+depth)
|
||||
-- 应用层全量合并后再截取
|
||||
```
|
||||
|
||||
```go
|
||||
// 核心思路:游标分页 + 多路归并
|
||||
// 第一步:每个分片多取一些数据(扩大缓冲区)
|
||||
// 第二步:应用层合并所有结果,排序后截取需要的条数
|
||||
|
||||
type PageRequest struct {
|
||||
UserID int64
|
||||
Limit int // 期望返回条数,比如 20
|
||||
AfterTs int64 // 游标:上一页最后一条的时间戳,首次请求传 0
|
||||
}
|
||||
|
||||
func QueryShardedOrders(req PageRequest) ([]Order, error) {
|
||||
// 1. 计算目标分片(这里只查 1 个分片,因为 user_id % 4 路由精确)
|
||||
shard := fmt.Sprintf("orders_%d", req.UserID%4)
|
||||
|
||||
// 2. 扩大窗口:请求 20 条,但从每个分片取 40 条作为缓冲
|
||||
// 为什么要多取?因为游标边界附近的数据可能分布在不同分片
|
||||
bufferSize := req.Limit * 2
|
||||
query := fmt.Sprintf(
|
||||
"SELECT * FROM %s WHERE user_id = %d AND created_at < %d ORDER BY created_at DESC LIMIT %d",
|
||||
shard, req.UserID, req.AfterTs, bufferSize,
|
||||
)
|
||||
rows, _ := db.Query(getConn(shard), query)
|
||||
orders := scanOrders(rows)
|
||||
|
||||
// 3. 截取需要的数量
|
||||
if len(orders) > req.Limit {
|
||||
orders = orders[:req.Limit]
|
||||
}
|
||||
return orders, nil
|
||||
}
|
||||
|
||||
// 当分片键不是查询条件时(比如按时间全局排序),需要扫描所有分片:
|
||||
func QueryAllShards(req PageRequest) ([]Order, error) {
|
||||
var allOrders []Order
|
||||
for i := 0; i < 4; i++ {
|
||||
shard := fmt.Sprintf("orders_%d", i)
|
||||
query := fmt.Sprintf(
|
||||
"SELECT * FROM %s WHERE created_at < %d ORDER BY created_at DESC LIMIT %d",
|
||||
shard, req.AfterTs, req.Limit*2,
|
||||
)
|
||||
rows, _ := db.Query(getConn(shard), query)
|
||||
allOrders = append(allOrders, scanOrders(rows)...)
|
||||
}
|
||||
|
||||
// 全局排序 + 截取
|
||||
sort.Slice(allOrders, func(i, j int) bool {
|
||||
return allOrders[i].CreatedAt.After(allOrders[j].CreatedAt)
|
||||
})
|
||||
if len(allOrders) > req.Limit {
|
||||
allOrders = allOrders[:req.Limit]
|
||||
}
|
||||
return allOrders, nil
|
||||
}
|
||||
```
|
||||
|
||||
> [!QUESTION] 为什么不在数据库层做多分片聚合?
|
||||
> 因为每个分片上的 `LIMIT` 只在自己分片范围内有效。举个具体例子:你有 4 个分片,想查全局第 100~120 条数据。如果每个分片执行 `LIMIT 100, 20`,得到的是"每个分片自己排第 100~120 条"——合起来 80 条数据,但它们和全局第 100~120 条完全不同。
|
||||
>
|
||||
> **正确做法**:从每个分片多取一些数据(扩大窗口),在应用层做全局排序后截取。代价是内存和延迟增加,但这是分布式系统的固有 Trade-off。
|
||||
|
||||
### 跨分片聚合(COUNT / SUM / AVG)
|
||||
|
||||
简单聚合(COUNT、SUM、AVG)的思路很直接:**每个分片各自算一遍,应用层再合并**。比如总订单数 = 分片 0 的 COUNT + 分片 1 的 COUNT + ……
|
||||
|
||||
但 `GROUP BY + 聚合` 就麻烦了——你需要合并各分片的分组结果,逻辑复杂且性能差。**更好的方案是预计算**:
|
||||
|
||||
```sql
|
||||
-- 方案:定时汇总表(cron 每小时执行一次)
|
||||
-- 每个分片各自聚合,结果写入同一张汇总表
|
||||
INSERT INTO summary_daily (date, total_orders, total_amount)
|
||||
SELECT DATE(created_at), COUNT(*), SUM(amount)
|
||||
FROM orders_0 GROUP BY DATE(created_at)
|
||||
UNION ALL
|
||||
SELECT DATE(created_at), COUNT(*), SUM(amount)
|
||||
FROM orders_1 GROUP BY DATE(created_at)
|
||||
UNION ALL
|
||||
SELECT DATE(created_at), COUNT(*), SUM(amount)
|
||||
FROM orders_2 GROUP BY DATE(created_at)
|
||||
UNION ALL
|
||||
SELECT DATE(created_at), COUNT(*), SUM(amount)
|
||||
FROM orders_3 GROUP BY DATE(created_at);
|
||||
|
||||
-- 查询时直接读汇总表,响应速度从"秒级"降到"毫秒级"
|
||||
SELECT date, SUM(total_orders), SUM(total_amount)
|
||||
FROM summary_daily
|
||||
WHERE date BETWEEN '2026-05-01' AND '2026-05-31'
|
||||
GROUP BY date;
|
||||
```
|
||||
|
||||
## 中间件方案
|
||||
|
||||
前面的代码示例都是手动计算分片路由——在生产环境中,我们更常用成熟的中间件来处理分片逻辑。中间件就像一个"智能路由器":应用发一条普通 SQL,中间件自动帮你拆分、路由、合并结果。
|
||||
|
||||
| 中间件 | 开发者 | 部署模式 | 核心特点 |
|
||||
|--------|-------|---------|---------|
|
||||
| **Vitess** | YouTube / Google | Proxy | 透明分片、在线 Re-sharding、K8s 原生 |
|
||||
| **ShardingSphere** | Apache | Proxy / JDBC | 功能最全、生态广、Java 生态为主 |
|
||||
| **MyCat** | 开源社区 | Proxy | 轻量简单、上手快 |
|
||||
| **Cobar** | Alibaba | Proxy | 已停止维护,被 ShardingSphere 取代 |
|
||||
|
||||
### Vitess 架构
|
||||
|
||||
```mermaid
|
||||
flowchart TB
|
||||
subgraph App["应用层"]
|
||||
GO["Go / Java App"]
|
||||
end
|
||||
|
||||
subgraph VT["Vitess Layer"]
|
||||
VTGate["vtgate<br/>SQL 路由 + 连接池"]
|
||||
VSchemas["vschema<br/>分片策略定义"]
|
||||
end
|
||||
|
||||
subgraph Shards["MySQL Instances"]
|
||||
S1["Shard-0<br/>mysqld-3000 ~ 3002"]
|
||||
S2["Shard-1<br/>mysqld-3003 ~ 3005"]
|
||||
S3["Shard-2<br/>mysqld-3006 ~ 3008"]
|
||||
end
|
||||
|
||||
GO --> VTGate
|
||||
VTGate --> VSchemas
|
||||
VSchemas -->|"keyspace/table routing"| S1
|
||||
VSchemas -->|"keyspace/table routing"| S2
|
||||
VSchemas -->|"keyspace/table routing"| S3
|
||||
|
||||
style VTGate fill:#00B6BC,color:#fff
|
||||
style VSchemas fill:#C44569,color:#fff
|
||||
```
|
||||
|
||||
> [!NOTE] Vitess 的核心优势
|
||||
> 1. **透明路由**:应用感知不到分片,SQL 无需改动
|
||||
> 2. **在线 Re-sharding**:数据迁移过程不影响线上服务
|
||||
> 3. **Global Search**:通过 secondary index 支持全分片搜索
|
||||
> 4. **Kubernetes Native**:原生支持 K8s 部署与弹性伸缩
|
||||
|
||||
## 数据迁移策略
|
||||
|
||||
**从零停机迁移角度,这是生产环境最关键的一环。**
|
||||
|
||||
### 双写迁移法(推荐)
|
||||
|
||||
双写迁移的思路很直观:**新旧两套库同时写入,逐步切换读流量**。就像搬新家时,先把旧家具搬到新房子,同时新旧两个地址都收快递——等新家收拾好了,再把快递地址改成新家。
|
||||
|
||||
```mermaid
|
||||
flowchart TD
|
||||
A["旧库 single_db"] --> B["新集群 sharded_db_0..N"]
|
||||
|
||||
subgraph "Phase 1: 存量数据搬迁"
|
||||
P1["离线导出旧表<br/>mysqldump"] --> P2["分批导入新集群<br/>每批 ~100 万行"]
|
||||
end
|
||||
|
||||
subgraph "Phase 2: 双写过渡"
|
||||
APP["应用代码"] -->|"读旧+写旧"| P1
|
||||
APP -->|"写新(异步)"| B
|
||||
NOTE1["新旧数据一致<br/>允许短暂不一致"]
|
||||
end
|
||||
|
||||
subgraph "Phase 3: 切读"
|
||||
P3["数据校验工具<br/>row count / checksum"] --> P4["切换读路由到新集群"]
|
||||
end
|
||||
|
||||
subgraph "Phase 4: 清理"
|
||||
P5["停止旧库写入"] --> P6["观察 1~2 周无异常<br/>下线旧库"]
|
||||
end
|
||||
|
||||
P1 -.->|阶段顺序| P2 -.->|阶段顺序| P3 -.->|阶段顺序| P4 -.->|阶段顺序| P5 -.->|阶段顺序| P6
|
||||
```
|
||||
|
||||
```go
|
||||
// 双写伪代码:写入新集群时走异步通道
|
||||
func CreateOrder(order *Order) error {
|
||||
// 同步写旧库(保证兼容)
|
||||
if err := writeOldDB(order); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// 异步写新分片(不阻塞主流程)
|
||||
go func() {
|
||||
shard := GetShardTableName(order.ID, 4)
|
||||
writeNewDB(shard, order)
|
||||
}()
|
||||
|
||||
return nil
|
||||
}
|
||||
```
|
||||
|
||||
### 渐进式迁移(适合超大表)
|
||||
|
||||
渐进式迁移的核心思想是**分批切换**——先迁移一部分流量到新集群,验证无误后再迁移下一批,最终全部切完。就像搬办公室:先把一个部门搬过去,安顿好了再搬下一个,而不是一次性把整层楼的人都搬走。
|
||||
|
||||
```mermaid
|
||||
flowchart LR
|
||||
subgraph Init["初始状态"]
|
||||
AllOld["全部流量"] --> OldDB["老库"]
|
||||
end
|
||||
|
||||
subgraph P1["Phase 1: 50% 流量"]
|
||||
P1Even["hash(user_id) % 2 == 0"] --> NewS01["新分片 0,1"]
|
||||
P1Odd["hash(user_id) % 2 == 1"] --> OldDB2["老库"]
|
||||
end
|
||||
|
||||
subgraph P2["Phase 2: 100% 流量"]
|
||||
P2A["hash(user_id) % 4 == 0,1"] --> NewS01B["新分片 0,1"]
|
||||
P2B["hash(user_id) % 4 == 2,3"] --> NewS23["新分片 2,3"]
|
||||
end
|
||||
|
||||
Init -->|"Phase 1: 先切一半"| P1
|
||||
P1 -->|"Phase 2: 再切剩余"| P2
|
||||
|
||||
style P1 fill:#FFEAA7,color:#000
|
||||
style P2 fill:#00D866,color:#fff
|
||||
```
|
||||
|
||||
> [!TIP] 渐进式迁移的核心优势
|
||||
> - **风险可控**:每次只影响部分用户,出问题时只需回滚那部分流量
|
||||
> - **可灰度验证**:先选一小批内部用户试跑,确认无误后再扩大范围
|
||||
> - **无需停机**:整个过程对用户透明,业务零感知
|
||||
>
|
||||
> 劣势:需要在应用层维护两套路由规则,增加了代码复杂度。
|
||||
|
||||
> [!WARNING] 迁移 checklist
|
||||
>
|
||||
> - [ ] DDL 变更兼容性:旧版代码能否读取新表结构?(永远先加字段、后删字段)
|
||||
> - [ ] 数据校验:用 `pt-table-checksum` 比对新旧数据一致性
|
||||
> - [ ] 回滚预案:切到新集群后发现问题,能在 5 分钟内切回
|
||||
> - [ ] 索引重建:新分片是否需要不同的索引策略?
|
||||
> - [ ] 监控接入:分片后的慢查询监控面板是否就绪?
|
||||
|
||||
## 分片的挑战与反模式
|
||||
|
||||
分库分表不是银弹,它解决了一个问题,却引入了更多问题。下表列出了最常见的"坑"和应对策略:
|
||||
|
||||
| 问题 | 具体表现 | 应对策略 |
|
||||
|------|---------|---------|
|
||||
| **数据倾斜** | 某些分片数据量远大于其他(比如大卖家的订单集中在某个分片) | 检查 hash 分布,必要时换分片键或加 salt 打散 |
|
||||
| **跨分片事务** | 一笔业务涉及多个分片,InnoDB 不支持跨库事务 | 用 Saga / TCC / 消息队列补偿(见 [[hhs/MySQL/08-工程实践/36-分布式事务]]) |
|
||||
| **Rebalance** | 新增节点后,老数据如何迁移到新分片? | 渐进式迁移,Vitess 内置 Scatter-Gather |
|
||||
| **复杂聚合** | COUNT / SUM 跨分片很贵,GROUP BY 更是噩梦 | 预计算 + 定时汇总到汇总表(见上方详解) |
|
||||
| **分页困难** | LIMIT 深分页在各分片各自生效,合并后结果不对 | 游标分页 + 应用层合并(见上方详解) |
|
||||
| **全局唯一约束** | UNIQUE KEY 在多分片下失效(两个分片可能各插入同一条) | 改为业务层面去重,或用 Redis SET |
|
||||
|
||||
> [!WARNING] 别过早分片
|
||||
>
|
||||
> 再次强调:**90% 的项目永远不需要分库分表**。InnoDB 单表千万级性能依然优秀。只有在满足前述三个条件时才考虑分片。如果团队规模小、迭代快,花两周优化 SQL 和索引的收益远高于花一个月做分片架构。
|
||||
|
||||
## 关联笔记
|
||||
|
||||
- [[hhs/MySQL/05-表设计/22-主键策略对比]] — 分布式 ID 生成方案(Snowflake / ULID / UUID)
|
||||
- [[hhs/GORM/13-多数据库支持]] — GORM 对多库连接的支持
|
||||
- [[hhs/MySQL/08-工程实践/35-Go 连接池配置]] — 多分片场景下的连接池隔离
|
||||
- [[hhs/MySQL/08-工程实践/38-监控指标]] — 分片集群的监控指标体系
|
||||
@@ -0,0 +1,262 @@
|
||||
---
|
||||
tags: [MySQL, 监控指标, QPS, TPS, Buffer Pool Hit Rate, Prometheus, Grafana, InnoDB]
|
||||
create time: 2026-05-16 00:00
|
||||
---
|
||||
|
||||
# 监控指标
|
||||
|
||||
## 概述
|
||||
|
||||
有效的监控体系能让你在问题爆发前发现问题。MySQL 的监控涵盖 **性能**、**可用性**、**容量** 和 **安全** 四个维度。
|
||||
|
||||
> [!TIP] 核心思想
|
||||
> 监控的目的不是「收集更多数据」,而是 **「用最少的关键指标发现最多的问题」**。初学者常犯的错误是把所有 SHOW STATUS 变量都记录下来——但真正需要告警的可能不到 20 个。先定义你的 SLA/SLO,再决定监控什么。
|
||||
|
||||
一个典型的 MySQL 监控栈自下而上:
|
||||
|
||||
```mermaid
|
||||
graph TD
|
||||
A["MySQL Server"] -->|"SHOW GLOBAL STATUS / performance_schema"| B["mysqld_exporter"]
|
||||
B -->|"HTTP scrape"| C["Prometheus"]
|
||||
C -->|"查询 + 告警规则"| D["Grafana"]
|
||||
C -->|"Alertmanager"| E["告警通道\n钉钉/飞书/PagerDuty"]
|
||||
D -->|"Dashboard"| F["运维 & 开发"]
|
||||
|
||||
style A fill:#f9d,stroke:#333
|
||||
style C fill:#bee,stroke:#333
|
||||
style D fill:#ddf,stroke:#333
|
||||
style E fill:#fdb,stroke:#333
|
||||
```
|
||||
|
||||
理解了这个架构,你就能定位任何一个环节出问题时该检查哪里。
|
||||
|
||||
## 核心指标速查
|
||||
|
||||
### 吞吐量类
|
||||
|
||||
| 指标 | SHOW STATUS 名称 | 健康范围 | 说明 |
|
||||
|------|-----------------|---------|------|
|
||||
| **QPS** | `Queries` | 视硬件而定 | 包含内部重写(如 INSERT → multiple row),非客户端请求数 |
|
||||
| **TPS** | `Com_commit` + `Com_rollback` | 视硬件而定 | 只统计 InnoDB 事务型操作 |
|
||||
| **Threads Connected** | `Threads_connected` | < MaxConnections × 0.8 | 当前已建立的连接总数 |
|
||||
| **Threads Running** | `Threads_running` | < CPU 核心数 | 正在执行查询的线程数(瞬时高峰可短暂超限) |
|
||||
|
||||
> [!WARNING] QPS ≠ 客户端请求数
|
||||
> `Queries` 计数的是服务器内部处理的查询数,而非客户端发送的请求数。一条 `INSERT INTO t VALUES(1),(2),(3)` 会被计数为 1 次 Client Query,但可能被优化器展开后计为多次 Internal Queries。如果需要精确的客户端请求量,应看 `Com_select` + `Com_insert` + `Com_update` + `Com_delete` 的总和。
|
||||
|
||||
```sql
|
||||
-- 计算实时 QPS 和 TPS
|
||||
SHOW GLOBAL STATUS LIKE 'Queries'; -- 累计查询数
|
||||
SHOW GLOBAL STATUS LIKE 'Com_commit'; -- 累计 commit 数
|
||||
SHOW GLOBAL STATUS LIKE 'Com_rollback'; -- 累计 rollback 数
|
||||
|
||||
-- QPS = Queries / Uptime
|
||||
-- TPS = (Com_commit + Com_rollback) / Uptime
|
||||
```
|
||||
|
||||
```go
|
||||
// Go 中采集指标的推荐方式:直接查询并返回 map
|
||||
func collectStatus(db *sql.DB) (map[string]string, error) {
|
||||
rows, err := db.Query("SHOW GLOBAL STATUS")
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer rows.Close()
|
||||
|
||||
metrics := make(map[string]string)
|
||||
for rows.Next() {
|
||||
var name, value string
|
||||
rows.Scan(&name, &value) // value 可能是数字或字符串,统一读取为 string
|
||||
metrics[name] = value
|
||||
}
|
||||
return metrics, rows.Err()
|
||||
}
|
||||
```
|
||||
|
||||
这段代码的核心思路是 **一次查询拿全部指标**,避免 N+1 次网络往返。实际生产环境中,你会在每个指标之间做差值运算得到速率,详见下方公式推导。
|
||||
|
||||
**速率计算公式:**
|
||||
|
||||
```
|
||||
ΔQPS = (Queries_now - Queries_prev) / (Timestamp_now - Timestamp_prev)
|
||||
ΔTPS = ((Com_commit_now + Com_rollback_now) - (Com_commit_prev + Com_rollback_prev)) / ΔSeconds
|
||||
```
|
||||
|
||||
Prometheus 的 `mysqld_exporter` 会自动帮你完成这个差值计算,如果用原生 SQL 采样则需自行实现。
|
||||
|
||||
### InnoDB 引擎指标
|
||||
|
||||
InnoDB 是绝大多数 MySQL 业务场景使用的存储引擎,其内部指标决定了数据库的整体健康状况。
|
||||
|
||||
| 指标 | SHOW STATUS 名称 | 健康阈值 | 说明 |
|
||||
|------|-----------------|---------|------|
|
||||
| **Buffer Pool 读请求** | `Innodb_buffer_pool_read_requests` | — | 从 Buffer Pool 中读取的逻辑请求总数 |
|
||||
| **Buffer Pool 磁盘读取** | `Innodb_buffer_pool_reads` | 命中率 ≥ 99% | 需回磁盘读取的次数(未命中) |
|
||||
| **Redo Log 写入量** | `Innodb_data_written` | 突增意味着写入密集 | 累计写入 Redo Log 的数据字节数 |
|
||||
| **Redo Log flush 等待** | `Innodb_log_waits` | 应接近 0 | Redo Log 缓冲区满导致刷盘等待 |
|
||||
| **死锁次数** | `Innodb_deadlocks` | 不应持续增加 | 每次死锁回滚一个事务 |
|
||||
| **平均行锁等待** | `Innodb_row_lock_time_avg` | < 10ms | 行级锁平均等待时间 |
|
||||
| **行锁总等待时间** | `Innodb_row_lock_time` | 监控趋势 | 所有行锁等待累积耗时(ms) |
|
||||
| **DML 计数** | `Innodb_rows_deleted/inserted/updated` | 监控比例变化 | 各类 DML 操作的累计次数 |
|
||||
|
||||
> [!NOTE] 如何判断 Buffer Pool 大小是否合理?
|
||||
> 只看命中率是不够的。更可靠的方法是观察 `Innodb_buffer_pool_reads` 的增长斜率:如果它几乎是一条水平线(单位时间内增长极少),说明当前 Buffer Pool 够用;如果斜率持续走高,就是增大 `innodb_buffer_pool_size` 的信号。一般建议设为物理内存的 **50%~70%**。
|
||||
|
||||
```sql
|
||||
-- Buffer Pool 命中率(推荐写法)
|
||||
SELECT
|
||||
(1 - A.value / B.value) * 100 AS hit_rate_pct
|
||||
FROM (SELECT VARIABLE_VALUE FROM performance_schema.global_status WHERE VARIABLE_NAME = 'Innodb_buffer_pool_reads') A,
|
||||
(SELECT VARIABLE_VALUE FROM performance_schema.global_status WHERE VARIABLE_NAME = 'Innodb_buffer_pool_read_requests') B;
|
||||
|
||||
-- 为什么不用 INFORMATION_SCHEMA.INNODB_METRICS?
|
||||
-- 该表需要额外启用,且部分版本中字段名不一致,推荐使用 global_status 视图
|
||||
```
|
||||
|
||||
> [!QUESTION] 思考
|
||||
> 假设你的 Buffer Pool 命中率为 99.5%,但这个值是从服务启动至今累计计算的。如果最近一小时内出现了缓存穿透,累计比率会显示出来吗?
|
||||
> **答案**:不会——累计值会稀释短期异常。正确的做法是用定时采集的 **差值** 来计算窗口内的实时命中率。
|
||||
|
||||
### 复制延迟指标
|
||||
|
||||
主从复制是 MySQL 高可用的基石。延迟超过容忍度,读写分离就会带来数据一致性问题。
|
||||
|
||||
| 指标 | SHOW REPLICA / SLAVE STATUS 字段 | 告警阈值 | 说明 |
|
||||
|------|--------------------------------|---------|------|
|
||||
| **Seconds_Behind_Master** | `Seconds_Behind_Master` | > 5s 警告,> 30s 严重 | 从库落后主库的秒数(NULL 表示连接断开) |
|
||||
| **IO Thread** | `Slave_IO_Running` (5.7) / `Replica_IO_Running` (8.0) | 必须 = Yes | IO 线程负责拉取 binlog |
|
||||
| **SQL Thread** | `Slave_SQL_Running` (5.7) / `Replica_SQL_Running` (8.0) | 必须 = Yes | SQL 线程负责重放事件 |
|
||||
| **Relay Log 空间** | `Relay_Log_Space` | 突增预警 | 中继日志积压通常意味着大事务 |
|
||||
|
||||
```sql
|
||||
-- 标准查看方式(MySQL 8.0.22+ 使用 REPLICA 关键字)
|
||||
SHOW REPLICA STATUS\G
|
||||
|
||||
-- MySQL 8.0+: 基于 GTID 的更精确延迟检测
|
||||
SELECT
|
||||
PROCESSLIST_TIME AS delay_seconds,
|
||||
INFO
|
||||
FROM performance_schema.threads t
|
||||
JOIN performance_schema.events_stages_current ess
|
||||
ON t.THREAD_ID = ess.THREAD_ID
|
||||
WHERE NAME = 'stage/replication_sql_waiting_for_master_to_send_event';
|
||||
```
|
||||
|
||||
> [!CAUTION] Seconds_Behind_Master 的陷阱
|
||||
> 当从库停止时,这个值会变成 NULL 而非一个巨大的数字。因此告警逻辑应同时检查 `Slave_IO_Running = No`、`Slave_SQL_Running = No` 和 `Seconds_Behind_Master IS NULL` 三种情况。
|
||||
|
||||
## 慢查询趋势
|
||||
|
||||
慢查询日志不只是排障工具,更是 **长期性能趋势** 的风向标。每天慢查询数量的突然增加,往往预示着即将发生的系统性问题。
|
||||
|
||||
```sql
|
||||
-- 每日慢查询统计
|
||||
SELECT DATE_FORMAT(event_time, '%Y-%m-%d') AS day,
|
||||
COUNT(*) AS slow_count,
|
||||
ROUND(AVG(query_time), 3) AS avg_time,
|
||||
ROUND(MAX(query_time), 3) AS max_time
|
||||
FROM mysql.slow_log
|
||||
GROUP BY day
|
||||
ORDER BY day DESC
|
||||
LIMIT 30;
|
||||
```
|
||||
|
||||
> [!TIP] 实操建议
|
||||
> 在生产环境不建议直接查 `mysql.slow_log`(它是表格式存储,数据量大时性能差)。推荐使用 Percona 的 **[pt-query-digest](https://www.percona.com/doc/percona-toolkit/LATEST/pt-query-digest.html)**,它能聚合相似查询并按类型统计,还能输出 HTML 报告。
|
||||
|
||||
一个实用的告警规则设计:
|
||||
|
||||
```mermaid
|
||||
flowchart LR
|
||||
A["Slow Query 阈值\nquery_time > 1s"] --> B{"单条超时 > 10s?"}
|
||||
B -->|是| C["立即告警\nP1 级别"]
|
||||
B -->|否| D{"当日累计 > 昨日 2x?"}
|
||||
D -->|是| E["趋势告警\nP2 级别"]
|
||||
D -->|否| F["正常记录"]
|
||||
|
||||
style C fill:#f99,stroke:#c00
|
||||
style E fill:#fd9,stroke:#ca0
|
||||
style F fill:#9f9,stroke:#0a0
|
||||
```
|
||||
|
||||
这种分级告警能帮你区分「偶发异常」和「系统性恶化」,避免告警疲劳。
|
||||
|
||||
## 告警分级策略
|
||||
|
||||
好的监控应该告诉你 **现在该做什么**,而不是让你从一堆告警里自己判断。
|
||||
|
||||
| 级别 | 触发条件示例 | 响应时效 | 通知渠道 |
|
||||
|------|-------------|---------|---------|
|
||||
| **P0 致命** | Slave_SQL_Running = No、磁盘空间 < 5%、Innodb_deadlocks > 0/min | 5 分钟内 | 电话 + 短信 + IM |
|
||||
| **P1 严重** | 延迟 > 30s、Threads_running > CPU×2、QPS 骤降 50%+ | 15 分钟内 | 短信 + IM |
|
||||
| **P2 警告** | 延迟 > 5s、命中率 < 95%、慢查询激增 | 1 小时内 | IM 群 |
|
||||
| **P3 提示** | Buffer Pool 利用率持续下降、表空间增长过快 | 当天处理 | 日报/周报 |
|
||||
|
||||
> [!QUESTION] 你的 SLA 是什么?
|
||||
> 不同的业务对可用性的要求差异巨大:金融系统可能要求 99.999%,而内部工具 99.9% 就足够了。**先明确业务目标,再反过来设计告警阈值**。不要盲目套用别人的配置。
|
||||
|
||||
## 监控工具选型
|
||||
|
||||
| 工具 | 定位 | 优点 | 缺点 |
|
||||
|------|------|------|------|
|
||||
| **Prometheus + mysqld_exporter** | 指标采集 | 云原生标准、告警丰富 | 需自建 Grafana Dashboard |
|
||||
| **PMM (Percona)** | 完整监控平台 | 开箱即用、包含 QAN 分析 | 较重,占用资源 |
|
||||
| **Zabbix** | 传统监控 | 成熟稳定、报警灵活 | 缺少查询级洞察 |
|
||||
| **Datadog/NewRelic** | SaaS 监控 | 零运维、可视化优秀 | 付费、数据出网 |
|
||||
| **阿里云 RDS 监控** | 云服务内置 | 免费、深度集成 | 锁定云厂商 |
|
||||
|
||||
### Prometheus + mysqld_exporter 快速搭建
|
||||
|
||||
```yaml
|
||||
# prometheus.yml 追加
|
||||
scrape_configs:
|
||||
- job_name: 'mysql'
|
||||
static_configs:
|
||||
- targets: ['localhost:9104'] # mysqld_exporter 端口
|
||||
```
|
||||
|
||||
```bash
|
||||
# 启动 exporter(MySQL 5.7)
|
||||
mysqld_exporter \
|
||||
--config.my-cnf=/etc/mysql/debian.cnf \
|
||||
--collect.global_status \
|
||||
--collect.engine_innodb_status \
|
||||
--collect.perf_schema.tableiowaits
|
||||
|
||||
# MySQL 8.0 推荐额外开启 perf_schema 指标
|
||||
mysqld_exporter \
|
||||
--config.my-cnf=/etc/mysql/debian.cnf \
|
||||
--collect.global_status \
|
||||
--collect.info_schema.innodb_metrics \
|
||||
--collect.perf_schema.eventswaitssummary
|
||||
```
|
||||
|
||||
> [!NOTE] mysqld_exporter 权限
|
||||
> exporter 需要专门的只读账号,至少授予 `REPLICATION CLIENT`, `PROCESS`, `SUPER` 权限即可。切勿用 root 账号运行。
|
||||
|
||||
## Grafana 关键 Dashboard
|
||||
|
||||
以下面板组合作为基线监控,可根据自身需求增减:
|
||||
|
||||
```
|
||||
1. Overview: QPS, TPS, Connections, Slow Queries
|
||||
2. InnoDB Buffer Pool: Hit Rate, Dirty Pages, Free Pages
|
||||
3. Replication: Seconds Behind Master, IO/SQL Thread Lag
|
||||
4. Disk I/O: Read/Write Latency, Throughput
|
||||
5. Network: Bytes Sent/Received per Second
|
||||
6. Memory: Used vs Available
|
||||
```
|
||||
|
||||
社区推荐的 Dashboard ID(导入到 Grafana 即可):
|
||||
|
||||
| 主题 | Dashboard ID | 作者 |
|
||||
|------|-------------|------|
|
||||
| MySQL Overview | [3131](https://grafana.com/grafana/dashboards/3131-mysql-database/) | Google |
|
||||
| Percona MySQL | [9967](https://grafana.com/grafana/dashboards/9967-percona-server-mysql-dashboard/) | Percona |
|
||||
| mysqld_exporter | [12234](https://grafana.com/grafana/dashboards/12234-mysqld-exporter-overview/) | Prometheus Community |
|
||||
|
||||
## 关联笔记
|
||||
|
||||
- [[hhs/MySQL/03-索引与查询优化/16-慢查询日志分析]] — pt-query-digest 配合监控系统
|
||||
- [[hhs/Redis/09-高级特性]] — Redis 监控指标的对比
|
||||
- [[hhs/GORM/16-日志与调试]] — GORM 级别的慢查询追踪
|
||||
@@ -0,0 +1,362 @@
|
||||
---
|
||||
tags: [MySQL, 安全加固, SSL/TLS, SQL Injection, ACL]
|
||||
create time: 2026-05-16 00:00
|
||||
---
|
||||
|
||||
# 安全加固
|
||||
|
||||
## 概述
|
||||
|
||||
数据库安全涉及访问控制、传输加密、数据保护和防御攻击等多个层面。MySQL 内置了丰富的安全机制,但默认配置往往不够严格。本节梳理生产环境必须做的安全加固措施。
|
||||
|
||||
## 最小权限原则
|
||||
|
||||
> [!QUESTION] 思考
|
||||
> 为什么应用账号不应该拥有 `DROP` 或 `ALTER` 权限?如果业务确实需要建表,应该怎么设计?
|
||||
|
||||
```sql
|
||||
-- ❌ 最差实践:应用直接用 root 连接(常见于开发环境)
|
||||
GRANT ALL PRIVILEGES ON *.* TO 'app_user'@'%';
|
||||
|
||||
-- ✅ 正确做法:最小权限 + 限制来源 IP
|
||||
CREATE USER 'app_user'@'10.0.1.%' IDENTIFIED BY 'strong_password_here';
|
||||
GRANT SELECT, INSERT, UPDATE, DELETE ON app_db.* TO 'app_user'@'10.0.1.%';
|
||||
-- ⚠️ 不给 CREATE、DROP、ALTER、FILE、PROCESS、SUPER 等管理权限
|
||||
FLUSH PRIVILEGES;
|
||||
```
|
||||
|
||||
### MySQL 角色系统(8.0+)
|
||||
|
||||
> [!TIP] 角色 vs 直接授权
|
||||
> 当用户数量增多时,逐个给用户赋权难以维护。角色可以理解为"权限模板",给角色授权后再把角色分配给用户。
|
||||
|
||||
```sql
|
||||
-- 创建角色(权限模板)
|
||||
CREATE ROLE 'app_reader', 'app_writer', 'app_admin';
|
||||
|
||||
-- 按角色分配权限
|
||||
GRANT SELECT ON app_db.* TO 'app_reader';
|
||||
GRANT SELECT, INSERT, UPDATE, DELETE ON app_db.* TO 'app_writer';
|
||||
GRANT ALL ON app_db.* TO 'app_admin';
|
||||
|
||||
-- 给用户分配角色
|
||||
GRANT 'app_reader', 'app_writer' TO 'app_user'@'%';
|
||||
SET DEFAULT ROLE ALL TO 'app_user'@'%'; -- 登录后自动激活这些角色
|
||||
```
|
||||
|
||||
> [!NOTE] 高危权限清单(绝对不可滥用)
|
||||
> - **FILE**: 可以读取服务器上的任意文件(`LOAD DATA INFILE`)→ 可读取 `/etc/shadow`
|
||||
> - **PROCESS**: 可以看到所有用户的 running query → 泄露业务逻辑和敏感数据
|
||||
> - **SUPER**: 可以修改全局配置(包括关闭 SSL 要求)→ 可能绕过多层安全防御
|
||||
> - **SHUTDOWN**: 可以直接关闭数据库 → 造成停机
|
||||
> - ** grant option**: 可以将自己拥有的权限授予他人 → 权限扩散
|
||||
>
|
||||
> 应用账号绝对不应该拥有以上任何权限。
|
||||
|
||||
## 账户初始化与清理
|
||||
|
||||
> [!QUESTION] 你以为干净的数据库,真的干净吗?
|
||||
> MySQL 初始化后会默认创建一些不安全的账户和数据库,很多团队忽略了这一步。
|
||||
|
||||
```sql
|
||||
-- 初始化后必须执行的操作:
|
||||
DROP DATABASE IF EXISTS test; -- 默认的 test 库任何人都可以访问
|
||||
DROP USER ''@'localhost'; -- 删除匿名账户(无密码即可登录)
|
||||
DROP USER 'root'@'%' ; -- 删除 root 的远程访问权限
|
||||
|
||||
-- 检查残留账户
|
||||
SELECT user, host, plugin FROM mysql.user WHERE user = '';
|
||||
|
||||
-- 确保 root 只能从本地登录
|
||||
RENAME USER 'root'@'%' TO 'root'@'localhost';
|
||||
```
|
||||
|
||||
## 密码安全
|
||||
|
||||
> [!QUESTION] 你的密码策略能挡住暴力破解吗?
|
||||
> `123456`、`password`、`admin123` 这类弱口令是黑客的首选攻击目标。
|
||||
|
||||
```sql
|
||||
-- 启用密码强度验证插件
|
||||
INSTALL COMPONENT 'file://component_validate_password';
|
||||
|
||||
-- 配置密码策略
|
||||
SET GLOBAL validate_password.policy = MEDIUM; -- LOW/MEDIUM/STRONG
|
||||
SET GLOBAL validate_password.length = 12; -- 最小长度建议 ≥ 16
|
||||
SET GLOBAL validate_password.mixed_case_count = 1; -- 大小写混合
|
||||
SET GLOBAL validate_password.number_count = 1; -- 数字要求
|
||||
SET GLOBAL validate_password.special_char_count = 1; -- 特殊字符
|
||||
|
||||
-- 强制现有用户使用强密码并定期轮换
|
||||
ALTER USER 'app_user'@'%' PASSWORD EXPIRE INTERVAL 90 DAY;
|
||||
-- 90 天强制更换密码
|
||||
|
||||
-- 服务账号可以关闭过期(避免定时任务因密码过期而失败)
|
||||
ALTER USER 'app_user'@'%' PASSWORD EXPIRE NEVER;
|
||||
```
|
||||
|
||||
## SQL 注入防护
|
||||
|
||||
> [!WARNING] 核心原则
|
||||
> SQL 注入的本质是**将用户输入当作 SQL 代码来执行**。参数化查询通过驱动层发送参数,服务端将其视为纯数据而非可执行语句。
|
||||
|
||||
```mermaid
|
||||
flowchart LR
|
||||
A["恶意输入: admin' OR '1'='1"] --> B{是否参数化?}
|
||||
B -->|❌ 字符串拼接| C["最终 SQL: SELECT * FROM users WHERE name = 'admin' OR '1'='1'"]
|
||||
C --> D["⚠️ 返回所有记录"]
|
||||
B -->|✅ 占位符 ?| E["driver 发送参数作为数据"]
|
||||
E --> F["最终 SQL: SELECT * FROM users WHERE name = ? \n(参数值: admin' OR '1'='1")"]
|
||||
F --> G["✅ 找不到匹配,返回空"]
|
||||
|
||||
style D fill:#FF6B6B,color:#fff
|
||||
style G fill:#00D866,color:#fff
|
||||
```
|
||||
|
||||
### 参数化查询(唯一有效的手段)
|
||||
|
||||
```go
|
||||
// ❌ 危险拼接(SQL 注入漏洞)
|
||||
query := fmt.Sprintf("SELECT * FROM users WHERE username = '%s'", userInput)
|
||||
db.Query(query)
|
||||
|
||||
// ✅ 正确:使用占位符(Driver 层处理转义)
|
||||
db.Query("SELECT * FROM users WHERE username = ?", userInput)
|
||||
|
||||
// ✅ 正确:GORM Prepared Statement(预编译,性能更好)
|
||||
db.Where("username = ?", userInput).First(&user)
|
||||
```
|
||||
|
||||
```java
|
||||
// Java PreparedStatement(预编译,同一语句可多次执行不同参数)
|
||||
PreparedStatement ps = conn.prepareStatement("SELECT * FROM users WHERE id = ?");
|
||||
ps.setInt(1, userId);
|
||||
ResultSet rs = ps.executeQuery();
|
||||
```
|
||||
|
||||
### 不能参数化的场景及替代方案
|
||||
|
||||
> [!IMPORTANT] 参数化只能处理**数据值**,不能处理标识符(表名、列名、排序方向)。
|
||||
|
||||
```sql
|
||||
-- 场景:动态表名(LIKE 'prefix_%')
|
||||
-- ❌ 不能用参数化,因为参数不能用于标识符
|
||||
SELECT * FROM ? WHERE id = 1; -- 语法错误
|
||||
|
||||
-- ✅ 白名单校验 + 反引号包裹
|
||||
allowedTables := map[string]bool{
|
||||
"users": true, "orders": true, "products": true,
|
||||
}
|
||||
if !allowedTables[tableName] {
|
||||
return errors.New("invalid table name")
|
||||
}
|
||||
query := fmt.Sprintf("SELECT * FROM `%s` WHERE id = ?", tableName)
|
||||
db.Query(query, id)
|
||||
|
||||
-- ✅ 动态 ORDER BY 使用白名单
|
||||
allowedColumns := map[string]bool{"created_at": true, "name": true, "id": true}
|
||||
col := r.URL.Query().Get("order_by")
|
||||
if !allowedColumns[col] {
|
||||
col = "created_at" // 默认排序
|
||||
}
|
||||
// 再配合 ASC/DESC 白名单
|
||||
direction := "ASC"
|
||||
if dir := r.URL.Query().Get("direction"); dir == "DESC" {
|
||||
direction = dir
|
||||
}
|
||||
query := fmt.Sprintf("SELECT * FROM users ORDER BY `%s` %s", col, direction)
|
||||
```
|
||||
|
||||
## 传输加密
|
||||
|
||||
### SSL/TLS 配置
|
||||
|
||||
> [!TIP] 为什么需要 TLS?
|
||||
> 内网通信不等于安全。同一台交换机下可以用 tcpdump 抓包,中间人攻击(MITM)在容器化环境中也完全可行。
|
||||
|
||||
```bash
|
||||
# 生成 CA + 服务端证书
|
||||
mysql_ssl_rsa_setup --datadir=/var/lib/mysql/mysql-ssl
|
||||
|
||||
# my.cnf 配置服务端 SSL
|
||||
[mysqld]
|
||||
require_secure_transport = ON # 强制所有连接使用 SSL(MySQL 8.0.12+)
|
||||
ssl-ca = /var/lib/mysql/mysql-ssl/ca.pem
|
||||
ssl-cert = /var/lib/mysql/mysql-ssl/server-cert.pem
|
||||
ssl-key = /var/lib/mysql/mysql-ssl/server-key.pem
|
||||
|
||||
# 可选:禁止不安全的 LOCAL_INFILE(防止通过 LOAD DATA LOCAL 读客户端文件)
|
||||
local-infile = 0
|
||||
```
|
||||
|
||||
```go
|
||||
// Go 驱动 SSL 配置(生产环境必须验证证书)
|
||||
import (
|
||||
"crypto/tls"
|
||||
_ "github.com/go-sql-driver/mysql"
|
||||
)
|
||||
|
||||
tlsConfig := &tls.Config{
|
||||
MinVersion: tls.VersionTLS12, // 不支持 TLS 1.0/1.1
|
||||
InsecureSkipVerify: false, // ⚠️ 测试环境可用 true,生产必须 false
|
||||
ClientAuth: tls.RequireAndVerifyClientCert, // mTLS:双向认证(可选)
|
||||
}
|
||||
mysql.RegisterTLSConfig("custom", tlsConfig)
|
||||
|
||||
dsn := "user:pass@tcp(host:3306)/db?tls=custom&parseTime=True"
|
||||
db, _ := sql.Open("mysql", dsn)
|
||||
```
|
||||
|
||||
> [!CHECK] 验证 TLS 是否生效
|
||||
> ```sql
|
||||
> SHOW SESSION STATUS LIKE 'Ssl_cipher';
|
||||
> -- 如果返回空,说明当前连接未使用加密
|
||||
>
|
||||
> SHOW VARIABLES LIKE '%ssl%';
|
||||
> -- Require_SSL 应为 YES
|
||||
> ```
|
||||
|
||||
### 身份认证插件
|
||||
|
||||
> [!NOTE] caching_sha2_password vs mysql_native_password
|
||||
> MySQL 8.0 默认使用 `caching_sha2_password`,它支持 SHA-256 哈希和密码缓存机制(性能更好)。但部分老旧驱动(如 Python MySQLdb < 2.1.0)只支持 `mysql_native_password`。
|
||||
|
||||
```sql
|
||||
-- MySQL 8.0 默认使用 caching_sha2_password(更安全)
|
||||
-- legacy 客户端可能需要切换回 mysql_native_password
|
||||
ALTER USER 'app_user'@'%' IDENTIFIED WITH mysql_native_password BY 'password';
|
||||
|
||||
-- 检查当前使用的认证插件
|
||||
SELECT user, host, plugin, authentication_string
|
||||
FROM mysql.user;
|
||||
|
||||
-- 统一切换为更安全的方式(推荐)
|
||||
ALTER USER 'app_user'@'%' IDENTIFIED WITH caching_sha2_password BY 'new_strong_password';
|
||||
```
|
||||
|
||||
## 信息与版本泄露
|
||||
|
||||
> [!QUESTION] 你在帮敌人认识你?
|
||||
> 数据库版本号让攻击者知道该用哪些 CVE 漏洞;主机名可能暴露部署架构。
|
||||
|
||||
```sql
|
||||
-- ❌ 默认暴露了大量信息
|
||||
SELECT VERSION(), @@hostname, @@basedir;
|
||||
|
||||
-- 方法1: 使用反向代理隐藏真实端口
|
||||
-- nginx 配置:将 /db 路径转发到 MySQL,应用通过 HTTP 走 ProxyProtocol
|
||||
|
||||
-- 方法2: 自定义 error_log 输出格式
|
||||
[mysqld]
|
||||
log_error_verbosity = 2 # 减少详细错误信息输出
|
||||
```
|
||||
|
||||
## 审计日志
|
||||
|
||||
> [!IMPORTANT] 通用日志 vs 专用审计
|
||||
> `general_log` 记录一切(包括普通查询),性能开销巨大。**仅用于临时排查**,不适合长期开启。生产审计推荐使用专用审计插件。
|
||||
|
||||
```sql
|
||||
-- MySQL Enterprise Audit Plugin(商业版)
|
||||
-- 社区版可以用 general_log 作为短期替代方案
|
||||
|
||||
-- ⚠️ 开启通用日志(性能开销较大,仅用于临时审计)
|
||||
SET GLOBAL general_log = 'ON';
|
||||
SET GLOBAL general_log_file = '/var/log/mysql/general.log';
|
||||
SET GLOBAL general_log = 'OFF'; -- 查完后立即关闭
|
||||
|
||||
-- slow_query_log 长期开启(记录慢查询,便于发现异常高频查询)
|
||||
SET GLOBAL slow_query_log = 'ON';
|
||||
SET GLOBAL long_query_time = 2; -- 超过 2 秒的记录
|
||||
SET GLOBAL log_queries_not_using_indexes = 'ON';
|
||||
```
|
||||
|
||||
### 社区审计方案(DDL/DML 追踪)
|
||||
|
||||
```sql
|
||||
-- 记录所有 DDL 操作到自定义审计表
|
||||
CREATE TABLE ddl_audit (
|
||||
id BIGINT AUTO_INCREMENT PRIMARY KEY,
|
||||
event_time DATETIME DEFAULT CURRENT_TIMESTAMP,
|
||||
user_host VARCHAR(255),
|
||||
ddl_statement TEXT
|
||||
);
|
||||
|
||||
-- DML 行级变更追踪(触发器方式)
|
||||
DELIMITER //
|
||||
CREATE TRIGGER audit_users_insert_after
|
||||
AFTER INSERT ON users
|
||||
FOR EACH ROW
|
||||
BEGIN
|
||||
INSERT INTO dml_audit (action, table_name, row_id, changed_at)
|
||||
VALUES ('INSERT', 'users', NEW.id, NOW());
|
||||
END//
|
||||
|
||||
CREATE TRIGGER audit_users_update_after
|
||||
AFTER UPDATE ON users
|
||||
FOR EACH ROW
|
||||
BEGIN
|
||||
IF OLD.status != NEW.status THEN
|
||||
INSERT INTO dml_audit (action, table_name, row_id, old_val, new_val, changed_at)
|
||||
VALUES ('UPDATE', 'users', NEW.id, OLD.status, NEW.status, NOW());
|
||||
END IF;
|
||||
END//
|
||||
DELIMITER ;
|
||||
|
||||
-- 使用 EVENT TRIGGER(MySQL 8.0.16+)记录 DDL
|
||||
CREATE EVENT TRIGGER trg_ddl_audit
|
||||
ON OBJECT SCALAR
|
||||
EVENT DDL
|
||||
EXECUTE AS OWNER
|
||||
WHEN (CURRENT_USER() NOT IN ('root'))
|
||||
DO LOG 'DDL detected by ' || CURRENT_USER();
|
||||
```
|
||||
|
||||
> [!TIP] 开源审计替代方案
|
||||
> - **MariaDB Audit Plugin**: 免费,支持事件类型过滤
|
||||
> - **Percona Audit Log Plugin**: Percona Server 内置
|
||||
> - **MySQL Router + ProxySQL**: 通过网络代理层拦截和记录所有查询
|
||||
|
||||
## 安全加固 Checklist
|
||||
|
||||
```mermaid
|
||||
flowchart TD
|
||||
A["🔒 MySQL 安全加固"] --> B["1. 访问控制"]
|
||||
B --> B1["最小权限原则"]
|
||||
B --> B2["限制来源 IP/CIDR"]
|
||||
B --> B3["禁用匿名账户"]
|
||||
|
||||
A --> C["2. 密码安全"]
|
||||
C --> C1["validate_password 插件"]
|
||||
C --> C2["定期轮换密码"]
|
||||
C --> C3["禁用空密码账户"]
|
||||
|
||||
A --> D["3. 传输加密"]
|
||||
D --> D1["SSL/TLS 强制"]
|
||||
D --> D2["TLS 1.2+"]
|
||||
D --> D3["禁用 LOAD DATA LOCAL"]
|
||||
|
||||
A --> E["4. 审计日志"]
|
||||
E --> E1["slow_query_log 常开"]
|
||||
E --> E2["general_log 按需开关"]
|
||||
E --> E3["binlog 备份保护"]
|
||||
|
||||
A --> F["5. 信息管控"]
|
||||
F --> F1["隐藏版本/主机名"]
|
||||
F --> F2["custom error messages"]
|
||||
F --> F3["关闭 performance_schema 敏感暴露"]
|
||||
|
||||
A --> G["6. OS & 网络"]
|
||||
G --> G1["mysql 目录 chmod 700"]
|
||||
G --> G2["删除 test 库 + 示例数据"]
|
||||
G --> G3["防火墙限制 3306 端口"]
|
||||
|
||||
style B1 fill:#00D866,color:#fff
|
||||
style D1 fill:#00B6BC,color:#fff
|
||||
style F1 fill:#FFB800,color:#fff
|
||||
```
|
||||
|
||||
## 关联笔记
|
||||
|
||||
- [[hhs/GORM/01-安装与初始化]] — GORM 连接参数中的安全配置
|
||||
- [[hhs/DEV/Go-Database]] — Go 驱动的安全连接配置
|
||||
@@ -0,0 +1,321 @@
|
||||
---
|
||||
tags: [MySQL, 踩坑, FileSort, 隐式转换, TIMESTAMP, DATETIME, AUTO_INCREMENT, FULLTEXT, 幻读, LEFT JOIN]
|
||||
create time: 2026-05-16 00:30
|
||||
---
|
||||
|
||||
# 常见踩坑
|
||||
|
||||
## 概述
|
||||
|
||||
MySQL 的设计有许多「反直觉」的行为。本章汇总最常见的陷阱和误区,帮助你在编码阶段就规避这些问题。
|
||||
|
||||
## 1. ORDER BY 产生 FileSort
|
||||
|
||||
```sql
|
||||
-- ❌ 看似有索引,但实际上触发了 FileSort
|
||||
CREATE INDEX idx_status ON orders(status);
|
||||
|
||||
EXPLAIN SELECT * FROM orders
|
||||
WHERE status = 'pending'
|
||||
ORDER BY created_at DESC;
|
||||
-- Extra: Using where; Using filesort
|
||||
|
||||
-- 为什么?idx_status 只按 status 排序,created_at 是无序的
|
||||
-- 虽然 WHERE 能用索引过滤,但 ORDER BY 无法利用该索引
|
||||
|
||||
-- ✅ 解决方法:创建联合索引
|
||||
ALTER TABLE orders ADD INDEX idx_status_created (status, created_at);
|
||||
|
||||
-- 现在的 EXPLAIN:
|
||||
-- key: idx_status_created
|
||||
-- Extra: Using where ← No filesort! 因为索引里已经是有序的
|
||||
```
|
||||
|
||||
```mermaid
|
||||
flowchart LR
|
||||
IDX["联合索引 idx(status, created_at)"]
|
||||
|
||||
IDX -->|"WHERE status=? ORDER BY created_at"| PERF["✅ 完全利用索引顺序"]
|
||||
IDX -->|"WHERE status=? LIMIT 100"| PERF
|
||||
|
||||
single_idx["单列索引 idx(status)"]
|
||||
single_idx -->|"WHERE status=? ORDER BY created_at"| SORT["❌ Filesort<br/>内存排序"]
|
||||
|
||||
style PERF fill:#00D866,color:#fff
|
||||
style SORT fill:#EE5A24,color:#fff
|
||||
```
|
||||
|
||||
> [!TIP] 教学要点
|
||||
> **问题**:为什么已有索引还不够?
|
||||
> **回答**:B+树索引只能保证第一列有序。当 WHERE 固定了第一列的值后,剩余行的第二列仍然保持插入顺序,而非目标字段的排序顺序。
|
||||
|
||||
## 2. COUNT(*) vs COUNT(1) vs COUNT(column)
|
||||
|
||||
```sql
|
||||
-- 三者语义不同,但在 InnoDB 中 COUNT(*) 最快
|
||||
SELECT COUNT(*) FROM users; -- 统计行数(忽略 NULL)
|
||||
SELECT COUNT(1) FROM users; -- 同 COUNT(*)(InnoDB 优化相同)
|
||||
SELECT COUNT(email) FROM users; -- 统计 email 非 NULL 的行数
|
||||
SELECT COUNT(DISTINCT city) FROM users; -- 去重计数
|
||||
|
||||
-- InnoDB 优化细节
|
||||
-- COUNT(*) 直接扫描聚簇索引的最左叶子页开始计数(O(N))
|
||||
-- COUNT(column) 除了扫描还需要检查是否为 NULL → 稍慢
|
||||
-- COUNT(DISTINCT) 需要额外排序/去重 → 最慢
|
||||
```
|
||||
|
||||
```mermaid
|
||||
graph TB
|
||||
subgraph "性能对比 (由快到慢)"
|
||||
A["COUNT(*)<br/>只数行数"] -->|快 10\%~30\%| B["COUNT(1)<br/>优化器等价"]
|
||||
B -->|稍慢| C["COUNT(col)<br/>需判 NULL"]
|
||||
C -->|最慢| D["COUNT(DISTINCT col)<br/>需排序去重"]
|
||||
end
|
||||
|
||||
style A fill:#00D866,color:#fff
|
||||
style B fill:#7BED9F,color:#000
|
||||
style C fill:#FFA500,color:#fff
|
||||
style D fill:#EE5A24,color:#fff
|
||||
```
|
||||
|
||||
> [!TIP] 结论
|
||||
> - 如果你只是想统计行数 → 用 `COUNT(*)`
|
||||
> - `COUNT(1)` 和 `COUNT(*)` 在 InnoDB 中是完全等价的(优化器视为同一计划)
|
||||
> - 不要在 COUNT 中放列名除非你真的要排除 NULL 值
|
||||
>
|
||||
> **问题思考**:为什么 `COUNT(*)` 不需要逐行判断字段值?因为 InnoDB 内部维护了行计数器,直接从聚簇索引叶子节点遍历即可。
|
||||
|
||||
## 3. TIMESTAMP 自动更新陷阱
|
||||
|
||||
```sql
|
||||
CREATE TABLE events (
|
||||
id BIGINT PRIMARY KEY,
|
||||
name VARCHAR(100),
|
||||
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
|
||||
updated_at DATETIME DEFAULT CURRENT_TIMESTAMP
|
||||
ON UPDATE CURRENT_TIMESTAMP -- ← 这里!
|
||||
);
|
||||
|
||||
INSERT INTO events (name) VALUES ('Meeting');
|
||||
-- created_at = 2026-05-16 10:00:00
|
||||
-- updated_at = 2026-05-16 10:00:00
|
||||
|
||||
-- 即使只修改 name,updated_at 也会自动更新
|
||||
UPDATE events SET name = 'New Meeting' WHERE id = 1;
|
||||
-- updated_at = 2026-05-16 10:05:00 ← 自动更新了!
|
||||
|
||||
-- 但如果修改的是 created_at 呢?
|
||||
UPDATE events SET created_at = '2026-01-01 00:00:00' WHERE id = 1;
|
||||
-- updated_at 同样会自动更新为当前时间!
|
||||
-- 这就是陷阱——修改任何字段都会触发 ON UPDATE
|
||||
```
|
||||
|
||||
> [!WARNING] 独立创建时间和更新时间
|
||||
> ```sql
|
||||
> -- 推荐模式:created_at 不使用 ON UPDATE,updated_at 用 ON UPDATE
|
||||
> CREATE TABLE good_events (
|
||||
> id BIGINT PRIMARY KEY,
|
||||
> name VARCHAR(100),
|
||||
> created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, -- 不受 ON UPDATE 影响
|
||||
> updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP
|
||||
> );
|
||||
> ```
|
||||
|
||||
> [!TIP] 为什么修改 created_at 也会触发 ON UPDATE?
|
||||
> `ON UPDATE CURRENT_TIMESTAMP` 作用于**整行**而非特定列。任何字段的更新都会让 `updated_at` 刷新为当前时间。这不是 bug,是 MySQL 的设计语义——"此行被修改了"。
|
||||
|
||||
## 4. VARCHAR 长度计算
|
||||
|
||||
```sql
|
||||
-- utf8mb4 下,VARCHAR(n) 的最大字节数 = n × 4
|
||||
-- MySQL 一行最大 65535 字节
|
||||
|
||||
-- ❌ 看起来合理,实际超限
|
||||
CREATE TABLE bad_table (
|
||||
col1 VARCHAR(16383), -- 16383 × 4 = 65532 bytes + 2 bytes length prefix = 65534 ✓
|
||||
col2 CHAR(1) -- 1 × 4 = 4 bytes + overhead = ❌ 超出 65535 行限制
|
||||
);
|
||||
-- ERROR 1118: Row size too large
|
||||
|
||||
-- ✅ 解决方案
|
||||
CREATE TABLE good_table (
|
||||
col1 VARCHAR(16382), -- 留余量
|
||||
col2 LONGTEXT -- 大文本走外存
|
||||
);
|
||||
```
|
||||
|
||||
> [!WARNING] 关于 65535 的常见误区
|
||||
> - 65535 是**行级别**的硬限制(不含 TEXT/BLOB 等外部存储字段)
|
||||
> - `VARCHAR(n)` 需要 **1~2 字节**的长度前缀来记录实际数据长度
|
||||
> - `innodb_large_prefix=ON` 时可突破到约 8KB(配合 DYNAMIC 页格式),但跨引擎兼容性差,不建议依赖
|
||||
>
|
||||
> **问题思考**:为什么 TEXT/BLOB 不计入行大小限制?因为它们的数据存储在溢出页(overflow pages)中,行内只保留一个 20 字节的指针。
|
||||
|
||||
## 5. LEFT JOIN 条件位置
|
||||
|
||||
```sql
|
||||
-- ❌ 把 LEFT JOIN 的过滤条件放在 WHERE,等效于 INNER JOIN
|
||||
SELECT u.name, o.amount
|
||||
FROM users u LEFT JOIN orders o ON u.id = o.user_id
|
||||
WHERE o.status = 'paid';
|
||||
-- WHERE o.status 会把 LEFT JOIN 的结果过滤掉不匹配的行 → 变成 INNER JOIN
|
||||
|
||||
-- ✅ 正确:把条件放在 ON 子句
|
||||
SELECT u.name, COALESCE(SUM(CASE WHEN o.status = 'paid' THEN o.amount ELSE 0 END), 0)
|
||||
FROM users u
|
||||
LEFT JOIN orders o ON u.id = o.user_id AND o.status = 'paid';
|
||||
|
||||
-- ✅ 或者:过滤右表应该在 WHERE 中显式检查 NULL
|
||||
SELECT u.name, o.amount
|
||||
FROM users u LEFT JOIN orders o ON u.id = o.user_id
|
||||
WHERE o.status = 'paid' OR o.id IS NULL;
|
||||
```
|
||||
|
||||
> [!TIP] LEFT JOIN vs INNER JOIN 的快速判断
|
||||
> **口诀**:左表的过滤条件放 WHERE(不影响左表完整性),右表的过滤条件放 ON(保留左表全量)。
|
||||
>
|
||||
> **问题思考**:如果 WHERE 中的列来自两张表(`WHERE u.city = 'Beijing' AND o.status = 'paid'`)会怎样?
|
||||
> **回答**:此时 WHERE 先执行 JOIN,再对结果集做二次过滤。这仍然是 LEFT JOIN,只是部分数据被 WHERE 筛掉了——它不会退化为 INNER JOIN。
|
||||
|
||||
## 6. 隐式类型转换导致索引失效
|
||||
|
||||
```sql
|
||||
-- phone 是 VARCHAR(20)
|
||||
ALTER TABLE users ADD INDEX idx_phone (phone);
|
||||
|
||||
-- ❌ 传入了 INT,MySQL 自动把 phone 转成 INT 再比较
|
||||
SELECT * FROM users WHERE phone = 13800138000;
|
||||
-- EXPLAIN: type=ALL, Rows=1000000 → 索引失效
|
||||
|
||||
-- ✅ 始终传入字符串
|
||||
SELECT * FROM users WHERE phone = '13800138000';
|
||||
-- EXPLAIN: type=ref, key=idx_phone → 走索引
|
||||
|
||||
-- 类似陷阱
|
||||
SELECT * FROM users WHERE id = '12345';
|
||||
-- varchar 列传 int 同理,MySQL 会做隐式转换
|
||||
```
|
||||
|
||||
> [!WARNING] 隐式转换的核心规则
|
||||
> - MySQL 的转换优先级:**INT > VARCHAR**。当列类型为 VARCHAR、值为 INT 时,MySQL 会把**每一行的 VARCHAR 值转为 INT**再比较 → 索引失效。
|
||||
> - 反之(列是 INT、传入 VARCHAR),MySQL 会将传入值转为 INT → **索引依然生效**。
|
||||
> - **结论**:永远让参数类型与列类型严格一致。这是后端 ORM 框架最容易忽略的点。
|
||||
|
||||
## 7. datetime 的精度与范围
|
||||
|
||||
```sql
|
||||
-- MySQL 8.0 默认 datetime(0) — 不带小数秒
|
||||
CREATE TABLE t1 (dt DATETIME);
|
||||
INSERT INTO t1 VALUES ('2026-05-16 10:30:45.123456');
|
||||
SELECT dt FROM t1;
|
||||
-- 输出: 2026-05-16 10:30:45 ← 微秒丢失!
|
||||
|
||||
-- ✅ 如果需要保留精度
|
||||
CREATE TABLE t2 (dt DATETIME(6));
|
||||
INSERT INTO t2 VALUES ('2026-05-16 10:30:45.123456');
|
||||
SELECT dt FROM t2;
|
||||
-- 输出: 2026-05-16 10:30:45.123456
|
||||
```
|
||||
|
||||
```mermaid
|
||||
graph LR
|
||||
subgraph "DATETIME vs TIMESTAMP"
|
||||
A["DATETIME<br/>存储: 8 bytes<br/>范围: 1000~9999<br/>不受时区影响"] --> B["适合业务时间<br/>如订单创建时间"]
|
||||
C["TIMESTAMP<br/>存储: 4 bytes<br/>范围: 1970~2038<br/>受 server 时区影响"] --> D["适合审计字段<br/>如最后登录时间"]
|
||||
end
|
||||
|
||||
style B fill:#00D866,color:#fff
|
||||
style D fill:#FFA500,color:#fff
|
||||
```
|
||||
|
||||
> [!WARNING] TIMESTAMP 有上限问题
|
||||
> TIMESTAMP 的最大值是 `2038-01-19 03:14:07`。如果你的系统需要在 2038 年之后使用,务必改用 `DATETIME`,否则会遇到无法插入数据的诡异 bug。这也是为什么很多团队统一只用 DATETIME。
|
||||
|
||||
## 8. AUTO_INCREMENT 删除后的断号问题
|
||||
|
||||
```sql
|
||||
CREATE TABLE users (
|
||||
id BIGINT PRIMARY KEY AUTO_INCREMENT,
|
||||
name VARCHAR(50)
|
||||
);
|
||||
|
||||
INSERT INTO users (name) VALUES ('Alice'), ('Bob'), ('Charlie'), ('Dave');
|
||||
DELETE FROM users WHERE id IN (2, 3);
|
||||
-- 当前 id: 1, 4
|
||||
INSERT INTO users (name) VALUES ('Eve');
|
||||
-- Eve 的 id = 5(不会复用 2 或 3)
|
||||
```
|
||||
|
||||
> [!WARNING] 自动递增号的特性
|
||||
> - AUTO_INCREMENT **不会回收已删除的值**。这是设计使然——保证全局唯一且有序追加写友好。
|
||||
> - 如果业务需要**紧凑编号**(如内部工号),必须手动处理(如重建表或使用序列表),但**不建议在生产系统这样做**。
|
||||
> - InnoDB 的 AUTO_INCREMENT 锁在内存中(mysql.auto_increment_helper 表),即使删除所有行也不会归零(除非 TRUNCATE)。
|
||||
|
||||
## 9. LIKE 前缀通配符导致全表扫描
|
||||
|
||||
```sql
|
||||
ALTER TABLE products ADD INDEX idx_name (name);
|
||||
|
||||
-- ❌ 前缀通配符 → 索引失效
|
||||
SELECT * FROM products WHERE name LIKE '%手机%';
|
||||
-- EXPLAIN: type=ALL → 全表扫描!
|
||||
|
||||
-- ✅ 前缀固定 → 可以利用索引(最左前缀匹配)
|
||||
SELECT * FROM products WHERE name LIKE '手机%';
|
||||
-- EXPLAIN: type=range → 走索引范围扫描
|
||||
|
||||
-- ✅ 替代方案:使用全文索引
|
||||
ALTER TABLE products ADD FULLTEXT INDEX ft_name (name);
|
||||
SELECT * FROM products WHERE MATCH(name) AGAINST('手机');
|
||||
```
|
||||
|
||||
> [!TIP] LIKE 索引利用规则速查
|
||||
> | 模式 | 是否走索引 | 原因 |
|
||||
> |------|-----------|------|
|
||||
> | `'abc%'` | ✅ | 可定位起始位置 |
|
||||
> | `'%abc'` | ❌ | 未知起始位置 |
|
||||
> | `'%abc%'` | ❌ | 同上 |
|
||||
>
|
||||
> **建议**:对中文搜索需求优先使用 Elasticsearch 等专业搜索引擎,不要依赖 LIKE。
|
||||
|
||||
## 10. 可重复读(RR)下的幻读陷阱
|
||||
|
||||
```sql
|
||||
-- Session A -- Session B
|
||||
BEGIN; BEGIN;
|
||||
INSERT INTO orders ...; ← 成功!
|
||||
COMMIT; COMMIT;
|
||||
SELECT COUNT(*) FROM orders; -- 结果包含了 B 插入的行
|
||||
```
|
||||
|
||||
```mermaid
|
||||
sequenceDiagram
|
||||
participant A as Session A
|
||||
participant DB as InnoDB
|
||||
participant B as Session B
|
||||
|
||||
A->>A: BEGIN
|
||||
A->>DB: SELECT COUNT(*)
|
||||
Note over A,DB: 读到 10 行
|
||||
|
||||
B->>B: BEGIN
|
||||
B->>DB: INSERT row_11
|
||||
B->>DB: COMMIT
|
||||
|
||||
A->>DB: SELECT COUNT(*)
|
||||
Note over A,DB: RR 级别下仍读到 11 行<br/>(Read View 未刷新)
|
||||
|
||||
A->>DB: UPDATE ... WHERE id > 100
|
||||
Note over A,DB: 间隙锁发现新行<br/>→ 阻塞等待 B 的事务释放
|
||||
```
|
||||
|
||||
> [!WARNING] RR 隔离级别的幻读是「有条件」的
|
||||
> - **普通 SELECT** 不会看到其他事务的新数据吗?**不一定**。InnoDB 的快照读只读第一次创建 Read View 时的状态,但 DML(UPDATE/DELETE)会刷新视图并感知新行。
|
||||
> - `SELECT ... FOR SHARE / FOR UPDATE` 会使用 next-key lock 阻止幻读——这才是真正的「不可幻读」。
|
||||
> - MySQL 的 RR ≠ SQL 标准定义的「完全隔离幻读」。这是最容易产生误解的地方之一。
|
||||
|
||||
## 11. 关联笔记
|
||||
|
||||
- [[hhs/MySQL/03-索引与查询优化/15-EXPLAIN 完全指南]] — 通过 EXPLAIN 识别上述问题
|
||||
- [[hhs/MySQL/03-索引与查询优化/14-联合索引与最左前缀]] — 索引失效的典型原因
|
||||
- [[hhs/MySQL/03-索引与查询优化/17-查询改写技巧]] — 各种问题的改写方案
|
||||
@@ -0,0 +1,25 @@
|
||||
---
|
||||
tags: [MySQL, 工程实践, 连接池, 监控, 分库分表]
|
||||
create time: 2026-05-20 23:55
|
||||
---
|
||||
|
||||
# 八、工程实践
|
||||
|
||||
## 概述
|
||||
|
||||
部署上线后,连接池怎么配、Schema 怎么迁移、数据量大了怎么拆分、出了问题怎么排查。本章聚焦生产环境中 MySQL 的运维和工程化管理,是"从会用到能管好"的最后一公里。
|
||||
|
||||
## 本章文档
|
||||
|
||||
| # | 主题 | 说明 |
|
||||
|---|------|------|
|
||||
| 35 | [[hhs/MySQL/08-工程实践/35-Go 连接池配置]] | `sql.DB` 几个关键参数的含义和调优思路 |
|
||||
| 36 | [[hhs/MySQL/08-工程实践/36-Schema 迁移管理]] | 版本化的建表脚本、谁在用、怎么保证可回滚 |
|
||||
| 37 | [[hhs/MySQL/08-工程实践/37-分库分表]] | 数据量太大时怎么拆分、跨分片查询怎么办 |
|
||||
| 38 | [[hhs/MySQL/08-工程实践/38-监控指标]] | QPS/TPS、慢查询数、连接数——哪些数据决定数据库健康 |
|
||||
| 39 | [[hhs/MySQL/08-工程实践/39-安全加固]] | 权限管理、加密传输、SQL 注入防护 |
|
||||
| 40 | [[hhs/MySQL/08-工程实践/40-常见踩坑]] | ORDER BY 文件排序、COUNT 的区别、timestamp 自动更新 |
|
||||
|
||||
## 关联笔记
|
||||
|
||||
- [[hhs/MySQL/README]] — MySQL 知识库总目录
|
||||
Reference in New Issue
Block a user