ARTICLE DETAIL

资讯详情

深耕网站建设、视觉设计与SEO优化的一线实战洞察。

DB2日期相差天数计算:DAYS、TIMESTAMPDIFF与性能避坑

DB2日期相差天数计算:DAYS、TIMESTAMPDIFF与性能避坑

1. 先把业务口径定死,否则算出来的天数永远是错的

DB2 里根据开始时间和结束时间算相隔天数,听起来是一行 SQL 的事,但我见过太多项目在上线半年后才发现天数差了一天。问题基本不在函数写法上,而在需求方说"相隔多少天"的时候,双方脑子里想的根本不是同一件事。有人默认 3 月 1 日到 3 月 2 日是 1 天,有人默认是 2 天;有人要的是自然日跨越数,有人要的是精确到秒的时长再折算。所以动手写 SQL 之前,先把这个口径钉死,比研究DAYS()怎么用重要得多。

这篇东西我按自己的实操顺序来写:先把 DB2 里几种时间类型的"相减语义"捋清楚,再把几个常用函数(DAYS、TIMESTAMPDIFF、MIDNIGHT_SECONDS)掰开揉碎讲一遍,然后给出可以直接抄的完整 SQL 和测试数据,接着处理算头算尾、工作日排除、按比例折算这些真实场景,最后聊性能、索引和一堆踩过的坑。适合正在做账期、工单时长、请假天数、逾期判断、报表口径这类需求的开发同学,也适合刚接手一套老 SQL 想搞明白"为什么这里要这么写"的人。

1.1 三种时间类型,三种完全不同的相减结果

DB2 里跟日期时间沾边的类型主要是DATE、TIME、TIMESTAMP,它们的相减行为差别很大,这一点很多人第一次碰会懵。

DATE - DATE得到的是天数差,类型是DECIMAL(8,0),比如DATE('2024-03-01') - DATE('2024-02-01')结果是29。这个是最符合直觉的,因为它就是纯粹的日历天数差。

TIMESTAMP - TIMESTAMP得到的不是天数,而是一个DECIMAL(14,0),值形如00000001000000。这串数字的每一位都有含义,从左到右分别是"年、月、日、时、分、秒",也就是所谓的时间戳间隔(timestamp duration)。上面那个例子表示的是"0 年 0 月 1 日 0 时 0 分 0 秒",注意它表示的是一个月的间隔,不是 29 天也不是 30 天。这就是最大的坑:时间戳相减得到的是按字段借位的间隔,不是绝对时长。

TIME - TIME我没怎么在生产里用过,结果同样是按"时分秒"字段借位的间隔类型,实际业务里基本都是先转成 TIMESTAMP 或者取秒数来处理。

理解了这个差异,后面所有的选择和取舍就有了依据。想拿自然日天数,走DATE那条路;想拿精确时长,得绕开TIMESTAMP直接相减,用别的组合方式拼出秒数。

1.2 需求方的"相隔天数"通常有三种口径

我把常见的口径归成三类,你可以拿这个去跟产品对一遍,省得来回改。

口径含义3月1日→3月2日3月1日→3月1日典型业务
A:纯时间差两个时点的绝对差值1 天0 天逾期天数、账龄、SLA 计时
B:自然日跨度(算头算尾)覆盖了多少个日历日2 天1 天请假天数、酒店住宿、住院天数
C:按比例折算不足一天按小时折算1.0 天(同一天不同时刻则带小数)0.xx 天计费、工时统计

口径 A 是数据库默认给你算出来的那个,口径 B 需要手动+1,口径 C 必须保留时间精度再除。这三种口径的 SQL 写法完全不同,一开始不确认,后面返工的量比写代码本身大。

还有一个隐含问题:时区。如果时间字段存的是带时区含义的值,但库里只存了本地时间,跨时区业务就一定会出错。我的建议是,凡是涉及跨国、跨区域的系统,库里统一存 UTC,展示层再转。这件事在建表阶段定,后期改代价极大。

2. 核心函数拆解:为什么 DAYS() 是最稳的那一把刀

2.1 DAYS() 的返回值和它的隐含前提

