1. 从一次线上事故说起为什么配额预扣必须做成原子操作去年冬天的一个凌晨我被值班电话叫醒——某个大模型 API 网关的账单在三个小时内暴涨了四十倍。排查下来原因并不复杂一个租户的客户端因为网络抖动疯狂重试而我们的配额校验逻辑是先查余额、再扣减、最后调用模型这种典型的读-改-写三段式。并发请求同时读到同一个余额快照全都判断余额充足于是全部放行。等扣减真正落库时余额早就被扣成负数了。这件事之后我把配额系统整个重写了一遍核心思路就一句话把检查余额和扣减余额合并成一个不可分割的原子动作。而实现这个原子动作最顺手的工具就是 Redis 加 Lua 脚本。这篇文章想聊的不是Redis 怎么装Lua 语法是什么这种入门内容而是把大模型 API 网关里配额防透支这一块的完整工程实践讲透。包括为什么选 Redis Lua 而不是数据库事务、预扣和实扣怎么分离、多租户的 key 怎么设计、超时和退款怎么兜底、以及我在真实压测里踩过的那些坑。如果你正在做 API 网关、计费系统、或者任何需要限流计量的服务这篇应该能直接抄作业。先明确一下适用场景你有一个对外提供大模型能力的服务背后可能是自研模型也可能是接的第三方 API前面有一层网关负责鉴权、限流、计费。每个租户或者说每个 API Key有独立的配额配额可能是按 token 数、按调用次数、或者按金额。你要保证的是——任何情况下租户的消耗都不能超过他账户里的余额哪怕瞬间来一万个并发请求。关键词里提到的 Redis、Lua、多租户、API、配额基本就是这篇文章的五个支柱。下面我按实际落地的顺序一块一块拆。2. 为什么数据库事务扛不住而 Redis Lua 可以2.1 读-改-写在并发下的经典失效先看最朴素的实现很多人第一版都会这么写def check_and_deduct(tenant_id, cost): balance db.query(SELECT balance FROM quota WHERE tenant_id %s, tenant_id) if balance cost: raise QuotaExceeded() db.execute(UPDATE quota SET balance balance - %s WHERE tenant_id %s, cost, tenant_id) return True单线程跑没问题一上并发就崩。假设余额 100两个请求各要扣 60两个线程同时执行到SELECT都读到 100都判断通过最后UPDATE执行两次余额变成 -20。这就是典型的竞态条件。有人会说那我加行锁不就行了SELECT ... FOR UPDATE。可以但代价是什么每次配额校验都要锁一行数据库记录大模型 API 的 QPS 动辄几千上万数据库连接池瞬间被打满锁等待队列排到天上去。而且大模型调用本身耗时几秒到几十秒如果锁的持有时间跨越整个调用周期那系统基本就废了。2.2 Redis 单线程模型带来的天然原子性Redis 处理命令是单线程的指命令执行阶段这意味着一条命令在执行过程中不会被其他命令打断。但注意单条命令原子不代表多条命令原子。如果你在客户端写balance redis.get(key) if int(balance) cost: redis.decrby(key, cost)这依然是读-改-写依然有竞态。因为GET和DECRBY之间别的客户端可能插进来。真正的解法是把这一串逻辑打包成一个整体交给 Redis 执行中间不被打断。这就是 Lua 脚本的价值——Redis 会把整个 Lua 脚本当作一个命令来执行脚本运行期间不会有其他命令插入。这跟数据库的存储过程有点像但轻量得多而且不需要网络往返。2.3 一次网络往返 vs 多次网络往返除了原子性Lua 脚本还有个巨大的性能优势。假设你的配额逻辑需要读余额、判断、扣减、写流水、更新统计五个操作。用普通命令就是五次网络往返每次哪怕只有 0.5ms加起来也是 2.5ms。而用 Lua 脚本一次往返搞定脚本在 Redis 内部执行耗时通常在微秒级。在大模型网关这种场景下配额校验是每个请求的必经之路它的延迟直接叠加到用户感知的总延迟上。省下来的这几毫秒乘以每天几百万次调用就是实打实的成本。2.4 一个必须澄清的误区Lua 脚本不是万能的这里要泼一盆冷水。Lua 脚本保证的是单次执行的原子性它不保证跨 key 的复杂事务回滚。如果你的脚本里写了扣 A 账户、加 B 账户执行到一半报错了Redis 不会自动回滚已经执行的命令。所以脚本逻辑要尽量简单把可能失败的操作前置或者设计成幂等的。另外Lua 脚本执行时间过长会阻塞整个 Redis 实例。Redis 有个lua-time-limit配置默认 5 秒超过之后 Redis 会开始响应其他命令但返回 BUSY脚本本身不会被打断。所以脚本里绝对不能有循环等待、大 key 遍历、或者任何可能长时间运行的操作。我见过有人在脚本里遍历一个几万元素的 Set 做统计直接把线上 Redis 卡死这是血泪教训。3. 预扣与实扣分离大模型场景下的特殊设计3.1 为什么不能等调用完再扣普通 API 计费通常是调用成功后再扣费因为你知道这次调用消耗了多少。但大模型 API 有个麻烦调用前你不知道会消耗多少 token。用户传一个 prompt模型可能回 10 个 token也可能回 4000 个 token还可能因为上下文超长直接报错。如果等调用完再扣就会出现这种情况租户余额只剩 100 token但他发了一个可能消耗 5000 token 的请求网关放行了模型跑完了扣费时发现余额不够这 4900 token 的亏空谁来承担要么平台自己吃要么就得做复杂的追缴逻辑都不优雅。所以正确的做法是预扣调用前先按一个预估上限把钱扣掉调用完成后按实际消耗结算多扣的部分退回。这就是预扣 实扣 退款三段式。3.2 预扣额度怎么估预扣多少是个技术活。估少了防不住透支估多了会误伤正常用户余额明明够但因为预扣太多被拒。我的做法是分两部分算输入部分直接按 prompt 的实际 token 数算这个调用前就能精确知道用 tokenizer 数一下。输出部分按max_tokens参数算如果用户没传就用模型的最大输出长度作为上限。两者相加就是预扣额度。这样最坏情况下也不会透支因为模型输出不可能超过max_tokens。def estimate_cost(prompt_tokens, max_tokens, price_per_token): estimated (prompt_tokens max_tokens) * price_per_token return estimated实际结算时用真实的prompt_tokens completion_tokens算实际费用然后退款 预扣 - 实际。如果实际超过预扣理论上不该发生但流式输出中断等边界情况可能有那就按实际扣允许短暂透支但要在流水里标记出来方便对账。3.3 预扣和实扣的 key 设计这里涉及多租户的 key 命名。我的设计是这样的quota:balance:{tenant_id} # 租户可用余额 quota:frozen:{tenant_id} # 租户冻结余额预扣但未结算 quota:req:{request_id} # 单次请求的预扣记录用于退款预扣时从balance扣减加到frozen同时写一条req:{request_id}记录预扣了多少。结算时从frozen扣掉预扣额把实际消耗从frozen里划走或者说把退款加回balance删除req记录。用frozen这个中间态的好处是你能随时知道有多少钱是被在途请求占用的。如果某个请求超时了、进程崩了、或者客户端断连了frozen里的钱不会凭空消失你可以通过扫描req:*记录做补偿退款。这比直接扣balance然后指望退款要可靠得多。3.4 请求 ID 的生成与幂等request_id必须全局唯一而且要能跟业务日志对上。我一般用{tenant_id}:{timestamp}:{uuid4}这种格式既保证唯一又方便按租户和时间范围排查。幂等性怎么保证预扣脚本里先检查req:{request_id}是否存在存在就直接返回之前的预扣结果不重复扣。这样即使客户端因为超时重试也不会扣两次钱。这个检查必须放在 Lua 脚本里跟扣减逻辑一起原子执行。4. 核心 Lua 脚本逐行拆解4.1 预扣脚本先上代码再逐段解释。-- KEYS[1] quota:balance:{tenant_id} -- KEYS[2] quota:frozen:{tenant_id} -- KEYS[3] quota:req:{request_id} -- ARGV[1] 预扣金额整数最小货币单位 -- ARGV[2] 请求ID的TTL秒防止req记录永久残留 local balance_key KEYS[1] local frozen_key KEYS[2] local req_key KEYS[3] local amount tonumber(ARGV[1]) local ttl tonumber(ARGV[2]) -- 幂等检查如果这个请求已经预扣过直接返回成功 if redis.call(EXISTS, req_key) 1 then return {1, tonumber(redis.call(GET, req_key))} end -- 读取当前余额 local balance tonumber(redis.call(GET, balance_key) or -1) if balance 0 then return {-1, 0} -- 账户不存在 end -- 余额不足 if balance amount then return {0, balance} end -- 执行预扣balance 减frozen 加 redis.call(DECRBY, balance_key, amount) redis.call(INCRBY, frozen_key, amount) -- 记录本次预扣设置TTL redis.call(SET, req_key, amount, EX, ttl) return {1, balance - amount}返回值设计成{状态码, 余额}的数组。状态码1表示成功0表示余额不足-1表示账户不存在。这样客户端一次调用就能拿到所有需要的信息不用再查一次余额。几个细节值得说为什么用tonumber包一层Redis 的GET返回的是字符串Lua 里做数值比较必须先转数字。而且如果 key 不存在GET返回falsetonumber(false)是nil所以要用or -1兜底。为什么金额用整数浮点数在 Lua 和 Redis 里都有精度问题。0.1 0.2 不等于 0.3 这种事在计费系统里是灾难。统一用最小货币单位比如厘或者token 数作为整数存储展示时再除以倍数。TTL 设多长取决于你的模型最大响应时间。如果模型最长可能跑 5 分钟那 TTL 至少设 10 分钟留足结算和补偿的时间。设太短会导致正常请求还没结算req记录就过期了退款逻辑找不到依据。4.2 结算脚本-- KEYS[1] quota:balance:{tenant_id} -- KEYS[2] quota:frozen:{tenant_id} -- KEYS[3] quota:req:{request_id} -- ARGV[1] 实际消耗金额 local balance_key KEYS[1] local frozen_key KEYS[2] local req_key KEYS[3] local actual tonumber(ARGV[1]) -- 取出预扣金额 local prepaid tonumber(redis.call(GET, req_key)) if not prepaid then return {-1, 0} -- 预扣记录不存在可能已结算或已过期 end -- 从冻结中扣除预扣额 redis.call(DECRBY, frozen_key, prepaid) -- 计算退款 local refund prepaid - actual if refund 0 then redis.call(INCRBY, balance_key, refund) elseif refund 0 then -- 实际超过预扣从余额补扣允许短暂透支 redis.call(DECRBY, balance_key, -refund) end -- 删除预扣记录 redis.call(DEL, req_key) return {1, refund}结算脚本的关键是先删记录再返回保证同一个request_id不会被结算两次。如果req_key不存在说明要么已经结算过要么 TTL 过期被清理了这时候返回-1让上层决定怎么处理通常是记一条告警日志人工介入。4.3 退款脚本异常兜底当请求因为超时、进程崩溃、或者上游模型报错而没能正常结算时需要一个补偿机制把冻结的钱退回去。-- KEYS[1] quota:balance:{tenant_id} -- KEYS[2] quota:frozen:{tenant_id} -- KEYS[3] quota:req:{request_id} local prepaid tonumber(redis.call(GET, KEYS[3])) if not prepaid then return {-1, 0} end redis.call(DECRBY, KEYS[2], prepaid) redis.call(INCRBY, KEYS[1], prepaid) redis.call(DEL, KEYS[3]) return {1, prepaid}这个脚本通常由一个后台补偿任务调用扫描那些frozen金额异常高、或者req记录即将过期的租户主动退款。也可以做成定时对账每隔几分钟把所有req:*记录跟实际在途请求列表比对找出孤儿记录退款。4.4 脚本加载与执行方式生产环境不要用EVAL直接传脚本字符串每次都要传输整个脚本内容浪费带宽。正确做法是用SCRIPT LOAD预加载拿到 SHA1之后用EVALSHA执行。import redis r redis.Redis(hostlocalhost, port6379, decode_responsesTrue) PREPAID_SCRIPT ...上面的预扣脚本... # 启动时加载拿到 SHA prepaid_sha r.script_load(PREPAID_SCRIPT) def prepaid(tenant_id, request_id, amount, ttl600): keys [ fquota:balance:{tenant_id}, fquota:frozen:{tenant_id}, fquota:req:{request_id}, ] result r.evalsha(prepaid_sha, 3, *keys, amount, ttl) return result注意evalsha的第二个参数是 key 的数量必须跟脚本里KEYS数组的长度一致否则会报错。这个参数很容易写错我第一次写的时候把3写成了2排查了半天。如果 Redis 重启或者脚本被SCRIPT FLUSH清掉了EVALSHA会返回NOSCRIPT错误。生产代码里要捕获这个异常自动降级到EVAL重新加载。或者更简单在应用启动时和捕获到NOSCRIPT时都执行一次SCRIPT LOAD。5. 多租户隔离key 设计与资源治理5.1 租户 key 的命名规范多租户系统最怕的就是 key 冲突和数据串号。我的命名规范是业务域:数据类型:租户标识用冒号分隔层次清晰也方便用SCAN按前缀统计。quota:balance:tenant_001 quota:frozen:tenant_001 quota:req:tenant_001:req_abc123 quota:usage:daily:tenant_001:20250115租户标识建议用不可变的 ID不要用租户名称因为名称可能改改了之后历史数据就对不上了。如果租户量很大比如几十万要注意 Redis 的 key 数量虽然单实例能存几千万 key但SCAN遍历会很慢。这种情况可以考虑分片按租户 ID 哈希到不同的 Redis 实例。5.2 大租户和小租户的资源隔离一个现实问题如果所有租户共用一个 Redis 实例某个大租户疯狂调用把 Redis 的 CPU 打满小租户也会跟着遭殃。这就是吵闹的邻居问题。我的做法是分级隔离租户等级存储方案说明免费/试用共享 Redis 实例独立 key成本低接受一定程度的相互影响付费标准共享 Redis 集群按租户哈希分片分散压力单分片故障影响面可控企业/大客户独立 Redis 实例或独立集群完全隔离SLA 可单独保障这个分级不是纯技术决策要跟商务策略配合。但技术上要预留好扩展点比如把 Redis 连接抽象成一个QuotaStore接口不同等级注入不同的实现。5.3 热 key 问题与本地缓存大模型网关里某些热门租户的balancekey 可能每秒被访问几万次成为热 key。Redis 单分片的处理能力是有上限的热 key 会导致该分片 CPU 飙升。缓解手段有几个本地缓存余额在网关进程内缓存余额设置很短的 TTL比如 1 秒大部分读请求走本地只有写操作预扣、结算才穿透到 Redis。但要注意本地缓存会导致余额判断有延迟可能短暂超扣。所以本地缓存只用于快速失败——如果本地缓存显示余额为 0直接拒绝不用查 Redis如果本地缓存显示有余额还是要走 Redis 原子预扣。key 拆分把一个热 key 拆成多个子 key比如balance:tenant_001:0到balance:tenant_001:9预扣时随机选一个。但这会让余额统计变复杂需要额外聚合一般不推荐。读写分离余额查询走从节点预扣和结算走主节点。但主从同步有延迟从节点读到的余额可能偏大只能用于展示不能用于扣减判断。我实际用的是第一种本地缓存做快速失败Redis 做最终裁决。实测下来热 key 的 QPS 能降 80% 以上。5.4 配额的多维度治理配额不只是总余额一个维度。真实业务里通常还有日配额每天最多消耗多少防止单日突发。月配额每月总量控制。QPS 限制每秒最多多少次调用防止刷接口。并发限制同时最多多少个在途请求。这些维度可以放在同一个 Lua 脚本里一起校验也可以拆成多个脚本。我倾向于拆开因为不同维度的更新频率和存储结构不一样。QPS 和并发用 Redis 的计数器加过期时间就能做日配额和月配额用带日期后缀的 key。-- 日配额校验简化版 local daily_key KEYS[1] -- quota:usage:daily:{tenant_id}:{date} local daily_limit tonumber(ARGV[1]) local amount tonumber(ARGV[2]) local used tonumber(redis.call(GET, daily_key) or 0) if used amount daily_limit then return {0, used} end redis.call(INCRBY, daily_key, amount) redis.call(EXPIRE, daily_key, 86400 * 2) -- 保留两天方便跨天对账 return {1, used amount}注意EXPIRE设了两天而不是一天因为跨天的时候可能还有前一天的请求在结算需要保留数据做对账。6. 压测与线上验证那些文档不会告诉你的坑6.1 压测方案设计配额系统的压测不能只测能不能扣对还要测高并发下会不会扣错。我的压测分三轮第一轮正确性验证。单线程模拟各种边界情况——余额刚好够、余额差 1、余额为 0、重复 request_id、预扣后不结算直接退款。这一轮用单元测试就能覆盖重点是验证 Lua 脚本的逻辑分支。第二轮并发一致性验证。用 100 个线程每个线程对同一个租户发起 1000 次预扣每次扣 1 元租户初始余额 50000 元。预期结果是恰好 50000 次成功50000 次失败最终余额为 0frozen 为 0假设都结算了。如果成功次数不等于 50000说明有并发漏洞。第三轮性能压测。用 wrk 或者 locust 打网关观察 P99 延迟和 Redis 的 CPU、内存、连接数。重点看 Lua 脚本的执行时间如果单个脚本超过 1ms就要考虑优化了。6.2 踩过的坑Lua 里的数字精度前面提过金额要用整数但这里有个更隐蔽的坑。Lua 5.1 里所有数字都是 double能精确表示的整数上限是 2^53。如果你的金额单位是厘一个大租户的余额可能是 10^12 厘10 亿元这还在安全范围内。但如果单位是更小的粒度或者余额特别大就可能溢出。Redis 的INCRBY和DECRBY内部用的是 64 位整数能表示到 2^63-1比 Lua 的精度范围大。所以在 Lua 里做比较时如果数字超过 2^53比较结果可能出错。我的做法是金额单位不要设太小够用就行同时加一个上限校验超过阈值的操作直接拒绝并告警。6.3 踩过的坑脚本里的 KEYS 和 ARGV 混用Lua 脚本里访问 Redis key必须通过KEYS数组传进来不能在脚本里拼接字符串。这是 Redis 集群模式的硬性要求——集群需要根据 key 计算槽位如果 key 是脚本里动态生成的集群不知道路由到哪个节点。我见过有人这么写-- 错误示范 local tenant_id ARGV[1] local balance redis.call(GET, quota:balance: .. tenant_id)单机模式能跑一上集群就报CROSSSLOT错误。正确做法是把完整的 key 通过KEYS传进来。如果确实需要动态 key可以用 hash tag比如quota:balance:{tenant_001}花括号里的内容相同就会路由到同一个槽位。6.4 踩过的坑连接池与超时网关到 Redis 的连接池配置很关键。池子太小高并发时请求排队池子太大Redis 连接数暴涨。我的经验值是最大连接数 网关实例数 × 单实例并发数 × 1.5留 50% 余量。超时设置要分两层连接超时和读写超时。连接超时设短一点比如 200ms读写超时根据 Lua 脚本的最长执行时间设比如 500ms。超时后要有降级策略——是拒绝请求还是放行我的选择是拒绝因为配额系统挂了还放行等于敞开了烧钱。但拒绝要返回明确的错误码让客户端知道是限流而不是业务错误。6.5 监控指标盯住这几个数配额系统上线后这几个指标必须监控指标含义告警阈值预扣失败率余额不足导致的拒绝比例突增 50% 以上frozen 总额所有租户冻结金额之和持续增长不下降孤儿 req 记录数超过 TTL 一半还没结算的记录超过总数 1%Lua 脚本 P99 耗时脚本执行时间超过 5msRedis 内存使用率防止 OOM超过 80%其中孤儿 req 记录数是我自己加的它反映的是有多少请求预扣了但没正常结算。这个数持续偏高说明结算链路有问题可能是模型调用超时太多或者结算服务有 bug。7. 结算链路的可靠性设计7.1 结算失败怎么办预扣成功了模型也调用了但结算的时候 Redis 挂了或者网络断了怎么办这时候frozen里的钱被占着用户余额显示变少但实际上这次调用可能只消耗了一点点。我的方案是结算消息队列化。模型调用完成后不直接同步结算而是发一条结算消息到 MQ由消费者异步结算。这样即使结算瞬间 Redis 不可用消息还在队列里可以重试。def on_model_response(request_id, tenant_id, actual_cost): # 先发消息保证不丢 mq.publish(quota.settle, { request_id: request_id, tenant_id: tenant_id, actual_cost: actual_cost, timestamp: time.time(), })消费者拿到消息后调用结算脚本如果失败就重试重试多次还失败就进死信队列人工处理。结算脚本本身是幂等的req_key不存在就返回-1所以重复消费不会重复扣款。7.2 对账任务最后的防线再可靠的系统也需要对账。我每天凌晨跑一个对账任务做三件事把所有租户的balance和frozen加起来跟数据库里的账户总额比对差异超过阈值就告警。扫描所有req:*记录找出创建时间超过 1 小时还没删除的说明结算链路卡住了触发补偿退款。把当天的所有结算流水汇总跟模型提供商的账单比对如果是接的第三方 API找出差异。对账任务用SCAN而不是KEYS因为KEYS会阻塞 Redis。SCAN要配合COUNT参数控制每次返回的数量我一般设 100避免单次返回太多。7.3 跨天结算的边界处理日配额和月配额的 key 带日期后缀跨天的时候会有边界问题。比如一个请求在 23:59:59 预扣00:00:01 结算这时候日配额的 key 已经变成新的一天了结算脚本去更新新一天的用量就会算错。我的处理方式是预扣时记录日期结算时按预扣时的日期去更新对应的日配额 key。具体做法是在req:{request_id}里不只存金额还存日期或者用两个 keyreq:amount:{request_id}和req:date:{request_id}。结算脚本先读日期再更新对应日期的配额。-- 结算时按预扣日期更新日配额 local prepaid_date redis.call(GET, KEYS[4]) -- req:date:{request_id} local daily_key quota:usage:daily: .. tenant_id .. : .. prepaid_date注意这里daily_key是动态拼接的在集群模式下会有问题。解决办法是用 hash tag把租户 ID 放在花括号里保证同租户的所有 key 路由到同一槽位。8. 从单机到集群扩展时的架构调整8.1 Redis 集群模式下的 key 路由单机 Redis 换成集群后最大的变化是 key 要分槽。Redis 集群有 16384 个槽位key 通过 CRC16 哈希后对 16384 取模决定槽位。如果一次 Lua 脚本操作多个 key这些 key 必须在同一个槽位否则报CROSSSLOT。配额系统的 key 天然适合用 hash tag 聚在一起quota:balance:{tenant_001} quota:frozen:{tenant_001} quota:req:{tenant_001}:req_abc花括号里的tenant_001相同所有 key 都会路由到同一个槽位。这样同一个租户的预扣、结算、退款都能在一个节点上原子完成。不同租户分散在不同节点实现了天然的负载均衡。8.2 主从切换时的数据一致性Redis 主从是异步复制的主节点写入后还没同步到从节点就宕机了这部分数据会丢。对于配额系统丢数据意味着用户可能被多扣或者少扣。缓解方案有两个WAIT 命令写入后调用WAIT 1 100等待至少 1 个从节点确认超时 100ms。但这会增加延迟而且不能完全避免丢失从节点也可能宕机。AOF 持久化开启appendfsync everysec最多丢 1 秒数据。对于计费系统这个损失通常可接受配合对账任务能找回来。我的选择是 AOF 对账兜底。WAIT 命令在高并发下对性能影响太大不适合配额这种高频场景。8.3 容量规划一个租户占多少内存估算一下每个租户至少 2 个 keybalance 和 frozen每个 key 大约 100 字节key 名 value 过期时间等元数据。10 万租户就是 20MB很小。但如果每个请求都留一条req记录按 TTL 10 分钟、QPS 1000 算同时存在的req记录有 60 万条每条 150 字节就是 90MB。这个量级单实例完全扛得住。真正占内存的是用量统计。如果按天、按月、按模型、按接口多维统计key 数量会爆炸。我的做法是明细数据不存 RedisRedis 只存当前余额和冻结额用量明细异步落库到时序数据库或者 ClickHouse需要查询时从那边查。9. 一些零散但重要的经验9.1 脚本版本管理Lua 脚本会迭代改了逻辑之后要保证所有网关实例都用新版本。我的做法是给脚本算一个内容哈希作为版本号跟 SHA1 一起存在配置中心。网关启动时拉取最新版本如果本地缓存的版本落后就重新SCRIPT LOAD。这样滚动发布的时候不会出现新旧脚本混用。9.2 测试环境不要用生产 Redis我见过有团队测试环境和生产环境共用一个 Redis只是 key 前缀不同。结果测试时跑了一个批量脚本把生产数据扫了一遍差点出事。物理隔离测试环境用独立的 Redis 实例哪怕小一点、慢一点也比混用安全。9.3 余额为负的处理理论上预扣机制能防止余额为负但实际中总有意外——比如实际消耗超过预扣、或者并发下的极端情况。如果发现余额为负不要慌先记录然后分析原因。我的处理是负余额的租户会被标记后续请求直接拒绝直到充值补平。同时触发告警人工排查是逻辑 bug 还是正常透支。9.4 给新手的建议如果你刚开始做配额系统不要一上来就追求完美。先用最简单的 Redis Lua 预扣跑通验证正确性再逐步加多租户隔离、结算队列、对账任务。我见过太多人一开始就设计了一套复杂的分布式事务方案结果连基本的并发扣减都没做对。另外多写测试。配额系统的 bug 都是钱测试用例要覆盖余额刚好够、余额差 1、余额为 0、重复请求、预扣后不结算、结算两次、跨天结算、并发扣减。这些场景用单元测试加集成测试都能覆盖成本很低收益极高。最后说个心态问题。配额系统是那种平时不出事一出事就是大事的系统。它不像业务功能那样有直观的用户反馈但一旦出问题要么是平台亏钱要么是用户投诉。所以宁可保守一点预扣多估一些拒绝率高一点也不要为了追求用户体验而放松风控。等系统稳定运行几个月积累足够的数据再逐步优化预扣策略这才是稳妥的路径。
阅读完成 · 觉得有帮助?