📚 全栈开发学习系列 · 阶段二(Web 全栈)
09 HTML 基础:网页骨架与语义化标签
10 CSS 基础:样式、布局与响应式
11 JavaScript 基础:变量、DOM 与事件
12 HTTP 协议基础:请求、响应与状态码
13 FastAPI 入门:构建第一个 RESTful API
14 PostgreSQL 基础:数据库设计与 SQL 操作(当前篇)
15 认证授权:JWT、Session 与 Cookie
16 综合实战:带登录的博客系统

PostgreSQL 基础:数据库设计与 SQL 操作

难度:基础 | 前置知识:FastAPI 基础(第 13 篇)| 预计阅读:25 分钟
读完本篇你将能:在 macOS 和 Linux 上独立安装 PostgreSQL 18,使用 psql 客户端执行 SQL 增删改查操作,设计包含主键、外键和约束的表结构,并通过 SQLAlchemy 2.0 异步引擎将上一篇的 Todo API 从内存列表升级为真实数据库持久化存储。
📑 本文目录
01关系型数据库与 PostgreSQL 简介
02安装与连接 PostgreSQL
03SQL 基础:建表与数据类型
04数据操作:CRUD 增删改查
05表设计:主键、外键与约束
06查询进阶:排序、过滤与聚合
07FastAPI + PostgreSQL 实战

01 关系型数据库与 PostgreSQL 简介

上一篇中我们用 FastAPI 构建了 Todo API,但数据存在 Python 内存列表里——服务重启数据就消失了。真实项目需要把数据存到磁盘上,这就是数据库的职责。

什么是关系型数据库

数据库(Database)是一个有组织的数据集合。关系型数据库(Relational Database)把数据组织成一张张二维表格——就像 Excel 表,每张表有行(记录)和列(字段),表与表之间通过外键建立"关系"。

想象一个图书馆:书籍表存所有书的信息,借阅者表存读者信息,借阅记录表通过书 ID 和读者 ID 把两张表关联起来——这就是"关系"的含义。图书管理员能通过借阅记录查出"谁借了哪本书",而不需要在书籍表里重复存读者信息。

特性 关系型(PostgreSQL) 非关系型(MongoDB)
数据结构 表格(行+列),需预定义结构 文档(JSON),结构灵活
查询语言 SQL(标准化) 各产品自有 API
事务支持 完整 ACID 事务 有限事务支持
适用场景 订单系统、用户管理、财务 日志、内容管理、实时数据

为什么选 PostgreSQL

PostgreSQL(简称 Postgres)是世界上最先进的开源关系型数据库,拥有超过 35 年的活跃开发历史。当前稳定版是 PostgreSQL 18,于 2025 年 9 月 25 日发布,引入了异步 I/O 引擎(读取性能最高提升 3 倍)、UUID v7 支持和虚拟生成列等重要特性。

选择 PostgreSQL 而非 MySQL 的核心理由:PostgreSQL 对 SQL 标准的遵守更严格,支持 JSONB 类型(可在关系型表中存储和查询 JSON 数据),拥有强大的扩展生态(PostGIS 地理空间、pgvector 向量搜索),且在数据完整性约束方面表现更优。FastAPI 官方文档的所有数据库示例均以 PostgreSQL 为默认数据库。

💡 小贴士
PostgreSQL 的 Logo 是一头大象,名字叫 Slonik。这头大象的来历是 1996 年一位数据库设计师用 ASCII 艺术画的大象,后来被社区采纳为官方标志。记住这头大象,在很多技术文档和会议中你都会看到它。

02 安装与连接 PostgreSQL

了解了 PostgreSQL 的定位,接下来在你的开发机上把它装起来。本系列的四设备协作策略中,macOS 作为主力开发机本地安装 PostgreSQL,树莓派作为后端服务器也运行 PostgreSQL,两者通过 psql 客户端统一连接。

macOS 安装(Homebrew)

Shell
brew install postgresql@18
brew services start postgresql@18
# 验证安装
psql --version

执行 psql --version 后应看到类似输出:

输出
psql (PostgreSQL) 18.0

Linux 安装(apt / 树莓派)

