AI 剪辑任务服务:生产级微服务骨架(综合实战)
难度:⭐⭐⭐⭐⭐ 最难(大厂生产形态,混合多个大章节)
这是后端专题的毕业设计:把 Day 1(并发/Context)、Day 5(Redis 幂等)、Day 6(Kafka 异步)、Day 8(etcd 注册/发现/选主、RPC 超时重试治理)、Day 9(仓储模式/依赖注入/消费幂等/优雅关闭)全部塞进一个服务。它的形状就是大厂一个真实微服务的形状:接口抽象基础设施 + 用例编排 + 并发 worker + 全链路治理。
考点(混合学习清单)
- 并发:worker 池消费 inbox、channel 关闭语义、
sync.WaitGroup优雅关闭 - Context:请求级超时、worker 处理超时、取消传播
- etcd:服务注册 + 租约 + 优雅下线(
ServiceRegistry)、选主(DistributedLock) - Redis:幂等键(
IdempotencyStore.Claim) - MySQL:仓储模式 + 状态机流转(
TaskStore) - Kafka:异步发布 + at-least-once 消费幂等(
TaskBroker) - RPC 治理:注册中心发现实例 + 单次调用超时 + 失败重试(
callInference) - 工程:错误包装(
%w)、构造器注入、幂等关闭、测试全 fake 无外部依赖
题目描述
实现 ClipService——"用户提交剪辑任务 → 异步 worker 处理 → 调推理服务 → 回写结果"的完整服务骨架:
- Start:把本服务注册到注册中心(带租约 + 返回反注册函数)→ 启动 N 个 worker 并发消费 inbox → 尝试抢
/leader/clip-scheduler锁(抢到者 = leader,只有 leader 能跑维护任务); - CreateTask:校验 → 幂等占坑(
Claim)→ 落库 pending → 发布到clip-tasktopic → 返回任务(重复幂等键返回ErrDuplicate,不重复发布); - SubmitIncoming:broker 回调入口(生产里由 Kafka consumer group 触发),入队给 worker;服务已关闭返回
ErrClosed; - HandleIncoming(worker 处理逻辑):消费幂等(终态跳过,Kafka 重投安全)→ 置 processing →
callInference(从注册中心发现推理实例,带超时 + 重试)→ 成功置 done + resultURL,失败置 failed; - RunMaintenance:仅 leader 可执行——把长期 stuck 的 pending 任务重新发布(重投补偿);
- Close:反注册 → 释放 leader 锁 → 停 worker → 等排空(幂等,可重复调用)。
函数签名
go
type Status string
const (
StatusPending Status = "pending"
StatusProcessing Status = "processing"
StatusDone Status = "done"
StatusFailed Status = "failed"
)
type Task struct {
ID string `json:"id"`
UserID string `json:"user_id"`
VideoURL string `json:"video_url"`
Title string `json:"title"`
Status Status `json:"status"`
ResultURL string `json:"result_url,omitempty"`
}
type CreateTaskReq struct {
ID string // 任务 ID
IDempotencyKey string // 幂等键(与 ID 可不同)
UserID string
VideoURL string
Title string
}
var (
ErrInvalidRequest = errors.New("invalid clip task request")
ErrDuplicate = errors.New("duplicate idempotency key")
ErrNoInference = errors.New("no available inference instance")
ErrNotLeader = errors.New("not leader")
ErrClosed = errors.New("service closed")
)
// ---------- 基础设施接口(生产:MySQL / Redis / Kafka / etcd / gRPC) ----------
type TaskStore interface {
Save(ctx context.Context, t *Task) error
Get(ctx context.Context, id string) (*Task, error)
UpdateStatus(ctx context.Context, id string, st Status, resultURL string) error
ListByStatus(ctx context.Context, st Status) ([]*Task, error) // 维护任务用
}
type IdempotencyStore interface {
Claim(ctx context.Context, key string, ttl time.Duration) (bool, error)
}
type TaskBroker interface {
Publish(ctx context.Context, topic string, msg []byte) error
}
type ServiceRegistry interface {
Register(ctx context.Context, key, value string, ttl time.Duration) (deregister func(), err error)
Discover(ctx context.Context, prefix string) ([]string, error)
}
type DistributedLock interface {
TryLock(ctx context.Context, key string, ttl time.Duration) (unlock func(), err error)
}
type InferenceTransport func(ctx context.Context, addr, method string, req any) (any, error)
type Options struct {
WorkerCount int
RegisterKey string // 本服务注册 key(含地址,如 /services/clip/10.0.0.1:8080)
RegisterValue string // 本服务元数据
InferencePrefix string // 注册中心里推理服务的前缀
TTL time.Duration
IdempotencyTTL time.Duration
InferenceTimeout time.Duration
InferenceRetry int
InboxSize int
}
type ClipService struct{ /* 自行设计 */ }
func NewClipService(store TaskStore, idem IdempotencyStore, broker TaskBroker,
registry ServiceRegistry, lock DistributedLock, transport InferenceTransport,
opts Options) *ClipService
func (s *ClipService) Start(ctx context.Context) error
func (s *ClipService) CreateTask(ctx context.Context, req CreateTaskReq) (*Task, error)
func (s *ClipService) SubmitIncoming(ctx context.Context, raw []byte) error
func (s *ClipService) HandleIncoming(ctx context.Context, raw []byte) error
func (s *ClipService) RunMaintenance(ctx context.Context) error
func (s *ClipService) IsLeader() bool
func (s *ClipService) Close() error提示
- Start 的注册失败要返回错误(注册不了就别上线);选主失败不是错误——当 follower 即可(follower 靠 watch 在 leader 挂了以后抢锁接任,见文档 8.4.3);
- workerLoop:
for { select { case raw := <-inbox: 处理; case <-stop: return } };处理包一层context.WithTimeout(30s)防单条任务卡死整个 worker; - SubmitIncoming:
select同时监听 inbox 可写 /stop已关(返回ErrClosed)/ctx.Done(); - Close 顺序:先反注册 + 释放锁,再
close(stop),最后wg.Wait()等 worker 排空;用sync.Once或closed标志保证幂等; - callInference:
Discover(InferencePrefix)→ 无实例返回ErrNoInference→ 循环重试(attempt % len(addrs)轮询换实例)→ 每次context.WithTimeout(InferenceTimeout)包住 transport 调用,cancel()防泄漏——这就是 Day 8 治理模式的现场复用; - 消费幂等:
HandleIncoming开头store.Get看状态,done/failed直接 return nil(Kafka at-least-once 重投安全); - 错误包装:跨层
fmt.Errorf("...: %w", err),测试用errors.Is断言。
与真实工程对照(这一段就是大厂代码的样子)
Start里"注册 + 选主 + 起 worker"对应生产 Go 服务的main()三件套:etcd 注册、leader 选举、协程池;callInference对应生产 gRPC 客户端拦截器链(超时 + 重试 + 熔断)的最小内联版;RunMaintenance对应"定时重扫补偿"(对账系统),是 at-least-once 语义闭环的最后一块;- 全部依赖都是接口 → 测试零外部依赖(本练习的 fake),生产替换为 MySQL/Redis/Kafka/etcd/gRPC 实现即可。
验收
- [ ]
CreateTask幂等:同幂等键第二次 →ErrDuplicate,发布只 1 次;校验失败零副作用 - [ ]
HandleIncoming成功链路:pending → processing → done + resultURL,推理调用 1 次 - [ ] 推理先失败后成功:重试后成功(transport 调用 2 次);一直失败 → 置 failed 并返回错误
- [ ] 消费幂等:已 done 的任务再次投递 → 直接跳过,不再调推理
- [ ] worker 池:3 worker 并发消费 6 条任务全部处理完成,
-race无冲突 - [ ] 选主:两个服务同时 Start,只有一个
IsLeader();非 leader 调RunMaintenance返回ErrNotLeader;leader 的RunMaintenance会重投 stuck 任务 - [ ] 注册生命周期:Start 后注册中心能看到本服务 key;Close 后反注册
- [ ] 优雅关闭:Close 幂等;关闭后
SubmitIncoming返回ErrClosed;worker 排空后再退出 - [ ]
go test -race ./09_microservice/02_clip_service_capstone -v全部通过
追问链(面试连问三层)
- 为什么
CreateTask先 Claim 再落库?(幂等占坑要原子,先落库再查会重复发布) - 发布失败任务已落库怎么办?(pending + RunMaintenance 重扫重投 = at-least-once)
- 重投会不会重复处理?(消费幂等:终态跳过 + 状态机,而不是"处理前删消息")
- leader 挂了谁接任?(follower watch 锁 key,租约过期 → 抢锁 → 接管,见 8.4.3)
Close为什么先反注册再停 worker?(先摘流量,再排空在途任务——优雅下线的标准顺序)
参考答案(Go)
点击展开参考答案
go
//go:build ignore
package answer
import (
"context"
"encoding/json"
"errors"
"fmt"
"sync"
"time"
)
type Status string
const (
StatusPending Status = "pending"
StatusProcessing Status = "processing"
StatusDone Status = "done"
StatusFailed Status = "failed"
)
type Task struct {
ID string `json:"id"`
UserID string `json:"user_id"`
VideoURL string `json:"video_url"`
Title string `json:"title"`
Status Status `json:"status"`
ResultURL string `json:"result_url,omitempty"`
}
type CreateTaskReq struct {
ID string
IDempotencyKey string
UserID string
VideoURL string
Title string
}
var (
ErrInvalidRequest = errors.New("invalid clip task request")
ErrDuplicate = errors.New("duplicate idempotency key")
ErrNoInference = errors.New("no available inference instance")
ErrNotLeader = errors.New("not leader")
ErrClosed = errors.New("service closed")
)
func (r CreateTaskReq) validate() error {
if r.ID == "" || r.IDempotencyKey == "" || r.UserID == "" || r.VideoURL == "" {
return ErrInvalidRequest
}
return nil
}
type TaskStore interface {
Save(ctx context.Context, t *Task) error
Get(ctx context.Context, id string) (*Task, error)
UpdateStatus(ctx context.Context, id string, st Status, resultURL string) error
ListByStatus(ctx context.Context, st Status) ([]*Task, error)
}
type IdempotencyStore interface {
Claim(ctx context.Context, key string, ttl time.Duration) (bool, error)
}
type TaskBroker interface {
Publish(ctx context.Context, topic string, msg []byte) error
}
type ServiceRegistry interface {
Register(ctx context.Context, key, value string, ttl time.Duration) (deregister func(), err error)
Discover(ctx context.Context, prefix string) ([]string, error)
}
type DistributedLock interface {
TryLock(ctx context.Context, key string, ttl time.Duration) (unlock func(), err error)
}
type InferenceTransport func(ctx context.Context, addr, method string, req any) (any, error)
type Options struct {
WorkerCount int
RegisterKey string
RegisterValue string
InferencePrefix string
TTL time.Duration
IdempotencyTTL time.Duration
InferenceTimeout time.Duration
InferenceRetry int
InboxSize int
}
const (
topicClipTask = "clip-task"
leaderKey = "/leader/clip-scheduler"
inferenceMtd = "Inference.Generate"
workerTimeout = 30 * time.Second
)
// ClipService 参考答案:生产级微服务骨架(混合 Day1/5/6/8/9 全部知识点)
type ClipService struct {
store TaskStore
idem IdempotencyStore
broker TaskBroker
registry ServiceRegistry
lock DistributedLock
transport InferenceTransport
opts Options
mu sync.Mutex
started bool
closed bool
leader bool
leaderUnlock func()
dereg func()
inbox chan []byte
stop chan struct{}
wg sync.WaitGroup
}
func NewClipService(store TaskStore, idem IdempotencyStore, broker TaskBroker,
registry ServiceRegistry, lock DistributedLock, transport InferenceTransport,
opts Options) *ClipService {
if opts.WorkerCount <= 0 {
opts.WorkerCount = 1
}
if opts.InferenceRetry <= 0 {
opts.InferenceRetry = 1
}
if opts.InboxSize <= 0 {
opts.InboxSize = 64
}
return &ClipService{
store: store, idem: idem, broker: broker,
registry: registry, lock: lock, transport: transport,
opts: opts,
}
}
func (s *ClipService) Start(ctx context.Context) error {
s.mu.Lock()
defer s.mu.Unlock()
if s.started {
return nil
}
dereg, err := s.registry.Register(ctx, s.opts.RegisterKey, s.opts.RegisterValue, s.opts.TTL)
if err != nil {
return fmt.Errorf("register to registry: %w", err)
}
s.dereg = dereg
if unlock, err := s.lock.TryLock(ctx, leaderKey, s.opts.TTL); err == nil && unlock != nil {
s.leader = true
s.leaderUnlock = unlock
}
s.inbox = make(chan []byte, s.opts.InboxSize)
s.stop = make(chan struct{})
for i := 0; i < s.opts.WorkerCount; i++ {
s.wg.Add(1)
go s.workerLoop()
}
s.started = true
return nil
}
func (s *ClipService) workerLoop() {
defer s.wg.Done()
handle := func(raw []byte) {
ctx, cancel := context.WithTimeout(context.Background(), workerTimeout)
err := s.HandleIncoming(ctx, raw)
cancel()
_ = err
}
for {
select {
case raw := <-s.inbox:
handle(raw)
case <-s.stop:
for { // 排空剩余任务后退出
select {
case raw := <-s.inbox:
handle(raw)
default:
return
}
}
}
}
}
func (s *ClipService) SubmitIncoming(ctx context.Context, raw []byte) error {
s.mu.Lock()
closed := s.closed
s.mu.Unlock()
if closed {
return ErrClosed
}
select {
case s.inbox <- raw:
return nil
case <-s.stop:
return ErrClosed
case <-ctx.Done():
return ctx.Err()
}
}
func (s *ClipService) CreateTask(ctx context.Context, req CreateTaskReq) (*Task, error) {
if err := req.validate(); err != nil {
return nil, err
}
ok, err := s.idem.Claim(ctx, "idem:"+req.IDempotencyKey, s.opts.IdempotencyTTL)
if err != nil {
return nil, fmt.Errorf("claim idempotency: %w", err)
}
if !ok {
return nil, ErrDuplicate
}
task := &Task{
ID: req.ID, UserID: req.UserID, VideoURL: req.VideoURL,
Title: req.Title, Status: StatusPending,
}
if err := s.store.Save(ctx, task); err != nil {
return nil, fmt.Errorf("save task: %w", err)
}
body, err := json.Marshal(task)
if err != nil {
return nil, fmt.Errorf("marshal task: %w", err)
}
if err := s.broker.Publish(ctx, topicClipTask, body); err != nil {
return nil, fmt.Errorf("publish task: %w", err)
}
return task, nil
}
func (s *ClipService) HandleIncoming(ctx context.Context, raw []byte) error {
var task Task
if err := json.Unmarshal(raw, &task); err != nil {
return fmt.Errorf("unmarshal task: %w", err)
}
cur, err := s.store.Get(ctx, task.ID)
if err != nil {
return fmt.Errorf("get task: %w", err)
}
if cur.Status == StatusDone || cur.Status == StatusFailed {
return nil // 消费幂等
}
if err := s.store.UpdateStatus(ctx, task.ID, StatusProcessing, ""); err != nil {
return fmt.Errorf("mark processing: %w", err)
}
url, err := s.callInference(ctx, cur)
if err != nil {
_ = s.store.UpdateStatus(ctx, task.ID, StatusFailed, "")
return fmt.Errorf("inference: %w", err)
}
if err := s.store.UpdateStatus(ctx, task.ID, StatusDone, url); err != nil {
return fmt.Errorf("mark done: %w", err)
}
return nil
}
func (s *ClipService) callInference(ctx context.Context, task *Task) (string, error) {
addrs, err := s.registry.Discover(ctx, s.opts.InferencePrefix)
if err != nil {
return "", fmt.Errorf("discover inference: %w", err)
}
if len(addrs) == 0 {
return "", ErrNoInference
}
var lastErr error
for attempt := 0; attempt < s.opts.InferenceRetry; attempt++ {
addr := addrs[attempt%len(addrs)]
callCtx, cancel := context.WithTimeout(ctx, s.opts.InferenceTimeout)
resp, err := s.transport(callCtx, addr, inferenceMtd, task)
cancel()
if err == nil {
url, _ := resp.(string)
return url, nil
}
lastErr = err
}
return "", fmt.Errorf("inference failed after %d tries: %w", s.opts.InferenceRetry, lastErr)
}
func (s *ClipService) RunMaintenance(ctx context.Context) error {
if !s.IsLeader() {
return ErrNotLeader
}
stuck, err := s.store.ListByStatus(ctx, StatusPending)
if err != nil {
return fmt.Errorf("list stuck tasks: %w", err)
}
for _, t := range stuck {
body, err := json.Marshal(t)
if err != nil {
return fmt.Errorf("marshal task %s: %w", t.ID, err)
}
if err := s.broker.Publish(ctx, topicClipTask, body); err != nil {
return fmt.Errorf("republish task %s: %w", t.ID, err)
}
}
return nil
}
func (s *ClipService) IsLeader() bool {
s.mu.Lock()
defer s.mu.Unlock()
return s.leader
}
func (s *ClipService) Close() error {
s.mu.Lock()
if s.closed {
s.mu.Unlock()
return nil
}
s.closed = true
if s.dereg != nil {
s.dereg()
}
if s.leaderUnlock != nil {
s.leaderUnlock()
}
stop := s.stop
s.mu.Unlock()
if stop != nil {
close(stop)
s.wg.Wait()
}
return nil
}