ARTICLE · INTELLIGENCE

战地情报 · 详情页

来自尧图项目组的一线实战观察与深度解析

基于Redis List的支付渠道加权随机抽签池设计与实现

基于Redis List的支付渠道加权随机抽签池设计与实现

早些年在做某个支付系统的渠道调度模块时,我被同一个问题反复折腾了好几个版本:那么多支付渠道,每个渠道的扣率、稳定性、单日限额都不一样,订单进来之后到底该优先走哪个?一开始用轮询,发现高并发下某个渠道会被瞬间打满;后来换纯随机,单看长期比例是对的,可线上的监控图却总是出现"高权重渠道连续被抽中十几次"的毛刺。最后从一张物理抽签桌那里获得了灵感:与其每次现场算概率,不如把权重直接物化成一批"签"放进池子里,按顺序取。于是就有了这套基于 Redis List 的"支付渠道加权随机抽签池"方案。

这篇文章就是围绕这个方案展开的:先拆解加权随机在支付调度场景里为什么难做,再讲清楚"概率均匀"这个目标到底意味着什么,然后完整还原 Redis List 版本的抽签池实现——包括 Key 设计、签的生成、原子化抽签、自动补签、动态调权,最后附上我实际踩过的坑。适合正在做支付渠道路由、流量分配、或者类似"按比例把请求均匀散到多台下游服务"的中后端开发同学参考。

1. 从支付调度说起:为什么需要"加权随机"

1.1 多渠道支付下最常见的三种调度策略

支付系统里的渠道调度,本质是一个带约束的资源分配问题。订单进来,你得决定这笔订单交给哪个渠道去处理。渠道之间并不是等价的:A 渠道虽然扣率高一点,但成功率稳定,适合承接大额;B 渠道便宜,可偶尔超时,只能给它一部分小额流量;C 渠道是新接入的,还处在灰度观察期,每天只能给个 5% 甚至更低的比例。这种情况下,"完全等概率"是不存在的,真实需求永远是"按权重分配"。

业界最常见的做法有三种。第一种是纯轮询,一个指针从头走到尾,走到哪个渠道就交给哪个渠道。好处是实现简单到令人发指,但坏处也明显:渠道之间能力不同,轮询会让弱渠道承受和强渠道一样的流量,压测时经常把弱渠道打死。第二种是固定计数,比如约定每 100 笔订单里前 50 笔走 A、后 30 笔走 B、剩下 20 笔走 C。这个方式短窗口内比例很准,但一旦流量模型变了,比如某个时段订单激增,计数器会被瞬间消耗,后面全在空转。第三种就是标题里提到的加权随机,每次请求进来,按照渠道权重计算一个概率分布,然后随机选一个渠道。

加权随机在理论上最灵活,长期下来每个渠道获得的流量会收敛到权重比例。但是"长期收敛"这四个字,在实际的支付场景里往往会变成事故。

1.2 纯随机最怕的不是概率不准,而是短窗口扎堆

我举个实际观察到的例子。某次压测中,我给三个渠道分别配置了权重 70%、20%、10%,从一天维度来看,三个渠道最终承担的真实订单比例确实非常接近这个数字。但把监控粒度切到分钟级之后,问题立刻浮出水面:某个渠道经常在 30 秒内连续命中十几笔,而另一个渠道同时段几乎零流量。从下游渠道的视角看,这就相当于你在用"剧烈抖动"的方式把流量打过来,弱渠道的网关限流、风控告警、超时重试全被触发了一遍。技术上这完全符合概率论——连续命中概率确实是权重值的幂次方,高权重渠道连续抽中 10 次的概率并不算低——但对渠道方来说,这种"抽风式"的流量模式是不可接受的,渠道侧甚至会觉得你的调度系统有 BUG。

所以支付调度场景里,我们真正追求的其实不是"长期概率准",而是"短窗口内也要均匀"。换句话说,任何一个 N 笔订单的时间窗口内,各渠道实际承接的比例都要尽量贴近权重比例,同时 N 又不需要太大。这比单纯追求期望值要难得多,因为纯随机天然带有波动,而波动在支付调度里意味着风险。

