做 Unity 项目做到一定阶段,你会发现 PlayerPrefs 真的不够用了。排行榜要跨端实时更新,用户会话要共享,游戏配置要随时热更,数字孪生项目里的设备状态还得让多个客户端同时读到。这时候多数人的第一反应是“上数据库”,但如果直接上 MySQL 或者 MongoDB,又重又慢,还牵扯一堆运维问题。我这两年帮团队接过的项目里,Redis + Unity 这套组合越来越多的出现在技术方案里,而且确实解决了不少实际问题。

别误会,这不是让你用 Unity 客户端直连生产环境的 Redis。更合理的架构是:Unity 端读写 Redis,要么走一个轻量后端网关,要么在编辑器工具、内部工具、局域网联机、数字孪生可视化这类可信任场景下直接连接。Redis 能火这么多年,靠的就是内存级的速度、丰富的数据结构、自动过期清理,以及极低的运维成本。这篇文章我把自己在 Windows、Docker、Linux 服务器上把 Redis 和 Unity 搭起来的完整过程写清楚,包括客户端选型、序列化陷阱、线程模型、主从复制和持久化配置,踩过的坑也都标出来。无论你是刚开始接触 Redis 的 Unity 开发者,还是已经在用但经常遇到诡异问题的老手,这篇应该都能给你一些参考。

1. Unity 项目引入 Redis 前,先理清你到底要解决什么问题

1.1 那些让 PlayerPrefs 力不从心的场景

PlayerPrefs 本质是本地键值存储,单机没问题,一旦涉及“多端共享”“实时同步”“服务端校验”就彻底没戏了。

我自己最早遇到这个瓶颈,是在一个展厅互动项目里。三台触摸屏同时展示同一套数字沙盘,用户在一号屏上切换了视角,二号屏三号屏也要跟着切。用 PlayerPrefs 怎么做?没得做。后来方案改成:三台 Unity 客户端同时订阅 Redis 的一个频道,一号屏把视角参数写进去,另外两个屏收到消息直接应用,延迟目测不到 50 毫秒,体感上就是“实时联动”。

类似的场景还有很多:

  • 排行榜 :玩家分数实时写入,Top100 实时读取,还要处理同分排序;
  • 用户会话 :登录 token 和玩家基本数据放 Redis,带过期时间,避免每次请求都查 SQL;
  • 服务端动态配置 :活动开关、礼包配置、公告内容,改完 Redis 客户端立刻生效,不重新发包;
  • 工业/数字孪生 :PLC、串口采集上来的实时数据通过 Redis 暂存,Unity 可视化端轮询或订阅刷新,比直连数据库高效得多;
  • 脱机缓存层 :多个微服务共用的热点数据,比如地图配置、物品表,统一放 Redis,降低数据库压力。

如果你手头有这些需求,Redis 几乎是最合适的选择。它不像传统数据库那样需要建表、写 SQL、做复杂查询,本质上就是“带数据结构的内存字典”,上手门槛低得多。

1.2 Redis 为什么适合做游戏/仿真工具的“在线内存层”

Redis 把所有数据保存在内存里,因此读写速度在微妙到毫秒级别。和 MySQL 动辄几毫秒到几十毫秒的查询延迟相比,Redis 的响应时间几乎可以忽略不计。对于 Unity 这种要求 60 帧稳定渲染的客户端来说,后端接口响应时间越短,前端等待越少,交互就越跟手。

除了快,Redis 的几个特性对游戏/仿真场景是量身定做的:

  • TTL 过期机制 :一条验证码、一个临时会话、一个限时活动的配置,都可以直接设置过期时间,到期自动删除,不需要写定时任务去清理。
  • 丰富的数据结构 :String 可以做缓存和计数器,Hash 适合存对象属性,List 可以做消息队列,Set 可以做去重和标签,ZSet 天然就是排行榜。这在后面的章节里我会逐个给出 Unity 侧的落地示例。
  • 发布订阅 :一个客户端发消息,所有订阅者都能收到。多人协同、数据联动、消息通知这类需求,一条 Subscribe 就搞定。
  • 单线程模型与原子操作 :Redis 的单个命令是原子性的,INCR、SETNX 这类操作天然防并发竞争。多个 Unity 端同时修改一个数值的时候,不会出现莫名其妙的数据错乱。

1.3 选 Redis 不选 MySQL/MongoDB 的判断依据

有团队上来就说“我们用 MySQL”,我通常都会问一句:你的数据有强事务要求吗?需要复杂关联查询吗?如果答案是“没有”,那用 MySQL 就是给自己找麻烦——建表、索引、连接池、事务隔离级别,每个环节都有维护成本,而且查询速度远不如 Redis。

MongoDB 的数据模型确实灵活,文档型结构很适合存放游戏存档和日志,但它的内存占用比 Redis 高不少,部署和维护也重。MongoDB 的服务端至少要 2GB 内存起步,跑起来风扇呼呼转;Redis 给个 128MB 的容器就能欢快地跑一天。

