做数据开发的人,十有八九每天都要跟日期打交道。离线数仓里跑批任务,第一件事就是定位业务日期;日志清洗,第一步就是解析时间字段;做报表,动不动就要求同比、环比、周活、月活。这套东西绕来绕去,最后全都落到一句话上:Hive里的日期格式转换和计算怎么写才不出错。
这篇内容就是围绕Hive日期处理那点事写的,覆盖字符串转时间戳、时间戳转日期、日期加减、求差、取周月季边界等常用场景。每个场景都有可以直接复制的SQL写法,我也把实际项目里踩过的坑一并交代了。不管你是刚接触数仓的新手,还是天天在写离线SQL的老手,只要工作里离不开日期字段,这篇都建议先收藏。
我为什么要专门写这个?之前接手某业务线的订单数仓,上游三份数据源的下单时间字段长得完全不一样:一份是标准字符串'2023-10-15 14:30:00',一份是毫秒时间戳,还有一份干脆用'20231015'这种裸数字当日期。要把三份数据按天关联统计,如果不先把时间口径统一成标准日期,后面的SQL根本没法写。那次我排查了半天,最后发现问题不出在业务逻辑,也不出在数据缺失,而是隐式转换和格式歧义在作怪。从那以后我就养成了习惯:凡是进数仓的日期,必须明确类型、格式、时区和时间戳单位这四件事。
1. 为什么Hive日期处理总是踩坑
1.1 日期在数仓里的地位
数仓里的日期字段比普通字段特殊得多。普通字段值错了最多是数据不准,日期字段值错了,直接导致分区定位错误、跑批数据重复或遗漏、报表指标对不上。尤其在生产环境里,凌晨跑批任务依赖前一天的业务日期做增量抽取,一旦日期计算差一天,整个链路的数据都跟着错。
我见过最典型的问题:上游表用string类型存日期,下游再用date类型做比较。Hive在比较时会做隐式转换,但转换规则并不是在所有版本里都一样,结果就是有时候能匹配上,有时候匹配不上。这种问题在实际生产里非常隐蔽,它不报错,只是默默给你算出一个看起来合理、实际上是错误的结果。所以我一直跟团队说,日期处理的第一原则不是“能查出来就行”,而是“每一次转换都要显式写出格式”。
1.2 必须搞清楚的三个基础概念
Hive里日期处理绕不开三个概念:时间戳、格式化字符串、日期类型。
时间戳本质上是数字,记录从1970-01-01 00:00:00 UTC到某个时刻经过的秒数(也有毫秒数),它适合做排序、区间判断和时区无关的比较。格式化字符串是给人看的,比如'2023-10-15 14:30:00',它适合做展示和分区命名。日期类型在Hive里分两种:date只精确到天,timestamp精确到纳秒。
这三者的关系可以理解成:时间戳是“机器视角的日期”,字符串是“人写的日期”,日期类型是“Hive的正式日期”。所有日期计算其实都是在它们之间做桥接。比如你有一个字符串日期,想算它加上三天之后是哪天,要么先转成日期类型再date_add,要么用字符串拼接,但后者极易出错。每当你拿不准该用哪个函数的时候,先问自己:我现在手里是什么类型,目标是什么类型,中间要用哪个函数搭桥。
2. 格式转换三件套:unix_timestamp、from_unixtime与to_date
2.1 unix_timestamp:把日期字符串变成秒数
unix_timestamp是字符串转时间戳的主力函数,基本语法有两种。
-- 使用默认格式 yyyy-MM-dd HH:mm:ss select unix_timestamp('2023-10-15 14:30:00'); -- 指定解析格式 select unix_timestamp('2023/10/15 14:30:00', 'yyyy/MM/dd HH:mm:ss'); -- 解析纯日期字符串 select unix_timestamp('2023-10-15', 'yyyy-MM-dd');第一次用的时候很容易踩一个坑:不指定格式时,Hive默认解析'yyyy-MM-dd HH:mm:ss'。如果你传入'2023-10-15'这种只有日期的字符串,很多Hive版本会返回NULL。不要指望它自动帮你补全00:00:00,它没那么聪明。
指定格式时还有一个隐藏要求:格式模板必须与实际字符串完全匹配,包括分隔符。比如'20231015'不能硬套'yyyy-MM-dd',得写'yyyyMMdd'。另外,unix_timestamp的返回单位是秒,这是个整数,处理毫秒时间戳的时候要特别注意。
2.2 from_unixtime:把秒数变回可读日期
有去就有回,时间戳转字符串用from_unixtime。
-- 秒级时间戳转默认格式 select from_unixtime(1697351400); -- 指定输出格式 select from_unixtime(1697351400, 'yyyy-MM-dd'); select from_unixtime(1697351400, 'yyyy-MM-dd HH:mm:ss');这个函数本身不复杂,复杂的是单位问题。有些上游系统的时间戳是毫秒级、甚至是微秒级的,比如'1697351400000'这种14位数,直接丢给from_unixtime会得到一个离谱的年份,或者转出来是个巨大数字。处理办法很简单:除以1000。
select from_unixtime(cast(1697351400000 / 1000 as bigint), 'yyyy-MM-dd HH:mm:ss');注意,from_unixtime的入参是整型,如果直接除,Hive可能给你一个小数,所以最好套一层cast( ... as bigint)。这一步很多人会漏,我一开始也漏过,结果就是时间粒度全乱。
2.3 to_date与date_format:提取和重排
to_date负责从'yyyy-MM-dd HH:mm:ss'这类字符串里提取日期部分,返回'yyyy-MM-dd'。
select to_date('2023-10-15 14:30:00'); -- 结果:2023-10-15这个函数特别适合从日志里拆出日期分区字段。比如日志时间戳是'2023-10-15 14:30:00.123',to_date也能正常取到日期,因为Hive会把小数部分忽略掉。但如果你拿到的是'20231015'这种紧凑格式,to_date就不认识了,得先用from_unixtime(unix_timestamp(...))绕一圈。
date_format是Hive 1.2.0之后提供的格式化函数,适用性更广,能输出星期、季度、十二小时制等更复杂的格式。
select date_format('2023-10-15 14:30:00', 'yyyy-MM-dd'); select date_format('2023-10-15 14:30:00', 'yyyy年MM月dd日'); select date_format('2023-10-15 14:30:00', 'HH:mm:ss'); select date_format('2023-10-15 14:30:00', 'yyyy-MM-dd HH:mm:ss');用date_format时也要提醒一句:它的格式符大部分继承自Java的SimpleDateFormat,像yyyy、MM、dd、HH都是常见写法,但大小写很敏感,写错一个字母,结果就完全不对。后面专门用一整节讲大小写带来的坑。
2.4 实战:统一多来源日志文件的日期格式
假设有一个ods_log_data表,里面有三类日志,时间字段分别叫log_time,但底层的存储格式不一样,同一列里既有字符串,又有秒级时间戳字符串,甚至还有毫秒级字符串。最稳的做法是统一提取成标准格式化字符串和日期分区字段。
select log_id, log_time, from_unixtime( cast( case when log_time regexp '^[0-9]{13}$' then cast(log_time as bigint) / 1000 when log_time regexp '^[0-9]{10}$' then cast(log_time as bigint) else unix_timestamp(log_time, 'yyyy-MM-dd HH:mm:ss') end as bigint ), 'yyyy-MM-dd HH:mm:ss' ) as std_time, from_unixtime( cast( case when log_time regexp '^[0-9]{13}$' then cast(log_time as bigint) / 1000 when log_time regexp '^[0-9]{10}$' then cast(log_time as bigint) else unix_timestamp(log_time, 'yyyy-MM-dd HH:mm:ss') end as bigint ), 'yyyy-MM-dd' ) as dt from ods_log_data;这段SQL的核心逻辑是先判断字段形态:13位数字当毫秒处理,10位数字当秒处理,其余当成标准字符串解析。全部先转成秒级时间戳,再统一格式化输出。这样下游拿到的std_time和dt口径完全一致,后面做任何计算都不会乱。这也是我在生产环境里最常用的清洗模板。
3. 日期计算实操:加减法与周期求值
3.1 date_add与date_sub:按天加减
对日期做加减是最常见的需求,date_add和date_sub就是干这个的。
-- 当前日期加3天 select date_add(current_date, 3); -- 当前日期减7天 select date_sub(current_date, 7); -- 对指定日期操作 select date_add('2023-10-15', 1); select date_sub('2023-10-15', 1);关键点在于:date_add的第二个参数可以是负数,写date_add(日期, -3)和date_sub(日期, 3)效果完全一样。所以我个人写代码时有个习惯:如果逻辑是“往前退N天”,统一用date_sub;如果逻辑是“往后推N天”,统一用date_add。这样别人review代码时不需要心算正负号。
关于入参类型,date_add和date_sub对字符串支持得比较好,'2023-10-15'这种格式可以直接传,但最好显式写成date '2023-10-15',能避免Hive把它当成字符串做隐式转换时出现意外。生产环境里,我更推荐把日期字段在建模层就统一成date类型,到了计算层就直接加减,少一层转换就少一个出错机会。
3.2 datediff:两个日期的天数差
求相隔天数用datediff。
select datediff('2023-10-15', '2023-10-01'); -- 结果:14这里最容易记反的是参数顺序:datediff(enddate, startdate),结束日期写在前面,开始日期写在后面。想当初我刚用的时候,按直觉写datediff(startdate, enddate),结果所有天数为负,排查了半天才知道是顺序问题。
datediff的入参可以是字符串、date或timestamp。如果传入的是带时间的字符串,比如'2023-10-15 14:00:00',Hive会尝试把它解析成日期再参与计算。但这里跟版本有关系,有些版本对带时间字符串的解析很宽松,有些版本直接返回NULL。稳妥做法是先把字符串用to_date或date_format转成纯日期格式再求差。
-- 稳妥写法 select datediff(to_date('2023-10-15 14:00:00'), to_date('2023-10-01 08:30:00'));3.3 months_between、add_months:按月逻辑
按天算完之后,接踵而来的就是按月算。add_months用来加减月份,months_between用来求两个日期的月数差。
-- 加一个月 select add_months('2023-10-15', 1); -- 减三个月 select add_months('2023-10-15', -3); -- 两个日期之间相差几个月 select months_between('2023-10-15', '2023-08-15'); -- 结果:2.0months_between返回的不一定是整数。比如算months_between('2023-10-15', '2023-10-01'),结果是0.45161290322580644这种小数,因为它精确到了“天”的粒度。用的时候要明确自己的需求:如果业务上只关心完整月数,可以用cast(... as int)截断,但截断前先想清楚是向下取整还是四舍五入。
add_months有个隐藏规则特别容易被忽略:当起始日期是某个月的最后一天时,加一个月会直接跳到目标月份的最后一天。比如add_months('2023-01-31', 1)返回'2023-02-28',不会因为2月没有31号就报错。这个行为看起来贴心,但对某些需要保持“每月同一天”的业务逻辑是个坑,计算还款日、账单日时尤其要注意。写代码时最好先用last_day判断是不是月末,再决定要不要走特殊逻辑。
3.4 last_day、next_day、trunc:求周月季边界
这一组函数是报表统计的利器,取周期边界就靠它们。
last_day返回当月的最后一天:
select last_day('2023-10-15'); -- 结果:2023-10-31trunc按指定单位截断日期,最常用的是按月份截断到当月第一天,按年份截断到当年第一天:
-- 当月第一天 select trunc('2023-10-15', 'MONTH'); -- 当年第一天 select trunc('2023-10-15', 'YEAR');next_day返回当前日期之后的下一个指定星期几。注意“之后”这两个字,它严格晚于当前日期,不会返回当天。
-- 2023-10-15是周日,下一个周一是2023-10-16 select next_day('2023-10-15', 'MO'); -- 结果:2023-10-16每周的日期写法在不同系统里写法不一样,Hive里常见的是MO、TU、WE、TH、FR、SA、SU,也可以写全称Monday。但某些老版本只支持全称,写缩写可能不识别,建议先在自己环境里做一次验证再大量使用。
3.5 完整示例:统计本周新增订单与本月新增用户
把上面的函数组合起来,就能解决真实报表需求。比如要统计本周一以来的新增订单,难点是先算出“本周一”是哪天。这里给出一个在生产环境验证过的写法:
-- 本周一 select date_sub(current_date, (dayofweek(current_date) + 5) % 7);解释一下这个公式。Hive的dayofweek返回值遵循Java规则:周日为1,周一为2,周二为3,依此类推,周六为7。我们要算的是“当前日期往回退几天能到本周一”。通过(dayofweek(current_date) + 5) % 7得到偏移量:周一时偏移0,周二偏移1,周日偏移6。这个公式虽然初看费解,但我在多个Hive版本上验证过结果一致,可以直接用。
完整的周报SQL可以这样组织:
select date_sub(current_date, (dayofweek(current_date) + 5) % 7) as monday , count(distinct order_id) as order_cnt from dwd_order_info where dt >= date_sub(current_date, (dayofweek(current_date) + 5) % 7) and dt < date_add(current_date, 1) group by date_sub(current_date, (dayofweek(current_date) + 5) % 7);注意这里用了“大于等于周一、小于明天”的区间方式,避免了使用dt <= current_date时把当天时间截断判断搞错的麻烦。分区字段是yyyy-MM-dd的字符串时,跟日期类型直接比较通常没问题,但严谨起见,我会统一写成from_unixtime(unix_timestamp(dt, 'yyyy-MM-dd'), 'yyyy-MM-dd')比较。
再看月维度的统计。要统计本月新增用户,先求本月第一天和本月最后一天:
-- 本月第一天 select trunc(current_date, 'MONTH'); -- 本月最后一天 select last_day(current_date);求本季度边界稍麻烦一点,因为不同Hive版本对trunc的QUARTER支持情况不太一致。我用一套不依赖版本的写法:
-- 本季度第一天 select add_months( trunc(current_date, 'MONTH'), -((month(current_date) - 1) % 3) ); -- 本季度最后一天 select date_add( add_months( add_months(trunc(current_date, 'MONTH'), -((month(current_date) - 1) % 3)), 3 ), -1 );思路是先取当月第一天,再根据当前月份所处的季度偏移量往前推1到2个月,就能定位到季度第一天。季度最后一天用“季度第一天加3个月再减1天”来算,逻辑清晰且对所有版本都友好。实际工作中,我把这些边界计算封装成了一组视图或者UDF,项目里所有报表统一调用,避免每个开发各写一套口径跑出来不一致。
4. 常见问题与排查技巧实录
4.1 yyyy和YYYY的坑
这个坑几乎每个用Hive的人都遇到过。yyyy(小写)表示自然年,YYYY(大写)表示“周年”(week-based year)。在跨年那一周,两者会给出完全不同的年份。
举个例子,'2022-01-01'恰好是周六,如果按ISO周历,这一周被归到2021年度的最后一周。你用from_unixtime(unix_timestamp('2022-01-01'), 'YYYY-MM-dd')格式化,会得到'2021-01-01',而用yyyy-MM-dd得到'2022-01-01'。年份直接差了一年,报表里如果按年汇总,这个差异会悄无声息地造成数据错位。
我的建议是,除非业务明确需要ISO周年,否则全部统一用小写yyyy。团队规范里也应该明确写死这一条,并把大小写检查写进SQL评审清单。
4.2 秒与毫秒的时间戳混用
时间戳单位混用是另一个高频问题。秒级时间戳是10位,毫秒级是13位。表面上看只是多几位数字,一旦混用,from_unixtime输出的日期会变成一个几年后的时间,或者一个不明所以的时间。
排查这种问题有个实用技巧:看数字位数。10位数字大概率是秒,13位大概率是毫秒。在数据清洗层,我习惯对所有时间戳字段先做一次长度判断:
case when length(cast(ts as string)) = 13 then ts / 1000 when length(cast(ts as string)) = 10 then ts else null end如果遇到16位甚至19位,那就是微秒或纳秒,需要继续除以1000或1000000。写成统一UDF处理最省心,不然每个脚本里都维护一套这种分支,迟早会漏。
4.3 字符串日期隐式比较的陷阱
Hive里字符串和日期做比较时会发生隐式转换,但转换规则对格式很敏感。'2023-10-15'和date '2023-10-15'比较没问题,但如果字符串是'2023-10-15 14:30:00',跟date '2023-10-15'比较时,Hive会把这个字符串转成日期再比,结果就是不等,因为前者带时间部分。
更隐蔽的是'2023/10/15'这种非标准格式。有些版本会把字符串强转成日期失败,直接返回NULL,而NULL参与过滤时会让整行数据消失。我曾经排查过一张表,某段时间的数据总是比预期少一批,问题就出在个别渠道传入的时间字段用了斜杠分隔,比较时隐式转换失败,行被过滤掉了。
所以从源头治理:所有接入的日期字段,在ODS层就统一清洗成'yyyy-MM-dd'或标准date类型,下游不要再用原生字符串直接参与日期计算。
4.4 时区带来的微妙差异
Hive集群的时区设置取决于运行环境。unix_timestamp和from_unixtime在做转换时,使用的是Hive进程所在时区。如果集群时区是UTC,而业务库是北京时间,同一个时间戳转出来的本地时间会差8个小时。
判断集群时区可以用这个查询:
select current_timestamp();如果当前时间跟你所在时区的本地时间不一致,说明集群时区不是你期望的时区。处理办法有两种:一是在会话级别设置时区,例如set time zone 'Asia/Shanghai',但这依赖环境配置,不是每个版本都支持;二是业务侧约定好,所有字段统一按某一时区写入,展示时再另行转换。
我的习惯是数仓内部所有的时间戳字段统一按UTC存储,只在应用层展示时转成业务时区。这个约定可以避免跨时区协作时产生“同一个订单时间在不同国家看不一样”的乱象。
4.5 NULL值传染与空串处理
日期函数遇到NULL或空字符串时的行为也是个大坑。比如unix_timestamp(NULL)返回NULL,date_add(NULL, 1)返回NULL,datediff(NULL, '2023-10-01')返回NULL。这些看起来像废话,但当上游数据里有脏值时,结果就是整条记录被带成NULL,聚合时被自动忽略,最终报表数据莫名变少。
更麻烦的是空字符串''。Hive字符串函数对空字符串的处理跟NULL不完全一样,比如datediff('', '2023-10-01')在很多版本里会尝试把空字符串转为日期,结果返回NULL,而不是报错或返回0。
我给的排查建议是:清洗层对所有日期字段加一个标准化表达式,把空串显式转成NULL:
case when trim(log_time) = '' then null when log_time is null then null else from_unixtime(unix_timestamp(log_time, 'yyyy-MM-dd HH:mm:ss'), 'yyyy-MM-dd') end as dt这样下游所有日期函数在面对空串时都统一返回NULL,至少行为可预测。然后数仓调度里再针对NULL日期单独告警,数据缺失一目了然,排查起来不至于像无头苍蝇。
最后再分享一个我个人的小习惯:日期格式模板我几乎不手写,全部放在统一的常量表或脚本头部。比如'yyyy-MM-dd'、'yyyy-MM-dd HH:mm:ss'这两个最常见,直接在文件顶部定义成变量。SQL里引用变量,既避免拼写错误,也方便后续统一调整。团队里只要有一份日期处理的公共规范,线上因为日期引发的数据事故至少能减少一半。