1、许苑–OJ判题系统
技术栈:Spring Boot+Spring Cloud Alibaba+Redis+Mybatis+MQ+Docker
项目地址: https://github.com/xuyuan-upward/xyoj-backend-microservice
1.1、项目介绍:
一个基于微服务的OJ系统,具备能够根据管理员预设的题目用例对用户提交的代码进行执行和评测的能力。此外,还自主实现了代码沙箱,可作为独立服务供其他开发者调用。
1.2、主要工作:
1、为⽀持多种代码沙箱的创建,使⽤静态⼯⼚模式实现对代码沙箱调⽤的扩展,提高了系统可扩展性和可维护性。
1.1、通过静态工厂模式实现了远程代码沙箱或者本地代码沙箱调用
2、采用了策略模式封装判题逻辑,以解决不同判题模式的差异,提高系统的灵活性。
2.1、根据获取到的代码沙箱返回的配置信息以及输出结果进行使用Java策略算法判断还是其他语言判断代码的正确性
2.2、根据不同语言选择算判题策略
3、 使⽤ Java Runtime类的exec⽅法编译和执⾏Java代码,通过Process类获取结果。
3.1、Runtime类进行命令的创建以及命令执行:
3.2、Process获取结果 :
4、为确保宿主机安全,利⽤Docker Java库创建隔离的容器环境执行代码。
4.1、引入docker库依赖
<dependency><groupId>com.github.docker-java</groupId><artifactId>docker-java</artifactId><version>3.3.0</version></dependency>
4.2、获取Docker客户端实例
DockerClient dockerClient = DockerClientBuilder.getInstance("unix:///var/run/docker.sock").build();
通过DockerClientBuilder
创建一个Docker客户端实例,用于与Docker守护进程通信。
unix:///var/run/docker.sock
是Docker守护进程的Unix套接字路径。
4.3、拉取Docker镜像
if (!isPullImag) {PullImageCmd pullImageCmd = dockerClient.pullImageCmd(Image);PullImageResultCallback pullImageResultCallback = new PullImageResultCallback(){@Overridepublic void onNext(PullResponseItem item) {System.out.println("下载镜像" + item.getStatus());super.onNext(item);}};pullImageCmd.exec(pullImageResultCallback).awaitCompletion();isPullImag = true;
}
如果镜像尚未拉取,则通过pullImageCmd
拉取指定的Docker镜像。
PullImageResultCallback
用于监听镜像拉取的进度和状态。
awaitCompletion()
确保镜像拉取完成后才继续执行后续代码。
4.4、创建并启动Docker容器
CreateContainerCmd containerCmd = dockerClient.createContainerCmd(Image);
HostConfig hostConfig = new HostConfig();
hostConfig.withMemory(150 * 1000 * 1000L); // 限制内存为150MB
hostConfig.withMemorySwap(0L); // 禁用交换内存
hostConfig.withCpuCount(1L); // 限制CPU核数为1
hostConfig.setBinds(new Bind(userCodeParentPath, new Volume("/app"))); // 绑定主机目录到容器
CreateContainerResponse createContainerResponse = containerCmd.withHostConfig(hostConfig).withNetworkDisabled(true) // 禁用网络.withReadonlyRootfs(true) // 只读文件系统.withAttachStdin(true) // 绑定标准输入.withAttachStderr(true) // 绑定标准错误.withAttachStdout(true) // 绑定标准输出.withTty(true) // 启用TTY.exec();
String containerId = createContainerResponse.getId();
dockerClient.startContainerCmd(containerId).exec();
创建容器时,配置了资源限制(内存、CPU)和安全性(禁用网络、只读文件系统)。
setBinds
将主机目录绑定到容器内的/app目录,用于存放用户代码。
containerCmd.exec()
创建 Docker 容器,并通过createContainerResponse获取容器ID,然后通过dockerClient.startContainerCmd(containerId).exec();
启动容器。
4.5、在容器中执行用户代码
String[] cmdArray = ArrayUtil.append(new String[]{"java", "-cp", "/app", "Main"}, inputArgsArray);
ExecCreateCmdResponse execCreateCmdResponse = dockerClient.execCreateCmd(containerId).withCmd(cmdArray) // 设置执行的命令.withAttachStderr(true) // 绑定标准错误.withAttachStdin(true) // 绑定标准输入.withAttachStdout(true) // 绑定标准输出.exec();
构造执行命令,例如java -cp /app Main <inputArgs>
。
通过execCreateCmd在容器中创建执行命令,并获取命令的ID。
4.6、 捕获执行结果
ExecStartResultCallback execStartResultCallback = new ExecStartResultCallback() {@Overridepublic void onNext(Frame frame) {StreamType streamType = frame.getStreamType();if (StreamType.STDERR.equals(streamType)) {errorMessage[0] = new String(frame.getPayload()); // 捕获错误输出} else {message[0] = new String(frame.getPayload()); // 捕获标准输出}}
};
dockerClient.execStartCmd(execId).exec(execStartResultCallback).awaitCompletion(TIME_OUT, TimeUnit.SECONDS);
通过ExecStartResultCallback
监听命令执行的输出:
- 如果是 STDERR,表示发生错误,捕获错误信息。
- 如果是 STDOUT,捕获程序正常输出。
4.7、监控内存使用
StatsCmd statsCmd = dockerClient.statsCmd(containerId);
ResultCallback<Statistics> statisticsResultCallback = statsCmd.exec(new ResultCallback<Statistics>() {@Overridepublic void onNext(Statistics statistics) {maxMemory[0] = Math.max(statistics.getMemoryStats().getUsage(), maxMemory[0]); // 获取内存使用峰值}
});
通过statsCmd
监控容器的内存使用情况,记录内存使用的峰值。
4.8、删除容器
dockerClient.stopContainerCmd(containerId).exec();
dockerClient.removeContainerCmd(containerId).exec();
执行完成后,停止并删除容器,释放资源。
5、为减少判题与服务模块之间的耦合,通过使⽤Rabbitmq技术进⾏解耦。
5.1、原因
原因:由于判题操作是一个比较重的服务(需要调用代码沙箱)然后判题服务监听到该队列的消息并进行判题处理,并且异步更改题目的判题状态。改造后的业务流程:用户提交题目时,由题目服务发送一条消息到队列
这样做的好处!
- 对用户来说:不需要在前端同步等待,优化了体验。
- 对系统来说:解耦了题目服务和判题服务,两者不需要相互调用。
使判题服务繁忙或宕机,题目服务依然可以发送判题任务到队列,等判题服务恢复后继续处理
5.2、过程
1、首先进行Rabbitmq初始化交换机与队列根据路由键进行绑定,并创建成Spring的一个 bean 对象
2、在提交题目那里调用生产者进行发送消息
生产者发送消息:
进行消费者监听对应的队列,然后调用判题方法:
6、为保护服务同时简化客户端调用,项目通过Spring Cloud Gateway聚合路由服务。
在第7点下面的gateway
7、微服务体现,以及nacos配置中心。
7.1、引入对应的SpringBoot SpringCloud SpringCloud Alibaba版本依赖管理
<dependencyManagement><dependencies><dependency><groupId>org.springframework.cloud</groupId><artifactId>spring-cloud-dependencies</artifactId><version>2021.0.5</version><type>pom</type><scope>import</scope></dependency><dependency><groupId>org.springframework.boot</groupId><artifactId>spring-boot-dependencies</artifactId><version>${spring-boot.version}</version><type>pom</type><scope>import</scope></dependency><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></dependencies></dependencyManagement>
7.2、引入nacos依赖
<!--nacos 配置和注册管理--><dependency><groupId>com.alibaba.cloud</groupId><artifactId>spring-cloud-starter-alibaba-nacos-discovery</artifactId></dependency>
7.3、进行引入SpringCloud的gateway依赖,以及微服务下knife4j的聚合
<dependency><groupId>org.springframework.cloud</groupId><artifactId>spring-cloud-starter-gateway</artifactId></dependency><!--用来实现knife4j文档聚合--><dependency><groupId>com.github.xiaoymin</groupId><artifactId>knife4j-gateway-spring-boot-starter</artifactId><version>4.3.0</version></dependency>
7.4、路由配置规则的设定
2、许苑刷题阁
技术栈:Spring Boot+ElasticSearch+Mybatis+Nacos+Hotkey+SaToken+Sentinel
项目地址: https://github.com/xuyuan-upward/mianshiyuan
2.1、项目介绍:
一个在线刷题平台,平台⽀持管理员创建题库、批量管理题目,用户可以通过高效
的搜索引擎进⾏题目检索,在线做题。项目核⼼围绕性能优化、数据一致性和高并发场景进⾏
设计, 确保用户的刷题体验流畅且稳定。
2.2、主要工作:
1、为实现用户刷题记录功能,基于Redis BitMap+Redisson实现用户年度刷题记录的统
计,相⽐数据库存储节约⼏百倍空间。
1.1、使用Bitmap 位图,是一种使用位(bit)来表示数据的 紧凑 数据结构。每个位可以存储两个值:0 或 1,常用于表示某种状态或标志.。
优点:
1.节约内存空间:因为每个位仅占用1位内存,特别在大规模存储二值数据(如布尔值)时,节约效果明显。
2.查询效率高:通过位运算(如与、或、非等),可以快速判断某个元素是否存在。这使得查找操作非常高效,时间复杂度为 O(1)。
代码(签到):
// 获取 Redis 的 BitMap// RBisSet是Redisson库中的一种数据类型,它对应Redis中的位图RBitSet signInBitSet = redissonClient.getBitSet(key);// 获取当前日期是一年中的第几天,作为偏移量(从 1 开始计数)int offset = date.getDayOfYear();// 查询当天有没有签到if (!signInBitSet.get(offset)) {// 如果当前未签到,则设置signInBitSet.set(offset, true);}
代码(获取某年某个用户的签到信息):
// 获取 Redis 的 BitSetRBitSet signInBitSet = redissonClient.getBitSet(key);// 加载 BitSet 到Java内存中,避免后续读取时发送多次请求BitSet bitSet = signInBitSet.asBitSet();// 统计签到的日期List<Integer> dayList = new ArrayList<>();// 从索引 0 开始查找下一个被设置为 1 的位int index = bitSet.nextSetBit(0);while (index >= 0) {dayList.add(index);// 继续查找下一个被设置为 1 的位index = bitSet.nextSetBit(index + 1);}
2、为提高题目搜索性能,采⽤Elasticsearch替代MySQL进⾏模糊查询,并通过定时任务,实现增量同步,保持数据一致性。
2.1、引入依赖
<!-- elasticsearch-->
<dependency><groupId>org.springframework.boot</groupId><artifactId>spring-boot-starter-data-elasticsearch</artifactId>
</dependency>
并通过注入elasticsearchRestTemplate
即可进行Elasticsearch的操作
@Resource
private ElasticsearchRestTemplate elasticsearchRestTemplate;
2.2、集成类似操作数据库的 Dao 实体类一样
2.3、继承ElasticsearchRepository,此时可以类似mybatis那样操作 es 了
public interface QuestionEsDao extends ElasticsearchRepository<QuestionEsDTO, Long> {List<QuestionEsDTO> findByUserId(Long userId);
2.4、并通过实现CommandLineRunner
接口,项目启动时,实现MySQL全量同步到 ES。
2.5、开启定时任务,每分钟进行对5分中之前修改的数据进行增量数据同步
3、为防止高并发下瞬时流量击垮数据库,接⼊Hotkey缓存热⻔题目,提高性能和安全性。
3.1、工作流程:
JD HotKey 是京东提供了一个轻量级通用的热 key 探测中间件。
首先在 dashboard 中配置了热点 key的规则,并部署 worker 用于统计 key 的访问数量,在后端项目中引入jd client 上报热点 key 的访问给 worke进行统计访问数量,一旦达到指定的热点 key 规定阈值,worker 会推送热点 key 到client jar包的后端,后端进行caffeine本地缓存。
主要代码:
String key = "bank_detail_" + id;// 如果是热点keyif (JdHotKeyStore.isHotKey(key)) {// 从本地缓存中获取缓存值Object cacheQuestionBankVO = JdHotKeyStore.get(key);if (cacheQuestionBankVO != null) {return ResultUtils.success((QuestionBankVO) cacheQuestionBankVO);}}// 查询数据库QuestionBankVO questionBankVO = questionBankService.getQuestionBankVO(questionBank, request);// 设置本地缓存 如果是热点key了才会设置对应的缓存 否则不做任何处理JdHotKeyStore.smartSet(key, questionBankVO);
分析:基于isHotKey
该方法会返回该 key 是否是热 key,如果是返回 true,如果不是返回 false,并且会将 key 上报到探测集群进行数量计算。该方法通常用于判断只需要判断 key 是否热、不需要缓存 value 的场景,如刷子用户、接口访问频率等。并基于get
方法获取缓存,热key返回缓存,不是热key返回null。没有缓存通过此smartSet
方法给热 key 赋值 value,如果是热 key,该方法才会赋值,非热 key,什么也不做。
本地缓存淘汰策略:
- 基于大小的淘汰
最大缓存大小:缓存可以配置最大条目数。一旦条目数超过限制,(LRU)最少使用次数的条目将被淘汰。 - 基于时间的淘汰
过期:缓存条目可以在一定时间后过期。这确保了过时、未使用的缓存条目会被自动移除。例如,可以将条目的过期时间配置为 10 分钟。 - 基于引用的淘汰
弱引用或软引用:此策略基于内存压力淘汰条目,当 JVM 需要更多内存时,会移除不再被强引用的缓存条目。
黑马Redis教学图例:
3.2、该JD Hotkey框架组成部分:
1、etcd集群
etcd作为一个高性能的配置中心,可以以极小的资源占用,提供高效的监听订阅服务。主要用于存放key规则配置,各worker的ip地址等。
2、client端jar包
就是在服务中添加的引用jar,引入后,就可以以便捷的方式去判断某key是否热key。同时,该jar完成了key上报、监听etcd里key的rule变化、以及拉取worker的ip、对热key进行本地caffeine缓存等。
3、worker端集群
worker端是一个独立部署的Java程序,启动后会连接etcd,并定期上报自己的ip信息,供client端获取地址并进行长连接。之后,主要就是对各个client发来的待测key进行累加计算,当达到etcd里设定的rule阈值后,将热key推送到各个client。
4、dashboard控制台
控制台是一个带可视化界面的Java程序,也是连接到etcd,之后在控制台设置各个APP的key规则,譬如2秒出现20次算热key。然后当worker探测出来热key后,会将key发往etcd,dashboard也会监听热key信息,进行入库保存记录。同时,dashboard也可以手工添加、删除热key,供各个client端监听。
4、为保护系统,通过Sentinel限流和熔断保护题库接口,异常时返回缓存数据。
4.1、什么是熔断?什么是限流?
- 熔断:熔断是指当调用的下游服务出现故障时,切断对该服务的调用,防止系统出现连锁故障。
工作原理:- 健康检查:熔断器监控系统会检查与其他服务的连接情况,当调用某个服务频繁失败时,它会进入 打开 状态。
- 打开状态:当熔断器处于打开状态时,所有对该服务的请求会切断,不会继续向故障的服务发送请求,从而避免进一步加重服务负担。
- 恢复状态:熔断器会在一段时间后进入 半开 状态,允许少量请求通过,如果这些请求成功,熔断器会重新恢复到 关闭 状态,恢复正常调用。如果失败,熔断器会重新进入 打开 状态,继续拒绝请求。
- 关闭状态:在没有问题时,熔断器保持关闭状态,正常传递请求。
- 限流:限流是指限制单位时间内对某个资源或服务的访问次数。
- 总结:熔断与限流可以同时使用,熔断器用于处理服务不可用的情况,而限流用于控制请求频率,保证系统的稳定运行。
4.2、项目示例运用:
Sentinel控制台部署
接入客户端用于和Sentinel进行通讯,引入依赖(SpringCloud Alibaba已经整合)
<!-- https://mvnrepository.com/artifact/com.alibaba.cloud/spring-cloud-starter-alibaba-sentinel -->
<dependency><groupId>com.alibaba.cloud</groupId><artifactId>spring-cloud-starter-alibaba-sentinel</artifactId><version>2021.0.5.0</version>
</dependency>
通过注解对listQuestionBankVOByPage资源进行保护定义:
@SentinelResource(value = "listQuestionBankVOByPage",blockHandler = "handleBlockHandler",fallback = "handleFallback")public BaseResponse<Page<QuestionBankVO>> listQuestionBankVOByPage(@RequestBody QuestionBankQueryRequest questionBankQueryRequest,HttpServletRequest request)
(1)value = "listQuestionBankVOByPage"
定义资源名称,Sentinel 会监控 listQuestionBankVOByPage 方法的调用情况。因为题库是经常访问的,故将它定义为保护资源
可以在 Sentinel 控制台 配置 限流、熔断、降级规则。
(2)blockHandler = "handleBlockHandler"
当触发限流或熔断时,会执行 handleBlockHandler 方法,而不是直接抛出异常。
这个 handleBlockHandler 方法需要和原方法的参数一致,并且返回类型相同。
该方法不能和 listQuestionBankVOByPage 方法定义在不同的类中(除非是 static 方法)。
(3)fallback = "handleFallback"
当方法发生异常(例如超时、空指针等)时,会执行 handleFallback 方法,提供降级处理逻辑。
handleFallback 方法也需要和 listQuestionBankVOByPage 方法的参数列表一致,返回值类型相同。
5、为防止不同客⼾端账号共享,通过UserAgent识别设备,Sa-Token检测同端登录冲突。
这行代码的作用是 用户登录 并将当前用户与指定的设备进行绑定,实现同一用户在不同设备上的登录互斥。
StpUtil.login(user.getId(), DeviceUtils.getRequestDevice(request));
这行代码的作用是 将用户的登录状态存储到当前会话中,方便后续通过会话获取和使用用户信息。
StpUtil.getSession().set(USER_LOGIN_STATE, user);
6、为防止内容盗取,设计分级反爬⾍策略:使⽤Redis统计访问题目频率,超限时⾃动报警
和封禁用户。
对getQuestionVOById
次数进行限制。10抛出访问频繁,20次踢下线。
3、许苑园–寻找共同兴趣的伙伴
技术栈:Spring Boot+Redis+Mybatis+WebSocket+ChatGPT+Vue3
项目地址: https://github.com/xuyuan-upward/xuyuan-matching
3.1、项目介绍:
一个实时的社交聊天平台,致力于为用户寻找共同兴趣的学习伙伴。基于目的
实现了伙伴交流聊天室、按共同兴趣爱好标签检索伙伴、推荐相似伙伴、组队,聊天,
ChatGPT问答等功能。