i007.cc

i007.cc

优先队列-降维打击

05.价值资料

对于游戏来说,redis有哪些应用场景?最佳实践是什么?

Redis 在游戏后端里几乎无处不在,但不同场景对应不同数据结构,用错了反而制造问题。系统过一遍。


一、排行榜(最经典的游戏场景)

数据结构:Sorted Set

ZADD leaderboard:season:42  2850  "player:uid_001"
ZADD leaderboard:season:42  3100  "player:uid_002"

# 查全服前 100 名(降序)
ZREVRANGE leaderboard:season:42 0 99 WITHSCORES

# 查某玩家排名(0-indexed)
ZREVRANK leaderboard:season:42 "player:uid_001"

# 查某分数段的玩家数量(用于段位分布分析)
ZCOUNT leaderboard:season:42 2000 3000

 

最佳实践:

全服排行榜单个 Sorted Set 最多放几百万条,超过会有性能问题。通常做分层设计

实时榜:Redis Sorted Set,只存当前赛季积分
  ↓ 每小时同步一次
数据库:MySQL,存历史赛季完整数据
  
好友榜:不用全量排行榜,在 Redis 里只存好友 uid 集合,
        用 ZMSCORE 批量拉取各好友分数,客户端本地排序

 

不同榜要拆分不同 key,不要把全服榜、好友榜、公会榜混在一个 Sorted Set 里:

leaderboard:global:season:{id}
leaderboard:guild:{guild_id}:season:{id}
leaderboard:weekly:{week}

 


二、Session 管理与玩家在线状态

数据结构:Hash + String + Set

# 登录 Token 存储(Hash 存多个字段比多个 String key 省内存)
HSET session:token:{token}  uid 12345  
                            device_id "abc"  
                            login_time 1720000000
                            platform "iOS"
EXPIRE session:token:{token} 86400    # 24小时过期

# 在线玩家集合(用于统计 CCU)
SADD online:players "uid_001" "uid_002"
SCARD online:players   # 实时 CCU

# 玩家最后活跃时间(心跳更新)
SET player:heartbeat:{uid} {timestamp} EX 60   # 60s 无心跳视为离线

 

最佳实践:

Token 过期不要依赖客户端删除,一定要设 TTL。心跳用 SET EX 而不是 SET + EXPIRE 两条命令,保证原子性。CCU 统计如果精度要求不高,用 HyperLogLog 代替 Set,内存从 O(n) 降到固定 12KB:

PFADD daily:active:2026-07-31  "uid_001" "uid_002"
PFCOUNT daily:active:2026-07-31   # 近似唯一用户数,误差 < 1%

 


三、分布式锁(防止并发问题)

游戏里最典型的场景:玩家同时点两次”领取奖励”,两个请求同时到达,不加锁就会发双倍奖励。

lua
-- Lua 脚本保证原子性(SET NX + EXPIRE 的原子版本)
-- 加锁
SET lock:reward:{uid}:{reward_id}  {request_id}  NX  EX 5

-- 解锁(必须验证是自己加的锁,防止误删别人的锁)
if redis.call("GET", KEYS[1]) == ARGV[1] then
    return redis.call("DEL", KEYS[1])
else
    return 0
end

 

最佳实践:

锁的 key 要尽量细粒度,不能用全局锁。lock:reward:{uid}:{reward_id} 只锁这个玩家的这个奖励,不影响其他玩家。锁的 TTL 设为业务处理时间的 3 倍,业务失败要主动释放锁,不能全靠超时兜底。

对于更高可靠性要求,用 Redlock 算法(向 5 个独立 Redis 节点同时加锁,3 个成功才算获锁),防止单节点故障导致锁失效。


四、限流与防刷(配合防作弊)

数据结构:String(计数器)+ Sorted Set(滑动窗口)

# 固定窗口限流:每分钟最多购买 10 次
key = "rate:buy:{uid}:{minute}"
count = INCR key
if count == 1: EXPIRE key 60
if count > 10: reject("操作太频繁")

# 滑动窗口限流(更精确,防止窗口边界突刺)
now = current_timestamp_ms()
window_start = now - 60000  # 60秒窗口

ZREMRANGEBYSCORE rate:action:{uid} 0 {window_start}  # 清理过期记录
count = ZCARD rate:action:{uid}
if count >= 10: reject()
ZADD rate:action:{uid} {now} {now}    # 记录本次操作
EXPIRE rate:action:{uid} 60

 

游戏里的典型应用:射击频率检测(每秒不能超过 N 次)、购买限流、聊天防刷。


五、缓存玩家数据(减少数据库压力)

数据结构:Hash(玩家属性)

# 玩家基础信息缓存
HSET player:profile:{uid}
     nickname "Ronaldo99"
     level    45
     vip      3
     avatar   "https://..."
EXPIRE player:profile:{uid} 3600   # 1小时 TTL

# 读取(只取需要的字段,不要 HGETALL)
HMGET player:profile:{uid} nickname level

 

