ARTICLE DETAIL

资讯详情

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

大模型API网关多租户配额控制:Redis Lua原子预扣实践

大模型API网关多租户配额控制:Redis Lua原子预扣实践 租户抢额度这事我前前后后踩了快一年的坑。简单说大模型 API 网关最怕的一件事就是某个租户在高峰期一口气把上游配额打穿下游账单爆表上游还给你回一堆401 unauthorized: incorrect api key provided、429或者400 context length exceeded。光靠数据库里的余额字段做判断等于给并发开了后门——你读到的余额和写出去的扣减之间永远隔着一道竞态条件的鸿沟。我的解法是用 Redis Lua 原子预扣把“查余额、冻结额度、写记录”三步捏成一个脚本再加上一套多租户治理的键设计和结算回补流程。这篇文章把整套方案的思路、代码、上线后的坑都摊开讲适合自己搭 API 网关、做模型聚合层、或者正在折腾配额计费系统的后端同学参考。1. 多租户网关的配额痛点为什么“超卖”是常态1.1 大模型 API 的计费与并发特征大模型 API 和普通 REST API 最大的不同在于计费维度。普通接口按次数收费一次调用一个固定价格配额控制只需要计数。但大模型按 token 收费而这笔账在调用发起那一刻根本算不清——请求里带的是max_tokens上游模型实际生成多少完全由输入和采样决定可能远小于上限也可能因为上下文不断累加而超出预估。这就是“预扣”这个概念出现的根本原因你必须在请求发出前冻结一笔足够的额度等上游真正返回usage之后再做结算。再加上多租户场景问题就更复杂了。一个企业网关下面可能挂着十几个部门每个部门又有自己的子密钥每个租户对模型的需求不一样有的主打低延迟推理有的偏重大规模离线批量处理每个租户的预算也不同有的按年包年有的按量计费。要是所有租户共享一个额度池高峰期一个做批处理的租户就能把其他租户的额度全部挤占掉这在商业上完全不可接受。所以配额系统必须支持精细的租户级隔离而且是原子隔离。还有一个往往被忽略的点大模型上游的错误形态实在太杂了。401说明密钥不对400可能说context超长429是上游限流5xx是上游不稳定。如果每一次失败都走“先扣费再退款”的逻辑系统会被退款请求淹没如果失败不处理又等于白扣用户钱。这套系统的设计目标就是在这些乱七八糟的异常状态下仍然保证每个租户的账目是准的。1.2 预扣与实扣的架构差异我做第一版的时候用的是最粗暴的方案“先调用、后扣费”。请求进来先查一把 Redis 里的余额够就放行调用结束之后再用异步任务把实际 token 数扣掉。听着很合理对吧上线第一个月就出事——有两个租户同时在抢最后 2000 token 的额度两个请求都读到了“余额还有 2000”都放行结果上游调用完一算实际消耗加起来 3500租户余额变负数了。虽然负数在技术上可以强行写入但后面的对账和计费直接乱套财务部门找了我三天。这就是竞态条件的典型场景。即便你给 Redis 的读操作加锁锁的粒度、锁的释放时机、锁的可靠性全都是一堆事。后来我想明白了一个道理配额控制这类操作本质上就是一段多步逻辑你必须让它在 Redis 内部一步做完而不是在应用层分三步做。Redis Lua 脚本正好就是为这种事设计的它保证脚本在 Redis 单线程事件循环里原子执行脚本执行期间不会有其他命令插入天然消灭竞态。预扣的思路也由此定下来请求进来时先按模型配置的预估上限比如max_tokens * 1.2留出缓冲从配额中冻结一笔额度冻结成功才允许调用上游调用完成后拿真实usage结算把冻结的额度释放、把真实消耗的额度写死。如果调用失败冻结额度原路退回。这套“先冻结、后结算、失败退回”的机制保证了任何时刻租户被冻结的额度都不会被其他请求占掉同时最终账目又完全是按照真实用量来算的。2. 技术选型Redis Lua 原子预扣的取舍逻辑2.1 为什么不是数据库行锁、也不是 WATCH/MULTI先说说我为什么放弃数据库方案。配额表用 MySQL 做行锁 UPDATE逻辑上也行但只要网关的 QPS 上来数据库的锁等待和连接池压力就会成为新的瓶颈。更麻烦的是配额系统往往要跟热 key 缓存、滑动窗口限流这些 Redis 里的数据联动放在数据库里就割裂了每次都要跨存储查两次。Redis 本身是内存数据库单键读写是微秒级在配额这个高频写场景下优势非常明显。那用 Redis 是不是就得写WATCHMULTI也不是不行但WATCH的机制是“先读、后写、提交时检查版本”一旦并发高任何一次版本变动都会让事务失败应用层就得重试。配额争抢本身就是高并发场景用WATCH会导致大量无效重试既浪费网络往返又让代码变得复杂。Lua 脚本则完全不同它把读写全包在服务端执行一个脚本就是一条命令天然没有版本检查失败的问题。还有一个实际原因配额逻辑往往不止“扣一个数字”这么简单。你可能需要同时检查总配额、已冻结、已花费、已退还四个字段还要更新冻结字段、刷新 TTL。这一套逻辑如果拆成多条独立命令那中间任何一个步骤失败都会造成状态不一致。比如先 HGET 再 HINCRBY中间客户端断开你会发现冻结没写进去。Lua 脚本一个原子执行要么全部成功要么全部不执行没有中间态。2.2 Redis 数据类型与配额存储模型配额数据我用了Hash而不是简单的String原因是配额状态有多个维度拆成多个 key 又没法在 Lua 里原子操作。每个租户或者租户维度一个 Hash字段固定total总配额比如 1000000 token或 10000 次调用prepaid当前已冻结但尚未结算的额度spent已实际消耗的额度refunded累计退回的额度主要用于审计和对账updated_at最后更新时间用于观察活跃度可用额度公式available total - prepaid - spent。注意refunded不参与可用额度计算它是个纯审计字段——真正的预扣逻辑里冻结和退还的净值已经反映在prepaid的变化里refunded的存在只是为了让你能查清楚“发生过多少次退款”方便排查问题。Key 的设计遵循多租户隔离原则quota:{tenantId}:{level}:{scopeId}。level可以是org组织级、app应用级、key密钥级scopeId是具体维度下的对象标识。比如quota:org:tenant_a:total和quota:app:tenant_a:batch_app:total。这样一套设计可以把配额控制颗粒度从“租户大池子”细化到“某个应用只能花自己名下的额度”同时 Lua 脚本的逻辑不用改只需要传入不同的 key。2.3 多租户键设计与数据隔离策略多租户治理的第一原则配额 key 里坚决不能出现用户的明文 ID 或密钥明文。我统一用网关内部生成的租户编号t_开头例如t_d4f9e1用户原始标识通过另一张映射表转换。这样即使 Redis 被脱库泄露的也只是无意义编号不能直接关联到真实用户。第二原则配额 key 要能区分“配额归属”和“调用主体”。同一个租户可能用同一个上游密钥发起请求但不同子业务要分开记账。所以我在网关的鉴权中间件里先解析 API Key再解析出租户 ID、应用 ID、密钥 ID 三层身份分别拼接对应的配额 Hash key。预扣脚本按优先级检查密钥级 应用级 组织级逐级扣减任何一个层级额度不足就拒绝放行。这样才能实现“子应用先扣自己的额度自己的额度不够再借组织额度”符合常见的商务模型。第三原则配额 key 必须有过期机制但过期策略要格外小心。我给 Hash 设置了动态 TTL每次访问都刷新。这个 TTL 不能太短否则长期运行的离线任务会把键刷掉了重启导致额度消失也不能太长否则一个彻底废弃的租户会一直占着 Redis 内存。我线上用的是 30 天活动窗口连续一个月无访问就自然回收。注意TTL 刷新必须放在 Lua 脚本里用EXPIRE做不能在应用层单独发命令否则脚本和过期之间又有竞态窗口。3. 核心实现原子预扣与结算回补的工程细节3.1 配额预扣 Lua 脚本逐段拆解下面这个脚本是我线上在跑的版本去掉了一些业务耦合把核心逻辑保留完整。它接收三个 key组织级、应用级、密钥级配额 Hash以及预扣参数逐级检查并冻结额度-- KEYS[1] org quota hash -- KEYS[2] app quota hash -- KEYS[3] key quota hash -- ARGV[1] requestId -- ARGV[2] tokens to prepay -- ARGV[3] ttl seconds local need tonumber(ARGV[2]) or 0 if need 0 then return { -2, invalid prepay token } end for i 1, 3 do local total tonumber(redis.call(HGET, KEYS[i], total) or 0) local prepaid tonumber(redis.call(HGET, KEYS[i], prepaid) or 0) local spent tonumber(redis.call(HGET, KEYS[i], spent) or 0) if total 0 then return { -3, no quota config at level .. i } end local available total - prepaid - spent if available need then return { -1, available, i } end redis.call(HSET, KEYS[i], prepaid, prepaid need) redis.call(HSET, KEYS[i], last_request_id, ARGV[1]) redis.call(HSET, KEYS[i], updated_at, redis.call(TIME)[1]) redis.call(EXPIRE, KEYS[i], ARGV[3]) end return { 0, need }逐段解释一下。脚本开头先做参数校验need 0直接返回错误码-2防止异常请求传入 0 或负数把配额弄坏。然后是三层循环每一层都做同样的事读total、prepaid、spent算available如果可用不够就立刻返回-1和当前可用值够就把prepaid加上need。关键点在第 14 行的返回值设计{ -1, available, i }里带了层级号i这样应用层收到失败后能明确知道是哪一级配额打穿了方便告警时定位到具体租户还是应用。第 17 行redis.call(TIME)[1]是从 Redis 服务器拿时间而不是应用时间避免各客户端节点时钟偏差导致updated_at记录错乱。第 18 行的EXPIRE给 Hash 刷新 TTL保证活跃配额键不过期。这里有个细节值得说三级 pre 扣是同时扣的而不是逐级兜底。组织额度扣了、应用额度扣了、密钥额度也扣了三个层级都冻结同一笔 token。等结算成功后三个层级各自把冻结转变成实际花费最终组织级的spent是三笔合计应用级是应用自己的那笔密钥级是密钥自己的那笔。要是设计成“先扣密钥级不够再扣组织级”存在一个致命问题如果你在密钥级扣了 100、组织级扣了 50之后请求在结算阶段成功怎么拆账你根本说不清那 100 是该记在组织头上还是密钥头上。三级同时扣每一级的账都是自洽的。代价是冻结的总额是三层之和但结算时按实际用量一补最终total - spent的结果依然正确因为三笔冻结最终释放或消耗的数量是对齐的。3.2 请求结算实际用量转入 spent预扣只是前半段。请求打完上游拿到usage之后必须把冻结额度转成真实消耗。结算同样用 Lua-- KEYS[1..3] three quota hash keys -- ARGV[1] actual consumed tokens -- ARGV[2] requestId local consumed tonumber(ARGV[1]) or 0 local requestId ARGV[2] if consumed 0 then return { -1, invalid consumed } end for i 1, 3 do local prepaid tonumber(redis.call(HGET, KEYS[i], prepaid) or 0) local spent tonumber(redis.call(HGET, KEYS[i], spent) or 0) -- 理论上冻结额一定大于等于实际消耗防呆处理 local release math.min(prepaid, consumed) local actual math.min(prepaid, consumed) redis.call(HSET, KEYS[i], prepaid, prepaid - release) redis.call(HSET, KEYS[i], spent, spent actual) redis.call(HSET, KEYS[i], last_settle_request, requestId) end return { 0, consumed }这里的逻辑是把三层 key 里冻结的额度按实际消耗一次性转正。因为三层冻结的是同一笔所以三层都扣同样的consumed。如果consumed大于冻结额即上游 token 数估算不准出现了超用math.min(prepaid, consumed)只释放实际冻结的量保证不会扣成负数。超出冻结的那部分我单独设计了一个“透支补收”任务异步从组织的total里追扣也会发告警——这种场景说明预扣系数设置不合理。结算阶段的日志我强烈建议全量记录requestId、租户 ID、模型 ID、预扣 token、实际 token、开始时间、结束时间、上游错误码。一是对账要用二是排查“明明可用额度还有为什么老报额度不足”这类问题时没有日志根本定位不了。3.3 失败退费与幂等设计上游调用失败后的退款是最容易被低估的工程点。一开始我直接在捕获到异常后调HINCRBY prepaid -N看着没问题但引入了一个严重 bug同一个请求因为网络重试退款被调了两次租户被多退了一笔。这就是幂等性没做好。正确的做法是给每笔退款绑定一个唯一的requestId在 Redis 里存一份已退清单。我用了一个refund:{requestId}字符串键值存退费金额带 24 小时 TTL。退款 Lua 脚本先检查这个键是否存在存在就直接返回{0, duplicate}幂等成功不存在才真正执行退费并写入清单键。-- KEYS[1] quota hash -- KEYS[2] refund receipt key -- ARGV[1] requestId -- ARGV[2] refund tokens local exists redis.call(EXISTS, KEYS[2]) if exists 1 then return { 0, duplicate } end redis.call(HSET, KEYS[1], prepaid, tonumber(redis.call(HGET, KEYS[1], prepaid) or 0) - tonumber(ARGV[2])) redis.call(HSET, KEYS[1], refunded, tonumber(redis.call(HGET, KEYS[1], refunded) or 0) tonumber(ARGV[2])) redis.call(SET, KEYS[2], ARGV[2], EX, 86400) return { 0, refunded }注意这里只退组织级应用级和密钥级也要做同样的操作正式代码里是三层循环我这里简化了。退款跟扣费同样要写refunded审计字段否则对账时你会困惑“为什么 spent 明明只有 800total 却只剩 700”——因为中间经历过两次失败退款减少量里包含退款产生的净差值。幂等设计还有一层如果请求发到上游之后超时了但实际模型可能已经生成了结果这属于“不确定状态”。我默认退回全部冻结额代价是极端情况下用户白嫖一次成功的调用。对大多数 API 网关来说宁可让一次调用不收费也不能因为没退费把矛盾留给客服。4. 多租户治理实战从初始化到限流联动4.1 租户配额管理后台设计多租户治理跟单租户最大的区别在于你需要一边精细分配一边动态调整。我做了一个极简的管理后台接口负责三个动作开通租户、调整配额、查看实时余量。开通租户时初始化脚本在 Redis 里写入组织级 Hashtotal根据购买的套餐计算。比如购买了 500 万 token 的租户total 5000000prepaid 0spent 0refunded 0。这个初始化也是个原子操作用一条 Lua 脚本保证不会重复创建-- KEYS[1] quota hash -- ARGV[1] total tokens if redis.call(EXISTS, KEYS[1]) 1 then return { -1, already exists } end redis.call(HSET, KEYS[1], total, ARGV[1], prepaid, 0, spent, 0, refunded, 0) redis.call(EXPIRE, KEYS[1], 2592000) return { 0, ok }调整配额时我刻意不做“直接改 total”的操作而是走一条“增量变更”接口。管理员在后台把租户从 500 万调到 800 万实际上只是在 Redis 里执行HINCRBY total 3000000。这么设计的好处是调额操作不会跟正在进行的预扣产生冲突Lua 脚本一进去一出来额度自然就变了。如果管理员做的是降额比如从 500 万降到 200 万脚本会算一下当前prepaid spent是否已经超过新总额如果超了就直接拒绝并明确提示“该租户已用超过目标额度请先冻结或联系财务”防止把正在跑的租户压垮。余量查询接口则直接用HGETALL拿回 Hash 全部字段在应用层算好available返回前端。前端展示的数据不再是单值而是“总量 / 已用 / 在途 / 剩余”四个数用户看到“在途”就知道系统是在预扣而非乱扣沟通成本反而降低了。4.2 配额耗尽后的降级策略配额耗尽后的表现决定了一家公司 API 网关在用户眼中的下限。我见过很多系统在额度不足时直接返回403 Forbidden这既浪费了一次请求又让用户没法知道“什么时候恢复”。我最终采用了一套三级降级策略第一级当available即将耗尽低于阈值比如 10%时在响应头里带上X-Quota-Warning: 10%同时推一条 Webhook 给租户管理员。这是温和提醒。第二级当available耗尽时如果租户开了“超额弹性”选项用 Lua 脚本把预扣转化为“透支扣费”直接把本次消耗挂到组织级total的红线上并且给请求追加一个x-quota-overage: true头。透支的前提是租户预先批准了超额额度并且超额部分会进入补款流程。没有开这个选项的租户则收到标准错误码429响应头带上Retry-After: 60表示 60 秒后可能恢复。第三级当超额弹性额度也打穿后比如透支到 120%直接返回402 Payment Required。这个码很多后端不熟悉但它语义上就是“欠费了请充值”比 403 精准得多。同时在后台给管理员发告警显示这个租户当前透支金额和累计调用趋势。这三级的核心逻辑还是放在 Lua 脚本里预扣脚本发现available need后不会立刻返回失败而是检查租户是否开了透支标志开了则进入透支分支把additional_overage字段加上超额量并返回{2, overage}。应用层拿到返回码 2 会打上x-quota-overage标记后继续放行。这个分支代码量不大但对商业体验影响极大。4.3 告警与对账补偿多租户系统不能只靠 Redis 上的实时数据过日子必须配合定时对账任务。我的做法是每小时对全量租户跑一遍扫描从 Redi s 里拉出所有配额 Hash检查total - spent - prepaid是否为负、是否接近阈值再把结果和前一天同时间段的快照做比对。对账任务输出的告警分几档紧急存在租户available为负说明降级策略没拦住警告某个租户消耗速率比日常平均高 3 倍可能被盗刷提示某个租户连续 7 天消耗低于 1%建议降配对账任务本身一定要做幂等它写补偿操作的时候也带上requestId防止跟运行中的实时请求产生双重扣减。我发现一个很隐蔽的坑对账任务从 Redis 拉 Hash 字段时用的是HGETALL如果这一刻正好有请求在写prepaid拿到的可能是个中间态——比如读了prepaid100但下一秒这个 100 被结算成了spent。这种微小的读不一致通常不会造成严重问题因为补偿逻辑只在available为负时才执行但保险起见我在补偿脚本里也用了 Lua 原子操作重新读、重新算、重新写杜绝“基于过期快照修正”这种事发生。5. 上线后踩过的坑问题排查与修复实录5.1 高频错误与排查速查表我把自己线上遇到的高频问题整理成了一份速查表懒人可以直接对照症状根因排查方法大量请求返回-1 available但后台显示余量充足结算任务延迟冻结额没及时转spent看 Redisprepaid字段若持续偏高检查结算消费队列空耗偶尔出现ERR unknown command EVALSHARedis 版本过低或脚本未提前加载确认 Redis ≥ 7.0上线前用SCRIPT LOAD预加载脚本并发一上来就出现redis command timed out单个配额 key 被大量请求争抢Lua 脚本执行排队给配额 Hash 做分片例如quota:org:t_a:shard0到shard7或升级 Redis 集群退款请求偶发重复用户余额被多退退款幂等键缺失或 TTL 太短检查refund:{requestId}键是否存在TTL 改 24 小时以上某租户spent远大于total-prepaid剩余值结算逻辑中actual大于冻结额检查模型预扣系数通常要把max_tokens乘 1.3~1.5 预留缓冲租户过期后再登录额度归零动态 TTL 到期Hash 被 Redis 回收改为“访问刷新 TTL 数据库持久化配额元信息”双写方案最坑的一次是“EVALSHA 报错”。我当时图省事每次请求都用EVAL发送完整脚本结果某次客户端升级后开始用EVALSHA引用脚本而 Redis 里脚本还没加载大量错误码直接打到业务层。现在我上线前会专门跑一个预热脚本把所有用到的 Lua 用SCRIPT LOAD加载一遍拿到 SHA 后应用层用EVALSHA调用。省了网络传输也消除了“脚本不存在”的风险。5.2 五个值得注意的工程细节第一个细节Redis 版本选择与稳定性的关系。Lua 5.1 是 Redis 默认嵌的如果你在脚本里用了 5.2 以后的语法对不起直接报错。我自己就踩过一个math.tointeger的坑这函数在 Redis 6 的 Lua 环境里不存在。写脚本时老老实实用tonumber和取整操作别追求花哨。第二个细节对哈希的 EXPIRE 刷新。很多帖子都说“要给配额 key 设过期时间”却没强调过期必须跟访问绑定。如果长时间没有请求Hash 过期了租户回来发现额度清零这问题在我前面的速查表里出现过。解决方案不是取消过期而是在每个 Lua 脚本里对访问到的 Hash 主动EXPIRE一次。这样冷数据自然淘汰热数据永远不过期。第三个细节把 Redis 超时视为不可用场景。我在网关上加了一个“配额系统熔断开关”当 Redis 连续超时超过阈值比如 5 次/秒自动把配额校验旁路掉请求直接放行同时高强度告警。这听起来违背了配额防透支的初衷但在实际业务里牺牲短时间超卖换取不宕机是划算的——因为一旦网关整个不可用损失的是所有租户的可用性而不只是配额超卖。旁路期间产生的消耗我会异步补记后续从冻结或spent里追回。第四个细节冷启动与 Redis 重启补偿。Redis 重启后内存里的配额 Hash 全没了此时如果直接放行所有请求等于默认每个租户无限额。我设计了一个冷启动检查Redis 刚启动时网关先从数据库加载所有活跃租户的配额元信息初始化回 Redis然后才开放预扣。这个过程不能做成同步加载完所有租户而是做懒加载——第一个请求到某租户时如果 Redis 里 key 不存在且数据库里有元数据就先初始化再放行。懒加载脚本和预扣脚本合并成一条保证初始化加预扣是一个原子操作。第五个细节不要把配额字段存成字符串 JSON。有人图省事在一个 String 里塞{total:100,spent:20}然后每次用GETSET覆盖。这在低并发下没问题但一旦并发上来就是典型的读改写竞态跟第一章说的数据库超卖问题如出一辙。Hash 的每个字段独立操作配合 Lua 脚本天然支持多字段原子读写这才是配额系统的正确姿势。5.3 监控与告警的落地实践监控这套系统比监控普通业务接口更要盯紧“速率”和“队列”两个维度。我在 Grafana 上配了几张核心面板预扣请求 QPS、Lua 脚本平均耗时尤其要区分EVAL和EVALSHA、配额耗尽返回码-1的分布、退款成功率、Redis key 总量变化曲线。有一个指标值得重点关注单 key 的 QPS 分位数。配额 Hash 是热点中的热点某个大租户的 key 可能每秒被访问几千次而 Redis 的 Lua 脚本虽然原子但执行期间整个 Server 是串行的一个慢脚本会拖住全 Redis 实例。我上了脚本耗时监控后发现一个异常某个模型 ID 配置错了它的max_tokens被设成一个异常大的值预扣循环里每次要做三次HGETALL单个脚本执行时间飙到 40ms。正常情况下单脚本应该在 1ms 以内40ms 意味着同一时刻其他租户的请求全部在排队。现在我会给所有 Lua 脚本设置 5ms 的性能上限超过就直接优化脚本或拆分 key。告警规则也定了三个级别脚本平均耗时超过 5ms 时 P2 告警超过 10ms 时 P1配额 key 耗尽率-2/-1返回占比超过 5% 时 P1因为这意味着部分租户已经开始被系统“拦截”请求需要业务侧立刻介入。后记说回开头的那个问题配额系统从来不是“写个减法”这么简单。它本质上是把“额度”这个抽象资源在极高并发和异常丛生的环境里管到一分钱都不差。Redis Lua 原子预扣帮我把竞态的根子拔掉了但真正让我踏实的是后来补上的三层隔离键设计、幂等退款、对账补偿、冷启动保护和性能监控。那些看起来不起眼的细节才是上线后能安稳睡觉的原因。最后分享一个我自己的操作习惯每次要改配额脚本我不会直接改线上正在跑的 Lua而是先在新 Redis 实例上用redis-cli模拟一遍极端场景——比如把total设成比prepaidspent还小、把need设成负数、把并发请求打到一个 key 上。脚本这东西执行期报错不可怕最怕的是逻辑漏洞让你在业务流量里花几周才发现。模拟完再上线能省掉至少三个不眠夜。
返回列表