MiniAgent 最近补上了多租户模式。用户通过邮箱和密码登录,服务端生成 Bearer Token,后续请求都带着这个 Token 访问 /message、/query、/todo、/admin/* 等接口。
这套设计安全边界很清晰:Token 不自包含用户状态,所有受保护请求都会到服务端数据库里查一次 sessions JOIN users,确认:
- Token 是否存在;
- Session 是否过期;
- Session 是否被撤销;
- 用户是否被封禁;
- 用户角色是什么;
- 用户名等基础信息是什么。
这和 JWT 的典型用法不同。JWT 常把身份和过期时间直接塞进 token,服务端只验签。MiniAgent 走的是服务端 Session 表,因此密码重置、用户封禁、登出都能在下一次请求立即生效。
但这个选择也带来了一个问题:当请求量变大时,数据库会成为压力点。
本文复盘这次优化的完整思考过程:问题从哪里来,哪些方案被考虑过,为什么最后选择一个 5 分钟刷新窗口,以及最终方案如何在性能和业务语义之间取得平衡。
问题:每个受保护请求都在写数据库
优化前,validate_session(token) 的核心路径可以简化成:
HTTP request
-> Authorization: Bearer <token>
-> SELECT sessions JOIN users
-> 检查 revoked_at / expires_at / disabled_at
-> UPDATE sessions.last_seen_at
-> 如果接近过期,顺便 UPDATE expires_at
-> commit
也就是说,每个受保护请求都至少包含:
- 1 次
SELECT; - 1 次
UPDATE; - 1 次事务提交。
单个用户、低频请求时,这完全没问题。SQLite 对 sessions.token 有主键索引,users.id 也是主键,查询本身很快。
真正的问题在写入。
SQLite 是嵌入式数据库,它适合轻量、可靠、部署简单的本地持久化,但它不是高并发写入数据库。读请求可以很多,写请求却会涉及写锁。更麻烦的是,当前 UserManager 使用同步 sqlite3,在 aiohttp 的 asyncio 请求处理路径里直接执行。一次短暂的 SQLite 写锁等待,也可能阻塞事件循环。
因此,请求量上来后,真正容易变慢的地方不是:
用户数量多 -> 表变大 -> 查询全表扫描
而是:
请求数量多 -> 每个请求都写 sessions -> 写锁和 commit 变多
这就是这次优化的起点。
不能直接删掉 last_seen_at
第一眼看上去,last_seen_at 似乎只是一个审计字段。既然每次更新它会造成写压力,那能不能干脆不更新,或者很久才更新一次?
这里需要先把 Session 语义讲清楚。
MiniAgent 的用户登录态不是一个固定 24 小时后死亡的 token。更准确地说,它是一个“活动保留窗口”:
用户登录后获得一个 Session
只要用户在有效期内断断续续回来
Session 就应该大致继续保留 24 小时
举个例子:
第一天 10:00 登录,有效到第二天 10:00
第二天 09:30 用户回来发起请求
这次请求应该成功
并且 Session 应该继续向后保留
这不是传统意义上很短的“闲置超时”。它更像一个长窗口的 rolling session。
因此,不能简单地说:
last_seen_at 不重要,不更新也行
如果完全不刷新活动时间或过期时间,就会出现活跃用户被固定 expires_at 踢下线的问题。
旧逻辑的问题:滑动得不够贴近业务语义
旧逻辑里并不是每次请求都把 expires_at 设置为 now + 24h,而是:
如果 expires_at 剩余时间小于 session_ttl / 3
才刷新 expires_at
否则只更新 last_seen_at
以默认 session_ttl = 86400 秒计算,三分之一 TTL 是 8 小时。
这个策略能减少一部分 expires_at 更新,但它依然每次请求都写 last_seen_at。而且它的业务语义也不是“每次活动后大致保留 24 小时”,而是“接近过期时才续期”。
举个极端一点的场景:
第一天 10:00 登录,有效到第二天 10:00
第一天 11:00 请求,不刷新 expires_at
第一天 12:00 请求,不刷新 expires_at
第二天 10:30 回来,Session 已过期
用户距离最后一次请求其实是 22.5 小时,但因为之前没有刷新 expires_at,还是会过期。
这不一定是 bug,因为旧语义就是接近过期才续期。但讨论优化时,我们意识到真正想要的是:
用户每次活动后,Session 大致向后保留 24 小时
但不要求精确到每一次请求、每一秒
这个“大致”是关键。它给了我们优化空间。
方案评估
方案一:每次请求都刷新 expires_at
最直接的 rolling session 是:
每次请求成功
-> UPDATE last_seen_at = now
-> UPDATE expires_at = now + 24h
这个语义最精确。但它没有解决性能问题,反而把每次请求写库这件事固定下来了。
对 MiniAgent 来说,这不是合适的第一步。当前瓶颈就是写入频率,不应该用更强的写入语义解决。
方案二:完全只读校验,不再刷新
另一个极端是:
每次请求只 SELECT
不更新 last_seen_at
不更新 expires_at
性能最好,但业务语义变了。Session 变成固定过期时间,用户断续回来不会延长有效期。
这会牺牲登录体验,也和现有“滑动 Session”的设计方向不一致。
方案三:迁移 PostgreSQL
如果并发规模继续扩大,PostgreSQL 是更长期的方向:
aiohttp
-> asyncpg / SQLAlchemy async
-> PostgreSQL connection pool
PostgreSQL 有连接池、行级锁、更好的并发读写能力,适合更大的多租户规模。
但它不是当前最小闭环。MiniAgent 仍然是一个个人 agent 演进出来的小型多租户系统,引入 PostgreSQL 会带来部署、备份、迁移、运维复杂度。
这次优化的目标不是把存储系统换掉,而是在当前 SQLite 模型下先把明显浪费的写入降下来。
方案四:Redis 缓存 Session
Redis 也可以降低数据库压力。比如把 token 校验结果放进 Redis,密码重置或封禁时清掉对应 key。
但 Redis 也会引入新的问题:
- Redis 和 SQLite 的一致性如何保证;
- 密码重置时如何精确失效所有设备;
- Redis 不可用时是否允许请求降级;
- 多实例部署时如何同步撤销;
- 是否需要额外的会话版本号。
这些问题不是不能解决,但对于当前阶段有点重。
最终选择:按刷新窗口节流写入
最终我们选择了一个中间方案:
每次请求都 SELECT 并实时校验有效性
但 last_seen_at / expires_at 只在刷新窗口到期时写一次
默认刷新窗口是 300 秒,也就是 5 分钟。
核心规则是:
如果 Session 有效:
- 距离上次 last_seen_at 未超过 5 分钟:
只读校验,不写库
- 距离上次 last_seen_at 超过 5 分钟:
UPDATE last_seen_at = now
UPDATE expires_at = now + session_ttl
为了保留旧逻辑的兜底,我们还保留了“接近过期时必须刷新”的条件:
refresh_due =
now - last_seen_at >= refresh_interval
OR
remaining(expires_at) < session_ttl / 3
这样可以避免某些边界情况下 Session 快过期但没有刷新。
最终请求路径
优化后的请求路径变成:
HTTP request
-> Authorization: Bearer <token>
-> SELECT sessions JOIN users
-> 检查 revoked_at / expires_at / disabled_at
-> 判断 refresh_due
-> false: 直接返回 user
-> true: 条件 UPDATE last_seen_at + expires_at,然后返回 user
用流程图表示:
flowchart TD
A["Protected request"] --> B["SELECT session JOIN user by token"]
B --> C{"revoked / expired / disabled?"}
C -->|yes| D["401 Unauthorized"]
C -->|no| E{"refresh due?"}
E -->|no| F["Return authenticated user"]
E -->|yes| G["Conditional UPDATE last_seen_at, expires_at"]
G --> F
这个方案的性能收益很直接:
优化前:
每个受保护请求 1 次 SELECT + 1 次 UPDATE + 1 次 commit
优化后:
每个受保护请求 1 次 SELECT
每个活跃 Session 每 5 分钟最多 1 次 UPDATE + commit
如果一个用户在页面上连续触发很多状态查询,例如 /auth/status、/history、/todo、/schedule,这些请求不再每个都写 sessions 表。
并发请求下的条件更新
刷新窗口方案还有一个细节:并发请求。
假设用户打开页面,前端同时发起 5 个受保护请求。它们几乎同时读到同一个旧的 last_seen_at,都认为需要刷新。
如果简单写:
UPDATE sessions
SET last_seen_at = ?, expires_at = ?
WHERE token = ?
那么 5 个请求可能都会写一次。
这会削弱优化效果。
因此最终使用条件更新:
UPDATE sessions
SET last_seen_at = ?, expires_at = ?
WHERE token = ?
AND revoked_at IS NULL
AND last_seen_at = ?
最后一个条件 last_seen_at = observed_last_seen_at 很关键。
请求 A 和请求 B 同时读到旧值:
last_seen_at = 10:00:00
请求 A 先更新成功:
last_seen_at = 10:05:00
请求 B 再执行条件更新:
WHERE last_seen_at = '10:00:00'
这时条件不再成立,rowcount = 0。请求 B 不需要报错,也不需要重试,因为它在前面已经完成了有效性校验,而且请求 A 已经替它刷新过活动时间。
这样,一批并发刷新请求里通常只有一个会真正写入。
为什么密码重置和封禁仍然能立即生效
这次优化最重要的安全边界是:不能让密码重置、登出、封禁变得延迟。
MiniAgent 的密码重置成功后,会把该用户所有已有 Session 标记为 revoked:
UPDATE sessions
SET revoked_at = ?
WHERE user_id = ?
AND revoked_at IS NULL
用户封禁则会设置 users.disabled_at,并撤销已有 Session。
优化后的 validate_session() 每次请求仍然读取:
sessions.revoked_at
sessions.expires_at
users.disabled_at
users.role
因此:
- 旧 Token 被设置
revoked_at后,下一次请求立即 401; - 用户被封禁后,下一次请求立即 401;
expires_at过期后,下一次请求立即 401;- 只有
last_seen_at和滚动expires_at的持久化会被节流。
这点非常重要。我们减少的是“活动记录写入频率”,不是“认证判断频率”。
SQLite 层面的补充优化
除了减少写入次数,这次还给认证数据库连接加了三个 SQLite pragma:
PRAGMA journal_mode = WAL;
PRAGMA synchronous = NORMAL;
PRAGMA busy_timeout = 5000;
它们分别解决不同问题:
| 配置 | 作用 |
|---|---|
journal_mode = WAL | 让读写并发体验更好,减少读写互相阻塞 |
synchronous = NORMAL | 在可靠性和性能之间取平衡 |
busy_timeout = 5000 | 遇到短暂锁竞争时最多等待 5 秒,而不是立刻失败 |
这些配置不会把 SQLite 变成高并发服务端数据库,但能让当前部署模型更稳。
配置化:给业务留下旋钮
刷新窗口不是写死的。最终新增配置:
auth:
session_ttl: ${AUTH_SESSION_TTL:-86400}
session_refresh_interval: ${AUTH_SESSION_REFRESH_INTERVAL:-300}
默认值是 300 秒。
如果更看重精确性,可以调小:
AUTH_SESSION_REFRESH_INTERVAL=60
如果更看重减少写入,可以调大:
AUTH_SESSION_REFRESH_INTERVAL=900
这里没有绝对正确值。它本质上是业务体验和数据库压力之间的取舍:
窗口越小:
Session 活动记录越精确
写入越多
窗口越大:
写入越少
活动记录和实际请求时间误差越大
当前选择 5 分钟,是因为 MiniAgent 的登录态语义允许几分钟误差,同时它能显著降低高频页面请求造成的写库压力。
测试覆盖
这次优化补了几类测试。
第一类是窗口内不写:
刚登录后立刻 validate_session
last_seen_at 和 expires_at 不应改变
第二类是窗口到期后刷新:
手动把 last_seen_at 调到 301 秒前
validate_session 后应更新 last_seen_at
expires_at 应向后推到 now + session_ttl
第三类是安全边界:
即使 last_seen_at 很新
只要 expires_at 已过期
validate_session 也必须返回 None
第四类是并发条件更新:
模拟 observed_last_seen_at 已被其他请求更新
当前请求的条件 UPDATE 不应覆盖更新后的 last_seen_at
最后跑全量测试:
uv run pytest
472 passed
这次优化没有做什么
有几个看起来相关、但这次刻意没有做的事情。
没有改成 aiosqlite
同步 sqlite3 仍然运行在请求路径里,这确实不是最终形态。改成 aiosqlite 可以避免同步数据库操作直接阻塞事件循环。
但 aiosqlite 不能消灭 SQLite 的单写者限制。它解决的是事件循环阻塞问题,不是写锁竞争本身。
所以这次先减少写入次数。后续如果还有压力,再把 UserManager 改为异步访问。
没有引入 session_version
密码重置目前通过批量更新 sessions.revoked_at 踢掉旧 Token。另一种更高效的方式是:
users.session_version
sessions.session_version
密码重置时只把 users.session_version += 1,校验时比较两边版本。这样不需要批量更新 Session。
这个方案很好,但这次的主要压力来自每请求 last_seen_at 写入,而不是单个用户频繁密码重置。因此先不引入新字段和迁移。
没有迁移到 PostgreSQL
PostgreSQL 是更大规模下的合理方向,但不是当前阶段的最小有效优化。
MiniAgent 的设计原则一直是:先把系统当前最明显的瓶颈处理掉,不为了未来规模提前引入一整套基础设施。
最终效果和取舍
最终方案可以总结成一句话:
认证判断每次都做,活动持久化按窗口做。
这样保留了服务端 Session 的核心安全能力:
- 密码重置立即踢旧 Token;
- 登出立即撤销当前 Token;
- 封禁立即生效;
- 过期时间每次请求都检查;
- 用户角色每次请求都从服务端确认。
同时,把高频请求下最浪费的写库路径降了下来:
- 从每个请求都更新
sessions.last_seen_at; - 变成每个 Session 每 5 分钟左右刷新一次;
- 并发刷新时只有一个请求真正写入。
这不是一个“架构大升级”,而是一次很典型的工程优化:先确认业务语义,找出真正的压力点,然后用最小改动减少热路径写入。
在 MiniAgent 当前规模下,这比直接引入 PostgreSQL、Redis 或重写认证层更合适。它把系统往可扩展方向推了一步,同时没有牺牲最重要的安全语义。
后续如果请求规模继续上升,下一步可以按顺序考虑:
- 把
UserManager从同步sqlite3改为aiosqlite; - 引入
session_version替代批量撤销 Session; - 增加认证耗时和 SQLite lock wait 日志;
- 最后再迁移 PostgreSQL 或引入 Redis Session 缓存。
但至少现在,MiniAgent 的多租户认证已经不再因为页面每个受保护请求都写一次 Session 表而白白消耗 SQLite 写入能力了。