单体应用如何支撑千所机构独立测评?拆解 sg-exam 行级多租户隔离与动态组卷内核

单体应用如何支撑千所机构独立测评?拆解 sg-exam 行级多租户隔离与动态组卷内核

在现代在线教育、企业技能评测以及高校机考场景中,传统的"私有化部署"(每个学校或客户单独购买服务器部署一套独立系统与数据库)正面临巨大的运维成本挑战。如果一家在线教育 SaaS 平台服务于 1,000 所学校,为每一所学校维护独立的主机、数据库和升级流水线,其硬件开销与版本管理将演变为彻底的运维灾难。

然而,如果简单地将所有机构合并到同一套单体应用和公用数据库中,最致命的挑战便随之而来:如何确保 A 学校的期末统考试题和机密学生成绩,绝对不可能被 B 学校的师生在任何接口甚至 SQL 注入漏洞中越权窥探?

GitHub 与 Gitee 上广受好评的高星开源教学与考试管理平台 sg-exam 给出了极具代表性的工业级答案。该项目基于 Spring Boot + Vue 构建,全面覆盖了日常练习、防作弊考试、问卷调查与音视频富媒体交互。更为硬核的是,它在单体应用的基础上,优雅实现了一套无侵入的行级多租户隔离体系(Row-Level Tenant Isolation)与高并发动态组卷算法。

本文将深入拆解 sg-exam 的多租户内核设计,揭开它如何在不依赖昂贵微服务中间件的前提下,以行级隔离保障千所机构数据独立性与毫秒级防作弊阅卷的底层逻辑。


1. 多租户数据隔离的三种技术流派与权衡抉择

在软件工程中,实现多租户(Multi-Tenancy)主要有以下三种经典数据隔离方案:

隔离方案 物理表现 隔离性与合规性 硬件成本 运维复杂度 适用场景
独立数据库 (Database-per-Tenant) 每个租户拥有独立的物理/云数据库实例 最高(物理隔离) 极高(千家客户需上千实例) 极高(每次发版需跑千次 DDL) 银行、政府特权大客户
共享数据库,独立 Schema (Schema-per-Tenant) 同一数据库实例,每个租户独立建 Schema/Schema 空间 较高(逻辑逻辑隔离) 较高(Schema 膨胀上限约数百) 高(跨 Schema 汇总统计复杂) 中型高客单价 B2B 客户
共享数据库,共享 Schema (Row-Level Tenant ID) 所有租户共用表结构,通过 tenant_id 行级字段隔离 极高(需框架层绝对防御) 极低(单套 RDS 支撑千所机构) 极低(单次 SQL 脚本全量生效) SaaS 化高并发在线教育平台

在面对海量普通院校、职业培训机构和企业内训时,"共享数据库 + 共享 Schema 的行级字段隔离" 是性价比最高、扩展能力最强的路线。sg-exam 正是选择了这一主流路径:在数据库层,用户、题库、试卷、批改记录等几乎所有核心表均打上 tenant_id 物理印记;在框架层,通过 AST 抽象语法树动态拦截注入,将租户边界从业务代码中完全剥离。


2. 行级多租户隔离架构:MyBatis-Plus AST 重写与上下文穿透

如果在日常开发中,依赖业务开发工程师在每一个 SQL 查询中手动补齐 WHERE tenant_id = ?,只要出现一次粗心漏写,就会造成严重的数据泄露甚至监管处罚。

sg-exam 采用的架构解法是:利用请求拦截器将租户标识注入线程上下文,结合 ORM 层的 AST(抽象语法树)重写机制,在 SQL 发送至 MySQL 前实现自动化、强制性的行级注入。

flowchart TD Req(["HTTP 请求 (带 Header: tenant-id 或 Subdomain)"]) --> Filter["TenantContextFilter / HandlerInterceptor"] Filter --> ThreadCtx["TenantContextHolder.setTenantId(id) (ThreadLocal 绑定)"] ThreadCtx --> Service["业务层 (ExamService / QuestionService) 纯业务代码"] subgraph ORM 数据持久层 Service --> Mapper["QuestionMapper.selectList() 原始SQL无租户条件"] Mapper --> Interceptor["TenantLineInnerInterceptor (SQL AST 解析与重写)"] Interceptor -- "自动解析 AST" --> Rewrite["追加: AND tenant_id = :current_tenant_id"] Rewrite --> MySQL[("MySQL 物理数据库 执行安全过滤")] end MySQL --> Result["返回严格属于该租户的数据集"] Result --> Clean["Filter.afterCompletion() 彻底清理 ThreadLocal 避免线程复用泄漏"]

