OJ平台远端判题子系统开发(五):Worker并发控制与系统可观测性

前几周完成了HTTP API和沙箱核心的开发,系统已能串行处理判题请求。但在实际使用场景中——比如ACM比赛开场时的集中提交——串行处理会导致后续提交在队列中长时间等待。本周的核心工作是实现受控并发判题,并建立系统可观测性体系。


一、并发方案设计:Worker令牌池模式

1.1 方案评估

Go语言中控制并发有两条主流路径:

方案 优点 缺点
semaphore.Weighted 可动态调整权重 抽象层级高,语义不够直观
Buffered Channel令牌池 Go惯用法,语义清晰 需手动管理令牌生命周期

选择Buffered Channel方案的原因是语义的直接性:make(chan struct{}, N) ——创建了恰好N个令牌,获取一个令牌往channel发送一个值,释放令牌从channel读取一个值。这个模型与"受控并发"的概念完全对应,代码阅读和维护的成本更低。

Go并发模式参考:https://go.dev/blog/pipelines

1.2 核心实现

type JudgeWorker struct {
    queue       queue.Queue
    judger      judger.Executor
    repo        repository.SubmissionRepository
    stats       *stats.Collector
    sem         chan struct{}      // 令牌池
    concurrency int
}

func NewJudgeWorker(q queue.Queue, j judger.Executor, r repository.SubmissionRepository,
    s *stats.Collector, concurrency int) *JudgeWorker {
    return &JudgeWorker{
        queue:       q,
        judger:      j,
        repo:        r,
        stats:       s,
        sem:         make(chan struct{}, concurrency),
        concurrency: concurrency,
    }
}

并发数通过 REMOTE_JUDGE_WORKER_CONCURRENCY 环境变量配置,默认值为4。

1.3 消费与令牌管理

func (w *JudgeWorker) Start(ctx context.Context) {
    go func() {
        w.queue.Consume(ctx, func(msg queue.Message) {
            select {
            case w.sem <- struct{}{}:
                w.stats.AddActiveWorkers(1)
                go func() {
                    defer func() {
                        <-w.sem
                        w.stats.AddActiveWorkers(-1)
                    }()
                    w.process(ctx, msg)
                }()
            default:
                w.stats.IncBusyRejects()
                w.repo.UpdateResult(ctx, domain.JudgeResult{
                    SubmissionID: msg.SubmissionID,
                    Status:       domain.StatusSystemError,
                    ErrorMessage: "worker busy, please retry later",
                })
            }
        })
    }()
}

关键行为:

  • sem <- struct{}{}:尝试获取令牌。channel未满时立即成功,满时走default分支
  • <-w.sem:通过defer确保无论判题成功或失败,令牌都会被释放
  • goroutine:每个判题任务跑在独立的goroutine中,Worker主循环不阻塞
  • 繁忙拒绝:令牌池满时直接将提交标记为System Error并提示重试

二、系统可观测性

并发引入后,系统行为变得更复杂,必须有对应的监控手段。

2.1 统计采集器

type Collector struct {
    mu               sync.Mutex
    totalSubmissions int64
    statusCounts     map[domain.SubmissionStatus]int64
    activeWorkers    int64
    busyRejects      int64
    maxWorkers       int
}

func (c *Collector) Stats() map[string]any {
    return map[string]any{
        "total_submissions": c.totalSubmissions,
        "active_workers":    atomic.LoadInt64(&c.activeWorkers),
        "max_workers":       c.maxWorkers,
        "busy_rejects":      atomic.LoadInt64(&c.busyRejects),
        "status_counts":     c.getStatusCounts(),
    }
}

四个核心指标:

  • total_submissions:累计提交总数
  • active_workers:当前正在执行判题的Worker数量
  • busy_rejects:因令牌池满被拒绝的提交数——如果这个指标持续增长,说明并发数配置不足
  • status_counts:各判题状态的分布统计

2.2 健康检查接口

GET /api/system/health 返回系统运行状况:

{
    "status": "ok",
    "queue": "memory",
    "repository": "memory",
    "sandbox": "mock",
    "judger_mode": "embedded",
    "worker_concurrency": 4,
    "timestamp": "2026-06-02T10:00:00Z"
}

2.3 统计接口

GET /api/system/stats 返回运行时统计数据:

{
    "total_submissions": 100,
    "active_workers": 2,
    "max_workers": 4,
    "busy_rejects": 0,
    "status_counts": {
        "Accepted": 80,
        "Wrong_Answer": 10,
        "Compile_Error": 5,
        "Runtime_Error": 5
    }
}

2.4 结构化日志与TraceId

日志采用JSON结构化格式:

logger.Info("worker.process", traceID, "judge task completed", map[string]any{
    "submissionId": submission.ID,
    "status":       submission.Status,
    "runtimeMs":    submission.RuntimeMs,
    "memoryKB":     submission.MemoryKB,
})

每个提交在创建时生成唯一的 traceId(格式:trace_{id}_{uuid前8位}),随日志贯穿HTTP API → Queue → Worker → Judger → Sandbox全链路。排查问题时,通过一个traceId即可串联所有相关日志。


三、测试验证

3.1 Mock模式服务启动验证

启动命令:

set REMOTE_JUDGE_SANDBOX=mock
set REMOTE_JUDGE_HTTP_ADDR=:8081
set REMOTE_JUDGE_GRPC_ADDR=127.0.0.1:9091
go run .\cmd\server

预期:健康检查返回 "sandbox":"mock",统计接口返回 "total_submissions":0

mock_server_start

健康检查接口验证

curl http://127.0.0.1:8081/api/system/health

health_check

统计接口验证

curl http://127.0.0.1:8081/api/system/stats

system_stats

3.2 Worker单元测试

测试文件 测试用例 验证内容
worker/judge_worker_test.go TestJudgeWorkerHandle Worker消费消息并正确更新提交状态

3.3 Benchmark

提交服务基准测试:

go test ./internal/service -bench . -benchmem -count=1 -timeout 60s
BenchmarkSubmissionServiceCreate-32    27211    206468 ns/op    549 B/op    6 allocs/op

benchmark_mock

单次提交创建约110μs,内存分配6次、600字节。对于判题系统而言,提交创建不是瓶颈——真正的耗时在Docker容器启动和代码编译运行阶段。Benchmark数据确认了这一点,因此并发控制的重点是判题执行阶段(Worker令牌池),而非提交创建阶段。


四、本周总结

完成内容

  1. Worker令牌池并发控制(Buffered Channel方案)
  2. 统计采集器(4项核心指标)
  3. 健康检查与统计接口
  4. 结构化日志 + TraceId全链路追踪
  5. Mock模式启动验证和Benchmark测试

调研查阅的资料

posted @ 2026-06-01 17:01  宋佳奇  阅读(6)  评论(0)    收藏  举报