ARTICLE DETAIL

资讯详情

深耕郑州网站建设与运营推广的一线实战洞察。

Redis Lua原子预扣:大模型API网关多租户配额防超扣实战

Redis Lua原子预扣:大模型API网关多租户配额防超扣实战 1. 从一次线上事故说起为什么预扣比事后扣费更靠谱去年冬天我们内部的大模型网关经历了一次挺尴尬的事故。那天下午两点多某个业务线的同学跑了一批批量任务短时间内打进来几千次请求把当月共享的 API 配额池直接打穿。等监控告警响起来的时候账单已经超了预算一大截更麻烦的是其他几个租户的请求全部被上游限流整个下午的调用成功率掉到了六成以下。事后复盘问题其实不在请求太多而在于我们的配额扣减逻辑是先调用、后扣费。请求进来先放行等上游返回了再异步写回 Redis 扣减额度。这个模型在低并发下没问题但一旦并发上来就会出现大量请求同时读到还有余额的旧快照全部放行最后集体超扣。这就是典型的检查与扣减不是原子操作导致的竞态。后来我们把方案改成了预扣 回滚请求进来先用一段 Redis Lua 脚本原子地预扣额度扣成功才放行去调上游上游返回失败或者超时再把预扣的额度还回去。这套逻辑上线之后超扣问题基本消失了多租户之间的额度隔离也清晰了很多。这篇就围绕这套方案展开把 Redis Lua 原子预扣的设计思路、多租户治理的坑、以及实际落地时踩过的雷完整讲一遍。适合正在做大模型 API 网关、SaaS 多租户计费、或者任何需要配额防透支场景的同学参考。哪怕你之前没写过 Lua跟着看下来也能直接抄作业。2. 为什么先查后扣一定会出事并发下的经典竞态2.1 一个被低估的时间窗口很多人第一反应是我在 Redis 里存一个 key请求进来先GET看余额够就DECR不够就拒绝这不就行了单线程跑当然没问题但线上是并发的。GET和DECR是两条独立命令中间那个时间窗口就是事故的温床。假设租户 A 剩余额度是 100同一瞬间来了 200 个请求每个请求消耗 1 个额度。因为GET是并发执行的这 200 个请求可能都读到余额 100于是全部通过检查然后 200 个DECR依次执行余额变成 -100。这就是超扣。窗口有多小在 Redis 单机 QPS 几万的情况下这个窗口可能只有几十微秒但高并发下就是会被撞上。2.2 分布式锁能解决但代价不小有人会说那加个分布式锁不就行了请求进来先抢锁抢到的人查余额、扣减、放锁其他人等着。逻辑上确实能保证串行但问题也很明显吞吐骤降所有配额操作被串行化锁本身成了瓶颈。大模型网关动辄每秒几千次调用锁竞争会非常激烈。锁的可靠性问题锁超时、锁误删、客户端崩溃导致锁没释放这些都是老生常谈的坑。为了配额扣减再引入一套锁的运维成本不划算。粒度问题如果锁的粒度是整个租户那同一租户的所有请求都得排队如果细化到每个请求那锁就失去意义了。所以分布式锁不是不能做而是用在这里性价比太低。我们真正需要的是把检查和扣减合并成一个不可分割的操作而不是把并发变成串行。2.3 Lua 脚本为什么是正解Redis 执行 Lua 脚本时整个脚本是原子执行的——脚本运行期间Redis 不会插入执行其他客户端的命令。这就天然满足了检查 扣减必须原子完成的需求而且不需要加锁不会牺牲并发。更关键的是Lua 脚本在 Redis 里是单次网络往返。如果不用脚本你得GET一次、判断、再DECR一次至少两次 RTT用脚本一次 RTT 搞定网络开销也省了。对于网关这种对延迟敏感的场景这个收益很实在。注意Redis 的 Lua 脚本原子性指的是脚本执行期间不被其他命令打断但它不保证事务回滚。脚本跑到一半报错前面已经执行的写操作不会自动撤销。所以脚本逻辑要写得足够健壮把可能出错的地方前置判断。3. 预扣模型的核心设计把额度当成冻结资金3.1 预扣、确认、回滚三段式我们把一次完整的调用拆成三个阶段预扣Reserve请求进来先原子地检查余额是否充足充足就扣掉预估额度返回一个预扣凭证。确认Commit上游调用成功把预扣的额度正式消费掉不再回滚。回滚Rollback上游调用失败或超时把预扣的额度原路返还。这个模型和银行转账里的冻结资金是一个道理。你下单的时候先把钱冻结发货了才真正扣款取消订单就解冻。好处是任何时刻账面上的可用额度都是准确的不会出现看起来还有钱实际已经被别人预定了的情况。3.2 额度该按什么维度扣这里有个容易忽略的设计点预扣的额度按什么算大模型 API 的计费通常和 token 数挂钩但请求进来的时候你还不知道会消耗多少 token。我们的做法是按请求预估上限预扣比如按模型的最大输出 token 数预扣一个保守值。上游返回后拿到实际 token 用量做一次差额调整实际用得少就把多扣的还回去实际用超了比如触发了重试再补扣。这样既保证了预扣阶段有明确的数字可扣又不会让用户为没消耗的额度买单。代价是脚本要支持调整操作逻辑稍微复杂一点但账目更准。3.3 多租户的 key 设计多租户场景下key 的设计直接决定了隔离性和可维护性。我们用的是分层 keyquota:{tenant_id}:{period}:balance # 可用余额 quota:{tenant_id}:{period}:reserved # 已预扣未确认 quota:{tenant_id}:{period}:used # 已确认消费period可以是2025-06这样的月份也可以是daily:2025-06-15。用{}包住tenant_id是为了配合 Redis Cluster 的 hash tag保证同一租户的所有 key 落在同一个 slot 上这样脚本里操作多个 key 才不会报 CROSSSLOT 错误。这个细节很多人第一次上 Cluster 会踩。4. 手写一段能上生产的 Lua 预扣脚本4.1 脚本入参怎么传Redis 的EVAL命令格式是EVAL script numkeys key1 key2 ... arg1 arg2 ...。numkeys后面是 key 的数量再后面是具体的 key最后是参数。key 和参数要严格分开传不要把 key 拼进参数里否则在 Cluster 模式下会出问题。我们的预扣脚本接收KEYS[1]余额 keyKEYS[2]已预扣 keyARGV[1]本次预扣额度ARGV[2]预扣凭证 ID用于后续确认/回滚4.2 预扣脚本实现-- reserve.lua -- KEYS[1] balance key -- KEYS[2] reserved key -- ARGV[1] amount to reserve -- ARGV[2] reservation id local balance tonumber(redis.call(GET, KEYS[1]) or -1) local amount tonumber(ARGV[1]) local resv_id ARGV[2] -- 余额 key 不存在说明租户未初始化或已过期 if balance 0 then return {-1, BALANCE_NOT_INITIALIZED} end if amount 0 then return {-2, INVALID_AMOUNT} end if balance amount then return {-3, INSUFFICIENT_BALANCE} end -- 原子扣减余额累加已预扣 redis.call(DECRBY, KEYS[1], amount) redis.call(INCRBY, KEYS[2], amount) -- 记录这笔预扣的明细用于后续确认或回滚 local detail_key KEYS[2] .. :detail: .. resv_id redis.call(SET, detail_key, amount, EX, 3600) return {0, balance - amount}这段脚本有几个设计考量。第一余额 key 不存在时返回-1而不是当成 0是为了区分没初始化和余额为零两种情况前者是配置问题后者是正常的额度耗尽。第二预扣明细单独存一个带 TTL 的 key是为了防止确认/回滚请求丢失导致额度永久卡在已预扣状态——一小时后自动过期虽然账目会短暂不准但至少不会永久锁死。第三返回值用数组而不是简单数字是为了携带错误码方便上层做精细化处理。4.3 确认与回滚脚本确认脚本很简单就是把预扣明细删掉表示这笔额度正式消费-- commit.lua -- KEYS[1] reserved key -- ARGV[1] reservation id local detail_key KEYS[1] .. :detail: .. ARGV[1] local amount redis.call(GET, detail_key) if not amount then return {-1, RESERVATION_NOT_FOUND} end redis.call(DEL, detail_key) redis.call(DECRBY, KEYS[1], tonumber(amount)) return {0, tonumber(amount)}回滚脚本则要把额度还回余额-- rollback.lua -- KEYS[1] balance key -- KEYS[2] reserved key -- ARGV[1] reservation id local detail_key KEYS[2] .. :detail: .. ARGV[1] local amount redis.call(GET, detail_key) if not amount then return {-1, RESERVATION_NOT_FOUND} end redis.call(INCRBY, KEYS[1], tonumber(amount)) redis.call(DECRBY, KEYS[2], tonumber(amount)) redis.call(DEL, detail_key) return {0, tonumber(amount)}注意回滚脚本里DEL放在最后且整个操作是原子的。如果脚本执行到一半 Redis 挂了虽然概率极低重启后 AOF 会重放整个脚本不会出现还了钱但没删明细的中间态。4.4 用 EVALSHA 减少网络开销每次EVAL都要把整段脚本传过去脚本大了带宽就浪费。生产上应该用SCRIPT LOAD先把脚本加载到 Redis拿到 SHA1之后用EVALSHA调用。如果 Redis 重启导致脚本缓存丢失EVALSHA会返回NOSCRIPT错误客户端捕获后重新SCRIPT LOAD再重试即可。import redis r redis.Redis(hostlocalhost, port6379, decode_responsesTrue) RESERVE_SHA r.script_load(open(reserve.lua).read()) def reserve(tenant_id, period, amount, resv_id): balance_key fquota:{{{tenant_id}}}:{period}:balance reserved_key fquota:{{{tenant_id}}}:{period}:reserved try: return r.evalsha(RESERVE_SHA, 2, balance_key, reserved_key, amount, resv_id) except redis.exceptions.NoScriptError: r.script_load(open(reserve.lua).read()) return r.evalsha(RESERVE_SHA, 2, balance_key, reserved_key, amount, resv_id)5. 多租户治理里那些文档不会写的坑5.1 租户额度初始化与续期新租户进来余额 key 是不存在的。如果直接调预扣脚本会返回BALANCE_NOT_INITIALIZED。我们的做法是在租户开通时用一段初始化脚本写入余额并设置合理的 TTL。TTL 的设置要结合计费周期月度配额就设到月底加几天缓冲避免月初零点大量 key 同时过期引发缓存雪崩式的初始化风暴。这里有个坑TTL 到期后 key 消失但用户可能还有未确认的预扣。如果这时候重新初始化余额那些预扣就凭空消失了账目对不上。我们的处理是初始化时先检查reservedkey如果还有残留说明有未完成的调用要么等它过期要么人工介入。生产上更稳妥的做法是余额 key 不设 TTL用定时任务在周期切换时显式迁移把控制权握在自己手里。5.2 热点租户的 key 竞争大租户的 QPS 可能占全站的很大比例所有请求都打同一个 balance keyRedis 单分片的压力会很大。我们试过几种缓解方案方案思路适用场景代价分片余额把余额拆成 N 份请求按 hash 打到不同分片超大租户需要处理分片间借还逻辑复杂本地预扣网关本地缓存一部分额度定期向 Redis 同步请求量极大且能容忍短暂超扣一致性变弱读写分离余额查询走从库扣减走主库读多写少从库延迟导致读到旧值我们最终选了分片余额把单个大租户的额度拆成 16 份请求按request_id哈希到不同分片。预扣时如果当前分片不够就尝试从其他分片借借不到才拒绝。这样单分片的压力降到 1/16效果立竿见影。代价是脚本复杂度上去了但值得。5.3 预扣凭证的幂等性网络抖动会导致客户端重试同一个预扣请求可能被提交两次。如果不做幂等就会扣两次额度。我们的做法是预扣凭证 ID 由客户端生成比如 UUID脚本里先检查这个 ID 对应的明细 key 是否已存在存在就直接返回上次的结果不再重复扣减。-- 幂等检查放在扣减之前 local detail_key KEYS[2] .. :detail: .. resv_id local existing redis.call(GET, detail_key) if existing then return {0, ALREADY_RESERVED, tonumber(existing)} end这个检查必须放在脚本最前面且和后续扣减在同一个原子操作里否则又会出现竞态。5.4 超时调用的额度回收上游调用超时是最难处理的。你不知道上游到底执行了没有——可能请求根本没到也可能到了但响应丢了。我们的策略是超时即回滚宁可少收钱也不能多扣。回滚后如果上游其实成功了那这笔就是漏计通过离线对账补回来。对于大模型这种按 token 计费的场景漏计的比例通常很低用对账兜底比实时精确更划算。提示回滚操作本身也要幂等。如果回滚请求重试第二次会因为明细 key 已被删除而返回RESERVATION_NOT_FOUND这是正常的上层当成成功处理即可。6. 从压测数据看这套方案到底扛不扛得住6.1 压测环境与基线我们在 3 节点 Redis Cluster每节点 8 核 16G上做了压测客户端用 200 个并发连接模拟 1000 个租户、每个租户 10000 额度。对比两组基线方案GET 判断 DECR三步走中间无锁。Lua 方案本文的预扣脚本。压测结果单次预扣操作的平均延迟方案P50P99超扣次数100 万次请求基线0.8ms3.2ms1247Lua 预扣0.9ms3.5ms0可以看到Lua 方案的延迟只比基线高了约 0.1ms但超扣从 1247 次降到 0。这 0.1ms 换来的是账目的绝对准确非常值。P99 略高是因为脚本执行期间会短暂阻塞其他命令但幅度在可接受范围内。6.2 脚本复杂度对性能的影响我们还测了脚本长度的影响。把预扣脚本从 15 行扩展到 60 行加入分片借还逻辑P99 从 3.5ms 涨到 5.1ms。这说明脚本不是越长越好每多一行 Lua 都在 Redis 主线程里执行会阻塞其他请求。经验值是单个脚本控制在 50 行以内超过就要考虑拆分或者换方案。如果脚本确实复杂可以考虑用 Redis 7 的Function替代EVALFunction 支持更结构化的组织和更好的复用但本质还是单线程执行性能特征类似。6.3 一个反直觉的发现压测里我们发现预扣额度的大小对性能几乎没影响但对业务影响很大。预扣太多用户可用额度被过度占用体验差预扣太少实际消费超了要补扣补扣失败就漏计。我们最后按模型最大输出 token 的 1.2 倍预扣实测下来补扣率不到 3%是个比较平衡的点。7. 上线后还需要盯住的几件事7.1 监控指标怎么设光有方案不够得有监控兜底。我们重点盯这几个指标预扣失败率按租户维度看突然升高说明该租户额度快耗尽可以提前通知。预扣与确认的时间差正常应该在秒级如果大量预扣长时间不确认说明上游有慢调用或者确认逻辑有 bug。回滚率回滚率突然飙升通常是上游故障的信号。reserved 与 balance 的比值这个比值持续偏高说明有大量额度卡在预扣状态可能是明细 key 的 TTL 设置不合理。这些指标我们都接了告警阈值根据历史数据动态调整避免误报。7.2 对账机制不能省再严谨的实时系统也会有漏网之鱼。我们每天凌晨跑一次对账把 Redis 里的used和上游账单做比对差异超过阈值就人工介入。对账的粒度是租户 模型 天这样能快速定位到是哪类调用出了问题。上线半年对账帮我们抓出了三次上游计费异常价值很大。7.3 租户额度调整的原子性运营同学经常需要临时给某个租户加额度。这个操作如果和预扣并发也可能出问题。我们的做法是加额度也走 Lua 脚本和预扣用同一把逻辑锁其实就是同一个原子操作序列保证加额度和扣减不会互相覆盖。别小看这个我们早期就是直接INCRBY加额度结果和预扣撞上出现过加了 1000 额度但实际只到账 800的诡异现象。8. 几个我踩过的具体坑供你避雷第一个坑是Cluster 模式下的 CROSSSLOT。早期 key 设计没加 hash tag脚本里操作 balance 和 reserved 两个 key结果这俩落在不同 slot直接报错。改成quota:{tenant_id}:...之后解决。这个错误在单机 Redis 上完全不会出现一上 Cluster 就暴露很容易被打个措手不及。第二个坑是Lua 里的数字精度。Redis 的 Lua 用的是 5.1数字都是 double超过 2^53 会丢精度。我们有个租户的额度单位是 token量级到了 10^15结果扣减出现误差。后来把单位从 token 改成千 token量级降下来就没事了。涉及大数运算的场景一定要注意这个。第三个坑是脚本里的随机性。有人在脚本里用math.random做分片选择结果发现 Redis 的 Lua 环境里随机数种子是固定的每次执行结果一样分片完全没起到负载均衡的作用。要随机的话把随机因子从客户端传进来别在脚本里生成。第四个坑是EVALSHA的 NOSCRIPT 处理。Redis 主从切换或者重启后脚本缓存会丢EVALSHA报NOSCRIPT。如果客户端没做重试请求就直接失败了。我们的客户端封装了自动SCRIPT LOAD加重试这个必须做否则运维一次重启就是一次故障。这套方案跑了大半年中间经历过几次流量高峰配额超扣的问题再没出现过。多租户的额度隔离也清晰了运营同学能实时看到每个租户的余额和消费对账效率高了不少。如果你也在做类似的配额系统建议直接从 Lua 预扣这条路走别在先查后扣上浪费时间——那个坑迟早要踩。
返回列表