第10章:分布式登录与Redis内存数据库——水平扩展的钥匙
本章一句话总结:Redis不是缓存,它是分布式游戏服务器的"神经系统"——所有进程共享状态、协调动作、支撑无状态服务的基石。
一、从单进程到分布式:登录架构的重新思考
在第9章我们聊过game和space进程的职责分离。但分布式架构带来了一个根本性问题:玩家的登录状态,应该存在哪里?
1.1 单进程时代的登录状态
// 单进程时代:登录状态存在内存里,简单粗暴
class LoginManager {
std::unordered_map<uint64_t, Account*> onlineAccounts_;
public:
Account* GetAccount(uint64_t accountId) {
return onlineAccounts_[accountId]; // 直接内存指针
}
};这很好,直到你需要开第二个进程。两个进程的内存互不相通,玩家在A进程登录后,B进程根本不知道这回事。
1.2 分布式时代的核心需求
| 需求 | 单进程方案 | 分布式方案 |
|---|---|---|
| 账号状态共享 | 进程内map | 共享存储(Redis) |
| 进程宕机恢复 | 玩家全部掉线 | 另一进程接管,玩家无感知 |
| 动态扩缩容 | 没法扩 | 新进程启动即参与负载均衡 |
| 登录信息查询 | 遍历进程内存 | 全集群统一查询 |
graph LR
A[客户端] -- 连接 --> B[Game进程1]
A -- 连接 --> C[Game进程2]
A -- 连接 --> D[Game进程3]
B -- 读写 --> E[Redis集群]
C -- 读写 --> E
D -- 读写 --> E
E -- 状态共享 --> B
E -- 状态共享 --> C
E -- 状态共享 --> D二、选择合适的game进程:负载均衡的入门课
2.1 玩家应该连到哪个game进程?
有四种常见的策略,从蠢到聪明:
轮询(Round Robin):每个新连接轮流分配给game进程。简单,但不考虑进程当前负载。
随机(Random):随机选一个。比轮询更差,因为随机不保证均匀。
最小连接数(Least Connections):选当前在线玩家最少的进程。好得多,但假设每个玩家的负载相同。
自定义负载分(Custom Score):综合CPU、内存、网络IO、当前在线数算一个分数,选分最低的。
struct GameProcessLoad {
uint32_t processId;
uint32_t currentPlayers; // 当前在线玩家数
float cpuPercent; // CPU占用(0.0-1.0)
uint64_t memoryBytes; // 内存占用
uint64_t networkInBytes; // 网络入流量
uint64_t networkOutBytes; // 网络出流量
time_t lastReportTime; // 上次上报时间(过期则不考虑)
// 综合负载分数,越小越空闲
double GetLoadScore() const {
// 权重可调,根据实际业务调整
constexpr double W_PLAYER = 1.0;
constexpr double W_CPU = 1000.0; // CPU打满=1000个玩家
constexpr double W_MEM = 0.001; // 每MB内存
constexpr double W_NET = 0.0001; // 每KB网络
return currentPlayers * W_PLAYER
+ cpuPercent * 100 * W_CPU
+ memoryBytes / 1024 / 1024 * W_MEM
+ (networkInBytes + networkOutBytes) / 1024 * W_NET;
}
};
// 选择最优进程
uint32_t SelectBestGameProcess(const std::vector<GameProcessLoad>& processes) {
auto best = std::min_element(processes.begin(), processes.end(),
[](const auto& a, const auto& b) {
return a.GetLoadScore() < b.GetLoadScore();
});
return best->processId;
}2.2 Redis存储负载信息
// 每个game进程定期上报自己的负载(每5秒)
void ReportLoadToRedis(RedisConnector* redis, const GameProcessLoad& load) {
// 使用Hash存储:key = "game:load:{processId}"
redis->HMSet(fmt::format("game:load:{}", load.processId), {
{"players", std::to_string(load.currentPlayers)},
{"cpu", fmt::format("{:.2f}", load.cpuPercent)},
{"memory", std::to_string(load.memoryBytes)},
{"last_report", std::to_string(load.lastReportTime)}
});
// 设置过期时间:15秒,超过则认为进程已宕机
redis->Expire(fmt::format("game:load:{}", load.processId), 15);
}
// 查询所有存活的game进程
std::vector<GameProcessLoad> QueryAliveGameProcesses(RedisConnector* redis) {
std::vector<GameProcessLoad> result;
// 使用Redis的SCAN或Keys查找所有game:load:*
auto keys = redis->Keys("game:load:*");
for (const auto& key : keys) {
auto data = redis->HGetAll(key);
if (data.empty()) continue;
GameProcessLoad load;
load.processId = ParseProcessId(key);
load.currentPlayers = std::stoul(data["players"]);
load.cpuPercent = std::stof(data["cpu"]);
load.memoryBytes = std::stoull(data["memory"]);
load.lastReportTime = std::stoll(data["last_report"]);
// 只考虑最近10秒内上报的进程
if (Now() - load.lastReportTime < 10) {
result.push_back(load);
}
}
return result;
}三、使用token登录game:无状态服务的核心
3.1 为什么token是分布式登录的钥匙?
Cookie和Session是Web开发的经典组合,但在游戏服务器里,有状态=无法扩展。如果session存在某个game进程的内存里,那玩家下次请求就必须落到同一个进程——这叫粘性会话(Sticky Session),严重制约水平扩展。
Token机制的本质:服务器不存会话状态,所有状态都在token里,或者token能索引到的共享存储里。
3.2 token的生命周期管理
class TokenManager {
public:
// 登录成功后生成token
std::string GenerateToken(uint64_t accountId) {
std::string token = GenerateRandomString(32); // 32字节随机串
// token -> 账号ID映射,存入Redis,TTL = 24小时
redis_>SetEx(fmt::format("token:{}", token),
std::to_string(accountId),
24 * 3600);
// 账号ID -> token映射,用于单点登录(后登录踢掉前登录)
auto oldToken = redis_>Get(fmt::format("account_token:{}", accountId));
if (!oldToken.empty()) {
// 踢掉旧token
redis_>Del(fmt::format("token:{}", oldToken));
}
redis_>SetEx(fmt::format("account_token:{}", accountId),
token, 24 * 3600);
return token;
}
// 验证token,返回账号ID
std::optional<uint64_t> ValidateToken(const std::string& token) {
auto accountIdStr = redis_>Get(fmt::format("token:{}", token));
if (accountIdStr.empty()) {
return std::nullopt; // token不存在或已过期
}
// 续期:活跃玩家延长token有效期
redis_>Expire(fmt::format("token:{}", token), 24 * 3600);
return std::stoull(accountIdStr);
}
// 登出时销毁token
void RevokeToken(const std::string& token) {
auto accountId = redis_>Get(fmt::format("token:{}", token));
if (!accountId.empty()) {
redis_>Del(fmt::format("account_token:{}", accountId));
}
redis_>Del(fmt::format("token:{}", token));
}
};坑点预警:token续期策略要慎重。如果每次请求都续期,Redis的写入压力会很大。可以改为"距离过期还有1小时才续期",减少不必要的写入。
四、Player组件设计:面向组件的游戏对象
4.1 组件化 vs 继承
// 继承方式:一个巨大的Player类
class BadPlayer {
// 基础属性
uint64_t playerId;
std::string name;
// 战斗相关
uint32_t hp, maxHp, mp, maxMp;
uint32_t attack, defense;
std::vector<Skill> skills;
// 背包相关
std::vector<Item> bag;
uint32_t bagCapacity;
// 社交相关
std::vector<uint64_t> friends;
std::vector<uint64_t> guildMembers;
// 任务相关
std::vector<Quest> activeQuests;
std::vector<uint32_t> completedQuests;
// ... 还有几十个字段
};这种写法的问题是:所有玩家都拖着全部字段,但很多字段只在特定时刻使用。一个刚创建的角色,背包是空的,但你已经为它分配了std::vector的内存。
4.2 组件化设计
// 纯数据组件,没有行为
struct TransformComponent {
Vector3 position;
Vector3 rotation;
Vector3 scale;
};
struct HealthComponent {
uint32_t hp;
uint32_t maxHp;
uint32_t mp;
uint32_t maxMp;
};
struct BagComponent {
std::unordered_map<uint32_t, Item> items; // slot -> item
uint32_t capacity;
};
struct SkillComponent {
std::vector<Skill> skills;
std::vector<cooldown_t> cooldowns; // 技能冷却
};
// Player是一个ID + 组件容器
class Player {
uint64_t playerId_;
std::unordered_map<std::type_index, std::shared_ptr<void>> components_;
public:
template<typename T>
void AddComponent(std::shared_ptr<T> comp) {
components_[typeid(T)] = comp;
}
template<typename T>
std::shared_ptr<T> GetComponent() const {
auto it = components_.find(typeid(T));
if (it == components_.end()) return nullptr;
return std::static_pointer_cast<T>(it->second);
}
template<typename T>
bool HasComponent() const {
return components_.count(typeid(T)) > 0;
}
};
// 使用
auto player = std::make_shared<Player>(10001);
player->AddComponent(std::make_shared<TransformComponent>());
player->AddComponent(std::make_shared<HealthComponent>(100, 100, 50, 50));
// 战斗系统只关心有HealthComponent的实体
if (auto health = player->GetComponent<HealthComponent>()) {
health->hp -= damage;
}坑点预警:
std::type_index做key的unordered_map有性能损耗。如果组件类型是固定的,可以用枚举ID代替,或者预计算type_index的哈希缓存。
五、Redis安装与C++接入
5.1 Redis安装(生产环境配置)
# 安装Redis(以Ubuntu为例)
sudo apt-get update
sudo apt-get install redis-server
# 编辑配置文件
sudo vim /etc/redis/redis.conf
# 关键配置项:
# bind 0.0.0.0 # 监听所有接口(生产环境建议绑定内网IP)
# port 6379 # 默认端口
# maxmemory 2gb # 最大内存限制
# maxmemory-policy allkeys-lru # 内存满时淘汰策略:LRU
# save "" # 关闭RDB持久化(纯缓存场景)
# appendonly no # 关闭AOF(纯缓存场景)
# tcp-keepalive 60 # TCP keepalive
# 启动
sudo systemctl start redis-server
sudo systemctl enable redis-server
# 验证
redis-cli ping
# 应返回 PONG5.2 hiredis库的使用
hiredis是Redis官方C客户端,轻量、高效。
#include <hiredis/hiredis.h>
// 基础连接
class RedisConnection {
redisContext* ctx_;
public:
bool Connect(const std::string& ip, int port, int timeoutMs = 5000) {
struct timeval tv = {timeoutMs / 1000, (timeoutMs % 1000) * 1000};
ctx_ = redisConnectWithTimeout(ip.c_str(), port, tv);
if (ctx_ == nullptr || ctx_>err) {
fprintf(stderr, "Redis连接失败: %s\n",
ctx_ ? ctx_>errstr : "无法分配内存");
return false;
}
// 可选:认证
// auto reply = (redisReply*)redisCommand(ctx_, "AUTH %s", password);
// freeReplyObject(reply);
return true;
}
// 基础命令
std::string Get(const std::string& key) {
auto reply = (redisReply*)redisCommand(ctx_, "GET %s", key.c_str());
if (reply == nullptr) return "";
std::string result;
if (reply->type == REDIS_REPLY_STRING) {
result = std::string(reply->str, reply->len);
}
freeReplyObject(reply);
return result;
}
bool SetEx(const std::string& key, const std::string& value, int seconds) {
auto reply = (redisReply*)redisCommand(ctx_, "SETEX %s %d %s",
key.c_str(), seconds, value.c_str());
if (reply == nullptr) return false;
bool ok = (reply->type == REDIS_REPLY_STATUS &&
std::string(reply->str) == "OK");
freeReplyObject(reply);
return ok;
}
~RedisConnection() {
if (ctx_) redisFree(ctx_);
}
};5.3 生产级RedisConnector组件
class RedisConnector {
public:
// 初始化连接池
bool Initialize(const std::string& ip, int port,
int poolSize, int timeoutMs) {
for (int i = 0; i < poolSize; ++i) {
auto conn = std::make_unique<RedisConnection>();
if (!conn->Connect(ip, port, timeoutMs)) {
return false;
}
pool_.push(std::move(conn));
}
poolSize_ = poolSize;
return true;
}
// 获取连接(线程安全)
std::unique_ptr<RedisConnection> Acquire() {
std::unique_lock<std::mutex> lock(mutex_);
// 等待可用连接
cv_.wait(lock, [this]() { return !pool_.empty() || shutdown_; });
if (shutdown_) return nullptr;
auto conn = std::move(pool_.front());
pool_.pop();
return conn;
}
// 归还连接
void Release(std::unique_ptr<RedisConnection> conn) {
std::lock_guard<std::mutex> lock(mutex_);
pool_.push(std::move(conn));
cv_.notify_one();
}
// 便捷:自动归还的包装
template<typename Func>
auto Execute(Func&& func) -> decltype(func(std::declval<RedisConnection*>())) {
auto conn = Acquire();
if (!conn) {
throw std::runtime_error("Redis连接池已关闭");
}
try {
auto result = func(conn.get());
Release(std::move(conn));
return result;
} catch (...) {
Release(std::move(conn));
throw;
}
}
private:
std::queue<std::unique_ptr<RedisConnection>> pool_;
std::mutex mutex_;
std::condition_variable cv_;
bool shutdown_ = false;
int poolSize_ = 0;
};
// 使用示例
RedisConnector redis;
redis.Initialize("127.0.0.1", 6379, 10, 5000);
// 自动管理连接生命周期
auto result = redis.Execute([](RedisConnection* conn) {
return conn->Get("token:abc123");
});六、Redis在login和game中的应用全景
6.1 登录流程中的Redis操作
sequenceDiagram
participant C as 客户端
participant G as Game进程
participant R as Redis
participant DB as 数据库
C->>G: 连接请求
G->>R: 检查IP限流(INCR ip:{ip} + EXPIRE)
R-->>G: 未超限
C->>G: 发送账号密码
G->>DB: 验证账号密码
DB-->>G: 验证成功
G->>G: 生成token
G->>R: SETEX token:{token} {accountId} 86400
G->>R: SETEX account_token:{accountId} {token} 86400
G->>R: HSET player:{accountId} ...(加载角色数据到Redis缓存)
G-->>C: 返回token
Note over C,R: 后续请求
C->>G: 请求(携带token)
G->>R: GET token:{token}
R-->>G: accountId
G->>R: HGETALL player:{accountId}(热数据)
G-->>C: 响应6.2 Redis数据结构选型指南
| 用途 | Redis数据结构 | 示例 |
|---|---|---|
| token存储 | String | SETEX token:xxx 86400 10001 |
| 玩家在线状态 | Hash | HSET player:10001 hp 100 mp 50 |
| 排行榜 | Sorted Set | ZADD rank:level 100 player:10001 |
| 好友列表 | Set | SADD friends:10001 10002 10003 |
| 聊天频道 | List | LPUSH chat:world "msg" |
| 限流计数 | String + INCR | INCR ip:1.2.3.4 |
| 分布式锁 | String + NX | SET lock:xxx my_value NX EX 10 |
| 配置热更新 | Hash | HSET config:game max_players 5000 |
七、数据删除策略:Redis不是保险箱
7.1 为什么需要主动清理?
Redis的内存是宝贵的。一个运营了3年的游戏,Redis里可能堆积了:已删号玩家的残留数据、测试服误写入的脏数据、过期但未被访问的key(如果用了惰性过期策略)。
7.2 分级删除策略
class DataCleanupManager {
public:
// 策略1:登录时清理旧数据
void OnPlayerLogin(uint64_t accountId) {
// 检查账号是否已被注销
auto status = redis_>HGet(fmt::format("account:{}", accountId), "status");
if (status == "deleted") {
// 拒绝登录,并触发异步清理
ScheduleCleanup(accountId);
return;
}
}
// 策略2:定时扫描过期数据(每晚3点执行)
void NightlyCleanup() {
// 扫描所有带TTL的key
auto cursor = 0;
do {
auto reply = redis_>Scan(cursor, "player:*", 1000);
cursor = reply.cursor;
for (const auto& key : reply.keys) {
// 检查对应的账号是否还在线
auto accountId = ExtractAccountId(key);
if (!IsPlayerOnline(accountId)) {
// 不在线且数据超过7天未更新则删除
auto lastUpdate = redis_>ObjectIdleTime(key);
if (lastUpdate > 7 * 86400) {
redis_>Del(key);
}
}
}
} while (cursor != 0);
}
// 策略3:账号注销时的级联删除
void OnAccountDeleted(uint64_t accountId) {
// 使用管道批量删除,减少RTT
auto pipe = redis_>Pipeline();
pipe->Del(fmt::format("token:{}", GetToken(accountId)));
pipe->Del(fmt::format("account_token:{}", accountId));
pipe->Del(fmt::format("player:{}", accountId));
pipe->Del(fmt::format("bag:{}", accountId));
pipe->Del(fmt::format("friends:{}", accountId));
pipe->Execute();
}
};八、性能瓶颈分析与优化
8.1 日志:性能杀手的第一嫌疑人
// 危险!每个请求都写日志
void OnMoveRequest(const MoveReq& req) {
LOG_INFO("玩家{}从({}, {})移动到({}, {})",
req.playerId, req.fromX, req.fromY, req.toX, req.toY);
// ... 处理移动
}
// 优化1:日志级别控制
void OnMoveRequest(const MoveReq& req) {
// 使用宏,DEBUG级别在Release编译时直接消失
DEBUG_LOG("玩家{}移动", req.playerId);
// ... 处理移动
}
// 优化2:异步日志
class AsyncLogger {
std::queue<std::string> buffer_;
std::thread writerThread_;
void WriterLoop() {
while (running_) {
std::vector<std::string> batch;
{
std::lock_guard<std::mutex> lock(mutex_);
batch.reserve(buffer_.size());
while (!buffer_.empty()) {
batch.push_back(std::move(buffer_.front()));
buffer_.pop();
}
}
// 批量写入文件
FILE* fp = fopen(logFile_.c_str(), "a");
for (const auto& msg : batch) {
fwrite(msg.c_str(), 1, msg.size(), fp);
}
fclose(fp);
std::this_thread::sleep_for(std::chrono::milliseconds(100));
}
}
};8.2 MessageComponent:网络层的内存优化
// 原始:每个消息都动态分配内存
class BadMessageComponent {
std::queue<std::vector<char>> sendQueue_;
void Send(const void* data, size_t len) {
std::vector<char> buffer(data, data + len); // 堆分配
sendQueue_.push(std::move(buffer));
}
};
// 优化:预分配内存池 + 环形缓冲区
class OptimizedMessageComponent {
static constexpr size_t BLOCK_SIZE = 65536; // 64KB
static constexpr size_t BLOCK_COUNT = 1024; // 总共64MB
struct MemoryBlock {
char data[BLOCK_SIZE];
size_t used = 0;
bool inUse = false;
};
std::array<MemoryBlock, BLOCK_COUNT> pool_;
std::queue<MemoryBlock*> freeList_;
std::mutex poolMutex_;
public:
OptimizedMessageComponent() {
for (auto& block : pool_) {
freeList_.push(&block);
}
}
MemoryBlock* AcquireBlock() {
std::lock_guard<std::mutex> lock(poolMutex_);
if (freeList_.empty()) {
// 池满了,要么扩容,要么等
return nullptr;
}
auto* block = freeList_.front();
freeList_.pop();
block->inUse = true;
block->used = 0;
return block;
}
void ReleaseBlock(MemoryBlock* block) {
std::lock_guard<std::mutex> lock(poolMutex_);
block->inUse = false;
freeList_.push(block);
}
};8.3 ConnectObj:连接对象的内存组织
// 原始:每个连接都存完整玩家数据
class HeavyConnectObj {
Account account_; // 账号信息
Player player_; // 角色信息(可能很大)
BagComponent bag_; // 背包
SkillComponent skills_; // 技能
// ... 所有组件都存这里
};
// 优化:延迟加载 + 引用计数
class LightConnectObj {
uint64_t accountId_;
uint64_t playerId_;
NetworkConnect* conn_;
// 游戏数据不在连接对象里,而在全局缓存中
// 需要时从Redis或本地缓存获取
std::shared_ptr<PlayerData> GetPlayerData() {
return PlayerDataCache::Instance().Get(playerId_);
}
};九、多进程登录协议完整回顾
sequenceDiagram
participant C as 客户端
participant L as Login/Game进程
participant R as Redis
participant S as Space进程
participant DB as 数据库
%% 连接阶段
C->>L: TCP连接
L->>R: INCR connect_limit:{ip}
alt 连接数超限
L-->>C: 断开连接
else 未超限
%% 登录阶段
C->>L: LoginReq(account, password)
L->>DB: 验证账号密码
DB-->>L: 成功
L->>L: 生成token
L->>R: SETEX token:{token} {accountId} TTL
L->>R: SETEX account_token:{accountId} {token} TTL
L->>R: HGET player:{accountId}(检查角色)
alt 无角色
L-->>C: 进入创角流程
C->>L: CreateRoleReq(name, ...)
L->>DB: 写入新角色
L->>R: HSET player:{accountId} ...
end
L->>R: 加载角色数据到Redis缓存
L->>R: SADD online_players {accountId}
L-->>C: LoginRes(success, token, playerData)
%% 进入游戏阶段
C->>L: EnterGameReq(token, mapId)
L->>R: GET token:{token}(验证)
R-->>L: accountId
L->>R: HGET game_load:*(查询space负载)
R-->>L: space列表
L->>L: 选择最优space
L->>S: 转发EnterSpace(accountId, playerData)
S->>S: 创建space内玩家对象
S->>R: HSET space:{spaceId}:players {accountId} ...
S-->>L: EnterSpaceAck
L-->>C: EnterGameRes(spaceAddr)
%% 后续通信
C->>S: 直接连接space(携带token)
S->>R: 验证token
S->>S: 处理游戏逻辑
Note over C,S: 定时心跳保持连接
C->>S: Heartbeat(每5秒)
S->>R: HSET player:{accountId} last_heartbeat {timestamp}
end十、本章总结与工程建议
mindmap
root((第10章:分布式登录与Redis))
分布式架构
game/space分离
负载均衡:最小连接数/自定义分数
Redis作为共享状态中心
Token机制
无状态=可扩展
SETEX + account_token映射
单点登录:踢旧留新
续期策略:避免频繁写入
组件化设计
继承 -> 组合
Player = ID + 组件容器
按需分配内存
Redis实战
hiredis连接池
数据结构选型
Pipeline批量操作
内存淘汰策略
性能优化
异步日志
内存池预分配
ConnectObj轻量化
延迟加载玩家数据
数据治理
登录时检查账号状态
夜间定时清理
注销时级联删除
Pipeline批量操作老工程师的最后几句
- Redis不是银弹:它是内存数据库,内存是贵的。不要把整个数据库塞进Redis,只放热数据。
- Pipeline是神器:单个Redis命令的RTT约1ms,100个命令串行执行就是100ms。用Pipeline批量发送,一次RTT完成全部。
- 连接池大小有讲究:太多连接浪费资源,太少会阻塞。经验公式:
连接数 = CPU核心数 * 2 + 有效磁盘数(来自Redis作者antirez的建议)。 - Monitor命令是双刃剑:
redis-cli monitor可以实时查看所有命令,但生产环境慎用——它会吃掉30%以上的性能。 - 持久化策略看场景:纯缓存(断电可重建)= 关持久化;会话存储 = RDB快照;支付相关 = AOF everysec。
- 分布式锁要小心:Redis的
SET NX EX实现的锁,如果持有锁的进程崩溃且还没执行完,锁会在超时后释放。确保你的超时时间大于业务处理时间。
本章核心代码速查:
GameProcessLoad::GetLoadScore()— 综合负载计算TokenManager— token生成/验证/销毁Player组件容器 — 类型安全的组件系统RedisConnector— 连接池封装DataCleanupManager— 三级数据清理策略OptimizedMessageComponent— 内存池优化LightConnectObj— 延迟加载设计
"Redis之于游戏服务器,就像脊髓反射之于人体——你可以不用大脑思考就完成基本动作。设计好的Redis层,能让你的分布式架构像呼吸一样自然。" — 凌晨4点,某游戏公司架构师在白板前写下的这句话。