cron 调度(中心调度→stream 投递)+ 成本计量(token→USD 台账、usage report)
实现一个中心化的 cron 调度器把定时任务投递进消息总线,并搭一套把 token 用量换算成美元台账的成本计量体系。
今日目标
- 能实现一个中心调度器,按 cron 表达式定时把任务投递进消息总线
- 能实现一套 token → USD 的成本台账,按调用记账
- 能产出一份简单的 usage report,按用户或时间维度汇总成本
昨天给 Agent 装上了长期记忆,也顺手留了一句话:每写一条记忆都要调一次 embedding,这是一笔新开销,而到目前为止没有任何人在看这笔账。今天把两件事一起办完——让 Agent 从"用户不开口就一动不动"变成会按点自己干活,同时把每一分花出去的钱当场记进台账。读完回到页面顶部,把上面三条勾掉。
小白版讲解
排班表由一个人排,不是每个员工各自定闹钟
一家公司有三名前台,谁几点上岗写在行政排的那张班表上。假如把排班改成"每个前台自己在手机上定闹钟,闹钟一响就去开门",结果不会是开门更准时,而是每天早上三个人挤在同一扇门前,同一件事被做了三遍。
从 D10 起,你的 Worker 就不止一个了:分片和租约保证了同一个用户的消息落到同一个 Worker,但 Worker 进程本身是多副本的,D14 会把它扩到 3 个。这时候如果你在 Worker 的启动代码里写一句"每天 9 点跑一次日报任务",那句话会在三个进程里各执行一次——一个任务被执行 3 次,用户收到 3 份一样的日报,你付 3 份钱。
把这笔钱算出来,感受会更直接。假设电商客服场景里有 1000 个用户开启了"每日订单摘要",每天早上 9 点各跑一次 Agent:每次请求的输入约 3000 token(系统提示词 + 工具定义 + 当天订单数据),输出约 500 token。按本课统一的价格口径,对话模型 openai/gpt-4o-mini 的输入是 0.15 美元每百万 token、输出是 0.60 美元每百万 token:
- 单次输入成本:3000 ÷ 1000000 × 0.15 = 0.00045 美元
- 单次输出成本:500 ÷ 1000000 × 0.60 = 0.0003 美元
- 单次合计:0.00075 美元
- 每月:0.00075 × 1000 用户 × 30 天 = 22.5 美元
一个月 22.5 美元听起来不痛不痒。但三个 Worker 各起各的 cron,这个数字就变成 67.5 美元——多花的 45 美元买到的全是重复的骚扰消息。而且它不会停在这里:D14 把副本数从 3 调到 10 的那天,账单和骚扰量一起变成十倍,没有任何人会收到告警,因为从每个进程自己的视角看,它只是老老实实地按点执行了一次。
所以正确的形状是把"谁该在什么时候被执行"这件事收到一个地方,Worker 只负责干活、不负责决定什么时候干:
┌─────────────┐ 命中 cron ┌───────────┐ consumer group ┌──────────┐
│ 调度器 │ ─────────────> │ koda:runs │ ─────────────────> │ Worker 1 │
│ (单实例) │ XADD 一条 │ 消息总线 │ 一条消息只给一个人 ├──────────┤
└─────────────┘ └───────────┘ │ Worker 2 │
只做一件事: ├──────────┤
决定"现在该发什么" │ Worker 3 │
└──────────┘这张图里的总线就是 D9 那条:调度器只是它的又一个生产者,Worker 那侧一行都不用改。定时任务不是一种新的执行方式,它只是"谁来按下那个按钮"换了个人——原来是用户按,现在是钟表按。想明白这一点,今天要写的代码量就小得可怕。
但把决定权收给一个进程,马上会有人问:那这个调度器自己挂了怎么办?它重启的那一分钟正好跨过 9 点,是漏发还是重发?如果为了高可用起两个调度器实例,不就又回到"同一个任务被投递两次"了吗?
cron 表达式与调度器的最小实现
先把"什么时候"这件事说清楚。cron 表达式是一串用空格分开的五个字段,从左到右分别是分、时、日、月、星期:
| 位置 | 含义 | 取值范围 |
|---|---|---|
| 第 1 位 | 分钟 | 0–59 |
| 第 2 位 | 小时 | 0–23 |
| 第 3 位 | 日 | 1–31 |
| 第 4 位 | 月 | 1–12 |
| 第 5 位 | 星期 | 0–6,其中 0 是周日 |
每个字段有三种写法:星号是"任意值",数字是"等于这个值"(多个用逗号分开),星号加斜杠加数字是"每隔 n 个单位"。几个例子:
| 表达式 | 含义 |
|---|---|
*/5 * * * * | 每 5 分钟一次 |
0 9 * * * | 每天 9:00 |
0 9 * * 1 | 每周一 9:00 |
0 0 1 * * | 每月 1 日 0:00 |
30 8,20 * * * | 每天 8:30 和 20:30 |
cron 的最小粒度是分钟,没有"秒"这一位(Quartz 那种六位写法是扩展,不是标准)。所以调度器的主循环就一句话:每分钟醒来一次,拿这一分钟去逐个匹配任务表,命中的就投递一条消息。
真正需要小心的是用什么当幂等键。错误写法是拿 Date.now() 当键的一部分:两个调度器实例的时钟不可能对齐到毫秒,一个在 09:00:00.120 醒来、另一个在 09:00:00.480 醒来,算出的键就不一样,去重完全失效。正确做法是用"计划触发的那一分钟",也就是把秒和毫秒截掉之后的时间戳——不管谁在这一分钟的哪一刻醒来,键都是同一个字符串。
// 只支持 * 、数字列表、以及 */n 三种写法,够用且一眼能读懂
function matchField(field, value) {
if (field === '*') return true
if (field.startsWith('*/')) return value % Number(field.slice(2)) === 0
return field.split(',').some((part) => Number(part) === value)
}
function matches(expr, d) {
const [minute, hour, dom, mon, dow] = expr.split(' ')
return (
matchField(minute, d.getUTCMinutes()) &&
matchField(hour, d.getUTCHours()) &&
matchField(dom, d.getUTCDate()) &&
matchField(mon, d.getUTCMonth() + 1) &&
matchField(dow, d.getUTCDay())
)
}
async function tick(now) {
// 关键一步:把秒和毫秒截掉。幂等键必须锚在「计划触发的那一分钟」上,
// 不能锚在「我这次醒来的时刻」上——两个调度器实例的醒来时刻永远不会相同。
const slot = new Date(Math.floor(now.getTime() / 60000) * 60000)
const stamp = slot.toISOString().slice(0, 16) + 'Z'
for (const task of TASKS) {
if (!matches(task.cron, slot)) continue
await bus.publish('koda:runs', {
idempotencyKey: `cron:${task.id}:${stamp}`,
userId: task.userId,
input: task.input,
})
}
}def match_field(field: str, value: int) -> bool:
if field == "*":
return True
if field.startswith("*/"):
return value % int(field[2:]) == 0
return value in {int(part) for part in field.split(",")}
def matches(expr: str, d: datetime) -> bool:
minute, hour, dom, mon, dow = expr.split()
return (
match_field(minute, d.minute)
and match_field(hour, d.hour)
and match_field(dom, d.day)
and match_field(mon, d.month)
# Python 的 weekday() 里周一是 0,cron 里周日是 0,先换算再比
and match_field(dow, (d.weekday() + 1) % 7)
)
async def tick(now: datetime) -> None:
# 幂等键锚在「计划触发的那一分钟」:replace 把秒和微秒抹平
slot = now.replace(second=0, microsecond=0)
stamp = slot.strftime("%Y-%m-%dT%H:%MZ")
for task in TASKS:
if not matches(task.cron, slot):
continue
await bus.publish(
"koda:runs",
{
"idempotencyKey": f"cron:{task.id}:{stamp}",
"userId": task.user_id,
"input": task.input,
},
)// 依赖:java.time(JDK 8+)。record 描述任务配置,比一堆 getter 干净
record CronTask(String id, String cron, String userId, String input) {}
static boolean matchField(String field, int value) {
if (field.equals("*")) return true;
if (field.startsWith("*/")) return value % Integer.parseInt(field.substring(2)) == 0;
return Arrays.stream(field.split(",")).anyMatch(part -> Integer.parseInt(part) == value);
}
static boolean matches(String expr, ZonedDateTime d) {
var f = expr.split(" ");
return matchField(f[0], d.getMinute())
&& matchField(f[1], d.getHour())
&& matchField(f[2], d.getDayOfMonth())
&& matchField(f[3], d.getMonthValue())
// DayOfWeek 里周一是 1、周日是 7;cron 里周日是 0,取模换算
&& matchField(f[4], d.getDayOfWeek().getValue() % 7);
}
static void tick(Instant now) {
// truncatedTo 是 java.time 的原生表达,比手动做除法取整更能说明意图
var slot = now.truncatedTo(ChronoUnit.MINUTES);
var stamp = DateTimeFormatter.ofPattern("yyyy-MM-dd'T'HH:mm'Z'")
.withZone(ZoneOffset.UTC).format(slot);
for (var task : TASKS) {
if (!matches(task.cron(), slot.atZone(ZoneOffset.UTC))) continue;
bus.publish("koda:runs", Map.of(
"idempotencyKey", "cron:" + task.id() + ":" + stamp,
"userId", task.userId(),
"input", task.input()));
}
}struct CronTask { let id: String; let cron: String; let userId: String; let input: String }
func matchField(_ field: String, _ value: Int) -> Bool {
if field == "*" { return true }
if field.hasPrefix("*/"), let step = Int(field.dropFirst(2)) { return value % step == 0 }
return field.split(separator: ",").compactMap { Int($0) }.contains(value)
}
// Calendar 一次性拆出所有字段,比反复调 component(_:from:) 少绕好几圈
func matches(_ expr: String, _ parts: DateComponents) -> Bool {
let f = expr.split(separator: " ").map(String.init)
guard let minute = parts.minute, let hour = parts.hour, let day = parts.day,
let month = parts.month, let weekday = parts.weekday else { return false }
// Calendar 的 weekday 里周日是 1、周六是 7;cron 里周日是 0
return matchField(f[0], minute) && matchField(f[1], hour) && matchField(f[2], day)
&& matchField(f[3], month) && matchField(f[4], weekday - 1)
}
func tick(now: Date) async throws {
var cal = Calendar(identifier: .gregorian)
cal.timeZone = TimeZone(identifier: "UTC")!
// 只取到分钟的字段再还原成 Date,秒和纳秒自然被丢掉
let parts = cal.dateComponents([.year, .month, .day, .hour, .minute, .weekday], from: now)
guard let slot = cal.date(from: parts) else { return }
let stamp = ISO8601DateFormatter().string(from: slot).prefix(16) + "Z"
for task in tasks where matches(task.cron, parts) {
try await bus.publish("koda:runs", [
"idempotencyKey": "cron:\(task.id):\(stamp)",
"userId": task.userId,
"input": task.input,
])
}
}现在回答上一节结尾那三个问题。它们的答案是同一个:D8 那张 runs 表上的 idempotency_key 唯一约束。 Worker 拿到消息之后照旧走 insert into runs ... on conflict do nothing,冲突就说明这一分钟的这个任务已经有人建过 run 了,直接 XACK 掉不执行。这是 D8 那招幂等键的第二次使用:D9 用它挡住的是消息总线"至少一次"投递带来的重复消费,今天用它挡住的是多个调度器实例、或者一次重启重放带来的重复触发。同一个唯一约束,两种完全不同的重复来源。
"漏发"和"重发"的取舍也随之明确:调度器崩溃 90 秒后拉起来,跨过的那一分钟已经过去了;要补发就让它启动时回看最近 N 分钟、逐分钟跑一遍匹配。有幂等键兜底,重复投递是无害的,这正是"宁可多发不可少发"能成立的前提。at-least-once 加幂等,是分布式系统里最省心的一组搭配——反过来先追求 exactly-once 再补幂等,通常两头都做不好。
一次调用花了多少钱:把 token 换算成美元
排班表讲完了,翻到台账这一页。
报销台账的规矩是当场记:谁、什么时候、为什么花、花了多少,四样缺一不可,而且要在花钱那一刻记,不能等月底凭记忆补。成本计量一模一样,区别只在"多少"这一栏要先做一次换算——模型 API 返回的是 token 数,账单上的单位是美元。
换算本身只是一次乘法。本课统一的价格口径是这张表:
| 项 | 价格 |
|---|---|
对话模型 openai/gpt-4o-mini 输入 | 0.15 美元 / 100 万 token |
对话模型 openai/gpt-4o-mini 输出 | 0.60 美元 / 100 万 token |
embedding 模型 openai/text-embedding-3-small | 0.02 美元 / 100 万 token |
注意输入和输出不是同一个价,输出通常贵三到四倍。这个差价直接决定优化方向:"往系统提示词里多塞 500 token"和"让模型多说 500 token"这两个改动,成本完全不在一个量级上。
拿 D12 的记忆写入算一笔,你就明白昨天那个钩子有多值钱。还是那 1000 个用户,假设每天每人沉淀 5 条记忆,每条按 400 字符的 chunk 大小算,用本课的保守口径"1 个字符按 1 个 token 估":
- 每天 embedding 的 token 数:1000 × 5 × 400 = 2000000 token
- 每天成本:2000000 ÷ 1000000 × 0.02 = 0.04 美元
- 每月成本:0.04 × 30 = 1.2 美元
对比同一批用户每月 22.5 美元的对话开销,embedding 只占大约百分之五。所以结论不是"embedding 贵",而是"embedding 便宜到你会忘了它存在"——直到某天有人把"每条消息都写一条记忆"上线,量涨 20 倍,这一项变成 24 美元,超过了对话本身。没有台账的团队会花两周时间找这笔钱是从哪冒出来的。
代码这一层要注意的其实只有一件事:金额不要用浮点数。
// 价格表是配置不是常量:厂商调价时只改这一张表,并且要能查到旧账用的是哪一版
const PRICING = {
'openai/gpt-4o-mini': { promptPerM: 0.15, completionPerM: 0.6 },
'openai/text-embedding-3-small': { promptPerM: 0.02, completionPerM: 0 },
}
// 保留 6 位小数:一次调用常常不到万分之一美元,四舍五入到 2 位会全部变成 0。
// JS 没有十进制类型,所以算完立刻 toFixed(6) 交给 numeric(12,6) 列去精确累加,
// 绝不在 JS 侧对一堆金额做 reduce 求和。
function costUsd(model, promptTokens, completionTokens) {
const price = PRICING[model]
if (!price) throw new Error(`未登记价格的模型:${model}`)
const micro = promptTokens * price.promptPerM + completionTokens * price.completionPerM
return (micro / 1_000_000).toFixed(6)
}
async function recordUsage(db, entry) {
await db.query(
`insert into usage_ledger
(id, user_id, run_id, model, kind, prompt_tokens, completion_tokens, cost_usd, created_at)
values ($1, $2, $3, $4, $5, $6, $7, $8, now())`,
[randomUUID(), entry.userId, entry.runId, entry.model, entry.kind,
entry.promptTokens, entry.completionTokens,
costUsd(entry.model, entry.promptTokens, entry.completionTokens)],
)
}from decimal import Decimal
# 金额一律 Decimal:0.15 在二进制浮点里是无限循环小数,
# 几十万条累加之后对账会差出几分钱,而对账差一分钱就要查一整天
PRICING = {
"openai/gpt-4o-mini": (Decimal("0.15"), Decimal("0.60")),
"openai/text-embedding-3-small": (Decimal("0.02"), Decimal("0")),
}
PER_MILLION = Decimal(1_000_000)
def cost_usd(model: str, prompt_tokens: int, completion_tokens: int) -> Decimal:
prompt_price, completion_price = PRICING[model]
total = prompt_price * prompt_tokens + completion_price * completion_tokens
return (total / PER_MILLION).quantize(Decimal("0.000001"))
def record_usage(conn, entry: LedgerEntry) -> None:
with conn.cursor() as cur:
cur.execute(
"""insert into usage_ledger
(id, user_id, run_id, model, kind,
prompt_tokens, completion_tokens, cost_usd, created_at)
values (%s, %s, %s, %s, %s, %s, %s, %s, now())""",
(uuid4().hex, entry.user_id, entry.run_id, entry.model, entry.kind,
entry.prompt_tokens, entry.completion_tokens,
cost_usd(entry.model, entry.prompt_tokens, entry.completion_tokens)),
)// 依赖:java.math.BigDecimal + JDBC。钱一律 BigDecimal,double 在这里是事故源
record Price(BigDecimal promptPerM, BigDecimal completionPerM) {}
static final BigDecimal PER_MILLION = new BigDecimal("1000000");
static final Map<String, Price> PRICING = Map.of(
"openai/gpt-4o-mini",
new Price(new BigDecimal("0.15"), new BigDecimal("0.60")),
"openai/text-embedding-3-small",
new Price(new BigDecimal("0.02"), BigDecimal.ZERO));
static BigDecimal costUsd(String model, int promptTokens, int completionTokens) {
var price = Optional.ofNullable(PRICING.get(model))
.orElseThrow(() -> new IllegalArgumentException("未登记价格的模型:" + model));
return price.promptPerM().multiply(BigDecimal.valueOf(promptTokens))
.add(price.completionPerM().multiply(BigDecimal.valueOf(completionTokens)))
// 除法必须显式给 scale 和舍入模式,否则除不尽会直接抛 ArithmeticException
.divide(PER_MILLION, 6, RoundingMode.HALF_UP);
}
static void recordUsage(Connection conn, LedgerEntry e) throws SQLException {
var sql = """
insert into usage_ledger
(id, user_id, run_id, model, kind,
prompt_tokens, completion_tokens, cost_usd, created_at)
values (?, ?, ?, ?, ?, ?, ?, ?, now())
""";
try (var ps = conn.prepareStatement(sql)) {
ps.setString(1, UUID.randomUUID().toString());
ps.setString(2, e.userId());
ps.setString(3, e.runId());
ps.setString(4, e.model());
ps.setString(5, e.kind());
ps.setInt(6, e.promptTokens());
ps.setInt(7, e.completionTokens());
ps.setBigDecimal(8, costUsd(e.model(), e.promptTokens(), e.completionTokens()));
ps.executeUpdate();
}
}struct Price { let promptPerM: Decimal; let completionPerM: Decimal }
enum LedgerError: Error { case unknownModel(String) }
// 注意一个反直觉的点:Swift 的 Decimal 也遵从 ExpressibleByFloatLiteral,
// 写 `let x: Decimal = 0.15` 是先过一遍 Double 再转过来的——正是本节要躲的东西。
// 实测 `0.1234567890123456789` 字面量会变成 0.12345678901234569216,
// 和 Decimal(string:) 的结果不相等。价格一律从十进制文本构造
private func usd(_ text: String) -> Decimal { Decimal(string: text)! }
let pricing: [String: Price] = [
"openai/gpt-4o-mini": Price(promptPerM: usd("0.15"), completionPerM: usd("0.60")),
"openai/text-embedding-3-small": Price(promptPerM: usd("0.02"), completionPerM: .zero),
]
func costUsd(model: String, promptTokens: Int, completionTokens: Int) throws -> Decimal {
guard let price = pricing[model] else { throw LedgerError.unknownModel(model) }
let total = price.promptPerM * Decimal(promptTokens)
+ price.completionPerM * Decimal(completionTokens)
var raw = total / 1_000_000
var rounded = Decimal()
// NSDecimalRound 是标准库里做定点舍入的正规写法,别拿 Double 转一圈
NSDecimalRound(&rounded, &raw, 6, .bankers)
return rounded
}
// PostgresQuery 是 ExpressibleByStringInterpolation:下面每个 \(…) 都会变成一个**绑定参数**,
// 不是字符串拼接,所以既没有注入风险,也不用手数 $1…$8 的位置对不对。
// 这是 Swift 在这一节比另外三门语言都干净的地方。
// 金额走 cost.description + ::numeric:把十进制文本交给 Postgres 自己转成 numeric,
// 不依赖客户端库有没有给 Decimal 提供绑定实现,精度也不会在中途丢——
// 与本节「钱不过浮点」是同一条原则,只是这次边界在进程外面
func recordUsage(_ db: PostgresConnection, _ entry: LedgerEntry) async throws {
let cost = try costUsd(model: entry.model, promptTokens: entry.promptTokens,
completionTokens: entry.completionTokens)
try await db.query(
"""
insert into usage_ledger
(id, user_id, run_id, model, kind,
prompt_tokens, completion_tokens, cost_usd, created_at)
values (\(UUID().uuidString), \(entry.userId), \(entry.runId), \(entry.model),
\(entry.kind), \(entry.promptTokens), \(entry.completionTokens),
\(cost.description)::numeric, now())
""",
logger: db.logger)
}四份代码里三份都在强调同一件事:Python 用 Decimal、Java 用 BigDecimal、Swift 用 Decimal,只有 JS 没有原生的十进制类型,所以它的策略是"算完立刻转成定点字符串交给数据库,绝不在内存里累加金额"。这不是洁癖——用双精度浮点累加十万条金额,最后总额和逐条相加的结果对不上,是成本系统最经典也最难解释的一类工单。
台账要写哪些字段才能对得起"台账"两个字
台账不是日志。日志是给你排查问题看的,删了也就删了;台账是要拿去对账、拿去给财务、拿去回答"这个月为什么涨了 40%"的,所以它的字段设计只有一条判据:任何一笔钱,都要能顺着字段追回到"是谁、因为哪一次执行、用哪个模型、花了多少 token"。
D13 在 D8 的三张表之外新增一张,字段就这么几个:
create table usage_ledger (
id text primary key,
user_id text not null,
run_id text, -- 可空:不是每笔开销都属于某一次 run
model text not null,
kind text not null, -- 'chat' | 'embedding'
prompt_tokens integer not null,
completion_tokens integer not null,
cost_usd numeric(12, 6) not null, -- 定点,不用 float
created_at timestamptz not null default now()
);
create index usage_ledger_user_id_idx on usage_ledger (user_id);
create index usage_ledger_created_at_idx on usage_ledger (created_at);逐个说清为什么是这几个:
user_id与run_id是两条不同的追溯线。 前者回答"这笔钱该算谁头上",后者回答"属于哪一次执行"。run_id允许为空,因为像"夜里批量重算记忆索引"这种系统级开销不属于任何一次用户执行,但它照样要有人认领。model要存调用当时用的那一个。 D4 的 fallback 会让同一段业务落到不同 provider 上,你要能算出"上个月有多少钱是被降级流量花掉的"。kind区分 chat 和 embedding。 两类的量级、增长曲线、优化手段完全不同,混在一起汇总等于没汇总。prompt_tokens与completion_tokens要分开存。 输入输出单价差四倍,只存一个 total 就永远算不回金额,也看不出"是提示词太长还是模型太啰嗦"。cost_usd用numeric(12, 6),并且要冗余存**,不要每次查询现算。** 价格会变。今天 0.15 美元的输入价,半年后可能是 0.10,你总不能让上半年的历史账单跟着一起变。金额在写入的那一刻就固化下来,这是台账和报表最本质的区别。
usage report:同一份台账,切出三种问题的答案
有了台账,报表就只是几条 group by。真正需要想清楚的是你到底要回答什么问题——维度选错了,报表再漂亮也没人看。常用的就三个切法:
-- 按用户切:回答「这个月谁花得最多」,用于定价分层、异常账号排查
select user_id,
sum(cost_usd) as cost,
sum(prompt_tokens + completion_tokens) as tokens,
count(*) as calls
from usage_ledger
where created_at >= date_trunc('month', now())
group by user_id
order by cost desc
limit 20;
-- 按天切:回答「什么时候开始涨的」,用于定位是哪次上线带来的
select date_trunc('day', created_at) as day, kind, sum(cost_usd) as cost
from usage_ledger
where created_at >= now() - interval '30 days'
group by 1, 2
order by 1;
-- 按模型切:回答「贵的那档模型是不是用多了」,用于验证 D4 的分层路由有没有生效
select model, kind, count(*) as calls, sum(cost_usd) as cost
from usage_ledger
where created_at >= date_trunc('month', now())
group by model, kind
order by cost desc;三个维度对应三种完全不同的行动:按用户切是商业动作(谁该涨价、谁在滥用);按天切是排障动作(对齐发布时间线找元凶);按模型切是优化动作(验证降档有没有真省到钱)。今天实验里那个 admin 脚本做的是第一种,打印出来长这样:
=== usage report 2026-09(按用户)===
user_id calls prompt_tokens completion_tokens cost_usd
u-1 6 4800 720 $0.000996
u-2 2 1600 240 $0.000332
------------------------------------------------------------
TOTAL 8 6400 960 $0.001328成本数据怎么反哺容量规划
最后一步,也是让台账从"财务玩具"变成"工程工具"的一步:把成本换算成单位经济学指标,再拿它去约束系统。
绝对金额没有信息量。"这个月花了 22.5 美元"既不能说明便宜也不能说明贵,你需要的是三个带分母的数:
| 指标 | 算法 | 用来回答 |
|---|---|---|
| 每次执行成本 | 当月总成本 ÷ 当月 run 数 | 每加一个功能,一次对话变贵了多少 |
| 每用户月成本 | 当月总成本 ÷ 当月活跃用户数 | 定价能不能覆盖成本 |
| 每美元产出 | 当月完成的业务动作数 ÷ 当月总成本 | 这套系统值不值得继续投入 |
拿本章那批数字算第一个:22.5 美元 ÷ 30000 次 = 0.00075 美元每次。这个数字的用途是当尺子。举个真会发生的例子:D5 里你注册了 10 个工具,按本课统一口径每个工具的定义在上下文里占 100 到 150 token,10 个就是 1000 到 1500 token,正好占了那 3000 输入 token 的一半。也就是说 每次调用里有一半的输入成本,是在为一份模型多半用不到的工具清单付费——按 1500 token 算,1500 ÷ 1000000 × 0.15 = 0.000225 美元每次,一个月 30000 次就是 6.75 美元。把其中 5 个低频工具改成按场景动态挂载,一个月省 3.4 美元左右,占对话总成本的 15%。这个判断不是拍脑袋来的,是从台账里量出来的。
有了尺子就能装护栏。最实用的是预算护栏:调用前先查这个用户当月已花费,超阈值就降档或直接拒绝。
const MONTHLY_LIMIT_USD = 5
// 返回值是「这次该用哪一档」,而不是 true/false——
// 直接拒绝会把用户挡在门外,降档只是让他慢一点、笨一点,体验差别巨大
async function pickTier(db, userId) {
const spent = await db.monthlySpend(userId)
if (spent >= MONTHLY_LIMIT_USD) return 'blocked'
if (spent >= MONTHLY_LIMIT_USD * 0.8) return 'cheap'
return 'default'
}MONTHLY_LIMIT_USD = Decimal("5")
async def pick_tier(db, user_id: str) -> str:
"""返回该用哪一档,而不是 True/False:降档比拒绝的体验好得多。"""
spent = await db.monthly_spend(user_id)
if spent >= MONTHLY_LIMIT_USD:
return "blocked"
if spent >= MONTHLY_LIMIT_USD * Decimal("0.8"):
return "cheap"
return "default"// 枚举而不是字符串:档位是有限集合,让编译器帮你穷举
enum Tier { DEFAULT, CHEAP, BLOCKED }
static final BigDecimal MONTHLY_LIMIT_USD = new BigDecimal("5");
static final BigDecimal WARN_RATIO = new BigDecimal("0.8");
static Tier pickTier(Ledger ledger, String userId) throws SQLException {
var spent = ledger.monthlySpend(userId);
if (spent.compareTo(MONTHLY_LIMIT_USD) >= 0) return Tier.BLOCKED;
if (spent.compareTo(MONTHLY_LIMIT_USD.multiply(WARN_RATIO)) >= 0) return Tier.CHEAP;
return Tier.DEFAULT;
}// 枚举带原始值,既能穷举又能直接落库
enum Tier: String { case `default`, cheap, blocked }
let monthlyLimitUsd: Decimal = 5 // 整数字面量是精确的,这个可以直接写
// 0.8 同样不能用浮点字面量,理由见上一块。Java 版那边写的是 new BigDecimal("0.8")
let warnRatio = Decimal(string: "0.8")!
func pickTier(_ ledger: Ledger, userId: String) async throws -> Tier {
let spent = try await ledger.monthlySpend(userId: userId)
if spent >= monthlyLimitUsd { return .blocked }
if spent >= monthlyLimitUsd * warnRatio { return .cheap }
return .default
}再往上一层是容量规划:把每天的成本和调用量画成两条线,配合"每次执行成本"这个比值,你能提前一两周看出"用户数还没涨、单次成本先涨了"这类信号——它通常意味着某次上线让提示词变长了,或者某个工具的返回体膨胀了。某生产级 IM Agent 平台把这条曲线和错误率、延迟并排挂在值班大盘上,因为成本是会先于故障出现的健康指标:单次成本异常上涨,往往比超时告警早好几天。
源码导读
动手实验
starter/ 里挖了四个练习点,MOCK=1 下零外部服务:Redis 和 Postgres 走的是内存实现(不是打桩——消费组语义和幂等键唯一约束都真的实现了一遍),时钟是可注入的假时钟,自检一秒内就把 15 分钟快进跑完,你不用真等一分钟。四个挖空处的默认值都会让程序"跑得起来但明显不对"——*/n 永不命中、幂等键用了 Date.now()、金额恒为 0、报表不分用户——先原样跑一次,把那 4 个 ❌ 看清楚再动手。装好 Docker 的话,在 lab 根目录 docker compose up -d,然后配上 REDIS_URL 与 DATABASE_URL 重跑自检,会看到基础设施那一行从 memory 变成 real,而结果一模一样:业务代码一行都没改。
- 补完
matchField的*/n分支,重跑自检看第 1 项从 ❌ 变 ✅,命中次数正好是 3 和 1。 - 把幂等键从
Date.now()改成"计划触发的那一分钟",重跑看第 2 项变 ✅:投递 8 次、只落 4 条 run。 - 观察第 3 项的 trace,确认两个 Worker 的消费者名不同、每条 run 只出现在其中一个的日志里——这一项脚手架已经写好,是 D9 消费组的直接复用,也是唯一一个 starter 原样跑就 ✅ 的。
- 实现
costUsd,用价格表把 prompt 和 completion 分别乘完再除以一百万,自己手算一遍 0.001328 这个数对不对。 - 补完按用户分组的汇总,然后跑
pnpm report,看到两个用户各自的当月成本,合计等于第 4 项那个数。
面试题
今天 4 道题在下方题库区,侧重分布式定时任务、成本控制与可观测性。展开后先看"分析过程"再看要点——第 4 题的追问(幂等键该锚在什么上)是这一章最容易被追着问的地方,别跳过。标注"国内高频 / 海外高频"方便按目标市场取舍。
检查清单与明日预告
- 能实现一个中心调度器,按 cron 表达式定时把任务投递进消息总线
- 能实现一套 token → USD 的成本台账,按调用记账
- 能产出一份简单的 usage report,按用户或时间维度汇总成本
- 能说清为什么幂等键要锚在"计划触发的那一分钟",而不是当前时刻
- 能不看讲义算出"1000 用户每天一次、输入 3000 输出 500"的月成本是 22.5 美元
- 实验的 5 条自检标准全部 ✅
- 4 道面试题不看要点也能答出至少 3 道
明天(D14)是 W2 的收口。到今天为止,调度器、总线、Worker、数据库、台账全都在你本机的一个终端里跑着,任何一样出问题你都是靠肉眼看日志发现的。真上线要面对三个新问题:一个镜像怎么起 3 个 Worker 副本、发版时怎么在不掐断正在跑的 run 的前提下换掉进程、以及怎么知道某个 Worker 是真的在干活还是已经悄悄死了。明天用 compose 多副本、优雅停机和心跳把这三件事补齐,然后把 D8 到 D13 串成一条完整的线复盘一遍——先把系统跑通再谈怎么运维它,顺序不能反。
面试题库
服务部署了多个实例,定时任务为什么不能让每个实例各自起一个 cron?你会怎么做?When a service runs multiple replicas, why not let each replica start its own cron? What would you do instead?
国内高频海外高频基础#scheduling#distributed-systems#cost分析过程 · 先想清楚再作答
- 题眼在「多个实例」四个字。只答「会重复执行」拿不到分,因为那是现象;面试官想看你能不能把现象换算成业务后果和钱。
- 先把重复的代价说具体:3 个副本各起 cron,同一个任务被执行 3 次,用户收到 3 份一样的推送,你付 3 份模型调用的钱。而且这个倍数会跟着副本数走——扩容到 10 个副本,账单和骚扰量一起变成十倍,却不会触发任何告警,因为从每个进程自己的视角看它只是老实地执行了一次。
- 然后给出正确的形状:把「谁该在什么时候被执行」收进一个中心调度器,它命中 cron 之后只做一件事——往消息总线投递一条任务消息;执行侧照旧靠消费组分摊,一条消息只会被一个消费者拿到。关键认知是「定时任务不是一种新的执行方式,只是把按按钮的人从用户换成了钟表」,所以执行侧一行代码都不用改。
- 接着主动补上「那调度器自己不就成单点了吗」——这是必被追问的一句。答案分两层:调度器无状态、崩了拉起来就行,短暂不可用的代价只是几分钟内的任务延迟;真要高可用就起两个实例,靠投递时的幂等键去重,而不是靠给调度器加分布式锁。
- 最后点一句选型:任务量不大时中心调度器加消息总线足够;量大或者任务本身有依赖关系时,业界会换成专门的调度框架(带任务依赖、重试策略、补数),但底层的「中心决定 + 队列分发」结构是一样的。
- 可以预期的追问:调度器崩溃 90 秒,中间跨过的那一分钟怎么办?答启动时回看最近 N 分钟逐分钟重放,因为有幂等键兜底,重复投递无害——这正是 at-least-once 加幂等这组搭配能成立的地方。
How to reason about it · think before answering
- The hinge is the phrase multiple replicas. Saying it would run twice is only the symptom; the interviewer wants the business and dollar consequence.
- Make the cost concrete: three replicas each running cron means the job fires three times, users get three identical pushes, and you pay for three model calls. The multiplier tracks replica count, so scaling to ten makes both the bill and the spam tenfold, with no alert firing, because from each process's own point of view it ran exactly once.
- Give the right shape: move the decision of who runs when into one central scheduler whose only job, on a cron match, is to publish a task message onto the bus; the execution side keeps using a consumer group so one message reaches exactly one consumer. The key insight is that a scheduled task is not a new execution path, it just swaps the user for a clock as the thing pressing the button, so the worker code stays untouched.
- Volunteer the obvious follow-up: doesn't the scheduler become a single point of failure? Two layers. It is stateless, so a crash costs you a few minutes of task delay; if you truly need HA, run two instances and dedupe on the idempotency key at publish time rather than bolting a distributed lock onto the scheduler.
- Close with sizing: a central scheduler plus a bus is enough at modest volume. At high volume, or when tasks have dependencies, teams move to a dedicated workflow scheduler with dependency graphs, retry policy and backfill, but the underlying central-decision-plus-queue shape is identical.
- Expect: the scheduler was down for 90 seconds and skipped a minute — now what? Replay the last N minutes on startup, one minute at a time. The idempotency key makes redundant publishes harmless, which is exactly what makes at-least-once plus idempotency the easy combination.
答题要点
- 每个实例各自起 cron 等于同一个任务被执行 N 次:用户收到 N 份重复推送,模型调用花 N 倍的钱,倍数随副本数线性增长且不会触发告警
- 正确形状是中心调度器命中 cron 后只往消息总线投递一条消息,执行侧靠消费组保证一条消息只被一个 Worker 拿到
- 定时任务不是新的执行路径,只是把触发者从用户换成了钟表,所以 Worker 侧不需要任何改动
- 调度器是无状态的,崩了拉起来即可;需要高可用就起两个实例靠投递时的幂等键去重,不要给它加分布式锁
- 崩溃期间跨过的时间点靠启动时回看最近 N 分钟重放补上,幂等键保证重复投递无害
Key points
- Per-replica cron means the job runs N times: N duplicate pushes, N times the model spend, scaling linearly with replica count and silently
- The right shape is a central scheduler that publishes one message to the bus on a cron match, with a consumer group ensuring exactly one worker picks it up
- A scheduled task is not a new execution path — only the trigger changed from a user to a clock, so worker code is unchanged
- The scheduler is stateless: restart on crash, and if you need HA run two and dedupe on the idempotency key rather than adding a distributed lock
- Missed minutes are recovered by replaying the last N minutes at startup, which is safe because the idempotency key absorbs duplicates
让你从零设计一套 token 成本计量和台账系统,你会怎么做?How would you design a token cost metering and ledger system from scratch?
国内高频海外高频进阶#cost#observability#data-modeling分析过程 · 先想清楚再作答
- 这题在考「你有没有真的对过账」。区分度在两个地方:金额用什么类型存,以及金额是冗余存还是查询时现算。答不到这两点的方案,上线三个月就会被财务打回来。
- 先立判据:台账不是日志。日志是给排查问题用的,删了就删了;台账要拿去对账、要回答「这个月为什么涨了 40%」,所以每一笔钱都必须能追回到「谁、因为哪一次执行、用哪个模型、花了多少 token」。字段设计全部由这条判据推出来。
- 然后给字段和理由,一一对应:user_id 回答该算谁头上、run_id 回答属于哪次执行(允许为空,因为有系统级批量开销)、model 存调用当时那一个(fallback 会让同一段业务落到不同模型上)、kind 区分 chat 和 embedding(两者量级和增长曲线完全不同)、prompt_tokens 与 completion_tokens 分开存(输入输出单价差三到四倍,只存 total 就算不回金额,也看不出是提示词太长还是模型太啰嗦)。
- 接着是两个最能体现经验的判断。第一,金额用定点类型:数据库用 numeric,代码里用 Decimal 或 BigDecimal,绝不用双精度浮点累加,否则十万条之后总额和逐条相加对不上。第二,cost_usd 要在写入那一刻算好并冗余存,不要查询时用当前价格表现算——价格会变,历史账单不能跟着一起变,这是台账和报表最本质的区别。
- 还要主动说记账的时机和事务边界:记账放在「拿到 usage 字段」那一刻,而不是「业务成功」那一刻,因为失败的调用同样产生费用,尤其 fallback 会一次业务跨两三次收费调用。台账写入不必和业务同事务(丢一条只是几厘钱,锁住台账表却会卡住用户对话),可以异步加重试,用 run_id 加调用序号做唯一约束防重;但如果产品有额度限制,配额扣减必须同事务,否则用户能靠并发把额度刷穿。
- 可以预期的追问:厂商调价了历史数据怎么办?答案是价格表本身要有生效时间和版本号,台账里既存算好的金额也可以存价格版本,这样重算和审计都有依据。
How to reason about it · think before answering
- This question tests whether you have ever reconciled a bill. The discriminators are the numeric type you store money in, and whether cost is stored or computed at query time. A design missing either gets rejected by finance within a quarter.
- Set the criterion first: a ledger is not a log. Logs exist for debugging and can be dropped; a ledger has to reconcile against the vendor invoice and answer why the bill grew 40% this month, so every charge must trace back to who, which run, which model, and how many tokens. Every field falls out of that.
- Then walk the fields with reasons: user_id says whose budget it hits; run_id says which execution it belongs to and is nullable because some spend is system-level batch work; model records the one actually used, since fallback routes the same workload to different providers; kind separates chat from embedding because their volumes and growth curves differ completely; prompt_tokens and completion_tokens are stored separately because input and output differ three- to four-fold in price, and a single total can neither reproduce the amount nor tell you whether the prompt is bloated or the model is verbose.
- Now the two judgments that show experience. First, money uses fixed-point: numeric in the database, Decimal or BigDecimal in code, never accumulated in binary floats, or the total will diverge from the sum of rows after a hundred thousand entries. Second, cost is computed at write time and stored redundantly, not recomputed from the current price table — prices change, and history must not change with them. That is the essential difference between a ledger and a report.
- Volunteer the timing and transaction boundary: record at the moment you receive the usage field, not at business success, because failed calls still cost money and a fallback spans two or three billable calls per business operation. Ledger writes need not share the business transaction — losing a row costs fractions of a cent, while locking the ledger table stalls user conversations — so write asynchronously with retries and a uniqueness constraint on run id plus call index. The exception is quota enforcement: if the product caps spend, the decrement must be transactional or concurrent requests will blow through the cap.
- Expect: what happens to history when the vendor changes prices? The price table itself needs effective dates and a version, and the ledger stores both the computed amount and the price version, so recomputation and audit both have a basis.
答题要点
- 台账不是日志:每一笔钱要能追回到谁、哪一次 run、哪个模型、多少 token,字段设计全由这条判据推出
- prompt_tokens 与 completion_tokens 必须分开存,因为输入输出单价差三到四倍,只存 total 既算不回金额也看不出问题出在哪一侧
- 金额用定点类型(数据库 numeric、代码 Decimal/BigDecimal),不要用浮点累加,否则总额和逐条相加对不上
- cost_usd 在写入那一刻算好并冗余存,不要查询时按当前价格现算——价格会变,历史账单不能跟着变
- 记账时机是拿到 usage 字段那一刻而不是业务成功那一刻,失败调用和 fallback 同样产生费用;台账可异步写入加重试,但配额扣减必须和业务同事务
Key points
- A ledger is not a log: every charge must trace to a user, a run, a model and a token count, and the schema follows from that
- Store prompt and completion tokens separately, since input and output prices differ three- to four-fold and a single total can neither reproduce the amount nor localize the problem
- Use fixed-point money (numeric in the database, Decimal or BigDecimal in code); float accumulation makes totals disagree with the sum of rows
- Compute cost at write time and store it, rather than recomputing from today's price table, so history stays stable when prices change
- Record at the moment usage is returned, not at business success — failed calls and fallbacks still cost money; ledger writes can be async with retries, but quota decrements must be transactional
一份 LLM 应用的 usage report 通常要覆盖哪些维度?这些维度分别用来做什么决策?Which dimensions should a usage report for an LLM product cover, and what decision does each one drive?
国内高频海外高频进阶#observability#cost#reporting分析过程 · 先想清楚再作答
- 这题最容易答成罗列维度:按用户、按天、按模型、按功能……列得越全越显得没想过。区分度在后半句——每个维度对应的是哪一类行动。列不出行动,说明你只做过报表没用过报表。
- 先给三个主维度和它们各自的行动类型:按用户切是商业动作(谁该涨价、谁在滥用、定价分层能不能覆盖成本);按天切是排障动作(对齐发布时间线,找出是哪次上线让成本跳了台阶);按模型和调用类型切是优化动作(验证分层路由有没有真省到钱、embedding 的量是不是失控了)。三个维度对应三个不同的看板受众。
- 然后升一层,指出绝对金额没有信息量,真正有用的是带分母的单位经济学指标:每次执行成本(当月总成本除以 run 数)、每用户月成本(除以活跃用户数)、每美元产出(完成的业务动作数除以总成本)。前两个用来判断定价能不能覆盖成本,第三个用来判断这套系统值不值得继续投入。
- 举一个能落地的用法证明你真用过:每次执行成本这个比值是把尺子。如果用户数没涨而单次成本涨了,几乎一定是某次上线让提示词变长了,或者某个工具的返回体膨胀了——这个信号通常比超时告警早好几天出现,所以成熟团队会把成本曲线和错误率、延迟并排挂在值班大盘上。
- 最后补一个大多数人会漏的维度:失败与降级。失败的调用照样收费,fallback 会让一次业务操作跨两三次收费调用。报表里不单独切出这一块,你和厂商账单的差额就会恰好集中在故障期,也就是最需要看清成本的时候。
- 可以预期的追问:报表要做到什么实时度?答案是分层——按天的汇总离线跑就够,但配额和预算护栏需要近实时的当月累计,通常用一张按用户按月的汇总表增量更新,而不是每次请求都扫一遍明细。
How to reason about it · think before answering
- The trap is listing dimensions: by user, by day, by model, by feature. Length signals you have not thought about it. The discriminator is the second half — which action each dimension drives. No action means you built reports but never used one.
- Give three primary dimensions with their action type: by user is a commercial action (who to reprice, who is abusing, whether tiering covers cost); by day is a debugging action (align with the release timeline to find which deploy stepped the cost up); by model and call kind is an optimization action (did tiered routing actually save money, is embedding volume running away). Three dimensions, three different dashboard audiences.
- Then go up a level: absolute dollars carry no information. What matters are unit-economics ratios with a denominator — cost per run (monthly cost over run count), cost per active user per month, and business actions completed per dollar. The first two say whether pricing covers cost; the third says whether the system deserves further investment.
- Prove you have used it with a concrete pattern: cost per run is a ruler. If user count is flat but cost per run climbs, it is almost always a deploy that lengthened the prompt or a tool whose response body grew. That signal usually appears days before latency alerts, which is why mature teams put the cost curve next to error rate and latency on the on-call dashboard.
- Add the dimension most people miss: failures and fallbacks. Failed calls are still billed, and a fallback spans two or three billable calls per business operation. Without slicing that out, your gap against the vendor invoice concentrates exactly during incidents, when you most need cost clarity.
- Expect: how fresh does the report need to be? Tier it — daily rollups can run offline, but quota and budget guardrails need near-real-time month-to-date totals, usually from an incrementally updated per-user monthly summary table rather than scanning the detail rows on every request.
答题要点
- 按用户切是商业动作(定价分层、异常账号),按天切是排障动作(对齐发布找成本跳变),按模型和调用类型切是优化动作(验证分层路由、盯 embedding 用量)
- 绝对金额没有信息量,要看带分母的指标:每次执行成本、每用户月成本、每美元产出
- 每次执行成本是把尺子:用户数没涨而单次成本涨了,通常是提示词变长或工具返回体膨胀,比超时告警早好几天出现
- 必须单独切出失败与降级的开销,否则和厂商账单的差额会集中在故障期
- 实时度要分层:按天汇总可离线跑,预算护栏需要近实时的当月累计,用增量汇总表而不是每次扫明细
Key points
- By user drives commercial decisions, by day drives debugging, and by model or call kind drives optimization — three dimensions, three audiences
- Absolute dollars say nothing; use ratios with a denominator: cost per run, cost per active user per month, and business actions per dollar
- Cost per run is a ruler: flat users with rising per-run cost usually means a longer prompt or a bloated tool response, and it shows days before latency alerts
- Slice out failed and fallback calls, or your gap against the vendor invoice concentrates during incidents
- Tier the freshness: daily rollups offline, near-real-time month-to-date totals from an incremental summary table for budget guardrails
怎么保证一个 cron 任务不会被重复投递或重复执行?幂等键应该怎么构造?How do you keep a cron job from being published or executed twice, and how should the idempotency key be built?
国内高频海外高频深入#idempotency#scheduling#distributed-systems分析过程 · 先想清楚再作答
- 题眼在「投递」和「执行」是两件事。很多人只答一半:要么只说消费组保证一条消息一个消费者(那只挡住了执行侧的重复),要么只说加锁(那只挡住了投递侧,还挡不干净)。完整答案要说清两侧各自的重复来源,以及一个能同时兜住的兜底。
- 先拆重复的来源:投递侧的重复来自多个调度器实例、调度器重启后的补发重放、以及消息总线本身的至少一次语义;执行侧的重复来自 Worker 处理到一半崩溃后消息被 XAUTOCLAIM 转交给别人。这两类重复用不同手段挡效率完全不同。
- 再给核心结论:不要用分布式锁去做互斥,用数据层的唯一约束做去重。原因是锁只能提供「大概率互斥」——租约到期而前任进程其实只是 GC 卡住的那一瞬间,两个调度器都会认为自己持有,各发一次;而唯一约束是在最终落库那一步判断的,无论上游发了几次,任务表里只会多一行。能在唯一约束上解决的问题,不要升级成分布式协调问题。
- 然后回答幂等键怎么构造,这是最容易翻车的一步:键必须是「任务 id 加计划触发的那一分钟」,绝不能用当前时刻。两个调度器实例的时钟不可能对齐到毫秒,一个在 09:00:00.120 醒来、另一个在 09:00:00.480 醒来,用 now 算出来的键不一样,去重完全失效。把秒和毫秒截掉之后,无论谁在这一分钟里的哪一刻醒来,算出的键都是同一个字符串。落到代码上就是 insert 加 on conflict do nothing,冲突说明已经有人建过这次执行,直接 ack 掉不执行。
- 补一句作用范围:这套只保证「同一个触发点只产生一次执行」,不保证「执行内部的副作用只发生一次」。如果这次执行要发短信、要扣款,那些副作用还得各自带自己的幂等键,因为 Worker 可能在发完短信之后、写完状态之前崩掉。这一层区分是加分项。
- 可以预期的追问:那漏发怎么办?答宁可多发不可少发——调度器启动时回看最近 N 分钟逐分钟重放,重复投递被幂等键吃掉。at-least-once 加幂等是分布式系统里最省心的一组搭配,反过来先追求 exactly-once 再补幂等,通常两头都做不好。
How to reason about it · think before answering
- The hinge is that publishing and executing are two separate problems. Most candidates answer half: either only the consumer group (which stops duplicate execution) or only a lock (which stops duplicate publishing, and imperfectly). A complete answer names the duplicate sources on both sides plus one backstop that covers both.
- Enumerate the sources: duplicate publishes come from multiple scheduler instances, from replay after a scheduler restart, and from the bus's own at-least-once semantics. Duplicate executions come from a worker crashing mid-processing and the message being reclaimed by another consumer. The two need different treatment.
- State the core conclusion: do not reach for a distributed lock, use a uniqueness constraint in the data layer. A lease only gives you probable mutual exclusion — in the instant when the TTL expires while the previous holder is merely stuck in GC, both schedulers believe they hold it and both publish. A uniqueness constraint is evaluated at the final insert, so no matter how many times upstream published, the table gains exactly one row. Do not escalate a problem solvable by a constraint into a distributed coordination problem.
- Then the key construction, which is where people fail: the key must be the task id plus the scheduled minute, never the current instant. Two scheduler clocks never align to the millisecond; one wakes at 09:00:00.120 and the other at 09:00:00.480, so keys built from now differ and dedup collapses. Truncate seconds and milliseconds and every instance computes the same string for that minute. In code this is an insert with on conflict do nothing; a conflict means the execution already exists, so ack the message and skip.
- Scope it honestly: this guarantees one execution per trigger point, not that side effects inside the execution happen once. If the run sends an SMS or charges a card, those side effects need their own idempotency keys, because the worker can crash after sending and before writing status. Making that distinction earns points.
- Expect: what about missed triggers? Prefer over-publishing to under-publishing — replay the last N minutes at startup and let the idempotency key absorb duplicates. At-least-once plus idempotency is the easiest combination in distributed systems; chasing exactly-once first and adding idempotency later usually achieves neither.
答题要点
- 投递重复和执行重复是两件事:前者来自多调度器实例、重启重放和总线的至少一次语义,后者来自 Worker 崩溃后消息被转交
- 用数据层唯一约束去重,不要用分布式锁互斥:租约过期而前任还活着的瞬间两个调度器都会各发一次,而唯一约束在落库那一步只放行一条
- 幂等键必须是任务 id 加计划触发的那一分钟,不能用当前时刻——两个实例的醒来时刻永远不同,用 now 会让去重完全失效
- 落到代码上是 insert 加 on conflict do nothing,冲突就直接 ack 不执行
- 这只保证一个触发点一次执行,执行内部的发短信、扣款等副作用要各自带幂等键;宁可多发不可少发,靠 at-least-once 加幂等兜底
Key points
- Duplicate publishing and duplicate execution are separate: the former comes from multiple schedulers, restart replay and at-least-once delivery; the latter from a crashed worker's message being reclaimed
- Dedupe with a database uniqueness constraint rather than a distributed lock: when a lease expires while the holder is only GC-stalled, both schedulers publish, whereas the constraint admits exactly one row
- Build the key from the task id plus the scheduled minute, never the current instant — instances never wake at the same millisecond, so a now-based key defeats dedup entirely
- In code this is an insert with on conflict do nothing; on conflict, ack the message and skip execution
- This guarantees one execution per trigger, not once-only side effects — SMS or payments inside the run need their own keys; prefer over-publishing and let at-least-once plus idempotency absorb it
评论
登录后即可参与讨论
还没有评论,来说第一句。