Skip to content

WebSocket ticket 存储设计决策 #151

Description

@znnnnnnn-wil

WebSocket ticket 存储设计决策:本期使用内存而非 Redis

状态:已确认
适用阶段:当前单进程示例版本
关联方案:[多轮语音助手改造方案](#150)

1. 要解决的问题

客户端完成账号认证和设备绑定后,需要先通过 HTTPS 申请一个短期、单次使用的 WebSocket ticket,再使用该 ticket 建立 WebSocket 连接。

服务端需要临时保存以下信息:

ticket_hash
→ user_id
→ account_device_id
→ expires_at

WebSocket 握手时,服务端根据 ticket 找回已认证的账号和设备身份,创建可信的 ConnectionContext。ticket 验证成功后必须立即失效,防止同一个 ticket 被重复连接或截获后重放。

本设计要决定:这些生命周期只有几十秒的数据,应当保存在当前后端进程的内存中,还是引入 Redis 等外部存储。

2. 当前结论

本期明确采用进程内存保存 WebSocket ticket,不引入 Redis。

建议实现统一接口:

WebSocketTicketStore
├── issue(...)
└── consume(...)

本期提供:

InMemoryWebSocketTicketStore

未来需要多进程或多实例部署时,再增加:

RedisWebSocketTicketStore

业务层和 WebSocket 认证流程只依赖接口,不直接依赖内存字典或 Redis 客户端。

3. 可选方案

方案 优点 缺点 当前是否采用
进程内存 实现简单、延迟低、无需部署额外组件 不能跨进程共享,重启后数据丢失
Redis 支持多进程、多实例共享,原子消费和 TTL 能力成熟 增加部署、监控、连接和故障处理成本 否,后续按条件升级
业务数据库 不需要新增 Redis,多个实例可共享 临时高频数据污染业务库,清理和原子消费更麻烦
完全无状态的签名 ticket 不需要保存 ticket 数据,天然支持多实例验签 很难保证真正的单次使用,仍需保存已消费状态

4. 为什么本期选择内存

4.1 当前运行方式是单进程

当前项目是单进程示例。ticket 的申请请求和 WebSocket 握手都会由同一个后端进程处理,因此不存在一个进程签发、另一个进程验证却找不到数据的问题。

在这一前提下,Redis 能解决的核心问题——跨进程共享和分布式原子消费——当前尚未出现。

4.2 ticket 生命周期非常短

ticket 的 TTL 暂定为 30 秒,并且成功使用一次后立即删除。它不是用户会话、登录状态或日程数据,不需要长期持久化。

后端重启时,少量尚未使用的 ticket 会丢失,但客户端只需重新申请一个 ticket 再连接。因为申请动作是自动完成的,这种失败可以被客户端恢复,不会造成业务数据丢失。

4.3 降低当前版本的系统复杂度

现在引入 Redis,还需要同时处理:

  • Redis 的部署和配置。
  • 开发、测试及生产环境的一致性。
  • 连接池、认证、TLS 和密钥管理。
  • Redis 不可用时的错误处理。
  • Redis 的监控、容量和数据清理。
  • 本地开发人员额外启动一个基础设施组件。

这些工作不会直接改善当前单进程版本的用户体验。现阶段使用内存,可以把开发精力集中在账号设备认证、多轮对话和日程能力上。

4.4 Redis 的必要性不由用户数量单独决定

是否迁移 Redis,关键取决于后端的部署拓扑,而不只是注册用户数量。

即使有很多注册用户,只要同时申请但尚未消费的 ticket 数量可控,并且服务仍是单进程,内存仍然可以工作。反过来,即使用户很少,只要使用多个 worker 或多个实例,也需要共享 ticket 存储。

因此不能采用“用户达到某个固定数量就必须上 Redis”的简单判断。更可靠的判断标准是是否出现跨进程验证、并发容量或可用性需求。

5. 为什么本期不选择 Redis

本期不选择 Redis,不代表 Redis 方案不好,而是它解决的问题与当前阶段不匹配。

主要原因如下:

  • 当前没有多个后端进程共享 ticket 的需求。
  • ticket 丢失后可以自动重新申请,不需要持久化保障。
  • ticket 只有约 30 秒生命周期,内存占用有限且容易回收。
  • 引入 Redis 会新增运行依赖和故障点。
  • 当前优先目标是验证业务流程,而不是建设分布式基础设施。

如果为了未来可能发生的多实例部署现在就强制引入 Redis,会增加本期实现和运维成本,但短期没有对应收益。

6. 为什么不使用业务数据库

把 ticket 存进 PostgreSQL、MySQL 等业务数据库在技术上可行,但不建议这样做:

  • ticket 是秒级临时认证数据,不属于需要持久化的业务记录。
  • 每次连接都会产生写入、查询和删除,给业务数据库增加无意义的短周期负载。
  • 需要定期清理过期记录。
  • “读取并消费”需要事务或条件删除,代码比内存实现更复杂。
  • ticket 数据会干扰备份、审计和数据库容量分析。

数据库应保存账号、设备、日程和对话记录等需要恢复的数据;短期 ticket 更适合内存或 Redis。

7. 为什么不使用完全无状态的签名 ticket

可以把 user_idaccount_device_id 和过期时间放入签名 token,WebSocket 握手时只校验签名和有效期。这样不需要服务端保存 ticket。

但本方案要求 ticket 只能使用一次。纯无状态 token 无法知道它是否已经被消费,同一个 token 在有效期内可以被重复提交。若要记录已经使用过的 token,仍然需要内存或 Redis,最终没有真正消除状态存储。

因此本期直接使用随机 ticket 加服务端状态,更容易实现和验证单次使用语义。

8. 内存方案的正确实现要求

内存方案虽然简单,但不能只随意保存一个普通字典。至少需要满足以下要求。

8.1 ticket 必须安全随机生成

ticket 应使用密码学安全的随机数生成器,不能使用时间戳、递增编号或普通伪随机数。

8.2 服务端只保存 ticket 哈希

接口将原始 ticket 返回客户端,服务端仅保存其哈希。WebSocket 握手收到 ticket 后,以相同算法计算哈希并查询。

这样即使内存调试信息被意外暴露,也不会直接泄露可使用的原始 ticket。

8.3 消费操作必须是原子的

验证与删除不能分成两个无保护的步骤,否则两个并发握手可能同时通过。

语义应类似:

ticket_record = ticket_store.pop(ticket_hash)

实现时使用进程内锁保护“读取并删除”。只有第一个请求可以取得记录,其余请求都应失败。

8.4 必须验证 TTL

消费时检查 expires_at。已过期的 ticket 即使仍在内存中,也必须判定无效并删除。

此外应有轻量清理机制,定期删除没有被使用的过期 ticket,避免长期积累。

8.5 不记录原始 ticket

日志、错误信息和监控数据中不得输出完整 ticket。WebSocket URL 也可能被代理或访问日志记录,因此 ticket 必须短期、单次使用。

8.6 后端重启后的客户端处理

后端重启会使未消费 ticket 失效。客户端遇到 ticket 无效或连接失败时,应自动重新执行:

重新申请 ticket
→ 使用新 ticket 建立 WebSocket

客户端不应无限重试,需要设置有限次数和退避策略,避免后端故障时形成重试风暴。

9. 当前方案的已知限制

  • 只能在单进程内共享 ticket。
  • 使用多个 Uvicorn worker 后会出现 ticket 不可见问题。
  • 多台后端实例之间不能共享 ticket。
  • 进程重启会清空所有尚未使用的 ticket。
  • 无法仅依赖内存完成跨实例限流、审计或全局统计。

这些限制在当前单进程示例阶段可以接受,但部署方式变化后必须重新评估。

10. 迁移到 Redis 的明确条件

出现以下任一情况时,应迁移到 Redis:

  • Uvicorn、Gunicorn 等开始使用多个 worker。
  • 后端部署多个容器、进程或服务器实例。
  • ticket 申请请求与 WebSocket 握手可能被负载均衡到不同实例。
  • 需要滚动发布,并希望发布期间已签发 ticket 继续有效。
  • 单进程的 ticket 数量、清理开销或内存占用达到不可接受水平。
  • 需要全局统一的 ticket 消费审计、限流或风控。

迁移时可使用 Redis TTL 自动过期,并通过 GETDEL 或 Lua 脚本实现原子的“读取并删除”。如果使用 Redis 集群,还需保证相关操作的键设计和原子性符合部署方式。

11. 迁移边界

WebSocketTicketStore 接口应把存储细节隔离在实现层。上层只关心:

issue(user_id, account_device_id, ttl)
→ 返回原始 ticket

consume(raw_ticket)
→ 成功时返回认证信息
→ 无效、过期或已使用时返回失败

从内存迁移到 Redis 时,不应修改:

  • 客户端申请 ticket 的接口。
  • WebSocket URL 和握手流程。
  • ConnectionContext 的结构。
  • 账号与设备的授权规则。

这样能够确保当前的简化选择不会成为以后扩展的阻碍。

12. 决策总结

当前单进程版本使用内存,是因为 ticket 短期、可重新申请、不需要持久化,而且当前没有跨进程共享需求。Redis 更适合多进程、多实例和高可用阶段,提前引入只会增加运行复杂度。

本决策的关键不是“永远不用 Redis”,而是先通过统一存储接口保留替换边界,在部署拓扑真正需要共享状态时再迁移。

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions