第 06 章

深入对比 MySQL

前五章建立了 PG 的堆 + MVCC + VACUUM + 索引 + 规划器模型——这一章把它和 MySQL/InnoDB 逐一对照,用差异照亮各自的设计取舍。每一节的套路一致:先讲 InnoDB 在机制层怎么做,再对照 PG,再点出这处差异照亮了哪个抉择。对照的基准是 PostgreSQL 18 与 MySQL 8.4 LTS / 9.7 LTS(截至 2026-06)。

带着 MySQL 的直觉读 PG,最大的风险不是看不懂,而是看懂了错的东西——把「表就是主键 B+tree」「长事务撑大 undo」「默认隔离是 RR」这些 InnoDB 的事实,悄悄套到 PG 上。这一章是整份教程的跨章辨析章:把前五章每个 PG 机制,都拉到 InnoDB 的对应实现旁边并排看。同样是「读不阻塞写」,两者代价相反;同样是 count(*),两边都全扫但 PG 多一条出路;同样是默认隔离级别,失败模式南辕北辙。看清差异,前五章的模型才真正长进直觉里。

本章你将建立的 schema

  • 聚簇索引 vs 堆——InnoDB 表本身是主键 B+tree,叶子存整行;PG 是无序堆 + 平权独立索引,叶子存 ctid。主键选择对二者的存储布局影响天差地别。
  • undo-log MVCC vs 堆内多版本——InnoDB 原地更新、旧版本进 undo log;PG 旧版本留在堆里成死元组。长事务在前者撑大 undo、在后者撑大表。
  • 三日志 vs 单 WAL——InnoDB/MySQL 用 redo + undo + binlog 三套日志,PG 用一套 WAL 同时干崩溃恢复、物理复制、逻辑解码。
  • 默认 RR vs RC——InnoDB 默认 REPEATABLE READ 靠 next-key 锁防幻读;PG 默认 READ COMMITTED,其 RR 是真正的快照隔离、SERIALIZABLE 是 SSI。失败模式相反:阻塞/死锁 vs 中止重试。
  • 优化器与索引差异——PG 的 join 算法、索引类型、事务型 DDL 的计划空间与能力面都比 InnoDB 大。

§1聚簇索引 vs 堆:主键决定一切,还是什么都不决定

InnoDB 的表就是主键 B+tree,行躺在主键里;PG 的表是无序堆,主键只是又一个指向 ctid 的索引。

为什么需要它

这是 PG 与 MySQL 分道扬镳的第一刀,也是后面所有差异的物理源头。第 1 章讲过 PG 的行存在无序堆里、每个索引叶子存 ctid;InnoDB 走的是完全相反的路——让主键决定行的物理位置。先把这一处对照吃透,「UUID 主键在 InnoDB 痛、在 PG 不痛」「二级索引为什么要回表」这类现象才有根。

InnoDB 怎么做:表即主键 B+tree

InnoDB 的表本身就是一棵以主键排序的 B+tree(聚簇索引 / clustered index,亦称索引组织表)。 这棵树的叶子节点直接存放整行数据——行不是独立躺在某个堆里,行就是主键 B+tree 的叶子内容。默认数据页 16KB。没有显式主键时,InnoDB 会挑第一个非空唯一索引、再不然用内部生成的 6 字节 row_id 当聚簇键。

这带来一个 MySQL 老手都熟的事实:二级索引(secondary index)的叶子节点存的是主键值,而不是行的物理指针。 于是一次非覆盖的二级索引查找要走两次 B+tree 下降——先在二级索引树里下降,找到匹配行的主键值;再拿这个主键值到聚簇索引树里第二次下降,才取到整行。这第二步就是 MySQL 语境里的回表。只有当查询要的列全在二级索引里(覆盖索引)时,才省掉第二次下降。

后果直接挂在主键的选择上。 用单调递增的主键(如 AUTO_INCREMENT),新行总是追加到 B+tree 最右侧的叶子页——顺序写、页填得满、几乎不分裂。换成随机的主键(如 UUID v4),插入点散布在整棵树中间,频繁触发页分裂:一个写满的中间页被迫一分为二,留下半空的页、制造碎片、降低页填充率。更隐蔽的代价是:因为每一个二级索引的叶子都存着主键值,主键越宽(UUID 16 字节起,文本主键更甚),所有二级索引就越胖、越占空间、缓存效率越低。

对照 PG:堆 + 平权独立索引

