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