PAYATHON 2026

如何在 Azure Usage Records 中确定唯一记录

支付阿杰

结论

不要用返回顺序或 Quantity 生成 Usage record 的唯一键,也不要自行拼接业务字段作为唯一键。

Azure Usage/Billing API 的不同版本返回结构并不完全相同:

  • 响应中有顶层 id 时,应将完整的 id 保存为记录的外部唯一标识。
  • 某些 Usage Details API 会同时返回 id 和 name。应优先保存完整 id,不能只保存 name。
  • 如果旧版 Usage API 确实没有稳定的记录标识,就无法通过 SubscriptionId + Date + MeterId + Quantity 等字段可靠推导出天然唯一键。同步时应采用“快照暂存、确定性指纹、重叠回拉、幂等 Upsert”的方式,不能依赖数组顺序。

Usage 数据还可能延迟到达,也可能因计费调整、重新评级或退款而变化。以前拉取过的记录不一定永久不变,所以唯一标识和同步策略需要一起设计。

检查响应中的 id

许多 Azure Usage/Billing REST API 返回的单条资源结构类似于:

{
  "id": "/subscriptions/00000000-0000-0000-0000-000000000000/providers/Microsoft.Consumption/usageDetails/...",
  "name": "...",
  "type": "Microsoft.Consumption/usageDetails",
  "properties": {
    "usageStart": "2026-08-01T00:00:00Z",
    "usageEnd": "2026-08-02T00:00:00Z",
    "meterId": "00000000-0000-0000-0000-000000000000",
    "quantity": 24
  }
}

顶层 id 是 Azure 资源标识,不属于 properties。如果程序只反序列化 properties,就可能误以为 API 没有返回唯一标识。

应检查这些位置:

value[n].id
value[n].name
value[n].properties

如果 SDK 使用自定义 DTO,还要确保模型包含顶层字段:

public sealed class UsageDetail
{
    public string? Id { get; set; }
    public string? Name { get; set; }
    public string? Type { get; set; }
    public UsageProperties? Properties { get; set; }
}

不同计费协议、API 路径和 api-version 返回的字段可能不同,不能假设所有 Azure Billing API 中的 id 都有相同语义。具体应以当前调用版本的实际响应和对应 API 文档为准。

SQL 中的推荐设计

不建议直接把 Azure id 作为聚集主键。可以保留数据库内部主键,再为 Azure 标识建立唯一约束:

CREATE TABLE dbo.AzureUsageDetail
(
    UsageRowId       bigint IDENTITY(1,1) NOT NULL
        CONSTRAINT PK_AzureUsageDetail PRIMARY KEY,

    AzureUsageId     nvarchar(1024) NULL,
    SubscriptionId   uniqueidentifier NOT NULL,
    UsageStartUtc    datetime2(7) NULL,
    UsageEndUtc      datetime2(7) NULL,
    MeterId          uniqueidentifier NULL,
    ResourceId       nvarchar(2048) NULL,
    Quantity         decimal(38, 12) NULL,
    Cost             decimal(38, 12) NULL,
    Currency         nvarchar(16) NULL,

    SourceHash        binary(32) NOT NULL,
    SourcePayload     nvarchar(max) NULL,
    FirstSeenUtc      datetime2(7) NOT NULL,
    LastSeenUtc       datetime2(7) NOT NULL
);

当 AzureUsageId 存在时,可以建立过滤唯一索引:

CREATE UNIQUE INDEX UX_AzureUsageDetail_AzureUsageId
ON dbo.AzureUsageDetail(AzureUsageId)
WHERE AzureUsageId IS NOT NULL;

如果同一数据库会存储不同 API、租户或 billing scope 的数据,唯一键最好包含来源范围:

CREATE UNIQUE INDEX UX_AzureUsageDetail_Source
ON dbo.AzureUsageDetail
(
    BillingAccountKey,
    ApiSource,
    AzureUsageId
)
WHERE AzureUsageId IS NOT NULL;

BillingAccountKey 和 ApiSource 需要作为实际列加入表中,以免不同计费接口偶然产生格式相同的标识。

使用 Upsert,不按顺序插入

每次抓取时,应先处理完所有分页,再根据 AzureUsageId 执行幂等更新,不能使用响应数组的下标。

下面是一个简化的 SQL Server 示例:

UPDATE target
SET
    target.SubscriptionId = source.SubscriptionId,
    target.UsageStartUtc  = source.UsageStartUtc,
    target.UsageEndUtc    = source.UsageEndUtc,
    target.MeterId        = source.MeterId,
    target.ResourceId     = source.ResourceId,
    target.Quantity       = source.Quantity,
    target.Cost           = source.Cost,
    target.Currency       = source.Currency,
    target.SourceHash     = source.SourceHash,
    target.SourcePayload  = source.SourcePayload,
    target.LastSeenUtc    = SYSUTCDATETIME()
FROM dbo.AzureUsageDetail AS target
JOIN dbo.AzureUsageStage AS source
  ON source.AzureUsageId = target.AzureUsageId
WHERE source.AzureUsageId IS NOT NULL
  AND source.SourceHash <> target.SourceHash;