PG 这边,第 1 章 §tuple 立下的地基此刻全部生效:行存在一个无序的堆里,物理顺序与任何键都无关。每一个索引——包括主键索引——都是独立的结构,叶子节点存的是 ctid,即 (block, offset) 这个物理坐标。PG 里没有聚簇索引这个概念,没有哪个索引是「主」的、其余是「从」的:主键索引和任意一个普通 B-tree 索引在结构上完全平权,都通过 ctid 一跳定位到堆里的行。

所以主键的选择对 PG 的存储布局影响很小。用 UUID 当主键,PG 基本没有 InnoDB 那套惩罚:主键索引该分裂还是分裂(这是 B-tree 的固有行为),但它不决定行的物理位置,不会把整张表的数据布局搅乱,更不会因为主键宽就让其它索引一起变胖——因为 PG 的二级索引叶子存的是 ctid(定长 6 字节),不是主键值。代价是另一面:PG 的二级索引无法靠「叶子存主键」实现覆盖时的去回表优化(它本来就一跳到堆,没有「回表」这一说),并且任何索引都会随更新删除而碎片化。PG 的 CLUSTER 命令能按某个索引把堆重排成物理有序,但那只是一次性操作——重排完之后新的插入更新照样打乱顺序,PG 不会像 InnoDB 那样持续维护聚簇有序。

InnoDB · 聚簇 PostgreSQL · 堆 二级索引 B+tree 叶子 = 主键值 ①下降 得主键 = 42 ②回表 聚簇 B+tree 按主键排序 叶子 = 整行 行就住在树里 两次 B+tree 下降才取到行 主键单调=顺序追加 UUID 主键=页分裂+二级索引变胖 主键索引 叶子 = ctid 二级索引 叶子 = ctid 各一跳 堆(无序行) 行 c 行 a 行 b 空位 每个索引平权·都一跳定位 主键不决定行的物理位置 UUID 主键基本无惩罚
图 6.1聚簇索引 vs 堆 + 独立索引。注意:左侧 InnoDB 的二级索引回表是第二次 B+tree 下降(叶子存主键值,再去聚簇树取整行),不是一次堆定位;右侧 PG 的每个索引都平权、叶子存 ctid,一跳即到堆里的行。
这处差异照亮了什么取舍

InnoDB 把成本压在写入与主键选择上:按主键范围扫描天然连续、I/O 友好(聚簇的红利),代价是必须持续维护主键有序、二级索引要回表、主键一宽则全盘受累。PG 把同一个抉择倒过来——堆写入只找空位、不维护任何全局顺序,所以主键选什么都不搅乱布局,代价是 PG 没有「按主键聚簇」的天然顺序红利,要靠 CLUSTER 一次性重排或 BRIN/索引扫描去补。一句话:InnoDB 让主键决定一切,PG 让主键几乎什么都不决定。

预测一下

一个团队抱怨「InnoDB 用 UUID v4 当主键,写入慢、表和索引都偏大」。把这套库迁到 PG、主键仍用 UUID,这些症状还在吗?为什么?

展开答案

基本消失。 根因在于 InnoDB 的表就是主键 B+tree——UUID 随机分布导致中间页频繁分裂、碎片化,且每个二级索引叶子都存着这个 16 字节主键、随之变胖。PG 没有聚簇索引:主键只是又一个叶子存 ctid 的独立索引,不决定行的物理位置,行照常往堆的空位塞。UUID 主键索引本身仍会按 B-tree 规则分裂(这躲不掉),但它不会搅乱整张表的数据布局,也不会让其它二级索引跟着变胖——因为 PG 的二级索引叶子存的是定长 ctid,与主键宽度无关。所以「写入慢 + 表/索引偏大」这组由聚簇引起的症状,在 PG 上基本不复现。

§2undo-log MVCC vs 堆内多版本:旧版本去了哪

两种 MVCC 都让读不阻塞写,但旧版本的去向相反——InnoDB 推进 undo log,PG 留在堆里等 VACUUM。

为什么需要它

第 2 章讲过 PG 的 MVCC:UPDATE 不原地改,新版本写进堆、旧版本成死元组,VACUUM 负责回收。InnoDB 同样是 MVCC、同样「读不阻塞写」,但实现路径完全不同。把两条路径并排看,才能解释一个让 MySQL 老手困惑的现象:为什么 PG 的长事务会把表撑肿,而 InnoDB 的长事务撑肿的却是别的东西。

InnoDB 怎么做:原地更新 + undo 链回溯

InnoDB 是原地更新(in-place update)。 UPDATE 直接在聚簇索引的叶子上修改那一行的当前值;被覆盖掉的旧值并不丢弃,而是被推入 undo log(回滚段,rollback segment)。每一行在聚簇索引里都带两个隐藏字段:DB_TRX_ID(最后修改它的事务 id)和 DB_ROLL_PTR(指向该行在 undo log 里的上一个版本的回滚指针)。

