构建 C++ 在线评测系统的完整实践

2026/04/20 go

构建 C++ 在线评测系统的完整实践

一、项目背景与需求分析

1.1 为什么要做这个系统

去年开始接触青少年编程教育领域,发现市面上的 OJ 系统要么太老旧(界面像上世纪的产物),要么功能过于复杂(面向 ACM 竞赛,对初学者不友好)。GESP、CSP-J/S 这些比赛的备赛需求很具体:

最终成果:百炼谷在线评测系统 - 一个面向青少年编程教育的 OJ 平台

  • 需要按难度分级的题库(入门、普及、提高)
  • 需要班级管理功能,老师能布置作业、查看进度
  • 需要竞赛模式,模拟真实比赛环境
  • 代码编辑器要对 C++ 友好,有语法提示

1.2 核心需求拆解

模块 核心功能 技术难点
用户系统 注册登录、角色权限、班级管理 密码安全、JWT 刷新机制
题目系统 题面展示、测试用例管理、标签分类 Markdown 渲染、富文本编辑
评测系统 代码编译执行、资源限制、结果判定 沙箱安全、并发控制
竞赛系统 计时、排行榜、防作弊 实时数据同步
教学系统 课程管理、学习轨迹、作业批改 数据统计分析

1.3 技术选型决策

后端语言选择

在 Java 和 Go 之间犹豫过。Java 生态成熟,但启动慢、内存占用高;Go 编译快、部署简单,适合这种需要频繁部署迭代的项目。最终选择 Go + go-zero 框架。

评测沙箱选择

调研了几个方案:

  • 自己基于 Docker 实现:灵活但工作量大,安全性需要自己把控
  • 使用开源 go-judge:基于 nsjail 的沙箱,功能完善,社区活跃
  • 使用洛谷的评测内核:不开源

最终选择 go-judge,通过 HTTP 接口调用,把评测逻辑和业务逻辑解耦。

二、系统架构设计

2.1 整体架构

┌─────────────────────────────────────────────────────────────┐
│                         前端层                               │
│  ┌──────────────┐  ┌──────────────┐  ┌──────────────┐      │
│  │   学生端      │  │   管理后台    │  │   竞赛页面    │      │
│  │  Vue3 + TS   │  │  Vue3 + TS   │  │  Vue3 + TS   │      │
│  └──────────────┘  └──────────────┘  └──────────────┘      │
└─────────────────────────────────────────────────────────────┘
                              │
                              ▼
┌─────────────────────────────────────────────────────────────┐
│                      API 网关层 (go-zero)                    │
│  ┌─────────────┐ ┌─────────────┐ ┌─────────────┐           │
│  │  JWT 认证   │ │  限流熔断   │ │  日志追踪   │           │
│  └─────────────┘ └─────────────┘ └─────────────┘           │
└─────────────────────────────────────────────────────────────┘
                              │
                              ▼
┌─────────────────────────────────────────────────────────────┐
│                      业务服务层                              │
│  ┌──────────┐ ┌──────────┐ ┌──────────┐ ┌──────────┐       │
│  │ 用户服务  │ │ 题目服务  │ │ 评测服务  │ │ 竞赛服务  │       │
│  │  (auth)  │ │ (problem)│ │(submission)│ │(contest) │       │
│  └──────────┘ └──────────┘ └──────────┘ └──────────┘       │
└─────────────────────────────────────────────────────────────┘
                              │
        ┌─────────────────────┼─────────────────────┐
        ▼                     ▼                     ▼
┌──────────────┐    ┌──────────────┐    ┌──────────────┐
│    MySQL     │    │    Redis     │    │   go-judge   │
│   (主存储)    │    │  (缓存/会话)  │    │  (评测沙箱)   │
└──────────────┘    └──────────────┘    └──────────────┘

2.2 目录结构设计

