资讯动态

实时数仓DWS层设计:FlinkSQL构建地区主题表实战

发布时间:2026/10/18 21:56:07 来源:尧图企业网站定制
做实时数仓的同学应该都有印象离线T1报表在业务眼里永远是“差一天”的。之前我们接过一个需求运营要各省的实时GMV拿来盯当日大促进度。我第一反应是让他们直接查Kafka里的DWD订单明细结果被怼回来了——半小时内的数据想拉一遍明细得扫几千万条消息BI工具根本扛不住查询响应也是硬伤。最后落地方案就是建一张DWS层的地区主题表用FlinkSQL做按地区按天的实时汇总写到ClickHouse供报表和大屏直接查。这篇文章把这张表从设计、SQL编写到上线维护的完整过程梳理一遍适合正在搭实时数仓、准备做公共汇总层或者刚接触FlinkSQL聚合的同学参考。我不会只贴一段能跑的SQL就完事而是把为什么这么设计、哪些地方容易踩坑、上线之后怎么运维都讲清楚。1. 一张“按地区聚合”的表为什么值得单独建1.1 不建这张表你会在哪几个地方难受先说场景。订单数据进了KafkaDWD层已经把下单、支付、退款这些事实清洗干净了理论上随时能查。但业务要的不是某一条订单而是“华东区今天出了多少单、卖了多少钱”这种人群视角的汇总。你让Flink没日没夜盯着Kafka然后把聚合结果吐出去看起来可行实际上问题也不少明细重放成本高BI查询没法直接连Kafka查一张宽表明细动辄扫几亿条记录慢且贵。多次口径消费好几个下游团队都自己写任务汇总地区维度的GMVA组按下单时间归日期B组按支付时间归日期最后出来的数字对不上挨骂的还是数据组。指标链路不收敛DWD明细往四面八方发谁需要谁自己聚合维表关联、时区处理每处重复一遍出了bug很难排查。数仓分层的本意就是在一层一层加工中把“明细”变成“口径确定的汇总”DWS层地区主题表在这个体系里扮演的是一个“统一口径的宽表出口”角色。业务端连上它用固定SQL拿数据而不是自己跟DWD明细死磕。1.2 地区主题表到底在回答哪些业务问题地区维度几乎每个业务都有电商看各省成交外卖看各商圈订单出行看各城市完单量。把DWS层的一张表按“地区”这个维度建好基本能覆盖这些高频问题业务问题对应指标统计维度各省今天卖了多少GMV、订单量、客单价省哪些城市活跃度高活跃用户数、下单用户数市大区之间对比活动效果支付金额、退款金额、净GMV大区/省客服看哪些地区退款高退款单量、退款金额省/市运营看新客渗透新客数、首单用户数省这里有一个值得注意的点地区维度是天然树状的大区→省→市→区县上层可以roll-up下层可以drill-down。也就是说汇总表里的字段设计必须考虑层级关系至少保留父级编码否则下游想看“华东六个省加起来”就得扫码式地求和还是容易漏口径。2. 粒度先行字段规划、主键和口径约定2.1 省/市/区县粒度怎么选要不要做一张多粒度表接手这个表之后我的第一个问题是这张地区主题表做到什么粒度只做省级太粗运营想看城市级就得重新开发直接做到区县级数据量倒是也不大但很多区县编码在业务侧没人维护做出来就是一堆“未知地区”。我当时的做法是一张表同时放多粒度用granularity字段区分。省、市、区县三档各自一行上级编码通过parent_code关联。这样一个下游BI标签页里拖“省”还是拖“市”都能直接命中不用建三张结构类似的表。担心点无非是数据量但这个体量其实可以忽略。全国地级市三百多个县级区划两千多个就算每天每个地区一行一天也就几千行加个日期分区之后一年下来还是很小的表。地区主题表最不怕的就是行数少怕的是口径乱。2.2 DWS地区主题表的字段清单和DDL样例我落地的表结构大致是这样ClickHouse引擎CREATE TABLE dws_region_stats ( biz_date Date, region_id String, region_name String, parent_id String, granularity String, -- province / city / district order_cnt UInt64, pay_order_cnt UInt64, gmv Decimal(18, 2), pay_amount Decimal(18, 2), refund_amount Decimal(18, 2), user_cnt UInt64, new_user_cnt UInt64, update_time DateTime ) ENGINE ReplacingMergeTree(update_time) PARTITION BY toYYYYMM(biz_date) ORDER BY (biz_date, region_id, granularity);字段本身不复杂但有几个我在设计时特别盯住的点order_cnt和pay_order_cnt是两类含义完全不同的指标一个是下单口径一个是支付口径不要图省事只放一个。gmv是“下单但未完全退款”的汇总pay_amount是实际支付金额refund_amount单独列。这样上游算“净成交”时直接用pay_amount - refund_amount口径由这张表说了算。user_cnt是活跃下单用户数new_user_cnt是首单用户数这两个指标如果不在这里算好下游很难统一。2.3 最容易扯皮的事口径归属和日期来源写这张表的过程中我和业务方扯最多的不是技术是口径。第一个是地区归属。一个订单从下单到支付再到退款地区到底按哪一刻算我们最终约定所有订单的销售归属地区一律取下单时用户选择的收货地区后续支付、退款都沿用这个地区不重新判断。这样退款不会被拆到另一个省份去报表上“某省退款”和“某省成交”才在同一套逻辑里。第二个是日期口径。订单事件时间到底是下单时间、支付时间还是业务数据库落库时间这块不统一白天看实时数据没问题半夜跨天的时候一定炸。当时我定了一条规则业务日期全部按下单事件的北京时间自然日归事件时间戳在进入Flink作业时就完成转换后面的窗口、分区全部基于这个转换后的字段不允许SQL里再加加减减。3. 核心计算FlinkSQL聚合的写法与背后的状态机制3.1 源表与结果表从Kafka DWD读到ClickHouseFlinkSQL写这个任务先把上下游表定义出来。上游是Kafka里的DWD订单明细大致长这样CREATE TABLE dwd_order_detail ( order_id STRING, user_id STRING, region_id STRING, region_name STRING, parent_id STRING, order_status STRING, pay_status STRING, pay_amount DECIMAL(18,2), event_time TIMESTAMP_LTZ(3), WATERMARK FOR event_time AS event_time - INTERVAL 30 SECOND ) WITH ( connector kafka, topic dwd_order_detail, properties.bootstrap.servers kafka-host:9092, properties.group.id dws_region_stats_group, format json, json.ignore-parse-errors true );注意这里有个细节event_time定义成了TIMESTAMP_LTZ不是TIMESTAMP。因为消息里带的时间是UTC绝对时间用LTZ之后Flink才能正确理解事件时间做窗口聚合时才不会把时区当成字符串乱切。结果表就是上一节那张ClickHouse表我用JDBC Sink连接WHERE和主键都不在写入时解决重复由下游ReplacingMergeTree兜底。3.2 核心SQL按省份按天聚合窗口选型说明聚合的逻辑很直接就是按地区分组、按天分窗把各项指标加总。当时跑通的SQL核心部分长这样INSERT INTO dws_region_stats SELECT DATE_FORMAT(window_start, yyyy-MM-dd) AS biz_date, region_id, region_name, parent_id, province AS granularity, COUNT(order_id) AS order_cnt, COUNT(IF(pay_status paid, order_id, NULL)) AS pay_order_cnt, SUM(pay_amount) AS gmv, ... FROM TABLE( TUMBLE(TABLE dwd_order_detail, DESCRIPTOR(event_time), INTERVAL 1 DAY) ) WHERE order_status IN (created, paid, refunded) GROUP BY window_start, region_id, region_name, parent_id;这段SQL有两点值得展开说第一窗口用的是TUMBLE(TABLE t, DESCRIPTOR(event_time), INTERVAL 1 DAY)这种窗口表值函数写法。这个语法在Flink 1.14以后已经是主流别再写老式的GROUP BY TUMBLE(ts, INTERVAL 1 DAY)了新版SQL Planner对TVF的支持更完整也方便后面做窗口内的多级聚合。第二biz_date取的是window_start不是CURRENT_DATE。这很关键。任务如果凌晨跑、跑到早上还没结束用CURRENT_DATE取日期会出现跨天的数据被记到第二天去。窗口的起止时间才是业务日期的唯一依据。3.3 状态生命周期窗口聚合为什么不要乱配TTL很多同学一提到Flink状态管理第一反应就是“设置TTL”。但窗口聚合这个场景状态生命周期是受窗口生命周期托管的当窗口的watermark越过窗口结束时间Flink会触发计算并清理该窗口的中间状态。你不需要额外设置TTL也千万别把TTL设成“1小时”这种短值——否则窗口数据还在等watermark补数据状态却被提前清掉了最终结果就是丢数据且极难排查。真正需要考虑TTL的地方是非窗口聚合和维表/去重状态。比如我们要算“每个地区累计的用户数”没有窗口边界状态会一直堆积这时候才适合通过table.exec.state.ttl控制一个合理范围并接受旧状态阶段性清理带来的指标滞后。这个区分我踩过之前有个任务图省事把每天的窗口聚合作业顺手配了state.ttl 1 d上线当天没事第二天出数就少了。后来查日志才发现是窗口还没触发状态先被清了。所以窗口任务里TTL这件事能不动就不动。4. 地区维度冗余字段还是运行期维表JOIN4.1 冗余字段方案成本最低但要维护上游地区维度的第一选择永远是让上游DWD在产出明细时就把地区编码和地区名称都带上也就是“维度冗余”。这么做的好处非常明显FlinkSQL聚合时完全不需要额外网络请求地区名称跟着事实数据一起滚进结果表GROUP BY直接带上即可加工链路最稳。坏处也有如果上游改了地区名称或行政区划发生变化已经在Kafka里流转的明细消息不会跟着变结果表里可能出现新旧名称并存。需要重建数据或者接受短期不一致。对于“省市区”这一层变化频率以年为单位的维度我是非常推荐冗余方案的。事实表在业务侧本来就会维护收货地址、用户所在地区之类的字段顺手把名称带上几乎零成本。4.2 维表JOIN方案FlinkSQL的Lookup Join写法如果你拿到的上游事实表只有地区编码没有名称那就得上维表JOIN。常规做法是把地区维表放MySQL或HBaseFlinkSQL用Lookup Join实时补全CREATE TABLE dim_region ( region_id STRING, region_name STRING, parent_id STRING, granularity STRING, PRIMARY KEY (region_id) NOT ENFORCED ) WITH ( connector jdbc, url jdbc:mysql://dim-host:3306/dim, table-name dim_region, lookup.cache LRU, lookup.cache.max-rows 20000, lookup.cache.ttl 1 h );查询时用FOR SYSTEM_TIME AS OF做维度版本关联SELECT o.region_id, r.region_name, r.parent_id, ... FROM dwd_order_detail AS o LEFT JOIN dim_region FOR SYSTEM_TIME AS OF o.proc_time AS r ON o.region_id r.region_id;lookup.cache参数一定要开。不开缓存的话每个地区编码每一条消息都查一次维表维表一有点抖动整条链路就会背压。LRU加ttl 1 h是常见折中缓存命中率高地区名称最多延迟1小时更新。4.3 我的选型结论和踩过的两个坑我个人在实时数仓里处理地区维度倾向80%场景用冗余字段方案。原因很现实实时链路每引入一个外部依赖就多一个故障点。维表JOIN再稳也有连接池耗尽、维表数据更新滞后的问题。地区名称这种低频数据不值得为了“减少冗余存储”付出实时一致性代价。如果确实要用维表JOIN有两个坑必须提前埋好维表里没有“未知地区”这一行。事实表里经常出现emp

读完文章,也想定制专属网站?

尧图设计师 24 小时内与您沟通定制方案

免费获取报价 →
↑