当然,这不是说 Redis 要替代所有数据库。实践中我经常把 Redis 放在数据库前面,做成缓存层:MySQL 存最终数据和需要复杂查询的业务数据,Redis 存热数据和临时状态。数据量几百 GB 没关系,Redis 只负责把访问最频繁的那一小部分兜住,架构反而更稳。

注意:Redis 不是“数据库”的完全替代品,它是“数据访问加速层 + 临时状态存储层”。把永久性用户资产只放 Redis,一旦实例宕机且没开持久化,数据就真没了。这一点务必想清楚。

2. 环境搭建:Windows 直装与 Docker 两条路线怎么选

2.1 Windows 本地开发:免安装 zip 包与开机自启

开发期不想折腾 Docker 的话,Windows 下跑 Redis 最简单的方式是找社区维护的 Windows 移植版。Windows 官方没有提供 Redis 服务端安装包,但开源社区有现成的 zip 包,解压就能用。

我习惯的目录结构是这样的:

D:\Redis\
  redis-server.exe
  redis-cli.exe
  redis.windows.conf
  data\

启动方式:

redis-server.exe D:\Redis\redis.windows.conf

想验证是否启动成功,另开一个 cmd 窗口:

redis-cli.exe -p 6379 ping

返回 PONG 就说明成功。不想每次手动启动,可以用 win+r 打开运行,输入 shell:startup ,把 redis-server.exe 的快捷方式扔进去,开机自动跑。

Windows 版 Redis 在本地开发调试够用了,但如果你的项目后续要部署到 Linux 服务器,建议尽早切换。原因很实在:Windows 寄生的 Redis 版本普遍落后于官方,有些新特性(比如 Redis 7 的 Function)用不了,而且性能表现也不如 Linux 原生。我的建议是 Windows 版只用来写业务逻辑、调通流程,生产环境一律 Linux + Docker。

2.2 Docker 起飞:redis-stack 镜像与内置可视化面板

Docker 是当前最舒服的一条路,尤其在你需要快速切换版本、搭建主从集群、模拟不同配置环境的时候。Docker 装 Redis 只需要一条命令:

docker run -d --name redis-stack \
  -p 6379:6379 -p 8001:8001 \
  -v /myredis/data:/data \
  -v /myredis/redis.conf:/usr/local/etc/redis/redis.conf \
  redis/redis-stack:latest \
  redis-server /usr/local/etc/redis/redis.conf

我强烈建议用 redis/redis-stack 而不是裸的 redis 镜像,因为它内置了 RedisInsight 可视化管理面板,访问 http://localhost:8001 就能看到当前所有 key、内存占用、连接数、慢查询日志,还能直接执行命令和查看数据。开发调试阶段这个面板能省非常多时间。

如果你的项目只需要最简版,不想挂配置文件:

docker run -d --name redis-dev -p 6379:6379 redis:7-alpine

启动后用任意 Redis 客户端工具连 localhost:6379 就行。Docker 的好处是环境隔离,不乱动宿主机的依赖,换版本也就一行命令的事,删掉容器重新拉镜像,开发环境分分钟重建。

2.3 连接验证与常用可视化管理工具

环境起来后,先别急着写 C# 代码,用命令行工具验证一下基本读写:

redis-cli -h localhost -p 6379

# 写入一个字符串
set hello "unity redis"

# 读取
get hello

# 查看所有 key
keys *

命令行验证通过后,再安装一个可视化管理工具。历史上有两个常用的:Redis Desktop Manager 和 Another Redis Desktop Manager。前者新版已经收费,社区版比较老旧,对新版 Redis 的支持不完整;后者完全开源免费,跨平台,支持 SSH 隧道、集群模式、JSON 查看,开发调试够用了。还有一个小技巧:如果你项目已经用 Docker,直接装 RedisInsight 最有性价比,不用再单装工具。

3. Unity 侧的客户端选型,以及连接管理器必须注意的三个细节

3.1 为什么我最终选了 StackExchange.Redis

Unity 里操作 Redis 的 C# 客户端,市面上基本就是 StackExchange.Redis 和 ServiceStack.Redis 二选一。

先说结论:直接用 StackExchange.Redis。原因有四个:

  • ServiceStack.Redis 从 v4 开始是商业许可,免费版有连接数和功能限制。团队内部几个人用看不出问题,一旦要接入多个 Unity 客户端,连接数限制就是噩梦。
  • StackExchange.Redis 是微软生态内最主流的 Redis 客户端,Stack Overflow 和 GitHub 上大量现成代码和踩坑资料,遇到问题容易搜到答案。
  • 它和 .NET Standard 2.0 兼容,Unity 2018 之后的版本都能直接跑。
  • 内部维护了连接池和自动重连机制,虽然使用者能感知底层变化,但完全不用自己手动管理复杂的连接状态。

