你有没有遇到过这种灵异事件:
线上定时任务显示“执行成功”,但业务数据没变化?或者任务日志里只有一半的记录?
排查半天,最后发现竟然是某位新同事在本地启动服务,误连了数据库,任务被他的笔记本电脑抢走执行了!
而在默认的 Quartz 配置下,你在数据库里只能看到一串类似 DESKTOP-8A... 的乱码,根本不知道是哪台机器在捣乱。
今天,我们通过自定义实例 ID 生成策略,彻底解决“节点身份识别”难题,让你的集群监控一目了然。


一、 致命场景:为什么默认策略是“埋雷”?

Quartz 默认的 org.quartz.scheduler.instanceIdAUTO,生成规则大致是“主机名+时间戳”。在以下场景中,这简直是灾难:

1. “谁偷了我的任务?”(开发误连场景)

  • 现象:生产环境有3台机器,但某个关键任务偶尔不执行,或者执行了但没产生文件(因为文件生成在开发人员的 C 盘里)。
  • 默认配置:查看 QRTZ_SCHEDULER_STATE 表,看到一个节点叫 DESKTOP-U1N23_1638244
  • 困局:你不知道这是谁的电脑。你只能群里吼:“谁连了生产库?赶紧退出来!”
  • 期望:如果 ID 显示 zhangsan@192.168.0.5#7612,你直接就能拿着 主机名或IP 去找人了。

2. “刚才挂的是哪台?”(故障溯源场景)

  • 现象:集群中某台机器因为 OOM 宕机了,其他健康节点触发了故障转移(Recovery)。
  • 默认配置:日志显示 Instance [docker-a1b2c_99212] failed.
  • 困局:在容器化环境或物理机集群中,单纯的主机名往往不够直观。你需要去翻 CMDB 才能知道是哪台物理机。
  • 期望:如果 ID 是 node_1@10.20.1.5#1732,不用查表,直接 SSH 连上去排查。

二、 核心目标

我们需要一个可读性强包含物理位置信息能区分环境的 ID 生成策略。

将策略调整为 机器名 (HostName) + IP + PID 是最实用的组合:

  1. 机器名 (HostName):在办公网环境下,电脑名通常就是员工名(如 zhangsan-PC),直接破案;在服务器环境下,主机名通常带有业务标识(如 prod-job-01)。
  2. IP:辅助确认网络环境(区分内网/外网/容器段)。
  3. PID:解决单机多活、重启瞬间的锁冲突。

理想格式主机名称 + @ + IP地址 + # + PID
例如:

  • 生产节点:task_node1@10.16.26.100#3212
  • 开发误连:zhangsan-PC@192.168.1.105#18823 (一眼识别内网混入)

为什么“机器名 + IP + PID”是黄金组合

在默认配置下,Quartz 使用单纯的主机名或自动生成的 UUID,这在以下场景非常无力:

  1. 办公网误连(抓人场景)
    • 如果只显示 IP 192.168.1.105,你需要去查 DHCP 记录才知道是谁。
    • 如果显示 zhangsan-PC@192.168.1.105,直接就知道是张三连了生产库,当场抓获。
  2. 服务器运维(定位场景)
    • 如果显示 prod-job-node-01@10.0.8.5,运维人员不需要查 CMDB 就能直接 SSH 到对应机器。
  3. 单机防撞(冲突场景)
    • 加上 #PID,即使在同一台服务器上启动了两个实例(如蓝绿发布或本地多开调试),也能保证 ID 唯一,避免集群锁异常。

三、 核心代码实现

1. 编写自定义生成器

我们复用之前高健壮性的 IP 获取逻辑,并调整 ID 拼接规则为:HostName + @ + IP + # + PID

package com.example.quartz.config;

import org.quartz.SchedulerException;
import org.quartz.spi.InstanceIdGenerator;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;

