找回密码
 register
搜索
查看: 104|回复: 0

洱海月 同账号同时登录两次处理说明(原理与防Bug)

[复制链接]
  • 打卡等级:本地老炮
  • 打卡总天数:533
  • 打卡月天数:22
  • 打卡总奖励:530
  • 最近打卡:2026-06-24 01:45:59
Waylee 发表于 2026-4-3 14:05 | 显示全部楼层 |阅读模式 | Google Chrome | Windows 10

马上注册,查看网站隐藏内容!!

您需要 登录 才可以下载或查看,没有账号?register

×

文档目的

这份文档不讲“怎么操作客户端”,只讲三件事:

  1. 为什么这类问题会出现。
  2. 为什么代码要这样写。
  3. 如何用更稳的方式防止同账号同时登录两次,以及由此引出的同角色双在线问题。

问题本质

“同账号同时登录两次”本质上不是一个单点问题,而是一个链路问题。

它至少会经过下面三层:

  1. 登录服:账号密码校验、角色列表、角色进入请求。
  2. 游戏服:账号与角色的在线态维护、连接建立、msgagent 创建。
  3. 角色服务 / 场景服:角色真正进入场景、切图、退出、存档。

只要其中任何一层没有做唯一性约束,或者“检查”和“写入”之间存在并发窗口,就可能出现:

  1. 同账号两个不同角色同时在线。
  2. 同一个角色被两个服务实例短时同时持有。
  3. 角色切图途中旧流程没有停干净,形成残留角色或鬼影角色。

代码为什么这样写

当前这套代码里,防线实际上分成了三层,每一层负责的目标不一样。

一层:登录服先做快速拦截

文件:

  1. services/login/logind.lua

这里的职责不是最终裁决,而是尽快挡掉明显非法的重复登录,避免请求继续往后流。

当前代码里有两类关键判断:

  1. 账号登录阶段调用 .gamed.check_uuid_is_online(uuid),优先处理“该账号在游戏中已经在线”的情况。
  2. 登录服本地维护 uuid_auth_fd,用于约束“一个账号在登录服只保留一个有效选角会话”。

这么写的原因是:

  1. 登录服最靠近客户端,拒绝越早,资源浪费越少。
  2. 选角界面和正式进游戏是两个阶段,不能只在账号密码登录时检查一次。
  3. 如果不限制登录服会话,同一个账号可以同时保留两个选角界面,后面很容易分别点进两个角色。

当前实际代码选择的是:

  1. 新会话进来时,直接关闭旧的选角连接。
  2. 这样能快速保证“同账号只保留一个登录服会话”。

这么写的优点是简单,缺点也很明显:

  1. 被顶掉的一端通常只是掉线,不一定能收到明确的重复登录提示。

关键代码 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

代码解释:

  1. uuid_auth_fd 记录“这个账号当前绑定的是哪个登录服 fd”。
  2. bind_auth_session 的作用是建立新的账号会话绑定。
  3. 如果 old_fd 存在并且不是当前 fd,说明同账号已经有旧的登录服会话。
  4. 当前实现选择直接 closeclient(old_fd),也就是让旧选角客户端掉线。
  5. get_auth_session 的作用是做二次校验,防止旧连接即使还没完全断干净,也继续操作角色列表或进入游戏。

这段代码防的 Bug 是:

  1. 同账号保留两个选角界面。
  2. 旧连接残留消息继续发包。

关键代码 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

代码解释:

  1. login_times[uuid] 是重登冷却,用来防止账号刚被顶或刚退出时立刻反复登录。
  2. check_uuid_is_online(uuid) 是登录服向游戏服确认“这个账号当前有没有角色已经在游戏中”。
  3. have_online == 2 表示旧角色还在退出队列里,所以返回等待时间。
  4. have_online == 1 表示账号确实已经在线,当前返回 ret.flag = 8
  5. 只有前面的检查都通过了,才会真正执行 bind_auth_session,把这个账号绑定到新的登录服会话。

这段代码防的 Bug 是:

  1. 账号已经在游戏中,还继续重复登录。
  2. 账号刚被挤下线或刚退出时反复重登。

二层:游戏服做最终唯一性裁决

文件:

  1. services/game/gamed.lua

登录服的判断不能代替游戏服判断,因为登录服前面的检查可能被并发绕过。

所以游戏服必须再做一次最终约束:

  1. 同一个 guid 不能重复进入。
  2. 同一个 uuid 不能在游戏服里同时挂两个角色。