读的时候,一个事务的一致性视图叫 read view。当它读到聚簇索引里的当前行、发现这一行的 DB_TRX_ID 对自己的 read view 不可见(即这行被一个「在该 read view 之后才开始、或此刻尚未提交」的事务改过)时,InnoDB 就顺着 DB_ROLL_PTR 进 undo log,沿 undo 链一步步回溯、重建出对这个 read view 可见的那个旧版本。换句话说,InnoDB 的旧版本是「按需从 undo 记录里现场重算」出来的,不是像 PG 那样以完整 tuple 形式躺在表里。

undo 由专门的 purge 线程回收:当某条 undo 记录所对应的旧版本,已经不再被任何活跃 read view 需要时,purge 才能把它清掉。这里埋着 InnoDB 长事务的核心代价——一个长时间运行、或开了事务却空闲不提交的事务,会一直钉住它的 read view,使得「它仍需回溯到的那些旧版本」对应的 undo 记录无法被 purge。后果是 history list length(待清理的旧版本链总长)持续增长、单行的 undo 版本链越拖越长,于是凡是要回溯老版本的读都被拖慢。但关键在于:这撑大的是 undo 表空间,表(聚簇索引)本身不膨胀——当前行始终是原地的那一份。

对照 PG:旧版本留在堆里成死元组

PG 的做法 第 2 章已经讲透:UPDATE 在堆里写一个新 tuple,旧 tuple 原地保留、被盖上 xmax 失效戳,待再无快照能看到它便成死元组。旧版本就是一份完整的、躺在堆里的 tuple——不需要「重算」,直接读。回收它的是 VACUUM,不是 purge 线程。

于是 PG 的长事务代价落在另一处:一个长事务持有的旧快照,会拦住 VACUUM——凡是「这个老快照仍能看到」的死元组,VACUUM 都不能回收(回收了老事务就读不到它该读的版本了)。死元组在堆里越积越多,表和它的所有索引一起膨胀(bloat)。这正是 InnoDB 与 PG 长事务症状的镜像:同一个「长事务拦住清理」的因,在 InnoDB 撑大 undo、在 PG 撑大关系文件。

InnoDB · undo PostgreSQL · 堆内 当前行(原地) balance = 80 DB_ROLL_PTR ↓ 回溯 undo: 旧值 100 DB_ROLL_PTR ↓ undo: 更旧值… 读旧版本=沿 undo 链重建 长事务 → undo 撑大 表本身不膨胀 同一个 heap page v1 旧版本 xmax = 205 死元组 v2 新版本 xmin = 205 当前有效 两份完整 tuple 并存堆中 VACUUM 回收死元组 读旧版本=直接读那份 tuple 长事务 → 表+索引膨胀 VACUUM 被老快照拦住
图 6.2两种 MVCC 的旧版本去向。注意:InnoDB 当前行原地、旧版本在 undo log 里按需重建,长事务撑大 undo;PG 新旧版本都是堆里的完整 tuple、直接读,长事务撑大 表与索引。同样「读不阻塞写」,膨胀的对象相反。
这处差异照亮了什么取舍

两种 MVCC 都实现了「读不阻塞写、写不阻塞读」,代价却是镜像的。PG 把旧版本以完整 tuple 留在堆里——读旧版本零成本(直接读),但膨胀关系文件、要靠 VACUUM 持续回收。InnoDB 把旧值压进 undo——表保持紧凑、当前行永远原地,但读旧版本要沿 undo 链回溯(链越长越慢),且长事务对 undo「收税」。谁更好取决于负载:PG 怕的是长事务 + 高频更新堆出的膨胀,InnoDB 怕的是长事务把 undo 链拖长后的回溯读放大。

§3三日志 vs 单 WAL:一套日志干几件事

MySQL 用 redo + undo + binlog 三套日志分担职责,PG 用一套 WAL 同时干崩溃恢复、物理复制、逻辑解码。

为什么需要它

第 3 章把 PG 的 WAL 立成「一切持久化与复制的单一事实源」。MySQL 的持久化故事截然不同——它的两层架构天生就把日志切成了多套。看清这个对照,才能解释「为什么 MySQL 复制要配 binlog 而不是 redo」「为什么 PG 流复制和逻辑复制共用一套日志」。

InnoDB/MySQL 怎么做:两层架构,三套日志

