PG 的 SQL 没什么门槛,写过 MySQL 的人半天就能上手。真正卡人的在:建完表查询报 relation "xxx" does not exist;删了十万行数据,表反而更大了;压测刚起来,连接数还没到一百,内存先满了。这三个现象分别指向对象模型、VACUUM 和进程模型。搞不清它们,PG 就只是个"能连上的库"。
下面按这个顺序写:装库、把概念弄对、讲 SQL 和索引、说并发与存储,最后落到和 MySQL、Oracle 的对比。赶时间可以跳着看。只想知道能不能替掉 MySQL,直接翻第五章;已经在用 PG、被膨胀或者慢查询卡住的,看 4.1、4.2 和 3.3;完全没碰过,从头读,前三章够用很久。
1. 分析PG适合你吗
如果你的业务是标准的 Web 读写、SQL 不复杂、团队里也没人写过 PG,那 MySQL 更省事。这不代表 PG 弱,只是它的长处你用不上,而它的短板------连接池、VACUUM、运维门槛。
反过来,下面这些事在 PG 里通常一条 SQL 就够,在 MySQL 里得建中间表,或者把逻辑搬回应用层。
-
**按属性过滤半结构化数据。**jsonb 加一个 GIN 索引,where attrs @> '{"channel":"app"}' 直接走索引。MySQL 的 JSON 多数情况只能靠函数索引兜一小部分。
-
**分组取最新一条、累计、排名。**DISTINCT ON、窗口函数、LATERAL 都在 SQL 层解决,不用先 group by 再 join 回来。
-
**检索和业务数据放一个库。**pgvector、PostGIS 都是扩展,装完能和普通表 JOIN,不用再维护一套外部系统。
-
**大表按时间切段、整段删除。**声明式分区,DROP PARTITION 比 DELETE 干净得多。
短板也摆在这。连接是进程模型,不配 PgBouncer,几百个连接就能把内存吃穿;表只增不减这件事必须靠 VACUUM 和 pg_repack 处理;国内云厂商的支持力度和招人难度,仍然不如 MySQL;单机写入吞吐要打平调优过的 MySQL,得花不少功夫。
所以它更适合数据模型还会变、查询复杂度说不准、或者需要把检索、地理、向量塞进同一个库的场景。一个结构稳定、以高并发简单读写为主的系统,用 PG 属于给自己找事。
2. 入门安装弄清概念
2.1 安装
练手用 Docker,生产用官方源。截至 2026 年 11 月,生产建议 18.x(当前小版本 18.6),19 还在 RC,等正式发布后跑过一两个小版本再上。
# 本地练手
docker run -d --name pg \
-e POSTGRES_PASSWORD=secret \
-p 5432:5432 \
-v pgdata:/var/lib/postgresql/data \
postgres:18
# Debian / Ubuntu
sudo apt install -y postgresql-18 postgresql-client-18
sudo systemctl enable --now postgresql
连上去先确认两件事:版本和编码。select current_setting('server_encoding') 应该是 UTF8。如果显示 SQL_ASCII,中文的排序和比较迟早出问题,而且建库之后再改很麻烦。
元命令不用背,\? 会列出全部。下面这几条是日常真会用的。
| 命令 | 什么时候用 |
|---|---|
| \l / \c dbname | 列出所有库 / 切库。跨库不能直接 JOIN,先切过去是最省事的办法 |
| \dn / \dt | 列模式 / 列当前 search_path 下的表。看不到表的时候先查 \dn |
| \d+ tablename | 看结构、索引、触发器、所属模式、磁盘占用。排障第一个该敲的命令 |
| \du / \df | 查角色和权限 / 查函数。权限问题、扩展装了哪些函数都在这 |
| \x / \timing | 结果转纵向,宽表必用 / 打开计时 |
顺手养成一个习惯:任何"表不见了""权限不对"的问题,先 \d+ 表名 和 \dn,比翻文档快得多。
2.2 库、模式、用户
层级是实例 → 数据库 → 模式 → 表。两个硬约束:跨数据库不能直接 JOIN,要用 postgres_fdw 或 dblink;同一个库里跨模式可以随便 JOIN。
这一层三家的定义完全不同,迁移事故基本都出在这。
| 层级 | PostgreSQL | MySQL | Oracle |
|---|---|---|---|
| 实例 | cluster,一个实例下多个库 | 一个实例下多个 database | 一个实例(RAC 可多实例),下含多个 schema |
| 中间层 | schema 是库里的命名空间,和用户解耦 | 没有独立 schema,database 就是 schema | schema 就是用户名,用户与 schema 绑定 |
| 跨层查询 | 跨 schema 可 JOIN,跨库要 FDW | 跨库要应用层或中间件 | 同实例跨 schema 可 JOIN |
| 常见用法 | 按租户建 schema,共享一套表结构 | 按租户建 database,物理隔离 | 按业务建用户,用户即 schema |
# 多租户:一个 schema 一套表
CREATE SCHEMA tenant_a;
SET search_path = tenant_a, public;
CREATE TABLE orders (
id bigint GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
amount numeric(12,2) NOT NULL
);
search_path 是"不写前缀时去哪找对象"。方便,也是攻击面:别把 public 模式的 CREATE 权限开给普通用户,否则可能被塞一个同名函数劫持调用。PG 15 起默认已经收紧了这块。
然后是这个最容易被误判的报错: ERROR: relation "Orders" does not exist
表确实在,只是名字被折成了 orders。PG 把不加引号的标识符折成小写,Oracle 折成大写,MySQL 看 lower_case_table_names。从 Oracle 迁过来的库,工具常为了保留原名加双引号,结果代码里必须处处带引号------迁移后"表不存在",十有八九是这个。
CREATE TABLE Orders (id int); -- 对象名是 orders,查询写 Orders 也命中
CREATE TABLE "Orders" (id int); -- 对象名就是大写 Orders,查询必须带引号
2.3 字段类型对比
| 用途 | PostgreSQL | MySQL | Oracle | 注意避坑 |
|---|---|---|---|---|
| 整数 | integer / bigint | INT / BIGINT | NUMBER(10) / NUMBER(19) | PG 没有 unsigned,需要时加 CHECK |
| 自增 | GENERATED AS IDENTITY | AUTO_INCREMENT | IDENTITY 列或 SEQUENCE | serial 是伪类型,新项目统一 identity |
| 字符串 | text / varchar(n) | VARCHAR / CHAR / TEXT | VARCHAR2 / CHAR / CLOB | text 没有长度限制,不必刻意限长 |
| 布尔 | boolean | TINYINT(1) | 没有原生布尔,用 NUMBER(1) | 0/1 和 true/false 得转换 |
| 时间 | timestamptz / date | DATETIME / TIMESTAMP | DATE(带时分秒)/ TIMESTAMP | Oracle 的 DATE 带时间,不能直接映成 date |
| 精确小数 | numeric | DECIMAL | NUMBER | 金额一律 numeric,别用 float |
| JSON | jsonb(可索引) | JSON(8.0 支持函数索引) | JSON(21c 起)/ XMLType | 只有 PG 能整体建 GIN 索引 |
| 数组 | int\[\]、text\[\] | 没有,用 JSON 模拟 | VARRAY / 嵌套表 | PG 数组能 ANY 查询并建 GIN |
| UUID | uuid | 没有原生,CHAR(36) 或 BINARY(16) | RAW(16) | PG 18 起内置 uuidv7(),时间有序,适合做主键 |
| 枚举与范围 | enum、int4range、tsrange | ENUM(改值要重建表) | 没有对应 | 范围类型配 GiST 能做区间重叠 |
真正拉开差距的是 jsonb、数组、范围类型这几个:它们能被索引、能被约束、能参与运算,不是拿字符串凑合出来的。
INSERT INTO orders (user_id, amount, attrs)
VALUES (1, 99.00, '{"channel":"app","tags":["new","promo"]}');
SELECT id, attrs->>'channel' AS channel, attrs->'tags' AS tags
FROM orders
WHERE attrs @> '{"tags":["promo"]}'::jsonb; -- 包含判定,可走 GIN
CREATE INDEX idx_orders_attrs ON orders USING GIN (attrs jsonb_path_ops);
但别把 jsonb 当 schema 用。要外键、要范围查询、要高频率过滤的字段,老老实实建列;jsonb 留给结构不稳定、通常整块读的属性,比如渠道扩展字段、事件 payload。核心业务字段全塞进去,等于把优化器的统计信息扔了。
2.4 正式建表
CREATE TABLE orders (
id bigint GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
user_id bigint NOT NULL,
amount numeric(12,2) NOT NULL CHECK (amount >= 0),
status text NOT NULL DEFAULT 'pending'
CHECK (status IN ('pending','paid','shipped','closed')),
attrs jsonb NOT NULL DEFAULT '{}'::jsonb,
created_at timestamptz NOT NULL DEFAULT now(),
total_cents bigint GENERATED ALWAYS AS (round(amount * 100)::bigint) STORED
);
CREATE INDEX idx_orders_user_time ON orders (user_id, created_at DESC);
用 identity 不用 serial。serial 是个带默认值的伪类型,顺手给你建个序列,还允许应用往里写;identity 是标准实现,GENERATED ALWAYS 能挡住脏写。
时间统一 timestamptz。它内部存 UTC、按会话时区显示,跨时区部署不会错;用 timestamp 存"有时区含义"的时间,八小时偏差是迟早的事。
生成列 STORED 在写入时算好落盘,适合分转元这种确定性派生字段,省得每次查询重算一遍。
3. 进阶PG的写法
3.1 三条能省一半代码的语法
窗口函数、LATERAL、DISTINCT ON。这三样在 MySQL 里要么没有,要么得绕一大圈。
SELECT user_id,
id,
amount,
sum(amount) OVER (PARTITION BY user_id
ORDER BY created_at
ROWS BETWEEN UNBOUNDED PRECEDING AND CURRENT ROW) AS cum_amount,
row_number() OVER (PARTITION BY user_id ORDER BY created_at DESC) AS rn
FROM orders;
它和 GROUP BY 的区别是保留明细行,所以能同时输出"这一单"和"这个用户到现在的累计"。执行计划里是 WindowAgg,通常带一次 Sort,索引往这个方向调能省掉排序。
SELECT u.id, u.name, t.order_id, t.amount
FROM users u
LEFT JOIN LATERAL (
SELECT id AS order_id, amount
FROM orders
WHERE user_id = u.id
ORDER BY created_at DESC
LIMIT 1
) t ON true;
LATERAL 让右边的子查询能引用左边的列,等于每行跑一次。"分组取最新一条"用它最干净。MySQL 要到 8.0.14 才支持。
用错的地方是拿它替代普通 JOIN:驱动行数一大,子查询次数跟着翻,性能直接塌。判断标准很简单,子查询里有 LIMIT 或聚合才用它。
-- 每个用户最新一单,比 row_number 子查询短
SELECT DISTINCT ON (user_id) user_id, id, created_at
FROM orders
ORDER BY user_id, created_at DESC;
-- 一次算出两个口径,比 CASE WHEN 清楚
SELECT status,
count(*) AS orders,
count(*) FILTER (WHERE amount >= 100) AS big_orders,
sum(amount) AS gmv
FROM orders
GROUP BY status;
3.2 六种索引
选错索引类型的代价不是慢一点,是根本用不上。
| 类型 | 擅长的查询 | PG 里用在哪 | 另两库对应 |
|---|---|---|---|
| B-tree | 等值、范围、排序、前缀匹配 | 默认类型。主键、外键、时间列、复合索引 | MySQL B+Tree、Oracle B-tree |
| Hash | 只有等值 | 大表精确匹配且不排序,用得少 | MySQL Memory、Oracle Hash Cluster |
| GIN | 多值包含、倒排 | jsonb、数组、tsvector 全文 | MySQL 全文索引、Oracle Text |
| GiST | 几何、范围、近邻 | PostGIS 空间查询、区间重叠 | Oracle Spatial(R-tree) |
| SP-GiST | 非平衡空间划分 | 点数据、前缀树、四叉树 | 没有直接对应 |
| BRIN | 块级摘要,超大表范围扫描 | 日志、时序表按时间过滤,体积极小 | 没有直接对应 |
-- 复合索引 + 覆盖列,避免回表
CREATE INDEX idx_orders_user_time ON orders (user_id, created_at DESC) INCLUDE (amount);
-- 部分索引:只给待处理的数据建,体积小一个数量级
CREATE INDEX idx_orders_pending ON orders (created_at) WHERE status = 'pending';
-- 表达式索引:让函数条件也能走索引
CREATE INDEX idx_orders_month ON orders ((date_trunc('month', created_at)));
-- 近邻检索(向量扩展)
CREATE INDEX idx_docs_embedding ON documents
USING hnsw (embedding vector_cosine_ops);
这里有个容易忽略的差异。PG 是堆表,所有索引都是二级索引,主键也不例外,数据行不按主键排。InnoDB 反过来,主键就是数据本身,二级索引里存主键值。这个差异直接改主键设计:InnoDB 要用短且递增的主键避免页分裂;PG 不在乎主键连不连续,在乎你真正查询的列有没有索引。
代价在另一头:PG 里按主键范围扫一张大表并不天然快,因为数据散在堆里。
索引也不是越多越好。每个索引写入时都要维护,还要参与 VACUUM,索引膨胀和表膨胀一样真实。单表控制在五个以内,定期查 pg_stat_user_indexes 的 idx_scan,把从没被扫过的删掉。
3.3 看执行计划,别猜
ANALYZE orders; -- 先保证统计信息是新的
EXPLAIN (ANALYZE, BUFFERS, VERBOSE)
SELECT user_id, sum(amount)
FROM orders
WHERE created_at >= now() - interval '30 days'
GROUP BY user_id
ORDER BY 2 DESC
LIMIT 20;
只跑 EXPLAIN 不给 ANALYZE,看的还是优化器的猜测。加上 BUFFERS 才能看到每个节点实际读了多少页------这是区分"索引不对"和"缓存不够"的关键。
| 节点 | 含义 | 看到它时想什么 |
|---|---|---|
| Seq Scan | 全表顺序扫描 | 小表正常。大表且过滤性强,说明缺索引,或者索引没被选中 |
| Index Scan / Index Only Scan | 走索引并回表 / 只读索引 | Index Only 的 Heap Fetches 偏高,说明 visibility map 脏,该 VACUUM 了 |
| Bitmap Heap Scan | 先建位图再批量回表 | 中等选择性、多条件组合时的正常形态,不用强行改成 Index Scan |
| Nested Loop | 嵌套循环连接 | 小结果集驱动大表最优。驱动行数一大就是灾难,去看上层 rows 估算 |
| Hash Join / Merge Join | 哈希 / 归并连接 | 大表连接的主要形态,通常是对的 |
| Sort | 排序 | 出现 external merge 说明 work_mem 不够。能靠索引顺序省掉最好 |
| Gather | 并行结果汇总 | 并行查询在工作,注意 workers 数和 work_mem 的乘积 |
顺序固定下来最省时间。先看估算行数和实际行数差多少,差一个数量级就先 ANALYZE,别急着改 SQL;再看 shared read 占比;最后看哪个节点的 actual time 最高,那才是贵的。cost 数值只能横向比同一份计划里的两个方案,别当绝对值看。
另一个高频问题是深分页。OFFSET 100000 意味着前面十万行白扫,改成 keyset:where id > 上一页最后一个 id。
4. 精通并发和存储才是分水岭
前三章的东西,认真读文档一周能会。这一章这几件事,是 PG 和另外两个库真正分开的地方,也最容易在生产上翻车。
4.1 MVCC:为什么 PG 不用 undo log
PG 的 MVCC 是物理多版本。行头记 xmin(谁创建的)和 xmax(谁删的),UPDATE 实际是写一条全新的行,旧版本留在堆里等清理,DELETE 只是打标记。可见性靠事务开始时的快照判断。
InnoDB 是行内放 trx_id 和回滚指针,旧版本进 undo log,由 purge 线程清;Oracle 用 undo 段配 SCN 构造一致性读。三者都能做到读不阻塞写,代价落在不同地方:PG 落在表空间和 VACUUM,MySQL 落在 undo 表空间,Oracle 落在 undo 表空间和 ORA-01555。
这条推论很实用:长事务、长查询、开着不动的游标,都会钉住最老的快照,让 VACUUM 无法回收它之后的死元组。PG 的头号纪律不是调参,是别让事务活太久。idle_in_transaction_session_timeout 一定要设。
| 隔离级别 | PostgreSQL | MySQL | Oracle |
|---|---|---|---|
| Read Uncommitted | 不支持,等同 Read Committed | 支持 | 不支持 |
| Read Committed | 默认,每条语句取新快照 | 支持 | 默认 |
| Repeatable Read | 快照隔离,没有幻读;写冲突报错而不是等锁 | 默认,靠 next-key lock 防幻读 | 不支持,用 Serializable 或闪回查询替代 |
| Serializable | SSI 实现,冲突抛序列化失败,应用必须重试 | 加锁实现,阻塞多 | 加锁实现 |
两个实际影响。PG 默认是 Read Committed,MySQL 默认 Repeatable Read,从 MySQL 迁过来时,依赖 RR 语义的逻辑要重判。PG 的可串行化用 SSI,不加锁,冲突时抛 40001 让你重试------应用不实现重试,高并发下会莫名其妙地失败。
BEGIN ISOLATION LEVEL REPEATABLE READ;
-- 业务操作
COMMIT;
4.2 VACUUM:不做这件事,表只会越来越大
UPDATE 留旧版本,就得有人清,这个动作是 VACUUM。它回收死元组占用的空间给后面复用、更新 visibility map 让 Index Only Scan 生效、顺带更新统计信息。注意回收的空间是留在表内复用,文件不会变小------这就是膨胀。
SELECT relname,
n_live_tup,
n_dead_tup,
round(n_dead_tup::numeric / nullif(n_live_tup + n_dead_tup, 0), 3) AS dead_ratio,
last_autovacuum,
last_autoanalyze
FROM pg_stat_user_tables
ORDER BY n_dead_tup DESC
LIMIT 10;
autovacuum 默认要等死元组超过 20%、且绝对数超过 50 行才动。大表等到触发时,通常已经很难看了。写入频繁的表按表下调。
ALTER TABLE orders SET (
autovacuum_vacuum_scale_factor = 0.02,
autovacuum_analyze_scale_factor = 0.05
);
空间真要还回去,别用 VACUUM FULL:它拿排他锁重写整表,生产上一执行就是停服。用 pg_repack 在线重建,或者干脆按时间分区、DROP PARTITION 整段释放。日志表用分区,主要就是为了这个。
4.3 分区、复制、高可用
分区解决两件事:大表切成能管的段,以及过期数据整段删掉,而不是产生一堆死元组。PG 10 之后是声明式分区,RANGE、LIST、HASH,支持子分区。
CREATE TABLE events (
id bigint NOT NULL,
created_at timestamptz NOT NULL,
payload jsonb
) PARTITION BY RANGE (created_at);
CREATE TABLE events_2026_10 PARTITION OF events
FOR VALUES FROM ('2026-10-01') TO ('2026-11-01');
CREATE TABLE events_2026_11 PARTITION OF events
FOR VALUES FROM ('2026-11-01') TO ('2026-12-01');
DROP TABLE events_2026_09; -- 整段释放,不产生死元组
两个限制得提前知道。分区表上的唯一索引必须包含分区键,否则保证不了全局唯一。分区表没有全局索引,跨分区查询靠分区裁剪收敛扫描范围,所以 WHERE 里一定要带分区键。
复制是两条路线。物理流复制传 WAL,整实例级别,主备完全一致,用来做高可用和读写分离;逻辑复制是表级发布订阅,能跨大版本、能只同步部分表,但 DDL 和序列不同步。
-- 发布端,wal_level 需为 logical,改完重启
CREATE PUBLICATION pub_orders FOR TABLE orders;
-- 订阅端
CREATE SUBSCRIPTION sub_orders
CONNECTION 'host=10.0.0.12 port=5432 dbname=app user=repl password=***'
PUBLICATION pub_orders;
4.4 PG 的能力边界在哪
MySQL 的能力边界由存储引擎定,Oracle 由版本和付费选件定,PG 由你装了哪些扩展定。这是三者最不像的地方,也是 PG 最有意思的地方。
| 扩展 | 给什么能力 | 什么时候装 |
|---|---|---|
| pg_stat_statements | 按 SQL 归一化统计调用次数、耗时、行数、缓存命中 | 生产必装,没有它就没法做性能分析 |
| pgvector | vector 类型 + HNSW / IVFFlat 索引 | RAG、语义检索,省掉一套外部向量库 |
| PostGIS | 几何类型、空间索引、空间函数 | 地理围栏、路径规划、门店范围 |
| TimescaleDB | 按时间自动切 hypertable | 指标、监控、IoT 时序 |
| Citus | 分布式表、分片、查询下推 | 单机容量到顶之后的水平扩展 |
| pg_partman | 分区自动创建与清理 | 日志、事件表的长期维护 |
| pg_repack | 在线重写表,回收膨胀 | VACUUM FULL 之外唯一实用的选择 |
| postgres_fdw | 把远程库的表映射成本地外部表 | 分库联合查询、冷热数据分离 |
装之前想两件事:扩展跟不跟得上 PG 大版本,以及它坏的时候谁来修。pgvector、PostGIS 这类有稳定维护者的可以放心用,小众扩展要掂量一下。
4.5 参数和监控,先改这六个
默认值是按"能在一台笔记本上跑起来"设的,直接上生产会明显浪费机器。先改这几个,剩下的按压测结果调。
| 参数 | 建议起点 | 为什么 |
|---|---|---|
| shared_buffers | 内存的 25% | PG 自己的缓存,要重启。再往上不会线性变好,剩下的留给 OS 做文件缓存 |
| effective_cache_size | 内存的 50%--75% | 不占内存,只是告诉优化器"大概有多少缓存可用"。设小了它会误判,不肯走索引 |
| work_mem | 4--16 MB | 每个排序或哈希节点单独占,按并发放大。别按"连接数 × work_mem"直接调 |
| maintenance_work_mem | 256 MB--1 GB | 影响 VACUUM 和 CREATE INDEX 的速度,调大收益很明显 |
| max_connections | 100--200 | 一个连接一个进程,必须配 PgBouncer 的 transaction 模式。加连接解决不了并发 |
| random_page_cost | SSD 设 1.1 | 默认 4.0 是按机械盘定的,SSD 上不调,优化器会过度偏好全表扫描 |
SELECT calls,
round(total_exec_time::numeric, 1) AS total_ms,
round(mean_exec_time::numeric, 2) AS mean_ms,
rows,
left(query, 80) AS query_sample
FROM pg_stat_statements
ORDER BY total_exec_time DESC
LIMIT 10;
监控至少盯四组:连接数和活跃会话(重点抓 idle in transaction)、缓存命中率、死元组与膨胀、复制延迟。这四组出问题,基本就是故障前兆。
5. 横向对比 MySQL&Oracle
5.1 MySQL:同一个 OLTP,两种做法
简单 CRUD 上两家都能跑,差异要等数据量、并发量和查询复杂度上来才显形。
| 维度 | PostgreSQL | MySQL(InnoDB) |
|---|---|---|
| 进程模型 | 多进程,一连接一后端进程,开销大,必须池化 | 单进程多线程,连接开销小,高并发仍要池化 |
| 默认隔离 | Read Committed | Repeatable Read |
| MVCC | 堆内多版本 + VACUUM 清理,代价是表膨胀 | undo log + purge 线程,代价是 undo 空间涨 |
| 表与主键 | 堆表,所有索引都是二级索引 | 主键即聚簇索引,二级索引存主键值 |
| 锁与幻读 | 行级锁,没有间隙锁,快照隔离下不出幻读 | next-key lock(行锁 + 间隙锁),RR 靠它防幻读 |
| DDL 事务性 | 完全事务化,ALTER 失败能回滚 | 8.0 起原子 DDL,跨语句组合仍不是事务 |
| 索引能力 | 六种类型,部分索引、表达式索引、INCLUDE 覆盖 | 以 B+Tree 为主,辅以全文和多值索引,没有部分索引 |
| JSON | jsonb 可整体建 GIN,运算符完整 | JSON 类型,8.0 支持函数索引和多值索引 |
| 复制 | 物理流复制 + 逻辑复制,可跨版本、可选表 | binlog 复制,异步 / 半同步 / MGR |
| 分区 | 声明式 RANGE / LIST / HASH,无全局索引 | 原生 RANGE / LIST / HASH / KEY,生态成熟 |
| 扩展性 | 极强。扩展、自定义类型、FDW 都是内置能力 | 受存储引擎和插件体系限制 |
| 运维门槛 | 较高。VACUUM、连接数、复制都得主动管 | 较低。国内云厂商和人才储备更厚 |
| 许可 | PostgreSQL License(类 BSD,可闭源分发) | GPL v2;商业版另有 Oracle 双许可 |
取舍就一句:要简单、要极致写入吞吐、要生态和招人方便,选 MySQL;要复杂查询、丰富类型、强扩展、混合负载,选 PG。
从 MySQL 迁过来,四个坑最多。无符号整数没有对应类型,得加 CHECK;ON DUPLICATE KEY UPDATE 要改成 INSERT ... ON CONFLICT DO UPDATE;间隙锁没了,依赖它做并发控制的逻辑要重审;字符串比较的大小写敏感性、NULL 排序规则都不一样。
5.2 Oracle:能力强,账也算得清
管 PG 叫"开源 Oracle",在能力上成立,在生态上不成立。PL/pgSQL 大量借鉴 PL/SQL,分区、并行、逻辑复制、行级安全这些都有,但 RAC、Data Guard、AWR 那一套,PG 没有等价物。
| 维度 | PostgreSQL | Oracle Database |
|---|---|---|
| 授权成本 | 开源免费,商业支持可选,主要成本是人力 | 按处理器或命名用户授权,核心数和选件都会推高成本 |
| 架构 | 多进程 + 共享内存,单机为主,靠 Citus 做分布式 | 实例 + SGA/PGA + 后台进程,RAC 可多实例共享存储 |
| SQL 方言 | 高度遵循标准 SQL,扩展语法自成体系 | 自有方言很强:ROWNUM、DUAL、CONNECT BY、MODEL |
| 过程语言 | PL/pgSQL,另有 Python、Perl、Tcl | PL/SQL,成熟度最高,包(Package)是独有能力 |
| 诊断体系 | EXPLAIN、pg_stat_*、pg_stat_statements,偏自己分析 | AWR / ASH / ADDM 成体系,工具链完善 |
| 高可用 | 流复制 + Patroni,逻辑复制,没有内置多活 | Data Guard(物理/逻辑备库)、RAC、GoldenGate |
| 分区与索引 | 声明式分区,分区表没有全局索引 | 分区成熟,支持全局索引和 Interval 分区 |
| 安全审计 | 行级安全 RLS、列权限、SCRAM、TLS,18 起支持 OAuth | 细粒度审计、TDE、Database Vault、Label Security |
| 运维工具 | pgBackRest、pgBadger、pg_repack,社区工具为主 | RMAN、Data Pump、OEM,官方闭环 |
从 Oracle 迁过来,语言层比想象容易,语义层比想象难。下面五处是最常见的事故源。
-
**空字符串就是 NULL。**Oracle 里 '' 等于 NULL,PG 里 '' 是合法的空串。所有依赖"空串即空值"的判断、唯一约束和业务逻辑都得重写,这是第一大坑。
-
**DATE 不是同一个 DATE。**Oracle 的 DATE 带时分秒,PG 的 date 只有年月日,对应的是 timestamp。字段映错就直接丢时间精度。
-
**行号和层级查询。**ROWNUM 改成 LIMIT/OFFSET 或 row_number();CONNECT BY 改写成 WITH RECURSIVE;DUAL 直接去掉。
-
**包和重载。**PL/SQL 的 Package 在 PG 里没有对应物,一般拆成一个 schema 加一组函数,状态变量要重新设计。
-
**标识符大小写。**Oracle 折大写,PG 折小写。迁移工具为了保名常加双引号,结果代码必须处处带引号,否则就是"表不存在"。
5.3 选型与迁移:一张表和三句实话
| 场景 | 选谁 | 理由 |
|---|---|---|
| 简单高并发 OLTP,SQL 不复杂 | MySQL | 生态成熟、云厂商支持好、招人容易,调优路径清晰 |
| 复杂报表、多表关联、窗口函数密集 | PostgreSQL | 优化器和查询能力更强,很多逻辑不用拆到应用层 |
| 半结构化数据,要按属性过滤 | PostgreSQL | jsonb + GIN 是三者里唯一成熟的方案 |
| 向量检索、RAG 知识库 | PostgreSQL | pgvector 能和业务表同库 JOIN,少维护一套系统 |
| 地理围栏、轨迹、门店范围 | PostgreSQL | PostGIS 是事实标准 |
| 已有核心 ERP,要 RAC 多活和完善审计 | Oracle | Data Guard、RAC、审计合规没有等价替代 |
| 海量时序指标和日志 | PostgreSQL | 分区 + TimescaleDB + BRIN 的组合成本最低 |
| 预算敏感,或者要闭源分发 | PostgreSQL | 类 BSD 许可,商用没有授权限制 |
新项目、模型还会变、查询复杂度说不准,优先 PG。已有成熟 MySQL 体系且业务简单,没有理由为了技术偏好迁。Oracle 只在既有系统、强合规或者需要 RAC 多活的场景继续持有,新建系统要先把十年授权账算清楚。
6. 学习顺序
不用按"先读文档再看源码"来。按踩坑成本排,大致是这样。
**第一周。**装库、psql 元命令、建表建约束、pg_dump 备份再恢复。过关标准是恢复出来的库能直接跑。
**第二到三周。**窗口函数、LATERAL、DISTINCT ON 各写五条;同一张表设计四种索引;每条 EXPLAIN 能讲出优化器为什么选这个计划。
**第四到六周。**按月分区、一主一备流复制、PgBouncer、pg_stat_statements 加四组监控。过关标准是一次主备切换做完,应用没感觉。
**两个月以后。**pgbench 压测调参、复现一次膨胀并用 pg_repack 修、做一次跨库迁移评审。到这一步才算能拍板,而不是"加个索引试试"。
五个误区顺手记一下:把 PG 当 MySQL 用,不配连接池也不调 autovacuum;觉得索引越多越好;业务里跑着跨小时的长事务;分页用 OFFSET 翻深页;上线之后从不对大表 ANALYZE。
真要上手,别从复制和调优开始。找一张五百万行的表,按 3.3 的办法把它从两秒优化到两百毫秒,比读十篇长文都管用。