news 2026/9/28 13:15:30

PostgreSQL增删改查实战:从建表到事务的避坑指南

作者头像

张小明

前端开发工程师

1.2k 24
文章封面图
PostgreSQL增删改查实战:从建表到事务的避坑指南

刚接触 PostgreSQL 的同学,尤其是从 MySQL 转过来的那批,上手第一个星期基本都在跟报错较劲。PostgreSQL 语法跟 MySQL 看着差不多,但骨子里很多习惯是反着的——单引号、双引号、布尔值、自增列、NULL 判断,样样都有讲究。这篇我不打算堆概念,直接按我平时写业务 SQL 的习惯,把增删改查(CRUD)从头到尾捋一遍,顺带把那些“文档里不写、但跑一遍就翻车”的细节全标出来。里面涉及的建表、INSERT 回显、联表 UPDATE、DELETE 恢复、事务提交这些东西,都是实际干活天天要碰的。适合刚入门的同学照着敲,也适合准备从 MySQL 迁过来的朋友做个对照参考。

1. 动手之前:连库,以及先看清你手里的 PostgreSQL

1.1 确认环境和连接方式

很多人装完 PostgreSQL,第一步就卡在“怎么连上”。它默认端口是 5432,如果之前机器上装过 MySQL(默认 3306),别想当然改端口,连接时明确指定即可。

命令行连接是最直接的方式:

psql -h 127.0.0.1 -p 5432 -U postgres -d postgres

如果不写-d,默认会和用户名同名的数据库,postgres用户默认连的是postgres数据库,这个库相当于 MySQL 里的系统库,不要在里面建业务表。我见过有人把表建在 postgres 库里,后面备份、权限全乱套,这属于基本功没打牢。启动后建议先跑一句:

select version();

看清你的版本。PG 10 之前和之后的写法差异不小,比如IDENTITY列就是 10 版本才有的新特性,网上很多老教程还在用serial,照着抄不一定报错,但不够规范。

1.2 练习库的准备

我习惯建一个独立的练习库,名字随意,比如demo:

create database demo;

创建完用\c demo切换。注意 PostgreSQL 的库名、表名、列名在不加双引号时会被折叠成小写。也就是说,你写CREATE DATABASE Demo,实际创建的是demo。如果你用双引号写成CREATE DATABASE "Demo",那它就会记住大写 D,以后每次连接都得加双引号,纯给自己找罪受,规范做法就是全部小写加下划线。

2. 建表才是增删改查的地基:字段类型与常用约束

2.1 类型选择的门道

从 MySQL 过来的人,第一坑就在自增列。MySQL 写惯了AUTO_INCREMENT,PG 里根本认不了。PostgreSQL 有两种做法:

-- 老写法:serial create table users ( id serial primary key, name text ); -- 新写法:generated identity(PG 10+) create table users ( id integer generated always as identity primary key, name text );

serial本质是创建序列后给列加默认值,靠的是序列的nextval()。generated always as identity更干净,它禁止你手动往 id 里插值,能防住“误插一个指定 id 打乱后续主键节奏”的问题。我现在新表一律用 identity,老表才继续用 serial。

字段类型上,PostgreSQL 的text和varchar(n)在性能上没有差异(不像某些数据库那样 text 有限制),所以短字符串用varchar纯粹是为了约束长度,长文本直接text。金额字段用numeric(12,2),不要用float——浮点算钱,月底对账差几分钱这种事,谁用谁知道。时间字段,凡是涉及业务时间的,一律timestamptz(带时区),它会自动换算成 UTC 存储,读出来再按会话时区还原,比不带时区的timestamp稳得多。

2.2 约束和默认值

建表时约束建议一次写全,不然后面补约束代价更大。常用约束有:

  • 主键primary key
  • 非空not null
  • 唯一unique(注意 NULL 不算重复,多个 NULL 是合法的,这个后面细讲)
  • 检查check,比如金额check (price >= 0)
  • 默认值default

默认值这里有个坑:default只对“这条 insert 没带这个字段”的情况生效,如果你显式插入null,默认值不会填进去,而是真的存成 NULL。想强制不让 NULL 进来,就得靠not null约束。

2.3 删除、重建表的顺序问题

业务上一个常见的优化需求是“清空这张表重新导数据”。如果你直接drop table再create table,依赖它的视图、外键和权限全部会丢。我建议保留表结构、只清数据用truncate,后面会单独说。要是确实要重建,记得先确认没有其他表外键引用它,否则 drop 会直接报错“cannot drop table ... because other objects depend on it”。

3. INSERT:插入数据的正确姿势

3.1 基础 INSERT 与批量插入

