笃行
首页
个人 & 心法
互联网/硬件后台
游戏基础架构
UE 引擎
游戏业务
AI / 大模型
数据结构与算法
机器学习数学
通用基础
GitHub
首页
个人 & 心法
互联网/硬件后台
游戏基础架构
UE 引擎
游戏业务
AI / 大模型
数据结构与算法
机器学习数学
通用基础
GitHub
  • 游戏业务实现(6 年)

    • 游戏业务实现
    • 多模板游戏活动框架
    • 赛事玩法(Bracket / 瑞士轮 / BO 系列)
    • 业务幂等性设计
    • 跨渠道限购与游戏侧分布式事务落地
    • Redis 房间推荐列表
    • 排行榜 / 榜单
    • 抽卡 / 掉落与保底
    • 邮件 / 发奖补偿
    • 匹配 / 段位(MMR)
    • 游戏反作弊
    • 游戏经济与成长
    • 游戏与互联网后台的本质差异
    • 业务代理 · 支付 · 商城

Redis 房间推荐列表

用 ZSet 做分值融合排序 + 实时更新 + 分页,撑起"可加入房间"的高频读写。

一句话结论

ZSet 管排序、Hash 管详情,融合分粗排 + 应用层精排,分桶避大 key。

场景问题

对局大厅要实时展示"你现在能加入的房间",需求听起来简单,落地全是硬约束:

  • 实时性:房间秒级增删,人数每次进出都在变,列表不能是几分钟前的快照;
  • 排序要"聪明":不是简单按创建时间倒序,而要融合热度(人多的靠前)、新鲜度(老房间降权)、匹配度(段位/模式贴近玩家的靠前);
  • 高频读:大厅是流量入口,每次进大厅、每次下拉刷新都在读,QPS 极高;
  • 高频写:每次有人进出房间就要更新该房间的排序权重;
  • 要翻页:一屏放不下,得能"加载更多"。

用 MySQL ORDER BY 硬扛会怎样

SELECT * FROM rooms WHERE status='open' ORDER BY score DESC LIMIT ?,? ——排序字段 score 每次人数变动都要更新,写放大严重;高并发读全压在这张表上,ORDER BY ... LIMIT 还要每次现算排序,深翻页 LIMIT 10000,20 更是灾难。房间还有秒级增删,DB 索引维护成本高。这条路撑不住大厅的读写量级。

实现方案

数据结构选型:ZSet 排序 + Hash 存详情

  • ZSet(有序集合) 存"房间 ID → 融合分值 score",负责排序与分页;
  • Hash 存每个房间的详情(人数、模式、房主、段位等),列表页拿到 ID 后批量取详情。
# ZSet:一个分区/模式一个 key,member=房间ID,score=融合分值
ZADD room:rank:{mode:rank}  1699999812.35  room:10086

# Hash:房间详情,单独存,TTL 兜底过期清理
HSET room:info:10086 owner 42 players 3 cap 5 mode rank rank_tier gold created 1699999000
EXPIRE room:info:10086 1800

分值融合:热度 × 时间衰减 × 匹配度

score 不是单一维度,而是几个信号的加权融合。时间衰减用创建时间(或最近活跃时间)参与计算,让新房间天然靠前、老房间自然沉底:

score = w1 * heat            # 热度:当前人数 / 满员比例
      + w2 * freshness       # 新鲜度:随时间衰减
      + w3 * match_affinity  # 匹配度:与请求玩家段位/模式的贴近程度

热度与新鲜度是房间自身属性,可直接写进 ZSet 的 score;匹配度依赖"谁在看",无法预先固化进全局 ZSet。工程上常见做法:ZSet 里存"热度 + 新鲜度"的基础分做粗排,取出候选后在应用层用玩家段位做精排微调。下面的写入是基础分:

// 房间状态变化时重算基础分并更新 ZSet
func (r *RoomRank) UpdateScore(ctx context.Context, room *Room) error {
    now := float64(time.Now().Unix())
    heat := float64(room.Players) / float64(room.Cap)        // 满员比例 [0,1]
    // 时间衰减:越晚创建 freshness 越大 → 越靠前;半衰期 300s
    freshness := now / 300.0
    decayHeat := heat * math.Exp(-(now-float64(room.Created))/600.0) // 老房热度再打折

    score := 100*decayHeat + freshness
    key := "room:rank:" + room.Bucket() // 按 模式:段位 分桶
    return r.rdb.ZAdd(ctx, key, redis.Z{Score: score, Member: room.IDStr()}).Err()
}

打个比方:房间推荐像共享单车 App 的附近调度——地图上先按距离近、电量足、锁没坏给每辆车打融合分(对应 ZSet 里"热度 + 新鲜度"),谁分高谁排前面;同时空闲太久 / 掉线的车辆自动从推荐里下架(TTL + 定时扫清脏 member),别再骗用户跑一趟发现车不见了;用户看到列表后还会按自己的段位/习惯精排(应用层按玩家匹配度做二次微调)。类比失效边界:共享单车调度是分钟级——中央系统慢几分钟没啥事;游戏房间是秒级刚需——一间热门房被同时推荐给几十个玩家,如果不加预扣位(推荐即软占一个槽、进房失败再释放),就会出现"列表看着有位,一点进去满员"的进房失败率飙升,比"看到车没了"糟糕十倍。所以缓存的强一致性、热点房间的并发保护,是共享单车那套调度不用面对、房间推荐必须补上的一层。

