从文件到数据库:构建高可用在线OJ系统的后端架构演进实践

在当今的在线编程评测系统(Online Judge,简称OJ)开发中,后端架构的选型与设计直接决定了系统的性能、可扩展性和可维护性。许多开发者最初会从简单的文件存储入手,但随着用户量和题目数量的增长,数据库的引入成为必然。本文将深入探讨如何将一个基于文件存储的OJ系统,平滑演进为采用数据库驱动的现代化后端架构,并分享其中的关键技术实践与架构设计思想。

一、项目背景与架构演进目标

我们从一个功能完整的文件版在线OJ系统出发。该系统最初将所有题目信息、用户提交记录等数据以文本文件的形式存储在服务器本地。这种设计在项目初期简单高效,但随着业务发展,暴露出诸多问题:数据查询效率低下、难以支持复杂的条件筛选、并发读写存在风险、以及数据备份与迁移困难。

因此,本次架构演进的核心目标是:将数据持久化层从文件系统迁移到关系型数据库,同时保持上层业务逻辑(控制器Controller和视图View)的接口不变,实现高内聚、低耦合的模块化设计。这不仅是技术栈的升级,更是对系统可维护性和未来扩展性的一次重要投资。

在这里插入图片描述

上图展示了我们即将进行改造的完整网站界面。接下来,我们将从数据库环境搭建开始,逐步完成这次架构升级。

二、数据库环境配置与表结构设计

任何数据库驱动的应用,第一步都是搭建稳定可靠的数据存储环境。我们选择MySQL作为关系型数据库,首先需要创建一个专用的数据库用户和相应的数据库,并授予最小必要权限,这符合生产环境的安全最佳实践。

1. 创建专用数据库用户
为了避免使用root账户带来的安全风险,我们创建一个仅对`oj`数据库有操作权限的用户。

mysql> create user 'oj_client'@'%' identified by '123456';
Query OK, 0 rows affected (0.00 sec)

2. 创建数据库并授权
接着,创建名为`oj_model`的数据库,并将所有权限授予新创建的用户。

mysql> create database oj;
Query OK, 1 row affected (0.01 sec)
mysql> grant all on oj.* to 'oj_client'@'%';
Query OK, 0 rows affected (0.00 sec)

完成上述操作后,我们可以使用新用户登录MySQL客户端进行验证,确保配置正确。

在这里插入图片描述

3. 核心表结构设计
表结构设计是后端架构的基石。我们使用MySQL Workbench这一可视化工具进行设计,这比手写SQL更直观且易于维护。根据业务需求,我们主要设计存储题目的核心表。

设计依据主要来源于之前文件版中描述题目的数据结构,通常包括:题目ID、标题、难度、描述、预设的代码框架、测试用例等字段。

在这里插入图片描述在这里插入图片描述

基于这些分析,我们在Workbench中设计出`oj_questions`表的具体结构。

在这里插入图片描述

一个良好的表设计应考虑以下几点:

  • 选择合适的数据类型:如用`INT`存储ID,`TEXT`或`MEDIUMTEXT`存储题目描述和代码。
  • 设置主键与索引:为`id`字段设置自增主键,并在经常查询的字段(如`title`)上建立索引以提升性能。
  • 考虑字符集:为了支持全字符(如emoji),建议使用`utf8mb4`字符集。
[AFFILIATE_SLOT_1]

三、数据迁移与Model层重构

数据库和表准备就绪后,下一步是将原有文件中的数据迁移到数据库中,并重构系统的数据访问层(Model)。

1. 初始数据录入
为了方便后续开发和测试,我们先手动录入部分题目数据。在MySQL Workbench中,可以方便地通过“Form Editor”进行数据插入。

在这里插入图片描述在这里插入图片描述

插入完成后,执行SQL查询语句验证数据是否成功写入。

mysql> select * from oj_questions\G
*************************** 1. row ***************************
number: 1
title: 判断回文数
star: 简单
desc: 给你一个整数 x ,如果 x 是一个回文整数,返回 true ;否则,返回 false 。
回文数是指正序(从左向右)和倒序(从右向左)读都是一样的整数。
例如,121 是回文,而 123 不是。
示例 1:
输入:x = 121
输出:true
示例 2:
输入:x = -121
输出:false
解释:从左向右读,-121 。 从右向左读,121- 。因此它不是一个回文数。
示例 3:
输入:x = 10
输出:false
解释:从右向左读,01 。因此它不是一个回文数。
进阶:你能不将整数转为字符串来解决这个问题吗?
header: #include <iostream>
  #include <string>
    #include <vector>
      #include <map>
        #include <algorithm>
          using namespace std;
          class Solution
          {
          public:
          bool isPalindrome(int x)
          {
          return true;
          }
          };
          tail: #ifndef COMPILER_ONLINE
          #include "header.cpp"
          #endif
          void Test1()
          {
          //匿名对象调用方法
          bool ret=Solution().isPalindrome(121);
          if(ret)
          {
          std::cout<<"通过用例1"<<std::endl;
          }
          else
          {
          std::cout<<"未通过用例1:"<<121<<std::endl;
          }
          }
          void Test2()
          {
          bool ret=Solution().isPalindrome(-19);
          if(!ret)
          {
          std::cout<<"通过用例2"<<std::endl;
          }
          else
          {
          std::cout<<"未通过用例2:"<<-19<<std::endl;
          }
          }
          int main()
          {
          Test1();
          Test2();
          return 0;
          }
          cpu_limit: 1
          mem_limit: 30000
          1 row in set (0.00 sec)
