Chapter 02

数据类型与表设计

PostgreSQL 丰富的类型系统是其核心竞争力,用对类型让存储更高效、查询更准确

2.1 数值类型

PostgreSQL 提供多种数值类型,选对类型既节省存储又保证精度:

类型别名存储大小范围适用场景
smallintint22 bytes-32768 ~ 32767状态码、评分(1-5 星)
integerint, int44 bytes±21 亿一般计数、普通 ID
bigintint88 bytes±92 千兆雪花 ID、大表主键
numeric(p,s)decimal可变精度 p,小数 s 位金融金额(不能用 float!)
realfloat44 bytes6 位十进制精度科学计算(允许精度损失)
double precisionfloat88 bytes15 位十进制精度坐标、统计数据
serial4 bytes1 ~ 2147483647旧式自增 ID(不推荐,见下)
bigserial8 bytes1 ~ 9223372036854775807旧式大表自增(不推荐)
⚠️

金额绝对不要用 float/real!浮点数存在精度问题:SELECT 0.1 + 0.2 在 float 下得到 0.30000000000000004。金融金额必须使用 numeric(15,2) 或将金额存储为最小单位整数(分/厘)。

SQL-- 正确:用 numeric 存储金额
CREATE TABLE payments (
  id        BIGINT GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
  amount    NUMERIC(15, 2) NOT NULL,   -- 最多13位整数+2位小数
  currency  CHAR(3) NOT NULL DEFAULT 'CNY'
);

-- 验证精度
SELECT (0.1::NUMERIC + 0.2::NUMERIC);  -- 结果:0.3(正确)
SELECT (0.1::FLOAT8 + 0.2::FLOAT8);   -- 结果:0.30000000000000004(错误)

2.2 字符类型

类型说明最大长度适用场景
text变长,无长度限制约 1 GBPostgreSQL 首选,文章、备注、任意字符串
varchar(n)变长,有上限n 个字符需要强制长度约束时(如 email、用户名)
char(n)固定长度,不足补空格n 个字符极少使用,如固定格式代码(ISO 国家码 CHAR(2))
💡

PostgreSQL 中 text vs varchar(n) 性能一致在 PG 内部,text 和 varchar 的存储机制完全相同,无性能差异。使用 text 更简洁;当需要长度约束时才用 varchar(n),约束通过 CHECK 约束实现。

SQL-- text 是首选
CREATE TABLE users (
  username  TEXT NOT NULL,
  email     TEXT NOT NULL,
  bio       TEXT,
  -- 用 CHECK 约束代替 varchar(n) 的长度限制
  CONSTRAINT username_length CHECK (char_length(username) BETWEEN 2 AND 50),
  CONSTRAINT email_format  CHECK (email ~* '^[A-Z0-9._%+-]+@[A-Z0-9.-]+\.[A-Z]{2,}$')
);

2.3 日期与时间类型

