Skip to content
数据类型与 DDL

数据类型与 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:00

timestamptz 记录的是时间轴上唯一的真实时刻;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_monetary locale 影响显示,范围有限,官方自己都建议改用 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,二选一都要管加值流程

五条军规:

  1. 时间一律 timestamptz,DEFAULT now(),展示层转时区;naive timestamp 出现在新表设计里要先被评审否决。
  2. 钱一律 numeric,float 与对账不共戴天。
  3. 主键默认 bigint IDENTITY;需要全局 ID 时 PG 18 用 uuidv7(),永远别拿随机 v4 当主键。
  4. 改大表先问"重不重写、锁多久":常量默认值加列、NOT VALID→VALIDATE 是安全三板斧;改类型、volatile 默认值加列走"新列双写"迁移。
  5. 派生值不手填:用生成列(18 起 virtual 是默认)或触发器统一维护 updated_at,把一致性交给数据库而不是程序员。