游戏后端分布式学习——缓存和DB的一致性套路

📅 2026/7/27 22:58:34
游戏后端分布式学习——缓存和DB的一致性套路
不同规模游戏下缓存的角色进程内的缓存其本质内存对象是权威DB 是备份。一致性窗口从上次存盘到崩溃这段时间的数据可能丢失容忍度游戏行业普遍接受丢失最近 30 秒数据因为玩家不会每分钟都在做不可逆操作即便丢了 30 秒经验对玩家体验影响有限可以通过关键操作立即存盘来缩小窗口如升级、交易、充值什么规模用这种模型单服 1-5 万人以内完全够用内存占用一个玩家对象约 5-50KB取决于数据复杂度5 万人 ≈ 250MB-2.5GB单台服务器完全 hold 住典型代表大多数 MMORPG 单区单服、SLG 单服✅ 结论百万 DAU 以下的 MMO进程内玩家对象 定时存盘是绝对主流不需要 Redis。什么时候需要引入 Redis 作为缓存层跨进程访问玩家数据场景服 A 的玩家要访问场景服 B 的玩家数据如跨服交易、跨服邮件进程内缓存无法跨进程共享单服人数突破 10 万内存占用过大10万人 × 50KB 5GB需要把不活跃的玩家数据换出到 Redis腾出内存微服务化架构玩家数据服务独立部署多个业务服务通过 Redis 访问进程内缓存无法被多个服务共享合服 / 跨服架构多个物理服的数据需要集中管理Redis 作为统一的缓存层MySQL 作为持久层高可用要求进程崩溃后玩家数据能从 Redis 快速恢复而不用每次都查 MySQLRedis MySQL 的一致性套路Cache Aside Pattern原理读先查缓存miss 则查 DB 并回填缓存写先更新 DB再删除缓存或失效缓存游戏场景玩家数据读写绝大多数 MMO 的标准做法物品信息、技能配置等读多写少的数据并发规模任何规模都适用从单服千人到千万 DAU 都能用对写并发有一定要求DB 写后删缓存短时间可能读到旧数据但最终一致缺点写后缓存删除到下次读回填之间存在不一致窗口很短需要处理缓存穿透/击穿/雪崩Read/Write Through读写穿透—— 缓存作为主要存储原理读同 Cache Aside但缓存层负责从 DB 加载对应用透明写应用只写缓存缓存组件负责同步写入 DB同步写或异步写两种实现方式Write Through同步写每次写缓存时缓存组件同步写 DB完成后才返回成功Write Behind异步写见下一节游戏场景需要强一致性且不想在应用层处理 DB 逻辑时例如玩家充值余额每次扣款必须写缓存写 DB 同步完成适用于封装好的缓存中间件如 Redis 的 RedisJSON 自定义 Lua 脚本做 Write Through并发规模中小规模单服万人以内因为每次写操作都要等 DB 写入延迟较高大规模如果 DB 写性能跟不上会严重拖慢缓存写入速度不推荐优点应用层代码简单只需读写缓存一致性高同步写 DB缺点写延迟 缓存延迟 DB 延迟性能不如 Cache Aside缓存组件复杂度增加Write Behind异步写回 / Write Back—— 高性能但可能丢数据原理应用只写缓存缓存组件异步批量写回 DB缓存可以积累一批修改后一次性写入 DB批处理游戏场景玩家位置、经验值、血量等高频更新但允许少量丢失的数据日志、行为数据如玩家操作记录典型实现Skynet 中定时存盘 Redis 作为写缓存先写 Redis再异步刷 MySQL并发规模高并发场景单服数万玩家每秒数万次写因为写操作只写缓存内存吞吐量极高适合大规模 MMO、实时对战、SLG 频繁行军等优点写性能极高内存操作异步落盘可合并写操作减少 DB 压力缺点数据可能丢失缓存宕机时未落盘的数据丢失一致性弱DB 数据滞后于缓存宕机恢复需要额外处理实现复杂需要处理缓存故障时的数据恢复Refresh Ahead缓存预热 / 主动刷新—— 预测性加载原理在数据过期前主动刷新缓存避免缓存失效时的穿透常用于热点数据如排行榜、热门商品、活动配置游戏场景跨服排行榜每秒可能有数千次读取但写入频率低。可以设置缓存 10 秒过期每 5 秒后台任务刷新一次保证缓存始终有效活动配置、公告定期从 DB 加载到缓存避免每次读取都查 DB并发规模超高读并发千万 QPS因为缓存几乎永不过期读操作全部命中缓存适合读远多于写的场景优点读性能极高缓存始终有效避免缓存击穿/雪崩缺点写操作需要同步更新缓存或让缓存自动过期后由 Refresh Ahead 刷新实现复杂度中等需要后台调度任务Double Delete延迟双删—— Cache Aside 的增强版原理写 DB 后先删一次缓存等一段时间如 500ms后再删一次目的是消除“第一次删除后并发读线程回填脏数据”的可能性游戏场景对一致性要求较高的写操作如玩家改名、公会转让、装备强化通常作为 Cache Aside 的补充而不是独立模式并发规模所有规模尤其适合写并发较高的场景优点极大降低脏数据概率实现简单加一个 fork 延时任务缺点增加了写延迟第二次删除是异步的不影响主流程不能 100% 保证极端情况下仍有极小窗口如何根据并发规模选择小规模单服 1 万玩家首选 Cache Aside简单可靠不需要 Write Behind内存够用DB 压力小不需要 Refresh Ahead热点数据不多中等规模单服 1-5 万玩家Cache Aside 延迟双删应对写并发对高频写如位置、经验可以考虑 Write Behind先写 Redis再异步刷 MySQL对热点读排行榜用 Refresh Ahead大规模单服 5-20 万玩家 / 多服架构Cache Aside Write Behind​ 混合使用关键数据充值、交易用 Cache Aside 延迟双删非关键高频数据位置、血量用 Write Behind先写 Redis定时刷 MySQLRefresh Ahead​ 用于排行榜、跨服数据可能需要 多级缓存本地缓存Skynet 进程内 Redis MySQL超大规模百万 DAU 级分布式微服务Write Behind​ 成为主力几乎所有写操作先入消息队列或 Redis再异步落 MySQLCache Aside​ 用于需要实时一致的关键路径Refresh Ahead​ 用于全局配置、热点数据引入 缓存预热 本地缓存​ 减少 Redis 压力缓存一致性的经典问题缓存穿透查不存在的玩家-- 方案A布隆过滤器推荐functioncheck_player_exists(player_id)ifnotbloom_filter.contains(player:..player_id)thenreturnfalse-- 一定不存在end-- 可能存在继续查returntrueend-- 方案B缓存空值简单但占空间functionget_player_safe(player_id)localkeyREDIS_KEY_PREFIX..player_idlocalcachedredis.get(key)ifcachedthenifcachedNULLthen-- 特殊标记returnnilendreturncjson.decode(cached)endlocaldatamysql.query(SELECT * FROM player WHERE id ?,player_id)ifnotdatathenredis.setex(key,300,NULL)-- 缓存空值5分钟returnnilendredis.setex(key,3600,cjson.encode(data))returndataend缓存击穿热点 key 失效场景某个热门玩家如公会会长、排行榜第一的缓存突然失效大量请求同时打到 MySQL。-- 方案A互斥锁分布式锁functionget_player_with_lock(player_id)localkeyREDIS_KEY_PREFIX..player_idlocalcachedredis.get(key)ifcachedthenreturncjson.decode(cached)end-- 尝试获取锁locallock_keylock:..keyifredis.setnx(lock_key,1)thenredis.expire(lock_key,10)-- 锁10秒超时-- 双重检查cachedredis.get(key)ifcachedthenredis.del(lock_key)returncjson.decode(cached)end-- 查 MySQLlocaldatamysql.query(SELECT * FROM player WHERE id ?,player_id)ifdatathenredis.setex(key,3600,cjson.encode(data))endredis.del(lock_key)returndataelse-- 获取锁失败短暂等待后重试skynet.sleep(10)-- 10msreturnget_player_with_lock(player_id)endend-- 方案B热点 key 永不过期 后台异步刷新缓存雪崩大量 key 同时失效场景运维误操作或批量设置相同过期时间导致大量玩家缓存同时失效MySQL 瞬间被打爆。-- 方案随机过期时间redis.setex(key,3600math.random(0,600),value)-- 1小时 ± 10分钟随机-- 方案多级缓存本地缓存 Redis-- Skynet 服务内再加一层本地缓存即使 Redis 挂了也能扛locallocal_cache{}functionget_player_multi_level(player_id)-- 1. 查本地缓存iflocal_cache[player_id]thenreturnlocal_cache[player_id]end-- 2. 查 Redislocaldataget_player(player_id)ifdatathenlocal_cache[player_id]dataendreturndataend延迟双删防脏读的关键问题场景时刻 T1: 线程A 更新 DBplayer_id100, level10 → 11 时刻 T2: 线程A 删除 Redis 缓存 时刻 T3: 线程B 查询 Redismiss从 DB 读到 level11写入 Redis 时刻 T4: 线程A 的更新操作由于某种原因如事务未提交回滚DB 中 level 仍为 10 时刻 T5: 此时 Redis 中是 level11DB 中是 level10 → 数据不一致解决方案functionsave_player_delayed_double_delete(player_id,data)-- 1. 第一次删缓存redis.del(REDIS_KEY_PREFIX..player_id)-- 2. 更新 DBmysql.update(player,data,{idplayer_id})-- 3. 延迟一段时间后再删一次skynet.fork(function()skynet.sleep(500)-- 500ms 后redis.del(REDIS_KEY_PREFIX..player_id)end)end延迟时间的选择略大于从 DB 读数据 写 Redis的耗时确保第二次删除能清掉第一次删除后可能被写入的脏数据。MMO 具体场景的一致性设计场景 1玩家登录1. 查 Redis热数据毫秒级返回 2. 未命中 → 查 MySQL → 回填 Redis 3. 在 Skynet 中创建 Player Object进程内缓存 4. 后续游戏逻辑操作进程内对象 5. 定时存盘进程内对象 → Redis → MySQL场景 2玩家下线1. 立即将进程内对象存盘Redis MySQL同步写确保不丢 2. 释放进程内对象 3. Redis 中的数据保留设置较长过期时间如 24 小时 - 如果玩家在 24 小时内重新登录直接从 Redis 加载快 - 超过 24 小时未登录Redis 自动过期下次登录从 MySQL 加载场景 3跨服访问玩家数据场景服A 的玩家要查看 场景服B 的玩家信息 1. 场景服A 直接查 Rediskey player:{player_id} 2. Redis 作为跨服数据共享层 3. 不活跃玩家的数据自动从 Redis 淘汰需要时从 MySQL 加载场景 4合服操作1. 原服A 和 原服B 的玩家数据都在各自 MySQL 2. 合服时 - 解决 ID 冲突player_id 重新映射 - 数据迁移到新 MySQL - 清除 Redis 中所有相关缓存强制下次从新 MySQL 加载 3. 玩家登录新服时从新 MySQL 加载数据到 Redis 和进程内场景 5玩家交易强一致性要求交易涉及两个玩家的数据变更必须原子性 1. 开启 MySQL 事务 2. 更新双方 DB 数据 3. 提交事务 4. 删除双方 Redis 缓存 5. 若任一步骤失败回滚事务不删缓存