# 游戏数据存储特殊需求

用生活化的比喻,让你从"只会用 MySQL"到"能设计支撑百万玩家的游戏数据存储方案"

前置知识:第06章 数据库深度进阶(B+树、MVCC、锁、分库分表)、第10章 高并发与分布式一致性(分布式锁、最终一致性)


阅读指南(初学者必看)

为什么你需要学习游戏数据存储?

游戏数据存储和普通 Web 应用完全不同:

  • 玩家背包可能有 1000 个物品 → 怎么存?一条记录还是多条?
  • 排行榜要实时更新 → MySQL 扛不住怎么办?
  • 玩家日志海量 → 怎么存得省钱又查得快?
  • 游戏服挂了 → 数据怎么不丢?

学完本章,你能回答:

  • 游戏玩家数据怎么建模?(MySQL + Redis + MongoDB 的组合拳)
  • 排行榜怎么设计?(Redis Sorted Set vs 数据库)
  • 日志和埋点数据怎么处理?(ClickHouse vs Elasticsearch)
  • 游戏数据怎么备份和恢复?(增量备份 + Binlog)

本文结构

第一部分:游戏数据存储架构(整体方案) 第二部分:玩家数据建模(核心数据) 第三部分:排行榜与社交数据(高频读写) 第四部分:日志与埋点(海量数据) 第五部分:数据备份与容灾(数据安全)


一、游戏数据存储架构

存储分层

` 游戏数据存储分层:

第一层:内存(Redis / 本地缓存)

  • 在线玩家数据
  • 排行榜、计数器
  • 热点数据
  • 延迟:< 1ms

第二层:关系型数据库(MySQL)

  • 玩家基础数据
  • 交易记录
  • 需要事务的数据
  • 延迟:1-10ms

第三层:文档数据库(MongoDB)

  • 复杂结构数据(背包、配置)
  • 日志、埋点
  • 延迟:5-20ms

第四层:列式存储(ClickHouse / Elasticsearch)

  • 大数据分析
  • 日志检索
  • 延迟:50-500ms `

架构图

游戏客户端 │ ▼ 网关服务器 │ ├──▶ Redis(在线数据缓存) │ └── 玩家状态、排行榜、计数器 │ ├──▶ MySQL(核心事务数据) │ └── 玩家账号、充值记录、交易日志 │ ├──▶ MongoDB(复杂结构数据) │ └── 玩家背包、邮件、配置 │ └──▶ ClickHouse(分析型数据) └── 运营报表、玩家行为分析


二、玩家数据建模

玩家核心数据(MySQL)

`sql -- 玩家基础表(小表,常驻内存) CREATE TABLE player ( player_id BIGINT PRIMARY KEY, account VARCHAR(64) NOT NULL UNIQUE, nickname VARCHAR(32) NOT NULL, level INT DEFAULT 1, exp BIGINT DEFAULT 0, gold BIGINT DEFAULT 0, diamond INT DEFAULT 0, vip_level INT DEFAULT 0, last_login_time TIMESTAMP, created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, INDEX idx_level (level), INDEX idx_last_login (last_login_time) ) ENGINE=InnoDB;

-- 分库分表:按 player_id % 16 分表 -- player_0, player_1, ..., player_15 `

玩家背包(MongoDB)

`javascript // MongoDB 文档 - 玩家背包 { _id: 10001, player_id: 10001, items: [ { item_id: 1001, count: 10, bind: false, expire_time: null }, { item_id: 2001, count: 1, bind: true, expire_time: null }, { item_id: 3001, count: 99, bind: false, expire_time: ISODate("2024-12-31") } ], capacity: 200, used_capacity: 3, updated_at: ISODate("2024-01-15") }

// 为什么用 MongoDB? // 1. 背包结构不固定(不同物品有不同属性) // 2. 嵌套数组适合文档模型 // 3. 读写性能高(不需要 JOIN) // 4. 扩容方便(水平分片) `

`java // Java 操作 MongoDB @Document(collection = "player_bag") public class PlayerBag { @Id private Long playerId; private List items; private int capacity; private int usedCapacity; private Date updatedAt; }

@Autowired private MongoTemplate mongoTemplate;

// 查询背包 PlayerBag bag = mongoTemplate.findById(playerId, PlayerBag.class);

// 添加物品 Query query = new Query(Criteria.where("playerId").is(playerId)); Update update = new Update().push("items", new Item(itemId, count, false, null)); mongoTemplate.updateFirst(query, update, PlayerBag.class); `

缓存策略(Redis)

