☰
离线数据开发交付实战:数仓分层、调度依赖与幂等重跑
2026/10/28 6:45:02 网站建设 项目流程

1. 先把“会写SQL”和“能交付数据”分开看

离线数据开发这件事,真正的分水岭不在会不会写 SQL,而在于你交出去那张表,第二天早上八点业务方打开看板时敢不敢直接用。我见过太多刚入行的同学,一手窗口函数写得行云流水,交上去的任务却天天延迟、口径反复、重跑一次数据翻倍。这一章我想聊的就是中间这段路:一条原始埋点日志落到存储集群,到最后变成看板上那个“日活跃用户数”,中间要过多少道关。

关键词先摆出来——离线数据开发、数仓分层、调度依赖、数据倾斜、幂等重跑、数据质量校验。这几个词基本覆盖了这一章的全部内容。如果你正在做数仓建设、负责每日批处理任务、或者刚接手一个已经跑了半年、没人敢动的历史链路,这篇东西应该能对上你的痛点;如果你还在学习阶段,也能从里面看出一份真实的离线开发工作到底长什么样,跟课本上那种“建表—插数—查询”三步走完全不是一回事。

我自己入行时最大的误区,是把 T+1 的批处理当成一道数学题:输入确定、逻辑确定、输出就应该确定。干了几年才明白,它更像一条流水线,任何一道工位的物料晚到、规格错了、机器抖了一下,最终产品就是废的。而离线开发的绝大部分工作量,不在“算得对不对”,在“能不能每天都算对、按时算对、算错了能不能拉回来”。

所以我给这一章的定位是:不教你 SQL 语法,教你把一条链路从能用做到能交付。下面按照我实际的作业顺序展开——先讲整体怎么设计,再讲关键环节怎么落地,然后是性能和成本怎么抠,最后是质量保障和踩坑记录。

1.1 离线开发真实的时间都花在哪

很多人以为离线开发是 80% 写 SQL、20% 干别的。我连续统计过自己三个月的工时,真实比例大概是:写核心逻辑 25%、建表和规范对齐 15%、调度配置和依赖梳理 15%、排查任务延迟和失败 25%、口径答疑和数据核对 20%。也就是说,四分之三的时间不在“写”上。

这个比例不是抱怨,而是提醒:如果你把全部精力投在 SQL 优化上,剩下那 75% 的坑一个都躲不掉。我踩过最典型的一次,是一张明细表逻辑完全正确,但分区字段用错了格式,一部分数据写进了dt=2024-03-01,另一部分写进了dt=20240301。查询时只扫了一个格式,指标直接少了三成,而任务日志一片绿色、没有任何报错。这种问题靠 SQL 能力完全防不住,只能靠规范和校验。

所以后面每一节我都会尽量把“为什么这么定规矩”讲清楚。规矩本身背下来没用,理解它挡的是哪一类事故,你才能在下一个项目里自己长出规矩来。

1.2 分层不是教条,是成本控制手段

数仓分层这件事,被讲得太多,反而容易变成形式主义。我的理解很朴素:每一层都是在用存储换重复计算,用一次性的整理换后续无数次的复用。

不分层会怎样?我接手过一个历史项目,看板上有 40 多张报表,每张报表的 SQL 都从原始日志表直接开始扫描、直接解析 JSON、直接关联维表。结果是:同一份会话切分逻辑在 40 个地方各写一遍,其中 7 个地方写得不一样;原始日志每天全量扫 40 次,集群资源被反复消耗;业务方问“为什么两个看板的活跃数不一样”,没人能回答,因为真的不一样。

分层之后,会话切分只做一次、放在明细层,40 张报表全部依赖它。这不仅省资源,更重要的是口径只在一个地方定义。口径一旦唯一,吵架的概率就下降一大半。

但分层也不能无限加。我见过分到七层的设计,从原始层到应用层中间套了四层中间表,改一个字段要动六个任务,回溯一次要跑一整天。我的经验是:中小规模的数据团队,ODS / DWD / DWS / ADS 四层足够,再多就是给自己上枷锁。

2. 一条离线链路的整体骨架怎么搭

