经营分析系统逻辑数据模型设计与落地实践
简介中国移动经营分析系统数据仓库逻辑数据模型的64页说明文档面向数据仓库架构师、BI工程师及电信行业数据分析人员重点展示大型运营商如何从业务视角构建支撑经营分析的数据基础。资源为1个PDF文件压缩包大小13.07MB内容系统覆盖数据仓库整体架构、ETL过程、逻辑数据模型设计、维度建模、数据集市与商业智能应用等核心环节并针对海量数据场景给出分区、索引、压缩、并行处理等性能优化思路。文档以客户、账户、通话记录、产品等真实业务实体为线索详细定义属性、关系与事实表结构读者可据此掌握从异构源系统整合、清洗加载到分析模型落地的完整路径同时理解数据安全治理与定期更新机制在生产中的具体实现。已有221人学习适合需要参考成熟案例开展数仓规划、模型设计或数模评审的数据从业者。1. 逻辑数据模型经营分析系统口径统一的基座某省移动的经营分析系统每天要处理几亿条话单周边挂着十几套报表平台。看起来各管一摊一到月底对口径就是一场持久战市场部说离网率是 2.8%网络部算出来是 3.1%审计要的数又从第三个口径来。问题根子不在 SQL 写得不细而在“离网”“出账收入”“有效用户”这些词没有一份所有人都认的正式定义。逻辑数据模型就是干这个的它不关心你用 Oracle 还是 Hive也不关心表怎么分桶把业务对象拆成实体、属性、关系和粒度沉淀成 64 页可评审的文档。数据工程师、BI 工程师和数据管理者看懂了它比多写几十个 ETL 任务更能解决长期问题。2. 主题域划分与维度建模经营分析数仓的骨架2.1 为什么经营分析系统不采用 3NF 建模常见做法是采用维度建模而不是 Bill Inmon 倡导的 3NF 企业模型。有人会问逻辑数据模型听起来很“企业级”为什么不上 3NF因为经营分析系统的查询模式是“按维度切、按指标汇总”每天几千个报表和即席查询都在做 GROUP BY。3NF 把数据拆得足够干净但一次收入分析要关联客户、账户、产品、订单等七八张表查询复杂度和数据库压力都扛不住。星型模型把核心业务行为设计成事实表把描述性信息放进维度表。事实表行数大、字段少维度表行数小、字段多查询时事实表先做分区裁剪和过滤再和少量维度表关联执行计划清晰可变。电信行业数据量级比一般电商高一个量级某省移动一个月的话单明细在几十亿到上百亿行维度建模是经过验证的可靠路径。2.2 客户、用户、账户三个实体必须拆开经营分析系统里最容易混淆的就是这三个实体。客户Customer是合同签约方用户Subscriber是实际使用网络的 SIM 标识账户Account是计费结算主体。一张手机卡可以对应一个用户但一个客户可能名下几十个用户缴费走同一个账户而用户和账户又不一定是 1:1。逻辑数据模型里不把这三个实体合并成“客户表”就是为了让 ARPU、出账收入这些指标在任意维度组合下都能自洽。主题域划分一般落在九大域客户、产品、营销、账务、渠道、资源、网络、地域、时间。移动经营分析逻辑模型中常见主题域和实体对应关系如下表主题域核心实体典型事实粒度客户域客户、用户、账户用户信息快照一个用户一条产品域产品、套餐、资费、产品实例套餐订购明细一个订购关系一条营销域营销活动、活动礼品、渠道酬金活动参与明细活动用户时间一条账务域账单、缴费、账单科目出账清单账户账期一条渠道域渠道、网点、工号渠道酬金结算渠道客户账期网络域基站、小区、设备网络话务统计基站小时每个域都对应逻辑模型文档中的一卷。评审模型时先看主题域边界是否清晰再看实体归属是否一致。比如“酬金”归渠道域还是营销域不同省公司吵过很多轮逻辑模型的价值就是把这些边界以文档形式固定下来。2.3 数仓引擎选型从 Oracle 小型机到 Doris 与 ClickHouse逻辑模型与物理引擎没有直接关系但选型决定了模型能不能跑得动。运营商早年常见做法是 Oracle RAC 跑在小型机上数仓的汇总层和集市层都用 Oracle。新项目分两种存量大数据批处理继续用 Hive/Spark新的即席查询和可视化报表在团队规模不大的前提下我一般优先看 Apache Doris 或 ClickHouse。它们都是列式存储压缩比高聚合查询快。“常用数据仓库有哪些、适合小型的”这类问题落到实际就是数据量在几百 GB 到几 TB、查询并发不高、没有专职 DBA 的团队用 Doris 单集群就够部署简单支持标准 MySQL 协议BI 工具直接连ClickHouse 查询更快但多表 JOIN 和精确去重在高基数场景写起来费劲。无论选哪个逻辑模型的主题域划分和字段定义都可以平移变的只是物理表的分区、分桶和排序键。这恰恰是逻辑数据模型文档最有价值的地方它把业务语义和具体引擎解耦了。3. 逻辑数据模型的关键表达粒度、时变字段与账期分区3.1 粒度定义先于字段定义逻辑模型文档里第一件要确认的事是粒度。一张表或一个实体描述的业务对象一行代表什么用户账务汇总表的粒度是“用户账期”话单明细表的粒度是“一条呼叫记录”营销活动效果表的粒度是“活动用户日期”。粒度不清字段就是摆设。比如“收入金额”放在用户账务汇总表里是用户当月出账金额放在账务明细里是每笔出账金额两个数在汇总时还会因为分摊规则产生差异。移动经营分析系统还有一个特殊点套餐变更、订购关系变更、星级评定变更非常频繁。客户星级、套餐档位、所属渠道这些字段在逻辑模型里必须显式标注“时变字段”否则报表只能取最新状态历史回查口径就乱了。时变字段的处理方式是拉链表或周期快照取决于业务查询是“回溯任意时点”还是“只看月末快照”。3.2 用拉链表支撑历史快照回查处理时变字段常见做法是缓慢变化维的 Type 2 实现在移动数仓落地为拉链表。每个客户的一条星级记录有生效开始时间和结束时间当前有效记录的结束时间是 9999-12-31。这样任意时间点的星级状态都能通过时间条件命中一条记录。下面是一段拉链表每日维护 SQL每天跑批分四段保留历史闭合记录、保留未变更的当前记录、闭合变更旧记录、开启新记录。-- dwd_cust_star_his客户星级拉链表 -- 引擎Spark SQL / Impala 兼容语法 -- 参数 ${bizdate} 为业务日期${pre_date} 为上一日格式均为 YYYY-MM-DD INSERT OVERWRITE TABLE dwd_cust_star_his PARTITION (p_date ${bizdate}) SELECT cust_id, star_level, start_dt, end_dt FROM ( -- 1) 历史已闭合记录原样保留 SELECT cust_id, star_level, start_dt, end_dt FROM dwd_cust_star_his WHERE p_date ${pre_date} AND end_dt 9999-12-31 UNION ALL -- 2) 当天未发生变更的当前有效记录沿用原区间 SELECT h.cust_id, h.star_level, h.start_dt, h.end_dt FROM dwd_cust_star_his h LEFT ANTI JOIN ( SELECT diff.cust_id FROM ( SELECT cust_id, star_level FROM ods_cust_star_di WHERE p_date ${bizdate} EXCEPT SELECT cust_id, star_level FROM dwd_cust_star_his WHERE p_date ${pre_date} AND end_dt 9999-12-31 ) diff ) chg ON h.cust_id chg.cust_id WHERE h.p_date ${pre_date} AND h.end_dt 9999-12-31 UNION ALL -- 3) 当日发生变更的客户闭合旧记录 SELECT h.cust_id, h.star_level, h.start_dt, ${pre_date} AS end_dt FROM dwd_cust_star_his h JOIN ( SELECT diff.cust_id FROM ( SELECT cust_id, star_level FROM ods_cust_star_di WHERE p_date ${bizdate} EXCEPT SELECT cust_id, star_level FROM dwd_cust_star_his WHERE p_date ${pre_date} AND end_dt 9999-12-31 ) diff ) chg ON h.cust_id chg.cust_id WHERE h.p_date ${pre_date} AND h.end_dt 9999-12-31 UNION ALL -- 4) 当日发生变更的客户从 ODS 开启新记录 SELECT cust_id, star_level, ${bizdate} AS start_dt, 9999-12-31 AS end_dt FROM ods_cust_star_di WHERE p_date ${bizdate} ) t;核心逻辑是用 EXCEPT 算出“今日 ODS 与昨日当前有效记录”的差集差集中的客户就是发生星级变更的对象。ODS 表设计为每日全量快照这样差集比对才准确。参数${bizdate}是跑批业务日期${pre_date}是上一日时间格式必须严格一致否则分区覆盖错位会导致历史记录丢失。此 SQL 在 Spark SQL、Impala 和 Hive 2.3 上可直接运行。3.3 账期分区账务分析必须的物理表达移动经营分析系统的物理模型无论数据放在 Oracle 还是 Doris都必须按账期分区。账期是电信计费的专业时间维度每月 1 日关账后生成上月账单之后任何历史调整都走调账科目不回改原账期。因此“账期分区”既是存储策略也是业务逻辑的物理约束。-- 出账明细表 DDL 示例Hive 风格 CREATE TABLE dwd_acct_bill_dtl ( acct_id STRING COMMENT 账户ID, user_id STRING COMMENT 用户ID, bill_subject STRING COMMENT 账单科目编码, bill_amt DECIMAL(16,2) COMMENT 账单金额单位元, region_id STRING COMMENT 归属地市编码, channel_id STRING COMMENT 入网渠道编码 ) COMMENT 账务域出账明细按账期覆盖 PARTITIONED BY (acct_month STRING COMMENT 账期格式YYYYMM) STORED AS ORC; -- 查询时强制指定账期避免全分区扫描 SELECT region_id, SUM(bill_amt) AS region_bill_amt FROM dwd_acct_bill_dtl WHERE acct_month 202404 GROUP BY region_id;查询计划里WHERE 条件直接裁剪到单个分区扫描量控制在月数据量级。如果换到 Doris对应的是 RANGE 分区加动态分区语义一样。账期字段建议统一用 STRING 的 YYYYMM不要用时间戳类型省去时区转换和格式比较的麻烦。4. 从逻辑模型到可运行应用分层落地与质量校验4.1 逻辑模型到物理模型的四层映射逻辑文档落在纸面上物理实施要分四层。移动经营分析数仓常见做法是ODS 贴源层、DWD 明细层、DWS 汇总层、ADS 应用层。逻辑模型主要在 DWD 和 DWS 落地ODS 只是源系统的近实时镜像。分层命名前缀示例特点贴源层 ODSods_ods_cdr_mobile_di保持源格式每日快照或增量明细层 DWDdwd_dwd_cdr_mobile清洗标准化按逻辑模型定义建模汇总层 DWSdws_dws_cust_star_his按主题域轻度汇总支撑常规报表应用层 ADSads_ads_mkt_campaign_rpt面向具体报表直接供 BI 查询ODS 表命名后缀_di表示每日快照_incr是增量。DWD 是逻辑模型落地的第一层客户、用户、账户三实体在这里严格拆表字段名统一为小写加下划线时间字段统一为 STRING 型。DWS 的字段命名必须和 DWD 保持一致避免同一含义字段在不同层不同名。4.2 跑批完成后的质量校验逻辑模型定义清楚后最容易被忽略的是“模型没变但数据变了”的校验。每天跑批完成至少要跑一层完整性校验。用记录数和关键金额的双对比是最低成本的方式-- 完整性校验ODS 与 DWD 的对比 WITH src AS ( SELECT COUNT(1) AS cnt, SUM(bill_amt) AS amt FROM ods_bill_incr WHERE p_date ${bizdate} ), dst AS ( SELECT COUNT(1) AS cnt, SUM(bill_amt) AS amt FROM dwd_bill_dtl WHERE p_date ${bizdate} ) SELECT CASE WHEN src.cnt dst.cnt AND ABS(src.amt - dst.amt) 0.01 THEN PASS ELSE FAIL END AS check_result FROM src, dst;记录数不一致先查清洗丢弃的脏数据金额不一致再查 JOIN 发散。除总量外建议再按地市、账期、渠道三个维度各跑一组 GROUP BY 对比确保分布一致而不仅是总量一致。校验 SQL 挂在调度系统的每个任务之后失败时阻断下游应用层刷新。4.3 常见实现坑小维度表 JOIN 放大、汇总口径漂移逻辑模型设计得再完美落地时还有几个高频坑。第一小维度表因缓慢变化产生的多版本记录JOIN 后行数膨胀。客户维度表保留了历史版本事实表按客户 ID 关联时如果不限定时间版本一个客户匹配多条历史记录事实行被放大。解决方式是在逻辑模型阶段就给时变维度定义好“生效日、失效日”JOIN 条件写明关联时间落在生效区间内。第二汇总层口径漂移。同一个“出账收入”指标A 报表从 DWD 直接 SUMB 报表从 DWS 汇总取值两边因过滤条件不同产生偏差。逻辑模型文档里每个指标要写清楚口径归属哪一层DWS 层汇总时只允许从 DWD 取数ADS 之间禁止互相取数。这条规则写进评审清单比最后靠人肉对数高效得多。4.4 性能优化分区裁剪与数据倾斜规避模型物理化时有三个性能优化动作最常见。一是排序键设计Doris 里把 GROUP BY 频率最高的维度和日期字段放在前几个 KEY二是分区裁剪所有查询强制带账期或日期条件禁止扫描全表三是数据倾斜规避集团客户等超大维度 key 的单日数据量可能占全量 30% 以上JOIN 时加 SALT 字段按随机数打散聚合后再还原。倾斜处理示例-- Spark SQL对热点 key 加水打散后再聚合 SELECT region_id, SUM(amt) AS region_amt FROM ( SELECT CASE WHEN cust_id GROUP_CLIENT_001 THEN CONCAT(cust_id, _, FLOOR(RAND()*10)) ELSE cust_id END AS join_key, region_id, amt FROM fact_bill WHERE p_date ${bizdate} ) t GROUP BY region_id;该方案只处理单个热点 key适用于运营商客户天然呈“二八分布”的场景。热点 key 数量多时就维护热点清单表动态带入 JOIN避免 SQL 里写死。5. 读 64 页逻辑模型文档的正确顺序与版本演进技巧一份 64 页的逻辑数据模型文档信息密度极高阅读顺序比阅读速度重要。我的习惯是五步先翻实体清单数一下总共多少个实体、哪些是核心实体再看实体关系图重点看每个关系上的基数标记1:N 的方向决定事实表和维度表的分工接着挑三个最关心的实体读字段定义特别注意“单位、取值范围、是否时变”三个标注然后看账期和分区说明确认事实表的时间口径最后回到指标定义把文档里提到的指标和现有 SQL 里的口径做映射。这套顺序下来比从头翻到尾至少省一半时间。逻辑模型最怕版本漂移。业务口径调整后模型文档没有同步更新三个月后 ETL 改了两版文档还停在初版。比较通用的做法是把逻辑模型导出为 PowerDesigner 或 Erwin 的 XML进 Git 做版本管理再跑一个 DIFF 脚本生成变更清单。日常项目里我用一段简短脚本做字段级 Diff# logic_model_diff.py # 输入旧版模型XML路径、新版模型XML路径 # 输出字段级差异清单新增/删除/类型变更 import xml.etree.ElementTree as ET def extract_fields(xml_path): tree ET.parse(xml_path) fields {} for ent in tree.iter(Entity): ent_name ent.get(Name) for attr in ent.iter(Attribute): fields[(ent_name, attr.get(Name))] attr.get(DataType) return fields old extract_fields(model_v1.xml) new extract_fields(model_v2.xml) added set(new) - set(old) removed set(old) - set(new) changed {k for k in set(old) set(new) if old[k] ! new[k]} print(f新增字段 {len(added)} 个:) for ent, col in sorted(added): print(f {ent}.{col}) print(f删除字段 {len(removed)} 个:) for ent, col in sorted(removed): print(f {ent}.{col}) print(f类型变更 {len(changed)} 个:) for ent, col in sorted(changed): print(f {ent}.{col}: {old[(ent, col)]} - {new[(ent, col)]})脚本只依赖 Python 标准库的 xml.etree.ElementTree不装第三方包。两个路径从命令行传入model_v1.xml对应主干版本model_v2.xml对应分支或待发布版本。输出直接打在 stdoutCI 里重定向到 PR 评论业务评审只盯这一份变更清单。字段级差异和代码提交记录串起来就是经营分析数仓长期演进的主线。本文还有配套的精品资源点击获取