PostgreSQL 表设计
好的表设计要同时解决三个问题:
- 数据是否正确:依靠类型、约束、主外键保证。
- 查询是否高效:围绕实际查询建立索引。
- 后期是否容易维护:命名清晰、关系合理、避免重复数据。
PostgreSQL 的表设计核心包括:字段、默认值、主键、外键、约束、索引、Schema 和分区。PostgreSQL 18 数据定义文档
建议按照以下顺序设计:
业务需求
↓
核心实体
↓
实体之间的关系
↓
字段与数据类型
↓
主键、外键和约束
↓
高频查询方式
↓
索引
↓
是否需要分区
不要一开始就考虑字段越多越好,也不要先建索引再考虑查询。
假设需要存储:
- A股基本信息
- 公司介绍
- 每日行情
- 题材概念
- 股票与题材关系
可以拆分为:
| 表 | 作用 | 关系 |
|---|---|---|
security |
股票基本信息 | 核心主表 |
security_profile |
公司详细资料 | 与股票一对一 |
daily_quote |
日线行情 | 与股票一对多 |
concept |
题材概念 | 独立字典表 |
security_concept |
股票与题材关系 | 多对多中间表 |
不要把所有信息都塞进一张 ashare 表,否则容易出现:
- 公司介绍被重复保存
- 行情数据与基本信息混杂
- 每次更新名称都要修改大量记录
- 题材只能用逗号分隔
- 查询和维护越来越困难
可以先用 Schema 对业务对象分类:
CREATE SCHEMA IF NOT EXISTS market;
之后使用完整名称:
market.security
market.daily_quote
market.concept
较大的项目可以继续拆分:
market 股票和行情
finance 财务数据
news 新闻资讯
system 系统配置
audit 审计日志
Schema 类似数据库内部的命名空间,不需要为了每个小功能都创建一个 Schema。
建议统一使用:
- 小写字母
- 单词之间使用下划线
- 不使用中文字段名
- 不使用 PostgreSQL 关键字
- 不使用带双引号的大小写名称
推荐:
trade_date
close_price
created_at
security_concept
不推荐:
"tradeDate"
"ClosePrice"
"交易日期"
"user"
"order"
约束和索引也应命名:
pk_security
uq_security_exchange_code
fk_daily_quote_security
ck_daily_quote_price
idx_daily_quote_trade_date
每张业务表原则上都应有主键。主键具有:
- 唯一性
- 非空性
- 自动建立唯一 B-tree 索引
security_id bigint GENERATED ALWAYS AS IDENTITY PRIMARY KEY
适合:
- 单体应用
- 内部系统
- 高频关联查询
- SQLAlchemy 模型
一般推荐使用 bigint identity,避免以后受 integer 范围限制。
PostgreSQL 18 可以使用时间有序的 UUIDv7:
id uuid PRIMARY KEY DEFAULT uuidv7()
适合:
- 分布式系统
- 对外暴露的资源ID
- 多个数据库之间合并数据
- 不希望暴露业务数据数量
普通业务系统并不需要为了“看起来高级”而全部使用 UUID。
例如直接使用股票代码:
code varchar(6) PRIMARY KEY
问题是股票代码本质上属于业务数据,可能涉及:
- 不同交易所
- 证券代码复用
- 股票、基金、债券代码体系
- 代码变更
- 数据源格式差异
更稳妥的设计是:
security_id bigint GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
exchange_code varchar(8) NOT NULL,
code varchar(6) NOT NULL,
UNIQUE (exchange_code, code)
即:
security_id:数据库内部标识(exchange_code, code):业务唯一标识
CREATE TABLE market.security (
security_id bigint
GENERATED ALWAYS AS IDENTITY,
exchange_code varchar(8) NOT NULL,
code varchar(6) NOT NULL,
name text NOT NULL,
board text,
industry text,
is_st boolean NOT NULL DEFAULT false,
is_active boolean NOT NULL DEFAULT true,
listed_on date,
delisted_on date,
extra jsonb NOT NULL DEFAULT '{}'::jsonb,
created_at timestamptz NOT NULL DEFAULT now(),
updated_at timestamptz NOT NULL DEFAULT now(),
CONSTRAINT pk_security
PRIMARY KEY (security_id),
CONSTRAINT uq_security_exchange_code
UNIQUE (exchange_code, code),
CONSTRAINT ck_security_code
CHECK (code ~ '^[0-9]{6}$'),
CONSTRAINT ck_security_name
CHECK (btrim(name) <> ''),
CONSTRAINT ck_security_listing_period
CHECK (
delisted_on IS NULL
OR listed_on IS NULL
OR delisted_on >= listed_on
),
CONSTRAINT ck_security_extra_object
CHECK (jsonb_typeof(extra) = 'object')
);
关键设计:
- 股票代码使用
varchar(6),不能使用整数,否则会丢失前导零。 - 名称使用
text,不建议使用定长char(n)。 - 退市不等于删除,使用
is_active或delisted_on标识。 - 经常查询的字段单独建列,不常用的扩展属性放入
jsonb。 - 交易所和代码组成业务唯一约束。
NULL 表示:
- 未知
- 尚未获取
- 不适用
- 缺失
它不等于:
0
空字符串
false
空数组
例如公司行业未知,应保存:
industry = NULL
不要保存:
industry = ''
industry = '未知'
除非“未知”本身就是明确的业务分类。
核心字段应声明:
code varchar(6) NOT NULL
name text NOT NULL
trade_date date NOT NULL
官方文档也建议,大多数表中的大部分字段通常应标记为 NOT NULL。
is_active boolean NOT NULL DEFAULT true
created_at timestamptz NOT NULL DEFAULT now()
extra jsonb NOT NULL DEFAULT '{}'::jsonb
不要滥用默认值。
例如:
close_price numeric(18,4) DEFAULT 0
通常不合理,因为价格为零与尚未获得价格不是一回事。
下面的定义只在插入时生效:
updated_at timestamptz NOT NULL DEFAULT now()
执行 UPDATE 时不会自动变化。可以由应用程序更新:
UPDATE market.security
SET name = '新名称',
updated_at = now()
WHERE security_id = 1;
也可以使用触发器:
CREATE OR REPLACE FUNCTION market.set_updated_at()
RETURNS trigger
LANGUAGE plpgsql
AS $$
BEGIN
NEW.updated_at := clock_timestamp();
RETURN NEW;
END;
$$;
CREATE TRIGGER trg_security_updated_at
BEFORE UPDATE ON market.security
FOR EACH ROW
EXECUTE FUNCTION market.set_updated_at();
数据库约束比应用层验证更可靠,因为无论数据来自:
- Python
- SQLAlchemy
- Excel 导入
- SQL 脚本
- 其他服务
都必须遵守数据库约束。
price numeric(18,4)
CONSTRAINT ck_price_positive
CHECK (price >= 0)
多字段检查:
CONSTRAINT ck_price_range
CHECK (high_price >= low_price)
需要注意:
CHECK (price > 0)
不能阻止 NULL,因为表达式遇到 NULL 时可能得到未知结果。因此如果不能为空,应同时声明:
price numeric(18,4) NOT NULL CHECK (price > 0)
CHECK 应只检查当前行,不能用它查询其他行或其他表。跨表一致性应该使用外键、唯一约束或触发器。
单列唯一:
email text UNIQUE
多列组合唯一:
UNIQUE (security_id, trade_date)
组合唯一的含义是组合不能重复,而不是每一列单独唯一。
唯一约束会自动创建唯一 B-tree 索引,不需要再创建相同索引。
默认情况下,多个 NULL 不被认为相等,因此唯一列可以出现多个 NULL。
如果希望最多只能有一个 NULL:
external_code text UNIQUE NULLS NOT DISTINCT
CREATE TABLE market.daily_quote (
security_id bigint NOT NULL,
trade_date date NOT NULL,
open_price numeric(18,4) NOT NULL,
high_price numeric(18,4) NOT NULL,
low_price numeric(18,4) NOT NULL,
close_price numeric(18,4) NOT NULL,
pre_close numeric(18,4),
volume bigint NOT NULL,
amount numeric(24,2) NOT NULL,
-- 统一保存为小数,例如 0.105 表示 10.5%
change_rate numeric(12,8),
turnover numeric(12,8),
created_at timestamptz NOT NULL DEFAULT now(),
CONSTRAINT pk_daily_quote
PRIMARY KEY (security_id, trade_date),
CONSTRAINT fk_daily_quote_security
FOREIGN KEY (security_id)
REFERENCES market.security (security_id)
ON DELETE RESTRICT,
CONSTRAINT ck_daily_quote_price_nonnegative
CHECK (
open_price >= 0
AND high_price >= 0
AND low_price >= 0
AND close_price >= 0
AND (pre_close IS NULL OR pre_close >= 0)
),
CONSTRAINT ck_daily_quote_high
CHECK (
high_price >= open_price
AND high_price >= low_price
AND high_price >= close_price
),
CONSTRAINT ck_daily_quote_low
CHECK (
low_price <= open_price
AND low_price <= high_price
AND low_price <= close_price
),
CONSTRAINT ck_daily_quote_volume
CHECK (volume >= 0),
CONSTRAINT ck_daily_quote_amount
CHECK (amount >= 0)
);
这里不需要额外的自增 id:
id bigint GENERATED ALWAYS AS IDENTITY
因为一只股票在一个交易日只有一条日线记录:
PRIMARY KEY (security_id, trade_date)
已经能准确标识每一行。
外键用于防止“孤儿数据”。
FOREIGN KEY (security_id)
REFERENCES market.security (security_id)
它保证行情表中的 security_id 必须存在于股票主表。
| 策略 | 含义 | 适用场景 |
|---|---|---|
RESTRICT |
有子记录时禁止删除 | 股票与历史行情 |
NO ACTION |
默认策略,在约束检查时验证 | 一般业务 |
CASCADE |
父记录删除时删除子记录 | 完全依附的明细 |
SET NULL |
父记录删除时外键设为NULL | 可选关系 |
SET DEFAULT |
改为默认值 | 较少使用 |
股票退市时不应该删除所有历史行情,因此:
ON DELETE RESTRICT
比 ON DELETE CASCADE 更合适。
外键不会自动为引用方字段创建普通索引,因此经常联表或删除父记录时,应为子表外键列建立合适索引。不过本例的主键:
PRIMARY KEY (security_id, trade_date)
已经能支持以 security_id 开头的查询,不需要再重复建立:
CREATE INDEX ON market.daily_quote (security_id);
公司详细资料可以单独保存:
CREATE TABLE market.security_profile (
security_id bigint PRIMARY KEY,
company_feature text,
primary_business text,
product_type text,
topic_points text,
raw_data jsonb,
updated_at timestamptz NOT NULL DEFAULT now(),
CONSTRAINT fk_security_profile_security
FOREIGN KEY (security_id)
REFERENCES market.security (security_id)
ON DELETE CASCADE
);
security_id 同时是:
- 主键
- 外键
这就限制了每只股票最多只有一条公司资料记录。
这种拆分适合大段文本,因为股票列表查询通常不需要同时读取主营业务和题材要点。
一只股票可以属于多个题材,一个题材也可以包含多只股票,因此是多对多关系。
CREATE TABLE market.concept (
concept_id bigint
GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
name text NOT NULL,
category text,
description text,
CONSTRAINT uq_concept_name
UNIQUE (name),
CONSTRAINT ck_concept_name
CHECK (btrim(name) <> '')
);
CREATE TABLE market.security_concept (
security_id bigint NOT NULL,
concept_id bigint NOT NULL,
source text,
added_on date,
updated_at timestamptz NOT NULL DEFAULT now(),
CONSTRAINT pk_security_concept
PRIMARY KEY (security_id, concept_id),
CONSTRAINT fk_security_concept_security
FOREIGN KEY (security_id)
REFERENCES market.security (security_id)
ON DELETE CASCADE,
CONSTRAINT fk_security_concept_concept
FOREIGN KEY (concept_id)
REFERENCES market.concept (concept_id)
ON DELETE CASCADE
);
查询某只股票的题材时,主键索引已经有效:
WHERE security_id = ?
查询某个题材包含哪些股票时,应增加反向索引:
CREATE INDEX idx_security_concept_concept_security
ON market.security_concept (concept_id, security_id);
不要用下面这种方式保存题材:
concepts text
-- '机器人,人工智能,算力,国企'
逗号分隔字符串无法可靠执行:
- 外键约束
- 精确匹配
- 去重
- 题材改名
- 高效索引
- 题材属性扩展
索引应来自查询,而不是来自字段。
先确定高频查询:
-- 查询一只股票的历史行情
WHERE security_id = ?
ORDER BY trade_date DESC;
-- 查询某个交易日全部股票
WHERE trade_date = ?;
-- 查询日期范围
WHERE trade_date BETWEEN ? AND ?;
已有主键:
PRIMARY KEY (security_id, trade_date)
它适合第一类查询。
为了支持按交易日查询全部股票,可以增加:
CREATE INDEX idx_daily_quote_trade_date_security
ON market.daily_quote (trade_date, security_id);
如果经常只读取收盘价和涨跌幅,可以考虑覆盖索引:
CREATE INDEX idx_daily_quote_trade_date_cover
ON market.daily_quote (trade_date, security_id)
INCLUDE (close_price, change_rate);
是否使用 INCLUDE 需要根据实际查询计划决定,因为它会增大索引。
索引设计原则:
- 等值条件通常放在联合索引前面
- 范围和排序字段通常放在后面
- 主键和唯一约束已自动创建索引
- 避免建立重复索引
- 外键引用方根据查询补充索引
jsonb、数组全文查询通常使用 GIN- 上线后用
EXPLAIN (ANALYZE, BUFFERS)验证
一份事实原则上只保存一次。
例如行业名称不应该在每条行情数据中重复:
daily_quote
code
name
industry
trade_date
close_price
更合理的是:
security
security_id
code
name
industry
daily_quote
security_id
trade_date
close_price
优点:
- 减少重复
- 避免更新不一致
- 降低存储开销
- 业务关系更清晰
为了性能,可以有意识地保存少量重复数据,例如:
- 日报统计结果
- 板块每日涨跌统计
- 预计算技术指标
- 物化视图
- 缓存表
但必须明确:
- 哪份数据是源数据?
- 重复数据如何刷新?
- 更新失败如何修复?
- 是否允许短暂不一致?
规范化解决正确性,反规范化解决特定性能问题。没有明确性能证据时,先保持规范化。
适合普通列的数据:
- 股票代码
- 名称
- 交易所
- 交易日期
- 收盘价
- 行业
- 是否ST
适合 jsonb 的数据:
- 不同数据源返回的扩展字段
- 结构不稳定的原始接口数据
- 很少参与关联和排序的附加属性
不推荐:
CREATE TABLE stock (
data jsonb
);
然后把代码、名称、行业、行情全部放进 data。
这会削弱类型检查、外键、唯一约束和常规索引能力。
PostgreSQL 18 支持虚拟生成列和存储生成列,虚拟生成列为默认方式。
例如计算涨跌额:
CREATE TABLE market.quote_example (
pre_close numeric(18,4) NOT NULL,
close_price numeric(18,4) NOT NULL,
change_amount numeric(18,4)
GENERATED ALWAYS AS (
close_price - pre_close
) STORED
);
不能直接插入生成列:
INSERT INTO market.quote_example (
pre_close,
close_price
)
VALUES (10.00, 10.50);
适合生成列的内容:
- 同一行字段之间的确定性计算
- 始终可以由原始字段重新计算的数据
- 需要统一计算规则的数据
不适合:
- 依赖其他表的数据
- 依赖当前时间的数据
- 经常修改计算逻辑的数据
当行情表增长到数千万甚至数亿条时,可以考虑按日期范围分区。
CREATE TABLE market.tick_quote (
security_id bigint NOT NULL,
recorded_at timestamptz NOT NULL,
price numeric(18,4) NOT NULL,
volume bigint NOT NULL,
PRIMARY KEY (security_id, recorded_at)
)
PARTITION BY RANGE (recorded_at);
创建月分区:
CREATE TABLE market.tick_quote_2026_09
PARTITION OF market.tick_quote
FOR VALUES FROM ('2026-09-01 00:00:00+00')
TO ('2026-10-01 00:00:00+00');
分区适合:
- 数据量非常大
- 查询通常带日期范围
- 需要快速删除历史数据
- 需要按月归档
- 单表维护成本明显增加
不要因为表可能变大就提前过度分区。分区会增加:
- 表数量
- 运维复杂度
- 分区创建和清理任务
- 唯一约束限制
- SQL 和迁移复杂度
分区表的主键或唯一约束通常必须包含所有分区键列。本例的主键包含 recorded_at,符合要求。PostgreSQL 表分区文档
你的分钟行情、逐笔行情更适合使用 TimescaleDB hypertable;股票基本信息、题材、行业等关系数据继续使用普通 PostgreSQL 表。
如果当前表使用:
code char(6)
name char(...)
建议新表优先改为:
code varchar(6)
name text
原因:
char(n)会填充尾部空格- Python 读取后可能需要额外处理
- 名称长度并不固定
varchar(6)更适合股票代码的业务约束
已有表可以转换:
ALTER TABLE ashare
ALTER COLUMN code TYPE varchar(6)
USING btrim(code);
ALTER TABLE ashare
ALTER COLUMN name TYPE text
USING btrim(name);
正式执行前需要检查:
- 是否存在超过6位的代码
- 是否存在重复代码
- 是否有视图、索引、外键依赖
- 修改大表是否会长时间锁表
- 应用程序是否依赖原字段类型
code integer
会把:
000001
变成:
1
应使用:
code varchar(6)
核心字段应使用 NOT NULL。
close_price DEFAULT 0
零价格和未知价格不是一回事。
基本信息、行情、题材、财务数据应根据实体关系拆分。
题材、行业、标签应使用关联表,或在简单场景中使用数组。
索引会增加插入、更新、存储和维护成本。
历史行情、订单、财务记录等重要数据不应因误删主记录而全部消失。
它只负责插入默认值,更新需要应用程序或触发器处理。
精确业务数据应使用 numeric。
稳定、核心、经常查询的属性应使用普通列。
创建一张表前,检查以下问题:
- 每一行代表什么?
- 主键是什么?
- 是否存在稳定的业务唯一键?
- 哪些字段必须
NOT NULL? - 哪些字段需要默认值?
- 金额是否使用
numeric? - 代码是否需要保留前导零?
- 时间应该使用
date、timestamp还是timestamptz? - 是否需要唯一约束?
- 是否需要检查约束?
- 是否存在一对一、一对多或多对多关系?
- 删除父记录时子记录应该怎么处理?
- 哪些查询最频繁?
- 现有主键或唯一索引是否已经覆盖查询?
- 是否真的需要
jsonb? - 是否真的达到需要分区的规模?
- 是否记录
created_at和updated_at? - 数据变化是否应该删除,还是保留历史状态?
表设计的核心原则可以概括为:
先用数据类型表达数据,再用约束保护数据,最后用索引服务查询。表结构负责正确性,索引负责速度,应用程序负责业务流程。