动手之前先把骨架画清楚,比写完再改省十倍力气。我现在的习惯是,动手写第一行 SQL 之前,先在纸上列三样东西:表清单(含分层归属)、每张表的更新方式(全量覆盖 / 增量追加 / 拉链 / 快照)、任务依赖图。这三样定下来,代码只是填充。

2.1 四层的职责边界与常见越界

层级存什么更新方式典型问题
ODS 原始层与源系统结构一致的落地数据每日增量追加或全量覆盖直接在这里做业务计算,导致源表一变全盘重写
DWD 明细层清洗、脱敏、规范化后的事实明细增量追加为主把聚合指标塞进来,导致粒度混乱
DWS 汇总层按主题 + 维度组合的轻度聚合每日覆盖对应分区维度组合爆炸,宽表列数过千
ADS 应用层直接对接报表、接口的结果表每日覆盖在应用层再做复杂关联,报表变慢

越界最常见的是 DWD 和 DWS 之间。有人图省事,在明细层直接算好一堆聚合指标存下来,结果明细层的粒度变得不唯一——同一行既有订单级字段又有用户级字段,后续谁都不敢用。我的原则是:明细层一行只代表一个业务事件,粒度必须能用一句话说清楚,比如“一行一个订单”“一行一个用户一天”。说不清楚就是设计有问题,别急着往下写。

另一个隐性成本是 ODS 层的膨胀。源系统推什么就存什么,几年下来原始层占了整个集群七成存储。我在实际项目里会给 ODS 定严格的生命周期:明细日志保留 90 天,全量快照保留 30 天,超过的对账类数据保留 180 天。定这个的时候一定要跟业务方确认,别自己拍脑袋删了人家要回溯的东西。

2.2 表设计的三条硬规矩

规矩一:分区字段永远放最后,永远是字符串日期。分区是离线表的第一性能杠杆,也是最容易出事的地方。字符串日期(20240301还是2024-03-01)必须在项目启动时统一,写进规范文档,而不是靠每个人自觉。我倾向用不带横线的yyyyMMdd,因为在很多文件系统路径里横线不是问题但容易和别的分隔符混淆,无横线最省心。

规矩二:命名要能自解释。我用的格式是层_业务域_主题_更新方式,比如dwd_trade_order_detail_di,di表示 daily increment 每日增量,df表示 daily full 每日全量,zip表示拉链表。新同事看一眼表名就知道更新方式,不用去翻文档——文档永远会过期,表名不会。

规矩三:生命周期必须显式声明。每张表创建时就要确定保留多久。没有生命周期的表会长成怪物,我见过一张存储了近三年的中间表,实际每次计算只用到最近 30 天分区,剩下 1000 多天分区纯占地方、纯拖慢元数据操作。

-- 建表时把分区、存储、压缩、生命周期一次定清楚 create table if not exists dwd_trade_order_detail_di ( order_id string comment '订单ID', user_id string comment '用户ID', sku_id string comment '商品ID', order_amount decimal(18,2) comment '订单金额', order_status string comment '订单状态', create_time string comment '创建时间' ) comment '交易域-订单明细-每日增量' partitioned by (dt string comment '业务日期 yyyyMMdd') stored as orc tblproperties ( 'orc.compress' = 'zstd', 'orc.stripe.size' = '268435456', 'lifecycle.days' = '365' );

存储格式我基本只用两种:列式 ORC 或 Parquet。选哪个更多取决于集群现状,别在一个项目里混着用。压缩我偏好 zstd,压缩比和 CPU 开销的平衡点比 snappy 好一截,尤其对宽表效果明显。

注意:orc.stripe.size调大对宽表扫描有好处,但别超过 512MB,否则小文件治理和并发读都会受影响。

2.3 依赖设计:依赖表,不要依赖任务

调度依赖这块,我踩过最大的坑是“任务依赖任务”。早期图省事,A 任务跑完触发 B 任务,看起来清晰。但只要中间有一次手工补数、一次临时插任务、一次任务改名,依赖图就乱了。更稳妥的做法是依赖数据:B 任务启动前检查上游分区是否就绪,就绪才跑。

具体落地有两种常见方式:一种是调度系统提供“依赖上游表分区”的配置项,直接配;另一种是任务开头加一段检查逻辑。