安装方式最简单的是直接到 NuGet 页面下载 StackExchange.Redis 的 nupkg 包,解压后把 netstandard2.0 目录下的 StackExchange.Redis.dll 、 System.IO.Compression.dll (部分版本需要)拖进 Unity 的 Assets/Plugins 目录。另外,建议使用 NuGetForUnity 插件管理依赖,后续升级版本也方便。

3.2 连接管理器的最简封装(单例 + 重连策略)

在 Unity 里,连接管理器的目标是最小化连接实例数。StackExchange.Redis 里的 ConnectionMultiplexer 是线程安全且建议全局共享的一个连接对象,不要每次读写都 new,否则会带来大量 TCP 连接和资源开销。

我一般是这样封装的:

using StackExchange.Redis;
using UnityEngine;

public class RedisManager
{
    private static ConnectionMultiplexer _redis;
    private static readonly object LockObj = new object();

    public static ConnectionMultiplexer Connection
    {
        get
        {
            if (_redis == null || !_redis.IsConnected)
            {
                lock (LockObj)
                {
                    if (_redis == null || !_redis.IsConnected)
                    {
                        var config = new ConfigurationOptions
                        {
                            EndPoints = { "localhost:6379" },
                            AbortOnConnectFail = false,
                            ConnectRetry = 5,
                            ConnectTimeout = 5000,
                            SyncTimeout = 3000,
                            KeepAlive = 30,
                            Password = "yourpassword"  // 没有密码就不填
                        };

                        _redis = ConnectionMultiplexer.Connect(config);

                        // 注册断线重连事件,方便排查
                        _redis.ConnectionRestored += (s, e) =>
                        {
                            Debug.Log($"[Redis] 连接恢复: {e.EndPoint}");
                        };
                        _redis.ConnectionFailed += (s, e) =>
                        {
                            Debug.LogWarning($"[Redis] 连接失败: {e.EndPoint}, {e.Exception?.Message}");
                        };
                    }
                }
            }

            return _redis;
        }
    }

    public static IDatabase GetDatabase()
    {
        return Connection.GetDatabase();
    }
}

这个封装的关键点是 AbortOnConnectFail = false 。这个参数的意思是:即使启动时连不上 Redis, Connect 也不会抛异常,而是改在后台持续重试连接。没有这个参数,Unity 在编辑器启动阶段如果 Redis 没开,直接就会 SocketException 导致编辑器像卡死了一样。这个坑我踩过不止一次,每次都是“怎么没跑就崩了?”然后用排查工具定位半天,最后发现只是忘了启动 Redis。

3.3 现网必踩:abortConnect=false 的真正含义

很多新人把 AbortOnConnectFail = false 当成“万能保险”,以为设置完就不会报错。实际不是。它只是让 Connect 方法不立刻失败,但后续所有 Redis 命令在连接没有恢复之前依然会超时或者抛异常。

SyncTimeout 和 AsyncTimeout 默认都是 5000 毫秒,如果你在游戏主线程里同步执行 Redis 命令,网络一抖动,你的 Unity 主线程就会被阻塞 5 秒,画面直接卡住。这是游戏客户端的大忌。

所以我在协议上有一个很明确的实践约定:

  • 主线程只发异步命令 ,用 async/await 或回调处理结果;
  • 绝对不要在主线程同步等待 Redis 响应 ,除非你在做编辑器工具。

为了安全,还可以把 SyncTimeout 调低到 1000 毫秒,让同步误用尽快暴露,而不是卡住玩家。

StackExchange.Redis 的命令 API 分为同步和异步两个版本,比如 StringSet 与 StringSetAsync 、 ListLeftPush 与 ListLeftPushAsync 。Unity 中我全面使用异步版本,配合协程或者 async/await ,完全不影响 UI 帧率。

Unity 的 async/await 在旧版本(2020 之前)需要自己处理 SynchronizationContext ,但新版本默认会把上下文切回主线程,使用上还算舒服。不过要注意,这个行为在某些平台(比如 WebGL)是受限的,后面第 7 章我会展开讲线程问题。

4. 五种核心数据类型,分别对应 Unity 项目里的哪些需求

4.1 String:缓存与计数器

String 是最基础的类型,应用场景也最直接:缓存配置 JSON、存储序列化后的对象、做计数器。

比如玩家金币发生变化:

// 增加 10 个金币
await db.StringIncrementAsync("player:1001:gold", 10);

// 减少 5 个金币
await db.StringDecrementAsync("player:1001:gold", 5);

关键是 INCR/DECR 是原子操作,多个客户端同时修改也不会出现“丢更新”的问题。缓存配置时顺带设置过期时间:

// 缓存一天
await db.StringSetAsync("config:activity:20250101", jsonStr, TimeSpan.FromDays(1));