INSERT INTO dbo.AzureUsageDetail
(
    AzureUsageId,
    SubscriptionId,
    UsageStartUtc,
    UsageEndUtc,
    MeterId,
    ResourceId,
    Quantity,
    Cost,
    Currency,
    SourceHash,
    SourcePayload,
    FirstSeenUtc,
    LastSeenUtc
)
SELECT
    source.AzureUsageId,
    source.SubscriptionId,
    source.UsageStartUtc,
    source.UsageEndUtc,
    source.MeterId,
    source.ResourceId,
    source.Quantity,
    source.Cost,
    source.Currency,
    source.SourceHash,
    source.SourcePayload,
    SYSUTCDATETIME(),
    SYSUTCDATETIME()
FROM dbo.AzureUsageStage AS source
WHERE source.AzureUsageId IS NOT NULL
  AND NOT EXISTS
  (
      SELECT 1
      FROM dbo.AzureUsageDetail AS target
      WHERE target.AzureUsageId = source.AzureUsageId
  );

这里将 UPDATE 和 INSERT 分开执行,更便于控制并发和审计。如果使用 MERGE,还需要考虑 SQL Server 的并发行为,以及项目所用版本的已知问题。

API 没有稳定 id 时怎么办

如果当前接口的响应确实没有稳定的行标识,客户端就无法仅凭可见字段证明两条内容完全相同的记录分别来自两台 VM。当两条记录的所有可见字段都相同时,Azure 没有返回的资源身份无法由客户端恢复。

这种情况下可以生成确定性指纹,但要明确它只是“内容指纹”,不等同于 Azure 原始记录 ID。

例如,可以规范化所有具有计费意义且相对稳定的字段,再计算 SHA-256:

using System.Globalization;
using System.Security.Cryptography;
using System.Text;

static string Normalize(string? value) =>
    value?.Trim().ToUpperInvariant() ?? "<NULL>";

static string DecimalText(decimal? value) =>
    value?.ToString("0.############################", CultureInfo.InvariantCulture)
    ?? "<NULL>";

static byte[] BuildUsageHash(UsageRow row)
{
    var canonical = string.Join("|", new[]
    {
        Normalize(row.SubscriptionId),
        Normalize(row.BillingPeriod),
        Normalize(row.UsageStartUtc?.ToUniversalTime().ToString("O")),
        Normalize(row.UsageEndUtc?.ToUniversalTime().ToString("O")),
        Normalize(row.MeterId),
        Normalize(row.ResourceId),
        Normalize(row.InstanceId),
        Normalize(row.ResourceLocation),
        Normalize(row.ChargeType),
        DecimalText(row.Quantity),
        DecimalText(row.Cost),
        Normalize(row.Currency)
    });

    return SHA256.HashData(Encoding.UTF8.GetBytes(canonical));
}

内容指纹有两个限制:

  1. 两条真实记录的所有返回字段完全相同时,会产生相同的指纹。
  2. Azure 修订同一条记录的费用、数量或其他字段后,指纹也会随之变化。

因此,内容指纹可以用来判断相同内容是否已处理,但不能作为绝对可靠的源记录身份。

如果必须保留内容完全相同的重复行,可以在一次完整快照内计算重复序号:

ROW_NUMBER() OVER
(
    PARTITION BY SourceHash
    ORDER BY SourceHash
) AS DuplicateOrdinal

随后用 SourceHash + DuplicateOrdinal 表示当前快照中的重复实例。这个序号只在该次完整快照内有效,不能认为它在多次 API 调用之间始终对应同一条物理记录。

可靠的同步流程

建议按以下流程同步:

  1. 根据 subscription、billing scope 和日期区间发起请求。
  2. 跟随 nextLink 读取所有分页,不能只处理第一页。
  3. 将本次完整结果写入带 RunId 的暂存表。
  4. 有稳定 id 时按 id Upsert;没有稳定 id 时,按规范化后的内容指纹处理。
  5. 每次重新拉取最近若干天或整个未结算账期,以覆盖延迟到达的数据。
  6. 保存原始 JSON、抓取时间、API 路径、api-version 和同步批次号,供后续审计。
  7. 只有分页全部读取成功、查询范围完全一致且账期状态允许时,才能判断记录是否缺失。
  8. 对未再次出现的记录,不要立即物理删除。应先更新 LastSeenUtc 或标记 IsMissingCandidate,等后续完整快照确认后再处理。

以下情况不能直接解释为记录已被删除:

  • API 请求中途失败;
  • 某一页读取失败;
  • 查询时间边界发生变化;
  • API 返回存在延迟;
  • 账单尚未最终确定;
  • 记录被重新评级,或被调整行替代。

关于 VM 名称和 Resource ID

较新的 Usage Details 数据在部分计费场景中可能包含 resourceId、instanceId 或相关属性,但并非所有费用都对应一台有名称的 Azure Resource。共享服务、网络流量、Marketplace、支持费用和调整项等记录可能没有 VM 名称。

即使响应包含 resourceId,也不能单独用它作为 Usage record 的唯一键。同一项资源在同一天可能产生多个 meter、多个计费区间或多个 charge line。

最终建议

优先使用响应顶层的完整 id,并确认当前 SDK 没有在反序列化时丢弃该字段。数据库可以保留内部主键,同时为计费范围、API 来源和 Azure id 建立唯一约束。抓取时处理完整分页,并通过重叠时间窗口执行幂等 Upsert。

如果旧接口确实没有稳定 id,就不存在可靠的字段组合可以还原 API 未提供的记录身份。此时应以完整范围快照为同步单位,使用内容指纹、重复计数和批次审计进行对账。如果业务必须严格区分字段完全相同的两条源记录,则需要改用能够返回稳定行标识的 Usage Details、Cost Management 导出或账单明细数据源。

备注:内容仅供参考。