2.1 租户上下文生命周期管理

当客户端(Web 端、移动 H5、小程序)发起 API 请求时,网关或请求头中携带的 tenant-id 会在拦截器中第一时间捕获并绑定到当前线程:

java 复制代码
package host.skillup.exam.tenant;

import org.springframework.web.servlet.HandlerInterceptor;
import javax.servlet.http.HttpServletRequest;
import javax.servlet.http.HttpServletResponse;

public class TenantContextInterceptor implements HandlerInterceptor {

    @Override
    public boolean preHandle(HttpServletRequest request, HttpServletResponse response, Object handler) {
        // 优先从 Header 提取,支持子域名映射模式
        String tenantIdStr = request.getHeader("tenant-id");
        if (tenantIdStr != null && !tenantIdStr.trim().isEmpty()) {
            try {
                Long tenantId = Long.parseLong(tenantIdStr.trim());
                TenantContextHolder.setTenantId(tenantId);
            } catch (NumberFormatException e) {
                TenantContextHolder.clear();
            }
        }
        return true;
    }

    @Override
    public void afterCompletion(HttpServletRequest request, HttpServletResponse response, Object handler, Exception ex) {
        // 极度关键:Tomcat 线程池复用线程,必须在请求结束时显式清理,防止线程串线!
        TenantContextHolder.clear();
    }
}

上下文存储容器:

java 复制代码
package host.skillup.exam.tenant;

public class TenantContextHolder {

    private static final ThreadLocal<Long> TENANT_HOLDER = new ThreadLocal<>();

    public static void setTenantId(Long tenantId) {
        TENANT_HOLDER.set(tenantId);
    }

    public static Long getTenantId() {
        return TENANT_HOLDER.get();
    }

    public static void clear() {
        TENANT_HOLDER.remove();
    }
}

2.2 MyBatis-Plus TenantLineHandler 自动化 SQL 注入

在底层,Spring Boot 整合 MyBatis-Plus 的 TenantLineInnerInterceptor。当执行任意 CRUD 时,拦截器内部基于 JSqlParser 对 SQL 进行语法树重构:

java 复制代码
package host.skillup.exam.config;

import com.baomidou.mybatisplus.extension.plugins.MybatisPlusInterceptor;
import com.baomidou.mybatisplus.extension.plugins.handler.TenantLineHandler;
import com.baomidou.mybatisplus.extension.plugins.inner.TenantLineInnerInterceptor;
import host.skillup.exam.tenant.TenantContextHolder;
import net.sf.jsqlparser.expression.Expression;
import net.sf.jsqlparser.expression.LongValue;
import net.sf.jsqlparser.expression.NullValue;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;

import java.util.Arrays;
import java.util.List;

@Configuration
public class MybatisPlusTenantConfig {

    /** 平台级全局字典、系统元数据表,豁免多租户过滤 */
    private static final List<String> IGNORE_TENANT_TABLES = Arrays.asList(
        "sys_config", "sys_dict", "sys_menu", "tenant_info"
    );

    @Bean
    public MybatisPlusInterceptor mybatisPlusInterceptor() {
        MybatisPlusInterceptor interceptor = new MybatisPlusInterceptor();
        interceptor.addInnerInterceptor(new TenantLineInnerInterceptor(new TenantLineHandler() {

            @Override
            public Expression getTenantId() {
                Long tenantId = TenantContextHolder.getTenantId();
                if (tenantId == null) {
                    return new NullValue();
                }
                return new LongValue(tenantId);
            }

            @Override
            public String getTenantIdColumn() {
                return "tenant_id";
            }

            @Override
            public boolean ignoreTable(String tableName) {
                // 忽略非租户业务表,避免错误注入
                return IGNORE_TENANT_TABLES.stream().anyMatch(t -> t.equalsIgnoreCase(tableName));
            }
        }));
        return interceptor;
    }
}

