☰
Hive SQL日期处理全攻略:时间戳转换、日期格式化与计算函数详解
2026/10/14 5:04:18 网站建设 项目流程

做数据开发这几年,我遇到最多的日期问题,往往不是“不会用某个函数”,而是同一个时间字段在不同表里长得完全不一样。早上刚拉了一张事件明细,event_time是 13 位毫秒时间戳;下午关联的用户表里给的是yyyy-MM-dd的字符串;晚上日报任务要求的输出格式偏偏又是yyyyMMdd。来回折腾几次之后,我把 Hive 里常用的日期格式转换和计算函数重新捋了一遍,也把那些文档里不会明说、但实际跑数时一定会踩到的细节记了下来。这篇内容就是这份整理,适合每天都在写 Hive SQL、需要在时间戳、日期字符串和 date/timestamp 之间反复切换的人,也适合刚入行想系统过一遍日期函数的同学。

1. 先弄清楚 Hive 里的日期类型和“伪日期”字符串

1.1 Hive 日期相关的三种载体

Hive 里的日期信息主要有三种存在形式,它们之间能互相转换,但使用场景完全不同。

第一种是字符串,最常见。业务表里存的'2024-01-01'、'2024-01-01 10:30:00'、'20240101',本质上都是字符串。这类数据好看、好排查,但做加减和比较时如果格式不统一,很容易出错。

第二种是时间戳,也就是 bigint 整数。事件明细表里尤其常见,有的存 10 位秒级,有的存 13 位毫秒级。时间戳的好处是计算方便、跟时区关系明确,缺点是肉眼不可读,查看数据时必须先转成字符串。

第三种是 Hive 内置的日期类型,包括date和timestamp。date只保留年月日,timestamp保留到时分秒甚至更细,属于“真正的日期类型”,可以直接和字符串做比较,也可以参与函数计算。

很多新手上来就背函数,忽略了三者之间的底层差别,导致date_format('20240101', 'yyyy-MM-dd')这种操作得不到想要的结果。原因很简单:'20240101'只是一个字符串,函数不会按照你的“想法”自动识别成日期,它只会按字面内容处理。所以先搞清楚你要处理的对象是什么形态,比记一百个函数都重要。

1.2 先建一张“业务格式对照表”

做日期转换之前,我建议先在本地维护一张格式对照表。Hive 遵循 Java 的日期格式符,大小写有明确区别,写错一个字母结果就完全不对。

业务常见格式Hive 格式符示例
标准日期 yyyy-MM-dd'yyyy-MM-dd'2024-01-01
紧凑日期 yyyyMMdd'yyyyMMdd'20240101
标准日期时间'yyyy-MM-dd HH:mm:ss'2024-01-01 10:30:00
12 小时制日期时间'yyyy-MM-dd hh:mm:ss'2024-01-01 10:30:00或2024-01-01 08:30:00
带毫秒的日期时间'yyyy-MM-dd HH:mm:ss.SSS'2024-01-01 10:30:00.123
中文日期格式'yyyy年MM月dd日'2024年01月01日

特别注意几组大小写:

  • MM是月份,mm是分钟;
  • HH是 24 小时制,hh是 12 小时制;
  • ss是秒,SSS是毫秒。

为什么先强调对照表?因为 Hive 的函数对格式是“严格匹配”的。字符串里多一个空格、少一个前导零,转换结果常常直接变成 NULL。比如unix_timestamp('2024-1-1', 'yyyy-MM-dd'),编译器并不会自动帮你补零,结果就是 NULL。这一条,很多新手会花整整一个下午去排查。

2. 格式转换的核心组合:from_unixtime、unix_timestamp、date_format

2.1 时间戳转字符串:from_unixtime

from_unixtime是时间戳转字符串的标准函数。

select from_unixtime(1704067200, 'yyyy-MM-dd HH:mm:ss'); -- 2024-01-01 08:00:00

这里容易踩的第一个坑是秒和毫秒的区分。from_unixtime接收的是秒级时间戳,如果源字段是 13 位毫秒时间戳,必须先除以 1000,并且做一次 bigint 转换:

