资讯动态

XXL-JOB实现定时邮件发送:从分布式调度到生产避坑全解读

发布时间:2026/10/15 13:05:44 来源:尧图企业网站定制
做定时邮件这个需求其实踩过不少坑。最开始我这边的项目里运营要每天上午十点给一批客户发日报第一个版本我用Spring自带的Scheduled写了个定时方法跑了一周都没事后来服务一扩容就出问题了——两个实例同时执行客户收到了两封一模一样的邮件。后来把任务调度统一迁到了XXL-JOB附带解决了失败重试、执行日志、告警通知这一整套问题。这篇文章就围绕任务调度服务接入 定时发送邮件这条主线把从环境准备、执行器代码到调度中心配置、生产环境避坑的完整链路整理出来给正好在做同类需求的同学一个可以直接抄作业的参考。如果你只是见过Scheduled还没接触过分布式任务调度平台这篇文章也适合你。我会把每一步为什么这么做讲清楚而不是只丢一份配置文件给你。1. 为什么定时发邮件绕不开分布式调度1.1 三种定时方案的取舍先说结论在单机、单实例、没有严格可靠性要求的场景下Scheduled完全够用。它足够简单写起来快也不用额外部署任何组件。但一旦你的服务是多实例部署或者任务要求执行成功可追溯、失败要自动重试、每次执行有完整日志它就有点撑不住了。原因在于Scheduled默认只在本地进程里按时间触发。多实例时要么所有实例同时执行造成重复要么你想方设法引入分布式锁控制只有一个实例执行但分布式锁本身也是成本而且Spring的定时任务在调度历史、告警、手动触发这些运维能力上几乎是空白的。第二种方案是自研一个调度模块用Quartz或者直接在数据库里维护任务表自己写触发逻辑、自己记日志。坦白说我早期一个小项目就这么干过后期维护成本很高。调度本身涉及时间轮、错过补偿、持久化、并发控制这些细节如果自己从头造轮子三个月后你会为一个时区转换bug排查一整天。第三种就是直接接入现成的分布式任务调度平台比如XXL-JOB。它把调度中心独立成一个服务业务侧的程序作为执行器接入谁在什么时候执行什么任务、执行结果如何、要不要重试、重试几次全部在调度中心的可视化界面上管理。对发送邮件这种对执行记录有强诉求的任务场景它提供的调度日志和失败告警比你自己写一堆表结构实用得多。1.2 XXL-JOB在发邮件这个场景里到底做了什么具体到发邮件任务调度的价值集中体现在四个细节上。第一是可观测性。邮件发送是典型的外部依赖型操作邮件服务商可能超时、拒收、被限流。任务跑完你不知道成没成功这是最尴尬的。接入XXL-JOB之后每次执行都会生成一条调度记录执行器返回的状态、异常堆栈、执行时长全部留在调度中心里。你可以按时间范围检索也可以直接在后台看到失败原因。第二是重试机制。你可以在任务配置里指定失败重试次数调度中心会自动对失败的执行重新触发。邮件发送时偶发的网络抖动、SMTP超时通过一两次自动重试基本上能扛过去。第三是路由策略。多实例部署后一次调度只会发给一个执行器节点不会再出现两台机器同时发邮件这种情况。这也是我当初迁移的第一个直接原因。第四是分片与错过补偿。虽然发邮件一般用不上分片但错过调度补偿在半夜任务上有用。比如凌晨的任务因为宕机错过了恢复后可以手动触发或配置补偿保证邮件不会因为一次进程重启就永久漏掉。简单说XXL-JOB对你的意义不在于定时这个动作本身而在于定时之后怎么知道结果、怎么处理失败、怎么防止重复。2. 调度中心和执行器的接入先让调度链路通起来2.1 调度中心初始化数据库脚本与启动配置调度中心本质是一个独立的Web应用。我第一次部署的时候没什么经验直接把源码拉下来本地启动结果报了数据库连接失败——它需要一个xxl_job库来存放任务、日志和执行器信息。初始化大致分三步。先在MySQL里建一个库比如xxl_job然后执行官方提供的tables_xxl_job.sql脚本它会自动建出xxl_job_info、xxl_job_log、xxl_job_registry、xxl_job_group这些表。这几张表是调度中心的根基任务配置在xxl_job_info里执行记录在xxl_job_log里执行器实例的注册信息在xxl_job_registry里。理解了这一点后面调接口排查问题时你会更有方向。接着是修改调度中心的配置文件。以常见的application.properties为例只需要关注几个项server.port8080 spring.datasource.urljdbc:mysql://127.0.0.1:3306/xxl_job?useUnicodetruecharacterEncodingUTF-8useSSLfalseserverTimezoneAsia/Shanghai spring.datasource.usernameroot spring.datasource.password你的数据库密码 xxl.job.accessTokendefault_tokenaccessToken建议配置一个自定义的token串调度中心和执行器会通过它做简单鉴权。如果不配置或者两边不一致执行器注册时会一直被拒绝这个坑在接入阶段特别常见。最后直接启动Spring Boot应用访问http://localhost:8080/xxl-job-admin默认账号密码是admin/123456。这里我需要强调一下调度中心和应用本身没有强耦合你完全可以把调度中心当作一个公共基础设施来部署多个业务服务共享同一个调度中心只是各自的AppName不同。2.2 执行器接入pom依赖、配置文件与执行器注册执行器就是你的业务服务本身。我们需要在Spring Boot项目里引入xxl-job-core依赖然后写配置类和配置文件让它启动时自动去调度中心注册自己。先看pom里需要添加什么dependency groupIdcom.xuxueli/groupId artifactIdxxl-job-core/artifactId version2.4.0/version /dependency版本号建议跟调度中心保持一致避免低版本客户端调高版本服务端时出现协议不兼容的问题。然后是application.yml里的关键配置xxl: job: admin: addresses: http://127.0.0.1:8080/xxl-job-admin accessToken: default_token executor: appname: mail-job-executor address: ip: port: 9999 logpath: /data/logs/xxl-job/mail-executor logretentiondays: 30appname是执行器在调度中心的唯一标识你在后台配置任务时就是通过它来选定执行器的。port是执行器内嵌的通信端口调度中心会回调这个端口来触发任务。这个端口必须保证能从调度中心所在机器访问到如果两台机器之间有防火墙限制任务会一直显示调度失败但执行器在后台却显示在线这种状态很容易误导人。接着需要一个配置类把执行器的核心Bean注入Spring容器Configuration public class XxlJobConfig { Value(${xxl.job.admin.addresses}) private String adminAddresses; Value(${xxl.job.accessToken}) private String accessToken; Value(${xxl.job.executor.appname}) private String appname; Value(${xxl.job.executor.port}) private int port; Value(${xxl.job.executor.logpath}) private String logPath; Bean public XxlJobSpringExecutor xxlJobExecutor() { XxlJobSpringExecutor executor new XxlJobSpringExecutor(); executor.setAdminAddresses(adminAddresses); executor.setAppname(appname); executor.setPort(port); executor.setAccessToken(accessToken); executor.setLogPath(logPath); return executor; } }这个Bean建议选XxlJobSpringExecutor它是框架提供的Spring集成实现启动时会自动扫描带有XxlJob注解的方法并注册到调度中心。如果你只加了依赖忘了写这个Bean执行器永远不会出现在后台的在线列表里。启动服务之后打开调度中心的执行器管理页面手动新增一个执行器AppName要填mail-job-executor然后刷新页面如果看到已注册的节点信息说明链路通了。2.3 路由策略与阻塞处理配置任务前要先想清楚执行器注册完之后后台还有很多任务参数新手经常忽略它们。先说路由策略。它的含义是调度中心触发任务时如果这个执行器下挂了多个实例节点到底发给谁。发邮件这个场景我推荐用第一个或者轮询。如果你希望始终由固定一台机器发信选第一个如果节点都无状态选轮询分摊压力。不建议用忙碌转移之外的模式做邮件发送因为没有意义还容易让你误判当前到底哪个节点在执行。再说阻塞处理。当上一个任务还没跑完下一个调度时间点已经到了你要选择单机串行丢弃后续调度覆盖之前调度。对发邮件来说我建议选单机串行。如果邮件量大单次执行时间超过调度间隔串行可以保证上一批发完再发下一批不丢任务而丢弃后续调度会导致邮件漏发运营会来找你。最后是失败重试次数。我一般给发送任务配置1到2次。太多次没有意义——因为大批量发送失败往往不是网络抖动而是发信服务商限流或认证失败重试3次以上只会放大问题。3. 邮件发送任务的Java实现JobHandler到JavaMailSender3.1 JobHandler的写法与命名规范任务的具体逻辑写在业务服务中通过XxlJob注解标记。先看一个最朴素的写法Component public class MailSendJob { private static final Logger logger LoggerFactory.getLogger(MailSendJob.class); XxlJob(dailyReportMailJob) public void handleDailyReportMail() throws Exception { XxlJobHelper.log(开始执行日报邮件任务); long start System.currentTimeMillis(); try { mailSendService.sendDailyReport(); XxlJobHelper.log(任务执行成功, 耗时: {} ms, System.currentTimeMillis() - start); } catch (Exception e) { XxlJobHelper.log(e); throw e; } } }这里有个细节。方法名是什么不重要重要的是XxlJob注解里的值它才是调度中心识别这个任务的Handler名称。你在后台新增任务时填的JobHandler必须跟这个值完全一致大小写敏感。另外要注意XxlJobHelper.log和普通的logger.info是有区别的前者会写入当前任务的执行日志调度中心后台可以看到普通日志只会进应用日志文件。排查线上问题时优先看调度中心的执行日志信息更加集中。3.2 邮件参数实体与发送方法的设计邮件功能不能只写一个用固定地址发一句话的Demo生产环境至少要面对收件人列表、抄送、动态内容、附件这几个维度。我会维护一个独立的邮件模块对外暴露如下接口public interface MailSendService { /** * 发送简单文本邮件 */ void sendSimpleMail(MailSendRequest request); /** * 发送HTML模板邮件 */ void sendHtmlMail(MailSendRequest request, String htmlContent); /** * 发送带附件的邮件 */ void sendAttachmentMail(MailSendRequest request, ListFile attachments); }对应的请求实体public class MailSendRequest { /** * 收件人多个用逗号分隔 */ private String to; /** * 抄送人 */ private String cc; /** * 主题 */ private String subject; /** * 自定义参数用于渲染模板 */ private MapString, Object params; }为什么不直接塞一个String content而是带params因为定时邮件的正文几乎都是动态拼装的。比如每天推送一份未支付订单汇总正文里要有日期、订单数量、金额合计、占比最高的商品类目。这些变量在任务执行时才从数据库里查出来放进params交给模板引擎渲染。实体里只放数据和渲染逻辑解耦后面想换模板或者加渠道都比较顺。3.3 用JavaMailSender发送一封真正能落地的HTML邮件Spring Boot里发邮件基本靠spring-boot-starter-mail封装好的JavaMailSender。先加依赖dependency groupIdorg.springframework.boot/groupId artifactIdspring-boot-starter-mail/artifactId /dependency然后配置SMTP参数。不同邮件服务商的地址和端口不一样但核心配置结构是一致的spring: mail: host: smtp.你的邮件服务商.com port: 465 username: no-replyexample.com password: 你的授权码 properties: mail: smtp: auth: true ssl: enable: true connectiontimeout: 5000 timeout: 5000 writetimeout: 5000有几个点要特别说明。password在很多主流邮件服务商那里不是登录密码而是单独开通SMTP服务后生成的授权码这个不要搞混。端口465对应SSL587对应STARTTLS具体以服务商文档为准。三个timeout建议必须配不配的话默认可能长达几分钟一旦SMTP服务端无响应你的调度任务会一直挂在执行中状态。再看发送HTML邮件的核心代码Async public void sendHtmlMail(MailSendRequest request, String htmlContent) { try { MimeMessage message mailSender.createMimeMessage(); MimeMessageHelper helper new MimeMessageHelper(message, true, StandardCharsets.UTF_8.name()); helper.setFrom(senderEmail); helper.setTo(request.getTo().split(,)); if (StringUtils.hasText(request.getCc())) { helper.setCc(request.getCc().split(,)); } helper.setSubject(request.getSubject()); helper.setText(htmlContent, true); mailSender.send(message); } catch (MailException | MessagingException e) { throw new RuntimeException(邮件发送失败, e); } }MimeMessageHelper的true参数表示这是一封multipart邮件也就是允许正文中嵌套HTML和附件。helper.setText(htmlContent, true)里的true表示正文按HTML解析如果漏掉这个参数收件人看到的会是一堆HTML标签。这个Async要注意。我把发送方法标注为异步是因为SMTP发送是阻塞IO一封带附件的大邮件可能耗时好几秒。如果任务里串行给几百人发邮件单次执行时间会被拉得很长。用线程池异步发送任务方法可以快速回收执行器的调度压力会小很多这在批量发送场景里几乎是必选项。4. 调度中心里配置任务与联调验证4.1 后台配置一个定时任务的完整步骤执行器已经在线代码里也写了XxlJob(dailyReportMailJob)下面就是在调度中心后台把这根线搭起来。第一步进入任务管理页面选择你新增的执行器mail-job-executor点击新增任务。第二步填写任务描述比如日报邮件定时发送方便运营同事也能看懂。第三步配置Cron表达式。日报邮件一般是每天上午10点0 0 10 * * ?这里想提醒一下时区问题。调度中心使用的是服务器本地时区如果你的服务器默认是UTC那么0 0 10 * * ?实际会在UTC时间的10点触发也就是北京时间18点。这是线上非常容易出现的低级问题。确认好服务器date命令输出的时区再配置Cron。第四步JobHandler填dailyReportMailJob必须和注解里完全一致。路由策略选第一个阻塞处理选单机串行失败重试次数填1。第五步保存后先在操作列点执行一次看看执行器能否正常跑通。很多人到这里会忽略一个叫调度类型的选项。你既可以选Cron也可以选固定速度比如每300秒跑一次。日报告这类任务用Cron更直观但如果你希望任务在每天零点跑完数据汇总后立刻发邮件业务代码里通常是通过调度类型选Cron 业务内查询数据实现的没有内置的日结后触发联动机制这一点要在设计任务时想清楚边界。4.2 调度日志、执行日志与失败重试的查看方法任务跑完之后最重要的验证入口是调度日志。在调度日志页面选择执行器和任务点查询能看到每次调度的记录。点进某一条记录里面分为调度日志和执行日志两部分。调度日志记录的是调度中心有没有成功触发执行器比如触发超时、网络不通会在这里体现执行日志记录的是你的业务代码跑到哪一步、抛了什么异常。一个非常有效的排查经验是先看调度日志再看执行日志不要上来就翻应用日志文件。因为调度日志能帮你定位问题到底出在调度层还是业务层现象可能原因排查方向调度日志显示调度失败执行器不在线或网络不通检查执行器端口和防火墙调度日志成功执行日志报错业务代码异常如数据库查询失败展开执行日志看异常堆栈任务显示成功但邮件未收到SMTP配置或收件人地址问题查看业务日志中发送结果任务一直显示运行中SMTP超时或线程阻塞检查三处timeout配置失败重试的显示方式也比较直观。每次调度会有一个独立的执行记录第一次失败的记录会标记为失败系统触发重试后会生成新的执行记录。你在时间轴上会看到连续几条记录前1条失败、后1条成功这就是重试生效了。需要提醒一句调度中心的重试是任务级别的重试它会重新调用你的JobHandler。如果你的任务逻辑内部自己还做了局部重试那实际执行次数就是任务重试次数 × 内部重试次数控制不好会放大下游压力。设计任务时要么依赖调度中心的重试要么在业务代码里重试不要两层叠加。4.3 手动执行和定时执行的一致性验证我接入任务后习惯先手动执行三次再改Cron。原因是手动执行可以跳过时间等待快速验证逻辑但手动执行通过执行一次触发的链路和定时触发走的链路基本一致唯一的区别是触发时机。有个容易被忽略的点手动执行默认情况下使用的参数如果是空你需要在任务代码里做好没有入参也能跑的兜底。比如日报任务JobHandler第一次执行前可能还没有任何上下文你要在方法里判断参数为空时使用默认策略否则你手动执行必挂。验证完手动执行再把Cron改成预期时间等真正触发那一刻回到调度日志看它是不是按点跑。这里我还会故意把Cron改成每2分钟一次来压测确认任务能稳定执行两三轮同时确认每轮执行之间没有重叠——如果发现重叠说明单次执行耗时超过2分钟就要考虑两种可能要么邮件发送的异步线程池满了要么数据库查询太慢。找到瓶颈再调整。5. 生产环境里的坑邮件频率、重试幂等与异常兜底5.1 发信限流定时任务瞬时并发触发被拒接入得很顺利结果第二天上线上午十点那批邮件发了一部分后面几百封全部失败。查执行日志异常信息是frequency limited之类。这就是典型的发信服务商限流。大多数邮件服务商对单个账号有每日发送上限和每分钟发送频率限制。你无法控制服务商那边的阈值但你可以控制自己的发送速率。解决办法是在业务代码里做一个简单的发送速率控制。不需要引入重量级中间件用Semaphore加固定间隔就够了private final Semaphore rateLimiter new Semaphore(10); private void acquireQuota() { try { rateLimiter.acquire(); Thread.sleep(200); // 这里在拿到许可后再加间隔相当于最多每秒5封 } catch (InterruptedException e) { Thread.currentThread().interrupt(); throw new RuntimeException(e); } }真正的生产实现会比这复杂一点但核心思路一致在批量发送循环里每发一封就限流一次。你甚至可以把每封邮件发送间隔做成配置项上线时根据服务商阈值调。另外我建议把邮件发送和任务解耦。任务只负责从库里查出待发送的收件人列表把发送请求丢进队列由一个独立的消费线程按配额慢慢发。这样即使收件人有5000个也不会把任务执行时间拖到几十分钟更不会触发调度中心的任务超时。5.2 重试幂等失败重试时如何避免邮件重复调度中心配置了失败重试次数后会出现一个特殊情况第一封邮件实际已经发出去了但SMTP返回时网络超时你的代码捕获到了超时异常把任务标记为失败调度中心随即重试于是收件人收到两封一模一样的邮件。要解决这个问题需要引入幂等机制。最简单的做法是在发送前生成一个唯一的业务标识比如bizId写入发信记录表状态为发送中然后再实际调用SMTPint affected mailRecordDao.insertIfNotExist(bizId); if (affected 0) { // 已经处理过这个业务ID直接跳过 return; }重试时同样的bizId再进来因为记录已存在任务直接返回成功不会重复发送。这个表本身也可以顺带充当发信历史记录记录每封邮件的收件人、主题、发送时间和最终状态后面运营要追查某个人为什么没收到邮件时查这张表就行。5.3 模板渲染和附件的边界防护我遇到过最隐蔽的一个问题是Freemarker模板渲染时某个变量为null渲染直接抛异常。单看邮件任务代码完全没问题因为报错点藏在模板引擎内部。现在我的做法是模板里用到所有变量在任务里都做一次空值保护同时在sendHtmlMail方法外层捕获到渲染异常时记录具体是哪个模板、哪批参数、哪个收件人然后再决定是整体跳过还是只跳过该收件人。大批量发送时我不希望一个变量问题让整轮任务全部失败。附件方面也有个限制。邮件服务商对单封邮件大小基本都有上限通常附件总大小建议控制在普通邮件服务商允许的范围以内比如单封不超过10到20MB。如果你生成了一份几十MB的Excel报表塞进邮件大概率会被对方服务器拒收。常规做法是超过阈值的大文件单独上传到对象存储邮件正文里放下载链接只有小文件才真正作为附件发出。5.4 任务之后的扩展方向发信记录和监控告警跑通基础功能之后我接着做了两件让维护省心很多的事。第一是前面提到的发信记录表把成功、失败分开统计第二是给任务本身的失败配了一个钉钉告警。也就是在JobHandler的catch块里除了重新抛出异常还会向告警通道发一条消息消息里带执行任务名、失败原因、影响收件人数。如果你已经接入了XXL-JOB调度中心本身自带失败告警功能可以配置邮件或者自定义接口。但要注意调度中心的告警是任务失败后触发它的告警内容相对固定不会包含你业务内的信息。所以我更推荐在业务代码里做一次轻量告警两个信息互补调度中心负责告诉你哪个任务挂了业务告警告诉你为什么挂、挂了多少人排查效率会高很多。另外建议定期做一次恢复演练。手动把Cron停掉模拟任务漏跑然后通过调度中心的后台执行一次手动补发确认补发逻辑符合预期。这个习惯在节假日或者链路调整之后尤其重要任务调度这种东西平时看起来稳出问题往往是在你最有信心的时候。

读完文章,也想定制专属网站?

尧图设计师 24 小时内与您沟通定制方案

免费获取报价 →
↑