import java.io.File;
import java.io.IOException;
import java.lang.reflect.Method;
import java.net.Inet4Address;
import java.net.InetAddress;
import java.net.NetworkInterface;
import java.net.SocketException;
import java.net.UnknownHostException;
import java.util.ArrayList;
import java.util.Enumeration;
import java.util.List;

/**
 * 自定义 Quartz 实例 ID 生成器
 * 策略:机器名 + IP + PID
 * 示例:prod-job-01@10.0.4.12#12048 或 zhangsan-PC@192.168.1.50#8080
 */
public class HostNameIpPidInstanceIdGenerator implements InstanceIdGenerator {

    private static final Logger log = LoggerFactory.getLogger(HostNameIpPidInstanceIdGenerator.class);

    @Override
    public String generateInstanceId() throws SchedulerException {
        try {
            // 1. 获取机器名 (HostName),直接定位是谁的机器
            String hostName = getHostName();

            // 2. 获取准确的局域网 IP (复用健壮性校验逻辑)
            String localIp = getLocalIpAddress();

            // 3. 获取进程 ID (PID),防止单机多实例冲突
            String pid = getProcessId();

            // 4. 拼接 ID (注意数据库字段长度限制,通常200字符足够)
            String instanceId = hostName + "@" + localIp + "#" + pid;

            log.info(">>> Quartz Cluster Identity: [{}]", instanceId);
            return instanceId;

        } catch (Exception e) {
            throw new SchedulerException("InstanceId 生成失败", e);
        }
    }

    /**
     * 获取机器名 (HostName)
     */
    private String getHostName() {
        try {
            return InetAddress.getLocalHost().getHostName();
        } catch (UnknownHostException e) {
            return "UNKNOWN_HOST";
        }
    }

    public static final String DEFAULT_PROCESSID = "-";

    /**
     * 获取进程 PID
     */
    public static String getProcessId() {
        try {
            // LOG4J2-2126 use reflection to improve compatibility with Android Platform which does not support JMX extensions
            final Class<?> managementFactoryClass = Class.forName("java.lang.management.ManagementFactory");
            final Method getRuntimeMXBean = managementFactoryClass.getDeclaredMethod("getRuntimeMXBean");
            final Class<?> runtimeMXBeanClass = Class.forName("java.lang.management.RuntimeMXBean");
            final Method getName = runtimeMXBeanClass.getDeclaredMethod("getName");

            final Object runtimeMXBean = getRuntimeMXBean.invoke(null);
            final String name = (String) getName.invoke(runtimeMXBean);
            //String name = ManagementFactory.getRuntimeMXBean().getName(); //JMX not allowed on Android
            // 返回格式通常为 "pid@hostname"
            return name.split("@")[0]; // likely works on most platforms
        } catch (final Exception ex) {
            try {
                return new File("/proc/self").getCanonicalFile().getName(); // try a Linux-specific way
            } catch (final IOException ignoredUseDefault) {
                // Ignore exception.
            }
        }
        return DEFAULT_PROCESSID + System.currentTimeMillis() % 100000;
    }

    /**
     * 健壮的 IP 获取逻辑 (排除回环与IPv6,获取真实局域网IP)
     */
    private static String getLocalIpAddress() {
        List<String> ips = getLocalHostIPs();
        // 当有多个网卡时,默认使用最后一个(避免当服务器上有docker服务时,获取到docker虚拟出网卡的ip)
        return ips.get(ips.size() - 1);
    }
    /**
     * 获取本机所有IP (排除回环与IPv6,获取真实局域网IP)
     */
    public static List<String> getLocalHostIPs() {
        List<String> res = new ArrayList<String>();
        Enumeration<?> netInterfaces;
        try {
            netInterfaces = NetworkInterface.getNetworkInterfaces();
            InetAddress ip = null;
            while (netInterfaces.hasMoreElements()) {
                NetworkInterface ni = (NetworkInterface) netInterfaces.nextElement();
                if (ni.isLoopback() || !ni.isUp() || ni.isVirtual()) {
                    // 跳过回环、非激活、虚拟网卡
                    continue;
                }
                Enumeration<?> nii = ni.getInetAddresses();
                while (nii.hasMoreElements()) {
                    ip = (InetAddress) nii.nextElement();
                    String hostAddress = ip.getHostAddress();
                    if (ip instanceof Inet4Address && ip.isSiteLocalAddress() && !ip.isLoopbackAddress()) {
                        res.add(hostAddress);
//                        System.out.println("本机的ip=" + hostAddress);
                    }
                }
            }
        } catch (SocketException e) {
            e.printStackTrace();
        }
        return res;
    }

}

