☰
构建最全MySQL黄历库:从表结构设计到查询优化与API封装
2026/10/14 8:31:07 网站建设 项目流程

简介:一套面向开发者和数据爱好者的MySQL万年历数据脚本,覆盖一九七零年至二一零零年间的农历、二十四节气、财神方位、每日宜忌、星座、天干地支与五行等传统日历信息,适用于黄历查询网站、传统习俗类应用或数据库学习项目。包内共有两个文件,一个CSV格式的数据文件和一个SQL脚本文件,压缩后体积约一点八六兆字节;CSV提供结构化原始数据,SQL负责建表并支持LOAD DATA方式快速导入,设计简洁、便于二次修改。数据表按模块拆分为日历、财神方位、宜忌事项、星座、干支、五行等多个实体,可灵活按日期或节气查询,也能进一步扩展前端展示。还支持按年份统计节气分布、关联查询某日农历与星座,方便民俗研究或日历产品开发。目前已有1611人学习下载,直接导入即可获得完整的历法数据基础,节省整理时间。

1. 万年历脚本与 MySQL 黄历库:为什么传统日历数据值得自己掌握

做内部信息系统、信息屏或工具小程序时,如果碰过“显示今天是农历几号、宜什么忌什么”这类需求,大概率会经历两个尴尬:第三方接口的字段要么缺农历干支,要么宜忌只给字符串不带结构化数据;等用户量上来,免费接口开始限流,付费接口又贵。于是自己攒一套“最全万年历脚本 + MySQL 黄历库”成了更靠谱的路线。这里的“最全”不是指某个现成包拿过来就能跑,而是指数据维度覆盖公历、农历、干支、生肖、节气、节日和每日宜忌,并且由脚本统一抓取或推算、统一入库、统一对外查询。适合想掌控数据、需要内网离线环境,或者被第三方接口涨价和限流折磨过的开发者。

2. 黄历数据模型设计:表结构、字段定义与三范式边界

在写任何脚本之前,先把表结构定下来。我看到不少初学者把日期、农历、宜忌全塞进一张表,几十个字段堆一起,第一版查询确实爽,但后面做统计、做筛选、做更新时会很难受,尤其当宜忌是列表型数据时,一张表根本装不下。这一章给出一个经过取舍的 MySQL 建表方案,同时解释为什么要把数据拆成“核心日历表 + 宜忌表 + 节日表”三张表,而不是一张 JSON 万能表。

2.1 公历、农历、干支在一张表里怎么不打架:从日期主键开始

核心日历表以公历日期作为主键,原因很简单:任何一天的公历日期是唯一的,而且查询时几乎总是先按公历日期定位。主键用DATE比用字符串更省空间,还能直接做区间比较。农历字段不能当作主键,因为农历存在闰月,一年里可能出现两个正月,而公历每一天绝不会重复,所以所有外键引用都应该指向公历日期,而不是农历日期。