# 任务前置检查:上游分区不存在就退出,不空跑、不写脏数据 dt=${bizdate} for tbl in dwd_trade_order_detail_di dwd_user_info_df; do cnt=$(hive -e "show partitions ${tbl} partition(dt='${dt}')" 2>/dev/null | wc -l) if [ "${cnt}" -eq 0 ]; then echo "[FATAL] 上游分区缺失: ${tbl} dt=${dt}" exit 1 fi done

这段检查看着土,但救过我很多次。有上游延迟两小时的情况,如果没有这个检查,下游会拿空分区跑出一个全零结果,然后正常写入、正常发报表、正常没人发现,直到业务方打电话来问为什么今天的订单是 0。

依赖设计还有两个细节值得强调。一是基线时间:核心链路必须约定一个最晚产出时间,所有任务的排期反推自这个时间点,而不是从零点顺着排。二是跨层依赖要收敛:ADS 层只依赖 DWS,不要越层去依赖 DWD,否则 DWD 一改,一堆应用层任务全废。

3. 关键环节的实现细节

骨架有了,接下来是血肉。这一节讲四个我认为最容易做错、也最影响后续维护的环节:建表落地、增量抽取与拉链、指标聚合口径、幂等重跑。

3.1 增量抽取:全量、增量、拉链怎么选

源系统的数据同步方式,基本决定了你后面所有设计。三种模式各有适用场景,选错了后面怎么补都别扭。

模式适用场景存储成本回溯难度
每日全量覆盖表小、无更新时间字段高(每天一份快照)极低
每日增量追加有可靠的更新时间或自增ID低中(需重放)
拉链表需要看历史任意时点状态低低

增量抽取最大的坑是漏数。源表用update_time做增量,如果上游是批量更新、事务提交时间晚于更新时间,你按update_time > 昨日零点抽,就会漏掉跨零点提交的那批。我在实际项目里会把抽取窗口往前开一段时间做重叠,比如每次都取update_time >= 昨日零点 - 2小时,然后在写入时按主键去重取最新。多抽一点数据不贵,漏一条数据要命。

去重取最新的写法,我用 row_number 最多:

insert overwrite table dwd_user_info_df partition (dt = '${bizdate}') select user_id, user_name, phone, level, update_time from ( select user_id, user_name, phone, level, update_time, row_number() over (partition by user_id order by update_time desc, ts desc) as rn from ( select *, current_timestamp() as ts from ods_user_info_inc where dt between date_sub('${bizdate}', 7) and '${bizdate}' ) t ) tt where rn = 1;

partition by里的排序字段很关键,一定要带一个确定性兜底字段(比如入库时间戳)。如果只按update_time排序,遇到两条记录时间完全相同时,结果是不确定的,重跑两次数据可能不一样——这在数据核对时是灾难。

3.2 拉链表:写对一次省三年力气

需要看“任意历史时点的状态”时,拉链表是最经济的方案。用户等级变了、商品价格调了、会员到期了,这类缓慢变化维用拉链最合适。

拉链的核心逻辑只有一句话:昨日开放链(end_date为极大值)与新快照对比,不变的保留,变的关闭旧链、开新链,新增的直接开链。

-- dwd_user_info_zip 拉链表实现(dt 同时是分区和快照日) with yesterday as ( select * from dwd_user_info_zip where dt = date_sub('${bizdate}', 1) ), today as ( select * from ods_user_info_full where dt = '${bizdate}' ), -- 找出发生变化的用户(注意 null 安全比较) changed as ( select y.user_id from yesterday y join today t on y.user_id = t.user_id where y.end_date = '9999-12-31' and ( not (y.phone <=> t.phone) or not (y.level <=> t.level) or not (y.user_name <=> t.user_name) ) ) insert overwrite table dwd_user_info_zip partition (dt = '${bizdate}') -- 1. 历史已关闭链 + 昨日未变化的开放链,原样带入 select user_id, user_name, phone, level, start_date, end_date from yesterday where end_date <> '9999-12-31' or user_id not in (select user_id from changed) union all -- 2. 变化用户的旧链,关闭掉 select user_id, user_name, phone, level, start_date, date_sub('${bizdate}', 1) from yesterday where end_date = '9999-12-31' and user_id in (select user_id from changed) union all -- 3. 变化用户的新链 + 全新用户,开链 select t.user_id, t.user_name, t.phone, t.level, '${bizdate}', '9999-12-31' from today t where t.user_id in (select user_id from changed) or t.user_id not in (select user_id from yesterday);

