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 异步交互流程

流程的关键在于:第一次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 接口粒度的问题与调整
最初将接口拆得较细,每个操作定义一个接口(如 SubmissionSaver、SubmissionFinder),原因是对"接口隔离原则"的理解过于字面化。实际使用时发现,多个小接口在组合时增加了不必要的复杂性,测试时需要构造多个mock对象。
解决方案:合并为两个大的接口(SubmissionRepository和ProblemRepository)。接口隔离原则的实质是"不应依赖不需要的方法",而非"每个接口只包含一个方法"。对仓储来说,"保存提交"和"查询提交"是同一组功能的不同操作,放在同一个接口中是合理的。
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分别测试四种组合,确认每个组件工厂函数正确创建对应的实现。
七、本周总结
完成内容
- 确定HTTP API + 异步队列的对外架构方案
- 实现Queue和Repository两个核心接口的双实现(memory / rabbitmq,memory / mysql)
- 完成gRPC + 自定义JSON Codec的内部通信方案
- 实现Embedded/Remote双模式切换
- 完成依赖注入组装和配置驱动体系
调研查阅的资料
- gRPC-Go encoding包文档:https://github.com/grpc/grpc-go/tree/master/encoding
- RabbitMQ amqp091-go客户端:https://github.com/rabbitmq/amqp091-go
- Go并发模式:https://go.dev/blog/pipelines
- Repository Pattern:https://martinfowler.com/eaaCatalog/repository.html
- RabbitMQ Go官方教程:https://www.rabbitmq.com/tutorials/tutorial-one-go

浙公网安备 33010602011771号