4.登录流程
4.1 登录流程图

graph TD
subgraph 客户端请求鉴权服
cli_click_login["点击“登录”按钮"]
cli_select_auth_svr["选择鉴权服务器地址(根据用户名选择)"]
cli_create_session["创建网络会话"]
cli_send_login_request["发送登录请求 {用户名, 密码, 登录类型}"]
end
subgraph 鉴权服处理
svr_handle_login_request["处理登录请求"]
svr_validate_parameters["校验参数完整性"]
svr_compute_position["计算服务器位置"]
svr_position_match{"服务器位置匹配?"}
svr_return_position_error["返回错误码 3(账号不应该在此服务器处理)"]
svr_acquire_lock["获取协程锁"]
svr_account_in_cache{"账号缓存存在?"}
svr_return_cached_info["直接返回缓存信息 {错误码=0, 账号ID}"]
svr_query_database["查询数据库,查找账号"]
svr_account_exists{"账号存在?"}
svr_return_not_registered_error["返回错误码 2(未注册或凭证错误)"]
svr_update_login_time["更新登录时间,并保存到数据库"]
svr_cache_account_info["缓存账号信息,并且要添加定时清理"]
svr_generate_jwt["生成 JWT 令牌"]
svr_return_login_response["返回登录响应 {令牌, 错误码=0, 账号ID}"]
svr_get_gate_token["基于账户ID计算得到Gate的IP地址和端口,得到对应Gate的Token"]
end
subgraph 客户端接收鉴权服响应 尝试请求网关服
cli_receive_login_response["接收登录响应"]
cli_receive_check_has_token{"是否返回了令牌?"}
cli_parse_token["解析令牌"]
cli_token_parse_success{"令牌解析成功?"}
cli_extract_gate_server["提取网关服务器地址"]
cli_create_gate_session["创建新的网络会话,连接网关服务器"]
cli_send_gate_login_request["发送网关登录请求 {令牌}"]
end
subgraph 网关服处理
gate_verify_jwt["网关服务器:验证 JWT 签名"]
gate_jwt_valid{"JWT 验证成功?"}
gate_return_invalid_token_error["返回错误:令牌无效"]
gate_parse_jwt["解析 JWT,提取用户信息"]
gate_query_account_status["查询账号状态"]
gate_check_account_cache{"账号在缓存中?"}
gate_load_from_db["从数据库加载账号信息"]
gate_create_new_account["创建新账号并保存到数据库"]
gate_add_to_cache["将账号添加到缓存"]
gate_update_session_id["更新SessionRunTimeId"]
gate_set_account_flag["给Session添加账号标识组件"]
gate_check_duplicate_session{"是否重复登录(SessionRunTimeId对比)"}
gate_get_old_session["获取老的Session"]
gate_check_get_old_session{"是否获取老的Session成功"}
gate_clear_old_session_flag["清空老Session的账号标识组件数据"]
gate_send_duplicate_login["给老Session发送重复登录消息"]
gate_delay_disconnect_old_session["延迟3秒断开老Session"]
gate_establish_session["建立会话并转发客户端至游戏服务器"]
gate_return_login_response["返回网关登录响应"]
end
subgraph 客户端响应网关服
cli_receive_gate_response["接收网关登录响应"]
cli_handle_gate_response["处理网关登录响应,登录成功"]
cli_handle_error["处理错误,退出登录流程"]
end
subgraph 网关服会话断开处理
session_disconnect{"会话断开?"}
session_get_account_id["获取账号ID"]
session_check_cache{"账号在缓存中?(一般会在缓存中)"}
session_get_account_session_id["获取账号的SessionRunTimeId"]
session_check_session_exists{"Session是否存在?(一般都存在)"}
session_cancel_timeout["取消可能存在的延迟下线定时器"]
session_decide_disconnect{"立即下线 or 延迟下线?"}
session_immediate_disconnect["立即下线(保存数据并移除缓存)"]
session_delay_disconnect["设置延迟下线定时器"]
end
cli_click_login --> cli_select_auth_svr --> cli_create_session --> cli_send_login_request
cli_send_login_request --> svr_handle_login_request --> svr_validate_parameters --> svr_compute_position --> svr_position_match
svr_position_match -- 否 --> svr_return_position_error --> cli_receive_login_response
svr_position_match -- 是 --> svr_acquire_lock --> svr_account_in_cache
svr_account_in_cache -- 是 --> svr_return_cached_info --> svr_get_gate_token --> cli_receive_login_response
svr_account_in_cache -- 否 --> svr_query_database --> svr_account_exists
svr_account_exists -- 否 --> svr_return_not_registered_error --> cli_receive_login_response
svr_account_exists -- 是 --> svr_update_login_time --> svr_cache_account_info --> svr_generate_jwt --> svr_return_login_response --> svr_get_gate_token
cli_receive_login_response --> cli_receive_check_has_token
cli_receive_check_has_token -- 是 --> cli_parse_token --> cli_token_parse_success
cli_receive_check_has_token -- 否 --> cli_handle_error
cli_token_parse_success -- 否 --> cli_handle_error
cli_token_parse_success -- 是 --> cli_extract_gate_server --> cli_create_gate_session --> cli_send_gate_login_request --> gate_verify_jwt --> gate_jwt_valid
gate_jwt_valid -- 否 --> gate_return_invalid_token_error --> cli_handle_error
gate_jwt_valid -- 是 --> gate_parse_jwt --> gate_query_account_status --> gate_check_account_cache
gate_check_account_cache -- 否 --> gate_load_from_db --> gate_account_exists{"账号在数据库中?"}
gate_account_exists -- 否 --> gate_create_new_account --> gate_add_to_cache
gate_account_exists -- 是 --> gate_add_to_cache
gate_check_account_cache -- 是 --> gate_check_duplicate_session
gate_check_duplicate_session -- 是 --> cli_handle_gate_response
gate_check_duplicate_session -- 否 --> gate_get_old_session
gate_get_old_session -->gate_check_get_old_session
gate_check_get_old_session -- 是 --> gate_clear_old_session_flag --> gate_send_duplicate_login --> gate_delay_disconnect_old_session --> gate_update_session_id
gate_check_get_old_session -- 否 --> gate_update_session_id
gate_add_to_cache --> gate_update_session_id --> gate_set_account_flag --> gate_establish_session --> gate_return_login_response --> cli_receive_gate_response --> cli_handle_gate_response
cli_handle_gate_response --> session_disconnect
session_disconnect -- 是 --> session_get_account_id --> session_check_cache
session_check_cache -- 是 --> session_get_account_session_id --> session_check_session_exists
session_check_session_exists -- 是 --> session_cancel_timeout --> session_decide_disconnect
session_decide_disconnect -- 立即下线 --> session_immediate_disconnect
session_decide_disconnect -- 延迟下线 --> session_delay_disconnect
session_check_cache -- 否 --> session_immediate_disconnect
session_check_session_exists -- 否 --> session_immediate_disconnect
4.2 登录时客户端应该具备的能力
客户端应该具备选择鉴权服务器地址和解析JWT的能力,登录得到Token时解析JWT
// 添加认证服务器选择组件(一致性哈希算法选择服务器)
_scene.AddComponent<AuthenticationSelectComponent>();
// 添加JWT解析组件(用于处理身份验证令牌)
_scene.AddComponent<JWTParseComponent>();
4.3 登录时鉴权服务器应该具备的能力
鉴权服务器应该具备处理登录逻辑和颁发ToKen证书的能力
// 用于鉴权服务器注册和登录相关逻辑的组件
scene.AddComponent<AuthenticationComponent>().UpdatePosition();
// 用于颁发ToKen证书相关的逻辑。
scene.AddComponent<AuthenticationJwtComponent>();
4.4 客户端发送登录请求给鉴权服务器
客户端发送登录请求,期望得到错误码和Token
// 调用 AuthenticationHelper.Login 方法验证用户名和密码,并等待返回登录结果
var result = await AuthenticationHelper.Login(scene, request.Username, request.Password);
4.5 鉴权服务器收到消息并进行处理
public class C2A_LoginRequrstHandler : MessageRPC<C2A_LoginRequrst, A2C_LoginResponse>
{
protected override async FTask Run(Session session, C2A_LoginRequrst request, A2C_LoginResponse response,
Action reply)
{
//...
}
}
4.6 执行登录方法,返回错误码和账号ID
// Login 方法实现账号登录逻辑,接受用户名和密码,返回错误码和账号ID
// 返回的错误码定义如下:
// 0:登录成功
// 1:参数不完整(用户名或密码为空)
// 2:账号不存在或密码错误(数据库中未找到匹配账号)
// 3:该账号不应在当前鉴权服务器处理(负载均衡判断失败)
internal static async FTask<(uint ErrorCode, long AccountId)> Login(this AuthenticationComponent self,
string userName, string password)
{
//...
}
4.7 校验登录参数及鉴权服务器位置
和注册时同理,需要校验登录参数及鉴权服务器位置。也可以自行添加登录请求间隔及会话超时时间。
// 检查用户名和密码是否为空,若任一为空则无法进行登录操作
if (string.IsNullOrEmpty(userName) || string.IsNullOrEmpty(password))
{
// 返回错误码1,表示参数不完整,同时账号ID返回0
return (1, 0);
}
// 利用哈希算法(MurmurHash3)计算用户名的哈希值,并对总鉴权服务器数取余
// 这个计算结果用于判断当前账号请求应该由哪台鉴权服务器处理
var position = HashCodeHelper.MurmurHash3(userName) % self.AuthenticationCount;
if (self.Position != position)
{
// 如果当前服务器的 Position 与计算得到的不匹配,
// 则说明此账号请求不该由当前服务器处理,返回错误码3
return (3, 0);
}
4.8 登录时高并发时原子逻辑
使用协程锁确保同一账号的登录请求不会同时操作数据库或缓存
// 获取当前场景对象,方便后续调用数据库和缓存操作
var scene = self.Scene;
// 从场景中获取世界数据库对象,用于查询账号信息
var worldDateBase = scene.World.DateBase;
// 计算用户名的标准哈希码,用作协程锁的键值,防止同一账号并发请求
var usernameHashCode = userName.GetHashCode();
// 使用协程锁机制,确保同一账号的登录请求不会同时操作数据库或缓存
using (var @lock =
await scene.CoroutineLockComponent.Wait((int)LockType.AuthenticationLoginLock, usernameHashCode))
{
//...
}
4.9 从鉴权服务器获得登录账户ID核心逻辑
- 采用缓存策略存储登录状态,避免频繁查数据库。
- 查询数据库,查找账号。
- 数据库中未查询到账号设置错误码返回。
- 数据库中查询到账号,更新登录账号登录时间并添加到缓存。缓存设置定时清理
// 使用协程锁机制,确保同一账号的登录请求不会同时操作数据库或缓存
using (var @lock =
await scene.CoroutineLockComponent.Wait((int)LockType.AuthenticationLoginLock, usernameHashCode))
{
/*
为了防止用户频繁提交登录请求对数据库造成过多压力,
采用缓存策略存储登录状态:
1. 将用户名和密码拼接成唯一的键 loginAccountsKey
2. 检查缓存中是否存在该账号的登录信息
3. 如果存在直接返回缓存中的账号信息,否则查询数据库
4. 为新查询的账号创建一个缓存实体,并设置超时自动清理,防止缓存无限增长
*/
Account account = null;
var loginAccountsKey = userName + password;
// 检查缓存中是否已有该账号的登录信息
if (self.LoginAccountCacheDic.TryGetValue(loginAccountsKey, out var accountCacheInfo))
{
// 尝试从缓存实体中获取 Account 组件
account = accountCacheInfo.GetComponent<Account>();
// 如果缓存中的账号数据无效(例如已被清理),返回错误码2
if (account == null)
{
return (2, 0);
}
// 成功从缓存中获取账号,返回错误码0及账号ID
return (0, account.Id);
}
// 如果缓存中没有找到对应账号,则初始化 result 变量并创建一个新的缓存实体
uint result = 0;
accountCacheInfo = Entity.Create<AccountCacheInfo>(scene, true, true);
// 从数据库中查询账号,条件为用户名和密码均匹配
account = await worldDateBase.First<Account>(d => d.Username == userName && d.Password == password);
// 若数据库中未查询到该账号,则将错误码设为2,表示账号不存在或密码错误
if (account == null)
{
result = 2;
}
// 若查询到账号,则更新账号的登录时间,并保存最新数据到数据库中
else
{
account.LoginTime = TimeHelper.Now;
await worldDateBase.Save(account);
// 调用 Deserialize 方法初始化账号实体(例如加载必要组件)
account.Deserialize(scene);
// 将查询到的账号数据添加到缓存实体中,方便后续快速登录验证
accountCacheInfo.AddComponent(account);
}
// 为缓存实体添加超时组件,设置缓存有效期为5000毫秒,到期后自动清理
accountCacheInfo.AddComponent<AccountCacheInfoTimeOut>().TimeOut(loginAccountsKey, 5000);
// 将缓存实体存入 AuthenticationComponent 的 LoginAccounts 字典中
self.LoginAccountCacheDic.Add(loginAccountsKey, accountCacheInfo);
// 如果之前查询账号过程中出现错误,则直接返回错误码及账号ID为0
if (result != 0)
{
return (result, 0);
}
// 若一切正常,返回错误码0以及账号的唯一ID,表示登录成功
return (0, account.Id);
}
4.10 解析结果,计算网关和Token
如果登录成功,基于返回的用户ID计算选择Gate服务器。基于用户ID,网关服务器IP端口以及网关服务器ID计算Token并赋值返回。计算选择Gate服务器的逻辑可以封装一下,更加优雅。
// 如果登录成功(ErrorCode 为 0),则继续执行后续分配 Gate 服务器的逻辑
if (result.ErrorCode == 0)
{
// 从配置表或其他方式中获取所有 Gate 服务器的配置信息
var gates = SceneConfigData.Instance.GetSceneBySceneType(SceneType.Gate);
// 根据当前账号的 ID 通过取余计算分配的 Gate 服务器位置下标
var gatePosition = result.AccountId % gates.Count;
// 根据计算得到的下标获取具体的 Gate 服务器配置信息
var gateSceneConfig = gates[(int)gatePosition];
// 从 Gate 服务器的配置中获取外网端口信息
var outerPort = gateSceneConfig.OuterPort;
// 根据 Gate 服务器配置的 ProcessConfigId 获取进程配置信息
var processConfig = ProcessConfigData.Instance.Get(gateSceneConfig.ProcessConfigId);
// 根据进程配置的 MachineId 获取机器配置信息(包含外网 IP 地址等)
var machineConfig = MachineConfigData.Instance.Get(processConfig.MachineId);
// 利用 AuthenticationJwtHelper 生成 JWT 令牌,包含场景、账号、外网地址和 Gate 服务器 ID 等信息,并赋值给响应
response.ToKen = AuthenticationJwtHelper.GetToken(scene, result.AccountId,
$"{machineConfig.OuterIP}:{outerPort}", gateSceneConfig.Id);
}
// 将登录结果的错误码赋值给响应,供客户端判断登录是否成功
response.ErrorCode = result.ErrorCode;
// 输出调试日志,显示当前处理登录请求的服务器 SceneConfigId
Log.Debug($"Login 当前的服务器是:{scene.SceneConfigId}");
获得Token方法自定义的声明Payload部分
// 定义GetToken方法,用于生成JWT令牌
public static string GetToken(this AuthenticationJwtComponent self, long aId, string address, uint sceneId)
{
// 创建JWT的负载部分,包含自定义的声明
var jwtPayload = new JwtPayload()
{
{ "aId", aId }, // 添加账号ID声明
{ "Address", address }, // 添加地址声明
{ "SceneId", sceneId } // 添加场景ID声明
};
// 创建JWT安全令牌,设置发行者、受众、声明、过期时间和签名凭证
var jwtSecurityToken = new JwtSecurityToken(
issuer: "Tao", // 设置令牌的发行者
audience: "Tao", // 设置令牌的受众
claims: jwtPayload.Claims, // 设置令牌的声明
expires: DateTime.UtcNow.AddMilliseconds(3000), // 设置令牌的过期时间
signingCredentials: self.SigningCredentials); // 设置令牌的签名凭证
// 使用JwtSecurityTokenHandler将令牌对象序列化为字符串,并返回
return new JwtSecurityTokenHandler().WriteToken(jwtSecurityToken);
}
4.11 客户端处理鉴权服务器的登录响应,并尝试解析Token
检查错误码。无错误则尝试解析Token。
// 发送登录的请求给鉴权服务器
var response = (A2C_LoginResponse)await _session.Call(new C2A_LoginRequrst()
{
Username = UsernameInput.text,
Password = PasswordInput.text,
LoginType = 1
});
// 如果登录失败,就直接返回了。
if (response.ErrorCode != 0)
{
Log.Error($"登录发生错误{response.ErrorCode}");
return;
}
// 登录成功,尝试解析ToKen
if (!_scene.GetComponent<JWTParseComponent>().Parse(response.ToKen, out var payload))
{
//解析失败后返回
return;
}
Payload定义如下,传入到解析ToKen方法中得到信息的
/// <summary>
/// JWT令牌负载数据结构
/// 用于存储和传输认证后的用户会话信息
/// </summary>
public sealed class Payload
{
/// <summary>
/// 账户唯一标识(自定义声明)
/// </summary>
public long aId { get; set; }
/// <summary>
/// 游戏服务器地址(自定义声明)
/// 格式示例:127.0.0.1:30001
/// </summary>
public string Address { get; set; }
/// <summary>
/// 游戏场景/地图ID(自定义声明)
/// </summary>
public uint SceneId { get; set; }
/// <summary>
/// 过期时间(标准JWT声明)
/// 使用Unix时间戳格式(秒数)
/// </summary>
public int exp { get; set; }
/// <summary>
/// 签发者(标准JWT声明)
/// 示例:AuthenticationServer
/// </summary>
public string iss { get; set; }
/// <summary>
/// 接收方(标准JWT声明)
/// 示例:GameServer
/// </summary>
public string aud { get; set; }
}
解析ToKen时分割Token进行解码和反序列化
/// <summary>
/// JWT解析组件扩展系统
/// 实现JWT令牌的解析和基本验证功能
/// </summary>
public static class JWTParseComponentSystem
{
/// <summary>
/// 解析JWT令牌并验证基本格式
/// </summary>
/// <param name="self">组件实例</param>
/// <param name="toKen">待解析的JWT令牌字符串</param>
/// <param name="payloadData">输出解析后的负载数据</param>
/// <returns>是否解析成功</returns>
/// <remarks>
/// 示例令牌结构:
/// payload:{"aId":433552570364198912,"Address":"127.0.0.1:8080","exp":1729842327,"iss":"Tao","aud":"Tao"}
/// </remarks>
public static bool Parse(this JWTParseComponent self, string toKen, out Payload payloadData)
{
payloadData = null;
try
{
// 步骤1:验证JWT基础结构
// JWT标准结构:Header.Payload.Signature
var tokens = toKen.Split('.');
// 验证令牌分段数量
if (tokens.Length != 3)
{
Log.Error($"无效的JWT令牌结构,分段数量:{tokens.Length}");
return false;
}
// 步骤2:处理Base64URL编码
// JWT的Payload不是标准的Base64格式,因为咱们C#的Convert要求是一个标准的Base64格式。
// JWT的Payload是Base64URL格式,它里面的"-"替代成了"+","_"替代成了"/",需要把这些给还原成Base64格式
var payloadSegment = tokens[1]
.Replace('-', '+') // 替换URL安全字符
.Replace('_', '/'); // JWT规范中的Base64URL编码转换
// 步骤3:Base64填充处理
// 因为Base64的编码长度需要是4的倍数,如果不是的话咱们需要把这个长度用=来填充,使其长度符合要求
// 注意:case 0和1不需要处理:
// - case 0:长度正好是4的倍数
// - case 1:根据RFC4648标准,输入长度不可能产生余数1的情况
switch (payloadSegment.Length % 4)
{
// case 0:
// {
// // 如果这个余数是0,那就表示长度已经是4的倍数,那就没必要处理了。
// break;
// }
// case 1:
// {
// // 如果这个余数是1,那就表示这个Base64格式是不正确的,不是咱们发放的token。
// // 这样情况下,也不需要处理了。
// // 可以打印个报错
// break;
// }
case 2:
payloadSegment += "=="; // 补2个填充字符
break;
case 3:
payloadSegment += "="; // 补1个填充字符
break;
}
// 步骤4:解码和反序列化
// 把Base64编码的字符串反序列化为字节数组
var payloadBytes = Convert.FromBase64String(payloadSegment);
// 把字节数组反序列化为JSON字符串
var payloadJson = System.Text.Encoding.UTF8.GetString(payloadBytes);
// 使用自定义JSON反序列化器
// 这里使用的是Newtonsoft.Json
payloadData = JsonHelper.Deserialize<Payload>(payloadJson);
// 步骤5:基础验证(可扩展)
// 注意:当前实现仅验证令牌结构,未验证签名和过期时间!
return true;
}
catch (FormatException ex)
{
Log.Error($"Base64解码失败: {ex.Message}");
return false;
}
catch (JsonException ex)
{
Log.Error($"JSON解析失败: {ex.Message}");
return false;
}
catch (Exception ex)
{
Log.Error($"令牌解析异常: {ex}");
return false;
}
}
}
4.12 客户端给网关服务器发送请求
给网关服务器发送请求,并传入Token凭证
// 根据ToKen返回的Address登录到Gate服务器
_session = SessionHelper.CreateSession(_scene, payload.Address, OnConnectComplete, OnConnectFail,
OnConnectDisconnect);
// 发送登录请求到Gate服务器
var loginResponse = (G2C_LoginResponse)await _session.Call(new C2G_LoginRequest()
{
ToKen = response.ToKen
});
// 处理Gate服务器登录结果
if (loginResponse.ErrorCode != 0)
{
Log.Error($"Gate登录失败,错误码: {loginResponse.ErrorCode}");
return;
}
Log.Debug($"登录Gate服务器成功!登录时间: {loginResponse.GameAccountInfo.LoginTime} 创建时间: {loginResponse.GameAccountInfo.CreateTime}");
4.13 登录时网关服务器应该具备的能力
网关服务器具备验证JWT和管理GameAccount的能力。GameAccountManageComponent实际上就是缓存GameAccount,并且可以增删改查。使用缓存是因为方便断线重连和顶号使用。只有下线处理中才可以清除这个缓存。
// 用于验证JWT是否合法的组件
scene.AddComponent<GateJWTComponent>();
// 用于管理GameAccount的组件
scene.AddComponent<GameAccountManageComponent>();
GameAccount定义如下,关联ToKen传递的账户ID
public sealed class GameAccount : Entity
{
// AuthenticationId: 用于存储身份验证的唯一标识符。
// 可以通过 Token 中传递的 AId 来设置此字段的值。
public long AuthenticationId;
// CreateTime: 记录账户创建的时间戳。
public long CreateTime;
// LoginTime: 记录账户最后一次登录的时间戳。
public long LoginTime;
// SessionRunTimeId: 用于标识会话的唯一运行时标识符。
// 使用 [BsonIgnore] 特性,表示在序列化时忽略此字段,
// 即该字段不会被保存到 MongoDB 数据库中,也不会参与查询操作。
[BsonIgnore] public long SessionRunTimeId;
}
GameAccountManageComponent定义如下
// GameAccountManageComponent 继承自 Entity 类,表示游戏账户管理组件
public sealed class GameAccountManageComponent : Entity
{
// GameAccountCacheDic 字典用于缓存游戏账户信息
// 键(Key):long 类型的账户 ID
// 值(Value):GameAccount 类型的账户实体
public readonly Dictionary<long, GameAccount> GameAccountCacheDic = new();
}
public sealed class GameAccountManageComponentDestroySystem : DestroySystem<GameAccountManageComponent>
{
protected override void Destroy(GameAccountManageComponent self)
{
// 遍历缓存字典中的所有 GameAccount 实体,调用 Dispose 方法释放资源。
foreach (var (_, gameAccount) in self.GameAccountCacheDic)
{
gameAccount.Dispose();
}
// 清空字典,确保没有留下任何对 GameAccount 实体的引用。
self.GameAccountCacheDic.Clear();
}
}
public static class GameAccountManageComponentSystem
{
// 向 GameAccountManageComponent 的缓存字典中添加一个新的 GameAccount 实体。
public static void Add(this GameAccountManageComponent self, GameAccount account)
{
// 使用账户的 Id 作为键,将 GameAccount 实体添加到字典中。
self.GameAccountCacheDic.Add(account.Id, account);
}
// 根据 accountId 从缓存字典中获取 GameAccount 实体,如果找不到则返回 null。
public static GameAccount? Get(this GameAccountManageComponent self, long accountId)
{
// 尝试获取字典中与 accountId 相关联的 GameAccount 实体,找不到时返回 null。
return self.GameAccountCacheDic.GetValueOrDefault(accountId);
}
// 尝试从缓存字典中获取 GameAccount 实体。如果找到则返回 true,并通过 out 参数返回账户对象,否则返回 false。
public static bool TryGet(this GameAccountManageComponent self, long accountId, out GameAccount? account)
{
// 尝试从字典中获取 GameAccount 实体,返回获取是否成功的结果,并通过 out 参数返回实体。
return self.GameAccountCacheDic.TryGetValue(accountId, out account);
}
// 从缓存字典中移除指定 accountId 的 GameAccount 实体,若 isDispose 为 true,还会销毁该实体。
public static void Remove(this GameAccountManageComponent self, long accountId, bool isDispose = true)
{
// 尝试从字典中移除 GameAccount 实体,如果移除失败(找不到指定 ID),则直接返回。
if (!self.GameAccountCacheDic.Remove(accountId, out var account))
{
return;
}
// 如果 isDispose 为 true,则销毁该 GameAccount 实体,释放其占用的资源。
if (isDispose)
{
account.Dispose();
}
}
}
4.14 Gate服务器处理请求,对Token进行校验
检查请求中的Token是否为空,不为空则检查有效性并获取账号ID
/// <summary>
/// 处理客户端登录请求的消息处理器
/// </summary>
public sealed class C2G_LoginRequestHandler : MessageRPC<C2G_LoginRequest, G2C_LoginResponse>
{
/// <summary>
/// 处理登录请求的核心方法
/// </summary>
/// <param name="session">客户端会话</param>
/// <param name="request">客户端发送的登录请求</param>
/// <param name="response">服务器返回的登录响应</param>
/// <param name="reply">回复客户端的回调函数</param>
protected override async FTask Run(Session session, C2G_LoginRequest request, G2C_LoginResponse response,
Action reply)
{
// 检查请求中的Token是否为空
if (string.IsNullOrEmpty(request.ToKen))
{
// 1、客户端漏传了 response.ErrorCode = 1;
// 2、恶意攻击导致的 session.Dispose();
session.Dispose();
return;
}
var scene = session.Scene;
// 验证Token的有效性,并获取账号ID
if (!GateJWTHelper.ValidateToken(scene, request.ToKen, out var accountId))
{
// 如果失败,表示肯定是恶意攻击、所以毫不犹疑,直接断开当前会话。
session.Dispose();
return;
}
//...
}
}
检查时不仅要验证令牌合法性,还要令牌中的场景 ID 是否与当前场景的配置 ID 匹配。如果不匹配,表示该连接不应连接到当前的 Gate,验证失败。验证成功的话从令牌的负载中提取账户 ID,外部使用。
/// <summary>
/// Gate 服务器的 JWT 帮助类,提供验证 JWT 令牌的功能。
/// </summary>
public static class GateJWTHelper
{
/// <summary>
/// 验证 JWT 令牌是否合法,并提取其中的账户 ID。
/// </summary>
/// <param name="scene">当前的场景对象。</param>
/// <param name="token">待验证的 JWT 令牌。</param>
/// <param name="accountId">输出参数,验证成功时返回账户 ID。</param>
/// <returns>如果验证成功且令牌中的场景 ID 与当前场景匹配,返回 true;否则返回 false。</returns>
public static bool ValidateToken(Scene scene, string token, out long accountId)
{
// 调用内部方法验证令牌,并获取其负载信息
if (!ValidateToken(scene, token, out JwtPayload payload))
{
// 如果令牌验证失败,设置 accountId 为 0,并返回 false
accountId = 0;
return false;
}
// 从令牌的负载中提取场景 ID 和账户 ID
var sceneId = Convert.ToInt64(payload["SceneId"]);
accountId = Convert.ToInt64(payload["aId"]);
// 检查令牌中的场景 ID 是否与当前场景的配置 ID 匹配
// 如果不匹配,表示该连接不应连接到当前的 Gate,返回 false
return sceneId == scene.SceneConfigId;
}
/// <summary>
/// 内部方法:验证 JWT 令牌的有效性,并提取其负载信息。
/// </summary>
/// <param name="token">待验证的 JWT 令牌。</param>
/// <param name="payload">输出参数,验证成功时返回令牌的负载信息。</param>
/// <returns>如果验证成功,返回 true;否则返回 false。</returns>
private static bool ValidateToken(Scene scene, string token, out JwtPayload payload)
{
// 调用 GateJWTComponent 组件的 ValidateToken 方法进行验证
return scene.GetComponent<GateJWTComponent>().ValidateToken(token, out payload);
}
}
// GateJWTComponentSystem 类用于处理与 JWT 相关的操作
public static class GateJWTComponentSystem
{
// Awake 方法用于初始化 GateJWTComponent 组件
public static void Awake(this GateJWTComponent self)
{
// 创建一个新的 RSA 实例,用于加密和解密
var rsa = RSA.Create();
// 使用从 Base64 编码的公钥字符串导入 RSA 公钥
rsa.ImportRSAPublicKey(Convert.FromBase64String(self.PublicKeyPem), out _);
// 创建签名凭据(SigningCredentials),使用 RSA 公钥和 SHA-256 算法
self.SigningCredentials = new SigningCredentials(new RsaSecurityKey(rsa), SecurityAlgorithms.RsaSha256);
// 配置验证参数(TokenValidationParameters),用于验证 JWT 令牌
self.TokenValidationParameters = new TokenValidationParameters
{
ValidateLifetime = false, // 禁用对令牌有效期的验证 实际项目中应该验证过期时间
ValidateIssuer = true, // 启用对令牌发行者的验证
ValidateAudience = true, // 启用对令牌受众的验证
ValidateIssuerSigningKey = true, // 启用对签名密钥的验证
ValidIssuer = "Tao", // 设置有效的令牌发行者为 "Tao"
ValidAudience = "Tao", // 设置有效的令牌受众为 "Tao"
IssuerSigningKey = new RsaSecurityKey(rsa) // 设置用于签名验证的 RSA 公钥
};
}
// ValidateToken 方法用于验证 JWT 令牌的合法性
public static bool ValidateToken(this GateJWTComponent self, string token, out JwtPayload payload)
{
payload = null; // 初始化 payload 为 null
try
{
// 创建 JwtSecurityTokenHandler 实例,用于处理和验证 JWT 令牌
var jwtSecurityTokenHandler = new JwtSecurityTokenHandler();
// 使用配置好的 TokenValidationParameters 验证令牌
jwtSecurityTokenHandler.ValidateToken(token, self.TokenValidationParameters, out _);
// 令牌验证成功,获取 JWT 令牌的有效载荷(Payload)
payload = jwtSecurityTokenHandler.ReadJwtToken(token).Payload;
return true; // 令牌验证通过,返回 true
}
catch (SecurityTokenInvalidAudienceException)
{
// 如果令牌的受众无效,输出错误信息并返回 false
Console.WriteLine("验证受众失败!");
return false;
}
catch (SecurityTokenInvalidIssuerException)
{
// 如果令牌的发行者无效,输出错误信息并返回 false
Console.WriteLine("验证发行者失败!");
return false;
}
catch (Exception e)
{
// 如果发生其他异常,输出异常信息并重新抛出异常
Console.WriteLine(e);
throw;
}
}
}
4.15 获取账号管理组件方便得到缓存
// 获取账号管理组件 在缓存中检查该账号是否存在
var gameAccountManageComponent = scene.GetComponent<GameAccountManageComponent>();
Log.Debug("检查账号是否在缓存中");
4.16 尝试从缓存获取账号,缓存未命中访问数据库
判断缓存中有没有当前有效的账户 ID。没有就要读数据库。注意这个数据库的集合不是注册时的Account,而是登录时的GameAccount。创建完将账户添加到缓存。
// 尝试从缓存中获取账号信息
if (!gameAccountManageComponent.TryGet(accountId, out var account))
{
// 如果缓存中不存在,则从数据库加载 先要先到数据库中查询是否有这个账号
account = await GameAccountHelper.LoadDataBase(scene, accountId);
// 检查到账号没有在数据库中,需要创建一个新的账号并且保存到数据库中
if (account == null)
{
Log.Debug("检查到账号没有在数据库中,需要创建一个新的账号并且保存到数据库中");
// 如果没有,就要创建一个新的并且保存到数据库。
// 如果不存在,表示这是一个新的账号,需要创建一下这个账号。
account = await GameAccountHelper.Create(scene, accountId);
}
else
{
Log.Debug("检查到账号在数据库中");
}
Log.Debug("把当前账号添加到缓存中");
// 把创建完成的Account放入到缓存中
gameAccountManageComponent.Add(account);
}
// 缓存有这个账号
else
{
Log.Debug("检测到当前账号已经在缓存中了");
//...
}
创建时给定创建时间并保存到数据库中,从数据库中读取GameAccount时基于账户ID匹配
/// <summary>
/// 创建一个新的GameAccount实例。
/// </summary>
/// <param name="scene">当前的场景对象。</param>
/// <param name="aId">从ToKen令牌中传递过来的账号ID。</param>
/// <param name="isSaveDataBase">指示是否在创建过程中将数据保存到数据库,默认为true。</param>
/// <returns>返回创建的GameAccount实例。</returns>
public static async FTask<GameAccount> Create(Scene scene, long aId, bool isSaveDataBase = true)
{
// 创建一个新的GameAccount实体,传入场景、账号ID,以及是否需要保存到数据库的标志。
var gameAccount = Entity.Create<GameAccount>(scene, aId, false, false);
// 设置创建时间和登录时间为当前时间。
gameAccount.LoginTime = gameAccount.CreateTime = TimeHelper.Now;
// 如果需要保存到数据库,则异步保存GameAccount数据。
if (isSaveDataBase)
{
await gameAccount.SaveDataBase();
}
// 返回创建的GameAccount实例。
return gameAccount;
}
/// <summary>
/// 从数据库中读取GameAccount
/// </summary>
/// <param name="scene">数据库所在的Scene</param>
/// <param name="accountId">账号Id</param>
/// <returns></returns>
public static async FTask<GameAccount?> LoadDataBase(Scene scene, long accountId)
{
var account = await scene.World.DateBase.First<GameAccount>(d => d.Id == accountId);
if (account == null)
{
return null;
}
account.Deserialize(scene);
return account;
}
/// <summary>
/// 保存账号到数据库中
/// </summary>
/// <param name="self"></param>
public static async FTask SaveDataBase(this GameAccount self)
{
await self.Scene.World.DateBase.Save(self);
}
4.17 先不管缓存中存在账号的情况,每次通过Gate验证且得到账户后,要给Session添加标识组件且更新信息
给当前Session添加一个账号标识组件,记录账户ID和账户对象。这个组件的作用主要用于延迟下线,避免账户频繁上下线。当Session销毁的时候会销毁这个组件。同时要更新SessionRunTimeId和账户信息用于返回。
// 给当前Session添加一个账号标识组件,当Session销毁的时候会销毁这个组件。
var accountFlagComponent = session.AddComponent<GameAccountFlagComponent>();
accountFlagComponent.AccountID = accountId;
accountFlagComponent.Account = account;
//每次登录成功,都要更新一下SessionRunTimeId
account.SessionRunTimeId = session.RunTimeId;
// 设置响应的账号信息
response.GameAccountInfo = account.GetGameAccountInfo();
Log.Debug($"当前的Gate服务器:{session.Scene.SceneConfigId} accountId:{accountId}");
游戏账号标志组件用于标识和关联游戏账号。
/// <summary>
/// 游戏账号标志组件,用于标识和关联游戏账号。
/// </summary>
public sealed class GameAccountFlagComponent : Entity
{
/// <summary>
/// 账号ID,用于唯一标识一个账号。
/// </summary>
public long AccountID;
/// <summary>
/// 关联的游戏账号实体引用。注意:当Account在其他地方被销毁时,这个引用可能仍然有效,但指向的可能是其他用户的账号。
/// </summary>
// 有一种可能,当在Account在其他地方被销毁
// 这时候因为这个Account是会回收到池子中,所以这个引用还是有效的
// 那这时候就会出现这个引用的Account可能是其他用户的了。
public EntityReference<GameAccount> Account;
}
当和客户端的Session销毁时,会执行游戏账号标志组件的销毁。游戏账号标志组件的销毁主要用于延迟下线,避免在短时间内的重复登录给服务器造成的压力,并且能更加方便的处理断线重连的逻辑。
/// <summary>
/// 处理 GameAccountFlagComponent 实体销毁时的逻辑。
/// </summary>
public sealed class GameAccountFlagComponentDestroySystem : DestroySystem<GameAccountFlagComponent>
{
/// <summary>
/// 当 GameAccountFlagComponent 实体被销毁时调用此方法。
/// </summary>
/// <param name="self">被销毁的 GameAccountFlagComponent 实体。</param>
protected override void Destroy(GameAccountFlagComponent self)
{
if (self.AccountID != 0)
{
// 执行下线过程,并要求在 5 分钟后完成缓存清理。
// 由于 5 分钟可能过长,建议在测试时将此时间缩短,例如改为 10 秒。
GameAccountHelper.Disconnect(self.Scene, self.AccountID, 1000 * 60 * 5).Coroutine();
// 重置 AccountID,以防止后续操作误用。
self.AccountID = 0;
}
// 解除对 Account 实体的引用,避免潜在的引用问题。
self.Account = null;
}
}
当和客户端的Session销毁时,会调用一下Disconnect方法,传入账户ID,可能会有几种情况进行调用:
- 客户端主动断开,如:退出游戏、切换账号、等。 客户端会主动发送一个协议给服务器通知服务器断开。
- 客户端断断线 客户端不会主动发送一个协议给服务器,是由服务器的心跳来检测是否断开了。
首先检查Gate账户缓存中有没有这个账户ID,正常来说不会出现缓存中没有这个账户ID还下线的情况。因为只有下线时我们才清除Gate账户缓存的。如果能拿到就获取了account且能拿到account的SessionRunTimeId。
其次,基于当前account的SessionRunTimeId,尝试询问当前Gate服有没有这个Session。正常来说是有的。如果没有这个Session还尝试断开不符合逻辑。
然后,取消可能存在的定时器。根据timeOut设置新的延迟下线定时器或者直接执行。执行的Disconnect方法操作是保存账号信息到数据库并且将自身从缓存中移除。
/// <summary>
/// 账号完整的断开逻辑,执行了这个接口后,该账号会完全下线。
/// </summary>
/// <param name="scene"></param>
/// <param name="accountId"></param>
/// <param name="timeOut"></param>
public static async FTask Disconnect(Scene scene, long accountId, int timeOut = 1000 * 60 * 3)
{
// 调用该方法有如下几种情况:
// 1、客户端主动断开,如:退出游戏、切换账号、等。 客户端会主动发送一个协议给服务器通知服务器断开。
// 2、客户端断断线 客户端不会主动发送一个协议给服务器,是由服务器的心跳来检测是否断开了。
// 如果是心跳检测断开的Session,我怎么能拿到当前的这个账号来进行下线处理呢?
// 通过给当前的Session挂载一个组件,这个组件存AccountId,当销毁这个Session时候呢,也会销毁这个组件。
// 销毁这个组件时,调用这个AccountId的断开连接方法。
// 这样的话,是不是可以在登录的时候,给这个组件把AccountId存到这个组件呢?
// 要检查当前缓存中是否存在该账号的数据
var gameAccountManageComponent = scene.GetComponent<GameAccountManageComponent>();
if (!gameAccountManageComponent.TryGet(accountId, out var account))
{
// 如果缓存中没有、那表示已经下线或者根本不存在该账号,应该在打印一个警告,因为正常的情况下是不会出现的。
Log.Warning($"GameAccountHelper Disconnect accountId : {accountId} not found");
return;
}
// 为了防止逻辑的错误,加一个警告来排除下
if (!scene.TryGetEntity<Session>(account.SessionRunTimeId, out var session))
{
// 如果没有找到对应的Session,那只有一种可能就是当前的链接会话已经断开了,一般的情况下也不会出现的,所以咱们也要打印一个警告。
Log.Warning(
$"GameAccountHelper Disconnect accountId : {accountId} SessionRunTimeId : {account.SessionRunTimeId} not found");
return;
}
// 如果存在定时任务的组件,需要取消延迟下线
if (account.IsTimeOutComponent())
{
account.CancelTimeout();
}
// 立即下线处理
if (timeOut <= 0)
{
// 执行立即下线。
await account.Disconnect();
return;
}
// 不存在延迟下线组件且有timeOut时间 设置延迟下线
account.SetTimeout(timeOut, account.Disconnect);
}
/// <summary>
/// 执行该账号的断开逻辑,不要非必要不要使用这个接口,这个接口是内部使用。
/// </summary>
/// <param name="self"></param>
public static async FTask Disconnect(this GameAccount self)
{
// 保存该账号信息到数据库中。
await SaveDataBase(self);
// 在缓存中移除自己,并且执行自己的Dispose方法。
self.Scene.GetComponent<GameAccountManageComponent>().Remove(self.Id);
}
/// <summary>
/// 获得GameAccountInfo 这是为了返回给客户端的
///
/// </summary>
/// <param name="self"></param>
/// <returns></returns>
public static GameAccountInfo GetGameAccountInfo(this GameAccount self)
{
// 其实可以不用每次都NEW一个新的GameAccountInfo
// 可以在当前账号下创建一个GameAccountInfo,每次变动会提前通知这个GameAccountInfo
// 又或者每次调用该方法的时候,把值重新赋值一下。
return new GameAccountInfo()
{
CreateTime = self.CreateTime,
LoginTime = self.LoginTime
};
}
4.18 缓存中存在账号
缓存中存在账号 可能存在多种情况:
- 同一客户端发送了重复登录的请求数据。
- 客户端经历的断线然后又重新连接到这个服务器上了(断线重连)。
- 多个客户端同时登录了这个账号(顶号)
根据SessionRunTimeId是否一致判断是否是同一客户端发送了重复登录的请求数据。可能发生的情况是用户连续点击登录。客户端应该设置点击间隔或菊花图。
如果是相同客户端的断线重连,或者另一个客户端顶号登录,都会产生新的Session。那么基于老的SessionRunTimeId尝试获取老的Session。如果获取的到老的Session我们需要断开他。获取到老的Session后,先获取其中的账号标识组件,清空里面的数据。给客户端发送重复登录的消息,并且三秒后断开老的Session(延迟三秒是希望让给客户端发送重复登录的消息能够送达客户端)。
为什么要获取到老的Session后,要先获取其中的账号标识组件,清空里面的数据呢?因为如果不清空数据,老的Session销毁时,会走进GameAccountFlagComponent销毁方法。GameAccountFlagComponent销毁时会判断账户ID是否为0,如果不为0会尝试调用GameAccountHelper.Disconnect,会对这个账户ID进行延迟下线处理。这样新的Session刚进来,什么都没做,这个账户ID就被设置延迟下线。几分钟后可能就被踢了,不合理。我们希望是每次账户连接Gate成功后,取消该账户ID的延迟下线,Session断开连接后,开启该账户ID的延迟下线定时器。
可以观察到,断线重连和顶号我们走到是相同的逻辑。关于断线重连和顶号的区别,主要的区别是老的Session的客户端是否还存在。相同客户端的断线重连,因为是相同客户端,服务端给老的Session发送的消息不会被收到,但是没有影响。如果是另一个客户端顶号处理,那么先上线的客户端其实还连接着老的Session,可以收到消息,客户端弹出弹窗什么的。
// 尝试从缓存中获取账号信息
if (!gameAccountManageComponent.TryGet(accountId, out var account))
{
//...
}
// 缓存有这个账号
else
{
Log.Debug("检测到当前账号已经在缓存中了");
// 移除 如果有延迟下线的计划任务,那就先取消一下。
account.CancelTimeout();
// 如果在Gate的缓存中已经存在了该账号那只能以下几种可能:
// 1、同一客户端发送了重复登录的请求数据。
// 2、客户端经历的断线然后又重新连接到这个服务器上了(断线重连)。
// 3、多个客户端同时登录了这个账号(顶号)。
//思路是通过SessionRunTimeId 来判断
//如果SessionRunTimeId相同,同一客户端发送了重复登录的请求数据。
//客户端经历的断线然后又重新连接到这个服务器上了(断线重连) SessionRunTimeId是不同的
// 检查当前会话的运行时ID是否与账号的会话ID匹配
if (session.RunTimeId == account.SessionRunTimeId)
{
// 如果执行到这里,说明是客户端发送了多次登录的请求,这样的情况下,直接返回就可以了,不需要做任何操作。
return;
}
Log.Debug("检测到当前账号的Session不是同一个");
// 尝试获取旧的会话
if (scene.TryGetEntity<Session>(account.SessionRunTimeId, out var oldSession))
{
Log.Debug("当前账号的Session在当前的系统中,所以需要发送一个重复登录的命令,并且要断开这个Session");
// 如果这个Session在当前框架中可以查询到。
// 那表示就是当前的会话还是在存在的,有如下几个可能:
// 1、客户端断线重连,要给这个Session发送一个消息,通知它有人登录了。
// 2、其他的客户端登录了这个账号,要给这个Session发送一个消息,通知它有人登录了。
// 获取旧会话的账号标识组件
var gameAccountFlagComponent = oldSession.GetComponent<GameAccountFlagComponent>();
gameAccountFlagComponent.AccountID = 0;
gameAccountFlagComponent.Account = null;
// 给老的客户端Session发送一个重复登录的消息
// 如果新的客户端Session是自己上次登录的,发送也不会收到,因为老的客户端Session已经被销毁
// 如果新的客户端Session是用其他客户端登录的,发送会收到。因为老的客户端Session会在另一个机子上存在
oldSession.Send(new G2C_RepeatLogin());
// 给当前Session做一个定时销毁的任务,因为不做这个定时销毁,直接销毁的话,有可能消息还没有发送过去就销毁了
oldSession.SetTimeout(3000);
}
}
// 给当前Session添加一个账号标识组件,当Session销毁的时候会销毁这个组件。
var accountFlagComponent = session.AddComponent<GameAccountFlagComponent>();
accountFlagComponent.AccountID = accountId;
accountFlagComponent.Account = account;
//每次登录成功,都要更新一下SessionRunTimeId
account.SessionRunTimeId = session.RunTimeId;
// 设置响应的账号信息
response.GameAccountInfo = account.GetGameAccountInfo();
Log.Debug($"当前的Gate服务器:{session.Scene.SceneConfigId} accountId:{accountId}");
4.19 总结
本文详细解析了游戏登录系统的技术实现,通过分层架构设计(客户端、鉴权服、网关服)和关键技术(JWT、缓存、协程锁)实现了高并发场景下的安全登录与会话管理。以下是核心要点总结:
核心流程
- 客户端:通过一致性哈希选择鉴权服务器,封装登录参数发送请求;解析JWT获取网关地址,建立网关连接。
- 鉴权服务器处理:
- 前置校验:验证参数完整性,控制请求频率,设置会话超时。
- 服务器位置匹配:通过用户名哈希计算目标服务器,拒绝非指定服务器请求。
- 并发控制:获取协程锁,确保同一账号登录操作原子性。
- 账号验证:先查登录缓存,未命中则查询数据库,验证密码匹配。
- JWT生成:包含账号ID、网关地址、场景ID,设置过期时间。
- 网关服务器处理:
- JWT验证:校验签名合法性,匹配场景ID,提取账号信息。
- 账号管理:缓存查询账号(无则加载/创建数据库记录),更新登录时间。
- 会话管理:通过SessionRunTimeId检测重复登录,旧会话延迟3秒断开;会话销毁时触发账号延迟下线。
核心设计
无状态认证
- 通过 JWT 令牌在客户端与服务器间传递用户信息,实现无状态会话管理,降低服务端内存消耗。
- 网关服验证 JWT 签名和场景 ID,确保请求合法性。
负载均衡与扩展性
- 一致性哈希算法动态选择鉴权服务器,支持水平扩展。
- 网关服通过账号 ID 计算分配策略,实现流量均衡。
高并发处理
- 协程锁保证同一账号登录操作的原子性,避免数据库竞争。
- 缓存机制(如鉴权服的账号缓存、网关服的 GameAccount 缓存)减少数据库压力。
技术亮点
- 顶号处理:通过 SessionRunTimeId 检测重复登录,实现旧会话延迟断开(3 秒),确保新会话优先。
- 延迟下线:会话断开后,账号缓存保留 5 分钟(可配置),优化断线重连体验。
- 模块化设计:通过
AuthenticationComponent、GameAccountManageComponent等组件封装核心逻辑,便于功能扩展。
潜在问题与建议
安全增强
- 启用 JWT 过期时间验证(
ValidateLifetime),设置合理有效期(如 15 分钟)。 - 密码存储应使用哈希加盐(如 BCrypt),避免明文传输和存储。
- 启用 JWT 过期时间验证(
异常处理优化
- 网关服在 Token 无效时返回明确错误码,便于客户端提示用户。
性能优化
- 缩短延迟下线定时器(如 30 秒),减少僵尸会话资源占用。
- 异步化数据库查询操作,提升系统吞吐量。
未来改进方向
- 扩展登录方式:支持第三方登录(如微信、Google)。
- 增强监控:添加登录成功率、延迟等指标监控,及时发现异常。
- 数据库优化:引入读写分离或 NoSQL 存储(如 Redis)加速高频查询。
通过以上设计,系统在保证安全性与稳定性的同时,具备良好的扩展性与高并发处理能力,为游戏服务的核心模块奠定了坚实基础。
转载请注明来源,欢迎对文章中的引用来源进行考证,欢迎指出任何有错误或不够清晰的表达。可以在下面评论区评论,也可以邮件至 785293209@qq.com