拓十年匠心定制 · 商业建站与技术教学双线并行 咨询热线:400-886-1026 service@lmnt.cn
ARTICLE DETAIL

资讯详情

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

Redis Lua原子预扣:大模型API网关配额防透支实践

Redis Lua原子预扣:大模型API网关配额防透支实践

做网关层大模型API治理有一段时间了,最让我记忆深刻的是某次月底账单事故:内部一个测试项目开了每日100万token的配额,结果一个压测脚本十几分钟就把当天配额烧穿,等发现时账单已经飘红。事后复盘,问题不在于没做限流,而在于限流逻辑用的是“先调用、后记账”,高并发下注定透支。后来我逐步落地了一套基于Redis Lua原子预扣与多租户治理的方案,核心解决的是“调用发生之前就把配额锁住”的问题,这篇文章把设计思路、Lua脚本、租户模型和踩过的坑全部拆开讲,适合正在做AI网关、API平台,或者给企业做大模型应用封装的后端同学参考;就算只是个人对接某个大模型接口,里面预扣的思路也可以直接借鉴。

1. 配额透支是怎么发生的:从一次超支账单讲起

1.1 后记扣减模式的竞态漏洞

大多数团队最开始做API配额控制,用的都是“后记扣减”。逻辑很简单:请求进来,计数器加1,判断加完之后是否超过阈值,超过就拒绝。听起来没问题,但在多实例部署下,这个流程存在一个经典竞态窗口。

两个请求同时到达网关的不同实例,它们各自执行“读计数器、判断、加1”三步。假设阈值是1000,计数器当前是999,请求A读到999,请求B也读到999,两边都判断“999小于1000,可以放行”,然后各自把计数器更新为1000。最终计数器变成1000,但实际放行了1001个请求。加了Redis的INCR命令也一样,INCR本身是原子的,但“INCR之后判断是否超限、超限要不要回滚”这两步没法做到整体原子,并发一上来照样出问题。

单机场景下可以用并发原语串行化,但网关是分布式服务,计数器放在Redis里,跨实例的“读-改-写”必须依赖原子原语。这也是整个防透支问题的起点:只要不是原子操作,就存在超发可能。

1.2 大模型API和普通API在配额控制上的本质差异

做过普通REST API限流的人,可能觉得大模型API没什么特殊。实际差异非常大。

普通API的单次消耗基本可预测,一次请求对应一次数据库查询或者一次文件读取,资源消耗相对固定。大模型API完全不同,上游按token计费,一次对话可能消耗几百token,也可能因为上下文拉满或者生成长度太长消耗几万token。更麻烦的是,输入token在请求发出前还能估算,输出token只能等完整响应拿到usage之后才知道。

这意味着按请求次数限流完全没意义。用户一个请求可能烧掉你几千token,也可能烧掉你几万token,次数限制防不住成本失控。只有按token做配额,才真正关联系到钱。

大模型请求还有一个特点:耗时特别长。普通API大多几百毫秒返回,大模型流式生成动辄几十秒甚至几分钟。预扣了配额之后,中间任何异常——超时、断连、上游500、客户端中途取消——都会让预扣记录迟迟无法清理。所以这套方案不能只做“预扣”,还得配套释放流程和补偿任务,这是普通限流方案里完全不需要考虑的事情。

2. Redis Lua原子预扣的设计逻辑:从竞态条件到脚本选型

2.1 先读再写为什么一定会出问题

要理解Lua脚本的价值,先看最直觉的错误做法。用伪代码描述:

balance = redis.get("balance") # 请求A读到100 balance = redis.get("balance") # 请求B也读到100 balance = balance - 50 # 请求A计算得到50 balance = balance - 50 # 请求B计算得到50 redis.set("balance", balance) # 请求A写回50 redis.set("balance", balance) # 请求B也写回50

两个请求各消耗50,最终余额却是50,而不是0。这不是Redis的问题,是“读-改-写”不是原子操作的问题。Redis官方其实有WATCH/MULTI/EXEC的乐观锁方案,可以解决一部分问题,但配额预扣的逻辑不是简单的DECRBY,而是“余额够不够、不够就整体中止、够才扣减”的条件操作。WATCH方案每轮失败都要重试,高并发下重试风暴带来的开销成倍放大,而且代码复杂度很高。

2.2 Lua脚本比MULTI事务强在哪

Redis的Lua脚本把多条命令打包成一个整体执行,脚本运行期间,Redis服务器不会处理其他客户端的命令,基于单线程调度模型实现真正的原子性。这不只是“一组命令按顺序执行”,更重要的是脚本内部可以做条件分支和中间计算。