coder-go/
├── api/coder/                    # 主 API 服务
│   ├── desc/                     # API 定义文件 (.api)
│   │   └── modules/              # 按模块拆分
│   │       ├── user.api
│   │       ├── problem.api
│   │       ├── submission.api
│   │       └── ...
│   ├── internal/
│   │   ├── handler/              # HTTP 处理器(自动生成)
│   │   ├── logic/                # 业务逻辑层
│   │   ├── svc/                  # 服务上下文
│   │   └── config/               # 配置定义
│   └── etc/
│       └── coder-api.yaml        # 配置文件
├── model/                        # 数据模型层(goctl 生成)
│   ├── usersmodel.go
│   ├── problemsmodel.go
│   └── ...
├── pkg/                          # 公共包
│   ├── judge/                    # 评测客户端
│   ├── jwtx/                     # JWT 工具
│   ├── errorx/                   # 错误处理
│   └── ...
└── admin/                        # 管理后台前端
    ├── src/
    │   ├── views/                # 页面组件
    │   ├── api/                  # API 接口
    │   └── components/           # 公共组件
    └── package.json

三、核心模块实现

3.1 用户认证系统

3.1.1 双 Token 机制

考虑到用户体验和安全性,采用 Access Token + Refresh Token 方案:

  • Access Token:有效期 2 小时,存储在内存中,频繁使用
  • Refresh Token:有效期 7 天,存储在 HTTP-only Cookie 中,用于续期
// pkg/jwtx/jwtx.go
// GenerateAccessToken 生成访问令牌
func GenerateAccessToken(secret string, expireSeconds int64, claims *Claims) (string, error) {
    claims.TokenType = TokenTypeAccess
    claims.RegisteredClaims = jwt.RegisteredClaims{
        ExpiresAt: jwt.NewNumericDate(time.Now().Add(time.Duration(expireSeconds) * time.Second)),
        IssuedAt:  jwt.NewNumericDate(time.Now()),
        Issuer:    "coder-go",
    }
    token := jwt.NewWithClaims(jwt.SigningMethodHS256, claims)
    return token.SignedString([]byte(secret))
}

// GenerateRefreshToken 生成刷新令牌(带版本号)
func GenerateRefreshToken(secret string, expireSeconds int64, claims *RefreshClaims) (string, error) {
    claims.TokenType = TokenTypeRefresh
    claims.RegisteredClaims = jwt.RegisteredClaims{
        ExpiresAt: jwt.NewNumericDate(time.Now().Add(time.Duration(expireSeconds) * time.Second)),
        IssuedAt:  jwt.NewNumericDate(time.Now()),
        Issuer:    "coder-go",
    }
    token := jwt.NewWithClaims(jwt.SigningMethodHS256, claims)
    return token.SignedString([]byte(secret))
}

Refresh Token 带版本号的设计是为了支持”踢人下线”功能。当管理员禁用用户或用户修改密码时,递增版本号,旧的 Refresh Token 就失效了。

3.1.2 密码安全

前端使用 RSA 公钥加密密码,后端用私钥解密后再用 bcrypt 哈希存储:

// api/coder/internal/service/auth/loginservice.go
func (s *LoginService) decryptPassword(encryptedPassword string) (string, error) {
    if s.svcCtx.Encrypt == nil {
        return encryptedPassword, nil
    }
    return s.svcCtx.Encrypt.DecryptPassword(encryptedPassword)
}

// 验证密码
func verifyPassword(passwordHash, password string) bool {
    err := bcrypt.CompareHashAndPassword([]byte(passwordHash), []byte(password))
    return err == nil
}

3.1.3 登录锁定

防止暴力破解,连续失败 5 次锁定账户 30 分钟:

// pkg/loginlock/loginlock.go
type LoginLock struct {
    redis *redis.Redis
}

