返回博客

内存 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?全没了,只剩这一个。

坑:全量快照写入之前,内存 Map 可能是空的

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。

不要只修 login,扫所有写路径

logoutdeleteSessionupdateUser 等任何会改 Map 并调用 persist() 的函数,都需要补上这一行。用 grep 找出所有调用 persist() 的地方,逐一确认上方有没有 ensureHydrated()

正解:换掉全量快照,用增量 upsert

如果有时间做更彻底的修复,把「把整个 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 就是「消失了」。复现步骤:

  1. 登录,检查 DB 的 sessions 条数(应为 1)。
  2. 触发模块重载(改一个文件触发 HMR,或重启 Node 进程)。
  3. 用另一个账号登录。
  4. 检查 DB 的 sessions 条数——如果还是 1,说明第一个 session 被覆盖了,bug 复现。

修复后再走一遍,两次登录后 DB 应有 2 条记录,两个 token 都能正常鉴权。

一句话外卖

全量快照持久化的正确性前提是「内存镜像 = DB 全量」,写路径必须先 hydrate 再写再 persist,否则空 Map 一次覆盖清空所有并发数据。