数据类型与 DDL
这一章解决两个问题:字段该选什么类型,以及表结构怎么建、怎么改才安全。手册以 PG 18 为基线、兼顾 14–17,所有版本归属都对照过官方 release notes 或当前文档。
本章建立的示例 schema(users / products / orders)是后续多章的公共底座,
03-SQL查询实战.md、04-索引实战.md、07-高级SQL.md、09-查询优化与EXPLAIN.md都会直接沿用,请不要跳过第 9 节。
数字类型:先想清楚"这个数是什么"
PG 的整数家族是 smallint(2 字节,±32768)、integer(4 字节,约 ±21 亿)、bigint(8 字节,约 ±9.2×10^18)。它们有历史别名 int2 / int4 / int8,两种写法完全等价。注意 PG 没有 tinyint——从 MySQL 过来的同学想找 0–255 的小整数,只能用 smallint 加一个 CHECK (col BETWEEN 0 AND 255),或者干脆用 smallint 不加约束,反正 2 字节和 1 字节的存储差异在真实业务里几乎可以忽略。
真正要花时间权衡的是 numeric 和浮点的选择。numeric(p, s) 是十进制定点数,精确存储、精确计算(超出 p 位精度会直接报 numeric field overflow 而不是悄悄截断);real(float4)和 double precision(float8)是二进制浮点,快但不精确:
SELECT 0.1::double precision + 0.2::double precision; -- 0.30000000000000004
SELECT 0.1::numeric + 0.2::numeric; -- 0.3这条差异决定了第一条类型军规:凡是钱,必须 numeric。理由不是"0.1+0.2 算出来差点"这么简单——二进制浮点无法精确表示任何十进制小数,误差会在乘税率、按比例分摊、多步累加中被放大并不可复现;对账时你会发现总账和明细差了几分钱,却查不出哪一步丢的。numeric 的代价是算术运算比 float8 慢、存储比 8 字节略大,但对金额列这点代价换来的是"每一分都能对上账"。科学计算、统计指标这类容忍误差的量才用 float8。金额精度取 numeric(12,2)(上限约 100 亿)或更宽的 numeric(18,4)(财务系统留四位小数分摊)足够。
自增主键:serial 是旧惯用法,IDENTITY 才是正路
serial 家族到底做了什么
serial 不是类型,而是一个"语法糖"。写下 id serial,PG 实际做了三件事:建一个 SEQUENCE、把列类型设为 integer、给列挂上 DEFAULT nextval('...'),最后用依赖关系把 sequence 绑到这一列(表删时 sequence 跟着删)。bigserial/smallserial 只是换成 bigint/smallint。
为什么官方文档推荐 GENERATED … AS IDENTITY
PG 的 serial 文档(datatypes-serial 一节)明确把它称为遗留惯用法,推荐 identity 列。差异集中在三点:
-- serial 写法
CREATE TABLE t1 (id bigserial PRIMARY KEY);
-- identity 写法(推荐)
CREATE TABLE t2 (id bigint GENERATED ALWAYS AS IDENTITY PRIMARY KEY);- 默认值可被覆盖。serial 只是 DEFAULT,显式传值就能绕过序列:
INSERT INTO t1 (id) VALUES (100);合法,之后序列爬到 100 附近还好,中间的空洞是并发隐患。GENERATED ALWAYS会拒绝用户写入(要强行写入需OVERRIDING SYSTEM VALUE),GENERATED BY DEFAULT行为接近 serial。数据被外部 ID 污染这类事故,ALWAYS 从源头掐死。 - sequence 是独立对象,权限跟着表走。serial 背后的 sequence 是独立的 catalog 对象:给了一张表的
INSERT权限,却没给 sequence 的USAGE, SELECT,插入直接报 permission denied for sequence——这个"表能写、序列不能取"的坑在权限精细化迁移时反复出现。identity 的 sequence 归属列本身,表权限自动覆盖它,不再需要单独 GRANT。 - pg_dump 与结构迁移行为。serial 表在 pg_dump 里被拆成
CREATE SEQUENCE+ALTER SEQUENCE OWNED BY+SELECT setval(...)一串对象,把库恢复到中途、或做表级导出时容易丢依赖链;identity 的序列定义(步长、起始值、缓存)内联在列定义里,dump/restore 是原子的。另外 serial 建完后改序列选项只能用ALTER SEQUENCE绕过,identity 支持ALTER TABLE ... ALTER COLUMN ... SET GENERATED ... / SET SEQUENCE OWNED BY ...的标准语法。
bigserial(或 bigint GENERATED ... AS IDENTITY)在绝大多数单库业务里都够用:bigint 是 9.2×10^18 个坑位,每天 1000 万单也要 2.5 亿年。真正要选型的是"主键用什么形态"。
主键选型:BIGSERIAL vs UUID v4 vs PG 18 uuidv7()
先说底层机制。PG 的 B-tree 索引按页组织(8KB 一页),一页装满就分裂。写入位置决定索引健康度:
- 自增 bigint:新行永远落在索引最右叶子页,写热点集中且可预测,页分裂少,缓存命中高。缺点是 ID 可枚举(爬虫顺着
id+1遍历)、分库/多写场景会撞号。 - 随机 UUID(v4)做主键:每个新 ID 都在 128 位全值域里随机落点,意味着每次插入都要在索引树中部找一个页写入。后果有三层:一是页分裂和随机 I/O 显著增多;二是"分裂后老页空洞"无法被 HOT 回收,B-tree 长期膨胀,实测随机 UUID 主键的索引体积能是自增主键的数倍;三是 16 字节主键会被每个二级索引的 leaf 整份复制一遍,所有二级索引跟着肥一圈。这就是为什么"用 UUID 做主键查询变慢"不是玄学,是页分裂和缓存局部性问题。
- uuidv7()(PG 18 新增):v7 把 Unix 毫秒时间戳放在 UUID 前 48 位,时间单调递增,因此索引写入重新回到最右侧,局部性接近自增;同时保持全局唯一、不可枚举、跨系统可生成。它保留了 UUID 的全部好处,丢掉了随机 v4 的索引代价,是"业务要在系统外生成 ID / 多写入方 / 未来可能分片"场景的首选。PG 18 同时提供了
uuidv4()作为gen_random_uuid()的别名。
SELECT uuidv7(); -- 例如 01953a4c-3f1e-7xxx-xxxx-xxxxxxxxxxxx,前缀含时间选型速判:单库为主、ID 不外泄、追求最省索引空间 → bigint identity;需要对外发号、多服务各自生成、有合并/分片预期 → PG 18 用 uuidv7(),17 及以下自己按 v7 规范生成,不要用随机 v4 做聚簇类主键。
字符串:忘掉 MySQL 的那套肌肉记忆
PG 里 text、varchar(n)、char(n) 底层是同一个变长类型(varlena),官方文档原话是三者"没有任何性能差异"。MySQL 时代"varchar(255) 比 TEXT 省空间/更快"的经验在 PG 里全部不成立:varchar(255) 和 text 存储一个字节都不差,区别只在于前者多一道长度校验。所以推荐默认 text,只有当"最长 n 个字符"本身是业务规则时才写 varchar(n),让数据库帮你挡住脏数据;想加别的文本约束用 CHECK。
char(n) 是补空格定长类型,坑在于它"补齐存储、比较时又忽略尾部空格":
SELECT 'abc'::char(10) = 'abc'::text; -- true,尾部空格在比较时被无视
-- 往 char(5) 里放 'abcdef' 不会报错,只发一个截断 WARNING,数据没了除非有"必须定长且带填充语义"的遗留协议对接,否则不要用 char(n)。二进制内容用 bytea:存图片缩略图、加密后的密文等,输出默认是 hex 格式(\x 前缀),比较和拼接有专门操作符,别用 text 存二进制(会受编码/换行规则折磨)。
时间类型:全手册第一军规——一律 timestamptz
timestamp 不带时区,存进去什么就是什么,是一个"墙上时钟的字面抄写";timestamptz(timestamp with time zone)在写入时按当前会话的 timezone 参数解释字面量,内部统一存成 UTC。取数时,timestamptz 会按会话时区渲染成"本地时间"。看一段可以亲手跑的差异:
CREATE TABLE tz_demo (naive timestamp, with_tz timestamptz);
INSERT INTO tz_demo VALUES ('2026-10-20 08:00:00', '2026-10-20 08:00:00');
SET timezone = 'Asia/Shanghai';
SELECT * FROM tz_demo; -- 两列都显示 08:00:00(+08)
SET timezone = 'UTC';
SELECT * FROM tz_demo; -- naive 仍显示 08:00:00;with_tz 显示 00:00:00timestamptz 记录的是时间轴上唯一的真实时刻;naive timestamp 只是数字,换个会话/换个应用服务器时区,同一个值代表的真实时刻就不一样了。业务数据一旦要跨时区(出海、多数据中心、夏令时),naive timestamp 的语义就靠"全公司都记住约定它是某个时区"来维持——约定是最不可靠的东西。所以:所有时间戳列一律 timestamptz,会话默认设好客户端时区,让展示层做本地化。唯一的例外是"纯日历日期"(生日、营业日)用 date,它本来就不该带时刻。
顺带认识四个"当前时间"函数,它们在事务/语句边界内行为不同:
SELECT now(), -- = transaction_timestamp():事务开始时刻,事务内恒定
transaction_timestamp(),
statement_timestamp(), -- 当前语句开始时刻
clock_timestamp(); -- 真实墙钟,每调用一次都不同now() 在一条语句/事务里取值稳定,这是它适合做 DEFAULT 和审计列的原因——同一事务里多行时间戳完全一致,便于排序和边界判断。做耗时统计要用 clock_timestamp(),否则语句内部算时长会得到恒为 0 的荒谬结果。另外三个都标记为 STABLE,只有 clock_timestamp() 是 VOLATILE,这决定了它们能不能出现在索引表达式等场景(见 04-索引实战.md)。
interval 是时长类型:now() - created_at > interval '30 days'、created_at + interval '2 hours 30 mins' 都直接可写,注意 interval '1 month' 是"自然月"加法(1月31日加一个月是2月28/29日),而 interval '30 days' 是固定 30 天,两者做周期计算不等价。date_trunc('month', created_at) 把时间截到月初,是分组报表的常客——但不要拿它包住列去做 WHERE 过滤,函数包列会让 B-tree 索引失效,应改写成左闭右开区间谓词,原因和写法在 09-查询优化与EXPLAIN.md 展开。
其他类型速览
每种一句"什么时候用它、注意什么",够用;深入用法各自见对应章节。
- boolean:
TRUE/FALSE/NULL三值,“flag 不为真"要用IS NOT TRUE而不是= false或NOT flag(后两者对 NULL 行不生效)。 - inet / cidr / macaddr:IP 地址专用类型,支持
<<=(包含)等操作符并可建索引。存 IP 千万别用 text——字符串等值查不了网段,排序也不是数值语义。 - money:受
lc_monetarylocale 影响显示,范围有限,官方自己都建议改用 numeric——记住"钱用 numeric"即可,本手册不出现 money。 - enum:枚举类型按定义顺序(不是字母序)排序和比较。加值要
ALTER TYPE ... ADD VALUE:PG 12 之前不能在事务块里执行;12 起可以在事务里执行,但新值要等提交后才能被使用。历史上 enum 新增值与缓存执行计划交互曾导致老连接报错(2021 年初的小版本修复了这条)。状态机字段用 enum 可读性好,但迁移脚本要按上述规则处理;嫌麻烦就用text + CHECK,代价是改约束才能扩值域。 - domain:带约束的可复用"类型别名”,
CREATE DOMAIN email AS text CHECK (VALUE ~ '^[^@]+@[^@]+$');之后列直接写email类型。适合把"手机号/邮箱/邮编"这类规则一次定义、处处复用。 - range(区间类型):
int4range / numrange / tstzrange表示一个区间,&&判断重叠。看一个完整可跑的小例子——会议室预订,“同一房间的时间不允许重叠”:
CREATE EXTENSION IF NOT EXISTS btree_gist; -- 让 int 列能进 GiST 索引
CREATE TABLE room_booking (
id int GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
room_id int NOT NULL,
during tstzrange NOT NULL,
-- 约束:room_id 相等 且 时间重叠 的行被拒绝
EXCLUDE USING gist (room_id WITH =, during WITH &&)
);
INSERT INTO room_booking (room_id, during)
VALUES (1, tstzrange('2026-10-21 14:00', '2026-10-21 15:00'));
INSERT INTO room_booking (room_id, during)
VALUES (1, tstzrange('2026-10-21 14:30', '2026-10-21 15:30')); -- 报错:冲突排除约束
INSERT INTO room_booking (room_id, during)
VALUES (2, tstzrange('2026-10-21 14:30', '2026-10-21 15:30')); -- 另一间房,合法底层靠 GiST 索引支撑,索引选型细节见 04-索引实战.md。
- 数组:
text[]、int[]等。查询操作符@>(包含)、&&(有交集)、<@(被包含),配 GIN 索引可加速(见04-索引实战.md)。边界情况:数组适合"标签、属性集合"这种整体读写的附属数据;用它替代多对多关联表会失去外键约束和逐元素统计能力,关系请老老实实建中间表。 - json vs jsonb:
json原样存服务器收到的文本(保留重复键、键顺序、多余空白,读时重新解析);jsonb存解析后的二进制(重复键只留最后一个、键不保序、读取快),且只有 jsonb 能建 GIN 索引。官方文档的建议就是绝大多数场景用jsonb。什么时候才用 json:需要保留原始报文做审计逐字节回放时。深入操作符与索引见07-高级SQL.md。
生成列:STORED 与 VIRTUAL
问题背景:total = quantity * price 这种派生字段,用应用层维护会忘记同步,用视图维护会丢约束,传统答案是冗余列 + 触发器。PG 12 起提供生成列(generated column),表达式自动算,用户不能直接写:
CREATE TABLE demo (
quantity int,
price numeric(10,2),
total numeric(12,2) GENERATED ALWAYS AS (quantity * price) STORED
);
INSERT INTO demo (quantity, price) VALUES (3, 9.99); -- total 不用给值
SELECT * FROM demo; -- total = 29.97
INSERT INTO demo (quantity, price, total) VALUES (1,1,1); -- 报错:不能直接写生成列- STORED(PG 12 起):写入时计算、占用存储,读零成本;行为上接近"自动维护的冗余列"。
- VIRTUAL(PG 18 起,且新语法下 virtual 是默认):不占存储,读取时才计算。官方类比很贴切:virtual 生成列像视图,stored 生成列像物化视图。写
GENERATED ALWAYS AS (...)不跟关键字时默认 virtual,要落盘需显式STORED。
关键差异在索引:virtual 列每次读都重算,所以不能直接拿 virtual 生成列建索引——想加速查询,要么改成 STORED,要么对"生成表达式本身"建表达式索引(见 04-索引实战.md);表达式里只能用内置不可变函数,不能引用自定义函数/类型,且不能引用同表其他生成列。14–17 没有 virtual,只有 STORED;跨版本兼容的写法是显式写 STORED(PG 18 也接受)。
约束体系
约束是数据库层的数据合同,PG 的约束种类比多数数据库全。逐个说语义和坑。
主键与外键:ON DELETE 怎么选
外键的引用动作有五种,日常真正纠结的是前三个:
| 动作 | 行为 | 典型场景 |
|---|---|---|
RESTRICT(PG 里等同默认 NO ACTION,区别在 deferrable 时才有) |
有子行就拒绝父行被删改 | 审计、订单历史——删用户之前先走业务注销流程 |
CASCADE |
删父行时级联删光子行 | 真"从属物"(帖子与帖子的编辑记录) |
SET NULL |
子行外键置 NULL | 引用关系断了但数据本身还有价值(referee 删除后保留比赛记录) |
SET DEFAULT 和 NO ACTION 少用。什么时候不用 CASCADE:经验法则——“你不确定要 CASCADE 的就不要 CASCADE”。它把删除变成不可逆的链式物理删除,一旦误删父行(或运维脚本条件写错),数据沿着依赖图一路蒸发且不可回头;交易类数据宁可 RESTRICT,让删除失败逼出清理逻辑。需要软删除时用 deleted_at 列,而不是 FK CASCADE。
DEFERRABLE / INITIALLY DEFERRED:默认约束在语句结束时检查;声明为 deferred 的约束推迟到事务提交才检查。典型场景是循环引用(A 的行引用 B 的行,B 的行又引用 A 的行,批量导入时互相等对方)和"同一事务里先插引用再插被引用"。FK 和 CHECK 支持;NOT NULL 和 PK/UNIQUE 不支持延迟。
UNIQUE
对 NULL 默认"互不相同"(多行 UNIQUE 列可以同时为 NULL,PG 15 起可用 UNIQUE NULLS NOT DISTINCT 改变为把 NULL 视为相等)。主键就是 UNIQUE + NOT NULL + 每表唯一。
CHECK
行级谓词,CHECK (price > 0)、CHECK (ends_at > starts_at)。版本坑:PG 12 之前 CHECK 约束对继承表没有完整执行——在父表加 CHECK 只校验父表现有数据并挂到子表,子表后续插入可以绕过校验;PG 12 起改为对继承子表递归创建并校验,堵住了这个洞。分区表不受影响(分区约束本来就会下推)。另外 CHECK 表达式必须不可变——CHECK (created_at > now() - interval '1 day') 这种写法直接报错,别试图用 CHECK 做"时间必须新鲜"的校验。
EXCLUDE
见上文会议室例子:EXCLUDE USING gist (col1 WITH =, during WITH &&),把"某种组合不允许同时出现"变成声明式约束,适合排班、预订、防重叠一切场景。需要对应 opclass 的索引类型支撑(范围类型基本靠 GiST)。
NOT NULL 的演进(14→18 最容易踩版本差异的一条)
历史上 NOT NULL 不是 pg_constraint 里的约束行,而是 pg_attribute 上的一个布尔位。后果之一:它不支持 NOT VALID,给亿级大表加 SET NOT NULL 要全程持有 ACCESS EXCLUSIVE 并做一次全表扫描——期间全库读写被堵住。可用的旧办法(PG 12 起):
-- PG 12+ 通用配方:先证明,再约束
ALTER TABLE orders ADD CONSTRAINT chk_coupon_not_null
CHECK (coupon_code IS NOT NULL) NOT VALID; -- 秒回
ALTER TABLE orders VALIDATE CONSTRAINT chk_coupon_not_null; -- 长扫描,但只拿 SHARE UPDATE EXCLUSIVE
ALTER TABLE orders ALTER COLUMN coupon_code SET NOT NULL; -- PG 12 起识别 CHECK 证明,跳过扫描
ALTER TABLE orders DROP CONSTRAINT chk_coupon_not_null;PG 18 把它变成了正路:NOT NULL 约束记录进 pg_constraint(每个 NOT NULL 拥有一行记录、可以有名字),ALTER TABLE ... ALTER COLUMN ... SET NOT NULL NOT VALID 与 VALIDATE CONSTRAINT 原生可用;外部表也支持 NOT NULL;删除约束时不用再遍历继承链,drop 也更快。18 之前用 CHECK 配方、18 起用原生两段式,是这一节要记住的版本线。(注意核对过官方 18.0 release notes:NOT NULL 支持 NOT VALID 属于 18,不是更早版本。)
DDL 实战面
CREATE TABLE IF NOT EXISTS 幂等建表,迁移脚本必备;CREATE SCHEMA 命名空间,按业务域或团队分组比按库分组成本低(同库可 JOIN,跨库不能)。
真正需要一张表的是"改表代价"——哪些 ALTER 操作重写全表、拿什么锁:
| 操作 | 重写全表? | 锁与代价 |
|---|---|---|
| 加列(无默认,或常量默认,PG 11+) | 否 | ACCESS EXCLUSIVE 一瞬间(仅改 catalog) |
| 加列(volatile 默认,如 now()) | 是 | ACCESS EXCLUSIVE + 全表重写,大表事故 |
ALTER COLUMN ... SET DEFAULT |
否 | 瞬时 |
| 改列类型(不兼容时) | 是 | 全表重写 + 索引重建,大表上按小时计 |
| 删列 | 堆不立即重写 | 短暂 AE 锁;但依赖它的索引/约束被重建,空间等 vacuum full/重写才回收 |
| ADD CHECK(NOT VALID → VALIDATE) | 否 | 加约束瞬间 AE;VALIDATE 长时间 SHARE UPDATE EXCLUSIVE |
| SET NOT NULL(18 的 NOT VALID→VALIDATE) | 否 | 同上 |
由此推出大表加列的安全流程(以给 orders 加"用券码"列并带默认值为例):
-- 1) 先加可空、无默认值的列:只动 catalog,秒级完成
ALTER TABLE orders ADD COLUMN coupon_code text;
-- 2) 应用层开始双写新列(新数据自己填),历史数据分批回填,避开高峰
UPDATE orders SET coupon_code = 'DEFAULT' WHERE id BETWEEN $1 AND $2; -- 每批几千行,循环推进
-- 3) 设默认值(不重写,PG 11+ 把常量默认值记在 catalog 里)
ALTER TABLE orders ALTER COLUMN coupon_code SET DEFAULT 'NONE';
-- 4) 最后才加 NOT NULL:18 用 SET NOT NULL NOT VALID + VALIDATE;14–17 走上面的 CHECK 配方反过来,ALTER COLUMN TYPE 这种全表重写操作,生产上宁可用"新列 + 双写 + 切换 + 删旧列"的在线迁移套路。
索引创建的一句话伏笔:CREATE INDEX CONCURRENTLY 建索引不阻塞写入(普通 CREATE INDEX 阻塞写、不阻塞读),代价是慢一些且失败会留下 INVALID 索引需要手动 drop,详见 04-索引实战.md。
其他常用 DDL:ALTER TABLE ... RENAME TO ... / ALTER INDEX ... RENAME TO ...(短暂 AE,改完应用层缓存的表名要跟着发版);TRUNCATE:拿 ACCESS EXCLUSIVE 秒级清空整表,可 RESTART IDENTITY 重置序列、CASCADE 连带清空引用它的表。它按结构整体丢弃数据而不是逐行删,所以比 DELETE FROM 全表快一个数量级、不触发行级触发器,但在事务里照样可以回滚;DROP 的 CASCADE 非常危险——PG 依赖关系图(pg_depend)里的每个依赖对象都会被顺走,误用 DROP COLUMN ... CASCADE 可能连带删掉视图、触发器、外键,操作前用 \d+ 表名 或查 pg_depend 确认依赖链。
注释与元信息:COMMENT ON TABLE users IS '下单用户';、COMMENT ON COLUMN ... 存进 catalog,psql \d+ 可见;把"为什么有这个字段"写进注释比写进 wiki 可靠,注释随库走。
建库层面:CREATE DATABASE shop TEMPLATE template0 ENCODING 'UTF8' —— 从 template1 建库时要求编码/排序规则与模板一致,需要自定义编码就用 template0 从零拷贝。排序规则方面 PG 15 起允许把 ICU collation 设为集群或数据库默认(此前 ICU 只能在具体列 COLLATE 层面使用,默认是 libc),LOCALE_PROVIDER icu + --icu-locale=zh_CN 可以获得更稳定的跨平台排序行为。
示例 schema 总装(后续章节公共底座)
三张表 + 一个更新时间触发器。注意类型选择的注释——这一页要反复回看。
-- 订单状态枚举(加值规则见上文 enum 一节)
CREATE TYPE order_status AS ENUM ('pending','paid','shipped','completed','cancelled');
CREATE TABLE users (
id bigint GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
email text NOT NULL UNIQUE, -- 登录标识;text 而非 varchar(n)
nickname text, -- 可为空:演示 JOIN/NULL 行为用
password_hash text NOT NULL, -- 应用层生成(bcrypt/argon2)
referrer_id bigint REFERENCES users(id) -- 自引用外键:邀请关系,可为空
ON DELETE SET NULL,
balance numeric(12,2) NOT NULL DEFAULT 0 -- 钱 = numeric,不是 float
CHECK (balance >= 0),
created_at timestamptz NOT NULL DEFAULT now(), -- 军规:一律 timestamptz
updated_at timestamptz NOT NULL DEFAULT now()
);
CREATE TABLE products (
id bigint GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
title text NOT NULL,
price numeric(12,2) NOT NULL CHECK (price > 0),
stock int NOT NULL DEFAULT 0 CHECK (stock >= 0),
tags text[] NOT NULL DEFAULT '{}', -- 标签:数组适合整体读写
attrs jsonb NOT NULL DEFAULT '{}', -- 扩展属性:99% 场景用 jsonb
created_at timestamptz NOT NULL DEFAULT now(),
updated_at timestamptz NOT NULL DEFAULT now()
);
CREATE TABLE orders (
id bigint GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
order_no text NOT NULL UNIQUE, -- 对外单号(应用可发 uuidv7)
user_id bigint NOT NULL REFERENCES users(id)
ON DELETE RESTRICT, -- 交易数据:宁可拒绝删用户
product_id bigint NOT NULL REFERENCES products(id)
ON DELETE RESTRICT,
quantity int NOT NULL CHECK (quantity BETWEEN 1 AND 99),
unit_price numeric(12,2) NOT NULL CHECK (unit_price >= 0), -- 下单时价格快照
status order_status NOT NULL DEFAULT 'pending',
paid_at timestamptz, -- 可为空:未支付就是 NULL
-- 派生金额:PG 18 默认 VIRTUAL(不占存储);14–17 请写 STORED
total_amount numeric(12,2) GENERATED ALWAYS AS (quantity * unit_price) VIRTUAL,
created_at timestamptz NOT NULL DEFAULT now(),
updated_at timestamptz NOT NULL DEFAULT now()
);
-- 通用"更新时间"触发器:UPDATE 自动刷新 updated_at
CREATE FUNCTION set_updated_at() RETURNS trigger
LANGUAGE plpgsql AS $$
BEGIN
NEW.updated_at := now();
RETURN NEW;
END $$;
CREATE TRIGGER trg_users_updated BEFORE UPDATE ON users
FOR EACH ROW EXECUTE FUNCTION set_updated_at();
CREATE TRIGGER trg_products_updated BEFORE UPDATE ON products
FOR EACH ROW EXECUTE FUNCTION set_updated_at();
CREATE TRIGGER trg_orders_updated BEFORE UPDATE ON orders
FOR EACH ROW EXECUTE FUNCTION set_updated_at();几个刻意的设计决定,后面会反复用到:referrer_id 可空(第 4、9 节 NOT IN 与自连接的素材);nickname/paid_at 可空(NULL 专题素材);total_amount 是 virtual 生成列(生成列不可直接写、聚合与 RETURNING 里当普通列读);unit_price 是快照价而不是 JOIN products 取现价(演示"别信 JOIN 来的会变的价");orders 对两张父表都是 RESTRICT(03 章演示 ON CONFLICT/删除失败)。
实战建议
类型选择速查(钱和时间已经在正文立过规矩,这里只收零碎的):
| 你要存的 | 选 |
|---|---|
| 整数 | int(21 亿够用)否则 bigint;smallint 只在范围天然小时用 |
| 金额/精确小数 | numeric(p,s),永远不用 float |
| 科学计数 | float8 |
| 任意文本 | text;只有"长度即规则"才 varchar(n) |
| 时间 | timestamptz;纯日历日期用 date |
| IP | inet;布尔标志 boolean + 三值意识 |
| 键值扩展字段 | jsonb;可索引、可查询 |
| 状态机 | enum 或 text+CHECK,二选一都要管加值流程 |
五条军规:
- 时间一律 timestamptz,DEFAULT now(),展示层转时区;naive timestamp 出现在新表设计里要先被评审否决。
- 钱一律 numeric,float 与对账不共戴天。
- 主键默认 bigint IDENTITY;需要全局 ID 时 PG 18 用
uuidv7(),永远别拿随机 v4 当主键。 - 改大表先问"重不重写、锁多久":常量默认值加列、NOT VALID→VALIDATE 是安全三板斧;改类型、volatile 默认值加列走"新列双写"迁移。
- 派生值不手填:用生成列(18 起 virtual 是默认)或触发器统一维护 updated_at,把一致性交给数据库而不是程序员。