func (l *LoginLock) RecordFailedLogin(ctx context.Context, username string) error {
    key := fmt.Sprintf("login_fail:%s", username)
    count, _ := l.redis.Incr(key)
    if count == 1 {
        l.redis.Expire(key, 30*60) // 30分钟过期
    }
    if count >= 5 {
        lockKey := fmt.Sprintf("login_lock:%s", username)
        l.redis.Setex(lockKey, "1", 30*60)
    }
    return nil
}

3.2 题目管理系统

3.2.1 数据库设计

题目表的核心字段:

CREATE TABLE problems (
    id BIGINT PRIMARY KEY COMMENT '分布式ID',
    problem_no VARCHAR(20) UNIQUE COMMENT '题号如 P1001',
    title VARCHAR(200) NOT NULL COMMENT '题目标题',
    description TEXT COMMENT '题面描述(Markdown)',
    input_format TEXT COMMENT '输入格式说明',
    output_format TEXT COMMENT '输出格式说明',
    constraints TEXT COMMENT '数据范围与提示',
    difficulty TINYINT DEFAULT 1 COMMENT '1-简单 2-中等 3-困难',
    time_limit INT DEFAULT 1000 COMMENT '时间限制(ms)',
    memory_limit INT DEFAULT 128 COMMENT '内存限制(MB)',
    source VARCHAR(100) COMMENT '题目来源',
    is_public TINYINT DEFAULT 1 COMMENT '是否公开',
    status TINYINT DEFAULT 1 COMMENT '0-下架 1-上架',
    submit_count INT DEFAULT 0 COMMENT '提交次数',
    accepted_count INT DEFAULT 0 COMMENT '通过次数',
    created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
    updated_at DATETIME DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP
);

测试用例单独存储,支持多组测试:

CREATE TABLE problem_test_cases (
    id BIGINT PRIMARY KEY,
    problem_id BIGINT NOT NULL COMMENT '关联题目ID',
    input TEXT COMMENT '输入数据',
    output TEXT COMMENT '期望输出',
    score INT DEFAULT 0 COMMENT '该测试点分值',
    is_hidden TINYINT DEFAULT 0 COMMENT '是否隐藏测试点',
    sort_order INT DEFAULT 0 COMMENT '排序',
    INDEX idx_problem_id (problem_id)
);

3.2.2 题面编辑器

管理后台使用 md-editor-v3 组件,支持 Markdown 编辑和实时预览:

<template>
  <md-editor
    v-model="form.description"
    :toolbars="toolbars"
    :upload-images="handleUploadImages"
  />
</template>

图片上传走 MinIO 对象存储,题面中引用图片使用相对路径,前端渲染时拼接 CDN 域名。

3.3 评测系统(核心)

3.3.1 评测流程

用户提交代码
    │
    ▼
创建提交记录(status=pending)
    │
    ▼
调用 go-judge 编译
    │
    ├── 编译失败 → 返回 compile_error
    │
    ▼
逐个执行测试用例
    │
    ├── 任意测试点超时 → time_limit_exceeded
    ├── 任意测试点超内存 → memory_limit_exceeded
    ├── 任意测试点答案错误 → wrong_answer
    │
    ▼
所有测试点通过 → accepted

3.3.2 流式响应实现

评测过程可能持续数秒,采用 SSE(Server-Sent Events)实时推送进度:

// api/coder/internal/logic/submission/runcodestreamlogic.go
func (l *RunCodeStreamLogic) RunCodeStream(req *types.RunCodeReq, client chan<- *types.RunCodeResp) error {
    // 1. 参数校验
    if err := ValidateRequest(req); err != nil {
        SendError(l.ctx, client, err.Error())
        return nil
    }

    userID := GetUserID(l.ctx)
    if userID == 0 {
        SendError(l.ctx, client, "未登录或登录已过期")
        return nil
    }

    // 2. 判断是提交还是测试运行
    if req.IsSubmit {
        return l.handleSubmit(req, userID, client)
    }
    return l.handleRun(req, client)
}

