经营分析系统逻辑数据模型设计与落地实践
2026/9/19 23:17:13 网站建设 项目流程

简介:中国移动经营分析系统数据仓库逻辑数据模型的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 直接 SUM,B 报表从 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对应分支或待发布版本。输出直接打在 stdout,CI 里重定向到 PR 评论,业务评审只盯这一份变更清单。字段级差异和代码提交记录串起来,就是经营分析数仓长期演进的主线。

本文还有配套的精品资源,点击获取

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询