类型存储精度说明
date4 bytes仅日期,如 2024-01-15
time8 bytes微秒仅时间,不含日期,不含时区
timestamp8 bytes微秒日期+时间,不含时区(存储时丢弃时区信息)
timestamptz8 bytes微秒日期+时间+时区(生产环境强烈推荐
interval16 bytes微秒时间段,如 '7 days'、'2 hours 30 minutes'
⚠️

时区陷阱:始终使用 timestamptztimestamp(without time zone)存储时会忽略时区,导致跨时区系统数据混乱。timestamptz 内部统一存储为 UTC,显示时自动转换为服务器时区。多时区应用必须用 timestamptz。

SQL-- timestamptz 实战示例
CREATE TABLE events (
  id          BIGINT GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
  title       TEXT NOT NULL,
  starts_at   TIMESTAMPTZ NOT NULL,
  ends_at     TIMESTAMPTZ,
  created_at  TIMESTAMPTZ NOT NULL DEFAULT NOW()
);

-- 插入时带时区
INSERT INTO events (title, starts_at)
VALUES ('会议', '2024-03-15 09:00:00+08');  -- +08 表示北京时间

-- 在不同时区显示同一时刻
SELECT
  starts_at AT TIME ZONE 'Asia/Shanghai'  AS beijing_time,
  starts_at AT TIME ZONE 'UTC'            AS utc_time,
  starts_at AT TIME ZONE 'America/New_York' AS ny_time
FROM events;

-- interval 运算
SELECT
  NOW() + INTERVAL '7 days'   AS next_week,
  NOW() - INTERVAL '1 month'  AS last_month,
  age(NOW(), '2000-01-01'::DATE) AS age_since_2000;

2.4 布尔与 UUID 类型

SQL-- 布尔类型(TRUE / FALSE / NULL)
ALTER TABLE users ADD COLUMN is_active BOOLEAN NOT NULL DEFAULT TRUE;

-- 布尔接受多种输入格式
SELECT 'true'::BOOLEAN, 'yes'::BOOLEAN, '1'::BOOLEAN,  -- 全部为 TRUE
       'false'::BOOLEAN, 'no'::BOOLEAN, '0'::BOOLEAN;  -- 全部为 FALSE

-- UUID:全球唯一标识符,128 位,无需中央协调
CREATE EXTENSION IF NOT EXISTS "uuid-ossp";    -- 启用 UUID 扩展

-- 或使用 PG 13+ 内置的 gen_random_uuid()(无需扩展)
CREATE TABLE api_keys (
  id         UUID PRIMARY KEY DEFAULT gen_random_uuid(),
  user_id    BIGINT NOT NULL,
  key_hash   TEXT NOT NULL,
  created_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
);

-- 插入时自动生成 UUID
INSERT INTO api_keys (user_id, key_hash) VALUES (1, 'abc123hash')
RETURNING id;
-- 输出: id = 550e8400-e29b-41d4-a716-446655440000

2.5 数组类型

PostgreSQL 原生支持数组,任何数据类型都可以是数组:integer[]text[]jsonb[]

SQL-- 建表时使用数组
CREATE TABLE products (
  id       BIGINT GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
  name     TEXT NOT NULL,
  tags     TEXT[]  DEFAULT '{}',        -- 标签数组,默认空数组
  scores   INTEGER[]                       -- 整数数组
);

-- 插入数组数据(两种语法)
INSERT INTO products (name, tags, scores) VALUES
  ('MacBook Pro', ARRAY['laptop', 'apple', 'premium'], ARRAY[95, 88, 92]),
  ('Dell XPS',    '{laptop,windows}',                   '{90,85}');

-- 查询数组
SELECT name, tags[1] AS first_tag FROM products;  -- 数组下标从 1 开始!

-- 包含查询:tags 包含 'laptop'
SELECT * FROM products WHERE 'laptop' = ANY(tags);

-- 数组包含操作符 @>(包含)
SELECT * FROM products WHERE tags @> ARRAY['laptop', 'apple'];

-- 展开数组为行(unnest)
SELECT name, unnest(tags) AS tag FROM products;

-- 数组聚合
SELECT array_agg(name ORDER BY name) AS all_products FROM products;

2.6 JSONB 类型

JSONB(JSON Binary)是 PostgreSQL 最强大的类型之一,以二进制格式存储 JSON,支持索引和高效查询。

📋

JSON vs JSONBjson 以文本原样存储,每次查询需重新解析;jsonb 以分解的二进制格式存储,写入稍慢但查询极快,支持 GIN 索引。99% 情况下选 jsonb

JSONB 的局限不保留键的原始顺序(会重新排序);不保留重复键(保留最后一个);null 语义与 SQL NULL 不同(jsonb null 是值,SQL NULL 是缺失)。

SQL-- JSONB 常用操作符
SELECT
  '{"name":"Alice","age":30,"address":{"city":"Beijing"}}'::JSONB
  ->  'name'        AS get_key_json,      -- 返回 jsonb: "Alice"
  ->> 'name'        AS get_key_text,      -- 返回 text: Alice(无引号)
  ->  'address' -> 'city' AS nested,    -- 嵌套访问
  #>  '{address,city}' AS path_json,     -- 路径访问,返回 jsonb
  #>> '{address,city}' AS path_text;    -- 路径访问,返回 text

-- 建表示例:用户档案(半结构化数据)
CREATE TABLE user_profiles (
  user_id    BIGINT PRIMARY KEY,
  profile    JSONB NOT NULL DEFAULT '{}'
);

INSERT INTO user_profiles VALUES
(1, '{"name":"Alice","skills":["Go","PostgreSQL"],"level":5}'),
(2, '{"name":"Bob","skills":["Python","Redis"],"level":3}'),
(3, '{"name":"Carol","skills":["Java","Kafka","Flink"],"level":8}');

-- 包含查询:profile 中包含 {"level": 5} 的记录
SELECT * FROM user_profiles WHERE profile @> '{"level":5}';

-- 键存在查询:检查是否有 "skills" 键
SELECT * FROM user_profiles WHERE profile ? 'skills';

-- 修改 JSONB 字段(jsonb_set)
UPDATE user_profiles
SET profile = jsonb_set(profile, '{level}', '6')
WHERE user_id = 1;

-- 追加数组元素
UPDATE user_profiles
SET profile = jsonb_set(
  profile,
  '{skills}',
  (profile->'skills') || '"Rust"'::JSONB
)
WHERE user_id = 1;

-- 展开 JSONB 为关系行
SELECT user_id, jsonb_each_text(profile) FROM user_profiles WHERE user_id = 1;

2.7 枚举类型

SQL-- 创建枚举类型
CREATE TYPE order_status AS ENUM (
  'pending', 'confirmed', 'shipped', 'delivered', 'cancelled'
);

CREATE TYPE user_role AS ENUM ('admin', 'editor', 'viewer');

-- 在表中使用枚举
CREATE TABLE orders (
  id      BIGINT GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
  status  order_status NOT NULL DEFAULT 'pending',
  total   NUMERIC(12, 2) NOT NULL
);

-- 枚举支持排序(按定义顺序)
SELECT * FROM orders ORDER BY status;  -- pending < confirmed < shipped < ...

-- 添加新枚举值(PG 9.1+,需注意不能删除值)
ALTER TYPE order_status ADD VALUE 'refunding' AFTER 'delivered';

-- 查看所有枚举值
SELECT * FROM pg_enum WHERE enumtypid = 'order_status'::REGTYPE;
⚠️

枚举类型的局限枚举值一旦添加无法删除,只能添加。如果枚举值经常变化,考虑用 TEXT + CHECK CONSTRAINT 代替,或建立独立的枚举表(lookup table),通过外键约束。

2.8 主键选型与最佳实践

方案一:BIGINT IDENTITY(推荐,大多数场景)

SQL-- PG 10+ 推荐的自增方式(替代旧式 SERIAL)
CREATE TABLE users (
  id         BIGINT GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
  -- GENERATED ALWAYS:不允许手动插入 ID(更安全)
  -- GENERATED BY DEFAULT:允许手动指定 ID(迁移数据时有用)
  username   TEXT NOT NULL UNIQUE,
  created_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
);

方案二:UUID(分布式/微服务场景)

SQL-- UUID v4(随机):防止 ID 枚举攻击,可跨库合并
CREATE TABLE sessions (
  id         UUID PRIMARY KEY DEFAULT gen_random_uuid(),
  user_id    BIGINT NOT NULL,
  expires_at TIMESTAMPTZ NOT NULL
);
-- 注意:UUID 主键的 B-Tree 索引因随机性会产生页面碎片(page split)
-- 大表可使用 UUIDv7(时间有序)或 pg_ulid 扩展改善写入性能

时区统一规范

SQL-- 全局设置服务器时区为 UTC(推荐)
-- 在 postgresql.conf 中设置:
-- timezone = 'UTC'

-- 或在会话级别设置
SET timezone = 'Asia/Shanghai';

-- 查看当前时区
SHOW timezone;

-- 最佳实践:数据库存 UTC,应用层转换显示时区
-- 所有 created_at / updated_at 字段统一用 TIMESTAMPTZ + DEFAULT NOW()
CREATE TABLE posts (
  id         BIGINT GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
  title      TEXT NOT NULL,
  body       TEXT,
  author_id  BIGINT NOT NULL REFERENCES users(id),
  created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
  updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
);
📌

本章小结金额用 numeric(p,s),不用 float。字符串首选 text。时间戳始终用 timestamptz 存 UTC。主键用 BIGINT GENERATED ALWAYS AS IDENTITY(普通场景)或 UUID DEFAULT gen_random_uuid()(分布式场景)。JSONB 是半结构化数据的首选,->/->>/>/@> 是最常用操作符。