redis 的延时双删、双重检查锁定在游戏服务端的使用(伪代码为C#)

前言

先简单描述一下这两个东西:
Redis 延时双删: 你要改数据库里的游戏数据时,先删一遍对应的 Redis 缓存,等数据库更新完成,再延迟固定时间补删一遍 Redis 缓存。核心就是兜底干掉并发读写过程中,偷偷溜进缓存的脏数据,从根源上解决缓存与数据库的一致性断层。
双重检查锁定(DCL): 你要创建全局唯一的对象、加载一份只会初始化一次的全局资源时,先判断对象 / 资源是否存在,不存在就加锁,加锁之后再判断一次是否存在,确认为空再执行创建 / 初始化。核心就是既保证单例 / 全局资源只创建一次、线程安全,又不用每次访问都加锁,完美兼顾高并发场景下的安全性和性能。

为啥游戏服务端非得用这两个东西?

咱做游戏服务端的都懂,线上最怕两件事:一是出脏数据,二是服卡炸了。 而这两个技术,刚好精准解决游戏业务里天生就带的两个致命痛点。

先说说为啥必须用延时双删:

游戏行业对数据一致性的要求,比普通互联网业务严苛得多 ------ 玩家充的元宝没到账、打副本掉的道具没加上、战力莫名其妙掉了,轻则玩家大规模投诉,重则出现刷道具 / 元宝的恶性 BUG,最后只能全服回档,营收和口碑双崩盘。
而游戏的读写架构,天生就容易踩一致性的坑:

1.咱们的标准架构就是「Redis 缓存挡在前,数据库落地兜底」,玩家操作、数据查询优先读缓存,缓存空了才查数据库,再把数据回写缓存,这是基操;

2.游戏的并发冲突极端集中:开服、国战、跨服 BOSS、副本结算这些场景,单服 TPS 直接拉满,玩家客户端一秒能发好几个请求,一边要改道具数据,一边又要读道具列表展示,并发读写冲突概率是普通业务的数倍;

3.最坑的经典脏数据场景,普通删缓存根本兜不住:你要给玩家加道具,先删了缓存,正要执行数据库更新,这时候玩家另一个读请求过来了,缓存是空的,直接去数据库读了旧的道具数量,反手就写回了 Redis。等你数据库更新完,缓存里已经躺着没人动的旧数据了,这个脏数据会一直留在那,直到玩家发现问题投诉。延时双删的第二次延迟删除,就是专门干掉这个偷偷回写的脏数据,给一致性上了个兜底保险。
再说说为啥必须用双重检查锁定:

游戏服务端里,有太多「必须全局唯一、只会初始化一次、全服高频访问」的资源,比如全服的活动配置、掉落表、怪物数值,还有单服的公会管理器、排行榜管理器、分布式锁组件这些。

这些东西有两个死要求:第一,必须全局唯一,不能重复创建实例,不然配置乱了、管理器重复了,直接导致掉落异常、活动时间错乱;第二,全服几万玩家的请求都要访问,性能要求拉满,不能每次访问都加锁,不然锁冲突直接把服卡成 PPT。

新手最容易踩两个坑:要么每次访问都加锁,性能直接崩;要么完全不加锁,开服的时候一堆协程同时去加载配置,重复创建了好几个实例,配置加载了好几遍,要么内存炸了,要么不同逻辑读到的配置不一样,直接出大事故。

双重检查锁定刚好完美解决这个问题:只有第一次创建 / 初始化的时候会加锁,之后所有访问都无锁直接拿结果,既保证了线程安全、全局唯一,又完全不影响高并发下的性能。

游戏服务端里的具体使用场景

一、Redis 延时双删的适用场景

全是咱们日常开发天天会碰的场景,落地就能用,适配 ET 框架的全异步协程模型:

1.玩家个人核心数据的更新

这是最常用、最稳妥的场景,比如玩家的等级、元宝、钻石、道具、战力、任务进度这些单玩家专属的 Key,格式一般是player:{playerId}:item、player:{playerId}:base,无全服热点冲突,一致性要求极高。

标准执行流程(适配 ET 框架):
先校验玩家请求合法性,锁定对应玩家 Actor,保证单玩家操作串行,从根源降低并发冲突

第一步:原子删除该玩家对应的目标缓存 Key,删除失败直接终止操作,避免缓存未删、数据库已更的断层

第二步:在数据库事务里执行玩家数据更新,比如加道具、更新任务进度,事务执行成功才进入后续流程,失败则直接回写缓存还原数据

第三步:用 ET 框架的TimerComponent做纯异步延时等待(严禁用 Sleep 阻塞协程),等待 300ms(可根据服内主从同步延迟调整),再执行第二次缓存删除,同时封装 Lua 脚本保证原子性,失败自动重试 2-3 次

公会 / 帮派数据的更新

比如公会升级、成员变更、仓库操作、公会战积分更新,这些都是单公会专属 Key,并发量可控,但是一致性要求极高,不然成员捐了贡献没到账、会长踢人不生效,直接引发玩家矛盾。

逻辑和玩家数据一致,仅需把延时时间拉长到 500ms,适配多成员同时读写的更高并发冲突概率。

低频更新的游戏配置数据

比如活动配置、掉落规则、玩法参数这些,策划只会偶尔热更,不会高频修改。用延时双删,先删全服节点的配置缓存,更新数据库的配置表,延迟 1s 再补删一遍缓存,确保全服所有节点的旧缓存都被清掉,不会出现部分服读旧配置、部分服读新配置的错乱问题。

二、双重检查锁定的适用场景

同样是游戏服务端的高频场景,C# 写法直接适配 ET 框架:

1.全局单例组件的创建

比如 ET 框架里单服的公会管理组件、排行榜管理组件、活动管理组件,这些都是整个服只有一个实例,必须全局唯一。

标准适配写法:

csharp 复制代码
private static readonly object lockObj = new object();
private static GuildManagerComponent _instance;
public static GuildManagerComponent Instance
{
    get
    {
        // 第一次无锁检查,99%的场景直接走这里,性能拉满
        if (_instance == null)
        {
            // 只有实例为空时才加锁,避免频繁锁冲突
            lock (lockObj)
            {
                // 第二次加锁后检查,防止加锁过程中已有其他协程创建了实例
                if (_instance == null)
                {
                    _instance = new GuildManagerComponent();
                    // 这里可补充初始化逻辑,比如从数据库加载全服公会数据
                }
            }
        }
        return _instance;
    }
}

这个写法,开服时哪怕几十个协程同时访问,也只会创建一次实例,之后所有访问都无锁,完全不影响性能,也不会出现重复创建的问题。

2.游戏配置表的懒加载

游戏里几十上百张配置表,不可能开服全量加载到内存,不然启动太慢,都是懒加载 ------ 用到的时候再加载到内存。

这时候必须用双重检查锁定,比如玩家打副本时,第一次用到某张副本掉落表,要是不用 DCL,同时 100 个玩家打这个副本,就会同时加载 100 次配置表,内存直接炸了,还会出现读到半加载配置的 BUG。用 DCL 就能保证配置表只加载一次,之后所有玩家直接读内存,又快又安全。

3.分布式锁的本地缓存实例

比如用 Redis 做分布式锁,同一个锁 Key 对应一个本地锁实例,用 DCL 保证同一个锁 Key,只会创建一个本地实例,不会重复创建,既保证锁的唯一性,又不用每次拿锁都新建对象,性能拉满。

绝对禁止使用的场景

这两个技术都不是万能银弹,用错了地方,轻则性能下降,重则直接炸服,这些场景绝对不能碰。

一、Redis 延时双删的禁用场景

1.全服热点 Key 绝对不能用

比如全服排行榜、世界 BOSS 血量、全服活动进度、开服福利这些,全服几万人同时读写的热点 Key,你用延时双删频繁删缓存,直接导致缓存击穿,所有请求全怼到数据库上,数据库瞬间被打满,服直接卡炸甚至宕机。

这类热点数据,应该用「分布式锁 + 缓存直接更新」或者「版本号校验机制」,改数据库的同时直接更新缓存,绝对不能删缓存,更不能用双删。

2.强一致性交易 / 充值场景,不能单独用

比如玩家充值、拍卖行交易、道具跨服转移这种一分钱都不能错的场景,单独用延时双删是兜不住的 ------ 延迟的这段时间里,依然可能有脏数据被读到。

这类场景,优先用分布式读写锁,关键操作甚至直接绕过缓存读数据库,一致性优先级永远高于性能,延时双删只能当兜底方案,不能当主力方案。

3.帧同步 / 战斗实时数据,完全没必要用

比如玩家的实时位置、战斗属性、技能 CD、血量蓝量这些毫秒级更新的数据,都是直接存在服内内存里,根本不会走 Redis 缓存,用延时双删纯纯多此一举,还会拖慢战斗结算速度。

3.高频更新的流水数据,别用

比如玩家的战斗日志、操作日志、在线时长统计这些,每秒都在写,几乎不会走缓存读,用延时双删频繁删缓存,纯浪费 Redis 性能,没有任何实际意义。

二、双重检查锁定的禁用场景

1.高频更新的全局变量,别用

比如全服当前在线人数、BOSS 当前血量这种每秒都在变的数值,根本不是单例 / 只加载一次的资源,用 DCL 完全没用,反而会因为加锁导致性能问题,这类场景应该用原子操作(Interlocked)更新,别碰 DCL。

2.非单例、非一次性加载的资源,别瞎用

比如玩家的临时数据、副本的临时对象,每个玩家、每个副本都有独立实例,根本不需要全局唯一,用 DCL 纯属画蛇添足,还会导致逻辑错误。

3.已有 Actor 模型保证串行的场景,别用

比如 ET 框架里,单玩家 Actor、单公会 Actor 的所有操作都是串行执行的,根本不会有并发创建的问题,在 Actor 内部用 DCL,完全是多此一举,还增加了代码复杂度。

4.会被频繁销毁重建的对象,别用

比如临时的战斗对象、副本房间对象,打完就销毁,下次再开再创建,根本不需要全局唯一,用 DCL 反而会导致单例锁死,出现旧对象没释放、新对象创建不了的问题。

总结

说到底,咱做游戏服务端的,不管是延时双删还是双重检查锁定,核心都是在「高并发性能」和「数据 / 线程安全」之间找最优平衡点 ------ 既不能为了安全把性能搞崩,也不能为了性能留一堆致命 BUG。

延时双删,本质是给缓存与数据库的一致性做兜底,专门解决游戏高并发读写导致的持久化脏数据问题,最适合用在玩家个人数据、公会数据这种单主体、一致性要求高、无全服热点的场景,绝对不能碰全服高频读写的热点 Key。

双重检查锁定,本质是给全局唯一资源的初始化做安全保障,专门解决单例创建、懒加载配置的并发安全问题,最适合用在全局组件、配置表加载这种只创建一次、全服高频访问的场景,别瞎用在频繁更新、频繁销毁的对象上。

最后再提一句,这两个技术都不是万能的,咱做游戏服务端,永远是场景优先,什么场景用什么方案,别为了用技术而用技术。能简单解决的问题,别搞复杂了,毕竟服稳了、玩家不投诉了,咱才能睡个安稳觉。

相关推荐
烛之武2 小时前
SpringBoot 实战篇
java·spring boot·后端
lclcooky2 小时前
Spring 核心技术解析【纯干货版】- XII:Spring 数据访问模块 Spring-R2dbc 模块精讲
java·后端·spring
神奇小汤圆2 小时前
Java 集合容器 - 高级篇
后端
GDAL2 小时前
BoltDB vs Redis 读性能对比:实测表现与原理差异
redis·boltdb
祭曦念2 小时前
学Rust3次都放弃?这篇文章帮你避开90%的新手劝退
后端
roman_日积跬步-终至千里2 小时前
【2025下半年系统架构设计师案例分析】电商平台 MySQL + Redis 与缓存击穿治理
mysql·缓存·系统架构
iPadiPhone3 小时前
万亿级流量的基石:Kafka 核心原理、大厂面试题解析与实战
分布式·后端·面试·kafka
snakeshe10103 小时前
Java 泛型深度解析:从手工封装到类型擦除与通配符
后端