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

时序库降采样审计「四盏绿灯账全错」:保留期越界不夹取 / 过期窗口用全局值 / 分桶时区错位 / avg 定标 / 空判据表假绿

日期:2026-10-26 | 环境:Python 3.11 + python:3.11-slim Docker | 分支 2026-10-26-1001 关键词:时序数据库、降采样 rollup、保留策略 retention policy、审计报告、假绿灯、 健康检查形同虚设、退出码分层、fail-closed、时区分桶、Decimal 定标、checksum、 __pycache__ 改完没生效、negative test anchor miss

一、问题背景

监控平台的时序落库服务 metrics-lake 每天凌晨跑一次审计:把原始样本降采样成 300s / 3600s 两级 rollup,按保留期清理过期样本,统计每组磁盘足迹写进 reports/rollup.json,供容量规划和账单核对。

容量侧报了三件事:

  1. edge-b / srv-gpu 早该过期,报告里还占着足迹,磁盘不减;
  2. disk-io 声明保留 900 天(策略上限 400 天),报告照抄 900 —— 于是它永远不过期;
  3. srv-cpu 的 3600s 桶落在 08:00,实际数据在本地 15:00~17:00,桶起点差 8 小时。

但值班同事看到的现象是一切正常:

$ python3 -m app.run
wrote /srv/tsdb/reports/rollup.json ok=True       # rc=0
$ python3 -m app.health
{"degraded": false, "issues": []}                 # rc=0
$ bash ops/repro.sh
RESULT: OK                                        # rc=0
$ jq '.ok,.counts' reports/rollup.json
true / {"bad_retention":1,"buckets":34,"clamped_retention":0,"expired":0,...}

四盏绿灯没有一件指向真相。 唯一说真话的是外部独立探针 (不 import app/,只按契约文档自己重算):

RESULT: BAD(不一致 19 条)
   1. counts.kept 期望 35 实际 38
   2. counts.expired 期望 3 实际 0
   3. counts.clamped_retention 期望 2 实际 0
   7. footprint.total_bytes 期望 1080 实际 1184
  18. buckets[300] 条数 期望 18 实际 20

二、复现步骤

git clone <repo> && cd scenario/2026-10-26-1001
docker compose up -d --build          # app/ 是构建时 COPY 进镜像的,没有 bind mount
docker compose exec -T tsdb bash      # 非交互必须 -T,否则 "the input device is not a TTY"

容器内(TSDB_ROOT=/srv/tsdb,时钟固定 now=1793000000):

bash ops/repro.sh                              # 恒 RESULT: OK —— 不是判定依据
python3 client/tsdb_probe.py --now=1793000000  # 外部独立探针,唯一说真话
bash ops/verify.sh                             # 逐条断言契约 → PASS=15 FAIL=20
bash ops/negative.sh                           # 问题态负向闸门 → NPASS=8 NFAIL=0

没有 Docker 也能跑(纯标准库):

cd scenario/2026-10-26-1001
TSDB_ROOT=$PWD NOW_EPOCH=1793000000 python3 -m app.run
TSDB_ROOT=$PWD python3 client/tsdb_probe.py --now=1793000000

时钟必须固定:不固定 now,「过期窗口」会随运行时刻漂移,复现不可重复。

三、根本原因

八处口径偏差,全部指向「实现与契约不一致,且没有任何东西去核对」:

1. 策略加载 fail-open(最致命)

cfg = dict(POLICY_DEFAULTS)          # UTC / 300s / skew=0 兜底
try:
    cfg.update(json.load(open(path)))
except OSError:
    pass                             # 文件没了?当没发生,用默认值继续跑

策略缺失、缺键、未知键、类型错、越界,全部静默放行。默认值里 timezone="UTC", 正好把「时区错位」也一起掩盖了。

2. 保留期只有两支,没有三档

契约要求 inf / 非法 / 越界夹取 三种语义分开:

if spec == "inf":
    return 36000
try:
    return int(spec)                 # 900 就返回 900,上限 400 完全没检查
except Exception:
    return raw_retention_days

disk-io 声明 900 天、srv-gpu 声明 0 天,两个都该被夹取却没夹; counts.clamped_retention 自然恒为 0。

3. 过期窗口用全局值,不是按组

def cutoff_of(conf, now):
    days = int(conf["raw_retention_days"])   # 永远是全局 7 天
    return now - days * 86400

而 edge-b 声明 2 天、srv-gpu 声明 0 天 —— 按 7 天算它们当然「没过期」。 契约明确写了:保留期按 (序列, 指标) 组各自判定。

4. 分桶时区错位:把本地秒换回了 UTC

base = (ts + 28800) // resolution * resolution
return base - LOCAL_OFFSET          # ← 契约要本地秒,这里又减回去了

对 300s 桶不显形(300 能整除 28800),对 3600s 直接差 8 小时。 选择性的 bug 最难发现:换个分辨率就正常了。

5. 小数位定标错

def fmt(value, places):
    q = Decimal(1).scaleb(-places)          # places 直接用样本原始位数
    return str(value.quantize(q))

