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 DAYS | M2 DATE相减 | M3 TSDIFF | M4 直转 | M5 精确折天 |
|---|---|---|---|---|---|---|
| 1 | 同一天 | 0 | 0 | 0 | 异常值 | 0.395833 |
| 2 | 隔一天同时刻 | 1 | 1 | 1 | 异常值 | 1.000000 |
| 3 | 闰年跨二月 | 29 | 29 | 30 | 异常值 | 29.000000 |
| 4 | 平年跨二月 | 28 | 28 | 30 | 异常值 | 28.000000 |
| 5 | 跨年两小时 | 1 | 1 | 0 | 异常值 | 0.083333 |
| 6 | 反向输入 | -9 | -9 | -9 | 异常值 | -9.000000 |
| 7 | 月末到闰日 | 29 | 29 | 30 | 异常值 | 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()相减 |
| 结果为巨大数字如 20240301 | TIMESTAMP相减结果直接转 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 天,那说明还有别的问题,比如时区或者脏数据,值得深挖一层。这个方法我用来排查过好几次线上数据偏差,比逐条肉眼核对快得多。