这段代码里有几个点值得单独说明。

第一是<=>空值安全比较。普通=在任一列为 null 时返回 null,会让“从有值变成 null”这种变化检测不到,链就断不了。这是我见过最多的拉链 bug。

第二是union all的三段必须在同一句insert overwrite里完成。分开写会因为覆盖写导致中间状态被冲掉,尤其是重跑的时候。

第三是拉链表的查询方式要教给业务方:查某天的全量状态,条件是start_date <= '2024-03-01' and end_date >= '2024-03-01'。这个条件不加索引,扫全表很慢,我一般会在开放链上单独建一张视图或者一张“当前状态表”应对高频查询。

注意:拉链表分区如果每天全量存一份,存储成本和每日全量表没区别。真正的省空间做法是只存一份不带分区的全量表,每日覆盖。分区拉链是为了方便回溯,两者取舍看团队习惯,我在数据量超过十亿的项目里会改用无分区覆盖。

3.3 指标聚合:口径固化比算得快更重要

聚合层最容易出的不是性能问题,是口径漂移。今天有人说活跃用户按登录算,明天说按有行为算,后天说要去掉内部账号。如果每次都在 SQL 里临时改,三个版本的数据在报表上并存,谁都对、谁都不信。

我的做法是把口径写死在 DWS 层的注释和代码里,业务方要改必须走变更流程、留痕。比如“日活跃用户”我会明确写成:当日有任意一次客户端启动事件、且非内部测试账号的去重用户数。

聚合的另一个技术点是去重指标不可加。UV 类指标跨天不能直接相加,必须用明细去重或用位图结构。这个坑我在一个周报项目上踩得很惨:日报 UV 加起来当周活,结果比实际高出 40%,因为一周内活跃的用户被重复计数了。

-- 正确做法:从明细重算,或者用位图合并 select dt, count(distinct user_id) as uv from dwd_traffic_page_view_di where dt between '20240301' and '20240307' group by dt union all select 'week_total' as dt, count(distinct user_id) as uv from dwd_traffic_page_view_di where dt between '20240301' and '20240307';

数据量再大一级的时候,count distinct会成为瓶颈,这时候用位图(bitmap)结构先按天聚合、再合并,性能能差出一个数量级。这属于进阶优化,后面讲性能的时候再展开。

3.4 幂等重跑:同一个参数跑十次结果必须一样

这是离线开发的底线要求。业务方说“昨天数据不对,重跑一下”,你重跑完数据翻倍了——这种事发生一次,你的数据就再也没人信了。

保证幂等的三条铁律:

  1. 所有日期参数化,代码里不出现写死日期。用调度系统传入的${bizdate}。
  2. 写入用insert overwrite指定分区,不用insert into。覆盖写天然幂等,追加写天然不幂等。
  3. 同一任务内不要有多次写同一分区的动作,如果有,合并成一次。
# 手动补数的标准姿势:显式传入业务日期,先干跑再实跑 bizdate=$1 if [ -z "${bizdate}" ]; then echo "usage: $0 yyyyMMdd" exit 1 fi echo "[INFO] 开始补数 dt=${bizdate}" # 第一步:只读校验,确认上游分区齐全 hive -e "select count(1) from dwd_trade_order_detail_di where dt='${bizdate}';" # 第二步:正式执行,覆盖写 hive -e " set hive.exec.dynamic.partition=true; set hive.exec.dynamic.partition.mode=nonstrict; insert overwrite table dws_trade_user_order_1d partition(dt='${bizdate}') select user_id, count(1), sum(order_amount) from dwd_trade_order_detail_di where dt='${bizdate}' group by user_id; "

