--- id: plan.week03 title: "第 3 周:项目一“团队任务看板”:权限 + Redis + 消息队列 + Docker 部署" type: week domain: plan stack: [go, gin, postgresql, redis, mq, docker, nginx, cicd] week: [3] est_hours: 12 order: 3 --- # 第 3 周:项目一“团队任务看板”:权限 + Redis + 消息队列 + Docker 部署 > 第一段“Go 全栈基础”的最后一周。上周 `week02-db-auth.md`,下周 `week04-python.md`。 > 前端界面放到第 7 周“前端工程化查漏”里用 Vue 3 / React 完整做,本周只做后端,并用 `api.http` 和测试验证。 **本周目标**:在 `todo-api` 的基础上做出能写进简历的后端项目:多人看板 + 成员权限;再补上招聘里常见的 Redis(缓存、限流、分布式锁)、消息队列(Redis Streams 入门,了解 RabbitMQ / Kafka)、Docker + Nginx 部署和 CI。 **原则**:Redis、消息队列、Docker 都“够用就好”,Go 部分到本周结束,之后主线是 Python 和 AI 应用。 **参考资料** - go-redis:https://redis.uptrace.dev/zh/ - Redis 命令(中文):https://redis.com.cn/commands.html - Redis Streams:https://redis.io/docs/latest/develop/data-types/streams/ - Docker 入门:https://docs.docker.com/get-started/ - RabbitMQ 教程:https://www.rabbitmq.com/tutorials ;Kafka 介绍:https://kafka.apache.org/intro ## 本周最终目录 ```text task-board/ # 把 todo-api/backend 复制过来改名,go.mod 改为 module task-board ├── docker-compose.yml # postgres、redis、migrate、api、worker、nginx ├── docker-compose.dev.yml ├── Dockerfile ├── deploy/nginx.conf ├── deploy/deploy.md ├── .github/workflows/ci.yml # 或你的托管平台对应的 CI 目录 ├── cmd/server/main.go ├── cmd/worker/main.go ├── db/migrations/000004_boards.up.sql / .down.sql └── internal/ ├── service/ │ ├── errors.go │ ├── permission.go # 权限判断 │ └── permission_test.go ├── cache/board_cache.go # 旁路缓存 + singleflight + 降级 ├── ratelimit/limiter.go # Lua 固定窗口限流 ├── lock/lock.go # Redis 分布式锁 ├── queue/stream.go # Redis Streams 生产和消费 ├── queue/stream_test.go └── ...(沿用上周的 auth、handler、middleware、store) ``` --- ## 第 1 天:看板建模和权限 ### 学习内容 **1. 从需求到表结构** - 说明:先列“谁、有什么、能做什么”:用户可以建多个看板;看板有成员,成员分 owner 和 member;任务属于看板,分“待办 / 进行中 / 已完成”三列,可以指派给成员。用户和看板是多对多,**角色放在关联表上**。 - 案例: ```sql -- 文件:db/migrations/000004_boards.up.sql CREATE TABLE boards ( id BIGSERIAL PRIMARY KEY, name TEXT NOT NULL, owner_id BIGINT NOT NULL REFERENCES users(id), created_at TIMESTAMPTZ NOT NULL DEFAULT now() ); CREATE TABLE board_members ( board_id BIGINT REFERENCES boards(id) ON DELETE CASCADE, user_id BIGINT REFERENCES users(id) ON DELETE CASCADE, role TEXT NOT NULL CHECK (role IN ('owner', 'member')), PRIMARY KEY (board_id, user_id) ); CREATE INDEX idx_members_user ON board_members(user_id); CREATE TABLE board_tasks ( id BIGSERIAL PRIMARY KEY, board_id BIGINT NOT NULL REFERENCES boards(id) ON DELETE CASCADE, title TEXT NOT NULL CHECK (char_length(title) BETWEEN 1 AND 100), status TEXT NOT NULL DEFAULT 'todo' CHECK (status IN ('todo', 'doing', 'done')), position INT NOT NULL DEFAULT 0, assignee_id BIGINT REFERENCES users(id) ON DELETE SET NULL, created_by BIGINT NOT NULL REFERENCES users(id), updated_at TIMESTAMPTZ NOT NULL DEFAULT now() ); CREATE INDEX idx_board_tasks ON board_tasks(board_id, status, position); ``` **2. service 层和权限判断** - 说明:权限判断必须在**后端**做(前端隐藏按钮只是体验)。放在 service 层,handler 只负责解析和响应。非成员访问看板返回 404 而不是 403,不暴露“这个看板存在”。 - 案例:权限函数 + 表格驱动测试(用假的仓储,不需要数据库)。 ```go // 文件:internal/service/errors.go package service import "errors" var ( ErrNotFound = errors.New("not found") ErrForbidden = errors.New("forbidden") ) ``` ```go // 文件:internal/service/permission.go package service import ( "context" "errors" ) type Role string const ( RoleOwner Role = "owner" RoleMember Role = "member" ) // MemberRepo 由数据库实现:SELECT role FROM board_members WHERE board_id=$1 AND user_id=$2 // 查不到时返回 ErrNotFound。 type MemberRepo interface { GetRole(ctx context.Context, boardID, userID int64) (Role, error) } func RequireRole(ctx context.Context, repo MemberRepo, boardID, userID int64, need Role) error { role, err := repo.GetRole(ctx, boardID, userID) if errors.Is(err, ErrNotFound) { return ErrNotFound // 不是成员:当作看板不存在 } if err != nil { return err } if need == RoleOwner && role != RoleOwner { return ErrForbidden } return nil } ``` ```go // 文件:internal/service/permission_test.go package service import ( "context" "errors" "testing" ) type fakeRepo map[[2]int64]Role func (f fakeRepo) GetRole(_ context.Context, b, u int64) (Role, error) { if r, ok := f[[2]int64{b, u}]; ok { return r, nil } return "", ErrNotFound } func TestRequireRole(t *testing.T) { repo := fakeRepo{{1, 10}: RoleOwner, {1, 20}: RoleMember} cases := []struct { user int64 need Role want error }{ {10, RoleOwner, nil}, {20, RoleMember, nil}, {20, RoleOwner, ErrForbidden}, {30, RoleMember, ErrNotFound}, } for _, c := range cases { got := RequireRole(context.Background(), repo, 1, c.user, c.need) if !errors.Is(got, c.want) && got != c.want { t.Errorf("user=%d need=%s got %v want %v", c.user, c.need, got, c.want) } } } ``` **易错点 / 面试常问(补充)** - 易错点:权限判断要放在服务层(或中间件)统一做,不能只靠前端隐藏按钮;每个接口都要校验“当前用户是不是这个看板的成员、角色够不够”。 - 易错点:查不到看板和没有权限时,对外可以都返回 404,避免泄露“这个看板存在”。 - 面试常问:RBAC(基于角色的权限控制)是什么;多租户数据隔离怎么做(每条查询都带上 `board_id` / 租户条件)。 ### 要建的文件 - 复制 `todo-api/backend` 为 `task-board/`,改模块名;`000004_boards` 迁移;`internal/service/*`。 ### 运行方式 ```bash make migrate-up && go test ./internal/service -v ``` ### 完成标准 - 能说清角色为什么放在关联表上、为什么非成员返回 404。 --- ## 第 2 天:看板和任务接口、拖动排序的事务 ### 学习内容 **1. 接口设计** | 方法 | 路径 | 权限 | | --- | --- | --- | | GET / POST | `/api/boards` | 登录 | | GET | `/api/boards/:boardId` | 成员 | | POST / DELETE | `/api/boards/:boardId/members[/:userId]` | owner | | GET / POST | `/api/boards/:boardId/tasks` | 成员 | | PATCH / DELETE | `/api/boards/:boardId/tasks/:taskId` | 成员(删除:创建者或 owner) | | POST | `/api/boards/:boardId/tasks/:taskId/move` | 成员 | - 说明:所有任务查询都**同时带上 `board_id` 条件**,防止用 A 看板的权限去改 B 看板的任务(越权)。 **2. 拖动排序用事务** - 说明:把任务移动到某列的第 n 个位置,需要“目标位置之后的任务整体后移一位”+“更新这个任务”两步,必须在一个事务里,否则并发时位置会错乱。 - 案例: ```sql -- move 的两条 SQL(在 Go 里用 withTx 包起来) UPDATE board_tasks SET position = position + 1 WHERE board_id = $1 AND status = $2 AND position >= $3 AND id <> $4; UPDATE board_tasks SET status = $2, position = $3, updated_at = now() WHERE id = $4 AND board_id = $1 RETURNING id, status, position; ``` - 创建看板也要事务:插入看板 + 把自己加为 owner。 **易错点 / 面试常问(补充)** - 易错点:只用事务还不够。PostgreSQL 默认“读已提交”隔离级别下,两个人同时往同一列拖动,仍可能得到重复的位置。可以在事务开头执行 `SELECT id FROM boards WHERE id = $1 FOR UPDATE` 锁住看板这一行,让同一看板的移动排队执行(建议作为本天的加练)。 - 易错点:事务里出错要回滚,Go 里常见写法是 `defer tx.Rollback(ctx)`,提交成功后再调用回滚是无害的。 - 面试常问:排序字段的几种设计(连续整数、间隔整数、小数 / 字符串排序键)和各自的代价;乐观更新失败时前端怎么回滚。 ### 要建的文件 - `internal/store/board_store.go`:`CreateBoard`(事务)、`ListBoardsByUser`、`GetRole`、`AddMember`、`RemoveMember`、`ListTasks`、`CreateTask`、`MoveTask`(事务)、`DeleteTask`。 - `internal/service/board_service.go`、`task_service.go`:每个方法先 `RequireRole`。 - `internal/handler/board_handler.go`、`board_task_handler.go`;`handler/errors.go` 把 `service.ErrNotFound/ErrForbidden` 映射到 404/403。 - 接口测试:未登录 401;非成员 404;member 加成员 403;跨看板用别的 `taskId` 返回 404。 ### 运行方式 ```bash go test ./... -race ``` ### 完成标准 - 两个账号在 `api.http` 里走通:A 建看板 → B 看不到 → A 加 B → B 能看到 → B 加成员得到 403。 --- ## 第 3 天:Redis 基础和缓存 ### 学习内容 **1. Redis 是什么、五种常用结构** - 说明:内存里的键值数据库,单线程执行命令(6.0 起网络 IO 可多线程),速度快。常用结构和典型用途:String(缓存、计数器)、Hash(对象字段)、List(简单队列、最近 N 条)、Set(去重、标签)、Sorted Set(排行榜、延时队列)。持久化有 RDB(快照)和 AOF(追加日志)两种。 - 案例:用 `redis-cli` 体验。 ```bash docker compose -f docker-compose.dev.yml up -d redis # compose 里加 redis:7 服务,端口 6379 docker compose -f docker-compose.dev.yml exec redis redis-cli ``` ```text SET board:1:name "转岗看板" EX 60 # 60 秒后过期 TTL board:1:name INCR page:views HSET user:1 name 小王 city 上海 LPUSH recent:1 task3 task2 task1 LRANGE recent:1 0 9 SADD board:1:tags 学习 工作 学习 # 自动去重 ZADD rank 90 小王 85 小李 ZREVRANGE rank 0 9 WITHSCORES ``` **2. 旁路缓存(Cache Aside)** - 说明:读:先查缓存,没有再查数据库并写回;写:**先更新数据库,再删除缓存**(而不是更新缓存,避免并发写导致缓存是旧值)。三个经典问题: - 缓存穿透:查不存在的数据,每次都打到数据库 → 缓存空值 / 布隆过滤器; - 缓存击穿:热点 key 过期瞬间大量请求 → 互斥锁或 singleflight 合并请求; - 缓存雪崩:大量 key 同时过期 → 过期时间加随机值。 - 另外要**降级**:Redis 挂了不能让接口跟着挂。 - 案例:看板任务列表缓存。 ```go // 文件:internal/cache/board_cache.go package cache import ( "context" "errors" "fmt" "log/slog" "math/rand/v2" "time" "github.com/redis/go-redis/v9" "golang.org/x/sync/singleflight" ) type BoardCache struct { rdb *redis.Client ttl time.Duration sf singleflight.Group } func NewBoardCache(rdb *redis.Client, ttl time.Duration) *BoardCache { return &BoardCache{rdb: rdb, ttl: ttl} } func key(boardID int64) string { return fmt.Sprintf("board:%d:tasks", boardID) } // GetOrLoad 返回看板任务列表的 JSON;load 负责从数据库查询并序列化。 func (c *BoardCache) GetOrLoad(ctx context.Context, boardID int64, load func(context.Context) ([]byte, error)) ([]byte, error) { k := key(boardID) b, err := c.rdb.Get(ctx, k).Bytes() if err == nil { return b, nil // 命中 } if !errors.Is(err, redis.Nil) { slog.Warn("redis 不可用,降级查数据库", "err", err) return load(ctx) } // 未命中:同一个看板的并发请求只查一次数据库(防击穿) v, err, _ := c.sf.Do(k, func() (any, error) { data, err := load(ctx) if err != nil { return nil, err } jitter := time.Duration(rand.Int64N(int64(30 * time.Second))) // 防雪崩 if err := c.rdb.Set(ctx, k, data, c.ttl+jitter).Err(); err != nil { slog.Warn("写缓存失败", "err", err) } return data, nil }) if err != nil { return nil, err } return v.([]byte), nil } // Invalidate 在任务增删改、移动成功后调用。 func (c *BoardCache) Invalidate(ctx context.Context, boardID int64) error { return c.rdb.Del(ctx, key(boardID)).Err() } ``` - 面试常问:为什么删缓存而不是更新缓存;“先删缓存再更新数据库”会有什么问题(并发读把旧值写回缓存);延迟双删是什么。 ### 要建的文件 - `internal/cache/board_cache.go`;`task_service` 的 `List` 走缓存,写操作后 `Invalidate`。 ### 运行方式 ```bash go get github.com/redis/go-redis/v9 golang.org/x/sync/singleflight go build ./... ``` ### 完成标准 - 第二次请求命中缓存(日志能看出来);停掉 Redis 后接口照常返回。 --- ## 第 4 天:限流和分布式锁 ### 学习内容 **1. 固定窗口限流(Lua 保证原子性)** - 说明:防暴力破解登录、防刷接口、保护下游(以后调大模型接口也要限流,有额度和费用)。“计数 +1”和“首次设置过期”必须原子执行,否则进程在两步之间崩溃会留下永不过期的 key;用 Lua 脚本在 Redis 里一次执行。其他算法:滑动窗口(更平滑)、令牌桶(允许一定突发),能讲清思路即可。 - 案例: ```go // 文件:internal/ratelimit/limiter.go package ratelimit import ( "context" "time" "github.com/redis/go-redis/v9" ) var fixedWindow = redis.NewScript(` local n = redis.call('INCR', KEYS[1]) if n == 1 then redis.call('PEXPIRE', KEYS[1], ARGV[1]) end return n`) type Limiter struct{ rdb *redis.Client } func New(rdb *redis.Client) *Limiter { return &Limiter{rdb: rdb} } // Allow 返回本次是否放行。Redis 出错时放行(fail-open),是否这样做要按业务决定。 func (l *Limiter) Allow(ctx context.Context, key string, limit int, window time.Duration) (bool, error) { n, err := fixedWindow.Run(ctx, l.rdb, []string{"rl:" + key}, window.Milliseconds()).Int() if err != nil { return true, err } return n <= limit, nil } ``` - 用法:登录接口按“IP + 邮箱”每分钟 5 次,超出返回 429 和 `Retry-After` 响应头(写一个 Gin 中间件包装它)。 **2. 分布式锁** - 说明:多个进程(比如部署了两个后端实例)要保证同一时间只有一个在做某件事(比如每天生成一次统计报表)。Redis 实现要点:`SET key 随机值 NX PX 过期时间` 加锁(不存在才设置,带过期防止死锁);释放时**只删自己的锁**(比较随机值后再删,也要用 Lua 保证原子)。业务执行时间可能超过过期时间,需要续期(看门狗)或保证任务本身幂等。 - 案例: ```go // 文件:internal/lock/lock.go package lock import ( "context" "crypto/rand" "encoding/hex" "time" "github.com/redis/go-redis/v9" ) var unlockScript = redis.NewScript(` if redis.call('GET', KEYS[1]) == ARGV[1] then return redis.call('DEL', KEYS[1]) end return 0`) type Lock struct { rdb *redis.Client key string token string } // TryLock 拿到锁返回 (lock, true);被别人持有返回 (nil, false)。 func TryLock(ctx context.Context, rdb *redis.Client, key string, ttl time.Duration) (*Lock, bool, error) { b := make([]byte, 16) if _, err := rand.Read(b); err != nil { return nil, false, err } token := hex.EncodeToString(b) ok, err := rdb.SetNX(ctx, "lock:"+key, token, ttl).Result() if err != nil || !ok { return nil, false, err } return &Lock{rdb: rdb, key: "lock:" + key, token: token}, true, nil } func (l *Lock) Unlock(ctx context.Context) error { return unlockScript.Run(ctx, l.rdb, []string{l.key}, l.token).Err() } ``` - 面试常问:为什么释放锁要比较随机值;锁过期了业务还没做完怎么办;Redis 主从切换时锁可能丢失(了解 Redlock 的争议即可),强一致需求可以用数据库行锁或 etcd / ZooKeeper。 ### 要建的文件 - `internal/ratelimit/limiter.go`、`internal/middleware/ratelimit.go`、`internal/lock/lock.go` ### 运行方式 ```bash go build ./... ``` ### 完成标准 - 连续错误登录 6 次,第 6 次返回 429;两个终端同时调用 `TryLock`,只有一个成功。 --- ## 第 5 天:消息队列入门(Redis Streams),了解 RabbitMQ / Kafka ### 学习内容 **1. 为什么要消息队列** - 说明:**解耦**(指派任务的接口不关心通知怎么发)、**异步**(接口快速返回,耗时工作交给后台)、**削峰**(高峰时先排队)。代价:系统更复杂,消息可能重复、可能延迟。 - 核心概念:生产者、消费者、消费者组、确认(ACK)、重试、死信队列。大多数队列提供“至少一次投递”,所以**消费者必须幂等**(同一条消息处理两次结果一样,常用唯一约束或“已处理 ID”记录)。 **2. Redis Streams 实战** - 说明:Redis 5 起的 Stream 类型支持消费者组和 ACK,小项目不用额外部署中间件。`XADD` 发消息,`XREADGROUP` 按组读取,`XACK` 确认;没确认的消息留在待处理列表(PEL)里,消费者挂了可以用 `XAUTOCLAIM` 认领。 - 案例:“任务被指派”事件 → worker 写通知。 ```go // 文件:internal/queue/stream.go package queue import ( "context" "errors" "log/slog" "strings" "time" "github.com/redis/go-redis/v9" ) func Publish(ctx context.Context, rdb *redis.Client, stream string, body []byte) (string, error) { return rdb.XAdd(ctx, &redis.XAddArgs{Stream: stream, Values: map[string]any{"body": body}}).Result() } type Handler func(ctx context.Context, id string, body []byte) error // Consume 持续消费,直到 ctx 被取消。处理成功才 ACK;失败的消息留在 PEL 里等待重试。 func Consume(ctx context.Context, rdb *redis.Client, stream, group, consumer string, h Handler) error { err := rdb.XGroupCreateMkStream(ctx, stream, group, "0").Err() if err != nil && !strings.Contains(err.Error(), "BUSYGROUP") { // 组已存在不算错 return err } for ctx.Err() == nil { res, err := rdb.XReadGroup(ctx, &redis.XReadGroupArgs{ Group: group, Consumer: consumer, Streams: []string{stream, ">"}, // ">" 表示只读从未投递过的新消息 Count: 10, Block: time.Second, }).Result() if errors.Is(err, redis.Nil) { continue // 超时没有新消息 } if err != nil { if ctx.Err() != nil { return nil } return err } for _, s := range res { for _, m := range s.Messages { body, _ := m.Values["body"].(string) if err := h(ctx, m.ID, []byte(body)); err != nil { slog.Warn("处理失败,稍后重试", "id", m.ID, "err", err) continue } rdb.XAck(ctx, stream, group, m.ID) } } } return nil } ``` ```go // 文件:internal/queue/stream_test.go package queue import ( "context" "os" "sync" "testing" "time" "github.com/redis/go-redis/v9" ) func TestPublishConsumeIdempotent(t *testing.T) { url := os.Getenv("REDIS_URL") if url == "" { t.Skip("未设置 REDIS_URL,跳过") } opt, _ := redis.ParseURL(url) rdb := redis.NewClient(opt) ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second) defer cancel() stream := "test:assigned:" + time.Now().Format("150405.000") defer rdb.Del(context.Background(), stream) // 同一个事件发两次,模拟“至少一次投递”造成的重复 for i := 0; i < 2; i++ { if _, err := Publish(ctx, rdb, stream, []byte(`{"eventId":"e1","taskId":7}`)); err != nil { t.Fatal(err) } } var mu sync.Mutex processed := map[string]bool{} // 真实项目里用数据库唯一约束 event_id 实现幂等 count := 0 go Consume(ctx, rdb, stream, "notify", "c1", func(_ context.Context, _ string, body []byte) error { mu.Lock() defer mu.Unlock() count++ processed[string(body)] = true if count == 2 { cancel() } return nil }) <-ctx.Done() if count != 2 || len(processed) != 1 { t.Fatalf("收到 %d 条,去重后 %d 条", count, len(processed)) } } ``` **3. RabbitMQ 和 Kafka(只学概念)** | | Redis Streams | RabbitMQ | Kafka | | --- | --- | --- | --- | | 定位 | 轻量,复用已有 Redis | 通用消息中间件,路由灵活 | 高吞吐的分布式日志,适合事件流和大数据 | | 核心概念 | stream、消费者组、PEL | 交换机(direct/topic/fanout)、队列、路由键、ACK、死信 | topic、分区、偏移量、消费者组、副本 | | 消息保留 | 按长度或手动裁剪 | 消费确认后删除 | 按时间或大小保留,可回放 | | 适合 | 小项目、简单异步任务 | 业务解耦、复杂路由、延迟和重试 | 日志收集、埋点、海量事件 | - 面试常问:如何保证消息不丢(生产者确认、持久化、消费成功再 ACK);如何处理重复消费(幂等);如何保证顺序(同一业务 key 发到同一分区 / 队列)。 **4. gRPC(只学概念)** - 说明:微服务之间常用 gRPC:用 `.proto` 文件定义接口和消息,生成多语言代码,基于 HTTP/2 和 Protobuf 二进制编码,比 JSON 接口更快、类型更严格;对外给浏览器的接口一般仍用 REST。读一遍官方 Go 快速入门(https://grpc.io/docs/languages/go/quickstart/),能说清 REST 和 gRPC 的取舍即可。 ### 要建的文件 - `internal/queue/stream.go`、`stream_test.go`;`task_service` 在负责人变化时 `Publish`;`cmd/worker/main.go` 调用 `Consume`,写 `notifications` 表(`event_id` 唯一约束做幂等,`INSERT ... ON CONFLICT DO NOTHING`)。 ### 运行方式 ```bash REDIS_URL=redis://localhost:6379/0 go test ./internal/queue -v go run ./cmd/server # 一个终端 go run ./cmd/worker # 另一个终端 ``` ### 完成标准 - 停掉 worker 后指派任务,再启动 worker,通知能补上;重复消息只产生一条通知。 --- ## 第 6 天:Docker、Compose、Nginx ### 学习内容 **1. Dockerfile 多阶段构建** - 说明:镜像是打包好的运行环境,容器是镜像的运行实例。多阶段构建:第一阶段用带编译器的镜像编译,第二阶段只拷贝编译好的二进制,镜像小、不含源码,并且用非 root 用户运行。 - 案例: ```dockerfile # 文件:Dockerfile FROM golang:1.24 AS build WORKDIR /src COPY go.mod go.sum ./ RUN go mod download # 依赖不变时这一层走缓存 COPY . . RUN CGO_ENABLED=0 go build -o /out/server ./cmd/server && \ CGO_ENABLED=0 go build -o /out/worker ./cmd/worker FROM gcr.io/distroless/static-debian12:nonroot COPY --from=build /out/ /app/ USER nonroot EXPOSE 8080 ENTRYPOINT ["/app/server"] ``` (Go 版本号按你本地 `go version` 改;国内拉取镜像慢可以配置镜像加速。) **2. Docker Compose 编排多个服务** - 说明:一个文件描述所有服务、网络和卷;服务之间用服务名互相访问(`postgres:5432`);`depends_on` + 健康检查控制启动顺序;配置用 `.env` 注入。 - 案例: ```yaml # 文件:docker-compose.yml services: postgres: image: postgres:16 environment: { POSTGRES_USER: board, POSTGRES_PASSWORD: "${POSTGRES_PASSWORD}", POSTGRES_DB: board } volumes: [pgdata:/var/lib/postgresql/data] healthcheck: { test: ["CMD-SHELL", "pg_isready -U board"], interval: 5s, retries: 10 } restart: unless-stopped redis: image: redis:7 restart: unless-stopped migrate: image: migrate/migrate volumes: [./db/migrations:/migrations] command: ["-path", "/migrations", "-database", "postgres://board:${POSTGRES_PASSWORD}@postgres:5432/board?sslmode=disable", "up"] depends_on: { postgres: { condition: service_healthy } } api: build: . environment: STORE: postgres DATABASE_URL: postgres://board:${POSTGRES_PASSWORD}@postgres:5432/board?sslmode=disable REDIS_URL: redis://redis:6379/0 JWT_SECRET: ${JWT_SECRET} depends_on: migrate: { condition: service_completed_successfully } redis: { condition: service_started } restart: unless-stopped worker: build: . entrypoint: ["/app/worker"] environment: DATABASE_URL: postgres://board:${POSTGRES_PASSWORD}@postgres:5432/board?sslmode=disable REDIS_URL: redis://redis:6379/0 depends_on: [api] restart: unless-stopped nginx: image: nginx:1.27 ports: ["80:80"] volumes: - ./deploy/nginx.conf:/etc/nginx/conf.d/default.conf:ro - ./web-dist:/usr/share/nginx/html:ro # 第 7 周前端构建产物放这里 depends_on: [api] restart: unless-stopped volumes: pgdata: ``` **3. Nginx 反向代理** - 说明:Nginx 对外只暴露 80/443,静态文件直接返回,`/api/` 转发给后端;单页应用的前端路由要回退到 `index.html`。以后代理流式接口(SSE)时要关闭缓冲。 - 案例: ```nginx # 文件:deploy/nginx.conf server { listen 80; root /usr/share/nginx/html; location /api/ { proxy_pass http://api:8080; proxy_set_header Host $host; proxy_set_header X-Real-IP $remote_addr; proxy_buffering off; # 流式接口需要 } location / { try_files $uri $uri/ /index.html; # 单页应用路由回退 } } ``` - Linux 常用命令(部署和排查要用):`ss -lntp`(看端口)、`df -h` / `free -h`(磁盘和内存)、`journalctl -u docker`、`docker compose logs -f api`、`docker stats`、`ufw allow 80`。 **易错点 / 面试常问(补充)** - 易错点:Dockerfile 先复制 `go.mod` / `go.sum` 再下载依赖、最后复制源码,这样改代码不会让依赖层缓存失效。 - 易错点:容器里服务要监听 `0.0.0.0` 而不是 `127.0.0.1`,否则宿主机访问不到;Compose 里服务之间用服务名访问(如 `postgres:5432`),不是 `localhost`。 - 易错点:`depends_on` 只保证启动顺序,不保证数据库已经可以连接,要配合 `healthcheck` 和 `condition: service_healthy`。 - 面试常问:镜像和容器的区别;多阶段构建为什么能让镜像变小;Nginx 反向代理做了什么(同域转发、静态文件、HTTPS、负载均衡)。 ### 要建的文件 - `Dockerfile`、`.dockerignore`、`docker-compose.yml`、`deploy/nginx.conf`、`.env.example`(`POSTGRES_PASSWORD`、`JWT_SECRET`) ### 运行方式 ```bash cp .env.example .env # 改掉密码和密钥 docker compose up -d --build curl -s localhost/api/health ``` ### 完成标准 - 一台只装了 Docker 的机器,一条命令跑起整套服务;后端镜像不含源码。 --- ## 第 7 天:部署到服务器 + CI + 收尾 ### 学习内容 **1. 部署** - 说明:最小流程:服务器装 Docker → 拉代码 → 配 `.env`(权限 600)→ `docker compose up -d --build`;更新时 `git pull` 再执行一次。数据库定期 `pg_dump` 备份。 - 案例:`deploy/deploy.md` 写成可以照抄的步骤清单。 **2. CI(持续集成)** - 说明:每次推送自动跑检查和测试,坏代码进不了主分支。CD(持续部署)是测试通过后自动部署,本计划先手动部署。不同平台配置位置不同:GitHub Actions 是 `.github/workflows/`,Gitea Actions 写法和它基本兼容,GitLab 是 `.gitlab-ci.yml`。先确认你的 `git.gozeros.com` 是哪种平台、是否开启了 CI。 - 案例(GitHub / Gitea Actions 写法): ```yaml # 文件:.github/workflows/ci.yml name: ci on: [push, pull_request] jobs: test: runs-on: ubuntu-latest services: postgres: image: postgres:16 env: { POSTGRES_USER: test, POSTGRES_PASSWORD: test, POSTGRES_DB: test } ports: ["5432:5432"] redis: image: redis:7 ports: ["6379:6379"] steps: - uses: actions/checkout@v4 - uses: actions/setup-go@v5 with: { go-version-file: go.mod } - run: go vet ./... - run: go install -tags postgres github.com/golang-migrate/migrate/v4/cmd/migrate@latest - run: migrate -path db/migrations -database "postgres://test:test@localhost:5432/test?sslmode=disable" up - run: go test ./... -race env: TEST_DATABASE_URL: postgres://test:test@localhost:5432/test?sslmode=disable REDIS_URL: redis://localhost:6379/0 - run: docker build -t task-board . ``` **易错点 / 面试常问(补充)** - 易错点:服务器上的密钥放在 `.env` 或 CI 的密钥配置里,不写进仓库和镜像;部署后检查 `docker compose ps` 和日志,确认迁移执行成功再放流量。 - 易错点:CI 里的测试要用 CI 提供的数据库服务(或跳过集成测试),不要连生产库。 - 面试常问:CI 和 CD 的区别;一次发布出问题怎么回滚(保留上一个镜像标签,`docker compose` 切回去;数据库迁移要向后兼容才能安全回滚)。 ### 要建的文件 - `deploy/deploy.md`、CI 配置;`task-board/README.md`:功能、架构图(mermaid:Nginx → API → PostgreSQL / Redis,API → Stream → Worker)、接口表、权限设计、缓存 / 限流 / 锁 / 队列的取舍、本地运行和部署步骤、已知不足。 - 提交推送,确认 `.env` 没有提交。 ### 运行方式 在服务器上按 `deploy.md` 执行一遍。 ### 完成标准 - 服务器上能访问 `/api/health`;能用 3 分钟讲清项目一:分层、权限、缓存、限流、分布式锁、消息队列与幂等、部署。 --- ## 本周相关专题 > 本节由 `tools/gen_week_topics.py` 根据专题文件的 `week` 字段自动生成,请勿手改;想调整就修改对应专题文件的 frontmatter 后重新生成。 - **AI 编程练习(每周)**:本周至少一个功能用 AI 编程工具(Cursor / Claude Code / Codex 等)完成,按 [AI 编程:工作流](../topics/ai/ai-coding/01-workflow-rules-skills.md) 写清需求和规则,按 [验证与证据](../topics/ai/ai-coding/02-verify-evidence.md) 保存测试结果和会话记录(脱敏后),第 15 周整理成“AI 编程 + 验证方法”的应聘案例。 | 技术栈 | 课程 / 场景 / 面试题 | | --- | --- | | [Gin Web 框架](../topics/backend/gin/index.md) | 课程:[中间件、JWT 鉴权和统一错误处理](../topics/backend/gin/02-middleware-auth.md);场景:[企业后台接口规范](../topics/backend/gin/scenarios/unified-api-response.md);[面试题](../topics/backend/gin/interview.md) | | [Go 语言](../topics/backend/go/index.md) | 课程:[错误处理和并发](../topics/backend/go/02-errors-concurrency.md);课程:[测试和工程化](../topics/backend/go/03-testing-engineering.md);场景:[批量调用第三方接口](../topics/backend/go/scenarios/batch-third-party-calls.md);[面试题](../topics/backend/go/interview.md) | | [消息队列(RabbitMQ / Kafka)](../topics/backend/mq/index.md) | 课程:[RabbitMQ](../topics/backend/mq/01-rabbitmq.md);课程:[可靠性](../topics/backend/mq/03-reliability.md) | | [PostgreSQL](../topics/backend/postgresql/index.md) | 课程:[索引、EXPLAIN、事务、隔离级别和 MVCC](../topics/backend/postgresql/02-index-tx-mvcc.md);场景:[订单状态机](../topics/backend/postgresql/scenarios/order-state-audit.md) | | [Redis](../topics/backend/redis/index.md) | 课程:[数据结构和常用命令](../topics/backend/redis/01-data-structures.md);课程:[缓存模式](../topics/backend/redis/02-cache-patterns.md);课程:[分布式锁和限流](../topics/backend/redis/03-lock-ratelimit.md);[面试题](../topics/backend/redis/interview.md) | | [CI/CD](../topics/devops/cicd/index.md) | 课程:[流水线设计](../topics/devops/cicd/01-pipeline.md) | | [Docker 与 Compose](../topics/devops/docker/index.md) | 课程:[镜像和 Dockerfile](../topics/devops/docker/01-image-dockerfile.md);课程:[Compose、网络和数据卷](../topics/devops/docker/02-compose-network-volume.md);场景:[一条命令拉起开发环境](../topics/devops/docker/scenarios/compose-dev-env.md);[面试题](../topics/devops/docker/interview.md) | | [Nginx](../topics/devops/nginx/index.md) | 课程:[反向代理、负载均衡和静态资源](../topics/devops/nginx/01-proxy-static.md) | **跨技术栈场景**:[秒杀 / 高并发抢购](../scenarios/flash-sale.md)、[工单系统](../scenarios/ticket-system.md) ## 本周自查清单 - [ ] 权限在后端 service 层判断,有测试;能讲清 401 / 403 / 404 的选择 - [ ] 会用事务处理“多步写入必须一起成功” - [ ] 会用 Redis 五种结构;能讲清旁路缓存、穿透 / 击穿 / 雪崩、降级 - [ ] 会写 Lua 限流和分布式锁,知道锁的坑 - [ ] 会用 Redis Streams 做消费者组,能讲清“至少一次 + 幂等”;能对比 RabbitMQ 和 Kafka;知道 gRPC 是什么 - [ ] 会写多阶段 Dockerfile、Compose、Nginx 反向代理,会基本的 Linux 排查命令 - [ ] 有 CI 配置,项目一已部署 - [ ] 在总纲进度记录里勾掉第 3 周和“第一段 Go 全栈基础”