--- 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 等批量优化手段