先讲一个我亲自处理的线上事故。某个面向企业客户的大模型API网关凌晨3点被连续报了十几条告警客户的配额包在5分钟内烧掉了原本够用一个月的额度。查完后端账单上游模型服务商结算金额直接翻了三倍。问题不在模型输出错误也不在请求参数异常而是我们的配额扣减逻辑根本不是原子的先读余额、再判断、再扣减三个步骤之间隔着一整个网络往返的距离并发一高几十个请求同时读到余额充足然后一起扣成功。这就是典型的透支。这篇文章就是来聊透这件事。我会从配额透支的根本原因讲起说明为什么用Redis的单线程模型配合Lua脚本做原子预扣是当前最稳妥的通用解法接着给出一个真正能落地到多租户生产环境的完整方案包括两层账本设计、预扣与回补的Lua脚本、动态调额、限流联动、以及在压测和上线时容易踩的坑。内容适合正在做API网关、模型聚合层、或者任何给别人发额度、又怕别人超用的后端团队参考。1. 为什么简单的计数器会透支并发窗口与共享资源困境1.1 先看图省事的方案读余额再扣减很多团队一开始的配额管理都是这样写的调大模型之前先查一下Redis里的剩余额度如果大于单次请求预估消耗就放行然后扣减不够就直接拒绝。伪代码大概是def check_and_consume(tenant_id, cost): balance redis.get(fquota:{tenant_id}) if int(balance) cost: return False redis.decrby(fquota:{tenant_id}, cost) return True这个逻辑在单线程场景下没有任何问题但在线上高并发下它就是一个最容易引发透支的坑。原因在于读余额和扣减之间不是一个整体操作。假设此刻余额还剩100来了30个请求每个请求消耗20额度。这30个请求在同一时间片中全都执行了redis.get读到的都是100然后全部通过判断再各自执行decrby。结果是额度被扣了600透支500上游账单直接爆炸。1.2 为什么分布式锁没能根治问题我见过不少团队为了解决这个问题先想到加分布式锁。比如用Redisson租一个锁保证检查扣减的临界区只有一个线程能进。这个思路能解决一部分问题但随之而来的是新的麻烦锁的粒度不好定。粒度小了防不住并发粒度大了QPS上不去一个租户的慢请求会拖累所有租户。锁的获取和释放本身有网络开销还会引入锁超时、锁失效、误删他人锁等问题。有些场景下服务节点一多锁服务本身的压力也不小尤其是面对像双11那样瞬间几百上千的突发请求。另外锁方案并没有改变三步操作的本质只是把它包在了锁里面。一旦锁的服务抖动整个链路还是会被拖入混乱。对我来说这个方案只能算中间产物不是终态。1.3 配额透支的真实代价透支的直接表现就是上游服务商返回401 unauthorized或403好一点的返回一个明确的配额耗尽错误。但更难受的是当你发出去100个请求才发现钱已经不够时用户看到的是错乱的结果前30个成功后面70个莫名失败而且失败的间隔又不均匀看起来完全随机。对于一个多租户系统这意味着某个租户的误操作可能挤占整个共享池导致所有租户集体故障。所以配额防透支的本质不是扣得准不准而是判断和扣减能不能作为一个不可分割的原子动作存在。想通了这一点方案就清楚了。2. 原子预扣的载体选型Redis单线程模型与Lua脚本的组合逻辑2.1 为什么选Redis而不是普通的MySQL事务多租户配额系统对存储的第一要求是低延迟和高吞吐。MySQL事务当然可以保证原子性但它的连接开销、行锁竞争和网络时延在热点配额Key上会很吃力。Redis的优势在于单线程串行处理所有命令天然回避了并发竞争问题同时它基于内存操作毫秒级的响应时间是做配额扣减的基础条件。Redis的另一个好处是数据结构丰富。配额管理不但需要简单的整型加减还需要记录使用明细、设置过期时间、做滑动窗口这些用Redis自身的数据类型都能实现。2.2 Lua脚本如何在Redis中保证原子性Redis从很早的版本就支持在服务端执行Lua脚本。调用EVAL时Redis会把整个脚本当作一个单独操作在执行期间不允许其他命令插入也就是说脚本内部的多条Redis命令是在一个不可分割的流程里完成的。这正好补上了读-判-扣三步之间的裂缝。需要注意的一点是Lua脚本在Redis里执行时所有Redis命令都是阻塞式的所以脚本逻辑必须尽量精简。一个配额扣减脚本通常只有十几个操作耗时在微秒到毫秒级完全可控。绝不能把复杂的业务逻辑塞进Lua里比如发HTTP请求、做大批量遍历那会把Redis主线程拖死。2.3 和分布式锁方案的对比复杂度与性能这里给一个简单的对比方便在方案评审时说清选型理由。需要注意这里讨论的是配额扣减这类高频短操作不是所有场景都适合用Lua替代锁。维度分布式锁 读改写Redis Lua原子脚本实现复杂度需要设计锁获取、重试、释放、看门狗只需编写脚本并传递参数原子性依赖锁的可靠性极端情况可能被绕开Redis服务端保证脚本执行期间无并发插入性能损耗额外一次甚至多次网络往返获取锁释放锁单次EVAL调用无额外往返锁粒度控制需要精细设计容易误伤天然按Key粒度串行运维风险锁超时、误删锁、主从切换下有坑脚本逻辑需严谨避免死循环和长耗时更直白地说分布式锁是在上层应用层面强行构造一个临界区而Lua原子脚本是把临界区下沉到了存储引擎层面。能用引擎能力解决的事就不要在上层自己造锁。3. 多租户配额治理的账本模型总账户、子账户与消费明细三层结构3.1 为什么直接从单一Key升级到三层账本单一Key结构做单租户或共享池够了一旦上多租户就会遇到一个问题总不能所有租户挤一个Key吧那样既无法做隔离也无法统计单个租户的消耗授权调整更是无从谈起。所以在实际项目里我习惯把配额账本拆成三层总账户代表整个租户在某一段时间内的总配额比如这个月买了1000万tokens存的是剩余量。子账户代表某个API Key、某个模型或某个应用维度的配额比如销售部门专用接入Key额度200万tokens。消费明细记录每一次扣减的时间、cost、剩余余额、关联请求ID便于对账和审计。这套模型的好处在于总账户负责总的钱袋子子账户负责某个出口的保险丝。某个子账户超了只熔断该出口不会拖垮整个租户。3.2 Redis Key设计与数据结构选择我习惯用冒号分隔做Key层级比如quota:account:{tenant_id} - 总账户剩余额度Hash或String quota:sub:{tenant_id}:{api_key_hash} - 子账户剩余额度String quota:detail:{tenant_id}:{date} - 当天消费明细List或Stream quota:lock:{tenant_id}:{api_key_hash} - 针对特定子账户的细粒度锁可选总账户和子账户的剩余额度用String类型直接存整数值操作方便。消费明细用Stream或者List按天分Key方便后续做时间窗口统计。如果还希望支持配额在某个自然日重置加一个EXPIRE或者用带日期的Key即可。3.3 配额包与突发额度的工程定义线上实际需求里很少有租户是一条额度用完就锁死的这个太死板。更常见的是基础配额包 突发额度池。基础配额包就是正常购买的额度突发额度池是一个共享的备用池在租户基础配额耗尽时允许它借用一部分共享额度避免关键业务中断。实现方式也不复杂只需要在Lua脚本里做子账户不足时去总账户借的逻辑。比如子账户余额不足时脚本会去检查总账户有没有富余有的话一次性从总账户划拨一部分到子账户再继续扣减。这样既保证了业务连续性又不会无限透支。4. 两层账本的原子预扣与回补手写Lua脚本的完整细节4.1 预扣脚本一次性完成检查-扣减-记录-返回下面这段是我在项目里一直沿用的核心预扣脚本做了适度简化但保留了主线。注释我会写得比较清楚方便你直接改造。-- KEYS[1]: 子账户Key -- KEYS[2]: 总账户Key -- KEYS[3]: 消费明细Key -- ARGV[1]: 本次消耗的额度的cost -- ARGV[2]: 允许从总账户临时借用的上限 -- ARGV[3]: 当前时间戳 -- ARGV[4]: 请求ID -- 1. 查询子账户剩余额度 local sub_balance tonumber(redis.call(GET, KEYS[1]) or -1) -- 2. 如果子账户余额大于等于cost直接扣减 if sub_balance tonumber(ARGV[1]) then redis.call(DECRBY, KEYS[1], ARGV[1]) redis.call(XADD, KEYS[3], *, cost, ARGV[1], time, ARGV[3], req_id, ARGV[4], type, sub) return {1, sub_balance - tonumber(ARGV[1])} end -- 3. 子账户不足尝试从总账户借用 local account_balance tonumber(redis.call(GET, KEYS[2]) or 0) -- 最多能从总账户借到裸余量减去保留量 if account_balance tonumber(ARGV[1]) then -- 从总账户扣减 redis.call(DECRBY, KEYS[2], ARGV[1]) -- 给子账户加回来但这个加回来并不真正改变子账户的可调用量而是补足本次扣减 redis.call(INCRBY, KEYS[1], tonumber(ARGV[1]) - tonumber(ARGV[1])) -- 直接扣减子账户为负数表示超支也可以选择给子账户设置为0 redis.call(SET, KEYS[1], 0) redis.call(XADD, KEYS[3], *, cost, ARGV[1], time, ARGV[3], req_id, ARGV[4], type, account) return {2, 0} end -- 4. 两边都不够拒绝扣减 return {0, 0}这里有个细节需要特别说明脚本第三分支中从总账户扣减之后子账户我选择了直接置0而不是继续透支。这么做的原因是子账户余额代表的是当前可用的保险丝如果让子账户变成负数下个请求进来判断时总是走借用分支会加重脚本的复杂度也容易让监控数据失真。让子账户为0下一次请求直接走借用分支或拒绝逻辑更清晰。如果你希望子账户记一笔负数以便对账也可以保留负数但建议在消费明细里额外记录一条type:account否则月底对账时你会困惑为什么子账户有负数而总账户的支出对不上。4.2 回补脚本用于处理上游调用失败时的额度返还调用大模型接口不是每次都会成功。超时、限流、服务端崩溃都可能让请求在扣减配额后实际没有产出结果。这时候如果额度不可逆会把损耗转嫁给用户显然不人性化。所以我专门写了一个回补脚本逻辑很简单把成本从子账户加回来同时消费明细里记录一条回补记录。-- KEYS[1]: 子账户Key -- KEYS[2]: 总账户Key -- KEYS[3]: 消费明细Key -- ARGV[1]: 回补的额度cost -- ARGV[2]: 原始请求ID -- ARGV[3]: 当前时间戳 local sub_balance tonumber(redis.call(GET, KEYS[1]) or 0) redis.call(INCRBY, KEYS[1], ARGV[1]) -- 如果子账户之前已经置0回补后它大于0则视为从总账户借来的部分已偿还 -- 如果子账户本来就是正数则本条回补相当于直接回到原始状态 redis.call(XADD, KEYS[3], *, event, refund, cost, ARGV[1], req_id, ARGV[2], time, ARGV[3]) -- 注意如果之前扣的是总账户的钱这里还需要额外INCRBY总账户 local cost_type redis.call(XRANGE, KEYS[3], -, , COUNT, 1) -- 为了保持博文简洁这里默认大部分情况下回补是回到子账户。 -- 实际生产代码里应该在扣减时记录费用来源回补时根据来源决定加回目标账户。 return {1}这个脚本我做了简化。生产环境里更稳妥的做法是在消费明细里保存type字段标记本次消费是sub还是account回补时先读取类型再决定往哪个账户加回。否则总账户和子账户的比例会失真。4.3 客户端调用与参数传递的注意事项Redis执行Lua脚本时参数传递有一个非常重要但很多人会忽略的规则KEYS数组只放Redis KeyARGV数组放其他参数。而且KEYS数组不能用字符串拼接产生——在集群模式下Redis Cluster是根据KEYS去计算槽位的如果KEYS没有直接作为参数传入会被重定向或报错CROSSSLOT。一个规范的调用示例Python客户端import redis r redis.Redis(hostlocalhost, port6379, decode_responsesTrue) lua_script -- 上面提到的预扣脚本 script_sha r.script_load(lua_script) sub_key quota:sub:tenant_a:key_hash_001 account_key quota:account:tenant_a detail_key quota:detail:20250213 result r.evalsha(script_sha, 3, sub_key, account_key, detail_key, 25, 100, int(time.time()), req_20250213_001)注意这里的3是KEYS的个数。后面依次是三个Key和四个ARGV。如果evalsha提示NOSCRIPT说明脚本在Redis重启后丢了需要先用script_load重新加载或者直接eval传原始脚本。生产环境建议把脚本放在单独的初始化模块里做加载和校验。4.4 原子脚本与基准测试耗时与QPS的直观数据单次预扣脚本总共执行了约4到6条Redis命令在我的测试服务器上普通SSD硬盘、千兆网卡、单实例Redis单次调用耗时一般在0.2到0.5毫秒之间。即使加上客户端网络开销通常也能稳定在1毫秒以内。如果使用Pipeline批量提交整体吞吐可以做到每秒1万次以上。有一个压测时的数据很直观同样的业务场景用读-判-扣三段式方案在1000并发下透支率大约在3%到5%之间换用Lua原子脚本后透支率直接降到0。这个对比足以说明问题。5. 多租户治理的隔离与动态调整如何优雅地发钱和收钱5.1 租户维度隔离硬隔离与软隔离的取舍多租户系统里最怕的就是一家爆炸全平台遭殃。配额层面的隔离我分两种硬隔离每个租户的Key完全独立租户A额度耗尽不影响租户B哪怕B还有富余也不会借给A。适合高等级客户或计费明确的场景。软隔离共享一个全局备用池某个租户额度不足时如果超卖额度池还有余量允许临时借用。适合希望提升整体资源利用率的场景。实际项目里通常是混合使用核心付费客户走硬隔离试用客户走软隔离。这里的关键是在Lua脚本中通过配置区分不同租户的模式。比如在租户元数据里加一个pool_type字段脚本根据这个字段决定是否进入借用总账户的分支。5.2 配额调整的原子操作升配、降配、间夜清零运营经常会提需求给某客户临时加量、月底全部清零、或者从按量付费改成包月模式。这些操作如果直接改Redis数据很容易产生脏读一边在扣减一边在修改可能导致改完余额变成负数或直接跳变。我更推荐的方式配额调整也走Lua脚本。比如升配本质上就是一个INCRBY降配需要保证调整后的余额不低于0清零就是SET 0。每个操作都原子执行不和其他扣减命令穿插就不会出现余额异常。还有一个细节值得注意间夜清零。很多配额是按自然日或自然月重置的。如果你用EXPIRE来实现要小心Key过期后新Key为空的逻辑——新请求进来得判断Key不存在时要不要从配置中心拉初始化值。为了避免这个分支我更习惯在每日凌晨的低峰期用一个定时任务做批量重置脚本对所有有效租户的Key执行SET为初始化值同时归档前一天的消费明细。5.3 多租户下的限流联动与熔断策略配额扣减只是钱够不够的问题但API系统还要面对机器扛不扛得住的问题。所以配额管理和限流必须联动。以一个内部实践为例先查子账户余额余额不足直接拒绝。再查请求速率用Redis的滑动窗口或令牌桶超速也拒绝。通过后扣减配额然后转发到上游大模型服务。如果上游连续返回502或超时系统会进入熔断状态临时不再扣减配额而是直接返回服务暂时不可用避免客户端重试导致配额被反复扣除。这里实际踩过一个坑当时没有熔断上游抖动时客户端疯狂重试一个客户的余额在5分钟内被重试请求烧掉一半。后来加了上游抖动不扣费的降级开关才把这个漏洞堵住。6. 实操中避不开的坑超时、时钟、预热与压测6.1 Redis连接池与超时规划高并发下Redis连接池很容易成为隐形瓶颈。因为Lua脚本是原子执行且执行时间很短但如果你没有合理规划连接池大小所有请求都在等待连接一样会把服务拖死。我的建议是根据业务QPS推算连接池大小一般经验是QPS/1000作为最小连接数再留50%的余量。设置合理的socket_timeout比如200毫秒。如果Redis响应超过这个时间直接失败降级不要无限等待。给配额服务做一个本地降级Redis超时后配额服务可以短暂地切到宽松模式——允许少量超用但不继续下探同时快速告警。这是防止Redis一抖动整个API不可用的有效手段。6.2 时钟跳跃与Key过期时间的坑原子预扣脚本内部没有用TIME这种命令的话一般不会受时钟跳变影响但如果你用TTL实现配额周期重置要特别注意绝对时间戳的更新。我自己遇到过的情况是服务器时钟被NTP调快了几秒导致一些租户的配额Key提前过期出现了明明还有余额却被清零的错误。解决方式是生成的Key带日期后缀过期时间设置为日期切换后凌晨时钟调整影响面就小很多。另外EXPIRE在某些异常情况下可能因为Key不存在而返回0导致你的重置逻辑失效。最稳妥的还是在脚本里先EXISTS再决定是否SET加EXPIRE。6.3 压测与预热别急着上生产第一次上线Lua脚本时我在压测中犯过一个错误没有预热就高并发直连Redis脚本加载和Key初始化同时进行导致初期请求大量报WRONGTYPE和NOSCRIPT。后来才意识到Redis脚本和配额Key需要有一个预热阶段。建议的预热步骤启动时对每个Lua脚本调用SCRIPT LOAD拿到SHA并缓存到本地。对每个租户的Key执行初始化脚本确保Key存在、类型正确、初始值正确。先用低并发比如100 QPS压5分钟观察有没有NOSCRIPT、CROSSSLOT、超时告警。再逐步提高到目标QPS观察Redis的CPU、内存和客户端连接数。6.4 消费明细的量级控制与Redis内存消费明细如果全量存Redis内存会涨得很快。假设一天1亿次调用每条明细200字节那就是20GB显然不合理。所以我的实践是明细数据只保留最近几小时的热数据用于实时对账超过1小时就异步刷入ClickHouse或ES做冷存储。Redis里的明细Key加了TTL比如12小时自动过期。这个方案实施后Redis内存从高峰的8GB降到了1.2GB查询历史明细也更快了。7. 进一步演进配额转移、模型级限量和统一管理台7.1 配额转移与资源包当业务线多的时候租户之间可能存在配额互转的需求。比如总公司给子公司发了一个大资源包子公司内部再按应用拆分。我在现有架构上扩展了两个操作TRANSFER总账户向子账户划拨和REPACK资源包拆分。这两个操作同样是Lua脚本逻辑也很简单检查来源账户余额扣减后再增加目标账户余额。关键在于所有涉及多Key的操作都必须放在同一个脚本里保证原子性否则还会走回老路。7.2 模型级限量再往下钻一层大模型API场景里不同模型的单价不同。GPT-4级别模型的单次消耗可能是普通模型的5到10倍。所以配额系统不单要管总量还要管结构。我在子账户下又加了一层模型限量项每条模型限量单独一个KeyLua脚本里通过模型名路由到对应Key做扣减。当一个请求同时消耗多个模型配额时脚本会按顺序扣减任何一步失败都整体回滚。这个逻辑看起来复杂但其实只是多加了几行判断。7.3 可观测性每一笔扣减都要可解释最后想强调的一点是配额系统本身不难难的是出问题后能不能查得清楚。我所在的团队有一套监控报表实时展示各租户剩余量、每秒消耗速率、透支拒绝次数、回补条数以及上游错误率。数据来源就是Redis里的消费明细和计数指标。灰度测试时还会做影子对账把Lua脚本计算出的剩余量和上游账单做比对如果不一致立刻告警。这套机制上线后我们基本告别了等客户投诉后才发现配额算错了的被动状态。回到开头那个半夜告警的事故现场——如果把核心逻辑换成Lua原子预扣加上多租户账本和回补机制那次事故就不会发生。原子性解决的是绝对不超扣多租户分层解决的是单个租户不能拖垮全局回补解决的是上游失败不坑客户。这套方案看似只是几段脚本但背后是把并发窗口压缩到存储引擎层的设计思路。配额治理没有银弹但先把账本结构理清楚再把每一步操作都做成原子动作就已经避免了绝大多数线上事故。如果你现在正在设计或重构配额系统不妨先从这两点开始动手。