`java @Service public class PlayerDataService { @Autowired private RedisTemplate<String, Object> redisTemplate; @Autowired private PlayerRepository playerRepository; @Autowired private MongoTemplate mongoTemplate;

// 读数据:先查缓存,再查数据库
public Player getPlayer(long playerId) {
    String key = "player:" + playerId;
    Player player = (Player) redisTemplate.opsForValue().get(key);
    if (player == null) {
        player = playerRepository.findById(playerId).orElse(null);
        if (player != null) {
            redisTemplate.opsForValue().set(key, player, 30, TimeUnit.MINUTES);
        }
    }
    return player;
}

// 写数据:先写数据库,再删缓存(Cache Aside)
@Transactional
public void updateGold(long playerId, long delta) {
    playerRepository.updateGold(playerId, delta);
    redisTemplate.delete("player:" + playerId);
}

} `


三、排行榜与社交数据

Redis 排行榜(Sorted Set)

`java @Service public class LeaderboardService { @Autowired private StringRedisTemplate redisTemplate;

// 更新玩家战力排行
public void updatePowerRank(long playerId, long power) {
    String key = "rank:power:weekly";
    redisTemplate.opsForZSet().add(key, String.valueOf(playerId), power);
    // 设置过期时间(每周清零)
    redisTemplate.expire(key, 7, TimeUnit.DAYS);
}

// 获取前 100 名
public List<RankEntry> getTop100() {
    String key = "rank:power:weekly";
    Set<ZSetOperations.TypedTuple<String>> top = 
        redisTemplate.opsForZSet().reverseRangeWithScores(key, 0, 99);
    
    List<RankEntry> result = new ArrayList<>();
    int rank = 1;
    for (ZSetOperations.TypedTuple<String> entry : top) {
        result.add(new RankEntry(rank++, 
            Long.parseLong(entry.getValue()), 
            entry.getScore().longValue()));
    }
    return result;
}

// 获取玩家自己的排名
public Long getPlayerRank(long playerId) {
    String key = "rank:power:weekly";
    Long rank = redisTemplate.opsForZSet().reverseRank(key, String.valueOf(playerId));
    return rank != null ? rank + 1 : null;
}

// 获取玩家前后 10 名(附近的人)
public List<RankEntry> getNearbyPlayers(long playerId) {
    String key = "rank:power:weekly";
    Long rank = redisTemplate.opsForZSet().reverseRank(key, String.valueOf(playerId));
    if (rank == null) return Collections.emptyList();
    
    long start = Math.max(0, rank - 10);
    long end = rank + 10;
    Set<ZSetOperations.TypedTuple<String>> nearby = 
        redisTemplate.opsForZSet().reverseRangeWithScores(key, start, end);
    
    // ... 转换逻辑
    return result;
}

} `

社交数据(好友系统)

`java // Redis 好友关系(Set) public class FriendService { @Autowired private StringRedisTemplate redisTemplate;

// 添加好友
public void addFriend(long playerId, long friendId) {
    String myKey = "friend:" + playerId;
    String friendKey = "friend:" + friendId;
    
    redisTemplate.opsForSet().add(myKey, String.valueOf(friendId));
    redisTemplate.opsForSet().add(friendKey, String.valueOf(playerId));
}

// 获取好友列表
public Set<String> getFriends(long playerId) {
    return redisTemplate.opsForSet().members("friend:" + playerId);
}

// 获取共同好友
public Set<String> getCommonFriends(long playerId1, long playerId2) {
    return redisTemplate.opsForSet().intersect(
        "friend:" + playerId1, 
        "friend:" + playerId2);
}

// 是否好友
public boolean isFriend(long playerId, long friendId) {
    return redisTemplate.opsForSet().isMember(
        "friend:" + playerId, 
        String.valueOf(friendId));
}

} `


四、日志与埋点

日志存储方案

` 日志类型与存储方案:

日志类型 存储方案 保留时间 查询场景
玩家行为日志 ClickHouse 90 天 运营分析、留存计算
游戏战斗日志 MongoDB 30 天 战斗回放、举报审核
系统错误日志 Elasticsearch 30 天 错误追踪、告警
充值消费日志 MySQL 永久 财务对账
性能监控日志 Prometheus 15 天 性能分析
`

ClickHouse 玩家行为表

`sql -- ClickHouse 建表 CREATE TABLE player_behavior ( event_time DateTime, player_id UInt64, event_type String, event_data String, level UInt16, vip_level UInt8, server_id UInt16 ) ENGINE = MergeTree() PARTITION BY toYYYYMMDD(event_time) ORDER BY (event_type, player_id, event_time);

-- 查询 DAU(日活跃用户) SELECT toDate(event_time) as date, uniqExact(player_id) as dau FROM player_behavior WHERE event_type = 'login' GROUP BY date ORDER BY date;

-- 查询留存率 SELECT date, d1_retention, d7_retention, d30_retention FROM retention_table; `

