OJ平台远端判题子系统开发(二):整体架构设计与异步队列方案

第一周确定了Docker作为代码隔离的技术基础。本周的工作围绕判题子系统的整体架构设计展开:选择同步还是异步的通信模型、如何抽象基础设施层、gRPC在项目中的角色定位。


一、核心决策:同步还是异步?

1.1 两种架构模型的对比

在设计对外接口时,有两种方案可供选择:

方案A——同步gRPC直连:OJ后端通过gRPC直接调用判题服务,等待判题完成后返回结果。调用方需要维护gRPC连接池、处理超时重试逻辑。

方案B——HTTP API + 异步队列:OJ后端通过HTTP POST提交判题请求后立即返回,判题在后台异步完成后,通过轮询获取结果。

对比维度 同步gRPC直连 异步队列(采用方案)
调用方复杂度 需管理连接池、处理超时 HTTP POST后即返回
削峰填谷能力 请求直接打到沙箱,无法缓冲 队列缓冲,Worker按能力消费
判题超时控制 gRPC deadline难以匹配判题时间 消息级别独立超时控制
容错能力 沙箱故障阻塞OJ后端 消息可重入队,独立恢复
扩展性 上下游耦合,故障连锁传导 完全解耦,独立扩缩

1.2 选择异步的核心原因

从ACM参赛者的角度看判题系统,最直观的认知是:判题不是毫秒级的操作。一次完整的判题包含编译和多个测试点的运行,C++编译通常需要数百毫秒到数秒,Go的编译在Alpine上耗时更长,再加上多个测试点依次运行,整个流程轻松超过5秒。如果让OJ后端这段时间一直保持连接等待,连接池会迅速耗尽。

另一个因素来自实际参赛体验:在比赛中提交代码后,通常需要等待一段时间才能看到结果——这个等待的过程就是"判题队列在消化任务"。这个体验直接说明了判题的异步本质。选择异步架构,就是用工程的方式还原了这个自然的等待过程:提交入队、后台处理、轮询获取结果。

1.3 异步交互流程

屏幕截图 2026-06-03 200646

流程的关键在于:第一次POST仅创建提交记录并返回,客户端需要通过轮询GET获取最终判题结果。


二、队列抽象:双实现策略

2.1 接口定义

队列是异步架构的核心组件。将其定义为接口,便于在开发环境和生产环境之间切换:

type Queue interface {
    Publish(ctx context.Context, msg Message) error
    Consume(ctx context.Context, handler MessageHandler) error
    Close() error
}

2.2 内存队列

基于Go的buffered channel实现,用于开发和测试环境,无需外部依赖:

type MemoryQueue struct {
    ch chan Message
}

func NewMemoryQueue() *MemoryQueue {
    return &MemoryQueue{ch: make(chan Message, 100)}
}

实现过程中遇到一个问题:最初设置的channel缓冲区为10,并发测试时发现队列被填满后Publish操作阻塞。因为Go的channel在满时send操作会阻塞,而测试流程中Consumer尚未启动,导致整个流程卡住。

解决方案:扩大缓冲区至100,并在Publish中使用select加超时机制:

func (q *MemoryQueue) Publish(ctx context.Context, msg Message) error {
    select {
    case q.ch <- msg:
        return nil
    case <-ctx.Done():
        return ctx.Err()
    }
}

2.3 RabbitMQ队列

生产环境方案,基于amqp091-go实现,支持消息持久化和消费者确认机制:

import amqp "github.com/rabbitmq/amqp091-go"
conn, _ := amqp.Dial(url)
ch, _ := conn.Channel()
queue, _ := ch.QueueDeclare("judge_queue", true, false, false, false, nil)

RabbitMQ Go客户端官方教程:https://www.rabbitmq.com/tutorials/tutorial-one-go

通过 REMOTE_JUDGE_QUEUE=memory|rabbitmq 环境变量切换。


三、仓储抽象:双实现策略

3.1 接口设计

同样采用接口抽象,支持开发和生产两套实现:

type SubmissionRepository interface {
    Save(ctx context.Context, sub *domain.Submission) error
    FindByID(ctx context.Context, id uint64) (*domain.Submission, error)
    UpdateResult(ctx context.Context, result domain.JudgeResult) error
}

type ProblemRepository interface {
    FindByID(ctx context.Context, id string) (*domain.Problem, error)
}

3.2 接口粒度的问题与调整

最初将接口拆得较细,每个操作定义一个接口(如 SubmissionSaverSubmissionFinder),原因是对"接口隔离原则"的理解过于字面化。实际使用时发现,多个小接口在组合时增加了不必要的复杂性,测试时需要构造多个mock对象。

