C++在线OJ负载均衡实战:从单体架构到高可用集群的设计与实现
1. 项目概述:从单点崩溃到弹性服务的蜕变
做在线评测系统(Online Judge, OJ)的,谁没经历过半夜被流量打崩的噩梦?尤其是当学校搞个编程竞赛,或者某门课的实验DDL快到了,几百上千个学生同时提交C++代码,编译、运行、判题这一套流程下来,服务器CPU瞬间飙到100%,内存告警,整个平台卡死,提交队列堆积如山。这场景,想想都头皮发麻。我手头这个“C++在线OJ负载均衡项目”,就是为解决这个核心痛点而生的。它不是一个简单的玩具,而是一个旨在构建高可用、高性能、可扩展的在线代码评测集群的实战工程。
简单来说,这个项目的目标,是把原来跑在一台“巨无霸”服务器上的OJ核心判题逻辑,拆解、分发到一个由多台服务器组成的“判题集群”中去。前端接收到的用户提交(主要是C++代码,也兼容其他语言),不再由单一节点处理,而是通过一个智能的“调度员”——负载均衡器,根据后端判题服务器的实时负载情况(如CPU、内存、队列长度),将任务动态、合理地分配出去。这样一来,单台服务器的压力被均摊,系统的整体吞吐量成倍提升,抗突发流量的能力也大大增强。无论是对于高校教学平台、编程竞赛网站,还是企业内部的代码测评系统,这套架构都具有普适的参考价值。
适合谁来参考这篇内容呢?如果你是一名后端开发工程师,正在为服务的性能瓶颈和扩容问题头疼;如果你是一名运维工程师,需要设计高可用的服务架构;或者你是一名计算机相关专业的学生,想通过一个综合项目深入理解网络编程、并发、分布式和系统设计,那么这篇文章里的思路、选型和踩坑经验,应该能给你不少启发。我们不空谈理论,只聚焦于从零到一构建一个能抗住真实压力的负载均衡OJ系统的全过程。
2. 核心架构设计与技术选型背后的思考
设计一个负载均衡系统,远不是装个Nginx配个upstream那么简单。尤其是对于OJ这种有状态、耗资源、要求实时反馈的特殊服务,我们需要对每一个环节进行深思熟虑。
2.1 为什么是“调度中心+判题集群”的分离架构?
最原始的OJ是单体架构:Web服务器(如Nginx/Apache)接收提交,直接调用本地的判题程序(如judged)执行。这种架构简单,但扩展性为零。一旦判题逻辑需要更新,或者机器资源不足,整个服务就要停机。
因此,我们采用了经典的“调度中心+判题集群”的微服务化思想。将系统拆分为三个核心角色:
- Web前端/API网关:负责用户交互、题目展示、提交接收。它只做最轻量的工作,接收代码后,立即生成一个判题任务(包含代码、题号、时间限制、内存限制等元数据),然后抛给“调度中心”。它本身不执行判题,是无状态的,可以轻松水平扩展。
- 负载均衡调度中心:这是整个系统的大脑。它维护着一个可用的判题服务器列表,并实时或定期从每台判题服务器拉取负载信息。当收到一个新的判题任务时,它根据预设的策略(如轮询、最少连接、基于权重的轮询等)选择一台最“闲”的判题服务器,将任务分发过去。同时,它还负责管理任务队列、处理超时、收集并返回判题结果。
- 判题服务器集群:这是干重活的地方。每台服务器上运行着判题守护进程,它从调度中心领取任务,在严格隔离的安全沙箱(如Docker、seccomp、ptrace)中编译(调用g++)、运行用户提交的C++代码,与标准输入输出对比,最终得出
Accepted、Wrong Answer、Time Limit Exceeded等结果,并将结果回传给调度中心。
注意:这里的安全沙箱是OJ系统的生命线。绝不能让用户提交的恶意代码(如死循环、
fork bomb、系统调用rm -rf /)影响到宿主机。Docker虽然方便,但启动开销较大;纯C++配合seccomp、setrlimit、chroot等系统调用可以实现更轻量级的隔离,但对开发者的要求更高。本项目为了平衡性能和安全性,采用了Docker方案,但为每个判题任务复用预先创建好的容器模板,以降低启动延迟。
2.2 负载均衡器的选型:Nginx vs 自研调度服务
提到负载均衡,很多人第一反应是Nginx。Nginx的stream模块或http模块确实可以做四层或七层负载均衡,配置简单,性能强悍。但对于OJ这个场景,直接使用Nginx有以下几个局限:
- 负载信息感知弱:Nginx的经典轮询、权重、IP Hash等算法,主要基于网络连接层面,无法感知后端判题服务的真实负载(如CPU使用率、内存剩余、当前判题任务队列长度)。这可能导致“旱的旱死,涝的涝死”——某台服务器明明已经满载,新的任务还被分配过去。
- 协议不匹配:OJ调度中心与判题服务器之间通信的消息,往往是自定义的、结构化的(例如JSON或Protocol Buffers格式的判题任务描述和结果回报)。Nginx更适合代理标准的HTTP/HTTPS或TCP流量,对于这种自定义协议,处理起来不够灵活。
- 状态管理复杂:判题任务有超时、失败重试等需求,这些业务逻辑如果强塞进Nginx的配置和模块中,会非常晦涩且难以维护。
因此,我选择了自研一个专用的负载均衡调度服务。这个服务使用C++编写(与判题核心同语言,减少环境差异),核心职责包括:
- 服务发现与健康检查:定期向所有注册的判题服务器发送心跳,检测其是否存活。
- 负载收集:判题服务器主动上报或由调度中心拉取其当前负载指标。
- 调度算法:基于收集到的负载信息,实现更智能的调度策略。
- 任务队列与状态机:管理等待调度的任务,处理任务超时、失败重试等。
- 通信桥梁:作为Web前端和判题集群之间的通信中介,进行协议转换和数据转发。
这个选择牺牲了一点“开箱即用”的便利,换来了极致的灵活性和对业务场景的深度适配。
2.3 通信协议的设计:为什么不用HTTP?
Web前端和调度中心之间,使用HTTP/HTTPS是合理的,因为这与常见的Web API交互方式一致。但调度中心与判题服务器之间,我选择了基于TCP Socket的自定义二进制协议,而非HTTP,主要基于性能考量:
- 低开销:HTTP协议的头部(Header)信息对于简单的任务分发和结果回报来说过于臃肿。自定义二进制协议可以设计得非常紧凑,减少网络传输的数据量。
- 低延迟:建立TCP长连接,避免每次通信都进行HTTP的三次握手。任务数据直接通过Socket收发,序列化和反序列化效率更高(例如使用Google的Protocol Buffers)。
- 双工通信:可以方便地实现推送机制,例如调度中心可以主动将新任务推送给判题服务器,判题服务器也可以主动上报状态变化。
一个简单的任务消息结构可能设计如下(用protobuf示意):
syntax = "proto3"; message JudgeTask { string task_id = 1; string code = 2; int32 problem_id = 3; int32 time_limit_ms = 4; int32 memory_limit_kb = 5; string language = 6; // “cpp”, “java”, “python” } message JudgeResult { string task_id = 1; int32 result_code = 2; // 0:AC, 1:WA, 2:TLE, 3:MLE, 4:RE, 5:CE int32 time_used_ms = 3; int32 memory_used_kb = 4; string compile_info = 5; // 编译错误信息 string output = 6; // 用于对比的输出(可选) }3. 核心模块实现与关键技术细节
纸上谈兵终觉浅,我们来深入看看几个核心模块是如何用C++实现的,以及其中有哪些值得注意的“坑”。
3.1 负载均衡调度服务的核心实现
调度服务是整个系统的中枢,我将其设计为一个基于事件驱动的网络服务,使用epoll(Linux)或IOCP(Windows)来处理高并发连接。
1. 负载指标收集与计算判题服务器会定期(如每秒)通过TCP连接向调度中心发送心跳包,心跳包里包含其当前的负载指标:
struct ServerStatus { std::string server_id; int cpu_usage; // 百分比,如 45 int memory_free_mb; // 剩余内存 MB int queue_size; // 当前等待处理的判题任务数 int total_workers; // 总判题工作进程数 int idle_workers; // 空闲工作进程数 // ... 其他指标 };调度中心维护一个std::unordered_map<std::string, ServerStatus>来记录所有服务器的状态。这里的关键是如何综合这些指标计算出一个“负载分数”,用于调度决策。一个简单的加权计算公式可以是:负载分数 = w1 * cpu_usage + w2 * (1 - memory_free_ratio) + w3 * queue_size其中权重w1, w2, w3需要根据实际场景调整。例如,内存不足比CPU满载更致命(可能触发OOM),所以w2可以设得大一些。
2. 调度算法实现有了负载分数,最简单的调度策略就是最小负载优先(Least Load):每次分配新任务时,遍历所有健康的服务器,选择负载分数最低的那一台。
std::string Scheduler::selectBestServer(const std::unordered_map<std::string, ServerStatus>& status_map) { std::string best_server; double min_load = std::numeric_limits<double>::max(); for (const auto& [server_id, status] : status_map) { if (!isServerHealthy(status)) continue; // 健康检查 double current_load = calculateLoadScore(status); if (current_load < min_load) { min_load = current_load; best_server = server_id; } } if (best_server.empty()) { // 所有服务器都不健康或过载,需要降级处理,如返回错误或放入等待队列 throw std::runtime_error("No available judge server."); } return best_server; }更复杂的策略可以考虑带权重的最小负载,或者预测性调度(根据任务的历史资源消耗预估其所需资源,再匹配服务器)。
3. 任务队列与超时管理为了防止某个判题服务器宕机导致任务丢失,调度中心需要维护一个持久化的任务队列(可以用Redis、RabbitMQ,或者简单的内存队列+定期持久化到磁盘)。每个任务被分配时,会启动一个定时器。如果在规定时间内(如题目时间限制的2倍)没有收到结果,则认为任务超时或失败,调度中心需要将任务重新放入队列(可能分配给另一台服务器),并标记原服务器可疑,触发健康检查。
实操心得:定时器的管理是个精细活。如果为每个任务都创建一个独立的线程或定时器,在任务量巨大时开销不可接受。我采用了时间轮(Time Wheel)算法,将所有的超时检查事件放在一个统一的循环结构中处理,极大地减少了系统开销。这是实现高性能调度服务的一个关键技巧。
3.2 判题服务器(Worker)的安全沙箱实现
判题服务器的核心职责是在安全的环境中运行不可信的代码。我们使用Docker来实现隔离。
1. 容器模板准备我们预先构建好一个包含了编译环境(g++、gcc、Java JDK、Python解释器等)和必要限制的Docker镜像。这个镜像已经设置好了:
- 用户权限:以非root用户运行。
- 资源限制:通过
docker run的--memory、--cpus参数限制最大内存和CPU使用。 - 系统调用过滤:通过Docker的
--security-opt seccomp=seccomp-profile.json使用一个严格的自定义seccomp配置文件,禁止危险的系统调用(如clone,fork,execve在某些场景下需要限制,ptrace,socket等)。
2. 判题流程判题服务器的工作进程(Worker)接到任务后的处理流程:
JudgeResult Worker::judge(const JudgeTask& task) { JudgeResult result; result.task_id = task.task_id; // 1. 为本次判题创建一个临时目录,用于存放用户代码、可执行文件、输入输出 std::string temp_dir = createTempDir(); // 2. 将用户代码写入文件 (e.g., main.cpp) writeCodeToFile(temp_dir, task.code, task.language); // 3. 准备测试用例的输入文件 std::vector<TestCase> test_cases = loadTestCases(task.problem_id); // 4. 编译(对于编译型语言) if (task.language == "cpp") { CompileInfo compile_info = compileCpp(temp_dir); // 内部调用 docker exec 运行 g++ if (!compile_info.success) { result.result_code = COMPILE_ERROR; result.compile_info = compile_info.message; cleanupTempDir(temp_dir); return result; } } // 5. 对每个测试用例运行程序 for (const auto& test_case : test_cases) { RunResult run_result = runInDocker(temp_dir, task, test_case.input); // 分析运行结果:是否超时(TLE)、超内存(MLE)、运行时错误(RE) // 对比输出是否正确(WA) if (run_result.exit_code != 0 || run_result.signal != 0) { result.result_code = RUNTIME_ERROR; break; } else if (run_result.time_used > task.time_limit_ms) { result.result_code = TIME_LIMIT_EXCEEDED; break; } else if (run_result.memory_used > task.memory_limit_kb) { result.result_code = MEMORY_LIMIT_EXCEEDED; break; } else if (run_result.output != test_case.expected_output) { result.result_code = WRONG_ANSWER; break; } // 更新用时和内存(取最大值) result.time_used_ms = std::max(result.time_used_ms, run_result.time_used); result.memory_used_kb = std::max(result.memory_used_kb, run_result.memory_used); } // 6. 所有用例通过 if (result.result_code == INITIAL) { // 初始状态,表示前面没出错 result.result_code = ACCEPTED; } // 7. 清理临时目录 cleanupTempDir(temp_dir); return result; }其中,runInDocker函数的核心是构造并执行docker run命令:
docker run --rm \ --network none \ # 禁用网络 --memory 256m \ # 限制内存 --cpus 1.5 \ # 限制CPU --read-only \ # 根文件系统只读 -v /tmp/judge_12345:/workspace:rw \ # 挂载临时目录 --security-opt seccomp=/path/to/seccomp.json \ oj-judge-image \ timeout 2 /workspace/main < /workspace/input.txt > /workspace/output.txt 2> /workspace/error.log这个命令在资源限制、安全隔离和超时控制上都做了严格设定。
踩坑记录:Docker容器的启动时间(即使
--rm)在毫秒级,对于超高频的判题请求仍是不可忽视的开销。我们的优化方案是容器池化。预先启动一定数量的“热”容器,判题时不是docker run,而是docker exec到这些已运行的容器中执行命令。判题结束后,清理容器内的临时文件即可。这能将每次判题的环境准备时间从几百毫秒降低到几毫秒。但池化管理也带来了复杂性,需要小心处理容器的状态污染和生命周期。
3.3 通信层的可靠性与性能优化
调度中心与多个判题服务器之间维持着大量的TCP长连接。保证这些连接的稳定和高效通信至关重要。
1. 连接管理与心跳机制每个判题服务器启动后,主动向调度中心注册并建立长连接。调度中心为每个连接创建一个会话(Session)对象进行管理。为了检测连接是否存活,双方需要实现心跳机制:每隔一段时间(如15秒),发送一个特定的PING消息,对方回复PONG。如果连续多次收不到PONG,则认为连接已断开,将该服务器标记为下线,并将其上的未完成任务重新调度。
2. 消息的序列化与协议设计我们选择了Protocol Buffers作为序列化工具,因为它跨语言、高性能、生成代码简洁。消息头我们自定义了一个简单的二进制格式,包含消息类型和长度:
[消息类型: 2字节][消息体长度: 4字节][Protobuf消息体]这样,接收方可以先读取固定的6字节头部,解析出消息类型和长度,再准确读取相应长度的消息体进行反序列化。
3. 异步网络模型无论是调度中心还是判题服务器,都采用Reactor模式配合非阻塞I/O。在Linux下,使用epoll监听所有连接套接字上的可读/可写事件。当某个连接有数据到达时,触发回调函数,读取数据、解析协议、处理业务逻辑、发送回复,整个过程都是非阻塞的,单线程就能处理成千上万的并发连接。这是支撑高并发的技术基石。
// 简化的 epoll 事件循环核心 void EventLoop::run() { while (is_running_) { int num_events = epoll_wait(epoll_fd_, events_, MAX_EVENTS, -1); for (int i = 0; i < num_events; ++i) { Connection* conn = static_cast<Connection*>(events_[i].data.ptr); if (events_[i].events & EPOLLIN) { // 可读事件:有数据到来 handleRead(conn); } if (events_[i].events & EPOLLOUT) { // 可写事件:可以发送数据 handleWrite(conn); } // ... 处理错误事件 EPOLLERR, EPOLLHUP } } }4. 系统部署、监控与压测实战
一个系统设计得再好,最终还是要落地运行。部署、监控和压测是检验其成色的关键环节。
4.1 部署架构与配置
我们假设一个中小型规模的部署场景:
- 1台调度中心服务器:配置较高(8核16G),因为它要处理所有连接和调度逻辑。
- 3-5台判题服务器:每台配置相似(4核8G),专门用于运行Docker容器判题。
- 1台Web前端/数据库服务器:也可以与调度中心合并部署。
所有服务器位于同一内网,以减少网络延迟。使用Docker Compose或Kubernetes来编排判题服务器上的Worker容器集群非常合适。调度中心通过内网IP或服务名来访问判题服务器。
关键配置示例(调度中心配置文件config.yaml):
server: listen_ip: "0.0.0.0" listen_port: 8888 worker_threads: 4 # 业务逻辑处理线程数,通常等于CPU核心数 judge_servers: - id: "judge-01" host: "192.168.1.101" port: 9999 weight: 10 # 权重,可用于加权调度 enabled: true - id: "judge-02" host: "192.168.1.102" port: 9999 weight: 10 enabled: true scheduler: policy: "least_load" # 调度策略 health_check_interval_sec: 5 load_update_interval_sec: 2 task_queue: type: "redis" # 使用Redis作为持久化队列 redis_host: "127.0.0.1" redis_port: 6379 queue_name: "oj_pending_tasks"4.2 监控与告警
没有监控的系统就是在“裸奔”。我们需要监控以下几个关键指标:
- 调度中心:TCP连接数、任务队列长度、任务处理速率(TPS)、各判题服务器的负载分数。
- 判题服务器:CPU使用率、内存使用率、Docker容器运行数量、判题成功/失败率、各类错误(CE/TLE/MLE/RE)的分布。
- 数据库/Redis:连接数、查询延迟、内存使用。
我们可以使用Prometheus + Grafana的组合。在每个服务中集成Prometheus客户端库(如prometheus-cpp),暴露指标接口。Prometheus定期拉取数据,Grafana用于可视化仪表盘。对于告警,可以配置Prometheus Alertmanager,当任务队列积压超过阈值,或判题服务器宕机时,通过邮件、钉钉、企业微信等渠道通知运维人员。
4.3 压力测试与性能调优
压测是验证负载均衡效果和系统瓶颈的唯一标准。我使用了自己编写的压测工具,模拟大量用户并发提交C++代码(一道简单的A+B问题)。
压测场景:
- 逐步增加并发用户数(100, 200, 500, 1000)。
- 观察在不同并发下,系统的响应时间(从提交到得到结果)和吞吐量(每秒处理判题数)的变化。
- 对比单机版OJ和负载均衡集群版的性能差异。
压测结果关键发现与调优:
- 瓶颈转移:单机版在并发约150时,响应时间急剧上升,CPU饱和。集群版(3台判题服务器)能将这个拐点推到450左右,吞吐量提升近3倍,证实了负载均衡的有效性。
- 调度中心成为新瓶颈:当并发继续升高(>800),判题服务器资源仍有富余,但调度中心所在服务器的CPU和网络中断处理出现瓶颈。调优措施:将调度中心的网络I/O线程与业务逻辑线程分离;将任务队列从内存迁移到Redis,减轻调度中心内存压力;考虑将调度中心本身也做成无状态,支持水平扩展(但需要引入分布式锁等机制来保证任务不重复调度)。
- 数据库连接池:Web前端查询题目、提交记录频繁访问数据库。最初没有使用连接池,导致高并发下数据库连接数爆满。引入连接池(如
mysql-connector-cpp自带的或第三方库)后,数据库侧压力显著下降。 - Docker守护进程压力:当判题请求非常密集时,大量并发的
docker exec命令会让Docker Daemon压力很大。调优措施:调整判题服务器上Docker Daemon的启动参数(如--max-concurrent-downloads,--max-concurrent-uploads);升级Docker版本到更稳定的发行版;或者,如前所述,采用容器池化技术,从根本上减少Docker命令的调用频率。
5. 常见问题排查与运维经验实录
在实际开发和运维中,会遇到各种各样稀奇古怪的问题。这里记录几个最具代表性的案例和解决思路。
5.1 判题结果不一致(幽灵错误)
问题描述:同一份C++代码,多次提交,偶尔会得到Wrong Answer,偶尔又是Accepted,结果随机出现。
排查过程:
- 首先怀疑是数据竞争。检查判题代码,在运行用户程序并收集输出时,是否使用了线程不安全的操作。排除了。
- 检查测试用例文件,确认其内容确定无误。
- 在判题服务器上,手动用Docker命令重复运行该程序,发现输出确实是稳定的。
- 开启更详细的日志,发现当出现
WA时,从Docker容器收集到的output字段有时末尾会多出一个换行符,有时又没有。而对比时使用的是字符串精确匹配。
根本原因:用户程序的标准输出流(std::cout)在程序结束时如果没有手动刷新(flush),缓冲区的内容可能不会完全写入。当Docker容器快速结束时,这部分未刷新的数据可能丢失,导致输出不完整。而是否丢失,具有随机性。
解决方案:在判题时,运行用户程序的命令中,限制其运行环境,并确保输出被完整捕获。更稳健的做法是,在对比输出前,对双方(用户输出和期望输出)都进行标准化处理,比如去除末尾的空白字符(空格、换行、制表符),再进行对比。很多成熟的OJ系统都采用这种“pe(Presentation Error)”判题逻辑,即只要非空白字符序列一致,就认为答案正确。
5.2 负载均衡“失衡”,某台服务器持续高负载
问题描述:在监控面板上发现,judge-02服务器的CPU使用率持续在90%以上,而其他服务器却很空闲。
排查过程:
- 检查调度中心的负载计算算法和服务器上报的数据。发现
judge-02上报的queue_size(任务队列长度)总是很大。 - 登录
judge-02,发现其上的判题Worker进程有大量处于“D”(不可中断睡眠)状态的进程。使用strace跟踪,发现这些进程卡在某个系统调用上。 - 检查该服务器上的Docker和磁盘I/O状态。使用
iostat发现磁盘的util长期100%,await(平均等待时间)极高。
根本原因:该服务器所在的虚拟机或物理机,其磁盘性能较差(可能是共享存储或HDD)。而判题过程中,需要频繁读写临时文件(编译产生的.o文件、可执行文件、输入输出文件)。磁盘I/O成为瓶颈,导致判题任务处理极其缓慢,队列堆积,负载分数虚高。但调度中心的负载计算模型主要考虑了CPU和内存,对磁盘I/O压力不敏感,导致它仍然持续地将新任务分配给这台已经“卡住”的服务器。
解决方案:
- 短期:在调度中心的负载计算模型中,加入磁盘I/O使用率或磁盘等待时间作为一项指标。如果某服务器磁盘I/O等待时间超过阈值,则大幅提高其负载分数,降低被选中的概率。
- 长期:为判题服务器配置高性能的本地SSD磁盘,并将临时目录挂载到SSD上。同时,考虑使用内存盘(
tmpfs)来存放临时文件,因为判题过程中的文件通常很小,生命周期也短,非常适合放在内存中,能极大提升I/O速度。
5.3 内存泄漏导致服务逐渐僵死
问题描述:调度中心服务在运行几天后,响应越来越慢,最终几乎无响应。重启后恢复正常。
排查过程:
- 使用
top和htop观察进程内存,发现调度中心进程的RES内存占用在缓慢但持续地增长。 - 使用Valgrind的
memcheck工具对调度中心程序进行检测。由于是长期运行的服务,Valgrind会极大拖慢速度,我们采用在测试环境中模拟高压力请求一段时间后进行分析。 - Valgrind报告指出,在
ServerStatus对象更新和任务结果回调函数中,存在一些std::string和std::vector内存未能正确释放的情况。
根本原因:在复杂的异步回调和多线程环境中,某些任务结果对象的生命周期管理出现了问题。例如,一个指向任务数据的shared_ptr在某个回调链中被意外地复制并持有了更长时间,导致其关联的内存无法及时释放。虽然每个任务泄漏的内存很小,但日积月累,总量就很可观。
解决方案:
- 仔细审查所有使用
new、malloc或者智能指针的代码,确保在异步操作完成后的回调中,能够正确释放资源。对于网络库和回调,明确所有权转移的时机。 - 引入内存池技术,对于频繁创建和销毁的小对象(如任务对象、状态对象),使用对象池进行复用,减少系统分配器的压力,也更容易管理内存。
- 为调度中心服务设置一个“软重启”机制。通过监控其内存使用,当超过某个阈值(如80%的系统内存)时,自动优雅地停止接受新连接,处理完存量任务后重启进程。这可以作为应对未知内存问题的最后防线。
5.4 网络分区导致“脑裂”
问题描述:在偶尔的网络抖动后,监控发现有两台判题服务器同时被标记为“主”判题服务器,都在处理任务,导致部分任务被重复执行。
排查过程:这通常发生在调度中心的高可用部署中(例如主备两个调度中心)。网络分区导致备调度中心认为主调度中心宕机,于是自己启动成为新的主节点,而实际上原主节点还在运行。
解决方案:引入分布式共识算法,如使用ZooKeeper或etcd来实现调度中心的Leader选举。所有调度中心实例启动时都向ZooKeeper注册,竞争创建一个 ephemeral 节点(如/oj/scheduler/leader),创建成功的实例成为Leader,对外提供服务。其他实例作为Follower监听这个节点。一旦Leader宕机或失联,其创建的ephemeral节点会自动消失,其他实例会立即发起新的选举。这样就能保证在任何时候,集群中只有一个有效的调度中心在工作,避免了“脑裂”和数据不一致。这是构建高可用分布式系统的经典模式。
