1. 这不是服务器“生病”,是定时任务在悄悄吃掉你的CPU——一个真实排障现场的复盘
你有没有过这种经历:凌晨三点,监控告警突然炸开,线上服务响应延迟从200ms飙到2秒,接口超时率瞬间冲到15%,但所有服务进程看起来都“活着”,日志里没有ERROR,内存没爆,磁盘IO也正常——你盯着屏幕,手心冒汗,却像在迷雾里开车,根本找不到刹车在哪。我上周就撞上了这个坑。一台跑着WMS系统和SpringCloud微服务集群的CentOS 7生产服务器,连续两天在每天上午10:15准时变慢,持续12分钟,之后又自动恢复。运维同事第一反应是查网络、查数据库连接池、查JVM GC日志,折腾一小时无果。最后是我翻出top -H按线程CPU排序,发现一个叫java的进程下有个线程ID(LWP)长期占着98%的单核CPU,而这个线程的堆栈快照里反复出现org.quartz.core.JobRunShell.run——它根本不是业务代码,而是Quartz调度器在执行一个没人记得起的定时任务。更讽刺的是,这个任务配置在application.yml里,cron表达式写的是0 0/5 * * * ?,意思是每5分钟执行一次,但实际逻辑里有个死循环读取本地CSV文件做数据清洗,而那个CSV文件上周被运维误操作同步了1.2GB的测试数据。问题不在服务器,而在一个被遗忘的、写得不严谨的定时任务上。这件事让我意识到,Linux系统排障最危险的陷阱,不是硬件故障或配置错误,而是那些“安静运行”的后台任务——它们不报错、不崩溃、不打日志,只是默默把CPU、内存、IO这些资源一点点啃光。今天这篇,不讲教科书式的命令大全,也不列一堆高大上的架构图,就带你回到那个真实的两小时排障现场,拆解我是怎么从“系统整体变慢”这个模糊现象,一步步剥洋葱,最终定位到那个藏在/etc/crontab和SpringBoot@Scheduled注解双重夹击下的“幽灵任务”。如果你用的是国产麒麟系统、或者正在搭建农产品销售系统、IM群发系统,甚至只是在虚拟机里装Ubuntu练手,只要你的服务器上跑着任何一种定时任务——cron、systemd timer、Quartz、XXL-JOB、甚至是Kettle里的spoon脚本——这篇文章里的思路和工具链,都能直接抄作业。
2. 排障不是靠猜,是靠建立“资源流向地图”——我的四层定位法
很多人一遇到服务器变慢,第一反应就是top看CPU,df -h看磁盘,free -h看内存,这没错,但太浅。就像医生不能只量体温就开药方,Linux排障的核心,是搞清楚“谁在消耗什么资源,为什么消耗,消耗得是否合理”。我给自己总结了一套四层定位法,不是线性流程,而是像侦探画关系网一样,层层交叉验证。这套方法在麒麟系统、CentOS、Ubuntu上都验证过,关键不在于命令多炫酷,而在于每一步都带着明确的“证伪”目的——我要排除什么,而不是证明什么。
2.1 第一层:确认“慢”是全局还是局部?锁定影响域
很多所谓的“服务器变慢”,其实是某个服务或某个用户会话的问题。我第一步永远不是登录服务器,而是先看外部表现。比如我们WMS系统,前端页面加载慢,但API网关的健康检查(curl -I http://gateway:8080/actuator/health)返回200,说明网关本身没挂;再用curl -s http://wms-service:8081/api/v1/stock/summary | wc -c测核心库存接口,发现耗时从300ms变成2.1s,而另一个订单查询接口/api/v1/order/list却只有400ms,这就很关键——说明问题不是整个JVM或整个服务器,而是特定业务路径。我立刻在WMS服务节点上执行:
# 查看当前所有Java进程的PID和启动参数,重点找带"wms"字样的 ps aux | grep java | grep wms # 对应PID,用jstat看GC情况(这里PID是12345) jstat -gc 12345 1000 3 # 同时用jstack抓线程快照,重定向到文件方便分析 jstack 12345 > /tmp/wms-thread-dump-$(date +%s).txt结果发现:GC频率正常,老年代没满,但jstack输出里有大量RUNNABLE状态的线程,堆栈都卡在java.io.FileInputStream.readBytes——这明显是IO阻塞,不是GC问题。这时候我就知道,问题大概率出在文件读取或网络请求上,而不是CPU计算瓶颈。这个判断直接跳过了查CPU占用率的步骤,节省了至少20分钟。很多新手会在这里陷入误区:看到top里Java进程CPU高,就以为是代码写得烂,其实90%的情况是它在等IO,CPU高只是“等待IO完成”这个动作的副产品。
2.2 第二层:资源消耗者是谁?用pidstat代替top看本质
top只能告诉你哪个进程CPU高,但它无法区分是计算密集型(真正在做加减乘除)还是IO密集型(在等磁盘或网络)。我第二步必用pidstat,它是sysstat包里的神器,能按秒级精度看每个进程的CPU、IO、上下文切换、线程数。安装很简单:
# CentOS/RHEL yum install -y sysstat # Ubuntu/Debian apt-get install -y sysstat然后执行:
# 每2秒刷新一次,显示所有进程的CPU、IO等待、上下文切换 pidstat -u -r -w -p ALL 2 # 关键看这几列: # %usr:用户态CPU时间占比(真正在跑代码) # %system:内核态CPU时间占比(系统调用、中断处理) # %iowait:CPU等待IO完成的时间占比(>5%就要警惕) # cswch/s:每秒上下文切换次数(>10000通常意味着线程争抢严重) # Command:进程名那天的数据显示,java进程的%iowait高达65%,而%usr只有12%,%system是8%。这意味着CPU大部分时间在干等,不是在干活。再结合jstack里FileInputStream.readBytes的线索,我立刻把矛头指向文件IO。这时候我不会急着去查/var/log,因为日志文件通常很小,不会导致1.2GB的IO压力。我想到一个更可能的地方:定时任务生成的临时文件、数据同步的缓存目录、或者Kettle(spoon)作业的输出路径。我用lsof命令锁定了具体文件:
# 查看java进程12345打开的所有文件,按文件大小倒序 lsof -p 12345 | awk '{print $7, $9}' | sort -nr | head -20 # 输出里赫然出现: # 1245678900 /opt/kettle/data/stock_full_sync_20240520.csv # 876543210 /tmp/wms_data_cache/20240520_stock.csv两个文件都超过1GB,而且路径都指向数据同步任务。到这里,问题已经呼之欲出:有一个定时任务在疯狂读写大文件。
2.3 第三层:谁在驱动这个IO?揪出定时任务的“双面人”
Linux里定时任务有两大阵营:系统级的cron和应用级的调度框架。cron在/etc/crontab、/etc/cron.d/、用户家目录的crontab -e里;而Java应用常用Quartz、SpringBoot@Scheduled、XXL-JOB,它们的配置分散在代码、配置文件或数据库里。那天我先查cron:
# 查看root用户的crontab crontab -u root -l # 查看系统级crontab cat /etc/crontab # 查看/etc/cron.d/下所有文件 ls -la /etc/cron.d/结果干净得让人失望——没有可疑任务。这时候我转向应用层。WMS系统用的是SpringBoot 2.7 + Quartz,定时任务配置在application-prod.yml里。我直接搜索关键词:
# 在项目jar包解压目录(或源码目录)搜索"cron"、"schedule"、"quartz" grep -r "cron\|@Scheduled\|quartz" ./src/main/resources/ --include="*.yml" --include="*.properties" # 输出关键行: # quartz.job-store-class=org.quartz.impl.jdbcjobstore.JobStoreTX # spring.quartz.properties.org.quartz.jobStore.driverDelegateClass=org.quartz.impl.jdbcjobstore.PostgreSQLDelegate # # 数据同步任务,每5分钟执行一次 # wms.job.stock-sync.cron: "0 0/5 * * * ?"找到了!wms.job.stock-sync.cron这个配置项。但问题来了:0 0/5 * * * ?是标准Quartz表达式,意思是“每5分钟触发一次”,可为什么故障只在上午10:15发生?我立刻意识到,这个cron表达式可能被动态覆盖了,或者有其他条件触发。我让开发同事导出Quartz的数据库表qrtz_triggers,果然发现一条记录:
SELECT TRIGGER_NAME, CRON_EXPRESSION, NEXT_FIRE_TIME FROM qrtz_triggers WHERE TRIGGER_NAME LIKE '%stock%'; -- 结果: -- stock-full-sync-trigger | 0 15 10 * * ? | 1716200100000 (对应2024-05-20 10:15:00)原来,这个任务有两个触发器:一个是每5分钟的基础同步,另一个是每天10:15的全量同步。全量同步的任务逻辑,正是读取那个1.2GB的CSV文件做全量库存导入。而基础同步任务因为数据量小,一直没暴露问题。这个细节,光看配置文件是发现不了的,必须查数据库。这就是为什么我说,排障不能只看表面配置,要深入到任务调度框架的存储层。
2.4 第四层:任务逻辑本身有没有“地雷”?代码级审查
定位到任务后,下一步是看它到底在干什么。我让开发把StockFullSyncJob类的代码发给我。核心逻辑是:
public void execute(JobExecutionContext context) { // 1. 从FTP服务器下载最新CSV String csvPath = downloadFromFTP("stock_full.csv"); // 2. 逐行读取CSV,解析后插入数据库 try (BufferedReader reader = new BufferedReader(new FileReader(csvPath))) { String line; while ((line = reader.readLine()) != null) { // 这里是性能杀手! StockData data = parseLine(line); stockMapper.insert(data); // 单条插入,没批量 } } }问题一目了然:BufferedReader.readLine()在读取1.2GB文件时,会频繁触发GC,且单条插入数据库,网络往返和事务开销巨大。更致命的是,downloadFromFTP方法没有超时控制,如果FTP服务器响应慢,整个任务就会卡住,导致后续所有任务堆积。我立刻让开发加了三重防护:
- 用
Files.lines(Paths.get(csvPath))替代BufferedReader,利用Stream API的惰性求值; - 批量插入,每1000条提交一次事务;
downloadFromFTP加上connectTimeout=30000和readTimeout=60000。
但这只是治标。真正治本,是把这个全量同步任务,从“每天一次”改成“只在数据源变更时触发”,通过监听FTP目录的inotifywait事件来驱动。这才是从根源上消除定时任务的盲目性。
3. 定时任务排障的“黄金工具链”——不是命令越多越好,是组合最准
网上教程动辄列出20个命令,但实战中,真正高频、精准、不可替代的就那么几个。我把它们按“发现问题”、“定位源头”、“验证修复”三个阶段打包成工具链,每个都附上真实场景下的参数详解和避坑点。记住,工具是死的,思路是活的,参数选错,效果差十倍。
3.1 发现问题:pidstat+iotop+dmesg—— 三位一体看IO真相
pidstat我已经介绍过,它是资源消耗的“体温计”。但光知道“热”不够,得知道“热源在哪”。这时候iotop就是X光机:
# 必须用root权限,否则看不到所有进程 sudo iotop -o -b -n 1 # 关键列解读: # TID:线程ID(注意,不是PID!) # PRIO:IO优先级,负数表示高优先级 # READ_RATE / WRITE_RATE:实时IO速率 # COMMAND:线程命令名(常显示为java或python)那天iotop输出里,TID为12346的线程,READ_RATE稳定在80MB/s,COMMAND显示java -Djava...,这和pidstat里java进程的高%iowait完全吻合。但iotop有个致命缺陷:它只显示实时速率,不显示历史累计IO。所以当我想确认“这个线程是不是从10:15开始就一直在读”,就得用dmesg查内核日志:
# 查看最近100行内核日志,过滤IO相关 dmesg -T | grep -i "io\|disk\|ata\|nvme" | tail -100 # 输出里有一行: # [Mon May 20 10:15:03 2024] ata1: exception Emask 0x0 SAct 0x0 SErr 0x0 action 0x0 # 这说明硬盘在10:15:03有异常,但不是错误,是高负载下的正常日志。dmesg的价值在于,它能告诉你IO压力是否触发了内核级别的调度调整。比如如果看到"cfq(Completely Fair Queuing)调度器被激活的日志,就说明IO队列已经拥堵,需要优化IO策略。
提示:
iotop默认刷新间隔是1秒,但在高IO场景下,1秒太长,会错过峰值。用-d 0.5可以设为0.5秒刷新,但会增加CPU开销,建议只在排查时临时使用。
3.2 定位源头:lsof+strace+journalctl—— 穿透进程看文件与系统调用
lsof是“谁在用什么文件”的终极答案。但有时lsof显示的文件路径是符号链接,或者文件已被删除但句柄还在(deleted状态),这时候就需要strace:
# 跟踪java进程12345的系统调用,只关注文件IO sudo strace -p 12345 -e trace=open,openat,read,write,close -s 256 -o /tmp/strace.log 2>&1 & # 等待10秒,然后kill %1停止跟踪 # 查看log,找read系统调用的文件描述符 grep "read(" /tmp/strace.log | head -10 # 输出类似: # read(15, "SKU001,100,InStock\nSKU002,200,..."..., 8192) = 8192read(15, ...)里的15就是文件描述符,再用lsof -p 12345 | grep "15u"就能找到这个fd对应的文件。那天我们就是这么确认,fd 15指向的就是那个1.2GB的CSV文件。
对于systemd管理的服务(比如你用systemctl start myapp启动的),journalctl比tail -f /var/log/myapp.log更可靠,因为它能捕获标准输出、标准错误,甚至内核消息:
# 查看myapp服务最近1小时的日志,按时间倒序 journalctl -u myapp.service --since "1 hour ago" -o short-iso | tail -50 # 关键技巧:用_GID过滤,只看特定用户组的日志(比如所有定时任务都用wms用户运行) journalctl _GID=1001 --since "2024-05-20 10:10:00" --until "2024-05-20 10:20:00"注意:
journalctl默认只保存最近三天日志,生产环境务必修改/etc/systemd/journald.conf,设置SystemMaxUse=2G和MaxRetentionSec=3month,否则排障时日志早没了。
3.3 验证修复:sar+crontab -l+curl健康检查 —— 用数据说话
修复后,不能只说“好了”,要用数据证明。sar(System Activity Reporter)是sysstat包里的历史性能库,它每10分钟自动采集一次系统指标,存在/var/log/sa/下:
# 查看昨天同一时段的CPU和IO统计(sar -u CPU, sar -b IO) sar -u -f /var/log/sa/sa20 | grep "10:1[0-5]" sar -b -f /var/log/sa/sa20 | grep "10:1[0-5]" # 修复前的数据: # 10:15:01 AM 12.34 65.43 10.21 0.00 0.00 12.02 # 修复后的数据: # 10:15:01 AM 3.21 2.15 1.02 0.00 0.00 3.38 # %iowait从65%降到2%,这才是硬指标。同时,用crontab -l确认任务配置已更新,用curl做端到端验证:
# 模拟定时任务触发后的业务效果 curl -s "http://wms-api:8081/api/v1/health/stock-sync" | jq '.status' # 返回"SUCCESS"才算真正闭环。4. 定时任务的“七宗罪”与防御清单——来自血泪教训的12条实操守则
排障的终点不是修复一个bug,而是建立一套防御体系。我把过去十年踩过的坑,总结成定时任务的“七宗罪”,每一条都配了可落地的防御守则。这些不是理论,是我在麒麟系统、阿里云ECS、本地VMware虚拟机上,用血换来的经验。
4.1 罪之一:cron表达式写错,任务“假死”不报错
最常见的错误是0 0 12 * * ?(Quartz)和0 0 12 * * *(cron)混用。前者是6字段(秒分时日月周),后者是5字段(分时日月周),少一个字段,Quartz就认为表达式无效,任务永远不会触发,但日志里只有一行WARN,根本不起眼。
防御守则1:所有cron表达式必须用在线校验器二次验证
- 推荐工具:https://crontab.guru/(针对标准cron)
- Quartz专用:https://www.freeformatter.com/cron-expression-generator-quartz.html
- 实操:把
application.yml里的wms.job.stock-sync.cron粘贴进去,确认它显示的“Next execution”时间符合预期。比如0 15 10 * * ?应该显示“Next: Today at 10:15 AM”。
防御守则2:在任务执行方法里,第一行强制打日志
@Slf4j @Component public class StockSyncJob { @Scheduled(cron = "${wms.job.stock-sync.cron}") public void syncStock() { log.info("【定时任务启动】StockSyncJob 开始执行,当前时间:{}", LocalDateTime.now()); // 后续逻辑... } }这样,哪怕任务没触发,日志里也会缺这条记录,运维一眼就能发现。
4.2 罪之二:任务没加超时,一个失败拖垮全局
那个FTP下载没超时的例子,就是典型。任务卡住,Quartz线程池被占满,其他所有任务排队等待,形成雪崩。
防御守则3:所有外部依赖(HTTP、FTP、DB)必须设超时
// FTPClient示例 FTPClient ftp = new FTPClient(); ftp.setConnectTimeout(30000); // 连接超时30秒 ftp.setDataTimeout(60000); // 数据传输超时60秒 ftp.setSoTimeout(60000); // Socket读超时60秒 // Spring RestTemplate示例 RestTemplate restTemplate = new RestTemplate(); HttpComponentsClientHttpRequestFactory factory = new HttpComponentsClientHttpRequestFactory(); factory.setConnectTimeout(5000); factory.setReadTimeout(10000); restTemplate.setRequestFactory(factory);防御守则4:任务方法用@Async或独立线程池隔离
// 不要这样(共用Web线程池) @Scheduled(cron = "0 */5 * * * ?") public void badSync() { ... } // 要这样(用专用线程池) @Scheduled(cron = "0 */5 * * * ?") @Async("taskExecutor") // 指向配置好的线程池 public void goodSync() { ... } // 配置线程池 @Bean("taskExecutor") public Executor taskExecutor() { ThreadPoolTaskExecutor executor = new ThreadPoolTaskExecutor(); executor.setCorePoolSize(2); // 核心线程数2,防止单个任务占满 executor.setMaxPoolSize(5); // 最大5,留余量 executor.setQueueCapacity(10); // 队列容量10,超了就拒绝 executor.setThreadNamePrefix("async-task-"); return executor; }4.3 罪之三:日志没分级,关键信息被淹没
任务里log.info("处理完成")和log.error("FTP连接失败", e)混在一起,当任务每5分钟执行一次,一天就是288条INFO日志,错误日志埋在里面,肉眼根本找不到。
防御守则5:为定时任务单独配置Logback日志文件
<!-- logback-spring.xml --> <appender name="TASK_FILE" class="ch.qos.logback.core.rolling.RollingFileAppender"> <file>logs/task.log</file> <rollingPolicy class="ch.qos.logback.core.rolling.TimeBasedRollingPolicy"> <fileNamePattern>logs/task.%d{yyyy-MM-dd}.%i.log</fileNamePattern> <timeBasedFileNamingAndTriggeringPolicy class="ch.qos.logback.core.rolling.SizeAndTimeBasedFNATP"> <maxFileSize>100MB</maxFileSize> </timeBasedFileNamingAndTriggeringPolicy> </rollingPolicy> <encoder> <pattern>%d{HH:mm:ss.SSS} [%thread] %-5level %logger{36} - %msg%n</pattern> </encoder> </appender> <logger name="com.example.wms.job" level="INFO" additivity="false"> <appender-ref ref="TASK_FILE"/> </logger>这样,所有定时任务日志都在task.log里,grep "ERROR" logs/task.log就能直达问题。
防御守则6:关键步骤加“耗时埋点”
long start = System.currentTimeMillis(); log.info("【任务启动】开始下载FTP文件"); String csvPath = downloadFromFTP("stock.csv"); log.info("【任务耗时】FTP下载完成,耗时{}ms", System.currentTimeMillis() - start); start = System.currentTimeMillis(); log.info("【任务启动】开始解析CSV"); parseAndInsert(csvPath); log.info("【任务耗时】CSV解析完成,耗时{}ms", System.currentTimeMillis() - start);当task.log里出现“FTP下载完成,耗时120000ms”,你就知道该查FTP了。
4.4 罪之四:没做幂等,重复执行引发数据错乱
全量同步任务如果被意外触发两次,数据库里就会有重复库存记录,WMS系统直接崩。
防御守则7:所有写操作前,先加分布式锁
// 用Redis实现简单锁 public boolean tryLock(String lockKey, long expireSeconds) { String value = UUID.randomUUID().toString(); Boolean locked = redisTemplate.opsForValue() .setIfAbsent(lockKey, value, expireSeconds, TimeUnit.SECONDS); return locked != null && locked; } // 在任务开头加锁 if (!tryLock("stock-full-sync-lock", 3600)) { log.warn("【任务拒绝】全量同步锁已被占用,本次跳过"); return; }防御守则8:任务状态表+唯一索引防重
CREATE TABLE job_execution_log ( id BIGINT PRIMARY KEY AUTO_INCREMENT, job_name VARCHAR(100) NOT NULL, trigger_time DATETIME NOT NULL, status ENUM('STARTED', 'SUCCESS', 'FAILED') NOT NULL, created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, UNIQUE KEY uk_job_time (job_name, trigger_time) );每次任务执行前,先INSERT INTO job_execution_log (job_name, trigger_time, status) VALUES ('stock-full-sync', NOW(), 'STARTED'),如果唯一索引冲突,说明已执行,直接return。
4.5 罪之五:资源没限制,一个任务吃光整机
那个1.2GB CSV读取,就是典型的内存和IO失控。BufferedReader默认缓冲区8KB,读1.2GB要分配15万次缓冲区,GC压力山大。
防御守则9:JVM启动参数强制限制内存
# 启动脚本里,必须指定-Xmx和-Xms java -Xms512m -Xmx2g -XX:+UseG1GC -jar wms.jar-Xmx2g是底线,再小,Quartz线程池和业务代码就抢内存了。
防御守则10:用cgroups限制单个进程的IO和CPU(适用于容器或物理机)
# 创建一个cgroup,限制java进程的IO带宽为50MB/s sudo cgcreate -g blkio:/wms-job echo "8:0 52428800" | sudo tee /sys/fs/cgroup/blkio/wms-job/blkio.weight_device # 把java进程加入cgroup echo 12345 | sudo tee /sys/fs/cgroup/blkio/wms-job/cgroup.procs这样,就算任务逻辑有缺陷,最多也只能吃掉50MB/s的IO,不会拖垮整台服务器。
4.6 罪之六:没做监控告警,问题发生后才被动响应
靠人盯监控太原始。我们必须让系统自己“喊疼”。
防御守则11:为每个定时任务配置Prometheus指标
// Micrometer + Prometheus @Component public class JobMetrics { private final Counter successCounter = Counter.builder("job.success") .description("Job execution success count") .register(Metrics.globalRegistry); private final Timer executionTimer = Timer.builder("job.execution.time") .description("Job execution time in seconds") .register(Metrics.globalRegistry); public void recordSuccess(String jobName) { successCounter.tag("job", jobName).increment(); } public void recordExecutionTime(String jobName, long durationMs) { executionTimer.tag("job", jobName).record(durationMs, TimeUnit.MILLISECONDS); } }然后在/actuator/prometheus端点里,就能看到job_execution_time_seconds_count{job="stock-full-sync"}这样的指标,用Grafana画图,设置告警:如果rate(job_execution_time_seconds_sum[1h]) / rate(job_execution_time_seconds_count[1h]) > 300(平均耗时超5分钟),就发企业微信告警。
4.7 罪之七:没做灰度发布,新任务直接上生产
新写的定时任务,第一版就部署到生产,风险极大。
防御守则12:所有新定时任务,必须走“三步灰度”
- Step 1:本地开发机验证
用@Profile("dev")注解,只在开发环境生效,且cron设为0 * * * * ?(每分钟一次),快速验证逻辑。 - Step 2:测试环境全量跑
在测试环境用真实数据源,跑满24小时,观察task.log和jstat输出。 - Step 3:生产环境“单实例+低频”首发
先在一台服务器上部署,cron改为0 15 10 * * 1-5(工作日10:15),观察一周无异常,再推全量。
5. 常见问题速查表与独家避坑技巧——那些文档里不会写的细节
最后,我把排障过程中最常遇到的10个“ WTF”问题,整理成速查表。每个问题都附上真实原因、排查命令、和一句“我试过最有效的解决办法”。
| 问题现象 | 可能原因 | 关键排查命令 | 我的独家技巧 |
|---|---|---|---|
top里CPU很高,但pidstat -u显示%usr很低 | 进程在等IO,不是真正在计算 | pidstat -u -r -w 1 | 看%iowait列,>5%就查iotop |
crontab -e改了,但任务没按新时间执行 | crond服务没重载,或用户crontab没生效 | sudo systemctl restart crond;crontab -l确认内容 | 改完立刻执行sudo systemctl status crond,看日志里有没有RELOAD字样 |
SpringBoot@Scheduled不执行,日志也没报错 | @EnableScheduling注解没加,或类没被Spring管理 | grep -r "@EnableScheduling" src/;grep -r "@Component" src/ | 在启动日志里搜"Scheduling",应该有"Started Scheduler字样 |
| 任务执行很快,但数据库里数据没更新 | 事务没提交,或用了@Transactional但方法是private | grep -r "@Transactional" src/;检查方法访问修饰符 | 把方法改成public,或用TransactionTemplate手动控制事务 |
lsof显示文件deleted,但任务还在读 | 文件被rm删除,但进程还持有句柄,空间没释放 | lsof -p PID | grep deleted | 重启对应进程,或用echo > /proc/PID/fd/FD_NUM清空句柄(高危,慎用) |
strace输出太多,找不到关键系统调用 | 默认跟踪所有系统调用,噪音太大 | strace -p PID -e trace=open,read,write,close | 加-s 256显示完整字符串,加-o file.log重定向到文件 |
journalctl查不到定时任务日志 | 日志级别设太高,或服务没用systemd管理 | journalctl -u service-name -o verbose;systemctl cat service-name | 在/etc/systemd/system/service-name.service里,确认StandardOutput=journal |
sar数据里IO等待很高,但iotop没看到大IO进程 | IO发生在内核态,比如RAID卡重建、SSD垃圾回收 | iostat -x 1 5;cat /proc/diskstats | 看iostat的%util列,>100%说明设备饱和,不是进程问题 |
国产麒麟系统里cron不执行,但CentOS上正常 | 麒麟默认禁用cron,或SELinux策略不同 | sudo systemctl status cron;sudo setsebool -P cron_can_network on | 麒麟系统务必执行sudo systemctl enable cron && sudo systemctl start cron |
| 虚拟机里Ubuntu的定时任务总比物理机慢1分钟 | 虚拟机时间不同步,导致cron触发时间偏移 | timedatectl status;sudo ntpdate pool.ntp.org | 在VMware设置里,勾选“启用客户机时间同步”,并用chrony替代ntpd |
实操心得:我曾经为一个
@Scheduled任务调试了3小时,最后发现是cron表达式里用了中文逗号“,”而不是英文逗号“,”,IDEA没报错,但Spring解析时直接忽略整个表达式。从此我养成习惯:所有定时任务配置,一律用vim打开,用:set list显示不可见字符,确保标点全是ASCII。
6. 写在最后:排障不是技术,是建立对系统的“肌肉记忆”
两小时定位那个定时任务,听起来很快,但背后是十年积累的“肌肉记忆”:看到%iowait高,手指就自动敲出iotop;看到readBytes,脑子就跳出BufferedReader的缓冲区陷阱;看到qrtz_triggers表,就知道要去查数据库而不是配置文件。这种直觉,