简介:这份万年历数据库资源面向需要日期数据支撑的开发与测试人员,覆盖1970年1月1日至2100年12月31日的完整日历信息,可直接用于考勤统计、排班系统、节假日计算、日程管理等场景,省去自行推算农历、星期与节气的繁琐工作。压缩包共3个文件,以1个sql脚本为主体,内含MySQL建表语句与全量插入语句,另附2张png截图用于展示数据表结构与运行效果,整体约872KB,导入前注意将编码设为utf8即可正常使用。目前已有1433人学习下载,说明该数据在同类需求中具备一定参考价值。拿到脚本后可直接建库并拉入执行,快速获得一张字段齐全、日期连续的日历表,便于二次查询与业务扩展,适合初中级开发者及需要快速搭建日期维度的项目使用。
1. 万年历数据库:从1970到2100,一张表扛住两万五千天
很多做排班、考勤、金融计息、节假日判断的系统,最后都会撞上同一堵墙:日期逻辑散落在业务代码里,今天加个“工作日”字段,明天补个“农历”字段,后天又要算“第几周”。等到某天运营说“帮我把2027年所有周一的日期导出来”,你才发现自己得写一堆循环去凑。万年历数据库要解决的就是这件事——把1970年1月1日到2100年12月31日这四万七千多天的日期属性,提前算好、落库、建索引,业务侧只查表不推算。
这个方案适合谁?适合正在做考勤系统、排班工具、财务计息模块、节假日营销配置的开发者。它不复杂,但极其吃细节:闰年、周数归属、农历转换、时区边界,任何一个算错,下游全是脏数据。我见过一个排班系统因为把某年12月31日归到了下一年的第1周,导致跨年那周的班次全部错位,排查了整整两天。所以这篇不聊虚的,直接把建表语句、插入逻辑、生成脚本和踩过的坑摊开讲。
2. 表结构怎么设计:字段取舍决定查询效率
2.1 为什么用一张宽表而不是多张关联表
常见做法是把日期基础信息、农历信息、节假日信息拆成三张表,用日期做主键关联。我一开始也这么设计,后来发现查询时几乎每次都要三表 JOIN,而万年历的数据量是固定的四万七千多条,宽表的存储成本完全可以接受。一张宽表的好处是:业务侧一条SELECT就能拿到全部属性,不用关心关联逻辑;索引建在日期列上,范围查询和单日查询都走同一条路径。
代价是插入时需要一次性算好所有字段。但万年历的数据是静态的——1970到2100的日期属性不会变(除非政策调整节假日,那是另一张配置表的事)。所以宽表在这个场景下是更务实的选择。
字段设计上,我一般会分四组:基础日期组、公历属性组、农历属性组、业务标记组。基础日期组放date_key(DATE类型主键)、year、month、day;公历属性组放day_of_week、day_of_year、week_of_year、quarter、is_weekend;农历属性组放lunar_year、lunar_month、lunar_day、is_leap_month、lunar_date_str;业务标记组放is_holiday、holiday_name、workday_adjust。后面两组允许为 NULL,因为农历和节假日需要额外数据源。
2.2 建表语句与索引策略
CREATE TABLE `calendar_master` ( `date_key` DATE NOT NULL COMMENT '公历日期,主键', `year` SMALLINT UNSIGNED NOT NULL COMMENT '年份', `month` TINYINT UNSIGNED NOT NULL COMMENT '月份 1-12', `day` TINYINT UNSIGNED NOT NULL COMMENT '日 1-31', `day_of_week` TINYINT UNSIGNED NOT NULL COMMENT '星期几 1=周一 7=周日', `day_of_year` SMALLINT UNSIGNED NOT NULL COMMENT '一年中的第几天 1-366', `week_of_year` TINYINT UNSIGNED NOT NULL COMMENT 'ISO周数 1-53', `quarter` TINYINT UNSIGNED NOT NULL COMMENT '季度 1-4', `is_weekend` TINYINT(1) NOT NULL DEFAULT 0 COMMENT '是否周末 1是 0否', `lunar_year` SMALLINT UNSIGNED DEFAULT NULL COMMENT '农历年', `lunar_month` TINYINT UNSIGNED DEFAULT NULL COMMENT '农历月 1-12', `lunar_day` TINYINT UNSIGNED DEFAULT NULL COMMENT '农历日 1-30', `is_leap_month` TINYINT(1) DEFAULT 0 COMMENT '农历是否闰月', `lunar_date_str` VARCHAR(20) DEFAULT NULL COMMENT '农历中文描述', `is_holiday` TINYINT(1) NOT NULL DEFAULT 0 COMMENT '是否法定节假日', `holiday_name` VARCHAR(50) DEFAULT NULL COMMENT '节假日名称', `workday_adjust` TINYINT(1) NOT NULL DEFAULT 0 COMMENT '调休上班标记 1需上班', PRIMARY KEY (`date_key`), KEY `idx_year_month` (`year`, `month`), KEY `idx_week` (`year`, `week_of_year`), KEY `idx_weekend` (`is_weekend`), KEY `idx_holiday` (`is_holiday`, `workday_adjust`) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='万年历主表 1970-2100';主键用DATE类型而不是自增 ID,是因为业务查询几乎全部围绕日期展开,用日期做主键天然去重,也避免了一次额外的唯一索引。idx_year_month服务于按月拉取,idx_week服务于按周统计,idx_weekend和idx_holiday服务于筛选。注意week_of_year用的是 ISO 8601 标准,周一为一周起点,跨年周归属需要特别处理,后面会讲。
提示:如果业务需要频繁按“农历月日”查询(比如生日提醒),建议额外加一个
idx_lunar索引在lunar_month, lunar_day上,但会略微增加插入时间。
2.3 字段类型选择的几个边界
year用SMALLINT UNSIGNED足够覆盖到 65535,但实际只用到 2100。day_of_year最大 366,SMALLINT没问题。week_of_year最大 53,TINYINT够用。is_weekend、is_holiday这些布尔语义的字段用TINYINT(1)是 MySQL 的惯例,不用BIT是因为很多 ORM 对BIT支持不好。
农历字段允许 NULL,是因为农历转换依赖外部数据源,如果暂时没有农历数据,插入时留空不影响公历查询。lunar_date_str存中文描述比如“正月初一”,方便直接展示,避免前端再拼。
3. 数据怎么生成:从日期循环到批量插入
3.1 用 Python 生成插入语句的完整脚本
四万七千多条数据,手写 INSERT 不现实。常见做法是用脚本生成 SQL 文件,再导入 MySQL。我用 Python 写生成脚本,因为日期计算库成熟,逻辑清晰。
import datetime import calendar def iso_week_info(d): """返回 ISO 周数和该周所属的 ISO 年份""" iso_year, iso_week, iso_weekday = d.isocalendar() return iso_year, iso_week def generate_insert(d): year = d.year month = d.month day = d.day # isoweekday: 周一=1 周日=7 day_of_week = d.isoweekday() day_of_year = d.timetuple().tm_yday iso_year, iso_week = iso_week_info(d) quarter = (month - 1) // 3 + 1 is_weekend = 1 if day_of_week >= 6 else 0 # 农历字段暂留 NULL,由后续农历数据源补充 sql = ( f"INSERT INTO calendar_master " f"(date_key, year, month, day, day_of_week, day_of_year, " f"week_of_year, quarter, is_weekend) VALUES (" f"'{d.isoformat()}', {year}, {month}, {day}, {day_of_week}, " f"{day_of_year}, {iso_week}, {quarter}, {is_weekend});" ) return sql def main(): start = datetime.date(1970, 1, 1) end = datetime.date(2100, 12, 31) delta = datetime.timedelta(days=1) lines = [] current = start while current <= end: lines.append(generate_insert(current)) current += delta with open("calendar_insert.sql", "w", encoding="utf-8") as f: f.write("SET NAMES utf8mb4;\n") f.write("START TRANSACTION;\n") f.write("\n".join(lines)) f.write("\nCOMMIT;\n") print(f"共生成 {len(lines)} 条插入语句") if __name__ == "__main__": main()这段脚本的核心是isocalendar(),它返回 ISO 年份、ISO 周数、ISO 星期几。注意iso_year可能和公历year不同——比如 2021 年 1 月 1 日是周五,ISO 归属是 2020 年第 53 周。这就是跨年周问题的根源。脚本里week_of_year存的是 ISO 周数,但year存的是公历年,查询时如果按year + week_of_year组合筛选,跨年那几天会漏掉或重复。解决办法是在表里额外加一个iso_year字段,或者查询时用date_key的范围来界定。
参数说明:start和end定义了日期范围,改这两个值就能生成不同区间的数据。delta是步长,固定一天。输出文件用START TRANSACTION和COMMIT包裹,四万多条插入在一个事务里,比逐条提交快一个数量级。
3.2 批量插入的性能优化与分批策略
四万七千条 INSERT 语句直接导入,MySQL 默认的max_allowed_packet可能不够。我一般会把脚本改成每 1000 条一组,用多值 INSERT 语法:
def chunk_inserts(dates, chunk_size=1000): """把日期列表按 chunk_size 分组,生成多值 INSERT""" for i in range(0, len(dates), chunk_size): chunk = dates[i:i+chunk_size] values = [] for d in chunk: day_of_week = d.isoweekday() day_of_year = d.timetuple().tm_yday iso_year, iso_week = d.isocalendar()[:2] quarter = (d.month - 1) // 3 + 1 is_weekend = 1 if day_of_week >= 6 else 0 values.append( f"('{d.isoformat()}', {d.year}, {d.month}, {d.day}, " f"{day_of_week}, {day_of_year}, {iso_week}, {quarter}, {is_weekend})" ) sql = ( "INSERT INTO calendar_master " "(date_key, year, month, day, day_of_week, day_of_year, " "week_of_year, quarter, is_weekend) VALUES " + ",".join(values) + ";" ) yield sql多值 INSERT 比单条 INSERT 快 5 到 10 倍,因为减少了网络往返和 SQL 解析次数。chunk_size设 1000 是个经验值,太大容易撞max_allowed_packet(默认 4MB 或 64MB),太小则优化效果不明显。导入前可以先SET GLOBAL max_allowed_packet = 67108864;放宽限制。
导入命令用mysql -u root -p your_db < calendar_insert.sql,如果文件太大,可以先用split切成多个小文件再逐个导入。
3.3 农历数据怎么补:外部数据源与更新策略
公历属性可以纯计算,农历不行。农历转换依赖天文算法或预置数据表。常见做法是找一份覆盖 1900 到 2100 的农历数据表,按年存储每年的农历月日映射,然后写脚本关联更新。
我一般会建一张临时表lunar_raw,把农历数据导入,然后用UPDATE ... JOIN回填主表:
UPDATE calendar_master c JOIN lunar_raw l ON c.date_key = l.solar_date SET c.lunar_year = l.lunar_year, c.lunar_month = l.lunar_month, c.lunar_day = l.lunar_day, c.is_leap_month = l.is_leap_month, c.lunar_date_str = l.lunar_str;农历数据源的质量直接决定回填结果。我踩过的坑是:某份数据源在 2057 年的闰月标记错了,导致那一年所有农历日期偏移一个月。所以回填后一定要抽查几个已知日期,比如春节、中秋,和权威日历对照。
4. 查询怎么写:高频场景的 SQL 与索引命中
4.1 按年、月、周、季度拉取日期列表
最常见的查询是“给我 2025 年 3 月的所有日期”:
SELECT date_key, day_of_week, is_weekend, lunar_date_str, is_holiday FROM calendar_master WHERE year = 2025 AND month = 3 ORDER BY date_key;这条走idx_year_month,命中范围扫描。注意ORDER BY date_key在 InnoDB 里因为主键就是date_key,所以排序成本很低。
按周拉取要小心跨年周:
SELECT date_key, day_of_week FROM calendar_master WHERE date_key BETWEEN '2025-12-29' AND '2026-01-04' ORDER BY date_key;这里用日期范围而不是year + week_of_year,就是为了绕开 ISO 周跨年的问题。如果非要按周号查,得同时匹配iso_year,但表里没存这个字段,所以范围查询是更稳妥的做法。
4.2 工作日与节假日筛选的正确姿势
“算 2025 年 3 月有多少个工作日”:
SELECT COUNT(*) AS workday_count FROM calendar_master WHERE year = 2025 AND month = 3 AND is_weekend = 0 AND is_holiday = 0 AND workday_adjust = 0;这里有个逻辑陷阱:调休上班的周末(workday_adjust = 1)应该算工作日,法定节假日的周末(is_holiday = 1)不算。所以更准确的写法是:
SELECT COUNT(*) AS workday_count FROM calendar_master WHERE year = 2025 AND month = 3 AND ( (is_weekend = 0 AND is_holiday = 0) OR workday_adjust = 1 );idx_holiday索引覆盖is_holiday和workday_adjust,但is_weekend不在这个索引里,所以查询会回表。如果这类统计非常频繁,可以考虑建一个联合索引(year, month, is_weekend, is_holiday, workday_adjust),但会增大写入开销。万年历数据写入是一次性的,所以这个代价可以接受。
4.3 农历查询与生日提醒场景
“查农历八月初五对应的公历日期”:
SELECT date_key, lunar_date_str FROM calendar_master WHERE lunar_month = 8 AND lunar_day = 5 AND is_leap_month = 0 ORDER BY date_key;如果没建idx_lunar,这条会全表扫描四万多行,虽然不算慢,但并发高了会拖累。建索引后走索引扫描,响应时间从几十毫秒降到几毫秒。
生日提醒场景通常是“查今天农历对应的公历日期”,反过来用:
SELECT lunar_date_str FROM calendar_master WHERE date_key = CURDATE();这条走主键,最快。然后业务侧拿农历月日去匹配用户表里的农历生日。
5. 避坑与排查:那些让我加班到凌晨的细节
5.1 跨年周归属错误导致排班错位
现象:某排班系统在 2024 年 12 月 30 日到 2025 年 1 月 5 日这一周,班次全部错位一天。原因:脚本用isocalendar()取周数,但表里year存的是公历年,查询时用WHERE year = 2024 AND week_of_year = 1去拉这一周,结果拉到了 2024 年 1 月的那一周。解决:查询跨年周一律用date_key BETWEEN范围,不要用year + week_of_year组合。如果业务必须按周号查,在表里加iso_year字段并建联合索引。
5.2 闰年 2 月 29 日插入失败
现象:导入脚本在 2000 年 2 月 29 日这条报错,提示日期无效。原因:脚本里用datetime.date(year, month, day)构造日期,但某段代码手动拼了f"{year}-02-29"字符串,而 2100 年不是闰年,2 月没有 29 日。解决:所有日期构造都用datetime.date加timedelta循环生成,不要手动拼字符串。2100 年不是闰年(能被 100 整除但不能被 400 整除),这是很多人会忽略的边界。
5.3 时区导致 CURDATE() 与预期差一天
现象:服务器时区是 UTC,业务在東八区,用CURDATE()查“今天”的日期,晚上 8 点后查到的还是前一天。原因:MySQL 的CURDATE()依赖服务器时区。解决:连接时设置SET time_zone = '+08:00';,或者业务侧传入日期参数而不是依赖数据库函数。万年历表本身存的是纯日期,不涉及时区,但查询时的“今天”定义要统一。
5.4 批量插入时 max_allowed_packet 超限
现象:导入 SQL 文件时中途报Packet for query is too large。原因:多值 INSERT 拼接的 SQL 语句超过了max_allowed_packet限制。解决:把chunk_size从 1000 降到 500,或者导入前执行SET GLOBAL max_allowed_packet = 67108864;。注意这个参数修改后需要重新连接才生效。
5.5 农历数据回填后未验证导致偏移
现象:某生日提醒功能在 2057 年全部提前了一个月。原因:农历数据源在 2057 年有一个闰月标记错误,回填时没有校验。解决:回填后抽查每年春节、中秋、端午对应的公历日期,和权威日历对照。我一般会写一个校验脚本,把春节日期和已知列表比对,不一致就报警。
6. 进阶技巧:把万年历用出花来
表建好、数据灌进去之后,真正的价值在于怎么用。我分享几个实际项目里验证过的技巧。
第一个是“工作日偏移计算”。业务常说“三个工作日后”,用万年历表可以一条 SQL 搞定:
SELECT date_key FROM calendar_master WHERE date_key > '2025-03-10' AND (is_weekend = 0 AND is_holiday = 0 OR workday_adjust = 1) ORDER BY date_key LIMIT 3;取第三条就是三个工作日后的日期。比在代码里循环判断快得多,而且逻辑集中在数据库层,不会因为不同服务的实现差异导致结果不一致。
第二个是“季度和周的双维度统计”。很多报表需要同时按季度和周汇总,万年历表里quarter和week_of_year都有,直接GROUP BY即可。但注意跨年周的归属,统计时用date_key的范围来界定周,而不是用week_of_year数字。
第三个是“节假日配置的热更新”。法定节假日每年由相关部门发布,万年历表里的is_holiday和workday_adjust需要每年更新。我一般把这两个字段的更新做成独立的配置表,主表只存公历和农历基础属性,节假日通过视图或 JOIN 关联。这样政策调整时只改配置表,不动主表。
第四个是“生成日期维度表供 BI 使用”。很多 BI 工具需要一张日期维度表来做时间智能计算,万年历表直接导出即可,字段齐全,比在 BI 里现算靠谱。
最后说一个我自己的习惯:每次导入完数据,先跑三条校验 SQL——总行数是否等于日期差加一、每年 2 月天数是否正确、每周的日期是否连续。这三条能拦住 90% 的导入错误。希望帮到你。
本文还有配套的精品资源,点击获取