表设计、键与约束
用书店示例建立关系、属性依赖、参照完整性和完整 DDL。
本分册采用 PostgreSQL 18 文档作为语法和行为基线。归档中 PostgreSQL 16 或其他环境的 SQL、计划与耗时保留为历史记录,2026-10-05 整理未重跑这些数据库实验。示例需在独立可丢弃数据库内使用,各章同名表可能有不同结构;先核对 DDL、角色和数据再执行。
上一章为了尽快跑通业务,书店只建了三张表:作者只是 books 上的一个文本列,orders 没有外键保护,一张订单也只能买一本书。原型这样"能用",真实业务一上来就露马脚:作者没法精确统计,订单可能指向不存在的顾客。本章补上从"会用 SQL"到"会设计数据库"的一跃。
本章回答三个问题:怎样把一份需求变成一组表,怎样让数据库自动拦住坏数据,怎样判断设计好不好。贯穿案例仍是在线书店,最终产出一套六张表、用于解释结构与接线的完整设计;全部示例 SQL 已在 PostgreSQL 16 上实测验证,依赖更新版本的功能会标注版本号。
3.1 坏设计有多贵:三类更新异常
为什么需要。 先看一个直觉方案:把订单要用的信息统统塞进一张大宽表,每行存订单号、顾客邮箱、城市、书名、作者、价格、分类。录入时很痛快,问题在使用中才爆发——坏设计从不当场报错,它会在几周后用数据不一致向你收债。
教科书把这类问题总结为三类异常:
- 更新异常(Update Anomaly):顾客改了邮箱,他的 100 张订单就要跟着改 100 行;漏改一行,库里就同时存在两个互相矛盾的"事实"。
- 插入异常(Insert Anomaly):刚注册、还没下过单的新顾客,邮箱无处可存——邮箱列长在订单行上,没有订单就没有行。
- 删除异常(Delete Anomaly):删掉顾客的最后一张订单,连他的邮箱也一起消失了。
三类异常的共同根源是冗余(Redundancy):同一个事实被存了不止一份。由此得到贯穿本章的第一设计原则:
每个事实只存一次,存在最适合它的那张表里。
后面的范式、主键与外键,都是这条原则的落实。
3.2 从需求到 ER 图:先在纸上画,再动手建表
为什么需要。 打开 psql 直接敲 CREATE TABLE,就像不看图纸直接砌墙:表里装了数据、应用写了代码之后再改结构,代价远高于现在在纸上改一根线。
是什么。 实践中通用的方法是实体关系建模。实体(Entity)是要记录的"东西",对应需求里的名词;关系(Relationship)是实体间的联系,对应需求里的动词;每个关系再标注基数(Cardinality):一对一(1:1)、一对多(1:N)、多对多(M:N)。把这些画成一张图,就是实体关系图(Entity-Relationship Diagram,ER 图)。
怎么用。 四步走,以书店为例。
第 1 步,读需求、圈名词。需求一句话:"顾客注册后浏览并购买图书;每本图书有一位或多位作者;每张订单包含一本或多本图书及数量。"圈出名词:顾客、图书、作者、订单,以及藏在句尾的那个——订单明细(Order Item)。初学者最容易漏掉这种隐含实体:购买数量属于"订单 × 某本书"这一关联。
第 2 步,给每个关系标基数:
| 关系 | 基数 |
|---|---|
| 顾客 – 订单 | 1:N(一个顾客多张订单,一张订单只属一个顾客) |
| 订单 – 明细 | 1:N(一张订单包含多行明细) |
| 图书 – 明细 | 1:N(一本书可出现在多张订单里) |
| 图书 – 作者 | M:N(一本书多位作者,一位作者多本书) |
第 3 步,M:N 引入联结表(Junction Table)。图书与作者无法直接互挂外键——一个外键列只能存一个值。办法是拆出第三张表 book_authors,每行记录一次"书 × 作者"配对。
第 4 步,把 ER 图机械翻译成 DDL。六张表的关系先画出来:
customers ─1───N─ orders ─1───N─ order_items ─N───1─ books
authors ─M───N─ book_authors ─N───1─ books翻译规则只有三条:实体对应 CREATE TABLE;1:N 在 N 侧的表里加外键列(如 orders.customer_id);M:N 用联结表落地。妙处在于机械:ER 图画对之后,翻译成 SQL 只是体力活,关键判断都发生在纸上。
3.3 主键:给每一行发身份证
为什么需要。 表里可能有两个叫"王小明"的顾客、两本同名的书。要精确指认某一行、并让其他表能稳定地引用它,每张表都需要主键(Primary Key)——它像身份证号:不重复、不发空。回到准确定义:主键等价于 UNIQUE 加 NOT NULL,每张表至多一个;PostgreSQL 会自动为主键建一个唯一 B-tree 索引(Index);外键默认引用的就是对方的主键。
自然键还是代理键。 主键的取值从哪来,有两条路线:
- 自然键(Natural Key):本身有业务含义的字段,如 ISBN、邮箱、身份证号。直觉上很诱人——ISBN 天生唯一,何必再造编号?但业务字段会变(顾客换邮箱)、会录错重号(ISBN 抄错一位)。
- 代理键(Surrogate Key):系统生成的、没有任何业务含义的编号。优点是单列、稳定不变、JOIN 书写简单;缺点是看数据时不如 ISBN 直观、多占一列。
主流实践是两者都要:代理键当主键,业务键另加 UNIQUE 约束——前者保证行的身份稳定,后者保证业务不重复,两个约束各司其职。
怎么用:identity 列。 PostgreSQL 10 起的标准写法:
CREATE TABLE customers (
customer_id bigint GENERATED ALWAYS AS IDENTITY PRIMARY KEY, -- 代理键:系统发号
email text NOT NULL UNIQUE, -- 自然键:业务上不许重复
name text NOT NULL,
created_at timestamptz NOT NULL DEFAULT now()
);GENERATED ALWAYS AS IDENTITY 的意思是:编号由系统用内部**序列(Sequence)**自动生成,插入时不用提供、默认也不许提供:
INSERT INTO customers (email, name) VALUES ('alice@example.com', '王小明');
-- 成功,customer_id 由系统生成
INSERT INTO customers (customer_id, email, name)
VALUES (100, 'bob@example.com', '李雷');
-- 错误:cannot insert a non-DEFAULT value into column "customer_id"
INSERT INTO customers (customer_id, email, name)
OVERRIDING SYSTEM VALUE
VALUES (100, 'bob@example.com', '李雷');
-- 成功:数据迁移等场景可这样显式指定编号另一种形式 GENERATED BY DEFAULT AS IDENTITY 则允许手工值优先;业务表一般选 ALWAYS,把发号权握在系统手里。
关于自动编号,必须先建立两个认知。其一,identity 只保证"生成",不保证"唯一":它自动附带 NOT NULL,但不会自动加唯一约束,PRIMARY KEY 必须显式写出来。其二,编号有空洞、不连续:序列值一旦被消耗就不退回,连失败的插入也照样消耗。在一张全新的表上依次执行:
INSERT INTO customers (email, name) VALUES ('alice@example.com', '王小明') RETURNING customer_id;
-- 返回 1
INSERT INTO customers (email, name) VALUES ('alice@example.com', '李雷');
-- 失败:duplicate key value violates unique constraint "customers_email_key"
INSERT INTO customers (email, name) VALUES ('bob@example.com', '李雷') RETURNING customer_id;
-- 返回 3 而不是 2:失败的那次插入同样消耗了一个编号所以:取编号永远用 RETURNING,别假设它从 1 连续排到 N,更别拿它当业务单号或统计口径。
顺带纠正一个老写法。 老教程里的 serial 不是真正的类型,只是"建整数列 + 挂序列默认值"的记法便利,不会自动加主键或唯一约束,权限与依赖管理也更麻烦;官方 Wiki 明确建议新代码一律改用 identity。
UUID:另一类代理键。 当多台机器要各自生成编号——分布式部署、手机 App 离线先造好数据再同步、不想对外暴露"你是第几位注册用户"——可用 uuid 类型做主键。假设书店要给合作方发放 API 令牌:
CREATE TABLE api_tokens (
token_id uuid PRIMARY KEY DEFAULT gen_random_uuid(), -- 谁都能离线生成,撞车概率可忽略
note text
);
-- 插入后得到形如 c9371708-68d8-49e1-8ff0-00e281bf1c22 的值gen_random_uuid() 生成 v4 随机 UUID,PostgreSQL 13 起内置、无需扩展。代价是 uuid 占 16 字节,是 bigint(8 字节)的两倍,索引和 JOIN 更耗内存;随机值还让 B-tree 索引的写入局部性变差(PostgreSQL 18 新增按时间排序的 uuidv7() 缓解,需 PG 18+)。常见搭配:内部表用 bigint identity,对外令牌用 UUID。
3.4 外键:把"参照完整性"交给数据库
为什么需要。 没有外键时,"订单指向不存在的顾客"只能靠应用代码检查,而代码会被忘掉、被绕过——任何能连上数据库的工具都能直接 INSERT。不如建表时声明一次,让数据库不分昼夜、不讲情面地替你把关。
是什么。 外键(Foreign Key)声明"本列的值必须出现在被引用表的主键或唯一约束列中",数据库由此保证参照完整性(Referential Integrity):
CREATE TABLE orders (
order_id bigint GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
customer_id bigint NOT NULL REFERENCES customers, -- 值必须是已有顾客的 customer_id
status text NOT NULL DEFAULT 'pending',
created_at timestamptz NOT NULL DEFAULT now()
);
INSERT INTO orders (customer_id) VALUES (99999);
-- 错误:violates foreign key constraint "orders_customer_id_fkey"被引用的行删除时怎么办:ON DELETE。 外键必须回答这个问题。五种动作,每一种都是业务决策而非语法糖:
| 动作 | 行为 | 书店的选择 |
|---|---|---|
| NO ACTION(默认) | 仍有引用时拒绝删除 | 顾客:有订单的顾客不许删,保护交易历史 |
| RESTRICT | 有引用时立即拒绝删除 | 图书:有销售记录的书不许删 |
| CASCADE | 连带删除引用它的行 | 订单:删除订单,明细跟着消失 |
| SET NULL | 把引用列置为 NULL | 需要列允许 NULL |
| SET DEFAULT | 把引用列置为默认值 | 需要列有默认值 |
实际感受一下(下面的 order_items 订单明细表要到 3.8 节才完整定义,这里先看两种动作的效果;想亲手验证的读者,可先跳到 3.8 建表、插入数据,再回到本节重放这两条 DELETE)——给订单 1 插入明细之后:
DELETE FROM books WHERE book_id = 1;
-- 错误:violates foreign key constraint "order_items_book_id_fkey"
-- 这本书有订单明细引用,RESTRICT 拦下了删除——销售历史受保护
DELETE FROM orders WHERE order_id = 1;
-- 成功,且 order_items 中订单 1 的明细同步消失(CASCADE)订单删除时明细跟着消失,因为明细离开订单没有存在意义;图书被引用时禁止删除,因为销售历史比"删数据"更重要——真要下架一本书,加状态字段,而不是 DELETE。
一个性能提醒。 外键声明不会自动在引用列上建索引:被引用一方(主键)自带唯一索引,但引用列(如 order_items.book_id)什么都没有——删除或更新父表行时,PostgreSQL 只能全表扫描子表找引用行。数据量上来后记得补上:
CREATE INDEX ON order_items (book_id);
-- order_items.order_id 不必另建:复合索引可服务"只按其最左列"的查找,
-- 复合主键 (order_id, book_id) 的最左列恰好是 order_id(最左前缀规则,第 4 章展开)3.5 约束:让坏数据根本进不来
为什么需要。 垃圾进,垃圾出。与其指望每位程序员都记得检查"价格不能为负",不如建表时声明一次——数据库不请假、不转岗、不会漏写 if。上一章见过约束清单,本节补上几个必须亲测过才敢信的行为细节。
是什么。 **约束(Constraint)**是写在表定义里的规则,违反规则的写入被直接拒绝。PostgreSQL 自动为约束生成可预测的名字(表名_列名_约束类型),错误消息里看到的就是它;也可用 CONSTRAINT 显式命名,让日后 ALTER TABLE ... DROP CONSTRAINT 和排查报错更可读:
price numeric(10,2) NOT NULL CONSTRAINT positive_price CHECK (price >= 0)NOT NULL——默认选它。 NULL 表示"未知",不是空字符串也不是 0;它会让比较、聚合乃至下面的 UNIQUE 都出现意外行为。能确定必有值的列都应标 NOT NULL,官方文档直言"大多数列应当这样标记"(它等价于 CHECK (col IS NOT NULL),但更快)。
UNIQUE——拒绝重复,但默认放行重复的 NULL。 默认行为(NULLS DISTINCT)下两个 NULL 不算相等:
CREATE TABLE t1 (code text UNIQUE);
INSERT INTO t1 VALUES (NULL); -- 成功
INSERT INTO t1 VALUES (NULL); -- 仍然成功!两个 NULL 互不冲突
CREATE TABLE t2 (code text UNIQUE NULLS NOT DISTINCT); -- PG 15+
INSERT INTO t2 VALUES (NULL);
INSERT INTO t2 VALUES (NULL); -- 第二条即报 duplicate key设计含 NULL 的唯一列时必须想清楚要哪种行为:需要"视 NULL 为相同"就用 NULLS NOT DISTINCT(PostgreSQL 15 起支持)。
CHECK——自定义规则,任何能得出布尔结果的表达式都行:
CREATE TABLE books (
book_id bigint GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
isbn text NOT NULL UNIQUE,
title text NOT NULL,
price numeric(10,2) NOT NULL CHECK (price >= 0),
stock integer NOT NULL DEFAULT 0 CHECK (stock >= 0),
published_year integer CHECK (published_year BETWEEN 1000 AND 2100)
);
INSERT INTO books (isbn, title, price) VALUES ('9787020002207', '红楼梦', -59.70);
-- 错误:violates check constraint "books_price_check"CHECK 同样反直觉:挡不住 NULL。官方规则是表达式结果为 true 或 NULL 都算通过——price 为 NULL 时,price >= 0 的结果是 NULL。要禁 NULL 就写 NOT NULL,两者互补而非替代。
DEFAULT——能推导的值让系统填。 created_at 用 now()(返回事务开始时间)、订单刚创建时状态天然是 'pending'、新书记账时库存为 0。原则是"能算出来的就别让用户填",每少一次手工输入就少一个出错机会。
3.6 范式:一张坏表的三年改造
为什么需要。 3.1 节的原则"每个事实只存一次"听着对,但拿到一张具体的表,怎么判断哪些事实被存重了?**范式(Normal Form)**就是把这句口号拆成一步步可执行的检查。
是什么。 用一张坏表演练。假设有人把订单存成了这样的大宽表:
CREATE TABLE bad_orders_flat (
order_id integer,
customer_email text,
customer_city text,
book_title text,
author_names text, -- 多位作者用逗号分隔塞在一起
book_price numeric(10,2),
book_category text
);
INSERT INTO bad_orders_flat VALUES
(1, 'alice@example.com', '北京', '红楼梦', '曹雪芹,高鹗', 59.70, '古典小说'),
(1, 'alice@example.com', '北京', '呐喊', '鲁迅', 25.00, '现代文学'),
(2, 'bob@example.com', '上海', '呐喊', '鲁迅', 25.00, '现代文学'),
(3, 'bob@example.com', '上海', '彷徨', '鲁迅', 23.00, '现代文学');四行数据里,alice 存了两遍、bob 存了两遍,《呐喊》和 25.00 存了两遍,"鲁迅"出现了三次。下面逐步改造。
第一步:1NF——每个字段只存一个值。 问自己:"想统计鲁迅的书卖了多少本,怎么查?"
SELECT * FROM bad_orders_flat WHERE author_names LIKE '%鲁迅%';
-- 能查出 3 行,但只是"差不多":按作者 GROUP BY 统计时,
-- '曹雪芹,高鹗' 会被当成一个"作者"整串分组;
-- 若某书作者存的是'周树人'(鲁迅本名),LIKE '%鲁迅%' 直接漏掉;
-- 前导通配符 % 还让普通 B-tree 索引无能为力,只能全表扫描。一个字段里塞多个值,查询和统计就永远精确不起来。**第一范式(1NF)**要求每列只存单一的原子值。修法:作者独立成表 authors,用联结表 book_authors 关联——正是 3.2 节画过的结构。
第二步:2NF——非键列要依赖整个键。 先补一个概念:主键不必是单列,也可以由多列组合而成(组合键),行的唯一性由整组列共同确定——3.8 节的 order_items 就会用 (order_id, book_id) 做组合主键。这张表里能唯一标识一行的是 (order_id, book_title) 组合:订单 1 有两行,同一本书又出现在多张订单里。问:"《呐喊》从 25.00 涨到 26.00,要 UPDATE 多少行?"——它在多少张订单里就改多少行,漏一行就出现两个价格。根源是 book_price 只依赖键的左半 book_title、与 order_id 无关,这叫部分依赖(Partial Dependency)。**第二范式(2NF)**要求非键列依赖整个组合键。修法:书的信息搬进 books 表,订单明细只留"哪单、哪书、几本、成交价"。
第三步:3NF——非键列之间不许互相依赖。 问:"'现代文学'要改名'现代小说',要改多少行?"——三行。book_category 依赖的是 book_title,而 book_title 并不是键:键 → 书名 → 分类,这叫传递依赖(Transitive Dependency)。**第三范式(3NF)**要求非键列只依赖键,不依赖其他非键列。修法:分类随书进 books 表。
教科书里有一句著名口诀:"键、全键、只依赖键"——1NF 每列原子,2NF 依赖整个键,3NF 除了键什么都不依赖。改造完成后得到的,恰好就是 3.2 节 ER 图里的那六张表——实践流程与理论推导殊途同归,它们本是同一件事的两个视角。
3.7 何时适度反范式化
为什么需要。 范式越高越好吗?不是。规范化的代价是"拆":查询时要把分开的事实重新 JOIN 拼起来,读得越密集,这笔账越明显。
是什么。 工程界的共识(ClickHouse 等持此观点):读密集、查询模式固定、JOIN 代价实测过高时,可以定点引入冗余,即反范式化(Denormalization);账单是存储膨胀、每次更新要多维护一份、结构更难演进。正确顺序永远是:先规范化到 3NF,上线后用实测数据找出真正慢的那条查询,再为它单独反范式——而不是开局就打着"性能"的旗号堆冗余。
怎么分辨正当与偷懒。 两类最容易混淆的情形:
- 正当的历史快照(Snapshot):order_items.unit_price 记录"下单那一刻的成交价"。书后来涨价,历史订单本就不应该变。成交价与当前定价是两个独立的事实。
- 不正当的偷懒:books 表既存 category_id 又存 category_name——后者可由前者推出,分类改名就要改遍全表,这才是真冗余。
再如 books 上冗余 average_rating(平均分):可从评分表聚合算出,冗余存储省掉实时聚合,但每次评分变动都要重算。判断心法:把反范式字段当作可随时重建的派生数据——丢了能重算;不可重建的才叫真冗余。
3.8 动手:在线书店完整建表
把前面的所有决定拼起来,就是这套六表设计(已在 PostgreSQL 16 原调研记录通过):
CREATE TABLE customers (
customer_id bigint GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
email text NOT NULL UNIQUE, -- 自然键:业务唯一
name text NOT NULL,
created_at timestamptz NOT NULL DEFAULT now()
);
CREATE TABLE authors (
author_id bigint GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
name text NOT NULL,
bio text -- 作者简介,可暂缺
);
CREATE TABLE books (
book_id bigint GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
isbn text NOT NULL UNIQUE,
title text NOT NULL,
price numeric(10,2) NOT NULL CHECK (price >= 0),
stock integer NOT NULL DEFAULT 0 CHECK (stock >= 0),
published_year integer CHECK (published_year BETWEEN 1000 AND 2100)
);
CREATE TABLE book_authors ( -- M:N 联结表:复合主键,不加代理键
book_id bigint NOT NULL REFERENCES books ON DELETE CASCADE,
author_id bigint NOT NULL REFERENCES authors ON DELETE CASCADE,
PRIMARY KEY (book_id, author_id)
);
CREATE TABLE orders (
order_id bigint GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
customer_id bigint NOT NULL REFERENCES customers, -- 未写 ON DELETE,默认 NO ACTION
status text NOT NULL DEFAULT 'pending'
CHECK (status IN ('pending','paid','shipped','cancelled')),
created_at timestamptz NOT NULL DEFAULT now()
);
CREATE TABLE order_items (
order_id bigint NOT NULL REFERENCES orders ON DELETE CASCADE,
book_id bigint NOT NULL REFERENCES books ON DELETE RESTRICT,
quantity integer NOT NULL CHECK (quantity > 0),
unit_price numeric(10,2) NOT NULL CHECK (unit_price >= 0), -- 成交价快照
PRIMARY KEY (order_id, book_id)
);
CREATE INDEX ON order_items (book_id); -- 外键列手动补索引(见 3.4)几处值得驻足的设计决定:
- 联结表 book_authors 用复合主键 (book_id, author_id),不加代理键——主键本身就保证同一配对不会重复出现;
- order_items 同样用 (order_id, book_id) 做复合主键:同一本书在同一张订单里只有一行,买三本是把 quantity 设为 3,不是再插一行;
- unit_price 是成交价快照(见 3.7):CHECK 只要求非负,不与 books.price 强制一致;
- orders.customer_id 用默认 NO ACTION:有订单的顾客删不掉,交易历史可追溯。
冒烟测试——在全新数据库里插入一条完整的购买链路。编号以 RETURNING 实际返回为准;在全新库里按下面的顺序执行,生成的编号依次是:customer_id = 1,authors 两行的 author_id = 1 和 2,book_id = 1,order_id = 1:
INSERT INTO customers (email, name) VALUES ('alice@example.com', '王小明');
INSERT INTO authors (name) VALUES ('曹雪芹'), ('高鹗');
INSERT INTO books (isbn, title, price) VALUES ('9787020002207', '红楼梦', 59.70);
INSERT INTO book_authors (book_id, author_id) VALUES (1, 1), (1, 2); -- 两人合著
INSERT INTO orders (customer_id) VALUES (1) RETURNING order_id; -- 返回 1
INSERT INTO order_items (order_id, book_id, quantity, unit_price)
VALUES (1, 1, 2, 59.70); -- 买 2 本《红楼梦》常见误区
- 没有依据地给字符串列限长:PostgreSQL 的无长度限制 varchar 与 text 没有通常所想的性能区别。确需限长时可以采用
varchar(n),或用CHECK (char_length(col) <= n)表达业务上限,并考虑约束变化的迁移。 - 用 char(n)、无时区的 timestamp、money:char(n) 空格填充且比较时忽略尾随空格;跨时区运算用 timestamptz;金额用 numeric(10,2)。
- 继续用 serial,或以为 identity/serial 自动保证唯一:serial 不是真类型,不会自动加主键或唯一约束;identity 也只自动 NOT NULL——唯一性必须靠显式的 PRIMARY KEY 或 UNIQUE。
- 以为 CHECK 能挡住 NULL、UNIQUE 能挡住重复 NULL:CHECK 表达式算出 NULL 一律放行;UNIQUE 默认 NULLS DISTINCT,多个 NULL 互不冲突。禁 NULL 用 NOT NULL,视 NULL 为相同用 NULLS NOT DISTINCT(PG 15+)。
- 拿会变的业务字段当唯一标识,还不加 UNIQUE:邮箱、手机号、ISBN 都可能变更或录重;代理键做主键 + 业务键加 UNIQUE,两个约束各司其职。
- 忘记给外键列建索引:父表删除或更新会引发子表全表扫描,是数据量上来后"莫名慢查询"的头号嫌疑之一。
- 假设自增编号连续、无空洞、可当业务序号:失败的插入、事务回滚都会消耗序列值,统计"今天多少单"用 COUNT(*)。
- 一上来就"为了性能"反范式化:先到 3NF,实测 JOIN 是瓶颈后再定点冗余;订单明细存成交价是正当快照,图书表同时存分类 ID 和分类名就是偷懒。
- 把多个值塞进一个字段(逗号分隔、JSON 数组)来"省表":违反 1NF,无法建外键、无法高效统计;有关联语义的数据用联结表,数组只适合标签这类无关联语义的场景。
参考来源
- PostgreSQL 官方文档 §5.5 约束(Constraints):官方资料
- PostgreSQL 官方文档 §5.3 Identity Columns:官方资料
- PostgreSQL 官方文档 §8.1.4 Serial Types:官方资料
- PostgreSQL 官方文档 §8.12 UUID Type:官方资料
- PostgreSQL 官方文档 §2.2 Concepts(教程):官方资料
- PostgreSQL 官方 Wiki:Don't Do This:官方资料
- PostgreSQL 官方发行说明(13):官方资料
- PostgreSQL 官方发行说明(15):官方资料
- PostgreSQL 官方发行说明(18):官方资料
- PostgreSQL 官方版本支持策略:官方资料
- IBM Db2 12 文档:Normalization in database design:官方资料
- Redgate 工程博客:What Is a Surrogate Key?:官方资料
- ClickHouse 工程博客:When to denormalize, when to join:官方资料
最后更新于