第 36 章

游戏数据库设计

游戏数据库设计实战

游戏数据库有独特的读写模式:极高的并发读写(百万级在线玩家)、大量实时更新(玩家位置/状态/道具)、超高频的排行榜查询、虚拟货币的精确性要求。MySQL 在游戏行业广泛使用,通常与 Redis 形成双剑合璧的架构。

游戏数据库的特点

特征 具体表现 应对策略
高频写入 玩家每次移动、攻击都触发数据更新 Redis 缓存 + 异步落库
热点数据 热门区域/副本的玩家数据集中访问 分区、分服
排行榜查询 实时全服/区排名,百万级排序 Redis Sorted Set
道具属性灵活 武器有攻击力,衣服有防御,宠物有技能 JSON 列 + 属性表
日志海量 每次战斗、充值、登录都要记录 单独日志库 + 分区
开服/合服操作 新服定期开,老服定期合并 分库 + 迁移工具

玩家账户体系

-- 账号表(全局唯一,跨服)
CREATE TABLE accounts (
    id           BIGINT UNSIGNED NOT NULL AUTO_INCREMENT,
    account_name VARCHAR(50)     NOT NULL COMMENT '登录账号',
    password_hash VARCHAR(128)   NOT NULL,
    email        VARCHAR(200),
    mobile       VARCHAR(20),
    platform     TINYINT         NOT NULL DEFAULT 1 COMMENT '1=自建 2=微信 3=QQ 4=Apple',
    platform_uid VARCHAR(100)    COMMENT '第三方平台 UID',
    register_ip  VARCHAR(45),
    last_login_at DATETIME,
    created_at   DATETIME        NOT NULL DEFAULT CURRENT_TIMESTAMP,
    PRIMARY KEY (id),
    UNIQUE KEY uk_account (account_name),
    UNIQUE KEY uk_platform (platform, platform_uid),
    INDEX idx_email (email)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='账号表(全局)';

-- 角色表(每个账号可以有多个角色/分服角色)
CREATE TABLE players (
    id           BIGINT UNSIGNED NOT NULL AUTO_INCREMENT,
    account_id   BIGINT UNSIGNED NOT NULL,
    server_id    SMALLINT        NOT NULL COMMENT '大区/服务器 ID',
    name         VARCHAR(32)     NOT NULL COMMENT '角色名(服务器内唯一)',
    level        SMALLINT        NOT NULL DEFAULT 1,
    exp          BIGINT          NOT NULL DEFAULT 0,
    vip_level    TINYINT         NOT NULL DEFAULT 0,
    -- 战斗力等核心属性
    power        INT             NOT NULL DEFAULT 0,
    hp           INT             NOT NULL DEFAULT 100,
    mp           INT             NOT NULL DEFAULT 50,
    -- 位置信息(实时更新走 Redis)
    map_id       INT             COMMENT '最后所在地图',
    pos_x        FLOAT,
    pos_y        FLOAT,
    -- 时间
    last_online  DATETIME,
    created_at   DATETIME        NOT NULL DEFAULT CURRENT_TIMESTAMP,
    PRIMARY KEY (id),
    UNIQUE KEY uk_server_name (server_id, name),
    INDEX idx_account (account_id),
    INDEX idx_server_level (server_id, level DESC),
    INDEX idx_server_power (server_id, power DESC)  -- 战力排行榜基础
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='角色表'
PARTITION BY LIST (server_id) (
    PARTITION p_s1_100  VALUES IN (1,2,3,...,100),
    PARTITION p_s101_200 VALUES IN (101,...,200),
    PARTITION p_others  VALUES IN (DEFAULT)
);

道具背包系统

-- 道具模板表(静态数据,通常从配置文件加载到 Redis)
CREATE TABLE item_templates (
    id          INT UNSIGNED    NOT NULL AUTO_INCREMENT,
    name        VARCHAR(100)    NOT NULL,
    type        TINYINT         NOT NULL COMMENT '1=武器 2=防具 3=消耗品 4=材料 5=宠物',
    quality     TINYINT         NOT NULL COMMENT '1=白 2=绿 3=蓝 4=紫 5=橙 6=红',
    max_stack   SMALLINT        NOT NULL DEFAULT 1 COMMENT '最大堆叠数',
    base_attrs  JSON            NOT NULL COMMENT '{"atk":100,"def":50,"spd":30}',
    icon        VARCHAR(200),
    PRIMARY KEY (id),
    INDEX idx_type_quality (type, quality)
) ENGINE=InnoDB;

-- 玩家背包(运行时数据)
CREATE TABLE player_items (
    id          BIGINT UNSIGNED NOT NULL AUTO_INCREMENT,
    player_id   BIGINT UNSIGNED NOT NULL,
    server_id   SMALLINT        NOT NULL,
    item_id     INT UNSIGNED    NOT NULL COMMENT '道具模板 ID',
    bag_slot    SMALLINT        COMMENT '背包格子位置(NULL=仓库)',
    quantity    INT             NOT NULL DEFAULT 1,
    level       TINYINT         NOT NULL DEFAULT 0 COMMENT '强化等级',
    attrs       JSON            COMMENT '个体属性(随机词缀、强化加成)',
    expire_at   DATETIME        COMMENT '限时道具过期时间',
    created_at  DATETIME        NOT NULL DEFAULT CURRENT_TIMESTAMP,
    PRIMARY KEY (id),
    INDEX idx_player (player_id, server_id),
    INDEX idx_expire (expire_at)  -- 定时清理过期道具
) ENGINE=InnoDB
PARTITION BY HASH (player_id) PARTITIONS 32;  -- 按玩家 ID 分区,均匀分布

道具转移(原子操作)

-- 玩家 A 将道具转移给玩家 B
BEGIN;
  -- 检查并扣除 A 的道具
  UPDATE player_items
  SET quantity = quantity - ?
  WHERE id = ? AND player_id = ? AND quantity >= ?;

  IF ROW_COUNT() = 0 THEN ROLLBACK; END IF;

  -- 增加 B 的道具(若已有则叠加)
  INSERT INTO player_items (player_id, server_id, item_id, quantity)
  VALUES (?, ?, ?, ?)
  ON DUPLICATE KEY UPDATE quantity = quantity + VALUES(quantity);
COMMIT;

排行榜设计

实时排行榜是游戏最高频的查询场景之一。纯 MySQL 方案在百万级用户时性能差,生产环境几乎全部使用 Redis Sorted Set。

-- MySQL 持久化排行榜数据(定期快照)
CREATE TABLE leaderboard_snapshots (
    id          BIGINT UNSIGNED NOT NULL AUTO_INCREMENT,
    server_id   SMALLINT        NOT NULL,
    season      SMALLINT        NOT NULL COMMENT '赛季',
    rank_type   TINYINT         NOT NULL COMMENT '1=等级 2=战力 3=PVP积分',
    player_id   BIGINT UNSIGNED NOT NULL,
    score       BIGINT          NOT NULL,
    rank        INT             NOT NULL,
    snapshot_at DATETIME        NOT NULL,
    PRIMARY KEY (id),
    UNIQUE KEY uk_snapshot (server_id, season, rank_type, player_id),
    INDEX idx_rank (server_id, season, rank_type, rank)
) ENGINE=InnoDB
PARTITION BY RANGE (season) (
    PARTITION p_s1  VALUES LESS THAN (2),
    PARTITION p_s2  VALUES LESS THAN (3),
    PARTITION p_max VALUES LESS THAN MAXVALUE
);
// Redis Sorted Set 实现实时排行榜
key := fmt.Sprintf("lb:pvp:server:%d:season:%d", serverID, season)

// 更新分数
rdb.ZAdd(ctx, key, &redis.Z{Score: float64(newScore), Member: playerID})

// 查询排名(0-based,+1 变成 1-based)
rank, _ := rdb.ZRevRank(ctx, key, playerID).Result()
fmt.Println("排名:", rank+1)

// 查询 Top 100
top100, _ := rdb.ZRevRangeWithScores(ctx, key, 0, 99).Result()

// 查询玩家周围排名(上下 5 名)
rank, _ = rdb.ZRevRank(ctx, key, playerID).Result()
nearby, _ := rdb.ZRevRangeWithScores(ctx, key,
    max(0, rank-5), rank+5).Result()

// 定期(每小时)将 Redis 排行榜快照到 MySQL
// 赛季结束时做最终快照,赛季奖励以此为准

虚拟货币体系

游戏虚拟货币(钻石、金币、点券)的设计原则与金融账户相同:必须精确、有流水、不可直接修改余额。

CREATE TABLE player_wallets (
    player_id   BIGINT UNSIGNED NOT NULL,
    server_id   SMALLINT        NOT NULL,
    -- 充值型货币
    diamonds    INT             NOT NULL DEFAULT 0 COMMENT '钻石(充值)',
    -- 游戏内产出货币
    gold        BIGINT          NOT NULL DEFAULT 0 COMMENT '金币',
    honor       INT             NOT NULL DEFAULT 0 COMMENT '荣誉点',
    version     INT             NOT NULL DEFAULT 0 COMMENT '乐观锁',
    PRIMARY KEY (player_id, server_id),
    CONSTRAINT chk_diamonds CHECK (diamonds >= 0),
    CONSTRAINT chk_gold     CHECK (gold >= 0)
) ENGINE=InnoDB;

CREATE TABLE currency_logs (
    id          BIGINT UNSIGNED NOT NULL AUTO_INCREMENT,
    player_id   BIGINT UNSIGNED NOT NULL,
    server_id   SMALLINT        NOT NULL,
    currency    TINYINT         NOT NULL COMMENT '1=钻石 2=金币 3=荣誉',
    change_type TINYINT         NOT NULL COMMENT '1=充值 2=消费 3=任务奖励 4=退款',
    amount      INT             NOT NULL COMMENT '变动量(正为收入,负为支出)',
    balance     INT             NOT NULL COMMENT '变动后余额',
    source_id   VARCHAR(64)     COMMENT '关联订单/任务 ID',
    created_at  DATETIME(3)     NOT NULL DEFAULT CURRENT_TIMESTAMP(3),
    PRIMARY KEY (id),
    INDEX idx_player_time (player_id, server_id, created_at)
) ENGINE=InnoDB
PARTITION BY RANGE (TO_DAYS(created_at)) (
    PARTITION p_2024_q1 VALUES LESS THAN (TO_DAYS('2024-04-01')),
    PARTITION p_2024_q2 VALUES LESS THAN (TO_DAYS('2024-07-01')),
    PARTITION p_max     VALUES LESS THAN MAXVALUE
);

匹配与战绩

-- 对战记录
CREATE TABLE battle_records (
    id           BIGINT UNSIGNED NOT NULL AUTO_INCREMENT,
    server_id    SMALLINT        NOT NULL,
    battle_type  TINYINT         NOT NULL COMMENT '1=PVP 2=PVE 3=公会战',
    started_at   DATETIME(3)     NOT NULL,
    ended_at     DATETIME(3),
    duration_ms  INT             COMMENT '战斗时长(毫秒)',
    winner       TINYINT         COMMENT '1=队伍1 2=队伍2 0=平局',
    detail       JSON            COMMENT '详细战报(伤害/技能使用等)',
    PRIMARY KEY (id),
    INDEX idx_server_type_time (server_id, battle_type, started_at)
) ENGINE=InnoDB
PARTITION BY RANGE (TO_DAYS(started_at)) (
    PARTITION p_30d  VALUES LESS THAN (TO_DAYS(DATE_SUB(NOW(), INTERVAL 30 DAY))),
    PARTITION p_cur  VALUES LESS THAN MAXVALUE
);

-- 玩家战绩(每场战斗的参战玩家记录)
CREATE TABLE battle_players (
    battle_id   BIGINT UNSIGNED NOT NULL,
    player_id   BIGINT UNSIGNED NOT NULL,
    team        TINYINT         NOT NULL,
    damage_dealt BIGINT         NOT NULL DEFAULT 0,
    damage_taken BIGINT         NOT NULL DEFAULT 0,
    kills       SMALLINT        NOT NULL DEFAULT 0,
    deaths      SMALLINT        NOT NULL DEFAULT 0,
    score_delta INT             NOT NULL DEFAULT 0 COMMENT 'PVP 分数变动',
    PRIMARY KEY (battle_id, player_id),
    INDEX idx_player (player_id)
) ENGINE=InnoDB;

行为日志(埋点)

-- 玩家行为日志(写多读少,单独数据库)
CREATE TABLE player_event_logs (
    id          BIGINT UNSIGNED NOT NULL AUTO_INCREMENT,
    player_id   BIGINT UNSIGNED NOT NULL,
    server_id   SMALLINT        NOT NULL,
    event_type  VARCHAR(50)     NOT NULL COMMENT 'login/logout/level_up/purchase/...',
    event_data  JSON            NOT NULL,
    client_ip   VARCHAR(45),
    device_id   VARCHAR(100),
    created_at  DATETIME(3)     NOT NULL DEFAULT CURRENT_TIMESTAMP(3),
    PRIMARY KEY (id),
    INDEX idx_player_event (player_id, event_type, created_at),
    INDEX idx_date (DATE(created_at))
) ENGINE=InnoDB
PARTITION BY RANGE (TO_DAYS(created_at)) (
    PARTITION p_d1 VALUES LESS THAN (TO_DAYS('2024-02-01')),
    -- 按月分区,超过 6 个月的数据归档到 ClickHouse
    PARTITION p_max VALUES LESS THAN MAXVALUE
) COMMENT='行为日志(建议 6 个月后归档到 ClickHouse/Doris)';

热更新配置数据

-- 游戏配置表(道具属性、关卡数据等,通常热更新)
CREATE TABLE game_configs (
    config_key  VARCHAR(100)    NOT NULL,
    config_val  JSON            NOT NULL,
    version     INT             NOT NULL DEFAULT 1,
    updated_at  DATETIME        NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
    PRIMARY KEY (config_key)
) ENGINE=InnoDB COMMENT='游戏配置(MySQL 持久化,Redis 缓存)';

-- 版本控制
CREATE TABLE game_config_history (
    id          INT UNSIGNED NOT NULL AUTO_INCREMENT,
    config_key  VARCHAR(100) NOT NULL,
    config_val  JSON         NOT NULL,
    version     INT          NOT NULL,
    operator    VARCHAR(50),
    created_at  DATETIME     NOT NULL DEFAULT CURRENT_TIMESTAMP,
    PRIMARY KEY (id),
    INDEX idx_key_version (config_key, version)
) ENGINE=InnoDB;

游戏数据库分服架构:国内手游通常采用"分服"架构,每个区服(server_id)在同一个 MySQL 实例上有独立的 Database,服务器合并时用数据迁移工具将两个 Database 的数据合并。这种架构天然隔离玩家群体,但跨服活动(排行榜、竞技场)需要聚合多个服的数据,通常通过专用的跨服 MySQL 实例实现。

本章评分
4.6  / 5  (3 评分)

💬 留言讨论