1.3 加权随机好,为什么还要"抽签池"

想明白上面这个问题之后,我的思路从"概率论"转向了"库存管理"。纯随机的波动来自"每次独立计算概率",那如果我把概率这件事提前算好、固化下来呢?比如准备一批签,总数 100 张,其中 A 渠道 70 张、B 渠道 20 张、C 渠道 10 张,把签全部洗乱放进一个池子里。每来一笔订单,伸手往池子里取一张签,按签上的渠道名去路由。取完这 100 张,重新洗一批 100 张再放进去。

这样一来,任何一个完整的批次周期内,三个渠道被取到的次数是严格精确的 70、20、10。洗牌又保证了取签顺序在短窗口上是随机的,不会出现"先连续 70 张全是 A"这种灾难。这个方案解决了两件事:一是用"批次精确比例"锁住了随机波动,二是用"抽样顺序随机"保留了灵活性。实际观察下来,分钟级的流量曲线平滑了很多,渠道方的告警安静了,团队内部管这套东西就叫"抽签池"。

2. 把权重翻译成"签":Redis List 抽签池的核心原理

2.1 概率均匀的本质:批次比例 + 乱序消费

"抽签池"这个名字其实已经概括了全部核心思想:权重不是用来做实时计算的,而是用来决定"池子里每种签放多少张"的。消费者从池子里取签时,不需要知道任何权重信息,它只需要做一件事——从队列头部或者尾部弹出一个元素。所以整个方案由两个相对独立的环节组成:

生成环节按权重比例生成一批渠道标识,比如权重是 A:B:C = 3:5:2,批次大小是 100,那就往列表里生成 30 个"A"、50 个"B"、20 个"C"。关键在于生成完之后必须洗牌。如果不洗牌直接按照"A"*30 + "B"*50 + "C"*20 的顺序放进 Redis List,那么 LPOP 先弹出来的 30 个全是 A,等于又变成了顺序调度,只是顺序粒度放大了而已。

消费环节就是 LPOP。每次请求从 Redis List 左侧弹出一个元素,元素内容就是被选中的渠道编码。这里需要注意一个认识误区:很多人以为 Redis List 没有随机弹出接口,所以认为 List 不适合做随机抽取。但实际上我们的随机性来源是"洗牌后的排列 + 从头顺序消费",而不是"在 List 内部做随机弹出一个位置"。洗牌在生成批次时就完成了,消费端只负责按序取走,这反而让逻辑变得极其简单,也更好在 Redis 层面做原子化。

"概率均匀"在这套机制里的含义是分层实现的:批次内部比例精确等于权重,这是均匀的统计学基础;洗牌后队列的前缀序列在任意短窗口内都大概率贴近权重比例,这是均匀的实时表现。和纯随机做对比,相当于把"期望值均匀"升级成了"实际库存比例均匀"。

2.2 为什么是 Redis List,而不是数据库或 Redis 其他结构

选型的时候我认真对比过几个方案,简单说说取舍逻辑。

首先是数据库方案,也就是把渠道权重和累计计数存进 MySQL,每次请求开启事务、读权重、算随机数、更新计数、提交。这个方案在低并发下完全可行,但支付渠道调度往往处于主链路的高并发位置,每笔订单都走一次数据库事务,性能扛不住,而且行锁竞争会让数据库成为新的瓶颈。更尴尬的是,权重调整时你要么用分布式锁锁住整个分配过程,要么接受切权过程中计数不一致。总之一旦量起来,数据库方案会引入大量不必要的复杂度。

然后是 Redis 里的其他结构。ZSet 可以用分数表示权重,但你要实现"按权重概率命中"仍需要在客户端算随机数,如果还要维护命中后的计数衰减,复杂度会快速膨胀。Set 有 SPOP 可以做随机弹出,但 Set 天然去重,同一个渠道编码只能存一个,这根本没法表达"放了 30 张 A 签"这个需求。String 可以用 INCR 做计数,但计数方案和"抽出即减少"的语义还隔了一层,需要在客户端写一堆判断逻辑。