CREATE TABLE `calendar` ( `d` DATE NOT NULL, `solar_year` SMALLINT NOT NULL, `solar_month` TINYINT NOT NULL, `solar_day` TINYINT NOT NULL, `lunar_year` SMALLINT NOT NULL, `lunar_month` TINYINT NOT NULL, `lunar_day` TINYINT NOT NULL, `lunar_is_leap` TINYINT(1) NOT NULL DEFAULT 0, `lunar_text` VARCHAR(20) NOT NULL, `ganzhi_year` VARCHAR(10) DEFAULT NULL, `ganzhi_month` VARCHAR(10) DEFAULT NULL, `ganzhi_day` VARCHAR(10) DEFAULT NULL, `zodiac` VARCHAR(6) DEFAULT NULL, `solar_term` VARCHAR(10) DEFAULT NULL, `week` TINYINT NOT NULL, PRIMARY KEY (`d`), KEY `idx_lunar_month` (`lunar_year`,`lunar_month`,`lunar_is_leap`), KEY `idx_solar_term` (`solar_term`) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;

这里故意把solar_year、solar_month、solar_day冗余出来,是为了避免在查询条件里写YEAR(d)或MONTH(d)。这类函数会让主键索引失效,后面第 5 章会专门讲这个坑。lunar_text存“闰二月初五”这样的中文显示文本,省得前端每次拿年月日重新拼。生肖其实可以从干支年推出来,但存下来能让查询和缓存少做一层映射,值很小,留着不亏。

参数说明:TINYINT足够存月日,SMALLINT存年份,日期最大范围到 2049 年没问题。lunar_is_leap用TINYINT(1)而不是BIT,方便pymysql直接取值。字符集必须用utf8mb4,因为要存生僻字和少数节日名称里的扩展字符,utf8mb4比utf8更保险。索引不是越多越好,主键用于按天查询;如果业务常按农历月查,(lunar_year, lunar_month, lunar_is_leap)联合索引就有意义;节气字段值稀疏,但搜“今天是不是立秋”时,solar_term上的索引仍能加速。写入大量数据时,多余索引会让插入变慢,所以只给频繁出现在WHERE或JOIN里的字段建索引。

2.2 宜忌和节日用独立表还是 JSON 字段:别把一行的 JSON 当万能

“宜忌”是黄历里用户最关注的字段。一个日期可能同时有“宜:祭祀、入宅、嫁娶”“忌:动土、安葬”等多种事项,天然是一对多关系。如果塞在calendar表里存一个 JSON 数组,查询“今天适不适合祭祀”就要写JSON_CONTAINS,统计“本月宜嫁娶几天”更麻烦。虽然 MySQL 8.0 提供 JSON 函数和多值索引,但为了兼容性、可读性和后续更新,独立表是更省心的选择。

CREATE TABLE `calendar_yanji` ( `id` BIGINT UNSIGNED AUTO_INCREMENT, `d` DATE NOT NULL, `type` ENUM('宜','忌') NOT NULL, `item` VARCHAR(32) NOT NULL, `sort_order` TINYINT NOT NULL DEFAULT 0, PRIMARY KEY (`id`), UNIQUE KEY `uk_d_type_item` (`d`,`type`,`item`), KEY `idx_d` (`d`) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4; CREATE TABLE `calendar_holiday` ( `id` BIGINT UNSIGNED AUTO_INCREMENT, `d` DATE NOT NULL, `name` VARCHAR(32) NOT NULL, `category` TINYINT NOT NULL COMMENT '1=公历节日 2=农历节日 3=节气 4=纪念日', PRIMARY KEY (`id`), UNIQUE KEY `uk_d_name` (`d`,`name`), KEY `idx_d` (`d`) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;

calendar_yanji的UNIQUE KEY uk_d_type_item非常关键,它是脚本重复执行的兜底手段。没有这个唯一键,抓一次数据插一次,跑两遍就会出现同样的“宜:祭祀”两行,查询时COUNT和显示都会出错。sort_order控制显示顺序,因为同样类型的宜忌条目没有自然顺序,需要人工指定。

calendar_holiday单独建表,是因为一个日期可能同时是公历节日、农历节日和节气。比如某天既是“国际劳动节”又是“农历四月初一”,把它们各存一行,联合查询时用LEFT JOIN就能把多节日全部带出。有人习惯基于calendar表的solar_term字段来当节日判断,那样不够通用,因为还有非节气的历史纪念日。拆表后,节日表可以由不同的数据源独立更新,不会污染核心日历表。

这里再补一句选型理由:如果只是给一个单机小工具显示最近三天黄历,JSON 字段会更快;但只要数据覆盖几十年、需要按节日统计、需要按月展示假期,三张表的模型是长期可维护的。一的“最全”应该建立在数据维度上,而不是建立在把所有东西塞进一行里。执行建表时,常见做法是mysql -u root -p < schema.sql,或者用迁移工具管理,不建议在业务程序里每次启动都执行CREATE TABLE IF NOT EXISTS,那会把权限和迁移搞得很乱。

3. 数据填充的两种路线:在线抓取黄历接口与本地农历推算

建好表之后,第一件事是灌数据。市面上没有一份可以直接下载的官方“最全黄历 SQL 文件”,所以常见做法有两种:在线抓取现成接口,或者用农历转换库在本地推算。先说抓取,它适合一次性初始化;再说本地推算,它适合长期更新和离线环境。两条路线可以混合:用抓取结果补全宜忌,用本地推算核对农历干支。

3.1 在线抓取:从公开黄历接口到 MySQL 的入库脚本

公开的“老黄历”接口通常能返回农历日期、干支、生肖、节气、宜忌、冲煞等字段,一次调用就覆盖了核心日历表的大部分内容。选型理由很直接:不用自己维护农历算法,接口字段已经满足“最全”的八成要求。风险是接口不稳定、限流、字段命名不统一。所以脚本里必须设置超时、失败重试和日志,不能一把梭。

import requests import pymysql from datetime import date, timedelta conn = pymysql.connect( host="127.0.0.1", user="root", password="yourpass", database="calendar_db", charset="utf8mb4" ) cur = conn.cursor() start = date(2025, 1, 1) end = date(2025, 12, 31) d = start while d <= end: # 这里的 URL 只是示意,实际按你的数据源文档替换 resp = requests.get( "https://your-provider.example.com/api/huangli?date=" + d.isoformat(), timeout=10 ) if resp.status_code != 200: print("failed", d, resp.status_code) d += timedelta(days=1) continue data = resp.json() cur.execute(""" INSERT INTO calendar (d, solar_year, solar_month, solar_day, lunar_year, lunar_month, lunar_day, lunar_is_leap, lunar_text, ganzhi_year, ganzhi_month, ganzhi_day, zodiac, solar_term, week) VALUES (%s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s) ON DUPLICATE KEY UPDATE lunar_text = VALUES(lunar_text) """, ( d, d.year, d.month, d.day, data["lunar_year"], data["lunar_month"], data["lunar_day"], data["leap"], data["lunar_text"], data["gz_year"], data["gz_month"], data["gz_day"], data["zodiac"], data["solar_term"], d.isoweekday() )) if d.day == 1: print("done", d.year, d.month) d += timedelta(days=1) conn.commit() cur.close() conn.close()

逻辑说明:requests.get必须带timeout,否则某个日期卡住时,整个循环会一直停在原地。d.isoweekday()返回 1 到 7,分别代表周一到周日;如果前端习惯 0-6,可以换成weekday() + 1。ON DUPLICATE KEY UPDATE处理重复执行,主键d不变,只会对lunar_text等可能变化的字段做更新,这相当于给了脚本一个“第二次跑也能修正数据”的入口。参数说明里最重要的就是连接串要带charset="utf8mb4",否则中文入库后就是乱码,这个问题我在第 5 章会展开。

这种逐日请求的方式有个明显风险:接口一天内可能只允许几十次请求,而你要灌十年数据。常见做法是每次请求之间time.sleep(0.1),并设置随机 User-Agent;如果收到 429,要做退避重试。我一般会把抓取和入库拆成两步:先把接口返回的 JSON 按日期存成本地文件,全部抓完校验数量无误后,再统一读取入库。这样能避免网络失败导致数据半更新,也方便后续排查“哪一天抓漏了”。

3.2 本地推算:用开源农历转换库补齐十年数据,不用看接口脸色

如果不想依赖在线接口,或者机房根本不能访问外网,本地推算路线就更合适。公历转农历的核心是“查表 + 校正”,没必要自己造轮子,直接用 Python 的lunar_python这类开源农历转换库就能拿到农历、干支、生肖、节气。要注意一个边界:这类库能算准历法字段,但“宜忌”通常不是纯历法能算出来的,它是根据当日干支、建除十二神、二十八宿、吉神凶煞等规则组合出来的。所以本地推算主要用于生成calendar表的历法字段,而calendar_yanji的成绩仍然需要来自整理好的规则资料或样本数据。

from datetime import date, timedelta from lunar_python import Solar, Lunar import pymysql def solar_to_lunar_row(d: date): solar = Solar.fromYmd(d.year, d.month, d.day) lunar = solar.getLunar() solar_term = lunar.getJieQi() if solar_term is None: solar_term = lunar.getZhongQi() return { "lunar_year": lunar.getYear(), "lunar_month": lunar.getMonth(), "lunar_day": lunar.getDay(), "lunar_is_leap": 1 if lunar.isLeap() else 0, "lunar_text": lunar.toString()[:12], "ganzhi_year": lunar.getYearInGanZhi(), "ganzhi_month": lunar.getMonthInGanZhi(), "ganzhi_day": lunar.getDayInGanZhi(), "zodiac": lunar.getYearShengXiao(), "solar_term": solar_term, } conn = pymysql.connect( host="127.0.0.1", user="root", password="yourpass", database="calendar_db", charset="utf8mb4" ) cur = conn.cursor() start = date(2025, 1, 1) end = date(2025, 12, 31) d = start while d <= end: row = solar_to_lunar_row(d) cur.execute(""" INSERT INTO calendar (d, solar_year, solar_month, solar_day, lunar_year, lunar_month, lunar_day, lunar_is_leap, lunar_text, ganzhi_year, ganzhi_month, ganzhi_day, zodiac, solar_term, week) VALUES (%s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s) ON DUPLICATE KEY UPDATE lunar_text = VALUES(lunar_text) """, ( d, d.year, d.month, d.day, row["lunar_year"], row["lunar_month"], row["lunar_day"], row["lunar_is_leap"], row["lunar_text"], row["ganzhi_year"], row["ganzhi_month"], row["ganzhi_day"], row["zodiac"], row["solar_term"], d.isoweekday() )) d += timedelta(days=1) conn.commit() cur.close() conn.close()

逻辑说明:lunar_python的Solar.fromYmd用于构造公历日期,getLunar()返回农历对象。getJieQi()返回“节”比如立春;如果当天不是节,再取getZhongQi()返回“气”比如雨水。两个都为空,说明当天没有节气,不需要写入。不同版本的方法名可能略有差异,写代码前看一遍本地库文档就行,我这里只是示意标准流程。

本地推算的优点是完全离线、可重现、没有网络开销和限流风险;缺点是需要单独处理宜忌规则。要补齐“最全宜忌”,常见做法是抓一个短周期的样本数据(比如一年的宜忌),分析出它和干支、农历月日之间的对应关系,再写成规则表,后续每天按规则生成。这个过程有一点玄学,不同版本的黄历书对宜忌说法并不完全一致,所以如果你不是做传统命理产品,直接用样本数据一夜填充即可,不必追求规则完美。

路线优点缺点适用场景
在线抓取字段全、省事依赖网络、限流一次性初始化
本地推算离线、稳定宜忌规则难生成长期更新、内网
混合路线历法用推,宜忌用抓两套代码都要维护最全与可控兼顾

4. 查询层:按日、区间与模糊搜索的 SQL 写法与索引优化

数据入库后,查询才是真正体现“最全”价值的地方。这一章按三个高频场景给出 SQL:当天黄历、整月/整年区间、关键字搜索。同时把索引失效的坑一起说清楚,因为你会发现“同样的数据,SQL 写法不同,响应时间可能差出一个数量级”。

4.1 当天黄历的联表查询:一条 SQL 把公历农历宜忌带全

用户问“今天黄历”,前端通常要一次拿到农历、干支、宜忌列表。最直接的方式是calendar表和calendar_yanji表按日期关联,再用GROUP_CONCAT把多行宜忌聚合成一列。

SELECT c.d, c.lunar_text, c.ganzhi_year, c.ganzhi_month, c.ganzhi_day, c.zodiac, c.solar_term, GROUP_CONCAT(DISTINCT CASE WHEN y.type = '宜' THEN y.item END) AS yi, GROUP_CONCAT(DISTINCT CASE WHEN y.type = '忌' THEN y.item END) AS ji FROM calendar c LEFT JOIN calendar_yanji y ON y.d = c.d WHERE c.d = '2025-02-10' GROUP BY c.d;

这条 SQL 的关键点有三个:第一,用LEFT JOIN而不是INNER JOIN,保证即使某天没有宜忌数据,公历农历信息也能返回;第二,GROUP_CONCAT默认长度上限是 1024 字节,宜忌条目一般不会超,但保险起见可以在会话前执行SET SESSION group_concat_max_len = 4096;第三,DISTINCT防万一,避免同一日期同一事项重复行造成列表重复。

如果你不想在应用层拆逗号,也可以直接返回多行,让前端按type分组渲染:

SELECT c.d, y.type, y.item FROM calendar c LEFT JOIN calendar_yanji y ON y.d = c.d WHERE c.d = '2025-02-10' ORDER BY y.type, y.sort_order;

当天的查询走calendar的主键d,是索引的最优点击,根本不用担心性能。应用层只要保证日期参数来自内部日期对象,不要接受用户随意拼接的日期字符串。

4.2 整月或整年区间查询:范围条件别写 YEAR(d)

月历视图或全年日历,常见需求是“展示 2025 年 2 月的月历,带农历和节气”。很多开发者会写成:

-- 反面写法,索引失效 SELECT d, lunar_text, solar_term FROM calendar WHERE YEAR(d) = 2025 AND MONTH(d) = 2;

问题在于YEAR(d)对列使用了函数,MySQL 无法用主键d上的 B+ 树索引去直接定位区间,只能全表扫描。几千行数据时还能扛,但如果你把节日表、宜忌表一起 JOIN 后再过滤,或者数据行数被放大到几十万,影响会非常明显。正确写法是范围条件:

SELECT d, lunar_text, solar_term FROM calendar WHERE d >= '2025-02-01' AND d < '2025-03-01' ORDER BY d;

使用< '2025-03-01'而不是<= '2025-02-28',可以避开“二月到底有几天”的麻烦。若是带上宜忌数据:

SELECT c.d, c.lunar_text, c.solar_term, GROUP_CONCAT(CASE WHEN y.type = '宜' THEN y.item END) AS yi, GROUP_CONCAT(CASE WHEN y.type = '忌' THEN y.item END) AS ji FROM calendar c LEFT JOIN calendar_yanji y ON y.d = c.d WHERE c.d >= '2025-02-01' AND c.d < '2025-03-01' GROUP BY c.d ORDER BY c.d;

此时 MySQL 先通过主键范围筛选出二月的 28 行,再拿着这些d去calendar_yanji.idx_d索引找对应的宜忌行,回表次数很少。如果数据量大,还可以在calendar上建一个覆盖索引(d, lunar_text, solar_term),让查询列都在索引里,减少回表。

4.3 模糊搜索节日和农历:小心 LIKE 前导通配符

做节日搜索时,用户输入“春节”或“除夕”,需要从calendar_holiday表里找出匹配的节日名,并关联到calendar拿到公历日期和农历显示。

SELECT h.name, h.category, c.d, c.lunar_text FROM calendar_holiday h JOIN calendar c ON c.d = h.d WHERE h.name LIKE '%春节%' ORDER BY c.d DESC LIMIT 20;

LIKE '%春节%'最前面的%会让uk_d_name索引无法使用,MySQL 只能扫节日表全部数据。因为节日表通常只有几百行,这个开销可以接受。但如果搜索表是核心日历表,比如用户搜“农历五月”,你应该限制返回条数和搜索词长度,避免放大扫描开销。更稳妥的做法是先加一层条件缩小范围,例如指定年份后再LIKE。

应用层写这条 SQL 时必须用参数化查询,不能直接拼接字符串。给一个pymysql示例:

cursor.execute(""" SELECT h.name, c.d, c.lunar_text FROM calendar_holiday h LEFT JOIN calendar c ON h.d = c.d WHERE h.name LIKE %s ORDER BY c.d DESC LIMIT 20 """, ("%" + keyword + "%",))

参数化的好处不仅是防 SQL 注入,还能让 MySQL 缓存执行计划。对于同一套 SQL 结构、不同参数值的场景,重复解析的开销也可以省下来。别嫌这个习惯麻烦,万年历这种接口一旦被外部调用,用户输入什么都可能传进来,用%s占位是最低成本的保险。

5. 万年历入库的常见问题与排查:重复数据、节气错位、乱码和查询性能

这一章写四个实战中大概率会踩的坑,每条按“现象/原因/解决”结构展开,遇到问题时可以直接对照排查。

5.1 重复执行脚本导致宜忌数据翻倍

现象:脚本第一次跑完,查询某天宜忌正常;第二次跑完,同一天同一条“宜:祭祀”出现两行,前端列表里祭祀重复出现,统计数据也翻倍。

原因:calendar_yanji表没有唯一键,脚本用了简单INSERT INTO,重复执行不会报错,而是继续插新行。这个问题在抓取和本地推算混合更新时尤其常见——抓取一次,推算一次,没有做幂等。

解决:给表加上UNIQUE KEY uk_d_type_item (d, type, item),并在写入时用“先删后插”或INSERT ... ON DUPLICATE KEY UPDATE。先删后插的常见做法是:

cur.execute("DELETE FROM calendar_yanji WHERE d = %s", (d,)) for item in yanji_items: cur.execute( "INSERT INTO calendar_yanji (d, type, item, sort_order) VALUES (%s, %s, %s, %s)", (d, item["type"], item["item"], item.get("sort_order", 0)) )

先删后插保证了页面不会短暂出现重复行,代价是如果中途失败,当天宜忌数据会缺失。对于每天更新的小数据量,这个代价可以接受;如果你追求更稳,可以先把当天数据插入临时表,再在一个事务里替换正式表。我的习惯是在脚本入口打印“开始日期、结束日期、共多少条”,跑完再数一遍库里的行数,发现翻倍立刻查唯一键是不是被哪个环节去掉。

5.2 节气日期与标准日历差一天

现象:某年立春显示为 2 月 4 日,但标准黄历是 2 月 3 日;或者某个节在当天 23 点交节,库里却记到了第二天。

原因:节气时刻依赖于时区。大部分节气资料按东八区表述,如果接口返回时间戳后没有转换到北京时间,或者本地推算库默认用 UTC,23 点多发生的交节就会被算到第二天。个别农历库对“当日节气”的定义是在节气开始时刻还是整天,也存在半天误差。

解决:统一固定时区。抓取接口时在请求参数里带timezone=Asia/Shanghai,或者对返回的timestamp做东八区转换;本地推算库要确认时区设置,或手动检查结果。为了排查,可以建一张reference_calendar对照表,把标准节气日灌进去,然后用一条 SQL 找出不一致记录:

SELECT c.d, c.solar_term, r.expected_d FROM calendar c JOIN reference_calendar r ON r.solar_term = c.solar_term WHERE c.d <> r.expected_d;

对比表的数据可以从公开节气表整理,或者用另一套独立来源抽样核对。不要只靠肉眼抽查几个节气,把一年 24 个节气全对比一遍才算真正放心。这个坑最隐蔽的地方在于:不是每个节气都会错,只有交节时间靠近午夜的节气才会错,所以特别容易漏掉。

5.3 大量中文乱码

现象:数据库里lunar_text字段存进去的是“闰二月初五”,但在网页页面看到“??????”或一乱串菱形。用mysql命令行查出来又是正常的。

原因:三层字符集不一致。建表用了utf8mb4,但连接串没指定charset="utf8mb4"时,连接默认用latin1,中文在写入前就被转成了不可识别字节。此外,如果从 CSV 导入,文件本身不是 UTF-8 编码,也可能乱码。

解决:库表统一utf8mb4,连接串强制指定:

conn = pymysql.connect( host="127.0.0.1", user="root", password="yourpass", database="calendar_db", charset="utf8mb4" )

如果是命令行导入 SQL 文件,先执行SET NAMES utf8mb4;再source。已经乱码的数据基本恢复不回来,必须从源数据重新导入。所以写脚本时,第一件调试动作就是print(conn.character_set_name()),确认拿到的是utf8mb4再继续。别等灌完几年数据才发现乱码,那是没有后悔药的血泪经验。

5.4 查询区间黄历响应慢

现象:WHERE YEAR(d) = 2026 AND MONTH(d) = 1这个查询要几百毫秒,换成WHERE d BETWEEN '2026-01-01' AND '2026-01-31'只要几毫秒。数据量明明不大,差距却很悬殊。

原因:主键索引被函数遮蔽。YEAR(d)和MONTH(d)会先对每一行做计算,再跟常量比较,索引无法用于范围扫描,只能全表扫描。即便表只有几千行,一旦 JOIN 了宜忌表,扫描行数会被放大,慢就开始明显。

解决:SQL 里一律用范围写法,不要对索引列套函数。此外,如果按农历月查询,WHERE lunar_year = 2025 AND lunar_month = 1,需要在calendar表建(lunar_year, lunar_month, lunar_is_leap)联合索引,否则农历查询一样会走全表扫描。遇到慢查询,先EXPLAIN:

EXPLAIN SELECT d FROM calendar WHERE d >= '2026-01-01' AND d < '2027-01-01';

看type是否为range,rows是否接近实际数据量。如果type是ALL,说明索引没用上,通常就是查询写法或缺索引的问题。这个排查习惯比背索引规则更有用,因为你可以直接看到 MySQL 到底怎么跑的。

6. 再进一步:把 MySQL 黄历封装成查日 API,配合缓存与限流

做完前面几步,你已经有一张可查询、可更新、可维护的 MySQL 黄历库。接下来最常见的需求是把能力暴露给内部其他系统调用。我一般会加两层东西:Redis 缓存和限流。黄历数据一天只变一次,缓存价值极高;限流则是保护 MySQL 不被一个循环刷死。

先看一个带缓存的最小接口示例,技术栈是 FastAPI + Redis + pymysql。思路是先用日期作为 Key 去 Redis 取,取不到再查库,查完写回缓存。

@app.get("/huangli/{day}") def get_huangli(day: str): cache = r.get(f"huangli:{day}") if cache: return json.loads(cache) data = query_mysql(day) # 内部走第 4 章的联表 SQL r.setex(f"huangli:{day}", 86400, json.dumps(data, ensure_ascii=False)) return data

setex的过期时间设 86400 秒,让缓存每天自然失效。黄历内容到次日可能会有细微调整,缓存时间不要设置成一星期,否则修了数据库,接口还在给旧数据。query_mysql里面就是第 4 章的联表 SQL 套一个fetchone,返回字典即可。

限流我用一种常见的固定窗口写法,记录某个客户端 IP 在当前分钟内的请求次数,超过阈值就返回 429。对内部系统已经够用,不需要引入额外网关组件。

@app.get("/huangli/{day}") def get_huangli(day: str, request: Request): ip = request.client.host key = f"rate:{ip}:{time.strftime('%Y%m%d%H%M')}" count = r.incr(key) if count == 1: r.expire(key, 60) if count > 30: raise HTTPException(status_code=429, detail="request too many") # 然后走缓存和查询逻辑

固定窗口有个小缺陷:窗口切换瞬间可能允许双倍流量,但内部使用场景下完全够用。严格一点的场合用 Redis Zset 做滑动窗口,代码量会多不少,收益有限。

验证方法很简单:本地起服务后,连续请求/huangli/2025-02-10,第一次能从日志看到查 MySQL,第二次开始命中 Redis,超过 31 次返回 429。我之前就是图省事,直接把查询 SQL 暴露给前端,结果被同事写了一个循环脚本秒了一下,数据库连接瞬间被打满。后来把缓存、限流和参数化查询一起加上,才算是彻底放心。做万年历这类数据服务,慢不怕,怕的是不可控。希望帮到你。

本文还有配套的精品资源,点击获取

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询