30 KiB
30 KiB
id, title, type, domain, stack, week, est_hours, order
| id | title | type | domain | stack | week | est_hours | order | |||||||||
|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|
| plan.week03 | 第 3 周:项目一“团队任务看板”:权限 + Redis + 消息队列 + Docker 部署 | week | plan |
|
|
12 | 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
本周最终目录
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;任务属于看板,分“待办 / 进行中 / 已完成”三列,可以指派给成员。用户和看板是多对多,角色放在关联表上。
- 案例:
-- 文件: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,不暴露“这个看板存在”。
- 案例:权限函数 + 表格驱动测试(用假的仓储,不需要数据库)。
// 文件:internal/service/errors.go
package service
import "errors"
var (
ErrNotFound = errors.New("not found")
ErrForbidden = errors.New("forbidden")
)
// 文件: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
}
// 文件: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/*。
运行方式
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 个位置,需要“目标位置之后的任务整体后移一位”+“更新这个任务”两步,必须在一个事务里,否则并发时位置会错乱。
- 案例:
-- 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。
运行方式
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体验。
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
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 挂了不能让接口跟着挂。
- 案例:看板任务列表缓存。
// 文件: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。
运行方式
go get github.com/redis/go-redis/v9 golang.org/x/sync/singleflight
go build ./...
完成标准
- 第二次请求命中缓存(日志能看出来);停掉 Redis 后接口照常返回。
第 4 天:限流和分布式锁
学习内容
1. 固定窗口限流(Lua 保证原子性)
- 说明:防暴力破解登录、防刷接口、保护下游(以后调大模型接口也要限流,有额度和费用)。“计数 +1”和“首次设置过期”必须原子执行,否则进程在两步之间崩溃会留下永不过期的 key;用 Lua 脚本在 Redis 里一次执行。其他算法:滑动窗口(更平滑)、令牌桶(允许一定突发),能讲清思路即可。
- 案例:
// 文件: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 保证原子)。业务执行时间可能超过过期时间,需要续期(看门狗)或保证任务本身幂等。 - 案例:
// 文件: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
运行方式
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 写通知。
// 文件: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
}
// 文件: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)。
运行方式
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
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注入。 - 案例:
# 文件: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)时要关闭缓冲。 - 案例:
# 文件: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)
运行方式
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 写法):
# 文件:.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 全栈基础”