通过这套机制,原本业务层书写的简易查询:

sql 复制代码
SELECT id, content, score FROM questions WHERE type = 'SINGLE'

在送往数据库前会被自动、安全地重写为:

sql 复制代码
SELECT id, content, score FROM questions WHERE type = 'SINGLE' AND tenant_id = 1002

即使业务代码发生任何失误,多租户隔离的红线在 SQL 驱动级就已经牢不可破。


3. 富媒体题库与"千人千卷"智能随机组卷内核

严肃机考场景与普通刷题最大的不同,在于必须防范考生之间的"抄袭交头接耳"。sg-exam 的核心设计之一正是千人千卷的动态加权组卷算法。

3.1 富媒体题库的数据建模

在现代多媒体教学中,题目不再只是纯文字。很多临床医学考试需要附带高清切片图、外语考试需要插入听力音频,而软件工程考试则包含高清交互视频。

sg-exam 采用"多态富媒体内容抽象 + 灵活结构化选项"的设计:

sql 复制代码
CREATE TABLE questions (
    id BIGINT PRIMARY KEY AUTO_INCREMENT,
    tenant_id BIGINT NOT NULL COMMENT '租户单位ID',
    subject_id BIGINT NOT NULL COMMENT '所属课程学科',
    type VARCHAR(32) NOT NULL COMMENT '题型: SINGLE, MULTI, JUDGE, ESSAY, MEDIA',
    title TEXT NOT NULL COMMENT '题干富文本 (支持 HTML/Markdown/LaTeX公式)',
    media_url VARCHAR(512) DEFAULT NULL COMMENT '富媒体资源外链 (音视频/3D模型)',
    options_json JSON NOT NULL COMMENT '结构化选项数组 [{"key":"A","text":"..."},{"key":"B","text":"..."}]',
    answer VARCHAR(256) NOT NULL COMMENT '标准参考答案',
    analysis TEXT DEFAULT NULL COMMENT '考点深度试题解析',
    difficulty INT DEFAULT 1 COMMENT '难度评级 (1~5)',
    score INT DEFAULT 5 COMMENT '默认单题分值',
    INDEX idx_tenant_subject (tenant_id, subject_id)
);

3.2 动态配额组卷的算法实现

在组织一场期末或资格考时,教务处往往制定了严苛的组卷蓝图(Exam Blueprint):

  • 单选题 10 道(简单 6 道、中等 3 道、困难 1 道)
  • 判断题 5 道(简单 3 道、中等 2 道)
  • 简答题 2 道

如果在数据库层直接执行 ORDER BY RAND() LIMIT N,在大数据量下会引发全表 Filesort,瞬时打爆 MySQL CPU。sg-exam 在应用层采用分层分桶加权采样算法(Stratified Bucket Sampling):

java 复制代码
package host.skillup.exam.engine;

import host.skillup.exam.model.QuestionDO;
import host.skillup.exam.model.ExamPaperVO;
import org.springframework.stereotype.Service;

import java.util.*;
import java.util.stream.Collectors;

@Service
public class DynamicExamPaperEngine {

    /**
     * 依据分层蓝图配额,从租户题库中安全、无偏向地抽取考生专属试卷
     */
    public ExamPaperVO generatePersonalizedPaper(Long tenantId, Long examId, Map<String, Integer> typeQuota) {
        // 1. 查询当前租户在当前考试大纲下的题库候选池 (受 TenantLineInnerInterceptor 保护)
        List<QuestionDO> pool = loadCandidateQuestions(tenantId, examId);

        // 2. 按题型分桶 (Bucketing)
        Map<String, List<QuestionDO>> bucketByType = pool.stream()
            .collect(Collectors.groupingBy(QuestionDO::getType));

        List<QuestionDO> selectedQuestions = new ArrayList<>();
        Random random = new Random();

        // 3. 逐个配额桶进行无放回随机抽样
        for (Map.Entry<String, Integer> quota : typeQuota.entrySet()) {
            String qType = quota.getKey();
            int requiredCount = quota.getValue();

            List<QuestionDO> candidates = bucketByType.getOrDefault(qType, Collections.emptyList());
            if (candidates.size() < requiredCount) {
                throw new IllegalStateException(String.format("租户 [%d] 题库题型 [%s] 容量不足,需 %d 题,现有 %d 题", 
                    tenantId, qType, requiredCount, candidates.size()));
            }

            // 洗牌算法无放回抽样
            Collections.shuffle(candidates, random);
            selectedQuestions.addAll(candidates.subList(0, requiredCount));
        }

        // 4. 打乱最终试卷中的题序,彻底阻断相邻考生对题
        Collections.shuffle(selectedQuestions, random);

        ExamPaperVO paper = new ExamPaperVO();
        paper.setTenantId(tenantId);
        paper.setExamId(examId);
        paper.setQuestions(selectedQuestions);
        paper.setTotalScore(selectedQuestions.stream().mapToInt(QuestionDO::getScore).sum());
        return paper;
    }