分页:ZREVRANGE 按 rank 翻页

按分值从高到低取,ZREVRANGE key start stop 天然支持分页(O(log N + M)):

# 第一页:前 20 个(带分值方便调试)
ZREVRANGE room:rank:rank_gold 0 19 WITHSCORES
# 第二页:20~39
ZREVRANGE room:rank:rank_gold 20 39
func (r *RoomRank) Page(ctx context.Context, bucket string, page, size int64) ([]*Room, error) {
    key := "room:rank:" + bucket
    start, stop := page*size, page*size+size-1
    ids, err := r.rdb.ZRevRange(ctx, key, start, stop).Result() // 分值倒序分页
    if err != nil {
        return nil, err
    }
    // 拿 ID 批量取详情(pipeline 一次往返)
    pipe := r.rdb.Pipeline()
    cmds := make([]*redis.MapStringStringCmd, len(ids))
    for i, id := range ids {
        cmds[i] = pipe.HGetAll(ctx, "room:info:"+id)
    }
    _, _ = pipe.Exec(ctx)

    rooms := make([]*Room, 0, len(ids))
    for i, c := range cmds {
        m, _ := c.Result()
        if len(m) == 0 { // 详情已过期但 ZSet 里残留 → 顺手清理脏 member
            r.rdb.ZRem(ctx, key, ids[i])
            continue
        }
        rooms = append(rooms, parseRoom(ids[i], m))
    }
    return rooms, nil
}

深翻页与"翻页一致性"

