18 KiB
转岗计划 · 第 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=disablebackend/sql/practice/day1_crud.sql:- 建一张练习表
practice_tasks。 - 插入 5 条数据,查询未完成的、按创建时间倒序、只取前 3 条,更新一条,删除一条。
- 每条语句上面写一行注释说明它在做什么。
- 建一张练习表
运行方式
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 -dmigrate-up:migrate -path db/migrations -database "$(DATABASE_URL)" upmigrate-down:回退一步down 1migrate-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、跑迁移、切换存储、跑集成测试;画一张表关系图(mermaiderDiagram)。- 提交并推送到
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、看板成员权限、按状态分列的任务,前后端完整联调。