契约要求 sum/avg 按组内最大小数位定标,且至少 2 位、最多 3 位。 原实现里 last 取到 187 就输出 187(0 位小数),18.75 又只有 2 位,全组不统一。

6. ok 写死 True

"ok": True,       # 契约:ok = expired==0 and skew==0 and bad_retention==0 and clamped_retention==0

7. 健康检查是空判据表

BAD_FIELDS = ()

def check(report):
    for field in BAD_FIELDS:      # 空循环,什么都查不出来
        ...
    return issues                 # 永远 []

def main(argv=None):
    ...
    return 0                      # 无条件 0

而且 check() 复用的就是 run.build() 的产物 —— 运动员兼裁判, 被测逻辑错了两边一起错,自然一起绿。

8. 退出码全塌成 0

run / health / service 全恒 0。契约要的三档分层 (0 干净 / 2 有真问题或事实不可读 / 3 契约不可用)完全消失, 上游编排根本无法根据退出码做出任何反应。

四、解决办法

4.1 策略加载改 fail-closed

class PolicyError(Exception): ...

def validate(cfg):
    missing = [k for k in REQUIRED if k not in cfg]
    if missing: raise PolicyError("policy 缺键: %s" % ",".join(missing))
    unknown = [k for k in cfg if k not in REQUIRED]
    if unknown: raise PolicyError("policy 未知键: %s" % ",".join(sorted(unknown)))
    if cfg["timezone"] != "Asia/Shanghai": raise PolicyError(...)
    if not _is_int(cfg["raw_retention_days"]) or cfg["raw_retention_days"] <= 0: raise ...
    if any(res[i] >= res[i+1] for i in range(len(res)-1)): raise ...   # 必须严格递增
    ...

删掉 POLICY_DEFAULTS。默认值不是「容错」,是把错误配置变成另一种错误数据。

4.2 保留期三档显式化

def retention_days(spec, conf):
    """返回 (days, kind),kind ∈ ok|bad|clamped"""
    if spec == "inf":
        return INF_RETENTION_DAYS, "ok"
    try:
        raw = int(spec)
    except (TypeError, ValueError):
        return int(conf["raw_retention_days"]), "bad"
    lo, hi = int(conf["min_retention_days"]), int(conf["max_retention_days"])
    if raw < lo: return lo, "clamped"
    if raw > hi: return hi, "clamped"
    return raw, "ok"

用 (值, 类别) 元组而不是「一个 int + 一堆 if 猜」, counts 的累加就是 if kind == "bad": ... elif kind == "clamped": ...,不会再写死某个具体值。

4.3 过期窗口按组

days, kind = retention_days(rows[0]["retention"], conf)
cutoff = now - days * 86400          # days 是该组的,不是全局的

4.4 分桶返回本地秒