单行插入很简单:

insert into users (name, email) values ('张三', 'zhangsan@example.com');

PostgreSQL 对大小写敏感的是标识符,字符串值用单引号,这是死规矩。你写成双引号包字符串,它立刻报错“column ... does not exist”,因为双引号被当成标识符了。MySQL 的INSERT IGNORE、ON DUPLICATE KEY UPDATE风格在 PG 里也不通用,PG 用的是标准 SQL 的ON CONFLICT。

批量插入用一条 INSERT 带多组值,性能比循环单插好一个量级:

insert into users (name, email) values ('李四', 'lisi@example.com'), ('王五', 'wangwu@example.com'), ('赵六', 'zhaoliu@example.com');

一次插几千行没问题,但如果数据有几万到几十万行,单条 SQL 太长,可以考虑copy或pg的格式化导入,这个属于数据导入范畴,不是基本 CRUD 的重点。

3.2 RETURNING:PostgreSQL 的回显利器

MySQL 插入后要拿到自增 id,通常得再查一遍last_insert_id。PG 一条 SQL 直接解决:

insert into users (name, email) values ('张三', 'zhangsan@example.com') returning id;

returning后面可以跟列、表达式,甚至returning *(返回整行)。这个特性不止 INSERT 能用,UPDATE、DELETE 也支持。实际开发中,我经常用它拿回后台生成的默认值,比如created_at、id,省掉后续一次查询,非常顺手。

3.3 ON CONFLICT 做 upsert

业务里“有就更新,没有就插入”的场景非常常见,PG 里这么写:

insert into users (id, name, email) values (1, '张三', 'zhangsan@example.com') on conflict (id) do update set name = excluded.name, email = excluded.email;

excluded是指“当前这次插入尝试写入的那些值”。它的意思是:如果 id 冲突,就用本次插入的值去更新对应字段。不需要更新所有字段的话,只列你要更新的列即可。如果唯一键是邮箱,冲突目标就写(email),一样成立。这个特性比“先 select 再 update/insert 二选一”节省一次往返,数据库压力也小很多。

3.4 从查询结果直接灌入

有时候要把一张表的数据搬到另一张表:

insert into users_bak (name, email) select name, email from users where created_at < '2024-01-01';

这是标准的「INSERT ... SELECT」,列数、类型务必对齐,而且它不会触发on conflict,如果需要去重,得先在 select 里做distinct。

4. SELECT:查询不止是“select *”

4.1 WHERE、排序、LIMIT

基础查询不啰嗦,重点讲容易翻车的地方:

select id, name, email from users where status = 'active' order by created_at desc limit 20;

limit和offset是 PG 常用分页手段,但大偏移量会越查越慢,因为数据库要逐个跳过前面的行。数据量大了,正确姿势是“键集分页”:

select id, name from users where id > 1000 order by id limit 20;

靠上一页最大的 id 往后翻,而不是offset 9999 limit 20,这个自己压测一下就能感受到差距。另外,order by里如果字段是 NULL,PG 默认 NULL 排最前面(ASC),MySQL 是排最后,两个库行为不一样,如果对排序要求严格,建议写NULLS LAST或NULLS FIRST显式控制。

4.2 NULL 的处理

NULL 是基本 SQL 里最阴险的东西。很多人会写:

select * from users where email = null;

结果一条都查不出来。NULL 不等于任何值,包括 NULL 自己。判断空值只能用is null或is not null。更坑的是not in遇到 NULL,比如:

select * from users where id not in (select user_id from orders);

只要orders.user_id里出现一个 NULL,整个查询返回空——因为id = NULL既不是真也不是假,是未知,not in的语义全被破坏了。我踩过这个坑之后,凡是not in一律改成not exists:

select * from users u where not exists ( select 1 from orders o where o.user_id = u.id );

这也是我强烈建议新人的一条铁律:业务代码里不要写not in,全用not exists替代。另外一个实用点:nullif函数可以在比较时把空串当 NULL:

select * from users where nullif(email, '') is null;

这条能把“空字符串”和“NULL”一并查出来,做数据清洗时很常用。

4.3 去重与去重后取一行

普通去重用distinct。但我见的更多场景是“按某个字段分组,每组取最新一条”,这种用distinct on最干净:

select distinct on (user_id) id, user_id, order_amount from orders order by user_id, created_at desc;

它的意思是:对user_id去重,每个 user_id 保留在order by里排最前的那一条。所以order by的第一列必须和distinct on的列一致,否则报错。这个写法比窗口函数更直观,适合临时查数。如果后续还要关联别的表,注意distinct on只对输出的行去重,你要是 select 多列关联数据,逻辑不变,只是输出变宽。

