Files
zhuangang/plan/week03-project1.md
T

858 lines
30 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
---
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 分钟讲清项目一:分层、权限、缓存、限流、分布式锁、消息队列与幂等、部署。
---
## 本周自查清单
- [ ] 权限在后端 service 层判断,有测试;能讲清 401 / 403 / 404 的选择
- [ ] 会用事务处理“多步写入必须一起成功”
- [ ] 会用 Redis 五种结构;能讲清旁路缓存、穿透 / 击穿 / 雪崩、降级
- [ ] 会写 Lua 限流和分布式锁,知道锁的坑
- [ ] 会用 Redis Streams 做消费者组,能讲清“至少一次 + 幂等”;能对比 RabbitMQ 和 Kafka;知道 gRPC 是什么
- [ ] 会写多阶段 Dockerfile、Compose、Nginx 反向代理,会基本的 Linux 排查命令
- [ ] 有 CI 配置,项目一已部署
- [ ] 在总纲进度记录里勾掉第 3 周和“第一段 Go 全栈基础”