DAYS()是 DB2 的一个标量函数,接收日期或时间戳表达式,返回一个INTEGER,含义是"从公元 0001 年 1 月 1 日起算的第几天"。DAYS('0001-01-01')返回1,DAYS('2024-03-01')返回739036(具体数值随日期增长,示例仅供理解量级)。

它的价值在于:把日期压成了一个单调递增的整数,两个日期之间的天数就是两个整数的差,不需要任何借位逻辑,也不会有月末、闰年、跨年的特殊处理。这就是它比TIMESTAMPDIFF稳的根本原因。

我常用的写法是:

SELECT DAYS(DATE('2024-03-01')) - DAYS(DATE('2024-02-01')) AS DAY_DIFF FROM SYSIBM.SYSDUMMY1; -- 结果:29(2024 是闰年)

几个必须注意的点。第一,如果传进去的是TIMESTAMP,DAYS()会先截掉时分秒取日期部分,也就是说DAYS(ts)等价于DAYS(DATE(ts)),做纯日期差的时候没影响,但你如果指望它带上小时,那是没有的。第二,如果传进去的是字符串,会走隐式转换,转换格式依赖当前会话的日期格式设置,这是移植性隐患,能显式DATE(...)或者TO_DATE(...)就别偷懒。第三,DAYS()是确定性函数,可以放心用在生成列和索引表达式里。

2.2 DATE 直接相减:语法糖,但类型得留个心眼

DATE和DATE直接相减在 DB2 里是合法的,结果就是天数,类型DECIMAL(8,0)。这个写法比DAYS()少敲几个字,很多老代码里都是这么写的。

SELECT DATE('2024-03-01') - DATE('2024-02-01') AS DAY_DIFF FROM SYSIBM.SYSDUMMY1; -- 结果:29

需要留心的是返回类型。DECIMAL(8,0)在往INTEGER变量里塞的时候,某些驱动或者某些版本的客户端会做隐式转换,绝大多数情况下没问题,但如果你把它拼进字符串、做CASE分支、或者参与更复杂的算术,偶尔会出现精度或者格式上的意外。所以我更倾向于显式CAST(... AS INTEGER),或者直接用DAYS()返回干净的INTEGER。

另外一个坑是字符串字面量。'2024-03-01' - '2024-02-01'这种写法,DB2 不一定会按日期解析,可能直接按数值减,得到0或者直接报错。带引号的字符串一定要显式转成 DATE 再运算,这一点在 review 别人代码的时候要特别留意,看起来很像但结果完全错。

2.3 TIMESTAMP 相减得到的 DECIMAL(14,0) 到底是什么鬼

这是我在 DB2 里见过的最容易被误用的东西。TIMESTAMP减TIMESTAMP得到的DECIMAL(14,0),结构是YYMMDDHHMMSS的字段拼在一起,每个字段独立借位。

举几个例子你就明白了:

SELECT CHAR(TIMESTAMP('2024-03-01-00.00.00.000000') - TIMESTAMP('2024-02-01-00.00.00.000000')) FROM SYSIBM.SYSDUMMY1; -- 结果:'00000001000000' —— 表示 1 个月,不是 29 天 SELECT CHAR(TIMESTAMP('2024-03-01-00.00.00.000000') - TIMESTAMP('2024-02-29-00.00.00.000000')) FROM SYSIBM.SYSDUMMY1; -- 结果:'00000001000000' —— 也是 1 天?不是,这里是 0 年 0 月 1 日

注意上面两个例子的字符结果一模一样,但代表的含义不同:第一个是 1 个月的间隔,第二个是 1 天的间隔。因为字段位置是固定的,"月"位在第 3-4 位,"日"位在第 5-6 位。这就是为什么你绝对不能把这个值当数字直接加减或者比较大小,00000001000000和00000000010000谁大?按数值比是前者大,但含义上前者是 1 个月、后者是 1 天,一个月可能是 28 到 31 天,大小关系不一定。

我见过有人写WHERE ts2 - ts1 > 1000000来判断是否超过若干天的,结果在某些月份边界上判定完全反了。这种 bug 极难复现,因为需要刚好跨月才触发。

2.4 TIMESTAMPDIFF 配 CHAR 的用法和它的精度陷阱

TIMESTAMPDIFF(interval, char_string)常见写法是:

