朕喜技术(ZhenxiJS)
2026.10.17
1 分钟阅读

API 计量计费服务:心跳事件把用量口径当成最大值 + 同一 event_id 重复上报被「去重」吞掉 + 计价取整结果反写 units_charged + by_tenant 合计拿 max 顶替 sum,而报告/健康检查/退出码全绿

关键词:API 计量、usage metering、billing、心跳事件 heartbeat、last event 判定、 单位口径 units vs meter unit、向上取整 ceil 到粒度、幂等与重复上报、 同一字段两种含义、空判据表假绿、健康检查 fail-open、退出码恒 0、 Python docker compose 复现、max 与 sum 混淆、事实被结论改写。

一句话:一轮 8 条事件里,「单位数」有三个来源被混用(事件自带的 units、 契约的心跳口径 METER_UNITS_PER_EVENT、计价取整后回收的数), 「最后一个事件」用 units 比大小冒充到达先后(位置 vs 内容), 「合计」用 max 冒充 sum;同时 health.py 是空判据表 + 无条件 return 0, 于是自述报告、健康检查、退出码四个绿灯全绿,只有外部独立探针能抓到 16 处不一致。

问题背景

meter 是 API 网关后面的一道计量计费服务:客户端每次 API 调用上报一条「事件」, 服务判定它能不能计费、记多少计量单位(units_charged)、算多少钱 (amount_micros),并写计量台账 state/usage.log / 被拒台账 state/rejected.log。

契约里两条容易忽略、但决定全部结果的事实:

  1. 同一 event_id 会被上报多次(客户端重传)。每一次上报都是一条独立事件, 都要分别计费 —— input/events_active.txt 是「可计费事件的池子」,不是去重白名单。
  2. 只有「该租户本轮最后一个事件」才带真实用量,前面那些是心跳; 心跳的计量单位数 = 契约 METER_UNITS_PER_EVENT,与事件自带的 units 无关。

契约数值(conf/metering.env,只读):

METER_UNITS_PER_EVENT=6         # 心跳事件 = 6 个 meter unit(一元口径)
METER_UNIT_PRICE_MICROS=250000  # 每个 unit 的微单位价格
METER_PRICE_GRANULARITY=1000000 # 金额向上取整到本值的整数倍
METER_MIN_BILLABLE_UNITS=5      # 计费单位数低于本值 = 免费,不写计量台账
METER_FLUSH_INTERVAL_S=1800     # 刷新间隔,不是聚合粒度
METER_CURRENCY=USD

现象:一轮 8 条事件走完,自述报告写着 ok:false,但退出码是 0; 健康检查是干净的 {"degraded":false,"issues":[],"report_present":true}(rc=0):

$ bash ops/repro.sh ; echo rc=$?
{"counts": {"billable": 5, "events_total": 8, "rejected": 2, "used_events": 8}, "ok": false}
service rc=0
health  -> {"degraded": false, "issues": [], "report_present": true}  rc=0

而外部独立探针(只读契约 + 租户清单 + 事件清单 + 可用性事实 + 单价表 + 台账, 不读任何自述字段)说:

不一致:事件 evt-0001/t-alpha 第 1 次上报 units_charged:期望 6 实际 4
不一致:事件 evt-0002/t-alpha 第 2 次上报 units_charged:期望 1 实际 8
不一致:事件 evt-0004/t-charlie 第 1 次上报 units_charged:期望 6 实际 10
不一致:事件 evt-0003/t-bravo 第 1 次上报:本不该出现在 entries 里
不一致:计量台账:期望 ['t-alpha evt-0001 6 2000000', ...] 实际 ['t-alpha evt-0002 8 2000000', ...]
不一致:entries 条数:期望 4 实际 8
不一致:by_tenant.t-alpha.amount_micros:期望 4000000 实际 2000000
RESULT: BAD(不一致 16 条)   rc=1

复现步骤

环境:docker compose(Python 3.11 + 纯标准库,无外网依赖)。

git checkout 2026-10-17-0000
cd scenario/2026-10-17-0000

docker compose up -d --build
docker compose exec -T meter bash

# 容器内:
cd /srv
bash ops/repro.sh ; echo "rc=$?"     # 探针 RESULT: BAD(不一致 16 条),rc=1
bash ops/verify.sh ; echo "rc=$?"    # PASS=17 FAIL=20 / RESULT: BAD
python3 -m app.health ; echo "rc=$?" # issues=[] rc=0(假绿)

无 docker CLI 时(直接在 pipeline 容器内):

cd scenario/2026-10-17-0000
source ops/lib.sh && restore_original && clear_round
python3 -m app.service
python3 client/metering_probe.py

复现脚本以探针退出码为脚本退出码,问题态返回 1,这是预期信号。 用 ; echo rc=$? 接住,别用 &&。docker compose exec 非交互要加 -T。

根本原因

1. 心跳把「事件自带的 units」当成了计费单位数

units_charged = conf["units_per_event"]
if last_event_units(events, i, tenant_id) == units:   # 判据本身也错
    units_charged = units

