Skip to content

典型业务功能设计:购物车、排行榜、附近的人与抢红包 ​

这几个功能都有「用 Redis 就行」的标准答案,但真正决定成败的是细节:同分的排行榜怎么排、第 50 万名怎么翻页、未登录用户的购物车放哪、抢红包怎么保证金额不多不少。

本文的 Redis 实验在 8.10.1 上完成,算法模拟用 JDK 21。

一、先说结论 ​

  • 购物车按登录状态分两套存储:未登录放客户端或带 TTL 的 Redis,登录后合并到持久化存储;商品价格不要存进购物车,结算时实时取。
  • 排行榜的同分问题要靠分数编码解决:把时间戳压进分数的低位,实测同分时先提交的排在前面。
  • 百万成员排行榜取榜和翻页都很便宜:实测在 Redis 8.10 上取前 10 名单条 7 微秒,翻到第 50 万名 9 微秒。限制翻页深度的理由是业务意义和名次一致性,不是性能。
  • 「附近的人」直接用 Redis 的 GEO 命令(底层就是 ZSET),实测按半径查询能返回距离并排序。
  • 抢红包用二倍均值法:实测 10 万轮模拟,金额始终守恒,各位置的平均金额都接近均值。

二、购物车 ​

2.1 按登录状态分两套 ​

状态存储理由
未登录客户端本地存储,或 Redis 中以设备标识为 key(带 TTL)数据量大、价值低,不值得持久化
已登录数据库为主,Redis 做缓存要跨设备同步、要长期保留

登录时把两边合并:同一商品取数量较大者或求和(按业务定),合并后清除未登录购物车。这一步要幂等——用户可能在多个标签页同时登录。

2.2 结构选择 ​

用 Hash 存一个用户的购物车,field 是 SKU:

bash
HSET cart:10086 sku:2001 2      # 商品 2001,数量 2
HINCRBY cart:10086 sku:2001 1   # 数量加一,原子操作
HDEL cart:10086 sku:2001
HGETALL cart:10086
EXPIRE cart:10086 2592000       # 未登录购物车设置 30 天

用 Hash 而不是给每个商品一个 key,可以整体读取、整体过期,内存也更省,原因见 Redis 数据结构与编码。

2.3 几个容易错的地方 ​

  • 不要在购物车里存价格和库存。 这两个值随时会变,展示时实时查询,下单时以服务端价格为准。购物车只存「用户想买什么、买多少」。
  • 数量要有上限:单个 SKU 的数量、购物车的商品种类都要限制,否则会被刷成大 key。
  • 失效商品要保留但标记:商品下架后不要直接从购物车删除,标记为失效并告知用户,否则用户会以为自己没加过。
  • 结算是另一回事:结算时要重新校验价格、库存、活动资格,购物车里的数据只是输入。

三、点赞与排行榜 ​

3.1 点赞用什么结构 ​

点赞需要回答三个问题:谁点过(判重)、有多少人点(计数)、最近谁点的(列表)。

bash
# 判重 + 列表:ZSET,member 是用户 ID,score 是时间戳
ZADD post:likes:9527 1758182400 user:1001
ZSCORE post:likes:9527 user:1001        # 判断是否点过
ZREVRANGE post:likes:9527 0 9           # 最近 10 个点赞的人
ZCARD post:likes:9527                   # 点赞数
ZREM post:likes:9527 user:1001          # 取消点赞

一个 ZSET 就能同时回答三个问题,不需要额外维护计数器。点赞数特别大的内容(几十万以上),可以把计数单独用 INCR 维护,列表只保留最近的 N 个。

3.2 同分排序:把时间戳压进分数 ​

score = 得分 × 10^11 + (10^11 − 提交时间戳的后 11 位)只用得分userB 100 · userA 100 · userC 90压缩分数userA · userB · userC同分时谁在前取决于成员名同分时先提交的在前还原真实得分:score / 10^11 取整分数是 64 位浮点,能精确表示的整数上限约 2^53,拼接时要确认不会溢出
图 1 · ZSET 同分时按成员字典序排列,与业务无关;把「得分」和「时间的补数」拼进一个分数,就能做到同分先达者在前

ZSET 的默认行为是:分数相同时按成员名的字典序排列。实测:

