返回文章列表

PostgreSQL 生产级数据模型、数据类型、约束与主键设计

从数据类型、NULL 语义、主键、租户键、约束、宽表、序列与 Go/pgx 写入路径,建立生产级 PostgreSQL 数据模型设计方法。

第 2 章:生产级数据模型、数据类型、约束与主键设计

技术基线:PostgreSQL 18;兼顾 PostgreSQL 14、15、16、17 的差异。Go 示例使用 github.com/jackc/pgx/v5pgxpool。资料核对日期:2026-06-20。

1. 本章定位

数据模型决定了数据能表达什么、数据库必须维护哪些索引、一次写入会修改多少页面、并发事务在哪些对象上竞争,以及故障恢复后能否继续维持业务不变量。错误的数据模型无法仅靠增加索引解决:索引不能把近似浮点数变成精确金额,不能替代缺失的租户隔离,不能让“先查库存再扣减”自动变成原子操作,也不能消除过宽主键对所有二级索引和外键的放大。

本章位于架构总览之后、物理存储与索引原理之前。它为后续 Page、Tuple、B-tree、MVCC、锁、WAL、复制和在线迁移建立业务入口:先决定“存什么、如何标识、由谁保证正确”,再讨论“数据库如何存、如何查、如何并发执行”。

本章不深入 Heap Page 格式、B-tree 分裂算法、隔离级别完整冲突矩阵、分区、在线 DDL 和分片;这些内容分别在后续章节展开。

2. 可验证的学习目标

完成本章后,你应能:

  1. 根据精度、范围、CPU 和空间要求,在 integerbigintnumericrealdouble precision 之间做出可解释选择。
  2. 说明 textvarchar(n)char(n) 的真实差异,并拒绝“所有字符串都用 varchar(255)”的惯性设计。
  3. 正确建模时间点、业务日期、当地墙上时间和持续时间,解释 timestamptimestamptz 的边界。
  4. 判断字段应使用关系列、jsonb、数组、枚举、Domain 还是独立关联表。
  5. 解释 SQL 三值逻辑、CHECKNULL 的行为,以及 [PG15+] NULLS NOT DISTINCT 的用途。
  6. 比较自然键、bigint Identity、Snowflake BIGINT、UUID v4 与 [PG18] uuidv7() 对索引、WAL、并发和外部接口的影响。
  7. 设计带 tenant_id 的主键、唯一约束和外键,阻止跨租户引用。
  8. 使用 PRIMARY KEYUNIQUEFOREIGN KEYCHECKEXCLUDEDEFERRABLE 表达业务不变量。
  9. 通过 pg_column_size、关系大小、TOAST 关系和执行计划识别宽表与 SELECT * 的代价。
  10. 复现应用层“先检查再写入”的竞态,并用条件更新、唯一约束或排斥约束消除竞态。
  11. 在 Go/pgx 中正确处理时间、精确数值、UUID、NULL、SQLSTATE、事务、幂等键和租户条件。
  12. 形成数据模型故障的生产 Runbook,并能分析其性能、并发和高可用影响。

3. 选型速查

设计问题默认选择需要警惕的误区生产判断
金额、税率、精确计量numeric(p,s) 或最小单位 bigintdouble precision 再格式化成两位小数先定义币种、精度、舍入规则,再选类型
长周期计数、内部主键bigint / bigint GENERATED ... AS IDENTITYinteger 赌增长不会超过 21 亿主键类型迁移会牵动 FK、索引、Go 类型和消息协议
字符串text机械使用 varchar(255)char(n)varchar(n) 只表达真实业务上限,char(n) 极少需要
绝对时间点timestamptztimestamp 当 UTCtimestamptz 保存瞬间,不保存原始 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 = ?约束层要阻止跨租户引用
外部 IDUUID 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 *

宽表的成本不只在磁盘。每页行数下降会降低缓存命中;大 textbyteajsonb 可能进入 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,并只对 4000140P01 这类明确可重试错误重试完整事务。

4.6 Go/pgx 落地边界

PostgreSQLGo/pgx 建议注意
numericpgtype.Numeric、十进制库或边界字符串不经 float64 处理金额
timestamptztime.Time连接统一 UTC,展示时再转用户时区
timestamp / date明确业务语义后再映射不擅自当 UTC,不让时区转换改日期
uuidpgtype.UUID 或已注册 UUID 类型字符串只用于 API 边界
可空字段pgtype.*sql.Null* 或指针零值不是 NULL
约束错误SQLSTATE + 约束名23505235032351423P01 等要分类处理

事务里不要调用慢外部服务。数据库事务先提交业务状态和 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 和空间选择数值类型。
  • 我能够区分 textvarchar(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 的对应页面:

  1. PostgreSQL 18 Documentation:Numeric Types、Character Types、Date/Time Types、Boolean Type、Enumerated Types、Domain Types、Arrays、JSON Types、UUID Type。
  2. PostgreSQL 18 Documentation:Constraints、Generated Columns、CREATE TABLE、CREATE SEQUENCE、Sequence Manipulation Functions、Default Values。
  3. PostgreSQL 18 Documentation:TOAST、Collation Support、ALTER COLLATION、Cumulative Statistics System、pg_stat_iopg_stat_wal
  4. PostgreSQL 18 Documentation:UUID Functions;uuidv4()uuidv7()、UUID 提取函数。
  5. PostgreSQL 18 Release Notes:uuidv7()、虚拟生成列、生成列逻辑复制、NOT ENFORCED 约束等版本变化。
  6. PostgreSQL 15 Release Notes:UNIQUE NULLS NOT DISTINCT
  7. PostgreSQL 17 Documentation:Generated Columns,用于确认 17 及以前仅支持 STORED
  8. PostgreSQL 18 Documentation:Logical Replication Restrictions、CREATE PUBLICATION,Sequence 与生成列复制边界。
  9. PostgreSQL 18 Documentation:Appendix A PostgreSQL Error Codes。
  10. PostgreSQL 18 pgstattuple Extension Documentation。
  11. pgx/v5 官方 pgxpoolpgconnpgtype API 文档。