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>)都有这个问题,加鉴权时要挨个过一遍。