写这篇东西的起因是我在好几个项目里都遇到过同一个需求: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-15 | 35 | 35 | 正确 |
| 生日未到 | 1990-12-01 | 35 | 34 | 错误 |
| 今天过生日 | 1995-07-11 | 30 | 30 | 正确 |
| 闰年出生 | 2000-02-29 | 25 | 25 | 正确(巧合) |
| 刚出生 | 2024-07-12 | 1 | 0 | 错误 |
| 空值 | NULL | NULL | NULL | 正确 |
关键问题很清晰:这个方法默认了“今年已经过完生日”。对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-15 | 35 | 35 | 正确 |
| 生日未到 | 1990-12-01 | 34 | 34 | 正确 |
| 今天过生日 | 1995-07-11 | 30 | 30 | 正确 |
| 闰年出生 | 2000-02-29 | 25 | 25 | 正确 |
| 刚出生 | 2024-07-12 | 0 | 0 | 正确 |
从表面看,这个方法比方法一准确不少,因为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-15 | 35 | 35 | 正确 |
| 生日未到 | 1990-12-01 | 34 | 34 | 正确 |
| 今天过生日 | 1995-07-11 | 30 | 30 | 正确 |
| 闰年出生 | 2000-02-29 | 25 | 25 | 正确 |
| 刚出生 | 2024-07-12 | 0 | 0 | 正确 |
| 空值 | NULL | NULL | NULL | 正确 |
六个样例全军覆没的情况没有发生,边界全部通过。这是我个人推荐在日常开发中作为默认方案的原因:没有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/365 | TIMESTAMPDIFF | DATE_ADD法 | 存储函数 |
|---|---|---|---|---|---|---|
| 1990-03-15 | 35 | 35 | 35 | 35 | 35 | 35 |
| 1990-12-01 | 34 | 35 | 34 | 34 | 34 | 34 |
| 1995-07-11 | 30 | 30 | 30 | 30 | 30 | 30 |
| 2000-02-29 | 25 | 25 | 25 | 25 | 25 | 25 |
| 2024-07-12 | 0 | 1 | 0 | 0 | 0 | 0 |
| NULL | NULL | NULL | NULL | NULL | NULL | NULL |
从这张表能明显看出,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为NULL,YEAR(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都改成同一种,而是希望你在动手前先想清楚:你的业务要的是“年份差”还是“周岁”,你的数据量能不能承受全表算年龄,你的下游系统对年龄误差的容忍度是多少。想清楚了,选型自然就出来了。