mysql> select * from oj_questions where number=2\G
*************************** 1. row ***************************
number: 2
title: 求最大值
star: 简单
desc: 求最大值,比如:vector v ={1,2,3,4,5,6,12,3,4,-1};
求最大值, 比如:输出 12
header: #include <iostream>
  #include <vector>
    #include <algorithm>
      using namespace std;
      class Solution
      {
      public:
      int Max(const vector<int> &v)
        {
        //将你的代码写在下面
        return 0;
        }
        };
        tail: #ifndef COMPILER_ONLINE
        #include "header.cpp"
        #endif
        void Test1()
        {
        vector<int> v = {1, 2, 3, 4, 5, 6};
          int max = Solution().Max(v);
          if (max == 6)
          {
          std::cout << "Test 1 .... OK" << std::endl;
          }
          else
          {
          std::cout << "Test 1 .... Failed" << std::endl;
          }
          }
          void Test2()
          {
          vector<int> v = {-1, -2, -3, -4, -5, -6};
            int max = Solution().Max(v);
            if (max == -1)
            {
            std::cout << "Test 2 .... OK" << std::endl;
            }
            else
            {
            std::cout << "Test 2 .... Failed" << std::endl;
            }
            }
            int main()
            {
            Test1();
            Test2();
            return 0;
            }
            cpu_limit: 1
            mem_limit: 30000
            1 row in set (0.00 sec)

2. 基于MVC架构的Model层重构
我们的系统遵循经典的MVC(Model-View-Controller)架构。这种分层设计的最大优势在于解耦。由于View和Controller层是通过定义良好的接口与Model层交互,因此当我们需要更换数据源(从文件到数据库)时,只需修改Model层的内部实现,而无需改动任何业务逻辑代码。

MVC 是一种软件架构模式,核心是把系统分成 3 个部分:
Model(模型):负责数据和业务逻辑(比如代码的编译 / 运行、用户数据存储);
View(视图):负责展示界面(比如用户提交代码的网页、结果展示页面);
Controller(控制器):负责接收用户请求,调用 Model 处理,再把结果传给 View 展示。

首先,回顾文件版Model的核心成员函数:

class Model
{
private:
bool LoadAllQuestion(const std::string &question_list);
public:
Model();
bool GetAllQuestion(std::vector<Question> *out);
  bool GetOneQuestion(const std::string &number, Question *out);
  ~Model() {};
  private:
  std::unordered_map<std::string, Question> _questions;
    };

在数据库版本中,我们不再需要在启动时将所有题目加载到内存中。数据库本身提供了高效的按需查询能力。因此,我们可以移除构造函数、析构函数和加载全部题目的逻辑,最终得到一个更简洁、专注于数据库操作的Model结构。

const std::string oj_questions = "oj_questions";
class Model
{
public:
bool QueryMysql(const std::string &sql, std::vector<Question> *out)
  {
  }
  bool GetAllQuestion(std::vector<Question> *out)
    {
    std::string sql = "select * from ";
    sql += oj_questions;
    return QueryMysql(sql, out);
    }
    bool GetOneQuestion(const std::string &number, Question *out)
    {
    bool res = false;
    std::string sql = "select * from ";
    sql += oj_questions;
    sql += " where number=";
    sql += number;
    std::vector<Question> result;
      if (QueryMysql(sql, &result))
      {
      if (result.size() == 1)
      {
      *out = result[0];
      res = true;
      }
      }
      return res;
      }
      };

架构启示:良好的接口设计是应对变化的关键。将易变的“数据存取方式”封装在稳定的“业务接口”之后,是构建健壮后端架构的核心原则之一。

四、数据库连接与查询实现

在C/C++中连接MySQL,需要使用官方的`mysql.h`头文件及对应的连接库(如`libmysqlclient`)。你需要确保开发环境已正确安装这些依赖。

在这里插入图片描述

关键实现步骤:

  1. 初始化连接:使用`mysql_init`和`mysql_real_connect`建立与数据库的连接。
  2. 执行查询:通过`mysql_query`执行SQL语句。
  3. 处理结果集:使用`mysql_store_result`获取结果,并按照建表时字段的顺序,通过`mysql_fetch_row`逐行读取数据。
  4. 字符集设置:这是中文开发者常遇到的坑。务必在连接后立即设置连接字符集,与数据库表的字符集(如`utf8mb4`)保持一致,否则会出现乱码。
  5. 资源释放:及时释放结果集和关闭连接,防止内存泄漏。

以下是一个核心查询函数`QueryMysql`的实现示例,它完成了根据题目ID获取题目详情的功能:

const std::string oj_questions = "oj_questions";
const std::string host = "127.0.0.1";
const std::string user = "oj_client";
const std::string password = "123456";
const std::string db = "oj";
const int port = 3306;
bool QueryMysql(const std::string &sql, std::vector<Question> *out)
  {
  // 初始化句柄
  MYSQL *mysql = mysql_init(nullptr);
  // 连接数据库
  if (mysql_real_connect(mysql, host.c_str(), user.c_str(), password.c_str(), db.c_str(), port, nullptr, 0) == nullptr)
  {
  LOG(FATAL) << "连接数据库失败\n";
  return false;
  }
  // 设置编码格式,确保无误
  mysql_set_character_set(mysql, "utf8mb4");
  LOG(INFO) << "连接数据库成功!\n";
  // 执行sql语句
  if (mysql_query(mysql, sql.c_str()) != 0)
  {
  LOG(WARNING) << sql << " execute error!\n";
  return false;
  }
  // 提取结果
  MYSQL_RES *res = mysql_store_result(mysql);
  int rows = mysql_num_rows(res);
  Question q;
  for (int i = 0; i < rows; i++)
  {
  MYSQL_ROW line = mysql_fetch_row(res);
  q.number = line[0];
  q.name = line[1];
  q.star = line[2];
  q.desc = line[3];
  q.header = line[4];
  q.tail = line[5];
  q.cpu_limit = atoi(line[6]);
  q.mem_limit = atoi(line[7]);
  out->push_back(q);
  }
  // 释放结果
  mysql_free_result(res);
  // 关闭连接
  mysql_close(mysql);
  return true;
  }

⚠️ 注意事项:在实际生产环境中,必须对SQL语句进行严格的防注入处理,例如使用预处理语句(Prepared Statements),而不是直接拼接字符串。

最后,将项目中所有包含旧版(文件版)Model头文件的地方,替换为新的数据库版头文件,完成依赖关系的切换。

在这里插入图片描述

五、系统测试、部署与未来展望

完成所有代码修改后,进行全面的集成测试是确保系统稳定性的最后一道关卡。

1. 综合功能测试
编译并启动OJ服务和独立的编译判题服务。

在这里插入图片描述

在浏览器中访问网站,测试核心功能:题目列表展示、题目详情查看、代码提交与判题。

在这里插入图片描述

确认页面无乱码,数据显示正常。

在这里插入图片描述

提交代码后,判题结果能正确返回,整个流程畅通无阻。

2. 项目构建与发布自动化
为了提升团队协作和部署效率,我们编写一个顶层的Makefile,实现一键编译、打包生成发布版本的功能。这个Makefile可以管理多个子模块的编译顺序和依赖,并最终将可执行文件、配置文件、静态资源等打包到一个发布目录中。

.PHONY: all
all:
@cd compile_server;\
make;\
cd -;\
cd oj_server;\
make;\
cd -;
.PHONY:output
output:
@mkdir -p output/compile_server;\
mkdir -p output/oj_server;\
cp -rf compile_server/compile_server output/compile_server;\
cp -rf compile_server/temp output/compile_server;\
cp -rf oj_server/conf output/oj_server/;\
cp -rf oj_server/questions output/oj_server/;\
cp -rf oj_server/template output/oj_server/;\
cp -rf oj_server/wwwroot output/oj_server/;\
cp -rf oj_server/oj_server output/oj_server/;
.PHONY:clean
clean:
@cd compile_server;\
make clean;\
cd -;\
cd oj_server;\
make clean;\
cd -;\
rm -rf output;

3. 项目拓展方向与架构思考
将数据层迁移到数据库,为系统打开了更广阔的扩展空间。以下是一些可行的演进方向:

  • 用户系统与权限管理:实现用户注册、登录,并基于此构建题目提交、个人成绩统计、竞赛等功能。
  • 微服务化拆分:将庞大的单体应用拆分为独立的微服务,如用户服务、题目服务、判题引擎服务。判题引擎可以部署在Docker容器中,实现资源隔离与弹性伸缩。
  • 通信协议优化:将判题服务与主服务间的HTTP调用,改为性能更高、更内聚的远程过程调用(RPC),例如使用rest_rpc等轻量级框架。
  • 引入中间件:使用Redis缓存热点题目数据,使用消息队列(如RabbitMQ)解耦提交与判题流程,提升系统吞吐量和响应速度。
  • 功能完善:增加更丰富的测试用例(多组输入输出)、题目分类标签、讨论区(论坛)等。
[AFFILIATE_SLOT_2]

总结
从文件存储到数据库驱动的演进,是一个典型服务端架构升级案例。它不仅提升了系统的数据管理能力和查询性能,更重要的是通过遵循MVC、接口隔离等设计原则,使得系统核心业务逻辑与数据存取细节解耦,为未来的持续迭代和扩展奠定了坚实的基础。本次实践中涉及的数据库设计、C++连接MySQL、模块化重构等技能,是每一位后端开发者构建可维护、高性能系统的必备能力。完整的代码和数据库SQL文件已归档至项目仓库,可供参考与实践。

posted on 2026-02-28 17:42  blfbuaa  阅读(38)  评论(0)    收藏  举报