Skip to content

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 指定下一次重置选定服务器或玩家时区,处理时区变更及夏令时
附近玩家、NPCGeo:真实经纬度按范围查询游戏平面坐标应另用空间索引,跨区域查询需合并边界结果
活动参与人数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;依赖版本由项目锁定。不同片段可按需采用,公共导入如下:

go
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 返回相同允许结果,但这不代表该请求的业务副作用已经完成。

go
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 调用中查找并移除一个到期任务,不提供可靠队列保证。必须由业务自行设置客户端重试策略:若响应丢失后自动重试,可能又取出另一个任务。

go
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。

排行榜同分时间排序 ​

下面检查完整组合分数的整数范围。积分越高越靠前,同积分时达到时间越早组合值越大,因此使用倒序查询。只适用于非负整数积分、秒级时间和明确的积分上界。

go
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 就声称解决了跨键原子性和精度问题。

租约锁与比较释放 ​

go
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,造成单个分片热点。

参考:go-redis、分布式锁、集群规格、事务。

继续阅读