比如这样一段预扣逻辑:读余额,判断“余额减预估消耗是否小于0”,小于0返回失败,大于等于0才扣减。MULTI事务做不到这一点,它只是把命令排队,在EXEC之前不会真正执行,没法根据前一条命令的结果决定后面要不要执行。Lua脚本可以因为它在执行时已经拿到了真实的中间值。

另外,脚本里的命令全部在Redis内存中直接执行,不经过网络往返,性能比“客户端发多条命令”快得多。一个预扣脚本的耗时通常在微秒级别,对Redis单线程的影响可控。这也是我选型时最看重的一点:原子性、条件分支、高性能,三者同时满足。

2.3 为什么不用数据库落库做扣减

有人会问,配额扣减用MySQL加行锁也能做,为什么非要Redis?我的经验是两个字:路径。

配额扣减是网关上的高频写路径。高峰期每秒几千次甚至上万次扣减,MySQL的行锁在并发竞争下会成为瓶颈,还要考虑连接池、磁盘IO、主从同步延迟。更关键的是,配额天然带时间窗口语义——日配额、小时配额、分钟配额,Redis的EXPIRE和TTL直接对应这些场景,数据库要清理过期窗口还得专门写定时任务。

Redis在这里的价值不是把账算得更准,而是把这笔账算得更快、更扛并发。最终的账单准确性靠定时对账兜底,这个思路要明确。

3. 预扣模块落地:数据结构、Lua脚本与请求生命周期

3.1 Redis键设计与配额字段

预扣方案需要一个核心Hash结构,我是这样设计的:

quota:{tenant}:{project}:{apiKey}:{window}

字段说明:

字段含义示例
total当前窗口总配额1000000
used已实际结算消耗320000
pending已预扣但未确认/未释放15000
available当前窗口可用总额1000000

注意这里有个关键点:为什么要有pending,而不是在请求前直接把used扣掉?因为大模型实际消耗的token数不确定。如果在调用前就扣used,预估消耗100但真实消耗80,那20个token就永久损失了。有了pending,就能做到“调用前锁定预估额度,调用后按实际消耗结算”,多退少补。

可用额度的计算公式是:

available = total - used - pending

预扣操作等价于:检查available是否大于等于预估消耗,是则增加pending;确认操作等价于:把pending转为used,同时抵扣实际消耗。

window字段我这里不细讲具体格式,实践上我倾向直接用日期做窗口后缀(如20250607),每天一个key,到期自然过期,不需要额外清数据。分钟级、小时级的窗口可以并行做多个key,各管各的维度。

3.2 预扣、确认、释放三段式Lua脚本

下面给出三个核心脚本,代码是简化版,重点演示机制。

预扣脚本:

-- KEYS[1]: quota:{tenant}:{project}:{apiKey}:{window} -- ARGV[1]: reserve_tokens(预估消耗,含安全系数) local available = tonumber(redis.call('HGET', KEYS[1], 'available') or '0') local used = tonumber(redis.call('HGET', KEYS[1], 'used') or '0') local pending = tonumber(redis.call('HGET', KEYS[1], 'pending') or '0') if available - used - pending < tonumber(ARGV[1]) then return -1 end redis.call('HINCRBY', KEYS[1], 'pending', ARGV[1]) return 1

确认结算脚本:

-- KEYS[1]: 同上 -- ARGV[1]: reserve_tokens(预扣时使用的估算值) -- ARGV[2]: actual_tokens(上游usage里返回的实际消耗) redis.call('HINCRBY', KEYS[1], 'used', tonumber(ARGV[2])) redis.call('HINCRBY', KEYS[1], 'pending', -tonumber(ARGV[1])) return 1

释放脚本:

-- 释放预扣但最终未消耗的配额 -- KEYS[1]: 同上 -- ARGV[1]: reserve_tokens local pending = tonumber(redis.call('HGET', KEYS[1], 'pending') or '0') if pending >= tonumber(ARGV[1]) then redis.call('HINCRBY', KEYS[1], 'pending', -tonumber(ARGV[1])) return 1 end return -1

Python侧调用示例:

import redis r = redis.Redis(host="localhost", port=6379, decode_responses=True) reserve_script = r.register_script(""" -- 预扣Lua脚本 """) confirm_script = r.register_script(""" -- 确认结算Lua脚本 """) release_script = r.register_script(""" -- 释放Lua脚本 """) reserve_script(keys=["quota:tenant1:proj1:key1:20250607"], args=[1500])

调用方必须保证每个请求严格按照“预扣一次,最终确认或释放一次”的节奏。确认和释放是互斥的,二选一,不能两个都做,也不能都不做。否则pending字段会越积越多,最终把整个配额池撑死。

3.3 按token估算预扣,而不是按请求次数

预扣值怎么来,决定了防透支的效果。OpenAI兼容接口里,请求体中包含messages或者prompt,可以用tokenizer粗略估算输入token数。输出token无法预知,但请求参数里通常有max_tokens,如果用户没传,就按模型上下文上限处理。