Shell
1
2
3
4
5
6
7
8
# 添加 PostgreSQL 官方 APT 源
sudo sh -c 'echo "deb https://apt.postgresql.org/pub/repos/apt $(lsb_release -cs)-pgdg main" > /etc/apt/sources.list.d/pgdg.list'
curl -fsSL https://www.postgresql.org/media/keys/ACCC4CF8.asc | sudo gpg --dearmor -o /etc/apt/trusted.gpg.d/postgresql.gpg
sudo apt update
sudo apt install -y postgresql-18
# 启动服务
sudo systemctl start postgresql

psql 客户端连接

PostgreSQL 安装后会自动创建一个与当前系统用户同名的数据库超级用户。macOS 上你的用户名是 e2do,那么直接输入 psql postgres 即可连接到默认的 postgres 数据库。

Shell
1
2
3
4
5
6
7
8
9
psql postgres
-- 创建项目专用数据库
CREATE DATABASE todoapp;
-- 切换到新数据库
\c todoapp
-- 退出 psql
\q

psql 提供了一组以反斜杠开头的元命令(meta-command),它们不是 SQL 而是客户端工具命令。最常用的几个:

命令 功能
\l 列出所有数据库
\dt 列出当前数据库的所有表
\d 表名 查看表结构(列、类型、约束)
\du 列出所有用户和角色
\c 数据库名 连接到另一个数据库
\q 退出 psql
⚠️ 常见错误
psql: error: connection refused — PostgreSQL 服务未启动
✓ 修复:macOS 执行 brew services start postgresql@18;Linux 执行 sudo systemctl start postgresql

03 SQL 基础:建表与数据类型

已经能用 psql 连接数据库了,接下来学习 SQL(Structured Query Language,结构化查询语言)。SQL 是与关系型数据库"对话"的标准语言——你用 SQL 告诉数据库"创建一张表"、"插入一条数据"、"查出所有完成的待办事项"。

SQL 语句按功能分为三大类:DDL(数据定义语言,如 CREATE/ALTER/DROP)、DML(数据操作语言,如 INSERT/SELECT/UPDATE/DELETE)和 DCL(数据控制语言,如 GRANT/REVOKE)。本篇重点学习 DDL 和 DML。

常用数据类型

建表时需要为每一列指定数据类型,这决定了该列能存什么数据、占多少空间。PostgreSQL 支持数十种数据类型,初学阶段掌握以下几种即可覆盖 90% 场景:

类型 用途 示例
INTEGER 整数(4 字节) 1, 42, -7
SERIAL 自增整数(自动生成 ID) 1, 2, 3...
VARCHAR(n) 定长上限字符串 'hello'(最多 n 字符)
TEXT 无长度限制字符串 长文本、文章内容
BOOLEAN 布尔值 TRUE, FALSE
TIMESTAMP 日期时间 2026-08-13 14:30:00
UUID 通用唯一标识符 a1b2c3d4-...
JSONB 二进制 JSON(可查询) {"key":"value"}
💡 小贴士
PostgreSQL 18 新增了 UUID v7 支持。与 v4(纯随机)不同,v7 包含时间戳前缀,在数据库索引中天然有序排列,写入和查询性能更优。如果你的项目使用 UUID 作主键,建议优先选 v7。

CREATE TABLE 创建表

现在用 SQL 创建上一篇 Todo API 需要的 todos 表。每条待办事项有:ID(自增主键)、标题、描述、优先级、完成状态和创建时间。

SQL
1
2
3
4
5
6
7
8
9
CREATE TABLE todos (
    id SERIAL PRIMARY KEY,
    title VARCHAR(100) NOT NULL,
    description TEXT,
    priority INTEGER DEFAULT 1 CHECK (priority BETWEEN 1 AND 5),
    completed BOOLEAN DEFAULT FALSE,
    created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
);

逐行解读这条建表语句:

• id SERIAL PRIMARY KEY — SERIAL 自动递增,每插入一条记录 id 自动 +1,无需手动指定。PRIMARY KEY 标记为主键,值唯一且不为空
• title VARCHAR(100) NOT NULL — 标题最多 100 字符,不允许为空
• priority INTEGER DEFAULT 1 CHECK(...) — 优先级默认 1,CHECK 约束确保值在 1-5 之间
• created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP — 创建时间默认为当前时刻,插入时无需手动填写

