对于游戏来说,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 脚本保证原子性(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 加随机抖动。
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 在你们项目里怎么用”,最有说服力的回答是选一个你深度参与过的场景(比如排行榜或分布式锁),说清楚:遇到了什么问题,为什么选这个数据结构,踩过什么坑,最后怎么优化的。这比把八个场景都泛泛一提要有力得多。
