← blog 技术原理与实践 · 2026-08-23

MiniAgent Session 校验优化:把每次请求写库改成按窗口刷新

复盘 MiniAgent 多租户认证中的一次性能优化:为什么同步 SQLite 的每请求 Session 写入会成为瓶颈,如何在不牺牲密码重置、封禁和滑动有效期语义的前提下,把写入频率降到每个 Session 每 5 分钟一次。

16 min read

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 或重写认证层更合适。它把系统往可扩展方向推了一步,同时没有牺牲最重要的安全语义。

后续如果请求规模继续上升,下一步可以按顺序考虑:

  1. 把 UserManager 从同步 sqlite3 改为 aiosqlite;
  2. 引入 session_version 替代批量撤销 Session;
  3. 增加认证耗时和 SQLite lock wait 日志;
  4. 最后再迁移 PostgreSQL 或引入 Redis Session 缓存。

但至少现在,MiniAgent 的多租户认证已经不再因为页面每个受保护请求都写一次 Session 表而白白消耗 SQLite 写入能力了。

Sources

No external sources for this entry.

Related