基于 rank 偏移的分页(ZREVRANGE start stop)在数据实时变动时会有漂移:翻到第二页时,第一页的房间可能已因人数变化重排,导致重复或漏项。可接受的场景直接用 offset 分页;要求稳定翻页时,改用 score 游标(ZREVRANGEBYSCORE key (lastScore -inf LIMIT 0 size),以"上一页最后一个分值"为游标续翻,避免偏移漂移。

实时更新与过期清理

  • 人数变化 → 更新 score:每次进/出房间调 UpdateScore 重算并 ZADD(member 相同即覆盖分值);
  • 满员 / 关闭 → 即删:ZREM 出 ZSet + DEL 房间详情,不让不可加入的房间出现在列表;
  • 过期清理:房间详情 Hash 设 EXPIRE TTL 兜底;再配一个定时扫描(ZRANGEBYSCORE 按最后活跃时间取出僵尸房间)批量 ZREM,防止 ZSet 里堆积详情已过期的脏 member(读路径也顺手清理,见上面代码)。
-- 原子更新:人数变化时用 Lua 保证"改人数 + 重算分 + 满员即删"不被打断
-- KEYS[1]=room:info:{id}  KEYS[2]=room:rank:{bucket}
-- ARGV[1]=delta(±1)  ARGV[2]=roomId  ARGV[3]=newScore
local players = tonumber(redis.call('HINCRBY', KEYS[1], 'players', ARGV[1]))
local cap = tonumber(redis.call('HGET', KEYS[1], 'cap'))
if players >= cap or players <= 0 then
    -- 满员或空房:从推荐列表移除
    redis.call('ZREM', KEYS[2], ARGV[2])
else
    redis.call('ZADD', KEYS[2], ARGV[3], ARGV[2])
end
return players

读写全景

避免大 key 与热点:分桶

单个 ZSet 装下全服所有房间会变成大 key(几十万 member,单 key 操作阻塞、迁移困难、内存热点)。按分区 / 模式 / 段位分桶,把一个巨 key 打散成多个小 key:

room:rank:{rank:gold}      # 排位·黄金段
room:rank:{casual:eu}      # 休闲·欧服
room:rank:{rank:diamond}   # 排位·钻石段

分桶天然贴合"玩家只看自己模式/段位的房间"的查询习惯——匹配度已隐含在桶里,读放大也小。{} 内是 hash tag,保证同桶的 rank 与 info key 落到同一 slot,便于 Lua/pipeline 原子操作。

为什么这么做

为什么用 ZSet 而非 DB ORDER BY?

  • 写:ZSet 更新一个 member 分值是 O(log N) 的内存跳表操作,比 DB 更新索引列 + 刷盘轻得多,扛得住人数频繁变动;
  • 读:ZREVRANGE 直接从已排好序的跳表取一段,O(log N + M),无需每次现算排序,抗高并发读;
  • 实时:内存态,秒级增删房间不涉及磁盘 IO 与索引重建。DB 的 ORDER BY ... LIMIT 每次查询都要排序、深翻页恶化,且排序列高频更新会拖垮整表。

为什么要做分值衰减?
不衰减的话,早创建、一度很热的老房间会永远霸榜——即便它已经冷清或即将解散。时间衰减让分值随时间自然回落,把展示机会让给新鲜、活跃的房间,列表始终反映"当下值得加入的房间"。这和资讯流的时间衰减是同一个道理。

为什么别的选择不行

MySQL ORDER BY score DESC LIMIT: 高频写放大、高并发读排序、深翻页恶化、秒级增删的索引维护成本,四座大山,撑不住大厅量级。(见开头场景警告。)

List / 普通 Set 存房间: List 无法按分值排序,Set 无序,都拿不到"按融合分排序 + 分页"的能力。ZSet 是唯一同时满足"排序 + 范围分页 + O(log N) 更新"的原生结构。

全局单个大 ZSet: 会形成大 key 与访问热点,单 key 阻塞、集群迁移困难、内存不均。必须按分区/模式分桶打散。

把详情也塞进 ZSet 的 member: member 存 JSON 会让 key 膨胀、更新详情要 ZREM+ZADD 两步且改变排序语义。详情用 Hash 独立存、ZSet 只存 ID 是清晰的职责分离。

沉淀结论

  • ZSet 排序 + Hash 存详情 是房间推荐列表的标准组合:一个管序,一个管料。
  • 融合分 = 热度 × 时间衰减 × 匹配度;基础分(热度+新鲜度)入 ZSet 粗排,匹配度在应用层精排。
  • 人数变化即重算 score、满员/关闭即 ZREM,用 Lua 保证原子;TTL + 定时扫 + 读路径顺手清,三管齐下清脏 member。
  • 分页用 ZREVRANGE;要稳定翻页改用 score 游标避免偏移漂移。
  • 按模式/段位分桶避免大 key 与热点,分桶还天然贴合查询习惯、隐含匹配度。
  • ZSet 相对 DB ORDER BY 的核心优势:O(log N) 写 + 免排序读 + 内存实时。

落地要点

  • 分桶 key 用 {} hash tag 把同桶的 rank 与 info 固定到同一 slot,Lua/pipeline 才能原子;
  • 读路径遇到"ZSet 有 ID 但 Hash 详情已过期"要顺手 ZREM,自愈脏数据;
  • 精排只在候选集(一两页)上做,别把全量拉出来在应用层排序。

记忆口诀

  • 结构:ZSet 管序 / Hash 管料 / ID 做桥
  • 分值:热度 / 时间衰减 / 匹配度(基础分粗排 + 应用层精排)
  • 更新:变化即重算 / 满员即 ZREM / Lua 保原子
  • 清理与翻页:TTL + 定时扫 + 读路径自愈 / score 游标防漂移 / 按模式段位分桶避大 key

内容来源

综合整理自 Redis 有序集合在游戏大厅/房间推荐场景的落地实践,涵盖分值融合、实时更新、分桶与翻页一致性等工程要点。

自测:合上资料能说清楚吗?

  1. 房间推荐列表为什么用 ZSet 而不用 MySQL ORDER BY score DESC LIMIT?请对比两者在读、写、实时性上的差异。
参考答案

ZSet 更新 member 分值是 O(log N) 内存跳表操作、读用 ZREVRANGE 免现算排序、内存态秒级增删;MySQL 排序列高频更新写放大、每次查询现算排序、深翻页恶化、索引维护贵,撑不住大厅量级。

  1. 融合分值由哪几个信号组成?为什么匹配度不能像热度那样直接写进全局 ZSet?
参考答案

融合分 = 热度 × 时间衰减 × 匹配度。热度、新鲜度是房间自身属性可入 ZSet;匹配度依赖"谁在看"(玩家段位/模式),无法预先固化,故基础分入 ZSet 粗排、匹配度在应用层精排。

  1. 为什么必须做分值时间衰减?不做会怎样?
参考答案

不衰减则早创建、一度很热的老房间永远霸榜,即便已冷清或将解散。时间衰减让分值随时间自然回落,把展示机会让给新鲜活跃的房间,与资讯流时间衰减同理。

  1. offset 分页(ZREVRANGE start stop)在数据实时变动时有什么问题?如何解决?
参考答案

数据重排时按 rank 偏移会漂移,翻页出现重复或漏项。解决:改用 score 游标,以"上一页最后一个分值"为界 ZREVRANGEBYSCORE (lastScore -inf LIMIT 0 size,避免偏移漂移。

  1. 为什么要按模式/段位分桶?脏 member(ZSet 有 ID 但详情已过期)怎么清理?
参考答案

单个全局 ZSet 会成大 key(阻塞、迁移难、热点),分桶打散且天然贴合查询习惯、隐含匹配度。脏 member 三管齐下:详情 Hash 设 TTL 兜底、定时扫描 ZREM、读路径遇空详情顺手 ZREM 自愈。

最近更新: 2026/9/10 11:38
Prev
跨渠道限购与游戏侧分布式事务落地
Next
排行榜 / 榜单