建表后用 \d todos 查看表结构,输出如下:

psql 输出
1
2
3
4
5
6
7
8
9
10
11
12
                   Table "public.todos"
  Column   |        Type        | Collation | Nullable |       Default
--------------+--------------------------+-----------+----------+-----------------------
 id          | integer              |           | not null | nextval(...)
 title       | character varying(100)  |           | not null |
 description | text                  |           |          |
 priority   | integer              |           |          | 1
 completed | boolean              |           |          | false
 created_at | timestamp without time ...|           |          | CURRENT_TIMESTAMP
Indexes:
    "todos_pkey" PRIMARY KEY, btree (id)
Check constraints:
    "todos_priority_check" CHECK (priority >= 1 AND priority <= 5)

可以看到 PostgreSQL 自动为 SERIAL 创建了一个序列(sequence)来生成自增 ID,并为主键自动创建了 B-tree 索引。CHECK 约束也被记录在表结构中,任何不符合约束的数据都会被拒绝插入。

04 数据操作:CRUD 增删改查

表结构已经就绪,现在往里面填数据。CRUD 是四个数据操作的首字母缩写:Create(创建/插入)、Read(读取/查询)、Update(更新)、Delete(删除)。这四个操作覆盖了日常 90% 的数据库交互。

INSERT 插入数据

SQL
1
2
3
4
5
6
7
8
9
-- 插入一条完整记录(省略有默认值的列)
INSERT INTO todos (title, description, priority)
VALUES ('学习 PostgreSQL', '完成数据库基础教程', 3);
-- 批量插入多条
INSERT INTO todos (title, priority) VALUES
    ('写技术博客', 2),
    ('复习 FastAPI', 4),
    ('部署到树莓派', 5);

id、completed 和 created_at 没有出现在 INSERT 列表中——因为它们都有 DEFAULT 值,PostgreSQL 会自动填充。INSERT 后 psql 会返回 INSERT 0 3,表示成功插入 3 行。

SELECT 查询数据

SQL
1
2
3
4
5
6
7
8
-- 查询所有列、所有行
SELECT * FROM todos;
-- 只查询指定列
SELECT id, title, priority FROM todos;
-- 条件查询:只看高优先级(>=4)
SELECT * FROM todos WHERE priority >= 4;

执行 SELECT * FROM todos; 后的输出:

psql 输出
1
2
3
4
5
6
id |     title     | priority | completed
----+-----------------+----------+-----------
  1 | 学习 PostgreSQL |      3    | f
  2 | 写技术博客     |      2    | f
  3 | 复习 FastAPI   |      4    | f
  4 | 部署到树莓派   |      5    | f

psql 中 BOOLEAN 值显示为 t(true)和 f(false),这是 psql 的简写显示方式,实际存储的值是 TRUE/FALSE。

UPDATE 更新数据

SQL
1
2
3
4
5
6
7
-- 标记 id=1 的待办为已完成
UPDATE todos SET completed = TRUE WHERE id = 1;
-- 修改标题和优先级
UPDATE todos SET title = '深入学习 PostgreSQL', priority = 4
WHERE id = 1;
-- UPDATE 0 1 表示更新了 1 行
⚠️ 常见错误
UPDATE todos SET completed = TRUE; — 忘记写 WHERE,所有行都会被更新!
✓ 正确:始终先写 WHERE 条件,或者先执行 SELECT 确认目标行,再改写为 UPDATE

DELETE 删除数据

SQL
-- 删除指定记录
DELETE FROM todos WHERE id = 4;
-- 删除所有已完成的待办
DELETE FROM todos WHERE completed = TRUE;

DELETE 同样需要 WHERE 条件——不带 WHERE 的 DELETE FROM todos; 会清空整张表。生产环境中这条命令的危险程度不亚于 rm -rf /。

05 表设计:主键、外键与约束

掌握了基本的增删改查后,我们来深入表设计。好的表结构能在数据库层面防止脏数据写入,减少应用层代码的校验负担。这一节用博客系统的文章表和标签表来演示多表关系设计。

