内存 Map 镜像 + 全量快照持久化:写入前不先 hydrate,悄悄清空所有并发 session
某个后台系统上线后,用户偶发性「要重新登录」——不是每次,也不是规律性的,而是有时一个客户端刚登进来,另一个客户端就被踢了。翻了半天 session 持久化逻辑,注释写着「sessions 已持久化到数据库,重启后不会丢失」。乍一看代码路径完整,login 写 Map,persist 存 DB,ensureHydrated 读 DB 填 Map……问题出在哪?
现象
- 两个客户端(比如浏览器开发调试页面 + 桌面 App,或两个浏览器标签)同时使用同一套后端。
- 服务器重启 / Next.js HMR 热更新 / serverless 冷启动后,第一个登录的客户端会把其他所有已有 session 全部抹掉。
- 受害方下一次请求时 session 不认识,返回 401,触发重登。
- 在只有一个客户端的开发环境里,这个 bug 几乎不会触发——因为没有「其他 session」被覆盖。
根因
典型代码结构长这样:
const sessions = new Map() // 模块级内存镜像
let hydrated = false
async function ensureHydrated() {
if (hydrated) return
const db = await readDb()
for (const entry of db.sessions || []) sessions.set(entry.token, entry)
hydrated = true
}
async function persist() {
// 全量快照覆盖写:把整个 Map 序列化存回 DB
await withDb(db => {
db.sessions = [...sessions.entries()].map(([t, e]) => ({ token: t, ...e }))
})
}
async function login(username, password) {
// 验证 ...
const token = crypto.randomBytes(24).toString('hex')
sessions.set(token, { userId, expiresAt: Date.now() + TTL })
await persist() // ← BUG:Map 里只有刚加进去的一个 token
}ensureHydrated 只在读路径(鉴权校验)里被调用。登录是写路径,直接往空 Map 里塞新 token,然后把这个只有一个 entry 的 Map 全量覆盖存回 DB。
DB 里原来有 100 个 session?全没了,只剩这一个。
persist() 的正确性依赖「Map 已包含 DB 里所有数据」这个前提。但进程重启后,模块重新初始化,Map 是空的,hydrated = false。只要写路径(login / delete / update)在调用 persist() 之前没有先 ensureHydrated(),就会把 DB 的完整数据覆盖成只有本次操作的那一条。
这个 bug 隐藏得很深:读路径(验证 token)通常会触发 hydration,所以「登录 → 用」在单客户端场景完全正常。只有当进程新实例的第一个 auth 操作是写(比如登录),并且 DB 里已有其他 session 时,才会触发全量清除。
正解
最小修复:每个调用 persist() 的写路径,在 cache.set() 之前先 await ensureHydrated()。
async function login(username, password) {
// 验证 ...
const token = crypto.randomBytes(24).toString('hex')
await ensureHydrated() // ← 加这一行:先把 DB 数据填进 Map,再写再持久化
sessions.set(token, { userId, expiresAt: Date.now() + TTL })
await persist() // 现在写的是「现有全量 + 新增」,而不是「只有新增」
}ensureHydrated 有幂等守卫(hydrated flag),热路径第二次调用直接 return,不会重复读 DB。
logout、deleteSession、updateUser 等任何会改 Map 并调用 persist() 的函数,都需要补上这一行。用 grep 找出所有调用 persist() 的地方,逐一确认上方有没有 ensureHydrated()。
如果有时间做更彻底的修复,把「把整个 Map 序列化到单列」改成「每个 token 独立一行,INSERT/DELETE by PK」。这样写路径天然是增量的,根本不存在「用空 Map 覆盖 DB」的可能,也顺手消灭了并发写之间的读-改-写竞态。
// 增量写,不再需要 ensureHydrated
async function addSession(token, data) {
await db.upsert('sessions', { token, ...data })
}
async function removeSession(token) {
await db.delete('sessions', { token })
}排查技巧
这个 bug 在日志里几乎没有报错,session 就是「消失了」。复现步骤:
- 登录,检查 DB 的 sessions 条数(应为 1)。
- 触发模块重载(改一个文件触发 HMR,或重启 Node 进程)。
- 用另一个账号登录。
- 检查 DB 的 sessions 条数——如果还是 1,说明第一个 session 被覆盖了,bug 复现。
修复后再走一遍,两次登录后 DB 应有 2 条记录,两个 token 都能正常鉴权。
一句话外卖
全量快照持久化的正确性前提是「内存镜像 = DB 全量」,写路径必须先 hydrate 再写再 persist,否则空 Map 一次覆盖清空所有并发数据。