4.2 Hash:玩家/设备属性就地更新

Hash 相当于一个二层的字典,最适合存“对象型数据”。拿在线玩家状态举例:

// 写入玩家坐标与朝向
await db.HashSetAsync("player:1001:state", new HashEntry[]
{
    new HashEntry("x", "12.5"),
    new HashEntry("y", "0"),
    new HashEntry("z", "-3.2"),
    new HashEntry("dir", "180")
});

// 只更新 x 坐标
await db.HashSetAsync("player:1001:state", "x", "13.8");

// 读取全部属性
var entries = await db.HashGetAllAsync("player:1001:state");

foreach (var item in entries)
{
    Debug.Log($"{item.Name}: {item.Value}");
}

用 Hash 的好处是:如果玩家只移动了 x 坐标,不需要把整个对象反序列化再重新写入,一条 HSET 命令就地解决。这在频繁上报状态的数字孪生项目里非常管用,PLC、串口采集的数据按设备为 key,每个点位是字段,Unity 端只更新变化的字段就行。

4.3 List:轻量消息队列与历史轨迹

List 底层是双向链表,头部插入、尾部弹出都很快。适合做“最近记录”和轻量级消息队列。

比如玩家最近 50 条操作日志:

// 左侧插入一条新记录
await db.ListLeftPushAsync("log:player:1001", "攻击了怪物A");

// 只保留最近 50 条
await db.ListTrimAsync("log:player:1001", 0, 49);

// 从右端遍历全部记录
var logs = await db.ListRangeAsync("log:player:1001");

两台 Unity 客户端之间做简单消息传递,也能用 List:A 端 LPUSH,B 端 BRPOP 阻塞等待。这个模式比直接轮询数据库更轻量,响应也及时。不过消息量大了之后,队列消费状态、失败重试这些都要自己考虑,真要上生产级的消息队列,还是得用 RabbitMQ 或 Kafka。Redis List 的优势在“轻量、够用”。

4.4 Set:去重、标签与在线集合

Set 是无序、去重的字符串集合。可以用来存储玩家已领取的奖励 ID、在线玩家 ID 列表、物品标签等。

// 玩家领取了活动奖励 ID 为 101
await db.SetAddAsync("award:player:1001", "101");

// 判断是否已经领过
bool isGot = await db.SetContainsAsync("award:player:1001", "101");

// 在线玩家集合加一人
await db.SetAddAsync("online:players", "player:1001");

// 统计在线人数
long onlineCount = await db.SetLengthAsync("online:players");

// 玩家下线时移除
await db.SetRemoveAsync("online:players", "player:1001");

判断“是否已经 XX”这种场景,Set.Contains 时间复杂度是 O(1),就算集合里有几万条数据,响应也快。另外 Set 之间还能做并集、交集、差集,比如“同时在线并且是公会成员”的玩家集合,一条命令就出来了。

4.5 ZSet:排行榜与热力 TOP N

ZSet 是有序集合,每个成员带一个 double 类型的分数,按分数自动排序。排行榜功能在游戏里几乎是标配,用 ZSet 实现是最省力的方案。

// 玩家分数增加 150
await db.SortedSetIncrementAsync("rank:season:1", "player:1001", 150);

// 获取 Top10
var top10 = await db.SortedSetRangeByRankWithScoresAsync(
    "rank:season:1", 0, 9, Order.Descending);

// 查询某玩家当前排名
long rank = await db.SortedSetRankAsync("rank:season:1", "player:1001", Order.Descending);

ZSet 在底层是一个跳跃表 + 哈希表的组合,写入、查询、排行全部都是 O(log N) 级别。配合 ZREMRANGEBYRANK 还能一键清理超出指定名次的数据,比如只保留前 1000 名,后面的自动删,方便又高效。

5. 序列化这件事,比想象中更容易翻车

5.1 Unity 自带类型序列化后无法直接还原

Redis 里存的是字符串或字节数组,所以在 Unity 侧“对象 -> 字符串 -> 对象”这个环节必然要做序列化。

最容易翻车的是 Unity 自带的 Vector3 、 Quaternion 、 Color 这种类型。虽然 JsonUtility 能序列化它们,但它序列化出来的是 { "x": 1.0, "y": 2.0, "z": 3.0 } ,反序列化时如果目标类型是简单的 POCO,字段名能对上;可万一你存的是 Dictionary<string, Vector3> ,JsonUtility 直接不支持 Dictionary,结果就是字段全丢。

所以我的建议是: 在数据边界处定义干净的 DTO,而不是直接把 Unity 类型丢给 Redis 。

[Serializable]
public class PlayerStateDTO
{
    public float x;
    public float y;
    public float z;
    public float dir;
    public int hp;
}

