一次服务器CPU飙升的排查与解决

1. 背景

"这个背景发生在我们项目从内部测试(5台设备)向小规模试点(40台设备)扩展的阶段。

当时我们的服务器配置非常基础,是阿里云的 2核4G 实例。

核心业务中有一个定时任务,负责每 30 秒拉取所有设备的实时状态(温度、打印进度)并校验设备位置是否合法。

在测试阶段只有 5 台设备时,系统运行非常平稳,CPU 占用一直低于 5%。但在我们接入了 40 台 试点设备,并上线了'位置校验'新功能后的第二天下午,运维告警说服务器 CPU 持续飙升到 92% 以上,页面打开非常卡顿。"

2. 排查

第一步:锁定嫌疑人 (Docker)

"我第一时间登录服务器,发现机器很卡。

我先执行了 docker stats,一眼就看到我们的后端 Java 容器 CPU 占用率高达 90% 多,而内存只用了 15%,所以排除了内存泄漏,锁定了是 计算密集型频繁上下文切换 的问题。

我观察了一会儿,发现 CPU 是呈周期性飙升的:每隔 30 秒飙上去,维持几秒后落下,这让我立刻怀疑到了那个 30 秒执行一次的定时任务。"

第二步:精准定位 (Arthas)

"为了拿到证据,我没有重启服务,而是进入容器内部,下载并启动了 Arthas

  1. 我执行 dashboard 命令观察。等到 30 秒周期到来时,果然看到一个名为 scheduled-pool-1 的线程 CPU 瞬间冲到了榜首。
  2. 我立刻记下线程 ID,执行 thread <ID>,Arthas 直接打印出了堆栈信息。
  3. 堆栈报错非常精确,指向了 DeviceSyncService.java 的第 86 行。
  4. 为了确认逻辑,我又用了 jad 命令反编译了这个类,彻底看清了当时的代码逻辑。"
3. 根因分析

"通过分析代码,我发现了问题的根源。这是一个典型的**'在循环中进行网络/DB交互'**的问题,在设备少时没感觉,设备一多就炸了。

代码逻辑是这样的:拿到 40 台设备列表后,写了一个 for 循环。

java 复制代码
// 原代码:每同步一台设备,就查一次数据库校验位置
for (Device device : deviceList) { // deviceList只有40台设备(小系统规模)
    // 第86行:循环内查库,每台设备查一次MySQL
    DeviceLocation location = locationMapper.selectByDeviceId(device.getId());
    if (location == null || !location.getFactoryId().equals(device.getFactoryId())) {
        device.setStatus("位置异常");
    } else {
        // 调用设备网关接口,获取实时状态
        String realStatus = deviceGateway.getStatus(device.getIp());
        device.setStatus(realStatus);
    }
}

在循环内部,我做了两件事:

  1. 查库:去 MySQL 查一次设备的位置信息。
  2. HTTP请求:调用设备网关接口获取状态。

为什么 40 台设备会让 2核 CPU 崩溃?

  • N+1 问题 :每 30 秒要执行 40 次数据库查询40 次 HTTP 请求
  • 连接开销 :当时我没有配置 HTTP 连接池,而是直接 new RestTemplate()。这意味着每 30 秒要进行 40 次 TCP 三次握手和四次挥手
  • 资源争抢 :对于 2核4线程 的 CPU 来说,短时间内爆发 80 次 IO 操作(DB+HTTP),会导致线程频繁在'运行'和'阻塞'状态间切换(Context Switching),CPU 大量时间消耗在内核态的调度上,而不是业务逻辑上。"
4. 解决与结果

"定位问题后,我做了两步低成本优化,没有加硬件:

优化一:批量IO代替单次IO

我重构了代码,将循环内的 40 次 select 查询,改为在循环外用 select ... where id in (...) 一次性批量查出所有数据,转为内存 Map 处理。数据库交互从 40 次降为 1 次。

java 复制代码
// 1. 提取所有设备ID(准备 IN 查询的参数)
Set<String> deviceIds = deviceList.stream()
    .map(Device::getId)
    .collect(Collectors.toSet());

// 2. 【核心优化】:一次 SQL 查出所有数据
List<DeviceLocation> locationList = locationMapper.selectBatchByDeviceIds(deviceIds);

// 3. 【内存加速】:将 List 转为 Map<DeviceId, Location>
// 这样在下面循环时,查找复杂度从 O(N) 降为 O(1),完全避免了嵌套循环
Map<String, DeviceLocation> locationMap = locationList.stream()
    .collect(Collectors.toMap(DeviceLocation::getDeviceId, Function.identity()));

// 4. 纯内存计算(速度极快)
for (Device device : deviceList) {
    // 直接从内存 Map 取,没有数据库 IO
    DeviceLocation location = locationMap.get(device.getId());
    
    if (location == null || !location.getFactoryId().equals(device.getFactoryId())) {
        device.setStatus("位置异常");
    }
}