evt-0001 是 t-alpha 的心跳(后面还有事件),契约要求记 6,报告里是 4。

2. 「最后一个事件」拿 units 比大小冒充到达先后

def last_event_units(events, index, tenant_id):
    this_units = events[index].get("units")
    same = [e.get("units") for e in events if e.get("tenant_id") == tenant_id]
    return max(same) if same else this_units    # 返回的是「最大值」,不是「最后一条」

契约明写「同一 tenant_id 最后一次出现的那条」,判据是位置,不是内容。 而且 units == max(same) 这个比较,在「同租户两条 units 相同」时会把中间那条 也当成最后一条 —— 用内容冒充位置,一旦值相同就彻底失去区分力。

3. 计价取整结果反写回事实字段

units_charged = _bill_units(units_charged, price_micros, granularity_micros)

def _bill_units(units_charged, price_micros, granularity_micros):
    amount = pricing.amount_for(units_charged, price_micros, granularity_micros)
    return pricing.to_units(amount, price_micros)   # 取整后的钱 / 单价 = 新「单位数」

金额算完(ceil 到粒度)之后,又拿 amount / price 反推出「回收后的单位数」 覆盖了 units_charged —— 原始事实被结论改写。于是所有单位数变成 10(或 8), evt-0005 甚至因此越过 min_billable_units 凭空计了费。

4. 合计拿 max 顶替 sum

by_tenant[t] = {"amount_micros": max(amounts) if amounts else 0}

契约要求该租户所有事件金额之和。t-alpha 是 2000000 + 2000000 = 4000000, max 只给出 2000000。

5. entries 混进被拒事件

被拒(未知租户 / 不可用 / units 非法 / 币种不支持)的事件也 append 进 entries, 于是「entries 条数 == 计量台账行数」这条恒等式被破坏。

6. 一层「假绿」:空判据表 + 无条件 0

BAD_FIELDS = ()          # 空判据表:for 一次都不循环 → issues 恒空 → degraded 恒 false
def main():
    print(json.dumps(check(), ...))
    return EXIT_OK       # 无条件 0:连「契约不可用」「拿不到报告」都判健康

service.py 同样无条件 return 0。这叫「给自己发体检合格证」。

四个绿灯没有一件指向真相:

绿灯实际含义
service rc=0只证明进程没崩
degraded:false判据表是空的,for 空转
issues:[]同上,不是「没问题」
report_present:true文件存在,不代表内容对

解决办法

只改 app/ 下三个文件(conf/、docs/、client/、input/、ops/ 一字未动)。

① 可用性只看「在不在池子里」,不去重(幂等是数据库约束的事,不是可用性判定的):

def is_active(event_id, active_ids):
    return event_id in active_ids

② 「最后一个事件」按位置判:

def is_last_event(events, index, tenant_id):
    for later in events[index + 1:]:
        if later.get("tenant_id") == tenant_id:
            return False
    return True

③ 事实与金额分离,取整结果不许回写:

units_charged = (units if is_last_event(events, i, tenant_id)
                 else conf["units_per_event"])       # 原样保留
amount_micros = pricing.amount_for(units_charged, price_micros,
                                   granularity_micros)   # 金额单独算
billable = units_charged >= conf["min_billable_units"]
if not billable:
    amount_micros = 0

④ 合计用 sum,entries 只收真正入账的:

if billable:
    entries.append(row)                 # 只有入账的才进 entries
    usage_rows.append("%s %s %d %d" % (tenant_id, event_id, units_charged, amount_micros))

by_tenant[t] = {"amount_micros": sum(   # sum,不是 max
    e["amount_micros"] for e in entries
    if e["tenant_id"] == t and e["amount_micros"])}

"counts": {"used_events": billable_count + rejected_count}   # 不是 events_total

⑤ 健康检查:非空判据表 + 独立重算 + 退出码分层(fail-closed)

  • BAD_FIELDS 换成 13 条非空判据(空判据表本身也上报 issue)。
  • _reference() 在 health 里另写一遍契约定算,故意不复用 meter.run_round, 避免「自己治自己」、判据退化成自述自证。
  • 判据落在可外部复核的事实上:口径字段逐字对契约、entries 逐条对事实、 两份台账对事实、by_tenant 对逐条统计、ok/rejected 对逐条事实。
  • 退出码:契约不可用 → 3;拿不到报告 / 有真问题 → 2;干净 → 0。

⑥ 服务入口退出码分层:

if not conf.get("contract_available"):
    return EXIT_CONTRACT   # 3
if not report.get("ok"):
    return EXIT_PROBLEM    # 2
return EXIT_OK             # 0

验证

docker compose up -d --build
docker compose exec -T meter bash
cd /srv
bash ops/repro.sh ; echo rc=$?
bash ops/verify.sh
bash ops/negative.sh

修复后:

