多维聚合实战:从SQL到Pandas的OLAP数据操作心法

📅 2026/7/15 4:48:38
多维聚合实战:从SQL到Pandas的OLAP数据操作心法
1. 项目概述当数据不再是一张“平铺直叙”的表格你有没有遇到过这样的场景销售部门要按季度、按区域、按产品大类看毛利同时还要对比去年同期财务团队需要把成本拆解到“部门-项目-费用类型-发生月份”四个维度再筛选出超预算的组合甚至一个简单的用户行为分析都要交叉统计“新老用户 × 设备类型 × 页面路径深度 × 当日活跃时段”。这时候Excel 的透视表点到第三层就开始卡顿SQL 里写个 GROUP BY 加上 CASE WHEN 嵌套三层自己都快看不懂了——这已经不是“汇总”问题而是多维聚合Multi-Dimensional Aggregation的实战现场。本篇标题中的 “Part 20: Data Manipulation in Multi-Dimensional Aggregation”绝非教科书里抽象的“高维数组”概念它直指现代数据分析中一个最硬核、也最容易被低估的环节如何在保留原始数据颗粒度的前提下自由、高效、可复现地对多个维度进行任意组合、切片、钻取与比较。核心关键词——多维聚合、数据操作、维度建模、OLAP思维、分组聚合、交叉分析——全部围绕一个现实目标让数据从“静态报表”变成“可交互的决策仪表盘”。它适合三类人一是刚从单表 GROUP BY 过渡到业务宽表开发的 SQL 工程师二是用 Pandas 做分析但总被pivot_table参数绕晕的 Python 数据分析师三是正在搭建 BI 系统、需要理解底层聚合逻辑的产品或数仓工程师。这不是讲理论而是拆解我在真实项目中处理过 12TB 日志、支撑 37 个业务方自助分析需求时反复打磨出的一套“多维数据操作心法”。2. 多维聚合的本质为什么不能只靠 GROUP BY 和嵌套子查询2.1 传统 SQL 聚合的“维度陷阱”很多人一上来就写SELECT region, product_category, quarter, SUM(revenue) AS total_revenue, AVG(profit_margin) AS avg_margin FROM sales_fact GROUP BY region, product_category, quarter;看起来没问题错。这只是“固定维度组合”的快照。一旦业务方问“给我看看华东地区手机类目下Q1 各个月份的环比增长”你就得重写一条新 SQL加MONTH(sale_date)到 GROUP BY再套一层窗口函数LAG()。更麻烦的是如果他们接着问“那华北地区电脑类目呢能和华东手机放一张表对比吗”——你立刻意识到GROUP BY 是“单向切片”而多维分析需要“立方体式导航”。就像你站在一座大楼里GROUP BY 只允许你从一个固定角度比如正南面看整栋楼的楼层分布而多维聚合是给你配了一套电梯旋转楼梯AR眼镜让你能随时切换到任意楼层、任意朝向、任意房间组合去观察。我曾接手一个电商后台的订单分析模块原始 SQL 有 43 个嵌套视图每个视图对应一个“预设维度组合”。运营提一个新需求DBA 就要花半天加一个视图上线前还得全量跑一遍物化。后来我们重构时发现90% 的查询其实只用到 5 个核心维度时间、地域、渠道、商品类目、用户等级但组合方式超过 200 种。这时候硬编码 GROUP BY 就是自缚手脚。2.2 维度建模给数据世界装上“坐标系”解决之道是引入维度建模Dimensional Modeling思路。它不改变数据本身而是为数据建立一套清晰、稳定的“地理坐标”。核心就两点事实表Fact Table存“发生了什么”是数字的海洋。比如sales_fact表每一行是一个订单明细字段是sale_id,amount,quantity,profit—— 这些都是可加的度量值measures。维度表Dimension Table存“在什么条件下发生的”是描述的大陆。比如dim_time含年、季、月、周、工作日标志、dim_region含大区、省份、城市、是否一线、dim_product含一级类目、二级类目、品牌、价格带。这些表通过外键如time_key,region_key关联到事实表。关键来了维度表不是简单字典它必须是退化维度Degenerate Dimension或缓慢变化维度SCD的合理设计。举个反例如果dim_region里只存一个province字段那“华东地区”就得靠WHERE province IN (上海,江苏,浙江,安徽)硬编码下次改成“长三角一体化区域”又要改代码。正确做法是在dim_region里加一列region_group值为East_China或Yangtze_River_Delta并确保这个字段在 ETL 过程中自动更新。这样所有分析都基于稳定语义而不是 SQL 里的字符串列表。提示维度表的主键必须是代理键surrogate key而非业务键如province_code。因为业务键可能变更比如“江苏省”缩写从JS改成Jiangsu而代理键region_sk 1024永远指向“那个江苏”。这是保证历史分析一致性的铁律。2.3 OLAP 思维从“查数据”到“玩数据”多维聚合的终极形态是OLAPOnline Analytical Processing操作。它包含四大原子动作每一种都对应一种数据操作需求OLAP 操作SQL 等价实现示意典型业务场景我踩过的坑Slice切片WHERE time_quarter 2024-Q1锁定某一季度看全局用字符串2024-Q1过滤不如用time_key BETWEEN 202401 AND 202403后者走索引快 17 倍Dice切块WHERE region IN (East,North) AND product_cat IN (Phone,PC)多条件交叉筛选子集IN列表超 500 项时MySQL 会放弃索引改用临时表应先建中间表temp_filter再 JOINRoll-up上卷GROUP BY region原 GROUP BY regionproduct_cat从细粒度升到粗粒度类目→大区SUM(profit)上卷没问题但AVG(profit_margin)上卷必须重算否则结果失真权重丢失Drill-down下钻GROUP BY region, city原 GROUP BY region从大区深入到城市下钻时若city有空值GROUP BY region, city会把空值单独成组需提前COALESCE(city, Unknown)这四种操作就是多维聚合的“操作系统内核”。任何 BI 工具Tableau/Power BI拖拽维度的行为底层都在生成对应的 Slice/Dice/Roll-up/Drill-down 逻辑。不懂这个就永远在“调参数”而不是“控逻辑”。3. 核心数据操作技术栈Pandas、SQL、DAX 的三维实战3.1 Pandas用pivot_table和crosstab构建内存立方体Python 数据分析中Pandas 是最贴近“交互式多维分析”的工具。但多数人只用df.groupby().sum()浪费了它内置的 OLAP 能力。真正高效的多维操作靠的是三个核心方法pd.pivot_table()你的第一台“分析引擎”它不是简单的行列转置而是带聚合逻辑的动态立方体生成器。看这个真实案例我们有用户行为日志user_log含字段user_id,event_type,page_path,device_type,event_date。要分析“不同设备上各页面路径的访问频次占比”传统写法# ❌ 错误示范两层 groupby代码冗长无法扩展 temp df.groupby([device_type, page_path]).size().reset_index(namecnt) temp[total_by_device] temp.groupby(device_type)[cnt].transform(sum) temp[pct] temp[cnt] / temp[total_by_device]而pivot_table一行搞定# ✅ 正确示范声明式定义维度即参数 result pd.pivot_table( df, indexdevice_type, # 行维度Y轴 columnspage_path, # 列维度X轴 valuesuser_id, # 聚合对象这里用 user_id 计数 aggfunccount, # 聚合函数等价于 COUNT(*)) fill_value0, # 空单元格填0避免 NaN marginsTrue, # 自动加总计行列Total 行/列 margins_nameAll # 总计行名称 ) # result 现在就是一个 device_type × page_path 的二维矩阵且含 All 行/列关键参数深挖aggfunc不仅支持count/sum/mean还支持自定义函数aggfunclambda x: x.nunique()统计去重用户数marginsTrue是神功能它自动执行 Roll-upAll行 所有设备的总访问量All列 所有页面的总访问量All,All单元格 全局总计fill_value0必须加否则NaN会污染后续计算比如result.sum(axis1)会跳过 NaN 行。pd.crosstab()专治“二元交叉分析”的轻量级武器当你的分析只涉及两个分类维度如gender×age_groupcrosstab比pivot_table更简洁、更快# 生成性别×年龄段的频次交叉表 ct pd.crosstab( df[gender], df[age_group], rownames[Gender], # 行名可选 colnames[Age Group], # 列名可选 marginsTrue, # 同样支持总计 normalizeindex # 神奇参数按行归一化直接得百分比 ) # normalizeindex → 每行加起来是100%即“男性中各年龄段占比” # normalizecolumns → 每列加起来是100%即“20-29岁用户中男女占比” # normalizeTrue → 全局归一化所有单元格加起来是100%实操心得crosstab在处理百万级数据时比等效的pivot_table快 3~5 倍因为它内部做了 C 语言级优化。但注意它只能处理两个维度第三个维度得靠groupby预处理。pd.melt()pivot_table()解锁“宽表变立方体”的终极组合现实数据常以宽表形式存在比如sales_wide表含region,Q1_sales,Q2_sales,Q1_profit,Q2_profit。想分析“各季度利润占比”就得先“熔化”melt成标准长表# 宽表 → 长表把季度列名变成值 long_df pd.melt( sales_wide, id_vars[region], # 保持不变的标识列 value_vars[Q1_sales, Q2_sales, Q1_profit, Q2_profit], # 要熔化的列 var_namemetric_quarter, # 新列名原列名存这里 value_nameamount # 新列名原列值存这里 ) # long_df 现在有 region, metric_quarter, amount 三列 # 接着用 str.extract 拆解 metric_quarter 得到 metric 和 quarter long_df[[metric, quarter]] long_df[metric_quarter].str.extract(r(\w)_(\w)) # 最后 pivot行region列quarter值SUM(amount)按 metric 过滤 profit_by_qtr pd.pivot_table( long_df[long_df[metric]profit], indexregion, columnsquarter, valuesamount, aggfuncsum )注意melt是“逆透视”pivot_table是“透视”二者组合就是数据形态的任意变形。我在处理某银行客户资产报表时靠这套组合拳把 87 个季度指标宽表5 分钟内转成可任意切片的长表立方体。3.2 SQL写出让 DBA 点赞的多维查询SQL 是多维聚合的基石。但写出高性能、易维护的多维查询有三大心法心法一用GROUPING SETS替代 N 个 UNION ALL传统做法要同时看“大区”、“大区类目”、“大区类目月份”三个粒度得写-- ❌ 低效三次扫描三次 GROUP BY SELECT region, NULL as category, NULL as month, SUM(sales) FROM t GROUP BY region UNION ALL SELECT region, category, NULL, SUM(sales) FROM t GROUP BY region, category UNION ALL SELECT region, category, month, SUM(sales) FROM t GROUP BY region, category, month;而GROUPING SETS一次扫描搞定-- ✅ 高效一次扫描多种粒度 SELECT region, category, month, SUM(sales) as total_sales, GROUPING_ID(region, category, month) as gid -- 生成分组 ID便于识别粒度 FROM t GROUP BY GROUPING SETS ( (region), -- 粒度1仅大区 (region, category), -- 粒度2大区类目 (region, category, month) -- 粒度3大区类目月份 );GROUPING_ID返回一个整数其二进制位表示对应维度是否参与分组(region)→001 1(region,category)→011 3(region,category,month)→111 7。你可以用CASE WHEN GROUPING_ID(...) 1 THEN Region Level来标注粒度。心法二用ROLLUP和CUBE自动生成层级与全组合ROLLUP(a,b,c)(a,b,c) (a,b) (a) ()适合有天然层级的维度如时间年→季→月-- 时间维度天然层级ROLLUP 完美匹配 SELECT year, quarter, month, SUM(sales), CASE WHEN GROUPING(month) 1 AND GROUPING(quarter) 0 THEN Quarter Total WHEN GROUPING(quarter) 1 AND GROUPING(year) 0 THEN Year Total END as level_desc FROM sales_fact GROUP BY year, quarter, month WITH ROLLUP;CUBE(a,b,c) 所有 2³8 种组合适合无层级的交叉分析如region×channel×product_type-- 获取所有可能的 region/channel/product_type 组合总计 SELECT region, channel, product_type, SUM(sales) FROM sales_fact GROUP BY CUBE(region, channel, product_type); -- 结果含(r1,c1,p1), (r1,c1,ALL), (r1,ALL,p1), (ALL,c1,p1), (r1,ALL,ALL), ... , (ALL,ALL,ALL)心法三用FILTER子句替代 CASE WHEN提升可读性统计“Q1 销售额”和“Q1 毛利率”传统写法SELECT region, SUM(CASE WHEN quarterQ1 THEN sales ELSE 0 END) as q1_sales, AVG(CASE WHEN quarterQ1 THEN profit_margin END) as q1_margin FROM t GROUP BY region;FILTER让逻辑更清晰SELECT region, SUM(sales) FILTER (WHERE quarterQ1) as q1_sales, AVG(profit_margin) FILTER (WHERE quarterQ1) as q1_margin FROM t GROUP BY region;FILTER是 PostgreSQL 9.4 和最新版 SQL Server 的标准语法语义明确只对满足条件的行执行聚合。它比CASE WHEN更安全不会因漏写ELSE 0导致 AVG 计算错误也更易维护。3.3 DAXPower BI用度量值构建动态分析模型在 Power BI 中多维聚合的灵魂是DAXData Analysis Expressions。它不像 SQL 那样“写一次查一次”而是定义动态度量值Measure随用户切片实时重算。核心就两条公式CALCULATE()DAX 的“上帝函数”它能修改当前上下文Context是实现 Slice/Dice/Roll-up 的核心。例如// 定义一个基础销售额度量 Sales Amount SUM(Sales[Amount]) // 创建“华东地区销售额”度量自动响应其他筛选器 East Sales CALCULATE([Sales Amount], Region[RegionGroup] East_China) // 创建“Q1 同比增长率”需跨时间上下文 YoY Growth Q1 VAR CurrentQ1 CALCULATE([Sales Amount], Date[Quarter] Q1, Date[Year] 2024) VAR LastQ1 CALCULATE([Sales Amount], Date[Quarter] Q1, Date[Year] 2023) RETURN DIVIDE(CurrentQ1 - LastQ1, LastQ1)CALCULATE的威力在于它不改变数据模型只临时“覆盖”当前筛选上下文。用户在报表里拖一个Product Category切片器East Sales度量会自动计算“华东地区所选类目”的销售额无需额外写代码。ALL()和ALLEXCEPT()控制上下文的“刹车片”当你要做“占比”分析如“某类目销售额占全站比例”必须清除部分上下文// 全站销售额清除所有筛选只留时间 Total Site Sales CALCULATE([Sales Amount], ALL(Product), ALL(Region)) // 某类目占全站比例清除 Product 筛选但保留 Region 和 Date Category % of Site DIVIDE([Sales Amount], CALCULATE([Sales Amount], ALLEXCEPT(Date, Region)))ALL()清除指定表的所有筛选ALLEXCEPT()保留指定列的筛选其余清除。这是避免“分母错误”的关键。我曾见一个 BI 报表毛利率占比总是不对最后发现是用了ALL(Date)却没保留Year导致分母是所有年份总和分子却是单年数据。4. 实战全流程从原始日志到自助分析看板的 7 步落地4.1 第一步定义核心维度与事实建模阶段不跳过这步后面全是徒劳。我们以电商用户行为日志为例事实表fact_user_eventevent_idPKuser_idFK to dim_userpage_idFK to dim_pagedevice_skFK to dim_devicetime_skFK to dim_timeevent_duration_sec度量停留时长is_bounce度量是否跳出0/1维度表dim_time退化维度每日 ETL 更新time_skPK格式20240520dateDATEyear,quarter,month,week_of_year,day_of_week,is_weekend,is_holiday维度表dim_userSCD Type 2记录用户等级变更user_skPKuser_id业务键user_level当前等级valid_from,valid_to,is_current关键决策为什么dim_user用 SCD Type 2因为用户等级会变如 VIP→SVIP而分析“SVIP 用户的历史行为”必须知道他在某天是不是 SVIP。Type 2 通过valid_from/to和is_current标志完美支持“时间点快照查询”。4.2 第二步ETL 中的维度一致性保障维度表不是静态字典必须在 ETL 流程中强制校验。我们在 Airflow DAG 中加入三道检查维度键完整性检查SELECT COUNT(*) FROM fact_user_event f LEFT JOIN dim_time d ON f.time_sk d.time_sk WHERE d.time_sk IS NULL报错则中断防止“未来日期”或“缺失日期”进入事实表。缓慢变化维度生效检查对dim_user运行SELECT COUNT(*) FROM dim_user WHERE is_current TRUE GROUP BY user_id HAVING COUNT(*) 1确保每个user_id只有一个当前版本。业务规则检查SELECT * FROM dim_time WHERE day_of_week NOT IN (1,2,3,4,5,6,7)拦截数据质量问题。实操心得这些检查脚本不是摆设。去年双十一大促期间上游日志系统时区配置错误导致dim_time生成了day_of_week 0的脏数据。正是这条检查让我们在凌晨 2 点收到告警30 分钟内修复避免了全天的分析事故。4.3 第三步构建基础聚合层Data Warehouse Layer在数仓中我们不直接暴露 10 亿行的事实表而是建聚合表Aggregate Table作为性能基石agg_user_daily按user_id,time_sk聚合存session_count,page_view_count,total_durationagg_region_monthly按region_sk,time_sk月粒度存new_user_count,active_user_count,avg_session_duration创建agg_region_monthly的 SQLPostgreSQLCREATE TABLE agg_region_monthly AS SELECT r.region_sk, r.region_name, r.region_group, t.year_month as time_month, -- 从 dim_time 中提取 202405 COUNT(DISTINCT f.user_id) as active_user_count, SUM(f.event_duration_sec) as total_duration_sec, COUNT(*) as event_count FROM fact_user_event f JOIN dim_region r ON f.region_sk r.region_sk JOIN dim_time t ON f.time_sk t.time_sk WHERE t.date 2024-01-01 -- 分区裁剪 GROUP BY r.region_sk, r.region_name, r.region_group, t.year_month; -- 添加复合索引加速按 region_group time_month 查询 CREATE INDEX idx_agg_reg_mon ON agg_region_monthly (region_group, time_month);为什么建聚合表因为fact_user_event单日 2 亿行COUNT(DISTINCT user_id)全表扫描要 40 秒。而agg_region_monthly单月仅 2 万行同样查询 0.2 秒。这是用空间换时间的经典 trade-off。4.4 第四步BI 层的度量值开发Power BI 示例在 Power BI Desktop 中连接agg_region_monthly表开发以下度量值// 1. 基础度量 Active Users SUM(agg_region_monthly[active_user_count]) // 2. 动态占比自动响应切片器 Region Share DIVIDE([Active Users], CALCULATE([Active Users], ALL(agg_region_monthly[region_group]))) // 3. 环比增长用 DAX 时间智能函数 MoM Growth VAR CurrentMonth SELECTEDVALUE(agg_region_monthly[time_month]) VAR PrevMonth DATEADD(SELECTEDVALUE(agg_region_monthly[time_month]), -1, MONTH) RETURN DIVIDE( CALCULATE([Active Users], agg_region_monthly[time_month] CurrentMonth), CALCULATE([Active Users], agg_region_monthly[time_month] PrevMonth) ) // 4. 条件高亮返回颜色代码用于卡片视觉对象 Status Color SWITCH( TRUE(), [MoM Growth] 0.1, #00CC66, // 绿色增长10% [MoM Growth] -0.05, #FF6666, // 红色下降5% #FFCC00 // 黄色其他 )关键技巧SELECTEDVALUE()函数是安全的当多值被选中时返回 BLANK()避免MAX()或MIN()引发的静默错误。DATEADD是 Power BI 内置的时间智能函数比手动字符串拼接CONVERT(INT, ...)更可靠。4.5 第五步前端交互设计避免“维度爆炸”多维分析最大的敌人是“维度爆炸”——用户拖 5 个维度生成 10 万行结果页面卡死。我们的解决方案是默认限制报表加载时只显示region_group和time_month两个维度其他如device_type隐藏在“高级筛选”抽屉中。智能采样当用户选择region_group All且time_month跨度 6 个月时后端自动启用TABLESAMPLE SYSTEM (10)PostgreSQL或TOP 10000SQL Server采样保证响应 2s。异步加载复杂下钻如点击某个区域看城市明细触发独立 API 请求不阻塞主视图。注意不要试图在前端用 JavaScript 做大数据聚合。我曾见过一个 Vue 应用把 50 万行 JSON 传给前端用Array.reduce()计算结果 Chrome 内存飙到 4GB。正确姿势是聚合永远在服务端或数据库完成前端只负责展示和交互。4.6 第六步性能压测与瓶颈定位上线前必须做真实压测。我们用pgbenchPostgreSQL模拟并发# 模拟 50 个用户持续 5 分钟执行核心查询 pgbench -U analyst -d dw_db -f query_agg.sql -c 50 -T 300query_agg.sql内容-- 核心查询按 region_group 和 time_month 查活跃用户 SELECT region_group, time_month, SUM(active_user_count) as users FROM agg_region_monthly WHERE time_month BETWEEN 202401 AND 202406 GROUP BY region_group, time_month ORDER BY region_group, time_month;压测结果发现当time_month BETWEEN 202401 AND 20241212个月时P95 响应从 120ms 涨到 1800ms。EXPLAIN ANALYZE显示Bitmap Heap Scan占用 92% 时间。优化方案分区表将agg_region_monthly按time_month范围分区PARTITION BY RANGE (time_month)每个分区存一年数据局部索引在每个分区上建INDEX ON (region_group, time_month)VACUUM ANALYZE定期清理死元组更新统计信息。优化后12 个月查询 P95 稳定在 210ms。4.7 第七步用户培训与自助分析赋能技术再强用户不会用也是零。我们设计了“30 分钟上手”培训第一课认识你的维度10 分钟发放《维度词典》PDF明确region_group的 5 个取值East/North/South/West/Central及其业务含义附地图示意图。第二课拖拽三原则10 分钟① 行/列放“分类维度”region, device值放“数字度量”users, duration② 切片器放“过滤维度”time_month, user_level一次最多选 3 个③ 点击图表任意位置右键“下钻”看明细左键“上卷”回退。第三课避坑指南10 分钟❌ 不要对time_month用“文本筛选”必须用日期切片器❌ 不要在一个图表里放超过 2 个“高基数维度”如user_id会崩溃✅ 遇到“数据为空”先检查右上角“筛选器”是否误开了✅ 想看“占比”直接右键度量值 → “显示值为” → “百分比”。培训后85% 的常规分析需求如“华东 Q2 各城市用户数”用户可自主完成数据团队支持工单下降 60%。5. 常见问题与排查技巧实录那些深夜救火的真实案例5.1 问题一Pandaspivot_table输出全是 NaN但数据明明有值现象df.head()看device_type和page_path都有值pd.pivot_table(df, indexdevice_type, columnspage_path, valuesuser_id, aggfunccount)却返回全 NaN 矩阵。排查思路检查device_type或page_path是否含不可见字符df[device_type].str.encode(utf-8).head()发现iPhone前有\xa0不间断空格检查数据类型df[device_type].dtype是object但部分值是float因 Excel 导入时空值变nan再混入字符串检查values字段user_id是字符串类型count函数对字符串计数正常但若user_id有Nonecount会忽略它。根因与解决# 步骤1清洗不可见字符 df[device_type] df[device_type].str.replace(\xa0, ).str.strip() df[page_path] df[page_path].str.replace(\u200b, ).str.strip() # 零宽空格 # 步骤2统一数据类型填充空值 df[device_type] df[device_type].fillna(Unknown).astype(str) df[page_path] df[page_path].fillna(/home).astype(str) # 步骤3确认 values 字段可计数 df[user_id] df[user_id].fillna(ANONYMOUS) # 避免 None 影响 count # 重试 pivot_table result pd.pivot_table(df, indexdevice_type, columnspage_path, valuesuser_id, aggfunccount)实操心得Pandas 对数据质量极度敏感。我养成了一个习惯每次read_csv()后立即运行df.info()和df.describe(includeall)一眼看出哪些列有null、哪些类型异常。这比调试pivot_table报错快 10 倍。5.2 问题二SQLGROUPING SETS查询结果行数远超预期现象SELECT a,b,c FROM t GROUP BY GROUPING SETS ((a),(a,b),(a,b,c))返回 120 万行但SELECT COUNT(*) FROM t只有 80 万行。排查思路检查GROUPING SETS是否误写成CUBECUBE(a,b,c)会生成 8 种组合而GROUPING SETS只应生成 3 种检查字段是否含NULLGROUP BY会把NULL视为一个有效分组值检查是否有重复键a列存在大量相同值但a,b组合唯一a,b,c组合也唯一理论上(a)分组行数 ≤(a,b)≤(a,b,c)。根因与解决 执行SELECT a, COUNT(*) FROM t GROUP BY a HAVING COUNT(*) 10000发现a Unknown有 50 万行。这意味着(a)分组中Unknown单独成一行计数 50 万(a,b)分组中Unknown与每个b值组合又生成几百行