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:1001 | String 类型,存剩余额度 |
| 预扣流水 | quota:flow:{tenantId} | quota:flow:1001 | Hash 类型,field 为 requestId |
| 租户配置 | quota:config:{tenantId} | quota:config:1001 | Hash,存配额上限、告警阈值等 |
| 日消耗统计 | quota:daily:{tenantId}:{date} | quota:daily:1001:20250115 | String,当日累计消耗 |
用{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(host='localhost', port=6379, decode_responses=True) def init_tenant_quota(tenant_id: int, total_quota: int, expire_days: int = 30): pipe = r.pipeline() quota_key = f"quota:tenant:{tenant_id}" config_key = f"quota:config:{tenant_id}" pipe.set(quota_key, total_quota, ex=expire_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 = f"quota:tenant:{tenant_id}" flow_key = f"quota: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 = f"quota: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(f"quota:tenant:{tenant_id}", diff) elif diff < 0: # 预扣少了,补扣差额(这种情况要告警,说明预估严重偏低) r.decrby(f"quota:tenant:{tenant_id}", -diff) alert(f"tenant {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(f"quota: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(f"quota: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 的 TTL,
TTL quota:tenant:{tenantId},看是不是过期时间设太短 - 检查租户初始化流程是否执行成功
预防措施就是在脚本里先判断if not remain then return -1 end,把 nil 情况显式处理掉。我见过有人图省事不判断,结果线上报错刷屏。
4.4 常见问题速查表
| 问题现象 | 可能原因 | 排查方法 | 解决方案 |
|---|---|---|---|
| 预扣返回 -1 | 配额 key 不存在或过期 | EXISTS+TTL检查 | 重新初始化或延长 TTL |
| 预扣返回 -2 但余额明明够 | 并发扣减导致瞬时不足 | 查看同时段请求量 | 提高配额或加限流 |
| 额度被超扣 | 未用 Lua 或脚本非原子 | 检查是否用了 MULTI/EXEC | 改用 Lua 脚本 |
| 回滚后额度没变 | 流水已被删除或 requestId 不一致 | HGET检查流水 | 统一 requestId 生成规则 |
| 集群报 CROSSSLOT | key 没有相同 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.1ms,Redis 单线程理论 QPS 上限 10000
- 考虑网络和其他命令开销,实际能扛 5000-8000 QPS
所以单实例够用,但为了高可用,建议主从 + 哨兵。如果租户数上到 1 万,就要考虑分片,按 tenantId 哈希到多个 Redis 实例。
提示:分片后,跨租户的统计(比如平台总消耗)需要额外聚合,不能直接在一个 Redis 里算。这是分片的代价,要提前想清楚。
6. 从预扣到全链路配额治理的扩展
预扣只是配额治理的起点。真正成熟的多租户平台,还需要考虑几个扩展方向。
第一是分级配额。不同租户有不同的配额策略,比如免费租户按天重置,付费租户按月重置,企业租户可以自定义周期。这需要在 config 里加reset_policy字段,配合定时任务做重置。
第二是配额借用与共享。同一个组织下的多个子租户,可以共享一个总配额池。这需要在 key 设计上加一层组织维度,预扣时先扣子租户,子租户不够再扣组织池。
第三是实时监控大盘。把每个租户的剩余额度、消耗速率、告警状态实时展示出来,运维才能第一时间发现问题。我一般用 Redis 的INFO和自定义指标上报到监控系统,配合 Grafana 做看板。
第四是防刷与风控。有些租户会恶意刷接口,短时间内消耗大量额度。这需要在预扣之前加一层速率限制,比如单租户每秒最多 100 次预扣。限流可以用 Redis 的滑动窗口实现,和预扣脚本合并成一个更大的原子操作。
这些扩展不是一开始就要全做,而是根据业务发展逐步叠加。我的建议是先把预扣 + 回滚 + 对账这三件套做扎实,这是地基。地基不稳,上面盖再多功能都是空中楼阁。
最后分享一个我在实际项目中总结的小技巧:给预扣脚本加一个"干跑"模式。在 ARGV 里传一个dry_run标志,脚本只检查余额不实际扣减,返回是否足够。这个模式在压测和调试时特别有用,可以在不影响真实配额的情况下验证逻辑。上线前用干跑模式跑一遍全链路,确认没问题再切到真实扣减,能避免很多低级错误。