Redis List 的优势很直接:LPUSH/RPOP 全是 O(1) 操作,高并发下 Redis 单线程的处理模型能保证所有弹出操作严格串行、天然原子,不会出现两个请求同时取到同一张签的情况。元素就是渠道编码字符串,内存开销很小。再加上可以给 Key 设置 TTL,每天凌晨过期,正好配合调度系统按天维度的重置逻辑。LLEN 可以直接用来监控剩余签数,配合 Lua 还能把"判断不足-补签-弹出"做成一个原子操作。这一套组合拳下来,List 几乎就是为这个场景量身定做的。

2.3 签生成算法与批次大小选择

签生成本身不复杂,但有几个细节必须处理好,否则后面全是坑。第一步是把权重比例换算成具体张数。假设渠道权重是 {"A": 3, "B": 5, "C": 2},批次大小 batch_size = 100,那么 A 的张数就是 100 * 3/(3+5+2) = 30,B 是 50,C 是 20。问题在于真实权重大概率不会刚好整除,比如三个渠道权重是 45%、30%、25%,批次大小是 100 时四个渠道还算能凑整,但批次大小是 60 呢?

我的处理方式是先把每个渠道的张数向下取整,余出来的差额用按剩余权重随机补签的方式塞回去,保证每一批的总张数严格等于 batch_size,同时比例误差被控制在个位数以内。这一步如果不做,每批都缺几张签,长时间跑下来实际配比会系统性偏移,监控图上就能看到某条渠道总是差一点。

批次大小的选择是一个真实的工程权衡。批次太小,比如只有 10 张,那单批内渠道 A 如果权重 30%,可能只放 3 张签,洗牌后再怎么随机,前几笔订单里也容易出现渠道缺失,短窗口均匀性反而变差。批次太大也不全是好事,渠道故障或者临时调权时,旧签在池子里滞留的时间会更长,权重生效的滞后性会变明显。我通常按"期望均匀窗口"来估算批次:如果目标是在 5 分钟内达到比例稳定,而这 5 分钟预估流量是 2000 笔,那么 batch_size 设在 1000~2000 比较合适。实际项目中我常用 1024 这个值,既能保证短窗口均匀,又不会让单批签在池子里活太久。

另外洗牌建议用 Fisher-Yates 算法,Python 里的 random.shuffle 底层就是它,不要在生成签时用"每次随机选一个渠道 append"这种近似手段,后者虽然也能用,但会在批次内部留下统计偏差。

3. 落地细节:Key 设计、原子抽签与自动补签

3.1 Key 设计与生命周期管理

抽签池的 Key 我习惯拆成两组。主 Key 是pay:pool:{yyyyMMdd},存储所有签,每个元素就是一个渠道编码字符串。日常高并发抽签只需要 LPOP 这个 Key。辅助 Key 是pay:pool:meta:{yyyyMMdd},用一个 Hash 存当天各渠道的权重配置、批次大小、签的总数等元信息。这样抽签逻辑不需要每次读权重表,补签任务也不需要重复从配置文件加载。

主 Key 必须设置 TTL,我一般设 48 小时。目的是防止某个历史 Key 因为异常没有被清掉,第二天又被人捞出来用。每天零点切换到新 Key,旧的 Key 让它自然过期即可。这里有一个容易被忽略的细节:补签任务判断"当前该用哪个 Key"时,不能直接按本地时间拼日期,因为多机部署时不同机器可能有时区漂移,一旦某一台还在写昨天的 Key,就会造成今天的新池子被旧数据污染。我的做法是由一个定时任务在每天切换时用 Redis 的 RENAME 来固定当天 Key,其他机器都从元信息里读当天的 Key 名称。

生命周期上,抽签池不会在应用启动时就一次性把全部签装好,因为一天的流量是随着时间慢慢释放的。我的默认策略是维护一个"低水位线":队列剩余张数低于某个阈值时,触发补签任务,一次性补足一个完整批次。这样既能避免一次性写入过多元素导致 Rpush 阻塞 Redis 主线程,又能保证任何时刻池子里都有足够的签供应突发流量。

