Files
zhuangang/plan/week03-postgresql.md
T

320 lines
18 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# 转岗计划 · 第 3 周学习计划(PostgreSQL + SQL + sqlc)
> 所属阶段:第一段 Go 全栈基础(第 1~5 周)。总纲见同目录 `README.md`,上周计划 `week02-gin-api.md`,下周计划 `week04-auth-project1.md`。
目标:把第 2 周任务清单里的 `MemoryStore` 换成 PostgreSQL。学会表结构设计、常用 SQL、索引、事务和数据库迁移,用 sqlc 生成类型安全的数据访问代码。因为上周用接口隔离了存储层,本周主要是新写一个 `PgStore`,接口代码基本不用动。
前置条件:第 2 周的 `todo-api` 已完成,`go test ./... -race` 能通过,前端能联调。
要求:SQL 自己写,AI 只帮你检查和解释。表结构设计、索引、事务是所有后端岗位的基本功,后面 RAG 和 Agent 项目设计状态表也靠它。
**为什么学 PostgreSQL 而招聘里 MySQL 更多**:招聘里写 MySQL 的明显更多,但两者的常用 SQL 大部分相同。本周用 PostgreSQL 学(它的 pgvector 扩展在 RAG 里也能用),文末附“MySQL 差异速查”和一个半天的小练习,面试时能说清差异就够了。
参考资料:
- PostgreSQL 官方文档(中文):http://www.postgres.cn/docs/current/
- sqlc 文档:https://docs.sqlc.dev/
- golang-migrate:https://github.com/golang-migrate/migrate
- pgx:https://github.com/jackc/pgx
---
## 最终项目结构
只列出本周新增或修改的文件,其他保持第 2 周的样子。
```
todo-api/
└── backend/
├── docker-compose.yml # 本地 PostgreSQL
├── Makefile # 常用命令:db-up、migrate-up、sqlc 等
├── .env.example # DATABASE_URL 示例(.env 本身不提交)
├── sqlc.yaml # sqlc 配置
├── db/
│ ├── migrations/
│ │ ├── 000001_create_tasks.up.sql
│ │ ├── 000001_create_tasks.down.sql
│ │ ├── 000002_add_tasks_indexes.up.sql
│ │ ├── 000002_add_tasks_indexes.down.sql
│ │ ├── 000003_create_tags.up.sql
│ │ └── 000003_create_tags.down.sql
│ └── queries/
│ ├── tasks.sql # 带 sqlc 注释的查询
│ └── tags.sql
├── sql/
│ └── practice/ # 练习用 SQL,不参与程序运行
│ ├── day1_crud.sql
│ ├── day2_schema.sql
│ ├── day3_queries.sql
│ └── seed.sql
├── internal/
│ ├── config/
│ │ └── config.go # 读环境变量
│ ├── db/ # sqlc 生成的代码(不要手改)
│ │ ├── db.go
│ │ ├── models.go
│ │ ├── tasks.sql.go
│ │ └── tags.sql.go
│ ├── model/
│ │ └── task.go # 新增 Tags 字段
│ ├── store/
│ │ ├── store.go # 接口方法加上 context.Context
│ │ ├── memory_store.go # 同步改签名
│ │ ├── pg_store.go # PostgreSQL 实现
│ │ ├── pg_tx.go # 事务辅助函数
│ │ └── pg_store_test.go # 集成测试(需要数据库)
│ └── handler/
│ └── task_handler.go # 传入 c.Request.Context(),新增分页和标签参数
└── main.go # 按配置选择 memory 或 postgres
```
## 本周数据库表
```
tasks
id BIGSERIAL PRIMARY KEY
title TEXT NOT NULL (长度 1~100,用 CHECK 约束)
done BOOLEAN NOT NULL DEFAULT false
created_at TIMESTAMPTZ NOT NULL DEFAULT now()
updated_at TIMESTAMPTZ NOT NULL DEFAULT now()
tags
id BIGSERIAL PRIMARY KEY
name TEXT NOT NULL UNIQUE
task_tags(多对多关联表)
task_id BIGINT REFERENCES tasks(id) ON DELETE CASCADE
tag_id BIGINT REFERENCES tags(id) ON DELETE CASCADE
PRIMARY KEY (task_id, tag_id)
```
---
## 第 1 天:装 PostgreSQL,用 psql 写增删改查
**学习内容**
- 用 Docker 跑 PostgreSQL(不用往系统里直接装)。没有 Docker 的话先装:`sudo apt install docker.io docker-compose-v2`。
- `psql` 常用命令:`\l` 列数据库、`\dt` 列表、`\d 表名` 看表结构、`\q` 退出。
- 基本 SQL:`CREATE TABLE`、`INSERT`、`SELECT ... WHERE ... ORDER BY ... LIMIT`、`UPDATE`、`DELETE`。
- 常用类型:`BIGSERIAL`、`TEXT`、`BOOLEAN`、`TIMESTAMPTZ`、`JSONB`(先知道有这个,Agent 项目会用到类似的思路)。
**要建的文件**
- `backend/docker-compose.yml`:
- 一个 `postgres:16` 服务,端口 `5432:5432`,环境变量 `POSTGRES_USER=todo`、`POSTGRES_PASSWORD=todo`、`POSTGRES_DB=todo`。
- 用具名卷 `pgdata` 保存数据,容器删了数据还在。
- `backend/.env.example`:`DATABASE_URL=postgres://todo:todo@localhost:5432/todo?sslmode=disable`
- `backend/sql/practice/day1_crud.sql`:
- 建一张练习表 `practice_tasks`。
- 插入 5 条数据,查询未完成的、按创建时间倒序、只取前 3 条,更新一条,删除一条。
- 每条语句上面写一行注释说明它在做什么。
**运行方式**
```bash
cd todo-api/backend
docker compose up -d
docker compose exec -T postgres psql -U todo -d todo < sql/practice/day1_crud.sql
docker compose exec postgres psql -U todo -d todo # 进入交互模式
```
**完成标准**:不看资料能写出带 `WHERE`、`ORDER BY`、`LIMIT` 的查询,以及 `UPDATE ... WHERE id = ...`,并能说出漏写 `WHERE` 的后果。
---
## 第 2 天:表结构设计和数据库迁移
**学习内容**
- 主键、外键、`NOT NULL`、`UNIQUE`、`CHECK`、`DEFAULT` 约束:能交给数据库保证的规则,就不要只靠代码保证。
- 一对多(一个用户多个任务)和多对多(任务和标签)怎么建表。
- 为什么要用迁移文件:表结构变更也要进 Git,每个环境按顺序执行,避免“代码升级了、库表没升级”导致接口报错。
- 安装 golang-migrate:`go install -tags 'postgres' github.com/golang-migrate/migrate/v4/cmd/migrate@latest`。
**要建的文件**
- `backend/sql/practice/day2_schema.sql`:
- 练习设计一套 `users`、`practice_tasks`(带 `user_id` 外键)、`tags`、`task_tags` 表。
- 写注释回答:删除用户时,他的任务该怎么处理?`ON DELETE CASCADE` 和 `ON DELETE RESTRICT` 各适合什么场景?
- `backend/db/migrations/000001_create_tasks.up.sql`:按上面“本周数据库表”建 `tasks` 表,`title` 加 `CHECK (char_length(title) BETWEEN 1 AND 100)`。
- `backend/db/migrations/000001_create_tasks.down.sql`:`DROP TABLE IF EXISTS tasks;`
- `backend/Makefile`:
- `db-up`:`docker compose up -d`
- `migrate-up`:`migrate -path db/migrations -database "$(DATABASE_URL)" up`
- `migrate-down`:回退一步 `down 1`
- `migrate-new`:`migrate create -ext sql -dir db/migrations -seq $(name)`
- `psql`:进入数据库
**运行方式**:`export $(cat .env | xargs) && make migrate-up`,再 `make psql` 用 `\d tasks` 看表结构;`make migrate-down` 再 `make migrate-up`,确认能来回执行。
**完成标准**:能说清楚“为什么上线时先跑迁移再发新代码”,以及 down 迁移的作用。
---
## 第 3 天:多表查询、分组统计和索引
**学习内容**
- `JOIN`(内连接)和 `LEFT JOIN` 的区别。
- `GROUP BY` + `COUNT` / `SUM`、`HAVING`。
- 子查询和 `EXISTS`。
- 索引:B-tree 索引适合什么查询;用 `EXPLAIN ANALYZE` 看查询计划,分清 `Seq Scan` 和 `Index Scan`。
- 索引的代价:写入变慢、占空间,不是越多越好。
**要建的文件**
- `backend/sql/practice/seed.sql`:用 `generate_series` 往 `practice_tasks` 插入 10 万条测试数据,随机分配给 100 个用户和若干标签。
- `backend/sql/practice/day3_queries.sql`,至少包含:
- 每个用户的任务总数和已完成数(`GROUP BY` + `COUNT(*) FILTER (WHERE done)`)。
- 查出没有任何任务的用户(`LEFT JOIN ... WHERE ... IS NULL`)。
- 查出带某个标签的任务(两次 `JOIN` 经过关联表)。
- 任务数超过 1000 的用户(`HAVING`)。
- 对“按用户查未完成任务并按时间倒序”的查询,加索引前后各跑一次 `EXPLAIN ANALYZE`,把耗时和计划写在注释里。
- `backend/db/migrations/000002_add_tasks_indexes.up.sql`:给 `tasks` 加 `(done, created_at DESC)` 的组合索引。
- `backend/db/migrations/000002_add_tasks_indexes.down.sql`:删除这个索引。
**运行方式**:在 `psql` 里执行 `\i` 或用第 1 天的方式执行 SQL 文件。
**完成标准**:能解释组合索引的“最左前缀”规则,能看懂 `EXPLAIN ANALYZE` 输出里最关键的两三行。
---
## 第 4 天:sqlc 生成代码,写 PgStore
**学习内容**
- sqlc 的思路:你写 SQL,它生成 Go 结构体和函数,编译期就能发现字段写错。
- pgx 连接池 `pgxpool`。
- `context.Context`:数据库调用都要带上下文,请求取消或超时时能停止查询。
- 安装 sqlc:`go install github.com/sqlc-dev/sqlc/cmd/sqlc@latest`;添加依赖 `go get github.com/jackc/pgx/v5`。
**要建的文件**
- `backend/sqlc.yaml`:引擎 `postgresql`,`schema` 指向 `db/migrations`,`queries` 指向 `db/queries`,输出包 `db` 到 `internal/db`,`sql_package: "pgx/v5"`。
- `backend/db/queries/tasks.sql`:
- `-- name: ListTasks :many`(支持按 `done` 过滤,用 `sqlc.narg('done')` 处理“可选参数”)
- `-- name: GetTask :one`
- `-- name: CreateTask :one`(`RETURNING *`)
- `-- name: UpdateTask :one`(同时更新 `updated_at = now()`)
- `-- name: DeleteTask :execrows`(根据影响行数判断是否存在)
- 运行 `sqlc generate`,生成 `internal/db/` 下的代码。读一遍生成的 `tasks.sql.go`,看懂它在做什么。
- 修改 `internal/store/store.go`:`TaskStore` 每个方法第一个参数加 `ctx context.Context`。
- 修改 `internal/store/memory_store.go` 和 `internal/handler/task_handler.go`:同步改签名,handler 里传 `c.Request.Context()`。
- `internal/store/pg_store.go`:
- `type PgStore struct { pool *pgxpool.Pool; q *db.Queries }`
- `func NewPgStore(pool *pgxpool.Pool) *PgStore`
- 实现 `TaskStore` 全部方法;`pgx.ErrNoRows` 和影响行数为 0 时返回 `store.ErrNotFound`。
- `func toModel(t db.Task) model.Task`:把生成的结构体转成自己的 `model.Task`,不让生成代码泄露到 handler 层。
- `Makefile` 增加 `sqlc` 目标:`sqlc generate`。
**运行方式**:`make sqlc && go build ./...`;`go test ./... -race`(内存版测试要继续通过)。
**完成标准**:能说清楚 `model.Task` 和 `db.Task` 为什么分开,以及给存储层加 `context` 的意义。
---
## 第 5 天:事务和标签功能
**学习内容**
- 事务的作用:几条写操作要么全成功、要么全失败。
- `BEGIN` / `COMMIT` / `ROLLBACK`;在 Go 里用 `pool.Begin(ctx)`,`defer tx.Rollback(ctx)`,最后 `tx.Commit(ctx)`。
- 隔离级别先了解“读已提交”(PostgreSQL 默认)就够,知道还有更严格的级别。
- `INSERT ... ON CONFLICT DO NOTHING / DO UPDATE`(“存在就跳过或更新”,Agent 项目里做幂等写入会用到)。
**要建的文件**
- `backend/db/migrations/000003_create_tags.up.sql` / `.down.sql`:建 `tags`、`task_tags` 表。
- `backend/db/queries/tags.sql`:
- `-- name: UpsertTag :one`(`INSERT ... ON CONFLICT (name) DO UPDATE SET name = EXCLUDED.name RETURNING id`)
- `-- name: AddTaskTag :exec`
- `-- name: ListTagsByTaskIDs :many`(一次查多个任务的标签,避免“查 N 个任务就发 N 次查询”)
- `internal/store/pg_tx.go`:
- `func (s *PgStore) withTx(ctx context.Context, fn func(q *db.Queries) error) error`:开启事务、执行函数、出错回滚、成功提交。
- `internal/model/task.go`:`Task` 增加 `Tags []string` 字段,`Validate()` 校验标签数量不超过 5 个、每个不超过 20 字。
- `internal/store/pg_store.go`:
- `Create` 改成在事务里:插入任务 → 逐个 `UpsertTag` → `AddTaskTag`。
- `List` 查出任务后,用 `ListTagsByTaskIDs` 一次性补上标签。
- `MemoryStore` 同步支持 `Tags`(简单存在结构体里即可),保证两种实现行为一致。
- 更新 `api.http`:新增带标签的创建请求。
**完成标准**:在 `Create` 里故意在插入标签后返回一个错误,确认任务也没有写进数据库(回滚生效),验证完再删掉这段代码。
---
## 第 6 天:配置切换和集成测试
**学习内容**
- 用环境变量选择存储实现,开发时也能随时切回内存版。
- 集成测试和单元测试的区别:集成测试连真数据库,跑得慢,但能发现 SQL 写错。
- 测试之间的数据隔离:每个测试前清空表(`TRUNCATE ... RESTART IDENTITY CASCADE`)。
**要建的文件**
- `internal/config/config.go`:
- `type Config struct { Port string; Store string; DatabaseURL string }`
- `func Load() (Config, error)`:`STORE` 只能是 `memory` 或 `postgres`;选了 `postgres` 但没有 `DATABASE_URL` 时返回错误。
- 修改 `main.go`:
- 调用 `config.Load()`。
- `postgres` 模式下创建 `pgxpool`,启动时 `Ping`,失败就打日志退出;优雅关机时关闭连接池。
- `/api/health` 增加数据库检查:数据库不通时返回 503。
- `internal/store/pg_store_test.go`:
- 没有设置 `TEST_DATABASE_URL` 时调用 `t.Skip`,这样没装数据库的环境也能跑 `go test`。
- `setupPg(t)`:连接测试库、清空表,`t.Cleanup` 里关闭连接。
- 测试:创建 → 获取 → 更新 → 按 `done` 过滤 → 删除 → 再获取返回 `ErrNotFound`;带标签创建后列表里能看到标签;重复标签名不会重复插入。
- `Makefile` 增加 `test-integration`:设置 `TEST_DATABASE_URL` 后跑 `go test ./internal/store -v`。
**运行方式**:`STORE=postgres go run .`,前端不用改,直接联调;`make test-integration`。
**完成标准**:切换 `STORE` 环境变量,前端功能完全一样;重启后端后数据还在。
---
## 第 7 天:分页、慢查询和收尾
**学习内容**
- 两种分页:`LIMIT/OFFSET`(简单,但页数越往后越慢)和“游标分页”(`WHERE id < 上一页最后一个 id`)。
- 慢查询排查的一般步骤:先找出慢的 SQL → `EXPLAIN ANALYZE` → 看是不是缺索引或者查了太多数据 → 改完再测。
**要建的文件和修改**
- `db/queries/tasks.sql`:新增 `-- name: ListTasksPage :many`,用游标分页(参数 `before_id`、`limit`)。
- `internal/handler/task_handler.go`:`GET /api/tasks` 支持 `?limit=20&before_id=xxx`,返回 `{ items, next_before_id }`;`limit` 默认 20、最大 100。
- 前端 `src/api/tasks.ts` 和 `TaskList`:加“加载更多”按钮。
- `todo-api/README.md`:补充数据库部分:如何启动 PostgreSQL、跑迁移、切换存储、跑集成测试;画一张表关系图(mermaid `erDiagram`)。
- 提交并推送到 `zhuangang` 仓库(路径 `todo-api/`)。**确认 `.env` 没有被提交**。
**完成标准**
- `go vet ./...` 没有警告,`gofmt -l .` 没有输出。
- `go test ./... -race` 和 `make test-integration` 都通过。
- 新电脑按 README 能从零跑起来:`docker compose up -d` → `make migrate-up` → `STORE=postgres go run .`。
- 在总纲 `README.md` 的进度记录里勾掉第 3 周。
---
## 本周自查清单
- [ ] 会用 Docker 启动 PostgreSQL,会用 psql
- [ ] 能独立写增删改查、`JOIN`、`GROUP BY`、`HAVING`
- [ ] 会设计一对多、多对多的表,知道外键删除策略怎么选
- [ ] 会写迁移文件,理解为什么表结构要版本化
- [ ] 能用 `EXPLAIN ANALYZE` 判断有没有走索引
- [ ] 会用 sqlc 生成代码,知道生成代码和业务模型为什么分开
- [ ] 会在 Go 里写事务,并验证过回滚
- [ ] 会用 `ON CONFLICT` 做“存在就跳过或更新”
- [ ] 写了能自动跳过的集成测试
- [ ] 会做游标分页
- [ ] 看过文末 MySQL 差异速查,能说出 3 个以上的差异
## 附:MySQL 差异速查(第 7 天有余力时做,或放到第 5 周的缓冲时间)
招聘里 MySQL 出现得多,会 PostgreSQL 后只需要知道这些差异:
| 方面 | PostgreSQL | MySQL 8 |
| --- | --- | --- |
| 自增主键 | `BIGSERIAL` 或 `GENERATED ... AS IDENTITY` | `BIGINT AUTO_INCREMENT` |
| 插入后拿回数据 | `INSERT ... RETURNING *` | 不支持 `RETURNING`,用 `LAST_INSERT_ID()` 再查一次 |
| 存在就更新 | `ON CONFLICT (...) DO UPDATE` | `ON DUPLICATE KEY UPDATE` |
| 布尔 | `BOOLEAN` | `TINYINT(1)`(`BOOLEAN` 是它的别名) |
| 时间 | `TIMESTAMPTZ` 带时区 | `DATETIME` 不带时区,`TIMESTAMP` 有范围限制;一般统一存 UTC |
| JSON | `JSONB`,可建索引 | `JSON`,索引要借助生成列 |
| 字符集 | 默认 UTF-8 | 建库时指定 `utf8mb4`,否则存不了表情符号 |
| 字符串比较 | 默认区分大小写 | 默认排序规则一般不区分大小写 |
| 存储引擎 | 只有一种 | 用 InnoDB(支持事务和外键) |
| 占位符(Go 驱动) | `$1, $2` | `?` |
**小练习(半天)**
- 在 `docker-compose.yml` 里加一个 `mysql:8` 服务(端口 `3306`)。
- `sql/practice/mysql_schema.sql`:把 `000001` 和 `000003` 迁移改写成 MySQL 语法,跑通。
- `sql/practice/mysql_notes.md`:记下改写时遇到的每一处差异。
- 了解即可:sqlc 也支持 MySQL(`engine: mysql`),GORM 两种数据库都支持,换库主要改的是 SQL 方言和驱动。
下周预告:第 4 周在这套基础上做项目一“团队任务看板”:注册登录、JWT、看板成员权限、按状态分列的任务,前后端完整联调。