SELECT TIMESTAMPDIFF(16, CHAR(TIMESTAMP('2024-03-01-00.00.00.000000') - TIMESTAMP('2024-02-01-00.00.00.000000'))) FROM SYSIBM.SYSDUMMY1;

第一个参数是间隔单位代码:1 表示微秒的分数、2 表示秒、4 表示分、8 表示小时、16 表示天、32 表示周、64 表示月、128 表示季度、256 表示年。日常用得最多的是 16(天)、8(小时)、2(秒)。

关键问题来了:当你要的天数涉及到跨月的时候,TIMESTAMPDIFF会给你近似值。因为它内部是把 duration 里的年、月、日拆开算的,一年按 365 天、一个月按 30 天折算。所以上面那个查询返回的是30,而实际上 2 月 1 日到 3 月 1 日在 2024 年(闰年)是 29 天。

这个误差有多大?最大能到 1 天,跨年的时候还会更明显。对于账期、计费这类对天数敏感的场景,1 天误差意味着真金白银的差错。

我的结论很明确:TIMESTAMPDIFF配 16(天)只在同一个自然月内可信,跨月请无条件改用DAYS()相减。如果你确实需要小时以下的精确时长,那应该用秒(代码 2)配合另一个思路,下面会讲。

3. 完整实操:从建表到五种写法跑一遍

3.1 构造测试数据,把边界情况一次覆盖

光看文档不踏实,我习惯先造一张小表把各种边界都塞进去。下面这张表的设计思路是覆盖闰日、月末、跨年、同一天不同时刻、以及跨月的场景。

CREATE TABLE T_TIME_SPAN ( ID INTEGER NOT NULL, BIZ_CODE VARCHAR(20), START_TS TIMESTAMP, END_TS TIMESTAMP, START_DT DATE, END_DT DATE ); INSERT INTO T_TIME_SPAN (ID, BIZ_CODE, START_TS, END_TS, START_DT, END_DT) VALUES (1, 'SAME_DAY', TIMESTAMP('2024-03-01-09.00.00.000000'), TIMESTAMP('2024-03-01-18.30.00.000000'), DATE('2024-03-01'), DATE('2024-03-01')), (2, 'NEXT_DAY', TIMESTAMP('2024-03-01-09.00.00.000000'), TIMESTAMP('2024-03-02-09.00.00.000000'), DATE('2024-03-01'), DATE('2024-03-02')), (3, 'LEAP_FEB', TIMESTAMP('2024-02-01-00.00.00.000000'), TIMESTAMP('2024-03-01-00.00.00.000000'), DATE('2024-02-01'), DATE('2024-03-01')), (4, 'NORMAL_FEB', TIMESTAMP('2023-02-01-00.00.00.000000'), TIMESTAMP('2023-03-01-00.00.00.000000'), DATE('2023-02-01'), DATE('2023-03-01')), (5, 'CROSS_YEAR', TIMESTAMP('2023-12-31-23.00.00.000000'), TIMESTAMP('2024-01-01-01.00.00.000000'), DATE('2023-12-31'), DATE('2024-01-01')), (6, 'REVERSED', TIMESTAMP('2024-03-10-00.00.00.000000'), TIMESTAMP('2024-03-01-00.00.00.000000'), DATE('2024-03-10'), DATE('2024-03-01')), (7, 'MONTH_END', TIMESTAMP('2024-01-31-00.00.00.000000'), TIMESTAMP('2024-02-29-00.00.00.000000'), DATE('2024-01-31'), DATE('2024-02-29'));

第 3 条和第 4 条是我特意放的一对,同样的"2 月 1 日到 3 月 1 日",闰年是 29 天、平年是 28 天,TIMESTAMPDIFF在这两条上都会返回 30,一比就露馅。第 6 条是参数顺序反了的场景,用来验证负数结果。第 7 条是月末到月末,跨了闰日。

3.2 五种写法横向实测对比

把常用的几种算法一次性跑出来,对照着看最直观。