这么写的原因是:

  1. 游戏服维护的是最终在线结构,必须是权威来源。
  2. 即使登录服由于并发时序放过了请求,游戏服也要兜底挡住。

当前修复里又加了一层按 guid 串行化的连接建立锁。

原因是:

  1. CGConnect -> create_msgagent 这一段如果并发执行,可能同时给同一角色建出两个 msgagent
  2. 这类问题不是靠“if u.agent then ...”就能彻底防住的,因为“判断”和“创建”之间可能让出执行权。

因此需要:

  1. guid 串行处理建链。
  2. 在锁内完成校验和绑定。

关键代码 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

代码解释:

  1. users[guid] 是游戏服当前在线角色表,按 guid 维护。
  2. handler.login 里先查 users[guid],是为了挡住“同一个角色再次进入”。
  3. check_uuid_online_state(uuid, guid) 再查同账号下是否已有别的角色在游戏服在线。
  4. 这层校验放在游戏服里,是因为游戏服才是最终在线态的权威。
  5. 即使登录服前面查过一次,这里还是要再查一次,防止并发穿透。

这段代码防的 Bug 是:

  1. 同账号两个不同角色同时在线。
  2. 同一个角色被重复进入。

关键代码 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)

代码解释:

  1. CGConnect 是客户端真正开始和游戏服建角色连接的入口。
  2. 如果两个连接几乎同时来,最危险的就是两个请求同时执行 create_msgagent
  3. get_guid_message_lock(guid) 的作用就是把同一 guid 的建链过程串起来。
  4. 只有拿到锁之后,才会检查 subid 是否还匹配、再去绑定 agent。
  5. 这样可以避免两个连接同时抢同一角色。

这段代码防的 Bug 是:

  1. 同一角色短时间内生成两个 msgagent
  2. 重连、切图、异常回连时出现双实例。

三层:角色服务发现异常必须立刻停流程

文件:

  1. services/msgagent.lua

CGEnterScene 是最危险的点之一,因为它已经接近“角色真正进场景”的最后一步了。

这里如果发现:

  1. save_lv 对不上。
  2. 当前 msgagent 不是游戏服认可的那个服务。
  3. guid 和数据不一致。
  4. 角色处于异常重连状态。

就说明这条角色流程已经不可信。

这时候代码为什么必须“登出后立刻 return”?

原因是:

  1. 如果只是调用 logout_ex,但函数继续往下执行,就可能出现“明明已经判定异常,后面却还是进了场景”。
  2. 这正是鬼影角色、残留角色、双实例短时共存最常见的成因之一。

所以这里的核心不是“记录日志”,而是:

  1. 一旦判定异常,后续逻辑必须立即停止。

关键代码 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

代码解释:

  1. CGEnterScene 是角色真正进入场景前的最后总检查。
  2. 这里同时核对了 save_lv、当前 agentguid、重连状态等关键状态。
  3. 只要这些状态有一个不对,就说明当前这条角色流程已经不安全。
  4. 此时不仅要 logout_ex,还必须 return
  5. 如果没有这个 return,就可能出现“明明已经判定异常,但后面仍继续进场景”。

这段代码防的 Bug 是:

  1. 旧角色流程继续跑。
  2. 角色残留在场景中。
  3. 鬼影角色。
  4. 同角色短时双在线。

防 Bug 的正确思路

这类问题不能只靠一个 if 修掉,应该按下面的原则设计。

原则一:同一个约束,至少要有两层检查

例如“同账号不能同时在线两个角色”:

  1. 登录服要查一次。
  2. 游戏服还要查一次。

原因是:

  1. 靠前的一层负责减少无效请求。
  2. 靠后的一层负责做最终裁决。

只做前者不够,因为并发窗口会绕过去。
只做后者也不够,因为代价太高,体验太差。

原则二:不要只做“检查”,还要保证“检查和写入”是原子的

下面这种写法风险很大:

  1. 先判断“当前没有 agent”。
  2. 再去创建 agent。

如果中间有并发,就可能两个请求都看到“没有 agent”,最后创建出两个。

正确思路是:

  1. 对关键资源加串行锁,至少按 guid 串行。
  2. 在锁内完成判断、创建、绑定。

原则三:异常分支必须立即结束

很多线上双在线问题,不是因为完全没有检测,而是因为:

  1. 代码已经检测到异常。
  2. 但异常处理后没有 return
  3. 后续流程还继续执行了。

