成本分摊数据模型:从账单行到责任成本
月度成本会上,平台账显示某集群花费 100,团队报表却把 64 归给交易、48 归给搜索,合计 112。两边都能展示精美图表,也都能解释自己的公式:一边按 CPU request 分摊节点,另一边按 CPU usage 分摊共享池。问题不是哪张图更漂亮,而是两条规则同时消费了同一条 origin charge,却没有共同主键、规则版本和守恒检查。
另一类争议更隐蔽。预付承诺在付款期形成一笔 BilledCost,使用期又通过 EffectiveCost 摊进资源;报表把两列相加后,既重复计算了承诺成本,又把现金流和权责成本混成一个数字。成本平台首先是账本系统,其次才是分析系统。
先把六类事实分开
可审计模型至少要区分资源、计量、价格、收费、分摊和责任六类事实。它们可能来自同一个导出文件,却不应塞进一张没有粒度声明的宽表。
| 事实 | 稳定主键 | 回答的问题 | 最常见误用 |
|---|---|---|---|
资源 asset | provider + account + provider resource ID + 生效区间 | 什么对象产生或承载费用 | 用可变名称代替 ID |
用量 usage | meter + resource + charge window + source row | 消耗了多少、单位是什么 | 把 Kubernetes request 当云账单用量 |
费率 rate | SKU/price ID + region + pricing unit + 生效区间 | 单位价格从何而来 | 用当前目录价回算历史账单 |
收费 charge | source export + source row ID/revision | 供应商交付了哪笔金额 | 把重复导出当新增费用 |
分摊 allocation | origin charge + rule version + target + revision | 一笔费用如何被拆给目标 | 覆盖原始账单行,无法重算 |
责任 ownership | target + owner + valid_from/to | 谁解释、审批和承担结果 | 用当下组织关系改写历史 |
usage × rate = cost 只对部分 usage charge 成立。最低消费、税、支持费、信用、退款、承诺购买、阶梯价和舍入都可能没有简单的资源用量乘法。原始收费必须保留供应商交付金额,推导值只能作为独立证据列,不能覆盖 source fact。
FOCUS 1.4 对 BilledCost 与 EffectiveCost 给出不同语义:前者服务于发票现金口径,后者把承诺等覆盖成本按消耗期确认,适合趋势、分摊和 chargeback。使用行在承诺覆盖下可能 BilledCost = 0 而 EffectiveCost > 0;购买行则可能有 BilledCost 而 EffectiveCost = 0。同一指标只能选择一种成本口径,并把口径写进字段名和报表合同。
粒度合同决定能否相加
一行数据的粒度不是“每天一条”这么简单。生产模型应显式声明:
charge grain =
source_provider
+ billing_account_id
+ source_dataset_instance
+ source_row_id
+ source_revision
allocation grain =
origin_charge_id
+ allocation_rule_id
+ allocation_rule_version
+ allocated_target_id
+ allocation_revision时间至少有两套窗口。BillingPeriodStart/End 对齐账期和发票,ChargePeriodStart/End 表示费用或使用生效窗口,通常采用左闭右开 [start, end)。按账期回答“这张发票多少钱”,按 charge period 回答“哪个时段消耗了成本”。跨窗口聚合时必须先选择时间语义,不能一边按账期筛选分子,一边按自然日筛选分母。
数量也必须带单位。ConsumedQuantity = 10 在没有 ConsumedUnit 时不可计算;GB-month、GB-hour、request、vCPU-hour 和 data transfer GB 不能直接相加。费率要同时保存 pricing quantity、pricing unit、币种和生效区间,避免把 每 1000 次请求 当成 每次请求。
用本地账本跑通最小模型
隔离实验使用 Python 3.11+ 与 DuckDB Python 客户端 1.5.4。DuckDB 只是本地验证器,生产可以落在数据仓库、湖仓或关系数据库;关键是表约束和证据字段不随引擎丢失。先创建独立虚拟环境并固定依赖:
python -m venv .venv
. .venv/bin/activate
python -m pip install --upgrade pip
python -m pip install duckdb==1.5.4
python -c "import duckdb; print(duckdb.__version__)"Windows PowerShell 激活命令为 .\.venv\Scripts\Activate.ps1。若安装失败,先检查 Python 架构、企业代理和私有 PyPI 镜像是否包含固定 wheel;不要临时去掉版本约束后继续生成不可复现结果。
创建 allocation_lab.py,用 DECIMAL 保存金额,用 source row 与 revision 保证导入幂等:
import duckdb
db = duckdb.connect("allocation-lab.duckdb")
db.execute("""
CREATE TABLE IF NOT EXISTS charge_fact (
origin_charge_id VARCHAR PRIMARY KEY,
source_provider VARCHAR NOT NULL,
billing_account_id VARCHAR NOT NULL,
source_row_id VARCHAR NOT NULL,
source_revision INTEGER NOT NULL,
charge_period_start TIMESTAMP NOT NULL,
charge_period_end TIMESTAMP NOT NULL,
resource_id VARCHAR,
consumed_quantity DECIMAL(38, 12),
consumed_unit VARCHAR,
billed_cost DECIMAL(38, 12) NOT NULL,
effective_cost DECIMAL(38, 12) NOT NULL,
billing_currency VARCHAR NOT NULL,
CHECK (charge_period_end > charge_period_start),
UNIQUE (source_provider, billing_account_id, source_row_id, source_revision)
);
CREATE TABLE IF NOT EXISTS allocation_fact (
origin_charge_id VARCHAR NOT NULL,
rule_id VARCHAR NOT NULL,
rule_version VARCHAR NOT NULL,
target_id VARCHAR NOT NULL,
allocated_ratio DECIMAL(18, 12) NOT NULL,
allocated_effective_cost DECIMAL(38, 12) NOT NULL,
evidence_type VARCHAR NOT NULL,
evidence_value DECIMAL(38, 12),
PRIMARY KEY (origin_charge_id, rule_id, rule_version, target_id),
CHECK (allocated_ratio >= 0 AND allocated_ratio <= 1)
);
""")
db.execute("""
INSERT OR REPLACE INTO charge_fact VALUES
('charge-node-a', 'provider-demo', 'acct-demo', 'row-a', 1,
CURRENT_TIMESTAMP - INTERVAL 1 HOUR, CURRENT_TIMESTAMP,
'node-a', 1, 'NodeHour', 100, 100, 'CNY');
INSERT OR REPLACE INTO allocation_fact VALUES
('charge-node-a', 'cpu-request-share', 'v1', 'team-order', 0.60, 60, 'cpu_request_core_hour', 6),
('charge-node-a', 'cpu-request-share', 'v1', 'team-search', 0.30, 30, 'cpu_request_core_hour', 3),
('charge-node-a', 'cpu-request-share', 'v1', '__idle__', 0.10, 10, 'idle_capacity', 1);
""")示例时间是语义占位值,不代表账期。执行脚本后,用同一成本口径检查每个 origin charge 的比例和金额:
SELECT
c.origin_charge_id,
c.effective_cost AS origin_cost,
SUM(a.allocated_effective_cost) AS allocated_cost,
SUM(a.allocated_ratio) AS ratio_sum,
c.effective_cost - SUM(a.allocated_effective_cost) AS delta
FROM charge_fact c
JOIN allocation_fact a USING (origin_charge_id)
GROUP BY c.origin_charge_id, c.effective_cost
HAVING ABS(delta) > 0.000001 OR ABS(ratio_sum - 1) > 0.000001;把查询加入脚本并打印结果。正向数据的预期是零行:100 被拆成 60、30、10,金额和比例都闭合。零行不是“没有数据”,而是所有 origin charge 均通过守恒条件;生产门禁应同时输出扫描行数,防止空表误报成功。
反向实验:制造一笔可解释的错账
把 team-search 的金额从 30 改为 42,但保留比例 0.30:
UPDATE allocation_fact
SET allocated_effective_cost = 42
WHERE origin_charge_id = 'charge-node-a'
AND target_id = 'team-search';重新执行守恒查询,预期返回 allocated_cost = 112、ratio_sum = 1、delta = -12。这证明“比例等于 1”不足以验收金额;反过来,金额闭合也不能证明 ratio 和 usage evidence 正确。生产检查至少拆成四条:
同一规则版本下分摊比例之和是否为 1。分摊金额之和是否等于 origin charge 的同口径金额。target、owner 和 evidence 是否完整且处于有效期。
source row 是否重复、迟到、被 replacement 或 delta correction 修订。
修复不是删除异常行,而是发布 v2 规则或新的 allocation revision,保留旧结果、修正原因与审批记录。关账前允许重算,关账后需要冲正或重开流程;覆盖旧分摊会让历史报表看似稳定,却失去争议复核能力。
分摊规则要能解释分子、分母与余数
按 usage 分摊共享节点时,可以写成:
ratio(target, window) =
eligible_usage(target, window)
/ sum(eligible_usage(all_targets, window))
allocated_cost = origin_effective_cost * ratio这里每个词都要落到字段:eligible 排除哪些系统进程,usage 取 request、实际使用还是加权组合,窗口如何对齐,样本缺失怎样处理,舍入余数落给哪个 target。CPU 与内存组合时还要记录权重和单位归一化方法。只保存最终 ratio,会让规则升级后无法解释旧结果。
Kubernetes request 是调度承诺,usage 是执行消耗,云厂商账单可能只认节点、磁盘、负载均衡器或网络流量。三者不是同一种事实。OpenCost 一类工具可以用 requests/usage 估算工作负载份额,但结果仍需通过节点资产与云账单对账;不能把监控样本直接改名为“发票成本”。
Shared、Idle 与 Unallocated 也不能合并。Shared 是明确的政策池,Idle 是资产成本与已分配工作负载成本的差额,Unallocated 表示目标映射缺失。隐藏 Unallocated 会虚增分摊覆盖率;把 Idle 全压给高用量团队,会让平台基础容量和业务消费责任失真。它们的再分摊规则由独立政策管理,但都必须保留 origin lineage。
导入、修正与重算要保持幂等
账单导出可能重复投递、覆盖文件、追加修订或迟到。数据入口至少保存 object key、etag/checksum、dataset instance、delivery watermark、schema version、ingested_at 和 source row hash。先落不可变 raw zone,再规范化;不要让解析任务直接覆盖财务事实表。
FOCUS 1.4 增加了 correction、delivery 和 completeness 治理语义,但供应商实际交付可能仍停在更早版本。消费端要把 FOCUS 规范版本、供应商 schema 版本与扩展列集合分别保存。看到一个名为 EffectiveCost 的列,也要先验证交付合同,不能用规范最新版反推供应商已经实现全部字段。
重算采用双写版本而不是原地更新:旧规则继续服务已关闭报表,新规则在 shadow 表生成结果,比较总额、target 差异、Unallocated 比例和最大单体偏差。业务、平台与财务共同确认后切换 report view;发生异常时把 view 指回旧版本,而不是从备份猜测历史状态。
权限、凭证与敏感字段要分层
原始账单可能暴露账号结构、资源 ID、区域、业务规模、合同折扣、税、信用、成本中心和租户名称。原始区只向账单摄取身份开放写入,规范化任务只读 raw、写 curated,分析身份只读授权视图。分摊规则发布者不应同时拥有原始账单删除权,报表管理员也不应能修改 owner 映射。
云账单接入优先使用工作负载身份、托管身份或短期角色,不把长期 AK/SK、服务账号密钥和数据库口令写入脚本。日志记录 request ID、数据集和行数,不打印完整凭证、真实折扣或带租户信息的账单行。导出给开发团队时使用受控视图,把 provider resource ID 映射为稳定代理键。
高基数明细会直接影响存储和查询成本。原始小时级数据按 provider、billing period 和 charge date 分区,避免按 resource ID 产生海量小分区;curated 层保留财务可追溯粒度,报表层再做日/月聚合。保留周期由重开账期、审计和合同要求决定,不能只按仪表盘查询速度裁剪。
选型看证据边界,不看图表数量
小团队可以在云原生导出加 SQL 模型上建立第一版账本;Kubernetes 需要工作负载分摊时引入 OpenCost 或 Kubecost;多云规范化使用 FOCUS 语义层;变更前估算则由 Infracost 一类工具提供。它们分别回答发票、资产、分摊或估算问题,没有一个工具天然拥有完整真相。
选型评审至少验证:是否保留原始行和修订;是否能导出模型;Billed/Effective/List/Contracted 口径是否可区分;分摊规则能否版本化;未分摊与空闲是否可见;多币种和时间窗口是否明确;权限能否拆分;退出后历史数据、规则和凭证能否核销。仪表盘好看但无法导出 origin lineage 的产品,不适合作为唯一成本账本。
清理实验、升级模型与退出平台
本地实验结束前先导出查询和校验结果,再删除数据库与虚拟环境:
python allocation_lab.py
rm -f allocation-lab.duckdb allocation_lab.py
deactivate
rm -rf .venvPowerShell 使用 Remove-Item -LiteralPath allocation-lab.duckdb,allocation_lab.py,递归删除 .venv 前先确认路径位于实验目录。共享数据仓库不要执行示例清理命令;应按变更单删除实验 schema,并确认外部 stage、临时角色和导出文件均已回收。
升级 schema 时先冻结一份输入快照和期望守恒结果,在新旧模型并行计算同一账期。门禁包括源金额、目标金额、币种、行数、重复率、Unallocated、迟到修订和 top-N target 差异。新增字段可以向后兼容,改变粒度、时间语义或成本口径必须发布新数据合同。
退出成本平台时,先导出 raw manifest、curated schema、规则版本、owner 映射、审计日志和关账快照,再把下游查询切到可替代数据源。确认旧平台停止摄取、外部 bucket 不再增长、服务身份与 token 已撤销、DNS 和 webhook 已移除,最后才删除运行组件。能卸载软件不等于能复原账本。
用不变量维持长期可信度
平台团队负责数据入口、schema、幂等和运行质量;FinOps 负责成本口径与分摊政策;财务负责发票、关账和冲正;业务 owner 负责解释资源与业务产出。规则变更必须带 owner、原因、生效区间、影响预览和回退版本。
长期门禁围绕几条可计算不变量:原始账单不被派生层覆盖;同一 origin charge 在同一规则版本内只分摊一次;比例和金额分别守恒;币种与时间窗口不隐式转换;Unallocated、Idle 和 Shared 可见;BilledCost 与 EffectiveCost 不相加冒充总成本;任何报表数字都能追到 source row、规则版本和责任目标。成本数据达到这个程度,争议才会从“相信哪张图”变成“哪条证据或哪条政策需要修正”。
