云上代金券与资源到期的每日快照与幂等兑换
账上有券,账单还是超了
这个月对账时遇到一组很典型的组合故障:账户里还有两张代金券,余额合计足够覆盖当月 ECS 与 RDS 的开销,财务看了一眼就放过了。结果第一张券 10 月 12 日 23:59:59 到期,第二张只覆盖对象存储;而真正花钱的包年包月实例 10 月 8 日到期后自动转成了按需。月底账单超预算四成,券一分没少,钱一分没省。
拆开看,代金券至少有三个互相独立的维度:余额(还能抵多少)、有效期(什么时候作废)、适用范围(能不能抵这笔)。只盯余额,等于只看了三分之一。资源侧同理,到期时间和自动续费开关跟券完全解耦——券再多,实例转按需照样产生费用。
为什么是每日快照,不是实时轮询
- 费用中心查询接口有 QPS 限制和日调用配额。cron 一分钟跑一次,一天 1440 次调用,换回来的信息量接近零。
- 余额只在兑换、核销、过期三个时刻变化,有效期根本不变。轮询拿不到新信息。
- 快照能回答“和昨天比差了什么”,实时轮询只有瞬时值。过期那一刻没采样到,信息就永久丢了。
- 只读快照可以放心交给所有值班同学执行;一旦脚本里混进写操作,权限和审计就得整套收紧。
快照流水线:原始 JSON 落盘 + 按天主键表
第一步只做一件事:把接口原始返回落盘,不做解析。解析逻辑改了还能重放,原始返回丢了只能等明天。
hcloud BSS <OperationName> --cli-region=cn-north-4 --cli-output=json > /var/lib/cost-guard/raw/coupons-$(date -u +%F).json
操作名用 API Explorer 里费用中心查询代金券列表对应的那个,不要直接照抄示例。第二步入库,主键是(snap_date, coupon_id),同一天重跑不会产生脏数据:
CREATE TABLE IF NOT EXISTS coupon_snapshot(snap_date TEXT NOT NULL, coupon_id TEXT NOT NULL, amount REAL, balance REAL, expire_at TEXT, scope TEXT, status TEXT, PRIMARY KEY(snap_date, coupon_id));
写入用 INSERT OR REPLACE,snap_date 取 UTC 日期,重跑当天任务就是覆盖,天然幂等。到期时间统一转成 UTC 再入库(形如 2026-11-12 23:59:59),否则下面的 julianday 差值会算错。
解析字段集中在一处,返回体字段跟文档对不上只改这里:FIELDS = {'id':'coupon_id','balance':'balance','expire':'expire_time','amount':'face_value','scope':'product_scope','status':'status'}。取不到就整条丢弃并打一条 skip coupon: field missing 日志,不要用默认值兜底——兜底出来的余额 0 会让你误判成券是空的。
有了按天表,“临期且有余额”就是一句 SQL:
SELECT coupon_id, balance, expire_at FROM coupon_snapshot WHERE snap_date = date('now') AND balance > 0 AND julianday(expire_at) - julianday('now') BETWEEN 0 AND 30 ORDER BY expire_at;
资源侧再建一张 resource_snapshot(snap_date, resource_id, product, expire_at, auto_renew),两条查询结果合并成当天的风险清单,再进告警。
兑换动作:一次性开关、幂等记录、失败不重试
代金券的兑换接口大概率不是幂等的:超时返回时你无法判断是“没成功”还是“成功了但响应丢了”。所以这个动作绝不能挂在定时任务里自动重试,必须拆成独立脚本,加三道闸。
- 一次性开关:脚本开头判断
[ -f /etc/cost-guard/redeem.enabled ] || exit 0。文件由人手动创建,跑完手动删除。没这个文件,脚本什么都不做。 - 幂等记录:先写记录再调接口。
CREATE TABLE redeem_log(coupon_id TEXT PRIMARY KEY, decided_at TEXT NOT NULL, state TEXT NOT NULL CHECK(state IN ('pending','done','failed')), err TEXT); - 失败不自动重试:接口失败就把 state 写成 failed 并
exit 1,交给告警和人。超时这类状态不确定的情况一律按 failed 处理,人工到控制台核对后再决定。
抢占式写入用一条语句拿结果,避免“查了再插”的竞态:
sqlite3 $DB "INSERT OR IGNORE INTO redeem_log(coupon_id,decided_at,state) VALUES('$CID',datetime('now'),'pending'); SELECT changes();"
返回 1 才继续调兑换接口,返回 0 说明这张券此前已处理过,直接退出。成功后再执行 UPDATE redeem_log SET state='done' WHERE coupon_id='$CID';
监控脚本里不要顺手触发任何写操作
这条不是洁癖。快照脚本每天被 cron 跑一次,值班同学排查问题还会手动跑几次,一旦里面混进兑换或续费,等于把写操作的执行次数交给“谁手滑跑了一遍”决定。更糟的是告警链路:写操作失败触发告警,告警动作又去重跑脚本,一个下午就能把兑换接口打進风控。
做法很简单:读和写分成两个脚本、两套权限、两条告警通道。只读脚本给所有人执行权限,只写 snapshot 表;写脚本只有运维负责人能执行,且必须有人为创建的一次性开关文件。审计也好交代——查 redeem_log 就能还原每一次写操作是谁、什么时候、为什么触发的。
告警节奏
按剩余天数分档:30 天进周报,14 天进日常群,7 天和 3 天各提醒一次,1 天内升到电话。去重键用 coupon_id + 档位,同一张券同一档只发一次;否则每天一跑就是每天一条,三天之后没人再看。
- 点赞
- 收藏
- 关注作者
评论(0)