1. 项目概述:多维聚合中的数据操作,远不止GROUP BY那么简单
“Part 20: Data Manipulation in Multi-Dimensional Aggregation”这个标题乍看像是一门数据库课程的第20讲,但如果你真在业务一线做过报表开发、BI建模或数据中台建设,就会立刻意识到——这根本不是语法复习课,而是一场关于“如何让聚合结果真正可用”的实战攻坚。我带过三届数据工程团队,每年都有至少两个项目卡死在这个环节:前端报表里明明写了SUM(sales)和GROUP BY region, product_category, month,可运营同事反馈“数字对不上”“同比环比算出来是负数”“钻取下一层就崩”……最后排查下来,90%的问题不出在SQL写错,而出在多维聚合前的数据状态没被正确干预、聚合过程中的空值与边界没被显式控制、聚合后结果集的结构没被主动重塑。换句话说,大家把“Data Manipulation”理解成了“先SELECT再GROUP BY”,却忽略了在GROUP BY之前、之中、之后,有整整三套必须手动介入的操作逻辑。这个Part 20,本质上是在教你怎么用数据操作(filtering、pivoting、windowing、imputation、hierarchy flattening)去驯服多维聚合这个“高维怪兽”。它适合所有正在用SQL、Pandas、Spark或DAX做分析的人,尤其适合那些已经能写出复杂JOIN但一到“按省+品类+周粒度看复购率”就反复返工的中级数据从业者。你不需要从零学聚合函数,你需要的是:当业务方甩来一张带5个维度、3个指标、2个时间对比要求的Excel需求表时,脑子里能立刻拆解出哪一步该过滤脏数据、哪一步该用窗口函数补缺失、哪一步该用透视重构维度顺序——这才是本篇要交付的核心能力。
2. 多维聚合的数据操作全景图:为什么不能只靠GROUP BY?
2.1 传统认知的致命盲区:GROUP BY只是聚合引擎,不是数据治理器
绝大多数人对多维聚合的理解停留在“GROUP BY A, B, C → SELECT SUM(X), AVG(Y)”这个链条上。这种理解在单表、干净数据、固定维度的场景下勉强可用,但一旦进入真实业务环境,立刻暴露三个结构性缺陷:
第一,维度组合爆炸导致的稀疏性问题。比如电商订单表按“省份+城市+店铺类型+商品一级类目+二级类目”五维分组,理论上组合数可达20×300×5×20×100=6000万种,但实际有效组合可能不到10万。GROUP BY本身不会帮你识别哪些组合是“业务有意义的”,它只会忠实地返回所有非空组合,结果集里充斥着大量“北京+朝阳区+旗舰店+手机+iPhone 15”这样合理但“销售额为0”的记录,而“西藏+那曲市+社区店+图书+儿童绘本”这种低频但关键的组合反而因数据量小被统计噪声淹没。这时候,单纯GROUP BY产出的是一份“技术正确但业务失焦”的原始数据,必须前置用FILTER + HAVING + ROLLUP预筛。
第二,聚合过程中的信息坍缩不可逆。GROUP BY会把明细行“折叠”成一行汇总值,但很多分析需要保留折叠前的上下文。例如计算“每个城市的客单价同比变化”,如果直接GROUP BY city, year_month再算LAG,你就丢失了“同一城市不同月份的订单数分布”这个关键中间态;而如果先用窗口函数计算每个订单的月度平均客单价,再按城市聚合,就能同时拿到聚合值和分布稳定性指标(如标准差)。GROUP BY是单向压缩,而真正的多维操作需要在压缩前后都保有数据形态的可控切换能力。
第三,维度层级关系未被显式建模。真实业务中维度从来不是扁平的。比如“产品类目”有三级:一级类目(电子)、二级类目(手机)、三级类目(旗舰机);“时间”有年→季度→月→周→日的嵌套。GROUP BY city, category_level1, category_level2强行拉平层级,会导致无法灵活上卷(roll-up)或下钻(drill-down)。正确的做法是用Hierarchy-aware操作:先构建维度树(如用递归CTE生成类目路径),再用GROUPING SETS定义多级聚合点,最后用CASE WHEN或DAX的ISINSCOPE动态判断当前视图层级——这些都不是GROUP BY能解决的。
提示:我在某零售客户项目中遇到过典型反例。他们用GROUP BY province, city, store_type统计GMV,结果发现“直辖市”(北京/上海/天津/重庆)的city字段全为空,因为系统设计时把直辖市视为province级单位。GROUP BY自动把NULL city归为一类,导致直辖市数据全部混进“未知城市”桶里。解决方案不是改GROUP BY,而是前置用COALESCE(city, province)做维度对齐——这就是典型的“聚合前数据操作”。
2.2 多维聚合数据操作的三层架构:Pre-Aggregation、In-Aggregation、Post-Aggregation
基于十年实战,我把多维聚合中的数据操作拆解为三个严格时序阶段,每个阶段对应不同的技术工具和业务意图:
Pre-Aggregation(聚合前操作):目标是让输入数据“准备好被聚合”。核心任务包括:
- 维度对齐:处理NULL值(如用COALESCE填充)、标准化命名(如“iOS”/“IOS”/“Ios”统一为“iOS”)、合并细粒度维度(如把“北京市朝阳区”和“北京朝阳”映射到同一geo_id)
- 数据清洗:剔除测试订单(order_id like 'TEST%')、过滤无效状态(status not in ('cancelled', 'pending'))、修正异常值(如price > 100000的订单人工复核)
- 预计算衍生字段:生成week_start_date(避免GROUP BY WEEK()导致跨年周错乱)、计算LTV分群标签(RFM)、打标新老客(用窗口函数查首次下单时间)
In-Aggregation(聚合中操作):目标是让聚合过程“智能且可控”。核心任务包括:
- 多级聚合定义:用GROUPING SETS同时产出(province)、(province, city)、(province, city, store_type)三套结果,避免多次扫描
- 空值策略注入:用COUNT(col) vs COUNT(*)区分“有值行数”和“总行数”,用AVG(COALESCE(col,0)) vs AVG(col) 控制零值参与计算
- 时间智能处理:用DATE_TRUNC('month', order_time)替代EXTRACT(MONTH FROM order_time),确保跨年连续性;用LAG/SUM OVER (PARTITION BY province ORDER BY month ROWS BETWEEN 11 PRECEDING AND CURRENT ROW) 计算滚动12个月
Post-Aggregation(聚合后操作):目标是让聚合结果“即拿即用”。核心任务包括:
- 结构重塑:用PIVOT将“维度列+指标列”转为“维度列+指标_202301+指标_202302…”宽表,适配BI工具拖拽
- 缺失值填充:对时间序列中缺失的月份,用GENERATE_SERIES生成完整时间轴,LEFT JOIN补空行,再用LAST_VALUE IGNORE NULLS向前填充
- 比率安全计算:避免直接SELECT a/b,改用CASE WHEN b=0 THEN NULL ELSE a/b END,并额外输出a和b的原始值供下钻验证
这三层不是理论模型,而是我团队每天在Airflow DAG里写的实际任务流:Pre-Aggregation是每日凌晨2点跑的清洗作业,In-Aggregation是T+1的聚合SQL,Post-Aggregation是T+1中午12点推送到BI平台的最终视图。漏掉任何一层,下游报表都会出现“数字漂移”。
2.3 工具选型逻辑:为什么Pandas/Spark/DAX/SQL要各司其职?
很多人纠结“该用SQL还是Pandas做多维聚合”,其实这是伪命题。真实项目中,这四种工具是流水线上的不同工位,选型依据不是“哪个更强大”,而是“哪个在哪个环节最不可替代”:
SQL(尤其是现代OLAP引擎如ClickHouse/StarRocks):绝对主导Pre-Aggregation和In-Aggregation。原因很实在:数据在数据库里,移动数据比移动代码成本高百倍。用SQL做COALESCE、FILTER、GROUPING SETS,毫秒级完成千万级数据清洗,而把全量数据导出到Python再处理,光IO就耗几分钟。我们规定:所有维度对齐、空值处理、基础聚合必须在SQL层完成,这是性能底线。
Pandas:专精Post-Aggregation中的“轻量级结构变形”。比如把SQL产出的“date, province, metric”长表,用pivot_table转成“province, metric_202301, metric_202302…”宽表;或者用apply结合自定义函数做复杂比率校验(如“当A/B>5时触发告警”)。但严禁用Pandas做百万行以上的聚合计算——它的内存模型和向量化能力远不如数据库原生聚合。
Spark:当Pre-Aggregation涉及跨源异构数据(如MySQL订单+MongoDB用户画像+HDFS日志)且单表超亿行时的唯一选择。用DataFrame API做unionByName对齐schema,用broadcast join加速小表关联,用checkpoint切断血缘避免OOM。不过Spark的调试成本高,我们只在数据量>5亿或需机器学习特征工程时启用。
DAX(Power BI):负责最终交付层的“动态聚合”。SQL产出的是静态快照,而DAX能在用户拖拽切片器时实时重算。比如用户选“华东大区”,DAX用CALCULATE(SUM(sales), ALL(region), VALUES(region[region_group]))动态上卷;选“iPhone 15”时,用ISINSCOPE(product[category])判断是否处于类目层级,决定显示同比还是定基比。这是SQL永远做不到的交互智能。
注意:曾有个项目试图用Pandas替代SQL做Pre-Aggregation,结果每日ETL从12分钟暴涨到2小时,原因是Pandas读取CSV时默认把所有字段当string,numeric字段要逐列astype,而SQL的CAST一次搞定。工具选型的本质,是尊重数据所在位置和计算范式。
3. 核心操作详解:从代码到业务语义的逐层穿透
3.1 Pre-Aggregation实战:用维度对齐消灭“幽灵组合”
维度对齐是多维聚合的生死线。我见过最惨的案例是某金融客户,因为“客户等级”维度在CRM系统里叫“VIP_Level”,在交易系统里叫“cust_tier”,在风控系统里叫“risk_grade”,三个字段值域还不一致(CRM:A/B/C/D;交易:Gold/Silver/Bronze;风控:High/Medium/Low)。直接JOIN后GROUP BY,产生27种无意义组合,报表里“Gold+High”和“Gold+Medium”的客户被算作不同群体,导致营销活动ROI测算完全失真。
解决方案不是写CASE WHEN硬映射,而是建立维度主数据表(Dimension Master Table)。以“客户等级”为例,我们建了一张dim_customer_tier:
| source_system | raw_value | unified_tier | tier_priority | is_active |
|---|---|---|---|---|
| CRM | A | Platinum | 1 | true |
| CRM | B | Gold | 2 | true |
| Transaction | Gold | Gold | 2 | true |
| Risk | High | Platinum | 1 | true |
然后在Pre-Aggregation SQL中强制LEFT JOIN这张表:
SELECT o.order_id, COALESCE(d.unified_tier, 'Unknown') as customer_tier, COALESCE(d.tier_priority, 999) as tier_priority_num, o.amount FROM orders o LEFT JOIN dim_customer_tier d ON o.source_system = d.source_system AND o.raw_tier_value = d.raw_value AND d.is_active = true WHERE o.status = 'completed' AND o.order_date >= '2023-01-01'这里的关键细节:
- COALESCE(d.unified_tier, 'Unknown'):确保维度字段永不为NULL,避免GROUP BY产生意外分组
- tier_priority_num:为后续排序和分层提供数值锚点,比如“TOP 10%高价值客户”直接用PERCENT_RANK() over (ORDER BY tier_priority_num)
- d.is_active = true:维度主数据支持版本管理,历史映射失效时只需关掉is_active,不影响历史报表
实操心得:维度主数据表必须由业务方和数据方共同维护,我们每月初开对齐会,用Jira跟踪每条映射规则的变更原因。曾有次CRM系统升级,把“A级”改成“钻石级”,业务方提前一周邮件通知,我们更新dim_customer_tier并加测试用例,上线零故障。没有主数据,多维聚合就是沙上筑塔。
3.2 In-Aggregation深度解析:GROUPING SETS与ROLLUP的业务场景化应用
GROUPING SETS常被当成高级语法炫技,但它解决的是真实业务痛点:如何用一次查询满足多维下钻需求。假设业务方要“看全国销售,也看各省销售,也看各省+各城市销售”,传统做法是写三条SQL:
-- 全国 SELECT SUM(amount) as total_sales FROM orders; -- 各省 SELECT province, SUM(amount) FROM orders GROUP BY province; -- 各省+各城市 SELECT province, city, SUM(amount) FROM orders GROUP BY province, city;三次全表扫描,IO翻三倍。用GROUPING SETS一条搞定:
SELECT COALESCE(province, 'ALL') as province, COALESCE(city, 'ALL') as city, SUM(amount) as sales, GROUPING(province) as grp_province, GROUPING(city) as grp_city FROM orders GROUP BY GROUPING SETS ( (), -- 全国汇总 (province), -- 各省汇总 (province, city) -- 各省各城市汇总 );结果集里,grp_province=1表示province字段是ROLLUP生成的ALL,grp_city=1同理。业务系统可根据这两个标志位,自动识别当前行的聚合层级,决定是否允许下钻。
但GROUPING SETS的威力不止于此。某物流客户要求“按承运商+线路+车型看运费,同时要线路维度的总计(忽略车型)和承运商维度的总计(忽略线路和车型)”。这需要四层组合:
GROUP BY GROUPING SETS ( (carrier, route, vehicle_type), -- 最细粒度 (carrier, route), -- 忽略车型 (carrier), -- 忽略线路和车型 () -- 全局总计 )这里的关键参数是GROUPING()函数返回的位掩码。它本质是二进制标识:GROUPING(carrier)=0表示carrier参与分组,=1表示被ROLLUP。我们可以用它做动态标签:
CASE WHEN GROUPING(carrier)=1 AND GROUPING(route)=1 AND GROUPING(vehicle_type)=1 THEN 'TOTAL' WHEN GROUPING(carrier)=0 AND GROUPING(route)=1 AND GROUPING(vehicle_type)=1 THEN 'CARRIER:' || carrier WHEN GROUPING(carrier)=0 AND GROUPING(route)=0 AND GROUPING(vehicle_type)=1 THEN 'ROUTE:' || route ELSE 'DETAIL' END as aggregation_level这样,下游BI工具拿到的就是自带语义标签的结果集,无需再写复杂逻辑判断层级。
实测经验:ClickHouse对GROUPING SETS优化极好,10亿行订单表聚合耗时从单条SQL的42秒(三次扫描)降到GROUPING SETS的15秒(一次扫描)。但注意:GROUPING SETS在MySQL 8.0以下不支持,PostgreSQL需9.5+,使用前务必确认引擎版本。
3.3 Post-Aggregation破局:用PIVOT和GENERATE_SERIES治愈时间序列焦虑
Post-Aggregation最常踩的坑是“时间序列不连续”。业务方要“近12个月销售额趋势图”,但数据库里只有有订单的月份有记录,比如2023年1月、3月、5月有数据,2月、4月为空。直接给BI工具,图表就断成三截。
传统方案是让BI工具自己补空,但Power BI的“显示所有值”选项在大数据量下卡顿,Tableau的Domain Padding又难控制。我们的标准解法是在Post-Aggregation层用SQL生成完整时间轴,再LEFT JOIN补空:
-- Step 1: 生成2023年1-12月完整序列 WITH full_months AS ( SELECT TO_CHAR(DATE '2023-01-01' + INTERVAL '1 month' * (n-1), 'YYYYMM') as yyyymm FROM generate_series(1,12) as n ), -- Step 2: 原始聚合结果(已含yyyymm字段) aggregated AS ( SELECT province, TO_CHAR(order_date, 'YYYYMM') as yyyymm, SUM(amount) as sales FROM orders WHERE order_date >= '2023-01-01' AND order_date < '2024-01-01' GROUP BY province, TO_CHAR(order_date, 'YYYYMM') ) -- Step 3: 补全所有省+所有月的组合 SELECT f.yyyymm, COALESCE(a.province, 'ALL') as province, -- 用ALL兜底,避免笛卡尔爆炸 COALESCE(a.sales, 0) as sales FROM full_months f CROSS JOIN (SELECT DISTINCT province FROM aggregated UNION SELECT 'ALL') p LEFT JOIN aggregated a ON f.yyyymm = a.yyyymm AND (a.province = p.province OR (p.province = 'ALL' AND a.province IS NOT NULL)) ORDER BY f.yyyymm, p.province;这段SQL的精妙之处在于:
CROSS JOIN (SELECT DISTINCT province... UNION SELECT 'ALL'):先生成所有省+ALL的维度组合,再与月份交叉,确保每个省都有12个月,ALL也有12个月LEFT JOIN ... ON ... OR (p.province = 'ALL' AND a.province IS NOT NULL):当province='ALL'时,把所有原始记录的sales加总到ALL行,实现自动上卷
但业务需求往往更复杂:“要每个省的月度销售,还要每个省的季度销售”。这时PIVOT登场。PostgreSQL 12+支持TABLE PIVOT,但兼容性差,我们用经典CASE WHEN方案:
SELECT province, SUM(CASE WHEN yyyymm IN ('202301','202302','202303') THEN sales ELSE 0 END) as q1_2023, SUM(CASE WHEN yyyymm IN ('202304','202305','202306') THEN sales ELSE 0 END) as q2_2023, ... FROM full_aggregated_result GROUP BY province;为防手写CASE出错,我们用Python脚本自动生成——把季度配置写在YAML里,脚本读取后输出SQL。这比在BI里拖拽季度字段稳定十倍。
4. 高频问题与避坑指南:那些文档里不会写的血泪教训
4.1 “空值陷阱”:为什么COUNT(*)和COUNT(col)差出十万八千里?
这是多维聚合中最隐蔽的坑。看这个真实案例:某SaaS公司统计“各套餐的付费客户数”,SQL写成:
SELECT plan_type, COUNT(*) as customer_count FROM customers GROUP BY plan_type;结果发现“Free”套餐客户数高达200万,但实际注册用户才150万。排查发现:表里有50万条记录的plan_type为NULL(新注册用户未选套餐)。COUNT(*)把NULL行也计入,而业务方要的“付费客户数”本意是“plan_type非空的客户”。
正确写法必须明确语义:
SELECT COALESCE(plan_type, 'Unselected') as plan_type, COUNT(*) FILTER (WHERE plan_type IS NOT NULL) as paying_customers, COUNT(*) as total_registered FROM customers GROUP BY 1;这里用了两个关键技巧:
COALESCE(plan_type, 'Unselected'):把NULL显式转为业务可理解的标签,避免GROUP BY产生神秘分组COUNT(*) FILTER (WHERE ...):PostgreSQL特有语法,精准计数满足条件的行。等价于SUM(CASE WHEN plan_type IS NOT NULL THEN 1 ELSE 0 END),但更简洁
在MySQL中,用COUNT(plan_type)即可,因为COUNT(列名)自动忽略NULL。但要注意:COUNT(plan_type)和COUNT(*)在有NULL时结果不同,这是MySQL的“特性”而非Bug。
踩坑实录:曾有个项目用
COUNT(id)统计订单数,结果比COUNT(*)少3%,因为id字段有3%的记录是NULL(上游系统bug)。我们立即加监控:每日校验COUNT(*) - COUNT(id),超过阈值自动告警。数据质量监控必须覆盖聚合前、中、后全链路。
4.2 “精度漂移”:浮点数聚合为何越算越不准?
财务类报表最怕这个。看这个例子:订单表有amount字段(DECIMAL(18,2)),按省份聚合:
SELECT province, SUM(amount) as total FROM orders GROUP BY province;结果发现:全国SUM(total) = 9999999.99,但SELECT SUM(amount) FROM orders= 10000000.00,差0.01元。
根源是浮点数累加误差。虽然amount是DECIMAL,但SUM过程中数据库可能用float中间计算(尤其在分布式引擎中)。解决方案分三层:
第一层:数据定义
- 金额字段必须用DECIMAL(p,s),禁用FLOAT/DOUBLE。我们规定:所有货币字段DECIMAL(18,2),汇率字段DECIMAL(18,6)
第二层:聚合控制
- 在ClickHouse中,用
sumDecimal函数替代sum:sumDecimal(amount)强制高精度累加 - 在Spark中,用
agg(sum(col.cast("decimal(18,2)")))确保中间态精度
第三层:结果校验
- 每日跑一致性检查:
SELECT ABS(SUM(total) - (SELECT SUM(amount) FROM orders)) < 0.01 FROM province_agg - 用
ROUND(SUM(amount), 2)包裹最终结果,但注意:ROUND应在最后一步做,避免中间ROUND放大误差
4.3 “维度爆炸”:当GROUP BY字段超10个时,如何避免查询超时?
某政务项目要求“按省、市、区、街道、社区、网格、楼栋、单元、楼层、房间号”10维统计人口,直接GROUP BY,ClickHouse OOM,Spark Executor Lost。
破局思路是分层聚合+物化中间结果:
- 第一层(粗粒度):按省、市、区聚合,产出dim_province_city_district_summary
- 第二层(中粒度):用第一层结果JOIN街道、社区表,按街道聚合,产出dim_street_summary
- 第三层(细粒度):用第二层结果JOIN楼栋表,按楼栋聚合
每层都用MATERIALIZED VIEW物化,查询时只扫当前层。10维需求被拆解为3次可控聚合,耗时从超时降到8秒。
关键技巧:在JOIN时用BROADCAST JOIN推送小表(如街道表仅10万行),避免Shuffle。我们用/*+ BROADCAST(streets) */提示Spark优化器。
4.4 “时间错位”:跨年周(ISO Week)如何正确聚合?
业务要“按周统计”,但12月31日可能是下一年的第1周。用EXTRACT(WEEK FROM date)在PostgreSQL中返回1-53,但2023-12-31是2024年第1周,导致数据错位。
正确方案是用ISO标准周:
-- PostgreSQL SELECT DATE_PART('year', date_trunc('week', order_date)::date) as iso_year, EXTRACT(WEEK FROM order_date) as iso_week, SUM(amount) FROM orders GROUP BY 1, 2 ORDER BY 1, 2;但注意:EXTRACT(WEEK FROM ...)在PostgreSQL中默认是ISO周,而MySQL的WEEK()函数默认不是。我们统一用DATE_FORMAT(order_date, '%x%v')(%x=ISO年,%v=ISO周)。
实操心得:所有时间维度字段,必须在Pre-Aggregation层就生成ISO_YEAR_WEEK、ISO_YEAR_QUARTER等标准化字段,禁止在BI层用函数计算——BI工具的时间函数兼容性差,且无法利用数据库索引。
5. 进阶实战:一个完整的多维聚合Pipeline案例
5.1 业务场景还原:某连锁药店的“门店健康度仪表盘”
需求原文:“要能看到全国所有门店的月度健康度评分,评分=0.3销售额同比+0.4客流同比+0.3*新客占比,其中同比要和去年同月比,新客占比=新客数/总客流。支持按省、市、区下钻,支持查看任意门店的近6个月趋势。”
表面看是简单计算,但暗藏杀机:
- 销售额同比:需处理去年同月无数据的情况(如新开店)
- 客流同比:客流表和销售表是两张独立表,需精确时间对齐
- 新客占比:新客定义是“首次到店”,需用窗口函数查min(visit_date)
- 下钻支持:需保证省→市→区层级数据可累加
5.2 Pipeline设计:五步走完数据操作闭环
Step 1: Pre-Aggregation — 构建统一事实表
- 从sales表提取:store_id, sale_date, amount
- 从traffic表提取:store_id, visit_date, visitor_count
- 从visits表(全量到店记录)提取:store_id, visit_date, customer_id
- 用store_id关联三表,生成fact_store_daily:
WITH first_visit AS ( SELECT customer_id, store_id, MIN(visit_date) as first_visit_date FROM visits GROUP BY customer_id, store_id ), daily_traffic AS ( SELECT t.store_id, t.visit_date, t.visitor_count, COUNT(DISTINCT CASE WHEN v.first_visit_date = t.visit_date THEN v.customer_id END) as new_visitor_count FROM traffic t LEFT JOIN first_visit v ON t.store_id = v.store_id AND t.visit_date = v.first_visit_date GROUP BY t.store_id, t.visit_date, t.visitor_count ) SELECT COALESCE(s.store_id, t.store_id) as store_id, COALESCE(s.sale_date, t.visit_date) as date, COALESCE(s.amount, 0) as sales_amount, COALESCE(t.visitor_count, 0) as traffic_count, COALESCE(t.new_visitor_count, 0) as new_visitor_count FROM sales s FULL OUTER JOIN daily_traffic t ON s.store_id = t.store_id AND s.sale_date = t.visit_date;
Step 2: In-Aggregation — 多级时间聚合
- 按store_id, year_month聚合基础指标:
SELECT store_id, TO_CHAR(date, 'YYYYMM') as yyyymm, SUM(sales_amount) as monthly_sales, SUM(traffic_count) as monthly_traffic, SUM(new_visitor_count) as monthly_new_visitor FROM fact_store_daily GROUP BY store_id, TO_CHAR(date, 'YYYYMM');
Step 3: In-Aggregation — 注入同比逻辑
- 用LAG窗口函数计算同比:
SELECT store_id, yyyymm, monthly_sales, LAG(monthly_sales, 12) OVER (PARTITION BY store_id ORDER BY yyyymm) as sales_ly, CASE WHEN LAG(monthly_sales, 12) OVER (PARTITION BY store_id ORDER BY yyyymm) > 0 THEN (monthly_sales - LAG(monthly_sales, 12) OVER (PARTITION BY store_id ORDER BY yyyymm)) / LAG(monthly_sales, 12) OVER (PARTITION BY store_id ORDER BY yyyymm) ELSE NULL END as sales_yoy FROM monthly_agg;
Step 4: Post-Aggregation — 生成健康度评分
- 用Pandas加载聚合结果,计算综合评分:
df['health_score'] = ( 0.3 * df['sales_yoy'].fillna(0) + 0.4 * df['traffic_yoy'].fillna(0) + 0.3 * df['new_visitor_ratio'].fillna(0) ) # 对新开店(去年无数据)设默认分0.5 df.loc[df['sales_yoy'].isna() & df['traffic_yoy'].isna(), 'health_score'] = 0.5
Step 5: Post-Aggregation — 构建维度层级视图
- 用SQL生成省→市→区层级:
WITH store_geo AS ( SELECT store_id, province, city, district FROM dim_stores ), store_score AS ( SELECT store_id, yyyymm, health_score FROM health_scores ) SELECT 'PROVINCE' as level, province as name, yyyymm, AVG(health_score) as score FROM store_geo g JOIN store_score s ON g.store_id = s.store_id GROUP BY province, yyyymm UNION ALL SELECT 'CITY' as level, city as name, yyyymm, AVG(health_score) as score FROM store_geo g JOIN store_score s ON g.store_id = s.store_id GROUP BY city, yyyymm;
这个Pipeline运行后,BI工具直接消费最后一张视图,拖拽level、name、yyyymm就能出图。全程无手工补数,无Excel加工,所有逻辑可审计、可回滚。
最后分享一个小技巧:我们在每个Pipeline步骤后加
SELECT COUNT(*) FROM step_x_result并记录到监控表。当某天step_2的count突然比step_1少50%,就知道是JOIN条件写错了——把监控埋进数据操作链,比事后救火强百倍。