Files
cs-note/hhs/MySQL/02-SQL核心/07-DML 增删改.md
T

291 lines
10 KiB
Markdown
Raw Normal View History

2026-05-24 11:42:38 +08:00
---
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 等批量优化手段