2. 配置 Spring Boot 生效

在 application.yml 或 quartz.properties 或 自定义bean 中指定这个类(根据自己项目选以下一种方式即可)。

方式 A:YAML 配置 (推荐)

application.yml

spring:
  quartz:
    properties:
      org.quartz.scheduler.instanceId: AUTO
      # 指定上述自定义类
      org.quartz.scheduler.instanceIdGenerator.class: com.example.quartz.config.HostNameIpPidInstanceIdGenerator
      org.quartz.jobStore.isClustered: true

方式 B:quartz.properties 配置

org.quartz.scheduler.instanceId = AUTO
org.quartz.scheduler.instanceIdGenerator.class=com.example.quartz.config.HostNameIpPidInstanceIdGenerator
org.quartz.scheduler.skipUpdateCheck = true
org.quartz.jobStore.isClustered=true

方式 C:Java Config 配置
如果你是手动创建 SchedulerFactoryBean:

@Bean
public SchedulerFactoryBean schedulerFactoryBean() {
    SchedulerFactoryBean factory = new SchedulerFactoryBean();
    Properties props = new Properties();
    // ... 其他配置
    props.put("org.quartz.scheduler.instanceId", "AUTO");
    props.put("org.quartz.scheduler.instanceIdGenerator.class", "com.example.quartz.config.CustomQuartzInstanceIdGenerator");
    
    factory.setQuartzProperties(props);
    return factory;
}

四、 效果验证:一张表看透集群状态

让我们直接查看数据库 QRTZ_SCHEDULER_STATE 表,对比改造前后的差异。

改造前(默认 AUTO):一头雾水
| SCHED_NAME | INSTANCE_NAME | LAST_CHECKIN_TIME | CHECKIN_INTERVAL |
| :--- | :--- | :--- | :--- |
| QuartzScheduler | DESKTOP-A1B2_168001 | 16843050000 | 7500 |
| QuartzScheduler | DESKTOP-A1B2_168002 | 16843050000 | 7500 |

问题:DESKTOP-A1B2 是哪台机器?为什么有两个?是重启产生的僵尸节点,还是真的跑了两个进程?根本分不清。

改造后(HOSTNAME+ IP + PID):一目了然

SCHED_NAMEINSTANCE_NAMELAST_CHECKIN_TIMECHECKIN_INTERVAL
QuartzSchedulerprod-job-01@10.20.1.5#8821168430500007500
QuartzSchedulerprod-job-02@10.20.1.6#8821168430500007500
QuartzSchedulerLisi-MacBook@192.168.1.50#12099168430500007500

真相大白:
一看第三行,HostName 显示 Lisi-MacBook,IP 是 192.168 网段。
根本不用查 IP 归属,直接给李四打电话:“把你本地服务停了!连错库了!”


五、 总结

自定义 InstanceID 不仅仅是为了好看,它是生产安全的一道防线。

  1. 可溯源:直接通过 IP 定位物理机/容器。
  2. 防误连:通过 IP 段快速区分 生产节点 vs 办公网节点。
  3. 快恢复:明确的节点标识有助于快速判断集群健康度。

别等出了事故再改代码,今天就给你的 Quartz 加上这个配置吧!


Q.E.D.


寻门而入,破门而出