select from_unixtime(cast(1704067200123 / 1000 as bigint), 'yyyy-MM-dd HH:mm:ss');

不除以 1000,函数会拿到一个超大整数,返回的日期会落在 1970 年附近,一眼就能看出来不对。第二个坑是时区。from_unixtime使用的是服务器所在时区。如果集群时区是东八区,那么时间戳转出来的就是北京时间;如果集群是 UTC,而你的业务口径要求北京时间,就需要自己加 8 个小时偏移。跨集群做数据交换时,这个差异很容易造成日报数据差一天。

from_unixtime的第二个参数可以自由控制输出格式,不只是标准格式。比如转成yyyyMMdd用于分区关联:

select from_unixtime(1704067200, 'yyyyMMdd'); -- 20240101

2.2 字符串转时间戳:unix_timestamp

unix_timestamp是from_unixtime的反向操作,把字符串按指定格式解析成秒级时间戳。

select unix_timestamp('2024-01-01 08:00:00', 'yyyy-MM-dd HH:mm:ss'); -- 1704067200

使用时的核心原则是:字符串的书写格式,必须和格式符完全一致。比如要把'20240101'转成时间戳,格式符就必须是'yyyyMMdd':

select unix_timestamp('20240101', 'yyyyMMdd');

如果你写unix_timestamp('20240101', 'yyyy-MM-dd'),Hive 不会自动去猜测意图,只会老老实实返回 NULL。

很多人不知道unix_timestamp('2024-01-01', 'yyyy-MM-dd')这样写也能出结果,因为标准字符串格式在某些 Hive 版本下会被默认解析。但为了不依赖版本行为,我建议永远显式写清楚 pattern,避免换一个环境后结果不一致。

2.3 万能的格式化输出:date_format

date_format可以理解为“不管输入是什么标准形态,只要你能被识别成日期,我就能按指定格式输出”。

select date_format('2024-01-01 13:30:45', 'yyyy-MM-dd HH:mm'); -- 2024-01-01 13:30 select date_format('2024-01-01 13:30:45', 'yyyyMMdd'); -- 20240101

它的输入可以是标准字符串、date 类型、timestamp 类型,但要注意,'20240101'这种紧凑字符串不能直接被date_format识别,需要先用unix_timestamp过渡一下。

date_format适合用在分组统计时做维度抽取。比如统计每个小时的事件量:

select date_format(event_time, 'yyyy-MM-dd HH') as hour_id, count(*) from event_log group by date_format(event_time, 'yyyy-MM-dd HH');

这里event_time如果本身就是timestamp类型,效果最好;如果是从字符串转过来的,建议在清洗层统一先转成标准格式。

2.4 常用转换链路速查表

日常工作中,超过八成的日期格式转换都可以用下面这个表直接套:

转换需求写法
秒级时间戳 →yyyy-MM-ddfrom_unixtime(ts, 'yyyy-MM-dd')
毫秒级时间戳 →yyyy-MM-ddfrom_unixtime(cast(ts/1000 as bigint), 'yyyy-MM-dd')
毫秒级时间戳 →yyyyMMddfrom_unixtime(cast(ts/1000 as bigint), 'yyyyMMdd')
yyyy-MM-dd HH:mm:ss→ 时间戳unix_timestamp(str, 'yyyy-MM-dd HH:mm:ss')
yyyyMMdd→yyyy-MM-ddfrom_unixtime(unix_timestamp(str, 'yyyyMMdd'), 'yyyy-MM-dd')
yyyy-MM-dd→yyyyMMdddate_format(str, 'yyyyMMdd')
yyyy-MM-dd HH:mm:ss→ 只取日期to_date(str)
yyyy-MM-dd HH:mm:ss→ 只取小时hour(str)

to_date这个函数我单独提一下,它专门用于“从完整日期时间字符串里抠出日期部分”,不用写 pattern,比较省事。但它同样要求输入格式能被 Hive 默认识别,如果是'20240101'这种紧凑字符串,不能直接给to_date,得先标准化。

3. 日期的加减、求差与月份计算:date_add、datediff、add_months 的边界行为