优化二:复用连接

我将 RestTemplate 注册为 Spring 单例 Bean,并配置了 HTTP 连接池(Keep-Alive)。这样 40 次请求复用了长连接,消除了 TCP 握手的 CPU 开销。(从"短连接"(每次握手挥手)改为"长连接"(Keep-Alive)。)

  1. 配置类 (RestTemplateConfig.java)

这一步是告诉 Spring:请给我造一个带连接池的 RestTemplate,不要用默认那个简陋的。

java 复制代码
@Configuration
public class RestTemplateConfig {

    @Bean
    public RestTemplate restTemplate() {
        // 使用 Apache HttpClient 作为底层实现(支持连接池)
        PoolingHttpClientConnectionManager connectionManager = new PoolingHttpClientConnectionManager();
        
        // 【核心参数配置】
        // 最大连接数(根据你的机器配置,2核设为 50-200 够用了)
        connectionManager.setMaxTotal(200);
        // 每个路由(目标IP)的最大并发数(你有40台设备,设为50保证每台都有连接可用)
        connectionManager.setDefaultMaxPerRoute(50);

        CloseableHttpClient httpClient = HttpClients.custom()
                .setConnectionManager(connectionManager)
                .evictIdleConnections(60, TimeUnit.SECONDS) // 定期清理空闲连接
                .build();

        HttpComponentsClientHttpRequestFactory factory = new HttpComponentsClientHttpRequestFactory(httpClient);
        // 设置超时时间(防止卡死)
        factory.setConnectTimeout(3000);
        factory.setReadTimeout(5000);

        return new RestTemplate(factory);
    }
}

2.Service 业务层代码

java 复制代码
@Service
public class DeviceSyncService {

    // 1. 注入配置好的单例 RestTemplate
    @Autowired
    private RestTemplate restTemplate;

    public void syncStatus() {
        List<Device> deviceList = deviceMapper.selectAll();

        for (Device device : deviceList) {
            try {
                // 2. 直接复用连接
                // 底层 ConnectionManager 会看:去往这个 IP 的连接有没有空闲的?
                // 有就直接用(0握手成本),没有才创建。
                String result = restTemplate.getForObject("http://" + device.getIp() + "/status", String.class);
                device.setStatus(result);
            } catch (Exception e) {
                log.error("设备 {} 连接失败", device.getIp());
            }
        }
    }
}

结果:

上线后,立竿见影。

  • 单次任务耗时从 2.8秒 缩短到 0.3秒
  • CPU 峰值从 92% 降到了 3% 左右
  • 即便后续设备增加到 100 台,这个架构也能轻松扛住。"

Q1: TCP 握手为什么消耗 CPU?不是消耗网络吗?

  • :TCP 握手不仅仅是发包。操作系统内核需要分配 socket 资源、计算序列号、进行中断处理。对于 2 核 CPU 来说,极短时间内(毫秒级)处理密集的连接建立和销毁(40次/30秒),会导致 System CPU(内核态占用) 升高,挤占了 Java 程序的运行资源。

Q2: 为什么不用 Redis 缓存位置信息?

  • :我想过用 Redis。但在只有 40 台设备的小规模下,MySQL 的一次批量查询(In Query)效率极高(毫秒级),直接查库最简单,不引入 Redis 还能降低系统复杂度(少维护一个组件的一致性)。如果未来扩容到几千台,我会考虑加 Redis 缓存。

Q3: Arthas 是怎么装进容器的?如果容器没网怎么办?

    • 有网时 :直接 curl -O ...arthas-boot.jar
    • 没网时 :我会先在本地下载好 jar 包,通过 docker cp arthas-boot.jar <container_id>:/tmp 复制进去,然后执行。

相关推荐
NGINX开源社区2 小时前
借助 Okta 和 NGINX Ingress Controller 实现 K8s OpenID Connect 身份验证
运维·nginx·kubernetes
m0_748229992 小时前
帝国CMS后台搭建全攻略
java·c语言·开发语言·学习
郝亚军2 小时前
如何在windows11和Ubuntu linux之间互传文件
linux·运维·ubuntu
码农娟2 小时前
Hutool XML工具-XmlUtil的使用
xml·java
j_xxx404_2 小时前
Linux:进程状态
linux·运维·服务器
济6172 小时前
linux 系统移植(第二十三期)---- 进一步完善BusyBox构建的根文件系统---- Ubuntu20.04
linux·运维·服务器
程序员 _孜然2 小时前
openkylin、ubuntu等系统实现串口自动登录
linux·运维·ubuntu
草青工作室2 小时前
java-FreeMarker3.4自定义异常处理
java·前端·python
hweiyu002 小时前
Linux 命令:csplit
linux·运维·服务器