第12章:同步与数据持久化——虚拟世界的记忆
本章灵魂提问:当你在主城看到100个玩家同时跑动、聊天、换装,为什么你的客户端不卡?当服务器突然宕机,你辛辛苦苦打了3小时的装备为什么不会消失?这两个问题的答案,分别藏在"同步"和"持久化"里。
一、同步:让千人看到"同一个"世界
MMO的核心幻觉是:几万个玩家觉得自己在同一个世界里。实际上,这些玩家分散在几十个进程、几百台机器上。同步(Synchronization)的职责,就是维护这个幻觉。
1.1 同步的本质问题
想象一个简单场景:玩家A向前走了1米。有多少人需要知道这件事?
- 理论上:全服所有玩家——因为A的新位置可能影响任何人(比如远程狙击)
- 实际上:只告诉A周围50米内的玩家——超出这个距离,渲染精度差异可以忽略
- 工程上:甚至不是"50米内所有玩家",而是**"可能看到A的玩家"**——躲在墙后的人不需要知道
这就是同步的核心矛盾:正确性 vs 带宽。你要在"不漏掉任何该看到的人"和"不把网络压垮"之间找到平衡点。
1.2 AOI:兴趣区域管理
AOI(Area of Interest)是同步系统的地基。它不解决"怎么传",只解决"传给谁"。
// AOI的核心接口:告诉我,某个位置周围的"观察者"有哪些
class AOIEntityManager {
public:
// 注册一个实体(玩家、NPC、掉落物)
void AddEntity(Entity* entity, float x, float z, float radius);
// 移动后更新位置
void UpdatePosition(Entity* entity, float newX, float newZ);
// 查询:who can see me?
std::vector<Entity*> QueryWatchers(float x, float z, float range);
// 查询:who can I see?
std::vector<Entity*> QueryObservables(Entity* viewer);
};两种经典AOI算法:
十字链表法(适合实体稀疏、移动频繁)
// 每个坐标轴维护一个有序链表
struct LinkedListAOI {
// X轴链表:所有实体按x坐标排序
std::list<AOINode*> m_xAxisList;
// Z轴链表:所有实体按z坐标排序
std::list<AOINode*> m_zAxisList;
void UpdatePosition(AOINode* node, float newX, float newZ) {
// 在X链表中找到新位置并移动节点——O(n)但n是AOI范围内的实体数,通常很小
auto xIt = m_xAxisList.begin();
while (xIt != m_xAxisList.end() && (*xIt)->x < newX) xIt++;
m_xAxisList.splice(xIt, m_xAxisList, node->xIter);
// 同理Z轴……
// 检查是否进出AOI——只和链表前后几个节点比较即可
CheckAOIChanges(node);
}
void CheckAOIChanges(AOINode* node) {
// 只检查x方向±AOI_RADIUS和z方向±AOI_RADIUS范围内的邻居
// 因为链表有序,超出这个范围的可以直接跳过
}
};优点:移动更新的时间复杂度是O(k),k是AOI半径内的实体数,不是全地图实体数。
缺点:实现复杂,链表操作的并发控制很烦人。
九宫格/灯塔法(适合实体密集、地图规则)
// 把地图切成固定大小的格子
class GridAOI {
static constexpr float GRID_SIZE = 100.0f; // 每个格子100x100
struct Grid {
std::unordered_set<Entity*> entities;
};
std::unordered_map<uint64_t, std::unordered_map<uint64_t, Grid*>> m_grids;
void AddEntity(Entity* entity, float x, float z) {
uint64_t gx = (uint64_t)(x / GRID_SIZE);
uint64_t gz = (uint64_t)(z / GRID_SIZE);
m_grids[gx][gz].entities.insert(entity);
entity->gridX = gx;
entity->gridZ = gz;
}
std::vector<Entity*> QueryWatchers(float x, float z, float range) {
std::vector<Entity*> result;
// 只查询以(x,z)为中心、range为半径覆盖到的格子
uint64_t minGX = (uint64_t)((x - range) / GRID_SIZE);
uint64_t maxGX = (uint64_t)((x + range) / GRID_SIZE);
uint64_t minGZ = (uint64_t)((z - range) / GRID_SIZE);
uint64_t maxGZ = (uint64_t)((z + range) / GRID_SIZE);
for (uint64_t gx = minGX; gx <= maxGX; gx++) {
for (uint64_t gz = minGZ; gz <= maxGZ; gz++) {
for (Entity* e : m_grids[gx][gz].entities) {
if (Distance(e, x, z) <= range) result.push_back(e);
}
}
}
return result;
}
};优点:实现简单,查询是O(1)到格子数,格子数固定。
缺点:如果AOI半径远大于格子大小,会扫很多格子;如果实体全挤在一个格子(比如主城复活点),退化为暴力遍历。
1.3 状态广播:该发什么?怎么发?
知道了"发给谁",接下来是"发什么"和"怎么发"。
状态分类:POS vs HP vs 装备
// 不是所有东西都需要实时同步
enum SyncPriority {
SYNC_PRIORITY_CRITICAL = 0, // 位置、朝向——每帧都发(或者尽可能频繁)
SYNC_PRIORITY_HIGH, // 血量变化——受伤时立即发
SYNC_PRIORITY_MEDIUM, // 动画状态——技能释放、跳跃
SYNC_PRIORITY_LOW, // 装备外观变化——延迟几秒没事
SYNC_PRIORITY_BATCH, // 背包变动——不需要广播,只发给本人
};
// 位置同步:100ms一次,但用"增量压缩"
struct PosSyncPacket {
uint64_t entityID;
int16_t deltaX; // 相对上次位置的增量,int16足够表示±32米(精度0.5cm)
int16_t deltaZ;
int8_t deltaY; // Y轴移动范围小
uint8_t rotY; // 朝向:0-255对应0-360度
};
// 总大小:8 + 2 + 2 + 1 + 1 = 14字节——1000个玩家同时动,每100ms只发14KB位置同步的"死区"(Dead Reckoning)
// 不要在玩家每走1cm都发包——客户端可以预测
class DeadReckoningSync {
public:
// 服务端记录的上次广播位置
Vector3 m_lastBroadcastPos;
Vector3 m_lastBroadcastVel;
bool ShouldBroadcast(Entity* entity) {
Vector3 predicted = m_lastBroadcastPos + m_lastBroadcastVel * timeSinceLastBroadcast;
float error = Distance(entity->pos, predicted);
// 只有当"实际位置"和"客户端预测位置"的误差超过阈值,才发矫正包
return error > POSITION_SYNC_THRESHOLD; // 通常2-5米
}
void OnBroadcast(Entity* entity) {
m_lastBroadcastPos = entity->pos;
m_lastBroadcastVel = entity->velocity;
}
};效果:玩家直线走路时几乎不发包(客户端用匀速预测),只有转弯、加速、被击退时才发矫正包。这能节省70%以上的位置同步带宽。
1.4 空间同步的完整架构
graph TB
subgraph "Space进程"
A[游戏主循环
60 ticks/sec]
A -->|"1. 实体移动/状态变更"| B[AOIManager]
B -->|"2. 计算AOI变化"| C[新增/移除/更新观察者列表]
C -->|"3. 生成状态包"| D[SyncPacketBuilder]
D -->|"4. 按玩家聚合"| E[PlayerSyncQueue]
E -->|"5. 批量压缩发送"| F[NetworkLayer]
end
F -->|"6. 二进制协议包"| G[Gateway]
G -->|"7. 转发到客户端"| H[客户端A]
G -->|"7. 转发到客户端"| I[客户端B]
style A fill:#f9f,stroke:#333
style E fill:#bbf,stroke:#333// SyncPacketBuilder:把零散的实体变化聚合成高效数据包
class SyncPacketBuilder {
public:
// 每帧收集所有变化
void AddPositionSync(Entity* entity);
void AddHPChange(Entity* entity, int32_t delta);
void AddAnimationSync(Entity* entity, uint32_t animID);
void AddEquipmentChange(Entity* entity, uint32_t slot, uint32_t itemID);
// 按玩家聚合:每个玩家只收到他能看到的实体的变化
std::unordered_map<uint64_t, std::vector<SyncPacket>> BuildPerPlayer() {
std::unordered_map<uint64_t, std::vector<SyncPacket>> result;
for (auto& [entityID, changes] : m_allChanges) {
Entity* entity = GetEntity(entityID);
// 谁能看到这个实体?
auto watchers = m_aoi.QueryWatchers(entity->pos.x, entity->pos.z, AOI_RADIUS);
for (Entity* watcher : watchers) {
if (watcher->IsPlayer()) {
result[watcher->id].insert(result[watcher->id].end(),
changes.begin(), changes.end());
}
}
}
return result;
}
};二、序列化:把内存对象变成"可以存/可以传"的字节流
同步和持久化都需要序列化。但两者的需求不同:
- 同步序列化:快、小、允许丢失部分精度(位置用int16而不是float)
- 持久化序列化:全、稳、必须100%可恢复(金币数量差1都是Bug)
2.1 Player序列化:分层设计
class Player {
public:
// ========== 第一层:基础属性(必须存) ==========
uint64_t m_id;
std::string m_name;
int32_t m_level;
int64_t m_exp;
int32_t m_hp, m_maxHp, m_mp, m_maxMp;
// ========== 第二层:空间状态(在线时有效) ==========
Vector3 m_position;
Vector3 m_rotation;
int32_t m_worldId; // 当前所在地图
int32_t m_instanceId; // 副本实例ID(如果是副本)
// ========== 第三层:装备与外观 ==========
Equipment m_equipment[EQ_SLOT_MAX]; // 每个槽位的装备
uint32_t m_appearanceData[APP_MAX]; // 捏脸/染色数据
// ========== 第四层:背包 ==========
Inventory m_inventory; // 可能几百个格子
BankStorage m_bank; // 仓库——容量更大
// ========== 第五层:任务与成就 ==========
QuestManager m_quests;
AchievementManager m_achievements;
// ========== 第六层:社交 ==========
uint64_t m_partyID;
uint64_t m_guildID;
FriendList m_friends;
BlackList m_blacklist;
// ========== 第七层:运行时状态(不存盘) ==========
uint64_t m_sessionID; // 当前连接
PlayerState m_state; // 正常/战斗中/跳转中
BuffManager m_buffs; // Buff——下线后通常清掉
CooldownManager m_cooldowns; // 技能CD——下线后通常清掉
};2.2 序列化实现:手动 vs 反射
// 手动版本:精确控制每个字段
class PlayerSerializer {
public:
static void SerializeForDB(const Player* player, ByteStream& stream) {
// 基础属性
stream << player->m_id << player->m_name << player->m_level;
// 装备:只存itemID和耐久——不存运行时计算的属性
for (int i = 0; i < EQ_SLOT_MAX; i++) {
const Equipment& eq = player->m_equipment[i];
stream << eq.itemID << eq.durability << eq.enchantLevel;
// 注意:不存eq.currentAttack——那是根据itemID查表算出来的
}
// 背包:用"差异编码"——空格子和大量相同物品可以压缩
SerializeInventoryOptimized(player->m_inventory, stream);
// 任务:只存"进行中的"和"已完成的"
stream <> player->m_quests.GetActiveQuestIDs();
stream <> player->m_quests.GetCompletedQuestIDs();
// 不存"可接但未接的"——那是资源表决定的
}
static void DeserializeFromDB(Player* player, ByteStream& stream) {
// 顺序必须与Serialize完全一致——这是手动序列化的最大风险
stream >> player->m_id >> player->m_name >> player->m_level;
// ...
}
};血泪教训:手动序列化一旦字段顺序写错,或者某次更新加了字段但反序列化没改,就会数据错位。我们曾经因此导致全服玩家的装备槽位偏移1——剑在头盔栏,头盔在衣服栏。修复花了3天,补偿了价值数万元的道具。
更安全的做法:带版本号和字段标签的序列化。
// 类Protocol Buffers的Tagged序列化
void SerializeWithTags(ByteStream& stream) {
stream.WriteTag(1, TYPE_UINT64); stream.WriteUInt64(m_id);
stream.WriteTag(2, TYPE_STRING); stream.WriteString(m_name);
stream.WriteTag(3, TYPE_INT32); stream.WriteInt32(m_level);
// ...
// 优势:新增字段不会破坏旧数据;跳过不认识tag的字段即可
}三、快照系统:时间机器
快照(Snapshot)不是持久化——它是内存中的时间点备份,用于:
- 回滚作弊操作(发现玩家开挂,回滚到5秒前)
- 调试复杂Bug("服务器crash时玩家状态是什么?")
- 副本保存点(团队副本中,灭团后从Boss战前的快照恢复)
3.1 增量快照设计
全量快照太慢。一个Player对象可能几MB,1000个玩家就是几GB。
class SnapshotSystem {
public:
struct FullSnapshot {
uint64_t timestamp;
std::vector<uint8_t> data; // 全量序列化
};
struct DeltaSnapshot {
uint64_t timestamp;
uint64_t baseSnapshotID; // 基于哪个全量快照
std::vector<FieldChange> changes;
};
struct FieldChange {
uint16_t fieldOffset; // 相对于Player对象起始地址的偏移
uint16_t fieldSize; // 变更大小
std::vector<uint8_t> newValue;
};
void TakeFullSnapshot() {
FullSnapshot snap;
snap.timestamp = Now();
for (Player* player : m_allPlayers) {
// 序列化每个玩家,拼接成大数据块
PlayerSerializer::SerializeTo(player, snap.data);
}
m_snapshots.push_back(snap);
}
void TakeDeltaSnapshot() {
// 只记录自上次快照以来"被修改过"的字段
DeltaSnapshot delta;
delta.timestamp = Now();
delta.baseSnapshotID = m_lastFullSnapshotID;
for (Player* player : m_dirtyPlayers) {
for (uint16_t dirtyField : player->GetDirtyFields()) {
FieldChange change;
change.fieldOffset = dirtyField;
change.newValue = player->GetFieldBytes(dirtyField);
delta.changes.push_back(change);
}
player->ClearDirtyFlags();
}
m_deltaSnapshots.push_back(delta);
}
};3.2 快照的存储策略
// 时间分层:越老的快照越粗
class SnapshotRetentionPolicy {
public:
std::vector<Snapshot> m_snapshots;
void Maintain() {
uint64_t now = Now();
// 最近1分钟:每秒一个增量快照
// 最近1小时:每分钟一个全量快照
// 最近1天:每小时一个全量快照
// 最近1周:每天一个全量快照
for (auto it = m_snapshots.begin(); it != m_snapshots.end();) {
uint64_t age = now - it->timestamp;
if (age < 60s) {
it++; // 保留全部
} else if (age < 1h && it->type != SNAPSHOT_FULL &&
(it - lastKept).total_seconds < 60) {
it = m_snapshots.erase(it); // 每分钟只保留一个增量
} else if (age < 1d && it->type != SNAPSHOT_FULL) {
it = m_snapshots.erase(it); // 超过1小时,只保留全量
} else {
it++;
}
}
}
};四、数据持久化:虚拟世界的保险箱
持久化是让玩家数据在服务器重启后依然存在的机制。没有持久化,游戏就是沙盒——下线即焚。
4.1 存盘策略:定时 vs 事件触发
enum SaveTrigger {
SAVE_INTERVAL, // 定时存盘(比如每5分钟)
SAVE_LOGOUT, // 玩家下线时
SAVE_LEVELUP, // 升级时——关键时刻,不能丢
SAVE_EXPENSIVE_OP, // 获得稀有装备/大额交易后
SAVE_MANUAL, // GM指令强制存盘
SAVE_PRE_TRANSFER, // 跳转前(上一章的内容!)
};
class SaveScheduler {
public:
void OnPlayerExpensiveOperation(Player* player) {
// 获得传说级武器?立即触发存盘
m_urgentSaveQueue.push(player);
}
void Tick() {
// 每帧处理一部分存盘请求,避免阻塞主逻辑
int savesThisFrame = 0;
while (!m_urgentSaveQueue.empty() && savesThisFrame < MAX_SAVES_PER_FRAME) {
Player* player = m_urgentSaveQueue.front();
m_urgentSaveQueue.pop();
PerformSave(player);
savesThisFrame++;
}
}
};核心原则:
- 普通数据(位置、经验值):5分钟定时存盘就够了
- 关键数据(装备获得、付费货币消耗):事件触发、立即存盘
- 跨服跳转数据:同步阻塞存盘——跳转前必须确认玩家数据已落库
4.2 MySQL连接池
直接每个请求new一个MySQL连接?性能会死。
class MySQLConnectionPool {
public:
struct Connection {
MYSQL* mysql;
bool inUse;
uint64_t lastUsedTime;
uint32_t queryCount; // 已执行查询数——超过阈值回收(防内存泄漏)
};
std::vector<Connection*> m_pool;
std::mutex m_mutex;
std::condition_variable m_cv;
Connection* Acquire() {
std::unique_lock<std::mutex> lock(m_mutex);
// 等可用连接
m_cv.wait(lock, [this]() {
for (auto* conn : m_pool) {
if (!conn->inUse) return true;
}
return false;
});
for (auto* conn : m_pool) {
if (!conn->inUse) {
conn->inUse = true;
// 检查连接是否还活着——MySQL有超时断开
if (mysql_ping(conn->mysql) != 0) {
Reconnect(conn);
}
return conn;
}
}
return nullptr; // 不应该到这里
}
void Release(Connection* conn) {
std::lock_guard<std::mutex> lock(m_mutex);
conn->inUse = false;
conn->lastUsedTime = Now();
m_cv.notify_one();
}
// 后台线程:定期回收超时连接
void MaintenanceThread() {
while (running) {
sleep(30s);
std::lock_guard<std::mutex> lock(m_mutex);
for (auto* conn : m_pool) {
if (!conn->inUse && Now() - conn->lastUsedTime > 300s) {
// 5分钟没用,断开回收
mysql_close(conn->mysql);
conn->mysql = nullptr;
}
}
}
}
};连接池参数调优:
- 初始连接数:5-10(启动时预创建)
- 最大连接数:根据MySQL服务器配置,通常50-100
- 连接超时:8小时(MySQL默认wait_timeout)
- 查询超时:5秒——超过就杀掉,防止慢查询拖垮服务
4.3 事务与一致性
// 玩家购买商店物品:扣钱+给物品——必须原子
class TransactionalSave {
public:
bool BuyItem(Player* player, uint32_t itemID, int32_t cost) {
Connection* conn = g_DBPool->Acquire();
// 开始事务
mysql_query(conn->mysql, "BEGIN");
try {
// Step 1: 检查余额(带锁查询——FOR UPDATE)
std::string checkSQL = fmt::format(
"SELECT gold FROM player_currency WHERE player_id={} FOR UPDATE",
player->id
);
mysql_query(conn->mysql, checkSQL.c_str());
// ... 解析结果 ...
if (playerGold < cost) {
mysql_query(conn->mysql, "ROLLBACK");
return false; // 余额不足
}
// Step 2: 扣钱
std::string deductSQL = fmt::format(
"UPDATE player_currency SET gold=gold-{} WHERE player_id={}",
cost, player->id
);
mysql_query(conn->mysql, deductSQL.c_str());
// Step 3: 加物品
std::string addItemSQL = fmt::format(
"INSERT INTO player_inventory (player_id, item_id, count) VALUES ({}, {}, 1)",
player->id, itemID
);
mysql_query(conn->mysql, addItemSQL.c_str());
// 提交
mysql_query(conn->mysql, "COMMIT");
return true;
} catch (...) {
mysql_query(conn->mysql, "ROLLBACK");
return false;
}
g_DBPool->Release(conn);
}
};重要:游戏里的"事务"和银行系统不一样——允许最终一致性。比如玩家A给了B 100金币,A的扣钱操作先落库,B的加钱操作可以延迟100ms。只要不出现"钱没了但对方没收到"或"双方都有钱"的情况即可。
4.4 批量写入优化
// 定时存盘时,不要把1000个玩家逐个INSERT——用批量SQL
class BatchPlayerSave {
public:
void SaveAllPlayers(const std::vector<Player*>& players) {
Connection* conn = g_DBPool->Acquire();
// 方案1:INSERT ... ON DUPLICATE KEY UPDATE(推荐)
std::string batchSQL = "INSERT INTO player_base (player_id, name, level, hp, mp, pos_x, pos_y, pos_z) VALUES ";
for (size_t i = 0; i < players.size(); i++) {
Player* p = players[i];
batchSQL += fmt::format("({}, '{}', {}, {}, {}, {}, {}, {})",
p->id, p->name, p->level, p->hp, p->mp,
p->pos.x, p->pos.y, p->pos.z);
if (i < players.size() - 1) batchSQL += ",";
}
batchSQL += " ON DUPLICATE KEY UPDATE ";
batchSQL += "name=VALUES(name), level=VALUES(level), hp=VALUES(hp), ";
batchSQL += "mp=VALUES(mp), pos_x=VALUES(pos_x), pos_y=VALUES(pos_y), pos_z=VALUES(pos_z)";
mysql_query(conn->mysql, batchSQL.c_str());
g_DBPool->Release(conn);
}
};效果:单条INSERT 1000次 ≈ 10秒。批量INSERT 1次 ≈ 100ms。差100倍。
五、容灾与回档:当世界崩塌时
5.1 服务器宕机恢复流程
graph TB
A[服务器宕机] --> B[监控告警触发]
B --> C[自动重启space进程]
C --> D[从数据库加载玩家数据
+从快照恢复运行态]
D --> E[通知Gateway重新注册地图]
E --> F[等待客户端重连]
F --> G[玩家数据校验:
上次存盘 vs 快照回滚点]
G -->|"数据一致"| H[恢复完成]
G -->|"数据冲突"| I[人工介入/自动回档]5.2 数据回档的三种策略
enum RollbackStrategy {
ROLLBACK_NONE, // 不回档——相信数据库就是对的(适用于非关键操作丢失)
ROLLBACK_TO_SNAPSHOT, // 回滚到最近快照——丢失快照后到现在的数据
ROLLBACK_TO_BACKUP, // 回滚到冷备份——丢失几小时数据(最坏情况)
};
class DisasterRecovery {
public:
void OnServerCrashRecovery() {
// Step 1: 读取宕机前最后的定时存盘数据
std::vector<PlayerData> dbData = LoadAllPlayersFromDB();
// Step 2: 如果有更近的快照,用快照覆盖
if (HasRecentSnapshot()) {
auto snapshotData = LoadLatestSnapshot();
for (auto& snap : snapshotData) {
// 快照比数据库新——用快照
if (snap.timestamp > GetDBSaveTime(snap.playerID)) {
dbData[snap.playerID] = snap;
}
}
}
// Step 3: 重建运行时对象
for (auto& data : dbData) {
Player* player = new Player();
player->DeserializeFrom(data);
// 注意:位置重置到安全区,防止玩家卡死在宕机前的bug位置
player->SetPositionToSafeZone();
m_world->AddPlayer(player);
}
// Step 4: 广播全服——"服务器已恢复,请重新登录"
BroadcastSystemMessage("服务器已恢复,所有在线玩家被传送至安全区");
}
};5.3 Binlog与数据审计
// 关键操作必须写审计日志——用于事后追溯和问题排查
class AuditLogger {
public:
void LogItemObtain(uint64_t playerID, uint32_t itemID, int32_t count,
const std::string& source) {
// 写入独立的审计表或Kafka——不影响主数据库性能
AuditEntry entry;
entry.timestamp = Now();
entry.playerID = playerID;
entry.action = "ITEM_OBTAIN";
entry.details = fmt::format("item={}, count={}, source={}", itemID, count, source);
// 异步写入——不阻塞游戏逻辑
g_AuditQueue->Push(entry);
}
};
// 用途:
// 1. 玩家说"我的装备丢了"——查审计日志看他怎么丢的
// 2. 发现复制Bug——查审计日志看哪些玩家异常获得了道具
// 3. 合规需求——某些地区法律要求游戏必须有完整的操作审计六、代码实战:一个完整的存盘流程
// ==================== 定时存盘主流程 ====================
void World::ScheduledSaveAllPlayers() {
uint64_t startTime = Now();
// 1. 收集需要存盘的玩家(标记为dirty的)
std::vector<Player*> dirtyPlayers;
for (auto& [id, player] : m_players) {
if (player->IsDirty()) {
dirtyPlayers.push_back(player);
}
}
LOG_INFO("Scheduled save: {} players dirty", dirtyPlayers.size());
// 2. 序列化
std::vector<PlayerSerializedData> serialized;
serialized.reserve(dirtyPlayers.size());
for (Player* player : dirtyPlayers) {
PlayerSerializedData data;
data.playerID = player->id;
ByteStream stream;
PlayerSerializer::SerializeForDB(player, stream);
data.bytes = stream.GetBytes();
serialized.push_back(data);
}
// 3. 批量写入MySQL(在后台线程执行)
g_DBThreadPool->Enqueue([serialized]() {
Connection* conn = g_DBPool->Acquire();
// 开启事务
mysql_query(conn->mysql, "BEGIN");
for (const auto& data : serialized) {
// 使用预处理语句——防SQL注入+性能更好
MYSQL_STMT* stmt = mysql_stmt_init(conn->mysql);
const char* sql = "INSERT INTO player_data (player_id, data_blob, save_time) "
"VALUES (?, ?, NOW()) "
"ON DUPLICATE KEY UPDATE data_blob=VALUES(data_blob), save_time=VALUES(save_time)";
mysql_stmt_prepare(stmt, sql, strlen(sql));
// 绑定参数
MYSQL_BIND bind[3];
memset(bind, 0, sizeof(bind));
bind[0].buffer_type = MYSQL_TYPE_LONGLONG;
bind[0].buffer = (void*)&data.playerID;
bind[1].buffer_type = MYSQL_TYPE_BLOB;
bind[1].buffer = (void*)data.bytes.data();
bind[1].buffer_length = data.bytes.size();
mysql_stmt_bind_param(stmt, bind);
mysql_stmt_execute(stmt);
mysql_stmt_close(stmt);
}
mysql_query(conn->mysql, "COMMIT");
g_DBPool->Release(conn);
});
uint64_t elapsed = Now() - startTime;
LOG_INFO("Scheduled save completed in {}ms", elapsed);
}
// ==================== 玩家下线存盘(必须同步完成) ====================
bool World::OnPlayerLogout(Player* player) {
// 下线存盘不能丢——用同步阻塞方式
Connection* conn = g_DBPool->Acquire();
// 先保存玩家数据
ByteStream stream;
PlayerSerializer::SerializeForDB(player, stream);
std::string sql = fmt::format(
"UPDATE player_data SET data_blob='{}', save_time=NOW(), is_online=0 WHERE player_id={}",
EscapeBlob(stream.GetBytes()), player->id
);
int result = mysql_query(conn->mysql, sql.c_str());
g_DBPool->Release(conn);
if (result != 0) {
// 存盘失败!不能让玩家下线——否则数据丢失
LOG_ERROR("Player {} logout save failed!", player->id);
player->Notify("数据保存失败,请稍后再试");
return false; // 阻止下线
}
// 同时写审计日志
g_AuditLogger->LogAction(player->id, "LOGOUT", fmt::format("pos=({},{},{})",
player->pos.x, player->pos.y, player->pos.z));
return true;
}
// ==================== 快照生成 ====================
void World::TakeSnapshot() {
Snapshot snap;
snap.timestamp = Now();
snap.worldID = m_worldId;
for (auto& [id, player] : m_players) {
SnapshotEntry entry;
entry.playerID = id;
entry.pos = player->pos;
entry.hp = player->hp;
entry.mp = player->mp;
entry.activeBuffs = player->m_buffs.GetSnapshot();
// ... 其他运行时状态
snap.entries.push_back(entry);
}
// 异步写入快照存储(可以是本地文件、Redis、或专门的快照服务)
g_SnapshotService->StoreAsync(snap);
}七、工程陷阱与最佳实践
7.1 存盘风暴(Save Storm)
场景:跨服活动结束,10000个玩家同时被传送回主城,同时触发下线/存盘。
后果:MySQL连接池耗尽,请求排队,游戏卡顿,玩家看到"保存中……"转圈。
解决方案:
class SaveRateLimiter {
public:
// 令牌桶:每秒最多N个存盘请求
TokenBucket m_bucket;
void RequestSave(Player* player) {
if (m_bucket.Acquire()) {
// 立即存盘
DoSave(player);
} else {
// 放入延迟队列,错峰存盘
m_delayedSaveQueue.push(player);
}
}
void Tick() {
// 每帧处理延迟队列中的一部分
int processCount = std::min((int)m_delayedSaveQueue.size(),
MAX_DELAYED_SAVES_PER_FRAME);
for (int i = 0; i < processCount; i++) {
Player* player = m_delayedSaveQueue.front();
m_delayedSaveQueue.pop();
DoSave(player);
}
}
};7.2 序列化版本兼容性
// 存档格式必须向后兼容——否则老玩家数据读不出来
class VersionedSerializer {
static constexpr uint32_t CURRENT_VERSION = 3;
public:
static void Serialize(Player* player, ByteStream& stream) {
stream << CURRENT_VERSION; // 先写版本号
stream << player->id << player->name;
// ... v3格式
stream << player->newFieldV3; // v3新增的字段
}
static bool Deserialize(Player* player, ByteStream& stream) {
uint32_t version;
stream >> version;
if (version == 1) {
return DeserializeV1(player, stream);
} else if (version == 2) {
return DeserializeV2(player, stream);
} else if (version == CURRENT_VERSION) {
return DeserializeV3(player, stream);
}
return false; // 未知版本
}
};7.3 数据校验:最后的防线
// 加载玩家数据后,校验关键数据合理性——防止 corruption
bool ValidatePlayerData(const PlayerData& data) {
// 金币不能是负数
if (data.gold < 0 || data.gold > MAX_GOLD_LIMIT) {
LOG_ERROR("Invalid gold: {}", data.gold);
return false;
}
// 等级不能超过当前版本上限
if (data.level < 1 || data.level > g_GameConfig->maxLevel) {
LOG_ERROR("Invalid level: {}", data.level);
return false;
}
// 装备槽位不能越界
for (const auto& eq : data.equipment) {
if (eq.slot >= EQ_SLOT_MAX) {
LOG_ERROR("Invalid equipment slot: {}", eq.slot);
return false;
}
}
// 位置必须在有效地图范围内
if (data.posX < data.worldMinX || data.posX > data.worldMaxX) {
LOG_WARN("Player position out of world bounds, resetting to safe zone");
data.posX = data.worldSafeX;
data.posZ = data.worldSafeZ;
}
return true;
}八、小结
同步和持久化是MMO的"呼吸和心跳"。
- 同步让千人同屏成为可能——AOI裁剪掉不需要的消息,Dead Reckoning压缩位置数据,批量广播节省带宽。
- 持久化让玩家的努力有回报——定时存盘兜底,事件触发存盘保护关键时刻,事务保证资产安全,快照和审计提供事后追溯。
没有同步,玩家看到的是一个破碎的世界——别人原地踏步,技能打空气。
没有持久化,玩家不敢投入——谁知道明天服务器会不会"失忆"?
老工程师的唠叨:> > 同步系统上线前,你必须做三件事:> 1. 1000个机器人在同一张地图跑30分钟,监控带宽和CPU——看AOI是否泄漏、广播是否爆炸> 2. 拔网线测试:客户端突然断开,服务端是否优雅处理?数据是否完整存盘?> 3. 模拟MySQL宕机:看游戏是否能进入"只读模式"(允许玩但不允许获得/消耗道具),而不是直接崩溃> > 持久化系统上线前,你必须做一件事:> 用随机字节替换生产数据库的备份,尝试恢复——如果你的恢复流程连 corrupted 备份都处理不了,> 那真正灾难来时你也处理不了。🔥