根因是架构。MySQL 是两层结构:上层是 server 层(解析、优化、执行、复制),下层是可插拔的存储引擎(InnoDB 是默认的那个)。 这道分层直接导致日志被切成三套,各管一摊:

  • redo log(InnoDB 层):物理日志,记录「对哪个页的哪个字节做了什么改动」,环形(固定大小、循环覆盖),唯一职责是崩溃恢复——重启后把已提交但未刷盘的页变更重放回来。
  • undo log(InnoDB 层):即 §2 的回滚段,服务于事务回滚与 MVCC 旧版本重建。
  • binlog(server 层):逻辑日志,记录「执行了什么 SQL / 行级数据怎么变」,有 statement / row / mixed 三种格式,是主从复制与时间点恢复(PITR)的数据源。因为它在 server 层,所以与具体存储引擎无关。

三套日志分属两层,引出一个出名的工程难题:提交一个事务,既要写 InnoDB 的 redo、又要写 server 层的 binlog,两者必须原子地一起成功或一起失败,否则主从就会不一致。InnoDB 用两阶段提交(redo prepare → 写 binlog → redo commit)保证这一点,并用 group commit 把多个并发事务的 fsync 批量合并,摊薄刷盘开销。

对照 PG:单一 WAL 一肩挑

PG 没有两层架构(存储与执行一体),也就没有「server 层日志 vs 引擎层日志」之分。第 3 章讲的那一套 WAL(Write-Ahead Log)就是全部,它同时承担 MySQL 要三套日志才分担得了的职责:

  • 崩溃恢复——这是 WAL 的本职,对应 MySQL 的 redo。
  • 物理流复制——备库直接接收并重放主库的 WAL 字节流,对应 MySQL 用 binlog 做的复制(但 PG 用的是物理 WAL,不是逻辑语句)。
  • 逻辑解码(logical decoding)——PG 能把同一份物理 WAL 解码成逻辑变更流,喂给逻辑复制 / CDC,这部分对应 MySQL 的 row 格式 binlog。

至于「旧版本 / 回滚」,PG 不需要单独的 undo 日志——因为旧版本本就是堆里的死元组(§2),回滚靠的也是 MVCC 可见性,不靠回放 undo。所以 MySQL 的 redo + undo + binlog 三件事,在 PG 这里被收敛成「WAL + 堆内多版本」两件事,而其中复制与 CDC 又都从那一份 WAL 派生。

MySQL · 三套日志 PostgreSQL · 单 WAL server 层 解析·优化·执行·复制 存储引擎层 · InnoDB 可插拔 redo 物理·恢复 undo 回滚·MVCC binlog 逻辑·复制 redo+binlog 两阶段提交 group commit 批量 fsync 单一 WAL 存储执行一体·无分层 崩溃 恢复 物理流 复制 逻辑 解码 一套日志承担三件事 无需独立 undo 日志
图 6.3三套日志 vs 单一 WAL。注意:MySQL 的两层架构把日志切成 redo(引擎·物理·恢复)+ undo(引擎·MVCC)+ binlog(server·逻辑·复制)三套,提交要两阶段协调;PG 一套 WAL 同时干崩溃恢复、物理复制、逻辑解码——MySQL 三套日志分担的职责,PG 用一套承担。
这处差异照亮了什么取舍

MySQL 的多套日志是可插拔存储引擎这个架构选择的直接代价:server 层要在不依赖具体引擎的前提下做复制,就必须有一份引擎中立的 binlog;引擎自己又要崩溃恢复(redo)和回滚(undo)。灵活性(换引擎不换复制层)换来了两阶段提交的复杂度。PG 一体化、只一套 WAL,省掉了 redo/binlog 双写与两阶段提交的协调成本,但也意味着复制是 WAL 物理级别的——架构的简洁与引擎的可插拔,是这里被交换的东西。

§4默认 RR vs RC:失败模式南辕北辙

InnoDB 默认 REPEATABLE READ 靠 gap 锁防幻读,PG 默认 READ COMMITTED;两者冲突时一个阻塞/死锁、一个直接中止重试。

为什么需要它

这是迁移时最容易被默认值咬到的一处。第 2 章讲了 PG 的隔离级别建立在快照可见性之上;InnoDB 的同名级别(甚至默认值都不同)走的是另一套机制,连失败的方式都相反。把默认隔离这一对照吃透,是后面迁移对照表里第一条的根。

InnoDB 怎么做:默认 RR + next-key 锁

InnoDB 默认隔离级别是 REPEATABLE READ(RR)。 在 RR 下,普通的一致性读用的是事务首次读时建立的快照(与 PG 的快照思路相通);但 InnoDB 还要在 RR 这一级防住幻读,靠的是next-key 锁——它是记录锁(record lock,锁住索引上某条已存在的记录)+ 间隙锁(gap lock,锁住索引记录之间那段不存在的区间)的组合。锁住「区间」意味着别的事务无法往这段间隙里插入新行,于是同一个范围查询两次读到的行集合不变——幻读被堵死。

