PostgreSQL 生产级数据模型、数据类型、约束与主键设计
从数据类型、NULL 语义、主键、租户键、约束、宽表、序列与 Go/pgx 写入路径,建立生产级 PostgreSQL 数据模型设计方法。
第 2 章:生产级数据模型、数据类型、约束与主键设计
技术基线:PostgreSQL 18;兼顾 PostgreSQL 14、15、16、17 的差异。Go 示例使用
github.com/jackc/pgx/v5与pgxpool。资料核对日期:2026-06-20。
1. 本章定位
数据模型决定了数据能表达什么、数据库必须维护哪些索引、一次写入会修改多少页面、并发事务在哪些对象上竞争,以及故障恢复后能否继续维持业务不变量。错误的数据模型无法仅靠增加索引解决:索引不能把近似浮点数变成精确金额,不能替代缺失的租户隔离,不能让“先查库存再扣减”自动变成原子操作,也不能消除过宽主键对所有二级索引和外键的放大。
本章位于架构总览之后、物理存储与索引原理之前。它为后续 Page、Tuple、B-tree、MVCC、锁、WAL、复制和在线迁移建立业务入口:先决定“存什么、如何标识、由谁保证正确”,再讨论“数据库如何存、如何查、如何并发执行”。
本章不深入 Heap Page 格式、B-tree 分裂算法、隔离级别完整冲突矩阵、分区、在线 DDL 和分片;这些内容分别在后续章节展开。
2. 可验证的学习目标
完成本章后,你应能:
- 根据精度、范围、CPU 和空间要求,在
integer、bigint、numeric、real、double precision之间做出可解释选择。 - 说明
text、varchar(n)、char(n)的真实差异,并拒绝“所有字符串都用 varchar(255)”的惯性设计。 - 正确建模时间点、业务日期、当地墙上时间和持续时间,解释
timestamp与timestamptz的边界。 - 判断字段应使用关系列、
jsonb、数组、枚举、Domain 还是独立关联表。 - 解释 SQL 三值逻辑、
CHECK对NULL的行为,以及[PG15+] NULLS NOT DISTINCT的用途。 - 比较自然键、
bigint Identity、Snowflake BIGINT、UUID v4 与[PG18] uuidv7()对索引、WAL、并发和外部接口的影响。 - 设计带
tenant_id的主键、唯一约束和外键,阻止跨租户引用。 - 使用
PRIMARY KEY、UNIQUE、FOREIGN KEY、CHECK、EXCLUDE和DEFERRABLE表达业务不变量。 - 通过
pg_column_size、关系大小、TOAST 关系和执行计划识别宽表与SELECT *的代价。 - 复现应用层“先检查再写入”的竞态,并用条件更新、唯一约束或排斥约束消除竞态。
- 在 Go/pgx 中正确处理时间、精确数值、UUID、
NULL、SQLSTATE、事务、幂等键和租户条件。 - 形成数据模型故障的生产 Runbook,并能分析其性能、并发和高可用影响。
3. 选型速查
| 设计问题 | 默认选择 | 需要警惕的误区 | 生产判断 |
|---|---|---|---|
| 金额、税率、精确计量 | numeric(p,s) 或最小单位 bigint | 用 double precision 再格式化成两位小数 | 先定义币种、精度、舍入规则,再选类型 |
| 长周期计数、内部主键 | bigint / bigint GENERATED ... AS IDENTITY | 用 integer 赌增长不会超过 21 亿 | 主键类型迁移会牵动 FK、索引、Go 类型和消息协议 |
| 字符串 | text | 机械使用 varchar(255) 或 char(n) | varchar(n) 只表达真实业务上限,char(n) 极少需要 |
| 绝对时间点 | timestamptz | 把 timestamp 当 UTC | timestamptz 保存瞬间,不保存原始 IANA 时区名 |
| 业务日期 | date | 转成午夜时间戳 | 出生日期、账期日、节假日不是瞬间 |
| 当地规则时间 | time/timestamp + IANA 时区名 | 只保存固定偏移 | “每天 09:00”要经过 DST 和时区规则解释 |
| 可变扩展属性 | 有界 jsonb | 核心字段全部塞 JSON | 需要 FK、唯一、统计、分页、局部更新时提升为关系列 |
| 小集合状态 | Enum、Domain 或引用表 | 状态值随业务频繁变化仍用 Enum | 稳定集合可用 Enum;带属性、标签、权限时用表 |
| 可选值 | 明确允许 NULL 的语义 | 用空字符串、0、false 伪装缺失 | 必填用 NOT NULL;比较用 IS DISTINCT FROM 等语义明确的写法 |
| 行内不变量 | CHECK | 以为 CHECK 能检查其他行 | CHECK 只看当前行,且 UNKNOWN 会通过 |
| 唯一与引用 | UNIQUE / FOREIGN KEY | 只在 Go 里先查重 | 数据库约束才有并发仲裁能力 |
| 区间互斥 | EXCLUDE USING gist | 用应用遍历或 CHECK 防重叠 | 房间预订、排班、时间段占用属于跨行不变量 |
| 多租户隔离 | (tenant_id, id) 进入 PK/UNIQUE/FK | 只在业务代码里拼 WHERE tenant_id = ? | 约束层要阻止跨租户引用 |
| 外部 ID | UUID v7 或 Snowflake BIGINT | 认为“分布式 ID”就不需要唯一约束 | 生成器负责分配,数据库唯一约束负责最终兜底 |
| 大字段 | 垂直拆分或按需读取 | 热点接口长期 SELECT * | TOAST 不是免费午餐,仍有解压、网络、WAL 和缓存代价 |
4. 生产建模原则
4.1 先确定语义,再讨论类型
类型不是存储细节,而是业务承诺。金额需要十进制精确,计数需要范围余量,科学统计可以接受近似误差;三者不能因为“都是数字”就统一成一个类型。字符串也是同理:text 通常足够,长度上限应来自业务规则,而不是来自历史习惯。
时间建模要先问四个问题:这是一个已经发生或计划发生的瞬间,还是一个业务日期;是否需要保留用户原始时区;是否会跨 DST;持续时间能否换算成固定秒数。多数审计字段用 timestamptz,营业规则、排班规则和当地墙上时间则需要单独保存时区规则。
4.2 让数据库维护业务不变量
Go 层校验负责更早失败和更好错误信息;数据库约束负责提交态正确性。只要两个请求可能并发,就不要把“先查再写”当作一致性边界。
常见映射:
| 业务规则 | 数据库表达 |
|---|---|
| 商品库存不能为负 | CHECK (stock >= 0) + 条件扣减语句 |
| 同租户 SKU 唯一 | UNIQUE (tenant_id, normalized_sku) |
| 订单行只能引用本租户商品 | FOREIGN KEY (tenant_id, product_id) |
| 同一房间时间段不能重叠 | EXCLUDE USING gist (... during WITH &&) |
| 同一请求只能扣一次库存 | UNIQUE (tenant_id, request_id) |
| 可空字段最多一条活动记录 | 部分唯一索引或 [PG15+] UNIQUE NULLS NOT DISTINCT |
CHECK 的 UNKNOWN 会通过,所以 price numeric CHECK (price > 0) 不能禁止 NULL;应写成 price numeric NOT NULL CHECK (price > 0)。普通 UNIQUE 默认允许多个 NULL;是否把 NULL 视为相同值,必须显式建模。
4.3 主键选型要算放大成本
主键不仅存在于主表索引。它会进入外键、二级索引、Join、逻辑复制身份、Outbox、日志、缓存键和外部接口。8 字节 bigint 与 16 字节 UUID 的差异,会被这些路径一起放大。
| 方案 | 适合 | 代价 |
|---|---|---|
bigint Identity | 单主写入、内部实体、索引紧凑优先 | 依赖数据库分配,可枚举,极高并发下可能有右侧页热点 |
| Snowflake BIGINT | 写库前就需要全局任务 ID,且要贯穿 DB/Redis/MQ/日志/回调 | 需要治理 worker_id、时钟回拨、同毫秒序列耗尽和多机房分配 |
| UUID v4 | 客户端/多系统离线生成、难枚举 | 随机写入、索引更宽、缓存工作集更大 |
[PG18] uuidv7() | 希望保留 UUID 外部特性,同时改善插入局部性 | 仍是 16 字节,会暴露粗粒度创建时间,不等于提交顺序 |
多租户共享表优先让 tenant_id 进入主键、唯一约束和外键。即使用全局唯一 ID,也应让授权查询携带 tenant_id,并对租户内自然键建立复合唯一约束。
4.4 控制行宽、TOAST 和 SELECT *
宽表的成本不只在磁盘。每页行数下降会降低缓存命中;大 text、bytea、jsonb 可能进入 TOAST,但读取、解压、网络传输和 Go 分配仍然存在;更新大字段还会增加 WAL、Vacuum 压力和复制延迟。
判断一个字段是否应该从热点表拆出去,可以看四点:是否很大,是否很少与热点字段同读,是否权限或生命周期不同,是否会频繁局部更新。大正文、原始回调、审计详情通常适合独立表或对象存储引用;热点列表页只选择必要列,不用 SELECT *。
4.5 并发正确性优先使用原子 DML
库存、余额、配额这类不变量应把检查和修改合并到同一条语句:
UPDATE app.product
SET stock = stock - $3,
version = version + 1,
updated_at = statement_timestamp()
WHERE tenant_id = $1
AND product_id = $2
AND stock >= $3
RETURNING stock, version;
返回零行不是数据库异常,而是业务结果:库存不足、对象不存在或租户条件不匹配。只有成功返回行的事务,才继续写订单明细、预留记录和 Outbox。复杂多行转账可使用稳定锁顺序或 Serializable,并只对 40001、40P01 这类明确可重试错误重试完整事务。
4.6 Go/pgx 落地边界
| PostgreSQL | Go/pgx 建议 | 注意 |
|---|---|---|
numeric | pgtype.Numeric、十进制库或边界字符串 | 不经 float64 处理金额 |
timestamptz | time.Time | 连接统一 UTC,展示时再转用户时区 |
timestamp / date | 明确业务语义后再映射 | 不擅自当 UTC,不让时区转换改日期 |
uuid | pgtype.UUID 或已注册 UUID 类型 | 字符串只用于 API 边界 |
| 可空字段 | pgtype.*、sql.Null* 或指针 | 零值不是 NULL |
| 约束错误 | SQLSTATE + 约束名 | 23505、23503、23514、23P01 等要分类处理 |
事务里不要调用慢外部服务。数据库事务先提交业务状态和 Outbox;支付、HTTP、对象存储、消息发送等外部动作由后续工作流处理。COMMIT 附近连接断开时,客户端不知道事务是否成功,只能用稳定幂等键查询确认,不能换一个新 request_id 再扣一次。
5. 最小生产示例
下面的 Schema 只保留本章关键点:租户键进入约束,金额保持精确,SKU 规范化唯一,库存扣减可原子执行,Outbox 与业务状态同事务。
CREATE SCHEMA IF NOT EXISTS app;
CREATE EXTENSION IF NOT EXISTS btree_gist;
CREATE DOMAIN app.nonnegative_amount AS numeric(18, 2)
CHECK (VALUE >= 0);
CREATE TABLE app.tenant (
tenant_id uuid PRIMARY KEY DEFAULT uuidv7(),
tenant_name text NOT NULL,
created_at timestamptz NOT NULL DEFAULT transaction_timestamp()
);
CREATE TABLE app.product (
tenant_id uuid NOT NULL,
product_id bigint GENERATED ALWAYS AS IDENTITY,
public_id uuid NOT NULL DEFAULT uuidv7(),
sku text COLLATE "C" NOT NULL,
normalized_sku text COLLATE "C"
GENERATED ALWAYS AS (lower(sku)) STORED,
title text NOT NULL,
price app.nonnegative_amount NOT NULL,
stock integer NOT NULL DEFAULT 0 CHECK (stock >= 0),
version bigint NOT NULL DEFAULT 0,
attributes jsonb NOT NULL DEFAULT '{}'::jsonb
CHECK (jsonb_typeof(attributes) = 'object'),
created_at timestamptz NOT NULL DEFAULT transaction_timestamp(),
updated_at timestamptz NOT NULL DEFAULT transaction_timestamp(),
CONSTRAINT pk_product PRIMARY KEY (tenant_id, product_id),
CONSTRAINT uq_product_public UNIQUE (tenant_id, public_id),
CONSTRAINT uq_product_sku UNIQUE (tenant_id, normalized_sku),
CONSTRAINT ck_product_sku CHECK (sku ~ '^[A-Za-z0-9._-]{1,64}$'),
CONSTRAINT fk_product_tenant
FOREIGN KEY (tenant_id) REFERENCES app.tenant (tenant_id)
);
CREATE TABLE app.stock_reservation (
tenant_id uuid NOT NULL,
request_id uuid NOT NULL,
product_id bigint NOT NULL,
quantity integer NOT NULL CHECK (quantity > 0),
created_at timestamptz NOT NULL DEFAULT transaction_timestamp(),
CONSTRAINT pk_stock_reservation PRIMARY KEY (tenant_id, request_id),
CONSTRAINT fk_stock_reservation_product
FOREIGN KEY (tenant_id, product_id)
REFERENCES app.product (tenant_id, product_id)
);
CREATE INDEX ix_stock_reservation_product
ON app.stock_reservation (tenant_id, product_id);
CREATE TABLE app.room_booking (
tenant_id uuid NOT NULL,
room_id bigint NOT NULL,
booking_id uuid NOT NULL DEFAULT uuidv7(),
during tstzrange NOT NULL,
PRIMARY KEY (tenant_id, booking_id),
CHECK (lower(during) IS NOT NULL AND upper(during) IS NOT NULL),
CONSTRAINT ex_room_booking_overlap
EXCLUDE USING gist (
tenant_id WITH =,
room_id WITH =,
during WITH &&
) DEFERRABLE INITIALLY IMMEDIATE
);
CREATE TABLE app.outbox (
tenant_id uuid NOT NULL,
event_id uuid NOT NULL DEFAULT uuidv7(),
aggregate_type text NOT NULL,
aggregate_id uuid NOT NULL,
event_type text NOT NULL,
payload jsonb NOT NULL CHECK (jsonb_typeof(payload) = 'object'),
occurred_at timestamptz NOT NULL DEFAULT transaction_timestamp(),
available_at timestamptz NOT NULL DEFAULT transaction_timestamp(),
published_at timestamptz,
attempts integer NOT NULL DEFAULT 0 CHECK (attempts >= 0),
last_error text,
PRIMARY KEY (tenant_id, event_id)
);
CREATE INDEX ix_outbox_pending
ON app.outbox (available_at, tenant_id, event_id)
WHERE published_at IS NULL;
一次库存预留的事务骨架:
BEGIN;
INSERT INTO app.stock_reservation (tenant_id, request_id, product_id, quantity)
VALUES ($1, $2, $3, $4)
ON CONFLICT (tenant_id, request_id) DO NOTHING;
UPDATE app.product
SET stock = stock - $4,
version = version + 1,
updated_at = statement_timestamp()
WHERE tenant_id = $1
AND product_id = $3
AND stock >= $4
RETURNING stock, version;
-- 只有上一步返回一行时,才继续写业务明细和 Outbox;零行应 ROLLBACK。
INSERT INTO app.outbox (
tenant_id, aggregate_type, aggregate_id, event_type, payload
)
VALUES (
$1, 'product', $5, 'stock.reserved',
jsonb_build_object('request_id', $2, 'product_id', $3, 'quantity', $4)
);
COMMIT;
生产代码还需要处理三件事:ON CONFLICT DO NOTHING 后确认幂等请求是否同参;UPDATE ... RETURNING 零行时返回库存不足或对象不存在;COMMIT 结果未知时用同一个 request_id 查询确认。
6. 验证与排障入口
6.1 类型、约束和生成列
SELECT n.nspname AS schema_name,
c.relname AS table_name,
a.attname AS column_name,
format_type(a.atttypid, a.atttypmod) AS data_type,
a.attnotnull,
a.attidentity,
a.attgenerated,
pg_get_expr(ad.adbin, ad.adrelid) AS default_expr
FROM pg_attribute AS a
JOIN pg_class AS c ON c.oid = a.attrelid
JOIN pg_namespace AS n ON n.oid = c.relnamespace
LEFT JOIN pg_attrdef AS ad
ON ad.adrelid = a.attrelid AND ad.adnum = a.attnum
WHERE a.attnum > 0
AND NOT a.attisdropped
AND n.nspname = 'app'
ORDER BY c.relname, a.attnum;
SELECT conrelid::regclass AS table_name,
conname,
contype,
condeferrable,
convalidated,
pg_get_constraintdef(oid, true) AS definition
FROM pg_constraint
WHERE connamespace = 'app'::regnamespace
ORDER BY conrelid::regclass::text, conname;
6.2 宽表和 TOAST
SELECT pg_size_pretty(pg_relation_size('app.product')) AS heap,
pg_size_pretty(pg_indexes_size('app.product')) AS indexes,
pg_size_pretty(pg_total_relation_size('app.product')) AS total;
SELECT c.reltoastrelid::regclass AS toast_relation,
pg_size_pretty(pg_relation_size(c.reltoastrelid)) AS toast_heap,
pg_size_pretty(pg_total_relation_size(c.reltoastrelid)) AS toast_total
FROM pg_class AS c
WHERE c.oid = 'app.product'::regclass;
诊断慢查询时先比较显式窄投影与 SELECT * 的 EXPLAIN (ANALYZE, BUFFERS, WAL)。DML 的 ANALYZE 会真实修改数据,生产环境默认在副本或事务隔离环境复现。
6.3 锁等待、序列和 Collation
SELECT a.pid,
a.state,
a.wait_event_type,
a.wait_event,
now() - a.xact_start AS xact_age,
pg_blocking_pids(a.pid) AS blockers,
left(a.query, 160) AS query
FROM pg_stat_activity AS a
WHERE cardinality(pg_blocking_pids(a.pid)) > 0;
SELECT schemaname,
sequencename,
data_type,
last_value,
max_value,
cache_size,
cycle
FROM pg_sequences
ORDER BY schemaname, sequencename;
SELECT n.nspname,
c.collname,
c.collversion AS recorded_version,
pg_collation_actual_version(c.oid) AS actual_version
FROM pg_collation AS c
JOIN pg_namespace AS n ON n.oid = c.collnamespace
WHERE c.collversion IS DISTINCT FROM pg_collation_actual_version(c.oid);
Collation 告警不能靠 REFRESH VERSION 直接消除。正确顺序是识别依赖索引,检查新规则下是否出现重复值,重建索引,再刷新版本记录。
7. 常见错误与修复
| 反模式 | 后果 | 修复 |
|---|---|---|
| 金额用浮点 | 舍入和相等判断错误 | numeric 或最小单位整数 |
所有字符串都 varchar(255) | 虚假规则和无意义迁移 | 默认 text,真实上限再约束 |
绝对时间用 timestamp | 跨时区解释不一致 | 用 timestamptz;墙上规则另存时区名 |
CHECK(x > 0) 但允许 NULL | 缺失值绕过规则 | 加 NOT NULL 或显式允许 NULL |
| 只在 Go 里检查唯一/库存 | 并发竞态 | 数据库唯一约束、排斥约束或原子 DML |
多租户 FK 不含 tenant_id | 跨租户引用泄漏 | 复合 PK/UNIQUE/FK,查询也带租户条件 |
| 建 FK 不建引用侧索引 | 父表删除/更新扫描子表 | 子表建 FK 前缀索引 |
| 默认 UUID v4 做所有主键 | 索引、FK、缓存和 WAL 放大 | 按场景选 Identity、Snowflake、UUID v7 或双键 |
Snowflake worker_id 可复用 | 扩容/重启后 ID 冲突 | 配置中心分配、时钟回拨保护、DB 唯一兜底 |
热点接口长期 SELECT * | TOAST、网络、Go 分配放大 | 显式投影,垂直拆分大字段 |
| 把 Sequence 当无空洞编号 | 回滚、缓存、崩溃产生空洞 | 单独设计受审计的业务编号流程 |
updated_at DEFAULT now() 后不更新 | 审计字段失真 | UPDATE 显式维护或受控触发器 |
| Collation 升级后只刷新版本 | 索引仍按旧规则排列 | 先检测冲突并重建依赖索引 |
| 提交未知后换新请求重试 | 重复扣款或重复扣库存 | 稳定幂等键 + 查询确认 |
8. 面试题
8.1【核心概念】integer、bigint、numeric 和浮点如何选?
- 30 秒回答:计数按范围选 integer/bigint;金额等十进制精确值用 numeric 或最小单位 bigint;科学近似计算用 real/double。类型同时影响溢出、CPU、行宽和索引。
- 深入回答:numeric 精确但变长且 CPU 更高;浮点不能用显示格式消除二进制误差。主键增长还要留迁移周期余量。
- 考察:是否从业务语义而非习惯选型。
- 常见错误:“double 够大,所以金额用 double。”
- 追问:固定两位货币为何仍可能选 numeric?
- 追问答案:多币种、税率、分摊和舍入规则可能超出固定分单位;numeric 更直接,但需定义精度、币种和舍入。
8.2【核心概念】text、varchar(n)、char(n) 有什么真实差异?
- 30 秒回答:text 与无长度 varchar 通常等价;varchar(n) 是长度约束;char(n) 空格填充且常更慢,不是性能优化。
- 深入回答:还要区分字符数与字节数,并考虑 Collation。业务无上限时不要写任意 255。
- 考察:是否摆脱其他数据库经验和伪规范。
- 常见错误:“varchar 比 text 更省空间。”
- 追问:SKU 如何设计?
- 追问答案:
text+ ASCII/长度 CHECK + 明确 Collation +(tenant_id, normalized_sku)唯一;保留原始展示值。
8.3【核心概念】timestamp 与 timestamptz 的边界是什么?
- 30 秒回答:timestamptz 表示绝对瞬间,内部统一后按会话时区显示;timestamp 表示没有时区的墙上时间。
- 深入回答:数据库不保存输入的 IANA 时区名。周期规则需 timestamp/time 加时区名;date 不应转换为午夜瞬间。
- 考察:时区、DST 与业务语义。
- 常见错误:“timestamptz 会在每行保存 Europe/Paris。”
- 追问:跨 DST 的“每天 09:00”如何存?
- 追问答案:保存当地时间和 IANA zone,执行时按当日规则解析为瞬间;不能保存固定 UTC 偏移代替。
8.4【核心概念】NULL 对 CHECK 和 UNIQUE 有何影响?
- 30 秒回答:NULL 比较产生 UNKNOWN;WHERE 不保留 UNKNOWN;CHECK 的 UNKNOWN 通过;普通 UNIQUE 允许多个 NULL,PG15+ 可用 NULLS NOT DISTINCT。
- 深入回答:必填列应同时 NOT NULL;可空比较用 IS DISTINCT FROM;是否允许一个或多个空值必须是显式业务决策。
- 考察:三值逻辑。
- 常见错误:“CHECK(x>0) 会禁止 NULL。”
- 追问:如何保证每个用户最多一个 active 记录?
- 追问答案:可用部分唯一索引
WHERE ended_at IS NULL,或适当的UNIQUE NULLS NOT DISTINCT组合;前者语义常更直接。
8.5【核心概念】PK、UNIQUE、FK、CHECK、EXCLUDE 分别解决什么?
- 30 秒回答:PK 标识行;UNIQUE 保候选键;FK 保引用存在;CHECK 保当前行谓词;EXCLUDE 保行间运算符不冲突。
- 深入回答:FK 引用侧需索引;CHECK 不能跨行;EXCLUDE 常用 GiST;只有 UNIQUE/PK/FK/EXCLUDE 可 Deferrable。
- 考察:能否把规则放到正确机制。
- 常见错误:“所有规则都能写 CHECK。”
- 追问:为什么时间重叠不适合 CHECK?
- 追问答案:它是跨行谓词,需并发仲裁;排斥约束能等待并发事务并在提交态保证不重叠。
8.6【原理/排障】Sequence 为什么有空洞?
- 30 秒回答:nextval 非事务性;回滚、冲突、缓存、崩溃都可能消耗值。它保证并发分配,不保证无空洞或提交顺序。
- 深入回答:CACHE 提吞吐但可扩大故障空洞;setval 也需谨慎。主键连续性没有业务价值时不应修补。
- 考察:事务边界和序列内部语义。
- 常见错误:“事务回滚后 ID 会回去。”
- 追问:无空洞发票号怎么办?
- 追问答案:独立受监管流程,在最终确认阶段串行分配,并记录作废号与审计;不能复用普通实体 PK。
8.7【原理/排障】bigint Identity、Snowflake、UUID v4、UUID v7 如何权衡?
- 30 秒回答:bigint Identity 最简单且紧凑,但依赖数据库分配;Snowflake 也是 8 字节并可应用层分布式生成,适合任务链路贯穿;UUID v4 分布式生成但随机、较宽;UUID v7 保留 UUID 分布式特性并改善时间局部性,但仍宽且泄露粗时间。
- 深入回答:还要分析 FK 放大、右侧热点、缓存、WAL、外部枚举风险、生成位置、worker_id 分配和时钟回拨。结论需基准验证。
- 考察:是否能拒绝单维度答案。
- 常见错误:“Snowflake 全局唯一,所以不用数据库唯一约束兜底。”或“UUIDv7 在任何负载都最快。”
- 追问:何时使用双键?
- 追问答案:内部高吞吐连接可用 Identity 或 Snowflake BIGINT,外部 API 用 UUIDv7;代价是额外唯一索引、映射和写放大。若 Snowflake 直接外露,还要评估时间泄露和可枚举风险。
8.8【原理/排障】有 FK 为什么删除父行仍很慢?
- 30 秒回答:PostgreSQL 不会自动为子表 FK 列建索引;父行删除/更新需检查子表,缺索引会扫描大量数据并延长锁。
- 深入回答:复合 FK 的索引列顺序需匹配;分区子表逐个检查;级联还可能形成大事务和 WAL。
- 考察:约束与访问路径的区别。
- 常见错误:“创建 FK 自动创建两边索引。”
- 追问:索引是否总与 FK 完全相同?
- 追问答案:至少应以 FK 列为可用前缀;可结合常用过滤追加列,但需验证 Planner 和写成本。
8.9【原理/排障】宽表 SELECT * 为什么会拖慢服务?
- 30 秒回答:读取无用列会扩大 Heap/TOAST 访问、解压、编码、网络和 Go 分配;宽行还降低页密度。
- 深入回答:窄覆盖索引可能 Index Only Scan,但依赖 Visibility Map。把大字段 INCLUDE 进索引通常适得其反。
- 考察:端到端成本,不只看 SQL 执行时间。
- 常见错误:“TOAST 后主表只有指针,所以 SELECT * 没影响。”
- 追问:何时垂直拆表?
- 追问答案:大/冷/权限不同/生命周期不同的列极少与热点字段同读写时;需衡量额外 Join 和一致性。
8.10【原理/排障】为什么 Go 先查库存再更新会超卖?
- 30 秒回答:检查和写入不是原子操作;并发事务可读到相同旧值。事务本身不自动串行化这段业务逻辑。
- 深入回答:用条件 UPDATE、行锁、唯一/排斥约束或 Serializable。成功条件与业务明细、Outbox 应同事务。
- 考察:竞态、MVCC 和正确事务边界。
- 常见错误:“用了 BEGIN/COMMIT 就安全。”
- 追问:条件 UPDATE 返回零行如何解释?
- 追问答案:可能库存不足或对象不存在;按业务需求再进行租户限定点查,但不要把第二次查询变成新的竞态写入。
8.11【原理/排障】Collation 升级告警应如何处理?
- 30 秒回答:先识别 Provider 版本差异与依赖对象,检测新规则下冲突,重建索引,再 REFRESH VERSION;不能只刷新。
- 深入回答:排序规则变化可能破坏 B-tree 顺序和唯一语义;主备和迁移目标应一致。重建需预算 I/O、WAL 和锁。
- 考察:数据结构依赖外部规则的运维意识。
- 常见错误:“警告只是元数据,可忽略。”
- 追问:为何 REINDEX 后还要验证重复?
- 追问答案:新规则可能把旧时不同的两个字符串视为相等,唯一索引重建会失败,需先制定数据合并策略。
8.12【架构设计】共享表多租户主键如何设计?
- 30 秒回答:租户内实体用
(tenant_id, id)PK/UNIQUE/FK,所有查询带 tenant_id;全局 ID 也不能替代租户授权边界。 - 深入回答:复合键阻止跨租户引用,但增宽索引;索引顺序按访问模式;再配合 RLS/服务授权形成纵深防御。
- 考察:隔离、性能和可运维性。
- 常见错误:“UUID 全球唯一,所以不需要 tenant_id。”
- 追问:tenant_id 放索引第一列总正确吗?
- 追问答案:不是。按全局 ID 点查、跨租户后台任务和高倾斜租户可能需要其他索引;必须基于查询和分布决定。
8.13【架构设计】如何设计余额或库存?
- 30 秒回答:权威明细采用追加账本/预留记录,缓存余额用原子条件更新并 CHECK;幂等键和 Outbox 同事务。
- 深入回答:多账户转移按稳定顺序锁行或 Serializable;不在事务中调用外部服务;定期从明细重算校验。
- 考察:不变量、审计、并发与恢复。
- 常见错误:“只存 balance 一列,失败时看日志。”
- 追问:热点单品如何扩展?
- 追问答案:预分片配额、按仓/批次扣减或队列化准入;代价是汇总和公平性复杂,最终不变量仍需数据库仲裁。
8.14【架构设计】汇总表与 Outbox 如何保证一致性?
- 30 秒回答:业务状态和 Outbox 同事务;消费者幂等更新汇总,记录水位与可重建来源。汇总允许的陈旧度要有 SLO。
- 深入回答:同步冗余提高写延迟,异步汇总引入延迟与重放;事件需稳定 ID、版本和顺序策略,不能依赖“恰好一次”。
- 考察:一致性边界与故障恢复。
- 常见错误:“提交后直接发消息就一定不会丢。”
- 追问:消费者重复处理怎么办?
- 追问答案:以 event_id 建唯一处理记录或幂等 upsert;业务更新与消费水位同事务。
8.15【架构设计】如何把接近上限的 integer 主键迁移为 bigint?
- 30 秒回答:先评估所有 FK、索引、序列和应用类型;采用兼容扩展、双写/回填/验证/切换,而不是高峰直接 ALTER 全表。
- 深入回答:列类型变化可能重写或长锁,需结合版本和具体转换验证;先改应用为 int64,处理逻辑复制与 Sequence,分阶段验证主从和回滚。
- 考察:跨层 Schema 迁移能力。
- 常见错误:“ALTER COLUMN TYPE bigint 是瞬时元数据操作,任何表都一样。”
- 追问:为什么先改 Go?
- 追问答案:旧 Go
int32扫描会在数据库扩容后溢出;读写兼容必须先于数据库开始产生大值。
9. 检查清单
- 我能够解释错误数据模型为何不能仅靠增加索引修复。
- 我能够根据精度、范围、CPU 和空间选择数值类型。
- 我能够区分
text、varchar(n)、char(n)和 Collation 的职责。 - 我能够正确建模瞬间、日期、墙上时间、时区名和 interval。
- 我能够说明 NULL 的三值逻辑、CHECK 行为和 NULLS NOT DISTINCT。
- 我能够比较 bigint Identity、Snowflake BIGINT、UUID v4、UUID v7 的索引、生成位置和并发代价。
- 我能够设计包含 tenant_id 的 PK、UNIQUE 和 FK。
- 我能够区分生成列、DEFAULT 与 Identity,并判断哪些派生字段适合用生成列。
- 我能够用 UNIQUE、EXCLUDE、原子 UPDATE 复现并消除竞态。
- 我能够通过
pg_column_size、TOAST 大小和 EXPLAIN 诊断宽表。 - 我能够解释 Sequence Cache、空洞、回滚和逻辑切换风险。
- 我能够在 Go/pgx 中正确处理 time.Time、numeric、UUID、NULL 和 SQLSTATE。
- 我能够实现带 tenant_id、幂等键、Outbox、超时和完整事务重试的写入。
- 我能够分析 Schema 对 CPU、I/O、WAL、Vacuum、P99 和复制延迟的影响。
- 我能够执行 Collation 版本故障的安全排查,而不是只刷新版本。
- 我能够在恢复验证中检查业务不变量、Sequence 与约束状态。
10. 官方资料来源
以下资料均为 PostgreSQL 或 pgx 官方资料,章节以 PostgreSQL 18 文档为主,并核对 14–17 的对应页面:
- PostgreSQL 18 Documentation:Numeric Types、Character Types、Date/Time Types、Boolean Type、Enumerated Types、Domain Types、Arrays、JSON Types、UUID Type。
- PostgreSQL 18 Documentation:Constraints、Generated Columns、CREATE TABLE、CREATE SEQUENCE、Sequence Manipulation Functions、Default Values。
- PostgreSQL 18 Documentation:TOAST、Collation Support、ALTER COLLATION、Cumulative Statistics System、
pg_stat_io、pg_stat_wal。 - PostgreSQL 18 Documentation:UUID Functions;
uuidv4()、uuidv7()、UUID 提取函数。 - PostgreSQL 18 Release Notes:
uuidv7()、虚拟生成列、生成列逻辑复制、NOT ENFORCED约束等版本变化。 - PostgreSQL 15 Release Notes:
UNIQUE NULLS NOT DISTINCT。 - PostgreSQL 17 Documentation:Generated Columns,用于确认 17 及以前仅支持
STORED。 - PostgreSQL 18 Documentation:Logical Replication Restrictions、CREATE PUBLICATION,Sequence 与生成列复制边界。
- PostgreSQL 18 Documentation:Appendix A PostgreSQL Error Codes。
- PostgreSQL 18
pgstattupleExtension Documentation。 - pgx/v5 官方
pgxpool、pgconn与pgtypeAPI 文档。