11 · 计费一致性#
简历原话:计费一致性:针对并发超限与崩溃漏扣导致的 Credits 透支,采用预扣 + 按量结算 + 超时对账补偿,同一任务重复提交只扣一次 Credits。
30 秒口述版#
ShoppingX 按任务的真实模型成本扣 Credits。最早是跑完再记账,有两个洞:同一个人并发发 20 条,每条进门都读到满余额,全放行后一起记账就透支了;worker 被 kill -9 时收尾不执行,钱花了账没记。我改成三段:进门锁用户行,按档位预扣并数在飞任务数,后到的请求看到的是扣过的余额;跑完按实际成本多退少补,结算和记账同一事务、以 run_id 幂等;每次模型调用把用量累加进 Redis,崩溃留下的过期预扣由对账任务按用量补扣。重复提交分层挡住,队列重投整轮重跑也只结算一次。
背景与问题#
计费口径先交代清楚。 每次模型调用按 input / output / cache_read 三档单价算出美元成本,命中前缀缓存的 input 享折扣价;对外换算成整数 Credits(1 Credit = $0.001),普通账号每天 2000 Credits、免登录访客 100 Credits。账本是 MySQL 里的一张日账表,唯一键是 (user_id, UTC 自然日),新的一天第一次记账插一行新的,跨天靠换行重置,不需要定时任务。
原来的做法:事后记账。 进门查一次「今天还剩多少」,大于 0 就放行;任务跑完,在收尾处把整轮成本累加进账本。单任务另有一个成本上限(token 预算闸),会被压成 min(单任务预算, 今日剩余)。这套在单用户串行时没问题,上了多副本、多用户并发之后出了四类问题:
- 并发超限透支。 一个脚本账号今天只剩 50 Credits,同时发 20 条请求。20 条进门都读到「剩 50」,全部放行,每条的单任务上限也都是 50——每条单独看都合规,加起来能花掉 20 倍。事后记账对并发是瞎的:钱在跑完之后才进账,进门那一刻谁也看不见别人正在花。
- 崩溃漏扣。 记账在任务收尾执行。worker 被 kill -9、容器 OOM、发版时被强杀,收尾代码根本不执行,模型调用的钱已经付给供应商,用户账上一分没记。还有一种更隐蔽的:用户点「取消」,取消信号会在记账那一步的第一个等待点把它打断,于是「烧完 token 再点取消」就能白用一整轮。
- 重复提交重复扣。 双击发送、刷新后重发、脚本超时重试,以及队列本身是 at-least-once:worker 崩了消息会被重投,整轮重跑一遍。每一次都走一遍记账,就是同一个任务扣多次。
- 并发数不是全局的。 「这个人同时在跑几个」原来只看 API 进程内存里的任务表,多副本下一个人打到两台实例就各算各的,并发上限形同虚设。
这四个问题的共同点:扣费的时机和任务的真实生命周期对不上。进门时没占住,结束时可能没机会记,重来时又记了一遍。
做法与取舍#
整条链路拆成三段:进门预扣 → 跑完按量结算 → 超时对账补偿,外加一层重复提交只认一次。核心数据是 MySQL 里一张预扣表 run_holds,一行对应一次 run,主键就是 run_id(队列里的 task_id,重投和接管时不变)。
1. 进门预扣:锁用户行,一次查询同时判并发和额度#
准入时开一个事务:
SELECT … FOR UPDATE锁住这个用户在 users 表里的那一行。同一个人的并发请求在这里排队,后到的那个一定看得见先到的写下的预扣行。- 一条聚合查询拿到两个数:
state IN (queued, running) AND expires_at > now的行数(在飞任务数)和 credits_held 之和(已占额度)。 - 可用额度 = 今日剩余 − 已占额度。在飞数 ≥ 3 → 回 429 + Retry-After(前面的跑完就能进,重试是对的);可用额度 ≤ 0 → 回 402(等多久都不会好,要等日切)。
- 否则插一行:state =
queued,credits_held = min(档位值, 可用额度),expires_at = now + 900 秒,提交事务。
档位按会话历史轮数分:普通轮预扣 20 Credits,多轮追问的重档预扣 60 Credits。worker 领到任务时把行翻成 running,这个状态在额度计算里和 queued 等价,只用来排障——库里能直接看出一条任务是卡在队列里,还是真有 worker 在跑它。
为什么锁用户行,而不是用 Redis 原子扣减余额。 余额的真相在 MySQL 账本里,预扣和账本在同一个库、同一个事务里才谈得上一致。用 Redis Lua 扣余额很快,但那就有两份余额:日切、结算、对账都得双写,双写之间崩一下又是一个不一致窗口。锁的粒度是「一个用户」,不同用户之间互不阻塞;同一个人真实的并发本来就只有个位数,行锁排队的代价可以忽略。
为什么不用乐观锁(version 列 + 重试)。 冲突恰恰发生在「同一个人猛发」的场景里,乐观锁会让这些请求集体重试、放大冲突;悲观锁让它们老实排队,逻辑也更直白。
为什么剩一点也放行。 另一种做法是「不够一个档位就拒」。但档位是猜的,一轮普通购物的真实花费通常只有几个 Credits,拿猜出来的数去拒真实请求,会让用户在额度末尾完全发不出任务。所以只在可用额度 ≤ 0 时才拒,剩一点时只占住剩下那点。透支的上界由单任务上限管:每次模型调用前检查本轮累计成本,超过上限就强制收尾,最多超出最后一次调用的成本。
否掉的方案:按最坏情况预扣。 按单任务上限($0.5 = 500 Credits)预扣最安全,但一个只剩 400 的用户就一条都发不出去,并发 3 条要先压 1500。预扣值应该贴近均值,猜错由结算纠正。
顺带解决全局并发数。 并发数就是同一次查询里的行数,不必再为「在跑几个」单开一套状态,多副本下天然是全局口径。
2. 跑完按量结算:条件更新做幂等,结算与记账同一个事务#
任务结束(成功、失败、用户取消都算)时,按本轮真实成本结算:
- 条件更新:
UPDATE run_holds SET state='settled', credits_charged=?, settled_at=now WHERE run_id=? AND state IN ('queued','running')。 - 影响 1 行才继续记账:在同一个事务里把成本累加进当天账本,然后提交。
- 影响 0 行说明这个 run 已经结算过(队列重投整轮重跑、对账任务先一步补过),直接返回,不再记账。
预扣行里 credits_held 和 credits_charged 两列都保留:前者是进门猜的,后者是真实花的,事后能统计「档位猜得准不准」,档位值据此调整。
为什么结算和记账必须在同一个事务。 分两步的话,中间崩一下只有两种结果:额度释放了但钱没记(又是漏扣),或者钱记了但预扣还占着(用户额度被多占到过期)。放进一个事务,要么都成,要么都不成。
账本累加怎么写。 在数据库里做加法:UPDATE … SET cost = cost + ?,而不是读出来加完再写回——后者在并发下会丢更新。影响 0 行说明今天这一行还不存在,再 INSERT;两个请求同时插入时靠唯一键挡下后到的那个,它捕获冲突后重跑一次 UPDATE。插入用 SAVEPOINT 包住,撞唯一键时只回滚这次插入,前面那条条件更新不受牵连。
取消路径也要记完。 结算在收尾处执行,而最常触发收尾的就是用户点取消。取消信号会在结算的第一个等待点把它打断,所以结算被放进一个独立的后台任务里跑到完,外层的取消照常传播,这个任务不受影响(并保一份强引用,防止被垃圾回收掉)。计费的依据是「花没花钱」,不是「任务成没成功」。
零成本释放。 任务还没跑起来就没了(入队失败、排队期间被取消、幂等命中),按 0 结算:行标 settled、credits_charged = 0,不记账。
否掉的方案:结算时直接删掉预扣行。 删了之后重投再来结算一次,查不到行,就只能走「没有预扣的老路」再记一遍——恰好是重复扣费。保留终态行,条件更新才有东西可判。
3. 超时对账补偿:崩溃留下的预扣,按已记用量补扣#
预扣行解决了「进门占住」,但崩溃时结算那一步不会执行,行会一直停在 running。这里分两件事处理:
额度不被卡住:读侧忽略过期行。 数在飞数、算已占额度时都带 expires_at > now,过期行自动不算。TTL 取 900 秒,是单轮最长耗时(整轮超时 300 秒)的三倍,覆盖排队加一次重投。所以就算对账任务晚几分钟,用户也不会被一条死掉的预扣挡住发新任务。
钱不被漏掉:用量流水 + 对账任务。
- 用量流水。 每次模型调用返回后,模型网关把这次的成本和 token 数累加进 Redis 哈希
billing:usage:{run_id},字段是cost_micro(整数,单位百万分之一美元)、in_tok、out_tok、calls,用HINCRBY原子累加,键 TTL 48 小时。正常结算时读的也是这份流水,和进程内的累计值互相校验。 - 对账任务。 每个 worker 进程里有一个 60 秒一次的定时协程,先抢
SET billing:reconcile:lock <实例名> NX EX 50,抢到的那一台才扫描。扫描条件是state IN ('queued','running') AND expires_at < now,按 expires_at 升序每批 200 行,走 (state, expires_at) 联合索引。 - 每行分三种情况:
- 任务租约还在(
task:lease:{task_id}存在,说明被别的 worker 接管、还在跑)→ 把 expires_at 顺延 900 秒,不结算。 - 租约不在、流水在 → 按流水里的成本结算,走同一条条件更新,行上记
settle_source = reconcile,和正常结算只差来源标记。 - 租约不在、流水也没有 → 行是 queued 的,说明模型一次都没调,按 0 释放;行是 running 但流水丢了(Redis 故障恢复后数据不全),按 credits_held 结算,也就是用户进门时已经授权过的那笔,同时打告警,需要时人工退还。
- 任务租约还在(
为什么流水放 Redis、不直接写 MySQL。 一轮任务要调好几次模型,每次都去 MySQL 累加,会和账本、预扣行抢行锁;Redis 单条 HINCRBY 原子且在毫秒以内。流水只是「对账时的证据」,丢了还有预扣额这个上界。
为什么用整数 micro-USD。 HINCRBYFLOAT 反复累加小数会有精度漂移,账对不齐;换成整数单位,累加是精确的,结算时再除回美元。
为什么对账和正常结算走同一条条件更新。 两者可能撞车:对账刚判定「租约不在」,原 worker 其实只是卡顿,下一秒它自己结算了。两边都用 WHERE state IN ('queued','running'),谁先提交谁生效,后到的影响 0 行直接跳过,钱只记一次。
否掉的方案:
- 只靠过期、不做对账(最早的版本):额度确实不会被卡住,但过期行上那轮的钱永远没记,这正是「崩溃漏扣」本身。
- 每步直接累加进日账本:账本上就有了「跑到一半」的中间态,结算时还得算差额回补,跨 UTC 零点的任务更难对齐。账本只接受结算后的终值,中间过程放流水里。
- Redis 键过期通知触发补扣:键空间通知是发了就不管的,订阅方断线期间的通知直接丢,不能拿来当计费依据。定时扫描慢一点,但不会漏。
4. 同一任务重复提交只扣一次#
「重复」有四种来源,各在一层挡住,挡住时都要把刚占的预扣当场按 0 释放:
| 重复来源 | 判定 | 结果 |
|---|---|---|
| 双击发送、刷新后重发(同会话、同一句话、上一轮还在跑) | MySQL 条件更新会话行上的「当前 run」字段,影响 0 行即上一轮还活着且问题相同 | 返回 already_running,把用户领回原任务,新预扣释放 |
| 同会话换了一句话 | 同一条条件更新,判定为覆盖 | 取消旧任务(旧任务按实际用量结算),起新任务——这是两次任务,不是重复 |
| 脚本 / 裸 API 调用、没带会话 id、5 秒内同一句话 | Redis SET dedup:{user_id}:{query 指纹} <thread_id> NX EX 5 | 返回 duplicate 和原会话 id,新预扣释放 |
| 队列 at-least-once 重投、worker 故障被接管 | run_id 就是 task_id,重投时不变 | 预扣插入撞主键按「已存在」放行,不再占一笔;结算的条件更新只成功一次 |
为什么预扣排在幂等判定之前。 从「判断是不是重复」到「占住会话、登记入队」这一段,必须中间没有任何异步等待,才能保证同一会话的两个请求不会被切开、双双判成「不重复」。预扣要查库,只能放在这段前面。代价是幂等命中的请求也会先占一笔再立刻还掉,多一次写库,换来判定的原子性,值得。
为什么指纹去重只对不带会话 id 的调用生效。 前端是先在本地生成会话 id、连上 WebSocket 再提交任务的,会话 id 存在浏览器本地、刷新不变,所以前端的重复全部落在第一层。如果前端请求也被并进另一个会话,事件全推给原会话,前端连着的那条 WebSocket 一个字都收不到,界面会卡死。真正需要跨会话去重的只有脚本调用。
为什么不另加 Idempotency-Key 请求头。 会话 id 加 run_id 已经承担了幂等键的角色:前端天然带会话 id,队列天然带 task_id。再加一个请求头,前端和队列都要改,挡住的重复并不会更多。
预扣环节本身也幂等。 同一个 run_id 再申请预扣会撞主键,捕获冲突后按「原来那笔仍有效」放行,不会占两份。和任务调度那条 bullet 衔接的点只有一个:接管时 run_id 不变,所以接管后的重跑复用同一行预扣、只结算一次。
流程图 / 架构图#
一次任务的计费时序#
sequenceDiagram
autonumber
participant C as 客户端
participant API as API 实例
participant DB as MySQL<br/>users / run_holds / 日账本
participant Q as Redis Stream 队列
participant W as Worker
participant R as Redis 用量流水
C->>API: 提交任务
API->>DB: 锁用户行,查在飞数与已占额度
alt 在飞数 ≥ 3
API-->>C: 429 + Retry-After
else 可用额度 ≤ 0
API-->>C: 402
else 放行
API->>DB: 插入预扣行 queued,占 min 档位与可用额度
API->>API: 幂等判定:同会话同问题 / 指纹去重
alt 判定为重复
API->>DB: 预扣按 0 结算释放
API-->>C: already_running 或 duplicate
else 新任务
API->>Q: 入队 task_id 等于 run_id
API-->>C: 返回 thread_id
Q->>W: 投递
W->>DB: 预扣行 queued 改 running
loop 每次模型调用
W->>R: HINCRBY 成本与 token
end
W->>DB: 事务内条件更新为 settled 并累加日账本
end
end
预扣行的状态流转#
stateDiagram-v2
state "已过期" as expired
[*] --> queued: 准入通过,占住档位额度
queued --> running: worker 领到任务
queued --> settled: 幂等命中 / 排队期间取消 / 入队失败,按 0 释放
running --> settled: 正常结束或用户取消,按真实用量结算
running --> expired: worker 崩溃,超过 900 秒无终态
queued --> expired: 消息丢失,超过 900 秒无终态
expired --> running: 对账发现租约仍在,顺延 900 秒
expired --> settled: 对账按用量流水补扣,流水缺失则按预扣额结算
settled --> [*]
「已过期」不是库里的一个状态值,只是 expires_at < now 的 queued / running 行。读侧不再把它算进占用,对账任务负责把它推到 settled。
数字怎么来的#
这条 bullet 没写量化指标,面试时被问到的通常是「这些参数怎么定的」和「你怎么证明它是对的」。
设计参数的口径#
| 参数 | 值 | 怎么定的 |
|---|---|---|
| 1 Credit 对应成本 | $0.001 | 让日常数字落在几十到几千之间,人好读;一次典型购物任务 $0.01 |
| 每日额度 | 普通账号 2000,访客 100 | 普通账号够几十次完整任务;访客按一轮约 3 Credits 算,够试 30 轮左右,不够拿来白嫖 |
| 预扣档位 | 普通 20,重档 60 | 取对应档位历史结算值(credits_charged)的高分位向上取整;重档给多轮追问留余量 |
| 用户并发上限 | 3 | 一个标签页同时最多一个任务在飞,留两个给多开标签页和脚本 |
| 预扣 TTL | 900 秒 | 整轮超时 300 秒的三倍,覆盖排队 + 一次重投 |
| 对账周期 | 60 秒,每批 200 行 | 过期行本身已不占额度,对账只管补记账,分钟级延迟可接受 |
| 指纹去重窗口 | 5 秒 | 覆盖脚本超时重试和双击,又不至于挡住用户隔一会儿真心再问一遍 |
上线前的三组验证#
1. 并发透支。 用一个测试账号,把今日剩余调到 50 Credits,用压测脚本同一时刻打 20 个任务提交请求,重复 10 轮。看三个量:放行数、429 / 402 分布、当天账本的最终消耗。改造前 20 条全放行,账本最终超出额度数倍;改造后每轮最多放行 3 条(并发上限),其余拿到 429,额度用尽后拿到 402,账本最终消耗超出额度的部分只出现在最后一轮,且不超过一次模型调用的成本。
2. 崩溃漏扣。 起两个 worker,提交一批任务,在任务跑到第 2~3 次模型调用时对持有它的 worker 执行 kill -9。然后把三份数据对齐:Redis 用量流水、预扣表的 credits_charged、模型网关侧记录的调用成本。改造前被杀任务的账本增量为 0;改造后过期 + 对账周期内(最长约 16 分钟)被杀任务全部结算,结算额与网关侧成本一致,settle_source 标记为 reconcile。被另一台 worker 接管跑完的任务只有一次结算,来源是正常结算。
3. 重复提交。 四种形态各打一遍:前端双击发送、同会话刷新后重发、脚本 5 秒内同一句话重复提交 5 次、队列里人为制造一次重投(worker 跑完但不 ack)。验收标准是每个逻辑任务在预扣表里只有一行 credits_charged > 0,日账本的 task_count 只加 1。四种形态全部满足。
这三组都放进了回归测试:并发那组用真实 MySQL 跑(SQLite 没有行锁,测不出并发问题),崩溃那组用进程级 kill 模拟。
追问 Q&A#
Q1:为什么按成本折 Credits 扣,不直接按 token 数扣?
按裸 token 数扣,会把「靠前缀缓存省下来的钱」也算成用户花的。多轮追问的会话,前缀缓存命中率很高,真实成本比 token 数低很多,而追问恰恰是产品希望用户做的事。所以按三档单价算出美元成本,再折成整数 Credits 给用户看,美元口径不外露,也不暴露供应商费率。
Q2:锁用户行会不会成为瓶颈?会不会死锁?
锁的粒度是一个用户,不同用户之间完全不互相阻塞;同一个人真实的并发是个位数,而且事务里只有一次聚合查询、一次插入,持锁时间在毫秒级。死锁方面,准入事务只按主键等值锁 users 一行,是记录锁,不带间隙锁。两条写路径锁的对象不交叉:准入锁 users 行、往 run_holds 插一行新的;结算只锁自己那条 run_holds 行和当天那一行账本,不碰 users。没有「A 等 B、B 又等 A」的环,就不会死锁。
Q3:预扣 20,但一轮实际只花 3 个,用户会不会觉得额度被吃了?
占用只在任务运行期间存在,结算时多退少补,任务一结束额度就回来。前端余额条显示的是「今日剩余 − 在飞占用」,任务结束后会刷新。预扣猜大的唯一代价是:同一个人在额度末尾时能并发的任务少一些,不会多扣钱。我们也保留了 credits_held 和 credits_charged 两列,定期看两者的分布再调档位。
Q4:你说解决了透支,可「剩一点也放行」不还是会超吗?
会超,但有上界。改造前超多少取决于并发数,理论上无上界;改造后并发的请求看得见彼此的占用,额度用完就是 402,能超的只有最后放进来的那一轮,而那一轮的单任务上限被压成了当时的剩余额度,每次模型调用前都会检查,超过就强制收尾,所以最多超出最后一次模型调用的成本。换来的是用户在额度末尾还能正常用。要做到严格零透支,就得「不够一个档位就拒」,这是拿猜的数拒真实请求,产品上不划算。
Q5:对账任务判定「租约不在」去补扣了,结果原 worker 只是卡了一下,随后自己也来结算,会不会扣两次?
不会。正常结算和对账补扣用的是同一条条件更新,WHERE state IN ('queued','running')。两边谁先提交谁生效,后到的影响 0 行,直接跳过记账。而且标 settled 和记账在同一个事务里,不存在「状态改了账没记」的中间态让另一方钻空子。
Q6:Redis 挂了,这套还能工作吗?
分开看。用量流水写失败不阻塞模型调用,只打日志——正常结算用的是进程内累计的成本,照样准确;受影响的只有「写流水失败 + 恰好又崩溃」的任务,对账时查不到流水,按进门时的预扣额结算并告警。对账锁拿不到,这一轮就不扫,下一轮再来,过期行不占额度,用户无感。指纹去重窗口不可用时,脚本类提交直接返回 503,不去猜它是不是重复——宁可让调用方重试,也不冒重复执行的风险。预扣和结算本身在 MySQL 上,不依赖 Redis。
Q7:为什么不用 TCC 或 Saga 这类分布式事务框架?
预扣 → 结算 / 释放本身就是 TCC 的思路:Try 占资源、Confirm 按实际量确认、Cancel 按 0 释放,过期对账相当于悬挂事务的恢复。但这里参与方只有一个 MySQL 库,Try 和 Confirm 都是本地事务,用不上协调者。引入框架多一个要运维的组件,解决的是我们没有的问题。
Q8:为什么不把计费做成事件流——每次模型调用发一条计费事件,消费者异步记账?
异步记账解决不了并发透支:准入那一刻要看见「别人正在花多少」,事件还在路上就读不到。预扣必须是同步的、和余额在同一个事务里。事件流适合做事后统计和对账,我们的 Redis 用量流水承担的就是这个角色,但它只当证据,不当账本。
Q9:一个任务跨过了 UTC 零点,钱记到哪一天?
记到结算那一天。账本按结算时刻的 UTC 日期落行,单轮最长 5 分钟,跨零点的量很小。反过来按开始日期记,就要回头改昨天那一行,而昨天的额度判定早就结束了,改了也没有意义,还会让「昨天的账」在今天变动,查账更乱。
Q10:用户取消了任务,要不要退钱?
不退已经花出去的部分。计费的依据是「花没花钱」,模型调用已经发生,成本已经付给供应商。取消后按到那一刻为止的真实用量结算,预扣里没用完的部分释放。如果取消时模型一次都没调(还在排队),就是按 0 释放。
Q11:MySQL 有主从的话,准入读余额会不会读到从库的旧数据?
准入和结算都在主库上的读写事务里执行,SELECT … FOR UPDATE 本身也只能在主库上加锁。只有前端展示余额的查询可以走从库,展示慢几百毫秒不影响正确性。
Q12:同一个用户的请求打到两台 API 实例,并发上限还准吗?
准。并发数是从 run_holds 表里数出来的,锁的是数据库里的用户行,和请求落在哪台实例无关。这也是为什么把并发上限和预扣放在同一次查询里:原来进程内存里的任务表,多副本下一人打两台就各算各的。
相关八股#
1. SELECT … FOR UPDATE 在 InnoDB 里加的是什么锁?
- 当前读,加排他锁(X 锁),直到事务提交或回滚才释放;其他事务的
FOR UPDATE/UPDATE/DELETE会阻塞等待,普通快照读不受影响(走 MVCC)。 - 锁的形态取决于隔离级别和命中情况:RR 下默认是 next-key lock(记录锁 + 前面的间隙锁);唯一索引等值查询且命中时退化为记录锁;等值查询没命中会退化成间隙锁;范围查询会锁住范围内的记录和间隙。RC 下基本只有记录锁。
- 没走索引的
FOR UPDATE会扫全表、给扫到的每一行加锁,效果接近锁表。 - 间隙锁之间不互斥,但会挡住插入意向锁,两个事务各持一段间隙锁再互相插入是常见死锁来源。
关联本项目:准入按 users 主键等值锁一行,只有记录锁,不会因为间隙锁把别的用户挡住或引出死锁。
2. 什么是丢更新?怎么避免?
- 两个事务都「读出旧值 → 在应用里计算 → 写回」,后写的覆盖先写的,前一次修改丢失。隔离级别 RR 也挡不住这种应用层的读改写。
- 三种常见解法:在数据库里做原子运算(
SET x = x + ?);悲观锁先FOR UPDATE再读改写;乐观锁带版本号条件更新、影响 0 行就重试。 - 原子运算最简单,适合纯累加;需要「先判断再改」的场景才用锁。
关联本项目:日账本累加用 SET cost = cost + ?;准入要「先看余额再决定放不放」,所以用悲观锁锁用户行。
3. 乐观锁和悲观锁怎么选?
- 悲观锁:先加锁再操作,冲突时排队等待;适合冲突频繁、重试代价高的场景。缺点是持锁期间阻塞他人,锁顺序不当会死锁。
- 乐观锁:不加锁,提交时用版本号 / CAS 校验,失败重试;适合读多写少、冲突少的场景。冲突一多,重试会放大负载,甚至活锁。
- 判断标准是「冲突概率 × 重试代价」。
关联本项目:冲突恰好集中在「同一个人猛发请求」的时刻,乐观锁会让这些请求集体重试,所以准入用悲观锁,锁粒度压到单个用户。
4. 接口幂等有哪些常见实现?
- 唯一约束:业务键上建唯一索引,重复插入撞键即判重(如订单号、run_id 作主键)。
- 状态机条件更新:
UPDATE … SET state = 终态 WHERE id = ? AND state IN (允许的前置状态),影响行数为 0 即已处理。 - 去重表 / 去重缓存:
SET key NX EX ttl,适合短时间窗口的重复提交。 - 请求级幂等键(Idempotency-Key 请求头):服务端按键缓存首次结果,重复请求直接返回。
- 注意幂等判定和业务写入要么在同一事务里,要么判定本身就是写入(CAS),否则「查 → 写」之间有竞态。
关联本项目:预扣用 run_id 主键防重复占用,结算用状态机条件更新防重复扣,脚本重复提交用 Redis SET NX EX 做 5 秒窗口。
5. TCC 是什么?和 Saga 有什么区别?
- TCC 把一个业务操作拆成三步:Try 检查并预留资源,Confirm 用预留的资源真正执行,Cancel 释放预留。Confirm / Cancel 都必须幂等,还要处理两个异常:空回滚(Try 没执行就收到 Cancel)和悬挂(Cancel 先到、Try 后到,预留永远没人释放)。
- Saga 把长事务拆成一串本地事务,每步配一个补偿操作,失败时反向补偿;没有资源预留,中间状态对外可见,隔离性更弱。
- 选型:资源必须先占住、不允许中间态被别人看到的(如资金、库存)用 TCC;流程长、参与方多、能接受中间态的用 Saga。
关联本项目:预扣 → 结算 / 释放就是单库版的 Try / Confirm / Cancel;「悬挂」对应崩溃后停在 running 的预扣行,靠 TTL 过期和对账任务推到终态。
6. 消息投递的 at-most-once、at-least-once、exactly-once 分别怎么实现?
- at-most-once:先 ack 再处理,处理失败消息就丢了。
- at-least-once:处理完再 ack,没 ack 的消息会重投,可能重复处理。Redis Stream 消费者组的 PEL(待确认列表)+ XCLAIM / XAUTOCLAIM 就是这种语义。
- exactly-once 在分布式系统里一般做不到「只投递一次」,工程上是 at-least-once + 消费端幂等,效果上等于只处理一次(effectively once)。Kafka 的事务 + 幂等生产者也是在这个思路上做的。
关联本项目:任务队列是 at-least-once,重投会整轮重跑;计费靠 run_id 不变 + 结算条件更新做消费端幂等,所以重跑不重复扣。
7. 金额为什么不能用浮点数存和算?
- 二进制浮点数表示不了大多数十进制小数(0.1 + 0.2 ≠ 0.3),反复累加误差会积累,对账时出现分位不平。
- 两种做法:数据库用定点数(MySQL
DECIMAL(p, s));或者用整数存最小单位(分、厘、百万分之一美元),计算全程整数,展示时再换算。 - Java 里用
BigDecimal,并用字符串构造(new BigDecimal("0.1")),不要用 double 构造;比较用compareTo不用equals(后者比较精度位)。
关联本项目:Redis 用量流水用整数 micro-USD 配合 HINCRBY 累加,避开 HINCRBYFLOAT 的精度漂移;对外的 Credits 也是整数。
8. 用 Redis SET key value NX EX 做分布式锁有什么问题?
- 锁过期但业务没做完:别的实例拿到锁,两边同时执行。解法是续期(看门狗,如 Redisson)或把业务做成幂等、让重复执行无害。
- 误删别人的锁:释放时要先比较 value 是不是自己,比较和删除用 Lua 脚本保证原子。
- 主从切换丢锁:主节点写入后还没同步就宕机,新主上没有这把锁。Redlock 用多个独立节点多数派加锁来缓解,但依赖时钟假设,有争议;强一致场景改用 ZooKeeper / etcd。
关联本项目:对账锁只用来「减少多台同时扫」,不承担正确性——就算两台同时扫到同一行,结算的条件更新也只会让一台成功,所以锁偶尔失效也不会重复扣费,不需要续期和 Redlock。
9. Redis 的过期键是怎么删的?键空间通知能拿来做业务触发吗?
- 过期删除是惰性删除 + 定期删除:访问时发现过期就删;后台每秒若干次随机抽一批带过期时间的键检查、删除过期的,过期比例高就继续抽。所以一个键过期后,真正被删掉的时刻是不确定的。
- 过期事件(
__keyevent@0__:expired)在键被删除时才发出,时间不准;通知是 Pub/Sub 推送,发了就不管,订阅方断线期间的事件直接丢,也没有重试。 - 需要可靠的延时触发,应该用有序集合按时间戳做延时队列、消息队列的延时消息,或者数据库里存截止时间再定时扫描。
关联本项目:崩溃后的补扣没有用 Redis 过期通知,而是在 MySQL 里存 expires_at、由对账任务定时扫描,慢一点但一条都不会漏。