这里要分清两种读:普通 SELECT 是一致性快照读,不加锁;而 SELECT ... FOR UPDATE / FOR SHARE 是加锁读(locking read),会沿扫描路径取 next-key 锁。间隙锁的副作用是出名的:两个事务各自锁住相邻间隙、再都想往里插,就互相等成死锁;或者一个事务的范围加锁读,会阻塞另一个事务对该范围的插入,哪怕它们插的根本不是同一行。

对照 PG:默认 RC,RR 是快照隔离,SERIALIZABLE 是 SSI

PG 的三件事都和 InnoDB 不同(第 2 章 §isolation 已铺垫):

  • 默认是 READ COMMITTED(RC),不是 RR。每条语句开始时取一个新快照,能看到此前已提交的最新数据。
  • PG 的 REPEATABLE READ 是真正的快照隔离(snapshot isolation):整个事务用一个固定快照,没有间隙锁。它不靠锁住区间防幻读,而是当两个事务的写产生冲突时,直接让其中一个抛出 40001 serialization failure(序列化失败)、回滚——由应用捕获并重试。
  • PG 的 SERIALIZABLE 是 SSI(Serializable Snapshot Isolation):在快照隔离之上追踪事务间的读写依赖,用谓词锁(predicate lock,即 SIReadLock,一种只做冲突检测、不阻塞别人的「软」锁)发现破坏可串行性的环,再中止其中一个事务。它仍然不阻塞,只在检测到危险时中止。
这处差异照亮了什么取舍

失败模式彻底相反。InnoDB 用 gap 锁把冲突挡在「写入之前」——代价是阻塞、以及间隙锁特有的死锁(两个事务插不同的行也能锁死对方)。PG 没有 gap 锁,根本不会出现这类「插不同行却互相阻塞」的情形;它把代价放到「冲突之后」——RR/SERIALIZABLE 在检测到冲突时直接中止事务、抛 40001 让你重试。一句话:InnoDB 让你等(甚至死锁),PG 让你重试。迁移的直接含义——PG 应用必须有重试逻辑来兜 40001,而它换来的是没有间隙锁那套阻塞/死锁。

预测一下

把一个 MySQL 应用迁到 PG,默认隔离级别从 RR 变成 RC,哪一类查询的行为会变?

展开答案

「同一事务里多次读同一数据、并期望两次结果一致」的那类查询会变。 在 MySQL RR 下,事务用首次读时的快照,整个事务期间重复读返回一致结果(且 next-key 锁还防住幻读)。迁到 PG 默认的 RC 后,每条语句各取新快照——同一事务里第二次读,会看到这期间其它事务已提交的改动,于是出现「不可重复读」和「幻读」。依赖事务内读一致性的逻辑(如「读余额→校验→再读余额比对」)必须显式 BEGIN ISOLATION LEVEL REPEATABLE READ,否则行为与原 MySQL 不同。注意 PG 的 RR 是真正的快照隔离、靠中止重试而非 gap 锁,所以切上去之后还要准备好处理 40001。

§5优化器与 join:计划空间谁更大

两者都是基于代价的优化器,但 PG 有三种 join、更丰富的可用索引类型,计划空间更大。

为什么需要它

第 5 章讲了 PG 规划器在 nested loop / hash / merge 三种 join 之间按代价择优。MySQL 的优化器历史上长期只有 nested-loop 一族,join 算法的演进近几年才补齐。把两边的可选项摆出来,才能解释「同一条多表查询,PG 与 MySQL 选的计划为何不同」。

MySQL 怎么做:从 nested-loop 一路补齐

MySQL 的 join 算法有一条清晰的演进线:

  • 历史上只有 nested-loop join 及其变体 block-nested-loop(BNL,块嵌套循环)——把外表成批读进 join buffer 再扫内表,缓解纯逐行嵌套的低效。
  • hash join 自 MySQL 8.0.18(2019-10)引入;到 8.0.20(2020-04)移除了 BNL,由 hash join 全面替代——包括非等值连接、外连接、半连接等场景。
  • MySQL 没有 merge join(sort-merge join)这种算法。
  • 优化器升级:9.7(2026-04)把 Hypergraph 优化器带进了 Community 版,扩展了连接顺序的搜索能力。

对照 PG:三种 join + 更多索引扫描方式