解决方案:合并为两个大的接口(SubmissionRepositoryProblemRepository)。接口隔离原则的实质是"不应依赖不需要的方法",而非"每个接口只包含一个方法"。对仓储来说,"保存提交"和"查询提交"是同一组功能的不同操作,放在同一个接口中是合理的。

3.3 两套实现

  • 内存仓储:基于 map[uint64]*domain.Submission,使用 sync.RWMutex 保护并发访问,通过 REMOTE_JUDGE_REPOSITORY=memory 启用
  • MySQL仓储:基于 database/sql + MySQL驱动,schema定义在 schema.sql 中,通过 REMOTE_JUDGE_REPOSITORY=mysql 启用

Repository模式参考:https://martinfowler.com/eaaCatalog/repository.html


四、gRPC在项目中的定位

4.1 对内协议而非对外接口

gRPC在本项目中仅用于server进程和独立judger进程之间的内部通信,对外暴露的接口始终是HTTP REST API。

4.2 自定义JSON Codec

调研gRPC-Go的encoding包文档后发现,gRPC-Go允许注册自定义的Codec,无需使用protoc:

type JSONCodec struct{}

func (c JSONCodec) Marshal(v any) ([]byte, error) {
    return json.Marshal(v)
}

func (c JSONCodec) Unmarshal(data []byte, v any) error {
    return json.Unmarshal(data, v)
}

func (c JSONCodec) Name() string {
    return "json"
}

采用JSON Codec的原因:无需安装protoc和编写.proto文件,减少工具链依赖;JSON格式可读性强,调试方便;判题场景下gRPC通信的开销相比Docker容器启动和代码编译运行的时间可以忽略。

gRPC-Go encoding包文档:https://github.com/grpc/grpc-go/tree/master/encoding

4.3 Embedded与Remote双模式

type Executor interface {
    Judge(ctx context.Context, req domain.JudgeRequest) (domain.JudgeResult, error)
    Health(ctx context.Context) error
}
  • Embedded模式:server进程内直接调用Judger,无网络开销,适合开发调试
  • Remote模式:server通过gRPC连接独立judger进程,适合分布式部署和Docker Compose场景(server和judger为两个独立容器)

两种模式通过 REMOTE_JUDGE_JUDGER_MODE=embedded|remote 切换。


五、依赖注入与配置驱动

所有组件的组装集中在 internal/app/app.go 中:

func New(cfg config.Config) *App {
    repo := createRepository(cfg)
    q := createQueue(cfg)
    sb := sandbox.Build(cfg)
    judger := createJudger(cfg, sb)
    w := worker.NewJudgeWorker(q, judger, repo, stats, concurrency)
    svc := service.NewSubmissionService(repo, problemRepo, q, stats)
    handler := api.NewHTTPServer(svc, querySvc, stats, cfg)
    ...
}

每个组件的依赖通过构造函数参数注入,测试时替换为mock实现。所有可切换的配置项均通过环境变量控制。

配置项汇总:

环境变量 默认值 说明
REMOTE_JUDGE_HTTP_ADDR :8080 HTTP监听地址
REMOTE_JUDGE_GRPC_ADDR 127.0.0.1:9090 gRPC监听/连接地址
REMOTE_JUDGE_JUDGER_MODE embedded Judger模式:embedded/remote
REMOTE_JUDGE_QUEUE memory 队列实现:memory/rabbitmq
REMOTE_JUDGE_REPOSITORY memory 仓储实现:memory/mysql
REMOTE_JUDGE_SANDBOX mock 沙箱实现:mock/docker

六、测试验证

6.1 队列阻塞测试

测试目的:验证MemoryQueue在buffer满时的行为

测试过程:构造buffer为10的队列,连续发送11条消息,观察第11条消息的Publish是否阻塞

测试结果:Publish在buffer满时阻塞。修复后使用select + context超时,Publish在context取消时正确返回error

6.2 接口切换测试

验证memory/rabbitmq队列和memory/mysql仓储能否通过环境变量正常切换——启动server分别测试四种组合,确认每个组件工厂函数正确创建对应的实现。


七、本周总结

完成内容

  1. 确定HTTP API + 异步队列的对外架构方案
  2. 实现Queue和Repository两个核心接口的双实现(memory / rabbitmq,memory / mysql)
  3. 完成gRPC + 自定义JSON Codec的内部通信方案
  4. 实现Embedded/Remote双模式切换
  5. 完成依赖注入组装和配置驱动体系

调研查阅的资料

posted @ 2026-04-07 12:45  宋佳奇  阅读(43)  评论(0)    收藏  举报