SELECT ID, BIZ_CODE, -- 写法1:DAYS() 相减,纯自然日差 DAYS(END_DT) - DAYS(START_DT) AS M1_DAYS, -- 写法2:DATE 直接相减 CAST(END_DT - START_DT AS INTEGER) AS M2_DATE_SUB, -- 写法3:TIMESTAMPDIFF 配 CHAR TIMESTAMPDIFF(16, CHAR(END_TS - START_TS)) AS M3_TSDIFF, -- 写法4:时间戳相减后取整数部分(错误示范,仅作对比) CAST(END_TS - START_TS AS BIGINT) AS M4_WRONG, -- 写法5:精确秒差再折天 DECIMAL( (DAYS(END_TS) - DAYS(START_TS)) * 86400 + (MIDNIGHT_SECONDS(END_TS) - MIDNIGHT_SECONDS(START_TS)) , 15, 6) / 86400 AS M5_EXACT_DAY FROM T_TIME_SPAN ORDER BY ID;

跑出来的结果大致是这样(单位为天):

ID场景M1 DAYSM2 DATE相减M3 TSDIFFM4 直转M5 精确折天
1同一天000异常值0.395833
2隔一天同时刻111异常值1.000000
3闰年跨二月292930异常值29.000000
4平年跨二月282830异常值28.000000
5跨年两小时110异常值0.083333
6反向输入-9-9-9异常值-9.000000
7月末到闰日292930异常值29.000000

这张表基本把问题说透了。M1 和 M2 结果一致,都是准确的自然日差;M3 在跨月的三条记录上全部偏大;M4 那一列我标了"异常值",因为把DECIMAL(14,0)直接转成整数,得到的是那串YYMMDDHHMMSS拼起来的巨大数字,完全没有业务意义;M5 是精确到秒的时长折算,同一天那条算出了 0.395833 天(约 9.5 小时),符合预期。

第 5 条特别值得说:跨年只差 2 小时,TIMESTAMPDIFF(16, ...)返回0,因为按天取整了。但这个 0 在业务上可能意味着"零天",而实际上是有 2 小时时长的。如果你的 SLA 判断用的是这个值,就会漏判。

注意:M4 这种写法在某些老代码里真的存在,尤其是从别的数据库迁移过来的项目。审查时看到CAST(xxx - yyy AS INT)且两边是 TIMESTAMP 的,直接标红。

3.3 封装成视图和自定义函数,避免重复造轮子

同一个口径反复写,早晚会有人写错。我的习惯是封一个视图出去,业务 SQL 一律从视图取。

CREATE VIEW V_TIME_SPAN_CALC AS SELECT ID, BIZ_CODE, START_TS, END_TS, -- 口径A:纯时间差(自然日) DAYS(END_TS) - DAYS(START_TS) AS DIFF_DAYS_EXCL, -- 口径B:算头算尾 DAYS(END_TS) - DAYS(START_TS) + 1 AS DIFF_DAYS_INCL, -- 口径C:精确到秒的时长(天,含小数) DECIMAL( (DAYS(END_TS) - DAYS(START_TS)) * 86400 + (MIDNIGHT_SECONDS(END_TS) - MIDNIGHT_SECONDS(START_TS)) , 15, 6) / 86400 AS DIFF_DAYS_EXACT, -- 精确时长(小时,含小数) DECIMAL( (DAYS(END_TS) - DAYS(START_TS)) * 86400 + (MIDNIGHT_SECONDS(END_TS) - MIDNIGHT_SECONDS(START_TS)) , 15, 6) / 3600 AS DIFF_HOURS_EXACT, -- 精确时长(秒,整数) (DAYS(END_TS) - DAYS(START_TS)) * 86400 + (MIDNIGHT_SECONDS(END_TS) - MIDNIGHT_SECONDS(START_TS)) AS DIFF_SECONDS FROM T_TIME_SPAN;

这里MIDNIGHT_SECONDS()是关键,它返回时间戳当天从零点起的秒数,范围 0 到 86399。把"天数 × 86400 + 当天秒数"两边一减,就得到了绝对秒差,完全绕开了时间戳相减的字段借位问题,跨月跨年都精确。

如果团队里 UDF 用得比较多,也可以包一层:

CREATE FUNCTION F_DIFF_DAYS (P_START TIMESTAMP, P_END TIMESTAMP) RETURNS INTEGER LANGUAGE SQL DETERMINISTIC NO EXTERNAL ACTION RETURN DAYS(P_END) - DAYS(P_START);

DETERMINISTIC这个修饰词一定要加,否则优化器不敢在生成列和索引表达式里用你。NO EXTERNAL ACTION是防止它被判定为有副作用,影响谓词下推。