补数这件事还有一个隐形成本:下游要不要一起补。我的经验是画清楚依赖图,补数时按层级自下而上顺着补,别跳。跳过中间层直接补 ADS,看起来快,实际上 ADS 依赖的是 DWS 的中间结果,跳过去以后口径就对不上了。补数窗口太大时(比如补一个月),我一般会拆成按天并行、分批执行,同时盯住集群队列资源,别把白天的正常任务挤掉。

4. 性能与成本:那些跑一夜也跑不完的任务

离线任务超过预期时间,八成是三个原因:数据倾斜、小文件过多、并行度配置不合理。这三件事我处理过太多次,几乎是条件反射。

4.1 数据倾斜:先定位,再动手

倾斜的表现很典型:任务卡在最后几个 reduce 不动,或者某个 stage 的耗时是其他 stage 的十倍以上。定位方法很直接,看每个 task 处理的数据量分布,最大值和中间值差十倍以上基本就确诊了。

倾斜的根因是某个 join key 或者 group by key 的数据量远超其他。常见的几个诱因:空值扎堆(几十亿条记录里关联键为 null)、头部用户(一个超级大客户占了八成订单)、默认值滥用(-1、unknown作为未知标识)。

处理手法按场景选:

空值导致的倾斜,最省事的办法是把空值打散,让它不参与关联。

-- 关联键为空时加随机后缀,避免全部涌向同一个 reduce select /*+ mapjoin(dim) */ ... from ( select case when user_id is null then concat('_null_', cast(rand() * 100 as int)) else user_id end as join_key, ... from dwd_traffic_page_view_di where dt = '${bizdate}' ) a left join dim_user dim on a.join_key = dim.user_id;

头部 key 导致的倾斜,用两阶段聚合:先给 key 加随机盐打散做局部聚合,再去盐做全局聚合。

-- 两阶段聚合:先局部去重,再全局合并 insert overwrite table dws_traffic_page_uv_1d partition(dt='${bizdate}') select user_id, count(distinct concat(part, '-', page_id)) as page_cnt from ( select user_id, page_id, cast(rand() * 10 as int) as part -- 打散成 10 份 from dwd_traffic_page_view_di where dt = '${bizdate}' ) t group by user_id;

大表 join 小表的场景,直接上 map join 把维表广播到每个节点,绕开 reduce 阶段,倾斜自然消失。前提是小表真的小,几百 MB 以内。

注意:mapjoin提示如果碰上维表实际有好几个 G,会把执行节点内存撑爆,报 OOM。用之前先看维表大小,别凭感觉加。

还有一种常被忽略的倾斜:动态分区写入倾斜。一个任务动态写入几十个分区,某个分区数据量特别大,仍然会卡。解法是在写入前加一层distribute by分区字段加随机数,让数据均匀落到各个 reducer。

4.2 小文件治理:不是为了好看

小文件是离线集群的慢性病。单文件只有几 KB,元数据开销远大于数据本身,扫描时每个文件都要打开、读头、关闭,任务耗时能翻好几倍。而且这个问题会自我强化:今天产出 5000 个小文件,明天的任务读它们又要开 5000 次,越跑越慢。

产生小文件的典型原因有三个:动态分区写入(每个分区至少一个文件)、reduce 数配置过大(每个 reduce 出一个文件)、频繁的小批量追加。

治理手段对应着来:

-- 写入前控制文件数:按分区字段 + 随机数分发,合并大文件 insert overwrite table dwd_trade_order_detail_di partition(dt='${bizdate}') select order_id, user_id, order_amount, order_status, create_time from ( select order_id, user_id, order_amount, order_status, create_time, cast(rand() * 20 as int) as bucket from tmp_order_stage where dt = '${bizdate}' ) t distribute by bucket;

再配两个参数控制写入时的合并:

set hive.merge.mapfiles = true; -- map 输出后合并小文件 set hive.merge.mapredfiles = true; -- reduce 输出后合并 set hive.merge.size.per.task = 134217728; -- 合并后单文件目标 128MB set hive.merge.smallfiles.avgsize = 67108864; -- 平均小于 64MB 就触发合并

目标文件大小我一般定在 128MB 到 256MB 之间。太小合并效果不明显,太大单任务并行度上不去,一个文件只能被一个 task 读,反而拖慢。

