Redis 游戏与通用后端场景
先从业务要求选择数据结构,再判断哪些操作需要一起完成、失败后如何恢复。各类型的基本操作见 plus 数据类型文章。下文命令与 Go 代码是教学示例,本次未连接 Redis 实跑,不构成已验证的生产组件。
游戏后端选型
| 场景 | 类型与操作 | 业务边界 |
|---|---|---|
| 会话令牌 | String:SET sess:token uid EX 3600 | 撤销、续期、不可预测 token |
| 在线状态 | ZSet:score 为心跳时间,按超时阈值清理 | 定义离线窗口,避免无界全量清理 |
| 好友、黑名单 | Set:SADD、SINTER | 双向关系一致性与集群分槽 |
| 背包堆叠 | Hash:HINCRBY inv:1001 potion 5 | 原子加减不自动保证扣除后非负,也不代替持久化资产账目 |
| 抽卡保底 | Hash 字段按卡池计数 | 计数、重置、扣费与发奖需要统一业务状态 |
| 按评级匹配 | ZSet 按分数区间取候选,再校验和移除 | 在 Lua 内完成 Redis 状态变更;ZPOPMIN 不支持任意区间 |
| 每日活跃、留存 | Bitmap:按日键、用户偏移,BITCOUNT、BITOP AND | 月份键加用户偏移得到月活,不是日活 |
| 连续签到 | Bitmap 按用户键、日期偏移 | 置位数量不是连续天数,需处理跨月和业务时区 |
| 最近聊天历史 | List:LPUSH 加 LTRIM 0 199 | 严格限长时将两步原子执行 |
| 实时聊天广播 | Pub/Sub | 离线或断线会丢消息,需要重放时设计 Stream 消费流程 |
| 一次性兑换码 | Set:SREM codes:unused X 返回 1 才取得 | 删除码与数据库发奖不在同一事务,需领取记录及补偿 |
| 每日任务重置 | String / Hash 的键含业务日期,EXPIREAT 指定下一次重置 | 选定服务器或玩家时区,处理时区变更及夏令时 |
| 附近玩家、NPC | Geo:真实经纬度按范围查询 | 游戏平面坐标应另用空间索引,跨区域查询需合并边界结果 |
| 活动参与人数 | HyperLogLog:PFADD、PFCOUNT | 近似计数,不能用于精确奖励资格 |
| 比赛结果流水 | Stream:消费组,落库后 XACK | 数据库唯一事件 ID 防重,超时待处理消息需要接管 |
通用后端选型
| 场景 | 类型与操作 | 业务边界 |
|---|---|---|
| 分布式锁 | String:SET lock:x token NX PX 10000 | 比较 token 后释放,处理租约失效和旧持有者写入 |
| 幂等键 | String:NX 占位并带 TTL | 区分处理中、已成功、失败;保存结果及请求摘要,不能把重复占位当成功 |
| 固定窗口限流 | String:计数与首次设置过期 | 合为短 Lua 脚本,避免两条命令间崩溃 |
| 滑动窗口限流 | ZSet:清理、计数、加入 | 同一脚本,明确窗口边界与请求 ID |
| 延迟任务 | ZSet:执行时间作分数 | 领取、处理、确认分离,设计租约与重投 |
| 缓存击穿 | String:短期回源互斥或提前刷新 | 锁超时、刷新失败、旧值覆盖均需处理 |
| 标签、功能名单 | Set:SISMEMBER | 单成员查询快,不等于大集合运算也没有成本 |
| 热榜 | ZSet 精确计分,或 Top-K 概率排名 | Top-K 的算法与误差、可用命令取决于部署;不能等同于精确排行榜 |
Top-K 是独立的概率型能力,不必先手工拼接 Count-Min Sketch。使用前查目标服务是否支持 TOPK.*;时间窗口和热度衰减仍需要业务设计。参考:Top-K。
Go 示例的使用前提
代码使用 github.com/redis/go-redis/v9。rdb 是由调用方配置认证、超时和重试策略的 *redis.Client;依赖版本由项目锁定。不同片段可按需采用,公共导入如下:
import (
"context"
"crypto/rand"
"encoding/hex"
"errors"
"time"
"github.com/redis/go-redis/v9"
)
func requestToken() (string, error) {
var b [16]byte
if _, err := rand.Read(b[:]); err != nil {
return "", err
}
return hex.EncodeToString(b[:]), nil
}滑动窗口限流
脚本使用 Redis 服务端时间,减少多个应用节点时钟不一致的影响。窗口为 (now-window, now]。同一次逻辑请求重试应复用 requestID;窗口内已接受的 ID 返回相同允许结果,但这不代表该请求的业务副作用已经完成。
var slidingWindow = redis.NewScript(`
local window, limit = tonumber(ARGV[1]), tonumber(ARGV[2])
if not window or window < 1 or window ~= math.floor(window)
or not limit or limit < 1 or limit ~= math.floor(limit)
or ARGV[3] == '' then
return redis.error_reply('invalid limiter arguments')
end
local t = redis.call('TIME')
local now = tonumber(t[1]) * 1000 + math.floor(tonumber(t[2]) / 1000)
redis.call('ZREMRANGEBYSCORE', KEYS[1], '-inf', now - window)
if redis.call('ZSCORE', KEYS[1], ARGV[3]) then return 1 end
if redis.call('ZCARD', KEYS[1]) >= limit then return 0 end
redis.call('ZADD', KEYS[1], now, ARGV[3])
redis.call('PEXPIRE', KEYS[1], window)
return 1
`)
func Allow(ctx context.Context, rdb *redis.Client, uid, requestID string,
limit int, window time.Duration) (bool, error) {
if uid == "" || requestID == "" || limit <= 0 || window < time.Millisecond {
return false, errors.New("invalid limiter arguments")
}
result, err := slidingWindow.Run(ctx, rdb, []string{"rl:" + uid},
window.Milliseconds(), limit, requestID).Int()
if err != nil {
return false, err
}
return result == 1, nil
}调用方通过 requestToken 创建请求 ID,并确保它不由不可信客户端随意复用。拒绝时不追加成员;旧成员按窗口清理。服务器时间回拨、淘汰、重启与故障切换仍会影响限额,严格配额应使用更强的权威状态。非常大的窗口集合会增加清理成本。
到期任务的原子取出
这个示例只演示一次 Redis 调用中查找并移除一个到期任务,不提供可靠队列保证。必须由业务自行设置客户端重试策略:若响应丢失后自动重试,可能又取出另一个任务。
var claimDue = redis.NewScript(`
local t = redis.call('TIME')
local now = tonumber(t[1])
local jobs = redis.call('ZRANGEBYSCORE', KEYS[1], '-inf', now, 'LIMIT', 0, 1)
if #jobs == 0 then return nil end
redis.call('ZREM', KEYS[1], jobs[1])
return jobs[1]
`)
func ClaimDue(ctx context.Context, rdb *redis.Client, key string) (string, bool, error) {
job, err := claimDue.Run(ctx, rdb, []string{key}).Text()
if errors.Is(err, redis.Nil) {
return "", false, nil
}
if err != nil {
return "", false, err
}
return job, true, nil
}任务入队时 score 使用约定的 Unix 秒时间;发送方与 Redis 的时钟偏差会影响触发时间。若不能接受“已删除、尚未处理”的丢失窗口,应原子移入处理中集合,记录租约、领取者 token、重试次数,再确认删除;超时重新投递并对业务执行去重。两个集合在 Cluster 中应使用共同 hash tag,例如 jobs:{region1}:ready 与 jobs:{region1}:processing。
排行榜同分时间排序
下面检查完整组合分数的整数范围。积分越高越靠前,同积分时达到时间越早组合值越大,因此使用倒序查询。只适用于非负整数积分、秒级时间和明确的积分上界。
func RankScore(score, achievedAt int64) (float64, error) {
const scale int64 = 10_000_000_000
const maxExact int64 = 1<<53 - 1
if score < 0 || achievedAt <= 0 || achievedAt >= scale {
return 0, errors.New("score or timestamp out of range")
}
tie := scale - achievedAt
if score > (maxExact-tie)/scale {
return 0, errors.New("composite score exceeds exact integer range")
}
return float64(score*scale + tie), nil
}
func SaveRank(ctx context.Context, rdb *redis.Client, key, uid string,
score, firstAchievedAt int64) error {
value, err := RankScore(score, firstAchievedAt)
if err != nil {
return err
}
return rdb.ZAdd(ctx, key, redis.Z{Score: value, Member: uid}).Err()
}firstAchievedAt 必须由可信业务状态提供,保留首次达到该积分的时间;同一秒达成仍会按成员顺序排序。并发更新不能让旧分覆盖新分,需要按业务规则增加条件更新。超过范围时应重新设计排序模型,不能仅加一个 ZSet 就声称解决了跨键原子性和精度问题。
租约锁与比较释放
var releaseLock = redis.NewScript(`
if redis.call('GET', KEYS[1]) == ARGV[1] then
return redis.call('DEL', KEYS[1])
end
return 0
`)
func Acquire(ctx context.Context, rdb *redis.Client, key string,
ttl time.Duration) (string, bool, error) {
if ttl < time.Millisecond {
return "", false, errors.New("TTL must be at least one millisecond")
}
token, err := requestToken()
if err != nil {
return "", false, err
}
ok, err := rdb.SetNX(ctx, key, token, ttl).Result()
if err != nil || !ok {
return "", false, err
}
return token, true, nil
}
func Release(ctx context.Context, rdb *redis.Client, key, token string) (bool, error) {
result, err := releaseLock.Run(ctx, rdb, []string{key}, token).Int()
return result == 1, err
}只有 err == nil && ok 才开始业务工作。释放时使用仍可用且有超时的 context,并处理释放失败;返回 0 可能表示已失去租约。获取锁的网络超时同样是结果不确定,不能自行认为取得了锁。
比较 token 防止误删新持有者的锁,却不能阻止旧持有者在暂停后继续写入。续租也可能失败;关键写入需要下游验证 fencing token 或使用权威存储的条件写入、事务。主从异步复制的故障切换可能导致两个持有者,不能把单实例示例当作故障切换期间的强互斥保证。
跨命令与跨系统边界
Lua 可把同一 Redis 上的读取、判断和写入串成一次执行;运行时错误不会自动回滚此前写入。WATCH 配合事务需要有界重试。数据库提交、消息确认、外部发奖属于跨系统流程,必须另外设计幂等、恢复或补偿。
Cluster 的相关多键命令与 Lua 键一般需要处于同一 slot,共同 hash tag 是控制同槽的手段;单键操作不必为了形式强行加 tag。不要把全部业务塞进一个共同 tag,造成单个分片热点。
继续阅读
- 数据与原子性
- 数据与原子性
- 数据与原子性