多维聚合实战:用Pandas+PyArrow构建可交互数据立方体 1. 项目概述当数据不再是一张“平铺直叙”的表格你有没有遇到过这样的场景销售部门要按季度、按区域、按产品大类看毛利同时还要对比去年同期财务团队需要把成本拆解到“部门-项目-费用类型-发生月份”四个维度再筛选出超预算的组合甚至一个简单的用户行为分析都要交叉统计“新老用户 × 设备类型 × 页面路径 × 时间段”的点击热力图。这时候Excel 的透视表点到第三层就开始卡顿SQL 里写个 GROUP BY 带上四个字段结果一跑就是五分钟还经常漏掉某个维度的空值组合——这根本不是数据量的问题而是你还在用“二维思维”处理多维现实。Multi-Dimensional Aggregation多维聚合说白了就是把数据当成一个有长、宽、高、甚至时间轴的立方体来切片、切块、钻取和旋转。它不是简单地“分组求和”而是构建一套可动态导航的数据骨架。而Data Manipulation in Multi-Dimensional Aggregation正是这个骨架上最核心的“关节活动”——它决定了你能不能在不重建模型的前提下自由地增删维度、调整层级、计算衍生指标、合并异构来源甚至让不同业务线的聚合口径在同一个底座上对齐。这不是数据库工程师的专利而是今天每个要从数据中拿结论的产品经理、运营分析师、BI 开发者都绕不开的基本功。本文不讲 OLAP 理论只聊我在三个真实项目里如何用 Python Pandas PyArrow 手动“拧紧”这些关节把一张扁平的订单表变成能支撑 27 种业务视图的活体数据立方体。2. 多维聚合的本质设计为什么不能直接 GROUP BY 四个字段2.1 从“表格思维”到“立方体思维”的认知跃迁很多人第一次接触多维聚合下意识就往 SQL 的 GROUP BY 上套。比如有一张sales_fact表字段包括order_date,region,product_category,sales_rep,amount。要查“各区域各品类的季度销售额”自然写出SELECT EXTRACT(YEAR FROM order_date) AS year, EXTRACT(QUARTER FROM order_date) AS quarter, region, product_category, SUM(amount) AS total_amount FROM sales_fact GROUP BY 1, 2, 3, 4;这看起来很完美。但问题立刻浮现如果某区域某季度某品类没有销售记录结果里就彻底消失而业务方明确要求“显示为 0”如果要加一个“同比增速”就得把今年和去年的数据拉到同一行这意味着要自连接或窗口函数SQL 变得极其臃肿如果产品经理突然说“把‘华东’和‘华南’合并成‘南方大区’”你得改 GROUP BY 的 WHERE 条件还得同步更新所有下游报表最致命的是当你要从“区域×品类”下钻到“区域×品类×销售代表”时发现sales_rep字段在原始事实表里是空值比例高达 40% 的脏数据直接 GROUP BY 会丢失大量信息。这些问题的根源在于传统 GROUP BY 是一种静态、扁平、无结构的操作。它把数据当作一堆散落的积木强行堆成一堵墙而多维聚合要求你先搭好一个带轨道、带滑槽、带锁扣的立体框架再把积木按规则嵌进去。这个框架就是Dimensional Model维度模型。2.2 维度模型的三要素事实、维度、层级缺一不可一个健壮的多维聚合方案必须显式定义三个核心组件事实表Fact Table它不是原始业务表而是经过清洗、标准化后的“度量容器”。关键原则是原子性每行代表一个不可再分的业务事件如一笔订单、一次点击而非汇总结果外键化所有描述性字段如地区、时间、产品全部被替换为整数型外键region_id,date_id,product_id主键通常是这些外键的组合度量集中只保留可加性数值amount,quantity,duration_ms所有计算逻辑如毛利率收入-成本必须后置到聚合层。维度表Dimension Table它是事实表的“说明书”。每个维度表独立存在包含该维度的所有可能取值及其丰富属性。例如dim_region表region_idregion_nameregion_levelparent_region_idis_activecreated_at101华东1NULLtrue2023-01-01102华南1NULLtrue2023-01-01103上海市2101true2023-01-01104广州市2102true2023-01-01提示region_level和parent_region_id构成了层级关系Hierarchy这是实现“上卷Roll-up”和“下钻Drill-down”的物理基础。没有这个你的“华东”就永远无法自动包含“上海市”。聚合粒度与预计算策略不是所有聚合都要实时计算。我们根据访问频次和时效性将聚合分为三级实时聚合Real-time用于监控大屏只保留最近 2 小时的分钟级汇总用内存数据库如 Redis Sorted Set准实时聚合Near-real-timeT1 日报用 Spark 或 DuckDB 每日凌晨跑批生成“日期×区域×品类”基础立方体离线聚合Offline月度经营分析用 Snowflake 或 BigQuery 预计算所有常见组合并物化为视图。我坚持一个经验95% 的业务需求都能被覆盖在 5 个核心维度时间、区域、产品、客户、渠道的两两或三三组合内。所以我们的第一版立方体只聚焦这 5 个维度的笛卡尔积而不是盲目追求“全维度爆炸”。2.3 为什么选择 Pandas PyArrow 而非纯 SQL 或专用 OLAP 引擎在选型阶段我们对比了三种主流路径方案优势我们放弃它的原因纯 SQLSnowflake/BigQuery语法统一、权限成熟、并发强业务逻辑变更需 DBA 审批迭代周期长达 3 天无法在本地快速验证复杂衍生指标如“复购率”需跨订单关联成本随查询量线性增长专用 OLAPApache Kylin/Doris查询秒级、支持高并发、内置多维模型学习成本高运维复杂模型变更需重建 Cube一次耗时 6 小时对非结构化文本维度如用户标签支持弱Python 生态Pandas PyArrow完全掌控逻辑、调试直观、可与 ML 流程无缝集成、本地秒级验证初期担心性能但实测 PyArrow 的列式内存和 Pandas 的groupby().agg()在千万级事实表上聚合耗时仅 1.8 秒最终拍板的关键决策点是一个真实的业务场景市场部要计算“抖音渠道新客的 7 日留存率”这个指标需要① 从fact_click中筛选抖音来源的新客首次点击② 关联fact_order找到他们在 7 天内是否下单③ 再关联dim_user获取用户画像做交叉分析。用 SQL 写需要三层嵌套子查询 LEFT JOIN CASE WHEN可读性极差而用 Pandas我们可以清晰地拆解为三个.merge()步骤每一步的中间结果都能df.head()查看出了问题一眼定位。更重要的是这个逻辑可以打包成一个RetentionCalculator类被其他项目直接 import 复用。工具的价值不在于它多快而在于它让复杂逻辑变得可理解、可测试、可协作。3. 核心数据操作详解在立方体上“拧螺丝”的七种手法3.1 手法一维度对齐Dimension Alignment——解决“同名不同义”的脏数据问题现场上游 CRM 系统导出的region字段是 “华东大区”而 ERP 系统里是 “华东区”BI 工具里又叫 “East China”。更糟的是CRM 里“华北”包含北京、天津、河北而 ERP 里“华北”只含北京、天津。如果直接用字符串匹配聚合结果会错乱。解决方案建立统一维度代理键Surrogate Key我们不碰原始字符串而是创建一个dim_region_master主维度表作为所有系统的“翻译官”# dim_region_master.csv (由数据治理团队维护) region_code,region_name_cn,region_name_en,standard_region_id,parent_id,valid_from,valid_to CRM_EAST,华东大区,East China Region,101,0,2023-01-01,9999-12-31 ERP_EAST,华东区,East China,101,0,2023-01-01,9999-12-31 CRM_NORTH,华北,North China,102,0,2023-01-01,2023-06-30 ERP_NORTH,华北,North China,102,0,2023-07-01,9999-12-31 # 在 ETL 过程中为每个来源系统做映射 def align_region(df: pd.DataFrame, source_system: str) - pd.DataFrame: # 加载对应系统的映射表 mapping_df pd.read_csv(fmappings/{source_system}_region_mapping.csv) # 用左连接完成对齐缺失值填充为 -1表示未知 df_aligned df.merge( mapping_df, left_onregion_raw, right_onregion_code, howleft ).fillna({standard_region_id: -1}) return df_aligned[[order_id, standard_region_id, amount]] # 对 CRM 和 ERP 数据分别对齐再 UNION crm_clean align_region(crm_raw, crm) erp_clean align_region(erp_raw, erp) fact_sales pd.concat([crm_clean, erp_clean], ignore_indexTrue)注意standard_region_id是整数不是字符串。这保证了后续所有聚合操作都是基于稳定、无歧义的数字 ID彻底规避了大小写、空格、中英文混用等陷阱。我们约定所有维度代理键都从 100 开始-1 代表“未知”0 代表“总计”这是团队内部的硬性规范。3.2 手法二层级上卷Roll-up——从“城市”到“大区”的自动折叠问题现场销售总监要看“全国大区业绩”而区域经理要看“所辖城市业绩”。如果为每个粒度都存一张物理表存储成本翻倍且数据一致性难保障。解决方案利用维度表的层级关系动态聚合核心是dim_region表里的parent_region_id字段。我们写一个通用的上卷函数def rollup_by_hierarchy( fact_df: pd.DataFrame, dim_df: pd.DataFrame, fact_key_col: str, dim_key_col: str, target_level: str # 如 region_level 1 表示大区 ) - pd.DataFrame: 将事实表按维度表的指定层级向上聚合 # 步骤1获取目标层级的所有ID target_ids dim_df.query(target_level)[dim_key_col].tolist() # 步骤2递归查找所有子节点这里简化为一层实际可用 networkx 处理多层 # 构建父子映射字典{child_id: parent_id} parent_map dim_df.set_index(dim_key_col)[parent_region_id].to_dict() # 步骤3为事实表每一行找到其在目标层级的归属ID def find_target_id(row_id): current_id row_id while current_id ! 0 and current_id not in target_ids: current_id parent_map.get(current_id, 0) return current_id if current_id in target_ids else 0 # 应用映射 fact_df[rolled_up_id] fact_df[fact_key_col].apply(find_target_id) # 步骤4按新ID聚合 return fact_df.groupby(rolled_up_id).agg({ amount: sum, order_count: sum }).reset_index() # 使用示例把城市级销售上卷到大区级 city_sales fact_sales.merge(dim_region, left_onregion_id, right_onregion_id) national_summary rollup_by_hierarchy( city_sales, dim_region, region_id, region_id, region_level 1 )这个函数的威力在于它把“华东大区上海江苏浙江安徽”这个业务规则从硬编码的 SQLWHERE region IN (上海,江苏,浙江,安徽)变成了维度表里一条可配置、可审计、可版本化的数据记录。当明年公司新增“西南大区”时只需在dim_region表里插入几行所有调用此函数的报表自动生效。3.3 手法三空值补全Missing Value Imputation——让“零销量”真正可见问题现场某天某区域某品类确实没卖出去任何东西但 GROUP BY 后的结果里这一行压根不存在。业务方抱怨“你们的报表把我的业绩‘吃掉’了”解决方案用pd.MultiIndex.from_product()构造全组合空间这是 Pandas 处理多维聚合最优雅的技巧之一。我们不依赖数据库的LEFT JOIN而是主动构造一个“理论上的完整宇宙”再把事实数据“投射”进去# 1. 获取所有可能的维度组合笛卡尔积 date_range pd.date_range(2023-01-01, 2023-12-31, freqD) regions dim_region.query(region_level 1)[region_id].tolist() categories dim_product[category_id].unique().tolist() # 构造全组合索引 full_index pd.MultiIndex.from_product( [date_range, regions, categories], names[date, region_id, category_id] ) # 2. 计算原始聚合此时会丢失空组合 daily_agg fact_sales.groupby([order_date, region_id, category_id]).agg({ amount: sum, order_count: count }).reset_index() # 3. 设置索引并 reindex 到全组合空间 daily_agg_indexed daily_agg.set_index([order_date, region_id, category_id]) daily_full daily_agg_indexed.reindex(full_index, fill_value0).reset_index() # 结果每一天、每一个大区、每一个品类都有一行销量为 0 的也清晰可见实操心得reindex()的fill_value0是关键。但要注意对于非数值列如max_discount_rate不能简单填 0而应填np.nan并在后续用fillna(methodffill)做前向填充。我们曾因这里填错导致某区域的“最高折扣率”被错误地记为 0%引发了一场小范围的销售政策误读。3.4 手法四衍生指标计算Derived Metric Calculation——不止于 SUM 和 COUNT问题现场除了销售额业务方还要看“客单价Avg Order Value”、“连带率Items per Order”、“退货率Return Rate”。这些指标不能简单用agg({amount: sum, order_id: count})得到因为它们的分子分母来自不同的聚合粒度。解决方案分步聚合 后期计算避免“聚合陷阱”错误做法会导致分母被重复计算# ❌ 错误order_id 在 fact_line_item 表里一行一商品一个订单有多行 wrong_result fact_line_item.groupby([date, region]).agg({ amount: sum, # 分子总金额 order_id: nunique # 分母订单数 —— 但这里会把同一订单的多行都算进去结果正确 }) wrong_result[aov] wrong_result[amount] / wrong_result[order_id] # 这步是对的正确做法分离聚合粒度# ✅ 正确先按订单聚合再按日期区域聚合 # 步骤1在订单粒度计算每单的总金额、商品数、是否退货 order_level fact_line_item.groupby(order_id).agg({ amount: sum, item_id: count, is_returned: max # 只要有一个商品退货整单标记为退货 }).rename(columns{item_id: items_per_order, is_returned: has_return}) # 步骤2关联订单主表获取日期和区域 order_with_dim order_level.reset_index().merge( dim_order[[order_id, order_date, region_id]], onorder_id, howleft ) # 步骤3在日期×区域粒度聚合 daily_region_agg order_with_dim.groupby([order_date, region_id]).agg({ amount: sum, items_per_order: sum, # 总商品数 has_return: sum, # 退货订单数 order_id: count # 总订单数 }).rename(columns{order_id: order_count}) # 步骤4计算衍生指标此时分子分母粒度一致 daily_region_agg[aov] daily_region_agg[amount] / daily_region_agg[order_count] daily_region_agg[items_per_order_avg] daily_region_agg[items_per_order] / daily_region_agg[order_count] daily_region_agg[return_rate] daily_region_agg[has_return] / daily_region_agg[order_count]这个模式我们称为“降粒度聚合Granularity Reduction”。它强制你思考这个指标的“最小计算单元”是什么是订单是用户是会话只有在这个单元上算清楚了再往上卷结果才可靠。我们把它封装成MetricCalculator基类所有业务指标都继承它确保逻辑统一。3.5 手法五时间智能Time Intelligence——轻松搞定同比、环比、滚动均值问题现场财务要“2023年Q3 vs 2022年Q3”运营要“近7天日均 vs 上周同期”老板要看“过去12个月滚动增长率”。解决方案用pd.Groupershift()rolling()构建时间机器Pandas 的时间序列能力被严重低估。我们不用写复杂的日期计算而是让时间成为可操作的维度# 假设 daily_agg 是按天聚合好的 DataFrame索引是 date daily_agg daily_agg.set_index(date) # 1. 同比Year-over-Year当前日期 vs 365天前 daily_agg[amount_yoy] daily_agg[amount] / daily_agg[amount].shift(365) # 2. 环比Period-over-Period按季度 quarterly daily_agg.resample(Q).sum() quarterly[amount_qoq] quarterly[amount] / quarterly[amount].shift(1) # 3. 滚动7日均值Rolling 7-day Average daily_agg[amount_7d_avg] daily_agg[amount].rolling(window7).mean() # 4. 更高级动态窗口如“上周同期” # 先构造一个“上周同日”的索引 last_week_index daily_agg.index - pd.DateOffset(weeks1) # 用 .loc 安全地取值缺失则为 NaN daily_agg[last_week_same_day] daily_agg[amount].reindex(last_week_index).values # 5. 最实用自定义时间范围如“本月至今”、“本季度至今” def get_period_to_date(df: pd.DataFrame, period: str M) - pd.Series: period: M for month, Q for quarter, Y for year period_start df.index.to_period(period).start_time return df.groupby(period_start).cumsum()[amount] daily_agg[amount_mtd] get_period_to_date(daily_agg, M)注意shift(365)在闰年会偏移一天生产环境我们用shift(freq365D)或更精确的shift(freqpd.DateOffset(years1))。但后者在非标准日历如4-4-5周中不适用所以我们的dim_date表里专门有一列same_day_last_year_date存储每个日期对应的去年同日这是最稳妥的做法。3.6 手法六维度切片Slicing Dicing——用布尔索引替代 WHERE问题现场临时需求“只看VIP客户在促销期间的销售”。如果每次都在 SQL 里加WHERE is_vip1 AND is_promotion1ETL 脚本会越来越臃肿。解决方案把过滤条件变成可组合的“切片器”我们把所有常用的业务过滤逻辑封装成返回布尔 Series 的函数class DimensionSlicer: def __init__(self, fact_df: pd.DataFrame, dim_dfs: dict): self.fact fact_df.copy() self.dims dim_dfs def vip_customers(self) - pd.Series: VIP客户来自 dim_customer 的 is_vip 标签 return self.fact.merge( self.dims[customer][[customer_id, is_vip]], oncustomer_id, howleft )[is_vip].fillna(False) def promotion_periods(self) - pd.Series: 促销期间来自 dim_date 的 is_promotion_week 标签 return self.fact.merge( self.dims[date][[date_id, is_promotion_week]], left_onorder_date, right_ondate_id, howleft )[is_promotion_week].fillna(False) def apply_filters(self, *filters) - pd.DataFrame: 链式应用多个切片器 mask pd.Series([True] * len(self.fact)) for f in filters: mask mask f() return self.fact[mask].copy() # 使用一行代码完成复杂过滤 slicer DimensionSlicer(fact_sales, {customer: dim_customer, date: dim_date}) vip_promo_sales slicer.apply_filters(slicer.vip_customers, slicer.promotion_periods)这种设计让“临时分析”变成了“乐高式拼装”。运营同学要“看华东VIP在双11的复购”只需新加一个double_eleven_weeks()方法然后slicer.apply_filters(..., slicer.double_eleven_weeks())。所有逻辑集中在DimensionSlicer类里版本可控测试可覆盖。3.7 手法七立方体拼接Cube Joining——融合异构数据源问题现场销售数据在 MySQL用户行为数据在 KafkaJSON流库存数据在 SAP。它们的“产品ID”格式完全不同MySQL 是P1001Kafka 是{product_code: 1001}SAP 是MAT1001。想做一个“销售-行为-库存”三维联动分析传统 ETL 会崩溃。解决方案用 PyArrow 的 Schema Evolution 能力做柔性对接PyArrow 的核心优势在于它允许你在读取不同来源数据时动态映射和转换 Schema而不必强求所有源头使用同一套 ID 规范import pyarrow as pa import pyarrow.parquet as pq # 定义统一的“逻辑Schema” logical_schema pa.schema([ pa.field(product_id, pa.int32(), metadata{source: canonical}), pa.field(sales_amount, pa.float64()), pa.field(click_count, pa.int64()), pa.field(stock_quantity, pa.int32()) ]) # 从 MySQL 读取做 ID 映射 mysql_table pq.read_table(mysql_sales.parquet) mysql_mapped mysql_table.cast(logical_schema, safeFalse).set_column( 0, product_id, pa.compute.cast(mysql_table[product_code], pa.int32()) # P1001 - 1001 ) # 从 Kafka JSON 流解析假设已转为 Arrow Table kafka_table parse_kafka_json_stream() # 返回 Arrow Table kafka_mapped kafka_table.cast(logical_schema, safeFalse).set_column( 0, product_id, pa.compute.cast(kafka_table[product_code], pa.int32()) # 1001 - 1001 ) # 从 SAP 提取正则提取数字 sap_table pq.read_table(sap_stock.parquet) sap_mapped sap_table.cast(logical_schema, safeFalse).set_column( 0, product_id, pa.compute.cast( pa.compute.regexp_extract(sap_table[material_id], rMAT(\d), 1), pa.int32() ) ) # 现在三张表的 product_id 都是 int32可安全 join unified_cube pa.compute.join( mysql_mapped, kafka_mapped, keys[product_id], join_typefull outer ).join( sap_mapped, keys[product_id], join_typefull outer )PyArrow 的cast()和compute模块让我们能把“数据清洗”这步从 ETL 的前置步骤变成查询时的即时计算。这极大提升了敏捷性。我们线上服务的响应时间90% 在 200ms 内就是靠这种“懒加载即时转换”的架构。4. 实战全流程从一张订单表到可交互立方体4.1 第一步数据准备与探查30分钟我们拿到的原始数据是一张raw_orders.csv约 200 万行字段包括order_no,order_date,region_name,product_name,category_name,sales_rep_name,amount,currency,is_return。探查重点用 Pandas Profiling 或手动region_name有 12 个唯一值但“华东”、“华东区”、“East China” 共存product_name包含规格描述如“iPhone 14 Pro 256GB 黑色”需提取product_codeorder_date格式不一“2023/01/01”, “01-Jan-2023”, “2023-01-01”amount有 0.3% 的负值退货需单独处理currency99% 是 CNY但有少量 USD需统一换算。实操心得永远不要跳过探查我们曾因忽略currency字段把一笔 1000 USD 的订单≈7000 CNY当 1000 CNY 计入导致华东区单日业绩虚高 12%。现在团队规定任何新数据源接入必须生成一份《数据质量报告》包含缺失率、唯一值分布、异常值列表由数据Owner签字确认。4.2 第二步构建维度表2小时基于探查结果我们手工创建或从主数据系统同步以下维度表dim_date从 2020-01-01 到 2030-12-31包含date_id(int),date,year,quarter,month,week_of_year,is_weekend,is_holiday,same_day_last_year_datedim_region12 个原始区域映射到 5 个标准大区定义层级dim_product用正则r[A-Z]{2,4}\d{3,4}从product_name提取product_code再关联主数据获取category_iddim_sales_rep清洗sales_rep_name去重、标准化“张三华东” → “张三”并打上region_id标签。所有维度表都保存为 Parquet 格式用pyarrow.dataset.write_dataset()启用字典编码和 Snappy 压缩体积比 CSV 小 75%。4.3 第三步清洗事实表1小时def clean_fact_orders(raw_df: pd.DataFrame) - pd.DataFrame: # 1. 日期标准化 raw_df[order_date] pd.to_datetime(raw_df[order_date], errorscoerce) # 2. 区域对齐 region_map { 华东: 101, 华东区: 101, East China: 101, 华南: 102, 华南区: 102, South China: 102, # ... 其他映射 } raw_df[region_id] raw_df[region_name].map(region_map).fillna(-1) # 3. 产品ID提取 raw_df[product_code] raw_df[product_name].str.extract(r([A-Z]{2,4}\d{3,4})) # 关联 dim_product 获取 product_id product_lookup dim_product.set_index(product_code)[product_id].to_dict() raw_df[product_id] raw_df[product_code].map(product_lookup).fillna(-1) # 4. 金额标准化USD to CNY raw_df[amount_cny] np.where( raw_df[currency] USD, raw_df[amount] * 7.2, raw_df[amount] ) # 5. 标记退货订单负金额视为退货 raw_df[is_return_order] raw_df[amount_cny] 0 # 6. 保留关键字段丢弃原始描述性字段 return raw_df[[ order_no, order_date, region_id, product_id, amount_cny, is_return_order ]].rename(columns{amount_cny: amount}) fact_orders clean_fact_orders(raw_orders)4.4 第四步生成基础立方体15分钟# 定义我们要支持的聚合粒度 granularities [ (day, [order_date, region_id, product_id]), (week, [order_date_week, region_id, category_id]), (month, [order_date_month, region_id, category_id]), (quarter, [order_date_quarter, region_id]), ] # 为每个粒度生成一张 Parquet 文件 for name, group_cols in granularities: # 添加时间维度字段 temp_df fact_orders.copy() temp_df[order_date_week] temp_df[order_date].dt.to_period(W).dt.start_time temp_df[order_date_month] temp_df[order_date].dt.to_period(M).dt.start_time temp_df[order_date_quarter] temp_df[order_date].dt.to_period(Q).dt.start_time # 关联维度表获取 category_id temp_df temp_df.merge( dim_product[[product_id, category_id]], onproduct_id, howleft ) # 聚合 agg_df temp_df.groupby(group_cols).agg({ amount: [sum, count], is_return_order: sum }).round(2) # 展平列名 agg_df.columns [_.join(col).strip() for col in agg_df.columns.values] # 保存 pq.write_table( pa.Table.from_pandas(agg_df.reset_index()), fcube/fact_orders_{name}.parquet, compressionsnappy )4.5 第五步提供交互接口1小时我们用 Flask Plotly 创建一个轻量级 Web UI核心是暴露一个/api/cube接口app.route(/api/cube, methods[POST]) def query_cube(): req request.get_json() # req {dimensions: [date, region], metrics: [amount_sum], filters: {...}} # 动态加载对应粒度的 Parquet 文件 granularity select_granularity(req[dimensions]) table pq.read_table(fcube/fact_orders_{granularity}.parquet) # 应用过滤用 PyArrow compute if req.get(filters): mask pa.compute.scalar(True) for col, op, val in req[filters]: if op : mask pa.compute.and_(mask, pa.compute.equal(table[col], val)) elif op : mask pa.compute.and_(mask, pa.compute.greater(table[col