3.2 用 Lua 保证抽签与补签的原子性

抽签和补签天然存在竞态条件:多个应用实例同时发现队列长度过低,同时执行补签,就会重复往池子里塞签,导致比例漂移。单纯靠"先 LLEN 再 RPUSH 再 LPOP"三个命令的组合是不够的,Redis 不会保证这一串命令的原子性。解法就是用一段 Lua 脚本把判断和操作封在一起。

脚本的主要逻辑是:

-- KEYS[1] 当前池 Key -- ARGV[1] 补充用签列表(渠道编码,逗号分隔) -- ARGV[2] 低水位阈值 local len = redis.call('LLEN', KEYS[1]) if len < tonumber(ARGV[2]) then local tickets = {} for v in string.gmatch(ARGV[1], '([^,]+)') do table.insert(tickets, v) end for i = 1, #tickets do redis.call('RPUSH', KEYS[1], tickets[i]) end end return redis.call('LPOP', KEYS[1])

这段脚本的语义是:只要队列长度低于阈值,就先补一整批签进去,然后立刻弹出一个。因为整个脚本在 Redis 里是原子执行的,并发请求进来时,Redis 单线程会串行执行这段脚本,补签只会发生一次,后面进来的请求发现长度已经高于阈值,直接 LPOP。这个设计把"检查-补充-取出"三条命令压缩成了一次 RTT,在高并发主链路里性能非常可观。

需要注意 ARGV[1] 不能太大。我见过的失败案例是有人把一批 5000 张签直接拼成一个超大字符串传进去,虽然不会出错,但 Lua 脚本执行期间会一直占用 Redis 主线程,导致其他命令集体阻塞。所以批次大小超过 2000 时,我会拆成几段脚本或者分段 RPUSH,宁可多花几次网络往返。

3.3 动态调整权重与渠道摘除

支付渠道调度里,"渠道临时故障要立刻摘除"和"新渠道灰度要逐步放量"是两种最常见的动态调整场景。

第一种场景:渠道故障。如果是单队列混池方案,我在抽签返回之后还会做一次二次校验,校验当前渠道是否还在白名单里。不在就直接丢弃这张签,重新走一次抽签逻辑。这里有个隐含的随机性损耗问题:丢弃一次抽取,相当于把一张签浪费掉了,剩余渠道的命中比例会略微上升。但在实际操作中,只要故障渠道权重不大(一般摘除的都是些小渠道),这种偏移可以忽略。如果故障渠道是权重 60% 的大渠道,反而应该直接整体重建池子,因为继续丢弃重抽会导致剩下的请求全部堆到其他渠道上,完全不均匀。

第二种场景:动态调权。最平滑的做法是不会立即重建池子,而是让旧批次自然消耗完,然后补签时用新批次。这样做的缺点是调权生效有延迟,延迟时间取决于当前队列剩余签数。想要更快生效,就得把当前队列里剩余的签全部删除,重新按新权重生成一个批次放进去。删除方式可以用 DEL 整个 Key,但要注意这会瞬间清空池子,如果同时有大量请求正在抽签,LPOP 会返回 nil,客户端必须处理这个空值,不能直接当路由失败。我通常采用折中方案:把新批次生成后直接 RPUSH 到原 Key 尾部,同时监控 LLEN,让旧签先被消费完,新签紧跟其后。这样权重切换是渐进式的,不会出现"开关一拨,流量瞬间全换"的抖动。

4. 可以直接抄的代码实现

4.1 签批次生成(Python)

这一节给出完整的实现核心代码。首先是签生成模块:

import random def get_weights(): # 实际项目中从配置中心或 Redis 元信息读取 # 格式: {"CHANNEL_A": 45, "CHANNEL_B": 30, "CHANNEL_C": 25} pass def build_tickets(weights: dict, batch_size: int = 1024) -> list: total = sum(weights.values()) tickets = [] remainder = 0.0 for ch, w in weights.items(): exact = w * batch_size / total count = int(exact) remainder += exact - count tickets.extend([ch] * count) # 把取整产生的差额按权重随机补进去 diff = batch_size - len(tickets) if diff > 0: channels = list(weights.keys()) w_list = list(weights.values()) for _ in range(diff): tickets.append(random.choices(channels, weights=w_list, k=1)[0]) random.shuffle(tickets) return tickets

这段代码有几个细节值得说。向下取整再补差额,比我见过有些文章里写的"round"更稳,因为 round 可能让总数超过 batch_size。补差额时用 random.choices 按权重抽样,不会破坏总体比例。最后洗牌不可省略,这是保证"短窗口随机"的关键一步,一旦省略,SEQ 渠道的签会连续排在队列头部,LPOP 出来的前 N 单全是同一个渠道。

4.2 抽签与补签逻辑

接下来是主链路抽签。这里我用一个简化版的 Redis 操作来展示,生产环境中建议直接把上一节的 Lua 脚本缓存到 Redis 服务端:

import redis pool = redis.ConnectionPool.from_url("redis://127.0.0.1:6379/0") r = redis.Redis(connection_pool=pool) POOL_KEY = "pay:pool:20250101" # 实际按当前日期生成 LOW_WATER_MARK = 128 def draw_channel(): # 优先尝试直接弹出 ticket = r.lpop(POOL_KEY) if ticket: return ticket # 队列空了,触发补充后再弹 tickets = build_tickets(get_weights(), batch_size=1024) lua_body = """ local appended = redis.call('RPUSH', KEYS[1], unpack(ARGV)) if appended > 0 then return redis.call('LPOP', KEYS[1]) end return nil """ # 实际项目中不建议用 unpack 传大列表,这里仅为展示 result = r.eval(lua_body, 1, POOL_KEY, *tickets) return result

生产化的时候有几个改动建议:不要在应用进程里当场生成 tickets 再传到 Lua,因为并发抽签的请求同时进到这段逻辑,可能同一个批次生成 N 次,浪费大量 CPU 和网络。正确做法是补签由独立的定时任务或分布式锁保护的任务去做,主链路只负责 LPOP,如果 LPOP 返回空,则快速从 redis 读取一个"备用批次"或最多重试一次。

4.3 监控脚本

抽签池运行得好不好,指标说了算。落地上我会在 Redis 侧做定时 LLEN 采样,同时在应用侧对每次抽签结果做计数,然后推给监控系统:

# 每分钟执行一次 redis-cli LLEN pay:pool:$(date +%Y%m%d) redis-cli HGETALL pay:pool:meta:$(date +%Y%m%d)

应用侧的计数器至少需要三个维度:按渠道统计的总命中数、按渠道统计的失败数(抽到后调用渠道失败)、以及补签触发次数。命中数和 LLEN 结合可以算出当前批次的剩余容量和消耗速度,方便预判什么时候要扩批次。补签触发次数如果突然上涨,说明流量模型发生了变化,可能是大促或者异常流量进来了,需要提前检查渠道配额。

5. 常见问题与排查实录

5.1 队列瞬间打空与并发补签竞争

第一个坑来自秒杀和大促场景。平时池子里的签够用五分钟消耗,结果瞬时流量进来,几百个请求同时 LPOP,队列长度以肉眼可见的速度掉到零。主链路很多人会写成"LPOP 为空则现场补签",这个逻辑在并发一高就出问题:几十个线程同时检测到空,同时补签,最终池子里被塞进几十倍于预期的签。结果就是渠道比例完全错乱,你本想让 A 走 50%,实际却走成了 70%。

解决方式是双保险。第一层,LPOP 返回空时,先尝试 SETpay:pool:replenish_lock:{yyyymmdd}1 EX 10 NX 获取补签锁,拿到锁的实例负责补签,拿不到的实例短暂 sleep 后再从头抽一次。第二层,补签任务本身用上一节提到的 Lua 脚本做原子化补签,判断队列长度低于低水位线才补。这样即使在锁失效的极端情况下,也不会重复塞入多批签。

