微服务划分
根据实际情况,将系统划分为下面五个微服务模块:
| 服务名称 | 端口 | 角色/定位 | 核心职责 | 具体功能点 | 涉及数据库表 |
|---|---|---|---|---|---|
| oj-gateway | 10020 | 网关 | 流量入口、鉴权 | 1. 统一路由转发 2. 统一鉴权 3. 跨域配置 4. 限流熔断(拓展) |
|
| oj-user | 8004 |
用户服务 | C端用户管理 | 1. 用户注册/登录 (C端) 2. 个人信息/头像修改 3. 个人主页/做题数据统计 4. Rating 分数管理 |
tb_user |
| oj-problem | 8006 | 题目服务 | 题库与提交 | 1. 题目列表/检索 2. 题目详情 (C端做题/B端管理) 3. 标签管理 4. 代码提交记录 5. 题解管理 |
tb_problemtb_problem_tagtb_problem_tag_relationtb_test_casetb_solutiontb_submit_record |
| oj-contest | 8005 | 竞赛服务 | 竞赛运营 | 1. 比赛创建/管理 2. 比赛报名/鉴权 3. 比赛题目列表 4. 实时榜单计算 (Redis Rank) |
tb_contesttb_contest_problemtb_contest_registration |
| oj-judge | 8002 | 判题服务 | 消费者/沙箱 | 1. 监听 MQ 消息 (判题任务) 2. 代码编译与沙箱运行 3. 判题结果回写 (Callback) |
|
| oj-job | 8001 |
任务服务 | 定时调度 (XXL-Job) | 1. 竞赛状态流转 (自动开始/结束) 2. Rating 结算 (赛后统分) 3. 数据统计归档 (每日AC数统计) 4. 消息推送 (比赛开始前提醒) |
|
| oj-system | 8003 | 系统服务 | 后台管理 |
管理员账号管理 | tb_sys_user |
父工程管理 spring-boot-dependencies 依赖
在父工程的 pom.xml 中管理以下依赖:(定义版本规则)
<properties>
<spring-boot.version>3.0.1</spring-boot.version>
</properties>
<dependencyManagement>
<dependencies>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>**spring-boot-dependencies** </artifactId>
<version>${spring-boot.version}</version>
<type>pom</type>
<scope>import</scope>
</dependency>
</dependencies>
</dependencyManagement>关键点只有三个:
-
type = pom:说明它不是 jar -
scope = import:只用于版本管理 -
放在
<dependencyManagement>里
👉 它不会进 classpath
效果是:
-
Maven 记录了一张 "版本映射表"
-
子模块只要声明:
<dependency> <groupId>org.springframework.boot</groupId> <artifactId>spring-boot-starter-web</artifactId> </dependency> -
只要是 Spring Boot 官方 BOM 里列出来的,都可以不写版本!
BOM 是怎么 "生效" 的?(机制版)
-
Maven 解析父 POM
-
读取
<dependencyManagement> -
加载 BOM 的版本映射
-
子模块声明依赖
-
Maven 自动补齐版本
但是上述依赖只能管理 Spring Boot 官方的 BOM,如果要在微服务中方便管理注册中心等依赖的版本,还得多引入两个依赖,所以通常微服务项目中的父工程 pom.xml文件写法 如下所示:
<properties>
<spring-boot.version>3.0.1</spring-boot.version>
<spring-cloud.version>2022.0.0</spring-cloud.version>
<spring-cloud-alibaba.version>2022.0.0.0-RC2</spring-cloud-alibaba.version>
</properties>
<dependencyManagement>
<dependencies>
***<!-- SpringCloud Alibaba微服务依赖配置 -->** *
* *<dependency>
<groupId>com.alibaba.cloud</groupId>
<artifactId>spring-cloud-alibaba-dependencies</artifactId>
<version>${spring-cloud-alibaba.version}</version>
<type>pom</type>
<scope>import</scope>
</dependency>
***<!-- SpringCloud微服务依赖配置 -->** *
* *<dependency>
<groupId>org.springframework.cloud</groupId>
<artifactId>spring-cloud-dependencies</artifactId>
<version>${spring-cloud.version}</version>
<type>pom</type>
<scope>import</scope>
</dependency>
***<!-- SpringBoot依赖配置 -->** *
* *<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-dependencies</artifactId>
<version>${spring-boot.version}</version>
<type>pom</type>
<scope>import</scope>
</dependency>
</dependencies>
</dependencyManagement>雪花算法作为主键
为什么不使用自增id:
-
数据迁移和备份: 如果你需要将数据从一个数据库迁移到另一个数据库,或者备份和恢复数据,自增主键可能会导致问题。例如,如果你在新数据库中已经存在与旧数据库相同的自增ID,那么插入操作可能会失败。
-
删除和插入操作: 如果表中存在大量删除和插入操作,自增主键可能会导致ID值的不连续。这可能会浪费存储空间,并可能导致某些应用程序或系统逻辑出现问题。
-
性能问题: 在高并发的写入操作中,自增主键可能会导致性能瓶颈。因为每次插入新记录时,数据库都需要找到下一个可用的自增ID,这可能会增加写操作的延迟。
-
可预测性: 自增主键的值是可预测的,因为它们总是按照递增的顺序生成。这可能会带来安全风险,例如,攻击者可能会尝试预测未来的ID值来插入恶意数据。
-
分布式环境问题: 在分布式数据库系统中,自增主键可能会带来挑战。如何保证各个节点生成的自增id是唯一的,这将需要额外的机制来协调各个节点。
如何处理自增id问题:
-
UUlD (Universally Unique ldentifier,通用唯一识别码):是一种软件建构的标准,用于在分布式计算环境中为元素提供唯一的辨识信息。UUID 共占 128 位,分为五段,它具有唯一性、全局性、不变性等特点。
-
雪花算法 (Snowflake):雪花算法是一种分布式唯一 ID 生成算法,用于生成全局唯一的 ID。它的设计目标是在分布式系统中生成 ID,保证 ID 的唯一性、有序性和趋势递增。雪花算法的核心思想是将一个 64 位的 ID 分成多个部分,分别表示不同的信息。