text
ZADD rank:raw 100 userA 100 userB 90 userC
ZREVRANGE rank:raw 0 -1 WITHSCORES
→ userB 100, userA 100, userC 90

userB 排在 userA 前面,纯粹因为字典序,与业务无关。通常的业务规则是「同分时先达到的排前面」,做法是把两个信息编码进一个分数:

text
score = 得分 × 10^11 + (10^11 − 提交时间戳的后 11 位)

时间取补数,是因为时间越早、补数越大,排序时才能排在前面。实测编码之后:

text
ZREVRANGE rank:packed 0 -1 → userA, userB, userC   (userA 提交更早)
ZSCORE / 10^11 取整 → 100  (还原真实得分)

注意精度:ZSET 的分数是 64 位浮点数,能精确表示的整数上限是 2^53(约 9 × 10^15)。所以「得分 × 10^11」要求得分不超过 9 万左右,超了就要缩短时间戳的位数。上线前一定要按业务的最大分值验算一次。

3.3 百万级排行榜 ​

ZSET100 万成员内存 74MB编码 skiplist取前 10 名单条 7µs翻到第 50 万名单条 9µs跳表的每个节点记录了跨度,按名次定位是 O(log N);按分数带 LIMIT 偏移,实测也只要 9µs只提供前 N 名 + 「我的排名」,是为了榜单有意义、翻页时名次不乱,而不是为了性能
图 2 · 100 万成员的 ZSET 占 74MB;在 Redis 8.10 上取前 10 名与翻到第 50 万名都是微秒级。排行榜的难点不在取榜速度,而在同分规则、实时更新与翻页期间的名次变化

实测 100 万成员的 ZSET:

指标实测
内存74MB
编码skiplist
取前 10 名(ZREVRANGE 0 9 WITHSCORES)145,773 次/秒,p50 0.175ms,单条 7µs
按名次翻到第 50 万名(ZREVRANGE 500000 500009 WITHSCORES)147,059 次/秒,p50 0.175ms,单条 9µs
按分数带偏移(ZREVRANGEBYSCORE ... LIMIT 500000 10)155,280 次/秒,p50 0.167ms,单条 9µs
我的排名(ZREVRANK)203,252 次/秒,p50 0.127ms

吞吐用容器内的 redis-benchmark(50 个连接)测得,单条耗时取自 SLOWLOG。

常有一种说法:深度分页要先跳过前面 50 万个成员,所以很贵。实测在 Redis 8.10 上并非如此:按名次翻到第 50 万名和取前 10 名同一量级。Redis 的跳表在每一层都记录了跨度,按名次定位是 O(log N),不需要逐个数过去。实测按分数带 LIMIT 500000 偏移也只要 9 微秒。这一点和 MySQL 的 LIMIT offset 不同,后者确实要读出并丢掉前面所有行,见 SQL 调优实战 的深分页一节。

即便如此,实用的做法仍然是不提供任意翻页,理由在业务上:翻到几十万名之后的榜单没有人看;榜单实时变化,翻页期间名次会整体移动,按页码翻会出现重复和遗漏。

  • 排行榜只展示前 100 名或前 1000 名;
  • 单独提供「我的排名」(ZREVRANK,复杂度 O(log N))和「我附近的几名」;
  • 需要完整榜单时(比如结算奖励),离线导出而不是在线分页。

其他要点:

  • 按周期分 key:rank:2026W38,每个周期一个 ZSET 并设置过期时间,避免无限增长。
  • 实时更新用 ZINCRBY,避免先读后写;分数编码方案下则需要重新计算完整分数再 ZADD。
  • 高并发写入同一个榜单会让单个 Redis 分片成为热点,可以按用户分片维护子榜单,定时合并成总榜。

四、附近的人 ​

Redis 的 GEO 命令底层就是 ZSET:把经纬度编码成 GeoHash 整数作为分数,因此可以直接用 ZSET 的命令查看数据。

bash
GEOADD shops 116.397 39.909 "店A" 116.405 39.915 "店B" 116.500 39.900 "店C"

GEOSEARCH shops FROMLONLAT 116.400 39.910 BYRADIUS 1 km ASC WITHDIST
→ 店A 0.2790, 店B 0.7009

