多彩编程 多彩编程MZPH · CODE BLOG
ARTICLE DETAIL

文章详情

深耕前端与后端开发技术的一线实战笔记与踩坑复盘。

PostgreSQL插入语句全攻略:语法、性能优化与故障排查

PostgreSQL插入语句全攻略:语法、性能优化与故障排查 PostgreSQL里使用频率最高的一条语句INSERT INTO绝对排得上号。不管是偏OLTP的业务库还是偏OLAP的数仓装载每天都有海量的行通过它被写进表里。但老实说我见过太多开发者把这条看似简单的语句用得七七八八要么只写单行、循环执行要么冲突处理全靠try/catch抛异常要么大批量导入时还在用VALUES一句句拼。这篇文章想把这几个层面一次讲透从基础语法到RETURNING、ON CONFLICT的进阶用法从批量提交到COPY加载再到线上事故级的排查思路所有内容都基于我在真实项目里踩过的坑和验证过的方案。适合后端开发、DBA和数据工程师阅读无论你刚接手PostgreSQL还是已经写了两三年都能找到可以直接抄走的东西。1. INSERT INTO的基础语法与执行逻辑1.1 最小可用的INSERT语句长什么样先从一个最典型的表结构说起。CREATE TABLE users ( id BIGSERIAL PRIMARY KEY, name TEXT NOT NULL, email TEXT NOT NULL, created_at TIMESTAMPTZ NOT NULL DEFAULT now() );往这张表里插入一条数据最标准的写法是INSERT INTO users (name, email) VALUES (张三, zhangsanexample.com);这里有一点很多人没意识到你写的列名列表决定了你插入的目标列而VALUES里的值按照这个列表的顺序一对一对应。数据库并不会管users表里实际列的顺序是不是name在前、email在后它只认你写出来的这一串。所以就算你把表结构里email放在name前面这条语句依然能正确执行。INSERT的完整执行路径也比大多数人想象的要长客户端把SQL发给服务端服务端做词法分析、语法解析、语义分析然后生成执行计划、执行插入动作写入WAL日志接着更新表数据和索引再向客户端返回命令完成的消息。这一整条链路里任何一环出现瓶颈都可能让你的一条简单INSERT变慢。这也是为什么后面会专门聊批量写入的事——单条插入本身就有固定开销业务量上来后不能无脑循环。还有一个小细节INSERT成功后如果没写RETURNING它只会返回一个类似INSERT 0 1的命令标签。你拿不到这条记录自动生成的主键值也拿不到任何被默认值填充的字段。想要这些数据需要用到后面讲的RETURNING子句。1.2 省略列名看似省事实际埋雷INSERT INTO语句允许你完全不写列名直接写值INSERT INTO users VALUES (李四, lisiexample.com);这种写法要求VALUES里必须覆盖表里的每一列而且顺序必须和表定义完全一致。问题是一旦你往表里新增一列、删除一列或者调整列顺序原来这条语句立刻就会报错或者发生严重的字段错位。更隐蔽的是如果新加的列恰好有默认值甚至可以插入成功但你把值对错了列业务逻辑当场就乱了。我接手过一个内部数据同步项目早期为了省事大量INSERT语句都不写列名后来某张表中间插入了一列结果所有相关任务的同步结果全部错位。排查了整整一个下午才发现问题不在代码逻辑而在这种隐藏的隐式映射。从那以后我给自己定了个规矩生产环境里不允许出现不带列名的INSERTDDL变更时也必须把相关SQL全部过一遍。如果你确实要省略某个字段可以用DEFAULT关键字显式声明走默认值INSERT INTO users (name, email, created_at) VALUES (王五, wangwuexample.com, DEFAULT);这么做既保留了列名的清晰度又能让数据库填充默认值两全其美。1.3 类型转换让数据库少做点“阅读理解”PostgreSQL对类型处理非常严格但虚掩的那层“方便之门”也经常坑人。拿日期时间字段举例INSERT INTO events (occurred_at) VALUES (2024-05-10 14:30:00);如果表字段是TIMESTAMP或者TIMESTAMPTZPostgreSQL通常会做一次隐式转换把这个字符串按会话时区解析成时间值。这个行为本身没错问题在于你的会话时区可能和业务要求的时区不一致尤其是当应用服务器和数据库服务器时区不同时同一个字符串会被解释成完全不同的时间点。我的建议是涉及时间的数据尽量由应用层参数化传入用Java、Go或者Python的日期时间对象而不是拼一个字符串进去。如果只能传字符串那就明确带时区偏移比如2024-05-10 14:30:0008。类似的还有数字类型PostgreSQL可以从字符串隐式转成整数但从整数转成某些自定义类型不一定成功这些坑最好在开发阶段就暴露别等到线上数据异常。2. RETURNING与ON CONFLICT的进阶用法2.1 RETURNING让数据库当场告诉你结果很多从其他数据库转过来的同学一开始都不知道PostgreSQL有RETURNING这个子句。它允许一条INSERT执行完后把实际写入的行返回给客户端。INSERT INTO users (name, email) VALUES (赵六, zhaoliuexample.com) RETURNING id;像上面这样你就拿到了数据库为这条新记录生成的自增主键。如果不写RETURNING你还得再发一条SELECT才能拿回这个id不仅多一次往返还可能在并发场景下拿到别人的数据。RETURNING可以直接返回你关心的任意字段也能返回表达式结果比如INSERT INTO users (name, email) VALUES (钱七, qianqiexample.com) RETURNING id, created_at, upper(email);实际项目里RETURNING最常见的场景就是插入一张表后需要立即用生成的id去关联子表。我记得之前做一个订单系统导入订单时需要一个订单主键ID再写订单明细如果不支持RETURNING只能先SELECT max(id)再插这在并发情况下完全不靠谱。换成RETURNING id之后代码逻辑干净了很多也不再依赖任何脆弱的查询顺序。要提醒的是RETURNING只能在INSERT、UPDATE、DELETE上使用而且它返回的是操作后的最终行数据。如果表上存在BEFORE触发器修改了某些字段你返回的值可能是被触发器改过的这不算什么大问题但你心里得有数别对数据结果做出错误假设。2.2 ON CONFLICT一条语句解决写入冲突INSERT碰见主键或唯一索引冲突是家常便饭。PostgreSQL提供ON CONFLICT让你在同一句SQL里决定冲突后该做什么而不是让应用层捕获异常重试。INSERT INTO users (id, name, email) VALUES (1001, 孙八, sunbaexample.com) ON CONFLICT (id) DO UPDATE SET name EXCLUDED.name, email EXCLUDED.email;这里有两个关键点。第一ON CONFLICT后面必须指定冲突目标通常是一组列名代表某个唯一索引或主键。第二DO UPDATE里想引用“本来要插入的新值”不能直接写VALUES里的参数名必须用EXCLUDED这个伪表。EXCLUDED代表了“如果真的插入会变成什么样子的一组行”。这个设计很多人第一次接触会觉得绕但用顺手之后写upsert逻辑会非常直接。如果你只想冲突时什么都不做就用DO NOTHINGINSERT INTO user_tags (user_id, tag) VALUES (1001, vip) ON CONFLICT (user_id, tag) DO NOTHING;这种写法在去重业务里很常见比如日志幂等入库、埋点内容跳过重复记录。需要注意的是如果表上有多个唯一约束而你ON CONFLICT没有明确指定冲突目标PostgreSQL只会把它当作DO NOTHING来对待不能用于DO UPDATE即便要指定也必须写明是哪个唯一索引。否则会报ON CONFLICT DO UPDATE requires inference specification or constraint name这类错误。还有一个不常被注意的坑ON CONFLICT的冲突检测依赖的是唯一索引或排他约束如果你的数据涉及部分索引PostgreSQL的推断逻辑会更复杂。如果你需要靠“部分唯一索引”去重就得在ON CONFLICT里写清索引谓词否则可能无法匹配到预期约束。2.3 把INSERT写进CTE里串联复杂业务单条INSERT写完之后很多业务还想要做下一件事比如根据插入结果继续更新另一张表。PostgreSQL支持数据修改CTE也就是把INSERT、UPDATE、DELETE放在WITH子句里然后带着RETURNING的结果继续处理。场景很常见导入一批用户发现有些用户已经存在就把他们的信息更新掉最后还要把这些用户的ID汇总出来供后续程序使用。WITH upserted AS ( INSERT INTO users (name, email) VALUES (周五, zhouwuexample.com) ON CONFLICT (email) DO UPDATE SET name EXCLUDED.name RETURNING id ) SELECT * FROM upserted;这个查询执行完你会拿到一个结果集里面是这次操作涉及的所有用户ID。更复杂的场景可以把新插入的id直接写进日志表WITH upserted AS ( INSERT INTO users (name, email) VALUES (吴九, wujiuexample.com) ON CONFLICT (email) DO UPDATE SET name EXCLUDED.name RETURNING id ) INSERT INTO operation_log (user_id, action) SELECT id, upsert FROM upserted;这种链式写法比“先INSERT再SELECT再INSERT”少了多次网络往返也避免了并发环境下的脏读。我通常建议凡是“插入后必须立刻使用结果”的业务优先考虑RETURNING凡是“插入后还要写关联表”的业务优先考虑数据修改CTE。3. 高吞吐插入的工程化实践3.1 事务与批量提交别再用一条INSERT一个事务早期我接手一个数据采集任务每来一条数据就往PostgreSQL里INSERT一次开启autocommit。结果跑一段时间后数据库连接CPU占用很高写入TPS远低于预期。后来分析发现问题就出在每个INSERT都自带一个事务开始事务、写入WAL、提交事务、回客户端等等。这一整套固定开销在单趟插入里占比极高。改成批量插入后情况明显改观。核心做法也很简单多条INSERT放到同一个事务里或者直接用一条多VALUES的INSERTINSERT INTO users (name, email) VALUES (A, aexample.com), (B, bexample.com), (C, cexample.com);实践下来的经验是单批次数据量不要太小也不要太大。太小事务固定开销摊不掉太大单条SQL过于庞大内存和解析成本跟着涨而且一旦失败回滚的代价也高。我个人习惯控制在1000到5000行一个批次或者按行大小限制在几MB以内。大批量导入前最好根据你的字段个数和每行字节数做简单估算。批量插入还有一个隐性收益减少应用和数据库之间的网络往返。每一条INSERT单独执行都至少有一次请求和响应批量后一次响应覆盖一整批网络开销显著下降。在我当时的采集场景里仅仅是改成批量提交吞吐就从每分钟几万行提升到了每分钟几十万行。3.2 预处理语句让解析开销降下来普通SQL执行时数据库每次都要做一遍完整的语法分析和执行计划生成。如果你的业务是高频重复插入同样的语句结构只是参数值不同完全可以利用预处理语句Prepared Statement把解析和计划生成压缩掉。PostgreSQL服务端预处理的写法是PREPARE insert_user (text, text) AS INSERT INTO users (name, email) VALUES ($1, $2); EXECUTE insert_user(测试A, testaexample.com); EXECUTE insert_user(测试B, testbexample.com);不过实际业务里很少有人直接用PREPARE/EXECUTE这种裸SQL姿态更常见的是通过驱动层或者ORM使用预编译语句。比如JDBC的PreparedStatement、Go的database/sql参数占位符它们底层都会走类似的预处理机制只要连接池配置得当重复执行相同SQL结构时能明显减少数据库端的CPU开销。这里要提一个坑某些连接池或者中间件开启预编译时如果预处理语句缓存过大反而会占内存、耗CPU。我的建议是遇到这类问题别急着关闭预编译先观察数据库端的缓存命中率和内存压力再调整连接池的prepared statement cache参数。一般默认配置是够用的真正需要调参的场景多半是超高并发。3.3 COPY大数据量导入的终极方案如果说INSERT是每天细水长流式写入的主力那COPY就是一次灌入大批量数据的推土机。PostgreSQL自带的COPY命令支持从文件或标准输入导入数据其速度远高于同等数据量的INSERT批量操作。COPY users (name, email) FROM /path/to/users.csv WITH (FORMAT csv, HEADER true);COPY为什么快因为它绕开了很多传统SQL执行链路里不必要的开销利用批量加载通道直接写入表文件和索引同时还能选择性地减少WAL日志量。如果你导入的是历史数据、初始化数据、或者数据仓库的ETL结果优先考虑COPY而非逐行INSERT。但COPY也不是万能的。它的灵活性比INSERT低不支持ON CONFLICT也不支持RETURNING子句。如果你想一边做去重一边导入最好先对源数据做预处理或者导入到临时表后在临时表里做一次INSERT INTO ... SELECT加过滤从临时表转写到正式表。这套组合拳我用的最多CREATE TEMP TABLE tmp_users (LIKE users INCLUDING DEFAULTS) ON COMMIT DROP; COPY tmp_users (name, email) FROM /path/to/users.csv WITH (FORMAT csv, HEADER true); INSERT INTO users (name, email) SELECT name, email FROM tmp_users ON CONFLICT (email) DO NOTHING;注意临时表在会话结束时自动消失本质上就是个中转站。别直接COPY到业务正式表除非你能保证源数据完全合规。3.4 索引和约束插入速度的主要耗能点很多人只关注SQL本身却忽略了索引和约束对INSERT速度的巨大影响。每一次插入PostgreSQL不仅要写表文件还要写每一个二级索引。索引越多插入越慢。大促期导数据时如果表上挂了七八个索引插入性能会肉眼可见地恶化。一个常用手段是大批量导入前先DROP掉非必要索引导入完成后再重建索引。比如ALTER TABLE users DROP INDEX idx_users_email; -- 执行大量插入 -- 导入完成后再创建索引 CREATE UNIQUE INDEX idx_users_email ON users (email);这招对临时性大批量导入很有效因为重建索引的代价往往远小于插入期间逐条维护索引的代价。但注意如果索引承担着唯一约束的业务逻辑在你DROP它的那段时间里重复数据可能已经混进去了所以重建后一定要做数据校验。另一个容易被忽视的约束是外键和CHECK约束。每一行插入都要检查外键对应是否存在检查CHECK表达式是否通过这些成本虽然单次不高但在海量插入时会被放大。如果你的导入流程发生在业务低峰期而且对约束完整性的要求可以通过事后校验满足也可以考虑临时禁用触发器ALTER TABLE users DISABLE TRIGGER ALL; -- 执行大量插入 ALTER TABLE users ENABLE TRIGGER ALL;但这招要极其慎重一旦触发器被禁原本靠触发器维护的数据同步、审计字段更新就全部失效。我的原则是尽量别碰触发器开关优先考虑索引取舍和批量插入策略。4. 常见故障与排查思路4.1 主键冲突报错先搞清楚是业务逻辑还是垃圾数据INSERT最常见的报错之一是主键或唯一索引冲突错误码通常是23505。大部分时候这是业务逻辑没做幂等导致的同一个订单、同一条日志被重复写入了。如果冲突在业务上是可以容忍的直接上ON CONFLICT DO NOTHING或者DO UPDATE做幂等覆盖。如果冲突绝不应该发生那就要排查数据链路是不是消费者重复消费了消息是不是上游任务重跑了是不是代码里没用事务导致部分数据写了一半我遇到过最头疼的一次不是插入重复而是排查“为什么同一个业务号插进去两次”。查到最后发现是某异步任务在分布式环境下被两个节点同时执行而代码里没有加分布式锁。这种情况就算你把INSERT写成INSERT IGNORE也只是掩盖了问题。正确的做法是保证任务执行的唯一性而不是在数据层一味兜底。4.2 死锁两个事务互相等导致的卡死插入操作本身也可能造成死锁。典型场景是两条记录在两个事务里以不同顺序被写入互相持有对方需要的锁。假设表里有两条记录id分别为1和2。事务A先插入id1再插入id2事务B先插入id2再插入id1。如果两个事务恰好交错执行A持有1的锁等待2B持有2的锁等待1死锁就形成了。PostgreSQL会在一段时间后检测到死锁并牺牲其中一个事务报错码40P01。规避方法其实很简单所有业务在批量操作时按固定顺序处理记录例如先按id排序再处理。对单条INSERT来说死锁很难发生但在批量INSERT存在外键关联、或者同时有UPDATE和INSERT交错产生锁竞争时这个风险不可忽视。另外应用层需要对40P01做重试机制万一真的被系统牺牲掉客户端捕获异常后重跑一次大部分情况都能顺利结束。4.3 插入速度突然变慢从这几个点查如果你发现原本流畅的INSERT批量任务突然变慢先别慌按顺序排查。第一用pg_stat_statements看高频SQL的调用次数和平均耗时。如果插入SQL本身平均耗时在增长去看表是不是已经膨胀严重。膨胀意味着同一逻辑行散落在多个数据页里顺序扫描和索引扫描都要读更多页面插入时也要维护更多的索引页。解决办法是执行VACUUM并在合适时机做重建。SELECT query, calls, total_exec_time / calls AS avg_ms FROM pg_stat_statements WHERE query ILIKE INSERT% ORDER BY total_exec_time DESC LIMIT 5;第二检查是否存在锁等待。一条INSERT在遇到其他事务持锁时会被阻塞看起来就是“执行特别慢”。用pg_locks视图或者pg_blocking_pids()函数就能揪出阻塞源。SELECT pid, state, wait_event_type, wait_event, query FROM pg_stat_activity WHERE state active AND wait_event IS NOT NULL;第三看磁盘和WAL。如果WAL目录所在的磁盘性能下降或者fsync导致每次提交都要刷盘插入延迟会直线上升。批量提交之所以能改善正是因为分摊了刷盘成本。还有别把日志和数据库文件放在同一个物理磁盘上否则读写互相争抢IO问题会更严重。第四检查是否开了太多的自动清理并发或者autovacuum是否长时间被某个长事务阻塞。PostgreSQL的MVCC机制决定了更新和删除会留下旧版本行这些旧数据要靠VACUUM清理。如果清理跟不上表膨胀就会拖垮一切DML操作。4.4 长事务与事务ID回卷别小看一条INSERT最后说一个很多开发都容易忽略的问题INSERT虽然只是写一行数据但它也开启了一个事务。如果这个事务一直不提交事务ID会一直占住导致后续开启的事务看到的事务快照越来越老。PostgreSQL的事务ID是32位的循环使用如果数据库长时间不清理理论上会出现事务ID回卷灾难。虽然数据库有防回卷机制会在逼近危险水位时自动冻结或者拒绝新事务但一旦进入那种模式你的INSERT就会全部停摆。避免这个问题的方法很简单不要开长事务导入数据时分批提交及时维护索引和表膨胀定期做VACUUM或依赖autovacuum正常工作。不要认为INSERT操作轻量就随便挂起事务我见过一个报表系统一个事务里导了上千万行数据事务开了好几个小时最后整张表进入锁死状态所有写入都排队业务彻底瘫痪。5. 一些可以立刻用起来的经验到这里INSERT INTO从基础语法到进阶用法从性能优化到故障排查基本都覆盖了。最后说几点我个人习惯养成的建议。第一写INSERT之前先想清楚幂等策略。如果数据可能重复直接用ON CONFLICT把规则定好别指望应用层报异常后补救。第二能用批量绝不用单条循环。哪怕你在ORM里写saveAll底层也要看一下是不是真的生成了一条多VALUES的SQL。第三大批量导入前先备份数据再考虑索引取舍别一上来就为了性能把索引都删了导入完成后才发现少了唯一性保障。我在实际项目里总结下来的顺序是设计表结构时明确唯一键业务写入层统一封装INSERT与ON CONFLICT数据导入走COPY临时表转写SQL最后靠自动化监控观察数据库的插入延迟、锁等待、膨胀率和VACUUM健康度。这四步做完Insert相关的日常麻烦能减少七八成。你如果现在正在被某个INSERT问题折磨不妨按这个思路重新梳理一遍大概率能找到症结。
返回列表