面试知识库

M16 · 账户体系与会话归属#

一句话:把「数据存住了」变成「数据是你的」。

一、背景:问题不是我以为的那个#

需求是「我要上线一个公网 demo,让用户注册、对话,主机重启后数据还在」。听起来是持久化问题, 我一开始也这么以为,于是先去查「哪些东西还在内存里、重启会丢」。

查完发现:数据一直是持久的,重启不会丢。 长期偏好在 Redis 里(开了 AOF、挂了持久卷),每一轮 对话、候选商品、会话产物都落在 output/ 卷上。真要重启,它们都还在。

那用户为什么会觉得数据会丢?因为他确实找不回来。缺的不是持久化,是归属

  • 侧栏那份「历史对话」清单,只活在浏览器的 localStorage 里。换台设备、换个浏览器、清一次缓存, 后端的数据明明还躺在磁盘上,界面上却一条都不剩。数据没丢,是没人知道它是谁的。
  • 更糟的是另一半:所有按会话 id 寻址的接口(读历史、下产物、连事件流、取消任务)从不问这个会话 归谁。而会话 id 会出现在 URL 里、事件流里。也就是说,谁拿到(或猜到)一个 id,谁就能读别人聊过 什么、下载别人的购物清单、盯着别人的实时事件流看。
  • 而「用户」这个概念,当时在前端是一行硬编码的常量 demo-user——所有访客其实是同一个人。谁的 偏好都写进同一个抽屉。

有意思的是,这个洞不是我发现的:项目里那个鉴权模块的注释早就写明了「会话维度的归属校验还没做, 需要一张归属表,属下一个增量」。它诚实地记下了自己没做完的那一半。M16 就是来收这笔账的。

所以这个里程碑真正做的是三件事:给用户一个身份(注册登录)、给会话一个主人(归属表)、 给每个接口一道门(属主校验)。

二、关键决策与取舍#

1. 为什么要新引一个关系库,不塞进已有的 Redis#

Redis 已经在跑了,直接拿它存用户不行吗?不行,而且理由不是”不够优雅”:

  • 账号需要唯一约束(用户名不能重名)。如果靠应用层”先查一下有没有人叫这个名,没有就插入”, 两个人同时注册同一个名字时,两边都会查到”没人叫这个”,然后都插入成功。这类竞态只有数据库的唯一 索引能真正挡住。
  • 会话必须属于一个真实存在的用户,这是外键要保证的事。
  • Redis 的 AOF 是”尽力而为”的持久化。偏好丢一条无所谓(用户再说一次就是了),账号丢一条是事故 ——用户会发现自己注册的账号登不上了。真源数据该有事务。

2. 为什么是 SQLite,不是 Postgres#

这个选择常被当成”图省事”,但在这里它是贴合部署形态的决定:

那台 VPS 上已经跑着 OpenSearch、Qdrant(一百多万个向量点)、Redis 三个吃内存的大件。而后端是 单进程单 worker 跑的——这不是偷懒,是早就定死的:任务表、并发队列、幂等窗口全是进程内状态, 多开一个 worker 会让”取消任务”和”重复提交去重”静默失效。

既然根本不存在多进程并发写,Postgres 能提供的核心能力(高并发写、连接池、独立扩展)在这里一条 都用不上,换来的却是实打实的一个容器、几百 MB 内存和一份运维负担。SQLite 零容器、零运维,库文件就 躺在持久卷上。

代价必须说清楚:多进程并发写 SQLite 会锁表。所以将来真要横向扩展,这个选择是要还的。还的方式 很便宜——因为我用了 ORM 而不是手写 SQL,换 Postgres 只需要改一行连接串,表结构和查询一个字都不用动。 这是”现在选便宜的,但别把自己焊死”。

3. 一个部署陷阱:库文件不能放在 data/#

生产编排里 data/只读挂载的(放的是语料)。SQLite 要写,放进去就直接炸。所以库文件单独放在 var/ 并在编排里挂一个命名卷——这一步要是漏了,容器每次重建,所有账号就全没了

这类问题的共性是:本地怎么跑都对,一上生产就完蛋,而且完蛋得很安静。

4. 认领会话:只校验读是不够的#

“归属”最直觉的实现是:谁最后发言,这段会话就算谁的。这是个洞。 攻击者只要拿别人的会话 id 发一句 话,就把人家的会话过户到自己名下了。

所以认领是一次性的:会话第一次出现时记下主人,此后这个主人永不更改;拿着别人的 id 来发消息, 直接拒。读要校验属主,写更要

5. 登录失败为什么不告诉你”是密码错还是没这个人”#

两种失败回一模一样的一句”用户名或密码错误”。区分开会更友好,但也会把登录口变成一个用户名探测器: 攻击者可以先枚举出哪些账号真实存在,再对着这批真账号集中撞库。这是拿一点点体验换一道实实在在的门槛。

同理,用户 id 用的是随机串而不是自增数字。自增 id 会泄漏”这站一共几个人、我是第几个注册的”,而且一旦 哪里误信了前端传来的 id,猜一个相邻的数字就能撞到真人。

