Files
zhuangang/plan/week03-postgresql.md
T

18 KiB
Raw Blame History

转岗计划 · 第 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 差异速查”和一个半天的小练习,面试时能说清差异就够了。

参考资料:


最终项目结构

只列出本周新增或修改的文件,其他保持第 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 条,更新一条,删除一条。
    • 每条语句上面写一行注释说明它在做什么。

运行方式

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