3.1 天数加减:date_add 与 date_sub

date_add和date_sub是使用频率最高的两个日期计算函数。

select date_add('2024-01-31', 1); -- 2024-02-01 select date_sub('2024-01-01', 1); -- 2023-12-31

跨月、跨年都由 Hive 自动处理,不需要你担心。第二个参数可以传负数,于是date_add(x, -1)和date_sub(x, 1)完全等价。

两个值得注意的点:

第一,在做近 N 天筛选时,很多人会写where dt between date_sub('2024-03-01', 29) and '2024-03-01'。这种写法没问题,但日期区间的边界计算要格外小心。比如“最近 30 天”到底是 30 天还是 31 天,取决于你算不算当天,建议先明确口径,再用常量日期代替函数嵌套,避免每次跑任务时都重复计算。

第二,date_add的第一个参数可以直接传字符串,Hive 会在内部把它转成日期类型。但如果你的字符串格式是'20240101',就不一定能被正确识别,大概率会报错或者返回 NULL。所以想用date_add操作紧凑日期字符串,务必先转成标准格式:

select date_add(from_unixtime(unix_timestamp('20240101', 'yyyyMMdd'), 'yyyy-MM-dd'), 1);

3.2 两个日期相差多少天:datediff

datediff的语法是datediff(结束日期, 开始日期),返回值是整数天数。

select datediff('2024-02-01', '2024-01-01'); -- 31 select datediff('2024-01-01', '2024-02-01'); -- -31

这里最容易被坑的就是参数顺序。datediff('2024-01-01', '2024-02-01')得到的是负 31,不是 31。我在 code review 里见过很多次这种低级但致命的错误,还遇到过有人为了凑正数,在表达式前面加个负号,绕来绕去反而更难维护。

正确用法是逻辑上“后面的日期减去前面的日期”。比如筛选最近 7 天数据:

where datediff('2024-03-01', dt) between 0 and 6

这个写法可读性不错,但要注意,如果dt是分区字段,后面我会专门讲性能问题,不建议在分区字段上直接套函数。

3.3 月份加减与月末截断:add_months 的隐蔽行为

add_months(start_date, n)用于给日期加 n 个月,n 可以是负数,对应月份减 n。

select add_months('2024-01-31', 1); -- 2024-02-29(闰年)或 2024-02-28(平年)

这个例子里藏着一个非常重要的边界行为:当起始日是月末时,Hive 不会帮你保留“31 日”,而是自动截断到目标月的最后一天。

有人会觉得这个行为很“坑”,但实际工作中它特别有用。比如你想求“上个月的最后一天”,可以先用add_months回到上个月,再用后面讲的last_day收尾,整个过程完全不需要自己判断大小月。

还有几个常见用法:

-- 三个月前的今天 select add_months('2024-03-15', -3); -- 2023-12-15 -- 跨年场景 select add_months('2023-12-15', 2); -- 2024-02-15

顺带提醒,add_months返回的是字符串,后续如果要继续做日期计算,建议先确认格式是标准yyyy-MM-dd,或者直接用cast(... as date)转成日期类型再接其他函数。

3.4 months_between:这个函数的计算结果可能带小数

months_between(date1, date2)用来计算 date1 和 date2 之间的月数,date1 晚于 date2 时结果为正。

select months_between('2024-03-15', '2024-01-15'); -- 2.0 select months_between('2024-03-31', '2024-02-29'); -- 1.0

如果两个日期处于不同月份的中间某天,Hive 的计算规则是按一个月 31 天折算小数位,所以months_between('2024-01-15', '2024-01-01')得到的不是一个整数,而是一个带小数的值。

这个行为跟 Oracle 等数据库统计口径不一致,很多从其他数据库迁移过来的同事会被搞晕。我的建议是:不要用months_between去算“精确到几月几天”的业务口径,它只适合判断“是否满足 N 个月”这种阈值场景。比如判断某个用户是否已满足 6 个月:

select case when months_between(current_date, register_date) >= 6 then 1 else 0 end

如果你要的是“每满一个月就算一个月”的业务口径,更稳妥的做法是先用add_months构造边界日期,再通过datediff判断,避免依赖months_between的小数规则。

