
1. 大模型 API 配额治理的核心痛点与设计思路做多租户大模型平台最怕的不是模型效果不好而是配额被击穿。我见过太多团队在早期用一句GET查余额、再一句DECR扣减的写法平时跑得好好的一到高峰期就出问题同一个租户的并发请求同时读到相同的剩余额度然后一起扣减结果实际消耗远超配额上限。更糟的是如果扣减之后调用模型失败额度已经扣掉了用户投诉、客服介入、人工补额度一整套流程下来运维成本极高。这个项目的核心目标很明确在 Redis 层用 Lua 脚本实现原子预扣配合多租户隔离与对账补偿机制把 API 配额透支的概率压到接近于零。它解决的不是能不能扣减的问题而是高并发下扣减是否准确、失败是否可回滚、多租户之间是否互不干扰这三个工程问题。适合正在做 AI 网关、模型代理平台、SaaS 化大模型服务的后端工程师和架构师参考也适合对 Redis Lua 原子操作感兴趣、想找一个真实落地场景来练手的开发者。先说清楚为什么不用数据库行锁。MySQL 的SELECT ... FOR UPDATE确实能保证一致性但在大模型 API 这种场景下单次请求的配额校验如果走数据库QPS 上到几千就会成为瓶颈。Redis 单机轻松扛住十万级 QPS而且 Lua 脚本在 Redis 内部是单线程原子执行的天然适合做读-判断-写这种复合操作。这就是选型的根本逻辑把并发控制下沉到 Redis把持久化和对账交给数据库。整个方案分三层接入层做租户识别和限流Redis 层做原子预扣和余额缓存数据库层做最终账本和异步对账。三层各司其职任何一层出问题都不会导致配额被无限透支。下面我会把每一层的设计细节、Lua 脚本的写法、多租户 key 的设计、以及实际踩过的坑全部展开讲。2. Redis Lua 原子预扣的核心实现细节2.1 为什么必须是 Lua 而不是 MULTI/EXEC很多人第一反应是用 Redis 事务MULTI/EXEC来做扣减。但事务有个致命问题它不能根据中间结果做条件判断。你需要先GET余额判断够不够再决定要不要DECR这个判断发生在客户端而客户端到 Redis 之间是有网络往返的并发场景下两个请求可能都读到余额充足然后都执行扣减。Lua 脚本不一样它在 Redis 服务端执行整个脚本执行期间 Redis 不会处理其他命令。也就是说读余额 → 判断是否足够 → 扣减 → 返回结果这一串操作是原子的中间不可能被其他请求插入。这是解决超扣问题的关键。注意Lua 脚本虽然原子但不要在里面写耗时逻辑比如循环几万次否则会阻塞整个 Redis 实例。脚本要短小精悍控制在毫秒级完成。2.2 预扣脚本的完整写法与参数设计先看核心脚本。我把它命名为quota_pre_deduct.lua逻辑是检查租户配额 key 是否存在存在则比较剩余额度足够就扣减并返回成功不够就返回失败码。-- KEYS[1]: 租户配额 key例如 quota:tenant:1001 -- KEYS[2]: 预扣流水 key用于记录本次预扣便于回滚 -- ARGV[1]: 本次预扣数量 -- ARGV[2]: 流水唯一 ID请求 ID -- ARGV[3]: 流水过期时间秒 local quotaKey KEYS[1] local flowKey KEYS[2] local amount tonumber(ARGV[1]) local requestId ARGV[2] local ttl tonumber(ARGV[3]) -- 配额 key 不存在说明租户未初始化或已过期 local remain redis.call(GET, quotaKey) if not remain then return -1 end remain tonumber(remain) if remain amount then return -2 end -- 原子扣减 redis.call(DECRBY, quotaKey, amount) -- 记录预扣流水value 存预扣数量用于后续回滚 redis.call(HSET, flowKey, requestId, amount) redis.call(EXPIRE, flowKey, ttl) return remain - amount返回值设计很讲究-1表示配额未初始化-2表示余额不足非负数表示扣减后的剩余额度。调用方根据返回值决定是放行、拒绝还是触发初始化流程。这种用负数表示错误码、非负数表示正常结果的设计在 Lua 脚本里很常见因为 Lua 的return只能返回一个值虽然可以用 table但解析起来麻烦。2.3 预扣流水的回滚脚本预扣之后如果模型调用失败必须把额度还回去。回滚脚本要保证幂等——同一个 requestId 重复回滚不能重复加额度。-- KEYS[1]: 租户配额 key -- KEYS[2]: 预扣流水 key -- ARGV[1]: 请求 ID local quotaKey KEYS[1] local flowKey KEYS[2] local requestId ARGV[1] local amount redis.call(HGET, flowKey, requestId) if not amount then -- 流水不存在说明已回滚或从未预扣直接返回 return 0 end amount tonumber(amount) redis.call(INCRBY, quotaKey, amount) redis.call(HDEL, flowKey, requestId) return amount这里用HGET判断流水是否存在存在才回滚并删除流水。这样即使因为网络重试导致回滚脚本被调用两次第二次也会因为流水已被删除而直接返回 0不会重复加额度。幂等性是回滚脚本的生命线没有它一次网络抖动就可能让租户凭空多出额度。2.4 多租户 key 的设计与隔离策略多租户场景下key 的设计直接决定了隔离性和可维护性。我采用的命名规范是key 类型命名格式示例说明配额余额quota:tenant:{tenantId}quota:tenant:1001String 类型存剩余额度预扣流水quota:flow:{tenantId}quota:flow:1001Hash 类型field 为 requestId租户配置quota:config:{tenantId}quota:config:1001Hash存配额上限、告警阈值等日消耗统计quota:daily:{tenantId}:{date}quota:daily:1001:20250115String当日累计消耗用{tenantId}这种带花括号的写法是为了配合 Redis Cluster 的 hash tag 机制。在集群模式下只有相同 hash tag 的 key 才会被分配到同一个 slot这样预扣脚本里同时操作quota:tenant:1001和quota:flow:1001才不会报 CROSSSLOT 错误。这个坑我在早期项目里踩过当时脚本在单机测试没问题一上集群就报错排查了半天才发现是 key 没加 hash tag。提示如果你的 Redis 是单机或主从模式hash tag 不影响功能但建议还是加上为将来迁移到集群留好余地。3. 多租户治理的完整实操流程3.1 租户配额初始化与预热新租户开通时需要把配额写入 Redis。这一步不能等到第一次请求才做否则第一个请求会拿到-1错误码。我的做法是在租户开通的异步流程里做初始化import redis import json r redis.Redis(hostlocalhost, port6379, decode_responsesTrue) def init_tenant_quota(tenant_id: int, total_quota: int, expire_days: int 30): pipe r.pipeline() quota_key fquota:tenant:{tenant_id} config_key fquota:config:{tenant_id} pipe.set(quota_key, total_quota, exexpire_days * 86400) pipe.hset(config_key, mapping{ total: total_quota, alert_threshold: int(total_quota * 0.2), status: active }) pipe.expire(config_key, expire_days * 86400) pipe.execute() return True这里用 pipeline 把多个命令打包发送减少网络往返。配额 key 设置了过期时间是为了防止僵尸租户的 key 永久占用内存。但要注意过期时间不能太短否则租户正在使用时 key 突然过期会导致预扣返回-1业务报错。我一般设置 30 天配合定时任务在租户活跃时续期。3.2 预扣与模型调用的完整链路一次完整的 API 调用配额处理链路是这样的网关层解析请求提取 tenantId 和本次预估消耗量比如按 token 数估算调用预扣脚本拿到返回值返回值为负直接拒绝返回 429 或自定义错误码返回值为非负放行调用模型模型调用成功记录实际消耗异步更新数据库账本模型调用失败调用回滚脚本把预扣额度还回去第 5 步的实际消耗和预扣的预估消耗往往不一致。比如预估 1000 token实际用了 800 token多扣的 200 需要在异步对账时补回。这就是为什么要有预扣 对账两套机制预扣保证不超支对账保证不冤枉用户。def pre_deduct(tenant_id: int, amount: int, request_id: str) - int: script local remain redis.call(GET, KEYS[1]) if not remain then return -1 end remain tonumber(remain) if remain tonumber(ARGV[1]) then return -2 end redis.call(DECRBY, KEYS[1], ARGV[1]) redis.call(HSET, KEYS[2], ARGV[2], ARGV[1]) redis.call(EXPIRE, KEYS[2], ARGV[3]) return remain - tonumber(ARGV[1]) quota_key fquota:tenant:{tenant_id} flow_key fquota:flow:{tenant_id} result r.eval(script, 2, quota_key, flow_key, amount, request_id, 3600) return int(result)注意eval的第二个参数是 key 的数量这里是 2对应KEYS[1]和KEYS[2]。这个数字写错是新手最常见的错误会导致参数错位脚本行为完全不可预期。3.3 异步对账与额度补偿预扣是多退少补里的多扣对账就是退。我设计了一个对账任务每 5 分钟跑一次扫描预扣流水对比实际消耗def reconcile(tenant_id: int): flow_key fquota:flow:{tenant_id} flows r.hgetall(flow_key) for request_id, pre_amount in flows.items(): actual query_actual_usage(request_id) # 从数据库或日志查实际消耗 if actual is None: continue # 还没结算跳过 diff int(pre_amount) - actual if diff 0: # 预扣多了退还差额 r.incrby(fquota:tenant:{tenant_id}, diff) elif diff 0: # 预扣少了补扣差额这种情况要告警说明预估严重偏低 r.decrby(fquota:tenant:{tenant_id}, -diff) alert(ftenant {tenant_id} request {request_id} under-estimated) r.hdel(flow_key, request_id)对账的难点在于如何知道实际消耗。如果模型调用是同步的调用方可以直接把实际 token 数写回如果是异步的就需要一个结算表来记录。我倾向于在网关层就把实际消耗算出来写入一个usage_log表对账任务读这个表。这样对账逻辑简单不依赖模型服务的回调。注意对账任务要加分布式锁防止多个实例同时跑导致重复退补。锁的 key 用lock:reconcile:{tenantId}过期时间设成任务预期执行时间的 2 倍。3.4 配额告警与自动降级光有扣减还不够租户额度快用完时要提前告警。我在预扣脚本的返回值里已经带了剩余额度网关层可以据此判断def check_and_alert(tenant_id: int, remain: int): config r.hgetall(fquota:config:{tenant_id}) threshold int(config.get(alert_threshold, 0)) if remain threshold and remain 0: send_alert(tenant_id, remain) if remain 0: # 额度耗尽标记租户为受限状态 r.hset(fquota:config:{tenant_id}, status, exhausted)告警阈值一般设成总额度的 20%这样租户有足够时间充值。如果租户额度耗尽网关层直接拒绝请求不再走预扣脚本减少 Redis 压力。这个状态标记 快速拒绝的设计在租户数量多的时候能显著降低 Redis 负载。4. 常见问题与排查技巧实录4.1 预扣成功但模型调用超时额度怎么处理这是最典型的问题。预扣成功了但模型服务超时没返回你不知道到底消耗了多少。我的处理策略是超时视为失败触发回滚。因为超时的情况下模型可能根本没开始处理或者处理了但结果丢了从用户体验角度这次调用是失败的不应该扣费。但这里有个隐患如果模型实际上处理了只是响应丢了回滚就相当于白送了一次。这种情况概率很低而且相比用户被扣了钱却没拿到结果的投诉白送一次的成本更低。所以宁可回滚也不要让用户吃亏。4.2 Redis 主从切换导致预扣丢失Redis 主从异步复制主节点写入后还没同步到从节点就宕机切换后从节点没有这条预扣记录额度就复活了。这个问题在金融级场景很致命但在 API 配额场景影响可控。我的应对方案是双写 对账兜底预扣的同时把流水异步写入数据库。如果 Redis 切换导致额度复活对账任务会发现数据库里有预扣记录但 Redis 里没有从而补扣。虽然有一段时间窗口内额度可能被超用但最终会收敛。如果对一致性要求极高可以考虑用 Redis 的WAIT命令强制等待至少 N 个副本确认。但WAIT会增加延迟需要权衡。4.3 Lua 脚本报错 attempt to compare nil with number这个错误几乎都是因为GET返回了 nil然后直接拿去和数字比较。根本原因是配额 key 不存在或已过期。排查步骤用redis-cli执行EXISTS quota:tenant:{tenantId}确认 key 是否存在检查 key 的 TTLTTL quota:tenant:{tenantId}看是不是过期时间设太短检查租户初始化流程是否执行成功预防措施就是在脚本里先判断if not remain then return -1 end把 nil 情况显式处理掉。我见过有人图省事不判断结果线上报错刷屏。4.4 常见问题速查表问题现象可能原因排查方法解决方案预扣返回 -1配额 key 不存在或过期EXISTSTTL检查重新初始化或延长 TTL预扣返回 -2 但余额明明够并发扣减导致瞬时不足查看同时段请求量提高配额或加限流额度被超扣未用 Lua 或脚本非原子检查是否用了 MULTI/EXEC改用 Lua 脚本回滚后额度没变流水已被删除或 requestId 不一致HGET检查流水统一 requestId 生成规则集群报 CROSSSLOTkey 没有相同 hash tag检查 key 命名加{tenantId}对账重复退补对账任务并发执行检查是否有分布式锁加锁 幂等4.5 实操心得预估消耗量怎么定预扣的 amount 是预估的估得准不准直接影响对账频率。我的经验是按输入 token 数 最大输出 token 数来估。比如输入 500 token模型最大输出 2000 token那就预扣 2500 token 对应的额度。这样估偏高的概率大对账时多是退用户体验好先扣后返用户看到的是最终值。如果按平均值估会出现大量补扣补扣时如果余额不够还得走欠费流程很麻烦。宁可多扣一点再退也不要少扣再补。5. 性能压测与容量规划5.1 压测数据与瓶颈分析我在 4 核 8G 的 Redis 实例上做过压测单脚本执行时间在 0.1ms 左右单实例 QPS 能到 8 万以上。瓶颈不在 Lua 脚本本身而在网络往返和连接数。用连接池 pipeline 批量预扣可以把吞吐再提升 30% 左右。但要注意预扣脚本里的HSET和EXPIRE会增加写放大。如果租户 QPS 很高流水 key 会迅速膨胀。我的做法是给流水 key 设置较短的 TTL比如 1 小时对账任务跑得比 TTL 快就行。如果对账周期是 5 分钟TTL 设 1 小时绰绰有余。5.2 容量估算方法假设平台有 1000 个租户每个租户日均 10 万次调用峰值 QPS 是均值的 5 倍总峰值 QPS 1000 × 10万 / 86400 × 5 ≈ 5787单次预扣约 0.1msRedis 单线程理论 QPS 上限 10000考虑网络和其他命令开销实际能扛 5000-8000 QPS所以单实例够用但为了高可用建议主从 哨兵。如果租户数上到 1 万就要考虑分片按 tenantId 哈希到多个 Redis 实例。提示分片后跨租户的统计比如平台总消耗需要额外聚合不能直接在一个 Redis 里算。这是分片的代价要提前想清楚。6. 从预扣到全链路配额治理的扩展预扣只是配额治理的起点。真正成熟的多租户平台还需要考虑几个扩展方向。第一是分级配额。不同租户有不同的配额策略比如免费租户按天重置付费租户按月重置企业租户可以自定义周期。这需要在 config 里加reset_policy字段配合定时任务做重置。第二是配额借用与共享。同一个组织下的多个子租户可以共享一个总配额池。这需要在 key 设计上加一层组织维度预扣时先扣子租户子租户不够再扣组织池。第三是实时监控大盘。把每个租户的剩余额度、消耗速率、告警状态实时展示出来运维才能第一时间发现问题。我一般用 Redis 的INFO和自定义指标上报到监控系统配合 Grafana 做看板。第四是防刷与风控。有些租户会恶意刷接口短时间内消耗大量额度。这需要在预扣之前加一层速率限制比如单租户每秒最多 100 次预扣。限流可以用 Redis 的滑动窗口实现和预扣脚本合并成一个更大的原子操作。这些扩展不是一开始就要全做而是根据业务发展逐步叠加。我的建议是先把预扣 回滚 对账这三件套做扎实这是地基。地基不稳上面盖再多功能都是空中楼阁。最后分享一个我在实际项目中总结的小技巧给预扣脚本加一个干跑模式。在 ARGV 里传一个dry_run标志脚本只检查余额不实际扣减返回是否足够。这个模式在压测和调试时特别有用可以在不影响真实配额的情况下验证逻辑。上线前用干跑模式跑一遍全链路确认没问题再切到真实扣减能避免很多低级错误。