SQL插入数据避坑指南:VALUES、批量插入与UPSERT的工程实践
简介SQL插入数据是日常数据库操作的高频场景这份PDF文档面向数据库初学者与需要规范化写法的开发人员系统梳理了INSERT INTO VALUES、INSERT INTO SELECT、以及省略目标列的简写写法三种常用方式并结合T-SQL与PL/SQL环境给出语法对比与注意事项。资源共1个文件为34KB的PDF格式便于随时查阅与离线学习。已有664人学习下载适合在面试复习或实际开发前快速补齐插入操作的细节知识。文档不仅说明了各方法的适用场景还重点强调了检查列约束、批量插入、事务处理、错误处理及性能优化等小贴士并针对省略目标列时SELECT顺序必须与表结构一致这一易错点做了专门提醒。通过这一份笔记读者可以快速掌握不同插入语句的选择依据避免因主键冲突、非空约束未满足或列顺序错位而导致的插入失败。1. SQL插入数据看着简单翻车都在细节上写SQL的插入数据可能是大多数人第一个写熟的语句但真正上了生产坑全在细节里批量插入拆错批、自增ID跳号、字符串拼接被注入、跨库迁移死活插不进去。这篇文章把三种最常用的插入方法——VALUES、INSERT INTO ... SELECT、UPSERT冲突更新从原理讲到落地每段代码都标注了适用场景和参数绕开我踩过的那些坑。适合写业务接口的开发、做数据同步和批处理的数据工程师以及刚接手数据库方向的运维。2. VALUES插入从单行到批量先把基础打牢VALUES是最原始的插入方法却撑起了从单元测试到生产环境的大部分写入操作。它看着简单但字段顺序、默认值、自增主键返回值、批量上限每一项都能影响线上行为。这一章从单行开始逐步改成多行批量最后给出三个主流数据库拿自增ID的写法。2.1 单行插入与字段顺序别被NULL误导最常见的单行写入是“INSERT INTO 表名 (字段...) VALUES (值...)”字段列表与值列表按位置一一对应不是按名字匹配。这个位置对应关系是很多翻车的源头源表字段顺序调整了目标表没调整插入就会静默串列数据错位还报不了错。所以不要依赖表结构的自然顺序INSERT和VALUES两侧都显式写清楚字段是最稳的。INSERT INTO user (name, age, created_at) VALUES (张三, 30, NOW());这条语句的含义是把“张三”、30、当前时间分别写入name、age、created_at三列。NOW()是MySQL取当前时间的函数PostgreSQL需要写成CURRENT_TIMESTAMPSQL Server也是CURRENT_TIMESTAMP。参数说明字段列表中的顺序决定了VALUES里每个值的去向多一个少一个都会直接报列数不匹配类型不一致时很多数据库会尝试隐式转换比如把字符串30转成数字30但这种隐式转换在字符集或格式不可识别时会中断整个语句。省略字段列表时没写的列会自动用默认值但很多新手把这个行为理解成“NULL就触发默认值”实际不是。INSERT INTO user (name, age) VALUES (李四, 25);假设created_at列带有DEFAULT CURRENT_TIMESTAMP这条语句里没写created_at数据库会填入默认值但如果写成INSERT INTO user (name, age, created_at) VALUES (李四, 25, NULL)除非该列允许NULL否则数据库不会把NULL替换成默认值而是会尝试写NULL最终报NOT NULL约束错误。显式想用默认值只能写DEFAULT关键字。INSERT INTO user (id, name, age) VALUES (DEFAULT, 王五, 28);DEFAULT关键字只适用于该列确实有默认值的场景。比如SQL Server里默认值GUID的列如果漏掉字段会由DEFAULT约束生成新GUID但如果在VALUES里写成空字符串数据库就会把空串存进这个列而不是生成GUID。这里很容易被“漏传就自动补”的直觉骗了。2.2 多行VALUES就是批量插入的变种VALUES语法天然支持多行把多组值用逗号连接一次发给数据库执行。INSERT INTO user (name, age) VALUES (赵六, 32), (孙七, 24), (周八, 27);一次多行插入比循环单行插入快很多原因是网络往返从N次变成1次数据库日志的写入次数也大幅下降事务日志的合并效果更明显。这个优势在ORM框架里同样适用比如JDBC的addBatch、C#的SqlDataAdapter批量提交底层都是把多条参数合并成一次发送。但多行VALUES有边界。MySQL的max_allowed_packet限制单次SQL包大小超过就报“Packet too large”SQL Server的限制更直接单个批处理的参数总数上限是2100个。假设每行10个参数2100个参数最多只能批量210行强行塞500行写给新手看的示例代码可能没事落到生产就会翻车。所以批量插入的行数要按参数个数反推而不是按感觉定。我一般把批量行的参数总数控制在1900以内给数据库留一点余地。如果单次需要插入几万行拆分批次跑比如每批500行分批提交。这里的“提交”是事务语义不是每批都COMMIT后面避坑章节会展开讲。2.3 插入后拿自增ID三种数据库的写法业务在插入数据后通常需要拿到自增主键去构造关联数据。但不同数据库的写法差异很大写错一个关键字轻则拿不到ID重则拿到别人那条数据的ID。MySQL里最常用的是LAST_INSERT_ID()它和当前连接绑定不会被其他连接干扰同一个连接里连续执行两条INSERT第二次会覆盖第一次的结果INSERT INTO user (name) VALUES (测试); SELECT LAST_INSERT_ID();PostgreSQL从INSERT语句里直接返回ID不需要二次查询INSERT INTO user (name) VALUES (测试) RETURNING id;RETURNING后面可以写多个字段包括计算表达式这是PG最推荐的做法因为它在同一条语句里原子完成插入和取值。SQL Server的写法更讲究INSERT INTO [user] (name) OUTPUT INSERTED.id VALUES (测试);OUTPUT可以直接返回插入后的整行数据比SCOPE_IDENTITY()更直观。注意SQL Server还有一个IDENTITY全局变量它返回的是当前会话最后一次插入操作生成的所有表的ID如果表上有触发器又往别的表插了数据IDENTITY就会返回触发器那张表的ID而不是你真正插入的user表。这个问题我在老项目里栽过后来一律用OUTPUT或SCOPE_IDENTITY()收口。3. INSERT INTO ... SELECT从查询表复制数据的最稳路径当要插入的数据来自另一张表或一段查询而不是手工写死的常量VALUES就不合适了。INSERT INTO ... SELECT能把查询结果直接灌进目标表常用于归档、数据同步、报表中间表、把一个库的查询结果插入到另一个库里。它最大的优点是逻辑和数据落在同一条SQL里事务一致性好缺点是目标表的约束和源数据质量问题会在批量执行时集中爆发。3.1 把查询结果直接灌进目标表语法上就是把VALUES替换成SELECT查询INSERT INTO order_archive (order_id, user_id, amount, create_time) SELECT order_id, user_id, amount, create_time FROM orders WHERE create_time 2024-01-01;这条语句会把orders表里2024年以前的数据复制到order_archive归档表。目标表字段和SELECT列表按位置对应不是按名字对应——这是最容易踩的坑源表字段顺序调整过或者SELECT列表改了顺序INSERT还是会按位置硬塞如果类型恰好兼容数据就错位了。所以SELECT里的字段顺序必须和INSERT字段列表严格一致不要写SELECT *。如果目标表已经存在用INSERT INTO ... SELECT如果目标表不存在常见做法是CREATE TABLE ... AS SELECT比如MySQL的CREATE TABLE order_archive AS SELECT * FROM orders WHERE ...。这会把字段类型一起复制但不会复制索引、约束、默认值。所以建完表后要单独补主键、唯一键和索引否则后续插入时没有约束拦截重复数据就直接落库。还要注意约束的影响源表里可能有一行数据在目标表违反唯一键或NOT NULL整条INSERT会被中断。数据库执行INSERT ... SELECT是单条语句遇到第一个违规行就报错回滚前面插入成功的行也会一起没掉。这时候要么先清洗源数据要么拆成小批次让失败影响范围可控。3.2 跨库和跨实例迁移时的写法同实例跨库是最简单的跨库插入MySQL直接写库名.表名INSERT INTO analysis.user_snapshot (user_id, user_name, updated_at) SELECT user_id, user_name, NOW() FROM user_center.user WHERE status 1;这条语句从user_center库的user表里选出有效用户写入analysis库的user_snapshot快照表。前提是两个库在同一MySQL实例下账号具备两个库的权限。SQL Server的同实例跨库写法类似用[database].[schema].[table]限定。如果目标库在另一台服务器就需要先建Linked Server把远程表映射成本地对象再执行INSERT SELECT性能不会太好网络延迟和数据量都会拖慢。跨实例不是一条SQL能解决的问题这是数据库同步工具的主场。常见做法是把源库数据导出成中间文件再在目标库批量导入或者用专门的同步工具做增量订阅。我不建议在业务代码里通过跨机房连接的SQL直插网络抖动一次整个批任务就挂在半路而且很难定位断点。3.3 插入前先去重的两个习惯从线上表往归档表插数据时重复执行同一条任务是最常见的重复数据来源。第一次跑完没记录进度第二次跑全量重复数据就进来了。两个习惯能挡掉大部分问题。第一个习惯在源查询里加GROUP BY让结果集天然去重。INSERT INTO order_archive (order_id, user_id, amount, create_time) SELECT order_id, MAX(user_id), MAX(amount), MAX(create_time) FROM orders WHERE create_time BETWEEN 2024-01-01 AND 2024-06-30 GROUP BY order_id;GROUP BY适合对聚合后的结果去重比如按订单ID取任意一组字段值。但如果要保留“每个订单最新一条”GROUP BY就不够精确这时候用窗口函数。第二个习惯用ROW_NUMBER按业务键分组取排序第一的那条。INSERT INTO order_archive (order_id, user_id, amount, create_time) WITH ranked AS ( SELECT order_id, user_id, amount, create_time, ROW_NUMBER() OVER (PARTITION BY order_id ORDER BY create_time DESC) AS rn FROM orders WHERE create_time BETWEEN 2024-01-01 AND 2024-06-30 ) SELECT order_id, user_id, amount, create_time FROM ranked WHERE rn 1;PARTITION BY order_id表示按订单ID分组ORDER BY create_time DESC表示每个分组里最新时间排第一rn1就是最新那条。这个方法在数据同步场景里尤其好用比如上游表每天更新状态同步任务重复跑只要把去重键设成业务主键就能稳定拿到最新一条不会重复插入。4. 幂等写入用UPSERTON DUPLICATE KEY UPDATE 与 INSERT OR REPLACE实际业务里“存在就更新不存在就插入”比“先查再写”可靠得多。两个请求同时到达时先查再写会查都查不到然后都走插入产生重复数据。UPSERT把判断放到数据库内部通过唯一索引保证幂等。不同数据库的写法差异很大这一章分别给MySQL、PostgreSQL、SQLite和SQL Server的落地写法。4.1 冲突更新MySQL 的 ON DUPLICATE KEY UPDATEMySQL里实现幂等插入是ON DUPLICATE KEY UPDATE触发条件是插入的行撞上唯一索引或主键。INSERT INTO user (user_id, user_name, mobile) VALUES (u1001, 张三, 13800138000) ON DUPLICATE KEY UPDATE user_name VALUES(user_name), mobile VALUES(mobile);VALUES(user_name)在MySQL 8.0.20之后已经标记为废弃建议用新别名写法INSERT INTO user (user_id, user_name, mobile) VALUES (u1001, 张三, 13800138000) AS new ON DUPLICATE KEY UPDATE user_name new.user_name, mobile new.mobile;别名写法可读性更好和PostgreSQL的EXCLUDED语义接近。前提是user_id或mobile必须存在唯一索引否则数据库无法判断冲突ON DUPLICATE KEY UPDATE就不会触发。这条语句有个让新手困惑的返回值插入新行受影响行数是1发生了更新受影响行数是2更新前后值没变化受影响行数是0。ORM框架拿到这些返回值时别简单理解成“影响了几条数据”。如果业务只想要“有就跳过没有才插”可以写ON DUPLICATE KEY UPDATE user_name user_name利用值没变时受影响行数为0的特性但更清晰的写法还是先判断唯一键在不在或者用INSERT IGNORE——不过INSERT IGNORE会吞掉很多其他错误比如字段太长、类型错误也一起忽略不建议在生产上开这个口子。4.2 PostgreSQL 的 ON CONFLICT 写法PostgreSQL的UPSERT是ON CONFLICT比MySQL更严谨可以指定冲突目标。INSERT INTO user (user_id, user_name, mobile) VALUES (u1001, 张三, 13800138000) ON CONFLICT (user_id) DO UPDATE SET user_name EXCLUDED.user_name, mobile EXCLUDED.mobile;EXCLUDED代表本次试图插入的那行数据EXCLUDED.user_name就是VALUES里传进来的user_name。ON CONFLICT (user_id)里的user_id必须是唯一索引或主键否则语句直接报错。如果不想更新只想跳过冲突行写法是DO NOTHING。这里有个细节如果表上有多个唯一键插入的数据同时撞上两个不同唯一键ON CONFLICT没有指定冲突目标时会报错因为数据库不知道按哪个约束执行更新。所以冲突目标一定要写不要想着靠省略它来简化。PostgreSQL还支持ON CONFLICT ON CONSTRAINT约束名在联合唯一键场景下比列名更精准。把Oracle查询结果插入到PostgreSQL表时要注意两边对主键冲突的处理习惯完全不同Oracle的MERGE和PG的ON CONFLICT不能直接平移。迁移时最好在目标表上先建好唯一索引再套用ON CONFLICT否则同步任务会反复报重复键。4.3 SQLite 与 SQL Server 的替代方案SQLite有个很老的INSERT OR REPLACE INTO写法简单INSERT OR REPLACE INTO user (user_id, user_name, mobile) VALUES (u1001, 张三, 13800138000);但REPLACE的语义是“删除旧行插入新行”不是原地更新。副作用是自增ID变了外键关联的明细表也会因为主键变化产生孤儿数据。SQLite 3.24之后也支持ON CONFLICT DO UPDATE和PostgreSQL的风格接近能用这个的新语法就不要用OR REPLACE。SQL Server比较尴尬没有标准ON DUPLICATE KEY UPDATE。老项目里常见的是MERGEMERGE INTO [user] AS t USING (SELECT u1001 AS user_id, 张三 AS user_name, 13800138000 AS mobile) AS s ON t.user_id s.user_id WHEN MATCHED THEN UPDATE SET user_name s.user_name, mobile s.mobile WHEN NOT MATCHED THEN INSERT (user_id, user_name, mobile) VALUES (s.user_id, s.user_name, s.mobile);MERGE在并发和触发器场景下有已知的偶发问题比如重复键和意外锁升级而且在简单幂等写入时它的执行计划并不好看。如果业务复杂度不高我更推荐在应用层做“先UPDATE受影响行数等于0再INSERT”或者显式IF EXISTS判断两条语句包在一个事务里。这样写虽然多一条SQL但行为可预期排错也更方便。升级到新版本SQL Server后仍然没有像MySQL那样顺手的原生UPSERT这个习惯可以一直保留。5. 插入数据的避坑手册5个让我熬夜的教训插入数据报错没什么怕的是报错之后数据已经错了一半或者线上安静地写入了错误数据。下面5条全是真实生产环境里遇到过的按现象、原因、解决写帮你省几晚加班。5.1 SQL注入不是只有拼接字符串才会中招现象某个后台管理接口用户输入的内容直接拼进VALUES字符串随后数据库出现越权数据查询甚至有人用万能密码绕过了登录校验。原因SQL语句是拼接出来的用户的单引号闭合了原有语句改变了语义。解决所有参数一律走预编译占位符Java用PreparedStatementC#用SqlParameterPython用cursor.execute(sql, params)不要用f-string或format拼SQL。INSERT INTO user (name, email) VALUES (?, ?)数据库收到的永远是参数值而不是SQL片段。字符串里的引号、分号、注释符都被当作普通字符处理。这个习惯要从第一行代码养成别等数据库被拖库再补。5.2 批量插入导致参数上限现象往SQL Server一次性INSERT 2101行直接报参数过多错误MySQL这边则报max_allowed_packet相关错误。原因SQL Server单个批处理最多2100个参数MySQL限制单次SQL包大小。行数多时参数也跟着多撞上限不奇怪。解决按每批500行拆批或者用SqlBulkCopy、LOAD DATA INFILE这类专门导入接口。拆批时注意事务边界不要拆完一批就提交一批否则中途失败只能留下半批数据后面对账会非常痛苦。5.3 默认值与NOT NULL的“假象”现象表中created_at有DEFAULT CURRENT_TIMESTAMP业务代码漏传了这列插入却报“column cannot be null”。原因INSERT语句里写了created_at NULL显式传NULL会覆盖默认值数据库不会自动把NULL替换成默认值。解决要么省略该列要么显式写成DEFAULT。同理SQL Server默认值GUID的列漏传时由DEFAULT生成新GUID但如果显式传了空字符串数据库会存空串而不是生成GUID。这两个细节都是“看上去该自动处理实际不会”的典型坑。INSERT INTO user (id, name, created_at) VALUES (DEFAULT, 张三, DEFAULT);DEFAULT关键字能用在所有有默认值的列上但前提是该列没有NOT NULL且没有外键约束。比如SQL Server 2022里GUID列如果用DEFAULT NEWID()上面写法就会生成新GUID不会报错。5.4 时间与字符串编码的隐性错误现象插入中文后查询变“???”或者时间字段比预期差8小时。原因连接字符集与目标表字符集不一致MySQL里utf8和utf8mb4混用是重灾区时间问题则来自驱动会话时区与数据库时区不对齐。解决连接串显式指定charsetutf8mb4表字段也用utf8mb4时间统一按UTC存储应用层按业务时区渲染或者JDBC连接串加serverTimezoneAsia/Shanghai。这类错误不在SQL本身排查顺序应该是先看连接参数再看表结构最后才查SQL。5.5 自增主键跳号不是bug也不是玄学现象事务回滚后自增ID从1变成5业务方以为发号器坏了。原因自增计数器不随事务回滚MySQL的InnoDB在事务回滚后不会把已分配的自增值退回去SQL Server重启还会根据种子重算导致更大跳跃。解决如果业务要求ID连续自增主键直接不适合改用序列或显式赋值如果只需要唯一就把跳号当作正常行为。插入失败出现ID空洞很正常不要额外去“修补”ID越修越乱。6. 高级玩法事务批量提交与性能验证方法插入性能优化不是玄学核心是三件事减少网络往返、合并日志提交、避开索引维护高峰。这章给两个马上能用的技巧和一个验证方法。把成千上万条插入包在一个事务里能把多次磁盘同步变成一次BEGIN; INSERT INTO user (name) VALUES (a), (b), (c); COMMIT;注意事务不是越大越好一个事务控制在1万行以内比较安全或者按主键范围分批提交。事务太长锁和日志都会压垮主库。验证插入速度时不要用代码里的Stopwatch应该在数据库端看实际耗时。MySQL可以开profiling或看慢查询日志的Query_timeSQL Server用SET STATISTICS TIME ON。最简单的验证是重复执行同一批数据对比平均耗时同时观察事务日志增长量。之前遇到“程序慢但SQL快”的怪事最后定位到是ORM逐条提交SQL而不是批量执行这就是慢SQL优化里最常见的误判。快速生成测试数据可以这样INSERT INTO user (name, age) SELECT CONCAT(user, n), n % 80 FROM ( SELECT (i : i 1) AS n FROM information_schema.tables, (SELECT i : 0) t LIMIT 10000 ) t;PostgreSQL直接用generate_series(1,10000)SQL Server用递归CTE。用途是压测插入和批量导入性能别在生产库上跑。我现在已经习惯在写任何插入逻辑前先自问三句有没有唯一键保证幂等批量参数上限是多少事务边界在哪里这三句能挡掉大部分线上写入事故。希望帮到你。本文还有配套的精品资源点击获取