这种 DTO 既能被 JsonUtility 序列化,也能被 Newtonsoft.Json 序列化,还能逐字段写入 Hash。千万不要偷懒把 Transform 整个塞进去,那里面包含大量 Unity 内部字段,序列化出来既大又不具可读性,还会在反序列化时报错。

5.2 Newtonsoft.Json 与 IL2CPP 的兼容处理

Unity 2019.4 之后,官方将 Newtonsoft.Json 做成了包管理器格式,安装路径是:

Window -> Package Manager -> Add package by name -> com.unity.nuget.newtonsoft-json

直接安装即可使用。但如果你的项目要发布到 Android / iOS / 微信小游戏平台,编译目标是 IL2CPP / AOT,就需要特别注意:Newtonsoft.Json 在 AOT 环境下会遇到一些反射裁剪问题。常见表现是运行时抛出 MissingMethodException 或 SerializationException 。

解决办法是添加 link.xml 配置文件,告诉 IL2CPP 保留要序列化类型的元数据。最简单的做法是把所有 DTO 类所在的程序集名写进去:

<linker>
  <assembly fullname="Assembly-CSharp" preserve="all" />
</linker>

但“preserve=all”会增大包体,不够精细。更稳妥的方式是只保留需要的类型:

<linker>
  <assembly fullname="Assembly-CSharp">
    <type fullname="PlayerStateDTO" preserve="all" />
    <type fullname="RankItem" preserve="all" />
  </assembly>
</linker>

每次新增 DTO 都要记得加,否则真机上一跑序列化就崩溃。编辑器正常不代表真机正常,这个坑在 Android 发布后排查极其痛苦,建议一开始就养成加 link.xml 的习惯。

5.3 场景数据、二进制协议与版本兼容

除了 JSON,Redis 也支持直接存二进制字节。如果对性能要求特别高,比如要缓存 Mesh 数据、Texture 数据、寻路网格这类大对象,可以走 MessagePack 这类二进制序列化方案,数据体积远比 JSON 小,解析速度也快不少。但要对版本兼容保持敏感:一旦数据结构加了字段,老版本客户端反序列化就会失败。

我经历过一次线上事故:一个数字孪生项目把设备状态缓存在 Redis 里,用 BinaryFormatter 序列化。升级客户端后,新版本序列化的数据老版本客户端根本读不了,结果同场有三台旧版本机,直接显示不出设备状态。后来全部改成 JSON + 字段可缺省策略,彻底规避了这种问题。

一个朴素的结论是: 跨语言、跨版本的数据交换,优先 JSON 而不是二进制序列化 。除非你确定所有读写端都在你控制范围内,并且会同步升级,否则别用二进制。

6. 实战一:基于 ZSet 的排行榜,以及缓存穿透的兜底逻辑

6.1 排行榜完整代码

排行榜是 Unity 游戏里最典型的需求,我直接给一套能跑的代码。需求:每日排行榜、Top100、实时查看个人名次。

using System;
using System.Collections.Generic;
using System.Threading.Tasks;
using StackExchange.Redis;
using UnityEngine;

public class RankService
{
    private readonly IDatabase _db;

    public RankService(IDatabase db)
    {
        _db = db;
    }

    private string RankKey(DateTime date)
    {
        return $"rank:day:{date:yyyyMMdd}";
    }

    // 上报分数(累加模式)
    public async Task AddScoreAsync(string playerId, double score, DateTime date)
    {
        string key = RankKey(date);
        await _db.SortedSetIncrementAsync(key, playerId, score);
    }

    // 获取 Top N,带分数
    public async Task<List<RankItem>> GetTopAsync(int n, DateTime date)
    {
        string key = RankKey(date);
        SortedSetEntry[] entries = await _db.SortedSetRangeByRankWithScoresAsync(
            key, 0, n - 1, Order.Descending);

        var result = new List<RankItem>();
        for (int i = 0; i < entries.Length; i++)
        {
            result.Add(new RankItem
            {
                Rank = i + 1,
                PlayerId = entries[i].Element.ToString(),
                Score = entries[i].Score
            });
        }
        return result;
    }

    // 查询个人名次(0 表示第一名,所以显示时 +1)
    public async Task<long> GetPlayerRankAsync(string playerId, DateTime date)
    {
        string key = RankKey(date);
        long? rank = await _db.SortedSetRankAsync(key, playerId, Order.Descending);
        return rank.HasValue ? rank.Value + 1 : -1;
    }

    // 保留前 1000 名,后面的清理掉,防止数据无限膨胀
    public async Task<int> TrimRankAsync(DateTime date, int keepCount = 1000)
    {
        string key = RankKey(date);
        RedisResult result = await _db.ExecuteAsync(
            "ZREMRANGEBYRANK", key, keepCount, -1);
        return (int)result;
    }
}

[Serializable]
public class RankItem
{
    public int rank;
    public string playerId;
    public double score;
}

