实现高并发凭证池:原子分配、租约与并发控制

📅 2026/8/21 7:30:16
实现高并发凭证池:原子分配、租约与并发控制
多账号并发调用中一个常见问题是配置了并发上限分步读写仍可能让多个请求同时占用同一个名额。API Key、OAuth token、浏览器会话都是「有限并发的凭证」。如果业务侧用GET 可用 key → 本地判断并发 → SET 占用这种没有事务保护的分步操作两个请求可能读到相同旧值并同时占位。竞争是否发生取决于并发窗口但一旦发生就会突破本地上限。本文以 Python 排序、Redis Lua 原子占位为参考方案说明执行器如何从池中租用凭证。同时比较 Lua、WATCH/MULTI、分布式锁和数据库事务的适用边界这些方案都可以实现并发控制只是冲突成本和真相源不同。适用场景持有一批上游账号需要按并发上限对外租用并且已经发现 GET/SET 抢占不稳定。先说结论本文的调用方拿到的不是裸 key而是一把租约key_id lease_id。调用结束后按协议上报释放若不返回 lease就需要用计数器、调用 ID 或其他方式关联具体占位否则难以精确释放并发名额。评分可以在应用层完成真正的「检查名额并占位」需要由同一个原子边界保护。本文用 Redis 单线程脚本一次完成也可以用数据库条件更新、行锁或 WATCH/MULTI 实现。lease 时长需要覆盖调用仍可能占用凭证的时间。可以选「租约覆盖最大调用时长结束后主动释放」也可以选「短租约 执行期间心跳续租」。长固定租约会降低名额周转率占用窗口更长在高吞吐、短耗时任务场景通常更适合短租约 续租。只有短租约而没有续租时任务尚未结束租约就会被回收下一次分配可能造成超额并发。Lua 返回nil不是 500是「这个有序列表此刻没有可占名额」。Redis 挂了才是 503。两种失败别混。分步抢占为什么存在超卖窗口很多人第一版是这样的asyncdefallocate_naive(provider:str):keysawaitredis.zrange(fkeys:{provider},0,-1)forkey_idinkeys:currentint(awaitredis.get(finflight:{key_id})or0)limitint(awaitredis.hget(fkey:{key_id},max_concurrent_uses)or1)ifcurrentlimit:awaitredis.incr(finflight:{key_id})returnkey_idreturnNone两个请求若同时跑到current limit都可能执行 incr上限是 1 的 key 就变成 2。SETNX 可以用全局或分片锁包住选取过程但需要处理锁租期、持有者身份和故障释放。WATCH/MULTI 也能做乐观事务候选热点较集中时冲突重试较多候选分散或吞吐不高时则足够简单。本文采用的拆法Python按质量排一个有序列表可以慢可以加 jitter Redis Lua按这个顺序原子地抢走第一把当前有空位的 key评分不进脚本。脚本里写加权公式改权重要发 Redis还不好单测。Python 里改公式、打日志、加 VIP 规则都方便。上游拿到的是租约不是一把裸 key内部接口可以长这样POST /api/internal/allocate-key X-Internal-Key: your-internal-key{provider:openai,model:gpt-4o,pool:default,lease_seconds:35}成功响应至少包含{key_id:key_01HZX...,lease_id:a1b2c3d4e5f6...,provider_model:gpt-4o,credential:{api_key:sk-...}}无可用 key 的返回建议可以更细一点429 Too Many Requests更贴近“池满/暂不可用需要退避”409 Conflict更贴近“请求条件与当前状态/策略冲突”例如模型/策略不支持、pool 不匹配被拒绝。分配存储或 Redis/Lua 执行失败更适合返回 503。不要把“池满/暂不可用”和“存储不可用”混成一种错误否则重试策略会被搞反前者应退避或切换池/排队后者应重试并触发告警排查。领域对象就是两段字符串dataclass(slotsTrue)classAllocationLease:key_id:strlease_id:strlease 的含义是在lease_seconds内这把 key 的一个并发名额被该请求占用。到期未释放分配脚本在下次抢占时回收。请求结束后打成功或失败上报把租约释放掉。租约不是只在「是否上报」时生效到期后下一次分配会把它清理。因此若上游调用可能持续 30 秒固定租约应覆盖调用超时和上报余量例如 35 秒或者保留 2 秒短租约但执行期间按小于 2 秒的间隔续租。长固定租约会降低名额周转率占用窗口更长短租约续租的代价是增加心跳流量。短租约的关键在于一旦续租失败应把该凭证视为“不再独占”。此时通常应尽快终止或停止使用该凭证对应的后续动作并触发切换到新 lease / 新 key 的路径是否允许“继续跑到结束再回收”取决于业务副作用是否可幂等、以及你能否在后续阶段做去重/对账。文章在这里需要明确你讨论的是“至少一次语义/依赖幂等补偿”续租失败不是可以忽略的缓存小抖动。Redis 数据结构与替代方案一种面向高并发分配的数据结构如下Redis Key类型作用provider:{provider}:pool:{pool}:keysZSET候选 keymember 是key_idkey:{key_id}HASHstatus健康/可分配性不表示是否在途占用、cooldown_until、max_concurrent_uses、last_used_atkey:{key_id}:leasesZSETmember 为lease_idscore 为过期时间戳lease:{lease_id}HASHkey_id、provider、pool、lease_until这套结构适合 Redis 已是高可用基础设施、分配吞吐较高且允许数据库异步对账的场景。其他原子边界包括WATCH/MULTI部署最少适合候选较少、冲突率不高的池收益是不用维护 Lua代价是热点竞争时客户端重试放大。分布式锁 普通命令逻辑容易放在应用层适合低频管理任务代价是锁范围大、吞吐较低还要正确处理锁超时和持有者校验。数据库事务/条件更新数据库直接作为真相源审计与一致性更直观适合吞吐中等的场景代价是热点行锁和连接压力。Redis Lua一次网络往返内完成多键判断与占位适合高并发热路径代价是脚本调试、集群 hash slot 约束和 Redis 故障处理更复杂。同步一把 key 进缓存不申请租约只更新候选集合和 HASHasyncdefsync_key(key,score:float):zsetfprovider:{key.provider}:pool:{key.pool}:keyshkeyfkey:{key.id}awaitredis.zadd(zset,{key.id:score})awaitredis.hset(hkey,mapping{provider:key.provider,status:key.status,cooldown_until:to_epoch(key.cooldown_until),last_used_at:to_epoch(key.last_used_at),max_concurrent_uses:str(max(key.max_concurrent_uses,1)),})ZSET 的 score 在 Lua 分配路径里不会被读。脚本只用ZSCORE判断 member 还在不在集合里不按分数挑人。分数是给运维看的也是 Python 排序的输入真正的挑选顺序由调用方传入的ordered_key_ids决定。删 key 时要把租约 HASH、租约 ZSET、provider 成员、key HASH 一起清。漏删 lease HASH下次释放校验会对不上名额悬挂到过期。Python 先评分再把候选列表喂给脚本本文让 Lua 不负责「哪个 key 质量更好」只负责「按给定顺序找到第一个当前可占用的 key」。也可以把简单评分放进脚本以减少候选传输代价是公式发布和测试更依赖 Redis。参考分配顺序如下按 pool 序列取候选。请求 default 只看 default请求 vip 先 vip没有再 fallback 到 default。default 不会反向抢 vip。对已到期的限流/冷却在 Python 里先做一次机会主义恢复写回 DB 和 Redis。按模型别名过滤丢掉没有插件、不支持该模型的 key。评分、加 jitter、从高到低排序。把排好序的key_id列表一次性交给 Lua。Lua 返回后二次确认 DB 里这把 key 仍可用不可用就释放租约、从列表里摘掉、再试。pool 规则就两行defpool_sequence(pool:str)-tuple[str,...]:ifpoolvip:return(vip,default)return(default,)评分可以写成加权和score capacity_weight * capacity_score idle_weight * idle_score success_weight * success_score - error_weight * error_penalty - rate_limit_weight * rate_limit_penalty - cooldown_weight * cooldown_penalty - freshness_penaltyidle_score看上次使用时间越久没用分越高超过 5 分钟封顶。从未用过的 key 视为最高 idle。success_score是成功率没有历史按 1.0。freshness_penalty看上次探活时间没刷新过或太旧会扣分逼调度偏向最近确认还活着的 key。排序时可以加入少量 jitter示例取 0.01。合适幅度取决于基础分数范围太小无法打散热点太大会掩盖质量差异。也可以采用加权随机、轮询或“最少在途”策略分别换取概率公平、可预测性或实时负载感知。喂给 Lua 的就是评分后的 id 列表。第一次返回空不立刻放弃先sync_key修 Redis 和 DB 的漂移再 eval 一次。第二次还是空才认为这个 pool 真没位了。这是缓存修复不是重试超卖。asyncdefallocate_key(provider,pool,ordered_key_ids,now,lease_seconds35):ifnotordered_key_ids:returnNonelease_iduuid4().hexresultawaitredis.eval(ALLOCATE_LUA,1,# numkeys后面第一个是 KEYS[1]fprovider:{provider}:pool:{pool}:keys,str(int(now.timestamp())),str(max(lease_seconds,1)),provider,pool,lease_id,*ordered_key_ids,)ifisinstance(result,str)andresult:returnAllocationLease(key_idresult,lease_idlease_id)returnNone参数对照记这张表KEYS[1] provider:{provider}:pool:{pool}:keys ARGV[1] now_ts 当前 unix 时间戳 ARGV[2] lease_seconds 至少 1 ARGV[3] provider ARGV[4] pool default / vip ARGV[5] lease_id Python 生成的 uuid ARGV[6] ordered_key_ids 评分后的候选下标越小优先级越高跨 provider 分配时不要把不同 provider 的 id 塞进同一次 EVAL——候选 ZSET 是分 provider 的。改成循环每把 key 单独抢一次顺序仍然是评分顺序原子性仍然在单次 Lua 里。脚本先问这把 key 现在能不能用开头把 ARGV 解出来键名用函数拼不要在脚本里再手写一次前缀localnow_tstonumber(ARGV[1])locallease_secondstonumber(ARGV[2])localproviderARGV[3]localpoolARGV[4]locallease_idARGV[5]localprovider_zsetKEYS[1]localfunctionlease_hash(id)returnlease:..idendlocalfunctionkey_lease_zset(key_id)returnkey:..key_id..:leasesend漏前缀会把租约写到错误的 key 上排障时 Redis 里空空如也其实写到隔壁去了。状态能不能用localfunctionis_usable(key_id)localhash_keykey:..key_idlocalstatusredis.call(HGET,hash_key,status)localcooldown_untilredis.call(HGET,hash_key,cooldown_until)localusablestatusavailableif(statusrate_limitedorstatuscooldown)andcooldown_untilandcooldown_until~thenlocalcooldown_tstonumber(cooldown_until)usablecooldown_tsandcooldown_tsnow_tsendreturnusableend三种结果status 只表达健康/可分配性inflight 占用由 leases 统计availablekey 当前不处于禁用/冷却限制允许进入“抢占名额”逻辑lease 占用不会改这个状态。是否还能再分配仍取决于key:{key_id}:leases的未过期租约数active_count_for是否小于上限。rate_limited/cooldown且冷却时间已过示例选择在分配路径上机会主义恢复以降低后台 Worker 扫描间隔带来的空窗。若业务更重视重新探活后的确定性也可以继续视为不可用等后台验证成功再恢复代价是可用容量恢复更慢。其他状态pending、各种 disabled、exhausted或冷却未到期跳过看下一把。示例把cooldown_until空字符串视为不可用因为临时状态缺少恢复时间时无法判断何时自动放行。另一种做法是使用缺失字段表示“无冷却”并在写入时校验状态与时间的组合关键是 Lua 与应用层采用同一编码避免把tonumber()得到的 nil 误判为到期。Python 过滤是第一道Lua 是第二道。两道都要有评分之后、EVAL 之前管理端可能已经把 key 禁用了Lua 里的 HASH 才是抢占瞬间的真相。顺手回收过期 lease这才是崩溃保险生效的地方localfunctionprune_expired(key_id)localzset_keykey_lease_zset(key_id)localexpiredredis.call(ZRANGEBYSCORE,zset_key,-inf,now_ts)for_,expired_lease_idinipairs(expired)doredis.call(DEL,lease_hash(expired_lease_id))endif#expired0thenredis.call(ZREMRANGEBYSCORE,zset_key,-inf,now_ts)endendlease ZSET 的 score 是过期时间戳。取出所有已经到期的lease_id删掉归属 HASH再从 ZSET 里清掉。回收的是过期的 lease不是“失联请求的全部资源”。未到期的 lease 不能随意回收除非你有额外的 fencing/强制回收机制。在本文要求「检查容量与回收过期租约之间不留竞争窗口」的条件下prune 放进同一段 Lua 有三点收益分配路径和回收路径如果分开会竞争。定时任务还没跑到新请求已经按「旧的占用数」拒绝了这把 key。进程崩溃留下的悬挂名额最多存活一个lease_seconds。下一次有人抢这把 keyprune 就会把它清掉正常长任务则需要续租或足够长的初始租期。Lua 原子性只覆盖这段脚本内部Redis 单线程执行整段脚本prune 和后面的ZCARD、ZADD之间不会被其他客户端插队。但脚本外的 Python 评分、DB 二次确认和释放上报仍可能并发发生因此全局一致性仍需要外层协议配合设计。示例只 prune当前正在检查的 key候选列表有 20 把时最多访问 20 个租约 ZSET避免一次分配演变为全池扫描。注意 prune 的成本与该 key 当前租约 ZSET 的规模成正比适合“每把 key 的 in-flight / 短租约数量有限”的形态若存在海量短租约历史堆积prune 仍可能变重需要更激进的后台回收、调整租约设计例如减少历史或引入额外的计数结构。占用数和上限localfunctionactive_count_for(key_id)prune_expired(key_id)returnredis.call(ZCARD,key_lease_zset(key_id))endlocalfunctionmax_concurrent_for(key_id)localhash_keykey:..key_idlocalvaluetonumber(redis.call(HGET,hash_key,max_concurrent_uses)or1)or1ifvalue1thenreturn1endreturnvalueendZCARD在 prune 之后执行统计仍未过期的 lease 数严格小于上限时才允许再占一个名额。示例将缺省、非数字或小于 1 的配置归一为 1这是一种偏保守的容错策略。严格配置模式也可以直接拒绝非法值避免静默降级具体上限应依据供应商并发限制、账号类型和压测结果设置。并发集成测试应覆盖这些不变量上限 1 时64 个并发 allocate 最多成功 1 个上限 2 时同时在途的成功租约不超过 2 个租约到期后在旧调用已经结束或续租协议确认失联的前提下名额可以再次分配。测试还应使用独立 Redis 实例或唯一 key 前缀避免并行用例互相污染。占位时一次写三处localfunctionmark_allocated(key_id)localexpires_atnow_tslease_seconds redis.call(ZADD,key_lease_zset(key_id),expires_at,lease_id)redis.call(HSET,lease_hash(lease_id),key_id,key_id,provider,provider,pool,pool,lease_until,tostring(expires_at))redis.call(HSET,key:..key_id,status,available)redis.call(HSET,key:..key_id,cooldown_until,)redis.call(HSET,key:..key_id,last_used_at,tostring(now_ts))returnkey_idend三处分别是租约 ZSETmemberlease_idscoreexpires_at。下次 prune 靠这个 score。租约 HASH记下三元组key_id/provider/pool和过期时间。释放时要核对不能只凭lease_id乱删。key HASH把 status 打回 available清空冷却更新上次使用时间。这里的 status 依然只代表健康/可分配性不记录“在途占用”占用发生在租约表key:{key_id}:leases/lease:{lease_id}不发生在 status 字段。占用数仍由 leases ZSET 统计。第三点是机会主义恢复的落盘当冷却已到期且本次抢占成功时把status回写为 available避免下一轮 Python 评分仍因旧的限流/冷却状态而低估容量。DB 里的状态可能暂时仍停在rate_limited可由后台巡检同步Lua 只保证“分配缓存这一侧”的可分配性立即恢复。脚本返回的是key_id字符串不是 JSON。Python 用这个字符串构造租约lease_id使用调用脚本时传入的值。在这套释放协议中两边需要保持同一 ID否则上报无法定位租约。主循环三道门第一把空位就返回fori6,#ARGVdolocalkey_idARGV[i]ifredis.call(ZSCORE,provider_zset,key_id)thenifis_usable(key_id)andactive_count_for(key_id)max_concurrent_for(key_id)thenreturnmark_allocated(key_id)endendendreturnnil对每个候选三道门ZSCORE为真。ZSCORE只用于判断 member 是否还在池内仍属于当前 provider/pool不参与优先级排序真正的候选优先级由 Python 传入的ordered_key_ids决定。不在集合里就跳过避免对幽灵 id 写租约。is_usable。冷却未到期、禁用、pending跳过。active_count max_concurrent_uses。prune 之后仍满员跳过。第一把同时满足三道门的 key占位然后return。后面的候选根本不会被看。这就是「按 Python 给的顺序抢第一把空位」。全部不满足return nil。redis-py 里变成None。上层把它理解成这个有序列表此刻没有可占名额不是 Redis 出错。Redis 出错会抛异常接口返回 503。不要把nil当成 500。常见原因候选集合本身为空例如该 pool 不存在/类型不支持或模型别名过滤后没有任何候选。这种情况 Python 根本不会调用 Lua直接按 404 返回而如果 Lua 返回 nil 表示“暂时没有可用并发名额”则更建议按 409/429 处理。候选都在冷却且时间还没到。候选都打满了并发上限。lease 还没过期上报也还没来。Redis 里的 ZSET 和 DB 脱节ZSCORE全是空。第一次空仅当“空是由于缓存漂移而实际上还有名额但缓存看不到”时才可能通过一次sync_key修复后再试成功若空是因为确实满员、或资源/配置不合法、或依赖DB/Redis/路由配置不可用即使重试也可能仍返回 nil。nil之后 VIP 请求会试 default pool。default 请求不会试 vip。这是 pool 规则不是 Lua 规则。Lua 一次只看见一个 provider 一个 pool 的 ZSET。释放时校验租约归属localproviderARGV[1]localpoolARGV[2]localkey_idARGV[3]locallease_idARGV[4]locallease_hashlease:..lease_idlocalstored_key_idredis.call(HGET,lease_hash,key_id)localstored_providerredis.call(HGET,lease_hash,provider)localstored_poolredis.call(HGET,lease_hash,pool)ifstored_key_id~key_idorstored_provider~providerorstored_pool~poolthenreturn0endredis.call(DEL,lease_hash)redis.call(ZREM,key:..key_id..:leases,lease_id)return1Python 调用numkeys0四个值全是 ARGV。本文选择同时校验lease_id与key_id/provider/pool不一致返回 0 且不删除。对于调用方而言返回 0 不一定是异常lease 可能已经过期并被prune_expired()回收/删除也可能是调用方传参不匹配幂等释放失败。更建议在日志里区分原因而不是只看返回值做告警升级。若lease_id是不可猜测的全局唯一能力令牌也可以只凭它释放以缩短接口增加三元组校验则更容易发现调用方串池或传错 key调用方传错key_id误释放别人的租约。重放一个已经过期、已经被 prune 掉的lease_id。HASH 不存在HGET全是 false对不上返回 0。不会把后来新写的同名 lease 删掉uuid 碰撞可忽略过期后 HASH 已 DEL更安全。pool 迁移后旧租约还在飞。stored_pool ~ pool直接拒绝。返回 1 才是真正释放删 HASH从该 key 的 lease ZSET 里摘掉。占用数立刻减一下一轮分配可以再占这个名额不必等lease_seconds。成功或失败上报后都应尝试释放。插件mark_success失败时也可以在finally释放避免等待租约过期。lease_id为空时清除某把 key 的所有租约属于管理面强清操作应和普通上报接口分开鉴权避免参数缺失误释放其他任务。Lua 赢了还不等于可以交给上游如果 Redis 是分配缓存而数据库保存管理状态脚本返回后可以再读一次 DBkey 已经被删释放租约当没抢到。不在本次 ranked 列表里、provider/pool 对不上、已经不可用、插件没了、模型别名对不上同样释放。更新last_used_at失败释放。再读一次状态又变了还是释放。这不是不信任 Lua。Lua 保证的是「这一瞬间在该 Redis 节点的 Lua 脚本内部」并发名额不超卖它并不自动等价于跨分片/多存储系统的全局一致性。若存在 Redis Cluster 多分片、应用层还要做 DB 二次确认、或有额外状态机步骤则全局一致性仍依赖外层架构与协议。管理端刚点了禁用时Redis HASH 可能短暂仍是 available二次确认把这种窗口收掉。失败路径一定释放否则名额会被一个「最终没发出去的分配」占到过期。存储也可以做成 Redis 主路径、DB 灾备但双写租约会引入两个真相源。若 primary 返回空后再去 DB 分配两边看不到彼此占位可能突破全局上限因此切换通常需要明确的 fencing token、单向故障转移或停止写入窗口。更简单的选择是 Redis 不可用时返回服务不可用把一致性置于可用性之前。本地没 Redis 时用 SQLiteBEGIN IMMEDIATE锁住写连接语义对齐 Lua先删过期 lease再按有序列表找第一把有空位的。两套存储实现同一个接口测试才能覆盖并发上限而不是只测 happy path。怎么验证这套是通的上限 1 不超卖。64 个并发一起 allocate 同一把 key只能赢 1 个其余是空。把上限调到 2前两次成功第三次空。lease 过期能回收。分配后不释放等超过lease_seconds再 allocate应能拿到。ZSET 里旧 lease_id 应被 prune 掉。释放后立刻能再抢。成功路径上报不必等过期。占用数减一下一个请求能拿到同一把 key。三元组对不上不删。用错的key_id去释放返回 0原租约还在。过期后再释放也是 0不要误伤新租约。VIP 单向 fallback。这是示例中的业务规则VIP 可回退到 defaultdefault 不反向使用 VIP。若目标是提高全局利用率也可以允许双向借用并预留最低容量代价是隔离语义更复杂。nil 和 503 分开。停 Redis 或 Lua 执行故障应返回 503。若 Lua 返回 nil 表示“当前无可用并发名额/池满或暂不可用”建议返回 429若是请求条件/策略冲突则返回 409。只有当资源或类型本身不存在例如 Python 根本不调用 Lua直接判定为 404才用 404。二次确认会吐回去。Lua 抢到后管理端立刻禁用该 key这次分配应释放租约并继续看列表不要把已禁用的 credential 交给上游。常见故障与排查线索lease 设成任务超时。10 分钟任务配 10 分钟租约进程一崩名额挂 10 分钟。lease 只做崩溃保险真正占用靠上报释放。没有 jitter。所有流量打到评分第一的 key另外 20 把闲着。看起来池子很小其实是调度扎堆。Lua 里挑人方式与 score。本文的实现里Python 负责候选排序与过滤Lua 只通过ZSCORE判断 member 是否仍属于当前 pool并在满足可用性条件时按“给定顺序”占位是否读取 score 取决于具体实现。把过滤也编码进 Redis 的话脚本可以进一步利用 score 做筛选或降参数但这不是本实现的必选规则。释放不校验三元组。错传key_id会把别人的名额放掉超卖从释放侧进来比分配侧更难查。把 nil 当成 Redis 故障。半夜告警风暴其实是都在冷却。监控上要把「无可用 key」和「分配存储不可用」分成两个指标。密钥进了分配日志。租约对象只打key_id和lease_idcredential 不要 dump 进 Redis 慢日志能扫到的地方。跨 provider 一次 EVAL。候选不在同一个 ZSET 里ZSCORE全空看起来池子没了。写在最后凭证池中Lua 解决的是 Redis 内原子占位不负责调度质量、跨存储一致性和账号健康。可以记三句评分可在应用层检查容量与占位要处于同一个原子边界上游拿到的是租约结束后主动释放固定租约覆盖任务时长短租约则配合续租和失联回收把这三块的成立条件写清楚后限流、冷却和 OAuth 刷新才有稳定的租约语义可以依附。Redis Lua 是高并发场景的一种工程选择数据库事务、乐观事务和锁也各有适用范围。最小实现首先需要一个清晰的原子边界。高并发且 Redis 已就绪时可以从 Lua 占位、覆盖任务时长的固定租约开始希望快速回收崩溃名额时则采用短租约、心跳续租与结束上报。吞吐较低时数据库条件更新或 WATCH/MULTI 可能更省维护。评分、jitter、VIP fallback 和二次确认应根据热点、公平性与双存储一致性需求逐步加入。