预见猿份
主题
首页面试题模拟面试SQL练习在线工具关于我们老苗一对一私教学员评价
实战项目
零基础学习路线项目前置基础创新WMS项目Java微服务框架与实战云岚到家项目闪聚支付项目学成在线项目青橙电商项目JVM原理与实战调优分布式事务专题Java高频面试题MySQL从入门到精通达梦数据库从入门到实战MongoDB从入门到实战PostgreSQL从入门到实战TiDB从入门到实战Java数据结构与算法Java 并发编程(JUC)实战课程老苗一对一私教学员评价
blog我的会员
  • PostgreSQL从入门到实战|Java 程序员的进阶关系数据库课程

    • 课程介绍
    • 第1章 认识PostgreSQL与环境搭建
    • 第2章 SQL基础(MySQL对照)
    • 第3章 高级SQL与编程对象
    • 第4章 事务、锁与MVCC
    • 第5章 Java集成实战
    • 第6章 索引与性能优化
    • 第7章 MySQL迁移与日常运维







----- 到底线了 -----

第2章 SQL 基础(MySQL 逐条对照) ​

🎯 本章学习目标 ​

  1. 会建库建表:理解 PG 无 ENGINE、无 AUTO_INCREMENT,改用 SERIAL/GENERATED ... AS IDENTITY 做自增主键。
  2. 掌握 PG 的类型系统优势:TEXT(不限长且不慢)、JSONB、数组 []、UUID、BOOLEAN、NUMERIC,并能和 MySQL 类型一一对应。
  3. 记住 PG 的标识符与字符串引号规则:字符串用单引号 '...',标识符用双引号 "..."(没有 MySQL 的反引号)。
  4. 会写 DML:INSERT ... RETURNING、批量插入、UPDATE ... FROM、DELETE,重点是 UPSERT 用 INSERT ... ON CONFLICT 替代 MySQL 的 ON DUPLICATE KEY UPDATE。
  5. 掌握分页:LIMIT n OFFSET m 与标准 OFFSET m ROWS FETCH NEXT n ROWS ONLY,且 PG 没有 MySQL 的 LIMIT m, n 逗号写法。
  6. 把常用字符串/日期函数从 MySQL「翻译」成 PG:|| 拼接、TO_CHAR/:: 转换、EXTRACT、AGE、now()。
  7. 建立「迁移十大坑」的警觉清单。

2.1 建库建表:从 MySQL DDL 到 PG DDL ​

2.1.1 一张表的最小组合 ​

MySQL 里你熟悉的建表:

sql
-- MySQL
CREATE TABLE t_user (
  id     INT PRIMARY KEY AUTO_INCREMENT,
  name   VARCHAR(50) NOT NULL,
  age    INT DEFAULT 0,
  email  VARCHAR(100) UNIQUE,
  score  DECIMAL(10,2),
  created DATETIME DEFAULT CURRENT_TIMESTAMP
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
1
2
3
4
5
6
7
8
9

PG 的等价写法:

sql
-- PostgreSQL
CREATE TABLE t_user (
  id      serial PRIMARY KEY,           -- 自增:serial = 建序列 + 默认 nextval
  name    varchar(50) NOT NULL,
  age     int DEFAULT 0,
  email   varchar(100) UNIQUE,
  score   numeric(10,2),
  created timestamp DEFAULT now()
);
-- 注释要单独写(PG 建表时不能内联 COMMENT)
COMMENT ON TABLE  t_user IS '用户表';
COMMENT ON COLUMN t_user.name IS '用户名';
1
2
3
4
5
6
7
8
9
10
11
12

逐处差异:

差异点MySQLPostgreSQL
存储引擎ENGINE=InnoDB无(PG 只有统一引擎,写了会报错)
字符集DEFAULT CHARSET=utf8mb4建库时指定 ENCODING 'UTF8',表级不写
自增主键AUTO_INCREMENTserial / bigserial / GENERATED AS IDENTITY
列注释内联 COMMENT '...'独立语句 COMMENT ON COLUMN ...
布尔tinyint(1) 约定原生 boolean(true/false)
无长度文本TEXTtext(不限长且性能不差,PG 的 varchar/text 底层几乎等价)

2.1.2 自增主键的三种写法 ​

PG 里没有 AUTO_INCREMENT,有三条路,理解它们的区别很重要:

sql
-- 写法1:serial(老牌语法糖,最省事)
id serial PRIMARY KEY          -- 等价于 int + 建一个序列 + 默认 nextval(...);要更大用 bigserial

-- 写法2:GENERATED AS IDENTITY(SQL 标准,PG10+ 推荐)
id int GENERATED ALWAYS AS IDENTITY PRIMARY KEY    -- ALWAYS 禁止手动插入该列
id int GENERATED BY DEFAULT AS IDENTITY PRIMARY KEY -- BY DEFAULT 允许手动指定(迁移带 id 数据时方便)

-- 写法3:手动序列(需要独立管理号段/跨表共享时)
CREATE SEQUENCE seq_user_id;
-- 插入时:id = nextval('seq_user_id')
1
2
3
4
5
6
7
8
9
10

serial 本质就是帮你建了个匿名序列并设为列默认值,ALTER TABLE t_user ALTER COLUMN id SET DATA TYPE bigint; 之类维护时它不会自动跟着改类型。新项目建议用写法2 IDENTITY:更贴标准、和列类型绑定更清晰。第 5 章 Java 取回生成主键、第 7 章迁移数据时的 id 处理都会用到这里的差异。

2.1.3 数据类型对照表(PG 特色类型加粗) ​

用途MySQLPostgreSQL备注
自增整数主键INT AUTO_INCREMENTserial / IDENTITY见 2.1.2
变长字符串VARCHAR(n)varchar(n)可省略 n(等同 text)
长文本TEXTtextPG 里 text 不慢,比 varchar 用起来更自由
定长字符CHAR(n)char(n)行为一致(右补空格)
精确小数DECIMAL(p,s)numeric(p,s)同义,PG 的 numeric 精度极高
金额DECIMALnumeric(12,2)两边都别用浮点存钱
浮点FLOAT/DOUBLEreal / double precision
布尔tinyint(1) 约定booleanPG 原生 true/false/null
JSONJSON/JSON 函数jsonb(首选)/jsonjsonb 二进制可索引,见 2.2
数组❌ 无原生int[] / text[]PG 独有,一个列存一组值
UUIDCHAR(36) 存uuid 原生类型16 字节,比较/索引高效
日期时间DATE/DATETIMEdate / timestamp / timestamptz见下方 ⚠️
时间间隔❌intervalINTERVAL '3 days' 可直接加减

⚠️ 日期时间大坑:PG 的 timestamp 不带时区(等同 MySQL DATETIME),timestamptz(timestamp with time zone)会按会话时区自动换算并存为 UTC。跨时区应用强烈建议统一用 timestamptz。而 PG 的 date 类型只含日期、不含时分秒(这点比达梦友好,达梦的 DATE 反而含时间)。

2.2 JSONB:关系库里的文档能力 ​

PG 的杀手级特性之一。用商品动态属性演示:

sql
CREATE TABLE t_product (
  id     serial PRIMARY KEY,
  name   text,
  attrs  jsonb            -- 动态属性袋
);

INSERT INTO t_product(name, attrs) VALUES
 ('衬衫', '{"size":"L","color":"白","tags":["促销","春夏"]}');

-- 按键取值(-> 返回 json,->> 返回文本)
SELECT name, attrs->>'size' AS size FROM t_product WHERE attrs->>'color' = '白';

-- 数组包含判断,并可给 jsonb 建 GIN 索引加速(第6章)
SELECT * FROM t_product WHERE attrs @> '{"tags":["促销"]}';

-- 存在某键
SELECT * FROM t_product WHERE attrs ? 'size';
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17

对照 MySQL 的 JSON + JSON_EXTRACT:PG 的 jsonb 是解析后的二进制存储,可建 GIN 索引、可做键级包含查询,性能和表达力都明显更强——这正是第 3 章索引与 MongoDB 课程里「文档 vs 关系」讨论的回响。

2.2.1 jsonb 操作符速查(第 6 章的 GIN 索引就吃这几个) ​

上面代码里出现的 @>、? 不是正则、也不是三目运算符,而是 jsonb 类型的内置操作符。下面全部用上面那一行「衬衫」举例:attrs = {"size":"L","color":"白","tags":["促销","春夏"]}。

你想问什么写法举例(以上面那行为准)返回类型能走 GIN 索引?
包含某一段@>attrs @> '{"size":"L"}' → trueboolean✅ 首选
数组里含某个元素@>attrs @> '{"tags":["促销"]}' → trueboolean✅
有某个键(不管值)?attrs ? 'size' → true;attrs ? 'weight' → falseboolean✅(仅 jsonb_ops)
任一个键存在?|attrs ?| ARRAY['size','weight'] → trueboolean✅(仅 jsonb_ops)
全部键都存在?&attrs ?& ARRAY['size','color'] → trueboolean✅(仅 jsonb_ops)
被包含于<@attrs <@ '{"size":"L","color":"白","tags":["促销","春夏"],"x":1}' → trueboolean✅(仅 jsonb_ops)
取子对象->attrs -> 'tags' → ["促销", "春夏"]jsonb❌ 取值不是检索
取值并转文本->>attrs ->> 'size' → Ltext❌ 要索引需建表达式索引
按路径取值#> / #>>attrs #>> '{tags,0}' → 促销jsonb / text❌
某个键等于某个值没有直接操作符两条等价写法:attrs @> '{"size":"L"}'(✅走 GIN)或 attrs->>'size'='L'(❌)boolean推荐前者

一句话记法:问号族(? / ?| / ?&)问的是「键」,箭头族(-> / ->>)取的是「值」,@> / <@ 比的是「整段结构」。业务里优先用 @>:语义清楚、参数能整段传、而且能被 GIN 索引接住(第 6 章)。

⚠️ 两个必踩的坑:

  1. -> 取出来的是 jsonb 值,所以 attrs -> 'size' = 'L' 不成立(类型不同比),要比较就用 ->> 或直接 @>。
  2. ? 在 JDBC / MyBatis 里会和预编译占位符撞车,psql 里也和管道符混淆。改用等价函数:jsonb_exists(attrs,'size')、jsonb_exists_any(attrs, ARRAY['a','b'])、jsonb_exists_all(attrs, ARRAY['a','b']),语义与操作符完全一致。
sql
-- 把上面的表跑一遍(就查 2.2 建的 t_product 那行衬衫)
SELECT attrs @> '{"size":"L"}'                        AS 包含_L,
       attrs @> '{"tags":["促销"]}'                   AS 包含_促销,
       attrs ?  'size'                                 AS 有_size_键,
       attrs ?  'weight'                               AS 有_weight_键,
       attrs ?| ARRAY['size','weight']                 AS 任一键,
       attrs ?& ARRAY['size','color']                  AS 全有键,
       attrs ->> 'size'                                AS 取文本,
       attrs #>> '{tags,0}'                            AS 路径取值
FROM t_product;
1
2
3
4
5
6
7
8
9
10
text
 包含_l | 包含_促销 | 有_size_键 | 有_weight_键 | 任一键 | 全有键 | 取文本 | 路径取值
--------+-----------+------------+-------------+--------+--------+--------+----------
 t      | t         | t          | f           | t      | t      | L      | 促销
1
2
3

2.3 引号规则:字符串单引号,标识符双引号,没有反引号 ​

你想表达MySQLPostgreSQL
字符串常量'abc' 或 "abc"只能 'abc'(双引号不是字符串!)
引用标识符(表/列名)反引号 `order`双引号 "order"
保留字当列名反引号包裹双引号包裹,如 SELECT "order" FROM ...
注释--、#、/* */--、/* */(没有 # 行注释)
sql
SELECT name AS "用户名", 'hello' AS greeting   -- 双引号=别名标识符,单引号=字符串
FROM t_user
WHERE name = 'O''Brien';                        -- 单引号转义:再写一个单引号
1
2
3

迁移高频 bug:把 MySQL 的 SELECT `status` FROM ... 直接搬到 PG 会语法错误——PG 不认识反引号,要改成 SELECT "status"(或者干脆别用保留字列名)。同理 MySQL 的 # 注释 在 PG 里非法。

2.4 DML:增删改(重点在 UPSERT) ​

2.4.1 插入与 RETURNING ​

sql
-- 单条,插入后直接拿回生成的主键和计算列(比 MySQL 再查 LAST_INSERT_ID 优雅)
INSERT INTO t_user(name, email) VALUES ('tom', 'tom@x.com') RETURNING id, created;

-- 批量插入
INSERT INTO t_user(name) VALUES ('a'), ('b'), ('c');

-- 插入查询结果
INSERT INTO t_user(name) SELECT name FROM t_archive WHERE flag = true;

-- PG 没有 MySQL 的 INSERT ... ON DUPLICATE KEY UPDATE,改用 ON CONFLICT(见 2.4.3)
1
2
3
4
5
6
7
8
9
10

注意:PG 没有 MySQL 的 INSERT ALL(那是 Oracle/达梦语法)。批量插入就是标准的 VALUES (...),(...),(...) 多值组。

2.4.2 更新与删除 ​

sql
-- 普通更新
UPDATE t_user SET score = score + 10 WHERE id = 1;

-- ⚠️ PG 没有 MySQL 的 UPDATE ... LIMIT n 写法。要限量更新用 CTID/子查询:
UPDATE t_user SET status = 'vip'
WHERE id IN (SELECT id FROM t_user WHERE score > 100 ORDER BY id LIMIT 10);

-- 更新可以带 RETURNING,删也能带 RETURNING
DELETE FROM t_user WHERE id = 5 RETURNING name;

-- UPDATE ... FROM:用另一张表的数据来更新(对应 MySQL 的 UPDATE a JOIN b)
UPDATE t_user u
SET score = s.add_score
FROM t_score_delta s
WHERE u.id = s.user_id;
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15

对照 MySQL 的 UPDATE t JOIN ...:PG 用 UPDATE ... FROM ...,语法不同但目的一致,迁移时必改。

2.4.3 UPSERT:INSERT ... ON CONFLICT ​

这是「存在则更新、不存在则插入」的标准解法,取代 MySQL 的 ON DUPLICATE KEY UPDATE:

sql
-- MySQL 思维:
-- INSERT INTO t_config(k,v) VALUES('theme','dark')
-- ON DUPLICATE KEY UPDATE v='dark';

-- PostgreSQL 写法:冲突目标写清楚,再指定更新动作
INSERT INTO t_config(k, v) VALUES ('theme', 'dark')
ON CONFLICT (k) DO UPDATE SET v = EXCLUDED.v;    -- EXCLUDED 指代"本想插入的那行"

-- 只想"冲突就忽略":
INSERT INTO t_config(k, v) VALUES ('x', '1')
ON CONFLICT (k) DO NOTHING;
1
2
3
4
5
6
7
8
9
10
11

要点:ON CONFLICT (列) 里必须是你建了唯一约束/唯一索引的列;EXCLUDED 是虚拟表,代表因冲突而没能插入的那行的新值。这个语义比 MySQL 的 ON DUPLICATE KEY 更明确(后者对所有唯一键都可能触发,易误伤)。

2.5 分页:LIMIT / OFFSET ​

sql
-- 写法1:PostgreSQL 风格(也和 MySQL 兼容)
SELECT * FROM t_user ORDER BY id LIMIT 10 OFFSET 20;   -- 跳过20取10

-- ⚠️ 不能用 MySQL 的逗号简写 LIMIT 20,10 —— PG 不认识这种写法,必须写 OFFSET 关键字

-- 写法2:SQL 标准写法(跨库移植性好)
SELECT * FROM t_user ORDER BY id OFFSET 20 ROWS FETCH NEXT 10 ROWS ONLY;
1
2
3
4
5
6
7

深分页性能问题和 MySQL 一样:OFFSET 1000000 会先扫过百万行。优化思路(游标式分页,用 WHERE id > lastId LIMIT 10)与第 4 章 MySQL/MongoDB 课程里讲的一致——基于排序键续翻,不用 offset。

2.6 常用函数对照(MySQL → PG) ​

需求MySQLPostgreSQL
字符串拼接CONCAT(a,b)a || b(也可 concat())
当前时间NOW()now() / current_timestamp
类型转换CAST(x AS CHAR)x::text(:: 是 PG 招牌转换符)或标准 CAST
格式化日期DATE_FORMAT(t,'%Y-%m-%d')TO_CHAR(t,'YYYY-MM-DD')
解析日期STR_TO_DATETO_DATE / '...'::date
取年份YEAR(t)EXTRACT(YEAR FROM t) / date_part('year',t)
日期差DATEDIFF(d1,d2)(天)d1 - d2(date 相减直接得天数)或 AGE()
日期加减DATE_ADD(t,INTERVAL 1 DAY)t + INTERVAL '1 day'
空值替代IFNULL(a,b)COALESCE(a,b)(标准,两边都有)
分组字符串聚合GROUP_CONCAT(x)string_agg(x, ',')
条件表达式IF(c,a,b)CASE WHEN c THEN a ELSE b END(PG 无 IF())
随机RAND()random()
取整ROUND(x,2)round(x, 2)(numeric 才支持第二参)
字符串长度LENGTH/CHAR_LENGTHlength()(按字符)/octet_length()(字节)
子串SUBSTRING(s,1,3)substring(s from 1 for 3) 或 substring(s,1,3)

几个高频示例:

sql
SELECT '张' || '三' AS full_name;                    -- 拼接用 ||
SELECT now()::date AS today;                          -- :: 截断到日期
SELECT TO_CHAR(created, 'YYYY-MM-DD HH24:MI') FROM t_user;   -- 格式化
SELECT EXTRACT(YEAR FROM now()) AS this_year;         -- 取部分
SELECT date_trunc('month', now()) AS month_start;      -- 截断到月初(做报表超有用)
SELECT age(now(), created) FROM t_user;               -- 返回 interval,如 '3 days 02:10:00'
SELECT id, string_agg(name, ',') FROM ... GROUP BY id; -- 组内拼串
1
2
3
4
5
6
7

2.7 迁移十大坑速查表 ​

#MySQL 习惯在 PG 里的后果正确姿势
1AUTO_INCREMENT语法错误serial / GENERATED AS IDENTITY
2ENGINE=InnoDB、CHARSET=语法错误删掉;字符集在建库时定
3反引号 `col`语法错误双引号 "col",或不用保留字列名
4LIMIT 20,10 逗号分页语法错误LIMIT 10 OFFSET 20
5ON DUPLICATE KEY UPDATE语法错误INSERT ... ON CONFLICT DO UPDATE
6双引号当字符串 "abc"把 abc 当列名,报列不存在字符串一律单引号
7# 注释语法错误-- 或 /* */
8TINYINT(1) 当布尔能用但不地道用原生 boolean
9datetime无时区,跨时区易错优先 timestamptz
10大小写混排的表名折叠成小写后处处对不上全小写下划线命名,别加引号
11空字符串 ''PG 里 '' 不等于 NULL(和达梦/Oracle 相反)需要判空就正常 = '' 或 IS NULL 分开处理

第 11 条特别提示:你在达梦课学过「空串即 NULL」,那是 Oracle 系血统的特性;PG 遵循标准,空串 '' 和 NULL 是两回事,别把达梦的这条经验带过来。

2.8 本章小结 ​

  1. 建表去掉 ENGINE/CHARSET/AUTO_INCREMENT,自增用 serial/IDENTITY,注释用独立 COMMENT ON。
  2. PG 类型更强:text 不限长不慢、jsonb 可索引、boolean/uuid/数组/interval 原生;日期首选 timestamptz。
  3. 引号规则是硬差异:字符串单引号、标识符双引号、没有反引号和 # 注释。
  4. DML 记住 RETURNING(插入/更新/删除都能带回列)、UPDATE ... FROM、限量更新靠子查询,UPSERT 用 ON CONFLICT。
  5. 分页只有 LIMIT n OFFSET m;函数对照表里 ||、::、TO_CHAR、string_agg、date_trunc 是必换项。

✏️ 课后练习 ​

  1. 把开头那段 MySQL 建表语句亲手改写成合法 PG DDL(用 IDENTITY),并用 COMMENT ON 给两列加注释,\d t_user 验证。
  2. 建一张带 jsonb attrs 的表,插入 3 条不同属性,写出:按某键等值查、按键存在查、按数组包含查 三种 SQL。
  3. 复现「空串≠NULL」:INSERT INTO t(name) VALUES(''),(NULL); 然后分别 WHERE name='' 与 WHERE name IS NULL,观察各命中谁,和达梦行为对比。
  4. 用 INSERT ... ON CONFLICT 实现「配置项存在则改值、不存在则插入」,并解释 EXCLUDED 指代什么。

📖 参考答案 ​

四条答案各自带一份自包含播种表(前缀 t_2x_),照抄即可跑,不依赖也不污染其他章节的表。执行环境沿用第 1 章:docker exec -it pg psql -U postgres -d mydb(或你本机的库)。

第 1 题:把 2.1.1 的 MySQL 建表语句改写成合法 PG DDL(用 IDENTITY),并加列注释

第 1 步,明确输入(就是要改写的那段 MySQL 原版):

sql
-- MySQL
CREATE TABLE t_user (
  id     INT PRIMARY KEY AUTO_INCREMENT,
  name   VARCHAR(50) NOT NULL,
  age    INT DEFAULT 0,
  email  VARCHAR(100) UNIQUE,
  score  DECIMAL(10,2),
  created DATETIME DEFAULT CURRENT_TIMESTAMP
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
1
2
3
4
5
6
7
8
9

第 2 步,改写成品(ENGINE/CHARSET 删除、AUTO_INCREMENT 换 IDENTITY、DATETIME 换 timestamptz):

sql
CREATE TABLE t_user (
  id      int GENERATED BY DEFAULT AS IDENTITY PRIMARY KEY,   -- 写法2:SQL 标准自增
  name    varchar(50) NOT NULL,
  age     int DEFAULT 0,
  email   varchar(100) UNIQUE,
  score   numeric(10,2),
  created timestamptz DEFAULT now()                           -- 带时区,别用裸 timestamp
);

COMMENT ON TABLE  t_user      IS '用户表';
COMMENT ON COLUMN t_user.name IS '用户名';
COMMENT ON COLUMN t_user.score IS '积分';
1
2
3
4
5
6
7
8
9
10
11
12

第 3 步,在 psql 里执行上面三句(会分别返回 CREATE TABLE 和三个 COMMENT),然后验结构:

sql
\d+ t_user
1

关键输出(对账点:自增列的 Default 那一栏):

text
                                       Table "public.t_user"
  Column  |          Type          | Collation | Nullable |             Default
----------+------------------------+-----------+----------+----------------------------------
 id       | integer                |           | not null | generated by default as identity
 name     | character varying(50)  |           | not null |
 age      | integer                |           |          | 0
 email    | character varying(100) |           |          |
 score    | numeric(10,2)          |           |          |
 created  | timestamp with time zone |         |          | now()
Indexes:
    "t_user_pkey" PRIMARY KEY, btree (id)
    "t_user_email_key" UNIQUE CONSTRAINT, btree (email)
1
2
3
4
5
6
7
8
9
10
11
12

看到 generated by default as identity 才算用对了写法 2;如果你写的是 serial,这一栏会变成 nextval('t_user_id_seq'::regclass) —— 这就是 2.1.2 里两种写法的唯一可见差异。

第 4 步,验注释真的落库了(\d+ t_user 最后的 Description 列,或直接查系统表):

sql
SELECT obj_description('t_user'::regclass)               AS table_comment;
--  table_comment
-- ---------------
--  用户表

SELECT col_description('t_user'::regclass, 2) AS name_comment,
       col_description('t_user'::regclass, 5) AS score_comment;
--  name_comment | score_comment
-- --------------+---------------
--  用户名       | 积分
1
2
3
4
5
6
7
8
9
10

第 5 步,跑一次自增并验证发号(列注释、类型、默认值三件事同时验):

sql
INSERT INTO t_user(name, score) VALUES('amy', 12.5) RETURNING id, created;
--   id |            created
--  ----+-------------------------------
--    1 | 2026-10-02 09:12:33.104822+08     ← id 从 1 起,created 由默认值 now() 填上

INSERT INTO t_user(name) VALUES('bob') RETURNING id, score, created IS NOT NULL AS has_created;
--   id | score | has_created
--  ----+-------+-------------
--    2 |       | t                 ← 没给的 score 保持 NULL(不是 0),DEFAULT 0 只作用于 age
1
2
3
4
5
6
7
8
9

第 6 步(选做,体会 ALWAYS 与 BY DEFAULT 的区别):把上面的 BY DEFAULT 换成 ALWAYS 后手动指定 id:

sql
INSERT INTO t_user(id, name) VALUES(99, 'zed');
-- ERROR:  cannot insert a non-DEFAULT value for column "id"
-- SQLSTATE: 428C9        ← ALWAYS 禁止手写值;第 7 章「迁移要带原 id」必须用 BY DEFAULT
1
2
3

第 2 题:建带 jsonb attrs 的表插 3 条,写出「按某键等值查 / 按键存在查 / 按数组包含查」三种 SQL

第 1 步,建表 + 播种(三行属性故意做成三种形态:两键+数组、单值、缺 size 键):

sql
CREATE TABLE t_2x_prod (id serial PRIMARY KEY, name text, attrs jsonb);

INSERT INTO t_2x_prod(name, attrs) VALUES
  ('衬衫', '{"size":"L","color":"白","tags":["促销","春夏"]}'),
  ('键盘', '{"size":"104键","color":"黑","tags":["新品"]}'),
  ('鼠标', '{"color":"灰"}');

SELECT id, name, attrs FROM t_2x_prod ORDER BY id;   -- 先确认输入落对了(3 行)
1
2
3
4
5
6
7
8

注意输出里 id 是 1/2/3:jsonb 会以二进制重存,键序不保留但值不变,{"size":"L","color":"白"} 查出来通常显示为 {"color": "白", "size": "L"}(按字典序展示),这不是 bug。

第 2 步,按某键等值查——用 ->> 取出文本再比(-> 取出来是 json 值,跟字符串比不成立):

sql
SELECT name, attrs->>'size' AS size FROM t_2x_prod WHERE attrs->>'color' = '白';
--  name | size
-- ------+------
--  衬衫 | L                                  ← 只有衬衫的 color 是「白」
1
2
3
4

第 3 步,按键存在查——用 ?(问「有没有这个键」):

sql
SELECT name FROM t_2x_prod WHERE attrs ? 'size' ORDER BY id;
--  name
-- ------
--  衬衫
--  键盘                                    ← 鼠标那行没有 size 键,被排除
1
2
3
4
5

第 4 步,按数组包含查——用 @>(jsonb 包含,能吃到 GIN 索引,第 6 章):

sql
SELECT name FROM t_2x_prod WHERE attrs @> '{"tags":["促销"]}' ORDER BY id;
--  name
-- ------
--  衬衫

-- 要多个标签同时命中(数组内「与」):
SELECT name FROM t_2x_prod WHERE attrs @> '{"tags":["促销","新品"]}';
--  name
-- --------            ← 空:没有一行同时带这两个标签

-- 「或」得靠索引化的两个条件并起来:
SELECT name FROM t_2x_prod
 WHERE attrs @> '{"tags":["促销"]}' OR attrs @> '{"tags":["新品"]}' ORDER BY id;
--  name
-- ------
--  衬衫
--  键盘
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17

第 5 步,把三种写法整理成选型表(面试与实战都用得上):

需求写法可走 GIN 索引说明
某键等于某值attrs->>'color' = '白'❌(函数表达式)想索引需建表达式索引 (attrs->>'color')
某键是否存在attrs ? 'size'✅(jsonb_ops)? 在 Java 里和占位符撞车,改用 jsonb_exists(attrs,'size')
结构包含attrs @> '{...}'✅最推荐:语义清楚 + 索引友好 + 参数可整段传

第 3 题:复现「空串 ≠ NULL」,并和达梦行为对比

第 1 步,建表并播三种值(空串、NULL、正常串各一行):

sql
CREATE TABLE t_2x_note (id int PRIMARY KEY, name text);
INSERT INTO t_2x_note VALUES (1, ''), (2, NULL), (3, 'abc');
1
2

第 2 步,先看服务端怎么存的(IS NULL 与 length 是最直接的判据):

sql
SELECT id, name, name IS NULL AS is_null, length(name) AS len, coalesce(name,'<NULL>') AS show
  FROM t_2x_note ORDER BY id;
--  id | name | is_null | len |  show
--  ----+------+---------+-----+----------
--   1 |      | f       |   0 |              ← 空串:不是 NULL,长度 0
--   2 |      | t       |     | <NULL>       ← NULL:长度也是 NULL(不是 0)
--   3 | abc  | f       |   3 | abc
1
2
3
4
5
6
7

第 3 步,分别用两种条件查,确认各自命中谁:

sql
SELECT id FROM t_2x_note WHERE name = '';        -- → 1(只有空串行)
SELECT id FROM t_2x_note WHERE name IS NULL;     -- → 2(只有 NULL 行)
SELECT id FROM t_2x_note WHERE name IS DISTINCT FROM '';   -- → 2,3(NULL 安全比较的写法)
SELECT count(*) FROM t_2x_note WHERE coalesce(name,'') = ''; -- → 2(把两种「没填」并成一类)
1
2
3
4

第 4 步,得出 PG 的结论并和达梦对照(这是本题真正要记的东西):

text
PG(遵循 SQL 标准):  '' 是一个长度 0 的值,NULL 是「无值」,两者严格区分
  WHERE name=''      → 只命中空串行
  WHERE name IS NULL → 只命中 NULL 行

达梦/Oracle(历史包袱):写入 '' 会被存成 NULL,两者合并
  WHERE name=''      → 一条都不命中(等价 name = NULL,恒为 unknown)
  WHERE name IS NULL → 空串行和 NULL 行都命中
1
2
3
4
5
6
7

迁移实战提醒:从达梦/Oracle 迁到 PG,原来靠 WHERE col IS NULL 兜住的「空串」数据现在要写 WHERE coalesce(col,'')='';反过来从 PG 迁走,length(col)=0 的判断在达梦里永远不成立。唯一约束在两边的行为也不同:PG 里 '' 会参与唯一性冲突、多个 NULL 互不冲突,达梦里空串即 NULL,于是「空串可以重复插」。

第 4 题:用 ON CONFLICT 实现「配置项存在则改值、不存在则插入」,并解释 EXCLUDED

第 1 步,建一张配置表——注意 ON CONFLICT (k) 要求 k 上有唯一约束,所以建表时就得留好:

sql
CREATE TABLE t_2x_config (
  k       text PRIMARY KEY,          -- 主键即唯一索引,可作为冲突目标
  v       text NOT NULL,
  updated timestamptz NOT NULL DEFAULT now()
);
1
2
3
4
5

第 2 步,第一次执行(此时表里没有 theme,走「插入」分支):

sql
INSERT INTO t_2x_config(k, v) VALUES('theme', 'light')
ON CONFLICT (k) DO UPDATE SET v = EXCLUDED.v, updated = now()
RETURNING k, v, updated > now() - interval '10 seconds' AS just_written,
          (xmax = 0) AS inserted;
--   k     |   v     | just_written | inserted
-- --------+---------+--------------+----------
--  theme  | light   | t            | t        ← inserted=t 说明这次真的插了新行
1
2
3
4
5
6
7

第 3 步,同键再执行一次(这次冲突,走 UPDATE 分支,值被改掉且不产生第二行):

sql
INSERT INTO t_2x_config(k, v) VALUES('theme', 'dark')
ON CONFLICT (k) DO UPDATE SET v = EXCLUDED.v, updated = now()
RETURNING k, v, (xmax = 0) AS inserted;
--   k     |  v    | inserted
-- --------+-------+----------
--  theme  | dark  | f        ← inserted=f:改的是既有行

SELECT count(*) FROM t_2x_config;    -- → 1(始终是同一行被更新,没有重复配置项)
SELECT v FROM t_2x_config WHERE k='theme';   -- → dark
1
2
3
4
5
6
7
8
9

第 4 步,解释 EXCLUDED(本题的得分点):EXCLUDED 是一个虚拟表,代表「这一次本想插入、但因为撞了冲突目标而没能插进去的那一行」,列名就是你 INSERT 里写的列,EXCLUDED.v 即新值 'dark'。所以:

  • SET 的右值可以用 EXCLUDED.*(拿新值),左值是被锁定的既有行列;
  • 想「保留旧值 + 只在旧值满足条件时才改」,用 WHERE 挂在 DO UPDATE 后面:
sql
-- 只在旧版本更低时才覆盖(乐观 UPSERT),否则 DO UPDATE 会变成无条件覆盖
INSERT INTO t_2x_config(k, v) VALUES('ver', 'v2')
ON CONFLICT (k) DO UPDATE SET v = EXCLUDED.v
 WHERE t_2x_config.v <> EXCLUDED.v
RETURNING k, v;
--   k   | v
-- ------+----
--  ver  | v2
1
2
3
4
5
6
7
8

第 5 步,三个必须知道的边界(踩过就回头查这段):

现象 / 报错原因正确做法
there is no unique or exclusion constraint matching the ON CONFLICT specification冲突目标列上没有唯一约束/排除约束补 PRIMARY KEY/UNIQUE,或改 ON CONFLICT DO NOTHING(不指定目标)
ON CONFLICT DO UPDATE 不能更新冲突列本身更新后就不再冲突,语义自相矛盾别写 SET k = EXCLUDED.k
唯一索引是「部分索引」(如 WHERE deleted=false)目标要写全ON CONFLICT (k) WHERE deleted = false DO UPDATE ...

对照 MySQL:ON DUPLICATE KEY UPDATE v = VALUES(v) 的 VALUES(v) ≈ PG 的 EXCLUDED.v;但 MySQL 会对任意唯一键冲突触发(表上有多个唯一键时容易误伤别的键),PG 必须显式指明冲突目标,行为可预测得多。

← 第1章 认识PostgreSQL与环境搭建第3章 高级SQL与编程对象 →








如果发现文档内容有错误或排版错乱,请及时联系站长老苗修改,不胜感激。联系我们
关于我们 | 隐私政策 | 豫ICP备2026003386号-4 | 豫公网安备41010202004008号
目录

本页无章节