如果使用了 mybatis-plus 的话,那么可以直接使用 IdType.``***ASSIGN_ID** ** *指定使用雪花算法。
@Data
@NoArgsConstructor
@AllArgsConstructor
@TableName("tb_system_user")
public class SystemUser extends BaseEntity {
@TableId(value = "user_id", **type = IdType.** ***ASSIGN_ID** *)
private Long userId; *// 主键ID,使用雪花算法*
* *private String userAccount;
private String password;
private String nickName;
}项目异常处理架构
项目的异常处理结构如下所示:
common-core
├── base
├── exception ← BaseException / BizException(公共异常)
├── result
└── utils
common-web
├── advice ← ExceptionAdvice
└── config
system
├── controller
├── service
├── entity
├── dto
├── vo
└── exception ← UserXxxException(业务异常)整个异常调用链如下所示:
业务模块中自定义异常类,继承BizException业务异常公共类,然后抛出异常,比如throw new UserNotFoundException()
↓
由 common-web 模块中的 ExceptionAdvice 捕获 BizException(多态特性拿到 UserNotFoundException)
↓
Result.fail(code, message)
↓
前端按 code 处理大致实现如下所示:
BaseException 抽象类继承于 RuntimeException,定义如下所示:
@Getter
public abstract class BaseException extends RuntimeException {
*/***
* * 业务错误码*
* */*
* *protected final int code;
protected BaseException(int code, String message) {
super(message);
this.code = code;
}
protected BaseException(ResultCode resultCode) {
super(resultCode.getMessage());
this.code = resultCode.getCode();
}
}然后 BizException 类是具体实现类,用于接收业务模块中的自定义异常,然后被 ExceptionAdvice 捕获并且处理。
public class BizException extends BaseException {
public BizException(ResultCode resultCode) {
super(resultCode);
}
public BizException(int code, String message) {
super(code, message);
}
}接着在 ExceptionAdvice 中捕获该具体实现类:
@RestControllerAdvice
public class ExceptionAdvice {
*/***
* * ==================== 业务异常 ====================*
* */*
* ***@ExceptionHandler(BizException.class)**
public Result<?> handleBizException(BizException e, HttpServletRequest request) {
String requestURI = request.getRequestURI();
*log*.error("请求地址'{}',发生业务异常:{}.", requestURI, e.getMessage(), e);
return Result.*fail*(e.getCode(), e.getMessage());
}
// ...
// 捕获非业务异常
}使用也很简单,在业务模块中定义自定义的异常类,然后继承 BizException 类即可,比如下面:
public class UserNotFoundException extends BizException {
public UserNotFoundException() {
super(ResultCode.DATA_NOT_FOUND);
}
}或需要上下文时:
public class UserDisabledException extends BizException {
public UserDisabledException(Long userId) {
super(
ResultCode.BIZ_ERROR.getCode(),
"用户已被禁用,userId=" + userId
);
}
}然后在 Service 层抛出异常,比如下面代码:
public SystemUser getById(Long userId) {
SystemUser user = userMapper.selectById(userId);
if (user == null) {
**throw new UserNotFoundException()** ;
}
return user;
}到此为止,业务模块抛出异常后就会自动被 ExceptionAdvice 捕获然后处理。
注意事项:需要对 ExceptionAdvice进行自动装配、依赖引入等操作才能生效 。
Swagger
Swagger 是一个接口文档生成工具,它可以帮助开发者自动生成接口文档。当项目的接口发生变更时,Swagger 可以实时更新文档,确保文档的准确性和时效性。Swagger 还内置了测试功能,开发者可以直接在文档中测试接口,无需编写额外的测试代码。
一、基本注解
| 注解 | 介绍 | 用于 | 常见属性 |
|---|---|---|---|
| @Tag | 接口分组说明(模块级) | 类(Controller) | name:模块名 description:模块说明 |
| @Operation | 单个接口说明 | 方法 | summary:接口标题 description:详细说明 requestBody:声明请求体,和下一个注解搭配使用 |
| @RequestBody(swagger) | 请求体说明(JSON) | 方法参数 | description:说明 required:必填 |
| @Content | 描述返回内容结构 | 方法 | mediaType:类型 schema:数据结构 |
| @ApiResponses | 接口返回结果集合 | 方法 | 包含多个 @ApiResponse |
| @ApiResponse | 单个返回结果说明 | 方法 | responseCode:状态码 description:说明 |
| @Parameter |
请求参数说明 | 方法参数 / 字段 | name:参数名 description:说明 required:是否必填 example:示例 |
| @Parameters | 多参数组合说明 | 方法 | 多个 @Parameter |
| @Schema | 数据模型 / 字段说明 | 类 / 字段 | description:说明 example:示例 required:必填 hidden:是否隐藏 |
| @Hidden | 从文档中隐藏接口或字段 | 类 / 方法 / 字段 | 无 |
| @SecurityRequirement | 接口安全要求说明 | 方法 / 类 | name:安全方案名 |
| @ArraySchema | 数组类型返回说明 | 方法 | schema:元素类型 |
| @ExampleObject | 示例数据 | 方法 / 参数 | name:示例名 value:示例内容 |
二、基本使用
首先在 common 模块下创建 common-swagger 子模块,然后引入依赖:
*<!-- swagger -->*
<dependency>
<groupId>org.springdoc</groupId>
<artifactId>springdoc-openapi-starter-webmvc-ui</artifactId>
<version>2.2.0</version>
</dependency>然后写配置文件:
package com.liren.common.swagger.config;
import io.swagger.v3.oas.models.OpenAPI;
import io.swagger.v3.oas.models.info.Info;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
@Configuration
public class SwaggerConfig {
@Bean
public OpenAPI openAPI() {
return new OpenAPI()
.info(new Info()
.title("在线 OJ 微服务系统")
.description("在线 OJ 微服务系统 API 文档")
.version("v1.0"));
}
}接着创建 META-INF/spring/org.springframework.boot.autoconfigure.AutoConfiguration.imports 文件,填入以下路径:
com.liren.common.swagger.config.SwaggerConfig然后在 modules 模块中导入 common-swagger 即可:
<dependency>
<groupId>com.liren</groupId>
<artifactId>common-swagger</artifactId>
<version>${common-swagger.version}</version>
</dependency>在需要编写文档的接口,比如 SystemUserController 中使用注解进行编写:
@RestController
@RequestMapping("/system/user")
**@Tag(name = "管理员用户API")**
public class SystemUserController {
@Autowired
private ISystemUserService systemUserService;
@PostMapping("/login")
@**Operation** (
summary = "用户登录",
description = "用户登录接口",
requestBody = @io.swagger.v3.oas.annotations.parameters.RequestBody(
required = true,
description = "登录信息",
content = @Content(
mediaType = "application/json",
schema = @Schema(implementation = LoginRequestDTO.class)
)
))
@**ApiResponses** (value = {
@ApiResponse(responseCode = "6003", description = "用户不存在"),
@ApiResponse(responseCode = "6005", description = "用户名或密码错误"),
})
public Result<LoginResponseVO> login(@Valid @RequestBody LoginRequestDTO loginDTO) {
return Result.*success*(systemUserService.login(loginDTO));
}
}在 LoginRequestDTO 中也可以细写内容:
@Data
@NoArgsConstructor
@AllArgsConstructor
**@Schema(description = "登录请求")**
public class LoginRequestDTO {
@NotBlank(message = "用户名不能为空")
**@Schema(description = "用户名")**
private String userAccount;
@NotBlank(message = "密码不能为空")
**@Schema(description = "密码")**
private String password;
}最后访问 http://localhost:8003/swagger-ui/index.html#
添加到 apifox 中用:http://localhost:8005/v3/api-docs
BCrypt加密工具
前面 [Hd40wUhgJiuneGklno9cu2yfnce] 中提到的 MD5 + 盐 的方式该摒弃了,了解即可!
后面生产环境一律选 BCrypt(或 Argon2 / PBKDF2)。
| 维度 | MD5 + 盐 | **BCrypt(Spring Security 默认方案) ** |
|---|---|---|
| 抗暴力破解 | ❌ 极差 | ✅ 强 |
| 抗彩虹表 | ⚠️ 只能靠盐 | ✅ 内建随机盐 |
| 计算成本 | ❌ 极低(GPU/ASIC 友好) | ✅ 可调、故意慢 |
| 算法状态 | ❌ 已淘汰 | ✅ 行业标准 |
| 合规/审计 | ❌ 不通过 | ✅ 可通过 |
使用 BCrypt 的标准写法:
public class BCryptUtil {**
** */*****
** * * 生成加密后的密文***
** * */***
** * *public static String encode(String content) {**
** **BCryptPasswordEncoder encoder = new BCryptPasswordEncoder()** ;**
** return encoder.**encode** (content);**
** }**
** **
** */*****
** * * 验证内容是否与密文匹配***
** * */***
** * *public static boolean isMatch(String content, String encoded) {**
** **BCryptPasswordEncoder encoder = new BCryptPasswordEncoder()** ;**
** return encoder.**matches** (content, encoded); // 使用内置的,不要直接equals()**
** }**
** }配置和封装Redis遇到的序列化问题
为什么用 RedisTemplate 而不是用 StringRedisTemplate❓❓❓
首先两者的区别:
| 特性 | StringRedisTemplate | RedisTemplate<String, Object> + JSON |
|---|---|---|
| Key 类型 | String | String |
| Value 类型 | String | Object(JSON) |
| 对象存储 | ❌ 需要手动序列化 | ✅ 自动序列化/反序列化 |
| 可读性 | 高,Redis-cli 直接可读 | 中等,JSON 可读,但对象结构复杂 |
| 泛型/集合支持 | ❌ 手动转换 | ⚠️ 需要 ObjectMapper 或 TypeReference |
| 场景 | 计数器、开关、状态 | 缓存 DTO/VO、微服务共享对象 |
在微服务中,缓存的值不再是简单字符串,而往往是完整的业务对象/DTO/VO/集合 。
示例:UserDTO(id, name, roles),微服务 A 写入缓存,微服务 B 读取缓存。如果用 StringRedisTemplate,就只能存 JSON 字符串:
stringRedisTemplate.opsForValue().set("user:1", JSON.toJSONString(userDTO));-
读取时必须自己写
JSON.parseObject(...) -
每个服务可能用不同的 JSON 库或配置 → 类型/序列化不一致
而对于对象操作,redisTemplate 不需要进行手动序列化即可拿到对象:
redisTemplate.opsForValue().set("user:1", userDTO);
UserDTO cachedUser = (UserDTO) redisTemplate.opsForValue().get("user:1");-
写入自动把对象转 JSON
-
读取自动恢复原对象
-
微服务无需每次自己手动序列化/反序列化(除非遇到泛型/集合的情况)
💡
工程经验法则
微服务统一缓存对象 →
RedisTemplate<String, Object>+Jackson简单字符串、状态标志 →
StringRedisTemplate千万不要用
<Object,Object>→ Key 不可控,运维噩梦
ThreadLocal vs TransmittableThreadLocal
本质差异:
-
ThreadLocal只在 **当前线程 ** 内生效 -
TransmittableThreadLocal (TTL)能把值 透传到线程池中执行的子任务线程
一、ThreadLocal 的定位与局限
工作机制
-
每个线程维护一个
ThreadLocalMap -
值只绑定在创建它的那个线程 上
在什么场景会失效
ThreadLocal<String> tl = new ThreadLocal<>();
tl.set("userA");
executor.submit(() -> {
System.out.println(tl.get()); // null
});原因很直接:
-
线程池里的线程 不是新创建的子线程
-
ThreadLocal不会跨线程传播
常见使用场景
-
Controller → Service → DAO 链路中的上下文
-
单线程请求上下文(如 Spring MVC 请求线程)
二、TransmittableThreadLocal(TTL)的核心价值
TransmittableThreadLocal 是 阿里巴巴开源组件 ,专门解决:
线程池 + ThreadLocal 上下文丢失问题
关键能力
-
在 任务提交时 捕获父线程中的 ThreadLocal
-
在 任务执行前 注入到执行线程
-
执行结束后 自动恢复现场(防止线程污染)
TransmittableThreadLocal<String> ttl = new TransmittableThreadLocal<>();
ttl.set("userA");
executor.submit(() -> {
System.out.println(ttl.get()); // userA
});本质一句话
TTL = ThreadLocal + 上下文快照 + 线程池感知
三、ThreadLocal vs TTL 对比表
| 维度 | ThreadLocal | TransmittableThreadLocal |
|---|---|---|
| 跨线程 | ❌ 不支持 | ✅ 支持 |
| 线程池 | ❌ 会丢失 | ✅ 可透传 |
| 实现复杂度 | JDK 原生 | 依赖三方库 |
| 线程污染防护 | 需要手动清理 | 内置恢复机制 |
| 性能 | 极低开销 | 有额外封装成本 |
四、使用 TTL 时必须注意的坑(实话实说)
-
必须配合包装或增强的线程池
-
原生
ExecutorService不会自动生效 -
推荐:
ExecutorService executor = TtlExecutors.getTtlExecutorService(rawExecutor); -
-
不是越多越好
-
高并发下存在上下文拷贝成本
-
只放 “轻量级上下文”
-
-
不要存大对象
- 会放大内存占用 + GC 压力
五、一句话选型建议
-
同步调用链 →
ThreadLocal -
线程池 / 异步 / 并发任务 →
TransmittableThreadLocal -
日志 traceId → 几乎必选 TTL(或 MDC + TTL)
📝 OJ 判题系统核心笔记
1. Docker 代码沙箱 (Code Sandbox)
核心定义:一个基于 Docker 容器技术的隔离执行环境 。它的作用是安全地运行用户提交的不可信代码,防止恶意代码(如死循环、挖矿、删除系统文件)破坏宿主机。
技术栈 :
-
Java +
docker-java(Docker Client API) -
Docker (镜像:
openjdk:8-alpine)
实现流程 (生命周期) :
-
拉取镜像,创建容器 :根据语言镜像(如 Java 8)创建一个全新的容器。
-
资源限制 (亮点) :通过
HostConfig限制容器内存 (100MB)、CPU (1核) 和 网络访问,防止资源耗尽攻击。 -
上传文件 :将用户代码 (
Main.java) 和输入用例 (input.txt) 通过 Tar 流上传到容器内部。 -
编译运行 :
-
在容器内执行
javac编译。 -
使用
java命令运行,并通过 输入重定向 (< input.txt) 注入数据。
-
-
收集结果 :捕获标准输出 (
stdout) 和错误输出 (stderr),以及运行耗时。 -
清理销毁 :无论运行成功与否,必须 销毁容器,释放资源。
2. Judge 判题模块 (核心业务)
核心定义:负责调度整个判题流程的消费者服务。它连接了题目管理(数据源)、消息队列(任务源)和代码沙箱(执行器)。
架构设计 :
-
模式 :生产者-消费者模型 (RabbitMQ)。
-
通信 :OpenFeign (调用 Problem 服务获取数据/回写结果)。
-
解耦 :引入 策略模式 (Strategy Pattern) 实现判题逻辑与执行逻辑分离。
判题全流程 :
-
监听消息 :从 RabbitMQ 的
oj.judge.queue获取submitId。 -
数据准备 :调用
RemoteProblemService获取提交的代码、语言以及题目测试用例 。 -
沙箱执行 :调用
JavaDockerCodeSandbox,传入代码和输入,获取沙箱运行状态 (Compile Error / Runtime Error / Normal)。 -
策略判题 (亮点) :
-
策略模式允许我们在不修改原有
JudgeReceiver代码的情况下,增加新的判题逻辑 。 -
根据语言或题目配置,由
JudgeManager选择对应的JudgeStrategy(如DefaultJudgeStrategy)。 -
策略类对比 沙箱输出 与 标准答案 。
-
将沙箱状态 (Normal/Error) 映射为业务结果 (AC/WA/CE/RE)。
-
-
结果回写 :将最终的
status(30-完成) 和judgeResult(AC/WA...) 更新回数据库。
3. 简历/面试 亮点话术
Q:为什么使用 Docker 做沙箱?
- A: 相比于 Java 原生的
SecurityManager,Docker 提供了操作系统级别的隔离(Namespace 和 Cgroups),能更彻底地限制 CPU、内存和文件系统权限,安全性更高,且支持多语言扩展。
Q:判题逻辑是怎么设计的?
-
A: 我使用了策略模式 (Strategy Pattern) 。因为不同语言(如 Java 和 C++)或不同题目(普通对比 vs 特殊判题 SPJ)的判题规则不同。
-
通过定义
JudgeStrategy接口,我可以灵活扩展新的判题规则,而不必修改核心的消费者逻辑,符合开闭原则 。