5.2 批次衔接处的"尾部扎堆"

还有一个比较容易忽略的问题:洗牌只发生在单个批次内部,但批次和批次之间是连续的。上一批最后一个元素是渠道 A,下一批洗牌后第一个元素恰好还是渠道 A,如果队列头部正好紧挨着,消费者就会连续两次抽到 A。单批次数越多,这种衔接处扎堆的概率越低,但事件上仍然有可能出现——有一次监控显示渠道 A 连续命中 7 单,排查后发现并不是随机异常,而是上一批末尾 A 和下一批头部 A 完美接上了。

处理方案也很简单:补签生成新批次时,先看一下当前 Redis List 的尾部元素(LRANGE key -1 -1),如果新批次洗牌后第一个元素和它相同,就把新批次第一个元素往后面挪一个位置,或者直接把新批次重新洗一次牌。这个逻辑放在补签脚本里,成本几乎为零。

5.3 Redis 故障与本地降级

Redis 是这套抽签池的命脉,一旦 Redis 出现长时间不可用,支付主链路不能跟着挂。我的降级策略是两级:第一级,Redis 连接异常时,快速切到本地内存版抽签池,本地池使用相同的签生成算法,在应用进程内维护一个数组和一个索引,每次请求从数组里顺序取,取完就重建。这个本地池不用完全精确,因为它的定位只是"撑过 Redis 故障的几分钟"。第二级,如果本地池也出问题(比如应用本身内存吃紧),就直接退回最简单的纯加权随机,把可用渠道和权重硬编码在配置里。

这里有个经验:降级代码一定要提前写好并频繁演练,而不是等 Redis 真挂了才临时写。我在生产上遇到过最尴尬的情况是 Redis 主从切换,客户端在切换期间报了十几秒的只读错误,等切换完成之后流量已经堆积了一大批,本地降级版本因为长时间没被触发过,竟然在加载渠道配置时抛了数组越界异常。所以我把降级路径的测试加进了每周的混沌演练用例里。

5.4 监控指标清单

最后给一份清单,这是线上运维时的常用指标表:

指标获取方式告警建议
队列剩余签数LLEN 主池 Key低于 5 倍低水位线触发 P3 告警
每小时补签次数应用计数器突增 3 倍以上关注流量异常
各渠道实际命中率应用计数器与权重偏差超过 10% 触发 P2
渠道调用失败率渠道网关返回统计超过 8% 触发渠道降级流程
单次抽签 P99 耗时应用埋点超过 20ms 排查 Redis 与网络
队列重建耗时补签任务统计超过 2s 检查批次大小与网络带宽

这些指标共同构成了抽签池的"体检报告"。我习惯每天早上看一眼前一天的命中率与权重偏差,如果偏差超过 10%,基本能定位到两种问题:要么补签逻辑里取整差额补得不对,要么某个渠道被摘除时没有同步调整其他渠道的权重。把它们记录下来,比等线上出故障再临时翻日志要轻松得多。

6. 最后一点个人体会

这套抽签池方案最让我满意的地方,在于它把"概率"这种抽象概念变成了可以监控、可以计量的库存。每一次 LPOP 都是一次不会出错的决策,不需要在并发环境里小心翼翼地维护随机数种子和权重表。线路调度、灰度发布、流量压制、甚至抽奖活动里的奖品配额,本质上都能套用同一个模型。

如果你也想在生产环境落地它,我的建议是不要一上来就追求动态调权和 Lua 原子补签这些高级特性。先用"生成一批签、写进 Redis List、LPOP 路由"这十几行代码跑通主链路,再把水位线、监控、补签锁一层层加进去。这种方式看起来慢,但每一步都清晰可控,等真正遇到问题的时候,你会对整个系统的行为有底,调试起来也远比自己拍脑袋蒙一个随机算法快得多。

RELATED READING

延伸阅读

更多一线实战笔记与深度复盘,助您持续精进