PG 规划器的 join 工具箱(第 5 章 §joins)一直是三种齐备:nested loop、hash join、merge join——后者是 MySQL 至今没有的。加上 PG 独有的 bitmap index scan(把多个索引的命中结果按位图合并后再回堆,详见 第 4 章),以及规划器能利用的索引类型远多于 InnoDB(B-tree / Hash / GIN / GiST / SP-GiST / BRIN,见下一节),PG 优化器的计划空间明显更大——同一条查询,可枚举的扫描方式与连接策略组合更多。两者都是基于代价(cost-based)的优化器,差别在可选算子的丰富度,而非选择范式。

这处差异照亮了什么取舍

更大的计划空间是双刃:PG 能为更多查询形态找到合适的物理计划(尤其大数据量等值连接用 hash、已排序输入用 merge、多条件用 bitmap 合并),但也意味着规划器要在更大的空间里搜索、更依赖准确的统计信息(ANALYZE)。MySQL 的算子少、计划空间小,规划更简单、更可预测,但遇到 PG 用 merge join / bitmap scan 能高效解决的形态时,往往只能退回到 nested-loop 系。丰富度换来覆盖面,也换来对统计与调优的更高要求。

§6索引类型与事务型 DDL:能力面的差距

PG 可被规划器利用的索引类型远多于 InnoDB,且 DDL 完全事务化;MySQL 8.0 的原子 DDL 不等于可回滚。

为什么需要它

第 4 章列举了 PG 的多种索引类型。把它们和 InnoDB 的索引清单并排,差距一目了然。而 DDL 是否事务化,则是迁移脚本与上线流程要重写的一处——MySQL 老手习惯的「DDL 隐式提交」在 PG 里不成立。

索引类型:InnoDB 的清单 vs PG 的清单

InnoDB 能用的索引类型:B+tree(绝大多数索引)、FULLTEXT(全文)、R-tree(空间索引,用于 GEOMETRY);此外 8.0 起支持用生成列做表达式索引(不能直接对表达式建索引,要先建 generated column)、invisible 索引(对优化器隐藏、用于灰度下线索引)、降序索引(descending index,真正按降序存储)。

PG 能用、且规划器能直接利用的索引类型:B-tree、Hash、GIN(倒排,适合数组 / jsonb / 全文)、GiST(通用平衡树,适合几何 / 范围 / 最近邻)、SP-GiST(空间划分)、BRIN(块范围索引,适合超大且物理有序的表);外加部分索引(partial index,带 WHERE 条件只索引子集)、表达式索引(直接对表达式建,无需先造生成列)、覆盖索引的 INCLUDE 子句(把非键列附在叶子上做覆盖)。第 4 章展开了这些类型各自的命中场景。结论:可被规划器利用的索引类型,PG 远多于 InnoDB——这也是上一节「PG 计划空间更大」的一个来源。

事务型 DDL:可回滚 vs 隐式提交

PG 的 DDL 完全事务化。 CREATE TABLE / ALTER TABLE / DROP TABLE 等都能放进 BEGIN ... COMMIT 里,和普通 DML 一样——中途 ROLLBACK 就把这些 DDL 一并撤销,仿佛没发生过。多条 DDL 加几条数据修正可以打包成一个原子迁移事务,要么全成、要么全回滚。

MySQL 8.0 引入的是「原子 DDL(atomic DDL)」,但这不等于「事务型 DDL」。 原子 DDL 保证的是:单条 DDL 语句要么完整成功、要么完整失败(即使中途崩溃也不会留下半成品),它把数据字典、存储引擎操作和 binlog 写入捆成一个原子单元。但是:DDL 语句会触发隐式提交(implicit commit)——执行一条 DDL 会先把当前事务提交掉,因此 DDL 不能被包在多语句事务里,也无法 ROLLBACK。换言之,MySQL 的「原子」管的是单条语句的崩溃安全,不是「多条 DDL 一起回滚」。

迁移陷阱

MySQL 老手习惯把 schema 变更脚本写成「一条条 DDL 顺序执行,错了再手动补救」——因为反正每条都隐式提交、回滚不了。迁到 PG 后这个习惯反而埋下隐患:PG 允许把整套迁移放进一个事务,应当利用这一点(出错自动整体回滚),而不是沿用「逐条提交」的老写法。把迁移脚本显式包进 BEGIN; ... COMMIT; 是 PG 上的最佳实践。

§7迁移对照表与一个迁移示例

把前六节的机制差异,落到迁移时具体要改的语法与行为上。

下面这张表把「从 MySQL 迁到 PG」时最常撞上的差异逐条列出,每一条都能追溯到前面某一节的机制根源。

