ARTICLE DETAIL

资讯详情

深耕网站视觉设计与运营推广的一线实战洞察。

FML快建模语言:声明式数据仓库维度建模与自动化实践

FML快建模语言:声明式数据仓库维度建模与自动化实践 1. 为什么需要一门“快建模语言”第一次接触 FML 是在一个数据仓库重构项目里。当时团队有 200 多张业务表要接入按传统方式写 DDL、配调度、拉血缘光是建表语句就写了三天改一个字段类型要同步改五六个地方。后来有人扔过来一份 FML 脚本几十行描述完了一整层维度模型编译后自动生成建表语句、调度依赖和数据血缘。那一刻我才意识到建模这件事本身是可以被“语言化”的。Fast Modeling Language简称 FML是一套面向数据仓库维度建模的领域特定语言。它把表结构、字段定义、主外键关系、分区策略、更新方式这些建模要素抽象成简洁的声明式语法你只需要描述“模型长什么样”不需要关心“底层怎么建”。编译引擎会把它翻译成目标平台可执行的 SQL同时生成配套的元数据。它解决的核心问题是建模意图与物理实现之间的割裂。传统方式下你在文档里画 ER 图在 SQL 里写建表语句在调度系统里配依赖三份东西各说各话时间一长没人知道哪个是最新的。FML 把这三者统一到一份脚本里改一处全链路同步。适合谁看如果你在做数据仓库、数据集市、指标平台每天跟维度建模、SQL、DataWorks 打交道被字段命名不一致、口径对不齐、血缘断链这些问题折磨过那 FML 值得花时间了解。哪怕你暂时不用它理解它的设计思路对梳理自己的建模流程也有帮助。下面我从实际使用角度把 FML 的完整用法拆开讲一遍。2. FML 整体设计与核心思路拆解2.1 声明式建模与命令式建表的本质区别传统建表是命令式的你先CREATE TABLE再ALTER TABLE加字段再写INSERT INTO ... SELECT灌数据每一步都是“怎么做”。FML 是声明式的你写table dwd_order { ... }描述这张表有哪些字段、什么类型、属于哪个主题域至于建表语句怎么拼、分区怎么加、注释怎么挂交给编译器。这个区别听起来抽象实际影响很大。命令式方式下表结构是“过程的结果”你想知道最终长什么样得把整个过程在脑子里跑一遍。声明式方式下表结构就是“你写的那段描述”所见即所得。改字段类型时命令式要找到对应的ALTER语句声明式直接改定义就行。我个人的体会是声明式建模在迭代频繁的场景下优势最明显。数据仓库前期需求变化快今天加个字段明天改个口径声明式改起来心理负担小不容易漏改。2.2 分层建模思想在 FML 中的落地维度建模经典分层是 ODS、DWD、DWS、ADS。FML 用layer关键字显式声明每张表属于哪一层编译器据此做几件事校验跨层引用是否合法比如 DWD 不能直接引用 ADS、自动生成层间调度依赖、按层套用不同的建表模板ODS 用外部表语法DWD 用分区表语法。为什么要在语言层面强制分层因为靠人自觉很难。我见过太多项目DWD 的表直接读 ODS 的原始日志跳过了清洗层短期看省事长期看口径混乱、血缘断裂。FML 把分层约束写进语法编译不过就是编译不过从机制上堵住了这个口子。2.3 元数据驱动与自动化血缘生成FML 脚本编译后会产出一份元数据描述文件里面记录了每张表的字段、类型、来源表、目标表、分区字段、更新频率。这份元数据是血缘分析的原料。传统方式下血缘靠解析 SQL 得到SQL 写得花一点就解析不准。FML 的血缘是“声明出来的”你写from: dwd_order编译器就知道这条血缘不需要猜。这个设计的好处是血缘准确率高。实测下来只要 FML 脚本写规范血缘覆盖率能到 95% 以上剩下 5% 是动态 SQL 或存储过程那部分本来就不适合用 FML 描述。2.4 与 DataWorks 等调度平台的衔接方式FML 本身不负责调度它只负责生成“调度需要的信息”。编译产物里有一份依赖描述格式是表名 - 上游表名列表。DataWorks 的调度配置可以读这份描述自动建立节点依赖。我试过把 FML 编译产物直接喂给 DataWorks 的 OpenAPI批量创建节点和依赖200 张表大概 10 分钟配完手工配的话至少两天。这里有个细节FML 生成的依赖是表级依赖不是节点级依赖。DataWorks 默认是节点级依赖需要做一层映射。映射规则很简单一张表对应一个产出节点表名即节点名。只要命名规范统一这层映射可以脚本化。3. 核心语法与实操要点解析3.1 表定义从字段声明到分区策略一张典型的 DWD 表定义长这样table dwd_order_detail { layer: DWD comment: 订单明细事实表 partition: dt, hour lifecycle: 365 fields: { order_id: string, 订单ID user_id: string, 用户ID product_id: string, 商品ID pay_amount: decimal(18,2), 支付金额 order_status: string, 订单状态 create_time: timestamp, 创建时间 } primary_key: order_id from: ods_order_log }逐行拆解。layer: DWD声明分层编译器据此校验引用合法性。partition: dt, hour声明两级分区dt 是日期hour 是小时适合日志类数据。lifecycle: 365是生命周期单位天编译器会生成对应的分区回收策略。fields块里每行是字段名: 类型, 注释注释用双引号包裹。primary_key声明主键用于生成唯一性校验和去重逻辑。from声明数据来源用于血缘。注意事项字段类型要写目标平台支持的类型。FML 支持类型映射比如string在 Hive 里映射成STRING在 MaxCompute 里映射成STRING在 MySQL 里映射成VARCHAR(255)。但decimal(18,2)这种带精度的各平台语法略有差异编译时会做适配。我踩过的坑是timestamp类型在有些平台叫datetimeFML 做了别名兼容但最好还是按目标平台的习惯写。3.2 维度与事实的区分role 关键字的使用FML 用role关键字区分维度表和事实表table dim_user { layer: DWD role: dimension comment: 用户维度表 fields: { user_id: string, 用户ID user_name: string, 用户名 register_date: string, 注册日期 user_level: string, 用户等级 } primary_key: user_id from: ods_user_info } table dwd_order_fact { layer: DWD role: fact comment: 订单事实表 fields: { order_id: string, 订单ID user_id: string, 用户ID amount: decimal(18,2), 金额 } primary_key: order_id foreign_keys: { user_id - dim_user.user_id } from: ods_order_log }role: dimension的表编译器会生成维度表模板默认加is_current字段做拉链。role: fact的表编译器会生成事实表模板默认按分区增量写入。foreign_keys声明外键关系用于生成 join 建议和血缘。为什么要有role因为维度表和事实表的物理设计差异很大。维度表通常要做缓慢变化维事实表通常要做分区增量。用role声明后编译器可以套用不同的模板减少重复配置。3.3 派生字段与计算逻辑的表达FML 支持在表定义里写派生字段table dws_user_order_summary { layer: DWS comment: 用户订单汇总表 partition: dt fields: { user_id: string, 用户ID order_count: bigint, 订单数 total_amount: decimal(18,2), 总金额 avg_amount: decimal(18,2), 客单价 } derived_fields: { avg_amount: total_amount / order_count } from: dwd_order_fact group_by: user_id }derived_fields里的表达式会在编译时转成 SQL 表达式。group_by声明聚合维度。编译器会生成INSERT INTO ... SELECT user_id, count(*), sum(amount), sum(amount)/count(*) FROM ... GROUP BY user_id。这里有个坑派生字段的表达式要符合目标平台的 SQL 方言。比如total_amount / order_count在有些平台会做整数除法需要写成total_amount * 1.0 / order_count。FML 不做方言转换表达式原样透传。我的做法是先在目标平台验证表达式再写进 FML。3.4 数据质量规则的声明式配置FML 支持在表定义里挂数据质量规则table dwd_order_detail { ... quality_rules: { not_null: order_id, user_id unique: order_id range: pay_amount 0 custom: order_status in (paid,shipped,completed) } }not_null声明非空字段unique声明唯一字段range声明数值范围custom写自定义 SQL 条件。编译后这些规则会生成对应的校验任务挂到调度上。为什么要把质量规则写进建模脚本因为质量规则和表结构是强相关的。字段改了规则也要改。放在一起改的时候不容易漏。我见过质量规则单独维护的项目表结构改了三个月规则还是老的跑出来的告警全是误报。4. 完整实操流程与核心环节实现4.1 环境准备与 FML 编译器安装FML 编译器是一个命令行工具依赖 Java 运行时。安装步骤# 确认 Java 版本要求 1.8 以上 java -version # 下载 FML 编译器以实际发布地址为准 wget https://example.com/fml-compiler-1.0.0.zip # 解压 unzip fml-compiler-1.0.0.zip -d /opt/fml # 配置环境变量 export FML_HOME/opt/fml export PATH$PATH:$FML_HOME/bin # 验证安装 fml --version安装完成后建一个工作目录放 FML 脚本mkdir -p ~/fml-project/models cd ~/fml-project目录结构建议按分层组织fml-project/ models/ ods/ dwd/ dws/ ads/ config/ fml-config.yaml output/config/fml-config.yaml是编译配置指定目标平台、连接信息、输出路径target: maxcompute output_dir: ./output connection: endpoint: your-endpoint project: your_project access_key: your_key secret_key: your_secret注意连接信息建议用环境变量注入不要明文写在配置文件里。FML 支持${ENV_VAR}语法读取环境变量。4.2 编写第一个 FML 模型文件在models/dwd/下建dwd_order_detail.fmltable dwd_order_detail { layer: DWD role: fact comment: 订单明细事实表 partition: dt lifecycle: 365 fields: { order_id: string, 订单ID user_id: string, 用户ID product_id: string, 商品ID pay_amount: decimal(18,2), 支付金额 order_status: string, 订单状态 create_time: timestamp, 创建时间 } primary_key: order_id foreign_keys: { user_id - dim_user.user_id product_id - dim_product.product_id } quality_rules: { not_null: order_id, user_id unique: order_id range: pay_amount 0 } from: ods_order_log }写完后编译fml compile --config config/fml-config.yaml --input models/dwd/dwd_order_detail.fml --output output/编译成功会输出output/ dwd_order_detail.sql dwd_order_detail.meta.json dwd_order_detail.dep.jsondwd_order_detail.sql是建表语句dwd_order_detail.meta.json是元数据dwd_order_detail.dep.json是依赖描述。4.3 编译产物解析与建表语句生成打开dwd_order_detail.sql内容大致如下CREATE TABLE IF NOT EXISTS dwd_order_detail ( order_id STRING COMMENT 订单ID, user_id STRING COMMENT 用户ID, product_id STRING COMMENT 商品ID, pay_amount DECIMAL(18,2) COMMENT 支付金额, order_status STRING COMMENT 订单状态, create_time TIMESTAMP COMMENT 创建时间 ) COMMENT 订单明细事实表 PARTITIONED BY (dt STRING) LIFECYCLE 365;对比手写建表语句FML 生成的版本有几个特点字段顺序与定义一致、注释完整、分区和生命周期自动带上、IF NOT EXISTS防止重复建表。这些细节手写时容易漏FML 统一处理。dwd_order_detail.meta.json内容{ table_name: dwd_order_detail, layer: DWD, role: fact, fields: [ {name: order_id, type: string, comment: 订单ID}, ... ], primary_key: [order_id], foreign_keys: [ {field: user_id, ref_table: dim_user, ref_field: user_id}, ... ], partition: [dt], lifecycle: 365 }这份元数据可以导入数据地图或血缘系统。4.4 批量编译与依赖关系自动生成实际项目里表很多逐张编译不现实。FML 支持批量编译fml compile --config config/fml-config.yaml --input models/ --recursive --output output/--recursive表示递归扫描子目录。编译完成后output/下会有所有表的产物。依赖关系在*.dep.json里格式{ table: dwd_order_detail, upstream: [ods_order_log], downstream: [dws_user_order_summary] }把output/下所有*.dep.json合并就得到全链路依赖图。我写过一个 Python 脚本做合并和拓扑排序输出调度需要的依赖顺序import json import os from collections import defaultdict deps defaultdict(list) for f in os.listdir(output): if f.endswith(.dep.json): with open(os.path.join(output, f)) as fp: data json.load(fp) deps[data[table]] data[upstream] # 拓扑排序 visited set() order [] def visit(t): if t in visited: return visited.add(t) for u in deps.get(t, []): visit(u) order.append(t) for t in deps: visit(t) print(order)这个顺序就是调度节点的执行顺序。4.5 与 DataWorks 调度配置的对接DataWorks 的调度配置可以通过 OpenAPI 批量创建。核心步骤读取 FML 编译产物中的表名和依赖调用 DataWorks 的CreateNode接口创建节点调用CreateDependency接口建立依赖我用的 Python 脚本大致逻辑import requests def create_node(table_name, sql_content): url https://dataworks.aliyuncs.com/api/nodes payload { name: table_name, type: ODPS_SQL, content: sql_content } resp requests.post(url, jsonpayload, headersauth_headers) return resp.json()[node_id] def create_dependency(node_id, upstream_node_id): url https://dataworks.aliyuncs.com/api/dependencies payload { node_id: node_id, upstream_node_id: upstream_node_id } requests.post(url, jsonpayload, headersauth_headers)实际使用时要注意 DataWorks 的接口有频率限制批量创建时加个time.sleep(0.5)避免触发限流。5. 常见问题与排查技巧实录5.1 编译报错类型不匹配与字段冲突最常见的编译错误是类型不匹配。比如decimal(18,2)在目标平台不支持或者timestamp写成了datetime。FML 的报错信息会指出具体行号和字段名ERROR: line 8, field pay_amount: type decimal(18,2) not supported on target maxcompute排查方法查目标平台的类型文档确认支持的类型列表。MaxCompute 支持DECIMAL(precision, scale)但 precision 最大 38scale 最大 18。超出范围会报错。字段冲突是指同一张表里字段名重复或者字段名与分区字段重名。FML 会报ERROR: line 12, field dt: conflicts with partition field解决方法是改字段名或者改分区字段名。我习惯分区字段统一用dt、hour业务字段避免用这两个名字。5.2 血缘断链from 声明遗漏的排查方法血缘断链通常是因为from声明遗漏。比如某张表实际读了ods_order_log但 FML 里没写from编译出的依赖就是空的。排查方法编译后检查*.dep.json如果upstream为空但表确实有上游就是漏了from。我写过一个校验脚本扫描所有 FML 文件检查有derived_fields或group_by的表是否都有fromimport re def check_from(fml_file): with open(fml_file) as f: content f.read() has_derived derived_fields in content or group_by in content has_from from: in content if has_derived and not has_from: print(fWARNING: {fml_file} has derived fields but no from declaration)这个脚本帮我抓出过好几处遗漏。5.3 调度依赖错乱分区字段与依赖粒度的关系调度依赖错乱通常是因为依赖粒度不对。FML 生成的是表级依赖但实际调度可能需要分区级依赖。比如dwd_order_detail按dt分区下游dws_user_order_summary也按dt分区依赖应该是dwd_order_detail.dt20240101 - dws_user_order_summary.dt20240101而不是表级依赖。FML 的*.dep.json里记录了分区字段调度配置时可以根据分区字段生成分区级依赖。我的做法是在 DataWorks 节点里写WHERE dt ${bizdate}依赖配置里指定bizdate参数这样依赖就自动对齐到分区。5.4 性能问题编译慢与生成 SQL 冗余表多的时候编译会慢。我试过 500 张表批量编译大概 3 分钟。慢的原因主要是类型校验和血缘分析。优化方法开启增量编译只编译改动的文件。FML 支持--incremental参数配合文件修改时间判断。生成 SQL 冗余是指编译出的建表语句里有重复的ALTER TABLE。这通常是因为 FML 脚本里同一张表定义了多次或者字段定义有重复。排查方法是检查 FML 文件确保一张表只定义一次。5.5 常见问题速查表问题现象可能原因排查方法解决方案编译报类型错误目标平台不支持该类型查报错行号和字段名改用目标平台支持的类型血缘为空from声明遗漏检查*.dep.json的upstream补上from声明调度依赖错乱依赖粒度不对检查分区字段和依赖配置改用分区级依赖编译慢表多且全量编译看编译日志开启增量编译生成 SQL 冗余表定义重复检查 FML 文件确保一张表只定义一次字段冲突字段名与分区字段重名看报错行号改字段名或分区字段名质量规则不生效规则语法错误看编译日志检查规则语法元数据导入失败JSON 格式错误用 JSON 校验工具检查修复 JSON 格式6. 进阶技巧与个人实操心得6.1 用模板减少重复定义项目里很多表结构相似比如日志类表都有dt、hour分区都有create_time字段。FML 支持模板template log_table { partition: dt, hour lifecycle: 90 fields: { create_time: timestamp, 创建时间 } } table ods_page_view : log_table { layer: ODS fields: { page_id: string, 页面ID user_id: string, 用户ID } from: raw_page_log }template定义公共部分table ... : template_name继承模板。编译时模板字段会自动合并到表定义里。这个技巧帮我省了大量重复代码尤其是 ODS 层几十张日志表模板一套每张表只写差异部分。6.2 版本管理与变更追踪FML 脚本建议放 Git 管理。每次改表结构提交一次 commitcommit message 写清楚改了什么、为什么改。这样回溯的时候能查到变更历史。我还会在 FML 文件头部加变更记录// 变更记录 // 2024-01-15 新增 pay_amount 字段 // 2024-01-20 修改 order_status 类型为 string table dwd_order_detail { ... }编译时这些注释会被忽略但人看的时候一目了然。6.3 与 CI/CD 流水线的集成FML 编译可以集成到 CI/CD 流水线里。每次提交 FML 脚本流水线自动编译、校验、生成 SQL然后部署到目标平台。我的流水线配置大致是stages: - compile - validate - deploy compile: script: - fml compile --config config/fml-config.yaml --input models/ --output output/ validate: script: - python scripts/check_deps.py output/ - python scripts/check_quality_rules.py output/ deploy: script: - python scripts/deploy_to_dataworks.py output/ only: - maincompile阶段编译validate阶段校验依赖和质量规则deploy阶段部署到 DataWorks。只有main分支才触发部署其他分支只编译校验。6.4 踩过的坑与避坑建议第一个坑分区字段顺序。FML 里partition: dt, hour和partition: hour, dt生成的建表语句分区顺序不同。Hive 和 MaxCompute 对分区顺序敏感顺序错了查询会报错。建议统一按粒度从粗到细写dt在前hour在后。第二个坑生命周期单位。lifecycle: 365是 365 天不是 365 个月。我见过有人写成lifecycle: 12以为是 12 个月结果数据 12 天就被回收了。FML 的生命周期单位固定是天写的时候注意。第三个坑外键引用的表不存在。FML 编译时会校验外键引用的表是否在同一个项目里定义。如果引用了外部表需要显式声明external: true否则编译报错。我一开始不知道这个被卡了半天。第四个坑质量规则的 custom 表达式。custom里写 SQL 条件时字段名要用表里的字段名不能用别名。比如order_status in (paid,shipped)是对的status in (...)会报字段不存在。第五个坑批量编译时的内存占用。500 张表以上批量编译JVM 默认堆内存可能不够报OutOfMemoryError。解决方法是调大堆内存export FML_OPTS-Xmx4g。6.5 后续扩展方向FML 目前主要覆盖建表和调度依赖后续可以往几个方向扩展。一是指标定义把指标口径也写进 FML编译时生成指标计算 SQL。二是数据服务把表定义编译成 API 配置自动生成查询接口。三是影响分析改一个字段时自动分析影响哪些下游表、哪些报表、哪些指标。我个人最期待的是影响分析。现在改字段还是靠人肉排查容易漏。如果 FML 能基于血缘自动算出影响范围改字段前先跑一遍影响分析心里就有底了。最后分享一个小技巧FML 脚本写完后先跑fml validate做语法校验再跑fml compile做编译。validate只检查语法不连目标平台速度快适合本地开发时频繁跑。compile会连目标平台做类型校验慢一些适合提交前跑。分开跑能省不少时间。
返回列表