老炮淘金13 个真实企业项目的架构高光 · S06基于一套零售 SaaS 促销系统源码实读关键词Redis ZSet · score 设计 · 梯度促销 · skiplist · 区间查询欢迎阅读个人主页知守观我的专栏老炮淘金13个真实企业项目的架构高光当前内容ZSet score 设计文章目录引言ZSet 凭什么能做区间匹配这套系统写入侧的设计score 复用一个字段的四种用法三个用 score 时绕不开的坑正确用法的另一半迁移检查清单老炮点评引言促销系统里有一类规则叫梯度满 3 件 9 折、满 5 件 8 折、满 10 件 7 折。运营配多少档开发管不着可能 3 档也可能 30 档。用户买 7 件系统要回答一个问题命中哪一档。这个问题的朴素解法是数据库里存一张梯度表命中时SELECT * FROM gradient WHERE promotion_id ? AND threshold 7 ORDER BY threshold DESC LIMIT 1。能跑但这条 SQL 在购物车主链路上大促时每个购物车、每个 SKU、每条促销都执行一次数据库扛不住。搬进缓存是共识问题是搬成什么结构。ListHash字符串里塞 JSON最近读的一套零售 SaaS 促销系统选了 ZSetscore 放门槛值。这个选型把「区间匹配」从 O(N) 遍历变成了 O(log N) 索引查询是 ZSet score 设计里一个很典型的妙用。这篇就拆它。本篇你能拿走三样东西「score 放比较基准值」的通用模式梯度门槛、延迟任务、排行榜、滑动窗口限流一表通吃两个绕不开的坑的防御写法double 精度、同分字典序区间命中压回 Redis 服务端的一条命令写法很多团队都知道用 Redis ZSet 的 score 字段来做梯度排序但这套系统在读写两端截然不同的操作恰好展示了所谓的『妙用』到底是怎么分半兑现的。本文拆解的完整排雷与修复链路全景图ZSet 凭什么能做区间匹配ZSet 内部是两张表一张哈希表管 member 到 score 的映射一张跳表skiplist按 score 排序管所有成员。哈希表保证按 member 取值是 O(1)跳表保证按 score 做范围查询是 O(log N)。梯度命中的语义是「找不超过购买量的最大门槛」落在跳表上就是一次范围定位ZREVRANGEBYSCORE promo:grad:10086 7 -inf LIMIT 0 1购买量 7 作为 score 上限负无穷作下限倒序取第一条。返回的就是「满 5 件」那一档。梯度配 3 档还是 300 档这条命令的耗时基本不变——跳表的层数按 log 增长300 个元素也就 8 层上下。对比其他结构List 存梯度要全量取出再遍历O(N)Hash 按 member 索引做不了范围比较字符串塞 JSON 数组序列化开销跟着体积线性涨。区间匹配语义只有 ZSet 原生支持这是选型的核心理由跟「ZSet 能做排行榜」是同一种能力。这套系统写入侧的设计规则生效时预热缓存梯度进 ZSet 的代码包名已省略Doublescoregradient.getThreshold().doubleValue();if(RuleTypeEnum.FULL_PARITY_PRICE.getChildCode().equals(promotionRule.getPromotionRuleType())||RuleTypeEnum.FULL_PARITY_DISCOUNT.getChildCode().equals(promotionRule.getPromotionRuleType())){scoregradient.getThresholdValue().doubleValue();}ZSetOperations.TypedTupleObjecttypedTuplenewDefaultTypedTuple(PromotionRuleGradientCopier.INSTANCE.convertEntityToCacheDto(gradient),score);tuples.add(typedTuple);普通梯度促销score 直接放门槛件数或金额。奇偶定价、奇偶打折例外score 放thresholdValue。两类规则「用于匹配的数值」不同写入侧用分支统一到一个字段上读取侧只需要跟 score 比较。梯度本体进 ZSet梯度挂的赠品明细存另一个 String keyredisCacheManager.opsForValue().set(RedisKeysConstant.getGiftKey(promotionId,gradient.getGradId().intValue()),gradItemCache);redisCacheManager.opsForZSet().add(RedisKeysConstant.getPreferentialKey(promotionId),tuples);两级存储拆开了两种访问频率命中匹配每次都发生走 ZSet数据量小赠品明细只在命中后取一次可能很长单独存。命中路径的传输量不被赠品列表拖累。预热前有一次delete清旧档再批量add新档刷新的语义也完整。score 复用一个字段的四种用法这套「score 放比较基准值」的思路是通用模式不止促销梯度延迟队列用ZRANGEBYSCORE key 0 {now}捞到期任务排行榜用ZREVRANGE取前 N滑动窗口限流用ZREMRANGEBYSCORE key 0 {now - window}清旧记录再ZCARD数当前窗口请求数。学会一种四种场景通吃。面试被问「ZSet 除了排行榜还能干嘛」这就是答案。三个用 score 时绕不开的坑score 是 double。门槛是金额的促销比如「满 99.99 元减 10」直接doubleValue()进 score浮点精度的账就来了99.99 在 double 里存的是 99.989999… 的近似值边界比较可能出错。工程上的标准做法金额乘 100 转整数分再进 score件数、时间戳本身就是整数不受影响。这套系统的threshold.doubleValue()如果承接的是带小数的金额门槛存在精度风险——梯度门槛字段的精度定义我在代码里没追到底此点存疑用同款设计的读者回去核一下自己的字段类型。score 相同按 member 字典序排。两条梯度门槛值一样别笑运营真的配出来过ZSet 不报错按 member 字符串字典序定先后。命中哪一档取决于 member 的序列化结果行为不可预期。防御办法在写入侧门槛查重相同门槛拒绝入库。如果怀疑线上已经踩了这个坑运维可以直接ZRANGE key 0 -1 WITHSCORES扫一眼看有没有分数相同的相邻元素。更新 score 要用 ZADD不是先删后加。ZINCRBY增量、ZADD覆盖都是原子操作。先ZREM再ZADD是两步中间窗口里这个 member 不存在并发读到「档丢了」。Redis 6.2 之后ZADD还给了GT/LT选项只在新值更大/更小时才更新排行榜防刷回退一行命令搞定。正确用法的另一半写入侧把 score 设计对了读取侧也要用上分值索引整个方案才算闭合。正确的命中读取就两步分值区间命令取档命中再取赠品明细。// 取命中档不超过 buyNum 的最高门槛SetObjecthitredisCacheManager.opsForZSet().reverseRangeByScore(key,0,buyNum,0,1);// 命中才取赠品if(CollUtil.isNotEmpty(hit)){GradCachegrad(GradCache)hit.iterator().next();GradItemCacheitemsgetGradItemInfo(promotionCode,grad.getGradId().intValue());}实话说我读的这套系统读取侧没走到这一步——它用range(key, 0, -1)把梯度全量拉回内存逐元素过一遍 JSON 转换再用 Java 的sorted重新排序跳表的区间索引闲置了。读取侧的正确姿势就是上面这段代码对比着看差距一目了然。写入侧正确、读取侧保守在同一份代码里共存恰好说明 score 设计的价值是分半兑现的写入侧决定数据能不能被索引读取侧决定索引有没有被用上。两半都做对妙用才算落地。针对上述“读写错位”的完整修复方案从双写到防击穿的架构演进图迁移检查清单# 1. 缓存里的梯度/门槛类数据用的什么结构grep-rnopsForZSet\|opsForList\|opsForHash--include*.java.|grep-iEgrad|threshold|promo|head-10# 2. score 来源字段的类型是不是 double 直接承接金额grep-rndoubleValue\|setScore--include*.java.|head-10# 3. 区间匹配有没有压到 Redis 服务端grep-rnrangeByScore\|reverseRangeByScore--include*.java.|wc-l第二条如果发现金额直接doubleValue()优先修。浮点精度的 bug 在测试环境极难复现上线后专挑大促的边界金额发作。迁移打卡跑第三条结果为 0 的话你的 ZSet 大概率在用 List 的方式干活。评论区报数。老炮点评ZSet 的 score 是 Redis 里最被低估的字段。多数人只用它做过排行榜排行榜恰恰是 score 最浅显的用法——排序谁都会区间匹配才是跳表真正的本事。门槛、时间戳、权重、上限下限凡是「拿一个数值跟一批数值比大小」的场景score 都接得住。但 score 的设计约束也要认它是 double它有字典序兜底行为它的索引只在范围命令里生效。用对了是 O(log N)用错了连 List 都不如——至少 List 的语义没人会误会。这套促销系统给我最大的启发是数据结构的价值一半在写入时的设计一半在读取时的兑现。选型会上拍板「用 ZSet」只完成了前半场后半场在每一次读取调用里。评审缓存代码时读写两侧的命令要对着看这是我读完这份代码养成的习惯。最后问一句你项目里 Redis 用到了几种数据结构ZSet 的 score 里放的是什么——分数、时间戳还是根本没想过这个问题评论区聊聊。下期预告 · S07《别急着上 MQ看真实企业系统如何用 80 行平铺代码抗住全生命周期》资产卡片从哪来入库单审核一次写八张表合同、采购、库存、卡片、折旧、盘点串成一条数据链。低频长生命周期系统的可追溯性设计下一篇讲。如果本文对你有帮助欢迎 点赞 ⭐ 收藏 关注 留言我是老炮18 年 Java 老兵仍在一线。订阅「老炮淘金」专栏13 个真实企业项目的架构取舍一篇讲透一个。《老炮淘金13 个真实企业项目的架构高光》专栏不只讲代码怎么写更从真实项目源码中拆解为什么这样设计、适用边界在哪里、代价是什么以及今天该如何取舍。欢迎关注后续继续按 S01、S02……追更。