ARTICLE · INTELLIGENCE

战地情报 · 详情页

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

写入用 ZSet,读取当 List?拆解一套真实零售 SaaS 的促销缓存翻车记

写入用 ZSet,读取当 List?拆解一套真实零售 SaaS 的促销缓存翻车记

老炮淘金: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、每条促销都执行一次,数据库扛不住。

搬进缓存是共识,问题是搬成什么结构。List?Hash?字符串里塞 JSON?最近读的一套零售 SaaS 促销系统选了 ZSet,score 放门槛值。这个选型把「区间匹配」从 O(N) 遍历变成了 O(log N) 索引查询,是 ZSet score 设计里一个很典型的妙用。这篇就拆它。

本篇你能拿走三样东西:

  1. 「score 放比较基准值」的通用模式:梯度门槛、延迟任务、排行榜、滑动窗口限流一表通吃
  2. 两个绕不开的坑的防御写法:double 精度、同分字典序
  3. 区间命中压回 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 的代码(包名已省略):

Doublescore=gradient.getThreshold().doubleValue();if(RuleTypeEnum.FULL_PARITY_PRICE.getChildCode().equals(promotionRule.getPromotionRuleType())||RuleTypeEnum.FULL_PARITY_DISCOUNT.getChildCode().equals(promotionRule.getPromotionRuleType())){score=gradient.getThresholdValue().doubleValue();}ZSetOperations.TypedTuple<Object>typedTuple=newDefaultTypedTuple(PromotionRuleGradientCopier.INSTANCE.convertEntityToCacheDto(gradient),score);tuples.add(typedTuple);

普通梯度促销,score 直接放门槛(件数或金额)。奇偶定价、奇偶打折例外,score 放thresholdValue。两类规则「用于匹配的数值」不同,写入侧用分支统一到一个字段上,读取侧只需要跟 score 比较。

梯度本体进 ZSet,梯度挂的赠品明细存另一个 String key:

redisCacheManager.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 的最高门槛Set<Object>hit=redisCacheManager.opsForZSet().reverseRangeByScore(key,0,buyNum,0,1);// 命中才取赠品if(CollUtil.isNotEmpty(hit)){GradCachegrad=(GradCache)hit.iterator().next();GradItemCacheitems=getGradItemInfo(promotionCode,grad.getGradId().intValue());}

实话说,我读的这套系统读取侧没走到这一步——它用range(key, 0, -1)把梯度全量拉回内存,逐元素过一遍 JSON 转换,再用 Java 的sorted重新排序,跳表的区间索引闲置了。读取侧的正确姿势就是上面这段代码,对比着看,差距一目了然。

写入侧正确、读取侧保守,在同一份代码里共存,恰好说明 score 设计的价值是分半兑现的:写入侧决定数据能不能被索引,读取侧决定索引有没有被用上。两半都做对,妙用才算落地。

针对上述“读写错位”的完整修复方案:从双写到防击穿的架构演进图:

迁移检查清单

# 1. 缓存里的梯度/门槛类数据用的什么结构grep-rn"opsForZSet\|opsForList\|opsForHash"--include='*.java'.|grep-iE"grad|threshold|promo"|head-10# 2. score 来源字段的类型,是不是 double 直接承接金额grep-rn"doubleValue\|setScore"--include='*.java'.|head-10# 3. 区间匹配有没有压到 Redis 服务端grep-rn"rangeByScore\|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……追更。

RELATED READING

延伸阅读

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