第2章 SQL 基础(MySQL 逐条对照)
🎯 本章学习目标
- 会建库建表:理解 PG 无
ENGINE、无AUTO_INCREMENT,改用SERIAL/GENERATED ... AS IDENTITY做自增主键。 - 掌握 PG 的类型系统优势:
TEXT(不限长且不慢)、JSONB、数组[]、UUID、BOOLEAN、NUMERIC,并能和 MySQL 类型一一对应。 - 记住 PG 的标识符与字符串引号规则:字符串用单引号
'...',标识符用双引号"..."(没有 MySQL 的反引号)。 - 会写 DML:
INSERT ... RETURNING、批量插入、UPDATE ... FROM、DELETE,重点是 UPSERT 用INSERT ... ON CONFLICT替代 MySQL 的ON DUPLICATE KEY UPDATE。 - 掌握分页:
LIMIT n OFFSET m与标准OFFSET m ROWS FETCH NEXT n ROWS ONLY,且 PG 没有 MySQL 的LIMIT m, n逗号写法。 - 把常用字符串/日期函数从 MySQL「翻译」成 PG:
||拼接、TO_CHAR/::转换、EXTRACT、AGE、now()。 - 建立「迁移十大坑」的警觉清单。
2.1 建库建表:从 MySQL DDL 到 PG DDL
2.1.1 一张表的最小组合
MySQL 里你熟悉的建表:
-- 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;PG 的等价写法:
-- 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 '用户名';逐处差异:
| 差异点 | MySQL | PostgreSQL |
|---|---|---|
| 存储引擎 | ENGINE=InnoDB | 无(PG 只有统一引擎,写了会报错) |
| 字符集 | DEFAULT CHARSET=utf8mb4 | 建库时指定 ENCODING 'UTF8',表级不写 |
| 自增主键 | AUTO_INCREMENT | serial / bigserial / GENERATED AS IDENTITY |
| 列注释 | 内联 COMMENT '...' | 独立语句 COMMENT ON COLUMN ... |
| 布尔 | tinyint(1) 约定 | 原生 boolean(true/false) |
| 无长度文本 | TEXT | text(不限长且性能不差,PG 的 varchar/text 底层几乎等价) |
2.1.2 自增主键的三种写法
PG 里没有 AUTO_INCREMENT,有三条路,理解它们的区别很重要:
-- 写法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')
serial本质就是帮你建了个匿名序列并设为列默认值,ALTER TABLE t_user ALTER COLUMN id SET DATA TYPE bigint;之类维护时它不会自动跟着改类型。新项目建议用写法2IDENTITY:更贴标准、和列类型绑定更清晰。第 5 章 Java 取回生成主键、第 7 章迁移数据时的 id 处理都会用到这里的差异。
2.1.3 数据类型对照表(PG 特色类型加粗)
| 用途 | MySQL | PostgreSQL | 备注 |
|---|---|---|---|
| 自增整数主键 | INT AUTO_INCREMENT | serial / IDENTITY | 见 2.1.2 |
| 变长字符串 | VARCHAR(n) | varchar(n) | 可省略 n(等同 text) |
| 长文本 | TEXT | text | PG 里 text 不慢,比 varchar 用起来更自由 |
| 定长字符 | CHAR(n) | char(n) | 行为一致(右补空格) |
| 精确小数 | DECIMAL(p,s) | numeric(p,s) | 同义,PG 的 numeric 精度极高 |
| 金额 | DECIMAL | numeric(12,2) | 两边都别用浮点存钱 |
| 浮点 | FLOAT/DOUBLE | real / double precision | |
| 布尔 | tinyint(1) 约定 | boolean | PG 原生 true/false/null |
| JSON | JSON/JSON 函数 | jsonb(首选)/json | jsonb 二进制可索引,见 2.2 |
| 数组 | ❌ 无原生 | int[] / text[] | PG 独有,一个列存一组值 |
| UUID | CHAR(36) 存 | uuid 原生类型 | 16 字节,比较/索引高效 |
| 日期时间 | DATE/DATETIME | date / timestamp / timestamptz | 见下方 ⚠️ |
| 时间间隔 | ❌ | interval | INTERVAL '3 days' 可直接加减 |
⚠️ 日期时间大坑:PG 的
timestamp不带时区(等同 MySQL DATETIME),timestamptz(timestamp with time zone)会按会话时区自动换算并存为 UTC。跨时区应用强烈建议统一用timestamptz。而 PG 的date类型只含日期、不含时分秒(这点比达梦友好,达梦的 DATE 反而含时间)。
2.2 JSONB:关系库里的文档能力
PG 的杀手级特性之一。用商品动态属性演示:
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';对照 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"}' → true | boolean | ✅ 首选 |
| 数组里含某个元素 | @> | attrs @> '{"tags":["促销"]}' → true | boolean | ✅ |
| 有某个键(不管值) | ? | attrs ? 'size' → true;attrs ? 'weight' → false | boolean | ✅(仅 jsonb_ops) |
| 任一个键存在 | ?| | attrs ?| ARRAY['size','weight'] → true | boolean | ✅(仅 jsonb_ops) |
| 全部键都存在 | ?& | attrs ?& ARRAY['size','color'] → true | boolean | ✅(仅 jsonb_ops) |
| 被包含于 | <@ | attrs <@ '{"size":"L","color":"白","tags":["促销","春夏"],"x":1}' → true | boolean | ✅(仅 jsonb_ops) |
| 取子对象 | -> | attrs -> 'tags' → ["促销", "春夏"] | jsonb | ❌ 取值不是检索 |
| 取值并转文本 | ->> | attrs ->> 'size' → L | text | ❌ 要索引需建表达式索引 |
| 按路径取值 | #> / #>> | attrs #>> '{tags,0}' → 促销 | jsonb / text | ❌ |
| 某个键等于某个值 | 没有直接操作符 | 两条等价写法:attrs @> '{"size":"L"}'(✅走 GIN)或 attrs->>'size'='L'(❌) | boolean | 推荐前者 |
一句话记法:问号族(? / ?| / ?&)问的是「键」,箭头族(-> / ->>)取的是「值」,@> / <@ 比的是「整段结构」。业务里优先用 @>:语义清楚、参数能整段传、而且能被 GIN 索引接住(第 6 章)。
⚠️ 两个必踩的坑:
->取出来的是 jsonb 值,所以attrs -> 'size' = 'L'不成立(类型不同比),要比较就用->>或直接@>。?在 JDBC / MyBatis 里会和预编译占位符撞车,psql 里也和管道符混淆。改用等价函数:jsonb_exists(attrs,'size')、jsonb_exists_any(attrs, ARRAY['a','b'])、jsonb_exists_all(attrs, ARRAY['a','b']),语义与操作符完全一致。
-- 把上面的表跑一遍(就查 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; 包含_l | 包含_促销 | 有_size_键 | 有_weight_键 | 任一键 | 全有键 | 取文本 | 路径取值
--------+-----------+------------+-------------+--------+--------+--------+----------
t | t | t | f | t | t | L | 促销2.3 引号规则:字符串单引号,标识符双引号,没有反引号
| 你想表达 | MySQL | PostgreSQL |
|---|---|---|
| 字符串常量 | 'abc' 或 "abc" | 只能 'abc'(双引号不是字符串!) |
| 引用标识符(表/列名) | 反引号 `order` | 双引号 "order" |
| 保留字当列名 | 反引号包裹 | 双引号包裹,如 SELECT "order" FROM ... |
| 注释 | --、#、/* */ | --、/* */(没有 # 行注释) |
SELECT name AS "用户名", 'hello' AS greeting -- 双引号=别名标识符,单引号=字符串
FROM t_user
WHERE name = 'O''Brien'; -- 单引号转义:再写一个单引号迁移高频 bug:把 MySQL 的
SELECT `status` FROM ...直接搬到 PG 会语法错误——PG 不认识反引号,要改成SELECT "status"(或者干脆别用保留字列名)。同理 MySQL 的# 注释在 PG 里非法。
2.4 DML:增删改(重点在 UPSERT)
2.4.1 插入与 RETURNING
-- 单条,插入后直接拿回生成的主键和计算列(比 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)注意:PG 没有 MySQL 的
INSERT ALL(那是 Oracle/达梦语法)。批量插入就是标准的VALUES (...),(...),(...)多值组。
2.4.2 更新与删除
-- 普通更新
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;对照 MySQL 的 UPDATE t JOIN ...:PG 用 UPDATE ... FROM ...,语法不同但目的一致,迁移时必改。
2.4.3 UPSERT:INSERT ... ON CONFLICT
这是「存在则更新、不存在则插入」的标准解法,取代 MySQL 的 ON DUPLICATE KEY UPDATE:
-- 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;要点:ON CONFLICT (列) 里必须是你建了唯一约束/唯一索引的列;EXCLUDED 是虚拟表,代表因冲突而没能插入的那行的新值。这个语义比 MySQL 的 ON DUPLICATE KEY 更明确(后者对所有唯一键都可能触发,易误伤)。
2.5 分页:LIMIT / OFFSET
-- 写法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;深分页性能问题和 MySQL 一样:OFFSET 1000000 会先扫过百万行。优化思路(游标式分页,用 WHERE id > lastId LIMIT 10)与第 4 章 MySQL/MongoDB 课程里讲的一致——基于排序键续翻,不用 offset。
2.6 常用函数对照(MySQL → PG)
| 需求 | MySQL | PostgreSQL |
|---|---|---|
| 字符串拼接 | 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_DATE | TO_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_LENGTH | length()(按字符)/octet_length()(字节) |
| 子串 | SUBSTRING(s,1,3) | substring(s from 1 for 3) 或 substring(s,1,3) |
几个高频示例:
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; -- 组内拼串2.7 迁移十大坑速查表
| # | MySQL 习惯 | 在 PG 里的后果 | 正确姿势 |
|---|---|---|---|
| 1 | AUTO_INCREMENT | 语法错误 | serial / GENERATED AS IDENTITY |
| 2 | ENGINE=InnoDB、CHARSET= | 语法错误 | 删掉;字符集在建库时定 |
| 3 | 反引号 `col` | 语法错误 | 双引号 "col",或不用保留字列名 |
| 4 | LIMIT 20,10 逗号分页 | 语法错误 | LIMIT 10 OFFSET 20 |
| 5 | ON DUPLICATE KEY UPDATE | 语法错误 | INSERT ... ON CONFLICT DO UPDATE |
| 6 | 双引号当字符串 "abc" | 把 abc 当列名,报列不存在 | 字符串一律单引号 |
| 7 | # 注释 | 语法错误 | -- 或 /* */ |
| 8 | TINYINT(1) 当布尔 | 能用但不地道 | 用原生 boolean |
| 9 | datetime | 无时区,跨时区易错 | 优先 timestamptz |
| 10 | 大小写混排的表名 | 折叠成小写后处处对不上 | 全小写下划线命名,别加引号 |
| 11 | 空字符串 '' | PG 里 '' 不等于 NULL(和达梦/Oracle 相反) | 需要判空就正常 = '' 或 IS NULL 分开处理 |
第 11 条特别提示:你在达梦课学过「空串即 NULL」,那是 Oracle 系血统的特性;PG 遵循标准,空串
''和 NULL 是两回事,别把达梦的这条经验带过来。
2.8 本章小结
- 建表去掉
ENGINE/CHARSET/AUTO_INCREMENT,自增用serial/IDENTITY,注释用独立COMMENT ON。 - PG 类型更强:
text不限长不慢、jsonb可索引、boolean/uuid/数组/interval原生;日期首选timestamptz。 - 引号规则是硬差异:字符串单引号、标识符双引号、没有反引号和
#注释。 - DML 记住
RETURNING(插入/更新/删除都能带回列)、UPDATE ... FROM、限量更新靠子查询,UPSERT 用ON CONFLICT。 - 分页只有
LIMIT n OFFSET m;函数对照表里||、::、TO_CHAR、string_agg、date_trunc是必换项。
✏️ 课后练习
- 把开头那段 MySQL 建表语句亲手改写成合法 PG DDL(用
IDENTITY),并用COMMENT ON给两列加注释,\d t_user验证。 - 建一张带
jsonb attrs的表,插入 3 条不同属性,写出:按某键等值查、按键存在查、按数组包含查 三种 SQL。 - 复现「空串≠NULL」:
INSERT INTO t(name) VALUES(''),(NULL);然后分别WHERE name=''与WHERE name IS NULL,观察各命中谁,和达梦行为对比。 - 用
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 原版):
-- 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;第 2 步,改写成品(ENGINE/CHARSET 删除、AUTO_INCREMENT 换 IDENTITY、DATETIME 换 timestamptz):
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 '积分';第 3 步,在 psql 里执行上面三句(会分别返回 CREATE TABLE 和三个 COMMENT),然后验结构:
\d+ t_user关键输出(对账点:自增列的 Default 那一栏):
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)看到
generated by default as identity才算用对了写法 2;如果你写的是serial,这一栏会变成nextval('t_user_id_seq'::regclass)—— 这就是 2.1.2 里两种写法的唯一可见差异。
第 4 步,验注释真的落库了(\d+ t_user 最后的 Description 列,或直接查系统表):
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
-- --------------+---------------
-- 用户名 | 积分第 5 步,跑一次自增并验证发号(列注释、类型、默认值三件事同时验):
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第 6 步(选做,体会 ALWAYS 与 BY DEFAULT 的区别):把上面的 BY DEFAULT 换成 ALWAYS 后手动指定 id:
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第 2 题:建带 jsonb attrs 的表插 3 条,写出「按某键等值查 / 按键存在查 / 按数组包含查」三种 SQL
第 1 步,建表 + 播种(三行属性故意做成三种形态:两键+数组、单值、缺 size 键):
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 行)注意输出里
id是 1/2/3:jsonb 会以二进制重存,键序不保留但值不变,{"size":"L","color":"白"}查出来通常显示为{"color": "白", "size": "L"}(按字典序展示),这不是 bug。
第 2 步,按某键等值查——用 ->> 取出文本再比(-> 取出来是 json 值,跟字符串比不成立):
SELECT name, attrs->>'size' AS size FROM t_2x_prod WHERE attrs->>'color' = '白';
-- name | size
-- ------+------
-- 衬衫 | L ← 只有衬衫的 color 是「白」第 3 步,按键存在查——用 ?(问「有没有这个键」):
SELECT name FROM t_2x_prod WHERE attrs ? 'size' ORDER BY id;
-- name
-- ------
-- 衬衫
-- 键盘 ← 鼠标那行没有 size 键,被排除第 4 步,按数组包含查——用 @>(jsonb 包含,能吃到 GIN 索引,第 6 章):
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
-- ------
-- 衬衫
-- 键盘第 5 步,把三种写法整理成选型表(面试与实战都用得上):
| 需求 | 写法 | 可走 GIN 索引 | 说明 |
|---|---|---|---|
| 某键等于某值 | attrs->>'color' = '白' | ❌(函数表达式) | 想索引需建表达式索引 (attrs->>'color') |
| 某键是否存在 | attrs ? 'size' | ✅(jsonb_ops) | ? 在 Java 里和占位符撞车,改用 jsonb_exists(attrs,'size') |
| 结构包含 | attrs @> '{...}' | ✅ | 最推荐:语义清楚 + 索引友好 + 参数可整段传 |
第 3 题:复现「空串 ≠ NULL」,并和达梦行为对比
第 1 步,建表并播三种值(空串、NULL、正常串各一行):
CREATE TABLE t_2x_note (id int PRIMARY KEY, name text);
INSERT INTO t_2x_note VALUES (1, ''), (2, NULL), (3, 'abc');第 2 步,先看服务端怎么存的(IS NULL 与 length 是最直接的判据):
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第 3 步,分别用两种条件查,确认各自命中谁:
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(把两种「没填」并成一类)第 4 步,得出 PG 的结论并和达梦对照(这是本题真正要记的东西):
PG(遵循 SQL 标准): '' 是一个长度 0 的值,NULL 是「无值」,两者严格区分
WHERE name='' → 只命中空串行
WHERE name IS NULL → 只命中 NULL 行
达梦/Oracle(历史包袱):写入 '' 会被存成 NULL,两者合并
WHERE name='' → 一条都不命中(等价 name = NULL,恒为 unknown)
WHERE name IS NULL → 空串行和 NULL 行都命中迁移实战提醒:从达梦/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 上有唯一约束,所以建表时就得留好:
CREATE TABLE t_2x_config (
k text PRIMARY KEY, -- 主键即唯一索引,可作为冲突目标
v text NOT NULL,
updated timestamptz NOT NULL DEFAULT now()
);第 2 步,第一次执行(此时表里没有 theme,走「插入」分支):
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 说明这次真的插了新行第 3 步,同键再执行一次(这次冲突,走 UPDATE 分支,值被改掉且不产生第二行):
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第 4 步,解释 EXCLUDED(本题的得分点):EXCLUDED 是一个虚拟表,代表「这一次本想插入、但因为撞了冲突目标而没能插进去的那一行」,列名就是你 INSERT 里写的列,EXCLUDED.v 即新值 'dark'。所以:
- SET 的右值可以用
EXCLUDED.*(拿新值),左值是被锁定的既有行列; - 想「保留旧值 + 只在旧值满足条件时才改」,用
WHERE挂在DO UPDATE后面:
-- 只在旧版本更低时才覆盖(乐观 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第 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 必须显式指明冲突目标,行为可预测得多。