表 6.1 · 迁移对照(MySQL 8.4/9.7 → PostgreSQL 18)
主题MySQL / InnoDBPostgreSQL迁移动作
默认隔离级别REPEATABLE READREAD COMMITTED需事务内读一致性的逻辑要显式声明 RR;并准备处理 40001 重试(§4)
count(*)全表扫描全表扫描,但可借 index-only scan + 可见性图(VM)走索引别假设任一边「count 很快」;PG 大表可建覆盖索引 + 勤 VACUUM 让 VM 全可见
自增主键AUTO_INCREMENTGENERATED ... AS IDENTITY 或 sequence都会留空隙(回滚/崩溃后跳号);PG 的 sequence 是非事务的(回滚不退号)
UUID 主键痛:页分裂、碎片、二级索引变胖(聚簇)基本无痛:主键不决定行物理位置(堆)InnoDB 侧考虑改顺序 UUID/雪花;迁 PG 后此惩罚消失(§1)
字符串大小写默认 utf8mb4_0900_ai_ci,不区分大小写默认区分大小写依赖不区分比较的逻辑:改用 citext 类型或在比较时 lower()
更新时间戳ON UPDATE CURRENT_TIMESTAMP无等价语法用 BEFORE UPDATE 触发器维护 updated_at
upsertINSERT ... ON DUPLICATE KEY UPDATEINSERT ... ON CONFLICT (...) DO UPDATEPG 必须指明冲突目标(哪个唯一约束/列),不能像 MySQL 那样「任意唯一键触发」
布尔BOOLEAN = TINYINT(1) 别名真正的 boolean 类型把 0/1 语义改成 true/false;注意 tinyint 列不会自动变 bool
枚举ENUM('a','b') 列内联用 CREATE TYPE ... AS ENUM 或 CHECK 约束先建枚举类型再引用;或退化成带 CHECK 的 text
建表子句ENGINE=InnoDB DEFAULT CHARSET=utf8mb4无引擎/字符集子句直接删掉 ENGINE= 与 CHARSET=;编码由数据库/集群层决定

一个迁移示例:DDL 逐行映射

把上表里几条最常见的差异,凝成一段真实迁移 diff。下面两段建表语句左右对应,逐行说明 MySQL 写法在 PG 里换成什么。两段均未在本机执行,仅用于演示语法映射。

