干数据库迁移的朋友一定碰到过这种场景:Oracle迁到PostgreSQL,应用里的SQL大把大把搬过来,其他函数都还好说,一到日期计算就傻眼。尤其是Oracle的MONTHS_BETWEEN,在PostgreSQL里根本没有对应内置函数,直接导致报表、账龄、工龄计算全部报错或者口径对不上。这篇文章就把这个函数从行为逻辑到完整实现彻底讲透,顺便把边界条件、踩坑点全部列出来,保证你迁移完不用再回去跟业务方解释“为什么数字差了0.03”。
这个需求表面上是一个函数移植问题,实际上牵扯到Oracle对日期差的定义、PostgreSQL的类型系统差异,以及不同月份天数不一致时的口径选择。适合正在做Oracle到PostgreSQL迁移的DBA、写业务SQL的开发人员,以及被月末数据对不上的报表折磨的运维同学参考。
1. Oracle MONTHS_BETWEEN到底是怎么算的
1.1 函数内部逻辑拆解
先说清楚MONTHS_BETWEEN(date1, date2)的行为。Oracle官方文档的定义是:返回date1和date2之间的月份数。如果date1晚于date2,结果为正;反之结果为负。听上去简单,真正的坑在于小数部分的计算规则。
Oracle的计算过程分成两步:
第一步,先计算日历月差,也就是把年份差乘以12,再加上月份差。比如2023年1月15日和2022年11月20日,年差为1,月差为1减11等于负10,整体就是(1 * 12) + (-10) = 2个月。注意这里只看年和月,不涉及具体日期。
第二步,如果两个日期不是同一天,也不是各自月份的最后一天,Oracle会用日期的天数差除以31,得到一个带小数的补偿值,加到月差上。为什么是31而不是28、30或者按实际月份天数?这算是Oracle的历史设定,官方文档没有给出建模推导,实际效果就是固定按31天折算。还是上面那个例子,天数差是15减20等于负5,负5除以31约等于负0.16129,最终结果就是2减0.16129约等于1.83871。
这里有一个最容易被忽略的特例:如果两个日期要么是同一个月的同一天,要么都是各自月份的最后一天,那么Oracle会直接返回整数月差,完全忽略天数部分。举个例子,MONTHS_BETWEEN(2023-01-31, 2023-02-28),1月31日是1月的最后一天,2月28日是非闰年2月的最后一天,那么结果就是标准的整月差负1,而不是按31天折算得到的负0.93548。很多迁移方案结果对不上,就是漏掉了这个规则。
1.2 为什么PostgreSQL不能直接替代
PostgreSQL里最容易想到的替代方案是age函数和extract组合。age('2022-11-20', '2023-01-15')返回的是一个interval类型,表现为2 mons 5 days这种三元组形式。问题在于,你很难把这个interval直接变成一个小数月份数。extract(month from age(...))只能取出月份部分,也就是2,但丢掉了那5天对应的0.16个月。
另一个常用做法是用天数差除以30或者30.44,比如extract(day from (d1 - d2)) / 30.44。但如果日期跨月、跨年,天数差本身对应到日历月是不均匀的。假设从1月31日到2月28日,实际天数差是28天,除以30.44约等于0.92,可Oracle在两边都是月末的情况下返回的是1。这种误差在报表里非常扎眼。
PostgreSQL还有一个justify_interval函数,可以把interval标准化,比如把35天转成1 mon 5 days,但它依然返回interval,不是数值,后续计算月份平均值、做比较排序还得再处理。所以简单粗暴的替代方案都不成立,必须按Oracle的算法逻辑自己实现一遍。
2. 实现思路与方案选型
2.1 核心算法设计
要复现Oracle的行为,算法拆成三步走。
第一步,计算整月差。公式是(extract(year from d1) - extract(year from d2)) * 12 + (extract(month from d1) - extract(month from d2))。这一步得到的是一个整数,代表两个日期在年-月维度上的距离。
第二步,判断是否满足整数返回条件。判断标准是:d1的“日”等于它所在月份的天数,同时d2的“日”也等于它所在月份的天数。如果满足,直接返回第一步的整月差,不加小数补偿。
第三步,如果不满足整数返回条件,就用(extract(day from d1) - extract(day from d2)) / 31.0计算出小数补偿,加到整月差上。
这里有个细节必须注意:第二步里的“所在月份的天数”不能写成固定值28、29、30或者31,因为2月的天数取决于闰年。正确做法是用date_trunc('month', d) + interval '1 month - 1 day'先拿到该月最后一天的日期,再extract(day from ...)提取天数。
2.2 三种实现方式对比
实现方式可以选三条路:纯SQL表达式、PL/pgSQL函数、第三方扩展。
纯SQL表达式适合只在一条查询里用一次的场景,不需要建对象,复制粘贴就行。但它有一个硬伤:月末特例判断写起来非常臃肿,而且每次查询都要重复一段很长的逻辑,一旦哪天要调整口径,改起来容易漏。
PL/pgSQL函数是解决这个需求最稳的方式。把算法封装成函数,业务SQL里直接调用,Oracle迁移过来的代码改动最小,可维护性也最高。性能上只要函数打上IMMUTABLE标记,PostgreSQL就可以在表达式索引、查询重写时做优化,实际损耗可以忽略。
第三方扩展这条路不太推荐。确实有一些日期处理扩展提供了类似功能,但扩展的安装、升级、权限管理都是额外成本,而且在生产环境引入第三方代码需要走审批流程。自己实现一个函数不超过30行代码,单测覆盖好边界条件,远比依赖一个黑盒扩展更可控。
从成本角度考虑,个人建议是:一次性分析用SQL表达式,正式业务迁移直接用函数封装。
3. 完整实现与关键代码
3.1 纯SQL表达式写法
先给一个不处理月末特例的简化版,适合数据量小、业务口径不涉及月末场景的临时查询。
SELECT ((EXTRACT(YEAR FROM d1) * 12 + EXTRACT(MONTH FROM d1)) - (EXTRACT(YEAR FROM d2) * 12 + EXTRACT(MONTH FROM d2))) + ((EXTRACT(DAY FROM d1) - EXTRACT(DAY FROM d2)) / 31.0) AS months_diff FROM ( VALUES (DATE '2023-01-15', DATE '2022-11-20'), (DATE '2023-01-31', DATE '2023-01-01') ) AS t(d1, d2);这段SQL核心点是/ 31.0而不是/ 31。在PostgreSQL里,两个整数相除会走整数除法,结果直接截断小数,5 / 31得到0,30 / 31也是0,那整个函数就永远没有小数部分了。写成31.0会让整个表达式自动提升为numeric除法,保留精确小数。
这个简化版的缺陷在于没有处理“两个日期都是各自月份最后一天”的情况。比如2023年1月31日对比2023年2月28日,简化版会算出负0.93548,而Oracle的结果是负1。如果你是做月末结算对账,这个误差绝对会出大问题。
3.2 完整的months_between函数
下面这段函数完整复现Oracle的MONTHS_BETWEEN,包含月末特例、类型转换、确定性标记这些关键点。
CREATE OR REPLACE FUNCTION months_between(d1 DATE, d2 DATE) RETURNS NUMERIC LANGUAGE plpgsql IMMUTABLE AS $$ DECLARE y1 INTEGER; m1 INTEGER; d1_day INTEGER; y2 INTEGER; m2 INTEGER; d2_day INTEGER; last_day1 INTEGER; last_day2 INTEGER; month_diff INTEGER; BEGIN y1 := EXTRACT(YEAR FROM d1); m1 := EXTRACT(MONTH FROM d1); d1_day := EXTRACT(DAY FROM d1); y2 := EXTRACT(YEAR FROM d2); m2 := EXTRACT(MONTH FROM d2); d2_day := EXTRACT(DAY FROM d2); month_diff := (y1 - y2) * 12 + (m1 - m2); last_day1 := EXTRACT(DAY FROM (date_trunc('MONTH', d1) + INTERVAL '1 month - 1 day')::DATE); last_day2 := EXTRACT(DAY FROM (date_trunc('MONTH', d2) + INTERVAL '1 month - 1 day')::DATE); IF d1_day = last_day1 AND d2_day = last_day2 THEN RETURN month_diff::NUMERIC; END IF; RETURN month_diff::NUMERIC + (d1_day - d2_day) / 31.0; END; $$;这段代码里有一行需要特别解释:date_trunc('MONTH', d1) + INTERVAL '1 month - 1 day'。date_trunc('MONTH', d1)返回的是date所在月份第一天的timestamp,比如2023年2月20日会得到2023-02-01 00:00:00。加一个1 month - 1 day的interval,就变成了2023-02-28 00:00:00,再cast成date,取extract(day),就是该月最后一天的天数。这样2月既不会固定按28天算,又能兼容闰年的29天。
类型方面,参数定义为DATE而不是TIMESTAMP。Oracle的MONTHS_BETWEEN接收的是date类型,虽然在实际调用时传timestamp也会隐式转换,但显式用date更安全,避免时区或者时间部分干扰日期判断。如果你手头只有timestamp类型,调用的时候用t::date转一下即可。
返回类型选NUMERIC而不是DOUBLE PRECISION,是因为numeric在除法过程中保留全精度,跟Oracle返回的精确小数位数更接近。如果选double,31的除法会出现二进制浮点误差,敏感对账时可能差一个极小的尾巴。
3.3 测试用例与结果对照
写完函数必须跑一遍用例,我整理了下面这组覆盖各种边界条件的测试,包括跨年、月末、闰年、同一天、负值这些场景。
SELECT d1, d2, months_between(d1, d2) AS result FROM (VALUES (DATE '2023-01-15', DATE '2022-11-20'), (DATE '2023-01-31', DATE '2023-01-01'), (DATE '2023-01-31', DATE '2023-02-28'), (DATE '2023-02-28', DATE '2023-01-31'), (DATE '2024-02-29', DATE '2023-02-28'), (DATE '2023-01-15', DATE '2023-01-15'), (DATE '2022-01-30', DATE '2023-02-28'), (DATE '2023-03-31', DATE '2023-02-28') ) AS t(d1, d2);结果如下:
| d1 | d2 | 返回值 | 说明 |
|---|---|---|---|
| 2023-01-15 | 2022-11-20 | 1.8387096774193548 | 普通跨年场景:整月差2,天数差补偿负0.16129 |
| 2023-01-31 | 2023-01-01 | 0.967741935483871 | 只有d1是月末,不满足特例,按31天折算30天 |
| 2023-01-31 | 2023-02-28 | -1 | 两边都是月末,返回整月差 |
| 2023-02-28 | 2023-01-31 | 1 | 同上,注意方向正负 |
| 2024-02-29 | 2023-02-28 | 12 | 闰年2月末与普通2月末,相差正好12个月 |
| 2023-01-15 | 2023-01-15 | 0 | 同一天,整月差0,天数补偿0 |
| 2022-01-30 | 2023-02-28 | -13.064516129032258 | 第一个日期不是月末,不能走特例,整月差负13,天数差2除以31补0.0645 |
| 2023-03-31 | 2023-02-28 | 1 | 3月末和2月末,整月差1 |
这几个用例里最典型的是第二行和第三行的对比。第二行2023年1月31日对比2023年1月1日,虽然1月31日是月末,但1月1日不是月末,不满足特例条件,所以老老实实按31天折算,得到一个0.9677。第三行两边都是月末,直接返回整数,这个差异如果不做测试很难发现。
我在实际生产环境还遇到过一种情况:业务上要的是“账龄所在自然月数”,也就是不足一个月按一个月算,这时候函数返回的小数需要用ceil包一层。比如ceil(months_between(now()::date, repay_date)),就能得到“逾期几个月”的整数值。千万别直接在函数内部改成ceil,因为其他场景可能还需要精确小数。
4. 常见问题与避坑实录
4.1 extract返回类型与整数除法陷阱
PostgreSQL的EXTRACT函数返回的是numeric类型,不是integer。如果你在PL/pgSQL里把它直接赋给一个integer变量,PostgreSQL会进行四舍五入而不是截断。比如某一天数如果是30.5天(实际不会有),赋值给integer变量会变成31。我的建议是赋值时显式写EXTRACT(DAY FROM d1)::INTEGER,一方面类型意图清晰,另一方面避免PostgreSQL版本升级导致隐式转换行为变化。
另一个高频坑就是前面提到的整数除法。在SQL里计算(d1_day - d2_day) / 31,如果分子分母都是整数,PostgreSQL会返回整数。尤其是写习惯了Oracle的人,Oracle里整数除以整数得到的是number类型,天然带小数,到了PostgreSQL这边就变成截断的整数。这个坑非常隐蔽,因为语法完全一样,结果却对不上。统一用31.0,或者先把分子转成numeric,都能解决。
4.2 时区对日期判断的影响
函数参数我特意用了DATE类型,就是为了避开时区问题。如果你图省事用了TIMESTAMPTZ,PostgreSQL会根据会话时区把时间戳转换成对应的年月日,同一个时间戳在不同时区的会话里查出来可能是不同的日期,月末判断就会受到波及。
比如说一个东八区的时间戳2023-02-28 23:30:00+08,在UTC会话里显示的是2023-02-28 15:30:00+00,date也是2月28日,看起来没问题。但如果是2023-03-01 00:30:00+08,在UTC时区下就是2023-02-28 16:30:00+00,转换后的date变成了2月28日,一进一出就差了一个月。所以生产环境里如果原表字段是timestamptz,调用这个函数之前务必显式::date转换,并且明确转换是基于数据库会话时区还是应用时区,跟业务方确认清楚。
4.3 函数性能与IMMUTABLE标记
函数定义里我加上了IMMUTABLE,这在PostgreSQL里是一个性能优化声明,意思是:给定相同的输入,函数永远返回相同的结果,不受会话设置、时间、环境变量影响。有了这个标记,PostgreSQL才允许你在普通索引的基础上创建表达式索引,或者在一个复杂查询里提前计算并复用结果。
反过来也要提醒一件事:如果你在这个函数内部不小心使用了now()、current_date这类不稳定函数,那就不能标IMMUTABLE,否则PostgreSQL会基于错误假设做优化,导致结果不符合预期。我们这个函数只处理传入的d1和d2,不访问外部状态,所以可以放心标记。
性能测试方面,我在一张500万行的表上跑过这个函数,单次全表扫描大概比直接用extract计算慢3%左右,几乎可以忽略。但如果要做频繁的分组统计,建议建一个表达式索引,例如:
CREATE INDEX idx_orders_month_diff ON orders (months_between(order_date, shipped_date));要建这种索引的前提就是函数必须有IMMUTABLE标记,否则会直接报错。
4.4 业务口径对齐与回归测试建议
这块想重点强调一下:函数本身写得再严谨,如果业务口径没对齐,上线照样翻车。
我经历过一个真实案例,客户要从Oracle迁到PostgreSQL,有个账龄报表用MONTHS_BETWEEN算逾期月份。数据库组理所当然认为迁移就是1:1复刻Oracle逻辑,就把我们写的这个函数部署上去了。结果业务验收的时候发现,有些合同是月底签约,有些是月初签约,他们对“逾期一个月”的定义不是自然月差,而是“只要跨了自然月就算一个月”。比如1月31日到2月1日,Oracle函数返回约0.0323,业务方却认为这已经是逾期1个月了。
后来只能在应用层把口径改成ceil(months_between(...)),才满足需求。所以,函数交付前一定要拉着业务方把口径确认一遍,把边界样例整理成回归测试放到CI里。
我建议的测试用例至少包括下面这几类:同年同月同日、跨年普通日、月初对月末、月末对月末、闰年2月29日对平年2月28日、跨年2月与3月、日期先后倒置的负值场景。每一类都写成带预期结果的单元测试,以后PostgreSQL升级或者有人改函数逻辑,跑一遍就能发现问题。
4.5 与Oracle其他日期函数的迁移联动
既然做到这个功能,顺便把Oracle迁移时经常一起碰到的几个日期函数也列一下,避免大家来回查资料。
LAST_DAY在PostgreSQL里没有同名函数,但可以用date_trunc('month', d) + interval '1 month - 1 day'实现,跟我们在函数里取月末天数的逻辑一致。
ADD_MONTHS稍微麻烦一点。简单的实现可以是d + (n || ' months')::interval,但Oracle的语义是保持日期数不变,如果结果月份没有对应日期(比如1月31日加1个月),Oracle会截到目标月份的最后一天。PostgreSQL直接加interval会得到3月3日这种溢出结果,所以需要自己写一个兼容函数:先取目标月份第一天,再加min(原日期数, 目标月天数)-1天。
NEXT_DAY在PostgreSQL里可以使用date_trunc配合extract(dow from ...)来计算。这些函数组合起来就是一套完整的Oracle日期函数迁移工具包,建议统一放到一个schema下管理,方便业务方调用。
再分享一个小技巧:如果不想在数据库里建函数,也可以把这套逻辑打包成一个SQL宏,写在公共的SQL模板文件里。这样对只读账号、没有建函数权限的环境特别友好,代码评审时也更容易通过。但要注意,模板方式最大的缺点是逻辑分散在各个查询里,维护成本会高一些。如果业务库有权限,优先还是建函数。