每月最后一天为什么写不出来

工具相关 ·

月度对账、月度报表、月度结算,这些任务都有一个共同的时间点:每月最后一天。第一次尝试的人往往会写下 0 0 31 ,然后发现 2 月、4 月、6 月这些没有 31 号的月份,任务直接不执行了。而期望的行为应该是顺延到当月末。

标准 cron 没有「月末」这个概念

cron 的日期字段取值范围是 1 到 31,它只做一件事:判断当前日期是否等于表达式里列出的某个数字。它不理解「月末」这种相对概念,也不做顺延。

所以 0 0 31 的准确含义是「当日期等于 31 号时执行」。在只有 30 天的月份里,条件永远不成立,任务就被跳过了——不是延后,是彻底不执行。

这就是为什么「每月最后一天」在标准 cron 里写不出来。它需要一个跨越月份边界的判断能力,而 cron 的字段是逐项匹配的。

方案一:宽范围触发加脚本判断

最通用、可移植性最好的做法,是让 cron 在一个较宽的日期范围内触发,再由脚本判断今天是不是月末。

# 每月 28 到 31 号每天凌晨触发,脚本内判断是否为当月最后一天
0 0 28-31 * * /usr/local/bin/month-end-job.sh
#!/bin/sh
# 如果明天是 1 号,说明今天是本月最后一天
[ "$(date -d tomorrow +\%d)" = "01" ] || exit 0
# 到这里才执行真正的月度逻辑

这个判断的技巧在于「看明天是不是 1 号」,而不是去列举每个月的天数。它天然适配闰年的 2 月,也不需要维护一张月份天数表。

需要注意 date -d tomorrow 是 GNU date 的写法,BSD 系的 macOS 不支持,需要改成 date -v+1d。另外 crontab 里的 % 要转义成 \%。

方案二:使用支持扩展语法的调度器

部分调度器在 cron 语法上做了扩展。Quartz 支持在日期字段使用 L,表示「本月最后一天」,写出来是 0 0 0 L * ?。类似的还有 W(最近的工作日)、#(第几个星期几)等。

这些扩展字符用起来很直观,但代价是可移植性。同一份配置换到另一个调度器上可能完全不认识这些字符,迁移时必须逐个核对。如果项目已经绑定在某个框架上,用扩展语法是划算的;如果任务需要在不同环境之间搬来搬去,还是用方案一更稳。

systemd timer 则提供了另一种表达方式。它用 OnCalendar= 描述时间,可以写成 --01 这类模式,再配合 OnCalendar 的补集思路,不过实现「月末」仍然不直接,通常还是回到脚本判断。

更难的问题:最后一个工作日

比「最后一天」更复杂的是「每月最后一个工作日」。工作日既排除了周末,还要排除法定节假日,而节假日是每年由人工公布的,没有任何表达式能预知。

这类需求只能靠数据驱动:维护一份节假日表,任务每天触发一次,脚本查询今天是不是本月最后一个工作日。判断逻辑是「明天是周末或节假日,且今天不是」。节日表可以放在数据库或配置文件里,每年更新一次。

检查清单

  • 标准 cron 无法表达「月末」,0 0 31 会让缺少 31 号的月份被跳过
  • 首选方案:用 28-31 宽范围触发,脚本判断「明天是不是 1 号」
  • date -d tomorrow 是 GNU 写法,BSD 系统需改为 date -v+1d
  • crontab 里的 % 必须转义为 \%
  • Quartz 的 L、W、# 等扩展语法不可移植,使用前确认调度器
  • 「最后一个工作日」需要节假日表,无法用表达式表达
阅读 14