主键与约束回顾

主键(PRIMARY KEY)是表中每行数据的唯一标识。一张表只能有一个主键,但主键可以包含多列(复合主键)。上一节已用 SERIAL PRIMARY KEY 创建了自增主键。

除了主键,PostgreSQL 还支持以下列级约束:

• NOT NULL — 该列不允许空值。用户邮箱、文章标题等必填字段应加此约束
• UNIQUE — 该列值不可重复。用户名、邮箱等需要唯一的字段
• CHECK(条件) — 自定义校验规则。如 age >= 18、priority BETWEEN 1 AND 5
• DEFAULT 值 — 插入时未指定则自动填充默认值

外键:建立表间关系

外键(FOREIGN KEY)是关系型数据库的核心特性。它让一张表的某列引用另一张表的主键,从而建立"关系"。比如每篇文章属于一个作者,文章表的 author_id 引用用户表的 id。

下面创建一个博客系统的三张表:users(用户)、posts(文章)、tags(标签),通过外键串联。

SQL — 博客系统建表
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
-- 用户表
CREATE TABLE users (
    id SERIAL PRIMARY KEY,
    username VARCHAR(50) UNIQUE NOT NULL,
    email VARCHAR(100) UNIQUE NOT NULL,
    created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
);
-- 文章表(外键引用 users.id)
CREATE TABLE posts (
    id SERIAL PRIMARY KEY,
    title VARCHAR(200) NOT NULL,
    content TEXT,
    author_id INTEGER REFERENCES users(id) ON DELETE CASCADE,
    published BOOLEAN DEFAULT FALSE,
    created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
);
-- 标签表
CREATE TABLE tags (
    id SERIAL PRIMARY KEY,
    name VARCHAR(30) UNIQUE NOT NULL
);
-- 文章-标签关联表(多对多关系)
CREATE TABLE post_tags (
    post_id INTEGER REFERENCES posts(id) ON DELETE CASCADE,
    tag_id INTEGER REFERENCES tags(id) ON DELETE CASCADE,
    PRIMARY KEY (post_id, tag_id)
);

关键设计要点:

• REFERENCES users(id) — posts.author_id 引用 users.id,插入文章时 author_id 必须是 users 表中已存在的 id
• ON DELETE CASCADE — 删除用户时,该用户的所有文章自动级联删除。也可选 ON DELETE SET NULL(设为空)或 RESTRICT(拒绝删除)
• PRIMARY KEY (post_id, tag_id) — 复合主键,一篇文章不能有重复标签
💡 小贴士
外键约束不仅在写入时校验数据合法性,还会阻止误删。尝试删除一个仍有文章关联的用户时,PostgreSQL 会报错 ERROR: update or delete on table "users" violates foreign key constraint。这是数据库在帮你的应用兜底——应用层代码写得再差,脏数据也进不了库。

索引:加速查询

当表数据量增大到上万行时,SELECT * FROM posts WHERE author_id = 5 会变慢——数据库需要逐行扫描整张表。索引(INDEX)就像书的目录,让数据库直接定位到目标行而不必全表扫描。

SQL
-- 为 author_id 创建索引
CREATE INDEX idx_posts_author ON posts(author_id);
-- 为 created_at 降序索引(按时间倒序查询常用)
CREATE INDEX idx_posts_created ON posts(created_at DESC);

索引不是越多越好——每个索引会占用磁盘空间,且每次 INSERT/UPDATE/DELETE 都需要同步更新索引。经验法则:主键和频繁出现在 WHERE 条件中的列建索引,内容频繁变动的列慎建索引。

06 查询进阶:排序、过滤与聚合

基础的 CRUD 已经能完成大部分工作,但真实业务中你还需要排序、分页、统计和跨表关联查询。这一节把 SELECT 的能力再扩展一层。

排序与分页

SQL
1
2
3
4
5
6
7
8
-- 按优先级降序、创建时间升序排列
SELECT * FROM todos
ORDER BY priority DESC, created_at ASC;
-- 分页:每页 10 条,取第 2 页
SELECT * FROM todos
ORDER BY id ASC
LIMIT 10 OFFSET 10;

