1. 为什么“多台服务器重复调度”不是Bug而是XXL-JOB的默认行为逻辑刚接手公司老系统时我被一个报警钉得头皮发麻凌晨三点订单对账任务连续触发了7次——日志里清清楚楚写着7台应用服务器各自执行了一次数据库里生成了7份完全重复的对账结果。运维同事甩来截图语气里带着三分质疑“你们XXL-JOB是不是没配对”我当时第一反应是查配置、翻文档、重装调度中心……折腾两天无果最后蹲在源码里扒了6小时才真正看懂一件事XXL-JOB本身从不承诺“单机执行”它只保证“任务可被调度”而“谁来执行”这件事压根就交给了你——不是框架忘了做而是它根本就没打算替你做决策。这恰恰是绝大多数人踩坑的起点把XXL-JOB当成Quartz集群版来用期待它像数据库主从一样自动选主、自动锁表、自动剔除副本。但现实是XXL-JOB的调度中心xxl-job-admin和执行器xxl-job-executor之间是典型的“发布-订阅”松耦合模型。调度中心只负责在指定时间点向所有在线执行器广播一次调度请求至于这10台执行器里谁接、谁不接、谁先接、谁后接——全由执行器自己决定。它甚至不关心你有没有加锁、有没有判重、有没有心跳检测。这种设计哲学很务实它把复杂度让渡给业务层换来的是极高的部署灵活性和故障隔离性。你可以让5台机器都跑同一个任务做容灾也可以让20台机器各跑不同任务做水平扩展框架不干预也不兜底。所以“重复调度”从来就不是XXL-JOB的缺陷而是它留给你的接口契约——就像Java里的Runnable接口不保证线程安全一样XXL-JOB的XxlJob(order-check)注解也不保证执行唯一性。你看到的“重复”其实是10个独立进程在同一时刻收到了同一份指令然后各自调用了自己的execute()方法。没有锁、没有协调、没有仲裁只有纯粹的广播。理解这一点才能跳出“框架有问题”的思维陷阱转而思考我该在哪一层、用什么机制、以什么代价去实现我真正需要的“有且仅有一次执行”这个问题的答案决定了你后续所有技术选型和架构设计的底层逻辑。提示不要试图在调度中心层面“禁止多台执行”那是违背XXL-JOB设计初衷的。正确路径永远是在执行器侧构建幂等与互斥机制。调度中心只管“发令”执行器必须学会“接令判重执行”。2. 四种落地方案的实测对比从数据库锁表到Redis分布式锁的取舍真相既然核心矛盾在执行器侧那解决方案就集中在“如何让多台机器在收到同一调度指令后只有一台真正干活”。我实测过四种主流方案每一种都在生产环境跑过至少3个月下面直接说结论、说参数、说踩过的坑不讲虚的。2.1 方案一MySQL for update 行锁最简单但最危险原理很简单在任务执行前先用SELECT ... FOR UPDATE锁定一张专用的任务锁表中某一行比如task_name order-check成功拿到锁的机器执行任务失败的直接return。代码骨架如下Transactional public void execute(String param) { // 尝试获取锁 int lockResult taskLockMapper.tryLock(order-check); if (lockResult 0) { log.warn(Task order-check locked by another instance, skip.); return; } try { // 执行核心业务逻辑 doOrderCheck(); } finally { // 必须释放锁实际是靠事务提交自动释放 taskLockMapper.unlock(order-check); } }对应的SQL-- 锁表结构 CREATE TABLE xxl_job_lock ( task_name varchar(100) NOT NULL PRIMARY KEY, locked_at datetime DEFAULT CURRENT_TIMESTAMP, locked_by varchar(50) DEFAULT NULL ) ENGINEInnoDB; -- 获取锁注意必须走主键索引 SELECT * FROM xxl_job_lock WHERE task_name order-check FOR UPDATE; -- 更新锁持有者可选用于监控 UPDATE xxl_job_lock SET locked_by server-01, locked_at NOW() WHERE task_name order-check;实测数据QPS 5010台执行器平均获取锁耗时8~12ms锁竞争失败率32%即约1/3的调度请求被直接跳过数据库CPU峰值45%集中在InnoDB行锁队列致命缺陷死锁高发当多个任务共用同一张锁表且执行顺序不一致时比如A任务先锁a再锁bB任务先锁b再锁a极易触发MySQL死锁检测导致事务回滚、任务丢失。我们曾因此在促销期间漏跑3笔千万级对账。单点瓶颈所有任务争抢同一张表的同一行数据库成了整个调度链路的木桶短板。一旦DB抖动所有任务集体卡住。锁粒度粗FOR UPDATE本质是行锁但如果你用LIKE或非主键字段查询会升级为表锁整张表被堵死。注意此方案仅适用于低频、非核心任务如每日报表生成。绝不可用于支付、对账、库存扣减等强一致性场景。我把它列为“新手速通版”用完务必换掉。2.2 方案二ZooKeeper临时节点强一致性但运维成本爆炸ZK的createEphemeral操作天然具备“创建成功即获得锁”的原子语义且Session断开自动清理非常适合分布式锁。我们曾用它支撑过金融级对账系统99.999%可用性。核心逻辑// 使用Curator框架 InterProcessMutex lock new InterProcessMutex(client, /locks/order-check); if (lock.acquire(3, TimeUnit.SECONDS)) { try { doOrderCheck(); } finally { lock.release(); } } else { log.info(Failed to acquire ZK lock, skip execution.); }优势绝对可靠ZK的ZAB协议保证了锁的强一致性不存在脑裂、假释放等问题。自动续期Curator的InterProcessMutex内置心跳保活避免因GC停顿导致锁误释放。可观测性强/locks/路径下实时可见所有锁状态运维排查一目了然。血泪教训ZK集群必须奇数节点我们最初用2节点ZK网络分区时出现双主两个执行器同时拿到锁导致资金重复划拨。连接池配置反直觉Curator默认连接池大小为1高并发下大量线程阻塞在acquire()上。必须显式设置client CuratorFrameworkFactory.builder() .connectString(zk1:2181,zk2:2181,zk3:2181) .connectionTimeoutMs(3000) .sessionTimeoutMs(6000) .retryPolicy(new ExponentialBackoffRetry(1000, 3)) .build(); // 关键增加连接数 client.getCuratorListenable().addListener(new ConnectionStateListener() { Override public void stateChanged(CuratorFramework client, ConnectionState newState) { if (newState ConnectionState.CONNECTED) { client.getZookeeperClient().getZooKeeper().getTestable().setConnectCount(10); } } });ZK不是万能胶它解决的是“谁先拿到锁”但不解决“拿到锁后执行失败怎么办”。必须配套实现任务状态机RUNNING → SUCCESS/FAIL → CLEANUP否则ZK节点残留会导致后续调度永久阻塞。2.3 方案三Redis SETNX Lua脚本平衡之选90%场景首选这是目前我们线上主力方案兼顾性能、可靠性与开发成本。核心是利用Redis的SET key value NX PX timeout命令——原子性地设置key、判断是否存在、设置过期时间三合一。但单纯用SETNX有个致命问题如果执行器A拿到锁后崩溃Redis里key永不过期任务永远卡死。所以必须搭配Lua脚本实现“加锁续期释放”原子操作。我们最终采用的方案是Redission的RLock但做了关键改造禁用自动续期Redission默认每30秒续期一次但我们的任务最长执行2小时频繁续期加重Redis压力。改为手动续期RLock lock redisson.getLock(xxl:job:lock:order-check); if (lock.tryLock(0, 2, TimeUnit.HOURS)) { // 等待0秒锁有效期2小时 try { // 执行前先延长锁有效期防止超时 lock.lock(2, TimeUnit.HOURS); doOrderCheck(); } finally { if (lock.isHeldByCurrentThread()) { lock.unlock(); } } }关键参数实测值Redis 6.2集群3主3从指标数值说明加锁平均耗时0.8ms比MySQL快15倍锁竞争失败率5.2%10台机器下极低Redis CPU占用12%集群负载均衡良好故障恢复时间1s主从切换不影响锁服务必须规避的三个坑不要用DEL直接删锁这是经典错误。A拿到锁B在A执行中尝试解锁DEL会误删A的锁。必须用Lua校验value再删除if redis.call(get, KEYS[1]) ARGV[1] then return redis.call(del, KEYS[1]) else return 0 end过期时间必须大于任务最大执行时间我们曾设为30分钟结果一笔对账任务因数据库慢查询跑了35分钟锁自动释放B机器拿到锁重复执行。现在规则是锁超时 任务预估最大耗时 × 1.5。Redis连接池要够大JedisPoolConfig.setMaxTotal(200)是底线否则高并发下连接等待导致锁获取延迟飙升。2.4 方案四XXL-JOB原生分片被严重低估的官方方案很多人不知道XXL-JOB从2.3.0版本起就内置了分片能力它不是用来“分摊负载”而是天然解决“多机互斥”问题的钥匙。原理是调度中心在触发任务时会把shardingParam如0/10作为参数传给所有执行器每个执行器根据自己的address哈希值与总分片数取模决定是否执行。配置方式极其简单调度中心任务配置页勾选“分片广播”在执行器代码里加判断XxlJob(order-check) public void execute(String param) { // 解析分片参数index0, total10 ShardingVO shardingVO XxlJobHelper.getShardingVO(); String address XxlJobHelper.getExecutorAddress(); int shardIndex Math.abs(address.hashCode()) % shardingVO.getTotal(); if (shardIndex ! shardingVO.getIndex()) { log.info(Skip execution: not my shard. index{}, total{}, shardingVO.getIndex(), shardingVO.getTotal()); return; } doOrderCheck(); // 只有shardIndex匹配的机器才执行 }优势直击痛点零依赖不用额外引入Redis/ZK纯XXL-JOB原生能力。绝对精准哈希算法保证同一任务在任意时刻全球只有且仅有一台机器满足shardIndex index条件。动态伸缩新增执行器后调度中心自动重新分配分片无需人工干预。适用边界仅适用于可水平切分的任务。比如“遍历用户表做风控扫描”可按user_id取模分片但“生成昨日全站GMV报表”这种全局聚合任务就不适合分片。分片数需提前规划total设太小如2扩容到20台机器后大部分机器永远闲置设太大如100单台机器可能承担多个分片失去负载均衡意义。我们经验公式是分片总数 执行器实例数 × 1.5。总结选型口诀临时验证/低频任务 → MySQL锁表快上手金融级强一致 → ZooKeeper贵但稳通用主力方案 → Redis Lua性价比之王可分片业务 → XXL-JOB原生分片最省心3. Redis分布式锁的深度避坑从SETNX到Redlock的实战演进选择Redis方案后真正的挑战才刚开始。我见过太多团队在SETNX上栽跟头不是锁失效就是死锁根源在于没吃透Redis的原子性边界和网络分区下的行为。下面把我三年踩过的坑浓缩成五条铁律。3.1 铁律一永远用SET key value NX PX timeout而不是SETNX EXPIRE这是教科书级错误。看似两步操作等价但中间存在微小时间窗口# 危险写法 127.0.0.1:6379 SETNX lock:order-check 1 (integer) 1 127.0.0.1:6379 EXPIRE lock:order-check 30 (integer) 1如果SETNX成功后EXPIRE命令因网络超时未到达Redis这个key就成了永不过期的“僵尸锁”整个任务永久阻塞。而SET ... NX PX是Redis单命令原子执行彻底规避此风险。实操验证我故意在SETNX后注入100ms网络延迟模拟EXPIRE失败场景复现了17次锁永久占用。换成SET单命令后连续压测10万次零失败。3.2 铁律二锁Value必须是唯一标识且带时间戳很多团队用固定字符串如locked作为value这会导致严重问题A机器加锁 →SET lock:order-check locked NX PX 30000A机器执行超时锁自动过期B机器加锁成功A机器终于执行完执行DEL lock:order-check→ 误删B的锁正确做法是value设为UUID时间戳String lockValue UUID.randomUUID().toString() _ System.currentTimeMillis(); // 加锁 jedis.set(lock:order-check, lockValue, NX, PX, 30000); // 释放锁Lua校验 String script if redis.call(get, KEYS[1]) ARGV[1] then return redis.call(del, KEYS[1]) else return 0 end; jedis.eval(script, Collections.singletonList(lock:order-check), Collections.singletonList(lockValue));为什么必须带时间戳排查时可通过value快速定位是哪次调度、哪个实例持有的锁防止因时钟漂移导致的误删不同机器时间差过大时UUID无法保证唯一性3.3 铁律三Redlock不是银弹多数场景用单Redis就够了Martin Kleppmann曾撰文质疑Redlock算法在时钟漂移下的安全性而AntirezRedis作者回应称其在真实网络中足够可靠。但对我们而言更关键的是Redlock带来的复杂度提升是否值得Redlock要求同时向5个独立Redis节点请求锁全部成功才算获取锁。我们实测发现5节点网络RTT平均增加42ms单节点2ms → Redlock 44ms节点故障时锁获取成功率从99.99%降至92.3%因需5/5成功运维成本翻倍5套Redis集群的备份、监控、升级全部要同步最终我们砍掉了Redlock理由很实在我们的Redis是3主3从集群单节点故障自动切主SLA 99.95%已满足业务需求任务本身具备幂等性对账结果写入时有唯一约束即使极小概率锁失效重复执行也无副作用开发和运维同学更熟悉单Redis操作出问题能3分钟定位提示Redlock只推荐用于“锁失效即导致资损”的场景如秒杀库存扣减。普通定时任务单Redis合理超时策略比Redlock更稳。3.4 铁律四锁续期必须用守护线程且心跳间隔要小于锁超时1/3Redission的自动续期很香但生产环境必须自己掌控。我们曾因GC停顿导致续期失败锁被释放引发重复执行。正确续期姿势// 启动守护线程 ScheduledExecutorService scheduler Executors.newSingleThreadScheduledExecutor(); scheduler.scheduleAtFixedRate(() - { try { // 只有当前线程持有锁才续期 if (lock.isHeldByCurrentThread()) { lock.lock(30, TimeUnit.SECONDS); // 延长30秒 } } catch (Exception e) { log.error(Failed to renew lock, e); } }, 10, 10, TimeUnit.SECONDS); // 每10秒续一次锁超时设30秒为什么是10秒锁超时30秒 → 续期间隔必须≤10秒1/3原则网络抖动容忍10秒内最多允许2次心跳失败GC安全边际CMS GC通常5秒G1 GC 1秒10秒间隔足够覆盖3.5 铁律五永远在finally块里释放锁且检查持有状态这是最常被忽略的细节。看这段危险代码lock.lock(30, TimeUnit.SECONDS); try { doOrderCheck(); } catch (Exception e) { log.error(Task failed, e); throw e; // 异常抛出lock.unlock()永远不会执行 }正确写法必须双重保险boolean locked false; try { locked lock.tryLock(0, 30, TimeUnit.SECONDS); if (!locked) return; doOrderCheck(); } finally { if (locked lock.isHeldByCurrentThread()) { lock.unlock(); } }补充技巧在unlock()后加日志log.debug(Released lock for {}, taskName)便于追踪锁生命周期对unlock()做异常捕获Redis连接闪断时unlock()可能抛RedisConnectionFailureException但不影响业务需静默处理4. 从代码到监控构建可观测的分布式任务执行全景视图解决了“不重复”下一步是“可看见”。我见过太多团队锁机制跑通了但一出问题就抓瞎不知道是锁没拿到还是拿到了但执行卡死还是执行完了但结果没写入没有监控等于裸奔。4.1 执行器侧埋点5个必打的日志黄金位点日志不是越多越好而是要在关键决策点留下不可篡改的证据链。我们在每个XxlJob方法里强制植入以下5个日志点XxlJob(order-check) public void execute(String param) { String taskId XxlJobHelper.getJobId() _ System.currentTimeMillis(); String executorAddress XxlJobHelper.getExecutorAddress(); // 1. 调度入口日志证明调度中心确实发出了指令 log.info([JOB-ENTRY] jobId{}, address{}, param{}, XxlJobHelper.getJobId(), executorAddress, param); // 2. 锁获取日志证明是否进入互斥流程 boolean locked lock.tryLock(0, 30, TimeUnit.SECONDS); log.info([LOCK-STATUS] jobId{}, address{}, locked{}, XxlJobHelper.getJobId(), executorAddress, locked); if (!locked) return; try { // 3. 业务执行开始记录精确时间戳 long start System.currentTimeMillis(); log.info([EXEC-START] jobId{}, address{}, start{}, XxlJobHelper.getJobId(), executorAddress, start); // 4. 核心业务逻辑此处doOrderCheck() doOrderCheck(); // 5. 执行完成日志含耗时用于性能分析 long cost System.currentTimeMillis() - start; log.info([EXEC-SUCCESS] jobId{}, address{}, cost{}ms, XxlJobHelper.getJobId(), executorAddress, cost); } catch (Exception e) { // 6. 异常日志带完整堆栈 log.error([EXEC-FAILED] jobId{}, address{}, error, XxlJobHelper.getJobId(), executorAddress, e); throw e; } finally { // 7. 锁释放日志证明资源已归还 if (lock.isHeldByCurrentThread()) { lock.unlock(); log.info([LOCK-RELEASED] jobId{}, address{}, XxlJobHelper.getJobId(), executorAddress); } } }日志价值当出现重复执行时搜索[JOB-ENTRY]可确认调度中心是否广播了多次排除调度中心问题搜索[LOCK-STATUS]可统计各机器锁获取成功率定位网络或Redis瓶颈cost字段直接暴露慢SQL、外部API超时等性能瓶颈4.2 自定义Metrics埋点用Prometheus监控锁健康度日志适合排查指标适合预警。我们在执行器中集成了Micrometer Prometheus暴露4个核心指标指标名类型说明报警阈值xxl_job_lock_acquire_totalCounter锁获取总次数—xxl_job_lock_acquire_failed_totalCounter锁获取失败次数5分钟内失败率 20%xxl_job_lock_held_secondsGauge当前锁持有时间秒 锁超时时间 × 0.8xxl_job_execution_cost_secondsSummary任务执行耗时分布P99 300s关键配置Spring Bootmanagement: endpoints: web: exposure: include: prometheus,health,metrics endpoint: prometheus: show-details: alwaysGrafana看板必备面板锁竞争热力图X轴时间Y轴执行器IP颜色深浅表示lock_acquire_failed_total增量执行耗时趋势图叠加P50/P90/P99曲线一眼识别性能劣化锁持有时间散点图横轴是锁超时时间纵轴是实际持有时间离散点越靠近对角线越健康4.3 调度中心增强自定义任务执行状态看板XXL-JOB后台只显示“成功/失败”但我们需要知道“成功是因为执行了还是因为跳过了”为此我们在调度中心二次开发了一个状态看板新增数据库字段xxl_job_log.lock_status ENUM(ACQUIRED,SKIPPED,FAILED)修改XxlJobTrigger类在触发执行前插入此状态后台页面增加筛选项“按锁状态查看”支持导出CSV分析实际收益发现某台机器因DNS解析失败持续返回SKIPPED及时修复网络配置统计出SKIPPED占比达35%说明锁超时设置过短立即调整为2小时FAILED日志集中于某几个任务定向优化其SQL和重试逻辑4.4 全链路Trace打通从调度到DB的100%追踪最后一步把调度中心、执行器、数据库串成一条线。我们用SkyWalking实现调度中心XxlJobTrigger.trigger()方法打点为traceId源头执行器通过HTTP Header透传traceIdMyBatis拦截器自动将traceId注入SQL注释/* traceIdabc123 */ SELECT * FROM order WHERE statuspendingDBA在慢SQL日志里直接greptraceId5分钟定位到具体调度实例和SQL这套组合拳下来任何一次重复执行我们都能在3分钟内给出答案“是调度中心重复触发查调度日志→ 否是锁机制失效查Redis key和日志→ 否是任务执行超时导致锁释放查xxl_job_log耗时和lock_held_seconds指标→ 是根因DB查询未加索引P99耗时42s锁超时仅30s → 已优化索引问题解决”这才是真正的“可观测性”不是堆监控工具而是让每个字节都说话。5. 终极防御任务幂等性设计——当锁失效时的最后一道保险再完美的锁机制也无法100%杜绝意外。网络分区、Redis脑裂、JVM崩溃……这些极端情况虽小概率但一旦发生就是P0事故。所以锁是手段幂等才是目的。我们所有核心任务都强制遵循“三段式幂等设计”。5.1 第一段业务ID前置校验最快拦截在任务入口用业务唯一标识如订单号、对账日期查库确认是否已处理public void doOrderCheck() { String bizKey order_check_ LocalDate.now(); // 业务键 // 1. 查询任务状态表 TaskStatus status taskStatusMapper.selectByBizKey(bizKey); if (status ! null status.getStatus() TaskStatus.DONE) { log.info(Task already completed, skip. bizKey{}, bizKey); return; } // 2. 插入任务状态唯一索引防并发 try { taskStatusMapper.insert(TaskStatus.builder() .bizKey(bizKey) .status(TaskStatus.RUNNING) .createTime(LocalDateTime.now()) .build()); } catch (DuplicateKeyException e) { log.info(Another instance is processing, skip. bizKey{}, bizKey); return; } // 3. 执行核心逻辑 actualOrderCheck(); // 4. 更新状态为完成 taskStatusMapper.updateStatus(bizKey, TaskStatus.DONE); }关键设计task_status.biz_key建唯一索引INSERT失败即代表已被抢占状态机PENDING → RUNNING → DONE/FAILED避免中间态残留状态表独立于业务库专为幂等服务避免拖慢主业务5.2 第二段数据库唯一约束最强兜底在最终写入结果的SQL里强制添加唯一约束。例如对账结果表CREATE TABLE order_reconciliation_result ( id bigint NOT NULL AUTO_INCREMENT, date date NOT NULL COMMENT 对账日期, amount decimal(18,2) NOT NULL, created_at datetime DEFAULT CURRENT_TIMESTAMP, PRIMARY KEY (id), UNIQUE KEY uk_date (date) -- 关键按日期唯一 ) ENGINEInnoDB;这样即使两个实例同时执行第二个INSERT必然因uk_date冲突而失败业务代码捕获SQLIntegrityConstraintViolationException后可优雅降级如记录告警但不中断。实测效果唯一索引冲突率0.002%百万级任务中仅20次冲突处理耗时5ms远低于锁机制的毫秒级开销完全不依赖外部组件DB自身保障5.3 第三段结果比对与自动修复主动防御最高阶的幂等不是阻止重复而是让重复变得无害。我们为对账任务设计了“结果指纹”机制每次对账生成MD5摘要MD5(汇总金额 订单数 异常订单ID列表)将摘要存入reconciliation_fingerprint表date为主键执行前先查摘要若存在且匹配则跳过若存在但不匹配触发告警并人工介入String fingerprint generateFingerprint(result); FingerprintEntity old fingerprintMapper.selectByDate(date); if (old ! null) { if (old.getFingerprint().equals(fingerprint)) { log.info(Fingerprint match, task is idempotent.); return; // 完全一致直接退出 } else { log.error(Fingerprint mismatch! date{}, old{}, new{}, date, old.getFingerprint(), fingerprint); alertService.send(Reconciliation fingerprint conflict!); return; // 不一致需人工核查 } } fingerprintMapper.insert(date, fingerprint);为什么比单纯查状态更优状态表只告诉你“是否执行过”指纹告诉你“执行结果是否一致”避免因数据修复、重跑等操作导致的状态误判为审计提供数学证明两次执行结果完全相同这套三层防御体系让我们在锁机制失效的极端情况下依然能保证“业务结果不变”。它不追求100%不重复那不现实而是确保重复不带来业务风险。这才是分布式系统设计的终极智慧接受不确定性用确定性手段控制其影响。我在实际项目中发现真正稳定的系统从来不是靠某一个“银弹”技术而是层层设防的纵深防御。锁解决的是“执行权争夺”幂等解决的是“执行结果可控”监控解决的是“问题可追溯”。三者缺一不可而它们的共同前提是你真正理解了XXL-JOB的设计哲学——它不是一个黑盒调度器而是一个开放的协作框架。你给它明确的契约如分片规则、锁约定它就给你确定的行为。那些所谓的“坑”往往是我们忘了签这份契约。