858 lines
30 KiB
Markdown
858 lines
30 KiB
Markdown
---
|
||
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 全栈基础”
|