4. 边界日期推算:月初、月末、季度边界、按周聚类的公式直接拿走

4.1 取月初和年初:trunc 的正确用法

trunc(日期, 'MM')返回当月第一天,trunc(日期, 'YYYY')返回当年第一天。

select trunc('2024-03-15', 'MM'); -- 2024-03-01 select trunc('2024-03-15', 'YYYY'); -- 2024-01-01

这个函数很容易理解,但有两个细节:

第一,trunc只支持到“月”和“年”这种粒度,不支持季度。想要季度第一天,需要自己组合。公式如下:

select add_months(trunc(dt, 'MM'), -(month(dt) - 1) % 3) as quarter_start;

比如dt='2024-05-20',当月第一天是 2024-05-01,month(dt)-1=4,4 % 3 = 1,所以add_months('2024-05-01', -1)得到 2024-04-01 季度初,逻辑自洽。

第二,trunc输出的结果格式取决于输入格式。如果输入的是字符串'2024-03-15',输出也是'2024-03-01'这种字符串;如果输入的是 timestamp 类型,输出也是 timestamp,注意观察类型即可。

4.2 取月末和次月月初:last_day 的正确打开方式

last_day(日期)返回该月的最后一天,自动处理大小月和闰年。

select last_day('2024-02-05'); -- 2024-02-29 select last_day('2024-03-05'); -- 2024-03-31

这个函数在月末跑批时几乎必用。两个常用推导:

-- 上个月最后一天 select last_day(add_months('2024-03-15', -1)); -- 2024-02-29 -- 下个月第一天:月末加一天 select date_add(last_day('2024-02-15'), 1); -- 2024-03-01

注意不要写成last_day('2024-02-15') + 1,字符串加数字不是你想要的语义。

4.3 按“周”日期计算:本周一、下周日、next_day 的边界

按周统计在业务里很常见,但 Hive 里很多人的周口径是混乱的。先说next_day函数:

-- 返回 2024-01-01 之后的下一个周一 select next_day('2024-01-01', 'MO'); -- 2024-01-08

2024-01-01本身就是周一,但next_day找的是“下一个”,不包含当天,所以结果是下周一的2024-01-08。这个边界很多人会记错,如果你希望包含当天,需要单独加一个判断。

计算“本周一”有更实用的办法,利用date_format(日期, 'u')获取星期几,1 代表周一,7 代表周日:

select date_sub(dt, cast(date_format(dt, 'u') as int) - 1) as week_start;

比如dt='2024-03-06',这天是周三,date_format返回 3,date_sub减 2 天得到2024-03-04,正好是本周一。想取本周日,就在这个基础上加 6:

select date_add(date_sub(dt, cast(date_format(dt, 'u') as int) - 1), 6) as week_end;

这套组合公式,比直接依赖weekofyear安全得多。

4.4 跨年周与按周聚合的注意点

weekofyear(dt)可以拿到一年中的第几周,但我在实际使用中发现,它在跨年边界上的表现并不总是符合预期。比如某些版本下,weekofyear('2024-12-30')这种日期可能归到第 1 周,让你的周报数据在年底那一两周出现“神秘缺失”。

我的建议是:如果周报的业务口径是“周一开始到周日结束”,不要直接拿weekofyear做分组,而是先算出week_start,再按week_start分组:

select date_sub(dt, cast(date_format(dt, 'u') as int) - 1) as week_start, count(*) from event_log group by date_sub(dt, cast(date_format(dt, 'u') as int) - 1);

这样即使跨年,每一周也有明确唯一的“周一日期”作为标识,不会产生周序号错乱。

5. 实战案例:从一张事件明细表同时产出日报、周报、月报

5.1 场景与原始数据形态

假设某业务线的明细表叫event_log,核心字段如下:

  • user_id:用户 ID
  • event_time:bigint 类型,毫秒级时间戳
  • event_type:事件类型字符串
  • dt:分区字段,字符串,格式yyyy-MM-dd