4.4 字符串与日期处理的几个高频函数

写业务 SQL 天天要碰字符串和日期。PG 字符串拼接用||:

select first_name || ' ' || last_name as full_name from users;

MySQL 的concat在 PG 也能用,但||更接近标准,连字段和字符串混拼时也更省事。字符串聚合,PG 里有string_agg:

select dept_id, string_agg(name, ',') from users group by dept_id;

用法相当于 MySQL 的group_concat。日期这块,PG 里最简单实用的就是区间过滤和截断:

-- 近 7 天 select * from orders where created_at >= now() - interval '7 days'; -- 按天分组统计 select date_trunc('day', created_at) as day, count(*) from orders group by day order by day desc;

now()返回当前事务开始时间,同一事务内不会变,这个点设计得很精准。跨时区项目,用timestamptz存时间,展示时at time zone 'Asia/Shanghai'转成本地时间,比你在应用层手写换算稳得多。

5. UPDATE:从单行到联表更新

5.1 基础 UPDATE 和 RETURNING

UPDATE 基础语法:

update users set status = 'inactive', updated_at = now() where id = 1 returning id, status, updated_at;

where条件务必写清楚,不然就是全表更新,生产环境一次误操作能把整张表的字段全洗掉。我用 UPDATE 之前一定先跑一遍同条件的 SELECT,确认影响范围。就算有把握,也建议边写边想:这个条件的索引在不在?where status = 'inactive'如果 status 没有索引,表一大就是全表扫描,几千行无所谓,百万行就得等。

5.2 联表 UPDATE

需要“根据 A 表的数据更新 B 表”,这是 MySQL 和 PG 差得比较大的地方。MySQL 可以UPDATE a JOIN b ON ... SET ...,PG 不能用 JOIN 直接拼在 UPDATE 后面,得用FROM子句:

update users u set email = o.contact_email from orders o where o.user_id = u.id and o.contact_email is not null;

注意这里的where既承担“关联条件”也承担“过滤条件”。多条订单对应一个用户时,PG 会随机选一行来更新(不保证是哪条订单的邮箱),所以关联前要确认数据唯一性。如果不唯一,建议先做子查询去重:

update users u set email = t.email from ( select distinct on (user_id) user_id, contact_email as email from orders order by user_id, created_at desc ) t where t.user_id = u.id;

5.3 批量更新时的锁与性能注意

批量 UPDATE 大表时,PG 的行锁、锁等待是实打实的问题。如果你用一个 UPDATE 更新 100 万行,事务会一直持有大量行锁,其他会话的读写会被堵住。凌晨跑任务没问题,白天在线系统这么干,必然引来线上告警。

我的做法是分批更新,比如每次 5000 行:

update users set status = 'inactive' where status = 'active' and id in ( select id from users where status = 'active' order by id limit 5000 );

跑完一批再跑下一批,循环到影响行数为 0。每批提交一次,锁释放得快,也能随时暂停观察负载。配合pg_stat_activity看看有没有长事务,就能做到心里有数。

6. DELETE:删除、清空与误删抢救

6.1 DELETE 与 TRUNCATE

delete from users where id = 1这是逐行删除,会触发行级触发器,会逐条写 WAL 日志,数据量一大就慢。要清空整张表,用truncate:

truncate table users;

truncate直接释放表的存储,速度快到离谱,但它会重置表的自增序列吗?默认不重置。如果你想清空数据并且让自增 id 从头开始,得加restart identity:

truncate table users restart identity;

这是最推荐的做法。truncate还有一层:它不能对有外键引用它的表直接用,除非在语句里也把关联表一起列出,或者加cascade:

truncate table users cascade;

cascade会把依赖该表的外键表也一并清空(除非外键是on delete cascade)。所以这个命令要谨慎,你得清楚哪些表会中招。我的习惯:先查一下外键关系再动手,绝不盲敲 cascade。

6.2 CASCADE 与约束

外键on delete cascade的意思是“删主表记录时,自动删除子表关联记录”。设计上它不是坏东西,但在生产环境要权衡。比如orders外键到users,你delete from users where id = 1,所有相关订单瞬间被删,数据恢复难度直线上升。我的经验:核心业务表,外键尽量用on delete restrict(默认行为),删不掉就报错让你知道有子数据存在;要清理,先显式处理子表,再删主表。

6.3 误删数据的抢救思路

