多维聚合中的数据操纵:维度对齐、稀疏填充与路径约束实战

多维聚合中的数据操纵:维度对齐、稀疏填充与路径约束实战 1. 项目概述当数据聚合从“加总”升级为“空间导航”你有没有遇到过这样的场景销售报表里只显示“华东区Q3总销售额1280万元”但当你点开下钻发现上海贡献了920万而江苏、浙江加起来才360万——这背后其实是同一组原始数据在不同维度组合下的投影结果。Multi-Dimensional Aggregation多维聚合不是简单地把数字加起来而是像用一组可旋转的棱镜去折射数据流每一次转动角度切换维度看到的光谱聚合结果都完全不同。而Data Manipulation in Multi-Dimensional Aggregation说白了就是掌握这套棱镜的调焦、滤光、叠加与重定向能力。它不是SQL里一个GROUP BY就能打发的事而是涉及维度建模、聚合路径控制、稀疏数据填充、跨层级计算一致性等一整套工程实践。我做过7个大型BI平台的数据建模最深的教训是90%的报表性能瓶颈和逻辑错误根源不在查询语句本身而在多维聚合前的数据操纵阶段没做对。这篇文章不讲理论推导只讲我在金融风控、电商实时看板、IoT设备指标分析三个真实项目中反复验证过的实操路径——从如何定义“合法的维度组合”到为什么必须预计算部分中间聚合层再到如何让一个指标在“按产品线时间”和“按区域客户等级”两种视图下保持数学自洽。适合正在搭建数仓、开发BI系统、或被老板一句“再加个下钻维度”就卡住的工程师和分析师。你不需要会写MDX或DAX但得理解维度不是标签是坐标轴聚合不是终点是新数据空间的起点。1.1 核心需求解析为什么“操纵”比“聚合”更关键很多人把“Data Manipulation”理解成清洗、去重、类型转换这类前置动作但在多维聚合语境下它的内涵要尖锐得多。我们来看一个典型冲突某电商平台要求同时支持两种分析视角——视角A“过去30天各品类在一线城市的GMV占比”维度时间[30天] × 城市等级[一线] × 品类视角B“高价值客户RFM评分85在各城市的客单价分布”维度客户分层[高价值] × 城市[具体城市名] × 指标[客单价]表面看只是GROUP BY字段不同但实际执行时会暴露出三个致命问题第一基数爆炸如果直接对全量订单表按“城市品类客户ID”三者联合分组仅北京上海广州深圳四个城市×300个核心品类×50万活跃客户组合数就超600亿内存直接OOM第二语义断裂视角A中的“一线城市”是行政分类北京/上海/广州/深圳/杭州/南京等而视角B中的“城市”是地理实体需精确到北京市朝阳区二者粒度不一致强行JOIN会导致数据漂移第三指标失真客单价总GMV/订单数但若在“城市×客户分层”粒度上先算GMV再除以订单数会丢失“同一客户在多城市下单”的关联性导致分母被重复计算。这些问题无法靠优化SQL解决必须在聚合前进行有目的的数据操纵比如将客户ID映射到其常驻城市解决语义断裂对高价值客户预生成“城市归属快照表”规避基数爆炸用窗口函数在客户ID粒度上先算单客GMV再向上聚合保障指标数学严谨。这就是为什么标题强调“Manipulation”而非“Aggregation”——聚合是结果操纵是手术刀没有精准的切片、缝合与重构再华丽的聚合视图都是沙上之塔。1.2 领域影响范围从OLAP引擎到前端渲染的全链路渗透这个主题的影响远超数据库层面。我曾参与一个跨境物流公司的实时运费看板项目最初团队认为只要把ClickHouse的物化视图建好就万事大吉结果上线后发现后端API响应时间从200ms飙升至2.3秒因前端强制要求“任意维度下钻时保留当前筛选器状态”导致每次请求都要动态重算所有可能的维度组合移动端App频繁崩溃因前端用JavaScript手动拼接维度过滤条件当用户连续点击5个筛选项后生成的WHERE子句长度超8KBV8引擎解析失败客服部门投诉报表数据矛盾因运营侧按“始发国目的国”看趋势而财务侧按“承运商服务等级”算成本两套聚合逻辑未对齐同一笔订单在不同视图中被计入不同成本池。根本原因在于多维聚合的数据操纵策略决定了整个数据链路的拓扑结构在存储层它决定是否采用星型模型还是雪花模型是否需要冗余维度属性如把“城市所属大区”直接存进事实表在计算层它决定使用预聚合Materialized View还是实时计算如Flink的KeyedState以及预聚合的粒度边界例如是否为“国家×月份”单独建一张汇总表在服务层它决定API设计是RESTful资源式每个维度组合对应一个endpoint还是GraphQL式客户端声明所需维度服务端动态编译在应用层它甚至影响前端组件库的设计——我们后来为BI工具自研了一套“维度约束引擎”当用户拖拽“产品线”到行区域时自动禁用“供应商等级”这个不可并存的维度因为底层数据模型中二者属于正交维度树的不同分支。所以这不是一个“怎么写GROUP BY”的技巧问题而是一个数据契约的设计问题你向业务方承诺的每一个可交互维度背后都是一套经过严格验证的数据操纵逻辑。漏掉任何一个环节都会在下游引发雪崩式信任危机。2. 核心细节解析与实操要点四类必须预处理的关键操纵在真实项目中我总结出四类高频且高风险的数据操纵操作它们共同构成了多维聚合的“安全护栏”。跳过其中任何一类轻则报表不准重则系统瘫痪。下面结合具体案例说明每类操作的触发条件、技术实现和避坑要点。2.1 维度对齐Dimension Alignment解决“同名不同义”的语义鸿沟典型场景某银行信用卡中心要分析“分期付款用户的地域分布”但数据源有三处核心交易系统记录每笔分期订单含user_id和transaction_city用户下单时填写的城市客户主数据系统含user_id和registered_province开户时登记的省份地理信息系统含city_name和province_code标准行政区划编码。问题来了transaction_city浦东新区在交易系统中是字符串但在GIS中需映射为province_codeSH而registered_province上海市又和province_codeSH不完全等价前者是自然语言后者是ISO编码。若不做对齐直接JOIN会产生大量NULL值或错误匹配。实操方案构建维度对齐中间表Alignment Bridge Table-- 步骤1标准化城市名称使用开源库libpostal或自研规则 CREATE TABLE city_standardized AS SELECT city_raw, CASE WHEN city_raw ~* 浦东|浦西|徐汇|静安 THEN 上海市 WHEN city_raw IN (BJ, Beijing) THEN 北京市 ELSE TRIM(BOTH FROM city_raw) END AS city_std, -- 步骤2映射到标准编码引用民政部最新区划表 COALESCE(d.code, UNK) AS province_code FROM raw_transaction t LEFT JOIN civil_affairs_district d ON t.city_std d.city_name OR t.city_std d.city_alias;提示绝对不要在JOIN时用模糊匹配如LIKE %浦东%我吃过亏——某次促销活动期间大量用户在transaction_city里填了“浦东新区-XX商场”导致匹配到“广西浦北县”最终上海地区GMV虚高37%。正确做法是建立确定性映射字典并设置UNK未知兜底码。关键参数选择逻辑对齐粒度必须统一到业务可解释的最小单位。例如金融风控中“城市”必须精确到地级市非省级因为贷款政策按地级市制定而物流时效分析中“城市”可放宽到省级因省内运输时效差异小。映射关系必须支持版本化管理。我们用Git管理city_mapping.csv每次区划调整如雄安新区设立都提交新版本并在ETL任务中指定mapping_version2024Q2确保历史报表可复现。2.2 稀疏填充Sparse Filling让“空值”变成有意义的零典型场景IoT设备监控平台要展示“各型号设备在过去24小时的在线率”但设备上报是非均匀的——高端型号每5分钟心跳一次低端型号每小时才报一次。若直接按device_model × hour分组统计会发现低端型号在多数小时桶里没有记录导致在线率计算为NULL而非0%进而使平均在线率严重偏高。实操方案用时间序列填充Time Series Gap Filling在ClickHouse中我们采用arrayJoinrange生成完整时间桶再LEFT JOIN原始数据-- 生成24小时完整时间序列每小时一个桶 WITH time_buckets AS ( SELECT toStartOfHour(addHours(now(), -24)) number * 3600 AS hour_start FROM numbers(24) ), -- 原始数据按设备型号和小时聚合 raw_agg AS ( SELECT device_model, toStartOfHour(event_time) AS hour_start, count() AS heartbeat_count FROM iot_heartbeat WHERE event_time now() - INTERVAL 24 HOUR GROUP BY device_model, hour_start ) -- 关键用ARRAY JOIN补全缺失小时 SELECT b.device_model, b.hour_start, COALESCE(r.heartbeat_count, 0) AS heartbeat_count, -- 在线率 实际心跳数 / 理论应有心跳数按设备型号的上报频率设定 round(COALESCE(r.heartbeat_count, 0) / (CASE WHEN b.device_model PRO-X1 THEN 12 -- 每5分钟1次 → 12次/小时 WHEN b.device_model BASIC-V2 THEN 1 -- 每小时1次 END), 4) AS online_rate FROM ( SELECT DISTINCT device_model FROM raw_agg ) AS models CROSS JOIN time_buckets AS b LEFT JOIN raw_agg AS r ON models.device_model r.device_model AND b.hour_start r.hour_start;注意这里CROSS JOIN生成笛卡尔积是可控的因为models表最多几百个型号time_buckets固定24行总行数1万。但若维度扩展到“设备型号×区域×固件版本”就必须改用JOIN替代CROSS JOIN否则内存爆炸。为什么不能用COALESCE(col, 0)因为COALESCE只能处理已存在行的NULL值而稀疏数据的本质是整行缺失。就像你查“昨天北京的PM2.5”如果监测站没上报数据库里根本没有这条记录COALESCE根本无从下手。必须先用算法“造出”本该存在的行再填充默认值。2.3 路径约束Path Constraint防止非法维度组合污染结果集典型场景零售企业数据模型中维度表结构如下dim_product产品含product_id,category,brand,is_premium是否高端dim_store门店含store_id,region,city,store_type旗舰店/社区店fact_sales销售事实含product_id,store_id,sale_date,amount业务方提出需求“分析高端产品在旗舰店的销售占比”。表面看只需WHERE is_premium1 AND store_typeflagship但问题在于并非所有高端产品都在旗舰店销售——部分高端产品只在官网销售其store_id为NULL而部分旗舰店也卖平价产品。若不做路径约束GROUP BY region, city时NULL的store_id会被归入“未知区域”导致区域占比失真。实操方案在事实表加载时注入维度路径标识-- ETL任务中为每条销售记录打上“有效维度路径”标签 INSERT INTO fact_sales_enhanced SELECT s.*, -- 路径1实体门店销售store_id非空且产品在该店有铺货 CASE WHEN s.store_id IS NOT NULL AND EXISTS (SELECT 1 FROM dim_store_inventory i WHERE i.store_id s.store_id AND i.product_id s.product_id) THEN STORE_SALES -- 路径2线上直销store_id为空但渠道标记为online WHEN s.store_id IS NULL AND s.channel online THEN ONLINE_DIRECT ELSE OTHER END AS sales_path FROM fact_sales s;后续所有多维聚合查询都强制要求WHERE sales_path IN (STORE_SALES, ONLINE_DIRECT)并禁止在sales_pathOTHER的数据上做业务分析。我们在BI工具前端做了硬性拦截当用户选择“旗舰店”维度时自动过滤掉sales_path ! STORE_SALES的数据避免业务人员误用。实操心得路径约束必须在数据接入源头完成而不是在报表层用WHERE过滤。因为后者会导致同一份物理数据被多次扫描不同报表用不同WHERE而前者只需一次计算所有下游消费方自动受益。我们曾因此将某报表集群的CPU负载从92%降至35%。2.4 层级折叠Hierarchy Folding让“钻取”不等于“重新计算”典型场景某教育SaaS平台要支持“课程销量”下钻顶层全国总销量下钻1按省份34个下钻2按城市300个下钻3按区县2800个若每次下钻都重新扫描全量订单表按区县粒度聚合单次查询耗时超40秒。而用户期望的是毫秒级响应。实操方案预计算层级折叠表Hierarchical Fold Table-- 创建预聚合表包含所有可能的层级组合 CREATE TABLE course_sales_folded AS SELECT -- 全国汇总level0 ALL AS country, NULL AS province, NULL AS city, NULL AS district, 0 AS level, sum(amount) AS total_amount, count(*) AS order_count FROM fact_orders UNION ALL -- 省份汇总level1 SELECT ALL AS country, province, NULL AS city, NULL AS district, 1 AS level, sum(amount) AS total_amount, count(*) AS order_count FROM fact_orders o JOIN dim_location l ON o.location_id l.id GROUP BY province UNION ALL -- 城市汇总level2 SELECT ALL AS country, province, city, NULL AS district, 2 AS level, sum(amount) AS total_amount, count(*) AS order_count FROM fact_orders o JOIN dim_location l ON o.location_id l.id GROUP BY province, city UNION ALL -- 区县汇总level3 SELECT ALL AS country, province, city, district, 3 AS level, sum(amount) AS total_amount, count(*) AS order_count FROM fact_orders o JOIN dim_location l ON o.location_id l.id GROUP BY province, city, district;前端BI工具根据用户当前钻取层级自动选择对应level的查询路径。例如用户在省份视图就查level1的数据点击某个省份下钻到城市就查level2且province广东省的数据。关键技巧在ClickHouse中给level字段建ORDER BY (level, province, city, district)利用其稀疏索引特性使level2 AND province广东省的查询速度提升17倍。3. 实操过程与核心环节实现从零搭建一个抗压的多维聚合管道下面以我主导的某跨境电商“实时广告ROI看板”项目为例完整还原从需求确认到上线的72小时实操过程。该项目要求支持毫秒级响应P95 300ms任意组合维度下钻渠道×国家×设备类型×广告位数据延迟 2分钟支持100并发查询整个管道采用Lambda架构批处理层T1离线聚合 速度层实时流聚合 服务层统一查询接口。3.1 需求拆解与维度契约定义第1小时第一步不是写代码而是和业务方一起画维度契约图Dimension Contract Diagram。我们用白板列出所有候选维度channel广告渠道Google/Facebook/TikTok/自有APPcountry投放国家US/UK/CA/AU/DE...共47国device_type设备类型mobile/desktop/tabletad_position广告位search_banner/home_feed/product_detailcampaign_type活动类型new_user_acquisition/retention_promotion然后逐个验证正交性检查campaign_type和channel是否正交否因为TikTok不支持retention_promotion活动必须在契约中标记campaign_type在channelTikTok时的合法取值为[new_user_acquisition]基数预估country × channel × device_type × ad_position最大组合数 47×4×3×4 2256远低于ClickHouse单表10万分区的推荐阈值可接受业务权重排序80%查询集中在channel × country其次channel × device_type因此这两组组合必须预计算其余组合允许实时计算。最终输出《维度契约V1.0》文档明确强制预计算维度组合channel × country,channel × device_type允许实时计算维度组合country × device_type × ad_position因业务方确认此组合月查询量50次禁止组合campaign_type × ad_position因无业务意义这一步节省了后续30%的开发时间。曾有团队跳过契约定义直接建模结果上线后发现Facebook渠道的ad_position只有news_feed一种取值导致channel × ad_position预聚合表99%的分区为空浪费了2TB存储。3.2 批处理层构建稳定基线第2-12小时使用Spark SQL构建T1离线聚合核心是分层物化策略L0层原子事实ads_impression_log曝光日志、ads_click_log点击日志、order_fact成交事实全部按dt日期分区存储L1层轻度聚合按channel × country × dt预聚合曝光量、点击量、花费SQL中强制GROUP BY channel, country, dt不加任何WHERE保证数据完整性L2层重度聚合按channel × country忽略日期计算累计ROI但不直接SUM而是用SUM_IF-- 关键用条件聚合避免重复计算 SELECT channel, country, sumIf(cost, dt 2024-01-01) AS total_cost, sumIf(clicks, dt 2024-01-01) AS total_clicks, sumIf(conversions, dt 2024-01-01) AS total_conversions, -- ROI (收入 - 成本) / 成本但收入来自订单事实表需JOIN sumIf(o.revenue, dt 2024-01-01) - sumIf(cost, dt 2024-01-01) AS net_profit FROM l1_aggregated a LEFT JOIN order_fact o ON a.channel o.channel AND a.country o.country AND a.dt o.order_date GROUP BY channel, country;注意这里sumIf比WHERE dt...更安全因为即使某天数据延迟到达也能被纳入统计避免基线跳变。我们曾因此避免了一次重大事故——某天Facebook数据延迟6小时若用WHERE过滤当天ROI会显示为0触发错误告警。3.3 速度层实时流聚合的三大陷阱第13-36小时用Flink SQL构建实时流处理Kafka中的ads_event流含曝光、点击、转化事件。这里踩过三个深坑陷阱1事件乱序导致聚合错误广告事件存在严重乱序用户上午点击下午才转化但转化事件因网络问题晚于点击事件到达。若直接TUMBLING WINDOW会漏计转化。解法启用Flink的ALLOW LATENESS机制-- 设置水位线延迟10分钟允许迟到事件 CREATE TABLE ads_events AS SELECT channel, country, device_type, COUNT(*) FILTER (WHERE event_type impression) AS impressions, COUNT(*) FILTER (WHERE event_type click) AS clicks, COUNT(*) FILTER (WHERE event_type conversion) AS conversions FROM ads_event_stream GROUP BY TUMBLING(rowtime, INTERVAL 1 MINUTE), channel, country, device_type -- 关键允许10分钟内迟到的事件触发更新 WITH WATERMARK FOR rowtime AS rowtime - INTERVAL 10 MINUTES;陷阱2状态膨胀State Explosion按channel × country × device_type分组若country有47个device_type有3种channel有4个理论上状态键最多564个。但实际运行中Flink WebUI显示状态键超2000个原因是country字段存在脏数据如countryUSA 带空格、countryUnited States全称。解法在Source Connector中注入清洗UDF// 自定义Flink UDF public class CountryNormalizer extends ScalarFunction { private static final MapString, String COUNTRY_MAP Map.of( USA, US, US , US, United States, US, UK, GB, Great Britain, GB ); public String eval(String rawCountry) { return COUNTRY_MAP.getOrDefault(rawCountry.trim().toUpperCase(), UNK); } }在SQL中调用SELECT country_normalizer(country) AS country, ...陷阱3实时-离线数据不一致实时流计算的clicks比离线批处理少3.2%排查发现离线层用click_log表而实时流用ads_event流二者对“有效点击”的定义不同离线层过滤了机器人流量。解法在实时流中复刻离线清洗逻辑用同一个is_human_clickUDFSELECT ..., COUNT(*) FILTER (WHERE is_human_click(event_payload)) AS human_clicks FROM ads_event_stream;确保两个口径完全一致。3.4 服务层统一查询网关的实现第37-72小时最后一步是构建查询服务层暴露REST API。我们放弃GraphQL学习成本高采用维度路由预编译查询模板方案步骤1定义维度路由规则{ route_rules: [ { name: channel_country, dimensions: [channel, country], table: ads_roi_l2, template: SELECT channel, country, roi FROM ads_roi_l2 WHERE {filters} }, { name: realtime_channel_device, dimensions: [channel, device_type], table: ads_realtime_agg, template: SELECT channel, device_type, clicks FROM ads_realtime_agg WHERE window_end now() - INTERVAL 5 MINUTE AND {filters} } ] }步骤2动态SQL编译引擎当收到请求GET /api/roi?channelgooglecountryusdevice_typemobile时解析参数识别出channel和country在channel_country规则中但device_type不在自动降级先查channel_country获取基础ROI再用device_type作为过滤条件因ads_roi_l2表中本就含device_type字段若参数含ad_position未预计算维度则路由到实时流表用WHERE ad_position search_banner过滤。关键性能优化所有预编译模板的WHERE子句都强制转为ClickHouse的PREWHERE比WHERE快3-5倍对高频查询如channelgoogle在应用层加Guava CacheTTL30秒命中率82%拒绝任何SELECT *强制要求客户端声明所需字段减少网络传输。上线后实测P95响应时间217ms目标300ms最大并发142目标100数据延迟98秒目标120秒4. 常见问题与排查技巧实录那些文档里不会写的血泪经验在20个多维聚合项目中我整理出一份高频问题速查表。这些问题往往在测试环境无法复现只有在真实高并发、数据倾斜、网络抖动场景下才会爆发。下面分享5个最具代表性的案例附带我的排查思路和根治方案。4.1 问题聚合结果随机波动同一查询两次执行结果不同现象某金融客户报表中“各分行不良贷款率”每天刷新时上海分行的数值在3.21%和3.25%之间跳变无规律。排查路径首先排除缓存直连数据库执行相同SQL结果仍跳变 → 非应用层缓存问题检查数据源发现loan_fact表中bad_loan_flag字段为VARCHAR取值为true/false但部分ETL任务用CAST(bad_loan_flag AS BOOLEAN)而ClickHouse对字符串转布尔的规则是true→true1→trueon→true但TRUE大写→NULL追溯数据发现上游系统在某次版本升级后将true改为TRUE导致这批数据在聚合时被过滤因WHERE bad_loan_flag true不匹配NULL。根治方案在ETL清洗层强制UPPER(bad_loan_flag) IN (TRUE, 1, ON)→1否则0在维度表中增加data_quality_score字段对bad_loan_flag的取值分布做监控当TRUE占比突增5%时告警。这个案例教会我多维聚合的稳定性始于对每一个布尔字段、每一个字符串枚举值的敬畏。我们后来在所有项目中推行“枚举值白名单”机制任何新出现的取值都需走变更审批流程。4.2 问题下钻到某一层级后数据量暴增10倍查询超时现象电商看板中“品类×品牌”聚合正常但下钻到“品类×品牌×SKU”时ClickHouse查询超时120秒。排查路径查看EXPLAIN发现JOIN dim_sku时ClickHouse选择了dim_sku表的全表扫描而非dim_product的索引检查表结构dim_sku有1200万行dim_product有80万行但dim_sku.sku_id未建索引而dim_product.product_id有主键索引根本原因dim_sku表设计为宽表含200属性列为节省存储未建二级索引但JOIN时ClickHouse无法高效定位。根治方案不建索引改用物化视图CREATE MATERIALIZED VIEW dim_sku_lookup ENGINE ReplacingMergeTree ORDER BY (product_id, sku_id) AS SELECT product_id, sku_id, brand, category FROM dim_sku;查询时JOIN dim_sku_lookup而非dim_sku性能提升22倍同时在BI工具中限制当用户选择SKU维度时自动禁用category和brand因二者已在dim_sku_lookup中冗余避免重复JOIN。4.3 问题实时流聚合指标与离线报表相差20%且无法对齐现象广告平台中实时看板显示“今日ROI12.3%”而T1离线报表为“昨日ROI15.1%”差值过大。排查路径时间窗口对齐确认实时流用now()-INTERVAL 24 HOUR离线用dt2024-01-01时间范围一致数据源比对发现实时流用kafka_ads_events离线用hive.ads_events二者event_time字段精度不同——实时流为毫秒级1672531200123离线为秒级1672531200导致实时流多计入了23毫秒内的事件更致命的是离线ETL中对event_time做了to_date(event_time)转换而to_date(1672531200123)在Hive中返回1970-01-01Unix时间戳解析错误根治方案统一时间戳处理规范所有系统强制使用BIGINT存储毫秒时间戳且在ETL中用from_unixtime(cast(event_time/1000 as bigint))转换建立“时间戳健康度”监控每日校验实时流与离线表的event_time分布当秒级精度事件占比99.9%时告警。4.4 问题新增一个维度后所有报表响应时间翻倍现象在“用户行为分析”系统中新增utm_source维度来源渠道后原本报表P95从150ms升至320ms。排查路径EXPLAIN显示新增维度后ClickHouse的GROUP BY从city, os变为city, os, utm_source导致哈希表内存占用从2GB升至8GB检查utm_source基数发现其取值达12000含大量拼写错误如goolge/facebok/twiiter远超预期的100个根本原因前端埋点SDK未做标准化直接上报UTM参数未经过清洗。根治方案在数据接入网关层做标准化用正则REGEXP_REPLACE(utm_source, [^a-zA-Z0-9_], _)统一格式实施“维度值熔断”当某维度的唯一值数500时自动触发告警并将超出部分归入OTHER对高频维度建布隆过滤器在ClickHouse中创建BLOOM_FILTER索引加速WHERE utm_source IN (...)查询。4.5 问题跨维度聚合时指标数值出现“幻影增长”现象某SaaS公司要计算“各功能模块的客户满意度”满意度满意客户数/总客户数。当按module模块和customer_tier客户等级两个维度聚合时满意客户数总和比按单维度module聚合时高出15%。排查路径发现问题本质是指标的可加性Additivity被破坏满意度是比率指标不可直接跨维度相加具体计算按module聚合时对每个模块分别算sum(satisfied)/sum(total)而按module × customer_tier聚合后再按module汇总时是