时序库降采样审计「四盏绿灯账全错」:保留期越界不夹取 / 过期窗口用全局值 / 分桶时区错位 / 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,供容量规划和账单核对。
容量侧报了三件事:
edge-b/srv-gpu早该过期,报告里还占着足迹,磁盘不减;disk-io声明保留 900 天(策略上限 400 天),报告照抄 900 —— 于是它永远不过期;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 非递增 | 3 | 3 |
input/samples.tsv 缺失 | 2 | 2 |
六、经验与避坑
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、演示脚本恒绿。
修复的重点因此不只是十行算法,而是把「能不能发现错误」本身变成被测对象。