LIMIT 10 OFFSET 10 表示跳过前 10 条(第 1 页),取接下来 10 条(第 2 页)。这是 FastAPI 分页接口背后最常用的 SQL 模式。

聚合与分组

聚合函数把多行数据合并计算为一个结果:COUNT(计数)、SUM(求和)、AVG(平均)、MAX(最大)、MIN(最小)。配合 GROUP BY 可以按维度分组统计。

SQL
1
2
3
4
5
6
7
8
9
-- 统计待办总数
SELECT COUNT(*) AS total FROM todos;
-- 按完成状态分组统计
SELECT completed, COUNT(*) AS cnt
FROM todos
GROUP BY completed;
-- 结果:completed=f cnt=3, completed=t cnt=1

JOIN 跨表关联查询

JOIN 是关系型数据库的杀手锏——它让你在一次查询中把多张表的数据拼在一起。最常用的是 INNER JOIN(内连接),只返回两张表中都有匹配的行。

SQL — JOIN 查询
1
2
3
4
5
6
7
8
9
-- 查询每篇文章及其作者名
SELECT posts.title, users.username, posts.created_at
FROM posts
INNER JOIN users ON posts.author_id = users.id
WHERE posts.published = TRUE
ORDER BY posts.created_at DESC;
-- 别名简写:posts→p, users→u
SELECT p.title, u.username FROM posts p JOIN users u ON p.author_id = u.id;

JOIN 的执行逻辑:取出 posts 表的每行,用 author_id 去 users 表中查找匹配的 id 行,把两张表的指定列拼在一起返回。WHERE 条件在 JOIN 之后过滤,只返回已发布的文章。

JOIN 类型 行为 使用场景
INNER JOIN 只返回两边都有匹配的行 最常用,文章+作者
LEFT JOIN 左表全保留,右表无匹配则填 NULL 查所有文章(含无标签的)
RIGHT JOIN 右表全保留,左表无匹配则填 NULL 较少使用

07 FastAPI + PostgreSQL 实战

理论学完了,现在做一件有实际意义的事——把上一篇用内存列表存储的 Todo API 升级为 PostgreSQL 持久化存储。使用 SQLAlchemy 2.0 的异步引擎 + asyncpg 驱动,与 FastAPI 的 async/await 体系完美融合。

安装依赖

Shell
pip install sqlalchemy[asyncio] asyncpg

SQLAlchemy 2.0 从 2023 年发布以来已成为 Python 生态的 ORM 标准。asyncpg 是一个高性能的 PostgreSQL 异步驱动,比传统的 psycopg2 快 3-5 倍。连接字符串格式为 postgresql+asyncpg://用户:密码@主机:端口/数据库名。

数据库模型与连接

database.py
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
from sqlalchemy.ext.asyncio import create_async_engine, async_sessionmaker
from sqlalchemy.orm import DeclarativeBase, Mapped, mapped_column
from sqlalchemy import String, Text, Integer, Boolean, select
# 连接字符串(本地开发)
DATABASE_URL = "postgresql+asyncpg://e2do@localhost:5432/todoapp"
engine = create_async_engine(DATABASE_URL, echo=True)
async_session = async_sessionmaker(engine, expire_on_commit=False)
class Base(DeclarativeBase):
    pass
class Todo(Base):
    __tablename__ = "todos"
    id: Mapped[int] = mapped_column(primary_key=True)
    title: Mapped[str] = mapped_column(String(100))
    description: Mapped[str | None] = mapped_column(Text, default=None)
    priority: Mapped[int] = mapped_column(default=1)
    completed: Mapped[bool] = mapped_column(default=False)
async def init_db():
    # 自动建表(开发环境用,生产用 Alembic 迁移)
    async with engine.begin() as conn:
        await conn.run_sync(Base.metadata.create_all)

SQLAlchemy 2.0 使用 Mapped[类型] + mapped_column() 声明模型,比旧版的 Column() 语法更类型安全。Python 3.10+ 的 str | None 语法直接对应 SQL 的可空列。

FastAPI 路由接入数据库