GEOSEARCH shops FROMLONLAT 116.400 39.910 BYRADIUS 10 km ASC WITHDIST
→ 店A 0.2790, 店B 0.7009, 店C 8.6047

GEOSEARCH(Redis 6.2 起,替代旧的 GEORADIUS)支持按半径或矩形范围搜索,ASC 按距离排序,WITHDIST 返回距离,COUNT n 限制数量。

工程上的注意点:

  • 位置更新频繁时要控制写入频率:用户每移动几十米更新一次即可,否则会产生大量写入。
  • 精度:GeoHash 是近似编码,边界附近可能有误差,对精度要求高的场景要在应用层用实际距离二次过滤。
  • 按城市或区域分 key:全国一个 key 会导致大 key 和热点,按城市拆分更容易扩展。
  • 隐私:位置是敏感信息,要有开关、模糊化(只精确到几百米)和过期策略。

五、抢红包:二倍均值法 ​

5.1 算法 ​

每次从剩余金额中随机分配,随机上界是「剩余金额 / 剩余人数 × 2」:

java
static List<Long> split(long totalFen, int count) {
    List<Long> out = new ArrayList<>(count);
    long rest = totalFen;
    int restCount = count;
    for (int i = 0; i < count - 1; i++) {
        long max = Math.max(1, rest / restCount * 2);
        long amount = Math.max(1, ThreadLocalRandom.current().nextLong(1, max));
        amount = Math.min(amount, rest - (restCount - 1));   // 给后面每人至少留 1 分
        out.add(amount);
        rest -= amount;
        restCount--;
    }
    out.add(rest);                                            // 最后一个人拿走剩余
    return out;
}

上界取两倍均值,是为了让期望值等于均值:随机值在 (0, 2×均值) 上均匀分布,期望正好是均值,所以每个位置的期望金额相同,先抢后抢不吃亏。

5.2 实测 ​

100 元分给 10 个人,模拟 10 万轮:

text
金额不等于总额的轮次:0
最小 0.01 元,最大 62.37 元
各位置平均金额:10.00  9.98  9.98  10.00  9.99  9.99  10.02  10.00  10.02  10.02

三个结论:总额守恒(因为最后一个人拿剩余)、位置公平(各位置平均值都在 10 元附近)、波动明显(最大 62 元,符合「拼手气」的体验预期)。

5.3 工程实现 ​

  • 提前拆分还是实时计算:高并发下推荐发红包时就把金额拆好放进 Redis 的 List,抢的时候 LPOP 取一个,天然原子且无需加锁。
  • 判重:用 Set 记录已抢过的用户,SADD 返回 0 表示已经抢过。
  • 落库异步化:抢到的结果先在 Redis 中确定,再通过消息异步写入数据库,用户侧立刻返回。
  • 超时退回:24 小时未抢完的红包要退回给发送者,用延时任务触发,见 延时任务的六种实现。
  • 金额用整数(分)计算,不要用 double。

六、常见误区 ​

  • 「购物车存价格方便展示」:价格会变,结算必须以服务端实时价格为准。
  • 「ZSET 同分会按插入顺序排」:实测按成员名的字典序排(ZREVRANGE 里是逆序),与业务无关。
  • 「ZSET 翻到后面会很慢」:实测翻到第 50 万名和取前 10 名同一量级。限制翻页深度是因为名次一直在变、深处的榜单没人看,而不是因为慢。
  • 「GEO 是独立的数据结构」:它就是 ZSET,可以用 ZSET 命令查看和清理。
  • 「抢红包要加分布式锁」:预先拆分 + LPOP 就是原子的,不需要锁。

小结 ​

这几个功能的共同点是:Redis 提供的结构已经解决了主要问题,真正需要设计的是边界——购物车的价格与失效、排行榜的同分与分页、位置数据的精度与隐私、红包的守恒与退回。把这些边界列出来逐个回答,比讨论「用哪个数据结构」更有价值。

海量数据的判重与过滤,见 位图、布隆过滤器与前缀树。短链服务的短码生成、并发创建、跳转状态码与目标地址校验,见 短链服务:短码怎么生成只是开头。


配套实验

参考资料

文章以 CC BY-NC-SA 4.0 授权 · 代码片段以 MIT 授权