// 处理代码提交
func (l *RunCodeStreamLogic) handleSubmit(req *types.RunCodeReq, userID int64, client chan<- *types.RunCodeResp) error {
    // 发送开始消息
    SendMessageWithStatus(l.ctx, client, "开始提交...", constants.StatusPending)
    
    // 获取题目信息
    problem, err := l.svcCtx.ProblemsModel.FindOne(l.ctx, problemID)
    if err != nil {
        SendError(l.ctx, client, "题目不存在")
        return nil
    }
    
    // 创建提交记录
    submissionID := l.svcCtx.IDGen.NextID()
    submission := &model.Submissions{
        Id:        submissionID,
        ProblemId: problemID,
        UserId:    userID,
        Code:      req.Code,
        Language:  req.Language,
        Status:    constants.StatusPending,
    }
    l.svcCtx.SubmissionsModel.InsertWithID(l.ctx, submissionID, submission)
    
    // 编译
    SendMessageWithStatus(l.ctx, client, "正在编译...", constants.StatusCompiling)
    compileResult := judge.Compile(req.Code, req.Language)
    if compileResult.Status != judge.StatusAccepted {
        l.updateSubmissionStatus(submissionID, string(judge.StatusCompileError), 0, 0, 0, compileResult.Error)
        SendFinalResult(l.ctx, client, judge.StatusCompileError, 0)
        return nil
    }
    
    // 执行测试用例
    testCases, _ := l.svcCtx.ProblemTestCasesModel.FindByProblemId(l.ctx, problemID)
    totalScore := 0
    maxMemory := int64(0)
    totalTime := int64(0)
    
    for i, tc := range testCases {
        SendMessageWithStatus(l.ctx, client, 
            fmt.Sprintf("正在运行测试点 %d/%d...", i+1, len(testCases)), 
            constants.StatusRunning)
        
        result := judge.Run(compileResult.Executable, tc.Input, problem.TimeLimit, problem.MemoryLimit)
        
        if result.Status != judge.StatusAccepted {
            l.updateSubmissionStatus(submissionID, string(result.Status), totalScore, totalTime, maxMemory, result.ErrorMessage)
            SendFinalResult(l.ctx, client, result.Status, totalScore)
            return nil
        }
        
        totalScore += tc.Score
        if result.Memory > maxMemory {
            maxMemory = result.Memory
        }
        totalTime += result.Runtime
    }
    
    // 全部通过
    l.updateSubmissionStatus(submissionID, string(judge.StatusAccepted), totalScore, totalTime, maxMemory, "")
    SendFinalResult(l.ctx, client, judge.StatusAccepted, totalScore)
    return nil
}

3.3.3 go-judge 调用封装

// pkg/judge/judge.go
type Client struct {
    endpoint string
    client   *http.Client
}

// Compile 编译代码
func (c *Client) Compile(ctx context.Context, code, language string) (*CompileResult, error) {
    langConfig, ok := Languages[language]
    if !ok {
        return nil, ErrLanguageNotSupported
    }
    
    req := &Request{
        Cmd: []Cmd{{
            Args: langConfig.CompileCmd,
            Env:  []string{"PATH=/usr/bin:/bin"},
            Files: []File{
                {Content: &code},
            },
            CPULimit:    DefaultCompileCPUTime.Nanoseconds(),
            MemoryLimit: DefaultCompileMemory,
            ProcLimit:   DefaultCompileProcLimit,
            CopyIn: map[string]File{
                langConfig.SourceFile: {Content: &code},
            },
            CopyOutCached: []string{"a.out"},
        }},
    }
    
    resp, err := c.sendRequest(ctx, req)
    if err != nil {
        return nil, err
    }
    
    result := resp[0]
    if result.Status != "Accepted" {
        return &CompileResult{
            Status: judge.StatusCompileError,
            Error:  result.Files.Stderr,
        }, nil
    }
    
    return &CompileResult{
        Status:     judge.StatusAccepted,
        FileID:     result.FileIds["a.out"],
        Executable: result.Files.Stdout,
    }, nil
}

