Hive其实是个老话题了,但每次带新人写Hive SQL,最容易翻车的往往不是那些复杂join和窗口函数,而是最基础的数据类型。你建表时随手写了个string,后续做聚合、做过滤、做日期比较的时候就开始各种诡异结果;你把decimal设成精度不够,跑完一天的数据发现汇总金额差了十几块。这套笔记就是围绕Hive SQL练习和数据类型的实操记录,适合刚接触数仓、准备用Hive处理离线数据的同学,也适合那些已经写了半年SQL但总被类型坑过的工程师。我会按自己实际踩坑的顺序,从类型设计逻辑讲到建表练习,再到常见问题排查,每一处都给到可以直接复用的写法。
1. 先从数据类型这件事说起:它决定了你的SQL能跑得多顺
很多人把数据类型当成建表时的填空题,系统给什么就填什么,填错了也无所谓。但Hive的数据类型和Java、Python里的类型还不太一样,它既要描述存储格式,又要影响计算时Hive如何调度底层引擎。比如你声明的是int,底层可能以二进制紧凑存储,扫描的时候走的是整数解析;你声明成string,虽然也能存数字,但每次比较都要走字符串比较,性能和正确性都会受影响。更麻烦的是,Hive在隐式转换上比较“随意”,它不像强类型数据库那样直接报错,而是默默帮你转换,转换出来的结果可能是你想当然的那样,也可能直接变成NULL,这才是真正的坑。
1.1 基础类型怎么选:数值型、字符串型、时间型的判断标准
先看数值型。Hive里常用的是tinyint、smallint、int、bigint四个整数类型,分别占1、2、4、8字节,范围从-128~127一直到-9223372036854775808~9223372036854775807。我见过很多同事处理订单金额时直接用int,结果订单表里偶尔出现一笔异常大额,数值溢出了,最后统计结果完全对不上。判断依据很简单:如果这个字段未来只做标识、类型编码,用int;如果可能参与较大量级运算,或者数值范围超过21亿,直接上bigint,别省。
浮点型方面,float和double都是近似存储,不建议用在金额、税率、百分比这类需要精确计算的字段上。金额一律用decimal(p,s),p代表总位数,s代表小数位数。比如金额字段,设计成decimal(12,2)表示最大10亿,保留两位小数,日常业务一般够用。有同学问,为什么不是decimal(10,2)?因为实际业务里会有累计值、汇总值,你SUM之后的总位数会变大,如果源头精度定太低,汇总层就会因为精度不足被截断。
字符串类型要区分string、varchar和char。string是变长,没有长度限制,底层存储不做补空格;varchar(n)最多n个字符,超出会被截断;char(n)是定长,不够会在末尾补空格。日常做分析,能用string就不要用char,因为char补空格之后,where a = 'abc'很可能是匹配不上的,你还得记得去掉右侧空格;string没有这个负担。如果你要处理的是明确代码、固定长度的业务编码,可以考虑varchar来限制脏数据,但不能指望数据库帮你严格校验,Hive的截断行为其实挺粗暴的。
时间类型是重灾区。Hive里date只有日期没有时间,timestamp包含日期和时间,还受时区配置影响,另有一个interval类型存时间间隔。建表时最常见的问题是把string当成日期用。不是不能做,而是你要保证所有写入数据都是yyyy-MM-dd HH:mm:ss这个统一格式,否则你在过滤、分组的时候,同一个日期可能因为带不带前导零被分到两个组。我的建议是ODS层可以先用string把原始值原样存下来,到DWD层再严格转成timestamp或date,这样既保留原始,又方便后续计算。
1.2 复合类型:ARRAY、MAP、STRUCT的设计隐喻
Hive还有三类复合类型:array、map、struct,它们本质上是嵌套结构。array是相同类型元素的列表,map是键值对,struct则是一个扁平的字段集合。这些类型在“一行数据里包含一组同质内容”的场景下非常好用,比如用户标签、商品标签、埋点事件序列。
举一个实际例子。埋点表里有一条访问记录,包含用户ID、访问时间、以及一个map<string,string>类型的properties字段,里面存放了环境信息、客户端版本等。我实习时第一次见这种结构,第一反应是这不违反范式吗?确实违反,但数仓有时就是为了减少join,把一组不常作为过滤条件的属性内嵌到一行里,查询效率会高很多。这时候你用map而不是拆成几十个字段,对整个表结构来说更清晰。
struct更像是把多个字段打包成一个“对象”。比如物流地址,可以定义成struct<province:string, city:string, detail:string>,然后通地址.city去访问,比把三个字段平铺更有层次。array则常用在标签列,比如一个用户多个兴趣标签,array<string>直接存,配合explode做炸开。
但复合类型也有代价:它们不太好做过滤,map的key无法直接建分区,array也不适合频繁修改。设计表的时候要想想,这个字段后边是只做展示,还是要参与关联?如果要做过滤,我宁可把它拆成单独的表,也不要塞进复合字段里。等真遇到性能问题的时候,你会发现拆开才是最省事的。
2. SQL练习第一站:建表和装数据的那些必修课
练习Hive SQL,第一步不是急着写select,而是把表建明白。我第一次练习的时候,照着教程建了一张测试表,字段全用string,也没设分区,结果后面的练习全在跟脏数据较劲。后来才慢慢明白,建表其实是在定义数据的“形状”,这个形状决定了后续所有操作的下限。
2.1 建表语句里的类型设计与分区选择
建表语法里最常见的两种写法是create table和create external table,区别主要在“删除表时删不删数据”。内部表删表时元数据和HDFS上的数据一起删,外部表只删元数据。练习环境里我建议用外部表,因为数据文件往往还要反复读取,万一删错还能补救。
类型设计上,有个非常实用的原则:原始字段能保留原样就先保留原样,加工字段再严格指定类型。比如日志里的timestamp字段,上游给的可能是毫秒时间戳,也可能是yyyy/MM/dd这种格式,如果你在ODS层就强行转成date,后续排查原始问题时就得回到源系统,但在ODS层保留string,你随时能用from_unixtime(cast(ts as bigint))去做转换。换句话说,源数据层别做太多类型加工,这不是偷懒,是为了保留排查空间。
分区字段的选择也跟类型有关。分区字段通常用日期,一般建表时写partitioned by (dt string)。这里有个细节:分区字段不能和普通字段重名,而且分区字段的实际值存在元数据里,不跟数据文件一起存储。dt用string还是date?我更推荐string,因为分区目录本质上就是字符串路径,用string可以避免隐式转换,还能保持格式统一。日常跑批只要按dt='2025-01-01'过滤,执行计划能直接定位分区目录,搜索效率比全表扫描高很多。
建表示例:
create external table if not exists dwd_order_daily ( order_id string, user_id string, order_amount decimal(12,2), pay_time timestamp, item_list array<string> ) partitioned by (dt string) row format delimited fields terminated by '\t' stored as orc;这段里几个点:订单金额用decimal(12,2),订单ID用string而不是bigint,因为订单ID一旦有多渠道前缀,用数字存就断了;item_list用了array<string>,因为一张订单可能包含多个商品;文件格式选了orc,列式存储,配合类型查询速度快。行分隔符用\t是因为原始文件是CSV转出来的,避免字段值里有逗号导致错列。
2.2 数据进表的方式:load、insert overite、还是直接指定分区
建完表后装数据有几种路径。常规的load data local inpath '文件路径' into table 表名可以一次性把本地文件装进表。但练习的时候我更推荐先从insert overwrite table 目标表 partition(dt='2025-01-01') select ...开始,因为这种“先查后写”的方式能让你顺带练习SQL查询。
实际跑一次大概是这样的:
insert overwrite table dwd_order_daily partition(dt='2025-01-01') select order_id, user_id, cast(amount as decimal(12,2)) as order_amount, from_unixtime(unix_timestamp(pay_time_str, 'yyyy-MM-dd HH:mm:ss')) as pay_time, split(item_str, ',') as item_list from ods_order_raw where dt = '2025-01-01';这里用了cast(amount as decimal(12,2)),把金额从字符串转成精确小数;用了from_unixtime先把字符串时间解析成时间戳,再转成timestamp类型;split用来把逗号分隔的字符串细节变成array<string>。这些转换都不是平白无故加的,而是对应着我在1.1里说的“加工层严格指定类型”原则。
还有insert into用的是追加方式,一般数仓离线场景很少用,因为幂等性不好。哪怕是练习,也建议写insert overwrite,因为你永远不希望同样的分区被插了两遍,后边统计分析发现数字翻倍,排查起来特别麻烦。
3. 常写常错的SQL操作:类型转换和复杂结构展开
建完表、装了数据,真正写业务SQL的时候,类型这个事儿就会反复出来捣乱。这一部分我按“转换、比较、展开、聚合”四个场景来拆解,每个都是我练习和实际工作中反复用过的。
3.1 cast、case when 和隐式转换之间的三角关系
最基础的转换是cast(字段 as 目标类型)。我强调一下,cast并不是无所不能:字符串转数字时,如果字段值里有非数字字符,转换结果会是NULL而不是报错;浮点转整数时会直接去掉小数部分,不是四舍五入。所以写转换之前,最好先用where 字段 rlike '^[0-9]+(\\.[0-9]+)?$'过滤掉脏值,或者用case when做一个保护。
还有一个比cast更隐蔽的是“隐式转换”。Hive里int和string比较时,会把string隐式转成int再做比较。这个特性听着方便,但坑很大:如果字符串里混入了不可见字符,转换直接返回NULL,整行过滤掉;如果字符串是科学计数法,转换结果还可能溢出。我踩过一次:一个枚举字段,上游正常的'1'、'2'都走整数比较,但某天突然来了个'01',Hive转成1倒是没问题,可转成什么样和你期望的‘01’完全不是一个意思,最后统计结果多了一批数据。
所以我的经验是:凡是参与where、join的字段,两边类型尽量显式保持一致,不要依赖隐式转换。比如join条件里一边是bigint,另一边是string,Hive可能要做全类型转换,效率下降不说,转换规则还会随版本变化,结果不保证稳定。
select user_id, case when order_amount is null then 0 else order_amount end as order_amount from dwd_order_daily where cast(order_id as string) != ''3.2 字符串、数字、日期三兄弟的互相伤害
跑数仓 query 离不开三个核心类型之间的互转。时间字段转出来的东西,经常因为格式问题跟预期不一样。unix_timestamp('2025-01-01 12:00:00')返回秒级时间戳;from_unixtime(1735699200, 'yyyy-MM-dd')返回日期字符串;to_date('2025-01-01 12:00:00')只取日期部分。真正容易乱的是毫秒时间戳,Hive里timestamp内部精确到纳秒,但如果你把毫秒值直接传给from_unixtime,得到的时间会放大1000倍。处理办法是先cast(ts/1000 as bigint)再转。
字符串和数字互转也有几个常用函数。trim去掉两侧空格,regexp_replace可以清掉多余字符。比较字符串大小时要注意它是按字典序比较的,不是按数值比较。所以两个都是字符串的'100'和'99'比较,'100'反而小于'99'。我遇到过同事用max(version)取最大版本号,结果拿到的是'9'而不是'10',就是因为版本号存成了字符串。
日期相关的转换,建议统一用date_format和date_add,不要自己拼字符串。比如计算昨天日期,直接写date_add(to_date('2025-01-05'), -1),比用regexp_replace拼字符串安全得多。
3.3 array 和 map 的展开与聚合
复合类型虽然好用,但查询时往往逃不掉展开。展开的方法是lateral view explode。比如我有订单表,每个订单有一个item_list字段,我想看每个商品单独一行,就可以这样:
select order_id, item from dwd_order_daily lateral view explode(item_list) t as item where dt = '2025-01-01';lateral view的作用是把一行的集合字段“摊开”成多行,摊开后t as item是临时表和列别名。map也可以展开,explode(map_col)会把每个键值对拆成一行,包含key和value两列。
但展开前一定要检查null值,因为explode如果遇到NULL或者空集合,整行会被丢掉。想保留原始行,可以用lateral view outer explode(...)。同样地,聚合回去也有函数:collect_list和collect_set,前者保留重复,后者去重。聚合时注意,如果字段本身就有NULL,collect_list会把NULL也收集进去,后续处理要用case when field is null过滤一下。
4. 常见问题与排查技巧实录
我在练习Hive SQL时,前前后后踩过不少坑,很多问题最后都定位到数据类型上。下面这些问题我不只一次在团队里遇到过,值得整理成一份排查对照表。
4.1 结果里面的NULL不是你想要的NULL
cast失败、隐式比较失败、split失败,都会产生NULL。排查思路很简单:先用一个select把可能出错的字段单独拎出来,看哪些行的值是NULL,再往前倒推是哪一步造成的。比如我写过cast(case when col rlike '[0-9]' then col end as int),明明过滤了,结果还是NULL。后来才发现,rlike '[0-9]'只匹配包含数字,不匹配“全是数字”,所以'abc123'也通过了,最后cast还是失败。
解决办法是每个关键转换都留一个原始字段做校验,比如建表时额外把原始amount_str也保留下来,发现问题后可以对比amount_str和amount,立刻能定位到是解析问题还是转换问题。
4.2 decimal精度不够导致的金额损失
这个我印象最深。我们有一张周汇总表,汇总字段定义成decimal(10,2),结果某天跑完发现汇总金额比明细加起来少了0.01元。原因很简单:上千行数据SUM之后,小数点后第三位发生了进位,但目标精度只有两位小数,Hive在写入时做了截断,第三位直接丢掉,虽然每行只少一点点,累积之后差距就出现了。
解决方法是把汇总层的精度设计得比明细层更“宽”。明细层decimal(10,2),汇总层至少decimal(20,2),这样SUM时不会因为单列精度不够而溢出。另外,double类型做sum也可能存在误差,需要精确金额计算时,所有中间步骤都不要先转成double。
4.3 分区字段类型和过滤条件不一致
有一个很常见的报错场景:建表时分区字段dt用的是string,但跑SQL时有人传了个日期类型变量,例如dt = date_add('2025-01-01', -1)。Hive会试着做类型转换,大多数情况能跑通,但在某些版本里会导致分区裁剪失效,全表扫描,慢得离谱。我的做法是把变量统一转成字符串:where dt = date_format(date_add('2025-01-01', -1), 'yyyy-MM-dd'),这样既保证裁剪,又避免隐式转换。
4.4 排查SQL性能问题时,先看执行计划里的类型提示
有时候我们不确定是不是类型导致的性能问题,最简单的做法是explain select ...,看执行计划里有没有出现类似Map join变成Common join、或者有GenericUDF做隐式转换的提示。我在调一个join时发现执行计划里出现了BUCKET不匹配,进一步确认是join字段一个用int一个用bigint,导致Hive无法走桶连接。改成相同类型之后,耗时直接降了40%。
4.5 快速自查表
| 症状 | 常见原因 | 快速解决办法 |
|---|---|---|
| 结果出现大量NULL | cast失败、split失败、隐式转换失败 | 单独select字段看转换前原始值,增加正则清洗 |
| 汇总金额比明细少 | decimal精度不足 | 汇总层加宽精度,中间不下转double |
| where过滤结果比预期少 | 字符串隐式转换规则不一致 | 关闭字段相等比较的隐式转换,统一显式cast |
| 时间经常差8小时或格式乱 | timestamp时区,或字符串格式不统一 | 统一用from_unixtime/to_date,指定yyyy-MM-dd格式 |
| 表扫描全表很慢 | 分区字段类型与过滤条件不一致 | 过滤条件显式转成string |
| explode后行变少 | null或空array/map | 使用lateral view outer explode |
4.6 排查笔记中的几个小建议
一个是做练习时,尽量多用describe formatted 表名来看表结构,里面会展示字段类型、存储类型、压缩方式。每次拿到一张新表,先执行一下,不要只看注释。第二个是不要害怕写“小结果”的验证SQL:比如select distinct 字段, typeof(字段) from 表 where ... limit 10,很多诡异问题都能靠这种低成本的探查SQL快速定位。第三个是拿不准函数返回值的时候,直接select 函数名(参数)跑一下,Hive把这种SQL当本地模式执行,几秒就出结果,比自己翻文档准确得多。
5. 后续还能怎么扩展
数据类型这些基本功,往后还会在更复杂的场景里用到。比如你在做UDF开发时,Hive的复杂类型会映射成Java的ArrayList、HashMap、Struct对象,如果不了解它们的底层结构,写出来的UDF处理map字段时就容易乱。再比如你用Spark SQL读Hive表,Spark和Hive的类型映射细节不太一样,char会被映射成String,tinyint会被映射成Byte,跨引擎跑同一段SQL都会遇到类型适配问题。这些都可以放到后面慢慢写,但前提是基础部分不能含糊。
我个人在实际练习里的体会是,数据类型不是建表那一下就定死的,它会跟着查询、加工、汇总每一步反复出现。你每避开一个坑,就会对Hive多一层理解。写这套笔记的时候,我自己也把之前踩过的类型问题重新过了一遍,有些当时没弄明白的点,现在回头看其实都非常规律。希望这篇能帮你减少一些试错时间,至少下次遇到“突然多出来的NULL”或者“对不上的一分钱”,你知道该先从哪里下手。