6. WebSocket 的 token 只能挂在 URL 上#

浏览器原生的 WebSocket 接口不允许设置自定义请求头——这是浏览器的规矩,绕不过去。所以 token 只能 挂 query 参数上。

代价是真实的:URL 比请求头更容易被记进各种访问日志、代理日志。缓解手段是这枚 token 本来就有过期时间 (默认 24 小时),泄漏窗口有限。要彻底干净,得用一次性的连接票据去换 WS 连接——那是另一摊工程,这里 诚实标注,不假装它不存在。

三、过程中踩到的三个坑#

坑一:外键约束以 500 的形式砸在用户脸上#

测试时冒出一个 FOREIGN KEY constraint failed。追下去发现是个真实的产品缺陷,不是测试写错了:

token 验签通过了,但它里面的用户 id 在库里查无此人——账号被删了、库被换了、或者这枚 token 是早年那个 “开发态发证口”给一个不存在的用户签的。这时候去认领会话,撞上外键,用户看到的是一个 500。

500 是在说”服务器坏了”,可服务器没坏,是你的凭证过期了。 改成 401「请重新登录」,用户知道该干嘛。 这个坑的价值在于:它是被外键逼出来的。如果当初图省事不加外键,这个”幽灵用户”会一路悄悄写进库里, 变成一堆没有主人的会话——错误会被推迟到更远、更难查的地方。约束的价值就是让错误当场暴露。

坑二:一个会把新用户彻底卡死的登出 bug#

浏览器里跑到”登出 → 换个账号登录”这步时发现的:登出只清了 token 和用户信息,忘了清”上次看的是哪段 会话”这个书签

后果是一条完整的死路:换个人登录 → 前端还指着上一个人的会话 → 他一发消息,后端一看”这不是你的会话” 直接拒绝 → 新用户一句话都发不出去,界面上还看不出任何原因

讽刺的是,正因为归属校验做对了,这个 bug 才暴露得这么彻底。这类跨用户的状态残留,单元测试基本抓不到 ——它只在”真的换个人登录”时才现形。必须真的用浏览器点一遍。

坑三:我差点修了一个不存在的 bug#

截图里顶栏右边的按钮看着像是被挤出屏幕了,我当即认定是自己新加的”退出”按钮撑爆了布局,还动手改了 CSS。改完再看——还是那样。

于是去了一下真实尺寸:按钮右边界离视口边缘还有 22 像素,根本没有溢出。是截图工具把画面缩放 后裁掉了右边一条,我看着”被截断”就脑补了一个 bug 出来。改动随即撤回。

教训很直白:看起来不对 ≠ 真的不对。 在动手改之前先量一下,一次测量胜过三次猜测。

四、验收:真的换个人试过#

后端 681 个测试全绿,其中新增的 13 条里,主心骨是”别人能不能碰到我的东西”——读历史、下产物、看事件 流、取消任务、删会话,逐个口子验证越权返回 403。

但真正让我确信的是浏览器里那一遍:注册 → 提问(真跑了一轮完整的 Agent,出了 9 款背包的清单)→ 登出 → 重新登录,历史完好无损地回来了。然后注册第二个账号,拿着他的真 token 去够第一个账号的会话—— 四个接口,全部 403。

这比任何单元测试都更能说明问题:数据现在真的是”你的”了。

五、面试可讲点#

  • “需求说的问题,往往不是真正的问题。” 用户说”怕重启丢数据”,查下来数据一直好好的,缺的是归属。 如果我照着字面去做持久化,会做一堆无用功,而真正的洞(谁都能读别人的对话)原封不动。
  • 约束不是负担,是提前暴露错误的机制。 外键把”幽灵用户”当场拦下,逼我把一个 500 改成正确的 401。
  • 技术选型要贴着部署形态走。 单机单 worker 的现实下选 SQLite,不是将就,是不为用不上的能力付账; 同时用 ORM 保住后路,换 Postgres 只是改一行连接串。
  • 有些 bug 只有真的用浏览器换个账号登录才会现形。 登出没清干净的那个书签就是典型。
  • “看起来坏了”要先量一量。 我差点为一个截图裁剪造成的错觉去改 CSS。

可能的追问:

  • 为什么不用 httpOnly Cookie 存 token?—— 前后端分离 + WebSocket 挂不了请求头,Cookie 在这两处都别扭。 代价(localStorage 怕 XSS)已在代码里标注,缓解靠 token 短期过期。
  • 侧栏删除会话,删的是什么?—— 只删归属记录(入口),磁盘上的对话正文还在。“彻底删除我的数据”是另一 件事(要连产物、正在跑的任务、事件流一起处理),本次没做,标注清楚了。
  • 下载产物的链接会不会 401?—— 本来会,已经修了。这是加鉴权时最容易漏的一类回归:<a href download> 是浏览器自己发的请求,带不上 token,产物接口一加属主校验,下载就稳定失败。改成用带 token 的请求 把文件取到内存、再造一个临时链接触发下载。凡是”浏览器替你发的请求”(<a><img src><form action>)都有这个问题,加鉴权时要挨个过一遍。