文档目的
这份文档不讲“怎么操作客户端”,只讲三件事:
- 为什么这类问题会出现。
- 为什么代码要这样写。
- 如何用更稳的方式防止同账号同时登录两次,以及由此引出的同角色双在线问题。
问题本质
“同账号同时登录两次”本质上不是一个单点问题,而是一个链路问题。
它至少会经过下面三层:
- 登录服:账号密码校验、角色列表、角色进入请求。
- 游戏服:账号与角色的在线态维护、连接建立、
msgagent 创建。
- 角色服务 / 场景服:角色真正进入场景、切图、退出、存档。
只要其中任何一层没有做唯一性约束,或者“检查”和“写入”之间存在并发窗口,就可能出现:
- 同账号两个不同角色同时在线。
- 同一个角色被两个服务实例短时同时持有。
- 角色切图途中旧流程没有停干净,形成残留角色或鬼影角色。
代码为什么这样写
当前这套代码里,防线实际上分成了三层,每一层负责的目标不一样。
一层:登录服先做快速拦截
文件:
services/login/logind.lua
这里的职责不是最终裁决,而是尽快挡掉明显非法的重复登录,避免请求继续往后流。
当前代码里有两类关键判断:
- 账号登录阶段调用
.gamed.check_uuid_is_online(uuid),优先处理“该账号在游戏中已经在线”的情况。
- 登录服本地维护
uuid_auth_fd,用于约束“一个账号在登录服只保留一个有效选角会话”。
这么写的原因是:
- 登录服最靠近客户端,拒绝越早,资源浪费越少。
- 选角界面和正式进游戏是两个阶段,不能只在账号密码登录时检查一次。
- 如果不限制登录服会话,同一个账号可以同时保留两个选角界面,后面很容易分别点进两个角色。
当前实际代码选择的是:
- 新会话进来时,直接关闭旧的选角连接。
- 这样能快速保证“同账号只保留一个登录服会话”。
这么写的优点是简单,缺点也很明显:
- 被顶掉的一端通常只是掉线,不一定能收到明确的重复登录提示。
关键代码 1:登录服会话唯一性
local function bind_auth_session(fd, session)
local uuid = assert(session.uuid)
local old_fd = uuid_auth_fd[uuid]
if old_fd and old_fd ~= fd then
-- 同账号只保留最后一个登录服会话,避免两个选角界面并存。
unregister_auth(old_fd)
loginservice.closeclient(old_fd)
end
auth[fd] = session
uuid_auth_fd[uuid] = fd
end
local function get_auth_session(fd)
local session = auth[fd]
if not session then
return nil
end
if uuid_auth_fd[session.uuid] ~= fd then
-- 旧 fd 即使还有残余消息,也不能继续操作当前账号。
unregister_auth(fd)
return nil
end
return session
end
代码解释:
uuid_auth_fd 记录“这个账号当前绑定的是哪个登录服 fd”。
bind_auth_session 的作用是建立新的账号会话绑定。
- 如果
old_fd 存在并且不是当前 fd,说明同账号已经有旧的登录服会话。
- 当前实现选择直接
closeclient(old_fd),也就是让旧选角客户端掉线。
get_auth_session 的作用是做二次校验,防止旧连接即使还没完全断干净,也继续操作角色列表或进入游戏。
这段代码防的 Bug 是:
- 同账号保留两个选角界面。
- 旧连接残留消息继续发包。
关键代码 2:登录时先查账号是否已在游戏服在线
local uuid = account.uuid
local havetime = login_times[uuid]
if havetime then
local subtime = havetime - os.time()
if subtime > 0 then
ret.flag = 21
ret.uNeedWaitTime = subtime * 1000
return ret
end
login_times[uuid] = nil
end
local have_online = skynet.call(".gamed", "lua", "check_uuid_is_online", uuid)
if have_online == 2 then
server.notify_tips(fd, idx, "请离游戏的角色正在退出队列中,请稍等尝试。。。")
login_times[uuid] = os.time() + 180
ret.flag = 21
ret.uNeedWaitTime = 180 * 1000
return ret
elseif have_online == 1 then
login_times[uuid] = os.time() + 20
ret.flag = 8
return ret
end
on_pwd_succ(mac)
local right = account.right or 0
bind_auth_session(fd, { uuid = uuid, mac = mac, right = right, uid = str_account })
ret.uuid = str_account
ret.flag = 0
ret.unknow_2 = ""
return ret,uuid
代码解释:
login_times[uuid] 是重登冷却,用来防止账号刚被顶或刚退出时立刻反复登录。
check_uuid_is_online(uuid) 是登录服向游戏服确认“这个账号当前有没有角色已经在游戏中”。
have_online == 2 表示旧角色还在退出队列里,所以返回等待时间。
have_online == 1 表示账号确实已经在线,当前返回 ret.flag = 8。
- 只有前面的检查都通过了,才会真正执行
bind_auth_session,把这个账号绑定到新的登录服会话。
这段代码防的 Bug 是:
- 账号已经在游戏中,还继续重复登录。
- 账号刚被挤下线或刚退出时反复重登。
二层:游戏服做最终唯一性裁决
文件:
services/game/gamed.lua
登录服的判断不能代替游戏服判断,因为登录服前面的检查可能被并发绕过。
所以游戏服必须再做一次最终约束:
- 同一个
guid 不能重复进入。
- 同一个
uuid 不能在游戏服里同时挂两个角色。
这么写的原因是:
- 游戏服维护的是最终在线结构,必须是权威来源。
- 即使登录服由于并发时序放过了请求,游戏服也要兜底挡住。
当前修复里又加了一层按 guid 串行化的连接建立锁。
原因是:
CGConnect -> create_msgagent 这一段如果并发执行,可能同时给同一角色建出两个 msgagent。
- 这类问题不是靠“if u.agent then ...”就能彻底防住的,因为“判断”和“创建”之间可能让出执行权。
因此需要:
- 按
guid 串行处理建链。
- 在锁内完成校验和绑定。
关键代码 3:游戏服最终校验账号和角色唯一性
local guid_message_locks = {}
local function get_guid_message_lock(guid)
local lock = guid_message_locks[guid]
if not lock then
-- 同一个 guid 的建链过程必须串行,避免并发创建两个 msgagent。
lock = queue()
guid_message_locks[guid] = lock
end
return lock
end
local function check_uuid_online_state(uuid, exclude_guid)
local have_on_line = 0
for key_guid, guid_info in pairs(users) do
if key_guid ~= exclude_guid and guid_info.uuid == uuid then
if guid_info.quit then
return 2
end
have_on_line = 1
end
end
return have_on_line
end
function handler.login(uuid, guid, game_ip, mac, right, fd)
local exist = users[guid]
if exist then
-- 同角色已经挂在游戏服在线表里,拒绝再次建立入口。
return nil
end
local uuid_state = check_uuid_online_state(uuid, guid)
if uuid_state ~= 0 then
-- 登录服前置检查可能被并发绕过,游戏服这里再做一次最终拦截。
return nil
end
...
end
代码解释:
users[guid] 是游戏服当前在线角色表,按 guid 维护。
handler.login 里先查 users[guid],是为了挡住“同一个角色再次进入”。
check_uuid_online_state(uuid, guid) 再查同账号下是否已有别的角色在游戏服在线。
- 这层校验放在游戏服里,是因为游戏服才是最终在线态的权威。
- 即使登录服前面查过一次,这里还是要再查一次,防止并发穿透。
这段代码防的 Bug 是:
- 同账号两个不同角色同时在线。
- 同一个角色被重复进入。
关键代码 4:CGConnect 建链必须串行
local key = request.key
local guid = request.guid
local ok_bind, agent, bind_err = xpcall(function()
return get_guid_message_lock(guid)(function()
local u = users[guid]
if not u or u.subid ~= key then
return nil, string.format(
"invalid CGConnect guid=%s key=%s expected_key=%s user_exists=%s",
tostring(guid),
tostring(key),
u and tostring(u.subid) or "nil",
tostring(u ~= nil)
)
end
-- 在锁内完成 agent 绑定,避免同一角色被两个连接同时抢占。
create_msgagent(fd,guid)
return agents[fd]
end)
end, debug.traceback)
代码解释:
CGConnect 是客户端真正开始和游戏服建角色连接的入口。
- 如果两个连接几乎同时来,最危险的就是两个请求同时执行
create_msgagent。
get_guid_message_lock(guid) 的作用就是把同一 guid 的建链过程串起来。
- 只有拿到锁之后,才会检查
subid 是否还匹配、再去绑定 agent。
- 这样可以避免两个连接同时抢同一角色。
这段代码防的 Bug 是:
- 同一角色短时间内生成两个
msgagent。
- 重连、切图、异常回连时出现双实例。
三层:角色服务发现异常必须立刻停流程
文件:
services/msgagent.lua
CGEnterScene 是最危险的点之一,因为它已经接近“角色真正进场景”的最后一步了。
这里如果发现:
save_lv 对不上。
- 当前
msgagent 不是游戏服认可的那个服务。
guid 和数据不一致。
- 角色处于异常重连状态。
就说明这条角色流程已经不可信。
这时候代码为什么必须“登出后立刻 return”?
原因是:
- 如果只是调用
logout_ex,但函数继续往下执行,就可能出现“明明已经判定异常,后面却还是进了场景”。
- 这正是鬼影角色、残留角色、双实例短时共存最常见的成因之一。
所以这里的核心不是“记录日志”,而是:
- 一旦判定异常,后续逻辑必须立即停止。
关键代码 5:CGEnterScene 发现异常必须立即返回
local my_data = ma_func:get_my_data()
local my_guid = ma_func:get_my_guid()
local ma_func_agent = ma_func:get_my_agent()
local game_lv,game_agent = skynet.call(".gamed", "lua", "check_save_lv", my_guid)
...
if my_data then
data_guid = my_data.attrib.guid
data_lv = my_data.game_flag.save_lv
...
if not game_lv or game_lv ~= data_lv then
bug_name = "服务数据版本号不同"
elseif game_agent ~= ma_func_agent then
bug_name = "角色服务ID异常"
elseif my_guid ~= data_guid then
bug_name = "数据GUID与服务GUID不符"
else
if change_scene_args then
...
else
if my_data.game_flag.login_user ~= 1 then
bug_name = "重连"
end
end
end
else
bug_name = "角色服务没有角色数据"
end
if bug_name then
...
ma_func:notify_tips("ERROR0001:"..bug_name)
CMD.logout_ex(my_guid)
-- 这里必须立刻终止,不能在判定异常后继续执行后面的进场景流程。
return
end
代码解释:
CGEnterScene 是角色真正进入场景前的最后总检查。
- 这里同时核对了
save_lv、当前 agent、guid、重连状态等关键状态。
- 只要这些状态有一个不对,就说明当前这条角色流程已经不安全。
- 此时不仅要
logout_ex,还必须 return。
- 如果没有这个
return,就可能出现“明明已经判定异常,但后面仍继续进场景”。
这段代码防的 Bug 是:
- 旧角色流程继续跑。
- 角色残留在场景中。
- 鬼影角色。
- 同角色短时双在线。
防 Bug 的正确思路
这类问题不能只靠一个 if 修掉,应该按下面的原则设计。
原则一:同一个约束,至少要有两层检查
例如“同账号不能同时在线两个角色”:
- 登录服要查一次。
- 游戏服还要查一次。
原因是:
- 靠前的一层负责减少无效请求。
- 靠后的一层负责做最终裁决。
只做前者不够,因为并发窗口会绕过去。
只做后者也不够,因为代价太高,体验太差。
原则二:不要只做“检查”,还要保证“检查和写入”是原子的
下面这种写法风险很大:
- 先判断“当前没有 agent”。
- 再去创建 agent。
如果中间有并发,就可能两个请求都看到“没有 agent”,最后创建出两个。
正确思路是:
- 对关键资源加串行锁,至少按
guid 串行。
- 在锁内完成判断、创建、绑定。
原则三:异常分支必须立即结束
很多线上双在线问题,不是因为完全没有检测,而是因为:
- 代码已经检测到异常。
- 但异常处理后没有
return。
- 后续流程还继续执行了。
所以异常处理必须满足:
- 记录日志。
- 清理状态。
- 立即终止当前流程。
缺一不可。
原则四:登录服会话和游戏服在线态要分开看
这两个概念不能混为一谈:
- 登录服会话:账号现在是不是还停留在登录/选角界面。
- 游戏服在线态:角色是不是已经真正进入游戏。
如果只检查游戏服在线态,会漏掉:
- 同账号两个选角界面同时存在。
如果只检查登录服会话,又挡不住:
- 已经进游戏的重复进入。
所以两边都要管。
原则五:客户端提示要和客户端现成逻辑保持一致
客户端已经有自己的提示链路,例如:
USERONLINE
OTHERSEVER_ONLINE
Login_Char_Playing
服务端如果要给玩家明确提示,最好复用现成返回码,而不是临时塞脚本文字。
原因是:
- 客户端已有现成 UI 逻辑。
- 已有按钮和后续跳转逻辑。
- 用户看到的行为更统一。
但是要注意:
- 如果服务端只是直接关闭旧连接,客户端通常不会走这些明确提示分支。
- 所以“想让客户端提示正确”本质上是服务端回包路径设计问题,不只是文案问题。
当前代码里最值得保留的防线
从防 Bug 角度看,下面这些写法是应该保留甚至继续强化的。
1. 登录服的账号会话唯一性
应该保留原因:
- 它能挡住“同账号双选角界面”。
- 这是后续很多问题的源头。
可优化的点:
- 当前是“直接踢旧连接”。
- 如果希望客户端提示更明确,可以改成“拒绝新登录并返回客户端现成的重复登录结果”。
2. 游戏服对 uuid 和 guid 的最终唯一性判断
应该保留原因:
- 它是最后一道权威防线。
- 没有这道防线,并发场景下登录服的判断一定不够。
3. 按 guid 串行化建链
应该保留原因:
- 这类问题最怕竞态。
- 同角色的
CGConnect、重连、切图重进都容易撞到这里。
4. CGEnterScene 异常后立即终止
应该保留原因:
- 这是阻止旧流程继续进场景的关键点。
- 它直接关系到是否会产生残留角色、鬼影角色和双实例短时共存。
如何继续降低风险
如果要继续把问题压到更低,建议从下面几件事继续做。
1. 所有“进入游戏”入口统一走同一套唯一性判断
包括:
- 正常登录。
- 切图重连。
- 跨服回本服。
- 断线重连。
原则是:
- 不要让某一条特殊路径绕过最终唯一性判断。
2. 对“旧流程继续跑”增加更多保护
例如:
- 旧
msgagent 一旦发现自己不是当前合法服务,就不只退出,还要确保不会再向场景写状态。
- 场景服在接收玩家进入时,也可以再核一次当前 agent 身份。
3. 日志字段尽量标准化
建议统一记录:
uuid
guid
fd
subid
game_agent
msgagent
save_lv
sceneid
reason
这样线上抓一次日志,就能快速判断是:
- 登录服会话冲突。
- 游戏服在线态冲突。
- 角色服务竞态。
- 场景切换残留。
4. 回归测试要固定覆盖四种场景
最少要测:
- 同账号双选角。
- 同账号不同角色同时进游戏。
- 同角色快速重连。
- 传送过程中切角色。
一句话总结
这类代码之所以要写成“登录服先拦、游戏服兜底、角色服务异常立即停”,根本原因只有一个:
- 重复登录问题本质上是并发和状态同步问题。
只在一个地方检查,不够。
只记录日志,不够。
发现异常但不立刻停,也不够。
真正能防 Bug 的写法,一定同时满足:
- 多层校验。
- 关键路径串行化。
- 异常后立即终止。
- 客户端提示路径和服务端回包路径保持一致。