4. 业务口径落地:算头算尾、工作日、按比例折算

4.1 算头算尾的三种实现和它们的差异

口径 B(算头算尾)最容易被忽略的就是那个+1。3 月 1 日到 3 月 1 日,按纯时间差是 0 天,但按"覆盖了几个日历日"是 1 天。请假一天、住一晚酒店,都是这个口径。

-- 口径A:不含起始日 SELECT DAYS(DATE('2024-03-05')) - DAYS(DATE('2024-03-01')) AS D FROM SYSIBM.SYSDUMMY1; -- 结果:4 -- 口径B:含起始日和结束日 SELECT DAYS(DATE('2024-03-05')) - DAYS(DATE('2024-03-01')) + 1 AS D FROM SYSIBM.SYSDUMMY1; -- 结果:5

这里有个细节:如果结束时间是当天下午 3 点,算头算尾应该算到这一天吗?我一般的处理是先按日期取整再算头算尾,因为"请假到 3 月 5 日下午 3 点"在业务上就是请到 3 月 5 日这一天结束。但如果业务说"到 3 月 5 日下午 3 点截止,按小时计",那就得走口径 C。

另外一个坑是负数场景。假设用户填错,结束时间早于开始时间,口径 B 加上+1之后会变成-8而不是-9,边界不连续。所以我习惯在视图里加一个CASE做归一化:

CASE WHEN END_TS < START_TS THEN 0 ELSE DAYS(END_TS) - DAYS(START_TS) + 1 END AS DIFF_DAYS_INCL_SAFE

报错还是归零,取决于业务。财务类的一般归零,工时类的可能需要保留负数用于冲销。

4.2 排除周末的工作日天数,两种做法各有取舍

工作日计算是另一个高频需求,比如"合同签订后 5 个工作日内完成"。DB2 没有现成的NETWORKDAYS,得自己算。

做法一:公式法。核心思路是算出总天数里包含多少个完整的周,每个整周有 2 天周末,剩下的零头按起始星期几单独判断。

WITH P AS ( SELECT DATE('2024-03-01') AS SD, DATE('2024-03-15') AS ED FROM SYSIBM.SYSDUMMY1 ), B AS ( SELECT SD, ED, DAYS(ED) - DAYS(SD) + 1 AS TOTAL, DAYOFWEEK_ISO(SD) AS W -- 1=周一 ... 7=周日 FROM P ) SELECT SD, ED, TOTAL, TOTAL - (TOTAL / 7) * 2 - CASE WHEN MOD(TOTAL, 7) = 0 THEN 0 WHEN W + MOD(TOTAL, 7) - 1 <= 7 THEN CASE WHEN W + MOD(TOTAL, 7) - 1 >= 6 THEN W + MOD(TOTAL, 7) - 5 ELSE 0 END ELSE CASE WHEN W <= 5 THEN 1 ELSE 0 END + CASE WHEN W + MOD(TOTAL, 7) - 1 >= 8 THEN 1 ELSE 0 END END AS WORK_DAYS FROM B;

这个逻辑有点绕,解释一下。TOTAL / 7 * 2是完整周带来的周末天数。零头部分MOD(TOTAL, 7)天,从起始星期W开始连续数,如果这个区间跨过了 6(周六)和 7(周日),就要额外扣。里面那两个分支分别处理"没绕回"和"绕回"的情况。说实话这段我每次写都要在纸上画一遍才敢确认,而且闰年、月初月末都验证过之后才敢上生产。

做法二:递归 CTE 生成日期序列再筛。可读性高得多,缺点是区间大时性能会掉。

WITH DATE_SEQ (D) AS ( SELECT DATE('2024-03-01') FROM SYSIBM.SYSDUMMY1 UNION ALL SELECT D + 1 DAYS FROM DATE_SEQ WHERE D < DATE('2024-03-15') ) SELECT COUNT(*) AS WORK_DAYS FROM DATE_SEQ WHERE DAYOFWEEK_ISO(D) <= 5;

这里DAYOFWEEK_ISO返回 1 到 7,1 是周一、7 是周日,取<= 5就是周一到周五。注意别用DAYOFWEEK,那个返回 1 是周日、7 是周六,混用会全错。