    private List<QuestionDO> loadCandidateQuestions(Long tenantId, Long examId) {
        // 模拟从持久层拉取数据
        return Collections.emptyList();
    }
}

4. 防作弊与自动化判分状态机

在全流程在线化考试中,防止考生通过浏览器快捷键切屏去搜索引擎找答案,是保障公平性的命门。

sg-exam 形成了一套前后端联动的防作弊状态机:

  1. 前端页面 Visibility API 监听 : 通过 document.addEventListener('visibilitychange', ...) 和 window.onblur 实时捕捉学生离开考场的事件;
  2. 防作弊心跳上报 : 每次切屏即刻向后台发送一次防作弊事件上报(report-cheat-event),后台自增该考生的 tab_switch_count;
  3. 熔断与强制收卷 : 当切屏次数达到设定阈值(例如 3 次),系统强制弹出警告;达到极限次数(如 5 次),前端立即禁用答题区,后台自动置起 cheat_flag = 1 并强制归档收卷,客观题直接判 0 分。
stateDiagram-v2 [*] --> 考场候考: 身份认证 & 人脸核身 考场候考 --> 答题中: 开始答题 (生成个人试卷) state 答题中 { [*] --> 正常作答 正常作答 --> 切屏警告: 触发 visibilitychange (计数 +1) 切屏警告 --> 正常作答: 计数 < 3 确认继续 切屏警告 --> 强制熔断: 计数 >= 阈值 (作弊封禁) } 答题中 --> 正常提交: 考生主动交卷 / 倒计时结束 强制熔断 --> 成绩置零: cheat_flag=1, 成绩废弃 正常提交 --> 客观题秒批: 对比 standard_answer 计算得分 客观题秒批 --> 包含主观题: 流转至教师人工阅卷池 客观题秒批 --> 纯客观题: 成绩实时发布归档

5. 多租户实战内核本地运行验证

为了让开发者能够亲眼见证行级多租户隔离、动态组卷与切屏防作弊判分的完整闭环,本文配套开发了一套自包含的独立运行引擎(含 SQLite 真实表、跨租户越权探测用例与防作弊熔断测试)。

执行测试命令:

bash 复制代码
python3 practice/multi_tenant_exam_engine.py

终端执行日志实录:

text 复制代码
======================================================================
🚀 开始执行 sg-exam 行级多租户隔离与动态组卷内核真实验证
======================================================================
[DB] sg-exam 多租户实战数据库初始化完成(租户表、题库表、试卷表就绪)。

[Test 1] 租户 1 (THU_CS) 题库查询隔离验证...
  -> Test 1 通过!租户 1 成功拉取 6 道自有题目,无跨租户泄漏。

[Test 2] 租户 2 (PKU_LAW) 题库查询隔离验证...
  -> Test 2 通过!租户 2 成功拉取 4 道自有题目,完全隔离。

[Test 3] 租户 1 动态配额智能组卷 (2 单选 + 1 判断)...
  -> Test 3 通过!成功抽取 3 题,总分 30 分,全部来自本租户。

[Test 4] 跨租户越权访问试卷渗透测试 (Tenant 1 -> Exam 201)...
  -> Test 4 通过!成功拦截越权违规: 跨租户越权拦截!当前租户 1 无权访问租户 2 的试卷 201

