1. 从一次配额超卖事故说起去年冬天我们内部的大模型网关上线第三周财务同事在群里甩了一张账单截图某个只有 5 个人的测试租户一天之内消耗了 1200 万 token账单金额直接顶到了当月预算的三分之一。运维第一反应是是不是被刷了查日志发现请求来源正常就是那个租户的定时任务写错了循环条件把同一个 prompt 反复提交了几万次。问题不在于用户写错代码而在于我们的配额系统根本没拦住。当时的实现是先查 Redis 剩余额度再调用模型最后扣减三步之间没有任何原子性保证。并发一上来几十个请求同时读到还剩 100 万 token然后各自放行最后一起扣减额度瞬间被击穿。这就是典型的配额超卖——和电商秒杀的超卖本质上是同一个问题只不过商品换成了 token。这篇文章要聊的就是怎么用Redis Lua 脚本做原子预扣把大模型 API 的配额管住同时兼顾多租户场景下的隔离、公平和可观测性。适合正在做 AI 网关、模型代理层、SaaS 平台计费的工程师也适合任何需要先占额度再消费的限流场景。读完你应该能直接把这套方案落到自己的系统里而不是停留在用 Redis 做个计数器的层面。2. 为什么查-判-扣三步走一定会出事2.1 并发下的经典竞态读到的额度是幻觉先把这个坑讲透因为很多人第一次写配额逻辑都会这么干。伪代码大概长这样remain redis.get(fquota:{tenant_id}) if remain cost: redis.decrby(fquota:{tenant_id}, cost) call_llm_api() else: raise QuotaExceeded()单线程跑没问题一上并发就崩。假设租户剩余 100两个请求各需要 80几乎同时执行get都读到 100都判断通过都执行decrby最终余额变成 -60。额度被透支了而且是在逻辑正确的代码下发生的。有人会说那我加个分布式锁不就行了。可以但锁的粒度、超时、续期又是一堆新问题而且在高 QPS 下锁竞争会严重拖慢吞吐。配额扣减这种操作本质是一个读-改-写的复合动作最合适的解法是把它变成一次原子操作而不是用锁去串行化。2.2 为什么不用 Redis 的 DECRBY 直接扣那有人会问DECRBY本身就是原子的为什么不能直接用因为配额扣减不是无条件扣它有一个前置判断余额够才扣不够要拒绝。DECRBY会无条件把值减下去减成负数你也不知道该不该放行。你需要的是判断 扣减这两步的原子性而 Redis 单条命令做不到所以必须上 Lua。Redis 执行 Lua 脚本时是单线程原子执行的脚本内部的所有命令不会被其他请求打断。这就等于把读余额、比大小、扣减、写回打包成一个不可分割的动作天然解决了竞态。这是整个方案的地基理解这一点后面的设计就顺了。2.3 预扣和实扣必须分开token 是预估的还有一个容易被忽略的点大模型调用的 token 消耗在请求发出前是预估的不是精确值。你只能根据 prompt 长度估算输入 token输出 token 更是完全不可控。所以配额系统必须支持两阶段预扣reserve请求进来时按预估上限先扣一笔防止并发透支。实扣commit模型返回后拿到真实用量把预扣和实扣的差额补回来或退回去。如果只做一次性扣减要么预扣太多导致用户额度被虚占要么预扣太少导致透支。两阶段是绕不开的。下面这张表把两种模式的差异列清楚维度单阶段扣减两阶段预扣实扣并发安全依赖锁易超卖Lua 原子保证额度准确性事后才知道可能透支预扣封顶实扣校准用户体验额度可能被虚占或透支差额及时返还实现复杂度低中需处理超时释放适用场景低并发、弱一致高并发、强一致计费3. Redis Lua 原子预扣脚本的完整设计3.1 数据结构选型为什么用 Hash 而不是 String配额数据我建议用Hash存而不是简单的 String 计数器。原因是一个租户的配额信息不止一个数字总额度、已用额度、预扣中的额度、最后更新时间、版本号。用 Hash 可以一次性读写多个字段而且HINCRBY本身也是原子的。结构大概长这样key: quota:{tenant_id}:{period} fields: total 总额度 used 已确认消耗 reserved 预扣中未结算 updated_at 最后更新时间其中period是配额周期比如2024-06表示按月重置。把周期放进 key 里天然实现了跨月自动隔离不用写定时任务去清零旧 key 设个过期时间自己就没了。这个技巧在多租户系统里特别省事。3.2 预扣脚本判断、扣减、记录流水一步到位核心的预扣 Lua 脚本如下我加了详细注释你可以直接拿去改-- KEYS[1] quota hash key -- KEYS[2] 预扣流水 key (用于后续结算) -- ARGV[1] 本次预扣量 -- ARGV[2] 请求唯一ID (request_id) -- ARGV[3] 流水过期时间(秒) -- 返回: 1成功, 0额度不足 local total tonumber(redis.call(HGET, KEYS[1], total) or -1) local used tonumber(redis.call(HGET, KEYS[1], used) or 0) local reserved tonumber(redis.call(HGET, KEYS[1], reserved) or 0) local cost tonumber(ARGV[1]) -- total 为 -1 表示未初始化视为无限制或需上层处理 if total 0 then return -2 end -- 可用额度 总额 - 已用 - 预扣中 local available total - used - reserved if available cost then return 0 end -- 原子累加预扣 redis.call(HINCRBY, KEYS[1], reserved, cost) redis.call(HSET, KEYS[1], updated_at, ARGV[3]) -- 记录这笔预扣供结算时使用 redis.call(HSET, KEYS[2], ARGV[2], cost) redis.call(EXPIRE, KEYS[2], tonumber(ARGV[3])) return 1几个设计要点值得展开说。第一available的计算是total - used - reserved把预扣中的量也算进占用这样并发请求看到的就是真实可用不会重复放行。第二返回值用不同数字区分状态-2表示配额未初始化0表示不足1表示成功上层可以据此做不同处理。第三流水 key 单独存是为了结算时能查到这笔预扣到底扣了多少。3.3 结算脚本多退少补把预扣转成实扣模型返回后调用结算脚本。逻辑是从流水里取出预扣量把reserved减掉把真实用量加到used上。如果真实用量小于预扣差额自动退回因为 reserved 减的是预扣量used 加的是真实量可用额度自然回升。-- KEYS[1] quota hash key -- KEYS[2] 预扣流水 key -- ARGV[1] request_id -- ARGV[2] 真实消耗量 -- 返回: 1结算成功, 0流水不存在(可能已过期) local reserved_cost redis.call(HGET, KEYS[2], ARGV[1]) if not reserved_cost then return 0 end local real_cost tonumber(ARGV[2]) reserved_cost tonumber(reserved_cost) -- 释放预扣 redis.call(HINCRBY, KEYS[1], reserved, -reserved_cost) -- 累加真实消耗 redis.call(HINCRBY, KEYS[1], used, real_cost) -- 删除流水防止重复结算 redis.call(HDEL, KEYS[2], ARGV[1]) return 1这里有个关键细节结算必须幂等。如果模型调用超时上层重试结算流水已经被删了第二次会返回 0不会重复扣。这就是为什么结算脚本第一步是检查流水是否存在而不是直接扣。幂等性在分布式系统里是保命的东西尤其是涉及钱的逻辑。3.4 预扣超时释放防止额度被僵尸请求占死预扣有个天然风险如果请求发出后进程崩了或者模型调用卡死这笔预扣就永远挂在reserved里额度被占死。解决办法是给预扣加一个兜底释放机制。我用的方案是双保险。第一层是流水 key 本身设了过期时间比如 10 分钟过期后流水消失。第二层是写一个补偿任务定期扫描reserved明显偏高的租户或者维护一个预扣时间戳的 ZSet把超时未结算的请求捞出来主动调用释放逻辑。-- 释放脚本用于超时补偿 -- KEYS[1] quota hash key -- KEYS[2] 预扣流水 key -- ARGV[1] request_id local reserved_cost redis.call(HGET, KEYS[2], ARGV[1]) if not reserved_cost then return 0 end redis.call(HINCRBY, KEYS[1], reserved, -tonumber(reserved_cost)) redis.call(HDEL, KEYS[2], ARGV[1]) return 1注意补偿任务的扫描频率不要太高否则会给 Redis 带来额外压力。我一般设成每分钟一次配合流水过期时间实际能兜住 99% 的异常情况。4. 多租户治理不只是加个 tenant_id 那么简单4.1 租户隔离的三个层次数据、额度、故障多租户系统里隔离这个词被用得很泛但落到配额治理上至少要分三层看。数据隔离是最基础的每个租户的配额 key 必须带tenant_id绝不能共用。这个好理解但有个坑如果租户数量很大比如上万每个租户一个 Hash keyRedis 的 key 数量会爆炸。这时候可以考虑按租户分片或者用{tenant_id}做 hash tag 把同一租户的 key 路由到同一个 slot方便后续做集群。额度隔离指的是每个租户有独立的配额池一个租户跑满不影响别人。但这里有个策略选择是硬隔离各自独立额度还是软隔离共享池 优先级我建议核心租户硬隔离长尾租户可以共享一个池子用权重分配。这样既保证了大客户的稳定性又不会让资源碎片化。故障隔离最容易被忽略。如果某个租户的请求把网关打挂了不能连累其他租户。这需要在网关层做并发限流而不只是配额限流。配额管的是总量并发管的是瞬时压力两者要配合。4.2 配额维度的组合按 token、按请求数、按并发只按 token 限流是不够的。我见过有租户用极短的 prompt 疯狂刷请求数token 消耗不大但把 QPS 打满了。所以配额至少要支持三个维度维度作用典型限制Token 总量控制成本100 万/天请求数控制调用频次1 万次/天并发数控制瞬时压力50 并发这三个维度可以放在同一个 Lua 脚本里一起判断也可以分开。我倾向于分开脚本、分开 key因为它们的重置周期和业务含义不同。Token 按月算请求数按天算并发是实时的用信号量思路请求结束就释放。混在一起会让脚本变得难以维护。4.3 租户配额的分层模型套餐、临时额度、赠送额度真实业务里租户的可用额度往往不是单一数字而是多个来源叠加套餐额度租户购买的基础配额按月重置。临时额度运营临时追加的有独立过期时间。赠送额度活动送的通常有使用优先级。这就需要一个额度分层扣减的逻辑优先扣快到期的、优先扣赠送的。实现上可以用多个 Hash 字段或者用有序集合按过期时间排序。Lua 脚本里按优先级依次尝试扣减直到扣够为止。这个逻辑稍微复杂但比一个数字走天下要健壮得多尤其是做商业化计费的时候。5. 把脚本接进网关调用链路与异常处理5.1 一次完整请求的生命周期把前面的脚本串起来一次大模型 API 调用的配额流程是这样的请求进入网关解析出tenant_id和预估 token 上限。调用预扣脚本成功则继续失败返回 429额度不足。调用大模型 API拿到响应和真实用量。调用结算脚本把预扣转成实扣。如果第 3 步抛异常调用释放脚本退回预扣。看起来简单但每一步都有异常分支。比如第 2 步成功、第 3 步超时、第 4 步没执行这时候预扣就悬空了得靠第 3.4 节的补偿任务兜底。再比如第 4 步执行了但网络抖动导致上层以为失败重试时靠幂等性保证不重复扣。5.2 预估 token 怎么算才不离谱预扣的量如果估得太离谱用户体验会很差。估太少会透支估太多会虚占额度。我的经验是输入 token用 tokenizer 精确计算别用字符数除以 4 这种土办法中文和代码的偏差很大。输出 token按max_tokens参数预扣因为这是用户自己设的上限最坏情况就是这个数。留一点余量在预扣量上乘 1.05防止 tokenizer 版本差异导致的少量偏差。如果用户没设max_tokens就按模型的最大输出长度预扣虽然会虚占但安全。等结算时差额会退回来用户感知不到。5.3 降级策略Redis 挂了怎么办配额系统依赖 Redis那 Redis 挂了是不是整个网关就瘫了这取决于你的业务容忍度。我的做法是分级降级Redis 短暂抖动本地缓存一份租户配额快照允许在快照基础上做有限放行同时告警。Redis 长时间不可用切换到宽松模式只做并发限流不做配额限流事后补账。宁可少收钱不可断服务。核心大客户单独走一套高可用 Redis 集群和其他租户物理隔离。这个策略的核心思想是配额是成本控制手段不是业务本身。为了配额把服务搞挂是本末倒置。6. 实测中踩过的坑和调优经验6.1 Lua 脚本里的数字精度陷阱Redis 的 Lua 用的是 5.1数字都是 double 类型。token 数量动辄上百万虽然 double 能精确表示到 2^53一般够用但如果你做的是超大规模比如按字节计费就要小心精度丢失。我遇到过一次某个租户的额度是 90071992547409932^531存进去读出来变成了 9007199254740992差了一个。虽然极端但提醒我们涉及大数运算时考虑用字符串存、在应用层算或者干脆用整数分片。6.2 脚本缓存与 EVALSHA 的正确用法生产环境千万别每次都用EVAL传整段脚本网络传输开销大。正确做法是启动时用SCRIPT LOAD把脚本加载进去拿到 SHA1之后用EVALSHA调用。但要注意Redis 重启或主从切换后脚本缓存会丢EVALSHA会报NOSCRIPT错误。所以客户端必须实现降级逻辑捕获NOSCRIPT后自动回退到EVAL并重新加载脚本。def eval_script(redis_client, script, keys, args): try: return redis_client.evalsha(script.sha, len(keys), *keys, *args) except redis.exceptions.NoScriptError: return redis_client.eval(script.body, len(keys), *keys, *args)这段代码看着简单但少了它线上会莫名其妙地报错而且只在 Redis 重启后出现很难复现。6.3 热点租户的 key 竞争问题如果某个大租户的 QPS 特别高所有请求都打向同一个 Hash key这个 key 就成了热点。Redis 单线程处理命令热点 key 会拖慢整个实例。解决办法有两个一是按租户分片把一个大租户的额度拆成 N 份请求按 hash 分散到不同分片各自预扣总量在应用层汇总二是本地预扣 定期同步网关本地先扣一部分额度用完再向 Redis 申请一批减少 Redis 访问频次。第二种方案我实测下来效果很好尤其适合 QPS 上万的场景。代价是额度可能短暂不准但对成本控制来说完全可以接受。6.4 监控指标光有日志不够配额系统必须配一套监控否则出了问题你都不知道。我关注的指标有这些指标含义告警阈值预扣失败率额度不足被拒的比例突增 50%预扣悬空数超时未结算的预扣笔数 100结算差额预扣与实扣的平均偏差 30%脚本执行耗时Lua 脚本 P99 延迟 10ms其中预扣悬空数最能反映系统健康度它一高说明有大量请求没正常结算要么是模型调用超时要么是补偿任务没跑起来。这个指标我设了专门的看板每天盯一眼。7. 几个容易被问到的实现细节7.1 为什么不用 Redis 的过期 key 做周期重置有人图省事把配额 key 设成 24 小时过期靠过期实现每日重置。这有个致命问题过期是惰性的key 到期后不一定立刻删除下次访问时才删。如果重置瞬间有大量请求可能读到旧值也可能读到新值行为不确定。而且过期时间是从 key 创建算起不是从自然日零点算起重置时间会漂移。正确做法是用带周期标识的 key如quota:{tenant}:20240601配合定时任务或首次访问时初始化。7.2 结算时真实用量超过预扣怎么办这种情况会发生比如模型输出比预估的长。结算脚本里used会加上真实用量如果超过totalused就大于total了可用额度变成负数。这时候有两种处理一是允许透支下次请求直接被拒直到额度回正二是把超出部分记到欠款字段下个周期优先扣。我倾向于第一种简单直接用户也能理解这次超了下次先还债。7.3 多实例部署下的时钟问题补偿任务如果部署在多个实例上可能重复执行。这时候要么用分布式锁选主要么让补偿逻辑本身幂等释放脚本已经是幂等的重复执行第二次返回 0。我选后者因为简单。另外判断超时用的时间戳最好统一从 Redis 取TIME命令避免各实例时钟不一致导致的误判。8. 写在最后的一点个人体会这套方案我在两个项目里落地过一个是内部的大模型网关一个是面向客户的 SaaS 计费平台。最大的感受是配额系统的难点从来不在 Redis 和 Lua 本身而在于边界情况的处理。预扣悬空、重复结算、时钟漂移、热点竞争这些才是真正花时间的地方。Lua 脚本可能半天就写完了但把它调稳、把监控补齐、把降级策略想清楚花了整整两周。如果让我给正在做这件事的人一句建议那就是先把预扣和结算的幂等性做扎实再考虑性能优化。我见过太多人一上来就纠结 QPS 和分片结果结算逻辑有 bug账对不上最后推倒重来。原子预扣是地基多租户治理是框架监控和降级是安全网三者缺一不可。至于具体的参数和阈值没有标准答案得根据你的业务量级和成本敏感度慢慢调。
企业数字化 ERP 产品动态
相关推荐
从零搭建面试Agent:记忆、工具调用与状态管理实战 1. 从“码上面试”这个项目名说起:我为什么要啃Agent这块硬骨头第一次看到“码上面试”这四个字,我脑子里蹦出来的不是某个具体产品,而是一个很朴素的念头:能不能让机器替我把那些重复性的面试准备工作干掉?比如根据一… · 2026/11/3 8:18:06
presume、assume、resume、consume 总搞混?词根拆解帮你一次分清 1. 为什么这四个词总被搞混:从词根拆解说起presume、assume、resume、consume 这四个词,长得像四胞胎,前缀不同但词根都是 -sume,意思却差了十万八千里。我带过几批做英文写作和翻译的朋友,几乎每个人都在这个词族上栽… · 2026/11/3 8:18:06
SSE 断线重连:补回 50 条,仍漏 27 条 版权与内容来源声明
本文为原创整理。文中涉及官方文档、开源仓库、论文与公开报道的内容,均在附表 A 中标注来源;引用官方原文保持原样,不作改写。文中命令、版本号与界面截图以本文成文时的实测/核验结果为准,标注「待验证」的部… · 2026/11/3 8:18:06
MySQL Workbench汉化指南:原理与多平台实操 1. 项目背景与需求解析 作为一名长期使用MySQL Workbench的DBA,我深刻理解英文界面对于部分使用者的障碍。尤其当团队中有非技术背景的成员需要参与数据库操作时,语言门槛会显著降低工作效率。这就是为什么MySQL Workbench汉化这个看似简单的需求&#x… · 2026/11/3 8:52:39
非标自动化设备行业ERP选型与实施指南 1. 非标自动化设备行业的管理痛点 非标自动化设备制造行业与传统标准化产品生产有着本质区别。这类企业通常面临以下典型管理难题: 产品高度定制化,每台设备都是"唯一"的,BOM(物料清单)和工艺路线无法标准化… · 2026/11/3 8:52:39
SpringAI 实战:从手写调用到框架化 AI 应用开发 1. 从一次版本升级踩坑说起:SpringAI 到底解决了什么问题 去年冬天,我接手了一个企业内部知识库问答系统的重构任务。当时的方案是用原生 HTTP 客户端直接调大模型接口,自己拼 JSON、自己处理流式返回、自己写重试逻辑。代码写了不到两千行&a… · 2026/11/3 8:52:39
Kubernetes高可用集群证书体系设计与实践 1. 项目概述在构建Kubernetes高可用集群时,证书体系是最关键也是最容易被忽视的部分。作为一位经历过多次K8s集群部署的老兵,我深知一个配置不当的证书体系会导致集群出现各种"灵异"问题。本文将基于三Master节点的生产环境,详细解… · 2026/11/3 8:52:39
Agent提示词模板管理:从硬编码到工程化编排与版本控制 1. 从硬编码到模板化:提示词管理的分水岭做 Agent 开发的人,几乎都经历过这个阶段:提示词直接写在 Python 文件里,用 f-string 拼接,改一个标点就得重新部署。项目小的时候没问题,一旦 Agent 数量超过三五个… · 2026/11/3 8:52:39
动态环境中路径规划的实时障碍物检测与避障策略 1. 项目背景与核心需求 在路径规划或空间扩展类算法中,节点扩展是最基础的操作之一。传统方法往往假设环境完全已知或无障碍物,但在实际应用中这种理想情况几乎不存在。最近我在开发一个自动化仓储机器人系统时,就遇到了扩展节点碰撞障碍物的… · 2026/11/3 8:51:38
双极步进电机控制实战:DRV8818PWPR与PIC18F86K22驱动调试 /* MD / 富文本中的 .toc(含博客园搬家等嵌套结构);.toc-box 在侧栏,不受影响 */#content_views .toc,/* 编辑器常在目录前后插入空 p(:empty 仍占 20px),一并去掉避免顶空隙 */#content_views.markdown_views > p:empty:has(+ .toc),#content_views.markdown_views … · 2026/11/2 13:10:28
STM32F207ZG + DRV8818PWPR 步进电机驱动实战解析 /* MD / 富文本中的 .toc(含博客园搬家等嵌套结构);.toc-box 在侧栏,不受影响 */#content_views .toc,/* 编辑器常在目录前后插入空 p(:empty 仍占 20px),一并去掉避免顶空隙 */#content_views.markdown_views > p:empty:has(+ .toc),#content_views.markdown_views … · 2026/11/3 6:04:24
DRV8818PWPR+dsPIC30F4013工业级双极步进电机控制方案 /* MD / 富文本中的 .toc(含博客园搬家等嵌套结构);.toc-box 在侧栏,不受影响 */#content_views .toc,/* 编辑器常在目录前后插入空 p(:empty 仍占 20px),一并去掉避免顶空隙 */#content_views.markdown_views > p:empty:has(+ .toc),#content_views.markdown_views … · 2026/11/3 5:52:46
STM32F411RE与MAX14431实现SPI隔离:工业现场抗共模干扰实战 /* MD / 富文本中的 .toc(含博客园搬家等嵌套结构);.toc-box 在侧栏,不受影响 */#content_views .toc,/* 编辑器常在目录前后插入空 p(:empty 仍占 20px),一并去掉避免顶空隙 */#content_views.markdown_views > p:empty:has(+ .toc),#content_views.markdown_views … · 2026/11/3 1:01:59
ESP32+大模型AI硬件开发:从Demo到产品的8个工程问题 /* MD / 富文本中的 .toc(含博客园搬家等嵌套结构);.toc-box 在侧栏,不受影响 */#content_views .toc,/* 编辑器常在目录前后插入空 p(:empty 仍占 20px),一并去掉避免顶空隙 */#content_views.markdown_views > p:empty:has(+ .toc),#content_views.markdown_views … · 2026/11/3 1:01:59
基于MAX14431BAEE+与PIC18F85J10的SPI隔离方案设计与实践 /* MD / 富文本中的 .toc(含博客园搬家等嵌套结构);.toc-box 在侧栏,不受影响 */#content_views .toc,/* 编辑器常在目录前后插入空 p(:empty 仍占 20px),一并去掉避免顶空隙 */#content_views.markdown_views > p:empty:has(+ .toc),#content_views.markdown_views … · 2026/11/3 1:01:59
双极步进电机控制实战:DRV8818PWPR与PIC18F86K22驱动调试 /* MD / 富文本中的 .toc(含博客园搬家等嵌套结构);.toc-box 在侧栏,不受影响 */#content_views .toc,/* 编辑器常在目录前后插入空 p(:empty 仍占 20px),一并去掉避免顶空隙 */#content_views.markdown_views > p:empty:has(+ .toc),#content_views.markdown_views … · 2026/11/2 13:10:28
STM32F207ZG + DRV8818PWPR 步进电机驱动实战解析 /* MD / 富文本中的 .toc(含博客园搬家等嵌套结构);.toc-box 在侧栏,不受影响 */#content_views .toc,/* 编辑器常在目录前后插入空 p(:empty 仍占 20px),一并去掉避免顶空隙 */#content_views.markdown_views > p:empty:has(+ .toc),#content_views.markdown_views … · 2026/11/3 6:04:24
DRV8818PWPR+dsPIC30F4013工业级双极步进电机控制方案 /* MD / 富文本中的 .toc(含博客园搬家等嵌套结构);.toc-box 在侧栏,不受影响 */#content_views .toc,/* 编辑器常在目录前后插入空 p(:empty 仍占 20px),一并去掉避免顶空隙 */#content_views.markdown_views > p:empty:has(+ .toc),#content_views.markdown_views … · 2026/11/3 5:52:46