递归 CTE 有两个实际的限制要提醒。一是递归深度,区间特别大(比如三五年)的时候可能会撞上资源上限,需要配合OPTIMIZE FOR n ROWS或者干脆换日历表。二是不同平台的语法细节有差异,标准写法是WITH xxx AS不带RECURSIVE关键字,如果换了平台报语法错,试着补上再跑。

做法三:预计算日历表。这是数仓里最稳的方案,建一张覆盖二十年、每天一行的日历表,带上是否工作日、是否节假日、所属财年等字段,所有工作日计算直接 join 聚合。一次性投入,后面所有报表都受益。

CREATE TABLE DIM_CALENDAR ( CAL_DATE DATE NOT NULL PRIMARY KEY, DAY_OF_WEEK SMALLINT, IS_WEEKEND SMALLINT DEFAULT 0, IS_HOLIDAY SMALLINT DEFAULT 0, IS_WORKDAY SMALLINT DEFAULT 0, FISCAL_PERIOD VARCHAR(10) );

节假日字段需要人工维护,但这也是优点——法定节假日调休本来就不是算法能算出来的,必须人工确认。我们内部的做法是每年年初导入一次,之后只在临时调整时更新。

4.3 不足一天按比例折算,精度得自己控制好

计费、工时这类场景经常要"0.5 天""1.25 天"这种值。上面视图里的DIFF_DAYS_EXACT就是干这个的,用秒差除以 86400,保留 6 位小数再按业务需要四舍五入。

SELECT ID, DIFF_DAYS_EXACT, -- 向上取整到 0.5 天 CEILING(DIFF_DAYS_EXACT * 2) / 2 AS ROUND_HALF_UP, -- 保留两位小数 DECIMAL(DIFF_DAYS_EXACT, 10, 2) AS ROUND_2, -- 不足一天按一天 CASE WHEN DIFF_DAYS_EXACT > 0 AND DIFF_DAYS_EXACT < 1 THEN 1 ELSE CEILING(DIFF_DAYS_EXACT) END AS MIN_ONE_DAY FROM V_TIME_SPAN_CALC;

DECIMAL(x, 10, 2)这个写法在 DB2 里默认是四舍五入(half up),不是截断。这一点和某些数据库不一样,如果用TRUNCATE或者FLOOR要显式写。曾经有个计费项目因为不知道这个默认行为,对账的时候差了三分钱,查了两天。

还有一处要留心:除法一定要先转 DECIMAL 再除。如果两边都是整数,DB2 的整数除法会直接截断,5 / 2得到2而不是2.5。我见过写成((DAYS(e)-DAYS(s))*86400 + ...) / 86400然后纳闷为什么小数全丢了的,问题就出在这里。

5. 性能与索引:别让一个函数把整条执行计划废掉

5.1 WHERE 里套函数的代价,比你想的大

最典型的写法是这样的:

-- 反例 SELECT * FROM T_ORDER WHERE DAYS(CURRENT DATE) - DAYS(CREATE_DATE) > 30;

这条 SQL 的逻辑没问题,但执行计划基本是全表扫描。原因很简单:CREATE_DATE被包在函数里了,优化器没法用它上面的索引做范围扫描,只能一行行算出来再比。

改成对列直接做谓词,索引立刻就能用上:

-- 正例 SELECT * FROM T_ORDER WHERE CREATE_DATE < CURRENT DATE - 30 DAYS;

两句话在业务上完全等价(都是"创建超过 30 天"),但性能可能是几十倍的差距。表小的时候感觉不出来,上了千万行就是分钟级和秒级的区别。