Q: 系统如何保证稳定性?
-
异步处理 :用户提交后直接返回 ID,判题在后台异步进行,削峰填谷。
-
超时控制 :沙箱运行设置了严格的超时时间,防止死循环卡死消费者。
-
资源隔离 :每个判题任务独占一个 Docker 容器,互不影响。
比赛业务全链路核心
1. 比赛状态管理
-
核心思想 :"时间即状态"
-
实现 :数据库存储
startTime和endTime -
逻辑 :service 层根据
LocalDateTime.now()动态计算状态(未开始/进行中/已结束),绝不依赖 数据库中可能滞后的status字段。
2. 题目关联
-
核心思想 :远程调用
oj-problem服务,实时拉取题目的标题和难度信息进行组装。 -
实现 :
oj-contest服务存储关联关系表tb_contest_problem(比赛ID、题目ID、展示号如 "A")。
3. 用户报名
-
规则 :只要比赛 未结束 且用户 未重复报名 ,即可写入报名表
tb_contest_registration。 -
设计 :支持 "赛前报名" 和 "赛中补票",灵活适应不同赛制。
4. 提交鉴权 —— 重难点
-
拦截位置 :
ProblemService的提交接口 (submitProblem)。 -
逻辑闭环 :
-
检测到请求中携带
contestId-> 暂停常规提交流程。 -
远程校验 :Feign 调用
ContestService检查 "比赛是否进行中" + "用户是否已报名" 。 -
安全判定 :利用 短路逻辑 (
!success || data==null || !data) 处理远程调用结果,完美规避自动拆箱导致的空指针异常 (NPE),确保只有验证通过才落库。
-

