说实话,这个题目第一次出现在我面前的时候,我以为又是那种“打开日历看下是不是周六日”的简单工具。直到业务方把需求列出来,我才意识到这里面的复杂度一点不比主流程低。
我当时维护的内部系统,核心诉求只有一个:接收一个日期,告诉调用方那天到底算不算工作日。后来需求慢慢发酵,从“判断今天是否上班”一路扩展到“我要算未来第30个工作日是哪天”“运营希望导出一整年的工作日历”“不同部门的大小周规则还不太一样”。整个过程走下来,我最大的感受是:工作日与节假日判定本身不难,真正难的是数据怎么来、谁来维护、缓存怎么刷、跨年怎么兜底,以及业务规则变化时系统扛不扛得住。
这篇文章我就用自己的项目经历,把整个模块从需求分析、技术选型、数据模型、核心算法到常见坑位完整梳理一遍。适合后端开发、系统架构师,也适合需要排班、时效计算、履约日期推算的产品同学参考。如果你正打算在自己的系统里加这套能力,可以直接对照着做。
1. 项目背景与需求分析
1.1 从“是不是工作日”这个问题开始
最初的需求很简单:前端页面上要显示“今日可预约”或者“今日休息”,所以要一个接口,输入一个日期,返回布尔值。我先用最朴素的方式实现了:
- 周一至周五返回“工作日”;
- 周六、周日返回“休息日”;
- 代码里预置几个固定节假日,比如元旦、国庆。
上线第一周没问题,第二周就出事了。业务方反馈说“10月1日到10月7日我们确实休息,但10月8日为什么还是显示休息”?我一看代码,哦,之前把国庆写成了“10月1日到10月8日”。修完这个Bug之后,又有下一个问题:某周末明明要补班,系统却显示休息,导致当天的履约单量预估差了一大截。
从这个阶段开始,我意识到这个模块不是一个简单的“周末判断”就能盖过去的。它背后其实是一个完整的节假日规则引擎,只是大多数系统的业务量不会一开始就逼你把它做成引擎,等出了问题再改就非常被动。
1.2 需求清单:别只做一个布尔接口
我在梳理需求的时候列了一个表,把后续所有相关场景都放了进去。这张表后来成了我设计接口和数据结构的基础。
| 场景 | 输入 | 期望输出 |
|---|---|---|
| 判断某日是否上班 | 日期 | true/false,最好带上当天类型 |
| 计算未来第N个工作日 | 起始日期、N | 一个具体的日期 |
| 计算某个时间段有几个工作日 | 起止日期 | 整数 |
| 运营导出年历 | 年份 | 每天标注工作/休息/节假日/补班 |
| 不同部门不同规则 | 日期、日历类型 | 按日历类型返回结果 |
这里特别想提醒一点:如果一开始只做“布尔接口”,后续业务要“上班但属于调休补班”这种标签时会非常尴尬。你在接口设计阶段就要把“当天类型”和“是否工作日”分开。当天类型可以有几种,并不完全等于是否上班:
- WORKDAY:普通工作日;
- WEEKEND:普通周末休息日;
- HOLIDAY:法定节假日或公告休息日;
- COMPENSATORY_WORKDAY:调休补班日。
其中 COMPENSATORY_WORKDAY 很可能落在周六或周日,但它是上班日;HOLIDAY 也可能落在工作日,但它是休息日。所以“是否为工作日”应该是根据类型推导出来的派生字段,而不是数据表里的一个独立布尔值。这样后续要区分“今天为什么休息”“今天为什么上班”都会方便很多。
1.3 关键边界条件与配置化取舍
除了基本场景,我还把一些边界条件写进了需求文档。比较典型的有几类:
- 跨年:起始日期是 12 月 29 日,要往后算 5 个工作日,结果可能落在次年 1 月,这时候必须能自动加载下一年的日历数据;
- 多日历共存:有的分公司执行单休,有的部门执行大小周,甚至有的岗位是按“国际工作日”排班,就不能一个系统只支持一套全国日历;
- 节假日数据发布时间不固定:下一年度的假期安排往往到年底才公布,系统不能因为没拿到明年的正式数据就罢工,至少要先按“周一至周五上班、周六日休息”的默认规则跑,等正式数据发布后自动覆盖;
- 历史数据需要保留:后续如果要做结算、排班留痕,必须能把某个历史日期当时属于什么类型给查出来,而不是依赖当前日历往回倒推。
关于“要不要做成配置化”,我的态度很明确:只要系统可能服务超过一年,就必须配置化。硬编码节日表看着省事,但你永远要为一个错误的上线版本付出更大代价。后面我会专门对比不同方案的取舍。
需要模型API调用? 免费领10W Token,多模型网关一键接入 Claude、DeepSeek 等主流模型。
2. 技术方案选型与数据设计
2.1 三种数据管理方案对比:硬编码、开源数据、自建配置
我评估过三种主流做法。
第一种是硬编码。典型表现是代码里直接写一个 HashMap,把“2025-01-01”扔进去。优点只有“快”,缺点有一堆:年度数据一发布就要发版;数据谁改的没有痕迹;出错只能靠人肉排查;遇到多日历基本无法扩展。
第二种是引入现成的开源节日库。很多语言都有这类库,能一次性返回某一天的节日/假期状态,而且支持农历转换,确实很省事。但我在评估之后没有把它作为生产主路径,原因是它们的数据更新节奏未必跟得上业务要求,而且一旦库的数据错了,你甚至不知道怎么修正。开源库更适合作为“种子数据来源”,用它把某年的规则初始化到自建系统里,再由运营或者开发手工复核一遍,而不是让线上判定逻辑直接依赖它。
第三种就是自建配置体系。我最后选的是这条路线。核心思想非常简单:把“每年有哪些休息日、哪些补班日”视为普通业务数据,存到数据库里,做成管理后台可维护的配置。判定逻辑只依赖数据库里的规则,不关心这些规则是怎么来的。这样做的好处是数据发布后不用发版,能留审计日志,也支持后续把导入动作做成半自动。
我这里不是否定开源库,而是建议把方案二和方案三结合起来:开源库负责“算”,自建配置负责“存和审”。等运行几年后积累了足够准确的历史数据,甚至可以慢慢脱离开源库。
2.2 数据模型:多日历、配置版本与审计日志
我最终的数据模型没有用复杂的一张“总配置表”去描述调休规则,而是把每一天的类型独立成一条记录。这样做最简单,也最好理解。
核心表设计大致如下:
sql复制CREATE TABLE sys_work_calendar (
id BIGINT PRIMARY KEY AUTO_INCREMENT,
calendar_type VARCHAR(16) NOT NULL COMMENT '日历类型,如 DEFAULT / SINGLE_REST / INTERNATIONAL',
day_date DATE NOT NULL COMMENT '日期',
day_type TINYINT NOT NULL COMMENT '1-工作日 2-周末 3-法定/公告休息日 4-调休补班日',
day_name VARCHAR(64) DEFAULT NULL COMMENT '节假日名称,如 春节、国庆',
source VARCHAR(32) DEFAULT 'MANUAL' COMMENT '数据来源 MANUAL/IMPORT/SYSTEM',
config_version VARCHAR(32) NOT NULL COMMENT '导入批次版本',
created_by VARCHAR(32),
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
updated_at DATETIME DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
UNIQUE KEY uk_calendar_type_date (calendar_type, day_date)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
很多同学看到这张表会有疑问:为什么不直接存“开始日期”和“结束日期”的区间段?因为区间段适合给人看,不适合给机器做随机判断。如果存储区间,你要判断某一天到底属于哪个区间,还得去查区间表,复杂度更高。反正是“按天展开”存储,一年的数据量也就 365 行,哪怕存十年也就三千多行,完全不用担心存储量。
config_version 字段是我后来加的。每次导入一批年度节假日数据,就生成一个版本号,比如 20250115_001。只要版本不同,就意味着这批数据是某一次整体操作的结果。这样做的好处有三个:
- 排错的时候可以快速知道线上跑的是哪一次导入的数据;
- 操作错了可以按版本做整体回滚;
- 业务当时算出来的结果如果和事后核对不一致,可以顺着版本找到那天的配置。
2.3 缓存与存储分层:从 MySQL 到进程内数组
判定模块很容易成为高频调用点。我们线上有一次促销活动,支付回调、库存预占、发货承诺都在调“下一工作日”接口,那个流量如果不做缓存,MySQL根本扛不住。
我采用的分层结构是这样的:
- MySQL 作为数据源,存储全部日历配置;
- Redis 作为多实例共享缓存,保存“年历对象”的序列化结果;
- 每个应用进程内再加一层 Caffeine 本地缓存,保存解析好的 byte 数组。
这里需要重点说下为什么最终要落到“进程内 byte 数组”。如果每次判断都从 Redis 拿一个 JSON,再解析成对象,耗时和 GC 压力都不小。而一年最多 366 天,完全可以用一个 366 字节的数组来表达每种日期的类型,甚至一个 int 就足以用位运算保存多天的类型。我们实际用的是 byte 数组,每天一个字节,取值范围 1 到 4,正好覆盖四种类型。
缓存Key的设计也值得强调:
text复制calendar:{calendarType}:{year}
例如:
text复制calendar:DEFAULT:2025
每次启动时应用会预加载“当前年份+下一年份”的日历,避免跨年请求打到数据库。Redis 缓存的过期时间可以设置为一年,但“配置发布”的时候必须主动删掉对应年份的缓存,而不是等它自然过期——这一点在后面的踩坑部分我会再详细说。
3. 核心算法与代码实现
3.1 核心判断逻辑:先按天生成年度类型数组
如果你已经决定采用“按天展开”的数据,下一步就是怎么把数据库里的配置变成一个可快速判断的结构。
我的实现里有一个核心类叫 AnnualCalendar,它负责把某一年所有日期初始化成一个数组。内部逻辑分两步走。
第一步:按“普通日历”初始化。默认没有节假日和调休时,周一至周五是工作日,周六、周日是休息日。
java复制public class AnnualCalendar {
private final int year;
private final byte[] dayTypes = new byte[367];
public AnnualCalendar(int year) {
this.year = year;
initByWeek();
}
private void initByWeek() {
int days = Year.of(year).length();
for (int doy = 1; doy <= days; doy++) {
LocalDate date = LocalDate.ofYearDay(year, doy);
DayOfWeek dow = date.getDayOfWeek();
if (dow == DayOfWeek.SATURDAY || dow == DayOfWeek.SUNDAY) {
dayTypes[doy] = DayType.WEEKEND.getCode();
} else {
dayTypes[doy] = DayType.WORKDAY.getCode();
}
}
}
public void applyRule(LocalDate date, DayType type) {
int doy = date.getDayOfYear();
dayTypes[doy] = type.getCode();
}
public byte getDayType(LocalDate date) {
return dayTypes[date.getDayOfYear()];
}
}
第二步:从配置表里读取当年所有特殊日期,覆盖到数组上。比如某年公告里写了“2月10日到2月17日放假,2月4日和2月18日补班”,那就把 2月10日至2月17日的类型设为 HOLIDAY,把 2月4日、2月18日 原本是周末或工作日的日期设为 COMPENSATORY_WORKDAY。
配置覆盖的顺序很关键:即使某一天按周判断是周末,只要公告说补班,就必须覆盖成“补班工作日”;同理,即使某一天按周判断是工作日,只要公告说放假,就必须覆盖成“休息日”。所以“按周初始化 + 按公告覆盖”这个顺序不能反。
对外判断“是否工作日”时,只要看类型是不是 WORKDAY 或 COMPENSATORY_WORKDAY 即可。
java复制public boolean isWorkday(LocalDate date) {
byte t = getDayType(date);
return t == DayType.WORKDAY.getCode() || t == DayType.COMPENSATORY_WORKDAY.getCode();
}
这套逻辑看起来简单,但很管用。它把“规则如何解读”抽离出来,数据里存的只是某一天应该是什么类型。以后如果政策规则变了,只需要修改数据导入层的解析逻辑,核心判断完全不用动。
3.2 计算未来第N个工作日:朴素循环与前缀和优化
业务里最常用的功能是“计算未来第N个工作日”。我最初实现就是单纯循环往下推:
java复制public LocalDate plusWorkdays(LocalDate start, int n) {
LocalDate cursor = start;
int step = n >= 0 ? 1 : -1;
int remain = Math.abs(n);
while (remain > 0) {
cursor = cursor.plusDays(step);
if (isWorkday(cursor)) {
remain--;
}
}
return cursor;
}
这个实现正确性很高,而且对于大多数业务场景已经够用了。比如计算订单承诺发货时间,最多往后推 30 天,循环几十次而已,没任何性能压力。
但如果你要做一个批量任务,给一万个订单计算时效,每个订单又要往后推三个月,那循环次数就会膨胀到百万级别。这时候再引入“前缀和数组”来做优化就很有必要。
前缀和数组的含义是:prefix[doy] 表示从当年第一天到第 doy 天一共有多少个工作日。这样计算任意区间 [start, end] 之间的工作日数量,就是一次减法;查找从 start 开始第 N 个工作日,可以使用二分查找。
java复制public int countWorkdays(LocalDate start, LocalDate end) {
if (start.isAfter(end)) {
return 0;
}
if (start.getYear() != end.getYear()) {
// 跨年时分段计算,这里省略细节
}
int s = start.getDayOfYear();
int e = end.getDayOfYear();
return prefix[e] - prefix[s - 1];
}
二分的思路不复杂:在“从 start 到年末”这段范围内找一个日期 target,使 countWorkdays(start, target) == n,这样就能把原本按天遍历的时间复杂度从 O(N) 降为 O(logN)。前提是你已经把两年甚至更长时间的工作日序列合并成一个连续数组。实践中,我会在进程内维护一个“跨年窗口”,例如从今年1月1日到后年12月31日,构建一个接近 1000 天的连续前缀和。大多数业务往前推三个月、半年都落在这个窗口内,基本可以覆盖。
3.3 配置刷新、版本管理与跨年兜底
配置什么时候加载、什么时候刷新,这是最容易出问题的环节。
我建议的机制是“启动预载 + 主动刷新 + 定时巡检”三管齐下。
- 启动预载:应用启动时把当前年和下一年的日历加载到本地缓存和 Redis;
- 主动刷新:运营后台每导入一次数据,发布一个刷新事件,所有实例收到事件后删除对应年份的缓存;
- 定时巡检:每天凌晨两点检查一次当年的日历数据是否已存在,如果发现当年还没有数据,就按“周一至周五上班、周六日休息”的默认规则先兜底,并记录告警。
为什么需要定时巡检?因为假期安排的发布日期不一定可控。如果运营只导入了当年,而配置导入入口有 bug,系统不会马上暴雷,但等到跨年时就可能出现“查不到下一年数据”的接口异常。巡检的意义就在于提前发现问题,而不是等到业务高峰期让用户去体验报错。
关于版本管理,我还会把 config_version 随缓存一起保存。比如在 Redis 的 value 里专门留一个字段存版本号,或者在进程内缓存对象里保存 createTime。这样排查问题时,可以直接对比“当前预期版本”和“实际运行版本”,快速判断是不是缓存没刷新。
3.4 对外接口定义与响应设计
接口设计我建议不要只返回一个布尔值,推荐返回一个结构化对象。因为前面也提到了,产品同学会问“今天为什么休息”,前端需要展示“今天休息:国庆节”,这些信息单靠 true/false 给不出来。
我定义的响应结构大致是这样的:
java复制public class DayInfo {
private LocalDate date;
private boolean workday;
private int dayType;
private String dayName;
}
- date:查询的日期;
- workday:当前是否上班;
- dayType:当天类型编码,1 工作日、2 周末、3 法定/公告休息日、4 调休补班日;
- dayName:如果是节假日,返回名称,比如“春节”“国庆节”;不是节假日则为空。
提供的主要接口有四个:
GET /calendar/day?date=2025-05-01&type=DEFAULTGET /calendar/next-workday?date=2025-05-01&offset=30&type=DEFAULTGET /calendar/workday-count?start=...&end=...&type=DEFAULTPOST /admin/calendar/import(管理端导入配置)
每个查询接口都带 type 参数代表日历类型,这样不同部门、不同地区可以通过传入不同的日历类型来区分。
4. 实操过程与踩坑记录
4.1 从建表到上线:完整步骤清单
我这里把实操链路完整列一遍,给想要直接照做的同学一个参考。
第一步,建表。按前文的 SQL 建出 sys_work_calendar,同时建一张 sys_calendar_import_log 记录导入日志,字段大致包括 id、calendar_type、year、config_version、operator、operate_type、created_at。
第二步,写数据导入入口。可以做成管理后台的网页面板,也可以先提供一个 JSON 导入接口。关键是接口里要支持“覆盖整个年份”的语义,而不是只支持单条插入,否则很容易出现重复数据。导入时用事务把该年份旧数据删掉,再插入新数据。
第三步,实现 AnnualCalendar 构建逻辑。从库里读取某年所有规则,生成 byte 数组,放入本地缓存。
第四步,接三层缓存。查询接口优先走本地缓存,未命中再查 Redis,再未命中才查 MySQL 并回填。
第五步,实现基础查询接口。
第六步,编写定时巡检任务和人工刷新接口。
第七步,接入测试环境,用下一年的模拟数据验证跨年场景。
第八步,发布上线。
在实际项目中,我建议首次导入数据时不要手工用 SQL insert,最好通过脚本统一执行,并且保留一份原始数据文件在代码仓库或文档库里。这样即使在管理后台还没完全建好的情况下,也能用脚本维护一段时间。
4.2 典型单元测试用例设计
工作日节假日判定这类模块,非常适合做单元测试,因为逻辑确定性高,不需要依赖外部环境。我建了一个测试类,专门覆盖各种边界。
| 用例名称 | 输入条件 | 期望结果 |
|---|---|---|
| 普通周三 | 2025-03-05 | WORKDAY,workday=true |
| 普通周六 | 2025-03-08 | WEEKEND,workday=false |
| 公告休息日 | 某年国庆第一天 | HOLIDAY,workday=false |
| 调休补班日 | 某年十一前补班周末 | COMPENSATORY_WORKDAY,workday=true |
| 跨年找工作日 | 12月31日往后推1天 | 结果落到次年1月首个工作日 |
| 区间跨年工作日数 | 12月25日到次年1月5日 | 按真实日历校验 |
| 多日历隔离 | DEFAULT 和 SINGLE_REST 查询同一天 | 返回不同结果 |
测试数据的准备不建议写死在代码里,而是准备两份 JSON 文件,一份是“公告规则”,一份是“期望结果”。测试基类会加载规则自动建表,再跑断言。这样每次拿到新年度数据时,只要补一份新文件,就能快速跑一次全链路回归。
4.3 我在实际项目中踩过的几个坑
这部分是我最想分享的,因为很多坑不看代码根本发现不了。
第一个坑:服务器时区设置导致日期差一天。我们有些环境部署在容器里,默认时区是 UTC,而业务时区是北京时间。LocalDate.now() 拿到的日期在东八区凌晨 0 点到 8 点之间可能和 UTC 日期差一天。比如北京时间 1 月 1 日早上 6 点,UTC 时间还是 12 月 31 日 22 点,如果不指定时区直接用 LocalDate.now(),拿到的是前一天的日期,会把 12 月 31 日当成今天,进而判断错误。解决办法是全局指定时区:
java复制LocalDate today = LocalDate.now(ZoneId.of("Asia/Shanghai"));
第二个坑:缓存只加载当年,跨年瞬间查询异常。我最初只在启动时加载“当年”,结果 12 月 31 日深夜和次年 1 月 1 日凌晨,业务方的查询压力全打到 MySQL 上,一些稍慢的接口直接超时。修复方式就是启动预载必须覆盖 N+1 年,且 addWorkdays 方法内部遇到新的一年时要能动态加载。
第三个坑:导入数据时误把“周一”这类普通工作日标记成“补班工作日”。调休配置的源头是公告里的“x月x日上班”字段,和“每周上班哪几天”是两套概念。解析数据时如果只看到日期就写类型,很容易把一些本来就该上班的日子重复标记。虽然最终 isWorkday 的结果是一致的,但运营后台统计调休天数时会出错,而且还会污染节假日名称展示。
第四个坑:调休数据并不总是覆盖周末。有些补班日可能就是正常工作日,比如安排周三补班,这时数据库这一行也会存成 COMPENSATORY_WORKDAY。语义上它能解释“为什么今天上班,但又和普通周三不一样”。如果业务上需要识别这种细微差别,就要保留 day_type 而不是单纯靠 isWorkday。
第五个坑:公司内部“工作日历”和“法定工作日历”不是一回事。有些部门执行大小周,有些岗位每周三固定休息,这些规则如果一开始就揉进同一张表,后面就谁也说不清。我后来把 calendar_type 独立出来,全国法定一套、某个事业部一套,两套数据互不干扰。哪怕现在用不到多日历,我仍然建议把 calendar_type 字段先放在表里,不要省。
5. 常见问题速查与经验建议
5.1 问题排查表
我把日常维护中常见的现象、原因、解决办法整理成一个速查表,方便大家遇到类似问题时直接对照。
| 现象 | 可能原因 | 排查与解决方法 |
|---|---|---|
| 明明当天是法定节假日,接口却返回工作日 | 节假日数据未导入或未刷新 | 检查 sys_work_calendar 表中当天数据,再检查 Redis 和本地缓存版本 |
| 调休补班日返回休息 | 只有默认周末规则,没有覆盖补班配置 | 确认导入规则里的补班日期是否被解析成 COMPENSATORY_WORKDAY |
| 跨年之后查询日期抛空指针 | 只加载了当年的缓存数据 | 启动预载改成当前年+下一年;addWorkdays 遇到新年度动态加载 |
| 不同实例返回结果不一致 | 部分实例缓存未刷新 | 检查刷新事件是否在所有实例生效,必要时手动清缓存 |
| 明明是北京时间的周一,凌晨查询返回周日 | 服务器时区用了 UTC | 统一用 ZoneId.of("Asia/Shanghai") 获取日期 |
| 查询某历史日期发现类型错误 | 当年数据后来被覆盖导入,历史被覆盖 | 引入操作日志和快照表;按 config_version 回滚 |
| 批量计算大量订单时效很慢 | 逐日循环推进导致调用量膨胀 | 引入前缀和数组+二分查找,或增加预生成任务 |
| 运营导入数据后线上没变化 | 刷新逻辑未触发 | 检查导入接口是否发布缓存刷新事件,确认 config_version 是否更新 |
维护这类模块时,一定要养成“先看数据、再看缓存、最后看代码”的排查习惯。大多数线上异常,最终都会落到数据缺失或数据错误上,代码逻辑反而是相对稳定的。
5.2 几点长期维护的经验
最后分享几个从维护角度沉淀下来的经验。
第一,节假日数据导入必须走“可审计”的通道。不管是管理后台手动导入,还是脚本批量导入,都要记录操作人、操作时间、导入批次。我当时就是因为有一次误导入没有日志,排查了一整个下午才定位到是谁在什么时候改的数据。有了 config_version 和审计表之后,这类问题五分钟内就能解决。
第二,把“解析规则”和“判定逻辑”分开。判定逻辑只能消费“某天是什么类型”的数组,不要直接去解析“10月1日至10月7日放假”这类公告文本。解析逻辑只出现在数据导入层。这样即使下一年政策表达方式变了,也只需要改导入解析器,判定模块一行代码都不用动。
第三,节假日名称要随着日期一起返回。很多系统最初只判断工作日,后来为了做 UI 展示、做审批流说明,就需要“今天为什么休息”。如果一开始就带着 dayName 字段,后续扩展会很轻松。
第四,定时巡检一定要有告警。我当时给巡检任务加了企业微信机器人通知,每天如果有缺失就报出来。虽然大多数时候一切正常,但一旦年底通知发布晚了,系统不会悄无声息出错,而是能提前暴露在群里,让产品同学知道当前返回的是“默认规则”,不是最终正式日历。
说白了,这类功能技术深度谈不上高,但它是很多业务流程的“地基”。我做下来最值钱的一个动作,不是把某个算法优化到极致,而是把节假日数据变成了一套可持续维护、可追溯、可回滚的配置体系。后面再接到任何“自定义日历”需求,基本不用动底层模型,加一套 calendar_type 数据就能跑。这种“地基稳了,上层随便盖”的感觉,才是这类模块最有价值的部分。