对于历史遗留的小文件堆积,只能靠一次性合并任务处理:读出来重写一遍,用distribute by控制输出文件数。这种任务放在低峰期跑,跑完核对行数和关键指标,确认没丢数据再删原表。

4.3 资源参数:算一遍比拍脑袋强

集群资源是有限的,参数配大了浪费、配小了跑不完。我习惯先算一遍数据量再定参数。

举个实际例子:某天明细数据 8000 万行,每行平均 200 字节,原始约 16GB,列存压缩比按 5 倍算,扫描量约 3.2GB。

单核扫描吞吐按经验值 20MB/s 估,处理 3.2GB 需要约 160 核秒。加上 join、聚合的 CPU 开销,实际按 3 倍冗余算,需要约 480 核秒。如果希望在 5 分钟内跑完,至少需要 2 个核;但实际不可能这么算,因为要留给 shuffle 和数据倾斜的余量。

我的经验公式是:executor 数 = min(数据量GB × 2, 队列可用核数 × 0.8),每个 executor 配 4 核、8GB 到 16GB 内存,内存和核数的比例控制在 2GB/核 到 4GB/核 之间。这个比例太低容易 OOM,太高则浪费。

set spark.executor.cores = 4; set spark.executor.memory = 12g; set spark.executor.memoryOverhead = 2g; set spark.executor.instances = 40; set spark.sql.shuffle.partitions = 800; -- 通常是总核数的 2~3 倍 set spark.sql.adaptive.enabled = true; -- 开自适应,自动处理部分倾斜 set spark.sql.adaptive.skewJoin.enabled = true;

shuffle.partitions这个参数特别值得说。默认 200,很多人不动它,结果数据量上亿的时候每个分区几百万行,单个 task 跑十几分钟;数据量小的任务又切得太碎,全是调度开销。我的粗略算法是:shuffle 数据量 GB 数 × 100 到 200,同时保证每个分区处理 100MB 到 200MB 的数据。开自适应执行之后这个参数的重要性下降了不少,但还是建议设一个合理初值。

5. 数据质量:让问题在业务方发现之前暴露

前面所有工作都做对了,仍然会出问题。数据质量保障的意义不是杜绝问题,是把发现问题的时点从“业务方投诉”提前到“任务产出后五分钟”。

5.1 校验规则清单与优先级

校验规则不用多,覆盖关键维度就够。我常用的六类,按优先级排下来是这样:

优先级规则类型具体检查失败处理
P0非空校验主键、分区字段不为空,总行数 > 0阻断下游
P0唯一性校验主键组合无重复阻断下游
P1数值合理性金额 ≥ 0,比率在 0~1 之间阻断下游
P1波动校验环比波动超过 ±30% 告警告警 + 人工确认
P2枚举校验状态字段在已知集合内告警
P2跨表一致性明细汇总 = 汇总表结果告警

P0 类失败必须阻断下游任务,宁可今天没数据,也不能让错数据流到报表上。P1 的波动校验需要谨慎设置阈值,太严了天天告警,大家就麻木了;太松了真出事也不响。我一般按历史同期波动区间来定,节假日单独配置。

-- 一个可复用的校验任务模板:任何一条不通过就退出非零 with chk as ( select 'row_cnt' as rule, count(1) as val from dws_trade_user_order_1d where dt='${bizdate}' union all select 'dup_cnt', count(1) from ( select user_id from dws_trade_user_order_1d where dt='${bizdate}' group by user_id having count(1) > 1 ) t union all select 'neg_amt', count(1) from dws_trade_user_order_1d where dt='${bizdate}' and order_amount < 0 ) select rule, val, case when rule = 'row_cnt' and val = 0 then 'FAIL' when rule in ('dup_cnt','neg_amt') and val > 0 then 'FAIL' else 'PASS' end as result from chk;

实际落地时,这类校验最好做成配置化:把规则写成元数据(表名、字段、规则类型、阈值),由一个统一的校验服务按配置执行,而不是每个任务手写一遍 SQL。手写的问题是漏、是忘、是规则不一致。做成服务之后,新建一张表只要登记几条规则,后面自动生效。

5.2 告警分级与值班处理