这段代码有几个细节说明一下:

  • SortedSetIncrementAsync 是原子加分,并发上报不会相互覆盖;
  • SortedSetRangeByRankWithScoresAsync 按名次区间取出带分数的数据,比一次性取全部再排序稳得多;
  • 用 ExecuteAsync 直接执行 ZREMRANGEBYRANK 做清理,避免 ZSet 无限膨胀。

6.2 缓存穿透/击穿/雪崩的 Unity 侧兜底

很多 Unity 开发者第一次接触 Redis 时,以为“加了缓存就万事大吉”,但其实缓存失效风暴才是真正的隐患。在游戏场景里尤其典型的就是排行榜和热更配置的缓存。

缓存穿透 :查询一个不存在的 key,导致每次都打到数据库或源头接口。兜底方案是把空结果也缓存起来,并给一个较短的过期时间(比如 30 秒)。我在 Unity 侧的封装是这样的:

public async Task<string> GetOrFetchAsync(string key, Func<Task<string>> fetch, int expireSeconds = 300)
{
    RedisValue cached = await _db.StringGetAsync(key);
    if (cached.HasValue)
    {
        // 命中缓存直接返回
        return cached.ToString();
    }

    // 没有缓存则回源加载
    string data = await fetch();

    if (data == null)
    {
        // 防穿透:空值也缓存,过期时间短一些
        await _db.StringSetAsync(key, "", TimeSpan.FromSeconds(30));
        return null;
    }

    await _db.StringSetAsync(key, data, TimeSpan.FromSeconds(expireSeconds));
    return data;
}

缓存击穿 :某个热点 key 在过期瞬间,大量请求同时回源。兜底方案用分布式锁,保证只有一个线程回源,其他线程等待后直接读新缓存。下面的分布式锁代码在下一节会细讲。

缓存雪崩 :大量 key 在同一时间过期,造成大面积回源。兜底方案是给不同的 key 设置不同的随机过期时间。

// 基准过期时间 300 秒 + 随机 0~60 秒,分散过期时间
int ttl = 300 + UnityEngine.Random.Range(0, 60);
await _db.StringSetAsync(key, data, TimeSpan.FromSeconds(ttl));

这些思想虽然最早来自 Web 后端,但 Unity 客户端面对多个场景切换、重连、同时回源的时候,同样适用。项目接的设备多了、用户多了,这些防线就是稳定性的基本盘。

7. 实战二:分布式锁和会话管理,多客户端协作的基础设施

7.1 SET NX EX 分布式锁的正确写法

多个 Unity 客户端同时处理同一份数据时,尤其是排行榜结算、活动奖励发放、设备状态写入这类场景,很容易出现并发冲突。Redis 里最经典的分布式锁写法是:

public async Task<bool> TryLockAsync(string lockKey, string token, TimeSpan expiry)
{
    return await _db.StringSetAsync(lockKey, token, expiry, When.NotExists);
}

这里的关键点是 When.NotExists 。只有 key 不存在时才能写入成功,写入成功代表抢到锁。token 用 Guid 生成,用来标识“这把锁是谁创建的”,防止误删别人的锁。

string token = Guid.NewGuid().ToString();
bool locked = await TryLockAsync("lock:rank:settle", token, TimeSpan.FromSeconds(10));

if (!locked)
{
    Debug.Log("另一台客户端正在结算,稍后重试");
    return;
}

try
{
    await SettleDailyRankAsync();
}
finally
{
    await ReleaseLockAsync("lock:rank:settle", token);
}

锁的过期时间不建议太长,正常 5~10 秒足够。如果业务逻辑超过了过期时间,锁会被自动释放,其他客户端就能进来,避免死锁。当然这也会导致临界区可能并发,如果要求绝对严格,就需要引入 RedLock 这类更复杂的方案。大多数游戏/展厅场景设置一个合理过期时间就够了。

7.2 Lua 保证释放锁的原子性

释放锁不能简单 DeleteAsync ,否则可能误删其他线程后来抢到的锁。标准做法是用 Lua 脚本比较 token,匹配才删除:

public async Task ReleaseLockAsync(string lockKey, string token)
{
    const string script = @"
        if redis.call('get', KEYS[1]) == ARGV[1] then
            return redis.call('del', KEYS[1])
        else
            return 0
        end";

    await _db.ScriptEvaluateAsync(script, new RedisKey[] { lockKey }, new RedisValue[] { token });
}

这段 Lua 脚本在 Redis 服务端原子执行,不会出现“先判断再删除”中间的间隙问题。判断和删除在同一个命令序列里完成,不会被并发请求插队。如果你看到有人用 Get + Del 两条命令来释放锁,那是错的,中间一定会产生竞态窗口。

7.3 会话管理与滑动过期

多端登录场景里,登录 token 对应的会话信息放 Redis,还可以实现“滑动过期”:用户每次活跃就把过期时间刷新一下,连续 30 分钟没活跃才自动踢下线。

