数仓开发这行干久了,谁还没在 Hive 里遇到过内置函数搞不定的需求。比如日志里塞了一整段自定义协议串,比如业务要按身份证号做脱敏,又比如一个字段里存了七八个标签等着按分号拆成多行。Hive 3.1.2 自带几百个内置函数,覆盖了日常大部分场景,但真到了个性化逻辑面前,终归有照顾不到的时候。这时候就得自己动手写 UDF 了。这篇文章就以 Hive 3.1.2 为基准环境,把 UDF 开发的完整流程拆开讲一遍,从 Maven 工程搭建、Java 代码实现,到函数注册上线,再到线上跑数的常见坑,让第一次接触 UDF 的读者也能照着走通。内容按层级递进:先写一个最简单的脱敏函数,再写一个处理复杂类型的 GenericUDF,最后写一个一对多的 UDTF,每一步都有完整代码和操作命令。1. 先搞清楚 Hive 的函数体系和 UDF 的定位1.1 Hive 3.1.2 的函数生态到底缺什么Hive 3.1.2 的内置函数数量其实已经很可观。字符串处理有 concat、substring、regexp_replace,数值计算有 round、floor、abs,日期时间有 datediff、date_add、from_unixtime,集合操作有 size、map_keys、sort_array,还有条件判断 nvl、case when,连 JSON 处理都自带了 get_json_object 和 json_tuple。如果只做常规 ETL 清洗,绝大多数需求都能靠内置函数拼出来。但实际业务里总有绕不过去的坎。就拿我遇到过的场景来说:某个埋点日志里存了一段自定义协议字符串,字段之间用特殊符号分隔,值不是简单的 KV 结构,而是带嵌套关系,内置函数拆起来非常费劲。还有一次要计算两个经纬度点之间的距离,Hive 里根本没有现成函数。再比如数据合规要求对身份证、手机号做脱敏,虽然能用正则写,但一个复杂的 regexp_replace 写出来又长又难维护,还容易出错。这些时候,自己写一个 UDF 往往是更干净的解法。1.2 UDF、UDAF、UDTF 到底指的是什么动手之前先把概念理清楚,Hive 的自定义函数分三种,每种对应不同的数据处理关系。UDF 全称 User Defined Function,接收一行输入,产出一个结果,是一对一关系。把字符串转大写、提取 URL 域名、做数据脱敏,都属于这一类。UDAF 全称 User Defined Aggregation Function,接收多行输入,产出一个结果,是多对一关系,类似内置的 sum、avg、count,只是逻辑由你自己定义。UDTF 全称 User Defined Table-Generating Function,接收一行输入,产出多行结果,是一对多关系,内置的 explode 就是一个典型例子。这篇文章重点讲 UDF 和 UDTF。UDAF 涉及聚合执行框架,内容更多,以后单独开一篇。你先要判断自己的需求属于哪一类,再决定用哪种方式实现。很多初学者上来就想写 UDAF,结果需求用 UDF 加窗口函数就能解决,反而凭空增加复杂度。2. Maven 工程搭建与开发环境准备2.1 JDK、Maven 和 Hive 3.1.2 的版本匹配开发 UDF 首先要把基础环境配好。Hive 3.1.2 基于 Hadoop 3.x 构建,底层对 Java 8 的支持最稳定。Java 11 在某些场景也能跑,但我建议直接用 JDK 8,能避开一堆兼容性问题。Maven 用 3.6 以上即可。在 pom.xml 里,最关键的是引入 hive-exec 依赖,并且 scope 必须设成 provided,原因后面细说。<properties> <project.build.sourceEncoding>UTF-8</project.build.sourceEncoding> <maven.compiler.source>1.8</maven.compiler.source> <maven.compiler.target>1.8</maven.compiler.target> </properties> <dependencies> <dependency> <groupId>org.apache.hive</groupId> <artifactId>hive-exec</artifactId> <version>3.1.2</version> <scope>provided</scope> </dependency> <dependency> <groupId>org.apache.hadoop</groupId> <artifactId>hadoop-common</artifactId> <version>3.1.0</version> <scope>provided</scope> </dependency> </dependencies>2.2 为什么依赖一定要用 provided很多人打 UDF 的 jar 包时习惯把所有依赖都打进去,结果上了生产环境之后各种 NoSuchMethodError 和 ClassCastException。原因很简单:Hive 的 classpath 里已经有自己的 hive-exec 和 hadoop-common,你再塞一份进去,两个不同版本的类同时出现,JVM 加载时就会乱套。所以这里有个原则:凡是 Hive 和 Hadoop 自带的库,都不要打进最终 jar 包。scope 设成 provided,编译时可用,打包时排除。如果你用到了第三方库,比如 fastjson、httpclient,那需要单独考虑打包方式,比如用 maven-shade-plugin 把这些依赖整合进去,或者放到 Hive 的辅助目录里。总之,尽量避免和集群自带类发生冲突。2.3 标准项目结构与本地调试思路一个标准的 Maven 项目结构大致如下:udf-demo/ ├── pom.xml └── src └── main └── java └── com └── example └── udf ├── MaskUDF.java ├── ExtractDomainUDF.java └── udtf └── SplitToRowsUDTF.java开发 UDF 不一定非要连上集群调试。最实用的办法是写单元测试,直接在本地调用 UDF 的 evaluate 方法验证输入输出。因为 hive-exec 依赖包含 Hive 的大部分实现,本地跑测试完全没问题。这样做反馈速度快,不用反复把 jar 包传到集群验证。等本地逻辑点通,再上传集群做集成测试,整个流程高效得多。3. 第一个 UDF:一个身份证脱敏函数3.1 继承 UDF 类的简单写法Hive 提供了一种最直接的 UDF 写法:继承 org.apache.hadoop.hive.ql.exec.UDF 类,然后实现一个名为 evaluate 的方法。Hive 运行时会从你写的类中找到合适的 evaluate 方法去调用。虽然这种写法在复杂场景下有局限,但处理基础字符串和数值类型时,简单又够用。我写一个身份证脱敏函数。需求很简单:输入一个身份证号,把中间的出生日期部分替换成四个星号,保留前三位和后四位。注意身份证号可能是 15 位或 18 位,如果输入是 null,应该返回 null,而不是抛异常。package com.example.udf; import org.apache.hadoop.hive.ql.exec.UDF; import org.apache.hadoop.io.Text; public class MaskUDF extends UDF { public Text evaluate(Text input) { if (input == null) { return null; } String value = input.toString(); if (value.length() < 8) { return input; } String masked = value.substring(0, 3) + "****" + value.substring(value.length() - 4); return new Text(masked); } }这里有个细节:Hive 的字符串类型在 Java 里的标准映射是 Text,也就是 Hadoop 的 Writable 类型。你当然可以直接用 Java String,Hive 会自动转换,但用 Text 能减少一次序列化转换开销。对性能敏感的场景,建议直接用 Writable 类型。3.2 务必用单元测试验证边界情况写完代码,先用 JUnit 在本地跑一把测试,不要急着打 jar 包。测试代码很简单:package com.example.udf; import org.apache.hadoop.io.Text; import org.junit.Test; import static org.junit.Assert.assertEquals; import static org.junit.Assert.assertNull; public class MaskUDFTest { @Test public void testMaskNormal() { MaskUDF udf = new MaskUDF(); Text result = udf.evaluate(new Text("110101199003074512")); assertEquals("110****4512", result.toString()); } @Test public void testMaskNull() { MaskUDF udf = new MaskUDF(); assertNull(udf.evaluate(null)); } @Test public void testShortValue() { MaskUDF udf = new MaskUDF(); Text input = new Text("12345"); assertEquals(input, udf.evaluate(input)); } }这种本地测试方式是 UDF 开发里最容易被忽略、但又很关键的一环。你可以在几秒内把各种边界情况跑完,不需要启动任何集群组件。null 值、空字符串、数据长度不足,这些边界情况是 UDF 最容易翻车的地方,先在本地测到位再说。3.3 打包上传,注册临时函数和永久函数确认逻辑没问题后执行mvn package,target 目录下会生成 jar 包。接下来把它放到集群上。注意注册永久函数时,Hive 要求 jar 包放在 HDFS 上而不是本地路径,所以先用 hdfs 命令传上去:hdfs dfs -mkdir -p /udf hdfs dfs -put udf-demo-1.0.jar /udf/进入 Hive 命令行,先注册临时函数验证:ADD JAR /udf/udf-demo-1.0.jar; CREATE TEMPORARY FUNCTION mask AS 'com.example.udf.MaskUDF'; SELECT mask('110101199003074512');临时函数只在当前会话里有效,退出 Hive 命令行就消失。如果只是临时跑数据用一下,这种方式最方便。如果函数要长期维护、被多个任务使用,那应该注册成永久函数:CREATE FUNCTION default.mask AS 'com.example.udf.MaskUDF' USING JAR 'hdfs:///udf/udf-demo-1.0.jar';注意两点。第一,永久函数的名称通常要带库名,比如 default.mask。第二,创建后如果函数有 bug,需要替换 jar 包,用 CREATE OR REPLACE FUNCTION 更方便:CREATE OR REPLACE FUNCTION default.mask AS 'com.example.udf.MaskUDF' USING JAR 'hdfs:///udf/udf-demo-1.0.jar';验证函数是否生效,用SHOW FUNCTIONS LIKE 'mask'查看。函数不再使用的时候,临时函数用DROP TEMPORARY FUNCTION mask,永久函数用DROP FUNCTION default.mask。我见过有人清临时函数的时候,把永久函数也误删了,这里提醒一句。4. 进阶:用 GenericUDF 处理复杂类型和参数4.1 为什么简单 UDF 不够用继承 UDF 的写法虽然方便,但有两个明显短板。一是它对参数类型的校验能力很弱,如果传入参数不是预期类型,往往要运行时报一堆难懂的异常。二是面对 Map、Array、Struct 这类复杂数据类型时,处理起来很不顺手。GenericUDF 解决了这两个问题。它的核心是三个方法:initialize 负责参数校验和返回类型声明,evaluate 负责真正的逻辑计算,getDisplayString 返回函数的展示信息。initialize 会在任务启动时执行,你可以在这里检查参数个数和类型,提前抛出异常,避免数据算到一半才失败。4.2 实现一个 URL 域名提取函数我以提取 URL 域名为例,展示 GenericUDF 的完整写法。这个函数的业务价值很直接:给定一条访问 URL,返回它的域名部分,比如把https://www.example.com/path/to/page转成www.example.com,方便做来源域名的统计分析。package com.example.udf; import org.apache.hadoop.hive.ql.exec.Description; import org.apache.hadoop.hive.ql.exec.UDFArgumentException; import org.apache.hadoop.hive.ql.exec.UDFArgumentLengthException; import org.apache.hadoop.hive.ql.exec.UDFArgumentTypeException; import org.apache.hadoop.hive.ql.metadata.HiveException; import org.apache.hadoop.hive.ql.udf.generic.GenericUDF; import org.apache.hadoop.hive.serde2.objectinspector.ObjectInspector; import org.apache.hadoop.hive.serde2.objectinspector.primitive.PrimitiveObjectInspectorFactory; import org.apache.hadoop.hive.serde2.objectinspector.primitive.StringObjectInspector; import org.apache.hadoop.io.Text; import java.net.URI; import java.net.URISyntaxException; @Description(name = "extract_domain", value = "_FUNC_(url) - extract domain from url", extended = "Example:\n > SELECT extract_domain('https://www.example.com/path') FROM src LIMIT 1;") public class ExtractDomainUDF extends GenericUDF { private StringObjectInspector stringOI; @Override public ObjectInspector initialize(ObjectInspector[] arguments) throws UDFArgumentException { if (arguments.length != 1) { throw new UDFArgumentLengthException("extract_domain 函数只需要 1 个参数,实际传入了 " + arguments.length); } if (arguments[0].getCategory() != ObjectInspector.Category.PRIMITIVE || !(arguments[0] instanceof StringObjectInspector)) { throw new UDFArgumentTypeException(0, "extract_domain 的参数类型必须是字符串"); } stringOI = (StringObjectInspector) arguments[0]; return PrimitiveObjectInspectorFactory.writableStringObjectInspector; } @Override public Object evaluate(DeferredObject[] arguments) throws HiveException { Object obj = arguments[0].get(); if (obj == null) { return null; } String url = stringOI.getPrimitiveJavaObject(obj); try { URI uri = new URI(url); String host = uri.getHost(); return host == null ? null : new Text(host); } catch (URISyntaxException e) { return null; } } @Override public String getDisplayString(String[] children) { return "extract_domain(" + children[0] + ")"; } }逐个说关键点。initialize 里的 arguments 数组代表函数参数的类型描述。我首先判断 arguments.length 是否为 1,不满足就抛 UDFArgumentLengthException。接着检查参数类型,通过 getCategory() 判断是不是基础类型,再用 instanceof 判断是不是字符串类型。如果不满足,抛 UDFArgumentTypeException。这类异常会直接在 Hive 客户端显示,比运行到一半报错友好得多。evaluate 是核心逻辑。注意 DeferredObject 的 get() 返回的是当前行该字段的实际值,可能为 null。UDF 开发的一大铁律就是处理 null:不应该抛 NPE,而是返回 null。我通过 stringOI.getPrimitiveJavaObject 读取 Java 字符串,再利用 java.net.URI 解析域名。解析失败就返回 null,不会让整个任务失败。getDisplayString 主要用于 explain 命令和错误信息展示,返回一个描述性字符串即可。这个例子虽然逻辑简单,但完整覆盖了 GenericUDF 的编写框架,后续往里面填充任何复杂逻辑都行。4.3 复杂数据类型 Map、Array、Struct 的读取方式上面只涉及了 String 类型。当你的 UDF 要处理 Map 或 Array 时,就需要用到对应的 MapObjectInspector 和 ListObjectInspector。举个例子,要写一个函数从 Map 参数里取出指定 key 对应的值并转成字符串。initialize 里可以这样拿 key 和 value 的检查器:MapObjectInspector mapOI = (MapObjectInspector) arguments[0]; ObjectInspector keyOI = mapOI.getMapKeyObjectInspector(); ObjectInspector valueOI = mapOI.getMapValueObjectInspector();evaluate 里从 DeferredObject 取出实际对象后,调用mapOI.getMap(value)拿到 Java 的 Map 结构,再按 key 去取。Array 同理,把 MapObjectInspector 换成 ListObjectInspector,getList 拿到的就是 java.util.List。实际开发中,处理复杂类型最容易犯的错是想当然把 Object 强转成 HashMap 或 ArrayList,然后 ClassCastException 直接爆出来。正确的做法永远是让 ObjectInspector 来解析对象,而不是靠 Java 强转。ObjectInspector 是 Hive 序列化层和你的计算逻辑之间的桥梁,绕开它等于把路走死了。5. 一对多的 UDTF:把字符串拆成多行5.1 场景:一个字段里存了多个标签日志数据里经常出现一个字段存多个值的情况。比如用户行为标签字段,写成click:1;buy:1;share:0,分号分隔,每个标签用冒号连接。要分析标签分布,就得先把每行的标签拆开,变成每个标签一行,再 group by。内置的 explode 可以处理 Array 或 Map,但它要求你先把字符串转成数组,类似split(tag_str, ';')。问题在于分隔符一旦比较复杂,或者需要同时输出多个字段,用 explode 就很难受。这时候 UDTF 就能派上用场,把拆分逻辑封装起来,SQL 层保持整洁。5.2 UDTF 实现:initialize、process、closeGenericUDTF 同样有三个核心方法。initialize 定义输出表的列名和列类型,process 处理每一行输入,close 做收尾。注意不同小版本对 initialize 方法签名可能有差异,以你使用的 IDE 提示为准。package com.example.udtf; import org.apache.hadoop.hive.ql.exec.UDFArgumentException; import org.apache.hadoop.hive.ql.metadata.HiveException; import org.apache.hadoop.hive.ql.udf.generic.GenericUDTF; import org.apache.hadoop.hive.serde2.objectinspector.ObjectInspector; import org.apache.hadoop.hive.serde2.objectinspector.ObjectInspectorFactory; import org.apache.hadoop.hive.serde2.objectinspector.StructObjectInspector; import org.apache.hadoop.hive.serde2.objectinspector.primitive.PrimitiveObjectInspectorFactory; import java.util.ArrayList; import java.util.List; import java.util.regex.Pattern; public class SplitToRowsUDTF extends GenericUDTF { @Override public StructObjectInspector initialize(ObjectInspector[] args) throws UDFArgumentException { if (args == null || args.length != 2) { throw new UDFArgumentException("split_to_rows(string, separator) 需要两个参数"); } List<String> fieldNames = new ArrayList<>(); List<ObjectInspector> fieldOIs = new ArrayList<>(); fieldNames.add("item"); fieldOIs.add(PrimitiveObjectInspectorFactory.javaStringObjectInspector); return ObjectInspectorFactory.getStandardStructObjectInspector(fieldNames, fieldOIs); } @Override public void process(Object[] args) throws HiveException { if (args[0] == null || args[1] == null) { return; } String input = args[0].toString(); String separator = args[1].toString(); if (separator.isEmpty()) { return; } String[] parts = input.split(Pattern.quote(separator)); for (String part : parts) { forward(new String[] { part }); } } @Override public void close() throws HiveException { } }这里有一个非常隐蔽的坑:input.split(separator)里的 separator 会被当作正则表达式处理。如果分隔符是点号.或者竖线|,结果会完全出乎意料。比如"a.b.c".split(".")得到的数组长度是 0,因为点在正则里匹配任意字符。我用Pattern.quote(separator)把分隔符转义成字面量,从根上避开这个问题。这个坑我在生产环境踩过不止一次,写在这里希望你不用再踩。另外,initialize 返回的字段名必须和查询里用到的别名一致。上面的例子输出列名是 item,使用时就应该写SELECT item FROM xx LATERAL VIEW split_to_rows(...) t AS item。5.3 配合 LATERAL VIEW 的使用方式UDTF 在 SQL 中的使用方式比较特殊,必须配合 LATERAL VIEW 关键字。看一个简单例子:SELECT user_id, item FROM user_tag LATERAL VIEW split_to_rows(tag_str, ';') t AS item;这条 SQL 会把 user_tag 表里每行的 tag_str 字段按分号拆成多个 item,再和 user_id 组合成宽表。如果 tag_str 是a;b;c,最终输出三行。LATERAL VIEW 还有一个常用变形是 OUTER:SELECT user_id, item FROM user_tag LATERAL VIEW OUTER split_to_rows(tag_str, ';') t AS item;区别在于,当 tag_str 为 null 或者拆分结果为空时,OUTER 会保留原行,item 置为 null;不带 OUTER 的普通 LATERAL VIEW 会把该行整行丢掉。这个差异在数据完整性要求高的场景里非常重要。我一般默认加 OUTER,除非业务明确要求丢弃空值行。6. 性能优化与线上排障实录6.1 高效 UDF 必须养成的编码习惯UDF 的性能直接决定整个任务跑批效率,因为每条数据都会经过这个函数。几条习惯值得刻意养成。第一,尽量避免在 evaluate 方法里创建昂贵对象。比如 Pattern.compile 非常耗时,如果每次调用都重新编译同样的正则,性能会差很多。应该把 Pattern 声明成成员变量,只在类初始化时编译一次。同理,Gson 这类 JSON 解析器也尽量声明成成员变量,它们的构造往往很重。第二,能返回 null 就早点返回。很多函数只在特定输入下才需要做复杂计算,比如脱敏函数遇到长度不足的字符串直接原样返回,没必要走 substring 逻辑。这种 early return 既清晰又高效。第三,理解 Hive 对函数实例的复用方式。同一个 UDF 实例会被多个 Task 复用,但不会同时被多个线程并发调用。你可以在成员变量里复用 Text 之类的对象,减少 GC 压力。但要注意,复用的对象在 evaluate 返回之后可能被 Hive 继续引用,如果下次调用把它值改了,可能影响结果。最稳妥的做法还是返回新对象,或者明确知道 Hive 不会缓存旧值。第四,慎用反射。GenericUDF 已经帮你绕开了很多反射开销,但如果自己代码里写反射调用,每一行数据的反射调用累积起来,数量非常惊人。6.2 常见报错与排查方法我梳理几个 UDF 上线和使用过程中最高频的错误,以及对应的排查思路。第一个是 ClassNotFoundException。注册函数时类名不对,或者 jar 包没正确上传,都会报这个错。先确认类名含包名写没写错,再确认 jar 包路径能不能被 Hive 访问到。临时函数用ADD JAR后,可以用LIST JAR查看已加载的 jar。第二个是 NoSuchMethodError。这八成是依赖冲突。你的 jar 包里打入了某个库的旧版本,和集群版本不一致。解决办法就是把 Hive 和 Hadoop 的依赖全部设成 provided,不要打进包里。第三个是函数不生效。如果你在 A 会话注册了临时函数,再到 B 会话里用,发现函数不存在,不用怀疑,这是会话隔离的正常现象。想跨会话使用,必须创建永久函数。另外有些环境创建永久函数后立刻跑 SQL 报找不到函数,可能是元数据缓存,等一会儿或者重连就好。第四个是参数类型不匹配。很多时候不是传错了类型,而是把 Hive 里的 NULL 值直接传给了参数,导致类型检查失败。对应办法是在 initialize 阶段做容错,或者在 SQL 层用 nvl 包一层。第五个是诡异 ClassCastException。如果报错信息指向 Hive 内部类,比如 StringObjectInspector,基本可以断定是打包时打入了多余的 Hive 依赖。把 pom.xml 里的相关依赖 scope 改成 provided 重新打包即可。6.3 日常操作命令速查最后给一张常用命令速查表,方便直接抄作业。操作命令加载 jarADD JAR hdfs:///udf/demo.jar;创建临时函数CREATE TEMPORARY FUNCTION fn AS 'com.example.udf.MaskUDF';创建永久函数CREATE FUNCTION default.fn AS '...' USING JAR 'hdfs:///udf/demo.jar';替换永久函数CREATE OR REPLACE FUNCTION default.fn AS '...' USING JAR 'hdfs:///udf/demo.jar';查看函数SHOW FUNCTIONS LIKE 'fn';删除临时函数DROP TEMPORARY FUNCTION fn;删除永久函数DROP FUNCTION default.fn;再补一个管理建议:函数名建议带业务前缀,比如 user_mask、flow_extract_domain,不要直接叫 mask 这种通用名字。公共 Hive 环境里,同名函数很容易被别人的 jar 包覆盖,出了事故排查也麻烦。上线顺序上,我习惯先在测试环境用临时函数验证逻辑,确认没问题后,再到正式环境的库下创建永久函数。发布前跑一遍单元测试,重点覆盖 null 值和边界情况,能省下非常多半夜报警的时间。UDF 开发这事,说到底是 Java 基础加 Hive 数据模型的结合。很多人一开始觉得写 UDF 很神秘,真正动手之后会发现,大部分工作都花在类型处理、空值判断和打包部署这些细枝末节上。我在生产环境里见过不少 UDF 因此翻车:有的没处理 null 导致任务半夜报警,有的分隔符忘了转义导致拆出来的数据缺胳膊少腿。所以上面这些细节,多看几遍不亏。如果想把 UDF 玩得更深,可以往两个方向走:一个是 UDAF,自定义聚合逻辑,比如实现分组内去重计数;另一个是 Hive 的宏(MACRO),如果逻辑不那么复杂,直接用宏在 SQL 里定义,连 Java 都不用写。我个人建议先从简单 UDF 入手,把本文代码完整跑通一遍,建立信心,再逐步深入。
数仓开发这行干久了,谁还没在 Hive 里遇到过内置函数搞不定的需求。比如日志里塞了一整段自定义协议串,比如业务要按身份证号做脱敏,又比如一个字段里存了七八个标签等着按分号拆成多行。Hive 3.1.2 自带几百个内置函数,覆盖了日常大部分场景,但真到了个性化逻辑面前,终归有照顾不到的时候。这时候就得自己动手写 UDF 了。这篇文章就以 Hive 3.1.2 为基准环境,把 UDF 开发的完整流程拆开讲一遍,从 Maven 工程搭建、Java 代码实现,到函数注册上线,再到线上跑数的常见坑,让第一次接触 UDF 的读者也能照着走通。内容按层级递进:先写一个最简单的脱敏函数,再写一个处理复杂类型的 GenericUDF,最后写一个一对多的 UDTF,每一步都有完整代码和操作命令。1. 先搞清楚 Hive 的函数体系和 UDF 的定位1.1 Hive 3.1.2 的函数生态到底缺什么Hive 3.1.2 的内置函数数量其实已经很可观。字符串处理有 concat、substring、regexp_replace,数值计算有 round、floor、abs,日期时间有 datediff、date_add、from_unixtime,集合操作有 size、map_keys、sort_array,还有条件判断 nvl、case when,连 JSON 处理都自带了 get_json_object 和 json_tuple。如果只做常规 ETL 清洗,绝大多数需求都能靠内置函数拼出来。但实际业务里总有绕不过去的坎。就拿我遇到过的场景来说:某个埋点日志里存了一段自定义协议字符串,字段之间用特殊符号分隔,值不是简单的 KV 结构,而是带嵌套关系,内置函数拆起来非常费劲。还有一次要计算两个经纬度点之间的距离,Hive 里根本没有现成函数。再比如数据合规要求对身份证、手机号做脱敏,虽然能用正则写,但一个复杂的 regexp_replace 写出来又长又难维护,还容易出错。这些时候,自己写一个 UDF 往往是更干净的解法。1.2 UDF、UDAF、UDTF 到底指的是什么动手之前先把概念理清楚,Hive 的自定义函数分三种,每种对应不同的数据处理关系。UDF 全称 User Defined Function,接收一行输入,产出一个结果,是一对一关系。把字符串转大写、提取 URL 域名、做数据脱敏,都属于这一类。UDAF 全称 User Defined Aggregation Function,接收多行输入,产出一个结果,是多对一关系,类似内置的 sum、avg、count,只是逻辑由你自己定义。UDTF 全称 User Defined Table-Generating Function,接收一行输入,产出多行结果,是一对多关系,内置的 explode 就是一个典型例子。这篇文章重点讲 UDF 和 UDTF。UDAF 涉及聚合执行框架,内容更多,以后单独开一篇。你先要判断自己的需求属于哪一类,再决定用哪种方式实现。很多初学者上来就想写 UDAF,结果需求用 UDF 加窗口函数就能解决,反而凭空增加复杂度。2. Maven 工程搭建与开发环境准备2.1 JDK、Maven 和 Hive 3.1.2 的版本匹配开发 UDF 首先要把基础环境配好。Hive 3.1.2 基于 Hadoop 3.x 构建,底层对 Java 8 的支持最稳定。Java 11 在某些场景也能跑,但我建议直接用 JDK 8,能避开一堆兼容性问题。Maven 用 3.6 以上即可。在 pom.xml 里,最关键的是引入 hive-exec 依赖,并且 scope 必须设成 provided,原因后面细说。<properties> <project.build.sourceEncoding>UTF-8</project.build.sourceEncoding> <maven.compiler.source>1.8</maven.compiler.source> <maven.compiler.target>1.8</maven.compiler.target> </properties> <dependencies> <dependency> <groupId>org.apache.hive</groupId> <artifactId>hive-exec</artifactId> <version>3.1.2</version> <scope>provided</scope> </dependency> <dependency> <groupId>org.apache.hadoop</groupId> <artifactId>hadoop-common</artifactId> <version>3.1.0</version> <scope>provided</scope> </dependency> </dependencies>2.2 为什么依赖一定要用 provided很多人打 UDF 的 jar 包时习惯把所有依赖都打进去,结果上了生产环境之后各种 NoSuchMethodError 和 ClassCastException。原因很简单:Hive 的 classpath 里已经有自己的 hive-exec 和 hadoop-common,你再塞一份进去,两个不同版本的类同时出现,JVM 加载时就会乱套。所以这里有个原则:凡是 Hive 和 Hadoop 自带的库,都不要打进最终 jar 包。scope 设成 provided,编译时可用,打包时排除。如果你用到了第三方库,比如 fastjson、httpclient,那需要单独考虑打包方式,比如用 maven-shade-plugin 把这些依赖整合进去,或者放到 Hive 的辅助目录里。总之,尽量避免和集群自带类发生冲突。2.3 标准项目结构与本地调试思路一个标准的 Maven 项目结构大致如下:udf-demo/ ├── pom.xml └── src └── main └── java └── com └── example └── udf ├── MaskUDF.java ├── ExtractDomainUDF.java └── udtf └── SplitToRowsUDTF.java开发 UDF 不一定非要连上集群调试。最实用的办法是写单元测试,直接在本地调用 UDF 的 evaluate 方法验证输入输出。因为 hive-exec 依赖包含 Hive 的大部分实现,本地跑测试完全没问题。这样做反馈速度快,不用反复把 jar 包传到集群验证。等本地逻辑点通,再上传集群做集成测试,整个流程高效得多。3. 第一个 UDF:一个身份证脱敏函数3.1 继承 UDF 类的简单写法Hive 提供了一种最直接的 UDF 写法:继承 org.apache.hadoop.hive.ql.exec.UDF 类,然后实现一个名为 evaluate 的方法。Hive 运行时会从你写的类中找到合适的 evaluate 方法去调用。虽然这种写法在复杂场景下有局限,但处理基础字符串和数值类型时,简单又够用。我写一个身份证脱敏函数。需求很简单:输入一个身份证号,把中间的出生日期部分替换成四个星号,保留前三位和后四位。注意身份证号可能是 15 位或 18 位,如果输入是 null,应该返回 null,而不是抛异常。package com.example.udf; import org.apache.hadoop.hive.ql.exec.UDF; import org.apache.hadoop.io.Text; public class MaskUDF extends UDF { public Text evaluate(Text input) { if (input == null) { return null; } String value = input.toString(); if (value.length() < 8) { return input; } String masked = value.substring(0, 3) + "****" + value.substring(value.length() - 4); return new Text(masked); } }这里有个细节:Hive 的字符串类型在 Java 里的标准映射是 Text,也就是 Hadoop 的 Writable 类型。你当然可以直接用 Java String,Hive 会自动转换,但用 Text 能减少一次序列化转换开销。对性能敏感的场景,建议直接用 Writable 类型。3.2 务必用单元测试验证边界情况写完代码,先用 JUnit 在本地跑一把测试,不要急着打 jar 包。测试代码很简单:package com.example.udf; import org.apache.hadoop.io.Text; import org.junit.Test; import static org.junit.Assert.assertEquals; import static org.junit.Assert.assertNull; public class MaskUDFTest { @Test public void testMaskNormal() { MaskUDF udf = new MaskUDF(); Text result = udf.evaluate(new Text("110101199003074512")); assertEquals("110****4512", result.toString()); } @Test public void testMaskNull() { MaskUDF udf = new MaskUDF(); assertNull(udf.evaluate(null)); } @Test public void testShortValue() { MaskUDF udf = new MaskUDF(); Text input = new Text("12345"); assertEquals(input, udf.evaluate(input)); } }这种本地测试方式是 UDF 开发里最容易被忽略、但又很关键的一环。你可以在几秒内把各种边界情况跑完,不需要启动任何集群组件。null 值、空字符串、数据长度不足,这些边界情况是 UDF 最容易翻车的地方,先在本地测到位再说。3.3 打包上传,注册临时函数和永久函数确认逻辑没问题后执行mvn package,target 目录下会生成 jar 包。接下来把它放到集群上。注意注册永久函数时,Hive 要求 jar 包放在 HDFS 上而不是本地路径,所以先用 hdfs 命令传上去:hdfs dfs -mkdir -p /udf hdfs dfs -put udf-demo-1.0.jar /udf/进入 Hive 命令行,先注册临时函数验证:ADD JAR /udf/udf-demo-1.0.jar; CREATE TEMPORARY FUNCTION mask AS 'com.example.udf.MaskUDF'; SELECT mask('110101199003074512');临时函数只在当前会话里有效,退出 Hive 命令行就消失。如果只是临时跑数据用一下,这种方式最方便。如果函数要长期维护、被多个任务使用,那应该注册成永久函数:CREATE FUNCTION default.mask AS 'com.example.udf.MaskUDF' USING JAR 'hdfs:///udf/udf-demo-1.0.jar';注意两点。第一,永久函数的名称通常要带库名,比如 default.mask。第二,创建后如果函数有 bug,需要替换 jar 包,用 CREATE OR REPLACE FUNCTION 更方便:CREATE OR REPLACE FUNCTION default.mask AS 'com.example.udf.MaskUDF' USING JAR 'hdfs:///udf/udf-demo-1.0.jar';验证函数是否生效,用SHOW FUNCTIONS LIKE 'mask'查看。函数不再使用的时候,临时函数用DROP TEMPORARY FUNCTION mask,永久函数用DROP FUNCTION default.mask。我见过有人清临时函数的时候,把永久函数也误删了,这里提醒一句。4. 进阶:用 GenericUDF 处理复杂类型和参数4.1 为什么简单 UDF 不够用继承 UDF 的写法虽然方便,但有两个明显短板。一是它对参数类型的校验能力很弱,如果传入参数不是预期类型,往往要运行时报一堆难懂的异常。二是面对 Map、Array、Struct 这类复杂数据类型时,处理起来很不顺手。GenericUDF 解决了这两个问题。它的核心是三个方法:initialize 负责参数校验和返回类型声明,evaluate 负责真正的逻辑计算,getDisplayString 返回函数的展示信息。initialize 会在任务启动时执行,你可以在这里检查参数个数和类型,提前抛出异常,避免数据算到一半才失败。4.2 实现一个 URL 域名提取函数我以提取 URL 域名为例,展示 GenericUDF 的完整写法。这个函数的业务价值很直接:给定一条访问 URL,返回它的域名部分,比如把https://www.example.com/path/to/page转成www.example.com,方便做来源域名的统计分析。package com.example.udf; import org.apache.hadoop.hive.ql.exec.Description; import org.apache.hadoop.hive.ql.exec.UDFArgumentException; import org.apache.hadoop.hive.ql.exec.UDFArgumentLengthException; import org.apache.hadoop.hive.ql.exec.UDFArgumentTypeException; import org.apache.hadoop.hive.ql.metadata.HiveException; import org.apache.hadoop.hive.ql.udf.generic.GenericUDF; import org.apache.hadoop.hive.serde2.objectinspector.ObjectInspector; import org.apache.hadoop.hive.serde2.objectinspector.primitive.PrimitiveObjectInspectorFactory; import org.apache.hadoop.hive.serde2.objectinspector.primitive.StringObjectInspector; import org.apache.hadoop.io.Text; import java.net.URI; import java.net.URISyntaxException; @Description(name = "extract_domain", value = "_FUNC_(url) - extract domain from url", extended = "Example:\n > SELECT extract_domain('https://www.example.com/path') FROM src LIMIT 1;") public class ExtractDomainUDF extends GenericUDF { private StringObjectInspector stringOI; @Override public ObjectInspector initialize(ObjectInspector[] arguments) throws UDFArgumentException { if (arguments.length != 1) { throw new UDFArgumentLengthException("extract_domain 函数只需要 1 个参数,实际传入了 " + arguments.length); } if (arguments[0].getCategory() != ObjectInspector.Category.PRIMITIVE || !(arguments[0] instanceof StringObjectInspector)) { throw new UDFArgumentTypeException(0, "extract_domain 的参数类型必须是字符串"); } stringOI = (StringObjectInspector) arguments[0]; return PrimitiveObjectInspectorFactory.writableStringObjectInspector; } @Override public Object evaluate(DeferredObject[] arguments) throws HiveException { Object obj = arguments[0].get(); if (obj == null) { return null; } String url = stringOI.getPrimitiveJavaObject(obj); try { URI uri = new URI(url); String host = uri.getHost(); return host == null ? null : new Text(host); } catch (URISyntaxException e) { return null; } } @Override public String getDisplayString(String[] children) { return "extract_domain(" + children[0] + ")"; } }逐个说关键点。initialize 里的 arguments 数组代表函数参数的类型描述。我首先判断 arguments.length 是否为 1,不满足就抛 UDFArgumentLengthException。接着检查参数类型,通过 getCategory() 判断是不是基础类型,再用 instanceof 判断是不是字符串类型。如果不满足,抛 UDFArgumentTypeException。这类异常会直接在 Hive 客户端显示,比运行到一半报错友好得多。evaluate 是核心逻辑。注意 DeferredObject 的 get() 返回的是当前行该字段的实际值,可能为 null。UDF 开发的一大铁律就是处理 null:不应该抛 NPE,而是返回 null。我通过 stringOI.getPrimitiveJavaObject 读取 Java 字符串,再利用 java.net.URI 解析域名。解析失败就返回 null,不会让整个任务失败。getDisplayString 主要用于 explain 命令和错误信息展示,返回一个描述性字符串即可。这个例子虽然逻辑简单,但完整覆盖了 GenericUDF 的编写框架,后续往里面填充任何复杂逻辑都行。4.3 复杂数据类型 Map、Array、Struct 的读取方式上面只涉及了 String 类型。当你的 UDF 要处理 Map 或 Array 时,就需要用到对应的 MapObjectInspector 和 ListObjectInspector。举个例子,要写一个函数从 Map 参数里取出指定 key 对应的值并转成字符串。initialize 里可以这样拿 key 和 value 的检查器:MapObjectInspector mapOI = (MapObjectInspector) arguments[0]; ObjectInspector keyOI = mapOI.getMapKeyObjectInspector(); ObjectInspector valueOI = mapOI.getMapValueObjectInspector();evaluate 里从 DeferredObject 取出实际对象后,调用mapOI.getMap(value)拿到 Java 的 Map 结构,再按 key 去取。Array 同理,把 MapObjectInspector 换成 ListObjectInspector,getList 拿到的就是 java.util.List。实际开发中,处理复杂类型最容易犯的错是想当然把 Object 强转成 HashMap 或 ArrayList,然后 ClassCastException 直接爆出来。正确的做法永远是让 ObjectInspector 来解析对象,而不是靠 Java 强转。ObjectInspector 是 Hive 序列化层和你的计算逻辑之间的桥梁,绕开它等于把路走死了。5. 一对多的 UDTF:把字符串拆成多行5.1 场景:一个字段里存了多个标签日志数据里经常出现一个字段存多个值的情况。比如用户行为标签字段,写成click:1;buy:1;share:0,分号分隔,每个标签用冒号连接。要分析标签分布,就得先把每行的标签拆开,变成每个标签一行,再 group by。内置的 explode 可以处理 Array 或 Map,但它要求你先把字符串转成数组,类似split(tag_str, ';')。问题在于分隔符一旦比较复杂,或者需要同时输出多个字段,用 explode 就很难受。这时候 UDTF 就能派上用场,把拆分逻辑封装起来,SQL 层保持整洁。5.2 UDTF 实现:initialize、process、closeGenericUDTF 同样有三个核心方法。initialize 定义输出表的列名和列类型,process 处理每一行输入,close 做收尾。注意不同小版本对 initialize 方法签名可能有差异,以你使用的 IDE 提示为准。package com.example.udtf; import org.apache.hadoop.hive.ql.exec.UDFArgumentException; import org.apache.hadoop.hive.ql.metadata.HiveException; import org.apache.hadoop.hive.ql.udf.generic.GenericUDTF; import org.apache.hadoop.hive.serde2.objectinspector.ObjectInspector; import org.apache.hadoop.hive.serde2.objectinspector.ObjectInspectorFactory; import org.apache.hadoop.hive.serde2.objectinspector.StructObjectInspector; import org.apache.hadoop.hive.serde2.objectinspector.primitive.PrimitiveObjectInspectorFactory; import java.util.ArrayList; import java.util.List; import java.util.regex.Pattern; public class SplitToRowsUDTF extends GenericUDTF { @Override public StructObjectInspector initialize(ObjectInspector[] args) throws UDFArgumentException { if (args == null || args.length != 2) { throw new UDFArgumentException("split_to_rows(string, separator) 需要两个参数"); } List<String> fieldNames = new ArrayList<>(); List<ObjectInspector> fieldOIs = new ArrayList<>(); fieldNames.add("item"); fieldOIs.add(PrimitiveObjectInspectorFactory.javaStringObjectInspector); return ObjectInspectorFactory.getStandardStructObjectInspector(fieldNames, fieldOIs); } @Override public void process(Object[] args) throws HiveException { if (args[0] == null || args[1] == null) { return; } String input = args[0].toString(); String separator = args[1].toString(); if (separator.isEmpty()) { return; } String[] parts = input.split(Pattern.quote(separator)); for (String part : parts) { forward(new String[] { part }); } } @Override public void close() throws HiveException { } }这里有一个非常隐蔽的坑:input.split(separator)里的 separator 会被当作正则表达式处理。如果分隔符是点号.或者竖线|,结果会完全出乎意料。比如"a.b.c".split(".")得到的数组长度是 0,因为点在正则里匹配任意字符。我用Pattern.quote(separator)把分隔符转义成字面量,从根上避开这个问题。这个坑我在生产环境踩过不止一次,写在这里希望你不用再踩。另外,initialize 返回的字段名必须和查询里用到的别名一致。上面的例子输出列名是 item,使用时就应该写SELECT item FROM xx LATERAL VIEW split_to_rows(...) t AS item。5.3 配合 LATERAL VIEW 的使用方式UDTF 在 SQL 中的使用方式比较特殊,必须配合 LATERAL VIEW 关键字。看一个简单例子:SELECT user_id, item FROM user_tag LATERAL VIEW split_to_rows(tag_str, ';') t AS item;这条 SQL 会把 user_tag 表里每行的 tag_str 字段按分号拆成多个 item,再和 user_id 组合成宽表。如果 tag_str 是a;b;c,最终输出三行。LATERAL VIEW 还有一个常用变形是 OUTER:SELECT user_id, item FROM user_tag LATERAL VIEW OUTER split_to_rows(tag_str, ';') t AS item;区别在于,当 tag_str 为 null 或者拆分结果为空时,OUTER 会保留原行,item 置为 null;不带 OUTER 的普通 LATERAL VIEW 会把该行整行丢掉。这个差异在数据完整性要求高的场景里非常重要。我一般默认加 OUTER,除非业务明确要求丢弃空值行。6. 性能优化与线上排障实录6.1 高效 UDF 必须养成的编码习惯UDF 的性能直接决定整个任务跑批效率,因为每条数据都会经过这个函数。几条习惯值得刻意养成。第一,尽量避免在 evaluate 方法里创建昂贵对象。比如 Pattern.compile 非常耗时,如果每次调用都重新编译同样的正则,性能会差很多。应该把 Pattern 声明成成员变量,只在类初始化时编译一次。同理,Gson 这类 JSON 解析器也尽量声明成成员变量,它们的构造往往很重。第二,能返回 null 就早点返回。很多函数只在特定输入下才需要做复杂计算,比如脱敏函数遇到长度不足的字符串直接原样返回,没必要走 substring 逻辑。这种 early return 既清晰又高效。第三,理解 Hive 对函数实例的复用方式。同一个 UDF 实例会被多个 Task 复用,但不会同时被多个线程并发调用。你可以在成员变量里复用 Text 之类的对象,减少 GC 压力。但要注意,复用的对象在 evaluate 返回之后可能被 Hive 继续引用,如果下次调用把它值改了,可能影响结果。最稳妥的做法还是返回新对象,或者明确知道 Hive 不会缓存旧值。第四,慎用反射。GenericUDF 已经帮你绕开了很多反射开销,但如果自己代码里写反射调用,每一行数据的反射调用累积起来,数量非常惊人。6.2 常见报错与排查方法我梳理几个 UDF 上线和使用过程中最高频的错误,以及对应的排查思路。第一个是 ClassNotFoundException。注册函数时类名不对,或者 jar 包没正确上传,都会报这个错。先确认类名含包名写没写错,再确认 jar 包路径能不能被 Hive 访问到。临时函数用ADD JAR后,可以用LIST JAR查看已加载的 jar。第二个是 NoSuchMethodError。这八成是依赖冲突。你的 jar 包里打入了某个库的旧版本,和集群版本不一致。解决办法就是把 Hive 和 Hadoop 的依赖全部设成 provided,不要打进包里。第三个是函数不生效。如果你在 A 会话注册了临时函数,再到 B 会话里用,发现函数不存在,不用怀疑,这是会话隔离的正常现象。想跨会话使用,必须创建永久函数。另外有些环境创建永久函数后立刻跑 SQL 报找不到函数,可能是元数据缓存,等一会儿或者重连就好。第四个是参数类型不匹配。很多时候不是传错了类型,而是把 Hive 里的 NULL 值直接传给了参数,导致类型检查失败。对应办法是在 initialize 阶段做容错,或者在 SQL 层用 nvl 包一层。第五个是诡异 ClassCastException。如果报错信息指向 Hive 内部类,比如 StringObjectInspector,基本可以断定是打包时打入了多余的 Hive 依赖。把 pom.xml 里的相关依赖 scope 改成 provided 重新打包即可。6.3 日常操作命令速查最后给一张常用命令速查表,方便直接抄作业。操作命令加载 jarADD JAR hdfs:///udf/demo.jar;创建临时函数CREATE TEMPORARY FUNCTION fn AS 'com.example.udf.MaskUDF';创建永久函数CREATE FUNCTION default.fn AS '...' USING JAR 'hdfs:///udf/demo.jar';替换永久函数CREATE OR REPLACE FUNCTION default.fn AS '...' USING JAR 'hdfs:///udf/demo.jar';查看函数SHOW FUNCTIONS LIKE 'fn';删除临时函数DROP TEMPORARY FUNCTION fn;删除永久函数DROP FUNCTION default.fn;再补一个管理建议:函数名建议带业务前缀,比如 user_mask、flow_extract_domain,不要直接叫 mask 这种通用名字。公共 Hive 环境里,同名函数很容易被别人的 jar 包覆盖,出了事故排查也麻烦。上线顺序上,我习惯先在测试环境用临时函数验证逻辑,确认没问题后,再到正式环境的库下创建永久函数。发布前跑一遍单元测试,重点覆盖 null 值和边界情况,能省下非常多半夜报警的时间。UDF 开发这事,说到底是 Java 基础加 Hive 数据模型的结合。很多人一开始觉得写 UDF 很神秘,真正动手之后会发现,大部分工作都花在类型处理、空值判断和打包部署这些细枝末节上。我在生产环境里见过不少 UDF 因此翻车:有的没处理 null 导致任务半夜报警,有的分隔符忘了转义导致拆出来的数据缺胳膊少腿。所以上面这些细节,多看几遍不亏。如果想把 UDF 玩得更深,可以往两个方向走:一个是 UDAF,自定义聚合逻辑,比如实现分组内去重计数;另一个是 Hive 的宏(MACRO),如果逻辑不那么复杂,直接用宏在 SQL 里定义,连 Java 都不用写。我个人建议先从简单 UDF 入手,把本文代码完整跑通一遍,建立信心,再逐步深入。