
写这篇东西的起因是我在好几个项目里都遇到过同一个需求MySQL里根据出生日期算客户年龄。第一次用YEAR(CURDATE()) - YEAR(birthdate)一行写完自我感觉良好直到后来做会员分群时发现一大批用户的年龄和证件年龄对不上才意识到这个简单需求里其实藏着一堆日期边界、闰年、时区、性能的坑。项目做多了以后我把几种常见写法都整理出来用同一批测试数据跑了一遍结论和直觉有不少出入今天完整分享一下。1. 先别急着写SQL把“年龄”的口径定义清楚1.1 业务系统里的年龄到底要算周岁还是“年份差”很多人看到“计算年龄”就直接开写但实际业务里“年龄”至少有两种口径一种是我们习惯的周岁也就是生日当天才算满一岁没过生日时年龄不能涨另一种简单按年份相减得到“年份差”比如1990年12月出生的人到了2025年1月按年份差已经算35岁但按周岁其实才34岁。绝大多数业务系统——用户中心、CRM、会员营销、风控模型——要的都是周岁。原因很直接营销活动按年龄发券、风控按年龄段判断行为模式动的是真金白银和准确率误差一岁都可能导致人群圈选错。所以写这篇比较之前我先统一口径本文默认要算的是“周岁”即一个用户过了今年生日才年龄加一生日当天已经算满生日未到则不能加。后面所有SQL都围绕这个口径展开如果你业务上就是要“年份差”那直接看方法一就够了不需要往下纠结。1.2 必须提前想清楚的三个边界场景生日还没到比如今天2025年7月11日1990年12月1日出生的人周岁应该是34岁而不是年份差算出来的35岁。今天是生日当天1995年7月11日出生的人今天就是生日理应按满30岁算。闰年2月29日出生这是最容易被忽略的2000年2月29日出生的人到2025年7月11日该按几岁算以及平年2月28日到底算不算“过了生日”不同数据库、不同写法的处理并不一致。再加上实际表里常见的birthdate为NULL、出生日期晚于当前日期之类脏数据一个“算年龄”的SQL真不是一行YEAR()能打包解决的。1.3 建一张测试表后面所有方法都用它验证为了对比公平我建了一张用户表插入了几条能覆盖边界的测试数据。后面每种方法都会用这张表跑一遍你可以直接用SQL快速复现。CREATE TABLE test_age ( id INT PRIMARY KEY AUTO_INCREMENT, user_name VARCHAR(50), birthdate DATE ); INSERT INTO test_age (user_name, birthdate) VALUES (已过生日, 1990-03-15), (生日未到, 1990-12-01), (今天过生日, 1995-07-11), (闰年出生, 2000-02-29), (刚出生, 2024-07-12), (空值, NULL);下面所有SQL都以“当前日期是2025-07-11”为基准演示运行时MySQL会取系统当天日期所以如果你在别的日期执行结果里“生日未到”这类行会自然变化不影响对逻辑的理解。2. 最容易想到的两种写法YEAR()差值法 与 DATEDIFF()折算2.1 方法一YEAR(CURDATE()) - YEAR(birthdate)这是最直观的写法代码只有一行SELECT id, user_name, birthdate, YEAR(CURDATE()) - YEAR(birthdate) AS age FROM test_age;跑出来的结果和测试预期对比如下测试用户出生日期YEAR差值结果周岁预期是否正确已过生日1990-03-153535正确生日未到1990-12-013534错误今天过生日1995-07-113030正确闰年出生2000-02-292525正确巧合刚出生2024-07-1210错误空值NULLNULLNULL正确关键问题很清晰这个方法默认了“今年已经过完生日”。对1月1日出生的人它几乎全年正确可对12月31日出生的人从1月1日到12月30日这364天里它都是错的虚高一岁。什么场景可以用它比如只想按“80后”“90后”“00后”这种出生年段统计人群不关心精确周岁那用YEAR差值完全够了性能也最好。但如果你要的是营销系统里的精确年龄这个方法不建议用于生产。2.2 方法二DATEDIFF()除以365取整第二种写法是很多人从“年份差”的坑里爬出来后想到的“修正版”既然年份差没有判断生日是否已过那我改成“用出生到今天的天数除以365向下取整”逻辑上很像日历年的长度。SELECT id, user_name, birthdate, FLOOR(DATEDIFF(CURDATE(), birthdate) / 365) AS age FROM test_age;同一批测试数据跑出来测试用户出生日期DATEDIFF/365结果周岁预期是否正确已过生日1990-03-153535正确生日未到1990-12-013434正确今天过生日1995-07-113030正确闰年出生2000-02-292525正确刚出生2024-07-1200正确从表面看这个方法比方法一准确不少因为DATEDIFF()用的是真实天数差“生日没到”的人自然凑不整一年。但我要提醒各位它只是在绝大多数样例上碰巧正确并没有从根本上理解“月”和“日”的关系。问题出在365天这个除数上。一个公历年并不是固定的365天闰年有366天实际年龄增加一次的周期是“跨过生日的日期”而不是“物理上攒够365天”。举个例子2024年7月12日出生到2025年7月11日DATEDIFF()返回364除以365取整为0此时还没满1岁正确。但如果这位用户运气好出生在闰年2月底从2月29日到下一年2月28日DATEDIFF()恰好是365除以365取整为1此时MySQL会认为他满1岁了可按照“过生日才算满”的规则2月28日他还没到生日。更麻烦的是临界日期的提醒提示如果系统在用户生日当天的凌晨跑批DATEDIFF()的临界值受具体时刻影响而日期计算函数只精确到“天”。一旦脚本执行时间和生日当天存在零点漂移结果就可能出现“明明今天才过生日却提前算大一岁”的情况。所以这个方法只适合对年龄精度要求不高的统计报表比如“大盘用户年龄分布”不适合作为精确年龄的计算标准发布到下游系统。3. 推荐优先使用的写法TIMESTAMPDIFF()精准计算3.1 TIMESTAMPDIFF的原理按整年周期计数而不是按天数折算TIMESTAMPDIFF()是MySQL专门用来计算两个日期之间完整时间差的函数语法是TIMESTAMPDIFF(unit, datetime_expr1, datetime_expr2)unit可以填YEAR、MONTH、QUARTER、WEEK、DAY、HOUR等。算年龄时用YEAR即可TIMESTAMPDIFF(YEAR, birthdate, CURDATE())它的底层逻辑和“除以365”完全不同它不是把两个日期拆成天数再折算而是直接比较“精确到月份和日期”的周期数。什么意思呢简单说TIMESTAMPDIFF(YEAR, 1990-12-01, 2025-07-11)返回的是从1990年12月1日到2025年7月11日之间完整走完了多少个“自然周年”。日期没对齐到12月1日就不进入下一个周年计数。这正好完美踩中周岁“生日没过不能算”的业务语义。3.2 测试验证与执行结果SELECT id, user_name, birthdate, TIMESTAMPDIFF(YEAR, birthdate, CURDATE()) AS age FROM test_age;结果如下测试用户出生日期TIMESTAMPDIFF结果周岁预期是否正确已过生日1990-03-153535正确生日未到1990-12-013434正确今天过生日1995-07-113030正确闰年出生2000-02-292525正确刚出生2024-07-1200正确空值NULLNULLNULL正确六个样例全军覆没的情况没有发生边界全部通过。这是我个人推荐在日常开发中作为默认方案的原因没有IF分支不用手工判断“今年生日过没过”一个函数解决可读性也不错。3.3 使用时的两个提醒第一个提醒是参数顺序。TIMESTAMPDIFF(unit, expr1, expr2)返回的是expr2 - expr1方向的时间跨度所以正确的写法是“出生日期放前面当前日期放后面”。写反了会得到负数年龄这在SQL里不报错但输出的业务数据就全反了-- 错误示范返回负数 SELECT TIMESTAMPDIFF(YEAR, CURDATE(), birthdate);第二个提醒是闰日边界。TIMESTAMPDIFF()对2月29日出生的人怎么算不同MySQL版本在极端边界上的处理可能存在细微差异。我在8.0上实测2000-02-29出生的用户到今天返回25岁符合预期但如果某天业务上需要精确判断“2月28日是否算闰日出生的人过生日”建议单独把这类用户的口径写成一条规则不要指望所有数据库函数完全一致。4. 面试常被追问的日期比较法DATE_FORMAT()与DATE_ADD()4.1 不依赖TIMESTAMPDIFF手写“生日已过”判断除了直接用函数还有一种写法在面试里经常出现先用年份差得到一个“未修正年龄”再用“今年生日是否已过”来判断是否需要减一。这个逻辑更接近我们自己处理年龄时的思维方式理解它能帮你彻底明白前两种方法为什么会有偏差。SELECT id, user_name, birthdate, YEAR(CURDATE()) - YEAR(birthdate) - (DATE_ADD(birthdate, INTERVAL (YEAR(CURDATE()) - YEAR(birthdate)) YEAR) CURDATE()) AS age FROM test_age;这里有个MySQL的小技巧布尔表达式(DATE_ADD(...) CURDATE())为真时返回1为假时返回0。所以整体含义是先算出年份差如果今年的生日日期比今天晚说明生日还没过减掉1否则不减。跑出来的结果和TIMESTAMPDIFF基本一致。4.2 可读性更好的CASE WHEN版本上面一行式写法虽然精简但在真实项目里阅读体验不好维护的人容易懵。更推荐用CASE WHEN写清楚SELECT id, user_name, birthdate, CASE WHEN DATE_ADD(birthdate, INTERVAL (YEAR(CURDATE()) - YEAR(birthdate)) YEAR) CURDATE() THEN YEAR(CURDATE()) - YEAR(birthdate) - 1 ELSE YEAR(CURDATE()) - YEAR(birthdate) END AS age FROM test_age;这和TIMESTAMPDIFF在绝大多数日期上结果一致但它有一个好处完全由你控制“生日已过”的判断标准。比如某业务规定“生日当天不发放年龄相关权益次日才生效”你只需要把改成就能调整口径而TIMESTAMPDIFF没有这个参数给你拧。4.3 DATE_FORMAT比较的潜在问题还有一派写法是用DATE_FORMAT(birthdate, %m-%d) DATE_FORMAT(CURDATE(), %m-%d)判断“今年生日是否已过”我自己也试过但实际用起来有个隐藏坑2月29日出生的人平年2月28日那天DATE_FORMAT(2000-02-29, %m-%d)得到02-29而DATE_FORMAT(2025-02-28, %m-%d)得到02-28。字符串比较02-29 02-28系统会认定“生日还没到”。但换用DATE_ADD的日期叠加法MySQL会把DATE_ADD(2000-02-29, INTERVAL 25 YEAR)在平年处理成哪一天则完全看MySQL的规则不同版本处理可能不同。所以如果业务上有大量2月29日出生的用户建议提前定好口径并把这部分用户单独写进回归测试不要在两种写法之间反复横跳。5. 大型项目里更优雅的解法封装成存储函数统一口径5.1 为什么推荐封装成函数当你一个系统里有十几个地方都要算年龄时最怕的是每个开发各写一套有人用YEAR差值有人用TIMESTAMPDIFF运营后台展示的年龄和对账报表的年龄就对不齐。这时候最好的做法是把年龄计算封装成一个统一的存储函数所有人调用同一个函数口径自然统一。MySQL创建函数很简单下面这个calc_age()可以直接放到你的工具库里DELIMITER $$ CREATE FUNCTION calc_age(birthdate DATE) RETURNS INT DETERMINISTIC BEGIN IF birthdate IS NULL THEN RETURN NULL; END IF; RETURN TIMESTAMPDIFF(YEAR, birthdate, CURDATE()); END$$ DELIMITER ;调用方式和内置函数一样SELECT id, user_name, birthdate, calc_age(birthdate) AS age FROM test_age;加了DETERMINISTIC是为了告诉MySQL这个函数对相同输入总是返回相同结果这在开启binlog或做主从复制时有实际意义否则可能报“This function has none of DETERMINISTIC, NO SQL, or READS SQL DATA in its declaration and binary logging is enabled”错误。实际排查时这个报错几乎每个第一次写存储函数的人都会遇到。5.2 高级用法返回精确到“岁月天”的年龄有些场景不止要完整岁比如保险行业要算“被保人距下一个生日还有多少天”或者是新生儿健康档案里要显示“1岁2个月15天”。这种需求用年龄函数拆解更清晰DELIMITER $$ CREATE FUNCTION calc_age_detail(birthdate DATE) RETURNS VARCHAR(50) BEGIN DECLARE y INT DEFAULT 0; DECLARE m INT DEFAULT 0; DECLARE d INT DEFAULT 0; DECLARE temp_date DATE; IF birthdate IS NULL THEN RETURN 未知; END IF; SET y TIMESTAMPDIFF(YEAR, birthdate, CURDATE()); SET temp_date DATE_ADD(birthdate, INTERVAL y YEAR); SET m TIMESTAMPDIFF(MONTH, temp_date, CURDATE()); SET temp_date DATE_ADD(temp_date, INTERVAL m MONTH); SET d DATEDIFF(CURDATE(), temp_date); RETURN CONCAT(y, 岁, m, 个月, d, 天); END$$ DELIMITER ;用1990-03-15做输入跑出来是“35岁3个月26天”逻辑和直观感受完全一致。这种函数特别适合放在报表SQL里比在应用层写一大串DateTime转换代码干净得多。提示存储函数虽然好用但不要把所有计算都塞进MySQL。函数一旦上线后续调试、版本迁移、跨数据库平台改造都会多一层成本。我在实际项目里的做法是统一的、被多处复用的年龄口径才封装成DB函数偶尔一次临时查询直接写TIMESTAMPDIFF。过度抽象反而增加维护负担。6. 五种方法在同一批测试数据上的实测对比把上面五种方法汇总到一棵大表里结果非常直观出生日期周岁预期YEAR差值DATEDIFF/365TIMESTAMPDIFFDATE_ADD法存储函数1990-03-153535353535351990-12-013435343434341995-07-113030303030302000-02-292525252525252024-07-12010000NULLNULLNULLNULLNULLNULLNULL从这张表能明显看出YEAR差值法的问题集中在“生日未到”和“刚出生”这两类人身上系统性偏差很严重DATEDIFF除以365在这次样例里表现不错但前面分析过它本质上没有处理“周年的自然边界”属于碰运气TIMESTAMPDIFF、DATE_ADD法、存储函数三种方案结果一致可以放心选一个长期使用。关于性能我也专门在一张100万行的表上跑过对比SELECT COUNT(*) FROM users WHERE TIMESTAMPDIFF(YEAR, birthdate, CURDATE()) 30这种写法即便函数本身计算不重MySQL也没法用上birthdate字段的索引执行计划基本都是全表扫描。DATEDIFF法和YEAR法同样存在这个问题。所以在过滤条件里算年龄瓶颈从来不是五种方法之间的CPU差异而是索引失效。真正需要按年龄过滤时与其在SQL里算年龄不如直接转成出生日期区间过滤-- 查询“已满30岁”的用户birthdate上有索引时可以走索引 SELECT id, user_name, birthdate FROM users WHERE birthdate DATE_SUB(CURDATE(), INTERVAL 30 YEAR);这个优化思路在报表系统里特别重要稍微改一下写法大表查询的速度能从秒级降到毫秒级。7. 选型建议不同场景该用哪种方法7.1 按场景直接给结论使用场景推荐方案原因临时查一条数据TIMESTAMPDIFF一行写完边界正确业务系统里展示年龄存储函数封装多处调用口径统一BI报表或数据仓库ETL在ETL阶段生成年龄列避免BI工具里各写各的按年龄过滤用户列表不要用函数转成出生日期区间能用索引性能差距大代际分析80后/90后YEAR差值法可接受精度要求低性能最优老旧SQL不修改但需评估先抽样对账看差异率不盲目全量替换7.2 老代码改造的正确姿势如果你的项目里有历史SQL里面用了大量的YEAR差值法我的建议是先别急着全线替换。写个对账脚本把用户的出生日期、旧SQL算出来的年龄、用TIMESTAMPDIFF算出来的年龄抽出来对比算一下“差异用户占比”。如果抽样结果差异在5%以内而且业务上只是看个大概年龄不涉及精准权益发放可以暂时不动或分批次改造如果差异超过10%比如会员分群、营销推送这些下游依赖很重那越早改越好因为年龄算错一岁推给用户的活动可能就是完全不合适的。这种“先评估再切换”的方式比一次性重写所有SQL稳妥得多也更容易说服团队里的其他人。7.3 别忘了时区和NULL值最后说两个特别容易踩但很少有人写的点。第一个是时区。MySQL的CURDATE()/NOW()返回的是当前会话时区下的日期如果数据库连接串没指定serverTimezone在同一台机器上不同客户端查出来的结果可能在跨天时段出现差异。比如凌晨0点30分UTC时区还是“昨天”Asia/Shanghai已经是“今天”年龄就差了1岁。排查这种问题时先确认所有应用连接的时区配置一致再谈SQL写法。第二个是NULL。年龄计算时如果birthdate为NULLYEAR(NULL)、TIMESTAMPDIFF(YEAR, NULL, ...)都会返回NULL这不一定是你想要的。运营场景里可能希望显示“未知”而不是NULL这就要在函数或SQL里显式处理SELECT id, user_name, CASE WHEN birthdate IS NULL THEN 未知 WHEN birthdate CURDATE() THEN 数据异常 ELSE CAST(TIMESTAMPDIFF(YEAR, birthdate, CURDATE()) AS CHAR) END AS age_text FROM test_age;顺便说一句生产表的birthdate字段我记得一定要加CHECK约束或应用层校验让它不允许大于当前日期。历史脏数据里“未来出生”的人会让任何年龄算法都算出负数这比NULL还不能忍。8. 如果你也在改老项目的年龄SQL我印象最深的一个坑发生在会员积分系统里。有一个用户的生日是12月30日12月29日凌晨系统自动给他发了“生日前一天祝福券”但按照周岁口径他第二天才满年龄那张券的发送年龄条件应该少一岁。排查下来发现策划配置条件时用的就是当年的YEAR差值SQL。问题不在SQL跑得慢也不在数据量大而是在于算法口径没有人和业务对齐过开发以为“年份差就是年龄”运营以为“系统会按周岁算”两边默认值完全不同最后用户体验出问题。后来我们做的事很简单在数据库里固化了一个年龄函数所有查询都走它同时在测试库里把“已过生日、生日未到、今天生日、闰年出生、刚出生、NULL”这六类用例建成回归测试集每次MySQL大版本升级或表结构变更时就跑一遍。从那以后年龄相关的故障基本绝迹。这篇内容不是让你把项目里所有年龄SQL都改成同一种而是希望你在动手前先想清楚你的业务要的是“年份差”还是“周岁”你的数据量能不能承受全表算年龄你的下游系统对年龄误差的容忍度是多少。想清楚了选型自然就出来了。