Files
2026-05-24 11:42:38 +08:00

291 lines
10 KiB
Markdown
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
---
tags: [MySQL, DML, INSERT, UPDATE, DELETE]
create time: 2026-05-16 00:00
---
# DML — 增删改
## 概述
DML(Data Manipulation Language)是日常使用最频繁的 SQL 类别——**增、改、删**构成了应用和数据库之间的数据交互主线。
> [!QUESTION] 思考:为什么说「写」比「读」更复杂?
> SELECT 只需要找到匹配的数据,而 INSERT / UPDATE / DELETE 不仅要改变状态,还要处理**并发冲突、约束校验、外键级联、触发器副作用**。理解这些隐藏行为,才能写出既正确又高效的写入逻辑。
本节聚焦 MySQL 特有的高效写入技巧和常见陷阱。
## INSERT
### 基础插入
```sql
INSERT INTO users (username, email, status)
VALUES ('alice', 'alice@example.com', 1);
-- 批量插入(性能远高于逐条插入)
INSERT INTO users (username, email, status) VALUES
('bob', 'bob@example.com', 1),
('charlie','charlie@example.com', 0),
('david', 'david@example.com', 1);
```
> [!TIP] MySQL 8.0.19+:INSERT ... RETURNING
> 和 PostgreSQL 一样,MySQL 从 8.0.19 起支持 `RETURNING` 子句,免去二次查询:
> ```sql
> INSERT INTO users (username, email, status)
> VALUES ('eve', 'eve@example.com', 1)
> RETURNING id; -- 直接拿到自增 ID
> ```
> **典型场景**:插入后需要立即获取新记录的自增主键做级联操作。替代了老方案中 `INSERT ... ; SELECT LAST_INSERT_ID();` 两条语句的组合。
### INSERT ... ON DUPLICATE KEY UPDATE
处理"存在则更新,不存在则插入"的场景:
```sql
INSERT INTO user_stats (user_id, total_orders, total_spent)
VALUES (1001, 5, 299.50)
ON DUPLICATE KEY UPDATE
total_orders = total_orders + VALUES(total_orders),
total_spent = total_spent + VALUES(total_spent);
```
> [!TIP] 常见陷阱
> - **多唯一键冲突**:如果有多条唯一键匹配同一行,只更新一次(返回 Row Count = 2)
> - **LAST_INSERT_ID()**:INSERT 时返回新 ID;ON DUPLICATE KEY UPDATE 时自动改写为旧行的主键 ID(方便级联操作)
> - **VALUES() 废弃警告**:MySQL 8.0.20+ 对 `VALUES(col)` 发出 DEPRECATION WARNING。对于单行插入无需改动,**多行批量插入**需改用子查询或分段处理。
```mermaid
flowchart TD
A["INSERT 语句"] --> B{"主键或唯一键\n是否存在"}
B -->|不存在| C["执行 INSERT"]
B -->|已存在| D["执行 UPDATE\nON DUPLICATE KEY UPDATE 子句"]
C --> E["返回 Row Count = 1"]
D --> F["返回 Row Count = 2\n匹配行加实际更新"]
style B fill:#FF9F43,color:#000
style C fill:#00D866,color:#fff
style D fill:#00B6BC,color:#fff
```
> [!NOTE] Row Count 的含义
> - `Row Count = 1`:正常插入新记录
> - `Row Count = 2`:唯一键冲突,走了 UPDATE 路径(但 UPDATE 没有实际改变值也算 2)
> - `Row Count = 0`:唯一键冲突,且 UPDATE 后的值与原来相同
### REPLACE INTO
当唯一键冲突时,**先删除旧行再插入新行**。
```sql
REPLACE INTO users (id, username, email) VALUES (1, 'alice_new', 'new@email.com');
```
```mermaid
sequenceDiagram
participant S as Server
participant DB as Database
S->>DB: REPLACE INTO users ...
Note over S,DB: Step 1: DELETE 已有记录
S->>DB: DELETE FROM users WHERE pk = 1
Note over S,DB: Step 2: INSERT 新记录
S->>DB: INSERT INTO users ...
Note over S,DB: 如果外键引用了被删行\n会触发 ON DELETE CASCADE
```
> [!WARNING] REPLACE vs ON DUPLICATE KEY UPDATE
> - **REPLACE**:本质是 DELETE + INSERT,会导致自增 ID 变化、触发 BEFORE/AFTER DELETE 钩子、级联外键删除
> - **ON DUPLICATE KEY UPDATE**:原地更新,不影响其他字段和自增值
>
> **优先用 `ON DUPLICATE KEY UPDATE`**,除非你确实需要完整的「删+插」语义。
### INSERT ... SELECT
从另一张表或查询结果批量插入数据——这是日常开发中比 `VALUES` 批量插入更高频的用法。
```sql
-- 基础:从查询结果插入
INSERT INTO user_archive (id, username, email, archived_at)
SELECT id, username, email, NOW()
FROM users
WHERE status = 0 AND updated_at < '2025-01-01';
-- 搭配聚合:插入每日统计快照
INSERT INTO daily_stats (stat_date, order_count, total_amount)
SELECT CURDATE(), COUNT(*), SUM(amount)
FROM orders
WHERE DATE(created_at) = CURDATE();
```
> [!QUESTION] INSERT ... SELECT 会锁表吗?
> 这取决于事务隔离级别和索引情况:
> - 在 **RC(Read Committed)** 下:只锁定被插入的目标表,对源表加短暂的共享锁
> - 在 **RR(Repeatable Read)** 下:源表的读取走一致性快照,**不会阻塞源表的写入**
> - 目标表:插入的行会加行锁,大批量插入时注意不要超长事务
> [!CAUTION] INSERT ... SELECT 的两个常见坑
> 1. **SELECT 中不能引用正在被插入的目标表**(MySQL 会报 `Table is specified twice`)。解决办法:嵌套一层子查询
> ```sql
> -- ❌ 报错:直接引用目标表
> INSERT INTO orders_backup SELECT * FROM orders WHERE id IN (SELECT id FROM orders WHERE ...);
> -- ✅ 正确:用子查询隔离
> INSERT INTO orders_backup SELECT * FROM orders WHERE id IN (SELECT t.id FROM (SELECT id FROM orders WHERE ...) t);
> ```
> 2. **大批量插入时分批执行**,避免长事务持有过多锁。可以用 `LIMIT` + 循环分批:
> ```sql
> -- 每次只插入 5000 条,循环直到 affected_rows = 0
> INSERT INTO user_archive
> SELECT * FROM users WHERE status = 0 LIMIT 5000;
> ```
## UPDATE
UPDATE 的核心原则只有一条:**精准定位、最小影响**。先思考「哪些行需要改」,再写 SET 子句。
```sql
-- 基础更新:定位到单行
UPDATE users SET status = 0 WHERE id = 100;
-- 多字段同时更新
UPDATE users SET
email = 'new@email.com',
updated_at = NOW()
WHERE email = 'old@email.com' AND status = 1;
-- JOIN 批量更新(从关联表同步数据)
UPDATE articles a
JOIN categories c ON a.category_id = c.id
SET a.category_name = c.name
WHERE a.category_name IS NULL;
```
> [!QUESTION] 为什么 UPDATE 忘记带 WHERE 这么危险?
> `UPDATE users SET status = 0;` 会把所有用户的状态设为禁用。MySQL 有一个安全措施叫 `sql_safe_updates`:
> ```sql
> SET sql_safe_updates = 1;
> -- 此时不带 WHERE 或不带主键条件的 UPDATE 会被拒绝
> -- 生产环境建议在连接层开启此选项
> ```
### LIMIT 与 ORDER BY
MySQL 特有的扩展:**UPDATE 可以加 LIMIT 控制影响行数**。
```sql
-- 只更新符合条件的前 100 条(配合 ORDER BY 可控)
UPDATE users SET status = 0
WHERE status = 1 AND created_at < '2024-01-01'
ORDER BY created_at ASC
LIMIT 100;
```
## DELETE vs TRUNCATE
DELETE 和 TRUNCATE 都能清空或减少表数据,但**底层机制完全不同**。选错可能导致性能灾难或数据意外丢失。
| 特性 | DELETE | TRUNCATE |
|------|--------|----------|
| **类型** | DML | DDL |
| **可回滚** | ✅ 事务内可 ROLLBACK | ❌ 隐式提交,不可回滚 |
| **WHERE 过滤** | ✅ 可以 | ❌ 全删 |
| **AUTO_INCREMENT** | 保留当前值 | 重置为 1 |
| **触发器** | 触发 DELETE 触发器 | 不触发 |
| **速度** | 逐行删除较慢 | 直接重建表极快 |
| **返回值** | 影响的行数 | 无返回值 |
| **锁粒度** | 行锁(可带 WHERE) | 表级元数据锁 |
### DELETE 进阶用法
```sql
-- DELETE 带 JOIN(MySQL 特有语法)
DELETE u FROM users u
LEFT JOIN orders o ON u.id = o.user_id
WHERE o.id IS NULL;
-- 删除所有没有订单的用户
-- 多表联合删除(同时删用户 + 订单)
DELETE t1, t2 FROM users t1
INNER JOIN orders t2 ON t1.id = t2.user_id
WHERE t1.status = 0;
```
> [!NOTE] ORDER BY + LIMIT 在 DELETE 中同样可用
> ```sql
> -- 只删除符合条件的前 50 条(按创建时间最早的优先)
> DELETE FROM users WHERE status = 0
> ORDER BY created_at ASC
> LIMIT 50;
> ```
---
## 高效写入策略
当需要处理大量数据时,**写入方式的选择直接影响性能和稳定性**。下面按数据量级给出分级方案:
```mermaid
flowchart TD
A["大量数据写入"] --> B{数据量}
B -->|小于 1 万条| C["单条 INSERT + 事务包裹"]
B -->|1 万至 100 万条| D["分批 INSERT\n每批 500 到 2000 条"]
B -->|大于 100 万条| E["LOAD DATA INFILE"]
C --> F["BEGIN; INSERT... COMMIT;"]
D --> G["每批独立事务\n降低锁竞争"]
E --> H["绕过 SQL 解析\n直接写数据文件"]
style E fill:#00D866,color:#fff
style D fill:#FF9F43,color:#000
```
```sql
-- LOAD DATA INFILE 示例(最快的批量导入方式)
LOAD DATA LOCAL INFILE '/tmp/users.csv'
INTO TABLE users
FIELDS TERMINATED BY ',' ENCLOSED BY '"'
LINES TERMINATED BY '\n'
(username, email, @status) -- @variable 用于预处理
SET status = CASE @status WHEN 'active' THEN 1 ELSE 0 END;
```
### 分批 INSERT 代码示例
对于中等规模数据,**事务 + 分批次** 是最实用的方案。以下 Go 伪代码展示了核心思路:
```go
const batchSize = 1000
rows := generateRecords() // 假设返回 50000 条记录
tx := db.Begin() // 外层可开启大事务
for i := 0; i < len(rows); i += batchSize {
end := min(i+batchSize, len(rows))
batch := rows[i:end]
// 构建动态 INSERT
cols := "(username, email, status)"
placeholders := strings.Repeat("(?, ?, ?),", len(batch))
sql := fmt.Sprintf("INSERT INTO users %s VALUES %s", cols, placeholders[:len(placeholders)-1])
var args []any
for _, r := range batch {
args = append(args, r.Username, r.Email, r.Status)
}
tx.Exec(sql, args...) // 单批提交
}
tx.Commit()
```
> [!CAUTION] 批量插入注意事项
> - **包大小限制**:MySQL 默认 `max_allowed_packet` 为 64MB,超大批次会报 `Packet too large` 错误
> - **长事务锁表**:单个大事务持有锁的时间越长,死锁概率越高 → **每批独立 commit** 更安全
> - **自增 ID 碎片**:大批量 INSERT 会导致自增 ID 跳跃,不影响功能,但会影响 `AUTO_INCREMENT` 当前值的准确性
## 关联笔记
- [[hhs/GORM/03-CRUD 操作]] — GORM 的 Create / First / Find 等方法的 SQL 生成机制
- [[hhs/GORM/11-批量操作]] — GORM 中 CreateInBatches 等批量优化手段