现在业务方要求三张报表:每日活跃用户数(日报)、每周活跃用户数(周报)、每月活跃用户数(月报),输出格式分别是yyyyMMdd、yyyyMMdd~yyyyMMdd、yyyyMM。

这个案例最有意思的地方在于:原始数据同时包含了毫秒时间戳、标准字符串分区、紧凑格式输出要求,正好可以把前面几个小节的内容串起来。

5.2 数据清洗与日期字段标准化

首先,在写报表之前,我习惯先把明细层的时间字段做一次标准化,建一个临时视图或者 CTE,让后面的日报、周报、月报都复用同一套日期逻辑:

with cleaned as ( select user_id, event_type, from_unixtime(cast(event_time / 1000 as bigint), 'yyyy-MM-dd') as event_date, date_format( from_unixtime(cast(event_time / 1000 as bigint), 'yyyy-MM-dd HH:mm:ss'), 'HH' ) as event_hour from event_log where dt = '2024-03-01' )

这个 CTE 做了两件事:把毫秒时间戳统一除以 1000 并转成标准日期;把完整时间转出小时字段,方便后面做小时粒度分析。这样后续所有报表都从cleaned出发,格式问题在一个地方解决,而不是散落在各个报表 SQL 里。

5.3 日报:按日分组统计 DAU

日报的 SQL 很简单:

with cleaned as (...) select date_format(event_date, 'yyyyMMdd') as day_id, count(distinct user_id) as dau from cleaned group by date_format(event_date, 'yyyyMMdd');

这里用count(distinct user_id)虽然直观,但数据量大时比较费资源。如果user_id基数很高且明细行数上亿,建议改成两步:先去重再计数,或者直接用approx_count_distinct做近似统计,误差在可接受范围内。

select date_format(event_date, 'yyyyMMdd') as day_id, approx_count_distinct(user_id) as dau from cleaned group by date_format(event_date, 'yyyyMMdd');

5.4 周报:先算本周一,再按周一分组

周报不能直接使用weekofyear,原因前面讲过。所以我先给每条记录算一个week_start,再按week_start分组:

with cleaned as (...), weekly as ( select date_sub(event_date, cast(date_format(event_date, 'u') as int) - 1) as week_start, user_id from cleaned ) select concat( regexp_replace(week_start, '-', ''), '~', regexp_replace(date_add(week_start, 6), '-', '') ) as week_range, count(distinct user_id) as wau from weekly group by week_start;

输出week_range的格式是20240226~20240303这类紧凑区间,业务方在 Excel 对齐时很好用。如果希望输出标准格式,直接select week_start即可。

5.5 月报:用 trunc 或 date_format 做月份分组

月报是最简单的,用date_format(event_date, 'yyyy-MM')或者trunc(event_date, 'MM')都行:

with cleaned as (...) select date_format(event_date, 'yyyy-MM') as month_id, count(distinct user_id) as mau from cleaned group by date_format(event_date, 'yyyy-MM');

注意,如果后续还要和其他月份数据关联,建议统一输出成yyyyMM紧凑格式,减少 join 时格式不一致导致的额外转换工作量。

5.6 连续日期序列:动态生成日历维度表

做报表补数、缺失日期补全时,经常需要一张连续的日期维表。Hive 没有generate_series,但我常用下面这招直接生成一整年的日期:

select date_add('2024-01-01', pos) as day from ( select posexplode(split(space(364), ' ')) as (pos, x) ) t;

原理是:space(364)生成 364 个空格,split按空格切成 365 个空字符串,posexplode把数组展开成多行,pos从 0 到 364。然后用date_add('2024-01-01', pos)得到2024-01-01一直到2024-12-31的完整日期序列。想生成任意天数,把 364 改成N-1即可。

这张临时日历表可以左连接业务数据,把没有产生事件的日期也补齐,在活跃趋势分析里非常实用。

6. 排错心得与性能习惯:日期函数最容易踩的隐性坑

6.1 字符串日期比较的“字典序”陷阱

Hive 里比较两个字符串日期,实际走的是字典序,不是日期序。'2024-02-01'和'2024-02-1'这种长度不一致的字符串来比较,结果很可能和你预想的完全不一样。

问题根源是数据质量差:同一列里混入了'2024-2-1'、'2024/02/01'、'20240201'等多种格式。

我的建议是在清洗层做一次强规则归一化。比如遇到'2024/02/01',先replace(dt, '/', '-');遇到完整日期时间,先to_date;遇到紧凑字符串,先走from_unixtime(unix_timestamp(...))链路。宁可在清洗层多花几分钟,也不要在业务层反复处理脏字符串。

6.2 unix_timestamp 匹配失败返回 NULL 的连锁反应

unix_timestamp('2024-01-01 08:00:00.123', 'yyyy-MM-dd HH:mm:ss')会返回 NULL,因为模式字符串里没有毫秒部分。这是非常隐蔽的错误:不是报错,而是某几行数据解析失败,导致最终统计结果少了一部分。

更麻烦的是,这种问题不会在总量上暴露得特别明显,只有当业务方发现“为什么这个用户的数据丢了”才会查回来。

收藏一个习惯:在清洗层做转换之前,先写一条检查 SQL,统计解析失败的行数:

select count(*) from event_log where unix_timestamp(event_time_str, 'yyyy-MM-dd HH:mm:ss') is null and event_time_str is not null;

如果结果不为 0,说明有脏格式,先处理脏数据再继续,效果会好很多。

6.3 时区与毫秒:两个最容易被忽略的单位

时区问题我前面提过,这里再展开一下。unix_timestamp和from_unixtime的时区基准都是服务器的本地时区。如果明细表里的时间戳来自海外数据源,存的是 UTC 时间,而你们集群时区是东八区,那么直接from_unixtime得到的时间会比业务实际时间早 8 小时。反映到日报上,就是“凌晨时段少了一截,白天多了一截”。

一个实用做法是:在清洗层统一业务时区。比如先判断业务是一个时区,再把时间戳整体换算成本地时间后再进入后续逻辑。换算方式可以用from_unixtime带时区偏移,也可以直接对时间戳做加减。不管选哪种,一定要在数据字典或者任务注释里写清楚,避免别人接手时再踩一遍。

毫秒的问题更加高频。事件表和埋点表很多都是 13 位时间戳,而日志解析出来的又可能是 10 位。我见过有人在同一个 SQL 里先除以 1000,最后一步又忘了,结果日期全变 1970 年。所以建议在数仓分层里统一规范:明细层统一转成标准字符串,或者统一成秒级时间戳,从源头消掉这个坑。

6.4 不要在分区字段上套函数

很多人的取数习惯是写:

select * from event_log where datediff('2024-03-01', dt) <= 7;

这个写法逻辑上没错,但如果dt是分区字段,datediff会把每个分区里的数据都取出来算一遍,分区裁剪完全失效,性能会非常差。

正确做法是把函数挪到常量一侧:

select * from event_log where dt >= date_sub('2024-03-01', 7) and dt <= '2024-03-01';

同理,尽量不用where date_format(dt, 'yyyyMMdd') = '20240301'这种写法,而是直接where dt = '2024-03-01'。在大分区表上,这个习惯能省 90% 以上的扫描成本。

6.5 格式符大小写速查表

最后把容易写错的格式符集中列一下,建议收藏:

格式符含义
yyyy四位年份(大写)
MM月份(大写)
dd日期(小写)
HH24 小时制(大写)
hh12 小时制(小写)
mm分钟(小写)
ss秒(小写)
SSS毫秒(大写)
u星期几,1 表示周一,7 表示周日
E星期简称(比如 Mon),不同版本支持情况不一致

写完之后,不需要背。我自己的习惯是写完日期相关 SQL,先挑几行样例数据,把每一步的中间结果select出来肉眼看一遍,确认跨月、跨年、闰年都没问题再放开跑。这个习惯帮我省下过无数个“查了半天发现日期错了一天”的深夜。

最后再分享一个落地经验:所有日期处理尽量在清洗层统一收敛,底层只保留标准字符串和时间戳,报表层只做加减和分组。这样业务侧维护起来会轻松很多,别人接你的任务时也不会被各种奇怪的日期格式折磨。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询