写“基本 SQL”的文章本不该牵涉恢复,但删错数据是三年来我见过最多的事故。讲点保命的东西:PG 的 MVCC 机制决定了,旧版本的数据在vacuum之前还留在数据文件里。如果你刚执行完 DELETE,立刻停止一切写操作,有一定概率从 WAL 日志或堆文件里抢救数据。有几种思路:

  1. 如果那个时刻点有备份:直接做基于时间点恢复(PITR)到误删之前,这是最稳的。
  2. 没有备份但有 WAL 归档:同样靠 PITR 或pg_waldump定位误删事务,再重建一个临时实例拉取数据。
  3. 什么都没配置:难度很大,基本只能自认倒霉。

所以对“DELETE 时效性数据”这件事,我常年坚持两条习惯:一是删除前先select count(*)和select * limit 10看清楚删什么;二是所有核心生产库必须打开连续归档并定期做全量备份测试恢复。

7. 事务:增删改查的“后悔药”

7.1 BEGIN / COMMIT / ROLLBACK

基本 CRUD 单条语句执行时,PG 默认是隐式事务,每句自动提交。但如果你要“先插订单、再扣库存、再更新用户余额”,就得手动开一个事务,保证要么全成功要么全失败:

begin; insert into orders (user_id, amount) values (1, 100); update users set balance = balance - 100 where id = 1; update products set stock = stock - 1 where id = 10; commit;

中途任何一条语句报错,事务自动变成 aborted 状态,后面所有语句都不让执行,只能rollback。还有一点:PG 的 DDL(建表、改字段)也支持事务回滚,这点跟 MySQL 不一样,你可以在事务里create table,回滚后表消失,这非常利于做迁移脚本调试。

7.2 读已提交与未提交的变化

PG 默认隔离级别是read committed,意思是每个语句只能看到已提交的数据。当你在事务 A 里插入一条数据但还没 commit,事务 B 是看不到这条数据的;同样,事务 A 自己能看到自己没提交的修改。这个特性容易引发一个经典场景:你开了事务,插入数据,然后在同一个连接里做查询,数据在;但你在另一个窗口查,怎么都查不到——这不是 BUG,是隔离级别,很多人第一次遇到就慌。真要同时看到,可以在事务里改隔离级别为repeatable read,但这又带来序列化失败的可能,不建议新手碰。

7.3 SAVEPOINT

事务里某一步出问题,不想全盘回滚,只想回退到中间某个点,可以用保存点:

begin; insert into users (name) values ('张三'); savepoint sp1; insert into users (name) values ('李四'); -- 李四这条插入出错了 rollback to sp1; -- 张三还在,把它提交 commit;

这对批量导入特别有用:一批数据里偶发几行坏数据,用保存点把坏行隔离掉,其余正常入库。比起“整批失败再重头来”,效率高很多。

8. 常见报错与排查实录

8.1 高频报错速查表

报错信息原因处理
syntax error at or near "xxx"多半是用了 MySQL 关键字或语法检查反引号、AUTO_INCREMENT、ON DUPLICATE KEY UPDATE等 MySQL 习惯写法
column "xxx" does not exist列名大小写被折叠建表时不要用双引号包大写列名;查询列名用全小写
operator does not exist: integer = text类型不匹配,比如数字列和字符串比较改用where id = '123'::int或用where id = cast('123' as int)
duplicate key value violates unique constraint唯一键冲突确认数据是否该被 upsert 覆盖,还是业务上重复
there is no unique or exclusion constraint matching the ON CONFLICT specificationon conflict后指定的列没有唯一约束给冲突目标列建立唯一索引
permission denied for table用户没有表权限用超级用户或 owner 授权grant select, insert, update, delete on ...
null value in column "xxx" violates not-null constraint插入了 NULL 到 not null 列传值或给默认值

这里面最烦的是“类型不匹配”。比如你从 CSV 里读出来的user_id是字符串,直接拼 SQL 拿去和 integer 列比较,PG 会拒绝隐式转换并报 operator 不存在,MySQL 通常默默做了转换。PG 的严格是特性,不是缺点,它能拦住大量隐性错误。

8.2 一个典型场景:外部工具连不上数据库

Navicat、pgAdmin、DBeaver 这类工具连不上 PG,十有八九不是密码错,而是这三件事:

  1. 服务没启动。Windows 上服务名叫postgresql-x64-16,用管理员身份运行net start postgresql-x64-16。Linux/macOS 用pg_ctl或 systemd 查状态。
  2. pg_hba.conf 不许远程连接。默认配置只允许本机127.0.0.1用 scram-sha-256 认证,要远程就连,得在配置文件里加一条允许网段的规则,然后pg_ctl reload。
  3. 端口被防火墙挡了。5432 没放行,外网当然连不上。

