前三节我们把一套游戏服务器的骨架从空目录怼到了能跑:进程起得来、HTTP 路由通得了、玩家连上 WebSocket 能进房间收到别人的广播。但只要按下 Ctrl+C,所有账号数据跟着进程一起蒸发,严格说那还只是个能演示的玩具。这一节要干两件实事:把 MongoDB 接进来做持久化,再顺着它把注册和登录这两条最基础、也最容易被写得千疮百孔的链路跑通。做完之后,玩家关掉客户端、你重启服务进程,他明天还能用同一个账号登回来,昵称和战绩都还在。
很多人会把注册登录当成"两个 CRUD 接口"草草了事,真上线才发现问题全在细节里:用户名大小写重复注册、密码用 MD5 存、并发下唯一性校验失效、登录失败没有节流被人拿字典刷、Token 过期之后长连接怎么办。这一节我按实际动手的顺序来:先说清楚为什么第四节才引数据库、MongoDB 在这套架构里站什么位置,再把连接、模型、注册、登录、联调、踩坑一层层拆开。有 Node.js 基础和一点数据库概念就能跟着做,完全没接触过 MongoDB 的也能看懂,因为我会把每个参数为什么这么设讲透。
1. 数据库为什么拖到第四节才引入
1.1 前三节的内存方案到底卡在哪
前三节的玩家数据是挂在进程里的一个Map上,键是 uid,值是昵称、房间号、坐标这些。这个做法在验证阶段特别爽:不用装任何中间件,改一行代码重启就能看到效果,调试的时候还能直接把整个对象打印出来。但它有三个绕不过去的硬伤,而且一个比一个致命。
第一是进程重启即清空。游戏服务器不像普通 Web 服务,发版频率高,热更新、崩溃重启、机器迁移都是常态,只要数据在内存里,每次重启就等于全服删档,这在小范围测试时还能忍,一旦有真实玩家进来就是事故。第二是没法多进程。单进程能扛的并发有限,等你想起要开两个工作进程分摊连接,就会发现有状态的数据互相看不见,玩家在 A 进程登录、请求被负载均衡打到 B 进程,直接判定未登录。第三是查不动。运营想知道"昨天注册且登录过两次的玩家有多少",内存里那点数据只能靠遍历,量一大就卡主线程。
所以引数据库不是为了"看起来正规",而是为了解决"数据活过进程生命周期"和"多进程共享视图"这两件事。想清楚这一点,后面所有设计取舍都有了判断标准:凡是会影响这两件事的方案,一律不做。
1.2 MongoDB 在游戏服务器里的位置
选型上我考虑过三种:关系型的 MySQL、键值型的 Redis、文档型的 MongoDB。Redis 做缓存和排行榜是一把好手,但它是内存数据库,持久化是补充手段不是主职,拿来当唯一数据源心里不踏实;MySQL 结构严谨、事务完备,代价是字段一改就要动表结构,玩家档案这种"字段随版本不断增加"的东西,写迁移脚本会写到你怀疑人生。
MongoDB 的文档模型刚好卡在这个需求上:玩家的账号、背包、任务进度天然就是一个嵌套结构,加字段不用改表,读出来直接是对象,序列化成本几乎为零。它还有一套够用的索引和原子更新能力,单文档操作天然原子,唯一索引能兜住注册并发,这几点对我们这节要做的功能已经完全够用。至于事务,它需要副本集部署,本地单机测的时候用不上,我会在第 4 节讲没有事务时怎么保证一致性。
注意:MongoDB 在这套架构里是主存储,不是缓存。别把它当成"顺手存一下"的地方,账号密码这类数据一旦写进去,就要按能跑三年的标准去设计字段和索引。
1.3 这一节要落地的三个目标和目录结构
我把这一节的目标拆成三条,做完这三条就算收工。第一条是把数据库连接做成单例,全局只连一次,随进程启动建立、随进程退出关闭,任何业务代码拿到的都是同一个连接池。第二条是设计账号集合,把唯一性约束交给数据库而不是应用层判断。第三条是跑通注册和登录两条 HTTP 接口,登录成功后发一个带签名的 Token,后续接口和 WebSocket 握手都认这个 Token。
目录我按功能切,不按技术层切,这样后面加好友、加背包的时候只新增目录,不动老文件:
game-api/ src/ app.js 入口,装配中间件和路由 db/ mongo.js 连接封装、单例、优雅关闭 indexes.js 索引初始化 modules/ auth/ password.js 密码哈希与校验 service.js 注册登录的业务逻辑 routes.js 接口定义与参数校验 middleware/ auth.js Token 校验中间件 ws/ gateway.js WebSocket 握手与身份绑定 .env 连接串、密钥等配置这个结构看着有点啰嗦,但好处是三个月后你回来改登录逻辑,只需要打开modules/auth一个目录。我见过太多项目把所有逻辑塞在一个index.js里,到八百行之后连自己都不敢动。
2. 把 MongoDB 接进来:连接层的几个关键参数
2.1 本地起服务的两种方式
本地开发我一般两种方式轮着用。装原生服务适合长期开发,一条命令就能起,开机自启,不占额外资源。用容器适合临时验证或者给同事复现环境,删掉容器数据就干净了。两者对业务代码完全透明,连接串都是标准的mongodb://协议。
原生安装后确认服务在跑,最简单的方式是用命令行客户端连一下,能看到版本号和管理库就说明通了。容器方式要注意把数据目录挂出来,否则容器一删数据全没,这个坑我在早期项目里踩过一次,测了一下午的账号第二天全不见了,还以为是代码写错了。
# 容器方式,端口映射到本机 27017,数据落在宿主机 ./mongo-data docker run -d --name gamedb \ -p 27017:27017 \ -v $(pwd)/mongo-data:/data/db \ mongo:7 # 验证 mongosh "mongodb://127.0.0.1:27017/game_server" --eval "db.runCommand({ping:1})"返回{ ok: 1 }就对了。这里有个细节值得说:连接串末尾的/game_server是默认库名,如果你的业务代码里又通过client.db('xxx')指定了一次,两个地方不一致,就会出现"明明写进去了却查不到"的灵异现象。我后来干脆把库名也收到环境变量里,全局只读一处。
2.2 连接池与超时参数怎么定
官方驱动的连接池默认值是够用的,但游戏服务器的流量特征和普通 Web 不太一样:峰值集中、单次查询极短、连接复用率高。所以几个参数需要按实际场景调一调,不能全用默认。
maxPoolSize是最容易设错的一个。有人觉得越大越好,直接写 500,结果数据库那边的连接数被打满,其他服务连不进来。正确的算法是:并发数约等于 QPS 乘以单次查询耗时(秒)。假设登录接口峰值 300 QPS,单次查询平均 3 毫秒,那么同时在飞的查询大约是 0.9 个,加上哈希计算和其他耗时,留 10 到 20 倍余量,maxPoolSize设 20 完全够。连接池不是越大越快,它只是排队区的宽度。
serverSelectionTimeoutMS决定的是"连不上时等多久再报错",默认 30 秒太长,启动阶段卡半分钟才告诉你端口不通,调试体验极差,设 5000 毫秒比较合适。maxIdleTimeMS控制空闲连接多久回收,设 60000 可以避免云环境中被中间设备静默断开后拿到坏连接。retryWrites保持开启,单节点也能受益于可重试写入的语义。
2.3 单例封装与优雅关闭
连接必须做成单例。我见过最离谱的写法是每个请求new MongoClient()再connect(),压测的时候连接数瞬间飙到几百,数据库直接拒连。正确的做法是模块级持有一个 client 实例,第一次调用时建立连接,后续直接返回,并且把"未连接就使用"的情况直接抛错,而不是静默返回一个空对象。
// src/db/mongo.js const { MongoClient } = require('mongodb'); const URI = process.env.MONGO_URI || 'mongodb://127.0.0.1:27017'; const DB_NAME = process.env.MONGO_DB || 'game_server'; const client = new MongoClient(URI, { maxPoolSize: 20, minPoolSize: 2, maxIdleTimeMS: 60000, connectTimeoutMS: 5000, serverSelectionTimeoutMS: 5000, retryWrites: true, }); let db = null; async function connectMongo() { if (db) return db; await client.connect(); db = client.db(DB_NAME); await require('./indexes').ensureIndexes(db); console.log('[mongo] connected:', DB_NAME); return db; } function getDb() { if (!db) throw new Error('MongoDB 未初始化,请先在启动流程里调用 connectMongo()'); return db; } async function closeMongo() { await client.close(); db = null; }配套的优雅关闭不能省。进程收到终止信号时如果直接退出,正在执行的写操作可能被打断,连接也不会被正常归还。加一段信号处理,先停止接收新请求,再关连接,最后退出,整个过程给个 10 秒兜底超时。
// src/app.js 片段 const { connectMongo, closeMongo } = require('./db/mongo'); async function bootstrap() { await connectMongo(); const server = app.listen(process.env.PORT || 3000); const shutdown = async (signal) => { console.log('[app] shutting down by', signal); server.close(); await closeMongo(); process.exit(0); }; process.on('SIGINT', () => shutdown('SIGINT')); process.on('SIGTERM', () => shutdown('SIGTERM')); } bootstrap().catch((e) => { console.error('[app] bootstrap failed', e); process.exit(1); });提示:索引初始化放在连接之后、开始监听之前执行,这样服务一旦对外可用,约束就已经生效,不存在"服务起来了但唯一索引还没建好"的窗口期。
3. 账号数据模型:字段设计与索引策略
3.1 账号集合该存哪些字段
账号集合我命名为users,字段设计的第一原则是"区分度高的放顶层,会变的放子文档"。账号名、密码哈希、创建时间这类几乎不变的信息放顶层,方便建索引;登录统计、设备信息这类频繁变动的放一起,减少文档移动的开销。
最容易被忽略的是大小写问题。玩家注册Tommy,下次输入tommy就登不进去了,这体验非常糟。我的处理是额外存一个accountLower字段,专门用来做归一化和唯一索引,展示用的原始account保留玩家输入的样子。这样既美观又不会重复。
还有个隐藏问题是全角字符。中文输入法下打出来的ABC和英文字符的ABC在数据库里是两个完全不同的字符串,玩家自己都分不清。注册时统一做一次 NFKC 归一化,能把全角转成半角,这类问题就消掉了。
| 字段 | 类型 | 说明 | 是否索引 |
|---|---|---|---|
_id | ObjectId | 主键,同时作为内部玩家 ID | 默认唯一 |
account | string | 玩家输入的原始账号,仅展示 | 否 |
accountLower | string | 归一化后的小写账号,登录查询用 | 唯一索引 |
passwordHash | string | 带算法与参数的哈希串 | 否 |
nickname | string | 游戏内昵称,可与账号不同 | 否 |
status | int | 1 正常,0 封禁 | 否 |
loginFailCount | int | 连续登录失败次数 | 否 |
lockUntil | Date/null | 锁定截止时间 | 否 |
createdAt | Date | 注册时间,UTC | 是 |
lastLoginAt | Date/null | 最近登录时间 | 否 |
passwordHash我建议存成一个自描述的字符串,比如scrypt$16384$8$1$盐$哈希,把算法和参数一起写进去。好处是将来想升级成本参数时,老用户还能用老参数校验,登录成功后再悄悄换成新参数,不需要让所有人重置密码。这个设计在真实项目里救过我一次。
3.2 唯一索引怎么建才不会翻车
唯一性必须由数据库来保证,不能靠"先查一下存不存在,不存在就插入"。这两步之间有个时间窗口,两个请求同时进来,都查到不存在,都去插入,结果就是两条同名账号。这个问题在压测时特别容易复现,本地一个人测永远测不出来。
唯一索引建在accountLower上,不带任何其他字段,否则就变成了组合唯一,起不到去重作用。建索引的时机很关键:如果集合里已经有重复数据,建索引会直接失败并报错。所以我在ensureIndexes里做了容错,先尝试建,失败就打印出来让人工处理,而不是让服务静默起不来。
// src/db/indexes.js async function ensureIndexes(db) { const users = db.collection('users'); await users.createIndex( { accountLower: 1 }, { unique: true, name: 'uniq_account_lower' } ); await users.createIndex({ createdAt: -1 }, { name: 'idx_created_at' }); console.log('[mongo] indexes ready'); } module.exports = { ensureIndexes };注意这里createIndex是幂等的,重复执行不会报错也不会重建,所以放在每次启动时跑完全没问题。
注意:千万别给
users集合建 TTL 索引。TTL 索引会自动删除过期文档,有人想当然地给lastLoginAt加个过期时间"清理不活跃用户",结果玩家一个月没上线,账号直接被数据库删了,这个事故的恢复成本极高。
3.3 为什么账号和玩家档案要分开
有人会问,反正都是玩家的数据,一个文档全存了不行吗?小规模确实行,但有两个隐患。第一是文档大小,玩家档案里如果放了背包、任务、邮件,很容易膨胀到几 KB,而登录校验只需要读密码哈希,读一次大文档纯属浪费带宽。第二是写冲突,登录时要更新lastLoginAt,如果和背包在同一个文档里,每次登录都会让整个大文档重写一遍。
我的做法是账号表只管身份认证,玩家档案另开一个集合,用_id关联。这样登录接口只碰小文档,档案在玩家真正进入游戏时再按需加载。至于两者的一致性,注册时先写账号再写档案,中间失败的话留下一个没有档案的账号,下次登录时检测到档案缺失就补建一份,用这种"懒修复"的方式兜底,比上分布式事务轻量得多。
4. 注册流程:从参数校验到并发兜底
4.1 参数校验该卡在哪一层
校验必须做在业务逻辑之前,而且要一次把规则说清楚,别让玩家试一次改一点。我的规则是:账号 4 到 20 位,只允许字母、数字和下划线,不允许纯数字(防止和未来的数字 ID 混淆);密码 8 到 32 位,必须同时包含字母和数字;昵称 1 到 16 个字符,允许中文。
这些规则我会写成一个独立函数,返回明确的错误码而不是一句"参数错误"。客户端拿到PASSWORD_TOO_WEAK可以直接弹对应的提示,拿到ACCOUNT_EXISTS就聚焦到账号输入框,体验完全不同。校验函数要保持纯函数,不碰数据库,这样单元测试写起来飞快。
function validateRegister({ account, password, nickname }) { if (typeof account !== 'string') return 'ACCOUNT_INVALID'; const acc = account.normalize('NFKC').trim(); if (!/^[A-Za-z0-9_]{4,20}$/.test(acc)) return 'ACCOUNT_INVALID'; if (/^\d+$/.test(acc)) return 'ACCOUNT_INVALID'; if (typeof password !== 'string') return 'PASSWORD_TOO_WEAK'; if (password.length < 8 || password.length > 32) return 'PASSWORD_TOO_WEAK'; if (!/[A-Za-z]/.test(password) || !/\d/.test(password)) return 'PASSWORD_TOO_WEAK'; if (nickname !== undefined) { if (typeof nickname !== 'string') return 'NICKNAME_INVALID'; const nick = nickname.trim(); if (nick.length < 1 || nick.length > 16) return 'NICKNAME_INVALID'; } return null; }密码长度上限设 32 不是拍脑袋,是因为哈希函数的输入越长计算量越大,公网上有人拿超长字符串打接口做资源消耗攻击。上限卡住,成本就可控。
4.2 密码哈希:为什么不用 MD5
这是注册链路里最重要的一个决定。MD5 和 SHA256 是快速哈希,设计目标是"算得快",一块普通显卡每秒能算几十亿次,拿彩虹表或者暴力枚举去撞 8 位密码基本是分钟级的。密码必须用慢哈希,也就是故意算得慢、并且可以调参的算法。
目前主流两个选择:bcrypt 和 scrypt。bcrypt 生态成熟,参数是 cost,每加 1 计算量翻倍;scrypt 是 Node 内置的,不需要装任何带编译的依赖参数更直观(N、r、p 三个参数控制内存和 CPU 消耗)。我这边选 scrypt,主要原因是部署省事——bcrypt 在部分精简环境里需要编译原生模块,构建流水线里经常卡住,scrypt 直接用crypto模块,零依赖。
参数上我用N=16384, r=8, p=1,单次计算大约几十毫秒,内存占用约 16MB。这个值在本地和服务器的实测体感是:注册接口单核大约每秒能处理 15 到 30 次,对普通游戏的开服峰值完全够。如果你预计开服瞬间有几千人同时注册,那就得提前算一笔账:假设单次哈希 50 毫秒,单核每秒 20 次,2000 人同时注册需要 100 核秒,也就是 8 核机器上大约 12 秒的排队,属于可接受范围,前提是你别在这个接口上再叠其他重活。
// src/modules/auth/password.js const crypto = require('crypto'); const { promisify } = require('util'); const scrypt = promisify(crypto.scrypt); const N = 16384, R = 8, P = 1, KEY_LEN = 32, MAXMEM = 64 * 1024 * 1024; async function hashPassword(plain) { const salt = crypto.randomBytes(16); const key = await scrypt(plain, salt, KEY_LEN, { N, r: R, p: P, maxmem: MAXMEM }); return `scrypt$${N}$${R}$${P}$${salt.toString('base64')}$${key.toString('base64')}`; } async function verifyPassword(plain, stored) { const parts = String(stored || '').split('$'); if (parts.length !== 6 || parts[0] !== 'scrypt') return false; const [, n, r, p, saltB64, keyB64] = parts; const salt = Buffer.from(saltB64, 'base64'); const expected = Buffer.from(keyB64, 'base64'); const key = await scrypt(plain, salt, expected.length, { N: Number(n), r: Number(r), p: Number(p), maxmem: MAXMEM, }); return key.length === expected.length && crypto.timingSafeEqual(key, expected); }最后那句timingSafeEqual不能省。普通字符串比较是逐字节比对、遇到不同就返回,攻击者能通过响应时间差一点点猜出密码。哈希比较必须用恒定时间函数。这个细节在业务代码里极不起眼,但确实是密码学实践的硬要求。
4.3 并发冲突的正确处理姿势
前面说了唯一索引兜底,代码层面就要接住这个异常。MongoDB 在唯一索引冲突时抛的错误码是11000,所以注册的写库逻辑应该是:直接插,插失败看错误码,是 11000 就返回账号已存在,其他错误往上抛交给全局错误处理。
这里有个反直觉的点:不要先查再插。除了前面说的竞态问题,还多了一次网络往返,高并发下白白增加延迟。直接插、靠索引拦截,一次往返解决问题,语义还更准确——唯一性由数据库这个唯一权威来裁定。
// src/modules/auth/service.js 注册核心 async function register({ account, password, nickname }) { const acc = String(account).normalize('NFKC').trim(); const accLower = acc.toLowerCase(); const passwordHash = await hashPassword(password); const now = new Date(); const doc = { account: acc, accountLower: accLower, passwordHash, nickname: (nickname || acc).trim(), status: 1, loginFailCount: 0, lockUntil: null, createdAt: now, updatedAt: now, lastLoginAt: null, }; try { const r = await getDb().collection('users').insertOne(doc); return { userId: r.insertedId.toString(), account: acc, nickname: doc.nickname }; } catch (e) { if (e && e.code === 11000) { const err = new Error('ACCOUNT_EXISTS'); err.status = 409; throw err; } throw e; } }返回给客户端的内容只包含 id、账号、昵称,绝不能把整个文档res.json(doc)甩出去,那等于把密码哈希直接送人。这个错误我在别人的代码里见过不止一次,通常是因为图省事。
4.4 接口串起来与一次真实联调
路由层负责把校验、业务、响应串起来,并且统一错误格式。我习惯让所有接口都返回{ code, data }或{ code, message }的结构,客户端只写一套解析逻辑。
// src/modules/auth/routes.js router.post('/register', async (req, res, next) => { try { const body = req.body || {}; const invalid = validateRegister(body); if (invalid) return res.status(400).json({ code: invalid }); const data = await service.register(body); await ensureProfile(data.userId); res.status(201).json({ code: 'OK', data }); } catch (e) { next(e); } });联调时我做了三轮验证。第一轮正常注册,返回 201 和用户 id。第二轮用同一个账号再注册一次,期望 409 且错误码是ACCOUNT_EXISTS——这一轮我一开始写错了,因为第一次注册时账号带了个空格,数据库里存的是Tommy,第二次我输入tommy,走了accountLower命中了唯一索引,说明归一化确实生效了。第三轮用curl并发打 20 个同样的注册请求,结果 1 个 201、19 个 409,没有出现两条重复数据,唯一索引的兜底得到验证。
5. 登录流程:校验顺序、节流与状态发放
5.1 校验顺序决定了安全性和体验
登录的校验有固定顺序,顺序错了要么泄露信息,要么白算哈希。我的顺序是:先查用户,查不到直接返回凭证错误;再判断账号状态是否封禁;再判断是否在锁定中;最后才算密码哈希。为什么把哈希放最后?因为哈希很贵,如果账号已经被封或者锁定,就没必要浪费这几十毫秒。
还有一个细节:用户不存在和密码错误必须返回同一个错误码。如果两者返回不同,攻击者就能拿这个接口当"账号探测器"用,一秒几千次枚举出哪些账号真实存在,然后再集中火力爆破。这个信息泄露在安全评审里是必查项。同理,用户不存在时也可以做一次假哈希计算,让响应时间保持一致,进一步消除时间侧信道,做到这一步就相当扎实了。
查询用户时一定要用 projection 只取需要的字段。passwordHash必须取,因为要校验;但背包、任务这些大字段一个都不要,减少网络传输和反序列化开销。这个习惯在玩家文档变大之后会救你的响应时间。
5.2 失败节流:让爆破成本高到不划算
没有节流的登录接口就是敞开的大门。我用的是账号级计数加锁,不用 IP 级,因为 IP 可以换,账号是攻击目标。规则是连续失败 5 次锁 15 分钟,登录成功清零。
实现上最关键的是原子性。如果先读计数、判断、再写回,中间被并发请求插队,计数就会少记,攻击者可以用并发绕过限制。MongoDB 4.2 以上支持聚合管道式更新,可以在一次往返里完成"自增、判断、加锁、清零"这一串动作,这个用法我用得很多,值得单独记一下。
const MAX_FAILS = 5; const LOCK_MS = 15 * 60 * 1000; async function recordLoginFail(userId) { const now = new Date(); await getDb().collection('users').updateOne( { _id: userId }, [ { $set: { loginFailCount: { $add: [{ $ifNull: ['$loginFailCount', 0] }, 1] } } }, { $set: { lockUntil: { $cond: [ { $gte: ['$loginFailCount', MAX_FAILS] }, new Date(now.getTime() + LOCK_MS), '$lockUntil', ], }, updatedAt: now, }, }, { $set: { loginFailCount: { $cond: [{ $gte: ['$loginFailCount', MAX_FAILS] }, 0, '$loginFailCount'], }, }, }, ] ); }管道里的$set是按顺序执行的,后一个能看到前一个的结果,所以"先自增、再判断要不要加锁、最后清零"可以一次搞定。返回 423 状态码并带上剩余秒数,客户端就能显示"账号已锁定,请 X 分钟后再试",比一句冷冰冰的失败友好得多。
5.3 Token 发放:为什么选无状态签名
登录成功后要发凭证,我选了 JWT 这种自包含的签名 Token,而不是服务端存 session。原因和游戏服务器的形态有关:玩家进来之后马上要建立长连接,可能还会连多个网关节点;session 存在单机内存里,多节点之间就要做共享,多一层依赖;存 Redis 又要多一个组件。JWT 把身份信息签在 Token 里,任何节点拿到密钥就能独立验证,天然适合横向扩展。
密钥必须从环境变量读,不要写在代码里,也不要提交到仓库。这是最基础的一条,但仍然是泄露事故的高发点。Token 里我放三个字段:uid、acc(账号名,方便日志排查)、ver(版本号,将来做强制下线时把版本号一加,老 Token 全部失效)。有效期 7 天,因为游戏玩家不像银行用户,频繁要求重新登录会掉留存。
const jwt = require('jsonwebtoken'); function issueToken(user) { return jwt.sign( { uid: user._id.toString(), acc: user.account, ver: 1 }, process.env.JWT_SECRET, { algorithm: 'HS256', expiresIn: '7d', issuer: 'game-api' } ); }校验中间件要显式指定algorithms: ['HS256']。不指定的话,某些历史版本存在算法混淆的风险,虽然新版库已经修复,但写死算法是成本极低的保险。
// src/middleware/auth.js function auth(req, res, next) { const raw = req.headers.authorization || ''; const token = raw.startsWith('Bearer ') ? raw.slice(7) : ''; if (!token) return res.status(401).json({ code: 'NO_TOKEN' }); try { req.user = jwt.verify(token, process.env.JWT_SECRET, { algorithms: ['HS256'] }); next(); } catch (e) { const code = e.name === 'TokenExpiredError' ? 'TOKEN_EXPIRED' : 'BAD_TOKEN'; res.status(401).json({ code }); } }TOKEN_EXPIRED单独返回,客户端就知道该走"静默重新登录"还是"弹登录框",这个区分在体验上差别很大。
5.4 长连接怎么和 Token 衔接
HTTP 登录拿到 Token 之后,客户端要拿它去连 WebSocket。两种常见做法:一是把 Token 放在连接 URL 的查询参数里,二是连上之后发第一条消息做认证。前者实现简单,但 Token 会出现在网关日志和代理访问日志里,长期看是个隐患;后者稍麻烦,但 Token 只走加密通道的报文,不落日志。
我的做法是:握手阶段先不接受任何业务消息,客户端连上后 5 秒内必须发送认证消息,服务端验证通过才把uid绑定到 socket 上,超时或验证失败直接断开并给出关闭码。这样既避免了日志泄露,又能把未认证的连接挡在业务逻辑之外。
// src/ws/gateway.js 片段 socket.authTimer = setTimeout(() => socket.close(4008, 'auth timeout'), 5000); socket.on('message', (raw) => { if (socket.uid) return handleGameMessage(socket, raw); let msg; try { msg = JSON.parse(raw.toString()); } catch { return socket.close(4001, 'bad json'); } if (msg.type !== 'auth') return socket.close(4001, 'auth required'); try { const payload = jwt.verify(msg.token, process.env.JWT_SECRET, { algorithms: ['HS256'] }); socket.uid = payload.uid; clearTimeout(socket.authTimer); socket.send(JSON.stringify({ type: 'auth_ok', uid: socket.uid })); } catch { socket.close(4001, 'auth failed'); } });另外一件必须提前想清楚的事:Token 有效期 7 天,但玩家的长连接可能挂机超过这个时间。我的处理是连接建立后不因为 Token 过期就踢人,因为认证是在建连那一刻做的;同时在登录接口之外提供一个刷新接口,客户端在收到 401 且错误码是TOKEN_EXPIRED时自动刷新。这样挂机玩家不会被半夜踢下线,安全边界也还在。
6. 问题排查与实操避坑
6.1 连接类问题速查表
启动阶段报错最集中,我整理了一张表,基本覆盖九成情况。
| 现象 | 常见原因 | 快速排查 | 处理方式 |
|---|---|---|---|
| 启动卡 30 秒后报超时 | 连接串写错或服务未启动 | 用命令行客户端直连一次 | 修正连接串,确认服务在跑 |
Authentication failed | 连接串缺用户名密码或认证库 | 检查串里是否有authSource | 补齐凭据,指定认证库 |
偶发ECONNRESET | 空闲连接被中间设备断开 | 看报错是否集中在低峰期 | 调小maxIdleTimeMS |
| 连接数暴涨 | 每个请求都新建客户端 | 搜索代码里new MongoClient | 改单例,全局复用 |
| 写入很慢但查询正常 | 磁盘 IO 或写关注过高 | 看慢日志和磁盘指标 | 调整写关注,检查磁盘 |
这张表里我踩得最深的是第三条。当时服务跑得好好的,每天凌晨会有一两个请求报连接重置,怎么都复现不出来。后来把空闲连接的回收时间从默认值调到 60 秒,问题就消失了。这类问题没有经验真的很难定位,因为它在业务侧表现为"偶发失败",很容易被当成网络抖动忽略掉。
6.2 索引与写入类问题
E11000 duplicate key这个错误在日志里出现时,先别急着改代码,要判断是哪种场景触发的。如果是注册接口,那是正常的并发冲突,业务层已经转成 409。如果在别的接口上看到它,那基本说明有代码在插已经存在的文档,比如重复投递的重试逻辑没做幂等。
索引建不上还有一个隐藏原因:集合里已有重复数据。这个时候createIndex会失败,日志里会打印具体是哪条索引、哪个键重复。处理方式有两种,清空测试数据重建,或者人工去重之后再建。生产环境只能走后者,所以每次新增唯一索引前,我都要先跑一遍分组统计确认没有重复。
另一个真实踩过的坑是查询没走索引。登录接口按accountLower查,本来是走唯一索引的,但我有一版代码在查询条件里多加了一个status: 1,看起来没问题,实际执行计划变成了全表扫描,因为索引里没有status。玩家数量到十万级之后,登录接口从 5 毫秒涨到 80 毫秒。修法很简单,把状态判断挪到查询之后做,或者建一个复合索引。判断有没有走索引,用执行计划看一眼就够了,别靠感觉。
const plan = await db.collection('users') .find({ accountLower: 'tommy' }) .explain('executionStats'); console.log(plan.executionStats.totalDocsExamined, plan.executionStats.executionTimeMillis);totalDocsExamined应该接近 1,如果它等于集合文档总数,就是全表扫描,必须处理。
6.3 我踩过的坑和几条硬经验
第一坑是密码字段被日志打出去。早期我为了排查问题,把整个req.body打进访问日志,结果日志文件里躺着明文密码。后来加了一层脱敏,密码、Token、验证码这类字段统一替换成星号。日志是会流转的,会被人看、会存到第三方系统,脱敏这件事必须在写第一行日志的时候就做。
第二坑是大小写和空格。同一个账号Tommy、tommy、tommy(末尾空格)在玩家眼里是同一个,在数据库里是三个。注册时就做normalize('NFKC').trim().toLowerCase(),把它归一化到唯一形态,这个处理放在最外层,所有入口都覆盖到。
第三坑是并发下的档案缺失。注册写入了账号但写档案失败,玩家登录后进游戏发现没有角色。我的兜底是登录成功之后检查档案是否存在,不存在就补一份默认档案,并在日志里记一条profile_repaired。这种"懒修复"的思路在游戏服务器里特别好用,比强一致方案轻得多。
第四坑是压测时 CPU 打满。一开始以为是数据库瓶颈,加了索引没变化,最后发现全耗在密码哈希上。这其实是好事,说明安全性在起作用。真正的解法是限制注册频率,同一个 IP 或设备每分钟最多 3 次,把机器资源留给正常玩家。
我在实际项目里的体会是,注册登录这两个接口的代码量占整个服务器的比例很小,但出事概率排在前列。原因不复杂:它们是唯一面向未认证用户的入口,所有的校验、限流、防爆破都要在这里做。写完能跑通只是及格线,把异常路径一个个想清楚,才算真正完成。后面再加好友、背包、排行榜,你会发现这套账号体系如果是干净的,扩展起来几乎不需要回头改。