成本数据基础:主键、窗口、币种与守恒
成本平台上线后的第一份月报,总额与云发票只差 0.3%,团队以为对账已经成功。几天后,搜索团队发现自己被分摊了两次同一块存储费用:磁盘改名后旧标签仍在,新的资产记录又使用当前名称回填历史。另一个团队则少了一整段跨区流量,因为平台只采集 CPU 和内存。多算与少算恰好互相抵消,全局总额看起来接近,局部责任却已经完全错误。
这种事故不能靠调整图表修复。成本数据必须保留供应商源行、不可变资产身份、半开时间窗口、金额口径、币种、规则版本和责任映射;每次转换都要能重放,每次分摊都要守恒,每次缺失都要显式进入质量状态。数据底座一旦把这些差异压成 date + service + cost,上层任何工具都会继承不可审计的历史。
第一原则是保留事实,而不是尽早聚合
成本数据至少由七类对象组成:
| 对象 | 建议主键 | 必须保留的语义 |
|---|---|---|
| 交付批次 | provider + dataset + delivery ID | schema 版本、校验和、水位、重跑关系 |
| 原始收费 | source dataset + source row ID + revision | 费用类别、原始金额、币种、账期与费用期 |
| 资产 | provider + account + immutable resource ID + 有效区间 | 类型、区域、规格、生命周期、父子关系 |
| 用量 | resource/meter + 窗口 + source row | 数量、单位、采样/聚合方式、完整性 |
| 价格 | price/SKU ID + 地域 + 生效区间 | 目录价、合同价、计价单位、阶梯与来源 |
| 分摊 | origin charge + rule version + target + revision | 权重、共享池、舍入、未分配和守恒差 |
| 责任 | target + owner + valid_from/to | 组织、成本中心、产品与审批责任的历史状态 |
资源名称、Tag、Kubernetes Label 和当前组织路径都可能改变,只能做属性或查找入口,不能充当历史主键。资产身份、Tag、Label 与 Owner通过带有效期映射处理资源重建、Owner 迁移和标签晚到;底座必须先保留原始身份,才能在规则修复后重算。
raw 层采用追加写入,不覆盖供应商文件。重复交付也要保存 delivery 元数据,再由规范层按源行 ID 和 revision 生成当前视图。这样既能证明某行何时到达,也能在解析器升级后重新构建结果。
粒度合同决定哪些数字能够相加
“每天每服务一行”不是完整粒度。两个看似相同的日成本可能分别按账期和费用期聚合,也可能一个使用 BilledCost,另一个使用 EffectiveCost。生产表需要把粒度写进契约:
dataset: allocated_effective_cost
grain:
- origin_charge_id
- allocation_rule_version
- target_id
- allocation_revision
window:
semantics: charge-period
interval: "[start, end)"
amount:
basis: effective
currencyRole: billing
identity:
snapshot: event-time
quality:
required: [origin_exists, currency_known, owner_resolved]只有粒度、窗口、成本口径和币种都相同的金额才可直接相加。不同粒度需要明确 roll-up;不同窗口需要先切分;不同币种需要受控汇率层;不同成本口径必须保留独立列。
FOCUS 1.4 提供 BilledCost、EffectiveCost、ListCost 和 ContractedCost 等语义。它帮助统一通用字段,不代表供应商已经交付相同版本,也不代表内部身份和分摊已经正确。规范层应同时记录 FOCUS 版本、供应商 schema 版本和扩展列集合。
时间至少有账期、费用期和数据水位
账期回答“这张发票属于哪个结算周期”,费用期回答“这笔使用或收费在哪个窗口发生”,数据水位回答“当前结果完整到哪里”。三者不能互换。
所有运行窗口采用左闭右开 [start, end):
WHERE charge_period_start >= :window_start
AND charge_period_start < :window_end相邻窗口共享边界却不会重复记录。跨窗费用需要按持续时间、供应商粒度或已批准政策切分,并保留源行 ID 与切分规则。按本地时区展示可以发生在 serving 层;底层事件、汇率生效与账单时间都应保存时区和原始值。
水位必须按数据源独立维护。账单、资产、Kubernetes 用量、价格和业务事件的延迟不同,complete_window 只能取满足当前决策所需全部数据源的最小水位。任何来源迟到时,报表应显示 stale 或 incomplete,而不是复用旧值伪装最新结果。
币种和成本口径不能藏在格式化层
金额列至少携带数值、币种角色和成本口径。100 不是合法成本事实,100 LAB billed in billing currency 才接近可解释。二进制浮点不适合承担发票守恒,仓库使用定点十进制并保留供应商精度与舍入规则。
需要区分:
Billing currency:供应商账单原始币种。Pricing currency:价格表表达币种。Payment currency:真实发票结算币种,存在时按发票关联。
Reporting currency:内部管理视图使用的换算币种。
换算必须使用批准的汇率类型、来源、有效期和版本。管理汇率不应覆盖原始账单金额;重算时新增派生列。报价、合同价、发票金额和权责成本也分别保留,不把 ListCost 与 EffectiveCost 相加成“更完整的总额”。
承诺购买、credit、tax、adjustment 和汇率连接需要共同遵守定价、币种、摊销与时间窗口中的口径与守恒关系。
分摊是带版本的账本变换
分摊不是按标签 GROUP BY。它把 origin charge 通过规则拆到一个或多个责任目标,并保留每个目标的权重、金额、舍入和版本:
for every origin charge c:
direct(c) + shared(c) + idle(c) + unallocated(c) = effective(c)
for every allocation revision r:
sum(target_amount where revision = r) = allocatable_origin_amountShared 是政策决定的共享来源,Idle 是已付资产与已分配消费之间的差额,Unallocated 是目标身份或维度缺失。它们不是三个可互换的“其他”桶。隐藏 Unallocated 会让分摊率看起来更高,却把数据质量问题改造成错误 chargeback。
共享成本的权重可以来自直接成本、request、usage、活跃租户或固定份额,但每个驱动都要有业务含义、完整水位和规则版本。权重总和为 0、目标缺失或来源窗口不完整时停止分摊,不把成本平均摊给仍有标签的团队。
Shared、Idle 与 Unallocated继续展开再分摊、舍入、争议和重算流程;基础层只接受能够回到 origin charge 的结果。
用最小实验验证主键、窗口和守恒
下面的 Node.js 实验不需要连接真实云账号。它读取两条虚构费用,检查重复主键、窗口合法性、币种一致性与分摊守恒。
新建 validate-cost-foundation.mjs:
const charges = [
{ id: "row-compute", start: "T0", end: "T1", currency: "LAB", effective: 75 },
{ id: "row-storage", start: "T0", end: "T1", currency: "LAB", effective: 25 },
];
const allocations = [
{ origin: "row-compute", target: "product-a", amount: 45, revision: 1 },
{ origin: "row-compute", target: "product-b", amount: 30, revision: 1 },
{ origin: "row-storage", target: "product-a", amount: 10, revision: 1 },
{ origin: "row-storage", target: "shared-platform", amount: 15, revision: 1 },
];
const issues = [];
const ids = new Set();
for (const charge of charges) {
if (ids.has(charge.id)) issues.push({ type: "duplicate-origin", id: charge.id });
ids.add(charge.id);
if (charge.start === charge.end) issues.push({ type: "empty-window", id: charge.id });
if (!charge.currency) issues.push({ type: "missing-currency", id: charge.id });
const sum = allocations
.filter((row) => row.origin === charge.id && row.revision === 1)
.reduce((total, row) => total + row.amount, 0);
if (sum !== charge.effective) {
issues.push({ type: "conservation", id: charge.id, expected: charge.effective, actual: sum });
}
}
for (const row of allocations) {
if (!ids.has(row.origin)) issues.push({ type: "orphan-allocation", origin: row.origin });
}
console.log(JSON.stringify({ result: issues.length ? "REVIEW" : "PASS", issues }, null, 2));
process.exitCode = issues.length ? 3 : 0;执行:
node validate-cost-foundation.mjs预期结果为 PASS,进程退出码为 0。随后做三个反向实验中的任意一个:复制 row-storage、把它的 start 和 end 都设为 T0,或把 product-a 的 10 改为 12。检查器应分别报告 duplicate-origin、empty-window 或 conservation,并返回退出码 3。
实验结束删除文件:
rm validate-cost-foundation.mjsPowerShell 使用 Remove-Item .\validate-cost-foundation.mjs。真实流水线应把同类检查写入转换任务和发布门禁,并把失败行隔离到 quarantine 表,不能只在仪表盘上显示一个红色百分比。
网络、存储和负载均衡必须进入资产图
只按 CPU 和内存分摊,会系统性漏掉跨区流量、NAT、负载均衡器、公共 IP、磁盘、快照、备份和请求型费用。它们经常缺少 workload 级资源 ID,需要通过网络方向、服务前端、VolumeClaim、节点、账号或时间重叠建立证据关系。
外围资产模型应记录:
asset relationship =
provider resource ID
+ parent/attachment ID
+ valid_from/to
+ region/zone
+ protocol or storage class
+ ownership confidence
+ source evidence网络费用必须区分 ingress、egress、跨区、跨地域、Internet、NAT 和服务处理费;存储必须区分卷、快照、备份、IOPS、吞吐与删除后的保留;负载均衡器必须区分固定资产费、处理量、规则和公网地址。无法可靠归属时进入 Unallocated,并给出补证路径。
网络、存储与负载均衡成本以生命周期、流量方向和资产关系定位孤儿费用,不用 CPU 权重掩盖缺失关系。
从零部署一条可维护的数据流水线
一条最小生产链不需要复杂产品,但需要清楚的目录、角色和状态:
cost-platform/
contracts/ # schema、粒度、质量规则
mappings/ # 供应商与 FOCUS 映射
policies/ # 分摊、汇率、关账规则
fixtures/ # 脱敏正反样例
transforms/ # 幂等转换
reconciliations/ # 守恒与发票对账
runbooks/ # 失败恢复、回滚与退出部署顺序是:先创建只追加 raw 区和独立采集身份;再注册 schema 与 delivery manifest;随后构建身份、价格和时间维表;之后生成规范费用与分摊;最后开放授权视图。每一步都记录输入水位、输出批次、代码 revision、策略 revision、行数、金额和质量结果。
任务重跑必须幂等。推荐以 source_row_id + source_revision + transform_revision 形成唯一键,新解析器写新 revision,不在原位修改旧结果。关账后出现迟到费用时生成 adjustment 批次,避免静默改写已经批准的历史。
故障判断从行数、主键和金额三条线开始
| 现象 | 判断证据 | 常见根因 | 修复路径 |
|---|---|---|---|
| 总额翻倍 | raw、规范、JOIN 前后行数 | 标签历史或价格表一对多 JOIN | 锁定有效区间,恢复旧视图后重算 |
| 总额接近但团队错位 | origin-to-target 明细、Owner snapshot | 当前标签回填历史、资源改名 | 使用事件时身份,保留旧责任映射 |
| 某窗口突然为 0 | delivery manifest、source watermark | 导出迟到、凭证失效、分区未发现 | 标记 incomplete,不沿用 0 |
| 币种换算跳变 | 汇率来源、类型、生效区间 | 多条汇率 JOIN、错用现货价 | 固定批准来源和唯一键 |
| Idle 消失、Shared 上升 | 规则版本、权重、来源桶 | 重复再分摊、策略变更未版本化 | 恢复原始桶并逐 origin 守恒 |
| 存储成本无 Owner | attachment、PV/PVC、删除时间 | 卷已脱挂、标签晚到 | 进入 Unallocated,补全生命周期 |
| 历史随升级改变 | schema、映射、修订方式 | 新 parser 覆盖旧事实 | 双轨解析,回滚 reader revision |
排查时先比较 (row count, distinct origin count, amount sum),再沿主键进入单行。只看总金额会漏掉局部抵消,只看行数会漏掉金额口径错误。
权限与敏感数据从 raw 区开始隔离
原始账单可能包含账号、资源 ID、客户或项目标签、合同价、折扣、发票、税务和组织结构。采集器只读供应商导出并只写不可变前缀;转换任务只读必要账号和列;财务审批者控制汇率、关账和 adjustment;团队视图在服务端按责任域过滤;临时导出设置 TTL 和下载审计。
真实账号、合同、内部资源 ID 和生产账单不能进入仓库 fixture。测试样例使用语义占位符和虚构币种。日志记录批次、匿名化主键和错误类型,不输出完整原始行或凭证。长期访问密钥禁止写入 SQL、Notebook、Chart values 和 CI 变量样例,优先使用短期身份并定期验证撤销路径。
容量模型要计算平台自己的成本
多账号、小时级账单、高基数标签和容器分摊会快速放大数据量。估算容量时至少记录:
daily storage = source rows × average row bytes × revision factor
allocation rows = origin rows × average target fan-out
recompute work = retained windows × policy revisions × active scenarios
query scan = selected partitions × projected columns × concurrency按 charge date、provider 和 billing account 分区通常比按高基数资源 ID 分区稳定。资源、SKU、汇率和 Owner 适合带有效期的维表;高频查询可以预聚合,但预聚合不能替代 raw 与 lineage。关账快照、争议证据和合规数据按要求长期保留,临时中间表、失败导出和个人下载采用短 TTL。
监控同时覆盖 freshness、coverage、duplicate、orphan、unknown currency、unallocated、conservation delta、invoice delta、重算时长和查询扫描量。成本平台自身的存储、计算、网络和维护人力也进入总拥有成本。
Schema 升级、回滚和退出要能重放历史
供应商 schema、FOCUS 版本、价格表或分摊规则升级时,用同一 raw 批次平行生成旧版和新版结果。逐层比较字段覆盖、主键数量、费用类别、币种、四类成本口径、Owner 迁移、分摊守恒和发票差异。新字段为空不等于 0,供应商交付旧规范也不能被转换层伪装成已经具备新规范全部语义。
读视图通过独立 revision 切换。失败时只回滚 reader 和转换任务,不删除争议批次;已经批准的财务政策通过 adjustment 修正,不能被代码回滚静默撤销。
退出数据平台前,导出 raw manifest、原始文件引用、schema、供应商映射、身份历史、价格与汇率、分摊规则、质量结果、关账快照和审计记录。在替代环境重放一个窗口,证明行数、主键、金额和责任一致。最后停止采集、撤销身份、清理临时存储和缓存,并按保留策略核销旧数据。
成本数据工程的成熟标志不是“所有费用都已分摊”,而是每一笔费用都能说明来源、时间、币种、口径、责任和置信度;缺失能够被看见,争议能够被重算,规则能够被回滚,平台能够被替换。