预扣值建议这样算:

estimated = estimate_input_tokens(messages) + max_tokens reserve_tokens = estimated * 1.25

这个1.25的安全系数很关键。不同模型的tokenizer算法不一样,估算存在误差;再加上某些代理场景下会往上下文里注入系统提示词,实际输入比用户传来的内容更大。系数设太小防不住估算偏差,设太大又会导致配额虚占、整体利用率下降。我给自己的项目定的1.25,跑了一个季度,误差率控制在1%以内,算是个经验值。

实际消耗的获取,依赖上游返回的usage字段。如果你的网关做的是流式转发,不用担心,流式响应结束时依然能拿到usage,可以正常触发确认结算。取usage里的prompt_tokens加completion_tokens,作为actual_tokens传给确认脚本。

3.4 超时释放与补偿任务

预扣成功但请求中途失败,比如上游超时、网络断开、连接池满、API Key失效返回401,这些场景都必须释放pending。释放分两条路径:

同步释放:在请求结束的finally块里,根据本次预扣值调用释放脚本。这条路径覆盖90%的情况。

异步补偿:进程崩溃、机器宕机、客户端连接一直挂着导致finally不执行,pending就会永久占用。必须有一个定时任务扫描“预扣时间超过阈值且仍未确认”的凭证,强制释放。

实现方式我推荐用Redis ZSet存预扣凭证,成员是请求ID,分数是预扣时的Unix时间戳。补偿任务用ZRANGEBYSCORE查出超过阈值的凭证——阈值通常设为“模型最长响应时间+缓冲”,比如10分钟——逐个执行释放脚本并删除凭证。增量扫描的思路对于量大的场景也适用。

4. 多租户治理:配额如何在租户、项目、密钥之间分配

4.1 租户层级模型与配额叠加关系

多租户治理不是给每个API Key配个额度就完了。真实平台上的结构通常是三层:

  • 租户:入驻团队或企业,有自己的组织ID,配额上限最高;
  • 项目:一个租户下多个项目,对应不同业务线,互相隔离;
  • API Key:项目下多个key,用于开发、测试、生产环境隔离,也方便单独吊销。

请求进来时,要同时过三层配额检查才能放行。租户级总配额决定这个租户最多能烧多少资源,项目级配额在租户范围内进一步细分,API Key级配额最细。某一层超了就直接拒绝,避免一个项目把整个租户的月预算烧光。

Redis key设计上,建议直接按层级拆开:

quota:tenant:{tenantId}:{date} quota:tenant:{tenantId}:project:{projectId}:{date} quota:tenant:{tenantId}:project:{projectId}:key:{apiKey}:{date}

多key的好处是排查问题很方便,出故障时直接看某一层的数据就行。代价是key数量多,但对Redis来说不是压力。

4.2 流控策略与优先级:不只是粗暴拒绝

配额防透支解决“能不能用”的问题,流控解决“怎么用更平滑”的问题。两者要配合,不能互相替代。

固定窗口实现简单,但会有突刺,尤其是每分钟刚开始的前几秒可能瞬间打满。滑动窗口用ZSET记录请求时间戳,过滤旧记录后计数,平滑很多,但每次请求多了几个命令的开销。令牌桶负责平滑速率,预扣负责成本封顶,这两个配合效果最好。

不同租户套餐可以这样配置:

租户套餐日配额RPS限流超额策略
免费50万token5 QPS直接拒绝
标准500万token50 QPS排队等待
企业5000万token500 QPS排队+熔断保护

排队等待是比直接拒绝更友好的策略。低优先级租户配额不足时直接拒绝,高优先级租户进入等待队列,等pending释放后再放行。这样在大促或集中调用场景下,核心业务不会被一个测试脚本挤垮。

4.3 成本归属、审计日志和密钥轮换

多租户治理还要做成本归属。每次调用都要记录租户、项目、API Key、模型、输入token、输出token、估算费用,异步写入审计日志,这样月底才能按租户分摊账单。我见过不少团队漏掉这一步,结果超支了都不知道是哪家租户干的。

密钥轮换也不能省。平台上要支持“旧key立即失效、新key无缝切换”,避免多个业务共用一个key导致无法追溯。API Key本身也属于租户资源,在配额治理里要单独建立一层。

配额预警是治理体验的关键。不要等额度烧到0才发通知,在剩余10%、5%的时候就应该触发webhook或邮件,让租户有时间调整。预警阈值可以做成租户可配置的,企业客户通常对成本更敏感。

5. 边界条件与可靠性兜底:Redis故障、时钟漂移和集群限制

5.1 Redis Cluster对Lua脚本的限制:hash tag和动态key

