尧图网站设计 尧图网站设计YAOTU DESIGN
ARTICLE DETAIL

资讯详情

深耕网站设计与一线实操的经验洞察。

大模型API配额管理:Redis Lua原子预扣与多租户治理实战

大模型API配额管理:Redis Lua原子预扣与多租户治理实战 1. 为什么大模型 API 的配额管理不能只靠一张表做过大模型应用接入的人大概都遇到过这种场景某个租户的 API Key 在凌晨被脚本刷爆第二天早上你打开账单发现额度已经透支了几十倍或者多个业务线共用一个上游 Key某条业务线突然放量把其他业务线全部挤到 429。这类问题的根子不在模型本身而在于配额扣减这个动作没有做到原子化。我最早的做法很朴素在业务库里建一张tenant_quota表字段大概是tenant_id、total_quota、used_quota、updated_at。每次请求进来先SELECT查一下剩余额度判断够不够够就UPDATE used_quota used_quota cost。这套逻辑在单机、低并发下跑得挺顺但只要并发一上来问题立刻暴露两个请求同时读到used_quota 990、total_quota 1000都判断还剩 10够用然后各自扣 8最终used_quota变成 1006超了。这就是典型的读-判-写竞态。有人会说加个数据库行锁不就行了SELECT ... FOR UPDATE确实能解决但代价是每次请求都要占一个数据库连接、持有一把行锁大模型 API 的 QPS 一旦上千数据库连接池瞬间被打满锁等待队列排到天上去。更麻烦的是大模型请求的耗时通常在几秒到几十秒如果把这把锁的持有周期拉长到整个请求生命周期那基本等于自杀。所以配额扣减必须满足三个硬性条件原子性、低延迟、与业务请求生命周期解耦。Redis 加 Lua 脚本这套组合恰好能同时满足这三点。Redis 单线程执行命令模型保证了脚本内部的逻辑不会被其他命令打断Lua 脚本在 Redis 里执行时是原子的脚本里读、判断、写这一串操作要么全做完要么全不做。而且整个扣减过程在内存里完成耗时通常在亚毫秒级跟数据库行锁完全不是一个量级。这就是标题里Redis Lua 原子预扣的由来——预扣两个字很关键它意味着我们在真正调用大模型之前就把额度锁住而不是等调用完再补扣。多租户治理则是另一层问题。单租户场景下你只要保证扣减原子就够了但多租户场景下你还得考虑租户之间的隔离、额度的分级、超限后的降级策略、以及配额数据的可观测性。我见过不少团队把配额逻辑写死在业务代码里结果新增一个租户维度就要改一遍代码维护成本极高。合理的做法是把配额治理抽象成一层独立的服务或中间件业务侧只负责申请额度和上报消耗其余全部下沉。这篇文章我会把这套方案从设计到落地完整拆一遍包括 Lua 脚本怎么写、Key 怎么设计、预扣和实扣怎么衔接、超时未回调怎么补偿、多租户维度怎么扩展。适合正在做大模型应用接入、API 网关、或者任何需要做资源配额治理的开发者参考。哪怕你现在只用单租户这套思路也能帮你把并发超扣的坑提前填上。2. 整体方案设计与核心思路拆解2.1 从先调用后扣费到先预扣后结算传统配额扣减有两种模式我分别说说它们的坑。第一种是先调用后扣费请求进来先放行去调大模型等拿到响应、算出 token 消耗再回写配额。这种模式的问题是如果并发请求同时涌入每个请求在调用前都不知道自己能不能抢到额度结果就是大家一起放行最后集体超支。更糟的是如果调用过程中服务崩了这笔消耗可能永远补不上账就对不平了。第二种是先扣费后调用请求进来先扣一笔固定额度调用完再按实际消耗多退少补。这种模式解决了并发超扣但引入了新问题——如果实际消耗远小于预扣值你得做退款如果实际消耗超过预扣值你得做补扣而补扣时可能额度已经不够了。我最终采用的是预扣 结算的两阶段模式本质上是第二种的改良版。具体来说预扣阶段请求进来根据请求的预估消耗比如按 max_tokens 或历史均值先原子扣减一笔额度。扣减成功才放行调用扣减失败直接返回 429。结算阶段调用完成后拿到实际 token 消耗与预扣值做差额调整。实际消耗小于预扣就退还差额大于预扣就补扣差额补扣允许透支到负数作为欠账记录。这个设计的核心考量是预扣值宁可估大不可估小。估大了最多是暂时占用额度结算时会退还估小了则可能在结算时补扣失败导致账目不准。我一般会把预扣值设为max_tokens的 1.2 倍留出 20% 的缓冲。2.2 为什么是 Redis Lua而不是数据库或分布式锁有人可能会问用 Redis 的DECRBY命令不就能原子扣减了吗为什么还要 LuaDECRBY确实是原子的但它只能做无条件扣减没法做判断余额是否充足再扣减。如果你先GET再DECRBY这两步之间就有竞态窗口。而 Lua 脚本可以把GET、比较、DECRBY三步打包成一个原子操作这是DECRBY单独做不到的。那用 Redis 分布式锁行不行可以但没必要。分布式锁的本质是把并发串行化而配额扣减本身只需要原子性不需要互斥性。用锁的话每个请求都要经历加锁、操作、解锁三步网络往返至少三次而 Lua 脚本一次往返就搞定。在高并发场景下这个差距会被放大到几十倍。而且分布式锁还有锁超时、锁误删、锁续期这些经典坑能不用就不用。至于数据库前面已经说过了行锁的持有成本和连接占用在高并发下不可接受。Redis 的内存操作天然适合这种高频、短平快的原子计算。2.3 多租户维度的 Key 设计多租户治理的第一件事是Key 的命名规范。我踩过的坑是早期 Key 设计太随意比如quota:123后来要加租户维度、加模型维度、加时间窗口维度整个 Key 体系就乱了。我现在用的 Key 规范是这样的quota:{tenant_id}:{resource_type}:{resource_id}:{window}举几个实际例子quota:t_1001:api:gpt-4:202501—— 租户 t_1001 在 2025 年 1 月对 gpt-4 的月度配额quota:t_1001:token:total:day:20250115—— 租户 t_1001 在 2025-01-15 这一天的 token 总消耗quota:t_1001:rpm:global:202501151030—— 租户 t_1001 在 10:30 这一分钟的请求数RPM 限流这种分层设计的好处是你可以按任意维度组合查询和扣减而且 Key 本身携带了语义信息排查问题时一眼就能看懂。窗口维度我用的是时间片思路比如月度配额用202501日配额用20250115分钟级限流用202501151030。这样每个时间片是独立的 Key天然支持滑动窗口和自动过期。注意Key 里不要放用户可控的原始字符串比如租户名。要用租户 ID 这种规范化标识否则容易出现 Key 注入或者 Key 过长的问题。Redis 的 Key 虽然理论上可以很长但超过 512 字节后性能会明显下降。2.4 预扣与结算的数据结构选型配额数据我用的是 Redis Hash而不是简单的 String。原因是 Hash 可以同时存多个字段比如HSET quota:t_1001:api:gpt-4:202501 total 1000000 used 234567 reserved 12000total总额度used已结算消耗reserved预扣中但未结算的额度这样设计的好处是可用额度 total - used - reserved预扣时增加reserved结算时减少reserved并增加used。两个字段分开账目清晰排查问题时能一眼看出有多少额度卡在预扣中状态。如果只用单个used字段预扣和结算都改它那你就分不清哪些是已确认消耗、哪些是临时占用。一旦出现结算失败你根本不知道这笔额度该不该退。Hash 的另一个好处是可以用HINCRBY做原子增减配合 Lua 脚本里的HGET判断逻辑非常顺。而且 Hash 支持HGETALL一次性拉取所有字段做配额看板时很方便。3. 核心细节解析与实操要点3.1 Lua 脚本的原子预扣实现先上核心脚本。这是预扣阶段的 Lua 脚本我把它命名为reserve_quota.lua-- KEYS[1]: 配额 Hash 的 Key -- ARGV[1]: 本次预扣额度 -- ARGV[2]: 预扣记录 ID用于后续结算和补偿 -- 返回: 1 表示预扣成功0 表示额度不足 local quota_key KEYS[1] local reserve_amount tonumber(ARGV[1]) local reserve_id ARGV[2] -- 读取当前配额状态 local total tonumber(redis.call(HGET, quota_key, total) or 0) local used tonumber(redis.call(HGET, quota_key, used) or 0) local reserved tonumber(redis.call(HGET, quota_key, reserved) or 0) -- 计算可用额度 local available total - used - reserved -- 额度不足直接返回失败 if available reserve_amount then return 0 end -- 原子增加预扣额度 redis.call(HINCRBY, quota_key, reserved, reserve_amount) -- 记录预扣明细用于后续结算和超时补偿 local detail_key quota_reserve: .. reserve_id redis.call(HSET, detail_key, quota_key, quota_key, amount, reserve_amount, status, reserved, ts, redis.call(TIME)[1]) redis.call(EXPIRE, detail_key, 3600) return 1这个脚本有几个细节值得展开说。第一tonumber(redis.call(HGET, ...) or 0)这个写法是为了处理 Key 不存在的情况。如果配额 Key 还没初始化HGET返回 nilLua 里 nil 不能直接参与算术运算所以要or 0兜底。但这里有个隐患如果 Key 不存在total会是 0available就是负数预扣必然失败。所以配额 Key 必须在租户开通时就初始化好不能等到第一次请求才懒加载。第二预扣明细我用了一个独立的 Keyquota_reserve:{reserve_id}存了配额 Key、预扣金额、状态和时间戳。这个明细的作用是支持超时补偿。如果请求调用大模型后一直没回调结算比如服务崩了这个明细会在 1 小时后过期但过期前我们可以通过定时任务扫描把超时未结算的预扣额度退回去。EXPIRE 3600是给明细设的兜底过期时间防止明细无限堆积。第三脚本返回 1 或 0而不是返回具体余额。这是为了减少网络传输业务侧只需要知道成功还是失败。如果确实需要余额信息可以再单独查一次或者让脚本返回余额但那样会增加脚本复杂度。3.2 结算脚本与差额处理预扣成功后请求去调大模型拿到实际消耗后进入结算阶段。结算脚本settle_quota.lua长这样-- KEYS[1]: 配额 Hash 的 Key -- KEYS[2]: 预扣明细 Key -- ARGV[1]: 实际消耗额度 -- 返回: 1 表示结算成功-1 表示明细不存在或已结算 local quota_key KEYS[1] local detail_key KEYS[2] local actual_amount tonumber(ARGV[1]) -- 检查预扣明细是否存在且状态为 reserved local status redis.call(HGET, detail_key, status) if status ~ reserved then return -1 end local reserve_amount tonumber(redis.call(HGET, detail_key, amount)) -- 减少预扣额度 redis.call(HINCRBY, quota_key, reserved, -reserve_amount) -- 增加实际消耗 redis.call(HINCRBY, quota_key, used, actual_amount) -- 标记明细为已结算 redis.call(HSET, detail_key, status, settled, actual, actual_amount) redis.call(EXPIRE, detail_key, 300) return 1这里的关键逻辑是先减 reserved再加 used。为什么不是直接调整差额因为预扣值和实际值可能差很多直接算差额容易出错。分开操作更清晰reserved 减掉预扣的全部金额used 加上实际的全部金额账目一目了然。如果实际消耗大于预扣值怎么办比如预扣了 100实际用了 150。这时候used会增加 150而reserved只减了 100相当于多消耗了 50。这 50 会体现在available的减少上如果额度本来就不够used可能超过total变成负数余额。这是允许的因为结算阶段的补扣不应该被拒绝——请求已经发生了消耗是既成事实拒绝补扣只会让账目更乱。负数余额会在下一次预扣时体现出来导致预扣失败从而阻止后续请求。提示如果你的业务不允许透支可以在结算脚本里加一个判断如果used actual_amount total就把超出部分记到一个欠账字段里而不是直接加到 used。但这样会让逻辑复杂很多我一般不建议除非有强合规要求。3.3 预扣值的估算策略预扣值估多少直接影响到并发能力和用户体验。估得太大额度被无效占用其他请求容易被误拒估得太小结算时补扣频繁账目波动大。我试过三种估算策略各有适用场景策略计算方式适用场景优缺点固定值按模型最大上下文估算请求长度差异小的场景简单但浪费严重历史均值该租户近 N 次请求的平均消耗请求模式稳定的场景较准但冷启动差动态预估按输入 token 数 × 系数输入长度可预知的场景最准但需要先算 token我最终采用的是动态预估 兜底的组合如果请求里带了max_tokens就按max_tokens × 1.2预扣如果没带就按该租户历史 P95 消耗预扣如果连历史都没有新租户就按模型的最大上下文预扣。这样既保证了准确性又不会因为冷启动而误拒。这里有个实操心得预扣值不要超过总额度的 10%。如果一个请求的预扣值就占了总额度的 10% 以上说明这个租户的额度设置得太小或者请求本身太大应该引导租户升级套餐而不是硬扛。我一般会在预扣脚本里加一个上限判断超过 10% 直接拒绝并返回明确的错误码让业务侧知道是额度配置问题而不是并发问题。3.4 多租户隔离与优先级多租户场景下光有配额扣减还不够还得考虑租户之间的隔离。我见过最坑的情况是一个免费租户疯狂刷请求把 Redis 的 CPU 打满导致付费租户的预扣脚本执行变慢体验直线下降。隔离我做了三层第一层是 Key 隔离。每个租户的配额 Key 完全独立互不影响。这层是基础Redis 的 Key 空间天然支持。第二层是限流隔离。除了配额每个租户还有 RPM每分钟请求数和 TPM每分钟 token 数限制。这两个限制用独立的 Key 做滑动窗口计数超限直接拒绝不进入预扣流程。这样即使某个租户配额充足也不能无限刷请求。第三层是优先级队列。付费租户和免费租户走不同的处理通道。具体实现上我在网关层给请求打了优先级标签高优先级请求的预扣脚本走独立的 Redis 连接池避免被低优先级请求挤占资源。这层隔离成本较高一般只有租户量级上千才需要。限流的滑动窗口我用的是 Redis 的 ZSET脚本大概是这样-- KEYS[1]: 限流 Key -- ARGV[1]: 当前时间戳毫秒 -- ARGV[2]: 窗口大小毫秒 -- ARGV[3]: 限制数量 -- 返回: 1 表示放行0 表示限流 local key KEYS[1] local now tonumber(ARGV[1]) local window tonumber(ARGV[2]) local limit tonumber(ARGV[3]) -- 移除窗口外的记录 redis.call(ZREMRANGEBYSCORE, key, 0, now - window) -- 统计窗口内请求数 local count redis.call(ZCARD, key) if count limit then return 0 end -- 记录本次请求 redis.call(ZADD, key, now, now .. : .. math.random(1000000)) redis.call(PEXPIRE, key, window) return 1ZSET 的 score 用时间戳member 用时间戳加随机数避免冲突。这个脚本的原子性同样由 Lua 保证不会出现统计完还没写入就被其他请求插队的问题。4. 实操过程与核心环节实现4.1 环境准备与 Redis 配置要点Redis 的安装本身没什么好说的apt install redis-server或者 Docker 起一个都行。但生产环境有几个配置必须调否则配额扣减的稳定性会打折扣。首先是持久化策略。配额数据是钱丢了就是事故。我建议开启 AOF并且用appendfsync everysec。这个配置下最多丢 1 秒的数据对配额场景可以接受。如果追求极致可以用appendfsync always但性能会下降一个数量级不推荐。appendonly yes appendfsync everysec其次是内存淘汰策略。配额 Key 绝对不能被杀掉所以maxmemory-policy不能设成allkeys-lru这种会随机淘汰的。我一般设成noeviction内存满了就报错让运维介入而不是悄悄丢掉配额数据。如果 Redis 实例是混用的既存配额又存缓存那配额 Key 要单独放一个实例或者用volatile-lru只淘汰带过期时间的缓存 Key。maxmemory-policy noeviction第三是Lua 脚本的缓存。Redis 执行EVAL时会把脚本缓存起来但每次传脚本内容会浪费带宽。生产环境应该用SCRIPT LOAD先加载脚本拿到 SHA1之后用EVALSHA调用。如果EVALSHA返回NOSCRIPT错误再回退到EVAL重新加载。这个逻辑在客户端 SDK 里一般都有封装但自己实现的话要注意。第四是连接池配置。预扣脚本是高频调用连接池太小会成为瓶颈。我一般按QPS × 平均耗时 × 2来估算连接数。比如 QPS 1000、平均耗时 1ms那连接数至少 2但实际要考虑网络抖动我一般设 20 到 50。同时要开启连接的健康检查避免拿到死连接。4.2 预扣流程的完整代码实现我用 Python 写一个完整的预扣 结算流程方便你直接抄作业。先看预扣部分import redis import uuid import time class QuotaManager: def __init__(self, redis_client): self.redis redis_client self.reserve_script self.redis.register_script( local quota_key KEYS[1] local reserve_amount tonumber(ARGV[1]) local reserve_id ARGV[2] local total tonumber(redis.call(HGET, quota_key, total) or 0) local used tonumber(redis.call(HGET, quota_key, used) or 0) local reserved tonumber(redis.call(HGET, quota_key, reserved) or 0) local available total - used - reserved if available reserve_amount then return 0 end redis.call(HINCRBY, quota_key, reserved, reserve_amount) local detail_key quota_reserve: .. reserve_id redis.call(HSET, detail_key, quota_key, quota_key, amount, reserve_amount, status, reserved, ts, redis.call(TIME)[1]) redis.call(EXPIRE, detail_key, 3600) return 1 ) def reserve(self, tenant_id, resource_type, resource_id, window, amount): quota_key fquota:{tenant_id}:{resource_type}:{resource_id}:{window} reserve_id str(uuid.uuid4()) result self.reserve_script( keys[quota_key], args[amount, reserve_id] ) if result 1: return reserve_id return Noneregister_script是 redis-py 提供的封装它会自动处理SCRIPT LOAD和EVALSHA的回退逻辑用起来很省心。如果你用的是其他语言的客户端找对应的脚本注册方法就行。结算部分def settle(self, reserve_id, actual_amount): detail_key fquota_reserve:{reserve_id} detail self.redis.hgetall(detail_key) if not detail or detail.get(bstatus) ! breserved: return False quota_key detail[bquota_key].decode() reserve_amount int(detail[bamount]) pipe self.redis.pipeline() pipe.hincrby(quota_key, reserved, -reserve_amount) pipe.hincrby(quota_key, used, actual_amount) pipe.hset(detail_key, status, settled) pipe.hset(detail_key, actual, actual_amount) pipe.expire(detail_key, 300) pipe.execute() return True这里我用 pipeline 把多个操作打包减少网络往返。但要注意pipeline 不是原子的如果中间某条命令失败前面的已经执行了。不过在这个场景下即使部分失败也不会造成账目错误——因为reserved和used的调整是独立的最坏情况是reserved减了但used没加导致额度虚高。为了绝对安全结算也应该用 Lua 脚本把整个逻辑包成原子操作。我上面为了代码可读性用了 pipeline生产环境建议改成 Lua。4.3 超时未结算的补偿机制预扣之后如果请求方崩了、网络断了、或者大模型调用超时了这笔预扣就会一直挂在reserved里导致额度被无效占用。我见过最严重的情况是一个租户的额度被卡了 80% 在reserved状态实际可用额度只剩 20%业务直接不可用。补偿机制的核心是定时扫描超时明细。我一般起一个独立的定时任务每分钟扫一次quota_reserve:*的 Key找出status reserved且ts超过阈值比如 5 分钟的记录把它们回滚。回滚脚本rollback_quota.lua-- KEYS[1]: 预扣明细 Key -- 返回: 1 表示回滚成功-1 表示无需回滚 local detail_key KEYS[1] local status redis.call(HGET, detail_key, status) if status ~ reserved then return -1 end local quota_key redis.call(HGET, detail_key, quota_key) local amount tonumber(redis.call(HGET, detail_key, amount)) redis.call(HINCRBY, quota_key, reserved, -amount) redis.call(HSET, detail_key, status, rolled_back) redis.call(EXPIRE, detail_key, 300) return 1扫描明细 Key 用SCAN命令不要用KEYS。KEYS会阻塞 Redis在 Key 数量多的时候是灾难。SCAN是游标式的每次返回一批对 Redis 压力小。def scan_timeout_reserves(self, timeout_seconds300): cursor 0 now int(time.time()) timeout_ids [] while True: cursor, keys self.redis.scan( cursorcursor, matchquota_reserve:*, count100 ) for key in keys: detail self.redis.hgetall(key) if detail.get(bstatus) breserved: ts int(detail.get(bts, 0)) if now - ts timeout_seconds: timeout_ids.append(key.decode().split(:, 1)[1]) if cursor 0: break return timeout_ids注意SCAN在 Redis 集群模式下要每个节点单独扫不能跨节点。如果你的配额数据分片在多个节点补偿任务要遍历所有节点。另外SCAN的count参数只是提示实际返回数量可能多也可能少不要依赖它做精确控制。4.4 配额初始化与租户开通流程前面提到配额 Key 必须在租户开通时初始化不能懒加载。初始化脚本很简单def init_quota(self, tenant_id, resource_type, resource_id, window, total): quota_key fquota:{tenant_id}:{resource_type}:{resource_id}:{window} # 用 SETNX 保证幂等避免重复初始化覆盖已有数据 if self.redis.exists(quota_key): return False pipe self.redis.pipeline() pipe.hset(quota_key, mapping{ total: total, used: 0, reserved: 0 }) # 设置过期时间比如月度配额设为 35 天 pipe.expire(quota_key, 35 * 24 * 3600) pipe.execute() return True这里有两个细节。第一用EXISTS判断而不是直接HSET是为了幂等。如果租户重复开通不会把已用的额度清零。第二过期时间要略大于窗口周期。月度配额设 35 天是为了让跨月的请求还能查到上个月的余额做对账。如果设成 30 天月底最后一天的请求可能在结算时发现 Key 已经过期了。租户开通流程我一般做成一个独立的接口参数包括租户 ID、资源类型、资源 ID、窗口、总额度。这个接口由运营后台调用不对外暴露。开通后配额数据会同步到 Redis 和数据库各一份Redis 用于实时扣减数据库用于对账和报表。5. 常见问题与排查技巧实录5.1 预扣成功但结算失败额度对不上这是最常见的问题。表现是租户反馈我没用那么多但额度显示用完了。排查思路分三步第一步查reserved字段。如果reserved很大说明有大量预扣没结算。用HGET quota:{tenant}:... reserved看一下再扫quota_reserve:*找status reserved的明细看它们的ts是不是很久以前。如果是就是补偿任务没跑或者跑失败了。第二步查结算日志。结算失败通常是三种原因明细 Key 过期了EXPIRE 3600太短、明细状态不是reserved被重复结算或已回滚、Redis 连接超时。我一般会在结算失败时打详细日志包括reserve_id、quota_key、actual_amount方便回溯。第三步对账。如果 Redis 和数据库对不上以数据库为准。数据库里应该有每次请求的消耗记录把这些记录按租户聚合和 Redis 的used对比。差异部分就是需要人工修正的。我踩过的一个坑是结算脚本里用了HGET判断状态但没考虑并发。两个线程同时结算同一个reserve_id都读到reserved都执行了扣减导致reserved被减了两次。后来我把结算也改成 Lua 脚本用HGETHSET的原子性保证只结算一次。5.2 高并发下预扣脚本执行超时Redis 是单线程的Lua 脚本执行时间过长会阻塞其他命令。我遇到过预扣脚本执行超过 100ms 的情况原因是脚本里做了HGETALL拉取整个 Hash而 Hash 字段很多历史遗留问题有人往配额 Hash 里塞了明细数据。排查方法是用SLOWLOG GET看慢查询找到执行时间长的脚本。优化方向有三个精简脚本逻辑只读必要的字段不要HGETALL。用HMGET精确读取。拆分大 Key如果单个 Hash 字段超过 100 个考虑拆成多个 Key。控制脚本复杂度Lua 脚本里不要做循环、不要做复杂计算。我见过有人在脚本里做 JSON 解析那纯粹是给自己找麻烦。提示Redis 有个lua-time-limit配置默认 5 秒。脚本超过这个时间会开始报错但不会自动终止。生产环境建议设成 100ms 到 500ms超过就告警。但要注意这个配置只是让 Redis 开始响应其他命令的SCRIPT KILL并不会真的杀掉脚本所以根本的解决办法还是让脚本足够简单。5.3 多租户场景下的 Key 冲突Key 冲突一般发生在租户 ID 或资源 ID 里包含了特殊字符比如冒号。如果租户 ID 是t:1001那 Key 就变成quota:t:1001:api:...解析的时候会错位。解决办法是在拼接 Key 之前对每个部分做规范化把冒号替换成下划线或者用 URL 编码。我一般用正则把非字母数字的字符都替换掉import re def normalize_key_part(part): return re.sub(r[^a-zA-Z0-9_-], _, str(part))另一个冲突场景是不同环境的 Key 混在一起。开发、测试、生产的 Redis 如果共用一个实例Key 就会互相覆盖。解决办法是在 Key 前缀里加环境标识比如quota:prod:t_1001:...。或者更彻底一点不同环境用不同的 Redis 实例。5.4 常见问题速查表问题现象可能原因排查方法解决方案额度显示用完但实际没用多少reserved 堆积HGET查 reserved 字段跑补偿任务回滚超时预扣预扣成功但结算报错明细 Key 过期查quota_reserve:*是否存在延长明细过期时间到 2 小时并发下额度超扣脚本非原子检查是否用了 pipeline 而非 Lua把结算逻辑改成 Lua 脚本脚本执行慢脚本逻辑复杂SLOWLOG GET看慢查询精简脚本拆分大 KeyKey 冲突租户 ID 含特殊字符检查 Key 命名规范化 Key 各部分补偿任务漏扫SCAN 游标未遍历完检查 SCAN 循环逻辑确保 cursor 归零才退出额度初始化被覆盖重复开通查初始化日志用 SETNX 或 EXISTS 保证幂等5.5 几个我踩过的坑和实操心得坑一预扣明细的过期时间设太短。我一开始设了 10 分钟结果有些大模型请求耗时超过 10 分钟比如长文本生成结算时明细已经过期导致结算失败。后来改成 2 小时并且加了补偿任务兜底。坑二补偿任务和正常结算并发。补偿任务扫描到一笔超时预扣准备回滚同时请求方终于回调了准备结算。两个操作并发可能导致reserved被减两次。解决办法是在回滚和结算脚本里都检查status只有reserved状态才处理处理完立刻改成settled或rolled_back。Lua 的原子性保证了检查和修改之间不会被打断。坑三Redis 主从切换导致数据丢失。如果 Redis 是主从架构主节点挂了切换到从节点从节点可能还没同步到最新的配额数据导致额度复活。解决办法是开启WAIT命令在预扣后等待至少一个从节点确认。但这会增加延迟我一般只在金融级场景用。普通场景下接受极小概率的数据丢失靠数据库对账兜底。坑四租户额度调整后没同步 Redis。运营后台给租户加了额度只改了数据库没改 Redis导致租户还是用不了。解决办法是额度调整接口里同时更新 Redis 和数据库并且加一个定时对账任务每小时比对一次发现不一致就告警。坑五Lua 脚本里的随机数。我在限流脚本里用了math.random但 Redis 的 Lua 环境里math.random每次执行的结果可能一样取决于 Redis 版本和初始化方式。后来改成用redis.call(TIME)拿时间戳加请求 ID 做唯一标识避免了这个坑。这套方案我在几个项目里跑过单实例 Redis 支撑 5000 QPS 的预扣没问题延迟稳定在 1ms 以内。多租户场景下只要 Key 设计合理、补偿机制到位基本不会出现额度对不上的情况。真正麻烦的是边界情况——服务崩溃、网络分区、Redis 故障切换这些场景下没有银弹只能靠对账和人工兜底。所以我的建议是配额系统一定要有对账机制Redis 负责实时扣减数据库负责最终一致两者定期比对差异部分自动或人工修正。这样即使中间环节出问题也不会造成资损。
返回列表