// Run 执行代码
func (c *Client) Run(ctx context.Context, fileID, input string, timeLimit, memoryLimit int64) (*RunResult, error) {
    req := &Request{
        Cmd: []Cmd{{
            Args: []string{"a.out"},
            Env:  []string{"PATH=/usr/bin:/bin"},
            Files: []File{
                {Content: &input},
            },
            CPULimit:    timeLimit * 1e6, // 转换为纳秒
            MemoryLimit: memoryLimit << 20,
            ProcLimit:   DefaultRunProcLimit,
            CopyIn: map[string]File{
                "a.out": {FileId: fileID},
            },
            CopyOut: []string{"stdout", "stderr"},
        }},
    }
    
    resp, err := c.sendRequest(ctx, req)
    if err != nil {
        return nil, err
    }
    
    result := resp[0]
    status := SandboxStatusMap[result.Status]
    
    return &RunResult{
        Status:  status,
        Runtime: result.Time / 1e6, // 转换为毫秒
        Memory:  result.Memory,
        Output:  result.Files.Stdout,
        Stderr:  result.Files.Stderr,
    }, nil
}

3.3.4 语言配置

目前支持 C++11/14/17:

var Languages = map[string]LanguageConfig{
    "cpp11": {
        Name:       "C++11",
        FileExt:    ".cpp",
        SourceFile: "main.cpp",
        CompileCmd: []string{"/usr/bin/g++", "-std=c++11", "-O2", "-o", "a.out", "main.cpp"},
        RunCmd:     []string{"./a.out"},
    },
    "cpp14": {
        Name:       "C++14",
        FileExt:    ".cpp",
        SourceFile: "main.cpp",
        CompileCmd: []string{"/usr/bin/g++", "-std=c++14", "-O2", "-o", "a.out", "main.cpp"},
        RunCmd:     []string{"./a.out"},
    },
    "cpp17": {
        Name:       "C++17",
        FileExt:    ".cpp",
        SourceFile: "main.cpp",
        CompileCmd: []string{"/usr/bin/g++", "-std=c++17", "-O2", "-o", "a.out", "main.cpp"},
        RunCmd:     []string{"./a.out"},
    },
}

3.4 工程规范

3.4.1 代码生成工作流

项目使用 goctl 工具链实现代码生成:

# Makefile
api-coder:
	cd api/coder/desc && goctl api go -api coder.api -dir ../

db-model:
	goctl model mysql datasource \
		--url "$(DB_URL)" \
		--table "$(TABLE)" \
		--dir "./model" \
		--cache=false \
		--style gozero

开发流程:

  1. 修改 .api 文件定义接口
  2. 执行 make api-coder 生成 Handler、Logic、Types
  3. 在 Logic 层实现业务逻辑
  4. 需要新表时执行 make db-model TABLE=xxx

3.4.2 分层约束

三大禁令

  1. Logic 层禁止直接 SQL:所有数据库操作通过 Model 层方法
  2. 禁止 for 循环 SQL:批量查询使用 FindByIDs
  3. 多表变更必须事务:使用 TransactCtx
// ❌ 错误:Logic 层直接写 SQL
func (l *Logic) GetUserProblems(userID int64) ([]Problem, error) {
    rows, _ := l.svcCtx.DB.Query("SELECT * FROM problems WHERE user_id = ?", userID)
    // ...
}

// ✅ 正确:调用 Model 层
func (l *Logic) GetUserProblems(userID int64) ([]Problem, error) {
    return l.svcCtx.ProblemsModel.FindByUserID(l.ctx, userID)
}

// ❌ 错误:N+1 查询
for _, id := range problemIDs {
    p, _ := l.svcCtx.ProblemsModel.FindOne(l.ctx, id)
    problems = append(problems, p)
}

