320 lines
18 KiB
Markdown
320 lines
18 KiB
Markdown
# 转岗计划 · 第 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、看板成员权限、按状态分列的任务,前后端完整联调。
|