5.2 KiB
5.2 KiB
id, title, type, domain, stack, tags, level, est_hours, related, week, order
| id | title | type | domain | stack | tags | level | est_hours | related | week | order | ||||||||||
|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|
| backend.postgresql.schema-sql | 表设计和 SQL:约束、JOIN、CTE、窗口函数、JSONB、UPSERT | lesson | backend |
|
|
basic | 4 |
|
|
1 |
表设计和 SQL:约束、JOIN、CTE、窗口函数、JSONB、UPSERT
解释说明
表设计的基本功
- 主键:一般用
BIGINT GENERATED ALWAYS AS IDENTITY(自增)或 UUID。对外暴露的 ID 用 UUID 或随机字符串可以避免被遍历。 - 约束是最后一道防线:
NOT NULL、UNIQUE、CHECK (amount > 0)、外键REFERENCES。应用代码有 bug 时,数据库约束能挡住脏数据。 - 范式和反范式:一般按第三范式拆表(一个事实只存一处);为了查询性能可以有意冗余(例如订单里冗余商品名称快照),但要清楚冗余字段谁负责更新。
- 时间:用
TIMESTAMPTZ(带时区)存时间,统一存 UTC,展示时再转换。 - 金额:用
NUMERIC(12,2)或以“分”为单位的整数,不要用浮点数。
常用 SQL
JOIN:内连接只保留两边都有的行;左连接保留左表全部行,右边没有就是 NULL。GROUP BY+ 聚合函数;HAVING过滤分组后的结果。- CTE(
WITH ... AS):把复杂查询拆成几步,可读性好。 - 窗口函数:
ROW_NUMBER() OVER (PARTITION BY 用户 ORDER BY 时间 DESC)不合并行就能做“每组取前 N 条”、排名、累计。 - UPSERT:
INSERT ... ON CONFLICT (键) DO UPDATE一条语句完成“有则更新、无则插入”,并发下也安全(依赖唯一约束)。 - RETURNING:插入、更新后直接返回新值,省一次查询。
- JSONB:存半结构化数据(如商品的可变属性),支持索引(GIN)和查询运算符
->>、@>。结构稳定、经常查询的字段还是建成普通列。
案例
电商后台的“每个用户最近 3 笔订单”“按月统计各渠道销售额”“商品扩展属性”都是上面这些语法的典型用途。下面用一个最小的订单库演示。
代码示例
-- 建表:约束写全
CREATE TABLE users (
id BIGINT GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
email TEXT NOT NULL UNIQUE,
created_at TIMESTAMPTZ NOT NULL DEFAULT now()
);
CREATE TABLE orders (
id BIGINT GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
user_id BIGINT NOT NULL REFERENCES users(id) ON DELETE RESTRICT,
channel TEXT NOT NULL CHECK (channel IN ('web', 'app', 'mini')),
amount NUMERIC(12, 2) NOT NULL CHECK (amount > 0),
attrs JSONB NOT NULL DEFAULT '{}',
created_at TIMESTAMPTZ NOT NULL DEFAULT now()
);
INSERT INTO users (email) VALUES ('a@example.com'), ('b@example.com');
INSERT INTO orders (user_id, channel, amount, attrs, created_at) VALUES
(1, 'web', 99.00, '{"coupon": "NEW10"}', now() - interval '3 day'),
(1, 'app', 25.50, '{}', now() - interval '2 day'),
(1, 'app', 300.00, '{"gift": true}', now() - interval '1 day'),
(1, 'web', 12.00, '{}', now()),
(2, 'mini', 45.00, '{"coupon": "VIP"}', now());
-- 1. 每个用户最近 3 笔订单(窗口函数)
SELECT * FROM (
SELECT o.*, ROW_NUMBER() OVER (PARTITION BY user_id ORDER BY created_at DESC) AS rn
FROM orders o
) t WHERE rn <= 3;
-- 2. 按渠道统计,只看销售额超过 50 的渠道(GROUP BY + HAVING)
SELECT channel, count(*) AS cnt, sum(amount) AS total
FROM orders GROUP BY channel HAVING sum(amount) > 50 ORDER BY total DESC;
-- 3. CTE:先算每个用户的消费总额,再关联用户表
WITH spend AS (
SELECT user_id, sum(amount) AS total FROM orders GROUP BY user_id
)
SELECT u.email, COALESCE(s.total, 0) AS total
FROM users u LEFT JOIN spend s ON s.user_id = u.id;
-- 4. JSONB 查询:用了优惠券的订单
SELECT id, attrs->>'coupon' AS coupon FROM orders WHERE attrs ? 'coupon';
-- 5. UPSERT:每日统计表,重复执行不会出错
CREATE TABLE daily_sales (day DATE PRIMARY KEY, total NUMERIC(12, 2) NOT NULL);
INSERT INTO daily_sales (day, total)
SELECT created_at::date, sum(amount) FROM orders GROUP BY 1
ON CONFLICT (day) DO UPDATE SET total = EXCLUDED.total
RETURNING *;
运行:docker run -d --name pg -e POSTGRES_PASSWORD=pg -p 5432:5432 postgres:17,然后 psql -h localhost -U postgres 粘贴执行。
易错点
NOT IN遇到 NULL:x NOT IN (子查询)只要子查询结果里有一个 NULL,整个条件就不成立,结果为空;改用NOT EXISTS。count(列)不统计 NULL,count(*)统计所有行。- 浮点数存金额:
0.1 + 0.2的精度问题会出现在账目里。 - LEFT JOIN 后在 WHERE 里过滤右表:会把左连接变回内连接,右表的条件要写在
ON里。 - 先查再插判断唯一:并发时两个请求都查不到、都插入成功;用唯一约束 +
ON CONFLICT。 - JSONB 什么都往里放:失去约束和类型检查,查询也难优化。
面试常问
见 PostgreSQL 面试题:范式、NOT IN 和 NULL、UPSERT 的并发安全、JSONB 的使用场景。