3.注册流程
3.1 注册流程图
注册流程先看整体链路。
这里的注册不是简单地“客户端传用户名和密码,服务端插一条账号记录”。真正落到游戏服务器里,至少要处理几件事:
- 客户端应该请求哪个鉴权服。
- 当前鉴权服是否真的负责这个账号。
- 请求频率是否正常。
- 注册参数是否合法。
- 同一个用户名在高并发下会不会被重复注册。
- 缓存和数据库里是否已经存在该账号。
- 注册结果如何返回给客户端。
可以把注册理解成一次“开户”:
- 客户端先根据用户名找到负责处理这个账号的柜台。
- 鉴权服先做基础检查,比如请求是否过于频繁、用户名密码是否为空。
- 然后鉴权服重新计算账号归属,确认这个请求确实应该由当前节点处理。
- 真正查缓存和查数据库之前,先按用户名加锁,避免两个相同用户名的注册请求同时创建账号。
- 最后确认账号不存在后,创建账号、写入数据库、写入短期缓存,并把结果返回客户端。

把上面的流程整理成 Mermaid,大概是这样:
flowchart TD
subgraph client_request_auth["客户端请求鉴权服"]
cli_click_register["点击注册按钮"]
cli_select_auth_svr["选择鉴权服务器地址"]
cli_create_session["创建网络会话"]
cli_send_register_request["发送注册请求"]
end
subgraph auth_server_handle["鉴权服处理注册请求"]
svr_handle_register_request["处理注册请求"]
svr_check_request_frequency{"检查请求频率是否正常?"}
svr_return_frequency_error["返回错误:请求过于频繁"]
svr_set_session_timeout["设置会话超时时间"]
svr_invoke_register_method["调用注册方法"]
svr_validate_parameters{"校验参数是否合法?"}
svr_validate_parameters_error["返回错误:参数非法"]
svr_compute_target_position["计算账号对应的目标鉴权服位置"]
svr_check_position_match{"当前鉴权服是否匹配?"}
svr_return_position_error["返回错误:账号不归当前鉴权服处理"]
svr_acquire_coroutine_lock["获取同用户名协程锁"]
svr_check_account_cache{"缓存中是否已有账号?"}
svr_return_account_exists_error["返回错误:账号已存在"]
svr_query_database["查询数据库"]
svr_check_account_exists{"数据库中是否已有账号?"}
svr_create_new_account["创建新账号"]
svr_save_account["保存账号到数据库"]
svr_cache_account_with_timeout["写入注册缓存并设置超时清理"]
svr_return_success["返回注册成功"]
end
subgraph client_receive_response["客户端接收鉴权服响应"]
cli_receive_response["接收注册响应"]
cli_receive_check_error{"检查错误码"}
cli_register_success["注册成功"]
cli_register_fail["注册失败"]
end
cli_click_register --> cli_select_auth_svr
cli_select_auth_svr --> cli_create_session
cli_create_session --> cli_send_register_request
cli_send_register_request --> svr_handle_register_request
svr_handle_register_request --> svr_check_request_frequency
svr_check_request_frequency -- 请求过于频繁 --> svr_return_frequency_error
svr_return_frequency_error --> cli_receive_response
svr_check_request_frequency -- 正常 --> svr_set_session_timeout
svr_set_session_timeout --> svr_invoke_register_method
svr_invoke_register_method --> svr_validate_parameters
svr_validate_parameters -- 参数非法 --> svr_validate_parameters_error
svr_validate_parameters_error --> cli_receive_response
svr_validate_parameters -- 参数合法 --> svr_compute_target_position
svr_compute_target_position --> svr_check_position_match
svr_check_position_match -- 不匹配 --> svr_return_position_error
svr_return_position_error --> cli_receive_response
svr_check_position_match -- 匹配 --> svr_acquire_coroutine_lock
svr_acquire_coroutine_lock --> svr_check_account_cache
svr_check_account_cache -- 已存在 --> svr_return_account_exists_error
svr_return_account_exists_error --> cli_receive_response
svr_check_account_cache -- 不存在 --> svr_query_database
svr_query_database --> svr_check_account_exists
svr_check_account_exists -- 已存在 --> svr_return_account_exists_error
svr_check_account_exists -- 不存在 --> svr_create_new_account
svr_create_new_account --> svr_save_account
svr_save_account --> svr_cache_account_with_timeout
svr_cache_account_with_timeout --> svr_return_success
svr_return_success --> cli_receive_response
cli_receive_response --> cli_receive_check_error
cli_receive_check_error -- 有错误 --> cli_register_fail
cli_receive_check_error -- 无错误 --> cli_register_success
这个流程里有两个点要先分清楚。
客户端选服只是第一层分流
客户端会根据用户名选择鉴权服,但客户端选择不能完全信任。
所以鉴权服收到请求后,仍然要重新计算账号应该落在哪个节点上。
如果请求打错节点,直接返回错误,不继续执行注册逻辑。协程锁要放在查缓存和查数据库之前
同一个用户名可能在很短时间内发起多次注册请求。
如果没有锁,两个请求可能都查到“账号不存在”,然后同时创建账号。
所以真正进入缓存和数据库判断前,需要先按用户名加锁,让同一个账号的注册逻辑串行执行。
3.2 客户端选择鉴权服务器
注册时,客户端不能永远只连一个固定的鉴权服。
单机 Demo 里这么做没问题:客户端点注册,直接连到唯一的鉴权服务器,然后发用户名和密码。
但放到真实项目里,注册和登录都是入口流量,一旦玩家量上来,单个鉴权服很容易变成瓶颈。
所以客户端需要具备一个能力:
根据账号信息选择目标鉴权服务器。
在这里,选择依据用的是用户名。
也就是客户端拿到用户名后,先算一个哈希值,再根据哈希结果把请求分到某个鉴权服节点上。
可以把它理解成银行开户分柜台:
- 所有玩家都挤到一个柜台,柜台压力会很大。
- 多开几个柜台后,需要一个规则决定“这个账号应该去哪个柜台处理”。
- 用户名哈希就是这个分流规则。
- 同一个用户名每次算出来的结果一致,后续注册、登录就能打到同一个目标鉴权服。
客户端侧先挂一个认证服务器选择组件:
// 添加认证服务器选择组件(一致性哈希算法选择服务器)
_scene.AddComponent<AuthenticationSelectComponent>();
这里的组件职责很简单:
在发起注册或登录之前,根据用户名选出一个鉴权服地址。
单鉴权的问题