Cache-Aside 模式(游戏后端最常用):

读数据:先查 Redis → 命中直接返回 → 未命中查 MySQL → 写入 Redis → 返回
写数据:先写 MySQL(持久化优先)→ 删除 Redis 缓存(不是更新!)
         → 下次读时重建缓存

 

写操作删缓存而不是更新缓存,是为了防止并发写导致缓存和数据库不一致。

缓存穿透防护: 玩家查一个不存在的 uid,每次都打到数据库。解决方案:不存在的 key 也缓存一个空值,TTL 设短一点(60s)。

result = db.query(uid)
if result is None:
    redis.set(f"player:{uid}", "NULL", ex=60)   # 缓存空值
else:
    redis.hset(f"player:{uid}", result)

 

缓存雪崩防护: 大量 key 同时过期,瞬间打垮数据库。解决方案:TTL 加随机抖动。

cpp
int ttl = BASE_TTL + rand() % (BASE_TTL / 5);   // ±20% 随机
redis.expire(key, ttl);

 


六、游戏房间与匹配队列

数据结构:Hash(房间状态)+ List/Sorted Set(匹配队列)

# 游戏房间状态
HSET room:{room_id}
     state     "waiting"        # waiting/playing/ended
     player1   "uid_001"
     player2   "uid_002"
     created   {timestamp}
     map_id    5
EXPIRE room:{room_id} 7200     # 2小时兜底清理

# 匹配队列(Sorted Set,score = 入队时间戳,优先匹配等待久的)
ZADD matchqueue:ranked:gold  {timestamp}  "uid_001:{mmr}:{region}"

# 取出等待最久的 N 个候选玩家
ZRANGE matchqueue:ranked:gold 0 49 WITHSCORES

 


七、Pub/Sub 与跨服通知

# 发布:全服公告、活动开始通知
PUBLISH channel:global:notice  '{"type":"event_start","event_id":42}'

# 发布:精准推送给特定玩家(服务器间通信)
PUBLISH channel:player:{uid}   '{"type":"friend_request","from":"uid_002"}'

# 订阅方(各游戏服务器节点监听)
SUBSCRIBE channel:global:notice
PSUBSCRIBE channel:player:*    # 通配符订阅

 

最佳实践: Pub/Sub 是”发后不管”,消息不持久化,订阅者离线会丢消息。如果需要可靠投递(比如离线消息),改用 Redis Stream

# 生产者:写消息到 Stream
XADD notifications:{uid} * type "friend_request" from "uid_002"

# 消费者:读取未消费消息,支持 ACK 确认,支持重放
XREADGROUP GROUP app_servers consumer1 COUNT 10 STREAMS notifications:{uid} >
XACK notifications:{uid} app_servers {message_id}

 


八、延迟任务队列

游戏里常见:签到奖励定时发放、赛季结算、限时活动到期处理。

# 用 Sorted Set 实现延迟队列,score = 执行时间戳
ZADD delay:tasks  {execute_at}  '{"type":"season_end","season_id":42}'

# 定时轮询(每秒执行一次)
tasks = ZRANGEBYSCORE delay:tasks 0 {now} LIMIT 0 100
for task in tasks:
    process(task)
    ZREM delay:tasks task

 


九、常见坑与最佳实践总结

Key 命名规范

{业务}:{对象}:{id}:{子项}

player:profile:uid_001
room:state:room_42
leaderboard:season:5:global
rate:buy:uid_001:2026073114    # 带时间窗口

 

冒号分隔,层次清晰,Redis Cluster 按 hash slot 分片时可以用 {uid_001} 花括号锁定 hash tag,保证同一玩家的 key 在同一个 slot。

绝对禁止的操作

KEYS * 在生产环境是灾难,会阻塞整个 Redis。用 SCAN 替代:

SCAN 0 MATCH player:* COUNT 100

 

HGETALL 在 Hash 字段很多时代价高,只取需要的字段用 HMGET

大 Value 要拆分,单个 Value 超过 10KB 就要考虑拆分或压缩,超过 1MB 基本上不应该存 Redis。

内存管理

所有 key 都要设 TTL,游戏里没有理由让数据永久存在 Redis——热数据最终都会从数据库重建。定期用 INFO memory 和 MEMORY USAGE {key} 监控内存使用。

持久化策略

排行榜、匹配队列:RDB 即可(允许少量数据丢失)
订单、锁、关键状态:AOF + fsync always(每次写都落盘,最安全)
缓存类数据:不需要持久化,重启从 DB 重建

 

游戏服务器通常对 Redis 的定位是”可重建的高速缓存 + 协调层”,真正不能丢的数据要进 MySQL,Redis 挂掉不能影响数据正确性。


面试时如果被问”Redis 在你们项目里怎么用”,最有说服力的回答是选一个你深度参与过的场景(比如排行榜或分布式锁),说清楚:遇到了什么问题,为什么选这个数据结构,踩过什么坑,最后怎么优化的。这比把八个场景都泛泛一提要有力得多。

发表回复