版本号解决同一用户不同端的鉴权等问题
多端登录下,如果 token 作为 Redis key,修改密码时无法定位并删除所有 token。
比如同一用户:
-
客户端 A → tokenA
-
客户端 B → tokenB
在 Redis 中:
-
tokenA -> userInfo
-
tokenB -> userInfo
在用户修改密码后:
-
你 无法知道 这个用户在 Redis 里还有多少 token
-
所以无法精确和快速删除,这是 token 作为 redis key 的天然缺陷
核心思路:让旧 token 自然失效,而不是主动删除它们 。
实际项目中通常通过在 JWT 中引入 token_version 版本号,然后在数据库中也维护 token_version 版本号,每次鉴权时候对比两者版本号是否一致。
在修改用户关键信息比如密码时递增数据库中的版本号,此时旧 JWT 中的版本号就和数据库中的版本号不同了,在校验阶段自然失效,从而实现全端强制下线。
恶意代码防护
| 防御层级 | 机制名称 | 具体手段 | 作用/效果 |
|---|---|---|---|
| 第一层:静态防线 | 黑名单正则检查 | 在代码运行前,针对 Java/C++/Python 分别匹配敏感关键词(如 exec、ProcessBuilder、socket、File 等)。 | 秒级拦截。防止明显的恶意操作启动,节省服务器资源。拦截后直接触发报警。 |
| 第二层:物理隔离 | Docker 硬隔离 | NetworkDisabled(true) (禁网)PidsLimit(64) (限制进程数)Memory/CPU Limit (限制内存/cpu) |
兜底保障。即使黑客绕过了正则(如代码混淆),代码在容器内也无法联网(防反弹Shell/挖矿)、无法炸机(防Fork炸弹)、无法越权。 |
| 第三层:业务惩罚 | 自动拉黑机制 | 判题服务检测到恶意代码 -> 调用用户服务 -> 修改用户状态为 0 (禁用) -> 强制用户下线 | 账号封禁。一旦检测到恶意提交,立即封禁账号,防止其换个代码继续攻击,增加了攻击者的成本。 |
| 第四层:准入控制 | 登录拦截 | 用户登录时校验 userStatus | 闭环处理。被封禁的用户即使持有旧 Token 或尝试重新登录,都会被系统拒绝,无法再访问 OJ 系统。 |
容器池
从 "即用即毁" 模式 -> "预热常驻+循环复用" 模式。
核心流程如下:
-
预热启动 ** (Pre-warming):**
-
在 Spring Boot 启动时 (
@PostConstruct),一次性创建固定数量(如 5 个)的 Docker 容器。 -
关键点 :启动命令设置为
tail -f /dev/null。这让容器像一台 "挂机" 的电脑一样保持运行状态,而不是执行完命令就退出。 -
将这些容器 ID 放入一个
BlockingQueue(阻塞队列)中管理。
-
-
借用容器 ** (Acquire):**
-
当判题请求到来时,不创建新容器,而是从队列中
take()一个现有的容器。 -
流量削峰 :如果队列为空(所有容器都在忙),请求会自动阻塞等待,这天然形成了一个限流保护机制,防止高并发把服务器内存撑爆。
-
-
环境重置 ** (Reset/Clean):**
-
这是复用模式最重要的一步。因为容器是 "脏" 的(可能有上一个人的代码),我们在使用前必须执行
rm -rf /app/*清理工作目录。 -
这保证了每个用户的代码运行环境是纯净的,互不干扰。
-
-
执行与归还 ** (Execute & Release):**
-
执行代码(上传 -> 编译 -> 运行)。
-
执行完毕后,将容器 ID
offer()回队列,供下一个请求使用。 -
容错机制 :如果执行过程中容器崩溃(如 OOM),则抛弃坏容器,重新创建一个新容器补入池中,维持池大小不变。
-
tips
-
项目中一个子模块要导入依赖前,要思考一下其它子模块是否也需要用到,是否可以将该依赖放到父工程中,让其它子模块也能一起用,而不是每个子模块都各自去引入同一个依赖。
- 例如:多个微服务之间的实体类可以抽出一个
BaseEntity,考虑放到一个common模块中后,在这些微服务的父工程引入common包依赖即可让多个微服务一起使用了。
- 例如:多个微服务之间的实体类可以抽出一个
-
操作 redis 时候,要关注并发问题。
-
比如典型的 "Check-Then-Act" (先检查后执行) 模式:
-
isMember(...):检查是否存在。 -
add(...):如果不存在则添加。 -
incrementScore(...):加分。
-
-
场景模拟 :假设用户在极短时间内对同一道题发起了两次请求(例如网络卡顿后重发,或者脚本并发刷分):
-
线程 A 执行
isMember,发现没 AC 过(返回 false)。 -
线程 B 同时执行
isMember,也发现没 AC 过(返回 false,因为 A 还没来得及写入)。 -
线程 A 执行
add和increment-> 分数 +1 。 -
线程 B 执行
add和increment-> 分数 +1 。 -
结果 :用户 AC 一道题,却涨了 2 分。
-
-
解决方案 :利用 Redis 的 原子性 。
-
Redis 的
SADD命令(对应opsForSet().add)会返回 成功添加的元素个数 。 -
如果集合中已经存在该元素,
SADD会返回 0。 -
我们可以直接调用
add,根据返回值判断是否是“第一次 AC”。这步操作是原子的,无需额外的锁。
-
-
ssh -L 2375:127.0.0.1:2375 liren@49.235.136.223创建隧道连接服务器 docker,更安全
idea
-
多线程
-
引入 redisson,完成以下内容:
