简介:一份面向Java开发者的年度日期详情入库方案,解决IT系统中维护每年工作日、周末与节假日数据的常见需求。资源将核心代码封装为单个Java文件,经7z压缩后仅2KB,便于快速查看与迁移。实现基于java.time的LocalDate遍历全年日期,借助DayOfWeek判断周末,并预留HolidayCalendar等节假日库接口;通过JDBC连接数据库,设计日期、工作日标识、周末标识、节假日标识等字段表结构,同时考虑批量插入与事务回滚,确保大数据量下的写入效率和一致性。代码层次清晰,包含连接初始化、日期列表构建、批量持久化及异常回滚等模块,并留有数据库连接与自定义节假日规则的配置扩展空间。适合需要构建考勤、排班、运营日历等模块的中级开发者,可借鉴其表设计、批量提交和边界测试(如闰年、补班)等思路。已有3738人学习下载,可直接作为年度工作日历数据初始化的参考脚本。
1. 把节假日、周末、工作日三态日历落库:这个需求解决的是“每天是不是工作日”
做考勤、排班、租金属性计费或者任务调度的后台系统时,几乎每个业务都要回答同一个问题:某一天是不是工作日?前几年我维护过一个考勤统计模块,早期写法是每次请求都现算——先判断周几,再去查节假日表,最后还要处理补班,结果同一套规则散落在三个服务里,每年年底节假日安排一公布就要熬夜改代码。后来换成了标题里这个思路:用 Java 把每年的节假日、周末、工作日详情按日期批量生成,提前落进一张数据库表,业务查询只做一次主键点查。这篇笔记就把这套方案从表结构、生成器、调休覆盖到运维习惯完整拆开讲,适合正在做日历类基础数据维护、或者想让考勤逻辑从“算”变成“查”的开发者。
2. 日历表结构设计:字段明细、唯一主键、覆盖规则表怎么搭配
2.1 三态日历事实表:为什么主键用日期而不是自增ID
先看结果表。这张表要回答的核心问题是“某天是三种状态中的哪一种”,所以它在建模上是一张事实表:每一行代表一个日期,只保留与该日期身份相关的属性,不掺业务状态。
我常用的建表语句如下:
CREATE TABLE t_calendar_detail ( calendar_date DATE NOT NULL COMMENT '日期,主键', year SMALLINT NOT NULL COMMENT '年份', month TINYINT NOT NULL COMMENT '月份', day TINYINT NOT NULL COMMENT '日', weekday TINYINT NOT NULL COMMENT '星期几,1=周一,7=周日', day_type TINYINT NOT NULL COMMENT '1=工作日,2=周末,3=节假日', holiday_name VARCHAR(64) DEFAULT NULL COMMENT '节假日名称,工作日/周末为NULL', remark VARCHAR(128) DEFAULT NULL COMMENT '备注,如“调休补班”', create_time DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, update_time DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, PRIMARY KEY (calendar_date), KEY idx_year_month (year, month) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='日历三态事实表';主键我没有用自增 ID,直接拿calendar_date做主键。原因是这张表的查询模式非常固定:要么按某个日期精确查,要么按年份/月份范围扫。日期本身全局唯一,用它做物理主键还能让按时间范围的扫描走聚簇索引顺序读,比自增主键再回表快一个量级。year/month/day/weekday这些冗余字段看着多余,但实际报表经常按“某年某月的工作日数”分组统计,直接从行里取比DATE_FORMAT包一层函数高效得多,也避免函数导致索引失效。
2.2 覆盖规则表:调休补班只改这张表
事实表只负责“最终结果”,它不应该是人直接改的地方。真正需要人工维护的是规则表,也就是每年年底公布的调休与法定节假日安排。我一般会再建一张覆盖规则表:
CREATE TABLE t_rule_override ( target_date DATE NOT NULL COMMENT '被覆盖的日期', override_type TINYINT NOT NULL COMMENT '1=调休补班(周末变工作日),3=法定节假日(工作日变节假日)', holiday_name VARCHAR(64) DEFAULT NULL COMMENT '节假日名称', source_year SMALLINT NOT NULL COMMENT '规则归属年份', effective_time DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP COMMENT '同一天多条规则时取最新', create_time DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, PRIMARY KEY (target_date), KEY idx_source_year (source_year) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='节假日/调休覆盖规则表';这张表只保存“与默认规则不一致”的日子。默认规则在代码里写死:周一至周五是工作日,周六周日是周末。与默认规则一致的日期根本不需要进规则表,这样每年要维护的记录通常只有三四十条。target_date做主键很关键——同一天的最终状态只能有一种,这个约束从表结构上就杜绝了“既放假又补班”的矛盾数据。
2.3 索引取舍:按日期范围查询为主,day_type 不要单独建索引
索引设计上,主键索引已经覆盖了精确日期查询。idx_year_month是为了“某年某月”的分组统计,比如查 6 月的应出勤天数。
踩过的坑是:最开始我给day_type单独建了索引,想着按状态过滤会快,实际完全没用。day_type只有三四个取值,区分度极低,优化器全表扫比走索引回表更快,这个索引纯属浪费写放大。如果非要做“按工作日查”的统计,正确做法是利用year + month组合索引,把日期范围先缩小到一个月再过滤状态。
还有个细节:日期务必用DATE类型,不要图导入方便用VARCHAR。用字符串存日期,等值查询还能靠主键索引碰巧命中,一旦做BETWEEN或者DATE_SUB运算,排序规则和隐式转换会让索引彻底失效,后面第 5 章会把这个坑单独展开。
3. 用 Java 批量生成全年日历并写库:生成器核心代码与 JDBC 批量参数
3.1 生成器核心流程:LocalDate 遍历 + 规则覆盖
生成全年的逻辑并不复杂:第一步从t_rule_override读出所有覆盖规则;第二步从 1 月 1 日遍历到 12 月 31 日;第三步对每个日期先按“工作日/周末”默认规则打分,再看有没有覆盖规则,有就按覆盖结果重写。这里我直接用 JDBC 做示例,换成MyBatis或Spring Data思路完全一致:
public void generateYear(Connection conn, int year) throws SQLException { Map<LocalDate, Integer> overrideMap = loadOverrideMap(conn, year); LocalDate start = LocalDate.of(year, 1, 1); LocalDate end = LocalDate.of(year, 12, 31); List<CalendarDetail> rows = new ArrayList<>(); for (LocalDate date = start; !date.isAfter(end); date = date.plusDays(1)) { DayOfWeek dow = date.getDayOfWeek(); // 1. 默认规则:周一至周五为工作日,周六周日为周末 int dayType = (dow == DayOfWeek.SATURDAY || dow == DayOfWeek.SUNDAY) ? 2 : 1; String holidayName = null; String remark = null; // 2. 覆盖规则优先:调休补班或法定节假日以规则表为准 if (overrideMap.containsKey(date)) { Integer override = overrideMap.get(date); dayType = override; remark = "规则覆盖"; } // 3. 拼装实体:year/month/day 直接从 LocalDate 取,不用字符串格式化 CalendarDetail detail = new CalendarDetail(); detail.setCalendarDate(date); detail.setYear(date.getYear()); detail.setMonth(date.getMonthValue()); detail.setDay(date.getDayOfMonth()); detail.setWeekday(dow.getValue()); detail.setDayType(dayType); detail.setHolidayName(holidayName); detail.setRemark(remark); rows.add(detail); } writeBatch(conn, year, rows); }这段代码有几个值得注意的参数与选型点。LocalDate遍历完全没有SimpleDateFormat的线程安全问题,生成 365 个日期对象对 JVM 来说开销可以忽略。getMonthValue()返回 1-12 的int,与表结构里TINYINT对应,不要用Month枚举直接存,否则下一步写库还要做一次转换。overDayType存的是最终类型,所以规则覆盖时直接赋值即可。
3.2 JDBC 批量写入:关闭自动提交与 rewriteBatchedStatements
generateYear里只组装数据,真正的性能差异在写库这一段。批量插入全年 365/366 条数据,逐条INSERT和批量addBatch在数据量小时看不出差别,但如果你要一次性生成未来五年甚至十年,差距就是秒级和分钟级的区别。
我在接入层一般这样写:
private void writeBatch(Connection conn, int year, List<CalendarDetail> rows) throws SQLException { // 先清理旧数据,保证任务可重复执行,不会主键冲突 try (PreparedStatement ps = conn.prepareStatement("DELETE FROM t_calendar_detail WHERE year = ?")) { ps.setInt(1, year); ps.executeUpdate(); } String sql = "INSERT INTO t_calendar_detail " + "(calendar_date, year, month, day, weekday, day_type, holiday_name, remark) " + "VALUES (?, ?, ?, ?, ?, ?, ?, ?)"; conn.setAutoCommit(false); // 关闭自动提交,攒一批再提交 try (PreparedStatement ps = conn.prepareStatement(sql)) { int batchSize = 2000; int count = 0; for (CalendarDetail d : rows) { ps.setObject(1, d.getCalendarDate()); ps.setInt(2, d.getYear()); ps.setInt(3, d.getMonth()); ps.setInt(4, d.getDay()); ps.setInt(5, d.getWeekday()); ps.setInt(6, d.getDayType()); ps.setString(7, d.getHolidayName()); ps.setString(8, d.getRemark()); ps.addBatch(); count++; // 每 2000 条执行一次,避免 single batch 过大 if (count % batchSize == 0) { ps.executeBatch(); } } ps.executeBatch(); // 收尾再刷一次 conn.commit(); } catch (SQLException e) { conn.rollback(); throw e; } finally { conn.setAutoCommit(true); } }这里有一个隐藏很深的性能参数:如果你连的是 MySQL 5.1 以上的版本,JDBC 连接串一定要加上rewriteBatchedStatements=true。不加这个参数,驱动拿到addBatch后依然是一条一条发给服务端,批处理名存实亡;加上之后驱动才会把多条INSERT重写为一条多值的INSERT语句。
jdbc:mysql://127.0.0.1:3306/yourdb?useUnicode=true&characterEncoding=utf8&rewriteBatchedStatements=truesetObject传LocalDate给DATE列,在 JDBC 4.2 之后有标准映射,比java.sql.Date.valueOf(date)少一层转换包装,代码也更干净。batchSize不建议一次顶满全年数据,MySQL 的max_allowed_packet会限制单次包大小,2000 一批在绝大多数默认配置下都能安全通过。
3.3 全量重建与增量补刷:两种调用方式怎么选
生成器写好后,调用策略有两种常见方式。
全量重建适合每年年初一次性执行:先删掉整年数据再插入整年数据,逻辑干净,表里永远只有一份完整结果。代价是执行期间该年份数据不可用,对排班系统来说,凌晨执行完全没问题。
增量补刷适合“节假日安排出了补充通知,只改后面三个月”的场景。比如年中突然宣布某一天放假,这时全量删掉整年再重建明显浪费,只刷受影响区间更合理。我的做法是给生成器加一个窗口参数:
public void rebuildWindow(Connection conn, LocalDate start, LocalDate end) throws SQLException { // 删除窗口内数据,重新生成该区间,规则表全量参与覆盖判断 generateRange(conn, start, end); }两种方式的取舍用一个简单标准判断:数据量不到 5 万行,无脑全量重建;超过 5 万行或业务要求“生成期间仍可读”,走增量窗口。实际项目里一般用不到这个阈值,全量重建足够。
4. 调休与补班:把节假日“覆盖”逻辑做成可回滚的规则映射
4.1 为什么单纯“周末等于休息日”会翻车
默认规则“周六周日为周末休息”在绝大多数日子里是成立的,但每年总有那么十几天被官方公布的放假安排打破。最典型的例子:五一、国庆前后往往会有“前一个周日补班”或“周六调休放假”,就是说默认规则里的周六周日可能被改成工作日,默认规则里周一到周五也可能被改成节假日。如果不做覆盖机制,只靠“周末自动休息”的代码,考勤系统在补班那天会漏判大量缺勤。
所以生成环节里我把覆盖逻辑设计成“先按默认规则定基调,再用规则表整体覆盖”,而不是写一堆if (date.equals(某节日))。因为节日日期每年都变,硬编码经不起时间推敲。
4.2 规则合并与去重:同一天多条规则取最新一条
覆盖规则表维护的人可能不止一个,每年元旦前 A 同学录入一批,元旦后 B 同学又改了几个日期。如果规则表出现target_date重复,主键约束会直接阻止,但历史数据替换时可能出现“同一日期先被标记调休补班,又被标记节假日”的修改。这种场景我用一句话处理:target_date上的记录永远只有最新一条,INSERT ... ON DUPLICATE KEY UPDATE保证幂等。
INSERT INTO t_rule_override (target_date, override_type, holiday_name, source_year, effective_time) VALUES (?, ?, ?, ?, CURRENT_TIMESTAMP) ON DUPLICATE KEY UPDATE override_type = VALUES(override_type), holiday_name = VALUES(holiday_name), source_year = VALUES(source_year), effective_time = CURRENT_TIMESTAMP;加载规则映射时也要注意逻辑方向:默认工作日被覆盖成节假日,默认周末被覆盖成补班工作日,这两类都从t_rule_override读,统一按target_date做Map<LocalDate, Integer>,生成器只认“日期最终的 day_type 值”,不再关心中间发生了什么。这样规则表与事实表彻底解耦,将来想回滚某条规则,直接删规则表里的记录再重新触发重建即可。
4.3 跨月边界与节假日名称归档:前后 15 天一起解析
每年公布的年放假安排,会指定“春节前一周的周日补班”,而这个补班日期很可能落在上一年的 12 月或者下一年的 1 月。所以加载规则时的查询条件不能只锁死当前年份,而要前后各留出缓冲,我把这个窗口叫做“年边界缓冲”。
SELECT target_date, override_type, holiday_name FROM t_rule_override WHERE target_date BETWEEN ? AND ?这里的两个问号分别是给定年份的1月1日减去15天和12月31日加上15天。生成时日期落入缓冲区内但年份不是目标年份的行,会直接忽略,只有落在目标年份内的日期才写入事实表。这个设计解决了很多团队“春节补班总漏一天”的毛病。
节假日名称归档也有讲究。规则表里的holiday_name要按真实节日写全,比如“国庆节”“中秋节”,不要写“10月1日放假”这种日期化命名。因为后续判断“某节假日是否调休”“今年中秋国庆是否重叠”都要靠这个名称做统计分析,命名规范了,报表层就不用再清洗。
5. 日历落库避坑:写日期字段最容易翻车的 5 个问题
5.1 YYYY 与 yyyy 混用:年尾日期被排到错误年份
现象:生成 12 月 31 日的数据时,控制台打印出来的年份是下一年。查数据库,发现这一行的year字段被写成了下一年的值。
原因:SimpleDateFormat或DateTimeFormatter里用了YYYY。这个模式代表“周周年份”,不是日历年。当某年的 12 月 31 日恰好属于下一周的 ISO 周期时,YYYY会输出下周所在的年份。
解决:生成器里完全不要依赖格式化字符串推导年份,直接用LocalDate.getYear()。如果历史代码用了DateTimeFormatter.ofPattern("yyyy-MM-dd"),一定要明确yyyy才是日历年。这个坑在年初或者年末跑批时特别容易触发,因为周周年份与日历年只在跨年那一两天不同。
5.2 主键冲突与重复生成:任务重跑直接抛异常
现象:同样的生成任务跑第二遍,插入第 366 行时报Duplicate entry '2025-12-31' for key 'PRIMARY'。
原因:生成器第一次执行成功后,事实表已经存在全年数据;第二次执行没有做清理直接INSERT,主键约束拦截。
解决:writeBatch开头一定要按年份先DELETE再插入,也就是前面代码里的DELETE FROM t_calendar_detail WHERE year = ?。这个操作把重跑任务从“不可重复执行”变成“幂等”,任何时间重放都不会产生脏数据。要注意,如果删除之后、插入之前有其他进程在查询这张表,会短暂查不到数据。低峰期运维,或者用临时表替换写入,二选一。
5.3 时区问题:日期整体偏移一天
现象:数据库里存好的日期,和本地代码里LocalDate对象完全对不上,总是差一天。比如代码里是 2025-01-01,库里变成 2024-12-31。
原因:生成时用了LocalDateTime或者Date,在写入DATE列时经过 JDBC 驱动做了时区换算。应用服务器时区与数据库会话时区不一致时,边界时间会漂移。
解决:全部改用LocalDate,数据库连接串显式指定时区参数:
useJDBCCompliantTimezoneShift=true&serverTimezone=Asia/Shanghai代码里ps.setObject(1, localDate)直接传纯日期对象,不要Date.valueOf(localDate)之后再转一次。时区问题属于典型的“本地没事、上线翻车”,因为本地数据库和线上数据库时区配置往往不同。
5.4 只维护放假没维护补班:考勤异常集中在节前
现象:某月底考勤报表突然冒出一批缺勤,正好都是之前调休补班的那几天。系统后台查“节假日配置”明明有记录。
原因:规则表只录入了“法定节假日”这一类覆盖,也就是只把工作日改成了节假日,却漏掉了“周末补班”这类逆向覆盖。默认规则认为那天是休息日,事实表也就按休息日记录,导致应出勤日被算成了休息。
解决:覆盖规则的设计必须同时支持两个方向:override_type=1表示周末改成工作日,override_type=3表示工作日改成节假日。生成逻辑不要只处理“放假”那一侧。另外,补班日期在规则表里也要保留holiday_name的原始信息,比如备注“国庆前补班”,方便报表侧解释异常出勤记录。
5.5 日期列隐式转换:VARCHAR 与 DATE 的混用导致索引失效
现象:表里calendar_date定义为VARCHAR,查询WHERE calendar_date BETWEEN '2025-01-01' AND '2025-01-31'慢到全表扫。
原因:字符串比较按字典序,'2025-1-1'和'2025-01-01'排序结果完全不同,逻辑上像一个日期,物理上排成两条序列。一旦数据量上来,所谓“日期范围查询”根本没有范围可言。
解决:表结构里列类型必须是DATE,参数传LocalDate,查询条件不要用DATE_FORMAT(calendar_date) = ...这种写法,因为对列做函数处理会让索引失效。正确姿势是calendar_date BETWEEN ? AND ?,让日期列裸着参与比较。
6. 进阶:增量窗口更新、结果校验与“只改规则表”的运维习惯
6.1 按未来 90 天窗口增量重建
全量重建适合年初,但年中若突发“某天临时放假”的通知,重跑整年代价不大,却也容易把已有人工订正的数据覆盖掉。我的习惯是平时只维护规则表,发布新规则后,对受影响的未来 90 天窗口做增量重建。窗口选择 90 天是因为绝大多数临时通知都不会影响超过一个季度,窗口太大没必要,太小又可能覆盖不全连续调休。
这种运维方式要求生成器把“删除与插入”限定在一个窗口内:
public void rebuildWindow(Connection conn, LocalDate start, LocalDate end) { deleteRange(conn, start, end); generateRange(conn, start, end); }删除和生成需要放在同一个事务里,避免删除成功、生成失败造成窗口空窗。
6.2 全年结果校验:对比 SQL 与总数范围
生成完数据,别急着上线,跑两组校验。第一组是总量校验,按day_type分组统计,全年三种类型加起来必须等于 366(闰年)或 365:
SELECT day_type, COUNT(*) FROM t_calendar_detail WHERE year = 2025 GROUP BY day_type;正常年份工作日数量通常在 240 到 260 之间,周末在 100 到 120 之间,节假日约 11 到 20 天。如果统计结果明显越界,说明规则覆盖环节出了问题。第二组是规则匹配校验,把规则表里所有补班日期与事实表交叉比对:
SELECT r.target_date FROM t_rule_override r LEFT JOIN t_calendar_detail c ON c.calendar_date = r.target_date WHERE r.override_type = 1 AND c.day_type != 1;查出来的每一行都是“规则表要求这天补班,事实表却没生效”的漏网之鱼。这两组 SQL 丢进定时任务里每天跑一次,能兜住绝大多数人为配置错误。
6.3 一个值得养成的习惯:规则表永远比事实表“高级”
我接手这个方案半年后,最大的体会是:事实表只允许被生成器写入,永远不要改单行数据。哪怕手工修一条“某天应该是节假日”的错误,也先去改规则表,再触发对应年份/窗口重建。
因为事实表是下游业务读取的唯一来源,手工改一条很可能覆盖掉生成逻辑期望的最终状态,之后规则表再变动,这一条手工数据就被静默抹掉了。相反,“规则表 → 生成器 → 事实表”的单向数据流,任何时刻都可以从头重算,等于给日历系统留了一颗后悔药。后来遇到“某个日期状态不对”的反馈,我先查规则表而不是直接改结果表,十次有九次都是规则漏配补班。希望帮到你。
本文还有配套的精品资源,点击获取