如果只有一个鉴权服,结构最简单,所有客户端都往这个节点发注册和登录请求。
这种方案前期很好理解,也方便调试,但问题也明显:
- 所有注册和登录请求都会打到同一个节点。
- 节点压力上来后,最先被打满的就是鉴权服。
- 这个节点一旦挂掉,注册登录入口就直接不可用。
- 后续想横向扩展时,需要重新设计请求分流方式。
所以单鉴权更适合本地 Demo 或早期验证流程。
只要开始考虑并发、可用性和扩容,就需要多鉴权服。
多鉴权服务器分流
多鉴权服的核心不是“多起几个进程”这么简单。
真正要解决的问题是:同一个账号的注册和登录请求,应该稳定落到同一个负责节点上。
否则就会出现几个麻烦:
- 第一次注册请求打到鉴权服 1。
- 第二次重试打到鉴权服 2。
- 登录时又打到鉴权服 3。
- 每个节点缓存状态不同,排查问题会很乱。
- 服务端还要频繁转发请求,反而增加复杂度。
比较直接的做法是:客户端拿到鉴权服列表后,根据用户名做哈希分流。

流程大概是:
- 客户端启动时拿到鉴权服列表。
- 注册或登录时,对用户名做哈希。
- 根据哈希结果选择一个鉴权服。
- 请求直接发给这个鉴权服。
这个方案的好处是简单,客户端就能完成第一层分流,不需要额外的中心转发节点。
但这里有一个边界要分清楚:
客户端选服只能作为第一层分流,不能完全信任客户端的选择。
客户端可能拿到的是旧配置,也可能被恶意修改请求目标。
所以服务端收到请求后,仍然要根据同一套规则重新计算一次账号归属。
如果发现这个账号不应该由当前鉴权服处理,就直接返回错误,不继续执行注册逻辑。
去中心化哈希环
一种更完整的做法,是让每个鉴权节点都维护一份相同的哈希环配置。
也就是:
- 每个鉴权服都知道当前有哪些鉴权节点。
- 每个节点都知道哈希空间怎么分布。
- 收到请求后,可以判断当前账号是否应该由自己处理。
- 节点增减时,需要同步新的哈希环配置。
这种方式减少了中心转发节点,但要求节点配置一致。
这里最容易出问题的是节点变更:
- 新增鉴权服后,哪些账号会迁移到新节点?
- 老节点下线后,原来负责的账号应该转到哪里?
- 客户端和服务端的节点列表如何保持一致?
- 灰度发布期间,新旧配置不一致怎么处理?
所以哈希分流本身不难,难的是节点列表、配置版本、服务发现和容错策略。
DHT 这种方案为什么不适合当前注册流程
分布式哈希表,也就是 DHT,常见于 P2P 或更大规模的去中心化系统。
它的特点是每个节点只维护一部分路由信息,通过多跳路由找到目标节点。
这个思路很适合一些真正去中心化的系统,但对普通游戏注册登录来说通常太重。
注册登录链路更看重:
- 请求路径短。
- 故障排查简单。
- 鉴权节点数量可控。
- 配置和服务发现可管理。
所以这里不需要把问题复杂化。
客户端根据用户名选鉴权服,服务端再做归属校验,已经能覆盖大部分注册登录入口分流需求。
哈希算法选择
分流时需要一个哈希函数,把用户名转换成一个数字。
这里可以先区分两个概念:
- 哈希函数:负责把用户名算成一个数字。
- 分流策略:负责把这个数字映射到某个服务器节点。
比如当前代码里就是:
MurmurHash3(username) % AuthenticationList.Count
这表示:
- 用 MurmurHash3 算用户名哈希。
- 再对服务器数量取模。
- 取模结果就是目标鉴权服下标。
这里要特别注意:
这段代码是哈希取模分流,不是严格意义上的一致性哈希环。
哈希取模的优点是简单。
但服务器数量变化时,% Count 的结果会大面积变化,很多用户名会被重新映射到不同节点。
真正的一致性哈希通常会把服务器节点和 Key 都映射到一个哈希环上,并配合虚拟节点减少节点增减时的迁移范围。
如果后面真的要做动态扩缩容,这块需要升级成哈希环或服务端统一路由。
BKDR Hash
BKDR Hash 是一种比较简单的字符串哈希算法。
它的特点是实现短、理解成本低,适合一些轻量场景,比如小规模字符串哈希、简单表结构索引等。
但放到鉴权服分流里,它不是最优选择:
- 分布均匀性一般。
- 大规模数据下碰撞概率相对更高。
- 对恶意构造输入没有防护能力。
- 更适合 Demo 或小规模工具逻辑。
简单说,BKDR 像一个很轻的手工分拣规则。
规则能用,但数据量上来以后,容易出现某些桶特别热,某些桶又比较空。
MurmurHash3
MurmurHash3 是一个常见的非加密哈希算法。
它适合用在:
- 哈希表。
- 缓存 Key。
- 分布式分流。
- 数据库分片辅助计算。
- 服务节点选择。
它的优势是速度快、分布相对均匀,适合把大量用户名分散到不同鉴权服上。
但 MurmurHash3 不是安全哈希。
它不能用于密码存储、签名、Token 防篡改这类安全场景。
这个点要分清楚:
- 用户名分流:可以用 MurmurHash3。
- 密码存储:不能用 MurmurHash3。
- Token 签名:不能用 MurmurHash3。
- 安全摘要:也不能用 MurmurHash3。
它更像是一个“分流用的编号器”,不是“安全用的保险柜”。
MurmurHash3 VS BKDR Hash