MySQL 8.4 · 原始 DDL sql
CREATE TABLE orders (
  id         BIGINT       NOT NULL AUTO_INCREMENT,
  user_id    BIGINT       NOT NULL,
  status     ENUM('new','paid','shipped') NOT NULL DEFAULT 'new',
  is_active  BOOLEAN      NOT NULL DEFAULT 1,
  created_at TIMESTAMP    NOT NULL DEFAULT CURRENT_TIMESTAMP,
  updated_at TIMESTAMP    NOT NULL DEFAULT CURRENT_TIMESTAMP
                           ON UPDATE CURRENT_TIMESTAMP,
  PRIMARY KEY (id),
  UNIQUE KEY uq_user_status (user_id, status)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;

-- upsert
INSERT INTO orders (user_id, status) VALUES (42, 'paid')
  ON DUPLICATE KEY UPDATE status = VALUES(status);
                                          -- 未在本机执行
PostgreSQL 18 · 等价 DDL sql
CREATE TYPE order_status AS ENUM ('new','paid','shipped');

CREATE TABLE orders (
  id         BIGINT       GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
  user_id    BIGINT       NOT NULL,
  status     order_status NOT NULL DEFAULT 'new',
  is_active  BOOLEAN      NOT NULL DEFAULT true,
  created_at TIMESTAMPTZ  NOT NULL DEFAULT now(),
  updated_at TIMESTAMPTZ  NOT NULL DEFAULT now(),
  CONSTRAINT uq_user_status UNIQUE (user_id, status)
);
-- updated_at 自动刷新需 BEFORE UPDATE 触发器(PG 无 ON UPDATE 语法)

-- upsert:必须指明冲突目标
INSERT INTO orders (user_id, status) VALUES (42, 'paid')
  ON CONFLICT (user_id, status) DO UPDATE
    SET status = EXCLUDED.status;
                                          -- 未在本机执行

逐行映射,把每处改动对回上表与机制:

  • AUTO_INCREMENT → IDENTITYBIGINT AUTO_INCREMENT 换成 BIGINT GENERATED ALWAYS AS IDENTITY。两者都会留下空隙(回滚/崩溃跳号);PG 底层用一个非事务 sequence,回滚不退号。
  • ENUM 内联 → CREATE TYPEMySQL 把枚举写在列上;PG 先 CREATE TYPE order_status AS ENUM (...) 再把列声明成这个类型(也可退化成带 CHECK 的 text)。
  • BOOLEAN DEFAULT 1 → trueMySQL 的 BOOLEAN 是 TINYINT(1) 别名、默认值写 1;PG 是真 boolean,默认值写 true。
  • ON UPDATE CURRENT_TIMESTAMP → 触发器PG 没有列级 ON UPDATE 自动刷新语法,updated_at 的自动更新要靠一个 BEFORE UPDATE 触发器(此处仅以注释标出)。TIMESTAMP 一并换成带时区的 TIMESTAMPTZ。
  • ON DUPLICATE KEY → ON CONFLICT (...)upsert 从 ON DUPLICATE KEY UPDATE 换成 ON CONFLICT (user_id, status) DO UPDATE——PG 必须显式指明冲突目标,且引用新值用 EXCLUDED.col(不是 MySQL 的 VALUES(col))。
  • 删掉 ENGINE/CHARSETENGINE=InnoDB DEFAULT CHARSET=utf8mb4 整段删除——PG 没有可插拔引擎(§3),编码在数据库/集群层定。

自测

  1. InnoDB 一次非覆盖的二级索引查找,要走几次 B+tree 下降?分别在哪棵树上、找的是什么?PG 的二级索引查找要几跳?
  2. 一个长时间不提交的事务,在 InnoDB 撑大的是什么、表本身会膨胀吗?在 PG 撑大的又是什么?为什么方向相反?
  3. InnoDB 默认 RR 下两个事务因间隙锁互相等待会发生什么?换成 PG 默认隔离级别 + 把它们的写改成冲突,PG 会用什么方式处理、应用要做什么?
  4. MySQL 8.0 的「原子 DDL」能让你把三条 ALTER TABLE 包进一个事务、出错时一起 ROLLBACK 吗?PG 呢?
查看参考答案

1. 走两次。第一次在二级索引 B+tree 上下降,叶子拿到匹配行的主键值;第二次拿这个主键值到聚簇索引 B+tree 上下降(即回表),叶子才是整行。只有覆盖索引能省掉第二次。PG 的二级索引一跳:叶子存 ctid,直接定位到堆里的行(§1,第 1 章)。

2. InnoDB 撑大的是 undo log(history list length 增长)——长事务钉住 read view,purge 无法回收旧版本;但表(聚簇索引)本身不膨胀,当前行始终原地。PG 撑大的是表 + 它的所有索引——长事务的老快照拦住 VACUUM,死元组在堆里堆积。方向相反,是因为旧版本去向相反:InnoDB 进 undo、PG 留在堆(§2,第 2 章)。

3. InnoDB 下两个事务各持相邻间隙锁、再都想插入,会互相等成死锁(InnoDB 检测到后回滚其一)。PG 默认是 READ COMMITTED、没有间隙锁,不会有这类「插不同行也阻塞」的等待;若提到 RR/SERIALIZABLE 且写冲突,PG 会让其中一个事务抛 40001 serialization failure 并回滚,应用需捕获并重试(§4,第 2 章)。

4. 不能。 MySQL 的原子 DDL 只保证单条 DDL 崩溃时不留半成品;DDL 会触发隐式提交,无法被包进多语句事务、也无法 ROLLBACK。PG 的 DDL 完全事务化:三条 ALTER 可放进同一个 BEGIN ... COMMIT,任一出错 ROLLBACK 时全部撤销(§6)。

进阶挑战

同一负载,量出两种 MVCC 相反的膨胀方向

设计一个能在两个引擎上对照的实验,实证「长事务在 InnoDB 撑大 undo、在 PG 撑大表」这条结论。在两边各建一张表灌入若干行,开一个故意不提交的长事务(持有快照),然后在另一个连接里对全表反复 UPDATE 多轮。在 PG 侧观察表与索引尺寸如何随轮次增长、VACUUM 为何回收不掉;在 InnoDB 侧观察 undo / history list length 如何增长、而表大小如何保持稳定。

提示

PG 侧:连接 A 跑 BEGIN ISOLATION LEVEL REPEATABLE READ; SELECT 1; 后挂着不提交;连接 B 反复 UPDATE t SET v = v + 1;。用 pg_total_relation_size('t') 看尺寸逐轮变大,VACUUM (VERBOSE) t; 会报告大量死元组「因 oldest xmin 被 A 钉住」而无法移除——这就是 第 3 章 §VACUUM 讲的回收被拦。InnoDB 侧:连接 A 同样开一个 REPEATABLE READ 事务读一下后挂起;连接 B 反复 UPDATE。查 SHOW ENGINE INNODB STATUS\G 里的 History list length 持续上涨,而表数据大小基本不动。把两边曲线放一起,§2 那张图就从纸面变成了实测。提交/结束长事务后,PG 需要 VACUUM 才能复用空间(但文件未必缩小)、InnoDB 的 purge 会追上来回收 undo——这一步也一并观察。