我 review SQL 的时候有个简单粗暴的规则:WHERE 子句里出现DAYS(、YEAR(、SUBSTR(、VARCHAR_FORMAT(包着索引列的,一律要求改写成列侧裸奔的形式。只有极少数实在改不了的场景才允许例外,而且必须有人签字确认。

5.2 生成列加索引,把计算结果固化下来

有些场景确实改不了,比如报表要按"相隔天数区间"分组统计。这时候有两个选择。

第一个是生成列。DB2 支持GENERATED ALWAYS AS,把表达式的结果物化成真实列,然后就能在上面建索引。

ALTER TABLE T_TIME_SPAN ADD COLUMN DIFF_DAYS INTEGER GENERATED ALWAYS AS (DAYS(END_DT) - DAYS(START_DT)); CREATE INDEX IX_T_TIME_SPAN_DIFF ON T_TIME_SPAN (DIFF_DAYS);

生成列有几个使用前提:表达式必须是确定性的(DAYS()满足,但CURRENT DATE不满足),不能引用 LOB 类型,表达式长度也有限制。另外要注意,生成列在每次插入更新时都会重新计算,会有轻微写入开销,读取收益远远大于这点开销。

第二个是物化视图或者汇总表。如果统计口径相对固定(比如按天、按月、按业务线汇总平均时长),可以走定时任务把结果算好落到汇总表,查询时直接读。这种方式在数据量特别大的时候最有效,代价是数据有延迟,得接受 T+1。

5.3 大批量统计的分组与聚合思路

按月统计平均相隔天数是常见需求,写法上有个小技巧:先按月份截取出分组键,再聚合。

SELECT VARCHAR_FORMAT(START_DT, 'YYYY-MM') AS MONTH_KEY, COUNT(*) AS CNT, AVG(DECIMAL(DAYS(END_DT) - DAYS(START_DT), 10, 2)) AS AVG_DAYS, MAX(DAYS(END_DT) - DAYS(START_DT)) AS MAX_DAYS, MIN(DAYS(END_DT) - DAYS(START_DT)) AS MIN_DAYS FROM T_TIME_SPAN WHERE START_DT >= DATE('2024-01-01') AND START_DT < DATE('2025-01-01') GROUP BY VARCHAR_FORMAT(START_DT, 'YYYY-MM') ORDER BY MONTH_KEY;

VARCHAR_FORMAT是 DB2 里做格式化比较稳妥的函数,比SUBSTR(CHAR(...))可读性好。外面那层WHERE用了裸列做范围条件,能吃到START_DT上的索引,这个细节在月结报表这种大查询里差别很明显。

还有一点,AVG里面套DECIMAL转换别省。如果直接AVG(整数),DB2 在某些版本上返回的是整数平均值,小数被截掉,报表上看着就是"平均 3 天""平均 4 天",实际可能是 3.7 天,精度损失很难察觉。

6. 常见问题与排查技巧实录

6.1 报错和异常现象速查表

下面这张表是我这些年攒下来的,遇到问题先对一遍,能省不少时间。

现象 / 报错大概率原因处理方式
SQL0180N 日期时间格式无效字符串与当前日期格式不匹配,或含非法字符显式TO_DATE()或DATE(),统一 ISO 格式
SQL0181N 日期时间值超出范围出现 2 月 30 日、13 月这类不存在的日期源头校验,或用CASE先过滤
结果恒为 0两个字符串直接相减,被当数值处理加DATE()显式转换
跨月时结果差 30 天TIMESTAMPDIFF(16, CHAR(...))的近似折算改用DAYS()相减
结果为巨大数字如 20240301TIMESTAMP相减结果直接转 BIGINT改走DAYS()或MIDNIGHT_SECONDS组合
小数部分消失整数除法先截断再赋值先DECIMAL(...,15,6)再除
负数结果起止参数顺序反了加CASE归一化,或调整参数顺序
索引不生效WHERE 子句里函数包住了索引列改写为列侧比较,或用生成列
递归 CTE 报错区间过大触发资源限制换日历表,或分段处理
结果为 NULL任一参数为 NULL,运算自动传播用COALESCE兜底,或在业务层保证非空

6.2 隐性截断、四舍五入和负数,三个最容易翻车的地方

截断。除了前面说的整数除法,还有一个隐蔽的:把TIMESTAMP赋给DATE变量时,时分秒会被静默丢弃,不报错。如果这段逻辑后面又拿去做差值,1 天的误差就出来了。我的习惯是核心计算全程用TIMESTAMP,只在最终展示或者按天分组时才转DATE。

四舍五入。DECIMAL类型转换默认是四舍五入,而CAST到整数是截断。CAST(2.9 AS INTEGER)得到2,DECIMAL(2.9, 10, 0)得到3。这两个在代码里长得像,行为完全不同。计费类的场景我一般会明确用CEILING、FLOOR或者ROUND显式表达意图,不用隐式转换。

负数。起止反了的情况前面提过,还有一种:跨时区数据没对齐导致的"负一天"。这种数据在报表上表现为某几条记录的时长是负的,但业务上明明是该有的。排查思路是先捞出来看这几个记录的时区字段,再看它们的时区标识是否一致。这类问题在单库里很难复现,因为产生负值的那个时间窗口可能只有几个小时。

6.3 字符型时间字段的处理和脏数据清洗

存量系统里日期存成VARCHAR(10)的情况太常见了,可能是'2024-03-01'、'20240301'、甚至'2024/3/1'混着来。这种字段直接算,报错是常态。

-- 先摸清格式分布 SELECT LENGTH(TRIM(START_STR)) AS L, COUNT(*) FROM T_LEGACY GROUP BY LENGTH(TRIM(START_STR)); -- 再统一转换,非法值转 NULL 不中断查询 SELECT CASE WHEN LENGTH(TRIM(START_STR)) = 10 AND START_STR LIKE '____-__-__' THEN DATE(START_STR) ELSE NULL END AS START_DT FROM T_LEGACY;

LIKE '____-__-__'这种下划线通配是个很便宜的预校验,能挡住大部分格式混杂的情况。不过它挡不住'2024-02-30'这种格式对但不存在的日期,那种还是得靠DATE()转换时抛错来发现。批量处理时建议分两批跑:第一批用宽松条件过滤出"格式看起来对"的,各自单独转;第二批处理剩下的脏数据。一次性硬转很容易让整条 SQL 挂在一个坏值上。

6.4 NULL 传播和默认值策略

DB2 里任何算术运算碰到 NULL 结果都是 NULL,DAYS(NULL) - DAYS(x)也是 NULL。这本身合理,问题在于下游拿到 NULL 之后可能直接进SUM,而SUM会忽略 NULL,导致总数偏小但没人发现。

SELECT ID, COALESCE(DAYS(END_DT) - DAYS(START_DT), 0) AS DIFF_DAYS, DAYS(END_DT) - DAYS(START_DT) AS DIFF_DAYS_RAW FROM T_TIME_SPAN;

我习惯同时留一列原始值和一列兜底值,报表用兜底值,核对时用原始值。这样既不会让 NULL 影响聚合,又保留了"这条记录其实是缺数据"的信息。如果直接用兜底值去COUNT,缺失情况就彻底看不见了。

在写LEFT JOIN拼接多张表算时长的时候尤其要注意,被驱动表的 NULL 会让整个表达式变 NULL,而很多人只检查了主表非空就以为万事大吉。

7. 踩过几个坑之后,我固定下来的写法

现在我的项目里基本是这么一套组合:视图统一提供口径 A/B/C 三种字段,业务 SQL 一律从视图取,绝不自己手写DAYS();所有涉及日期的 WHERE 条件都写成裸列比较;需要按天数范围筛选的,走生成列加索引;工作日和节假日一律查日历表,不用公式硬算。

一个特别想强调的细节:上线前一定要用跨月、跨年、闰日、月末这四类数据各跑一遍。我见过不止一次,日常数据都是三五天的短区间,测试全过,结果到了年底做年度汇总,跨年的记录天数集体少 1,因为用的是TIMESTAMPDIFF而没人发现。这种问题在测试环境里如果只造近三个月的数据,是永远暴露不出来的。

另外分享一个查历史数据的小技巧。当你怀疑某张表的天数算错,但不知道错在哪批记录上,可以拿两种算法跑一遍做全量比对,只捞不一致的:

SELECT ID, DAYS(END_DT) - DAYS(START_DT) AS D1, TIMESTAMPDIFF(16, CHAR(END_TS - START_TS)) AS D2 FROM T_TIME_SPAN WHERE DAYS(END_DT) - DAYS(START_DT) <> TIMESTAMPDIFF(16, CHAR(END_TS - START_TS)) AND END_TS IS NOT NULL AND START_TS IS NOT NULL;

跑出来的记录就是两种口径有分歧的地方,通常集中在跨月的边界上。如果数量很少且都是已知的近似误差,就不用管;如果出现大量不一致或者差值超过 1 天,那说明还有别的问题,比如时区或者脏数据,值得深挖一层。这个方法我用来排查过好几次线上数据偏差,比逐条肉眼核对快得多。

返回列表