// ✅ 正确:批量查询
problems, _ := l.svcCtx.ProblemsModel.FindByIDs(l.ctx, problemIDs)

3.4.3 错误处理

统一错误码:

// pkg/errorx/errorx.go
const (
    CodeSuccess          = 0
    CodeInvalidParam     = 4000
    CodeUnauthorized     = 4010
    CodePermissionDeny   = 4030
    CodeResourceNotFound = 4040
    CodeInternalError    = 5000
    CodeCreateFailed     = 5001
    CodeUpdateFailed     = 5002
    CodeDeleteFailed     = 5003
)

type Error struct {
    Code    int    `json:"code"`
    Message string `json:"message"`
}

func NewError(code int, message string) error {
    return &Error{Code: code, Message: message}
}

3.4.4 分布式 ID

使用雪花算法生成 64 位 ID,对外暴露时使用 sqids 转换为短码:

// pkg/sqidx/sqidx.go
func EncodeInt64(id int64) (string, error) {
    s, _ := sqids.New(sqids.Options{
        Alphabet: "abcdefghijklmnopqrstuvwxyz0123456789",
    })
    return s.Encode([]uint64{uint64(id)})
}

func DecodeInt64(code string) (int64, error) {
    s, _ := sqids.New(sqids.Options{
        Alphabet: "abcdefghijklmnopqrstuvwxyz0123456789",
    })
    ids, _ := s.Decode(code)
    if len(ids) == 0 {
        return 0, errors.New("invalid code")
    }
    return int64(ids[0]), nil
}

这样数据库里存 123456789,API 返回 a7x9k2,更友好且防止遍历。

四、前端实现

4.1 技术栈

  • Vue 3:Composition API,逻辑复用更方便
  • TypeScript:类型安全,IDE 提示友好
  • Element Plus:组件库,快速搭建界面
  • Monaco Editor:VS Code 同款编辑器
  • Pinia:状态管理,比 Vuex 简洁
  • Tailwind CSS:原子化 CSS

4.2 代码编辑器

<!-- admin/src/components/CodeEditor/MonacoEditor.vue -->
<template>
  <div ref="editorContainer" class="monaco-editor-container"></div>
</template>

<script setup lang="ts">
import { ref, onMounted, onBeforeUnmount, watch } from 'vue'
import * as monaco from 'monaco-editor'

const props = defineProps<{
  modelValue: string
  language?: string
  theme?: string
}>()

const emit = defineEmits<['update:modelValue']>()

const editorContainer = ref<HTMLElement>()
let editor: monaco.editor.IStandaloneCodeEditor

onMounted(() => {
  editor = monaco.editor.create(editorContainer.value!, {
    value: props.modelValue,
    language: props.language || 'cpp',
    theme: props.theme || 'vs-dark',
    automaticLayout: true,
    minimap: { enabled: false },
    fontSize: 14,
    lineNumbers: 'on',
    roundedSelection: false,
    scrollBeyondLastLine: false,
    readOnly: false,
  })
  
  editor.onDidChangeModelContent(() => {
    emit('update:modelValue', editor.getValue())
  })
})

onBeforeUnmount(() => {
  editor?.dispose()
})
</script>

4.3 流式评测结果展示

<script setup lang="ts">
import { fetchEventSource } from '@microsoft/fetch-event-source'

const runCode = async () => {
  const ctrl = new AbortController()
  
  await fetchEventSource('/api/submissions/run/stream', {
    method: 'POST',
    headers: {
      'Content-Type': 'application/json',
      'Authorization': `Bearer ${token}`,
    },
    body: JSON.stringify({
      problem_id: problemId,
      code: code.value,
      language: language.value,
      is_submit: true,
    }),
    signal: ctrl.signal,
    onmessage(msg) {
      const data = JSON.parse(msg.data)
      status.value = data.status
      message.value = data.message
      
      if (data.status === 'accepted' || data.status === 'wrong_answer') {
        // 评测结束
        ctrl.abort()
      }
    },
  })
}
</script>