main.py — 数据库版 Todo API
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
from contextlib import asynccontextmanager
from fastapi import FastAPI, HTTPException, Depends
from pydantic import BaseModel, Field
from sqlalchemy.ext.asyncio import AsyncSession
from sqlalchemy import select, delete
from database import async_session, Todo, init_db
async def get_db():
    async with async_session() as session:
        yield session
@asynccontextmanager
async def lifespan(app):
    await init_db()
    yield
app = FastAPI(lifespan=lifespan)
class TodoCreate(BaseModel):
    title: str = Field(..., min_length=1, max_length=100)
    description: str | None = None
    priority: int = Field(default=1, ge=1, le=5)
@app.get("/todos")
async def list_todos(db: AsyncSession = Depends(get_db)):
    result = await db.execute(select(Todo).order_by(Todo.id))
    return result.scalars().all()
@app.post("/todos", status_code=201)
async def create_todo(todo: TodoCreate, db: AsyncSession = Depends(get_db)):
    new_todo = Todo(title=todo.title, description=todo.description,
        priority=todo.priority)
    db.add(new_todo)
    await db.commit()
    await db.refresh(new_todo)
    return new_todo
@app.get("/todos/{todo_id}")
async def get_todo(todo_id: int, db: AsyncSession = Depends(get_db)):
    result = await db.execute(select(Todo).where(Todo.id == todo_id))
    todo = result.scalar_one_or_none()
    if not todo:
        raise HTTPException(404, "待办不存在")
    return todo
@app.delete("/todos/{todo_id}", status_code=204)
async def delete_todo(todo_id: int, db: AsyncSession = Depends(get_db)):
    await db.execute(delete(Todo).where(Todo.id == todo_id))
    await db.commit()

与上一篇内存版的关键区别:

• Depends(get_db) — 依赖注入获取数据库会话,每个请求独立会话,请求结束自动关闭
• select(Todo) — SQLAlchemy 2.0 的查询语法,替代旧版 query() API
• db.commit() — 写操作后必须提交事务,否则数据不落盘
• lifespan — 应用启动时自动建表,替代旧版 @app.on_event("startup")(已废弃)
💡 小贴士
生产环境不要用 create_all() 自动建表——它只在表不存在时创建,无法修改已有表结构。使用 Alembic(SQLAlchemy 的数据库迁移工具)管理表结构变更,每次修改模型后生成迁移脚本,逐步应用到生产数据库。可搜索关键词 "Alembic SQLAlchemy migration" 了解详情。

启动服务:fastapi dev main.py。打开 http://localhost:8000/docs 测试 API,创建几条待办事项。现在关闭终端重启服务——数据还在,因为它们已经持久化到 PostgreSQL 中了。

✏️ 动手练习
🟢 基础验证
在 psql 中创建一个 todoapp 数据库,建一张 todos 表,插入 3 条待办事项,然后用 SELECT 查询所有数据。参考解法:本文第三章和第四章的 SQL 语句,按顺序执行即可。
🟡 组合应用
创建 users 和 posts 两张表(外键关联),插入 2 个用户和 3 篇文章,用 INNER JOIN 查询"每个用户发表了哪些文章",并用 GROUP BY 统计每个用户的文章数量。提示:参考第五章和第六章的 SQL 语法。
🔴 开放挑战
将本文的 FastAPI + PostgreSQL Todo API 扩展为支持分页查询(添加 page 和 page_size 查询参数)和按标题模糊搜索(使用 SQL 的 LIKE 或 ILIKE)。思考:分页时如何避免 OFFSET 在大数据量下的性能问题?可搜索关键词 "cursor pagination PostgreSQL" 了解游标分页方案。
📖 知识回顾
关系型数据库 PostgreSQL 18 psql 元命令 CREATE TABLE 数据类型 CRUD 操作 主键与外键 约束与索引 JOIN 关联查询 聚合与分组 SQLAlchemy 2.0 异步 asyncpg 驱动
下篇预告
15 认证授权:JWT、Session 与 Cookie
将学习用户认证的核心机制:Session 与 Cookie 的工作原理、JWT 的生成与验证流程,并在 FastAPI 中实现完整的用户注册、登录和接口鉴权
关注公众号 · 持续获取全栈开发系列更新