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。
- 我执行
dashboard命令观察。等到 30 秒周期到来时,果然看到一个名为scheduled-pool-1的线程 CPU 瞬间冲到了榜首。- 我立刻记下线程 ID,执行
thread <ID>,Arthas 直接打印出了堆栈信息。- 堆栈报错非常精确,指向了
DeviceSyncService.java的第 86 行。- 为了确认逻辑,我又用了
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); } }在循环内部,我做了两件事:
- 查库:去 MySQL 查一次设备的位置信息。
- 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)。)
- 配置类 (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复制进去,然后执行。
- 有网时 :直接