def bucket_start(ts, resolution):
    return ((ts + LOCAL_OFFSET) // resolution) * resolution    # 本地秒,别再减回去

4.5 定标抽成显式函数

def group_places(rows):                       # 组内最大小数位,钳到 [2,3]
    return max(2, min(3, max([r["places"] for r in rows] or [0])))

def scale_places(rollup, g_places, pts):
    return g_places if rollup == "sum" else max(p[2] for p in pts)

def fmt(value, places):
    places = max(2, min(3, places))
    return str(Decimal(value).quantize(Decimal(1).scaleb(-places), rounding=ROUND_HALF_UP))

4.6 ok 由 counts 推导

def is_ok(counts):
    return (counts["expired"] == 0 and counts["skew"] == 0
            and counts["bad_retention"] == 0 and counts["clamped_retention"] == 0)

4.7 健康检查独立重算 + 非空判据表

关键:health.py 不 import app.engine,只借 contract 加载策略, 自己把过滤 / 保留期 / 分桶 / 定标 / 足迹 / checksum 全量重写一遍, 再和报告逐字段比对。

BAD_FIELDS = ("counts", "footprint", "groups", "buckets", "checksum", "ok")

def check(report, root=None, now=None):
    if report is None: return ["report missing or unparsable"], 0
    exp = recompute(root, now)            # ← 独立实现
    issues = compare(exp, report)         # 逐字段
    if not exp["ok"]:
        issues.append("报告 ok=false:存在被策略改写/丢弃的样本")
    return issues, checks

输出形如 {"checks": 23, "degraded": true, "issues": [...]}。

4.8 退出码分层

EXIT_OK, EXIT_FACTS, EXIT_CONTRACT = 0, 2, 3
# run:      PolicyError→3,OSError(samples 不可读)→2,写出报告→0
# health:   有偏差/ok=false→2,策略不可用→3
# service:  完全透传 health

退出码本身就是 API。 三个码塌成一个 0,等于把告警能力归零。

五、验证

$ python3 client/tsdb_probe.py --now=1793000000
{"checksum": "1e79775a9bb53b76", "counts": {"bad_retention": 1, "buckets": 30,
 "clamped_retention": 2, "expired": 3, "holdout": 1, "kept": 35, "raw_samples": 40,
 "series": 7, "skew": 1}, "ok": false, "result": "OK"}             # rc=0

$ bash ops/verify.sh
PASS=35 FAIL=0
RESULT: OK                                                         # rc=0

$ jq -c "{counts,footprint,ok,checksum}" reports/rollup.json
{"counts":{"bad_retention":1,"buckets":30,"clamped_retention":2,"expired":3,"holdout":1,
 "kept":35,"raw_samples":40,"series":7,"skew":1},
 "footprint":{"raw_bytes":840,"rollup_bytes":240,"total_bytes":1080},
 "ok":false,"checksum":"1e79775a9bb53b76"}

$ python3 -m app.health   → {"checks":23,"degraded":true,"issues":["报告 ok=false:…"]}   rc=2
$ python3 -m app.service  → 同上                                                          rc=2

注意 ok=false 是正确结果:这份数据本来就有 3 条过期、1 条 skew、 1 条非法保留、2 条越界夹取。修复不是让绿灯亮起来,是让报告说真话。

退出码分层的 8 个场景验证:

场景期望实际
策略文件缺失 / 未知键 / 缺键 / 类型错 / rollup_resolutions 非递增33
input/samples.tsv 缺失22

六、经验与避坑

1. 「绿灯」必须先证明它能红

ok: True 写死、BAD_FIELDS = () 空表、return 0 无条件 —— 这三种写法看起来 「永远不会误报」,实际上是永远不会报警。 自检方法:故意制造一个已知错误,看闸门会不会红。红不了就是假绿灯。

2. 裁判席不能由运动员兼任

health.check() 复用 run.build() 的产物,被测逻辑错了两边一起错。 正解:健康检查独立重算(哪怕代码重复)。 「复用 = 把 bug 复制到裁判席,两边一起错一起绿。」

3. 默认值兜底是隐蔽的 fail-open

cfg = dict(DEFAULTS)
try: cfg.update(json.load(open(path)))
except OSError: pass

这五行代码看起来「很健壮」,实际把「配置缺失」变成了「用一套可能完全错误的配置 悄悄跑完」,而且默认 timezone="UTC" 顺手把时区 bug 也盖住了。 配置类输入应该 fail-closed:读不到 / 不合法就拒绝启动(退出码 3),不要猜。

4. 三档语义不要挤成一个分支

inf / 非法 / 越界夹取,三者处理完全不同。挤成 if spec == "inf" else int(spec) 就丢掉了「夹取」这一档,而且「非法」只能靠 except 兜到默认值 —— 与「用户就是想要默认值」无法区分。 用 (值, 类别) 元组把语义显式化,别用异常做控制流。

5. 时区 bug 常常是「分辨率相关」的

ts + 28800 再 - 28800 看着像「抵消了」,但 300s 能整除 28800 所以不显形, 3600s 就暴露。测试要覆盖到不能整除 offset 的分辨率, 否则分桶错误会长期潜伏。

6. 契约里的「本地秒」这种口径要写死

bucket_start 到底是 UTC 秒还是本地秒,是这类系统最容易吵的地方。 契约文档里明确「本地秒」,代码里就只返回值本身,别在返回前做一次「顺手」的转换。

7. __pycache__ 会让「改完没生效」

同一个 Python 解释器会话里 import 过的模块不会重载。 改完源码再跑,行为仍是旧的,而 grep 显示源码是新的 —— 非常容易误判成「逻辑写错了」。 处置:export PYTHONDONTWRITEBYTECODE=1,或每次 rm -rf app/__pycache__, 或 python3 -B。Docker 场景还要记住 app/ 是构建时 COPY 进镜像的, 改完必须 docker compose up -d --build。

8. 负向测试的锚点会随修复失效

ops/negative.sh 用字符串替换注入变异,锚点写的是问题态代码 (return int(spec)、return base - LOCAL_OFFSET、"ok": True,…)。 修完之后再跑它必然 ANCHOR_MISS + 「基线不再是红的」→ 8 条全 FAIL。 这是预期行为,不是回归失败:它的定位是「解题前先证明闸门对问题态敏感」。 修完之后要做负向测试,得新写一组锚定新实现的变异 (例如删掉 validate() 的未知键检查、把 is_ok() 改成 return True)。

9. 用 repro.sh 这类「演示脚本」当判定依据很危险

它在问题态也输出 RESULT: OK。判定口径只能来自独立复算(探针 / 逐条断言脚本), 演示脚本的作用是「让人看到现象」,不是「判定对错」。

10. 固定时钟才可复现

带「过期窗口」「now 相关」的逻辑,必须把 now 注入(NOW_EPOCH), 否则今天跑和明天跑结果不同,checksum 也不同,无法当作回归基线。


一句话总结:这份代码的 bug 不是「算错了」,而是没有任何机制能发现它算错了 —— ok 写死、判据表空、退出码塌成 0、演示脚本恒绿。 修复的重点因此不只是十行算法,而是把「能不能发现错误」本身变成被测对象。

版权声明

本文为博主原创文章,遵循 CC BY-NC-SA 4.0 许可协议。 转载请注明出处:http://localhost/blogs/2026-10-26-1001

分享这篇文章