我自己排查顺序是:先psql本机连一次,本机能连说明服务没毛病;再用工具连,失败就看错误是“timeout”还是“auth failed”。“timeout”多半是网络和防火墙,“auth failed”才是密码或认证方式问题,方向不一样,别对着密码改半天网络配置。

还有一个细节:PostgreSQL 客户端默认对密码用 SCRAM 加密校验,如果你的服务版本旧(10 以下)、密码里带特殊字符,部分老客户端工具连不上,升级一下工具版本基本能解。

再分享一个我长期在用的排查命令:

select pid, state, wait_event_type, query_start, now() - query_start as duration, query from pg_stat_activity where state = 'active' order by query_start;

看看有没有长期处于 active 状态的慢查询,有没有锁等待,都能在这里一行看出来。这个视图对定位“数据库怎么突然卡了”这类问题,价值比翻任何日志都高。

我个人在这条路上最深的体会是:SQL 这种东西,看十篇教程不如自己跑通一遍。你拿一个本地 PG 实例,建三张有关联的表,往里插几十行数据,然后反复练 INSERT 的 upsert、SELECT 的 distinct on、UPDATE 的联表、DELETE 的级联清理,每个都加上returning看一眼输出,跑过一次就忘不掉。等到你发现“哦,原来这个报错是这么回事”的时候,基本就算是真正上手了。

版权声明: 本文来自互联网用户投稿,该文观点仅代表作者本人,不代表本站立场。本站仅提供信息存储空间服务,不拥有所有权,不承担相关法律责任。如若内容造成侵权/违法违规/事实不符,请联系邮箱:809451989@qq.com进行投诉反馈,一经查实,立即删除!
网站建设 2026/9/28 13:14:39

高维数据角点定位:缺角屏幕工业检测的Yolo-ArbV2方案

1. 屏幕角点定位到底难在哪1.1 从一块碎屏说起手机摔地上&#xff0c;屏幕左上角磕掉一块&#xff0c;售后检测设备要判断这块屏还能不能修、触控有没有偏移、贴合是否到位。检测工位上那台工业相机拍下屏幕图像&#xff0c;算法需要在图像里找到屏幕的四个角点——左上、右上、…

作者头像 李华
网站建设 2026/9/28 13:14:19

游戏脚本法定分析:从2048自动博弈到cmd启动器的合规边界

我最早被“游戏脚本”这个词吸引&#xff0c;是因为看到群里有人为了一个网页小游戏反复刷熟练度&#xff0c;硬写了一整晚按键脚本&#xff0c;第二天却被官方封号&#xff1b;也有人从网上Copy了一段“逆天脚本”&#xff0c;粘到控制台后页面直接卡死&#xff0c;连浏览器标…

作者头像 李华
网站建设 2026/9/28 13:14:12

AgentScope多智能体框架实战:从核心原理到多角色协作研究助手

1. 为什么我会盯上 AgentScope 这个多智能体框架第一次听到 AgentScope 这个名字&#xff0c;是在一个做智能客服系统的朋友那里。他当时吐槽说&#xff0c;用传统方式编排多个 AI 角色协作&#xff0c;代码写得像蜘蛛网&#xff0c;一个角色改个提示词&#xff0c;整条链路都得…

作者头像 李华
网站建设 2026/9/28 13:13:55

两阶段鲁棒优化与CCG算法:微电网不确定性调度的核心方法

1. 两阶段鲁棒优化到底在解决什么问题接触两阶段鲁棒优化这个方向之前&#xff0c;我一直在用传统的确定性优化跑微网调度模型&#xff0c;也就是把光伏出力、负荷曲线都当成已知量&#xff0c;输入一个固定的预测值&#xff0c;求解器算出机组出力计划。这个东西做论文仿真确实…

作者头像 李华
网站建设 2026/9/28 13:12:22

ARIMAX多变量时间序列预测:从原理到Python实现与避坑指南

简介&#xff1a;基于ARIMAX的多变量预测模型Python源码与配套数据集&#xff0c;面向有一定时间序列分析基础、希望用Python实现多元外生变量预测的读者&#xff0c;常用于经济指标、销量预测、能源负荷等场景&#xff0c;也是科研与竞赛中常用的预测方案。压缩包内共7个文件&…

作者头像 李华
网站建设 2026/9/28 13:12:20

静态验证实践指南:从工具选型到CI流水线接入的完整路径

写静态验证这事儿&#xff0c;我先说个真实场景。上个月我在改一个老服务的内存缓存逻辑&#xff0c;代码自测没问题&#xff0c;提交后CI却在五分钟时报红了&#xff0c;跑下来的错误指向一处我没注意到的空指针分支。其实这不算编译错误&#xff0c;也不至于让功能立刻崩溃&a…

作者头像 李华