[Test 5] 正常答题提交与客观题自动批改...
  -> Test 5 通过!得分: 60, 结果: 考试合格

[Test 6] 异常切屏防作弊熔断测试 (切屏 4 次)...
  -> Test 6 通过!检测到作弊并熔断置零: 作弊违规 (切屏超限),成绩判定 0 分

[Audit] 验证数据库落地表: 沉淀 2 条考试记录,成功标记 1 次防作弊事件。

======================================================================
🎉 全部多租户隔离与组卷测试用例通过: 6/6 (100% SUCCESS)
======================================================================

6. 避坑指南:多租户生产落地四大隐蔽陷阱

在将该架构推向生产环境时,必须时刻警惕以下四个高危陷阱:

  1. Redis 缓存跨租户串门(Cache Tenant Bleed) : 在单租户系统中,试卷缓存 Key 通常写为 exam:paper:{exam_id}。在多租户系统中,如果依然沿用此命名,当不同租户创建了相同自增 ID 的试卷时,直接发生缓存覆盖。铁律:所有 Redis 缓存键必须严格加上租户前缀,格式形如 tenant:{tenant_id}:exam:paper:{exam_id}。

  2. 异步线程池与 ThreadLocal 上下文断裂 : 在 Spring Boot 中使用 @Async 或 CompletableFuture 调度试卷判分或生成统计报表时,主线程的 ThreadLocal 不会自动传递给工作线程。由于缺少 tenant_id,异步任务在查询数据库时会抛出异常或误查全表。必须为线程池配置 TaskDecorator,在任务提交时深拷贝租户上下文并在执行完毕后显式清理。

  3. 批量数据导入与外键越权穿透 : 教师在后台 Excel 批量导入题目时,如果未强制核验所属分类、所属试卷的 tenant_id,恶意攻击者可能通过篡改下拉框 ID,将题目挂靠到其他学校的试卷下。所有导入与关联操作必须进行复合双字段比对(WHERE id = ? AND tenant_id = ?)。

  4. 定时任务(@Scheduled)的租户空转 : 后台自动收卷或过期试卷清理定时任务由系统内核线程触发,没有前端 HTTP 请求注入上下文。定时任务必须显式使用租户游标(Cursor),按租户列表轮询建立独立的租户作用域,严禁跨租户一次性批量更新。


7. 结语

从千所机构的独立运营,到动态试卷的高并发实时抽取,sg-exam 的架构实践向我们展示了:优秀的企业级系统设计并不在于盲目引入微服务和复杂的分布式中间件,而在于用最扎实的语言特性与框架机制(如 AST SQL 拦截、ThreadLocal 安全上下文、分层无偏抽样算法),在单体系统上打造出坚不可摧的业务多租户护城河。

如果你也正准备搭建属于团队或学校的企业级考核与培训平台,不妨深入品味这套行级多租户设计,让你的系统在兼顾安全合规的同时,展现出极致的运行效能!


📂 开源实操代码与云端演练:


本文首发于边学边练技术中台:www.skillup.host/bxbl/6602.h...

相关推荐
Wynnhao1 小时前
交付时说“测过了”不够:我做了个可复现的验收证据包,已开源
开源
TTATTC1 小时前
每加一个登录端都要改 Controller?Spring Boot 泛型策略工厂与模板切面演进
docker·开源
程序员老赵1 小时前
Docker 部署 MaxKB:轻松搭建企业级智能体平台
前端·docker·agent
万联WANFLOW1 小时前
LiteLLM 1.107.0 投毒排查实战:依赖、Docker、CI 环境分别怎么查?
安全·docker·llm
冬奇Lab2 小时前
开源项目第243期:ArtCraft —— 给艺术家的 AI 创作 IDE,先搭场景再生成,把「抽卡」变成「手艺」
人工智能·开源
miofly2 小时前
Gemini 4 Argon发布,机器人ERQA推理得分87%但缺可比基准
开源·github
摩尔线程3 小时前
摩尔线程开源四大通信库 构建完整MUSA通信栈
开源·摩尔线程
Khalil三省3 小时前
MacBook 刘海显示歌词:让 Mac 也有灵动岛歌词
macos·开源·效率工具·音乐