如果Redis是单机模式,上面脚本直接跑没问题。一旦上了集群,必须考虑slot分布问题。集群模式下,Lua脚本里访问的所有key必须落在同一个hash slot,否则运行时报CROSSSLOT错误。

解决方案是给key加hash tag,例如把:

quota:tenant1:proj1:key1:20250607

改成:

quota:{tenant1:proj1:key1}:20250607

花括号里的内容参与哈希计算,这样同一租户下同一个API Key的所有窗口key都会落在同一个slot,脚本可以正常跨多个key操作。同时要提醒一点:脚本内部不能动态拼接生成key名,所有key必须由调用方通过KEYS数组传进来,这是集群模式下Redis的要求。

5.2 主从切换时预扣记录丢失的补偿策略

Redis主从切换总是绕不开的话题。切换期间几秒钟的写入丢失,如果刚好丢了预扣记录,就会出现“实际烧了但配额没扣”的透支。任何分布式系统都很难做到严格强制,配额控制的目标是把透支限制在可接受范围,而不是追求永不透支。

我的兜底思路是两级:

网关本地内存缓存一份“本机成功预扣的凭证”,定期同步到持久化存储(MySQL或ES);对账任务每天跑一次“上游账单消耗 vs Redis配额消耗”的对比,差值超过阈值就拉日志定位。历史上我们出现过两次主从切换导致的少量透支,都是靠对账发现的,误差不超过几千token,影响可控。

5.3 对账:Redis计数和上游账单怎么拉平

对账的口径要提前定义清楚。上游账单给出的是某租户按模型维度统计的实际消耗token和费用,Redis统计给出的是网关视角的消费。两者之间的误差不可避免,来自预扣溢出、异常补偿、计费舍入等。

实际操作中我们这样处理:按“租户+模型+日期”三个维度做每日对账,误差率允许范围定在5%以内。超过5%说明可能存在逻辑bug,比如usage解析错误、某个分支漏了确认或释放。误差率本身可以做成监控指标,发布新版本之前先观察一天对账数据,确认没引入偏差再全量推。

6. 真实踩坑记录:三个配额治理事故的完整排查链路

6.1 事故一:pending长期不释放,配额被“幽灵占用”

现象:某个项目的可用额度越来越小,但实际请求量并没有增长。

排查过程分了三步。先看Redis里的配额Hash,发现used不高,pending却持续增长。再查网关日志,确认请求确实完成、上游返回正常。最后看代码,问题出在释放逻辑只放在了成功分支,异常分支里漏了finally释放。

这个bug很隐蔽,因为看起来请求都正常完成了,但只要有一次上游连接异常,或者客户端半路断开,pending就永久挂着。日积月累就成幽灵占用。修复方案是:

  • 释放逻辑放到泛型finally里,覆盖所有分支;
  • 增加ZSet补偿任务,超时强制释放;
  • 监控pending占available的比例,超过20%告警。

6.2 事故二:分布式压测打热单个配额key

现象:压测期间Redis某个节点CPU打满,整个平台请求变慢,不只是压测租户受影响。

定位过程:先是看到Redis节点监控CPU飙升,然后发现所有流量都指向同一个租户的配额key。Redis是单线程模型,同一个key的命令全在一个节点排着执行,再快也扛不住集中流量。

修复思路做了三件事:一是把配额Key按分钟窗口拆分,让访问分散到不同Key;二是热门租户的配额在本地缓存50ms,批量更新,大幅减少对Redis的读压力;三是把这类热点租户的Key分布到集群不同节点。

6.3 事故三:月底对不上账,最后定位到时钟偏差

现象:某租户的上游账单消耗和Redis统计结果误差超过10%,持续了一个月,每天都有偏差但方向不定。

一开始怀疑usage解析,看了代码没有问题。又怀疑确认结算漏执行,日志核对也没问题。最后排查到固定窗口的时间戳用了网关机器本地时钟,几台服务器时钟漂移不一致,导致部分请求被算到了相邻时间窗口,日维度对账自然对不上。

修复很简单:统一用Redis的TIME命令获取服务器时间,或者在所有网关机器上做NTP同步,同时监控时钟偏移量。这个坑提醒我,所有时间相关逻辑必须集中一个时间源,分布式系统里最容易忽略的就是机器本地时钟。

最后分享几个个人实操心得。预扣系数1.25是个不错的起点,但要根据自己业务的实际误差率调整,误差率长期偏高就先查tokenizer估算逻辑,不要急着调系数。补偿任务的扫描频率别设太短,1分钟一次就够了,避免连续执行ZRANGEBYSCORE占用Redis主线程。最重要的是,超额处理别一刀切拒绝,在资源允许范围内留一个排队等待或者降级到替代模型的“软拒绝”路径,租户侧的体感会好非常多,成本控制和用户体验之间需要做平衡。

返回列表