五、部署与运维

5.1 部署架构

服务器
├── Docker Compose
│   ├── coder-api        # Go API 服务
│   ├── coder-judge      # go-judge 沙箱
│   ├── mysql            # 数据库
│   ├── redis            # 缓存
│   └── minio            # 对象存储
├── Nginx
│   ├── 反向代理到 coder-api
│   └── 静态文件服务(前端)

5.2 Makefile 工具

# 部署命令
make deploy ENV=test          # 部署测试环境
make deploy ENV=prod          # 部署生产环境

# 开发命令
make api-coder                # 生成 API 代码
make db-model TABLE=problems  # 生成 Model 代码
make run-api                  # 本地运行 API
make run-f                    # 本地运行前端

# 运维命令
make status ENV=test          # 查看服务状态
make logs ENV=test            # 查看日志
make restart ENV=test         # 重启服务

5.3 配置管理

# api/coder/etc/coder-api.yaml
Name: coder-api
Host: 0.0.0.0
Port: 8002
Timeout: 30000
Mode: dev

Mysql:
  DataSource: root:xxx@tcp(127.0.0.1:3306)/coder?charset=utf8mb4&parseTime=true

Redis:
  Host: 127.0.0.1:6379
  Type: node
  Pass: "xxx"

Auth:
  AccessSecret: "xxx"
  AccessExpire: 7200
  RefreshSecret: "xxx"
  RefreshExpire: 604800

GoJudge:
  Endpoint: http://127.0.0.1:5050

MinIO:
  Endpoint: 127.0.0.1:9000
  AccessKeyID: xxx
  SecretAccessKey: xxx
  BucketName: coder

六、遇到的问题与解决方案

6.1 评测超时问题

现象:某些代码在本地运行正常,但在沙箱中超时。

原因:go-judge 的 CPU 时间限制和真实时间限制混淆。

解决:明确区分 cpuLimitrealLimit,评测时主要限制 CPU 时间。

CPULimit:  timeLimit * 1e6,      // CPU 时间(纳秒)
RealLimit: timeLimit * 3 * 1e6,  // 真实时间放宽 3 倍

6.2 内存泄漏

现象:评测服务运行一段时间后内存占用持续增长。

原因:go-judge 的缓存文件没有清理。

解决:定期调用 go-judge 的清理接口,或设置缓存过期时间。

6.3 数据库连接池耗尽

现象:高峰期出现 too many connections 错误。

解决:调整连接池参数:

// 在配置中设置
MaxOpenConns: 100
MaxIdleConns: 10
ConnMaxLifetime: 3600

6.4 前端编辑器加载慢

现象:Monaco Editor 首次加载需要 3-5 秒。

解决

  1. 使用 CDN 加载编辑器资源
  2. 开启 gzip 压缩
  3. 按需加载语言包,只加载 C++ 相关

七、总结

这个项目从需求分析到上线用了大约 3 个月时间。最大的收获是:

  1. 技术选型要务实:不要追求最新最酷的技术,go-judge 虽然不如自研沙箱灵活,但稳定可靠,节省了大量时间。

  2. 代码生成提效:goctl 的代码生成能力让 API 开发效率提升很多,特别是前后端联调时,接口定义一确定,代码就生成好了。

  3. 工程规范很重要:三大禁令虽然刚开始觉得约束多,但避免了后期很多坑,特别是 N+1 查询问题。

  4. 评测系统是核心:评测的稳定性和安全性直接决定产品可用性,这方面值得投入时间打磨。

目前系统已经支撑了几次校内竞赛和日常教学,累计提交代码超过 10 万次。下一步计划:

  • 接入 AI 辅助判题,对错误代码给出提示
  • 基于用户行为数据做个性化题目推荐
  • 支持更多编程语言(Python、Go)

如果你也在做类似系统,欢迎交流。

Search

    Table of Contents