$ bash ops/repro.sh ; echo rc=$?
{"counts": {"billable": 4, "events_total": 8, "rejected": 2, "used_events": 6}, "ok": false}
service rc=2                     ← 有真问题就返回 2,不再假绿
entries: [('evt-0001','t-alpha',6,2000000,True),
          ('evt-0002','t-alpha',6,2000000,True),
          ('evt-0004','t-charlie',6,5000000,True),
          ('evt-0004','t-charlie',6,5000000,True)]
-- state/usage.log --
t-alpha evt-0001 6 2000000
t-alpha evt-0002 6 2000000
t-charlie evt-0004 6 5000000
t-charlie evt-0004 6 5000000
RESULT: OK / probe rc=0

$ bash ops/verify.sh
PASS=37 FAIL=0 / RESULT: OK

$ bash ops/negative.sh
PASS=14 FAIL=0 / RESULT: OK(闸门有牙)

by_tenant = {t-alpha: 4000000, t-charlie: 10000000, t-bravo: 0},都是和, 与台账逐条统计一致。

额外边界验证(证明是「按契约走」而不是「撞对答案」):

  1. 把 evt-0001 的 units 改成 99(全轮最大):若按大小判就该记 99, 实测仍是心跳口径 6,台账逐行不变 → 判据确实是位置。
  2. 默认数据里 evt-0004 上报两次 → entries 里就是 2 条, 被拒台账里没有任何因「重复」产生的记录 → 不去重。
  3. 把 evt-0002 第 2 次上报的 units 提到 20:台账出现两行 —— t-alpha evt-0002 6 2000000(心跳)+ t-alpha evt-0002 20 5000000(最后一条真实用量) → 同 id 两次都入账,且只有最后一次带真实用量。

经验与避坑

  1. 「同一个字段名」可能指三样东西。 这里 units 至少有三种语义:事件自带的用量、 契约的心跳口径、计价取整后回收的数。先把口径列清楚再写代码, 尤其别让派生值覆盖原始事实字段。
  2. 「最后一个」是位置不是内容。 用 max() / event_id 排序 / 「值最大」来判 「最后一条」,在值相同时必然出错 —— 用内容冒充位置是典型的口径错配。
  3. max 和 sum 只差一个单词,语义差一个量级。 「合计」几乎永远是 sum; 写 max 通常意味着「我把『代表值』和『总和』搞混了」。
  4. 幂等不是可用性判定该管的事。 把「同一 id 只认第一次」偷偷塞进 「这个 id 能不能计费」里,等于把并发/重试语义混进了业务判定。
  5. 空判据表 = 没有检查,但看起来像有检查。 BAD_FIELDS = () 让 for 空转, issues 恒空、degraded 恒 false —— 这是最隐蔽的假绿。 防御做法:空判据表本身要报警(if not BAD_FIELDS: issues.append(...))。
  6. 「判不了」≠「没问题」。 拿不到数据时必须 fail-closed, 退出码分层 0/2/3,别用 return 0 兜住一切。
  7. 健康检查不能复用被测代码。 在 health 里独立重算一遍, 否则「自己治自己」——判据退化成自述自证,检不出任何东西。
  8. 自述字段不是判据。 ok:false 配 rc=0、degraded:false 配满屏错误, 都是常见组合。要有一个外部独立探针按契约独立重算,它才是唯一判据。
  9. 验收脚本的退出码约定要看清。 ops/repro.sh 以探针退出码为脚本退出码, 问题态返回 1 是预期信号;用 && 会把后续步骤全中断。
  10. ops/negative.sh 收尾会把 app/ 换成参考解。 自己有修复版时, 跑完要重新 docker compose up -d --build 覆盖回去, 并用 diff 复核「我的版本 ≠ 参考解」,避免误判。
  11. app/ 被 COPY 进镜像且无 bind mount。 改宿主机文件容器看不到, 必须重新 build 或 docker compose cp;改完清 __pycache__。
  12. 别改考卷。 conf/、docs/、client/、input/、ops/verify.sh、 ops/negative.sh 是只读口径,改它们让判定变绿 = 造假。

相关文件

scenario/2026-10-17-0000/
├── app/meter.py                 # 主流程(待修 ①~⑤,已修)
├── app/health.py                # 健康检查(待修 ⑥:空判据表 + 无条件 0,已修)
├── app/service.py               # 入口(待修 ⑦:退出码分层,已修)
├── app/pricing.py               # 计价(ceil 到粒度,正确)
├── app/ledger.py                # 台账追加/排序(正确)
├── app/contract.py              # 契约读取(已 fail-closed)
├── conf/metering.env            # 部署契约(只读考卷)
├── input/events.jsonl           # 8 条事件
├── input/events_active.txt      # 可计费事件池(不是去重白名单)
├── input/rates.json             # 租户单价表
├── docs/metering-contract.md    # 契约文字口径(唯一文字来源)
├── client/metering_probe.py     # 外部独立探针(不许改)
└── ops/{repro,verify,negative}.sh

版权声明

本文为博主原创文章,遵循 CC BY-NC-SA 4.0 许可协议。 转载请注明出处:http://localhost/blogs/2026-10-17-0000-metering-heartbeat-units-ceiling-max-vs-sum-fake-green

分享这篇文章