所以异常处理必须满足:

  1. 记录日志。
  2. 清理状态。
  3. 立即终止当前流程。

缺一不可。

原则四:登录服会话和游戏服在线态要分开看

这两个概念不能混为一谈:

  1. 登录服会话:账号现在是不是还停留在登录/选角界面。
  2. 游戏服在线态:角色是不是已经真正进入游戏。

如果只检查游戏服在线态,会漏掉:

  1. 同账号两个选角界面同时存在。

如果只检查登录服会话,又挡不住:

  1. 已经进游戏的重复进入。

所以两边都要管。

原则五:客户端提示要和客户端现成逻辑保持一致

客户端已经有自己的提示链路,例如:

  1. USERONLINE
  2. OTHERSEVER_ONLINE
  3. Login_Char_Playing

服务端如果要给玩家明确提示,最好复用现成返回码,而不是临时塞脚本文字。

原因是:

  1. 客户端已有现成 UI 逻辑。
  2. 已有按钮和后续跳转逻辑。
  3. 用户看到的行为更统一。

但是要注意:

  1. 如果服务端只是直接关闭旧连接,客户端通常不会走这些明确提示分支。
  2. 所以“想让客户端提示正确”本质上是服务端回包路径设计问题,不只是文案问题。

当前代码里最值得保留的防线

从防 Bug 角度看,下面这些写法是应该保留甚至继续强化的。

1. 登录服的账号会话唯一性

应该保留原因:

  1. 它能挡住“同账号双选角界面”。
  2. 这是后续很多问题的源头。

可优化的点:

  1. 当前是“直接踢旧连接”。
  2. 如果希望客户端提示更明确,可以改成“拒绝新登录并返回客户端现成的重复登录结果”。

2. 游戏服对 uuidguid 的最终唯一性判断

应该保留原因:

  1. 它是最后一道权威防线。
  2. 没有这道防线,并发场景下登录服的判断一定不够。

3. 按 guid 串行化建链

应该保留原因:

  1. 这类问题最怕竞态。
  2. 同角色的 CGConnect、重连、切图重进都容易撞到这里。

4. CGEnterScene 异常后立即终止

应该保留原因:

  1. 这是阻止旧流程继续进场景的关键点。
  2. 它直接关系到是否会产生残留角色、鬼影角色和双实例短时共存。

如何继续降低风险

如果要继续把问题压到更低,建议从下面几件事继续做。

1. 所有“进入游戏”入口统一走同一套唯一性判断

包括:

  1. 正常登录。
  2. 切图重连。
  3. 跨服回本服。
  4. 断线重连。

原则是:

  1. 不要让某一条特殊路径绕过最终唯一性判断。

2. 对“旧流程继续跑”增加更多保护

例如:

  1. msgagent 一旦发现自己不是当前合法服务,就不只退出,还要确保不会再向场景写状态。
  2. 场景服在接收玩家进入时,也可以再核一次当前 agent 身份。

3. 日志字段尽量标准化

建议统一记录:

  1. uuid
  2. guid
  3. fd
  4. subid
  5. game_agent
  6. msgagent
  7. save_lv
  8. sceneid
  9. reason

这样线上抓一次日志,就能快速判断是:

  1. 登录服会话冲突。
  2. 游戏服在线态冲突。
  3. 角色服务竞态。
  4. 场景切换残留。

4. 回归测试要固定覆盖四种场景

最少要测:

  1. 同账号双选角。
  2. 同账号不同角色同时进游戏。
  3. 同角色快速重连。
  4. 传送过程中切角色。

一句话总结

这类代码之所以要写成“登录服先拦、游戏服兜底、角色服务异常立即停”,根本原因只有一个:

  1. 重复登录问题本质上是并发和状态同步问题。

只在一个地方检查,不够。
只记录日志,不够。
发现异常但不立刻停,也不够。

真正能防 Bug 的写法,一定同时满足:

  1. 多层校验。
  2. 关键路径串行化。
  3. 异常后立即终止。
  4. 客户端提示路径和服务端回包路径保持一致。
您需要登录后才可以回帖 登录 | register

本版积分规则

QQ|雪舞知识库 ( 浙ICP备15015590号-1 | 萌ICP备20232229号|浙公网安备33048102000118号 )|天天打卡

GMT+8, 2026-6-24 07:03 , Processed in 0.064224 second(s), 26 queries .

Powered by Discuz! X5.0

© 2001-2026 Discuz! Team.

快速回复 返回顶部 返回列表