日志采集架构

游戏服务器 → Filebeat(日志收集)→ Kafka(消息队列)→ ├── Logstash → Elasticsearch(错误日志) ├── Flink → ClickHouse(行为分析) └── 直接归档 S3(冷数据)


五、数据备份与容灾

备份策略

` 备份分层:

  1. 实时备份

    • MySQL 主从复制(Binlog)
    • Redis AOF + RDB
    • MongoDB Replica Set
  2. 定时备份

    • 每天凌晨全量备份(MySQL dump)
    • 每小时增量备份(Binlog)
    • 每周 MongoDB dump
  3. 异地备份

    • 跨区域复制(AWS S3 Cross-Region)
    • 冷数据归档( Glacier / 阿里云 OSS 归档)
  4. 备份验证

    • 每月恢复演练
    • 自动备份完整性检查 `

MySQL 主从切换

`sql -- 1. 检查主库状态 SHOW MASTER STATUS; -- File: mysql-bin.000123 -- Position: 1234567

-- 2. 从库追平数据 -- 确保 Exec_Master_Log_Pos = 主库 Position

-- 3. 从库提升为主库 STOP SLAVE; RESET SLAVE ALL;

-- 4. 应用连接新主库 -- 修改配置中心的数据源

-- 5. 原主库修复后作为新从库 CHANGE MASTER TO MASTER_HOST='new_master_host', MASTER_USER='repl', MASTER_PASSWORD='password', MASTER_LOG_FILE='mysql-bin.000123', MASTER_LOG_POS=1234567; START SLAVE; `

游戏数据容灾设计

` 容灾等级:

RTO(恢复时间目标):

  • 核心业务(登录、充值):RTO < 5 分钟
  • 游戏玩法:RTO < 30 分钟
  • 数据分析:RTO < 4 小时

RPO(恢复点目标):

  • 充值数据:RPO = 0(实时同步)
  • 玩家数据:RPO < 5 分钟(Binlog)
  • 日志数据:RPO < 1 小时(可接受部分丢失) `

自问自答

Q1:为什么背包用 MongoDB 不用 MySQL?

背包数据结构复杂且不固定(不同物品有不同属性),MongoDB 的文档模型天然适合。MySQL 需要多个表 JOIN,性能差且 schema 变更困难。

Q2:Redis 排行榜怎么保证数据不丢?

定期(如每 5 分钟)将 Redis 排行榜数据持久化到 MySQL。Redis 重启后从 MySQL 恢复基础数据,再接收新的实时更新。

Q3:ClickHouse 和 Elasticsearch 怎么选?

ClickHouse 适合结构化数据的聚合分析(留存、付费率),查询快。Elasticsearch 适合文本搜索和日志检索。游戏运营分析用 ClickHouse 更合适。

Q4:游戏数据怎么删除玩家数据(GDPR 合规)?

逻辑删除:标记 deleted=true,保留必要记录(如充值记录用于财务)。物理删除:90 天后清理非必要数据。必须记录删除操作日志。

Q5:怎么处理跨服数据迁移?

玩家从 A 服迁到 B 服:1)锁定玩家账号;2)导出数据(MySQL + MongoDB + Redis);3)导入 B 服;4)更新路由表;5)解锁账号。整个过程需要在维护时间窗口进行。


实践任务

  1. 设计玩家数据模型:MySQL 基础表 + MongoDB 背包 + Redis 缓存
  2. 实现排行榜服务:Redis Sorted Set + 定时持久化到 MySQL
  3. 实现好友系统:Redis Set + 共同好友查询
  4. 搭建日志采集:Filebeat + Kafka + ClickHouse,统计 DAU
  5. 数据备份脚本:MySQL 全量 + 增量备份,自动上传到 OSS
  6. 容灾演练:模拟主库宕机,完成主从切换

与其他章节的关联

本节内容 关联章节 关联点
MySQL 建模 第06章 数据库深度进阶 索引、分库分表、事务
Redis 缓存 第10章 高并发与分布式一致性 缓存策略、分布式锁
MongoDB 第06章 数据库 文档数据库的适用场景
日志采集 第14章 游戏实时通信优化 性能监控数据来源
容灾 第15章 云原生与容器化 K8s 的 StatefulSet 持久化

上一章:12-游戏协议设计与优化 | 下一章:14-游戏实时通信优化