
做支付网关的同学应该都有这种经历渠道真的挂掉的那一刻系统不一定有感知反而是从投诉和工单里才发现“哦刚才某个渠道成功率掉了一半”。我之前在某个网关项目里被这个事折腾过好几次后来把统计从定时扫库换成了基于 Redis ZSet 的实时成功率统计滑动窗口、渠道维度、分钟级刷新全都有了报警也从滞后十几分钟变成秒级感知。这篇就把整套思路和落地细节完整讲一遍数据模型怎么设计、ZSet 的 score 和 member 该怎么定、为什么推荐“完成时间”作为统计口径、常用命令和 Lua 原子脚本长什么样以及线上流量上来之后的拆分和排错套路。适合正在做支付网关、渠道转发、短信/消息推送这类需要实时成功率监控的中小团队参考不需要额外引入时序数据库纯 Redis 就能扛住大多数场景。1. 为什么想到了用 Redis ZSet1.1 支付成功率统计的核心矛盾先明确一下要统计的东西所谓渠道成功率我习惯定义成“一段时间内支付请求到达终态成功或失败的数量中成功占了多少”。注意这里强调的是“到达终态”不是“发起了多少请求”。因为支付请求发起之后很多会卡在等待回调如果按发起量算会把一大堆未出结果的请求也混进来成功率会被虚低。这个统计有几个硬性诉求需要滑动窗口而不是固定时间戳。比如“最近5分钟成功率”意味着每一秒往前走窗口都要跟着滑动旧数据要不断被剔除新数据要不断加进来。需要按渠道拆开看。几十个渠道放在一起算没有任何意义必须能单独看每个渠道的成功率才能知道该切谁的量。数据要能快速回溯。出问题的时候至少要能看最近几分钟甚至最近一小时的数据趋势不能只有一张黑盒数字。最初我们是靠定时任务每分钟去查数据库统计最近5分钟的订单分布。数据量和渠道一多这个查询越来越慢而且因为数据库事务和索引压力高峰期查询经常把主业务拖住。后来也试过用普通计数 key 去累加结果发现滑动窗口很难做要么就是定时清零要么就是维护多个桶手动轮转代码绕来绕去还容易算错。1.2 ZSet 为什么能对上这个需求Redis 的 ZSet有序集合很多人只拿它做排行榜其实是浪费。它的核心能力恰好是这套滑动窗口统计的天然底座score 可以用来存时间戳。事件发生的时间点写入 score整个集合天然按时间排好序。member 是唯一的。以请求唯一 ID 作为 member同一个请求重复回调时不会重复计数幂等性直接被 Redis 接管。ZCOUNT 命令可以按 score 范围直接计数。一条命令就能拿到“窗口内有多少个事件”复杂度是 O(log N)比逐条扫描快得多。ZREMRANGEBYSCORE 可以按 score 范围批量删除。窗口滑走之后的旧数据用这一条命令就能清掉集合不会无限膨胀。还有一点容易被忽略Redis 本身是单线程执行命令多个命令可以通过 Lua 脚本打包成一个原子操作。统计时先清理过期数据再计数的组合操作不会被并发请求插入导致前后不一致这在做实时统计时非常宝贵。1.3 与其他方案的横向对比这里放一张选型对比是我当时做技术选型时实际用过的判断矩阵方案实时性滑动窗口请求幂等实现复杂度适用场景String 计数器轮流覆盖分钟级需手动轮转无低极少量计数不推荐用于成功率Hash 按时间分片分钟级聚合历史分片无中中等流量但需要额外处理幂等Redis ZSet 滑动窗口近实时天然支持member 去重中中等流量首选几百 QPS 到几千 QPS 都能扛Redis Stream近实时需要消费端维护有高偏消息队列做统计不如 ZSet 直接外部时序数据库高天然支持看实现高大规模、长期存储、复杂聚合分析从我实际使用感受来说ZSet 方案最舒服的一点是“轻”不需要任何额外组件Redis 本来就是基础设施把统计逻辑写进去就行。而且当你需要保留一个渠道最近5分钟的数据做排查时直接 ZRANGEBYSCORE 把 member 捞出来甚至能看到具体是哪些请求失败了这一点是普通计数器完全做不到的。2. 数据模型与 key 设计2.1 统计口径用“完成时间”而不是“发起时间”这一步是最容易踩坑的地方我甚至觉得整个方案里最重要的就是这里。很多人第一次设计时会想“请求来了先往 all 集合写一条请求成功了再往 succ 集合写一条”。听起来很直觉但这里埋了一个大坑all 集合里的 score 是“请求发起时间”succ 集合里的 score 是“请求完成时间”两个集合的时间基准不一致。支付渠道的耗时差异很大有些渠道秒回有些渠道可能要等几十秒甚至到超时才回调。假设一个请求在 12:00:30 发起12:05:20 才成功那么用“最近5分钟”去统计时all 集合认为它是 12:00:30 的请求succ 集合认为它是 12:05:20 的请求。于是 12:00 这个窗口内all 里有个未完成的请求成功率被拉低12:05 这个窗口内succ 里突然多了一笔成功成功率又虚高。如果渠道延迟集中爆发统计数据就会变得非常难看且难以解释。我的做法是统一使用“完成时间”作为唯一的时间基准请求到达终态时无论成功还是失败把这条请求写进 all 集合score 用完成时间如果结果是成功再往 succ 集合写同样的 memberscore 同样用完成时间统计时all 集合的窗口范围就是“这个窗口内完成的所有请求”succ 集合的窗口范围是“这个窗口内成功的请求”两者基准一致成功率就是真实完成率。代价是你会丢弃“发起量”这个指标。如果产品需要看发起量和完成量的差值建议单独用一组普通计数器去统计发起量不要跟成功率混在一个数据模型里。2.2 member 和 score 怎么定义我把这部分的压箱底经验总结成几个规则member 建议用“渠道ID 请求唯一标识”拼接。只写请求 ID 有风险万一两个渠道的请求 ID 恰好重合同一个 member 会被 ZADD 覆盖统计直接少一条。score 建议用毫秒时间戳。秒级时间戳在高峰期同一秒会有一堆相同 score虽然不影响计数但清理边界不够精细。毫秒级时间戳完全不用担心精度问题Redis 的 double 类型存毫秒时间戳离精度上限还差很多年。key 按照渠道拆分。一个渠道一对 key不要所有渠道都塞进同一个集合。拆开之后单个 key 的数据量受控统计时也方便单独取数。具体 key 命名我习惯这样payflow:{channelId}:window:all payflow:{channelId}:window:succ比如渠道 A 的 key 就是payflow:channelA:window:all和payflow:channelA:window:succ。如果有多环境建议加上环境前缀比如prod:payflow:channelA:window:all避免跟测试数据混在一起。2.3 单 key 滑动窗口与内存估算最直观的方案就是一个渠道一对 ZSet所有请求都往这两个集合里写。窗口之外的数据靠定期清理。这种方案适合总 QPS 不超过 500 的场景简单直接。内存消耗要提前估算。假设一个渠道峰值 100 QPS窗口 5 分钟那单个 ZSet 里最多同时存在的 member 数量约为100 QPS × 300 秒 30000 条Redis ZSet 每条 member 的内存开销除了字符串本身还有跳表节点和字典项的开销粗略按每条 150 到 250 字节估算一个集合大概 5 到 10 MBall 和 succ 两个集合加起来一个渠道约 10 到 20 MB。如果渠道数量多这个数字会线性增长。我整理了一张估算表直接按“峰值 QPS × 窗口秒数 × 每条 200 字节 × 2 个集合”来粗算峰值 QPS窗口 5 分钟 member 数all succ 估算内存5015000约 6 MB20060000约 24 MB500150000约 60 MB1000300000约 120 MB当峰值 QPS 到 1000 以上单 key 的 ZSet 就是一个大 key 了内存涨得快不说清理时还可能阻塞 Redis。这时候就要考虑下面的分钟分片方案。2.4 数据清理为什么不能依赖 TTL这里要强调一个关键点Redis 的 TTL 是作用在 key 上的不能作用在 ZSet 内部的 member 上。也就是说你不能指望给一条记录设置一个过期时间让它5分钟之后自动从集合里消失。所以 zset 方案的清理方式只有两种用 ZREMRANGEBYSCORE 批量删除窗口之外的数据或者干脆放弃单 key改成按时间分片每个分片 key 设置 TTL过期后整个 key 被 Redis 回收。ZREMRANGEBYSCORE 的复杂度是 O(log N M)其中 M 是删除的成员数。最怕的就是删除量巨大比如一条命令删掉几十万个 member在 Redis 单线程模型下这个命令会阻塞其他请求直接影响主业务。实操中的安全做法是把清理拆细。不要每次统计时一次性把窗口外所有旧数据全部删掉而是单独跑一个定时任务每分钟只清理“上一分钟”的数据。比如当前时间是 15:20:30清理任务只删 score 小于 15:19:30 的成员把删除量控制在“一分钟写入量”的级别这样对 Redis 的影响就小很多了。3. 从写入到统计的完整实现3.1 核心命令速览先把这套方案用到的命令列出来方便后面看代码时对照命令作用ZADD key score member写入一条事件member 已存在时更新 scoreZCOUNT key min max统计 score 在 [min, max] 范围内的成员数ZREMRANGEBYSCORE key min max删除 score 在 [min, max] 范围内的成员ZRANGEBYSCORE key min max查询范围内的成员用于排查具体失败请求EVAL / EVALSHA执行 Lua 原子脚本ZADD 需要注意的是同一个 member 重复写入时不会新增而是更新 score。这个特性用于幂等去重非常完美。如果你把同一个请求的多次回调都写进来了最终只会计数一次。3.2 写入流程请求完成时入账写入逻辑在支付回调处理链路里做。当渠道返回终态不管是成功还是失败先写 all如果成功再写 succ。我摘一段核心代码public void recordResult(String channelId, String requestId, boolean success) { String allKey payflow: channelId :window:all; String succKey payflow: channelId :window:succ; long ts System.currentTimeMillis(); String member channelId : requestId; try (Jedis jedis jedisPool.getResource()) { Pipeline p jedis.pipelined(); p.zadd(allKey, ts, member); if (success) { p.zadd(succKey, ts, member); } p.sync(); } }这里有两个细节用 Pipeline 把两次 ZADD 合批发送。虽然两个命令作用在不同 key但同一请求完成时all 和 succ 写入应该是几乎同时发生的Pipeline 可以减少一次网络 RTT。同一个请求如果在成功后又收到一个重复的成功通知第二次 ZADD 不会新增 member只会把 score 刷新成一个新的时间戳。所以这套模型天然免疫重复回调。如果后续想统计“某渠道的请求量”作为流量大盘指标建议另外用一个独立的 key 去 INCR避免把“发起量”和“完成量”混进同一个 ZSet 里。3.3 滑动窗口统计Lua 原子脚本统计的时候需要先清理窗口外的过期数据再统计窗口内的数量。如果把这两步分开执行清理和统计之间可能会插入新的写入导致统计结果稍微滞后但更关键的是如果在清理命令执行之前正好有旧数据没删掉ZCOUNT 会多算。所以我强烈建议把清理和统计打包成 Lua 脚本这样 Redis 在执行脚本期间不会插入其他命令整个操作是原子的。脚本如下-- KEYS[1] all key -- KEYS[2] succ key -- ARGV[1] 窗口起始时间戳毫秒包含 -- ARGV[2] 窗口结束时间戳毫秒包含 local all_key KEYS[1] local succ_key KEYS[2] local window_start tonumber(ARGV[1]) local window_end tonumber(ARGV[2]) -- 清理窗口之前的数据如果不希望统计前清理可单独定时做 redis.call(ZREMRANGEBYSCORE, all_key, 0, window_start - 1) redis.call(ZREMRANGEBYSCORE, succ_key, 0, window_start - 1) local all_count redis.call(ZCOUNT, all_key, window_start, window_end) local succ_count redis.call(ZCOUNT, succ_key, window_start, window_end) return {all_count, succ_count}Java 侧调用时把 key 和参数传进去就行String script ……上面的 Lua 代码……; ListLong result (ListLong) jedis.eval( script, Arrays.asList(allKey, succKey), Arrays.asList(String.valueOf(windowStart), String.valueOf(windowEnd)) ); long total result.get(0); long success result.get(1); double rate total 0 ? 0 : success * 100.0 / total;这里的 windowStart 和 windowEnd 怎么取以最近5分钟为例windowEnd System.currentTimeMillis() windowStart windowEnd - 5 * 60 * 1000如果你不希望统计命令本身承担清理的额外耗时也可以把清理任务单独放到定时任务里每分钟执行一次统计脚本只做两个 ZCOUNT。不过我更推荐统计前清理因为维护成本低逻辑集中只要控制好单次删除量就行。3.4 分钟分片聚合方案上面说的是单 key 滑动窗口。流量再上一个台阶后单 key 方案会变成大 keyZREMRANGEBYSCORE 的阻塞风险也会显现。这时候我比较推荐把 ZSet 按分钟分片。思路很简单每一分钟生成一个分钟级 key成员还是那些请求score 还是毫秒时间戳但每个 key 里的数据量只有这一分钟的写入量。key 格式payflow:{channelId}:{yyyyMMddHHmm}:all payflow:{channelId}:{yyyyMMddHHmm}:succ写入的时候按当前时间落到对应的分钟 key 里。TTL 直接设置 2 小时过期后 Redis 自动回收整个 key不需要再写任何清理逻辑。统计最近5分钟时需要聚合当前分钟和过去4个完整分钟的数据。可以写一个 Lua 脚本循环处理传入的 key 对-- KEYS 按 [all, succ, all, succ, ...] 顺序传入 -- ARGV[1] 窗口开始时间戳 -- ARGV[2] 窗口结束时间戳 local total 0 local success 0 local window_start tonumber(ARGV[1]) local window_end tonumber(ARGV[2]) for i 1, #KEYS, 2 do local all_key KEYS[i] local succ_key KEYS[i 1] total total redis.call(ZCOUNT, all_key, window_start, window_end) success success redis.call(ZCOUNT, succ_key, window_start, window_end) end return {total, success}分钟分片方案的好处特别明显数据量上限被钳死在一分钟之内不存在大 key 问题清理数据只需要让 key 过期不走 ZREMRANGEBYSCORE完全没有阻塞风险可以方便地拿到某个分钟点上的独立成功率做环比和趋势分析非常顺手。缺点是统计略微绕一点需要自己拼分钟 key 列表并聚合。但如果你用 Lua整体复杂度并不高完全可控。4. 线上调优与实战经验4.1 大流量下的 key 拆分和内存优化分钟分片解决了时间维度上的膨胀但如果某个渠道单分钟请求量特别大一个分钟 ZSet 仍然可能到几十 MB 级别。比如渠道峰值 2 万每分钟每个 ZSet 就是一条大 key。这种场景下我建议在分钟分片的基础上再增加一层 shard 分片payflow:{channelId}:{shard}:{yyyyMMddHHmm}:all payflow:{channelId}:{shard}:{yyyyMMddHHmm}:succshard 的值可以是对 requestId 做 hash 后取模比如 4、8 或 16。统计时把多个 shard 的结果加起来。这本质上就是把大 key 切小避免单 key 内存和操作耗时过高。不过要提醒一句shard 和分钟分片叠加后key 数量会非常多。假设 20 个渠道、8 个 shard、保留 120 分钟最多会同时存在20 × 8 × 120 × 2 38400 个 key这个数量级对 Redis 来说没什么压力但要注意 key 命名的规范和可视化工具里的展示。建议统一封装 SDK不要让业务代码直接拼 key。否则一旦格式不统一统计时拼接 key 就是一场灾难。4.2 幂等与重试member 唯一性到底怎么用支付场景里同一个请求收到多次回调是常态渠道超时重试、回调通知重推、内部重复消费。如果你的统计逻辑依赖外部幂等那是给自己找麻烦。ZSet 方案的 member 唯一性天然把这个问题解决了一大半。我实际踩过的坑是 member 粒度定义错误。一开始我为了记录更多信息把 member 拼成了“requestId 回调次数 状态”结果同一个请求被回调三次集合里就有了三条记录成功率被重复计算。后来改正为直接用业务请求 ID 作为 member 的一部分彻底解决了这个问题。所以这里的经验是member 的粒度必须等于“一个请求只应计数一次”的边界如果同一个业务请求会发起多次子请求每个子请求都想单独统计那 member 应该用子请求 ID如果同一个业务请求的重复回调不想重复计数那 member 必须回到业务请求 ID 维度。具体怎么取舍取决于你的成功率想表达什么维度的质量。判断标准很简单成员 ID 的粒度决定统计结果的粒度。4.3 与监控告警的联动统计做出来之后更重要的是怎么消费这套数据。我的做法是让统计任务定期把每个渠道的成功率刷到一个普通 string key 里比如rate:{channelId}:{yyyyMMddHHmm} 0.987这样监控系统只需要批量读几个很小的 string key根本不需要直接碰 ZSet 里的庞大数据。同时 string key 也设置 2 小时 TTL过期自动删除不会堆积。告警阈值我建议分级处理不要一个阈值走天下。常用的分级大概是成功率低于 95%黄色告警记录日志并观察成功率低于 80%橙色告警立刻通知值班人成功率低于 50%红色告警直接拉起小组群并评估切流。另外不要只看单次窗口数据就告警。支付渠道偶尔会有一次瞬时抖动下一秒就恢复这种误报非常烦人。建议连续 N 个窗口比如 3 个连续统计周期低于阈值才真正触发告警能过滤掉绝大部分毛刺。还有一个经验截图胜过长篇日志。告警消息里直接把 ZRANGEBYSCORE 捞出来的最近失败请求 ID 列表前几条一起发出去排查问题的人一眼就能看出是渠道回调延迟还是内部系统问题省掉大量翻日志的时间。5. 常见问题与排查速查表5.1 成功率数据对不上账这是上线后最容易被业务追问的问题。我把常见现象、可能原因和解决办法整理成了一个排查表遇到问题直接对照现象可能原因解决办法成功率异常偏低all 和 succ 统计口径不一致发起时间和完成时间混用统一按“完成时间”写入 all 和 succ成功率虚高失败事件没写入 all只写了成功事件保证所有终态成功和失败都进 all计数明显少于实际请求member 粒度太大多个请求共用同一个 ID 被覆盖检查 member 拼接逻辑确认使用唯一请求 ID计数明显多于实际请求重试/重复回调未去重member 包含每次回调粒度将 member 收敛到“一个请求只计一次”的粒度单窗口成功率波动剧烈统计窗口太短样本量太小拉长窗口到 5/10 分钟或加连续多窗口判断表中最后一条尤其常见。如果渠道本身流量不高比如一分钟只有几十笔请求那最近1分钟窗口的 30 笔里只要挂 1 笔成功率就从 100% 掉到 96% 以上。所以窗口时长至少要保证“单窗口样本量大于 100”否则成功率本身不具备统计意义。5.2 实时性与延迟的取舍ZSet 滑动窗口方案能做到接近实时因为每次统计就是从当前时间往前推一个窗口数据基本秒级可用。但它的代价是清理任务要根据流量来控制频率否则删除大段数据会阻塞 Redis。分钟分片方案的延迟上限是 1 分钟左右。如果你可以接受每分钟刷新一次成功率这个延迟完全够用。但在一些极致场景下业务方会要求“最近5分钟的成功率曲线能精确到秒级”分钟分片就满足不了了。我的建议是当前分钟用滑动窗口历史分钟用整分钟分片两者结合。具体做法当前分钟的 all 和 succ 使用滑动窗口 ZSet只保留当前分钟开始到现在的数据过去几分钟的数据落在各自的分钟 key 里统计“最近5分钟”时把过去4个完整分钟 key 的 ZCARD 结果加上当前分钟 key 的 ZCOUNT窗口起点到当前时间结果两者相加。这样既保持了当前分钟数据的实时性又通过分片避开了大数据量堆积和阻塞风险。代价是实现稍微复杂一点但当你真正被流量和大 key 教训过之后会发现这点复杂度非常值得。5.3 Redis 资源告警与性能这套方案在生产环境跑久了最容易遇到的问题是内存上涨和阻塞。内存持续上涨重点排查是不是清理逻辑没有执行。比如只写入了 ZADD但忘了跑 ZREMRANGEBYSCORE 清理任务或者分钟分片 key 忘了设置 TTL导致过期数据一直堆积。解决方式是给所有分片 key 统一设置 EXPIRE并且把清理任务纳入定时巡检。阻塞问题重点排查是不是 ZREMRANGEBYSCORE 一次删了大量 member。老版本的 Redis 是单线程模型大范围删除会阻塞其他命令。我的建议是删除操作按上一分钟颗粒度来做每次删除量控制在分钟级别写入量的范围。如果已经用了分钟分片直接靠 DEL 或者 TTL 回收整个 key彻底绕开这个问题。还有一个实践建议成功率统计应该尽量部署在独立的 Redis 实例或至少独立的 namespace 里不要和支付主流程的核心读写混在一个 Redis 上。统计任务再轻也是额外开销万一统计数据量失控不能让它把交易链路的缓存或锁拖挂。我自己在项目里最真实的感受是ZSet 这套方案的价值不在于“用了什么高深技术”而在于把一个业务指标拆解成合适的数据结构再围绕这个结构把写入、统计、清理、告警做成闭环。如果你正在做支付网关或类似的实时监控场景按照这套方案复刻大概率能少走很多弯路。唯一的建议就是把数据拆分策略前置设计好不要等线上流量涨上来再改到时候迁移数据的成本比现在多写几行代码要高得多。
拿不准这条消息跟你有没有关系?
工种不同、批次不同,要求可能差很多。打电话把你的情况说清楚,我们按信阳、平顶山本地的口径给你捋一遍。