告警最怕两件事:该响的不响,不该响的响个不停。

我经手的一个项目,上线初期每天几百条告警,群里刷屏,两周之后所有人对告警都免疫了,真正的一次数据丢失告警被淹没在里面,三天后才被发现。这个教训很贵。

后来我按这个思路改造:告警分级(P0 电话、P1 群消息 @ 责任人、P2 只记录不推送);同一规则连续失败合并成一条,不重复推送;有明确修复中的任务进入静默期,修好自动解除。

值班处理流程也定死了:收到 P0 告警,五分钟内确认影响范围(哪些下游表、哪些报表),十分钟内判断是重跑能解决还是需要改代码,超过半小时无法恢复就立即通知业务方“今日报表延迟”,别硬扛着等。及时通报延迟,比事后解释错误数据要容易得多。

6. 常见问题速查与几条不外传的经验

写到这里,把前面几节的核心问题整理成一张速查表,方便真出事时对着看。

6.1 问题速查表

现象大概率原因排查动作处理方式
任务卡在最后几个 reduce数据倾斜看 task 数据量分布打散空值 / 加盐两阶段聚合
任务整体跑得慢但均匀并行度不足或小文件多看输入文件数和平均大小调 shuffle 分区,合并小文件
结果数据量为 0上游分区缺失或日期参数错误检查上游分区、检查${bizdate}补依赖检查,修正参数格式
重跑后数据翻倍用了insert into或追加写检查写入语法改insert overwrite指定分区
两个报表数字不一致口径定义不唯一比对两边 SQL 的过滤条件口径收口到同一张 DWS 表
同一任务两次结果不同排序字段不确定、用了随机函数检查 window 排序、rand()补确定性排序字段,去掉随机
拉链表断链或链未关闭null 比较失效检查是否用<=>或nvl改空值安全比较
内存溢出 OOMmapjoin 维表过大 / 单分区数据过多看维表大小、看单 task 行数去掉 mapjoin 提示,加盐打散

6.2 我踩过之后才记住的几件事

第一件:不要在没有备份的情况下覆盖写核心表。我做过一次错误的重跑,把一张跑了一年的核心汇总表最新分区覆盖成了错误数据,而前一天的分区已经被生命周期清理掉了。恢复的办法只有从更上游重放,花了六个小时。从那以后,核心表我都会多留一个备份分区,或者写入前先跑一遍到临时表、校验通过再切换。

第二件:临时表一定要带日期后缀和责任人前缀。曾经集群里有一千多张tmp_001、tmp_test、tmp_new这样的表,谁也不敢删,因为不知道是谁的、还有没有用。现在我的临时表统一叫tmp_${owner}_${scene}_${bizdate},跑完自动清理,超过七天没人动的自动归档。

第三件:别迷信“跑通了就是对的”。任务不报错、结果有数据,和数据正确完全是两回事。我现在的习惯是每张核心表配一个对账任务,和上游独立口径算一遍,两边差千分之一以内才算通过。对账逻辑一定要独立写,不能复用被测任务的代码,否则错了也是一起错。

第四件:把“为什么这么设计”写进注释。我接手过最痛苦的一个项目,所有 SQL 都没有注释,一个复杂的关联条件没人知道是为了过滤测试单还是为了修某个历史 bug。后来我删了它,指标就崩了。现在我写复杂逻辑必留注释,尤其是那些“看起来多余”的条件——它们往往是用事故换来的。

第五件:不要在生产链路上做实验。想试新写法,先复制一张小表跑一遍对比结果,一致了再改生产。这个习惯看着慢,实际省时间。

我个人在实际操作中的体会是,离线数据开发这行的门槛不在技术深度,在工程纪律。同一套 Hive 或者 Spark 语法,谁都能学会,但能不能做到每张表都有生命周期、每个任务都能幂等重跑、每条核心链路都有校验兜底,决定了你交出去的数据是资产还是负债。上面这些规矩,单拎出来每一条都很朴素,甚至有点啰嗦,但它们是真的在我手里救过场的东西。链路跑通只是开始,让它在半年后、一年后、在别人手里也能稳稳跑着,才是这一章真正想说的。

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

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

立即咨询