代码示例
下面这段是客户端侧根据用户名选择鉴权服的示例。
代码里用的是 MurmurHash3 + 取模,适合本地开发和固定节点数量的简单分流。
如果后续鉴权服节点会频繁增减,就需要进一步改成真正的一致性哈希环,或者把节点选择交给服务发现和网关层处理。
/// <summary>
/// 认证服务器选择组件扩展系统
/// 实现基于一致性哈希的服务器选择逻辑
/// </summary>
public static class AuthenticationSelectComponentSystem
{
// 预定义的认证服务器地址列表(本地开发环境配置)
private static readonly List<string> AuthenticationList = new List<string>()
{
"127.0.0.1:20001", // 认证服务器节点1
"127.0.0.1:20002", // 认证服务器节点2
"127.0.0.1:20003" // 认证服务器节点3
};
/// <summary>
/// 根据用户名选择目标认证服务器地址
/// </summary>
/// <param name="self">组件实例</param>
/// <param name="userName">用户名</param>
/// <returns>选中的认证服务器地址</returns>
public static string Select(this AuthenticationSelectComponent self, string userName)
{
// 使用MurmurHash3算法计算用户名的哈希值
// 特点:高随机性、低碰撞率,适合分布式系统哈希计算
var userNameHashCode = HashCodeHelper.MurmurHash3(userName);
// 通过取模运算确定目标服务器索引
// 计算逻辑:哈希值 % 服务器总数(结果范围:0 到 AuthenticationList.Count-1)
var authenticationListIndex = userNameHashCode % AuthenticationList.Count;
// 在列表长度为 3 时,取模结果范围为 0~2
return AuthenticationList[(int)authenticationListIndex];
}
}
这段代码的重点不在于算法多复杂,而是先把注册入口拆开:
- 客户端不直接固定连某一个鉴权服。
- 客户端根据用户名做第一层分流。
- 服务端收到请求后还要重新校验账号归属。
- 当前实现是简单哈希取模,节点数量固定时够用。
- 如果节点会动态变化,就不能只靠
% Count,需要升级成真正的一致性哈希或服务发现方案。
3.3 鉴权服务器初始化
客户端已经能根据用户名选择鉴权服了,但服务端自己也要知道一件事:
当前这个鉴权服,到底是第几个节点。
如果把多鉴权服理解成多个开户柜台,那每个柜台启动时都要知道:
- 当前一共有几个柜台。
- 自己是第几个柜台。
- 哪些账号应该由自己处理。
- 收到不属于自己的账号请求时,要直接拒绝,而不是继续注册。
所以鉴权服务器启动时,需要把当前节点在鉴权服列表中的位置记录下来。
// 用于鉴权服务器注册和登录相关逻辑的组件
scene.AddComponent<AuthenticationComponent>().UpdatePosition();
AuthenticationComponent 这里不只是挂一个空组件。
它后面要负责注册、登录、账号归属判断、缓存管理等逻辑,所以初始化时先调用 UpdatePosition()。
// 更新当前鉴权服务器在所有鉴权组中的位置和总数
public static void UpdatePosition(this AuthenticationComponent self)
{
// 1、通过远程接口或者本地文件来拿到所有鉴权服务器的配置信息,这里通过 SceneConfigData 获取
var authentications = SceneConfigData.Instance.GetSceneBySceneType(SceneType.Authentication);
// 2、获取当前 Scene 对应的配置文件
var sceneConfig = SceneConfigData.Instance.Get(self.Scene.SceneConfigId);
// 3、在鉴权服务器组中找到当前 Scene 配置的位置索引
self.Position = authentications.IndexOf(sceneConfig);
// 4、设置鉴权服务器的总数量
self.AuthenticationCount = authentications.Count;
// 输出日志,记录当前鉴权服务器的位置和总数
Log.Info($"鉴权服务器启动成功!Position:{self.Position} AuthenticationCount:{self.AuthenticationCount}");
}
这段代码的核心是两个字段:
Position:当前鉴权服在鉴权服列表中的下标。AuthenticationCount:当前一共有多少个鉴权服。
后面判断账号归属时,会重新计算:
Hash(username) % AuthenticationCount
如果算出来的位置等于当前服务器的 Position,说明这个账号应该由当前鉴权服处理。
如果不相等,就说明请求打错节点了。
这里有个工程细节要注意:
客户端选服和服务端归属判断必须使用同一份节点列表、同一套哈希规则。
否则客户端算出来应该打到鉴权服 1,服务端自己算出来却认为应该由鉴权服 2 处理,就会出现正常请求被误拒的情况。
真实项目里,这块通常还要补上:
- 配置版本号。
- 服务发现或配置中心。
- 节点上下线时的灰度策略。
- 客户端节点列表过期后的重试逻辑。
当前这版先用 SceneConfigData 固定配置跑通流程,适合学习和本地 Demo。
3.4 注册请求的前置检查
鉴权服收到注册请求后,不应该一上来就查缓存、查数据库、创建账号。
注册入口是比较容易被刷的地方,所以真正进入核心注册逻辑前,至少要做几层检查:
- 请求频率是否过高。
- 当前 Session 是否需要设置超时。
- 用户名和密码是否为空。
- 当前账号是否应该由当前鉴权服处理。
这些检查就像银行开户前的排队和材料检查。
材料不完整、排队频率异常、跑错柜台,都不应该进入真正的开户流程。
检查请求频率
// 检查客户端请求间隔是否符合要求,以防止用户发送过于频繁的请求
if (!session.CheckInterval(2000))
{
// 如果请求过于频繁,则将错误码设置为3,表示操作过于频繁,然后直接返回
response.ErrorCode = 3;
return;
}
这里限制的是同一个 Session 的请求间隔。
如果客户端在极短时间内重复发注册请求,一般有几种可能:
- 用户连续点击注册按钮。
- 客户端逻辑异常,重复发送请求。
- 恶意脚本在刷接口。
- 网络重试逻辑没有控制好。
这类请求没必要继续走数据库逻辑,直接返回错误更合适。
不过这里有个小问题:
当前示例里错误码 3 既可能表示“请求过于频繁”,后面也可能表示“账号不归当前鉴权服处理”。
Demo 阶段能跑,但正式项目里最好拆成不同错误码,比如:
ErrorCode.RequestTooFrequentErrorCode.InvalidAuthNodeErrorCode.InvalidParameterErrorCode.AccountAlreadyExists
否则客户端和日志排查时会很难判断到底是哪一类错误。
设置会话超时时间
// 为当前会话设置一个超时时间,确保请求在规定时间内完成,否则断开Session
session.SetTimeout(3000);
注册请求不应该长期占着连接。
设置 Session 超时的目的,是防止一些异常连接一直挂在鉴权服上。
比如客户端发起注册后卡住、不继续发送数据、不主动断开,这类连接如果一直保留,会慢慢消耗服务器资源。
可以把它理解成窗口办业务的超时时间。
排到窗口后,如果一直不提交材料,窗口不能无限等下去,到了时间就要释放资源。
当前示例设置的是 3000ms。
这个值适合本地 Demo,真实项目里要结合网络环境、服务器处理耗时、客户端重试策略来调整。
校验注册参数是否合法
if (string.IsNullOrEmpty(username) || string.IsNullOrEmpty(password))
{
// 返回错误码1,表示参数不完整
return 1;
}
最基础的参数校验是用户名和密码不能为空。
正式项目里一般还会继续补:
- 用户名长度限制。
- 密码长度限制。
- 用户名字符集限制。
- 敏感词过滤。
- 保留字过滤。
- 密码复杂度规则。
- 是否允许同一设备短时间大量注册。
这里只做空值判断,属于最小可运行版本。
判断当前账号是否应该由当前鉴权服处理
var position = HashCodeHelper.MurmurHash3(username) % self.AuthenticationCount;
if (self.Position != position)
{
// 返回错误码3,表示该账号不应在当前服务器上进行注册操作
return 3;
}
这一段是服务端的二次校验。
客户端已经根据用户名选择过鉴权服,但客户端不能完全信任。
可能是客户端节点列表过期,也可能是请求被篡改,也可能是测试时手动连错了服务器。
所以鉴权服收到请求后,仍然要自己算一次:
- 用用户名算哈希。
- 对鉴权服数量取模。
- 得到账号应该归属的鉴权服位置。
- 和当前服务器的
Position对比。
如果不匹配,说明这个账号不应该在当前服务器注册,直接返回错误。
这一步很关键。
否则客户端想打哪个鉴权服就打哪个鉴权服,多鉴权分流就失去了意义。
3.5 同用户名注册的并发控制
注册逻辑最怕的不是普通请求,而是同一个用户名在很短时间内发起多次注册。
比如两个请求几乎同时进来:
- 请求 A 查缓存,发现账号不存在。
- 请求 B 也查缓存,发现账号不存在。
- 请求 A 查数据库,发现账号不存在。
- 请求 B 也查数据库,发现账号不存在。
- 两边都开始创建账号。
如果没有额外控制,就可能出现重复创建、数据冲突、缓存状态异常等问题。
所以真正查缓存和查数据库之前,要先按用户名加锁。
锁的粒度不能太大,也不能太小。
- 锁整个注册系统:太粗,会把所有注册请求串行化。
- 锁用户名:比较合适,只让同一个用户名的注册请求串行。
- 不加锁:并发时容易出脏数据。
这里用用户名哈希作为协程锁的 key。
// 获取用户名的哈希码,用于协程锁的键值,防止多个注册请求并发导致数据混乱
var usernameHashCode = HashCodeHelper.MurmurHash3(username);
var scene = self.Scene;
// 使用协程锁确保同一用户名的注册请求是串行执行的
using (var @lock =
await scene.CoroutineLockComponent.Wait((int)LockType.AuthenticationRegisterLock, usernameHashCode))
{
//...
}
这里原来如果写成:
var usernameHashCode = username.GetHashCode();
在单进程、本地临时锁场景下也能跑。
但 string.GetHashCode() 不适合表达跨进程、跨版本、跨平台稳定语义,所以这里更推荐和前面的选服规则保持一致,统一使用 MurmurHash3(username)。
这样至少能保证这一篇里的哈希语义是统一的:
- 客户端选鉴权服用 MurmurHash3。
- 服务端判断账号归属用 MurmurHash3。
- 同用户名协程锁也用 MurmurHash3。
不过这里还要分清楚一个边界:
协程锁只能解决当前服务进程内的并发问题。
如果同一个账号请求可能进入多个鉴权服,或者节点列表不一致导致请求打散,仅靠本地协程锁不够。
这也是为什么前面必须先做“当前鉴权服是否匹配”的归属校验。
真正严谨的最后一道保险,还应该放在数据库层,比如给 Username 建唯一索引。
程序锁是为了减少并发冲突,数据库唯一约束才是防重复数据的最终兜底。
3.6 注册核心逻辑
注册核心逻辑可以拆成三步:
- 先查注册缓存。
- 缓存没有再查数据库。
- 数据库也没有,才创建账号并写库。
这里的顺序不要反过来。
缓存像柜台旁边的一张临时登记表。
刚注册过的账号先放在这张表里,短时间内再来查,就不用马上打到数据库。
数据库才是真正的账本,最终是否存在账号还是要以数据库为准。
// 使用协程锁确保同一用户名的注册请求是串行执行的
using (var @lock =
await scene.CoroutineLockComponent.Wait((int)LockType.AuthenticationRegisterLock, usernameHashCode))
{
// 首先通过缓存判断该用户名是否已经存在,减少对数据库的访问次数
if (self.RegisterAccountCacheDic.TryGetValue(username, out var account))
{
// 返回错误码2,表示该用户已经存在
return 2;
}
// 如果缓存中没有找到,再查询数据库判断账号是否存在
var worldDateBase = scene.World.DateBase;
var isExist = await worldDateBase.Exist<Account>(d => d.Username == username);
if (isExist)
{
// 数据库中存在相同用户名,则返回错误码2,表示账号已存在
return 2;
}
// 执行到此处说明缓存和数据库中都没有该账号信息,需要创建新账号
account = Entity.Create<Account>(scene, true, true);
// 将注册信息写入新创建的账号实体
account.Username = username;
account.Password = password;
account.CreateTime = TimeHelper.Now;
// 将新账号保存到数据库中,持久化存储
await worldDateBase.Save(account);
var accountId = account.Id;
// 将新账号添加到缓存字典中,便于后续快速登录等操作
self.RegisterAccountCacheDic.Add(username, account);
// 为账号添加超时组件,设置缓存失效时间为4000毫秒,自动清理过期账号缓存
account.AddComponent<AccountTimeOut>().TimeOut(4000);
// 记录注册日志,输出注册来源、用户名及账号ID,便于系统运维和问题追踪
Log.Info($"Register source:{source} username:{username} accountId:{accountId}");
// 返回0表示注册成功
return 0;
}
这段代码里有几个点要单独记一下。
先查缓存
if (self.RegisterAccountCacheDic.TryGetValue(username, out var account))
{
return 2;
}
注册成功后,会把账号放进短期缓存。
如果短时间内同一个用户名再次注册,就可以直接返回“账号已存在”,避免重复查数据库。
这个缓存不是账号系统的最终数据源,只是为了减少短时间内的重复查询。
再查数据库
var isExist = await worldDateBase.Exist<Account>(d => d.Username == username);
if (isExist)
{
return 2;
}
缓存没有命中,并不代表账号一定不存在。
可能是缓存刚过期,也可能是服务刚重启,也可能这个账号是很久之前注册的。
所以还要查数据库。
真正判断账号是否存在,最终还是要看数据库。
创建账号并保存
account = Entity.Create<Account>(scene, true, true);
account.Username = username;
account.Password = password;
account.CreateTime = TimeHelper.Now;
await worldDateBase.Save(account);
这里是最小 Demo 写法。
如果只是跑通流程,直接写入 Username、Password、CreateTime 能理解。
但真实项目里不能明文保存密码。
至少要做:
- 每个密码使用独立 salt。
- 使用适合密码存储的慢哈希算法,比如 Argon2id、bcrypt、PBKDF2。
- 不要直接用 MD5、SHA1、SHA256 这类快速哈希保存密码。
- 不要把密码明文写进日志。
也就是说,示例里的:
account.Password = password;
只适合学习流程。
正式项目里这里应该写入的是密码哈希结果,而不是原始密码。
写入短期缓存
self.RegisterAccountCacheDic.Add(username, account);
account.AddComponent<AccountTimeOut>().TimeOut(4000);
注册成功后,把账号写入缓存,并设置超时时间。
这块的作用是处理短时间内的重复请求。
比如玩家点了一次注册,客户端因为网络抖动又重试了一次。
缓存还没过期时,后一次请求就能更快判断账号已存在。
这里缓存时间是 4000ms,比较像本地 Demo 的短期保护。
真实项目里要根据请求重试时间、数据库压力、内存占用来调整。
数据库唯一约束是最后兜底
协程锁和缓存都属于业务层保护。
真正严谨的注册系统,还应该在数据库层给用户名加唯一约束。
原因很简单:
- 服务可能重启。
- 多节点配置可能不一致。
- 程序锁可能只覆盖当前进程。
- 极端情况下可能有并发写入绕过业务判断。
所以数据库层最好保证 Username 唯一。
业务层负责提前拦截,数据库层负责最终兜底。
Account 定义
Account 一般可以先这样定义:
public sealed class Account : Entity
{
public string Username { get; set; }
public string Password { get; set; }
public long CreateTime { get; set; }
public long LoginTime { get; set; }
}
这里也要标注清楚:
Password 字段在 Demo 里可以直接放字符串,方便理解流程。
真实项目里更准确的命名应该是类似:
public string PasswordHash { get; set; }
同时可以根据项目需要补:
- Salt
- Platform
- Channel
- LastLoginIp
- LastLoginDevice
- AccountStatus
- TokenVersion
这些不是注册流程第一版必须有的字段,但后面做登录、封禁、顶号、风控时会用到。
3.7 客户端处理注册响应
客户端收到注册响应后,不应该只看“有没有返回”。
真正要判断的是错误码。
// 发送一个注册的请求消息到目标服务器
var response = (A2C_RegisterResponse)await _session.Call(new C2A_RegisterRequest()
{
Username = UsernameInput.text,
Password = PasswordInput.text
});
// 处理注册结果
if (response.ErrorCode != 0)
{
Log.Error($"注册失败,错误码: {response.ErrorCode}");
return;
}
Log.Debug("注册成功!");
这段逻辑比较简单:
ErrorCode == 0:注册成功。ErrorCode != 0:注册失败。
不过正式项目里客户端不应该直接把数字错误码展示给玩家。
最好在协议层或公共模块里维护一份错误码枚举:
public enum ErrorCode
{
Success = 0,
InvalidParameter = 1,
AccountAlreadyExists = 2,
RequestTooFrequent = 3,
InvalidAuthenticationNode = 4,
}
这样客户端处理时更清楚:
if (response.ErrorCode == (int)ErrorCode.AccountAlreadyExists)
{
// 提示账号已存在
}
数字错误码能跑,但不利于维护。
尤其是前面已经出现过同一个错误码表达多种含义的情况,后面最好拆开。
客户端这里还可以继续补几类处理:
- 注册按钮防连点。
- 注册请求期间显示 Loading。
- 请求超时提示。
- 错误码转本地化文本。
- 注册成功后是否自动跳到登录流程。
- 注册成功后是否断开当前鉴权 Session。
最后这个点要看整体登录设计。
如果注册和登录是两个独立流程,注册成功后可以断开当前注册 Session,再由玩家点击登录重新建立登录流程。
如果产品希望注册后自动登录,也可以在服务端注册成功后直接返回登录凭证,但那就是“注册 + 登录合并流程”,需要额外设计 Token、在线状态和 Gate 绑定。
3.8 总结
注册流程看起来只是创建一个账号,实际拆开后会涉及不少工程细节。
这篇里当前版本的主线是:
- 客户端根据用户名选择鉴权服。
- 鉴权服启动时记录自己的
Position和AuthenticationCount。 - 鉴权服收到请求后,先做频率、超时、参数检查。
- 服务端重新计算账号归属,确认请求确实打到正确节点。
- 对同一个用户名加协程锁,避免并发重复注册。
- 先查缓存,再查数据库。
- 数据库不存在时创建账号、保存账号、写入短期缓存。
- 客户端根据错误码判断注册结果。
这套流程的核心不是“插入一条 Account 数据”,而是保证几个边界:
请求不能乱打
客户端可以做第一层分流,但服务端必须重新校验。
客户端选错节点、配置过期、请求被篡改,都不能直接进入注册核心逻辑。
同用户名不能并发创建
注册最怕两个相同用户名同时查到“不存在”。
所以查缓存和查数据库之前要加锁,让同一个用户名的注册逻辑串行。
缓存不是最终数据源
缓存只是为了减少短时间内重复请求打到数据库。
账号是否真的存在,最终还是要查数据库。
数据库要做最终兜底
业务层锁和缓存都可能因为多节点、重启、配置不一致而失效。
真正不能重复的字段,比如用户名,数据库层最好加唯一索引。
密码不能明文存
示例代码里的 account.Password = password 只是 Demo 写法。
真实项目里必须存密码哈希,不能存明文密码。
错误码要标准化
注册流程里会出现很多失败原因:
- 参数非法。
- 请求太频繁。
- 账号已存在。
- 请求打错鉴权服。
- 数据库写入失败。
- Session 超时。
这些错误不能长期靠 1 / 2 / 3 这种数字硬记。
后面应该统一成枚举或错误码表,客户端和服务端共用。
放到游戏服务器里,可以把注册流程理解成“开户”:
- 客户端先找到负责这个账号的柜台。
- 柜台确认材料完整、请求正常、账号归自己处理。
- 对同一个用户名加锁,避免两个窗口同时开户。
- 查临时登记表和正式账本。
- 都没有记录时,才创建新账号。
- 创建成功后,返回结果给客户端。
到这里,注册流程只是账号系统的第一步。
后面登录流程还要继续处理密码校验、Token 签发、Gate 绑定、顶号、在线状态、重连和强制下线。
转载请注明来源,欢迎对文章中的引用来源进行考证,欢迎指出任何有错误或不够清晰的表达。可以在下面评论区评论,也可以邮件至 785293209@qq.com