public async Task<bool> ValidateAndRefreshSessionAsync(string token, TimeSpan sessionTimeout)
{
    string key = $"session:{token}";

    // 更新过期时间的原子操作:过期时间向后滑动
    bool refreshed = await _db.KeyExpireAsync(key, sessionTimeout);
    if (refreshed)
    {
        // 顺手更新最后活跃时间
        await _db.HashSetAsync(key, "lastActive", DateTime.UtcNow.ToString("O"));
    }

    return refreshed;
}

KeyExpireAsync 在 key 不存在时返回 false,存在时更新过期时间并返回 true。这样一条命令就完成了“校验 + 滑动续期”,不需要先读取判断再写入,又省一次网络往返,又避免条件竞争。

8. 躲不开的线程模型问题:Unity 主线程、Async 回调与 Redis 响应

8.1 为什么不能在 Redis 回调里直接操作场景对象

Unity 引擎的大部分 API 只能在主线程调用。修改 Transform 、操作 UI、实例化对象,如果在非主线程里做,轻则报 UnityException: get_transform can only be called from the main thread ,重则直接崩溃。

StackExchange.Redis 的异步回调往往在线程池线程上执行。这意味着如果你这样写:

private async void OnClick()
{
    RedisValue val = await _db.StringGetAsync("some:key");
    // 这里不一定在主线程!
    _text.text = val.ToString();
}

在多数平台下 await 会尽量回到调用时的同步上下文,所以 MonoBehaviour 里的 await 通常能回到主线程。但部分场景下这个上下文会丢失,尤其是从回调或事件处理器发起 await 时,就可能在非主线程里更新 UI,导致诡异报错。

最稳妥的方案是: 不让业务逻辑直接依赖 await 后的线程,而是把结果放队列,由 MonoBehaviour 的 Update 统一处理 。

8.2 ConcurrentQueue 缓冲 + MonoBehaviour 轮询的通用模式

我现在的做法是一个简易消息分发器:

using System;
using System.Collections.Concurrent;
using UnityEngine;

public class MainThreadDispatcher : MonoBehaviour
{
    public static MainThreadDispatcher Instance { get; private set; }

    private readonly ConcurrentQueue<Action> _executionQueue = new ConcurrentQueue<Action>();

    private void Awake()
    {
        if (Instance != null && Instance != this)
        {
            Destroy(gameObject);
            return;
        }

        Instance = this;
        DontDestroyOnLoad(gameObject);
    }

    public void ExecuteOnMainThread(Action action)
    {
        _executionQueue.Enqueue(action);
    }

    private void Update()
    {
        while (_executionQueue.TryDequeue(out Action action))
        {
            action?.Invoke();
        }
    }
}

用法:

RedisValue val = await _db.StringGetAsync("some:key");
string content = val.ToString();  // 非主线程也能安全读取纯字符串

// 丢回主线程执行 UI 更新
MainThreadDispatcher.Instance.ExecuteOnMainThread(() =>
{
    _text.text = content;
});

这套模式无论在 Socket、WebSocket 还是 Redis 回调里都适用,而且不会引入额外依赖。我的实际经验是:凡是涉及外部网络回调更新 UI 的地方,统一走这个分发器,几乎不可能再踩线程崩溃的坑。

8.3 如何估算超时与并发参数

StackExchange.Redis 有几个参数需要根据项目实际情况调整,否则默认配置不一定适合 Unity 客户端:

  • ConnectTimeout :建立连接的超时时间,默认 5000ms,局域网内可以缩到 2000ms。
  • SyncTimeout :同步命令执行的超时时间,默认 5000ms。如果你在主线程不小心用了同步 API,这个值越大主线程卡顿越久。建议调成 1000ms。
  • AsyncTimeout :异步命令完成的超时时间。异步任务超时通常表现为 TaskCanceledException 或 TimeoutException ,需要捕获处理。
  • KeepAlive :默认 60 秒。Unity 客户端在移动网络下可能频繁切换网络,建议调成 30,加快断线检测。
  • ConnectRetry :首次连接时重试次数,默认 3,移动端网络不稳定可以调大一点,但没必要太夸张,5 次足够。

并发量方面的最大误区是“操作多就多建连接”,这是错的。 ConnectionMultiplexer 内部自带连接池和命令管道,它会把很多命令复用同一条 TCP 连接。游戏客户端通常只需要一个 ConnectionMultiplexer 实例就够了。只有在需要隔离不同业务的故障域时,才考虑拆多个实例,比如一个负责缓存、一个负责消息订阅。

9. 主从复制、持久化与安全基线,上线前必须过一遍

9.1 docker-compose 搭一主二从

如果你的项目要长期跑,单点 Redis 无论如何都不够稳。Redis 官方支持主从复制,配置也非常简单。我习惯用 docker-compose 一次性拉起一主二从:

services:
  redis-master:
    image: redis:7-alpine
    container_name: redis-master
    ports:
      - "6379:6379"
    command: >
      redis-server
      --appendonly yes
      --appendfsync everysec
      --requirepass masterpass
    volumes:
      - redis-master-data:/data

  redis-slave1:
    image: redis:7-alpine
    container_name: redis-slave1
    depends_on:
      - redis-master
    command: >
      redis-server
      --replicaof redis-master 6379
      --masterauth masterpass
      --requirepass slavepass
    volumes:
      - redis-slave1-data:/data

  redis-slave2:
    image: redis:7-alpine
    container_name: redis-slave2
    depends_on:
      - redis-master
    command: >
      redis-server
      --replicaof redis-master 6379
      --masterauth masterpass
      --requirepass slavepass
    volumes:
      - redis-slave2-data:/data

volumes:
  redis-master-data:
  redis-slave1-data:
  redis-slave2-data:

启动:

docker-compose up -d

然后在任意一个从节点执行:

docker exec -it redis-slave1 redis-cli -a slavepass info replication

输出里能看到:

role:slave
master_host:redis-master
master_link_status:up

变成 up 说明主从同步正常。

主从架构的价值主要体现在两点:一是数据冗余,主节点挂了从节点还有数据;二是读写分离,把读操作分流到从库,降低主库压力。不过要注意,主从复制不是强一致的,主库写入后,从库复制数据存在毫秒级延迟。如果是排行榜这种写入后立刻读的数据,建议读写都走主库,从库只做备份和长期数据导出。

9.2 RDB 与 AOF 的选择逻辑

Redis 的持久化方式有两种:RDB 快照和 AOF 追加日志。

RDB 是定期把内存数据全量写入磁盘。优点是文件小、恢复快,缺点是如果宕机发生在最近一次快照之后,这部分数据就丢了。 AOF 是每执行一条写命令就把命令追加到日志文件,数据更安全,但文件体积大、恢复速度慢。

我的建议是:游戏/仿真项目默认开 AOF,使用 appendfsync everysec 策略,系统最多丢一秒数据。这是性能和可靠性之间比较平衡的点。

redis-server --appendonly yes --appendfsync everysec

RDB 也不建议关。可以把 RDB 作为“灾难恢复”的兜底。日常生产环境建议两者都开。RDB 做定期全量备份,AOF 做实时崩溃恢复,双保险。

9.3 密码、危险命令与内存淘汰策略

最后必须要提安全和资源控制。很多 Unity 开发者为了本地调试方便,Redis 直接裸奔,等部署到服务器也没改配置,结果被扫描工具加密勒索。这种事情网上已经有很多前车之鉴。

至少要做以下几件事:

  1. 设置 requirepass :没有密码的 Redis 在公网裸奔等于把数据送人。
  2. 绑定内网地址 :修改 bind 配置,只允许内网访问,不要暴露公网。
  3. 禁用危险命令 :通过 rename-command 把 FLUSHALL、FLUSHDB、KEYS 等命令禁用或改名,防止误操作或恶意清空。
  4. 设置最大内存与淘汰策略 :防止数据无限增长撑爆内存。常用的淘汰策略是 allkeys-lru ,内存满时自动淘汰最近最少使用的 key。
maxmemory 2gb
maxmemory-policy allkeys-lru
rename-command FLUSHALL ""
rename-command FLUSHDB ""
rename-command KEYS ""

以上配置在 docker-compose 的 command 里追加即可。

另外提一句热词里频繁出现的“redis 缓存治理”。很多团队在缓存这一层出问题,核心原因是“没有治理”。什么叫治理?就是:哪些 key 必须带过期时间、哪些 key 是永久保留的、过期时间多长、内存告警阈值多少、命中率监控怎么做。提前把这些规则定好,比事后排查快得多。我也见过一个团队因为统一不设置过期时间,Redis 内存从 10GB 涨到 50GB,最后 OOM 直接被系统 kill,所有缓存全部失效,数据库瞬间被打穿。这些教训希望你能避开。


我在实际项目中最深的体感是:Redis 本身并不复杂,复杂的是接入 Unity 之后那一圈“边界问题”——序列化、线程、重连、超时、数据失效策略。这些点每个都不算难,但任何一个没处理好,都会在上线后以诡异的方式回报你。宁可前期多花半小时把连接管理器、DTO、线程分发器这些底座打好,也不要等线上出问题再补窟窿。如果你也是第一次在 Unity 里搭 Redis,建议先按这篇文章把环境跑通,用排行榜加会话管理这两个功能练手,踩过一遍坑之后,你会对这套组合拳有更踏实的把握。

Logo

立足具身智能前沿赛道,致力于搭建全球化、开源化、全栈式技术交流与实践共创平台。

更多推荐